diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8dd22d431..fa8aff8a8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to use for the release' + description: "Tag to use for the release" required: true name: Release jobs: diff --git a/api/v1/server/authn/middleware.go b/api/v1/server/authn/middleware.go index 9d02271d7..16d76ccd7 100644 --- a/api/v1/server/authn/middleware.go +++ b/api/v1/server/authn/middleware.go @@ -52,27 +52,33 @@ func (a *AuthN) authenticate(c echo.Context, r *middleware.RouteInfo) error { return a.handleNoAuth(c) } - var err error + var cookieErr error if r.Security.CookieAuth() { - err = a.handleCookieAuth(c) + cookieErr = a.handleCookieAuth(c) c.Set("auth_strategy", "cookie") - } - if err != nil && !r.Security.BearerAuth() { - return err - } - - if err != nil && r.Security.BearerAuth() { - err = a.handleBearerAuth(c) - c.Set("auth_strategy", "bearer") - - if err == nil { + if cookieErr == nil { return nil } } - return err + if cookieErr != nil && !r.Security.BearerAuth() { + return cookieErr + } + + var bearerErr error + + if r.Security.BearerAuth() { + bearerErr = a.handleBearerAuth(c) + c.Set("auth_strategy", "bearer") + + if bearerErr == nil { + return nil + } + } + + return bearerErr } func (a *AuthN) handleNoAuth(c echo.Context) error { diff --git a/api/v1/server/handlers/api-tokens/create.go b/api/v1/server/handlers/api-tokens/create.go index 78fce53e8..23dd2e1e1 100644 --- a/api/v1/server/handlers/api-tokens/create.go +++ b/api/v1/server/handlers/api-tokens/create.go @@ -34,7 +34,7 @@ func (a *APITokenService) ApiTokenCreate(ctx echo.Context, request gen.ApiTokenC expiresAt = &e } - token, err := a.config.Auth.JWTManager.GenerateTenantToken(ctx.Request().Context(), tenant.ID, request.Body.Name, expiresAt) + token, err := a.config.Auth.JWTManager.GenerateTenantToken(ctx.Request().Context(), tenant.ID, request.Body.Name, false, expiresAt) if err != nil { return nil, err diff --git a/api/v1/server/handlers/api-tokens/revoke.go b/api/v1/server/handlers/api-tokens/revoke.go index 2e79c873b..ec640f18d 100644 --- a/api/v1/server/handlers/api-tokens/revoke.go +++ b/api/v1/server/handlers/api-tokens/revoke.go @@ -3,6 +3,7 @@ package apitokens import ( "github.com/labstack/echo/v4" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/apierrors" "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" "github.com/hatchet-dev/hatchet/pkg/repository/prisma/db" ) @@ -10,6 +11,12 @@ import ( func (a *APITokenService) ApiTokenUpdateRevoke(ctx echo.Context, request gen.ApiTokenUpdateRevokeRequestObject) (gen.ApiTokenUpdateRevokeResponseObject, error) { apiToken := ctx.Get("api-token").(*db.APITokenModel) + if apiToken.Internal { + return gen.ApiTokenUpdateRevoke403JSONResponse( + apierrors.NewAPIErrors("Cannot revoke internal API tokens"), + ), nil + } + err := a.config.APIRepository.APIToken().RevokeAPIToken(apiToken.ID) if err != nil { diff --git a/api/v1/server/run/run.go b/api/v1/server/run/run.go index 2b3326cae..12d7a2bcc 100644 --- a/api/v1/server/run/run.go +++ b/api/v1/server/run/run.go @@ -75,7 +75,7 @@ func NewAPIServer(config *server.ServerConfig) *APIServer { } // APIServerExtensionOpt returns a spec and a way to register handlers with an echo group -type APIServerExtensionOpt func(config *server.ServerConfig) (*openapi3.T, func(*echo.Group) error, error) +type APIServerExtensionOpt func(config *server.ServerConfig) (*openapi3.T, func(*echo.Group, *populator.Populator) error, error) func (t *APIServer) Run(opts ...APIServerExtensionOpt) (func() error, error) { e, err := t.getCoreEchoService() @@ -95,13 +95,13 @@ func (t *APIServer) Run(opts ...APIServerExtensionOpt) (func() error, error) { return nil, err } - err = t.registerSpec(g, spec) + populator, err := t.registerSpec(g, spec) if err != nil { return nil, err } - if err := f(g); err != nil { + if err := f(g, populator); err != nil { return nil, err } } @@ -140,7 +140,7 @@ func (t *APIServer) getCoreEchoService() (*echo.Echo, error) { g := e.Group("") - if err := t.registerSpec(g, oaspec); err != nil { + if _, err := t.registerSpec(g, oaspec); err != nil { return nil, err } @@ -153,7 +153,7 @@ func (t *APIServer) getCoreEchoService() (*echo.Echo, error) { return e, nil } -func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T) error { +func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T) (*populator.Populator, error) { // application middleware populatorMW := populator.NewPopulator(t.config) @@ -291,7 +291,7 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T) error { mw, err := hatchetmiddleware.NewMiddlewareHandler(spec) if err != nil { - return err + return nil, err } mw.Use(populatorMW.Middleware) @@ -301,7 +301,7 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T) error { allHatchetMiddleware, err := mw.Middleware() if err != nil { - return err + return nil, err } loggerMiddleware := middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ @@ -355,5 +355,5 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T) error { allHatchetMiddleware, ) - return nil + return populatorMW, nil } diff --git a/cmd/hatchet-admin/cli/token.go b/cmd/hatchet-admin/cli/token.go index c925818f2..358b48343 100644 --- a/cmd/hatchet-admin/cli/token.go +++ b/cmd/hatchet-admin/cli/token.go @@ -90,7 +90,7 @@ func runCreateAPIToken(expiresIn time.Duration) error { expiresAt := time.Now().UTC().Add(expiresIn) - defaultTok, err := serverConf.Auth.JWTManager.GenerateTenantToken(context.Background(), tokenTenantId, tokenName, &expiresAt) + defaultTok, err := serverConf.Auth.JWTManager.GenerateTenantToken(context.Background(), tokenTenantId, tokenName, false, &expiresAt) if err != nil { return err diff --git a/frontend/app/package.json b/frontend/app/package.json index 68a676719..c666ae4d8 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -48,14 +48,19 @@ "@visx/axis": "^3.5.0", "@visx/brush": "^3.6.0", "@visx/curve": "^3.3.0", + "@visx/event": "^3.3.0", "@visx/gradient": "^3.3.0", + "@visx/grid": "^3.5.0", "@visx/group": "^3.3.0", "@visx/mock-data": "^3.3.0", "@visx/pattern": "^3.3.0", "@visx/responsive": "^3.3.0", "@visx/scale": "^3.5.0", "@visx/shape": "^3.5.0", + "@visx/text": "^3.3.0", + "@visx/tooltip": "^3.3.0", "@visx/vendor": "^3.5.0", + "ansi-to-html": "^0.7.2", "axios": "^1.6.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", @@ -63,12 +68,15 @@ "cron-parser": "^4.9.0", "cronstrue": "^2.47.0", "dagre": "^0.8.5", + "date-fns": "^3.6.0", + "dompurify": "^3.1.6", "jotai": "^2.6.0", "js-confetti": "^0.12.0", "monaco-themes": "^0.4.4", "prism-react-renderer": "^2.3.0", "qs": "^6.11.2", "react": "^18.2.0", + "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", "react-icons": "^5.0.1", @@ -83,6 +91,7 @@ }, "devDependencies": { "@types/dagre": "^0.7.52", + "@types/dompurify": "^3.0.5", "@types/node": "^20.10.1", "@types/qs": "^6.9.10", "@types/react": "^18.2.37", diff --git a/frontend/app/pnpm-lock.yaml b/frontend/app/pnpm-lock.yaml index 7a868571e..6d0928565 100644 --- a/frontend/app/pnpm-lock.yaml +++ b/frontend/app/pnpm-lock.yaml @@ -104,9 +104,15 @@ importers: '@visx/curve': specifier: ^3.3.0 version: 3.3.0 + '@visx/event': + specifier: ^3.3.0 + version: 3.3.0 '@visx/gradient': specifier: ^3.3.0 version: 3.3.0(react@18.2.0) + '@visx/grid': + specifier: ^3.5.0 + version: 3.5.0(react@18.2.0) '@visx/group': specifier: ^3.3.0 version: 3.3.0(react@18.2.0) @@ -125,9 +131,18 @@ importers: '@visx/shape': specifier: ^3.5.0 version: 3.5.0(react@18.2.0) + '@visx/text': + specifier: ^3.3.0 + version: 3.3.0(react@18.2.0) + '@visx/tooltip': + specifier: ^3.3.0 + version: 3.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@visx/vendor': specifier: ^3.5.0 version: 3.5.0 + ansi-to-html: + specifier: ^0.7.2 + version: 0.7.2 axios: specifier: ^1.6.2 version: 1.6.8 @@ -149,6 +164,12 @@ importers: dagre: specifier: ^0.8.5 version: 0.8.5 + date-fns: + specifier: ^3.6.0 + version: 3.6.0 + dompurify: + specifier: ^3.1.6 + version: 3.1.6 jotai: specifier: ^2.6.0 version: 2.7.2(@types/react@18.2.73)(react@18.2.0) @@ -167,6 +188,9 @@ importers: react: specifier: ^18.2.0 version: 18.2.0 + react-day-picker: + specifier: ^8.10.1 + version: 8.10.1(date-fns@3.6.0)(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -204,6 +228,9 @@ importers: '@types/dagre': specifier: ^0.7.52 version: 0.7.52 + '@types/dompurify': + specifier: ^3.0.5 + version: 3.0.5 '@types/node': specifier: ^20.10.1 version: 20.12.2 @@ -1624,6 +1651,9 @@ packages: '@types/dagre@0.7.52': resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==} + '@types/dompurify@3.0.5': + resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==} + '@types/eslint@8.56.7': resolution: {integrity: sha512-SjDvI/x3zsZnOkYZ3lCt9lOZWZLB2jIlNKz+LBgCtDurK0JZcwucxYHn1w2BJkD34dgX9Tjnak0txtq4WTggEA==} @@ -1672,6 +1702,9 @@ packages: '@types/swagger-schema-official@2.0.25': resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -1741,6 +1774,12 @@ packages: peerDependencies: react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + '@visx/bounds@3.3.0': + resolution: {integrity: sha512-gESmN+4N2NkeUzqQEDZaS63umkGfMp9XjQcKBqtOR64mjjQtamh3lNVRWvKjJ2Zb421RbYHWq22Wv9nay6ZUOg==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + react-dom: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + '@visx/brush@3.6.1': resolution: {integrity: sha512-HGSxYTqeZI/hjD+dwCVfY5lnbVv0bHC8PuWSswL15+cbTGT8G2/qoe6RSmzn6bgVcqdURpZztqDVWWa48PvHIw==} peerDependencies: @@ -1762,6 +1801,11 @@ packages: peerDependencies: react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + '@visx/grid@3.5.0': + resolution: {integrity: sha512-i1pdobTE223ItMiER3q4ojIaZWja3vg46TkS6FotnBZ4c0VRDHSrALQPdi0na+YEgppASWCQ2WrI/vD6mIkhSg==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + '@visx/group@3.3.0': resolution: {integrity: sha512-yKepDKwJqlzvnvPS0yDuW13XNrYJE4xzT6xM7J++441nu6IybWWwextyap8ey+kU651cYDb+q1Oi6aHvQwyEyw==} peerDependencies: @@ -1796,6 +1840,12 @@ packages: peerDependencies: react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + '@visx/tooltip@3.3.0': + resolution: {integrity: sha512-0ovbxnvAphEU/RVJprWHdOJT7p3YfBDpwXclXRuhIY2EkH59g8sDHatDcYwiNPeqk61jBh1KACRZxqToMuutlg==} + peerDependencies: + react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 + react-dom: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 + '@visx/vendor@3.5.0': resolution: {integrity: sha512-yt3SEZRVmt36+APsCISSO9eSOtzQkBjt+QRxNRzcTWuzwMAaF3PHCCSe31++kkpgY9yFoF+Gfes1TBe5NlETiQ==} @@ -1853,6 +1903,11 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + ansi-to-html@0.7.2: + resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==} + engines: {node: '>=8.0.0'} + hasBin: true + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2178,6 +2233,12 @@ packages: resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} engines: {node: '>= 0.4'} + date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2234,6 +2295,9 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dompurify@3.1.6: + resolution: {integrity: sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==} + dotenv@16.4.5: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} @@ -2257,6 +2321,9 @@ packages: resolution: {integrity: sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==} engines: {node: '>=10.13.0'} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3290,6 +3357,12 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-day-picker@8.10.1: + resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} + peerDependencies: + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -3374,6 +3447,12 @@ packages: peerDependencies: react: '>= 0.14.0' + react-use-measure@2.1.1: + resolution: {integrity: sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -5387,6 +5466,10 @@ snapshots: '@types/dagre@0.7.52': {} + '@types/dompurify@3.0.5': + dependencies: + '@types/trusted-types': 2.0.7 + '@types/eslint@8.56.7': dependencies: '@types/estree': 1.0.5 @@ -5433,6 +5516,8 @@ snapshots: '@types/swagger-schema-official@2.0.25': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@2.0.10': {} '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.2.2))(eslint@8.57.0)(typescript@5.2.2)': @@ -5535,6 +5620,14 @@ snapshots: prop-types: 15.8.1 react: 18.2.0 + '@visx/bounds@3.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@types/react': 18.2.73 + '@types/react-dom': 18.2.23 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + '@visx/brush@3.6.1(react@18.2.0)': dependencies: '@visx/drag': 3.3.0(react@18.2.0) @@ -5570,6 +5663,18 @@ snapshots: prop-types: 15.8.1 react: 18.2.0 + '@visx/grid@3.5.0(react@18.2.0)': + dependencies: + '@types/react': 18.2.73 + '@visx/curve': 3.3.0 + '@visx/group': 3.3.0(react@18.2.0) + '@visx/point': 3.3.0 + '@visx/scale': 3.5.0 + '@visx/shape': 3.5.0(react@18.2.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + '@visx/group@3.3.0(react@18.2.0)': dependencies: '@types/react': 18.2.73 @@ -5629,6 +5734,16 @@ snapshots: react: 18.2.0 reduce-css-calc: 1.3.0 + '@visx/tooltip@3.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@types/react': 18.2.73 + '@visx/bounds': 3.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-use-measure: 2.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@visx/vendor@3.5.0': dependencies: '@types/d3-array': 3.0.3 @@ -5706,6 +5821,10 @@ snapshots: ansi-styles@6.2.1: {} + ansi-to-html@0.7.2: + dependencies: + entities: 2.2.0 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -6079,6 +6198,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.1 + date-fns@3.6.0: {} + + debounce@1.2.1: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6125,6 +6248,8 @@ snapshots: dependencies: esutils: 2.0.3 + dompurify@3.1.6: {} + dotenv@16.4.5: {} eastasianwidth@0.2.0: {} @@ -6142,6 +6267,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.1 + entities@2.2.0: {} + env-paths@2.2.1: {} error-ex@1.3.2: @@ -7266,6 +7393,11 @@ snapshots: queue-microtask@1.2.3: {} + react-day-picker@8.10.1(date-fns@3.6.0)(react@18.2.0): + dependencies: + date-fns: 3.6.0 + react: 18.2.0 + react-dom@18.2.0(react@18.2.0): dependencies: loose-envify: 1.4.0 @@ -7346,6 +7478,12 @@ snapshots: react: 18.2.0 refractor: 3.6.0 + react-use-measure@2.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + debounce: 1.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react@18.2.0: dependencies: loose-envify: 1.4.0 diff --git a/frontend/app/src/components/cloud/logging/logs.tsx b/frontend/app/src/components/cloud/logging/logs.tsx new file mode 100644 index 000000000..444fafbd0 --- /dev/null +++ b/frontend/app/src/components/cloud/logging/logs.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useRef, useState } from 'react'; +import AnsiToHtml from 'ansi-to-html'; +import DOMPurify from 'dompurify'; +import { LogLine } from '@/lib/api/generated/cloud/data-contracts'; + +const convert = new AnsiToHtml({ + newline: true, + bg: 'transparent', +}); + +type LogProps = { + logs: LogLine[]; + onTopReached: () => void; + onBottomReached: () => void; +}; + +const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}; + +const LoggingComponent: React.FC = ({ + logs, + onTopReached, + onBottomReached, +}) => { + const containerRef = useRef(null); + const [refreshing, setRefreshing] = useState(false); + const [lastTopCall, setLastTopCall] = useState(0); + const [lastBottomCall, setLastBottomCall] = useState(0); + const [firstMount, setFirstMount] = useState(true); + const previousScrollHeightRef = useRef(0); + + const handleScroll = () => { + if (!containerRef.current) { + return; + } + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + previousScrollHeightRef.current = scrollHeight; + const now = Date.now(); + + if (scrollTop === 0 && now - lastTopCall >= 1000) { + if (logs.length > 0) { + onTopReached(); + } + setLastTopCall(now); + } else if ( + scrollTop + clientHeight >= scrollHeight && + now - lastBottomCall >= 1000 + ) { + if (logs.length > 0) { + onBottomReached(); + } + setLastBottomCall(now); + } + }; + + useEffect(() => { + setTimeout(() => { + const container = containerRef.current; + + if (container && container.scrollHeight > container.clientHeight) { + if (firstMount) { + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }); + + setFirstMount(false); + } + } + }, 250); + }, [containerRef, firstMount]); + + useEffect(() => { + if (refreshing) { + const timer = setTimeout(() => { + setRefreshing(false); + }, 1000); + return () => clearTimeout(timer); + } + }, [refreshing]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const previousScrollHeight = previousScrollHeightRef.current; + const currentScrollHeight = container.scrollHeight; + const { scrollTop, clientHeight } = container; + + const isAtBottom = scrollTop + clientHeight >= previousScrollHeight; + + if (!isAtBottom) { + const newScrollTop = + scrollTop + (currentScrollHeight - previousScrollHeight); + container.scrollTo({ top: newScrollTop }); + } else { + container.scrollTo({ top: currentScrollHeight, behavior: 'smooth' }); + } + }, [logs]); + + const showLogs = + logs.length > 0 + ? logs + : [ + { + line: 'Waiting for logs...', + timestamp: new Date().toISOString(), + instance: 'Hatchet', + }, + ]; + + return ( +
+ {refreshing && ( +
+ Refreshing... +
+ )} + {showLogs.map((log, i) => { + const sanitizedHtml = DOMPurify.sanitize(convert.toHtml(log.line), { + USE_PROFILES: { html: true }, + }); + + const logHash = log.timestamp + generateHash(log.line); + + return ( +

+ + {new Date(log.timestamp) + .toLocaleString('sv', options) + .replace(',', '.') + .replace(' ', 'T')} + + {log.instance} + +

+ ); + })} +
+ ); +}; + +const generateHash = (input: string): string => { + const trimmedInput = input.substring(0, 50); + return cyrb53(trimmedInput) + ''; +}; + +// source: https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js +const cyrb53 = function (str: string, seed = 0) { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; + +export default LoggingComponent; diff --git a/frontend/app/src/components/molecules/brush-chart/area-chart.tsx b/frontend/app/src/components/molecules/brush-chart/area-chart.tsx index 4b7238ef5..0a10675b9 100644 --- a/frontend/app/src/components/molecules/brush-chart/area-chart.tsx +++ b/frontend/app/src/components/molecules/brush-chart/area-chart.tsx @@ -1,19 +1,66 @@ -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import { Group } from '@visx/group'; -import { AreaClosed } from '@visx/shape'; -import { AxisLeft, AxisBottom, AxisScale } from '@visx/axis'; +import { AreaClosed, Line, Bar } from '@visx/shape'; +import { + withTooltip, + TooltipWithBounds, + Tooltip, + defaultStyles, +} from '@visx/tooltip'; +import { GridRows, GridColumns } from '@visx/grid'; +import { WithTooltipProvidedProps } from '@visx/tooltip/lib/enhancers/withTooltip'; +import { scaleTime, scaleLinear } from '@visx/scale'; +import { AxisLeft, AxisBottom } from '@visx/axis'; import { LinearGradient } from '@visx/gradient'; import { curveMonotoneX } from '@visx/curve'; -import { AppleStock } from '@visx/mock-data/lib/mocks/appleStock'; +import { localPoint } from '@visx/event'; +import { max, extent, bisector } from '@visx/vendor/d3-array'; +import { timeFormat } from '@visx/vendor/d3-time-format'; +import { Text } from '@visx/text'; + +const getDate = (d: MetricValue) => d.date; +const getValue = (d: MetricValue) => d.value; + +// format to 2 decimal places +export const format2Dec = (d: number) => { + if (!d.toFixed) { + return '0.00'; + } + + return `${d.toFixed(2)}`; +}; + +const bisectDate = bisector((d) => d.date).left; + +export interface MetricValue { + date: Date; + value: number; +} +type TooltipData = MetricValue; + +const formatDate = timeFormat('%y-%m-%d %I:%M:%S'); + +const accentColor = '#ffffff44'; +const background = '#1E293B'; +const background2 = '#8c77e0'; +const accentColorDark = '#8c77e0'; + +const tooltipStyles = { + ...defaultStyles, + border: '1px solid white', + color: 'white', + background, +}; + +const axisColor = '#cecece'; -// Initialize some variables -const axisColor = '#fff'; const axisBottomTickLabelProps = { textAnchor: 'middle' as const, fontFamily: 'Arial', fontSize: 10, fill: axisColor, }; + const axisLeftTickLabelProps = { dx: '-0.25em', dy: '0.25em', @@ -23,79 +70,253 @@ const axisLeftTickLabelProps = { fill: axisColor, }; -// accessors -const getDate = (d: AppleStock) => new Date(d.date); -const getStockValue = (d: AppleStock) => d.close; +export const formatPercentTooltip = (d: number) => `${format2Dec(d)}%`; -export default function AreaChart({ - data, - gradientColor, - width, - yMax, - margin, - xScale, - yScale, - hideBottomAxis = false, - hideLeftAxis = false, - top, - left, - children, -}: { - data: AppleStock[]; - gradientColor: string; - xScale: AxisScale; - yScale: AxisScale; +type AreaChartProps = { + data: MetricValue[]; + gradientColor?: string; width: number; - yMax: number; - margin: { top: number; right: number; bottom: number; left: number }; + height: number; hideBottomAxis?: boolean; hideLeftAxis?: boolean; - top?: number; - left?: number; children?: React.ReactNode; -}) { - if (width < 10) { - return null; - } - return ( - - - - data={data} - x={(d) => xScale(getDate(d)) || 0} - y={(d) => yScale(getStockValue(d)) || 0} - yScale={yScale} - strokeWidth={1} - stroke="url(#gradient)" - fill="url(#gradient)" - curve={curveMonotoneX} - /> - {!hideBottomAxis && ( - 520 ? 10 : 5} - stroke={axisColor} - tickStroke={axisColor} - tickLabelProps={axisBottomTickLabelProps} - /> - )} - {!hideLeftAxis && ( - - )} - {children} - - ); -} + yLabel?: string; + xLabel?: string; + yDomain?: [number, number]; + xDomain?: [Date, Date]; + centerText?: string; + tooltipFormat?: (d: number) => string; +}; + +export default withTooltip( + ({ + data, + gradientColor = background2, + width, + height, + hideBottomAxis = false, + hideLeftAxis = false, + children, + yLabel, + xLabel, + yDomain, + xDomain, + centerText, + showTooltip, + hideTooltip, + tooltipFormat, + tooltipData, + tooltipTop = 0, + tooltipLeft = 0, + }: AreaChartProps & WithTooltipProvidedProps) => { + if (width < 10) { + return null; + } + + const innerWidth = width; + const innerHeight = height; + + const dateScale = useMemo( + () => + scaleTime({ + range: [0, width], + domain: xDomain || (extent(data, getDate) as [Date, Date]), + }), + [width, data, xDomain], + ); + + const yScale = useMemo( + () => + scaleLinear({ + range: [height, 0], + domain: yDomain || [0, 1.3 * (max(data, getValue) || 0)], + nice: true, + }), + [height, data, yDomain], + ); + + const handleTooltip = useCallback( + ( + event: + | React.TouchEvent + | React.MouseEvent, + ) => { + const { x } = localPoint(event) || { x: 0 }; + const x0 = dateScale.invert(x); + const index = bisectDate(data, x0, 1); + const d0 = data[index - 1]; + const d1 = data[index]; + let d = d0; + if (d1 && getDate(d1)) { + d = + x0.valueOf() - getDate(d0).valueOf() > + getDate(d1).valueOf() - x0.valueOf() + ? d1 + : d0; + } + + showTooltip({ + tooltipData: d, + tooltipLeft: x, + tooltipTop: yScale(getValue(d)), + }); + }, + [showTooltip, yScale, dateScale, data], + ); + + return ( +
+ + {centerText && ( + + {centerText} + + )} + + + + + + + data={data} + x={(d) => dateScale(d.date) || 0} + y={(d) => yScale(d.value) || 0} + yScale={yScale} + strokeWidth={1} + stroke="url(#gradient)" + fill="url(#gradient)" + curve={curveMonotoneX} + height={innerHeight} + /> + {!hideBottomAxis && ( + 520 ? 10 : 5} + stroke={axisColor} + tickStroke={axisColor} + tickLabelProps={axisBottomTickLabelProps} + label={xLabel} + /> + )} + {!hideLeftAxis && ( + + )} + {children} + + hideTooltip()} + /> + {data.length > 0 && tooltipData && ( + + + + + + )} + + {data.length > 0 && tooltipData && ( +
+ + {tooltipFormat + ? tooltipFormat(getValue(tooltipData)) + : getValue(tooltipData)} + + + {formatDate(getDate(tooltipData))} + +
+ )} +
+ ); + }, +); diff --git a/frontend/app/src/components/molecules/brush-chart/brush-chart.tsx b/frontend/app/src/components/molecules/brush-chart/brush-chart.tsx deleted file mode 100644 index 5b665da02..000000000 --- a/frontend/app/src/components/molecules/brush-chart/brush-chart.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { useRef, useState, useMemo } from 'react'; -import { scaleTime, scaleLinear } from '@visx/scale'; -import appleStock, { AppleStock } from '@visx/mock-data/lib/mocks/appleStock'; -import { Brush } from '@visx/brush'; -import { Bounds } from '@visx/brush/lib/types'; -import BaseBrush, { - BaseBrushState, - UpdateBrush, -} from '@visx/brush/lib/BaseBrush'; -import { PatternLines } from '@visx/pattern'; -import { Group } from '@visx/group'; -import { max, extent } from '@visx/vendor/d3-array'; -import { BrushHandleRenderProps } from '@visx/brush/lib/BrushHandle'; -import AreaChart from './area-chart'; -import { Button } from '@/components/ui/button'; - -// Initialize some variables -const stock = appleStock.slice(1000); -const brushMargin = { top: 10, bottom: 15, left: 50, right: 20 }; -const chartSeparation = 30; -const PATTERN_ID = 'brush_pattern'; -export const accentColor = '#ffffff44'; -export const background = '#1E293B'; -export const background2 = '#8c77e0'; -const selectedBrushStyle = { - fill: `url(#${PATTERN_ID})`, - stroke: 'white', -}; - -// accessors -const getDate = (d: AppleStock) => new Date(d.date); -const getStockValue = (d: AppleStock) => d.close; - -export type BrushProps = { - width: number; - height: number; - margin?: { top: number; right: number; bottom: number; left: number }; - compact?: boolean; -}; - -function BrushChart({ - compact = false, - width, - height, - margin = { - top: 20, - left: 50, - bottom: 20, - right: 20, - }, -}: BrushProps) { - const brushRef = useRef(null); - const [filteredStock, setFilteredStock] = useState(stock); - - const onBrushChange = (domain: Bounds | null) => { - if (!domain) { - return; - } - const { x0, x1, y0, y1 } = domain; - const stockCopy = stock.filter((s) => { - const x = getDate(s).getTime(); - const y = getStockValue(s); - return x > x0 && x < x1 && y > y0 && y < y1; - }); - setFilteredStock(stockCopy); - }; - - const innerHeight = height - margin.top - margin.bottom; - const topChartBottomMargin = compact - ? chartSeparation / 2 - : chartSeparation + 10; - const topChartHeight = 0.8 * innerHeight - topChartBottomMargin; - const bottomChartHeight = innerHeight - topChartHeight - chartSeparation; - - // bounds - const xMax = Math.max(width - margin.left - margin.right, 0); - const yMax = Math.max(topChartHeight, 0); - const xBrushMax = Math.max(width - brushMargin.left - brushMargin.right, 0); - const yBrushMax = Math.max( - bottomChartHeight - brushMargin.top - brushMargin.bottom, - 0, - ); - - // scales - const dateScale = useMemo( - () => - scaleTime({ - range: [0, xMax], - domain: extent(filteredStock, getDate) as [Date, Date], - }), - [xMax, filteredStock], - ); - const stockScale = useMemo( - () => - scaleLinear({ - range: [yMax, 0], - domain: [0, max(filteredStock, getStockValue) || 0], - nice: true, - }), - [yMax, filteredStock], - ); - const brushDateScale = useMemo( - () => - scaleTime({ - range: [0, xBrushMax], - domain: extent(stock, getDate) as [Date, Date], - }), - [xBrushMax], - ); - const brushStockScale = useMemo( - () => - scaleLinear({ - range: [yBrushMax, 0], - domain: [0, max(stock, getStockValue) || 0], - nice: true, - }), - [yBrushMax], - ); - - const initialBrushPosition = useMemo( - () => ({ - start: { x: brushDateScale(getDate(stock[50])) }, - end: { x: brushDateScale(getDate(stock[100])) }, - }), - [brushDateScale], - ); - - // event handlers - const handleClearClick = () => { - if (brushRef?.current) { - setFilteredStock(stock); - brushRef.current.reset(); - } - }; - - const handleResetClick = () => { - if (brushRef?.current) { - const updater: UpdateBrush = (prevBrush) => { - const newExtent = brushRef.current!.getExtent( - initialBrushPosition.start, - initialBrushPosition.end, - ); - - const newState: BaseBrushState = { - ...prevBrush, - start: { y: newExtent.y0, x: newExtent.x0 }, - end: { y: newExtent.y1, x: newExtent.x1 }, - extent: newExtent, - }; - - return newState; - }; - brushRef.current.updateBrush(updater); - } - }; - - return ( -
- - {/* */} - {/* */} - - - - setFilteredStock(stock)} - selectedBoxStyle={selectedBrushStyle} - useWindowMoveEvents - renderBrushHandle={(props) => } - /> - - - - -
- ); -} -// We need to manually offset the handles for them to be rendered at the right position -function BrushHandle({ x, height, isBrushActive }: BrushHandleRenderProps) { - const pathWidth = 8; - const pathHeight = 15; - if (!isBrushActive) { - return null; - } - return ( - - - - ); -} - -export default BrushChart; diff --git a/frontend/app/src/components/molecules/time-picker/date-time-picker.tsx b/frontend/app/src/components/molecules/time-picker/date-time-picker.tsx new file mode 100644 index 000000000..701f5fa71 --- /dev/null +++ b/frontend/app/src/components/molecules/time-picker/date-time-picker.tsx @@ -0,0 +1,69 @@ +import { add, format } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { TimePicker } from './time-picker'; +import { CalendarIcon } from '@radix-ui/react-icons'; + +type DateTimePickerProps = { + date: Date | undefined; + setDate: (date: Date | undefined) => void; + label: string; +}; + +export function DateTimePicker({ date, setDate, label }: DateTimePickerProps) { + /** + * carry over the current time when a user clicks a new day + * instead of resetting to 00:00 + */ + const handleSelect = (newDay: Date | undefined) => { + if (!newDay) { + return; + } + if (!date) { + setDate(newDay); + return; + } + const diff = newDay.getTime() - date.getTime(); + const diffInDays = diff / (1000 * 60 * 60 * 24); + const newDateFull = add(date, { days: Math.ceil(diffInDays) }); + setDate(newDateFull); + }; + + return ( + + + + + + handleSelect(d)} + initialFocus + /> +
+ +
+
+
+ ); +} diff --git a/frontend/app/src/components/molecules/time-picker/time-picker-input.tsx b/frontend/app/src/components/molecules/time-picker/time-picker-input.tsx new file mode 100644 index 000000000..b115da038 --- /dev/null +++ b/frontend/app/src/components/molecules/time-picker/time-picker-input.tsx @@ -0,0 +1,144 @@ +import { Input } from '@/components/ui/input'; + +import { cn } from '@/lib/utils'; +import React from 'react'; +import { + Period, + TimePickerType, + getArrowByType, + getDateByType, + setDateByType, +} from './time-picker-utils'; + +export interface TimePickerInputProps + extends React.InputHTMLAttributes { + picker: TimePickerType; + date: Date | undefined; + setDate: (date: Date | undefined) => void; + period?: Period; + onRightFocus?: () => void; + onLeftFocus?: () => void; +} + +const TimePickerInput = React.forwardRef< + HTMLInputElement, + TimePickerInputProps +>( + ( + { + className, + type = 'datetime-local', + value, + id, + name, + date = new Date(new Date().setHours(0, 0, 0, 0)), + setDate, + onChange, + onKeyDown, + picker, + period, + onLeftFocus, + onRightFocus, + ...props + }, + ref, + ) => { + const [flag, setFlag] = React.useState(false); + const [prevIntKey, setPrevIntKey] = React.useState('0'); + + /** + * allow the user to enter the second digit within 2 seconds + * otherwise start again with entering first digit + */ + React.useEffect(() => { + if (flag) { + const timer = setTimeout(() => { + setFlag(false); + }, 2000); + + return () => clearTimeout(timer); + } + }, [flag]); + + const calculatedValue = React.useMemo(() => { + return getDateByType(date, picker); + }, [date, picker]); + + const calculateNewValue = (key: string) => { + /* + * If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1. + * The second entered digit will break the condition and the value will be set to 10-12. + */ + if (picker === '12hours') { + if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') { + return '0' + key; + } + } + + return !flag ? '0' + key : calculatedValue.slice(1, 2) + key; + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + return; + } + e.preventDefault(); + if (e.key === 'ArrowRight') { + onRightFocus?.(); + } + if (e.key === 'ArrowLeft') { + onLeftFocus?.(); + } + if (['ArrowUp', 'ArrowDown'].includes(e.key)) { + const step = e.key === 'ArrowUp' ? 1 : -1; + const newValue = getArrowByType(calculatedValue, step, picker); + if (flag) { + setFlag(false); + } + const tempDate = new Date(date); + setDate(setDateByType(tempDate, newValue, picker, period)); + } + if (e.key >= '0' && e.key <= '9') { + if (picker === '12hours') { + setPrevIntKey(e.key); + } + + const newValue = calculateNewValue(e.key); + if (flag) { + onRightFocus?.(); + } + setFlag((prev) => !prev); + const tempDate = new Date(date); + setDate(setDateByType(tempDate, newValue, picker, period)); + } + }; + + return ( + { + e.preventDefault(); + onChange?.(e); + }} + type={type} + inputMode="decimal" + onKeyDown={(e) => { + onKeyDown?.(e); + handleKeyDown(e); + }} + {...props} + /> + ); + }, +); + +TimePickerInput.displayName = 'TimePickerInput'; + +export { TimePickerInput }; diff --git a/frontend/app/src/components/molecules/time-picker/time-picker-utils.ts b/frontend/app/src/components/molecules/time-picker/time-picker-utils.ts new file mode 100644 index 000000000..9bd251e6c --- /dev/null +++ b/frontend/app/src/components/molecules/time-picker/time-picker-utils.ts @@ -0,0 +1,226 @@ +/** + * regular expression to check for valid hour format (01-23) + */ +export function isValidHour(value: string) { + return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value); +} + +/** + * regular expression to check for valid 12 hour format (01-12) + */ +export function isValid12Hour(value: string) { + return /^(0[1-9]|1[0-2])$/.test(value); +} + +/** + * regular expression to check for valid minute format (00-59) + */ +export function isValidMinuteOrSecond(value: string) { + return /^[0-5][0-9]$/.test(value); +} + +type GetValidNumberConfig = { max: number; min?: number; loop?: boolean }; + +export function getValidNumber( + value: string, + { max, min = 0, loop = false }: GetValidNumberConfig, +) { + let numericValue = parseInt(value, 10); + + if (!isNaN(numericValue)) { + if (!loop) { + if (numericValue > max) { + numericValue = max; + } + if (numericValue < min) { + numericValue = min; + } + } else { + if (numericValue > max) { + numericValue = min; + } + if (numericValue < min) { + numericValue = max; + } + } + return numericValue.toString().padStart(2, '0'); + } + + return '00'; +} + +export function getValidHour(value: string) { + if (isValidHour(value)) { + return value; + } + return getValidNumber(value, { max: 23 }); +} + +export function getValid12Hour(value: string) { + if (isValid12Hour(value)) { + return value; + } + return getValidNumber(value, { min: 1, max: 12 }); +} + +export function getValidMinuteOrSecond(value: string) { + if (isValidMinuteOrSecond(value)) { + return value; + } + return getValidNumber(value, { max: 59 }); +} + +type GetValidArrowNumberConfig = { + min: number; + max: number; + step: number; +}; + +export function getValidArrowNumber( + value: string, + { min, max, step }: GetValidArrowNumberConfig, +) { + let numericValue = parseInt(value, 10); + if (!isNaN(numericValue)) { + numericValue += step; + return getValidNumber(String(numericValue), { min, max, loop: true }); + } + return '00'; +} + +export function getValidArrowHour(value: string, step: number) { + return getValidArrowNumber(value, { min: 0, max: 23, step }); +} + +export function getValidArrow12Hour(value: string, step: number) { + return getValidArrowNumber(value, { min: 1, max: 12, step }); +} + +export function getValidArrowMinuteOrSecond(value: string, step: number) { + return getValidArrowNumber(value, { min: 0, max: 59, step }); +} + +export function setMinutes(date: Date, value: string) { + const minutes = getValidMinuteOrSecond(value); + date.setMinutes(parseInt(minutes, 10)); + return date; +} + +export function setSeconds(date: Date, value: string) { + const seconds = getValidMinuteOrSecond(value); + date.setSeconds(parseInt(seconds, 10)); + return date; +} + +export function setHours(date: Date, value: string) { + const hours = getValidHour(value); + date.setHours(parseInt(hours, 10)); + return date; +} + +export function set12Hours(date: Date, value: string, period: Period) { + const hours = parseInt(getValid12Hour(value), 10); + const convertedHours = convert12HourTo24Hour(hours, period); + date.setHours(convertedHours); + return date; +} + +export type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours'; +export type Period = 'AM' | 'PM'; + +export function setDateByType( + date: Date, + value: string, + type: TimePickerType, + period?: Period, +) { + switch (type) { + case 'minutes': + return setMinutes(date, value); + case 'seconds': + return setSeconds(date, value); + case 'hours': + return setHours(date, value); + case '12hours': { + if (!period) { + return date; + } + return set12Hours(date, value, period); + } + default: + return date; + } +} + +export function getDateByType(date: Date, type: TimePickerType) { + switch (type) { + case 'minutes': + return getValidMinuteOrSecond(String(date.getMinutes())); + case 'seconds': + return getValidMinuteOrSecond(String(date.getSeconds())); + case 'hours': + return getValidHour(String(date.getHours())); + case '12hours': + return getValid12Hour(String(display12HourValue(date.getHours()))); + default: + return '00'; + } +} + +export function getArrowByType( + value: string, + step: number, + type: TimePickerType, +) { + switch (type) { + case 'minutes': + return getValidArrowMinuteOrSecond(value, step); + case 'seconds': + return getValidArrowMinuteOrSecond(value, step); + case 'hours': + return getValidArrowHour(value, step); + case '12hours': + return getValidArrow12Hour(value, step); + default: + return '00'; + } +} + +/** + * handles value change of 12-hour input + * 12:00 PM is 12:00 + * 12:00 AM is 00:00 + */ +export function convert12HourTo24Hour(hour: number, period: Period) { + if (period === 'PM') { + if (hour <= 11) { + return hour + 12; + } else { + return hour; + } + } else if (period === 'AM') { + if (hour === 12) { + return 0; + } + return hour; + } + return hour; +} + +/** + * time is stored in the 24-hour form, + * but needs to be displayed to the user + * in its 12-hour representation + */ +export function display12HourValue(hours: number) { + if (hours === 0 || hours === 12) { + return '12'; + } + if (hours >= 22) { + return `${hours - 12}`; + } + if (hours % 12 > 9) { + return `${hours}`; + } + return `0${hours % 12}`; +} diff --git a/frontend/app/src/components/molecules/time-picker/time-picker.tsx b/frontend/app/src/components/molecules/time-picker/time-picker.tsx new file mode 100644 index 000000000..115c21288 --- /dev/null +++ b/frontend/app/src/components/molecules/time-picker/time-picker.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { Label } from '@/components/ui/label'; +import { TimePickerInput } from './time-picker-input'; +import { ClockIcon } from '@radix-ui/react-icons'; + +interface TimePickerProps { + date: Date | undefined; + setDate: (date: Date | undefined) => void; +} + +export function TimePicker({ date, setDate }: TimePickerProps) { + const minuteRef = React.useRef(null); + const hourRef = React.useRef(null); + const secondRef = React.useRef(null); + + return ( +
+
+ + minuteRef.current?.focus()} + /> +
+
+ + hourRef.current?.focus()} + onRightFocus={() => secondRef.current?.focus()} + /> +
+
+ + minuteRef.current?.focus()} + /> +
+
+ +
+
+ ); +} diff --git a/frontend/app/src/components/ui/calendar.tsx b/frontend/app/src/components/ui/calendar.tsx new file mode 100644 index 000000000..99b332676 --- /dev/null +++ b/frontend/app/src/components/ui/calendar.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons'; +import { DayPicker } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md' + : '[&:has([aria-selected])]:rounded-md', + ), + day: cn( + buttonVariants({ variant: 'ghost' }), + 'h-8 w-8 p-0 font-normal aria-selected:opacity-100', + ), + day_range_start: 'day-range-start', + day_range_end: 'day-range-end', + day_selected: + 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', + day_today: 'bg-accent text-accent-foreground', + day_outside: + 'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30', + day_disabled: 'text-muted-foreground opacity-50', + day_range_middle: + 'aria-selected:bg-accent aria-selected:text-accent-foreground', + day_hidden: 'invisible', + ...classNames, + }} + components={{ + IconLeft: () => , + IconRight: () => , + }} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; diff --git a/frontend/app/src/components/ui/envvar.tsx b/frontend/app/src/components/ui/envvar.tsx index a1599e056..51bd5f683 100644 --- a/frontend/app/src/components/ui/envvar.tsx +++ b/frontend/app/src/components/ui/envvar.tsx @@ -120,6 +120,7 @@ const EnvGroupArray: React.FC = ({ {!disabled && (
@@ -153,17 +178,23 @@ function Sidebar({ className, memberships, currTenant }: SidebarProps) { , + , , , , void; to: string; name: string; + prefix?: string; }) { const location = useLocation(); - const selected = location.pathname === to; + const hasPrefix = prefix && location.pathname.startsWith(prefix); + const selected = hasPrefix || location.pathname === to; return ( diff --git a/frontend/app/src/pages/main/tenant-settings/github/index.tsx b/frontend/app/src/pages/main/tenant-settings/github/index.tsx index e1e063e70..238949a12 100644 --- a/frontend/app/src/pages/main/tenant-settings/github/index.tsx +++ b/frontend/app/src/pages/main/tenant-settings/github/index.tsx @@ -1,16 +1,26 @@ import { Separator } from '@/components/ui/separator'; -import { useApiMetaIntegrations } from '@/lib/hooks'; import { useQuery } from '@tanstack/react-query'; import { queries } from '@/lib/api'; import { columns as githubInstallationsColumns } from './components/github-installations-columns'; import { DataTable } from '@/components/molecules/data-table/data-table'; import { Button } from '@/components/ui/button'; +import useCloudApiMeta from '@/pages/auth/hooks/use-cloud-api-meta'; export default function Github() { - const integrations = useApiMetaIntegrations(); + const cloudMeta = useCloudApiMeta(); - const hasGithubIntegration = integrations?.find((i) => i.name === 'github'); + const hasGithubIntegration = cloudMeta?.data.canLinkGithub; + + if (!cloudMeta || !hasGithubIntegration) { + return ( +
+

+ Not enabled for this tenant or instance. +

+
+ ); + } return (
@@ -43,7 +53,7 @@ function GithubInstallationsList() {

Github Accounts

- +
diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/github-button.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/github-button.tsx new file mode 100644 index 000000000..131215d87 --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/github-button.tsx @@ -0,0 +1,36 @@ +import { Button } from '@/components/ui/button'; +import { ManagedWorkerBuildConfig } from '@/lib/api/generated/cloud/data-contracts'; +import { GitHubLogoIcon } from '@radix-ui/react-icons'; + +export default function GithubButton({ + buildConfig, + commitSha, + prefix, +}: { + buildConfig: ManagedWorkerBuildConfig; + commitSha?: string; + prefix?: string; +}) { + return ( + + ); +} + +function getHref(buildConfig: ManagedWorkerBuildConfig, commitSha?: string) { + const root = `https://github.com/${buildConfig.githubRepository.repo_owner}/${buildConfig.githubRepository.repo_name}`; + return commitSha ? root + '/commit/' + commitSha : root; +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-activity.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-activity.tsx new file mode 100644 index 000000000..7d124c10c --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-activity.tsx @@ -0,0 +1,191 @@ +import { queries } from '@/lib/api'; +import { useQuery } from '@tanstack/react-query'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Spinner } from '@/components/ui/loading'; +import RelativeDate from '@/components/molecules/relative-date'; +import { Button } from '@/components/ui/button'; +import { ArrowRightIcon, ChevronLeftIcon } from '@radix-ui/react-icons'; +import { cn } from '@/lib/utils'; +import { useState } from 'react'; +import { + ManagedWorker, + ManagedWorkerEvent, + ManagedWorkerEventStatus, +} from '@/lib/api/generated/cloud/data-contracts'; +import { Separator } from '@/components/ui/separator'; +import { ManagedWorkerBuild } from './managed-worker-build'; +import GithubButton from './github-button'; + +export function ManagedWorkerActivity({ + managedWorker, +}: { + managedWorker: ManagedWorker | undefined; +}) { + const [buildId, setBuildId] = useState(); + + if (buildId) { + return setBuildId(undefined)} />; + } + + return ; +} + +function EventList({ + managedWorker, + setBuildId, +}: { + managedWorker: ManagedWorker | undefined; + setBuildId: (id: string) => void; +}) { + const getLogsQuery = useQuery({ + ...queries.cloud.listManagedWorkerEvents(managedWorker!.metadata.id || ''), + enabled: !!managedWorker, + refetchInterval: () => { + return 5000; + }, + }); + + if (!managedWorker || getLogsQuery.isLoading) { + return ; + } + + const events = getLogsQuery.data?.rows || []; + + return ( +
+ {getLogsQuery.isLoading && } + {events.length === 0 && ( + + + + No events found + + + + )} + {events.map((item, index) => ( + + ))} +
+ ); +} + +function Build({ buildId, back }: { buildId: string; back: () => void }) { + return ( +
+
+ +
+ + +
+ ); +} + +function ManagedWorkerEventCard({ + managedWorker, + event, + setBuildId, +}: { + managedWorker: ManagedWorker; + event: ManagedWorkerEvent; + setBuildId: (id: string) => void; +}) { + return ( + + +
+
+ + + {event.message} + +
+ +
+ {event.message} +
+ + {renderCardFooter(managedWorker, event, setBuildId)} +
+ ); +} + +function renderCardFooter( + managedWorker: ManagedWorker, + event: ManagedWorkerEvent, + setBuildId: (id: string) => void, +) { + if (event.data) { + const data = event.data as any; + + const buttons = []; + + if (data.build_id) { + buttons.push( + , + ); + } + + if (data.commit_sha) { + buttons.push( + , + ); + } + + if (buttons.length) { + return {buttons}; + } + } + + return null; +} + +const RUN_STATUS_VARIANTS: Record = { + SUCCEEDED: 'border-transparent rounded-full bg-green-500', + FAILED: 'border-transparent rounded-full bg-red-500', + CANCELLED: 'border-transparent rounded-full bg-gray-500', + IN_PROGRESS: 'border-transparent rounded-full bg-yellow-500', +}; + +function EventIndicator({ severity }: { severity: ManagedWorkerEventStatus }) { + return ( +
+ ); +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-build-logs.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-build-logs.tsx new file mode 100644 index 000000000..12aa94e2c --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-build-logs.tsx @@ -0,0 +1,28 @@ +import { queries } from '@/lib/api'; +import { useQuery } from '@tanstack/react-query'; +import LoggingComponent from '@/components/cloud/logging/logs'; + +export function ManagedWorkerBuildLogs({ buildId }: { buildId: string }) { + const getBuildLogsQuery = useQuery({ + ...queries.cloud.getBuildLogs(buildId), + refetchInterval: 5000, + }); + + const logs = getBuildLogsQuery.data?.rows || [ + { + line: 'Loading...', + timestamp: new Date().toISOString(), + instance: 'Hatchet', + }, + ]; + + return ( +
+ {}} + onTopReached={() => {}} + /> +
+ ); +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-build.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-build.tsx new file mode 100644 index 000000000..55bb63f20 --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-build.tsx @@ -0,0 +1,43 @@ +import { queries } from '@/lib/api'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner } from '@/components/ui/loading'; +import { ManagedWorkerBuildLogs } from './managed-worker-build-logs'; +import RelativeDate from '@/components/molecules/relative-date'; + +export function ManagedWorkerBuild({ buildId }: { buildId: string }) { + const getBuildQuery = useQuery({ + ...queries.cloud.getBuild(buildId), + }); + + if (getBuildQuery.isLoading) { + return ; + } + + const build = getBuildQuery.data; + + return ( +
+

Build Overview

+
+
+
+ Build ID: {build?.metadata?.id} +
+
+ Created: +
+ {build?.finishTime && ( +
+ Finished: +
+ )} +
+ Status: {getBuildQuery.data?.status} +
+
+
+

Build Logs

+ +
+ ); +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-instances-columns.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-instances-columns.tsx new file mode 100644 index 000000000..27c37bf67 --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-instances-columns.tsx @@ -0,0 +1,55 @@ +import { ColumnDef } from '@tanstack/react-table'; +import { DataTableColumnHeader } from '@/components/molecules/data-table/data-table-column-header'; +import { Instance } from '@/lib/api/generated/cloud/data-contracts'; + +export type InstanceWithMetadata = Instance & { + metadata: { + id: string; + }; +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.name} +
+ ), + enableSorting: true, + enableHiding: false, + }, + { + accessorKey: 'state', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.original.state}
+ ), + enableSorting: true, + enableHiding: false, + }, + { + accessorKey: 'commitSha', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( +
+ {row.original.commitSha.substring(0, 7)} +
+ ); + }, + enableSorting: false, + enableHiding: false, + }, +]; diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-instances-table.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-instances-table.tsx new file mode 100644 index 000000000..88ff88cf6 --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-instances-table.tsx @@ -0,0 +1,195 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { queries } from '@/lib/api'; +import invariant from 'tiny-invariant'; +import { TenantContextType } from '@/lib/outlet'; +import { useOutletContext } from 'react-router-dom'; +import { DataTable } from '@/components/molecules/data-table/data-table.tsx'; +import { columns } from './managed-worker-instances-columns'; +import { Loading } from '@/components/ui/loading.tsx'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardFooter, +} from '@/components/ui/card'; +import { capitalize } from '@/lib/utils'; +import { ArrowPathIcon } from '@heroicons/react/24/outline'; +import { VisibilityState } from '@tanstack/react-table'; +import { BiCard, BiTable } from 'react-icons/bi'; +import { Instance } from '@/lib/api/generated/cloud/data-contracts'; +import { Badge } from '@/components/ui/badge'; + +export function ManagedWorkerInstancesTable({ + managedWorkerId, +}: { + managedWorkerId: string; +}) { + const { tenant } = useOutletContext(); + invariant(tenant); + + const [columnVisibility, setColumnVisibility] = useState({}); + const [rotate, setRotate] = useState(false); + + const [cardToggle, setCardToggle] = useState(true); + + const listManagedWorkerInstancesQuery = useQuery({ + ...queries.cloud.listManagedWorkerInstances(managedWorkerId), + refetchInterval: 5000, + }); + + const data = useMemo(() => { + const data = listManagedWorkerInstancesQuery.data?.rows || []; + + return data; + }, [listManagedWorkerInstancesQuery.data?.rows]); + + if (listManagedWorkerInstancesQuery.isLoading) { + return ; + } + + const emptyState = ( + + + No Instances + +

+ There are no instances currently active for this managed worker + pool. +

+
+
+ +
+ ); + + const card: React.FC<{ data: Instance }> = ({ data }) => ( +
+
+
+

+ {data.name} +

+ +
+
+ CPUs: {data.cpus} {data.cpuKind} +
+
+ Memory: {data.memoryMb} MB +
+
+
+
+ ); + + const actions = [ + , + , + ]; + + const dataWithMetadata = data.map((d) => ({ + ...d, + metadata: { + id: d.instanceId, + }, + })); + + return ( + + ); +} + +const INSTANCE_STATUSES: Record< + string, + { + text: string; + variant: 'successful' | 'failed' | 'outline'; + } +> = { + started: { + text: 'Running', + variant: 'successful', + }, + suspended: { + text: 'Suspended', + variant: 'failed', + }, + destroyed: { + text: 'Destroyed', + variant: 'failed', + }, + stopped: { + text: 'Stopped', + variant: 'outline', + }, +}; + +const StateBadge = ({ state }: { state: string }) => { + let instanceStatus = INSTANCE_STATUSES[state]; + + if (!instanceStatus) { + instanceStatus = { + text: capitalize(state), + variant: 'outline', + }; + } + + return ( + + {capitalize(instanceStatus.text)} + + ); +}; diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-logs.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-logs.tsx new file mode 100644 index 000000000..345ee8ea0 --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-logs.tsx @@ -0,0 +1,191 @@ +import { queries } from '@/lib/api'; +import { useQuery } from '@tanstack/react-query'; +import { + LogLine, + ManagedWorker, +} from '@/lib/api/generated/cloud/data-contracts'; +import LoggingComponent from '@/components/cloud/logging/logs'; +import { useState, useEffect } from 'react'; +import { Input } from '@/components/ui/input'; +import { DateTimePicker } from '@/components/molecules/time-picker/date-time-picker'; +import { Button } from '@/components/ui/button'; +import { ArrowPathIcon } from '@heroicons/react/24/outline'; +import { ListCloudLogsQuery } from '@/lib/api/queries'; + +export function ManagedWorkerLogs({ + managedWorker, +}: { + managedWorker: ManagedWorker; +}) { + const [queryParams, setQueryParams] = useState({}); + const [beforeInput, setBeforeInput] = useState(); + const [afterInput, setAfterInput] = useState(); + const [searchInput, setSearchInput] = useState(''); + const [lastUpdatedAt, setLastUpdatedAt] = useState(); + const [mergedLogs, setMergedLogs] = useState([]); + const [rotate, setRotate] = useState(false); + + const getLogsQuery = useQuery({ + ...queries.cloud.getManagedWorkerLogs( + managedWorker?.metadata.id || '', + queryParams, + ), + enabled: !!managedWorker, + refetchInterval: 15000, + }); + + useEffect(() => { + const logs = getLogsQuery.data?.rows || []; + + if (!lastUpdatedAt && getLogsQuery.isSuccess) { + setLastUpdatedAt(getLogsQuery.dataUpdatedAt); + setMergedLogs(logs); + } else if ( + getLogsQuery.isSuccess && + getLogsQuery.dataUpdatedAt !== lastUpdatedAt + ) { + setLastUpdatedAt(getLogsQuery.dataUpdatedAt); + + setMergedLogs((prevLogs) => { + return mergeLogs(prevLogs, logs); + }); + } + }, [ + lastUpdatedAt, + getLogsQuery.isSuccess, + getLogsQuery.data, + getLogsQuery.dataUpdatedAt, + ]); + + const handleBottomReached = async () => { + if ( + getLogsQuery.isSuccess && + lastUpdatedAt && + // before input should be before the last log in the list + (!beforeInput || + beforeInput?.toISOString() > + mergedLogs[mergedLogs.length - 1]?.timestamp) + ) { + setQueryParams({ + ...queryParams, + before: beforeInput?.toISOString(), + after: mergedLogs[mergedLogs.length - 1]?.timestamp, + direction: 'forward', + }); + } + }; + + const handleTopReached = async () => { + if ( + getLogsQuery.isSuccess && + lastUpdatedAt && + // after input should be before the first log in the list + (!afterInput || afterInput?.toISOString() < mergedLogs[0]?.timestamp) + ) { + setQueryParams({ + ...queryParams, + before: mergedLogs[0]?.timestamp, + after: afterInput?.toISOString(), + direction: 'backward', + }); + } + }; + + const refreshLogs = () => { + setMergedLogs([]); + setQueryParams({ + ...queryParams, + before: beforeInput && beforeInput.toISOString(), + after: afterInput && afterInput.toISOString(), + }); + setRotate(!rotate); + }; + + const datesMatchSearch = + beforeInput?.toISOString() === queryParams?.before && + afterInput?.toISOString() === queryParams?.after; + + return ( +
+
+
+ setQueryParams({ + ...queryParams, + search: searchInput, + }) + } + > + setSearchInput(e.target.value)} + className="h-8 w-[150px] lg:w-[250px]" + /> + {/* hidden button for submitting input */} +
+
+
+ +
+ ); +} + +const mergeLogs = (existingLogs: LogLine[], newLogs: LogLine[]): LogLine[] => { + const combinedLogs = [...existingLogs, ...newLogs]; + const uniqueLogs = Array.from( + new Map( + combinedLogs.map((log) => [log.timestamp + log.instance + log.line, log]), + ).values(), + ); + + // sort logs by timestamp with collisions resolved by log line + uniqueLogs.sort((a, b) => { + if (a.timestamp === b.timestamp) { + return a.line < b.line ? -1 : 1; + } + return a.timestamp < b.timestamp ? -1 : 1; + }); + + // NOTE: this was used to truncate log lines to 300, but was causing issues with the scroll position + // in the LoggingComponent. I've left this here in case we want to revisit this in the future. + // if (uniqueLogs.length > 300) { + // // if favoring forward, truncate the newest logs + // if (favoredDirection === 'forward') { + // return uniqueLogs.slice(-300); + // } + + // return uniqueLogs.slice(0, 300); + // } + + return uniqueLogs; +}; diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-metrics.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-metrics.tsx new file mode 100644 index 000000000..d0a342005 --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/managed-worker-metrics.tsx @@ -0,0 +1,244 @@ +import { queries } from '@/lib/api'; +import { useQuery } from '@tanstack/react-query'; +import { + ManagedWorker, + SampleStream, +} from '@/lib/api/generated/cloud/data-contracts'; +import { Loading } from '@/components/ui/loading'; +import AreaChart, { + MetricValue, + format2Dec, + formatPercentTooltip, +} from '@/components/molecules/brush-chart/area-chart'; +import { useMemo, useState } from 'react'; +import { useParentSize } from '@visx/responsive'; +import { Separator } from '@/components/ui/separator'; +import { GetCloudMetricsQuery } from '@/lib/api/queries'; +import { DateTimePicker } from '@/components/molecules/time-picker/date-time-picker'; +import { Button } from '@/components/ui/button'; +import { ArrowPathIcon } from '@heroicons/react/24/outline'; + +export function ManagedWorkerMetrics({ + managedWorker, +}: { + managedWorker: ManagedWorker; +}) { + const [beforeInput, setBeforeInput] = useState(); + const [afterInput, setAfterInput] = useState(); + const [queryParams, setQueryParams] = useState({ + // default after is 1 day + after: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + }); + const [rotate, setRotate] = useState(false); + + const getCpuMetricsQuery = useQuery({ + ...queries.cloud.getManagedWorkerCpuMetrics( + managedWorker?.metadata.id || '', + queryParams, + ), + enabled: !!managedWorker, + refetchInterval: () => { + return 5000; + }, + }); + + const getMemoryMetricsQuery = useQuery({ + ...queries.cloud.getManagedWorkerMemoryMetrics( + managedWorker?.metadata.id || '', + queryParams, + ), + enabled: !!managedWorker, + refetchInterval: () => { + return 5000; + }, + }); + + const getDiskMetricsQuery = useQuery({ + ...queries.cloud.getManagedWorkerDiskMetrics( + managedWorker?.metadata.id || '', + queryParams, + ), + enabled: !!managedWorker, + refetchInterval: () => { + return 5000; + }, + }); + + if ( + getCpuMetricsQuery.isLoading || + getMemoryMetricsQuery.isLoading || + getDiskMetricsQuery.isLoading + ) { + return ; + } + + const refreshMetrics = () => { + setQueryParams({ + after: afterInput?.toISOString(), + before: beforeInput?.toISOString(), + }); + setRotate(!rotate); + }; + + const datesMatchSearch = + beforeInput?.toISOString() === queryParams?.before && + afterInput?.toISOString() === queryParams?.after; + + return ( +
+
+

+ Metrics +

+
+ + + +
+
+ +

+ CPU +

+ + {getCpuMetricsQuery.data?.length === 0 && ( + + )} + {getCpuMetricsQuery.data?.map((d, i) => { + return ( + + ); + })} +

+ Memory +

+ + {getMemoryMetricsQuery.data?.map((d, i) => { + return ( + { + return d / (1000 * 1000); + }} + yLabel="Memory (MB)" + tooltipFormat={(d) => { + return format2Dec(d) + ' MB'; + }} + /> + ); + })} +

+ Disk +

+ + {getDiskMetricsQuery.data?.map((d, i) => { + return ( + { + return d / (1000 * 1000); + }} + yLabel="Disk (MB)" + tooltipFormat={(d) => { + return format2Dec(d) + ' MB'; + }} + /> + ); + })} +
+ ); +} + +type MetricsChartProps = { + sample: SampleStream; + normalizer?: (value: number) => number; + yLabel: string; + tooltipFormat?: (d: number) => string; +}; + +function MetricsChart({ + sample, + normalizer, + yLabel, + tooltipFormat, +}: MetricsChartProps) { + const { parentRef, width, height } = useParentSize({ debounceTime: 150 }); + + const values: MetricValue[] = useMemo( + () => + sample.values?.map((v) => { + return { + date: new Date(v[0] * 1000), + value: normalizer ? normalizer(parseFloat(v[1])) : parseFloat(v[1]), + }; + }) || [], + [sample, normalizer], + ); + + return ( +
+ +
+ ); +} + +function MetricsPlaceholder({ start, end }: { start: Date; end: Date }) { + const { parentRef, width, height } = useParentSize({ debounceTime: 150 }); + + return ( +
+ +
+ ); +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/update-form.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/update-form.tsx new file mode 100644 index 000000000..a60cbc48e --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/components/update-form.tsx @@ -0,0 +1,500 @@ +import { queries } from '@/lib/api'; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useQuery } from '@tanstack/react-query'; +import { ExclamationTriangleIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { z } from 'zod'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Input } from '@/components/ui/input'; +import EnvGroupArray, { KeyValueType } from '@/components/ui/envvar'; +import { + createOrUpdateManagedWorkerSchema, + getRepoName, + getRepoOwner, + getRepoOwnerName, + machineTypes, +} from '../../create/components/create-worker-form'; +import { ManagedWorker } from '@/lib/api/generated/cloud/data-contracts'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; + +interface UpdateWorkerFormProps { + onSubmit: (opts: z.infer) => void; + isLoading: boolean; + fieldErrors?: Record; + managedWorker: ManagedWorker; +} + +export default function UpdateWorkerForm({ + onSubmit, + isLoading, + fieldErrors, + managedWorker, +}: UpdateWorkerFormProps) { + const { + watch, + handleSubmit, + control, + setValue, + formState: { errors }, + } = useForm>({ + resolver: zodResolver(createOrUpdateManagedWorkerSchema), + defaultValues: { + name: managedWorker.name, + buildConfig: { + githubInstallationId: managedWorker.buildConfig.githubInstallationId, + githubRepositoryBranch: + managedWorker.buildConfig.githubRepositoryBranch, + githubRepositoryName: + managedWorker.buildConfig.githubRepository.repo_name, + githubRepositoryOwner: + managedWorker.buildConfig.githubRepository.repo_owner, + steps: managedWorker.buildConfig.steps?.map((step) => ({ + buildDir: step.buildDir, + dockerfilePath: step.dockerfilePath, + })) || [ + { + buildDir: '.', + dockerfilePath: './Dockerfile', + }, + ], + }, + runtimeConfig: { + numReplicas: managedWorker.runtimeConfig.numReplicas, + cpuKind: managedWorker.runtimeConfig.cpuKind, + cpus: managedWorker.runtimeConfig.cpus, + memoryMb: managedWorker.runtimeConfig.memoryMb, + envVars: managedWorker.runtimeConfig.envVars, + }, + }, + }); + + const [machineType, setMachineType] = useState( + '1 CPU, 1 GB RAM (shared CPU)', + ); + + const installation = watch('buildConfig.githubInstallationId'); + const repoOwner = watch('buildConfig.githubRepositoryOwner'); + const repoName = watch('buildConfig.githubRepositoryName'); + const repoOwnerName = getRepoOwnerName(repoOwner, repoName); + const branch = watch('buildConfig.githubRepositoryBranch'); + + const listInstallationsQuery = useQuery({ + ...queries.github.listInstallations, + }); + + const listReposQuery = useQuery({ + ...queries.github.listRepos(installation), + }); + + const listBranchesQuery = useQuery({ + ...queries.github.listBranches(installation, repoOwner, repoName), + }); + + const [envVars, setEnvVars] = useState( + envVarsRecordToKeyValueType(managedWorker.runtimeConfig.envVars), + ); + + const nameError = errors.name?.message?.toString() || fieldErrors?.name; + const buildDirError = + errors.buildConfig?.steps?.[0]?.buildDir?.message?.toString() || + fieldErrors?.buildDir; + const dockerfilePathError = + errors.buildConfig?.steps?.[0]?.dockerfilePath?.message?.toString() || + fieldErrors?.dockerfilePath; + const numReplicasError = + errors.runtimeConfig?.numReplicas?.message?.toString() || + fieldErrors?.numReplicas; + const envVarsError = + errors.runtimeConfig?.envVars?.message?.toString() || fieldErrors?.envVars; + const cpuKindError = + errors.runtimeConfig?.cpuKind?.message?.toString() || fieldErrors?.cpuKind; + const cpusError = + errors.runtimeConfig?.cpus?.message?.toString() || fieldErrors?.cpus; + const memoryMbError = + errors.runtimeConfig?.memoryMb?.message?.toString() || + fieldErrors?.memoryMb; + const githubInstallationIdError = + errors.buildConfig?.githubInstallationId?.message?.toString() || + fieldErrors?.githubInstallationId; + const githubRepositoryOwnerError = + errors.buildConfig?.githubRepositoryOwner?.message?.toString() || + fieldErrors?.githubRepositoryOwner; + const githubRepositoryNameError = + errors.buildConfig?.githubRepositoryName?.message?.toString() || + fieldErrors?.githubRepositoryName; + const githubRepositoryBranchError = + errors.buildConfig?.githubRepositoryBranch?.message?.toString() || + fieldErrors?.githubRepositoryBranch; + + useEffect(() => { + if ( + listInstallationsQuery.isSuccess && + listInstallationsQuery.data.rows.length > 0 && + !installation + ) { + setValue( + 'buildConfig.githubInstallationId', + managedWorker.buildConfig.githubInstallationId || + listInstallationsQuery.data.rows[0].metadata.id, + ); + } + }, [managedWorker, listInstallationsQuery, setValue, installation]); + + // if there are no github accounts linked, ask the user to link one + if ( + listInstallationsQuery.isSuccess && + listInstallationsQuery.data.rows.length === 0 + ) { + return ( + + + Link a Github account + + You don't have any Github accounts linked. Please{' '} + + link a Github account + {' '} + first. + + + ); + } + + return ( + <> +
+ Change the configuration of your worker. This will trigger a + redeployment. +
+
+
+
+ + { + return ( + + ); + }} + /> + {nameError && ( +
{nameError}
+ )} +
+
+ + + + Build configuration + + + + { + return ( + + ); + }} + /> + {githubInstallationIdError && ( +
+ {githubInstallationIdError} +
+ )} + + + { + return ( + + ); + }} + /> + {githubRepositoryOwnerError && ( +
+ {githubRepositoryOwnerError} +
+ )} + {githubRepositoryNameError && ( +
+ {githubRepositoryNameError} +
+ )} + + { + return ( + + ); + }} + /> + {githubRepositoryBranchError && ( +
+ {githubRepositoryBranchError} +
+ )} + + { + return ( + + ); + }} + /> + {buildDirError && ( +
{buildDirError}
+ )} + + { + return ( + + ); + }} + /> + {dockerfilePathError && ( +
+ {dockerfilePathError} +
+ )} +
+
+ + + Runtime configuration + + +
+ Configure the runtime settings for this worker. +
+ + { + return ( + { + if (e.target.value === '') { + field.onChange(e.target.value); + return; + } + + field.onChange(parseInt(e.target.value)); + }} + min={1} + max={16} + id="numReplicas" + placeholder="1" + /> + ); + }} + /> + {numReplicasError && ( +
{numReplicasError}
+ )} + + { + return ( + + ); + }} + /> + {cpuKindError && ( +
{cpuKindError}
+ )} + {cpusError && ( +
{cpusError}
+ )} + {memoryMbError && ( +
{memoryMbError}
+ )} + + { + setEnvVars(value); + setValue( + 'runtimeConfig.envVars', + value.reduce>((acc, item) => { + acc[item.key] = item.value; + return acc; + }, {}), + ); + }} + /> + {envVarsError && ( +
{envVarsError}
+ )} +
+
+
+ +
+ + ); +} + +function envVarsRecordToKeyValueType( + envVars: Record, +): KeyValueType[] { + return Object.entries(envVars).map(([key, value]) => ({ + key, + value, + hidden: false, + locked: false, + deleted: false, + })); +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/index.tsx b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/index.tsx new file mode 100644 index 000000000..91d948710 --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/$managed-worker/index.tsx @@ -0,0 +1,179 @@ +import { Separator } from '@/components/ui/separator'; +import { queries } from '@/lib/api'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useNavigate, useParams } from 'react-router-dom'; +import invariant from 'tiny-invariant'; +import { relativeDate } from '@/lib/utils'; +import { CpuChipIcon } from '@heroicons/react/24/outline'; +import { Loading } from '@/components/ui/loading.tsx'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ConfirmDialog } from '@/components/molecules/confirm-dialog'; +import { ManagedWorkerLogs } from './components/managed-worker-logs'; +import { ManagedWorkerMetrics } from './components/managed-worker-metrics'; +import { ManagedWorkerActivity } from './components/managed-worker-activity'; +import { CreateManagedWorkerRequest } from '@/lib/api/generated/cloud/data-contracts'; +import { ManagedWorkerInstancesTable } from './components/managed-worker-instances-table'; +import UpdateWorkerForm from './components/update-form'; +import { cloudApi } from '@/lib/api/api'; +import { useApiError } from '@/lib/hooks'; +import GithubButton from './components/github-button'; + +export default function ExpandedWorkflow() { + const navigate = useNavigate(); + const [deleteWorker, setDeleteWorker] = useState(false); + + const params = useParams(); + invariant(params['managed-worker']); + + const managedWorkerQuery = useQuery({ + ...queries.cloud.getManagedWorker(params['managed-worker']), + refetchInterval: 5000, + }); + + const [fieldErrors, setFieldErrors] = useState>({}); + const { handleApiError } = useApiError({ + setFieldErrors: setFieldErrors, + }); + + const createManagedWorkerMutation = useMutation({ + mutationKey: ['managed-worker:create', params['managed-worker']], + mutationFn: async (data: CreateManagedWorkerRequest) => { + invariant(managedWorker); + const res = await cloudApi.managedWorkerUpdate( + managedWorker.metadata.id, + data, + ); + return res.data; + }, + onSuccess: () => { + managedWorkerQuery.refetch(); + }, + onError: handleApiError, + }); + + const deleteManagedWorkerMutation = useMutation({ + mutationKey: ['managed-worker:delete', params['managed-worker']], + mutationFn: async () => { + invariant(managedWorker); + const res = await cloudApi.managedWorkerDelete(managedWorker.metadata.id); + return res.data; + }, + onSuccess: () => { + setDeleteWorker(false); + navigate('/workers/managed-workers'); + }, + onError: handleApiError, + }); + + if (managedWorkerQuery.isLoading || !managedWorkerQuery.data) { + return ; + } + + const managedWorker = managedWorkerQuery.data; + + return ( +
+
+
+
+ +

+ {managedWorker.name} +

+
+
+
+
+ Created {relativeDate(managedWorker.metadata.createdAt)} +
+ +
+
+ + + + Activity + + + Instances + + + Logs + + + Metrics + + + Configuration + + + + + + +

+ Instances +

+ + +
+ +

+ Logs +

+ + +
+ + + + +

+ Configuration +

+ + + +

+ Danger Zone +

+ + + + +
+
+
+
+ ); +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/components/managed-worker-columns.tsx b/frontend/app/src/pages/main/workers/managed-workers/components/managed-worker-columns.tsx new file mode 100644 index 000000000..070f100ac --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/components/managed-worker-columns.tsx @@ -0,0 +1,69 @@ +import { ColumnDef } from '@tanstack/react-table'; +import { Link } from 'react-router-dom'; +import { ChevronRightIcon } from '@radix-ui/react-icons'; +import RelativeDate from '@/components/molecules/relative-date'; +import { DataTableColumnHeader } from '@/components/molecules/data-table/data-table-column-header'; +import { ManagedWorker } from '@/lib/api/generated/cloud/data-contracts'; + +export const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + +
+ {row.original.name} +
+ + ), + enableSorting: true, + enableHiding: false, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + sortingFn: (a, b) => { + return ( + new Date(a.original.metadata.createdAt).getTime() - + new Date(b.original.metadata.createdAt).getTime() + ); + }, + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + enableSorting: true, + enableHiding: true, + }, + { + header: () => <>, + accessorKey: 'chevron', + cell: ({ row }) => { + return ( +
+ +
+
+ +
+ ); + }, + enableSorting: false, + enableHiding: false, + }, +]; diff --git a/frontend/app/src/pages/main/workers/managed-workers/components/managed-workers-table.tsx b/frontend/app/src/pages/main/workers/managed-workers/components/managed-workers-table.tsx new file mode 100644 index 000000000..70faf85bd --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/components/managed-workers-table.tsx @@ -0,0 +1,159 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { queries } from '@/lib/api'; +import invariant from 'tiny-invariant'; +import { TenantContextType } from '@/lib/outlet'; +import { Link, useOutletContext } from 'react-router-dom'; +import { DataTable } from '@/components/molecules/data-table/data-table.tsx'; +import { columns } from './managed-worker-columns'; +import { Loading } from '@/components/ui/loading.tsx'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardFooter, +} from '@/components/ui/card'; +import { ArrowPathIcon, CpuChipIcon } from '@heroicons/react/24/outline'; +import { SortingState, VisibilityState } from '@tanstack/react-table'; +import { BiCard, BiTable } from 'react-icons/bi'; +import RelativeDate from '@/components/molecules/relative-date'; +import { ManagedWorker } from '@/lib/api/generated/cloud/data-contracts'; +import GithubButton from '../$managed-worker/components/github-button'; + +export function ManagedWorkersTable() { + const { tenant } = useOutletContext(); + invariant(tenant); + + const [sorting, setSorting] = useState([ + { + id: 'lastRun', + desc: true, + }, + ]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [rotate, setRotate] = useState(false); + + const [cardToggle, setCardToggle] = useState(true); + + const listManagedWorkersQuery = useQuery({ + ...queries.cloud.listManagedWorkers(tenant.metadata.id), + refetchInterval: 5000, + }); + + const data = useMemo(() => { + const data = listManagedWorkersQuery.data?.rows || []; + + return data; + }, [listManagedWorkersQuery.data?.rows]); + + if (listManagedWorkersQuery.isLoading) { + return ; + } + + const emptyState = ( + + + No Managed Workers + +

+ There are no managed workers created in this tenant. +

+
+
+ +
+ ); + + const card: React.FC<{ data: ManagedWorker }> = ({ data }) => ( +
+
+
+ +

+ {data.name} +

+
+

+ Created +

+ +

+ {data.runtimeConfig.numReplicas}{' '} + {data.runtimeConfig.numReplicas == 1 ? 'instance' : 'instances'} with{' '} + {data.runtimeConfig.cpus} CPUs and {data.runtimeConfig.memoryMb} MB + memory +

+
+
+
+ + + +
+
+
+ ); + + const actions = [ + , + , + ]; + + return ( + + ); +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/create/components/create-worker-form.tsx b/frontend/app/src/pages/main/workers/managed-workers/create/components/create-worker-form.tsx new file mode 100644 index 000000000..ee34ad12f --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/create/components/create-worker-form.tsx @@ -0,0 +1,598 @@ +import { queries } from '@/lib/api'; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useQuery } from '@tanstack/react-query'; +import { ExclamationTriangleIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { z } from 'zod'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Input } from '@/components/ui/input'; +import { Step, Steps } from '@/components/ui/steps'; +import EnvGroupArray, { KeyValueType } from '@/components/ui/envvar'; + +export const machineTypes = [ + { + title: '1 CPU, 1 GB RAM (shared CPU)', + cpuKind: 'shared', + cpus: 1, + memoryMb: 1024, + }, + { + title: '1 CPU, 2 GB RAM (shared CPU)', + cpuKind: 'shared', + cpus: 1, + memoryMb: 2048, + }, + { + title: '2 CPU, 2 GB RAM (shared CPU)', + cpuKind: 'shared', + cpus: 2, + memoryMb: 2048, + }, + { + title: '2 CPU, 4 GB RAM (shared CPU)', + cpuKind: 'shared', + cpus: 2, + memoryMb: 4096, + }, + { + title: '4 CPU, 8 GB RAM (shared CPU)', + cpuKind: 'shared', + cpus: 4, + memoryMb: 8192, + }, + { + title: '8 CPU, 16 GB RAM (shared CPU)', + cpuKind: 'shared', + cpus: 8, + memoryMb: 16384, + }, + { + title: '1 CPU, 1 GB RAM (performance CPU)', + cpuKind: 'performance', + cpus: 1, + memoryMb: 1024, + }, + { + title: '1 CPU, 2 GB RAM (performance CPU)', + cpuKind: 'performance', + cpus: 1, + memoryMb: 2048, + }, + { + title: '2 CPU, 2 GB RAM (performance CPU)', + cpuKind: 'performance', + cpus: 2, + memoryMb: 2048, + }, + { + title: '2 CPU, 4 GB RAM (performance CPU)', + cpuKind: 'performance', + cpus: 2, + memoryMb: 4096, + }, + { + title: '4 CPU, 8 GB RAM (performance CPU)', + cpuKind: 'performance', + cpus: 4, + memoryMb: 8192, + }, + { + title: '8 CPU, 16 GB RAM (performance CPU)', + cpuKind: 'performance', + cpus: 8, + memoryMb: 16384, + }, +]; + +export const createOrUpdateManagedWorkerSchema = z.object({ + name: z.string(), + buildConfig: z.object({ + githubInstallationId: z.string().uuid().length(36), + githubRepositoryOwner: z.string(), + githubRepositoryName: z.string(), + githubRepositoryBranch: z.string(), + steps: z.array( + z.object({ + buildDir: z.string(), + dockerfilePath: z.string(), + }), + ), + }), + runtimeConfig: z.object({ + numReplicas: z.number().min(1).max(16), + envVars: z.record(z.string()), + cpuKind: z.string(), + cpus: z.number(), + memoryMb: z.number(), + }), +}); + +interface CreateWorkerFormProps { + onSubmit: (opts: z.infer) => void; + isLoading: boolean; + fieldErrors?: Record; +} + +export default function CreateWorkerForm({ + onSubmit, + isLoading, + fieldErrors, +}: CreateWorkerFormProps) { + const { + watch, + handleSubmit, + control, + setValue, + formState: { errors }, + } = useForm>({ + resolver: zodResolver(createOrUpdateManagedWorkerSchema), + defaultValues: { + buildConfig: { + steps: [ + { + buildDir: '.', + dockerfilePath: './Dockerfile', + }, + ], + }, + runtimeConfig: { + numReplicas: 1, + cpuKind: 'shared', + cpus: 1, + memoryMb: 1024, + envVars: {}, + }, + }, + }); + + const [machineType, setMachineType] = useState( + '1 CPU, 1 GB RAM (shared CPU)', + ); + + const installation = watch('buildConfig.githubInstallationId'); + const repoOwner = watch('buildConfig.githubRepositoryOwner'); + const repoName = watch('buildConfig.githubRepositoryName'); + const repoOwnerName = getRepoOwnerName(repoOwner, repoName); + const branch = watch('buildConfig.githubRepositoryBranch'); + + const listInstallationsQuery = useQuery({ + ...queries.github.listInstallations, + }); + + const listReposQuery = useQuery({ + ...queries.github.listRepos(installation), + }); + + const listBranchesQuery = useQuery({ + ...queries.github.listBranches(installation, repoOwner, repoName), + }); + + const [envVars, setEnvVars] = useState([]); + + const nameError = errors.name?.message?.toString() || fieldErrors?.name; + const buildDirError = + errors.buildConfig?.steps?.[0]?.buildDir?.message?.toString() || + fieldErrors?.buildDir; + const dockerfilePathError = + errors.buildConfig?.steps?.[0]?.dockerfilePath?.message?.toString() || + fieldErrors?.dockerfilePath; + const numReplicasError = + errors.runtimeConfig?.numReplicas?.message?.toString() || + fieldErrors?.numReplicas; + const envVarsError = + errors.runtimeConfig?.envVars?.message?.toString() || fieldErrors?.envVars; + const cpuKindError = + errors.runtimeConfig?.cpuKind?.message?.toString() || fieldErrors?.cpuKind; + const cpusError = + errors.runtimeConfig?.cpus?.message?.toString() || fieldErrors?.cpus; + const memoryMbError = + errors.runtimeConfig?.memoryMb?.message?.toString() || + fieldErrors?.memoryMb; + const githubInstallationIdError = + errors.buildConfig?.githubInstallationId?.message?.toString() || + fieldErrors?.githubInstallationId; + const githubRepositoryOwnerError = + errors.buildConfig?.githubRepositoryOwner?.message?.toString() || + fieldErrors?.githubRepositoryOwner; + const githubRepositoryNameError = + errors.buildConfig?.githubRepositoryName?.message?.toString() || + fieldErrors?.githubRepositoryName; + const githubRepositoryBranchError = + errors.buildConfig?.githubRepositoryBranch?.message?.toString() || + fieldErrors?.githubRepositoryBranch; + + useEffect(() => { + if ( + listInstallationsQuery.isSuccess && + listInstallationsQuery.data.rows.length > 0 && + !installation + ) { + setValue( + 'buildConfig.githubInstallationId', + listInstallationsQuery.data.rows[0].metadata.id, + ); + } + }, [listInstallationsQuery, setValue, installation]); + + // if there are no github accounts linked, ask the user to link one + if ( + listInstallationsQuery.isSuccess && + listInstallationsQuery.data.rows.length === 0 + ) { + return ( + + + Link a Github account + + You don't have any Github accounts linked. Please{' '} + + link a Github account + {' '} + first. + + + ); + } + + return ( + <> +
+ Create a new managed worker. +
+ + +
+
+ Give your worker a name. +
+ + { + return ( + + ); + }} + /> + {nameError && ( +
{nameError}
+ )} +
+
+ +
+
+ Configure the Github repository the worker should deploy from. +
+ +
+ + { + return ( + + ); + }} + /> + {githubInstallationIdError && ( +
+ {githubInstallationIdError} +
+ )} + + + { + return ( + + ); + }} + /> + {githubRepositoryOwnerError && ( +
+ {githubRepositoryOwnerError} +
+ )} + {githubRepositoryNameError && ( +
+ {githubRepositoryNameError} +
+ )} + + { + return ( + + ); + }} + /> + {githubRepositoryBranchError && ( +
+ {githubRepositoryBranchError} +
+ )} + + { + return ( + + ); + }} + /> + {buildDirError && ( +
{buildDirError}
+ )} + + { + return ( + + ); + }} + /> + {dockerfilePathError && ( +
+ {dockerfilePathError} +
+ )} +
+
+
+ +
+
+ Configure the runtime settings for this worker. +
+ + { + return ( + { + if (e.target.value === '') { + field.onChange(e.target.value); + return; + } + + field.onChange(parseInt(e.target.value)); + }} + min={1} + max={16} + id="numReplicas" + placeholder="1" + /> + ); + }} + /> + {numReplicasError && ( +
{numReplicasError}
+ )} + + { + return ( + + ); + }} + /> + {cpuKindError && ( +
{cpuKindError}
+ )} + {cpusError && ( +
{cpusError}
+ )} + {memoryMbError && ( +
{memoryMbError}
+ )} + + { + setEnvVars(value); + setValue( + 'runtimeConfig.envVars', + value.reduce>((acc, item) => { + acc[item.key] = item.value; + return acc; + }, {}), + ); + }} + /> + {envVarsError && ( +
{envVarsError}
+ )} +
+
+ +
+
+ Review the settings for this worker. +
+ +
+
+
+ + ); +} + +export function getRepoOwnerName(repoOwner: string, repoName: string) { + if (!repoOwner || !repoName) { + return; + } + return `${repoOwner}::${repoName}`; +} + +export function getRepoOwner(repoOwnerName?: string) { + if (!repoOwnerName) { + return; + } + + const splArr = repoOwnerName.split('::'); + if (splArr.length > 1) { + return splArr[0]; + } +} + +export function getRepoName(repoOwnerName?: string) { + if (!repoOwnerName) { + return; + } + + const splArr = repoOwnerName.split('::'); + if (splArr.length > 1) { + return splArr[1]; + } +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/create/index.tsx b/frontend/app/src/pages/main/workers/managed-workers/create/index.tsx new file mode 100644 index 000000000..6cd08e2ff --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/create/index.tsx @@ -0,0 +1,56 @@ +import { Separator } from '@/components/ui/separator'; +import invariant from 'tiny-invariant'; +import { useNavigate, useOutletContext } from 'react-router-dom'; +import { TenantContextType } from '@/lib/outlet'; +import { ServerStackIcon } from '@heroicons/react/24/outline'; +import CreateWorkerForm from './components/create-worker-form'; +import { useMutation } from '@tanstack/react-query'; +import { CreateManagedWorkerRequest } from '@/lib/api/generated/cloud/data-contracts'; +import { cloudApi } from '@/lib/api/api'; +import { useState } from 'react'; +import { useApiError } from '@/lib/hooks'; + +export default function CreateWorker() { + const navigate = useNavigate(); + + const { tenant } = useOutletContext(); + invariant(tenant); + + const [fieldErrors, setFieldErrors] = useState>({}); + const { handleApiError } = useApiError({ + setFieldErrors: setFieldErrors, + }); + + const createManagedWorkerMutation = useMutation({ + mutationKey: ['managed-worker:create', tenant], + mutationFn: async (data: CreateManagedWorkerRequest) => { + const res = await cloudApi.managedWorkerCreate(tenant.metadata.id, data); + return res.data; + }, + onSuccess: (data) => { + navigate(`/workers/managed-workers/${data.metadata.id}`); + }, + onError: handleApiError, + }); + + return ( +
+
+
+
+ +

+ New Worker +

+
+
+ + +
+
+ ); +} diff --git a/frontend/app/src/pages/main/workers/managed-workers/index.tsx b/frontend/app/src/pages/main/workers/managed-workers/index.tsx new file mode 100644 index 000000000..b2c35b33b --- /dev/null +++ b/frontend/app/src/pages/main/workers/managed-workers/index.tsx @@ -0,0 +1,28 @@ +import { Separator } from '@/components/ui/separator'; +import invariant from 'tiny-invariant'; +import { Link, useOutletContext } from 'react-router-dom'; +import { TenantContextType } from '@/lib/outlet'; +import { ManagedWorkersTable } from './components/managed-workers-table'; +import { Button } from '@/components/ui/button'; + +export default function ManagedWorkers() { + const { tenant } = useOutletContext(); + invariant(tenant); + + return ( +
+
+
+

+ Managed Worker Pools +

+ + + +
+ + +
+
+ ); +} diff --git a/frontend/app/src/router.tsx b/frontend/app/src/router.tsx index fef974ec3..e55007cbd 100644 --- a/frontend/app/src/router.tsx +++ b/frontend/app/src/router.tsx @@ -123,15 +123,6 @@ const routes: RouteObject[] = [ }; }), }, - { - path: '/events/metrics', - lazy: async () => - import('./pages/main/events/metrics').then((res) => { - return { - Component: res.default, - }; - }), - }, { path: '/workflows', lazy: async () => @@ -170,7 +161,7 @@ const routes: RouteObject[] = [ }), }, { - path: '/workers', + path: '/workers/all', lazy: async () => import('./pages/main/workers').then((res) => { return { @@ -187,6 +178,37 @@ const routes: RouteObject[] = [ }; }), }, + { + path: '/workers/managed-workers', + lazy: async () => + import('./pages/main/workers/managed-workers').then((res) => { + return { + Component: res.default, + }; + }), + }, + { + path: '/workers/managed-workers/create', + lazy: async () => + import('./pages/main/workers/managed-workers/create').then( + (res) => { + return { + Component: res.default, + }; + }, + ), + }, + { + path: '/workers/managed-workers/:managed-worker', + lazy: async () => + import( + './pages/main/workers/managed-workers/$managed-worker' + ).then((res) => { + return { + Component: res.default, + }; + }), + }, { path: '/tenant-settings/overview', lazy: async () => @@ -209,6 +231,15 @@ const routes: RouteObject[] = [ }, ), }, + { + path: '/tenant-settings/github', + lazy: async () => + import('./pages/main/tenant-settings/github').then((res) => { + return { + Component: res.default, + }; + }), + }, { path: '/tenant-settings/webhooks', lazy: async () => diff --git a/internal/services/webhooks/webhooks.go b/internal/services/webhooks/webhooks.go index 39d946ad2..7f7562ad4 100644 --- a/internal/services/webhooks/webhooks.go +++ b/internal/services/webhooks/webhooks.go @@ -132,7 +132,7 @@ func (c *WebhooksController) check() error { expiresAt := time.Now().Add(100 * 365 * 24 * time.Hour) // 100 years - tok, err := c.sc.Auth.JWTManager.GenerateTenantToken(context.Background(), tenantId, "webhook-worker", &expiresAt) + tok, err := c.sc.Auth.JWTManager.GenerateTenantToken(context.Background(), tenantId, "webhook-worker", true, &expiresAt) if err != nil { c.sc.Logger.Error().Err(err).Msgf("could not generate token for webhook worker %s of tenant %s", id, tenantId) return diff --git a/internal/testutils/env.go b/internal/testutils/env.go index 3ea3b0d6d..cf84a753d 100644 --- a/internal/testutils/env.go +++ b/internal/testutils/env.go @@ -79,7 +79,7 @@ func Prepare(t *testing.T) { } } - defaultTok, err := serverConf.Auth.JWTManager.GenerateTenantToken(context.Background(), tenantId, "default", nil) + defaultTok, err := serverConf.Auth.JWTManager.GenerateTenantToken(context.Background(), tenantId, "default", false, nil) if err != nil { t.Fatalf("could not generate default token: %v", err) } diff --git a/pkg/auth/token/token.go b/pkg/auth/token/token.go index 3b600d893..5fabf173b 100644 --- a/pkg/auth/token/token.go +++ b/pkg/auth/token/token.go @@ -13,8 +13,8 @@ import ( ) type JWTManager interface { - GenerateTenantToken(ctx context.Context, tenantId, name string, expires *time.Time) (*Token, error) - UpsertTenantToken(ctx context.Context, tenantId, name, id string, expires *time.Time) (string, error) + GenerateTenantToken(ctx context.Context, tenantId, name string, internal bool, expires *time.Time) (*Token, error) + UpsertTenantToken(ctx context.Context, tenantId, name, id string, internal bool, expires *time.Time) (string, error) ValidateTenantToken(ctx context.Context, token string) (string, error) } @@ -82,7 +82,7 @@ func (j *jwtManagerImpl) createToken(ctx context.Context, tenantId, name string, }, nil } -func (j *jwtManagerImpl) GenerateTenantToken(ctx context.Context, tenantId, name string, expires *time.Time) (*Token, error) { +func (j *jwtManagerImpl) GenerateTenantToken(ctx context.Context, tenantId, name string, internal bool, expires *time.Time) (*Token, error) { token, err := j.createToken(ctx, tenantId, name, nil, expires) if err != nil { return nil, err @@ -94,6 +94,7 @@ func (j *jwtManagerImpl) GenerateTenantToken(ctx context.Context, tenantId, name ExpiresAt: token.ExpiresAt, TenantId: &tenantId, Name: &name, + Internal: internal, }) if err != nil { return nil, fmt.Errorf("failed to write token to database: %v", err) @@ -102,7 +103,7 @@ func (j *jwtManagerImpl) GenerateTenantToken(ctx context.Context, tenantId, name return token, nil } -func (j *jwtManagerImpl) UpsertTenantToken(ctx context.Context, tenantId, name, id string, expires *time.Time) (string, error) { +func (j *jwtManagerImpl) UpsertTenantToken(ctx context.Context, tenantId, name, id string, internal bool, expires *time.Time) (string, error) { token, err := j.createToken(ctx, tenantId, name, &id, expires) if err != nil { return "", err @@ -114,6 +115,7 @@ func (j *jwtManagerImpl) UpsertTenantToken(ctx context.Context, tenantId, name, ExpiresAt: token.ExpiresAt, TenantId: &tenantId, Name: &name, + Internal: internal, }) if err != nil { return "", fmt.Errorf("failed to write token to database: %v", err) diff --git a/pkg/auth/token/token_test.go b/pkg/auth/token/token_test.go index 45761f5a1..3cfe40b25 100644 --- a/pkg/auth/token/token_test.go +++ b/pkg/auth/token/token_test.go @@ -42,7 +42,7 @@ func TestCreateTenantToken(t *testing.T) { // make sure no cache is used for tes t.Fatal(err.Error()) } - token, err := jwtManager.GenerateTenantToken(context.Background(), tenantId, "test token", nil) + token, err := jwtManager.GenerateTenantToken(context.Background(), tenantId, "test token", false, nil) if err != nil { t.Fatal(err.Error()) @@ -83,7 +83,7 @@ func TestRevokeTenantToken(t *testing.T) { t.Fatal(err.Error()) } - token, err := jwtManager.GenerateTenantToken(context.Background(), tenantId, "test token", nil) + token, err := jwtManager.GenerateTenantToken(context.Background(), tenantId, "test token", false, nil) if err != nil { t.Fatal(err.Error()) @@ -143,7 +143,7 @@ func TestRevokeTenantTokenCache(t *testing.T) { t.Fatal(err.Error()) } - token, err := jwtManager.GenerateTenantToken(context.Background(), tenantId, "test token", nil) + token, err := jwtManager.GenerateTenantToken(context.Background(), tenantId, "test token", false, nil) if err != nil { t.Fatal(err.Error()) diff --git a/pkg/repository/api_token.go b/pkg/repository/api_token.go index 57b0e9a7d..2882d4fa5 100644 --- a/pkg/repository/api_token.go +++ b/pkg/repository/api_token.go @@ -20,6 +20,8 @@ type CreateAPITokenOpts struct { // (optional) A name for this API token Name *string `validate:"omitempty,max=255"` + + Internal bool } type APITokenRepository interface { diff --git a/pkg/repository/prisma/api_token.go b/pkg/repository/prisma/api_token.go index 08af8a26c..b35e2eab9 100644 --- a/pkg/repository/prisma/api_token.go +++ b/pkg/repository/prisma/api_token.go @@ -45,6 +45,7 @@ func (a *apiTokenRepository) CreateAPIToken(opts *repository.CreateAPITokenOpts) optionals := []db.APITokenSetParam{ db.APIToken.ID.Set(opts.ID), db.APIToken.ExpiresAt.Set(opts.ExpiresAt), + db.APIToken.Internal.Set(opts.Internal), } if opts.TenantId != nil { @@ -77,6 +78,7 @@ func (a *apiTokenRepository) ListAPITokensByTenant(tenantId string) ([]db.APITok return a.client.APIToken.FindMany( db.APIToken.TenantID.Equals(tenantId), db.APIToken.Revoked.Equals(false), + db.APIToken.Internal.Equals(false), ).Exec(context.Background()) } @@ -108,6 +110,7 @@ func (a *engineTokenRepository) CreateAPIToken(ctx context.Context, opts *reposi createParams := dbsqlc.CreateAPITokenParams{ ID: sqlchelpers.UUIDFromStr(opts.ID), Expiresat: sqlchelpers.TimestampFromTime(opts.ExpiresAt), + Internal: sqlchelpers.BoolFromBoolean(opts.Internal), } if opts.TenantId != nil { diff --git a/pkg/repository/prisma/db/db_gen.go b/pkg/repository/prisma/db/db_gen.go index d0b2dd9ef..cf28b0b37 100644 --- a/pkg/repository/prisma/db/db_gen.go +++ b/pkg/repository/prisma/db/db_gen.go @@ -443,6 +443,9 @@ model APIToken { // when to next alert about expiration nextAlertAt DateTime? + // whether this token is for internal (internal to Hatchet) use + internal Boolean @default(false) + // an optional name for the token name String? @@ -2177,6 +2180,7 @@ const ( APITokenScalarFieldEnumExpiresAt APITokenScalarFieldEnum = "expiresAt" APITokenScalarFieldEnumRevoked APITokenScalarFieldEnum = "revoked" APITokenScalarFieldEnumNextAlertAt APITokenScalarFieldEnum = "nextAlertAt" + APITokenScalarFieldEnumInternal APITokenScalarFieldEnum = "internal" APITokenScalarFieldEnumName APITokenScalarFieldEnum = "name" APITokenScalarFieldEnumTenantID APITokenScalarFieldEnum = "tenantId" ) @@ -3076,6 +3080,8 @@ const aPITokenFieldRevoked aPITokenPrismaFields = "revoked" const aPITokenFieldNextAlertAt aPITokenPrismaFields = "nextAlertAt" +const aPITokenFieldInternal aPITokenPrismaFields = "internal" + const aPITokenFieldName aPITokenPrismaFields = "name" const aPITokenFieldTenant aPITokenPrismaFields = "tenant" @@ -7449,6 +7455,7 @@ type InnerAPIToken struct { ExpiresAt *DateTime `json:"expiresAt,omitempty"` Revoked bool `json:"revoked"` NextAlertAt *DateTime `json:"nextAlertAt,omitempty"` + Internal bool `json:"internal"` Name *string `json:"name,omitempty"` TenantID *string `json:"tenantId,omitempty"` } @@ -7461,6 +7468,7 @@ type RawAPITokenModel struct { ExpiresAt *RawDateTime `json:"expiresAt,omitempty"` Revoked RawBoolean `json:"revoked"` NextAlertAt *RawDateTime `json:"nextAlertAt,omitempty"` + Internal RawBoolean `json:"internal"` Name *RawString `json:"name,omitempty"` TenantID *RawString `json:"tenantId,omitempty"` } @@ -51198,6 +51206,11 @@ type aPITokenQuery struct { // @optional NextAlertAt aPITokenQueryNextAlertAtDateTime + // Internal + // + // @required + Internal aPITokenQueryInternalBoolean + // Name // // @optional @@ -53013,6 +53026,74 @@ func (r aPITokenQueryNextAlertAtDateTime) Field() aPITokenPrismaFields { return aPITokenFieldNextAlertAt } +// base struct +type aPITokenQueryInternalBoolean struct{} + +// Set the required value of Internal +func (r aPITokenQueryInternalBoolean) Set(value bool) aPITokenSetParam { + + return aPITokenSetParam{ + data: builder.Field{ + Name: "internal", + Value: value, + }, + } + +} + +// Set the optional value of Internal dynamically +func (r aPITokenQueryInternalBoolean) SetIfPresent(value *Boolean) aPITokenSetParam { + if value == nil { + return aPITokenSetParam{} + } + + return r.Set(*value) +} + +func (r aPITokenQueryInternalBoolean) Equals(value bool) aPITokenWithPrismaInternalEqualsParam { + + return aPITokenWithPrismaInternalEqualsParam{ + data: builder.Field{ + Name: "internal", + Fields: []builder.Field{ + { + Name: "equals", + Value: value, + }, + }, + }, + } +} + +func (r aPITokenQueryInternalBoolean) EqualsIfPresent(value *bool) aPITokenWithPrismaInternalEqualsParam { + if value == nil { + return aPITokenWithPrismaInternalEqualsParam{} + } + return r.Equals(*value) +} + +func (r aPITokenQueryInternalBoolean) Order(direction SortOrder) aPITokenDefaultParam { + return aPITokenDefaultParam{ + data: builder.Field{ + Name: "internal", + Value: direction, + }, + } +} + +func (r aPITokenQueryInternalBoolean) Cursor(cursor bool) aPITokenCursorParam { + return aPITokenCursorParam{ + data: builder.Field{ + Name: "internal", + Value: cursor, + }, + } +} + +func (r aPITokenQueryInternalBoolean) Field() aPITokenPrismaFields { + return aPITokenFieldInternal +} + // base struct type aPITokenQueryNameString struct{} @@ -188037,6 +188118,7 @@ var aPITokenOutput = []builder.Output{ {Name: "expiresAt"}, {Name: "revoked"}, {Name: "nextAlertAt"}, + {Name: "internal"}, {Name: "name"}, {Name: "tenantId"}, } @@ -188673,6 +188755,84 @@ func (p aPITokenWithPrismaNextAlertAtEqualsUniqueParam) nextAlertAtField() {} func (aPITokenWithPrismaNextAlertAtEqualsUniqueParam) unique() {} func (aPITokenWithPrismaNextAlertAtEqualsUniqueParam) equals() {} +type APITokenWithPrismaInternalEqualsSetParam interface { + field() builder.Field + getQuery() builder.Query + equals() + aPITokenModel() + internalField() +} + +type APITokenWithPrismaInternalSetParam interface { + field() builder.Field + getQuery() builder.Query + aPITokenModel() + internalField() +} + +type aPITokenWithPrismaInternalSetParam struct { + data builder.Field + query builder.Query +} + +func (p aPITokenWithPrismaInternalSetParam) field() builder.Field { + return p.data +} + +func (p aPITokenWithPrismaInternalSetParam) getQuery() builder.Query { + return p.query +} + +func (p aPITokenWithPrismaInternalSetParam) aPITokenModel() {} + +func (p aPITokenWithPrismaInternalSetParam) internalField() {} + +type APITokenWithPrismaInternalWhereParam interface { + field() builder.Field + getQuery() builder.Query + aPITokenModel() + internalField() +} + +type aPITokenWithPrismaInternalEqualsParam struct { + data builder.Field + query builder.Query +} + +func (p aPITokenWithPrismaInternalEqualsParam) field() builder.Field { + return p.data +} + +func (p aPITokenWithPrismaInternalEqualsParam) getQuery() builder.Query { + return p.query +} + +func (p aPITokenWithPrismaInternalEqualsParam) aPITokenModel() {} + +func (p aPITokenWithPrismaInternalEqualsParam) internalField() {} + +func (aPITokenWithPrismaInternalSetParam) settable() {} +func (aPITokenWithPrismaInternalEqualsParam) equals() {} + +type aPITokenWithPrismaInternalEqualsUniqueParam struct { + data builder.Field + query builder.Query +} + +func (p aPITokenWithPrismaInternalEqualsUniqueParam) field() builder.Field { + return p.data +} + +func (p aPITokenWithPrismaInternalEqualsUniqueParam) getQuery() builder.Query { + return p.query +} + +func (p aPITokenWithPrismaInternalEqualsUniqueParam) aPITokenModel() {} +func (p aPITokenWithPrismaInternalEqualsUniqueParam) internalField() {} + +func (aPITokenWithPrismaInternalEqualsUniqueParam) unique() {} +func (aPITokenWithPrismaInternalEqualsUniqueParam) equals() {} + type APITokenWithPrismaNameEqualsSetParam interface { field() builder.Field getQuery() builder.Query diff --git a/pkg/repository/prisma/dbsqlc/api_tokens.sql b/pkg/repository/prisma/dbsqlc/api_tokens.sql index 21b1f66f0..3b875b116 100644 --- a/pkg/repository/prisma/dbsqlc/api_tokens.sql +++ b/pkg/repository/prisma/dbsqlc/api_tokens.sql @@ -13,12 +13,14 @@ INSERT INTO "APIToken" ( "updatedAt", "tenantId", "name", - "expiresAt" + "expiresAt", + "internal" ) VALUES ( coalesce(@id::uuid, gen_random_uuid()), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, sqlc.narg('tenantId')::uuid, sqlc.narg('name')::text, - @expiresAt::timestamp + @expiresAt::timestamp, + COALESCE(sqlc.narg('internal')::boolean, FALSE) ) RETURNING *; diff --git a/pkg/repository/prisma/dbsqlc/api_tokens.sql.go b/pkg/repository/prisma/dbsqlc/api_tokens.sql.go index efacedfc9..e8612a7cb 100644 --- a/pkg/repository/prisma/dbsqlc/api_tokens.sql.go +++ b/pkg/repository/prisma/dbsqlc/api_tokens.sql.go @@ -18,15 +18,17 @@ INSERT INTO "APIToken" ( "updatedAt", "tenantId", "name", - "expiresAt" + "expiresAt", + "internal" ) VALUES ( coalesce($1::uuid, gen_random_uuid()), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, $2::uuid, $3::text, - $4::timestamp -) RETURNING id, "createdAt", "updatedAt", "expiresAt", revoked, name, "tenantId", "nextAlertAt" + $4::timestamp, + COALESCE($5::boolean, FALSE) +) RETURNING id, "createdAt", "updatedAt", "expiresAt", revoked, name, "tenantId", "nextAlertAt", internal ` type CreateAPITokenParams struct { @@ -34,6 +36,7 @@ type CreateAPITokenParams struct { TenantId pgtype.UUID `json:"tenantId"` Name pgtype.Text `json:"name"` Expiresat pgtype.Timestamp `json:"expiresat"` + Internal pgtype.Bool `json:"internal"` } func (q *Queries) CreateAPIToken(ctx context.Context, db DBTX, arg CreateAPITokenParams) (*APIToken, error) { @@ -42,6 +45,7 @@ func (q *Queries) CreateAPIToken(ctx context.Context, db DBTX, arg CreateAPIToke arg.TenantId, arg.Name, arg.Expiresat, + arg.Internal, ) var i APIToken err := row.Scan( @@ -53,13 +57,14 @@ func (q *Queries) CreateAPIToken(ctx context.Context, db DBTX, arg CreateAPIToke &i.Name, &i.TenantId, &i.NextAlertAt, + &i.Internal, ) return &i, err } const getAPITokenById = `-- name: GetAPITokenById :one SELECT - id, "createdAt", "updatedAt", "expiresAt", revoked, name, "tenantId", "nextAlertAt" + id, "createdAt", "updatedAt", "expiresAt", revoked, name, "tenantId", "nextAlertAt", internal FROM "APIToken" WHERE @@ -78,6 +83,7 @@ func (q *Queries) GetAPITokenById(ctx context.Context, db DBTX, id pgtype.UUID) &i.Name, &i.TenantId, &i.NextAlertAt, + &i.Internal, ) return &i, err } diff --git a/pkg/repository/prisma/dbsqlc/models.go b/pkg/repository/prisma/dbsqlc/models.go index 895a10ba6..be19cd209 100644 --- a/pkg/repository/prisma/dbsqlc/models.go +++ b/pkg/repository/prisma/dbsqlc/models.go @@ -598,6 +598,7 @@ type APIToken struct { Name pgtype.Text `json:"name"` TenantId pgtype.UUID `json:"tenantId"` NextAlertAt pgtype.Timestamp `json:"nextAlertAt"` + Internal bool `json:"internal"` } type Action struct { diff --git a/pkg/repository/prisma/dbsqlc/schema.sql b/pkg/repository/prisma/dbsqlc/schema.sql index 717c8cdce..dd729a487 100644 --- a/pkg/repository/prisma/dbsqlc/schema.sql +++ b/pkg/repository/prisma/dbsqlc/schema.sql @@ -47,6 +47,7 @@ CREATE TABLE "APIToken" ( "name" TEXT, "tenantId" UUID, "nextAlertAt" TIMESTAMP(3), + "internal" BOOLEAN NOT NULL DEFAULT false, CONSTRAINT "APIToken_pkey" PRIMARY KEY ("id") ); diff --git a/prisma/migrations/20240716125848_v0_38_0/migration.sql b/prisma/migrations/20240716125848_v0_38_0/migration.sql new file mode 100644 index 000000000..75eecaa83 --- /dev/null +++ b/prisma/migrations/20240716125848_v0_38_0/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "APIToken" ADD COLUMN "internal" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ff313a06b..c51e515f0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -369,6 +369,9 @@ model APIToken { // when to next alert about expiration nextAlertAt DateTime? + // whether this token is for internal (internal to Hatchet) use + internal Boolean @default(false) + // an optional name for the token name String? diff --git a/sql/migrations/20240716125857_v0.38.0.sql b/sql/migrations/20240716125857_v0.38.0.sql new file mode 100644 index 000000000..0e1708cb8 --- /dev/null +++ b/sql/migrations/20240716125857_v0.38.0.sql @@ -0,0 +1,2 @@ +-- Modify "APIToken" table +ALTER TABLE "APIToken" ADD COLUMN "internal" boolean NOT NULL DEFAULT false; diff --git a/sql/migrations/atlas.sum b/sql/migrations/atlas.sum index d9e82295c..6a9379c5b 100644 --- a/sql/migrations/atlas.sum +++ b/sql/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:fc2JeZ5rMJmnCco4GenTzhobz8nIrA/vNMFprROPl1s= +h1:394H9PJADpQPrt2bBIL7cH1BVeZhl+AgHcuUCKqyEQY= 20240115180414_init.sql h1:Ef3ZyjAHkmJPdGF/dEWCahbwgcg6uGJKnDxW2JCRi2k= 20240122014727_v0_6_0.sql h1:o/LdlteAeFgoHJ3e/M4Xnghqt9826IE/Y/h0q95Acuo= 20240126235456_v0_7_0.sql h1:KiVzt/hXgQ6esbdC6OMJOOWuYEXmy1yeCpmsVAHTFKs= @@ -39,3 +39,4 @@ h1:fc2JeZ5rMJmnCco4GenTzhobz8nIrA/vNMFprROPl1s= 20240704211315_v0.35.2.sql h1:/AzVYp+jzwPGx8JHUCPjBi2CnXmFvtsTWL3SgrC49IE= 20240712142946_v0.36.0.sql h1:YA/z+ZRR9QhqF+dCXy1fBgJczSyyaSEFXgThcID6SfI= 20240715154334_v0.37.0.sql h1:/lu8OPyH2rHPJRk3wL+LBsHp698YMyh0wLz+bRu7qXU= +20240716125857_v0.38.0.sql h1:BFa19pXab9GHd0xkSqLRT3eNer9QKoVf7SpR6O03l+Y= diff --git a/sql/schema/schema.sql b/sql/schema/schema.sql index 717c8cdce..dd729a487 100644 --- a/sql/schema/schema.sql +++ b/sql/schema/schema.sql @@ -47,6 +47,7 @@ CREATE TABLE "APIToken" ( "name" TEXT, "tenantId" UUID, "nextAlertAt" TIMESTAMP(3), + "internal" BOOLEAN NOT NULL DEFAULT false, CONSTRAINT "APIToken_pkey" PRIMARY KEY ("id") );