From 927808525d27aba11ba9ab2469b4846dd9fce956 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 8 Nov 2026 15:31:53 +0000 Subject: [PATCH] style: configure prettier and format branch files --- .prettierignore | 1 + .prettierrc.json | 7 +- package.json | 5 +- ...app-monitoring-history.integration.spec.ts | 190 ++-- src/app/monitoring/actions.ts | 58 +- src/app/monitoring/app-volumes-monitoring.tsx | 170 ++- .../app-volumes-monitoring.unit.spec.ts | 36 +- src/app/monitoring/disk-chart.tsx | 30 +- src/app/monitoring/monitoring-client.tsx | 75 +- src/app/monitoring/monitoring-nodes.tsx | 256 ++--- src/app/monitoring/page.tsx | 70 +- .../agents/create/create-agent-dialog.tsx | 355 +++---- .../create-template-agent-setup-dialog.tsx | 427 ++++---- .../agents/create/rename-agent-dialog.tsx | 133 +-- .../agents/details/agent-actions.ts | 59 +- .../agents/details/agent-drawer.tsx | 340 +++--- .../details/agent-sandbox-drawer-settings.tsx | 228 ++-- .../details/agent-sandbox-status-actions.tsx | 92 +- .../details/builds/agent-builds-grid.tsx | 300 +++--- .../agents/details/configuration/actions.ts | 498 ++++----- .../agent-container-config-card.tsx | 300 +++--- .../configuration/agent-rate-limits-card.tsx | 242 ++--- .../agent-env-var-edit-overlay.tsx | 138 +-- .../environment/agent-env-vars-card.tsx | 142 +-- .../model/agent-model-configuration-card.tsx | 68 +- .../agent-model-configuration-editor.tsx | 309 +++--- .../network/agent-network-policy-card.tsx | 256 +++-- ...etwork-policy-egress-rule-edit-overlay.tsx | 244 ++--- .../agents/details/sandboxes/actions.ts | 76 +- .../details/sandboxes/agent-access-dialog.tsx | 161 ++- .../sandboxes/agent-sandboxes-card.tsx | 938 ++++++++--------- .../details/source/agent-source-card.tsx | 252 +++-- .../source/agent-source-wizard-dialog.tsx | 686 ++++++------ .../volumes/agent-volume-edit-overlay.tsx | 421 ++++---- .../details/volumes/agent-volumes-card.tsx | 203 ++-- .../create-template-app-setup-dialog.tsx | 230 ++-- .../apps/create/edit-app-dialog.tsx | 74 +- .../new-network-policy-explanation-dialog.tsx | 105 +- .../[projectId]/apps/details/actions.ts | 57 +- .../details/advanced-network-settings.test.ts | 45 +- .../apps/details/app-details-drawer.tsx | 481 ++++----- .../apps/details/app-details-settings.tsx | 348 +++--- .../apps/details/app-status-actions.tsx | 299 +++--- .../apps/details/configuration/actions.ts | 449 ++++---- .../configuration/app-container-config.tsx | 388 ++++--- .../details/configuration/app-rate-limits.tsx | 383 +++---- .../app-source-wizard/build-method-step.tsx | 69 +- .../apps/details/credentials/actions.ts | 121 ++- .../details/credentials/db-credentials.tsx | 135 ++- .../details/credentials/db-gate-db-tool.tsx | 237 +++-- .../apps/details/credentials/db-tools.tsx | 50 +- .../credentials/phpmyadmin-db-tool.tsx | 189 ++-- .../apps/details/deployments/actions.ts | 81 +- .../app-details-deployments-tab.tsx | 90 +- .../deployments/deployments-grid-view.tsx | 222 ++-- .../apps/details/deployments/deployments.tsx | 156 ++- .../deployments/history-actions.unit.spec.ts | 39 +- .../deployments/webhook-deployment.tsx | 102 +- .../apps/details/environment/actions.ts | 23 +- .../environment/app-details-environment.tsx | 218 ++-- .../apps/details/environment/env-edit.tsx | 182 ++-- .../apps/details/network/policy/actions.ts | 222 ++-- .../policy/app-network-policy-rule-dialog.tsx | 333 +++--- .../app-network-policy-rule-section.tsx | 278 ++--- .../network/policy/basic-auth-edit-dialog.tsx | 149 ++- .../details/network/policy/basic-auth.tsx | 187 ++-- .../network/policy/network-policy-graph.tsx | 620 ++++++----- .../details/network/policy/network-policy.tsx | 275 +++-- .../apps/details/network/ports/actions.ts | 51 +- .../network/ports/node-port-edit-dialog.tsx | 245 +++-- .../details/network/ports/node-ports-card.tsx | 188 ++-- .../[projectId]/apps/details/runtime/logs.tsx | 337 +++--- .../apps/details/runtime/logs.unit.spec.ts | 149 ++- .../apps/details/runtime/monitoring-app.tsx | 71 +- .../runtime/monitoring-app.unit.spec.ts | 106 +- .../apps/details/runtime/terminal-overlay.tsx | 38 +- .../details/runtime/terminal-streamed.tsx | 136 ++- .../app-source-wizard-dialog.tsx | 922 ++++++++-------- .../framework-configuration-step.tsx | 154 ++- .../app-source-wizard/framework-step.tsx | 71 +- .../apps/details/source/app-source.tsx | 262 +++-- .../source/framework-configuration-card.tsx | 172 +-- .../apps/details/volumes/actions.ts | 356 +++---- .../backups/app-details-backup-list.tsx | 179 ++-- .../backups/app-details-backups-tab.tsx | 343 +++--- .../backups/volume-backup-edit-overlay.tsx | 313 +++--- .../details/volumes/backups/volume-backup.tsx | 311 +++--- .../volumes/shared-storage-edit-overlay.tsx | 282 +++-- .../details/volumes/storage-edit-overlay.tsx | 437 ++++---- .../apps/details/volumes/volumes-list.tsx | 619 +++++------ .../[projectId]/choose-template-dialog.tsx | 251 ++--- .../[projectId]/create-project-actions.tsx | 163 +-- .../network-graph/connection-edge.tsx | 23 +- ...oject-network-graph-agent-context-menu.tsx | 113 +- ...project-network-graph-app-context-menu.tsx | 202 ++-- ...ject-network-graph-canvas-context-menu.tsx | 142 ++- ...-network-graph-connection-context-menu.tsx | 67 +- .../overview/network-graph/graph-legend.tsx | 44 +- ...ject-network-graph-agent-workload-node.tsx | 72 +- ...roject-network-graph-app-workload-node.tsx | 107 +- .../project-network-graph-internet-node.tsx | 29 +- ...oject-network-graph-drawer-session.spec.ts | 143 ++- .../project-network-graph-drawer-session.ts | 231 ++-- .../project-network-graph-focus.spec.ts | 19 +- .../project-network-graph-focus.ts | 48 +- .../project-network-graph-projection.ts | 310 +++--- ...ect-network-graph-visual-semantics.spec.ts | 29 +- .../project-network-graph-visual-semantics.ts | 42 +- .../project-network-graph.spec.ts | 232 ++-- .../network-graph/project-network-graph.tsx | 989 +++++++++--------- .../use-project-network-graph.ts | 132 +-- .../[projectId]/overview/project-overview.tsx | 289 +++-- .../[projectId]/overview/workloads-table.tsx | 268 +++-- src/app/project/[projectId]/page.tsx | 159 ++- .../build-logs-dialog-content.tsx | 38 +- .../deployment-status-badge.tsx | 146 ++- .../drawer/drawer-tab-scroll-area.tsx | 14 +- .../drawer/nested-drawer.tsx | 127 +-- .../drawer/settings-section.tsx | 39 +- .../health-check-settings.tsx | 630 +++++------ .../workload-components/health-check.model.ts | 4 +- .../source-wizard/container-image-step.tsx | 78 +- .../source-wizard/dockerfile-path-step.tsx | 96 +- .../fields/branch-loading-state.tsx | 59 +- .../source-wizard/fields/icon-input.tsx | 29 +- .../source-wizard/fields/secret-input.tsx | 75 +- .../fields/secret-summary-row.tsx | 51 +- .../source-wizard/fields/summary-row.tsx | 15 +- .../source-wizard/git-branch-step.tsx | 52 +- .../source-wizard/git-https-url-step.tsx | 84 +- .../source-wizard/git-ssh-url-step.tsx | 136 +-- .../public-deploy-key-dialog.tsx | 56 +- .../source-wizard/readonly-info.tsx | 33 +- .../source-wizard/source-summary-step.tsx | 121 ++- .../source-wizard/source-type-step.tsx | 84 +- .../source-wizard/types.ts | 56 +- .../source-wizard/wizard-progress.tsx | 67 +- .../llm-gateways/llm-gateway-edit-overlay.tsx | 564 +++++----- .../settings/platform/operations/actions.ts | 10 +- .../monitoring-history-settings.tsx | 122 ++- src/app/settings/platform/operations/page.tsx | 28 +- src/app/sidebar-client.tsx | 562 +++++----- .../custom/app-monitoring-history.tsx | 369 ++++--- .../app-monitoring-history.unit.spec.ts | 103 +- .../custom/build-status-indicator.tsx | 128 +-- .../build-status-indicator.unit.spec.ts | 107 +- src/components/custom/domains-card.tsx | 199 ++-- src/components/custom/error-state.tsx | 111 +- src/components/custom/file-mounts-card.tsx | 176 ++-- src/components/custom/submit-button.tsx | 24 +- .../custom/workload-builds-table.tsx | 375 +++---- src/frontend/utils/format.utils.ts | 74 +- src/server.ts | 144 ++- .../adapter/app-history-metrics.adapter.ts | 12 +- src/server/adapter/app-history.adapter.ts | 87 +- src/server/adapter/kubernetes-api.adapter.ts | 326 +++--- src/server/adapter/longhorn-api.adapter.ts | 164 ++- src/server/adapter/volume-summary.adapter.ts | 44 +- src/server/services/cluster.service.ts | 323 +++--- .../services/cluster.service.unit.spec.ts | 84 +- src/server/services/monitoring.service.ts | 304 +++--- .../services/monitoring.service.unit.spec.ts | 380 ++++--- .../services/node-summary-cache.service.ts | 16 +- .../node-summary-cache.service.unit.spec.ts | 27 +- .../app-history.service.ts | 106 +- .../app-history.service.unit.spec.ts | 66 +- ...itoring-history-query.service.unit.spec.ts | 93 +- .../app-monitoring-history.service.ts | 298 +++--- ...pp-monitoring-history.service.unit.spec.ts | 97 +- .../app-volume-filesystem.service.ts | 137 +-- ...app-volume-filesystem.service.unit.spec.ts | 48 +- .../model/app-monitoring-history.model.ts | 32 +- .../app-volume-monitoring-usage.model.ts | 24 +- src/shared/model/node-resource.model.ts | 8 +- src/shared/utils/app-volume-identity.utils.ts | 18 +- src/shared/utils/constants.ts | 119 +-- .../utils/kubernetes-size-converter.utils.ts | 185 ++-- ...bernetes-size-converter.utils.unit.spec.ts | 221 ++-- .../utils/path-builder.utils.unit.spec.ts | 24 +- yarn.lock | 10 + 180 files changed, 16246 insertions(+), 16642 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..daf9120e --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +src/shared/model/generated-zod/ diff --git a/.prettierrc.json b/.prettierrc.json index 088517c2..4ddc855d 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -2,7 +2,7 @@ "endOfLine": "lf", "semi": true, "singleQuote": true, - "jsxSingleQuote": true, + "jsxSingleQuote": false, "tabWidth": 2, "useTabs": false, "trailingComma": "none", @@ -10,6 +10,7 @@ "arrowParens": "always", "objectWrap": "collapse", "plugins": ["prettier-plugin-tailwindcss"], - "tailwindStylesheet": "app/globals.css", - "tailwindFunctions": ["cn", "cva"] + "tailwindStylesheet": "src/app/globals.css", + "tailwindFunctions": ["cn", "cva"], + "bracketSpacing": true } diff --git a/package.json b/package.json index 7cb86acc..97135c8f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", "mcp:eval": "node scripts/mcp-eval.mjs", - "openapi-generate": "bash scripts/update-k8s-custom-crds.sh && bash scripts/update-openapi.sh" + "openapi-generate": "bash scripts/update-k8s-custom-crds.sh && bash scripts/update-openapi.sh", + "format": "prettier --write ." }, "dependencies": { "@aws-sdk/client-s3": "^3.1159.0", @@ -100,6 +101,8 @@ "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "^29.0.2", "postcss": "^8.4.47", + "prettier": "^3.9.10", + "prettier-plugin-tailwindcss": "^0.8.1", "shadcn": "^4.21.0", "tailwindcss": "^4.3.3", "tsc-alias": "^1.8.16", diff --git a/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts b/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts index 7555564c..5dbf794e 100644 --- a/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts +++ b/src/__tests__/integration/server/services/app-monitoring-history.integration.spec.ts @@ -6,98 +6,114 @@ import metrics from '@/server/services/standalone-services/app-history.service'; import filesystem from '@/server/services/standalone-services/app-volume-filesystem.service'; describe('App monitoring history persistence', () => { - const ctx = createPrismaTestContext('app-monitoring-history'); + const ctx = createPrismaTestContext('app-monitoring-history'); - it('retains the boundary sample and removes history when its App is deleted', async () => { - const db = ctx.getDataAccess().client; - await db.project.create({ data: { id: 'project', name: 'Project' } }); - await db.app.create({ data: { id: 'app', name: 'App', projectId: 'project' } }); - const cutoff = new Date(Date.now() - 3600_000); - await adapter.save([ - { appId: 'app', sampledAt: new Date(cutoff.getTime() - 1), cpuCores: 1, ramBytes: 2 }, - { appId: 'app', sampledAt: cutoff, cpuCores: 3, ramBytes: 4 }, - ], [], [], cutoff); - expect((await history.getHistory(['app'])).points.filter(point => !point.isGap)) - .toMatchObject([{ cpuCores: 3, ramBytes: 4 }]); - await db.app.delete({ where: { id: 'app' } }); - expect((await history.getHistory(['app'])).points).toEqual([]); - }); + it('retains the boundary sample and removes history when its App is deleted', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + await db.app.create({ data: { id: 'app', name: 'App', projectId: 'project' } }); + const cutoff = new Date(Date.now() - 3600_000); + await adapter.save( + [ + { appId: 'app', sampledAt: new Date(cutoff.getTime() - 1), cpuCores: 1, ramBytes: 2 }, + { appId: 'app', sampledAt: cutoff, cpuCores: 3, ramBytes: 4 } + ], + [], + [], + cutoff + ); + expect((await history.getHistory(['app'])).points.filter((point) => !point.isGap)).toMatchObject([{ cpuCores: 3, ramBytes: 4 }]); + await db.app.delete({ where: { id: 'app' } }); + expect((await history.getHistory(['app'])).points).toEqual([]); + }); - it('applies a reduced retention period to existing history', async () => { - const db = ctx.getDataAccess().client; - await db.project.create({ data: { id: 'project', name: 'Project' } }); - await db.app.create({ data: { id: 'app', name: 'App', projectId: 'project' } }); - const now = Date.now(); - await adapter.save([{ appId: 'app', sampledAt: new Date(now - 2 * 3600_000), cpuCores: 1, ramBytes: 2 }], [], [], new Date(now - 24 * 3600_000)); - await adapter.setRetentionHours(1); - expect((await history.getHistory(['app'])).points).toEqual([]); - await adapter.save([], [], [], new Date(now - (await adapter.getRetentionHours()) * 3600_000)); - expect(await db.appMonitoringSample.count()).toBe(0); - }); + it('applies a reduced retention period to existing history', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + await db.app.create({ data: { id: 'app', name: 'App', projectId: 'project' } }); + const now = Date.now(); + await adapter.save([{ appId: 'app', sampledAt: new Date(now - 2 * 3600_000), cpuCores: 1, ramBytes: 2 }], [], [], new Date(now - 24 * 3600_000)); + await adapter.setRetentionHours(1); + expect((await history.getHistory(['app'])).points).toEqual([]); + await adapter.save([], [], [], new Date(now - (await adapter.getRetentionHours()) * 3600_000)); + expect(await db.appMonitoringSample.count()).toBe(0); + }); - it('stores shared Volume usage once and exposes it only through accessible mounts', async () => { - const db = ctx.getDataAccess().client; - await db.project.create({ data: { id: 'project', name: 'Project' } }); - await db.app.createMany({ data: [ - { id: 'one', name: 'One', projectId: 'project' }, - { id: 'two', name: 'Two', projectId: 'project' }, - ] }); - await db.appVolume.create({ data: { id: 'base', appId: 'one', containerMountPath: '/data', size: 1 } }); - await db.appVolume.create({ data: { id: 'shared', appId: 'two', containerMountPath: '/share', size: 1, sharedVolumeId: 'base' } }); - const sampledAt = new Date(); - await adapter.save([], [{ id: 'sample', volumeId: 'base', sampledAt, status: 'available', usedBytes: 42, measuredAt: sampledAt }], [ - { sampleId: 'sample', volumeId: 'base' }, { sampleId: 'sample', volumeId: 'shared' }, - ], new Date(sampledAt.getTime() - 3600_000)); - expect(await db.appVolumeMonitoringSample.count()).toBe(1); - expect((await history.getHistory(['two'])).volumes.map(volume => volume.label)).toEqual(['/share']); - expect((await history.getHistory(['one', 'two'])).points[0]).toMatchObject({ storageBytes: 42, availableVolumes: 1 }); - await db.appVolume.delete({ where: { id: 'base' } }); - expect((await history.getHistory(['one', 'two'])).points).toEqual([]); + it('stores shared Volume usage once and exposes it only through accessible mounts', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + await db.app.createMany({ + data: [ + { id: 'one', name: 'One', projectId: 'project' }, + { id: 'two', name: 'Two', projectId: 'project' } + ] }); + await db.appVolume.create({ data: { id: 'base', appId: 'one', containerMountPath: '/data', size: 1 } }); + await db.appVolume.create({ data: { id: 'shared', appId: 'two', containerMountPath: '/share', size: 1, sharedVolumeId: 'base' } }); + const sampledAt = new Date(); + await adapter.save( + [], + [{ id: 'sample', volumeId: 'base', sampledAt, status: 'available', usedBytes: 42, measuredAt: sampledAt }], + [ + { sampleId: 'sample', volumeId: 'base' }, + { sampleId: 'sample', volumeId: 'shared' } + ], + new Date(sampledAt.getTime() - 3600_000) + ); + expect(await db.appVolumeMonitoringSample.count()).toBe(1); + expect((await history.getHistory(['two'])).volumes.map((volume) => volume.label)).toEqual(['/share']); + expect((await history.getHistory(['one', 'two'])).points[0]).toMatchObject({ storageBytes: 42, availableVolumes: 1 }); + await db.appVolume.delete({ where: { id: 'base' } }); + expect((await history.getHistory(['one', 'two'])).points).toEqual([]); + }); - - it('collects shared Volume measurements and reads them through each App', async () => { - const db = ctx.getDataAccess().client; - await db.project.create({ data: { id: 'project', name: 'Project' } }); - await db.app.createMany({ data: [ - { id: 'one', name: 'One', projectId: 'project' }, - { id: 'two', name: 'Two', projectId: 'project' }, - ] }); - await db.appVolume.create({ data: { id: 'base', appId: 'one', containerMountPath: '/data', size: 1 } }); - await db.appVolume.create({ data: { id: 'shared', appId: 'two', containerMountPath: '/share', size: 1, sharedVolumeId: 'base' } }); - const measuredAt = new Date(); - const appMetrics = vi.spyOn(metrics, 'getAppMetrics').mockResolvedValue(new Map([ - ['one', { cpuCores: 1, ramBytes: 100, measuredAt }], - ['two', { cpuCores: 2, ramBytes: 200, measuredAt }], - ])); - const volumeMetrics = vi.spyOn(filesystem, 'getVolumeFilesystemUsage').mockResolvedValue(new Map([ - ['base', { pvcName: 'pvc-base', status: 'available', usedBytes: 42, measuredAt: measuredAt.toISOString() }], - ['shared', { pvcName: 'pvc-base', status: 'available', usedBytes: 42, measuredAt: measuredAt.toISOString() }], - ])); - try { - await history.collect(); - expect(await db.appVolumeMonitoringSample.count()).toBe(1); - expect((await history.getHistory(['one', 'two'])).points[0]).toMatchObject({ cpuCores: 3, ramBytes: 300, storageBytes: 42, availableVolumes: 1 }); - expect((await history.getHistory(['two'])).volumes.map(volume => volume.label)).toEqual(['/share']); - } finally { - appMetrics.mockRestore(); - volumeMetrics.mockRestore(); - } + it('collects shared Volume measurements and reads them through each App', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + await db.app.createMany({ + data: [ + { id: 'one', name: 'One', projectId: 'project' }, + { id: 'two', name: 'Two', projectId: 'project' } + ] }); + await db.appVolume.create({ data: { id: 'base', appId: 'one', containerMountPath: '/data', size: 1 } }); + await db.appVolume.create({ data: { id: 'shared', appId: 'two', containerMountPath: '/share', size: 1, sharedVolumeId: 'base' } }); + const measuredAt = new Date(); + const appMetrics = vi.spyOn(metrics, 'getAppMetrics').mockResolvedValue( + new Map([ + ['one', { cpuCores: 1, ramBytes: 100, measuredAt }], + ['two', { cpuCores: 2, ramBytes: 200, measuredAt }] + ]) + ); + const volumeMetrics = vi.spyOn(filesystem, 'getVolumeFilesystemUsage').mockResolvedValue( + new Map([ + ['base', { pvcName: 'pvc-base', status: 'available', usedBytes: 42, measuredAt: measuredAt.toISOString() }], + ['shared', { pvcName: 'pvc-base', status: 'available', usedBytes: 42, measuredAt: measuredAt.toISOString() }] + ]) + ); + try { + await history.collect(); + expect(await db.appVolumeMonitoringSample.count()).toBe(1); + expect((await history.getHistory(['one', 'two'])).points[0]).toMatchObject({ cpuCores: 3, ramBytes: 300, storageBytes: 42, availableVolumes: 1 }); + expect((await history.getHistory(['two'])).volumes.map((volume) => volume.label)).toEqual(['/share']); + } finally { + appMetrics.mockRestore(); + volumeMetrics.mockRestore(); + } + }); - it('reads a day of five-minute history for 100 Apps', async () => { - const db = ctx.getDataAccess().client; - await db.project.create({ data: { id: 'project', name: 'Project' } }); - const appIds = Array.from({ length: 100 }, (_, index) => `app-${index}`); - await db.app.createMany({ data: appIds.map(id => ({ id, name: id, projectId: 'project' })) }); - const now = Date.now(); - const samples = Array.from({ length: 288 }, (_, index) => appIds.map(appId => ({ - appId, sampledAt: new Date(now - index * 5 * 60_000), cpuCores: 0.25, ramBytes: 1024, - }))).flat(); - await adapter.save(samples, [], [], new Date(now - 24 * 3600_000)); - const result = await history.getHistory(appIds); - expect(result.points).toHaveLength(288); - expect(result.points[0]).toMatchObject({ cpuCores: 25, ramBytes: 102400 }); - }, 30000); - + it('reads a day of five-minute history for 100 Apps', async () => { + const db = ctx.getDataAccess().client; + await db.project.create({ data: { id: 'project', name: 'Project' } }); + const appIds = Array.from({ length: 100 }, (_, index) => `app-${index}`); + await db.app.createMany({ data: appIds.map((id) => ({ id, name: id, projectId: 'project' })) }); + const now = Date.now(); + const samples = Array.from({ length: 288 }, (_, index) => + appIds.map((appId) => ({ appId, sampledAt: new Date(now - index * 5 * 60_000), cpuCores: 0.25, ramBytes: 1024 })) + ).flat(); + await adapter.save(samples, [], [], new Date(now - 24 * 3600_000)); + const result = await history.getHistory(appIds); + expect(result.points).toHaveLength(288); + expect(result.points[0]).toMatchObject({ cpuCores: 25, ramBytes: 102400 }); + }, 30000); }); diff --git a/src/app/monitoring/actions.ts b/src/app/monitoring/actions.ts index f2f302e9..8a403f02 100644 --- a/src/app/monitoring/actions.ts +++ b/src/app/monitoring/actions.ts @@ -1,38 +1,38 @@ -'use server' +'use server'; -import monitoringService from "@/server/services/monitoring.service"; -import clusterService from "@/server/services/cluster.service"; -import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; -import { UserGroupUtils } from "@/shared/utils/role.utils"; -import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model"; -import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model"; -import { NodeResourceModel } from "@/shared/model/node-resource.model"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; +import monitoringService from '@/server/services/monitoring.service'; +import clusterService from '@/server/services/cluster.service'; +import { getAuthUserSession, simpleAction } from '@/server/utils/action-wrapper.utils'; +import { UserGroupUtils } from '@/shared/utils/role.utils'; +import { AppMonitoringUsageModel } from '@/shared/model/app-monitoring-usage.model'; +import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model'; +import { NodeResourceModel } from '@/shared/model/node-resource.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; export const getNodeResourceUsage = async () => - simpleAction(async () => { - await getAuthUserSession(); - return await clusterService.getNodeResourceUsage(); - }) as Promise>; + simpleAction(async () => { + await getAuthUserSession(); + return await clusterService.getNodeResourceUsage(); + }) as Promise>; export const getVolumeMonitoringUsage = async () => - simpleAction(async () => { - const session = await getAuthUserSession(); - let volumesUsage = await monitoringService.getAllAppVolumesUsage(); - volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId)); - return volumesUsage; - }) as Promise>; + simpleAction(async () => { + const session = await getAuthUserSession(); + let volumesUsage = await monitoringService.getAllAppVolumesUsage(); + volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId)); + return volumesUsage; + }) as Promise>; export const getMonitoringForAllApps = async () => - simpleAction(async () => { - const session = await getAuthUserSession(); - let updatedNodeRessources = await monitoringService.getMonitoringForAllApps(); - updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); - return updatedNodeRessources; - }) as Promise>; + simpleAction(async () => { + const session = await getAuthUserSession(); + let updatedNodeRessources = await monitoringService.getMonitoringForAllApps(); + updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); + return updatedNodeRessources; + }) as Promise>; export const getReservedVolumeStorage = async () => - simpleAction(async () => { - await getAuthUserSession(); - return await clusterService.getReservedVolumeStorage(); - }) as Promise>; + simpleAction(async () => { + await getAuthUserSession(); + return await clusterService.getReservedVolumeStorage(); + }) as Promise>; diff --git a/src/app/monitoring/app-volumes-monitoring.tsx b/src/app/monitoring/app-volumes-monitoring.tsx index f3e5182e..26fbe4a1 100644 --- a/src/app/monitoring/app-volumes-monitoring.tsx +++ b/src/app/monitoring/app-volumes-monitoring.tsx @@ -1,12 +1,6 @@ 'use client'; - -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useMemo } from 'react'; import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model'; @@ -15,92 +9,94 @@ import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.util import { ExternalLink } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; -import { Progress } from "@/components/ui/progress" +import { Progress } from '@/components/ui/progress'; import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; import { AppVolumeIdentityUtils } from '@/shared/utils/app-volume-identity.utils'; -export default function AppVolumeMonitoring({ - volumesUsage -}: { - volumesUsage?: AppVolumeMonitoringUsageModel[] -}) { - - const updatedVolumeUsage = volumesUsage; - const totals = useMemo(() => { - const unique = new Map(); - for (const volume of volumesUsage ?? []) unique.set(AppVolumeIdentityUtils.observationKey(volume.projectId, volume.pvcName), volume); - const values = [...unique.values()]; - return { - used: values.reduce((sum, value) => sum + (value.usedBytes ?? 0), 0), - capacity: values.reduce((sum, value) => sum + (value.capacityBytes ?? 0), 0), - incomplete: values.some(value => value.status !== 'available'), - }; - }, [volumesUsage]); - - if (!updatedVolumeUsage) { - return - - App Volumes Capacity - - - - - - } +export default function AppVolumeMonitoring({ volumesUsage }: { volumesUsage?: AppVolumeMonitoringUsageModel[] }) { + const updatedVolumeUsage = volumesUsage; + const totals = useMemo(() => { + const unique = new Map(); + for (const volume of volumesUsage ?? []) unique.set(AppVolumeIdentityUtils.observationKey(volume.projectId, volume.pvcName), volume); + const values = [...unique.values()]; + return { + used: values.reduce((sum, value) => sum + (value.usedBytes ?? 0), 0), + capacity: values.reduce((sum, value) => sum + (value.capacityBytes ?? 0), 0), + incomplete: values.some((value) => value.status !== 'available') + }; + }, [volumesUsage]); + if (!updatedVolumeUsage) { return ( - - - App Volumes Capacity - - - - {updatedVolumeUsage.length} App Volumes | Total used {KubeSizeConverter.convertBytesToReadableSize(totals.used)} / {KubeSizeConverter.convertBytesToReadableSize(totals.capacity)}{totals.incomplete ? ' (incomplete)' : ''} - - - Project - App - Mount Path - Capacity - - - - - {updatedVolumeUsage.map((item) => ( - - {item.projectName} - {item.appName} - {item.mountPath} - - {item.status === 'available' && item.usedBytes !== undefined && item.capacityBytes !== undefined && item.capacityBytes > 0 ? ( - - ) : Unavailable ({item.status})} - - - - - - - - ))} - -
-
-
+ + + App Volumes Capacity + + + + + ); + } + + return ( + + + App Volumes Capacity + + + + + {updatedVolumeUsage.length} App Volumes | Total used {KubeSizeConverter.convertBytesToReadableSize(totals.used)} /{' '} + {KubeSizeConverter.convertBytesToReadableSize(totals.capacity)} + {totals.incomplete ? ' (incomplete)' : ''} + + + + Project + App + Mount Path + Capacity + + + + + {updatedVolumeUsage.map((item) => ( + + {item.projectName} + {item.appName} + {item.mountPath} + + {item.status === 'available' && item.usedBytes !== undefined && item.capacityBytes !== undefined && item.capacityBytes > 0 ? ( + + ) : ( + Unavailable ({item.status}) + )} + + + + + + + + ))} + +
+
+
+ ); } -function VolumeUsage({ usedBytes, capacityBytes }: { - usedBytes: number; - capacityBytes: number; -}) { - const percentage = Math.round(usedBytes / capacityBytes * 100); - return <> - = 90 ? 'red' : percentage >= 80 ? 'orange' : undefined} /> -
- {KubeSizeConverter.convertBytesToReadableSize(usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(capacityBytes)} -
- ; +function VolumeUsage({ usedBytes, capacityBytes }: { usedBytes: number; capacityBytes: number }) { + const percentage = Math.round((usedBytes / capacityBytes) * 100); + return ( + <> + = 90 ? 'red' : percentage >= 80 ? 'orange' : undefined} /> +
+ {KubeSizeConverter.convertBytesToReadableSize(usedBytes)} / {KubeSizeConverter.convertBytesToReadableSize(capacityBytes)} +
+ + ); } diff --git a/src/app/monitoring/app-volumes-monitoring.unit.spec.ts b/src/app/monitoring/app-volumes-monitoring.unit.spec.ts index eeee8ab3..7c153d5e 100644 --- a/src/app/monitoring/app-volumes-monitoring.unit.spec.ts +++ b/src/app/monitoring/app-volumes-monitoring.unit.spec.ts @@ -6,18 +6,30 @@ import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitor afterEach(cleanup); it('counts shared volumes once and marks incomplete totals', () => { - const base: AppVolumeMonitoringUsageModel = { - projectId: 'p', projectName: 'Project', appName: 'App', appId: 'a', mountPath: '/data', - pvcName: 'pvc-1', status: 'available', usedBytes: 1024, capacityBytes: 4096, - availableBytes: 3072, isBaseVolume: true, - }; - render(React.createElement(AppVolumeMonitoring, { volumesUsage: [ + const base: AppVolumeMonitoringUsageModel = { + projectId: 'p', + projectName: 'Project', + appName: 'App', + appId: 'a', + mountPath: '/data', + pvcName: 'pvc-1', + status: 'available', + usedBytes: 1024, + capacityBytes: 4096, + availableBytes: 3072, + isBaseVolume: true + }; + render( + React.createElement(AppVolumeMonitoring, { + volumesUsage: [ base, { ...base, appId: 'b', appName: 'Other', isBaseVolume: false }, - { ...base, appId: 'c', pvcName: 'pvc-2', status: 'unavailable', usedBytes: undefined, capacityBytes: undefined, availableBytes: undefined }, - ] })); - expect(screen.getByText(/Total used.*incomplete/).textContent).toContain('1 KiB'); - expect(screen.getByText(/Total used.*incomplete/).textContent).toContain('4 KiB'); - expect(screen.getByText('Unavailable (unavailable)')).toBeTruthy(); - expect(screen.getAllByText('1 KiB / 4 KiB')).toHaveLength(2); + { ...base, appId: 'c', pvcName: 'pvc-2', status: 'unavailable', usedBytes: undefined, capacityBytes: undefined, availableBytes: undefined } + ] + }) + ); + expect(screen.getByText(/Total used.*incomplete/).textContent).toContain('1 KiB'); + expect(screen.getByText(/Total used.*incomplete/).textContent).toContain('4 KiB'); + expect(screen.getByText('Unavailable (unavailable)')).toBeTruthy(); + expect(screen.getAllByText('1 KiB / 4 KiB')).toHaveLength(2); }); diff --git a/src/app/monitoring/disk-chart.tsx b/src/app/monitoring/disk-chart.tsx index 928d915e..c2e4cccb 100644 --- a/src/app/monitoring/disk-chart.tsx +++ b/src/app/monitoring/disk-chart.tsx @@ -5,20 +5,22 @@ import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.util import { Progress } from '@/components/ui/progress'; export default function ChartDiskRessources({ nodeRessource }: { nodeRessource: NodeResourceModel }) { - const { diskUsageAbsolut: used, diskUsageAvailable: available, diskUsageCapacity: capacity } = nodeRessource; + const { diskUsageAbsolut: used, diskUsageAvailable: available, diskUsageCapacity: capacity } = nodeRessource; - if (used === undefined || available === undefined || capacity === undefined || capacity <= 0) { - return

Storage metrics unavailable

; - } + if (used === undefined || available === undefined || capacity === undefined || capacity <= 0) { + return

Storage metrics unavailable

; + } - return ( -
-
- {(used / capacity * 100).toFixed(0)}% used - {KubeSizeConverter.convertBytesToReadableSize(used)} / {KubeSizeConverter.convertBytesToReadableSize(capacity)} -
- -

Available: {KubeSizeConverter.convertBytesToReadableSize(available)}

-
- ); + return ( +
+
+ {((used / capacity) * 100).toFixed(0)}% used + + {KubeSizeConverter.convertBytesToReadableSize(used)} / {KubeSizeConverter.convertBytesToReadableSize(capacity)} + +
+ +

Available: {KubeSizeConverter.convertBytesToReadableSize(available)}

+
+ ); } diff --git a/src/app/monitoring/monitoring-client.tsx b/src/app/monitoring/monitoring-client.tsx index 97b03214..7285479e 100644 --- a/src/app/monitoring/monitoring-client.tsx +++ b/src/app/monitoring/monitoring-client.tsx @@ -20,47 +20,46 @@ const MONITORING_POLL_INTERVAL_MS = 10000; * so the page runs one pausable poller instead of three independent intervals. */ export default function MonitoringClient({ - resourcesNodes, - appsRessourceUsage, - volumesUsage, - reservedVolumeStorage, + resourcesNodes, + appsRessourceUsage, + volumesUsage, + reservedVolumeStorage }: { - resourcesNodes?: NodeResourceModel[]; - appsRessourceUsage?: AppMonitoringUsageModel[]; - volumesUsage?: AppVolumeMonitoringUsageModel[]; - reservedVolumeStorage?: number; + resourcesNodes?: NodeResourceModel[]; + appsRessourceUsage?: AppMonitoringUsageModel[]; + volumesUsage?: AppVolumeMonitoringUsageModel[]; + reservedVolumeStorage?: number; }) { + const [updatedReservedStorage, setUpdatedReservedStorage] = useState(reservedVolumeStorage); + const [updatedNodes, setUpdatedNodes] = useState(resourcesNodes); + const [updatedApps, setUpdatedApps] = useState(appsRessourceUsage); + const [updatedVolumes, setUpdatedVolumes] = useState(volumesUsage); - const [updatedReservedStorage, setUpdatedReservedStorage] = useState(reservedVolumeStorage); - const [updatedNodes, setUpdatedNodes] = useState(resourcesNodes); - const [updatedApps, setUpdatedApps] = useState(appsRessourceUsage); - const [updatedVolumes, setUpdatedVolumes] = useState(volumesUsage); + const refreshMonitoringData = useCallback(async () => { + try { + const [nodes, volumes, apps, reservedStorage] = await Promise.all([ + Actions.run(() => getNodeResourceUsage()), + Actions.run(() => getVolumeMonitoringUsage()), + Actions.run(() => getMonitoringForAllApps()), + Actions.run(() => getReservedVolumeStorage()).catch(() => undefined) + ]); + setUpdatedNodes(nodes); + setUpdatedReservedStorage(reservedStorage); + setUpdatedVolumes(volumes?.filter((volume) => !!volume.isBaseVolume)); + setUpdatedApps(apps); + } catch (ex) { + toast.error('An error occurred while fetching current resource usage'); + console.error('An error occurred while fetching monitoring data', ex); + } + }, []); - const refreshMonitoringData = useCallback(async () => { - try { - const [nodes, volumes, apps, reservedStorage] = await Promise.all([ - Actions.run(() => getNodeResourceUsage()), - Actions.run(() => getVolumeMonitoringUsage()), - Actions.run(() => getMonitoringForAllApps()), - Actions.run(() => getReservedVolumeStorage()).catch(() => undefined), - ]); - setUpdatedNodes(nodes); - setUpdatedReservedStorage(reservedStorage); - setUpdatedVolumes(volumes?.filter((volume) => !!volume.isBaseVolume)); - setUpdatedApps(apps); - } catch (ex) { - toast.error('An error occurred while fetching current resource usage'); - console.error('An error occurred while fetching monitoring data', ex); - } - }, []); + usePolling(refreshMonitoringData, { intervalMs: MONITORING_POLL_INTERVAL_MS }); - usePolling(refreshMonitoringData, { intervalMs: MONITORING_POLL_INTERVAL_MS }); - - return ( -
- - - -
- ); + return ( +
+ + + +
+ ); } diff --git a/src/app/monitoring/monitoring-nodes.tsx b/src/app/monitoring/monitoring-nodes.tsx index ded34d52..afa2b74b 100644 --- a/src/app/monitoring/monitoring-nodes.tsx +++ b/src/app/monitoring/monitoring-nodes.tsx @@ -1,36 +1,17 @@ 'use client'; -import { - Label, - Pie, - PieChart, -} from 'recharts'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; +import { Label, Pie, PieChart } from 'recharts'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import { NodeResourceModel } from '@/shared/model/node-resource.model'; -import { - useBreadcrumbs, -} from '@/frontend/states/zustand.states'; +import { useBreadcrumbs } from '@/frontend/states/zustand.states'; import { useEffect, useMemo } from 'react'; import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; import { Progress } from '@/components/ui/progress'; -export default function ResourcesNodes({ - resourcesNodes, - reservedVolumeStorage, -}: { - resourcesNodes?: NodeResourceModel[]; - reservedVolumeStorage?: number; -}) { - +export default function ResourcesNodes({ resourcesNodes, reservedVolumeStorage }: { resourcesNodes?: NodeResourceModel[]; reservedVolumeStorage?: number }) { const getDiskUsageAbsolut = (node: NodeResourceModel) => node.diskUsageAbsolut ?? 0; const getDiskUsageCapacity = (node: NodeResourceModel) => node.diskUsageCapacity ?? 0; const toPercent = (used: number, capacity: number) => (capacity > 0 ? (used / capacity) * 100 : 0); @@ -38,58 +19,42 @@ export default function ResourcesNodes({ const updatedNodeRessources = resourcesNodes; const setBreadcrumbs = useBreadcrumbs((state) => state.setBreadcrumbs); - useEffect( - () => setBreadcrumbs([{ name: 'Monitoring', url: '/monitoring' }] - ), [setBreadcrumbs]); + useEffect(() => setBreadcrumbs([{ name: 'Monitoring', url: '/monitoring' }]), [setBreadcrumbs]); const clusterStats = useMemo(() => { - if (!updatedNodeRessources) return { - cpuUsage: 0, cpuCapacity: 1, - ramUsage: 0, ramCapacity: 1, - diskUsageAbsolut: 0, diskCapacity: 1 - }; + if (!updatedNodeRessources) return { cpuUsage: 0, cpuCapacity: 1, ramUsage: 0, ramCapacity: 1, diskUsageAbsolut: 0, diskCapacity: 1 }; - return updatedNodeRessources.reduce((acc, node) => ({ - cpuUsage: acc.cpuUsage + node.cpuUsage, - cpuCapacity: acc.cpuCapacity + node.cpuCapacity, - ramUsage: acc.ramUsage + node.ramUsage, - ramCapacity: acc.ramCapacity + node.ramCapacity, - diskUsageAbsolut: acc.diskUsageAbsolut + getDiskUsageAbsolut(node), - diskCapacity: acc.diskCapacity + getDiskUsageCapacity(node), - }), { - cpuUsage: 0, cpuCapacity: 0, - ramUsage: 0, ramCapacity: 0, - diskUsageAbsolut: 0, diskCapacity: 0 - }); + return updatedNodeRessources.reduce( + (acc, node) => ({ + cpuUsage: acc.cpuUsage + node.cpuUsage, + cpuCapacity: acc.cpuCapacity + node.cpuCapacity, + ramUsage: acc.ramUsage + node.ramUsage, + ramCapacity: acc.ramCapacity + node.ramCapacity, + diskUsageAbsolut: acc.diskUsageAbsolut + getDiskUsageAbsolut(node), + diskCapacity: acc.diskCapacity + getDiskUsageCapacity(node) + }), + { cpuUsage: 0, cpuCapacity: 0, ramUsage: 0, ramCapacity: 0, diskUsageAbsolut: 0, diskCapacity: 0 } + ); }, [updatedNodeRessources]); const getUsageColor = (percentage: number) => { - if (percentage >= 90) return "var(--chart-1)"; - if (percentage >= 80) return "var(--chart-4)"; - return "var(--chart-2)"; + if (percentage >= 90) return 'var(--chart-1)'; + if (percentage >= 80) return 'var(--chart-4)'; + return 'var(--chart-2)'; }; - const pieChartConfig = { - used: { - label: "Used", - color: "var(--chart-1)", - }, - free: { - label: "Remaining", - color: "var(--muted)", - }, - } satisfies ChartConfig; + const pieChartConfig = { used: { label: 'Used', color: 'var(--chart-1)' }, free: { label: 'Remaining', color: 'var(--muted)' } } satisfies ChartConfig; const getChartData = (used: number, capacity: number) => { const percentage = capacity > 0 ? (used / capacity) * 100 : 0; return [ { status: 'used', value: used, fill: getUsageColor(percentage) }, - { status: 'free', value: Math.max(0, capacity - used), fill: 'var(--color-free)' }, + { status: 'free', value: Math.max(0, capacity - used), fill: 'var(--color-free)' } ]; }; if (!updatedNodeRessources) { - return + return ; } return ( @@ -106,20 +71,22 @@ export default function ResourcesNodes({ } /> - @@ -135,22 +102,27 @@ export default function ResourcesNodes({ - KubeSizeConverter.convertBytesToReadableSize(value as number)} />} /> + KubeSizeConverter.convertBytesToReadableSize(value as number)} />} + /> - @@ -164,48 +136,90 @@ export default function ResourcesNodes({ Node filesystem usage and volume reservations - {updatedNodeRessources.some(node => node.diskUsageCapacity === undefined) && ( + {updatedNodeRessources.some((node) => node.diskUsageCapacity === undefined) && (

Incomplete: some node filesystem metrics are unavailable.

)} {clusterStats.diskCapacity > 0 ? ( <> - + - ( - {name === 'reserved' ? 'Reserved beyond node usage' : name === 'used' ? 'Used' : 'Remaining'}: {KubeSizeConverter.convertBytesToReadableSize(value as number)} - )} />} /> + ( + + {name === 'reserved' ? 'Reserved beyond node usage' : name === 'used' ? 'Used' : 'Remaining'}:{' '} + {KubeSizeConverter.convertBytesToReadableSize(value as number)} + + )} + /> + } + /> -

- Node filesystem: {KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskUsageAbsolut)} used. - Volume reservation: {reservedVolumeStorage === undefined ? 'reservation unavailable' : `${KubeSizeConverter.convertBytesToReadableSize(reservedVolumeStorage)} reserved`}. - Total node capacity: {KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskCapacity)}. + Node filesystem: {KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskUsageAbsolut)} used. Volume reservation:{' '} + {reservedVolumeStorage === undefined + ? 'reservation unavailable' + : `${KubeSizeConverter.convertBytesToReadableSize(reservedVolumeStorage)} reserved`} + . Total node capacity: {KubeSizeConverter.convertBytesToReadableSize(clusterStats.diskCapacity)}.

- ) :

Storage metrics unavailable

} + ) : ( +

Storage metrics unavailable

+ )}
@@ -233,7 +247,9 @@ export default function ResourcesNodes({
{((node.cpuUsage / node.cpuCapacity) * 100).toFixed(0)}% - {node.cpuUsage.toFixed(2)} / {node.cpuCapacity} Cores + + {node.cpuUsage.toFixed(2)} / {node.cpuCapacity} Cores +
@@ -242,7 +258,9 @@ export default function ResourcesNodes({
{((node.ramUsage / node.ramCapacity) * 100).toFixed(0)}% - {KubeSizeConverter.convertBytesToReadableSize(node.ramUsage)} / {KubeSizeConverter.convertBytesToReadableSize(node.ramCapacity)} + + {KubeSizeConverter.convertBytesToReadableSize(node.ramUsage)} / {KubeSizeConverter.convertBytesToReadableSize(node.ramCapacity)} +
@@ -259,7 +277,10 @@ export default function ResourcesNodes({ <>
{diskPercent.toFixed(0)}% - {KubeSizeConverter.convertBytesToReadableSize(diskUsedAndReserved)} / {KubeSizeConverter.convertBytesToReadableSize(diskCapacity)} + + {KubeSizeConverter.convertBytesToReadableSize(diskUsedAndReserved)} /{' '} + {KubeSizeConverter.convertBytesToReadableSize(diskCapacity)} +
@@ -276,4 +297,3 @@ export default function ResourcesNodes({ ); } - diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 196abf9f..b955c179 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -1,44 +1,40 @@ -'use server' +'use server'; -import { getAuthUserSession } from "@/server/utils/action-wrapper.utils"; -import PageTitle from "@/components/custom/page-title"; -import clusterService from "@/server/services/cluster.service"; -import MonitoringClient from "./monitoring-client"; -import monitoringService from "@/server/services/monitoring.service"; -import { UserGroupUtils } from "@/shared/utils/role.utils"; -import { CatchUtils } from "@/shared/utils/catch.utils"; +import { getAuthUserSession } from '@/server/utils/action-wrapper.utils'; +import PageTitle from '@/components/custom/page-title'; +import clusterService from '@/server/services/cluster.service'; +import MonitoringClient from './monitoring-client'; +import monitoringService from '@/server/services/monitoring.service'; +import { UserGroupUtils } from '@/shared/utils/role.utils'; +import { CatchUtils } from '@/shared/utils/catch.utils'; export default async function ResourceNodesInfoPage() { + const session = await getAuthUserSession(); - const session = await getAuthUserSession(); + let [resourcesNode, volumesUsage, updatedNodeRessources, reservedVolumeStorage] = await Promise.all([ + CatchUtils.resultOrUndefined(() => clusterService.getNodeResourceUsage()), + CatchUtils.resultOrUndefined(() => monitoringService.getAllAppVolumesUsage()), + CatchUtils.resultOrUndefined(() => monitoringService.getMonitoringForAllApps()), + CatchUtils.resultOrUndefined(() => clusterService.getReservedVolumeStorage()) + ]); - let [resourcesNode, volumesUsage, updatedNodeRessources, reservedVolumeStorage] = await Promise.all([ - CatchUtils.resultOrUndefined(() => clusterService.getNodeResourceUsage()), - CatchUtils.resultOrUndefined(() => monitoringService.getAllAppVolumesUsage()), - CatchUtils.resultOrUndefined(() => monitoringService.getMonitoringForAllApps()), - CatchUtils.resultOrUndefined(() => clusterService.getReservedVolumeStorage()) - ]); + // filter by role + volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId)); + // only base volumes, no shared volumes + volumesUsage = volumesUsage?.filter((volume) => !!volume.isBaseVolume); + updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); - // filter by role - volumesUsage = volumesUsage?.filter((volume) => UserGroupUtils.sessionHasReadAccessForApp(session, volume.appId)); - // only base volumes, no shared volumes - volumesUsage = volumesUsage?.filter((volume) => !!volume.isBaseVolume); - updatedNodeRessources = updatedNodeRessources?.filter((app) => UserGroupUtils.sessionHasReadAccessForApp(session, app.appId)); - - return ( -
- - -
- -
-
- ) + return ( +
+ +
+ +
+
+ ); } diff --git a/src/app/project/[projectId]/agents/create/create-agent-dialog.tsx b/src/app/project/[projectId]/agents/create/create-agent-dialog.tsx index cfb4d63a..fd103445 100644 --- a/src/app/project/[projectId]/agents/create/create-agent-dialog.tsx +++ b/src/app/project/[projectId]/agents/create/create-agent-dialog.tsx @@ -1,208 +1,181 @@ 'use client'; -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import SelectBox from "@/components/custom/multiselect-field"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { Actions } from "@/frontend/utils/nextjs-actions.utils"; -import { createAgent, getLlmGateways, getModelAliasesForGateway } from "../../actions"; -import { useRouter } from "next/navigation"; -import { PathBuilderUtils } from "@/shared/utils/path-builder.utils"; -import { Loader2 } from "lucide-react"; -import { useDialog } from "@/frontend/states/zustand.states"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { toast } from "sonner"; +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import SelectBox from '@/components/custom/multiselect-field'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; +import { createAgent, getLlmGateways, getModelAliasesForGateway } from '../../actions'; +import { useRouter } from 'next/navigation'; +import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; +import { Loader2 } from 'lucide-react'; +import { useDialog } from '@/frontend/states/zustand.states'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { toast } from 'sonner'; interface LlmGatewayOption { - id: string; - name: string; + id: string; + name: string; } function CreateAgentForm({ projectId }: { projectId: string }) { - const router = useRouter(); - const { closeDialog } = useDialogContext(); - const [name, setName] = useState(''); - const [selectedGatewayId, setSelectedGatewayId] = useState(''); - const [modelAliases, setModelAliases] = useState([]); - const [selectedModelAliases, setSelectedModelAliases] = useState([]); - const [loadingGateways, setLoadingGateways] = useState(false); - const [loadingAliases, setLoadingAliases] = useState(false); - const [gateways, setGateways] = useState([]); - const [submitting, setSubmitting] = useState(false); - - useEffect(() => { - loadGateways(); - }, []); - - const loadGateways = async () => { - setLoadingGateways(true); - try { - const result = await Actions.run(() => getLlmGateways()); - setGateways(result as LlmGatewayOption[]); - } catch (error) { - console.error('Failed to load LLM gateways:', error); - } finally { - setLoadingGateways(false); - } - }; - - useEffect(() => { - if (!selectedGatewayId) { - setModelAliases([]); - setSelectedModelAliases([]); - return; - } - loadModelAliases(selectedGatewayId); - }, [selectedGatewayId]); - - const loadModelAliases = async (gatewayId: string) => { - setLoadingAliases(true); - setSelectedModelAliases([]); - try { - const result = await Actions.run(() => getModelAliasesForGateway(gatewayId)); - setModelAliases(result as string[]); - } catch (error) { - toast.error('Failed to load model aliases. Please try again.'); - console.error('Failed to load model aliases:', error); - setModelAliases([]); - } finally { - setLoadingAliases(false); - } - }; - - const handleSubmit = async () => { - if (!name.trim() || !selectedGatewayId || selectedModelAliases.length === 0) { - return; - } - - setSubmitting(true); - const result = await Toast.fromAction( - () => createAgent(name.trim(), projectId, selectedGatewayId, selectedModelAliases), - 'Agent created' - ); - - if (result.status === 'success') { - closeDialog(); - router.push(PathBuilderUtils.projectAgentDrawer(projectId, result.data!.id, 'sandboxes')); - } - setSubmitting(false); - }; - - const canSubmit = name.trim() && selectedGatewayId && selectedModelAliases.length > 0 && !submitting; - - return ( - <> - - Create Agent - - Create a new Agent in this project. You need an LLM Gateway and a model alias. - - -
-
- - setName(e.target.value)} - /> -
-
- - {loadingGateways ? ( -
- Loading gateways... -
- ) : gateways.length === 0 ? ( -

- No LLM Gateways configured. Add one in Settings. -

- ) : ( - - )} -
-
- - {!selectedGatewayId ? ( -

Select a gateway first

- ) : loadingAliases ? ( -
- Loading model aliases... -
- ) : modelAliases.length === 0 ? ( -

No model aliases available

- ) : ( - setSelectedModelAliases(Array.isArray(value) ? value : [])} - options={modelAliases.map((alias) => ({ value: alias, label: alias }))} - placeholder="Select model aliases" - inputPlaceholder="Search model aliases..." - emptyPlaceholder="No model aliases found." - /> - )} -
+ const router = useRouter(); + const { closeDialog } = useDialogContext(); + const [name, setName] = useState(''); + const [selectedGatewayId, setSelectedGatewayId] = useState(''); + const [modelAliases, setModelAliases] = useState([]); + const [selectedModelAliases, setSelectedModelAliases] = useState([]); + const [loadingGateways, setLoadingGateways] = useState(false); + const [loadingAliases, setLoadingAliases] = useState(false); + const [gateways, setGateways] = useState([]); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + loadGateways(); + }, []); + + const loadGateways = async () => { + setLoadingGateways(true); + try { + const result = await Actions.run(() => getLlmGateways()); + setGateways(result as LlmGatewayOption[]); + } catch (error) { + console.error('Failed to load LLM gateways:', error); + } finally { + setLoadingGateways(false); + } + }; + + useEffect(() => { + if (!selectedGatewayId) { + setModelAliases([]); + setSelectedModelAliases([]); + return; + } + loadModelAliases(selectedGatewayId); + }, [selectedGatewayId]); + + const loadModelAliases = async (gatewayId: string) => { + setLoadingAliases(true); + setSelectedModelAliases([]); + try { + const result = await Actions.run(() => getModelAliasesForGateway(gatewayId)); + setModelAliases(result as string[]); + } catch (error) { + toast.error('Failed to load model aliases. Please try again.'); + console.error('Failed to load model aliases:', error); + setModelAliases([]); + } finally { + setLoadingAliases(false); + } + }; + + const handleSubmit = async () => { + if (!name.trim() || !selectedGatewayId || selectedModelAliases.length === 0) { + return; + } + + setSubmitting(true); + const result = await Toast.fromAction(() => createAgent(name.trim(), projectId, selectedGatewayId, selectedModelAliases), 'Agent created'); + + if (result.status === 'success') { + closeDialog(); + router.push(PathBuilderUtils.projectAgentDrawer(projectId, result.data!.id, 'sandboxes')); + } + setSubmitting(false); + }; + + const canSubmit = name.trim() && selectedGatewayId && selectedModelAliases.length > 0 && !submitting; + + return ( + <> + + Create Agent + Create a new Agent in this project. You need an LLM Gateway and a model alias. + +
+
+ + setName(e.target.value)} /> +
+
+ + {loadingGateways ? ( +
+ Loading gateways... +
+ ) : gateways.length === 0 ? ( +

No LLM Gateways configured. Add one in Settings.

+ ) : ( + + )} +
+
+ + {!selectedGatewayId ? ( +

Select a gateway first

+ ) : loadingAliases ? ( +
+ Loading model aliases...
- - - - - - ); + ) : modelAliases.length === 0 ? ( +

No model aliases available

+ ) : ( + setSelectedModelAliases(Array.isArray(value) ? value : [])} + options={modelAliases.map((alias) => ({ value: alias, label: alias }))} + placeholder="Select model aliases" + inputPlaceholder="Search model aliases..." + emptyPlaceholder="No model aliases found." + /> + )} +
+
+ + + + + + ); } interface CreateAgentDialogProps { - children: React.ReactNode; - projectId: string; + children: React.ReactNode; + projectId: string; } export function CreateAgentDialog({ children, projectId }: CreateAgentDialogProps) { - const { openDialog } = useDialog(); + const { openDialog } = useDialog(); - const handleOpen = () => { - openDialog(, { maxWidth: '480px' }); - }; + const handleOpen = () => { + openDialog(, { maxWidth: '480px' }); + }; - return ( -
- {children} -
- ); + return
{children}
; } diff --git a/src/app/project/[projectId]/agents/create/create-template-agent-setup-dialog.tsx b/src/app/project/[projectId]/agents/create/create-template-agent-setup-dialog.tsx index 42d8fab8..53e381ff 100644 --- a/src/app/project/[projectId]/agents/create/create-template-agent-setup-dialog.tsx +++ b/src/app/project/[projectId]/agents/create/create-template-agent-setup-dialog.tsx @@ -1,228 +1,225 @@ -'use client' +'use client'; -import type { z } from "zod"; -import { Fragment, useActionState, useEffect, useState } from "react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; -import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import SelectBox from "@/components/custom/multiselect-field"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { Actions } from "@/frontend/utils/nextjs-actions.utils"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { AgentTemplateModel, agentTemplateZodModel } from "@/shared/model/agent-template.model"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { createAgentFromTemplate, getLlmGateways, getModelAliasesForGateway } from "../../actions"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { TemplateDetailsPanel } from "../../template-details-panel"; +import type { z } from 'zod'; +import { Fragment, useActionState, useEffect, useState } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import SelectBox from '@/components/custom/multiselect-field'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { AgentTemplateModel, agentTemplateZodModel } from '@/shared/model/agent-template.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { createAgentFromTemplate, getLlmGateways, getModelAliasesForGateway } from '../../actions'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { TemplateDetailsPanel } from '../../template-details-panel'; interface LlmGatewayOption { - id: string; - name: string; + id: string; + name: string; } -export default function CreateTemplateAgentSetupDialog({ - agentTemplate, - projectId -}: { - agentTemplate: AgentTemplateModel; - projectId: string; -}) { - const { closeDialog } = useDialogContext(); - const [gateways, setGateways] = useState([]); - const [loadingGateways, setLoadingGateways] = useState(false); - const [loadingAliases, setLoadingAliases] = useState>({}); - const [modelAliases, setModelAliases] = useState>({}); +export default function CreateTemplateAgentSetupDialog({ agentTemplate, projectId }: { agentTemplate: AgentTemplateModel; projectId: string }) { + const { closeDialog } = useDialogContext(); + const [gateways, setGateways] = useState([]); + const [loadingGateways, setLoadingGateways] = useState(false); + const [loadingAliases, setLoadingAliases] = useState>({}); + const [modelAliases, setModelAliases] = useState>({}); - const form = useForm, unknown, z.output>({ - resolver: zodResolver(agentTemplateZodModel), - defaultValues: agentTemplate - }); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(agentTemplateZodModel), + defaultValues: agentTemplate + }); - const [state, formAction] = useActionState((state: ServerActionResult, - payload: AgentTemplateModel) => createAgentFromTemplate(state, payload, projectId), - FormUtils.getInitialFormState()); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AgentTemplateModel) => createAgentFromTemplate(state, payload, projectId), + FormUtils.getInitialFormState() + ); - useEffect(() => { - const loadGateways = async () => { - setLoadingGateways(true); - try { - const result = await Actions.run(() => getLlmGateways()); - setGateways(result as LlmGatewayOption[]); - } catch (error) { - toast.error('Failed to load LLM gateways.'); - console.error('Failed to load LLM gateways:', error); - } finally { - setLoadingGateways(false); - } - }; - loadGateways(); - }, []); + useEffect(() => { + const loadGateways = async () => { + setLoadingGateways(true); + try { + const result = await Actions.run(() => getLlmGateways()); + setGateways(result as LlmGatewayOption[]); + } catch (error) { + toast.error('Failed to load LLM gateways.'); + console.error('Failed to load LLM gateways:', error); + } finally { + setLoadingGateways(false); + } + }; + loadGateways(); + }, []); - useEffect(() => { - if (state.status === 'success') { - form.reset(); - const agentLabel = ((agentTemplate?.templates.length ?? 0) > 1) ? 'Agents' : 'Agent'; - toast.success(`${agentLabel} created successfully`, { - description: `Click deploy to start the ${agentLabel}.`, - }); - closeDialog(true); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [agentTemplate?.templates.length, closeDialog, form, state]); + useEffect(() => { + if (state.status === 'success') { + form.reset(); + const agentLabel = (agentTemplate?.templates.length ?? 0) > 1 ? 'Agents' : 'Agent'; + toast.success(`${agentLabel} created successfully`, { description: `Click deploy to start the ${agentLabel}.` }); + closeDialog(true); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [agentTemplate?.templates.length, closeDialog, form, state]); - useEffect(() => { - form.reset(agentTemplate); - setModelAliases({}); - setLoadingAliases({}); - }, [agentTemplate, form, projectId]); + useEffect(() => { + form.reset(agentTemplate); + setModelAliases({}); + setLoadingAliases({}); + }, [agentTemplate, form, projectId]); - const loadModelAliases = async (templateIndex: number, gatewayId: string) => { - form.setValue(`templates.${templateIndex}.modelAlias` as any, []); - setLoadingAliases((current) => ({ ...current, [templateIndex]: true })); - try { - const result = await Actions.run(() => getModelAliasesForGateway(gatewayId)); - setModelAliases((current) => ({ ...current, [templateIndex]: result as string[] })); - } catch (error) { - toast.error('Failed to load model aliases.'); - console.error('Failed to load model aliases:', error); - setModelAliases((current) => ({ ...current, [templateIndex]: [] })); - } finally { - setLoadingAliases((current) => ({ ...current, [templateIndex]: false })); - } - }; + const loadModelAliases = async (templateIndex: number, gatewayId: string) => { + form.setValue(`templates.${templateIndex}.modelAlias` as any, []); + setLoadingAliases((current) => ({ ...current, [templateIndex]: true })); + try { + const result = await Actions.run(() => getModelAliasesForGateway(gatewayId)); + setModelAliases((current) => ({ ...current, [templateIndex]: result as string[] })); + } catch (error) { + toast.error('Failed to load model aliases.'); + console.error('Failed to load model aliases:', error); + setModelAliases((current) => ({ ...current, [templateIndex]: [] })); + } finally { + setLoadingAliases((current) => ({ ...current, [templateIndex]: false })); + } + }; - return ( - <> - - Create Agent "{agentTemplate.name}" - Configure the agent workspace and its model access. - -
- form.handleSubmit((data) => formAction(data))()}> - -
-
-
-

Configuration

-

Choose a gateway and at least one model alias.

-
-
- {agentTemplate.templates.map((t, templateIndex) => ( - - {templateIndex > 0 &&
} - {agentTemplate.templates.length > 1 && -

{t.name}

} - ( - - Agent Name - - - - - - )} - /> - ( - - LLM Gateway - {loadingGateways ? ( -
- Loading gateways... -
- ) : ( - - )} - -
- )} - /> - ( - - Model Alias - {loadingAliases[templateIndex] ? ( -
- Loading model aliases... -
- ) : ( - - field.onChange(Array.isArray(value) ? value : [])} - options={(modelAliases[templateIndex] ?? []).map((alias) => ({ value: alias, label: alias }))} - placeholder="Select model aliases" - inputPlaceholder="Search model aliases..." - emptyPlaceholder="No model aliases found." - className={!form.watch(`templates.${templateIndex}.llmGatewayId` as any) ? "pointer-events-none opacity-50" : undefined} - /> - - )} - -
- )} - /> - {t.inputSettings.map((input, settingsIndex) => ( - ( - - {input.label} - - - - {input.randomGeneratedIfEmpty && - If left empty, a random value will be generated.} - - - )} - /> - ))} -
- ))} -
-
- -
-
- -

{state.message}

- Create agent -
-
- - - ); + return ( + <> + + Create Agent "{agentTemplate.name}" + Configure the agent workspace and its model access. + +
+ form.handleSubmit((data) => formAction(data))()}> + +
+
+
+

Configuration

+

Choose a gateway and at least one model alias.

+
+
+ {agentTemplate.templates.map((t, templateIndex) => ( + + {templateIndex > 0 &&
} + {agentTemplate.templates.length > 1 &&

{t.name}

} + ( + + Agent Name + + + + + + )} + /> + ( + + LLM Gateway + {loadingGateways ? ( +
+ Loading gateways... +
+ ) : ( + + )} + +
+ )} + /> + ( + + Model Alias + {loadingAliases[templateIndex] ? ( +
+ Loading model aliases... +
+ ) : ( + + field.onChange(Array.isArray(value) ? value : [])} + options={(modelAliases[templateIndex] ?? []).map((alias) => ({ value: alias, label: alias }))} + placeholder="Select model aliases" + inputPlaceholder="Search model aliases..." + emptyPlaceholder="No model aliases found." + className={!form.watch(`templates.${templateIndex}.llmGatewayId` as any) ? 'pointer-events-none opacity-50' : undefined} + /> + + )} + +
+ )} + /> + {t.inputSettings.map((input, settingsIndex) => ( + ( + + {input.label} + + + + {input.randomGeneratedIfEmpty && If left empty, a random value will be generated.} + + + )} + /> + ))} +
+ ))} +
+
+ +
+
+ +

+ {state.message} +

+ Create agent +
+
+ + + ); } diff --git a/src/app/project/[projectId]/agents/create/rename-agent-dialog.tsx b/src/app/project/[projectId]/agents/create/rename-agent-dialog.tsx index c066ecd8..e699e911 100644 --- a/src/app/project/[projectId]/agents/create/rename-agent-dialog.tsx +++ b/src/app/project/[projectId]/agents/create/rename-agent-dialog.tsx @@ -1,70 +1,79 @@ 'use client'; -import { Button } from "@/components/ui/button"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { RenameAgentModel, renameAgentZodModel } from "@/shared/model/rename-agent.model"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useRouter } from "next/navigation"; -import { useActionState, useEffect } from "react"; -import { useForm } from "react-hook-form"; -import type { z } from "zod"; -import { toast } from "sonner"; -import { renameAgent } from "../../actions"; -import { Agent } from "@prisma/client"; +import { Button } from '@/components/ui/button'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { RenameAgentModel, renameAgentZodModel } from '@/shared/model/rename-agent.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/navigation'; +import { useActionState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; +import { toast } from 'sonner'; +import { renameAgent } from '../../actions'; +import { Agent } from '@prisma/client'; export function RenameAgentDialog({ agent }: { agent: AgentExtendedModel }) { - const { closeDialog } = useDialogContext(); - const router = useRouter(); - const form = useForm, unknown, z.output>({ - resolver: zodResolver(renameAgentZodModel), - defaultValues: { name: agent.name }, - }); - const [state, formAction] = useActionState((state: ServerActionResult, payload: RenameAgentModel) => renameAgent(state, payload, agent.id), - FormUtils.getInitialFormState(), - ); + const { closeDialog } = useDialogContext(); + const router = useRouter(); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(renameAgentZodModel), + defaultValues: { name: agent.name } + }); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: RenameAgentModel) => renameAgent(state, payload, agent.id), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - toast.success('Agent renamed successfully.'); - closeDialog(); - router.refresh(); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [closeDialog, form, router, state]); + useEffect(() => { + if (state.status === 'success') { + toast.success('Agent renamed successfully.'); + closeDialog(); + router.refresh(); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [closeDialog, form, router, state]); - const submit = () => { - form.handleSubmit((data) => formAction(data))(); - }; + const submit = () => { + form.handleSubmit((data) => formAction(data))(); + }; - return ( -
- - - Rename Agent - Choose a new display name for this agent. - -
- ( - - Name - - - - )} /> -

{state.status === 'error' && !state.errors ? state.message : ''}

-
- - Rename - - -
- - ); + return ( +
+ + + Rename Agent + Choose a new display name for this agent. + +
+ ( + + Name + + + + + + )} + /> +

{state.status === 'error' && !state.errors ? state.message : ''}

+
+ + Rename + + +
+ + ); } diff --git a/src/app/project/[projectId]/agents/details/agent-actions.ts b/src/app/project/[projectId]/agents/details/agent-actions.ts index 42d2fa3c..dbabf3ad 100644 --- a/src/app/project/[projectId]/agents/details/agent-actions.ts +++ b/src/app/project/[projectId]/agents/details/agent-actions.ts @@ -1,39 +1,38 @@ -'use server' - -import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; -import { getAuthUserSession, simpleAction, isAuthorizedReadForWorkload, isAuthorizedWriteForWorkload } from "@/server/utils/action-wrapper.utils"; -import { ensureDeleteAgentInProject, RequesterIdentity } from "@/server/utils/shared-authorization.utils"; -import agentService from "@/server/services/agent.service"; -import podService from "@/server/services/pod.service"; -import eventService from "@/server/services/event.service"; +'use server'; +import { SuccessActionResult } from '@/shared/model/server-action-error-return.model'; +import { getAuthUserSession, simpleAction, isAuthorizedReadForWorkload, isAuthorizedWriteForWorkload } from '@/server/utils/action-wrapper.utils'; +import { ensureDeleteAgentInProject, RequesterIdentity } from '@/server/utils/shared-authorization.utils'; +import agentService from '@/server/services/agent.service'; +import podService from '@/server/services/pod.service'; +import eventService from '@/server/services/event.service'; export const deployAgent = async (agentId: string, forceBuild = false) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); - const deploymentId = await agentService.deploy(agentId, forceBuild); - return new SuccessActionResult({ deploymentId }, 'Successfully started agent deployment.'); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); + const deploymentId = await agentService.deploy(agentId, forceBuild); + return new SuccessActionResult({ deploymentId }, 'Successfully started agent deployment.'); + }); export const deleteAgent = async (agentId: string) => - simpleAction(async () => { - const session = await getAuthUserSession(); - const identity: RequesterIdentity = { type: 'session', session }; - const agent = await agentService.getById(agentId); - ensureDeleteAgentInProject(identity, agent.projectId); - await agentService.deleteById(agentId); - }); + simpleAction(async () => { + const session = await getAuthUserSession(); + const identity: RequesterIdentity = { type: 'session', session }; + const agent = await agentService.getById(agentId); + ensureDeleteAgentInProject(identity, agent.projectId); + await agentService.deleteById(agentId); + }); export const getPodsForAgent = async (agentId: string) => - simpleAction(async () => { - await isAuthorizedReadForWorkload(agentId); - const agent = await agentService.getById(agentId); - return podService.getPodsForAgent(agent.projectId, agentId); - }); + simpleAction(async () => { + await isAuthorizedReadForWorkload(agentId); + const agent = await agentService.getById(agentId); + return podService.getPodsForAgent(agent.projectId, agentId); + }); export const getAgentEvents = async (agentId: string) => - simpleAction(async () => { - await isAuthorizedReadForWorkload(agentId); - const agent = await agentService.getById(agentId); - return eventService.getEventsForAgent(agent.projectId, agentId); - }); + simpleAction(async () => { + await isAuthorizedReadForWorkload(agentId); + const agent = await agentService.getById(agentId); + return eventService.getEventsForAgent(agent.projectId, agentId); + }); diff --git a/src/app/project/[projectId]/agents/details/agent-drawer.tsx b/src/app/project/[projectId]/agents/details/agent-drawer.tsx index 7f262d20..33b22fa3 100644 --- a/src/app/project/[projectId]/agents/details/agent-drawer.tsx +++ b/src/app/project/[projectId]/agents/details/agent-drawer.tsx @@ -1,29 +1,11 @@ 'use client'; import { type Ref } from 'react'; -import { - Bot, - Hammer, - Pencil, - Settings, - X, -} from 'lucide-react'; +import { Bot, Hammer, Pencil, Settings, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, -} from '@/components/ui/drawer'; +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '@/components/ui/empty'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useDialog } from '@/frontend/states/zustand.states'; import { DrawerTabScrollArea } from '@/app/project/workload-components/drawer/drawer-tab-scroll-area'; @@ -36,188 +18,156 @@ import AgentSandboxDrawerSettings from './agent-sandbox-drawer-settings'; import type { AgentExtendedModel } from '@/shared/model/agent-extended.model'; import { RolePermissionEnum } from '@/shared/model/role-extended.model'; import type { AgentSandboxTemplateInfo } from '@/shared/model/agent-sandbox-template-info.model'; -import { - AgentDrawerNavigationUtils, - type AgentDrawerTab, -} from '@/shared/utils/agent-drawer-navigation.utils'; +import { AgentDrawerNavigationUtils, type AgentDrawerTab } from '@/shared/utils/agent-drawer-navigation.utils'; import { formatDateTime } from '@/frontend/utils/format.utils'; export function AgentSandboxDrawer({ - contentRef, - agent, - role, - templateInfo, - storageClasses, - runtimeClasses, - open, - onOpenChange, - onOpenChangeComplete, - requestedTab, - onTabChange, + contentRef, + agent, + role, + templateInfo, + storageClasses, + runtimeClasses, + open, + onOpenChange, + onOpenChangeComplete, + requestedTab, + onTabChange }: { - contentRef?: Ref; - agent?: AgentExtendedModel; - role?: RolePermissionEnum; - templateInfo?: AgentSandboxTemplateInfo; - storageClasses: string[]; - runtimeClasses: string[]; - open: boolean; - onOpenChange: (open: boolean) => void; - onOpenChangeComplete: (open: boolean) => void; - requestedTab?: string | null; - onTabChange: (tab: AgentDrawerTab) => void; + contentRef?: Ref; + agent?: AgentExtendedModel; + role?: RolePermissionEnum; + templateInfo?: AgentSandboxTemplateInfo; + storageClasses: string[]; + runtimeClasses: string[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onOpenChangeComplete: (open: boolean) => void; + requestedTab?: string | null; + onTabChange: (tab: AgentDrawerTab) => void; }) { - const { openDialog } = useDialog(); - const readonly = role !== RolePermissionEnum.READWRITE; - const hasGitSource = agent?.sourceType === 'GIT' || agent?.sourceType === 'GIT_SSH'; - const activeTab = AgentDrawerNavigationUtils.resolveTab(requestedTab, { readonly, hasGitSource: !!hasGitSource }); + const { openDialog } = useDialog(); + const readonly = role !== RolePermissionEnum.READWRITE; + const hasGitSource = agent?.sourceType === 'GIT' || agent?.sourceType === 'GIT_SSH'; + const activeTab = AgentDrawerNavigationUtils.resolveTab(requestedTab, { readonly, hasGitSource: !!hasGitSource }); - const handleTabChange = (tab: string) => { - onTabChange(AgentDrawerNavigationUtils.resolveTab(tab, { readonly, hasGitSource: !!hasGitSource })); - }; + const handleTabChange = (tab: string) => { + onTabChange(AgentDrawerNavigationUtils.resolveTab(tab, { readonly, hasGitSource: !!hasGitSource })); + }; - const openRenameDialog = () => { - if (!agent) return; - openDialog(, { maxWidth: 'max-w-md' }); - }; + const openRenameDialog = () => { + if (!agent) return; + openDialog(, { maxWidth: 'max-w-md' }); + }; - return ( - - - - + + +
+
+ +
+
+ + {agent?.name ?? 'Agent Sandbox'} + {agent && role === RolePermissionEnum.READWRITE && ( + - - -
-
- -
-
- - {agent?.name ?? 'Agent Sandbox'} - {agent && role === RolePermissionEnum.READWRITE && ( - - )} - - - Agent Sandbox{agent ? ` · ${agent.project.name}` : ''} - {templateInfo?.lastDeployedAt - ? ` · Last deployed ${formatDateTime(templateInfo.lastDeployedAt)}` - : ''} - -
- {agent && ( -
- -
- )} -
- {agent && role ? ( - - - - - Sandboxes - - {!readonly && hasGitSource && ( - - - Builds - - )} - {!readonly && ( - - - Configuration - - )} - - - ) : ( -
- )} -
- {agent && role ? ( - <> - - - - - - {!readonly && hasGitSource && ( - - - - - - )} - {!readonly && ( - - - - - - )} - - ) : ( - - - - - - Agent Sandbox unavailable - - This Agent Sandbox could not be loaded. It may have been deleted. - - - - )} -
- - - - ); + size="icon-sm" + className="shrink-0 opacity-0 transition-opacity group-hover/title:opacity-100 focus-visible:opacity-100" + onClick={openRenameDialog} + > + + Rename Agent Sandbox + + )} +
+ + Agent Sandbox{agent ? ` · ${agent.project.name}` : ''} + {templateInfo?.lastDeployedAt ? ` · Last deployed ${formatDateTime(templateInfo.lastDeployedAt)}` : ''} + +
+ {agent && ( +
+ +
+ )} +
+ {agent && role ? ( + + + + + Sandboxes + + {!readonly && hasGitSource && ( + + + Builds + + )} + {!readonly && ( + + + Configuration + + )} + + + ) : ( +
+ )} +
+ {agent && role ? ( + <> + + + + + + {!readonly && hasGitSource && ( + + + + + + )} + {!readonly && ( + + + + + + )} + + ) : ( + + + + + + Agent Sandbox unavailable + This Agent Sandbox could not be loaded. It may have been deleted. + + + )} +
+
+
+
+ ); } diff --git a/src/app/project/[projectId]/agents/details/agent-sandbox-drawer-settings.tsx b/src/app/project/[projectId]/agents/details/agent-sandbox-drawer-settings.tsx index 41d3fcb3..70d229fb 100644 --- a/src/app/project/[projectId]/agents/details/agent-sandbox-drawer-settings.tsx +++ b/src/app/project/[projectId]/agents/details/agent-sandbox-drawer-settings.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - Boxes, - Container, - HardDrive, - KeyRound, - Network, -} from 'lucide-react'; +import { Boxes, Container, HardDrive, KeyRound, Network } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { SettingsSection } from '@/app/project/workload-components/drawer/settings-section'; import { useNestedDrawer } from '@/app/project/workload-components/drawer/nested-drawer'; @@ -26,133 +20,109 @@ import type { AgentExtendedModel } from '@/shared/model/agent-extended.model'; import { DrawerCard, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; export default function AgentSandboxDrawerSettings({ - agent, - readonly, - storageClasses, - runtimeClasses, + agent, + readonly, + storageClasses, + runtimeClasses }: { - agent: AgentExtendedModel; - readonly: boolean; - storageClasses: string[]; - runtimeClasses: string[]; + agent: AgentExtendedModel; + readonly: boolean; + storageClasses: string[]; + runtimeClasses: string[]; }) { - const { openNestedDrawer } = useNestedDrawer(); + const { openNestedDrawer } = useNestedDrawer(); - return ( -
- - - - openNestedDrawer({ - title: 'LLM Gateway configuration', - description: 'Configure the LLM gateway and model aliases for this agent.', - content: , - }) - } - /> - + return ( +
+ + + + openNestedDrawer({ + title: 'LLM Gateway configuration', + description: 'Configure the LLM gateway and model aliases for this agent.', + content: + }) + } + /> + - - - - - Advanced Container Configuration - Configure the container command, arguments, isolation and other advanced settings. - - - - - - - - Health Checks - Configure the health to determine when the Agent Sandbox is ready. - - - + + + + + Advanced Container Configuration + Configure the container command, arguments, isolation and other advanced settings. + + + + + + + + Health Checks + Configure the health to determine when the Agent Sandbox is ready. + + + + + + - - - + + + - - - + + + + - - - - - - - - - - Network Policies - Configure the policies to control the traffic from the Agent Sandbox. - - - - - - - -
- ); + + + + + Network Policies + Configure the policies to control the traffic from the Agent Sandbox. + + + + + + +
+ ); } diff --git a/src/app/project/[projectId]/agents/details/agent-sandbox-status-actions.tsx b/src/app/project/[projectId]/agents/details/agent-sandbox-status-actions.tsx index e7e73667..1fb6a57a 100644 --- a/src/app/project/[projectId]/agents/details/agent-sandbox-status-actions.tsx +++ b/src/app/project/[projectId]/agents/details/agent-sandbox-status-actions.tsx @@ -2,66 +2,46 @@ import { Hammer, Rocket } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Toast } from '@/frontend/utils/toast.utils'; import { deployAgent } from './agent-actions'; import type { AgentExtendedModel } from '@/shared/model/agent-extended.model'; -export function AgentSandboxStatusActions({ - agent, - readonly, -}: { - agent: AgentExtendedModel; - readonly: boolean; -}) { +export function AgentSandboxStatusActions({ agent, readonly }: { agent: AgentExtendedModel; readonly: boolean }) { + if (readonly) { + return null; + } - if (readonly) { return null; } + const supportsRebuild = agent.sourceType === 'GIT' || agent.sourceType === 'GIT_SSH'; - const supportsRebuild = agent.sourceType === 'GIT' || agent.sourceType === 'GIT_SSH'; - - return ( - -
- - void Toast.fromAction(() => deployAgent(agent.id))} - > - - Deploy - - } - /> - Deploy configuration - - {supportsRebuild && ( - - void Toast.fromAction(() => deployAgent(agent.id, true))} - > - - Rebuild - - } - /> - Rebuild - - )} -
-
- ); + return ( + +
+ + void Toast.fromAction(() => deployAgent(agent.id))}> + + Deploy + + } + /> + Deploy configuration + + {supportsRebuild && ( + + void Toast.fromAction(() => deployAgent(agent.id, true))}> + + Rebuild + + } + /> + Rebuild + + )} +
+
+ ); } diff --git a/src/app/project/[projectId]/agents/details/builds/agent-builds-grid.tsx b/src/app/project/[projectId]/agents/details/builds/agent-builds-grid.tsx index c92e7b84..be8b8e9d 100644 --- a/src/app/project/[projectId]/agents/details/builds/agent-builds-grid.tsx +++ b/src/app/project/[projectId]/agents/details/builds/agent-builds-grid.tsx @@ -1,28 +1,10 @@ 'use client'; import { useCallback, useState } from 'react'; -import { - Boxes, - EllipsisVertical, - File, - GitCommit, - Hammer, - LucideTerminal, -} from 'lucide-react'; +import { Boxes, EllipsisVertical, File, GitCommit, Hammer, LucideTerminal } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '@/components/ui/empty'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; import { Item, ItemContent, ItemTitle } from '@/components/ui/item'; import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; import ShortCommitHash from '@/components/custom/short-commit-hash'; @@ -38,162 +20,142 @@ import { appBuildMethodLabels } from '@/shared/model/app-source-info.model'; import type { BuildJobModel } from '@/shared/model/build-job'; export function AgentBuildsGrid({ agentId }: { agentId: string }) { - const { openConfirmDialog } = useConfirmDialog(); - const { openNestedDrawer } = useNestedDrawer(); - const [builds, setBuilds] = useState(); + const { openConfirmDialog } = useConfirmDialog(); + const { openNestedDrawer } = useNestedDrawer(); + const [builds, setBuilds] = useState(); - const updateBuilds = useCallback(async () => { - const response = await getWorkloadBuildsAction({ - workloadId: agentId, - workloadType: 'agent', - }); - if (response.status === 'success') { - setBuilds(response.data ?? []); - } - }, [agentId]); + const updateBuilds = useCallback(async () => { + const response = await getWorkloadBuildsAction({ workloadId: agentId, workloadType: 'agent' }); + if (response.status === 'success') { + setBuilds(response.data ?? []); + } + }, [agentId]); + + usePolling(updateBuilds, { intervalMs: 10000, runImmediately: true }); - usePolling(updateBuilds, { - intervalMs: 10000, - runImmediately: true, + const stopBuild = async (build: BuildJobModel) => { + const confirmed = await openConfirmDialog({ + title: 'Stop Build', + description: 'The build will be stopped and removed. Are you sure you want to stop this build?', + okButton: 'Stop & Remove Build' }); + if (!confirmed) return; - const stopBuild = async (build: BuildJobModel) => { - const confirmed = await openConfirmDialog({ - title: 'Stop Build', - description: 'The build will be stopped and removed. Are you sure you want to stop this build?', - okButton: 'Stop & Remove Build', - }); - if (!confirmed) return; + await Toast.fromAction(() => deleteWorkloadBuildAction(build.name, { workloadId: agentId, workloadType: 'agent' })); + await updateBuilds(); + }; - await Toast.fromAction(() => deleteWorkloadBuildAction(build.name, { - workloadId: agentId, - workloadType: 'agent', - })); - await updateBuilds(); - }; + const showLogs = (build: BuildJobModel) => { + openNestedDrawer({ + title: 'Build Logs', + description: `View the logs for the build started ${formatDateTime(build.startTime)}.`, + content: ( + + ) + }); + }; - const showLogs = (build: BuildJobModel) => { - openNestedDrawer({ - title: 'Build Logs', - description: `View the logs for the build started ${formatDateTime(build.startTime)}.`, - content: ( - - ), - }); - }; + if (!builds) { + return ( +
+
Latest Builds
+ +
+ ); + } - if (!builds) { - return ( -
-
Latest Builds
- -
- ); - } + if (builds.length === 0) { + return ( +
+
Latest Builds
+ + + + + + No builds available + This Agent Sandbox has no builds yet. + + +
+ ); + } - if (builds.length === 0) { - return ( -
-
Latest Builds
- - - - - - No builds available - - This Agent Sandbox has no builds yet. - - - + return ( +
+
Latest Builds
+
+ {builds.map((build) => ( + +
+ + + {build.status} + + {build.gitCommitMessage && ( +
+ {build.gitCommitMessage.length > 200 ? `${build.gitCommitMessage.slice(0, 200)}…` : build.gitCommitMessage} +
+ )} +
+ + {(build.status === 'RUNNING' || build.status === 'PENDING') && ( + + + + Build actions + + } + /> + + void stopBuild(build)}> + Stop Build + + + + )}
- ); - } - - return ( -
-
Latest Builds
-
- {builds.map(build => ( - -
- - - {build.status} - - {build.gitCommitMessage && ( -
- {build.gitCommitMessage.length > 200 - ? `${build.gitCommitMessage.slice(0, 200)}…` - : build.gitCommitMessage} -
- )} -
- - {(build.status === 'RUNNING' || build.status === 'PENDING') && ( - - - - Build actions - } /> - - void stopBuild(build)} - > - Stop Build - - - - )} -
-
- {build.gitCommit &&
- - {build.gitCommit} -
} - {build.gitCommit && } - {build.buildMethod && <> - - {build.buildMethod === 'DOCKERFILE' && } - {build.buildMethod === 'FRAMEWORK' && } - {appBuildMethodLabels[build.buildMethod] ?? '-'} - - - } - {formatDateTime(build.startTime)} -
-
- ))} +
+ {build.gitCommit && ( +
+ + {build.gitCommit} +
+ )} + {build.gitCommit && } + {build.buildMethod && ( + <> + + {build.buildMethod === 'DOCKERFILE' && } + {build.buildMethod === 'FRAMEWORK' && } + {appBuildMethodLabels[build.buildMethod] ?? '-'} + + + + )} + {formatDateTime(build.startTime)}
-
- ); + + ))} +
+
+ ); } diff --git a/src/app/project/[projectId]/agents/details/configuration/actions.ts b/src/app/project/[projectId]/agents/details/configuration/actions.ts index 8c32fb95..2ff04124 100644 --- a/src/app/project/[projectId]/agents/details/configuration/actions.ts +++ b/src/app/project/[projectId]/agents/details/configuration/actions.ts @@ -1,319 +1,283 @@ -'use server' +'use server'; -import agentService from "@/server/services/agent.service"; -import agentEnvironmentVariableService from "@/server/services/agent-environment-variable.service"; -import { isAuthorizedReadForWorkload, isAuthorizedWriteForWorkload, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; +import agentService from '@/server/services/agent.service'; +import agentEnvironmentVariableService from '@/server/services/agent-environment-variable.service'; +import { isAuthorizedReadForWorkload, isAuthorizedWriteForWorkload, saveFormAction, simpleAction } from '@/server/utils/action-wrapper.utils'; import { - agentModelConfigurationZodModel, - AgentModelConfigurationModel, - agentSourceInfoContainerZodModel, - agentSourceInfoGitSshZodModel, - agentSourceInfoGitZodModel, - agentGitBranchesLookupZodModel, - agentDockerfileDetectionZodModel, - AgentSourceInfoInputModel, - AgentGitBranchesLookupModel, - AgentDockerfileDetectionModel, - agentRateLimitsZodModel, - AgentRateLimitsModel, - agentEnvVarEditZodModel, - AgentEnvVarEditModel, - agentContainerConfigZodModel, - AgentContainerConfigModel, - isQuickStackReservedEnvName, -} from "@/shared/model/agent-config.model"; -import { AgentVolumeEditModel, agentVolumeEditZodModel } from "@/shared/model/volume-edit.model"; -import agentVolumeService from "@/server/services/agent-volume.service"; -import agentNetworkPolicyService from "@/server/services/agent-network-policy.service"; -import projectService from "@/server/services/project.service"; + agentModelConfigurationZodModel, + AgentModelConfigurationModel, + agentSourceInfoContainerZodModel, + agentSourceInfoGitSshZodModel, + agentSourceInfoGitZodModel, + agentGitBranchesLookupZodModel, + agentDockerfileDetectionZodModel, + AgentSourceInfoInputModel, + AgentGitBranchesLookupModel, + AgentDockerfileDetectionModel, + agentRateLimitsZodModel, + AgentRateLimitsModel, + agentEnvVarEditZodModel, + AgentEnvVarEditModel, + agentContainerConfigZodModel, + AgentContainerConfigModel, + isQuickStackReservedEnvName +} from '@/shared/model/agent-config.model'; +import { AgentVolumeEditModel, agentVolumeEditZodModel } from '@/shared/model/volume-edit.model'; +import agentVolumeService from '@/server/services/agent-volume.service'; +import agentNetworkPolicyService from '@/server/services/agent-network-policy.service'; +import projectService from '@/server/services/project.service'; import { - AgentNetworkPolicyEgressRuleEditModel, - agentNetworkPolicyEgressRuleEditZodModel, - AgentNetworkPolicySettingsModel, - agentNetworkPolicySettingsZodModel, -} from "@/shared/model/agent-network-policy-edit.model"; -import { FormValidationException } from "@/shared/model/form-validation-exception.model"; -import { ServiceException } from "@/shared/model/service.exception.model"; -import agentGitSshKeyService from "@/server/services/agent-git-ssh-key.service"; -import gitService from "@/server/services/git.service"; -import { ContainerCommangArgsUtils } from "@/shared/utils/container-command-args.utils"; -import clusterService from "@/server/services/cluster.service"; -import { z } from "zod"; -import { HealthCheckModel, healthCheckZodModel } from "@/shared/model/health-check.model"; - + AgentNetworkPolicyEgressRuleEditModel, + agentNetworkPolicyEgressRuleEditZodModel, + AgentNetworkPolicySettingsModel, + agentNetworkPolicySettingsZodModel +} from '@/shared/model/agent-network-policy-edit.model'; +import { FormValidationException } from '@/shared/model/form-validation-exception.model'; +import { ServiceException } from '@/shared/model/service.exception.model'; +import agentGitSshKeyService from '@/server/services/agent-git-ssh-key.service'; +import gitService from '@/server/services/git.service'; +import { ContainerCommangArgsUtils } from '@/shared/utils/container-command-args.utils'; +import clusterService from '@/server/services/cluster.service'; +import { z } from 'zod'; +import { HealthCheckModel, healthCheckZodModel } from '@/shared/model/health-check.model'; export const saveAgentModelConfiguration = async (prevState: any, inputData: AgentModelConfigurationModel, agentId: string) => - saveFormAction(inputData, agentModelConfigurationZodModel, async (validatedData) => { - await isAuthorizedWriteForWorkload(agentId); - await agentService.saveAgent({ - ...validatedData, - id: agentId - }); - }); + saveFormAction(inputData, agentModelConfigurationZodModel, async (validatedData) => { + await isAuthorizedWriteForWorkload(agentId); + await agentService.saveAgent({ ...validatedData, id: agentId }); + }); export const saveAgentSource = async (prevState: any, inputData: AgentSourceInfoInputModel, agentId: string) => { - return simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); + return simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); - if (inputData.sourceType === 'GIT') { - const validatedFields = agentSourceInfoGitZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); - } - const validatedData = validatedFields.data; - await agentService.saveAgent({ - ...validatedData, - buildMethod: 'DOCKERFILE', - containerImageSource: null, - containerRegistryUsername: null, - containerRegistryPassword: null, - sourceType: 'GIT', - id: agentId, - }); - return; - } + if (inputData.sourceType === 'GIT') { + const validatedFields = agentSourceInfoGitZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); + } + const validatedData = validatedFields.data; + await agentService.saveAgent({ + ...validatedData, + buildMethod: 'DOCKERFILE', + containerImageSource: null, + containerRegistryUsername: null, + containerRegistryPassword: null, + sourceType: 'GIT', + id: agentId + }); + return; + } - if (inputData.sourceType === 'GIT_SSH') { - const validatedFields = agentSourceInfoGitSshZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); - } - const publicKey = await agentGitSshKeyService.getPublicKey(agentId); - if (!publicKey) { - throw new ServiceException('Generate SSH keys before saving a Git SSH source.'); - } - const validatedData = validatedFields.data; - await agentService.saveAgent({ - ...validatedData, - buildMethod: 'DOCKERFILE', - gitUsername: null, - gitToken: null, - containerImageSource: null, - containerRegistryUsername: null, - containerRegistryPassword: null, - sourceType: 'GIT_SSH', - id: agentId, - }); - return; - } + if (inputData.sourceType === 'GIT_SSH') { + const validatedFields = agentSourceInfoGitSshZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); + } + const publicKey = await agentGitSshKeyService.getPublicKey(agentId); + if (!publicKey) { + throw new ServiceException('Generate SSH keys before saving a Git SSH source.'); + } + const validatedData = validatedFields.data; + await agentService.saveAgent({ + ...validatedData, + buildMethod: 'DOCKERFILE', + gitUsername: null, + gitToken: null, + containerImageSource: null, + containerRegistryUsername: null, + containerRegistryPassword: null, + sourceType: 'GIT_SSH', + id: agentId + }); + return; + } - if (inputData.sourceType === 'CONTAINER') { - const validatedFields = agentSourceInfoContainerZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); - } - const validatedData = validatedFields.data; - await agentService.saveAgent({ - ...validatedData, - containerRegistryUsername: validatedData.containerRegistryUsername || null, - containerRegistryPassword: validatedData.containerRegistryPassword || null, - gitUrl: null, - gitBranch: null, - gitUsername: null, - gitToken: null, - sourceType: 'CONTAINER', - buildMethod: 'DOCKERFILE', - id: agentId, - }); - return; - } + if (inputData.sourceType === 'CONTAINER') { + const validatedFields = agentSourceInfoContainerZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); + } + const validatedData = validatedFields.data; + await agentService.saveAgent({ + ...validatedData, + containerRegistryUsername: validatedData.containerRegistryUsername || null, + containerRegistryPassword: validatedData.containerRegistryPassword || null, + gitUrl: null, + gitBranch: null, + gitUsername: null, + gitToken: null, + sourceType: 'CONTAINER', + buildMethod: 'DOCKERFILE', + id: agentId + }); + return; + } - throw new ServiceException('Invalid Source Type'); - }); + throw new ServiceException('Invalid Source Type'); + }); }; export const ensureAgentGitSshPublicKey = async (agentId: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); - return await agentGitSshKeyService.ensurePublicKey(agentId); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); + return await agentGitSshKeyService.ensurePublicKey(agentId); + }); export const generateOrRegenerateAgentGitSshKey = async (agentId: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); - return await agentGitSshKeyService.generateOrRegenerate(agentId); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); + return await agentGitSshKeyService.generateOrRegenerate(agentId); + }); export const getAgentGitBranches = async (agentId: string, inputData: AgentGitBranchesLookupModel) => - simpleAction(async () => { - const validatedFields = agentGitBranchesLookupZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please make sure that you entered the correct Git credentials.', validatedFields.error.flatten().fieldErrors); - } - await isAuthorizedWriteForWorkload(agentId); - return await gitService.listRemoteBranches({ - id: agentId, - workloadType: 'agent', - ...validatedFields.data, - }); - }); + simpleAction(async () => { + const validatedFields = agentGitBranchesLookupZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please make sure that you entered the correct Git credentials.', validatedFields.error.flatten().fieldErrors); + } + await isAuthorizedWriteForWorkload(agentId); + return await gitService.listRemoteBranches({ id: agentId, workloadType: 'agent', ...validatedFields.data }); + }); export const detectAgentDockerfilePath = async (agentId: string, inputData: AgentDockerfileDetectionModel) => - simpleAction(async () => { - const validatedFields = agentDockerfileDetectionZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please make sure that you entered the correct Git source information.', validatedFields.error.flatten().fieldErrors); - } + simpleAction(async () => { + const validatedFields = agentDockerfileDetectionZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please make sure that you entered the correct Git source information.', validatedFields.error.flatten().fieldErrors); + } - await isAuthorizedWriteForWorkload(agentId); - return await gitService.detectDockerfilePath({ - id: agentId, - workloadType: 'agent', - ...validatedFields.data, - }); - }); + await isAuthorizedWriteForWorkload(agentId); + return await gitService.detectDockerfilePath({ id: agentId, workloadType: 'agent', ...validatedFields.data }); + }); export const saveAgentRateLimits = async (prevState: any, inputData: AgentRateLimitsModel, agentId: string) => - saveFormAction(inputData, agentRateLimitsZodModel, async (validatedData) => { - await isAuthorizedWriteForWorkload(agentId); - await agentService.saveAgent({ - id: agentId, - ...validatedData - }); - }); + saveFormAction(inputData, agentRateLimitsZodModel, async (validatedData) => { + await isAuthorizedWriteForWorkload(agentId); + await agentService.saveAgent({ id: agentId, ...validatedData }); + }); export const saveAgentContainerConfig = async (prevState: any, inputData: AgentContainerConfigModel, agentId: string) => - saveFormAction(inputData, agentContainerConfigZodModel, async (validatedData) => { - await isAuthorizedWriteForWorkload(agentId); - if (validatedData.runtimeClassName) { - const runtimeClasses = await clusterService.getRuntimeClasses(); - if (!runtimeClasses.includes(validatedData.runtimeClassName)) { - throw new FormValidationException('The selected RuntimeClass is no longer available in the cluster.', { - runtimeClassName: ['Select an available RuntimeClass.'], - }); - } - } - await agentService.saveAgent({ - ...validatedData, - containerCommand: ContainerCommangArgsUtils.serializeContainerCommandItems(validatedData.containerCommand), - containerArgs: ContainerCommangArgsUtils.serializeContainerCommandItems(validatedData.containerArgs), - id: agentId, + saveFormAction(inputData, agentContainerConfigZodModel, async (validatedData) => { + await isAuthorizedWriteForWorkload(agentId); + if (validatedData.runtimeClassName) { + const runtimeClasses = await clusterService.getRuntimeClasses(); + if (!runtimeClasses.includes(validatedData.runtimeClassName)) { + throw new FormValidationException('The selected RuntimeClass is no longer available in the cluster.', { + runtimeClassName: ['Select an available RuntimeClass.'] }); + } + } + await agentService.saveAgent({ + ...validatedData, + containerCommand: ContainerCommangArgsUtils.serializeContainerCommandItems(validatedData.containerCommand), + containerArgs: ContainerCommangArgsUtils.serializeContainerCommandItems(validatedData.containerArgs), + id: agentId }); + }); export const saveAgentHealthCheck = async (prevState: any, inputData: HealthCheckModel) => - saveFormAction(inputData, healthCheckZodModel, async (validatedData) => { - await isAuthorizedWriteForWorkload(validatedData.workloadId); + saveFormAction(inputData, healthCheckZodModel, async (validatedData) => { + await isAuthorizedWriteForWorkload(validatedData.workloadId); - let updateData = { - healthCheckPeriodSeconds: validatedData.periodSeconds, - healthCheckTimeoutSeconds: validatedData.timeoutSeconds, - healthCheckFailureThreshold: validatedData.failureThreshold, - }; + let updateData = { + healthCheckPeriodSeconds: validatedData.periodSeconds, + healthCheckTimeoutSeconds: validatedData.timeoutSeconds, + healthCheckFailureThreshold: validatedData.failureThreshold + }; - if (validatedData.enabled && validatedData.probeType === 'HTTP') { - await agentService.saveAgent({ - ...updateData, - healthChechHttpGetPath: validatedData.path || null, - healthCheckHttpPort: validatedData.httpPort || null, - healthCheckHttpScheme: validatedData.scheme || null, - healthCheckHttpHeadersJson: validatedData.headers && validatedData.headers.length > 0 - ? JSON.stringify(validatedData.headers) - : null, - healthCheckTcpPort: null, - id: validatedData.workloadId, - }); - return; - } + if (validatedData.enabled && validatedData.probeType === 'HTTP') { + await agentService.saveAgent({ + ...updateData, + healthChechHttpGetPath: validatedData.path || null, + healthCheckHttpPort: validatedData.httpPort || null, + healthCheckHttpScheme: validatedData.scheme || null, + healthCheckHttpHeadersJson: validatedData.headers && validatedData.headers.length > 0 ? JSON.stringify(validatedData.headers) : null, + healthCheckTcpPort: null, + id: validatedData.workloadId + }); + return; + } - if (validatedData.enabled && validatedData.probeType === 'TCP') { - await agentService.saveAgent({ - ...updateData, - healthCheckTcpPort: validatedData.tcpPort || null, - healthChechHttpGetPath: null, - healthCheckHttpPort: null, - healthCheckHttpScheme: null, - healthCheckHttpHeadersJson: null, - id: validatedData.workloadId, - }); - return; - } + if (validatedData.enabled && validatedData.probeType === 'TCP') { + await agentService.saveAgent({ + ...updateData, + healthCheckTcpPort: validatedData.tcpPort || null, + healthChechHttpGetPath: null, + healthCheckHttpPort: null, + healthCheckHttpScheme: null, + healthCheckHttpHeadersJson: null, + id: validatedData.workloadId + }); + return; + } - await agentService.saveAgent({ - ...updateData, - healthChechHttpGetPath: null, - healthCheckHttpPort: null, - healthCheckHttpScheme: null, - healthCheckHttpHeadersJson: null, - healthCheckTcpPort: null, - id: validatedData.workloadId, - }); + await agentService.saveAgent({ + ...updateData, + healthChechHttpGetPath: null, + healthCheckHttpPort: null, + healthCheckHttpScheme: null, + healthCheckHttpHeadersJson: null, + healthCheckTcpPort: null, + id: validatedData.workloadId }); + }); export const saveAgentEnvVar = async (prevState: any, inputData: AgentEnvVarEditModel, agentId: string) => - saveFormAction(inputData, agentEnvVarEditZodModel, async (validatedData) => { - await isAuthorizedWriteForWorkload(agentId); - if (isQuickStackReservedEnvName(validatedData.name)) { - throw new FormValidationException(`The environment variable name "${validatedData.name}" is reserved and cannot be used.`, { - name: [`The environment variable name "${validatedData.name}" is reserved and cannot be used.`], - }); - } - await agentEnvironmentVariableService.saveEnvironmentVariable(agentId, validatedData); - }); + saveFormAction(inputData, agentEnvVarEditZodModel, async (validatedData) => { + await isAuthorizedWriteForWorkload(agentId); + if (isQuickStackReservedEnvName(validatedData.name)) { + throw new FormValidationException(`The environment variable name "${validatedData.name}" is reserved and cannot be used.`, { + name: [`The environment variable name "${validatedData.name}" is reserved and cannot be used.`] + }); + } + await agentEnvironmentVariableService.saveEnvironmentVariable(agentId, validatedData); + }); export const deleteAgentEnvVar = async (agentId: string, name: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); - await agentEnvironmentVariableService.deleteEnvironmentVariable(agentId, name); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); + await agentEnvironmentVariableService.deleteEnvironmentVariable(agentId, name); + }); -const actionAgentVolumeEditZodModel = agentVolumeEditZodModel.merge(z.object({ - id: z.string().nullish(), -})); +const actionAgentVolumeEditZodModel = agentVolumeEditZodModel.merge(z.object({ id: z.string().nullish() })); export const saveAgentVolume = async (prevState: any, inputData: AgentVolumeEditModel & { id?: string }, agentId: string) => - saveFormAction(inputData, actionAgentVolumeEditZodModel, async (validatedData) => { - await isAuthorizedWriteForWorkload(agentId); - await agentVolumeService.saveVolume({ - ...validatedData, - agentId: agentId, - id: validatedData.id ?? undefined, - }); - }); + saveFormAction(inputData, actionAgentVolumeEditZodModel, async (validatedData) => { + await isAuthorizedWriteForWorkload(agentId); + await agentVolumeService.saveVolume({ ...validatedData, agentId: agentId, id: validatedData.id ?? undefined }); + }); export const deleteAgentVolume = async (volumeId: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(volumeId); - await agentVolumeService.deleteVolume(volumeId); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(volumeId); + await agentVolumeService.deleteVolume(volumeId); + }); export const saveAgentNetworkPolicySettings = async (prevState: any, inputData: AgentNetworkPolicySettingsModel, agentId: string) => - saveFormAction(inputData, agentNetworkPolicySettingsZodModel, async (validatedData) => { - await isAuthorizedWriteForWorkload(agentId); - await agentNetworkPolicyService.saveSettings({ - ...validatedData, - agentId, - }); - }); + saveFormAction(inputData, agentNetworkPolicySettingsZodModel, async (validatedData) => { + await isAuthorizedWriteForWorkload(agentId); + await agentNetworkPolicyService.saveSettings({ ...validatedData, agentId }); + }); -const actionAgentNetworkPolicyEgressRuleEditZodModel = agentNetworkPolicyEgressRuleEditZodModel.merge(z.object({ - id: z.string().nullish(), -})); +const actionAgentNetworkPolicyEgressRuleEditZodModel = agentNetworkPolicyEgressRuleEditZodModel.merge(z.object({ id: z.string().nullish() })); export const saveAgentNetworkPolicyEgressRule = async (prevState: any, inputData: AgentNetworkPolicyEgressRuleEditModel & { id?: string }, agentId: string) => - saveFormAction(inputData, actionAgentNetworkPolicyEgressRuleEditZodModel, async (validatedData) => { - const session = await isAuthorizedWriteForWorkload(agentId); - await agentNetworkPolicyService.saveEgressRule({ - ...validatedData, - agentId, - id: validatedData.id ?? undefined, - }, session); - }); + saveFormAction(inputData, actionAgentNetworkPolicyEgressRuleEditZodModel, async (validatedData) => { + const session = await isAuthorizedWriteForWorkload(agentId); + await agentNetworkPolicyService.saveEgressRule({ ...validatedData, agentId, id: validatedData.id ?? undefined }, session); + }); export const deleteAgentNetworkPolicyEgressRule = async (ruleId: string) => - simpleAction(async () => { - const rule = await agentNetworkPolicyService.getEgressRuleById(ruleId); - const session = await isAuthorizedWriteForWorkload(rule.agentNetworkPolicy.agentId); - await agentNetworkPolicyService.deleteEgressRule(ruleId, session); - }); + simpleAction(async () => { + const rule = await agentNetworkPolicyService.getEgressRuleById(ruleId); + const session = await isAuthorizedWriteForWorkload(rule.agentNetworkPolicy.agentId); + await agentNetworkPolicyService.deleteEgressRule(ruleId, session); + }); export const getAppsForAgentNetworkPolicy = async (agentId: string) => - simpleAction(async () => { - await isAuthorizedReadForWorkload(agentId); - const projects = await projectService.getAll(); - return projects.map((project) => ({ - id: project.id, - name: project.name, - apps: project.apps.map((app) => ({ id: app.id, name: app.name })), - })); - }); + simpleAction(async () => { + await isAuthorizedReadForWorkload(agentId); + const projects = await projectService.getAll(); + return projects.map((project) => ({ id: project.id, name: project.name, apps: project.apps.map((app) => ({ id: app.id, name: app.name })) })); + }); diff --git a/src/app/project/[projectId]/agents/details/configuration/agent-container-config-card.tsx b/src/app/project/[projectId]/agents/details/configuration/agent-container-config-card.tsx index 49827019..07f7b255 100644 --- a/src/app/project/[projectId]/agents/details/configuration/agent-container-config-card.tsx +++ b/src/app/project/[projectId]/agents/details/configuration/agent-container-config-card.tsx @@ -1,171 +1,155 @@ 'use client'; -import type { z } from "zod"; -import { useActionState, useEffect, useId } from "react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import ContainerCommandArgsFields from "@/components/custom/container-command-args-fields"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { NestedDrawerFooter } from "../../../../workload-components/drawer/nested-drawer"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { - agentContainerConfigZodModel, - AgentContainerConfigModel, -} from "@/shared/model/agent-config.model"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { saveAgentContainerConfig } from "./actions"; -import { ContainerCommangArgsUtils } from "@/shared/utils/container-command-args.utils"; +import type { z } from 'zod'; +import { useActionState, useEffect, useId } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import ContainerCommandArgsFields from '@/components/custom/container-command-args-fields'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { NestedDrawerFooter } from '../../../../workload-components/drawer/nested-drawer'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { agentContainerConfigZodModel, AgentContainerConfigModel } from '@/shared/model/agent-config.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { saveAgentContainerConfig } from './actions'; +import { ContainerCommangArgsUtils } from '@/shared/utils/container-command-args.utils'; const DEFAULT_RUNTIME_CLASS = '__quickstack_default__'; -export default function AgentContainerConfigCard({ agent, readonly, runtimeClasses }: { - agent: AgentExtendedModel; - readonly: boolean; - runtimeClasses: string[]; +export default function AgentContainerConfigCard({ + agent, + readonly, + runtimeClasses +}: { + agent: AgentExtendedModel; + readonly: boolean; + runtimeClasses: string[]; }) { - const formId = useId(); - const inputValue = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? value : ''; - const form = useForm, unknown, z.output>({ - resolver: zodResolver(agentContainerConfigZodModel), - defaultValues: { - containerCommand: ContainerCommangArgsUtils.parseStoredContainerCommandItems(agent.containerCommand), - containerArgs: agent.containerArgs - ? JSON.parse(agent.containerArgs).map((arg: string) => ({ value: arg })) - : [], - workingDir: agent.workingDir ?? '', - runtimeClassName: agent.runtimeClassName ?? '', - deployFileBrowser: agent.deployFileBrowser, - }, - disabled: readonly, - }); + const formId = useId(); + const inputValue = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? value : ''); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(agentContainerConfigZodModel), + defaultValues: { + containerCommand: ContainerCommangArgsUtils.parseStoredContainerCommandItems(agent.containerCommand), + containerArgs: agent.containerArgs ? JSON.parse(agent.containerArgs).map((arg: string) => ({ value: arg })) : [], + workingDir: agent.workingDir ?? '', + runtimeClassName: agent.runtimeClassName ?? '', + deployFileBrowser: agent.deployFileBrowser + }, + disabled: readonly + }); - const [state, formAction] = useActionState( - (state: ServerActionResult, payload: AgentContainerConfigModel) => - saveAgentContainerConfig(state, payload, agent.id), - FormUtils.getInitialFormState(), - ); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AgentContainerConfigModel) => saveAgentContainerConfig(state, payload, agent.id), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - toast.success('Container configuration saved. Click "Deploy" to apply changes.'); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [form, state]); + useEffect(() => { + if (state.status === 'success') { + toast.success('Container configuration saved. Click "Deploy" to apply changes.'); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [form, state]); - return ( -
- - form.handleSubmit((data) => formAction(data))()}> -
-
-
-

Runtime

-

- Leave command and arguments empty to use the QuickStack default container startup. -

-
- -
+ return ( + + + form.handleSubmit((data) => formAction(data))()}> +
+
+
+

Runtime

+

Leave command and arguments empty to use the QuickStack default container startup.

+
+ +
- ( - - Runtime Class - -

- Selects the Kubernetes RuntimeClass for new agent sandboxes. -

- -
- )} - /> + ( + + Runtime Class + +

Selects the Kubernetes RuntimeClass for new agent sandboxes.

+ +
+ )} + /> - ( - - Working Directory - - - - - - )} - /> + ( + + Working Directory + + + + + + )} + /> - ( - -
- Deploy File Browser -

- Adds a File Browser sidecar pod to each agent sandbox. -

-
- - - -
- )} - /> -
- {!readonly && ( - - Save - {state?.status === 'error' && !state?.errors && ( -

{state.message}

- )} -
- )} - -
- - ); + ( + +
+ Deploy File Browser +

Adds a File Browser sidecar pod to each agent sandbox.

+
+ + + +
+ )} + /> +
+ {!readonly && ( + + + Save + + {state?.status === 'error' && !state?.errors &&

{state.message}

} +
+ )} + +
+ + ); } diff --git a/src/app/project/[projectId]/agents/details/configuration/agent-rate-limits-card.tsx b/src/app/project/[projectId]/agents/details/configuration/agent-rate-limits-card.tsx index 6d3c1ed9..6b095c8c 100644 --- a/src/app/project/[projectId]/agents/details/configuration/agent-rate-limits-card.tsx +++ b/src/app/project/[projectId]/agents/details/configuration/agent-rate-limits-card.tsx @@ -1,140 +1,116 @@ 'use client'; -import type { z } from "zod"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from "@/components/custom/drawer-card"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { agentRateLimitsZodModel, AgentRateLimitsModel } from "@/shared/model/agent-config.model"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useActionState, useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { saveAgentRateLimits } from "./actions"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; +import type { z } from 'zod'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { agentRateLimitsZodModel, AgentRateLimitsModel } from '@/shared/model/agent-config.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useActionState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { saveAgentRateLimits } from './actions'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; -export default function AgentRateLimitsCard({ agent, readonly }: { - agent: AgentExtendedModel; - readonly: boolean; -}) { - const form = useForm, unknown, z.output>({ - resolver: zodResolver(agentRateLimitsZodModel), - defaultValues: { - cpuRequest: agent.cpuRequest ?? undefined, - cpuLimit: agent.cpuLimit ?? undefined, - memoryRequest: agent.memoryRequest ?? undefined, - memoryLimit: agent.memoryLimit ?? undefined, - }, - disabled: readonly, - }); +export default function AgentRateLimitsCard({ agent, readonly }: { agent: AgentExtendedModel; readonly: boolean }) { + const form = useForm, unknown, z.output>({ + resolver: zodResolver(agentRateLimitsZodModel), + defaultValues: { + cpuRequest: agent.cpuRequest ?? undefined, + cpuLimit: agent.cpuLimit ?? undefined, + memoryRequest: agent.memoryRequest ?? undefined, + memoryLimit: agent.memoryLimit ?? undefined + }, + disabled: readonly + }); - const [state, formAction] = useActionState( - (state: ServerActionResult, payload: AgentRateLimitsModel) => - saveAgentRateLimits(state, payload, agent.id), - FormUtils.getInitialFormState(), - ); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AgentRateLimitsModel) => saveAgentRateLimits(state, payload, agent.id), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - toast.success('Rate limits saved. Click "Deploy" to apply changes.'); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [form, state]); + useEffect(() => { + if (state.status === 'success') { + toast.success('Rate limits saved. Click "Deploy" to apply changes.'); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [form, state]); - return ( -
- form.handleSubmit((data) => formAction(data))()}> - - - Container Rate Limits - - Configure Kubernetes resource limits per sandbox container instance. - - - -
- ( - - CPU Request (m) - - - - - - )} - /> - ( - - CPU Limit (m) - - - - - - )} - /> - ( - - Memory Request (MB) - - - - - - )} - /> - ( - - Memory Limit (MB) - - - - - - )} - /> -
-
- {!readonly && ( - - Save - {state?.status === 'error' && !state?.errors && ( -

{state.message}

- )} -
- )} -
-
- - ); + return ( +
+ form.handleSubmit((data) => formAction(data))()}> + + + Container Rate Limits + Configure Kubernetes resource limits per sandbox container instance. + + +
+ ( + + CPU Request (m) + + + + + + )} + /> + ( + + CPU Limit (m) + + + + + + )} + /> + ( + + Memory Request (MB) + + + + + + )} + /> + ( + + Memory Limit (MB) + + + + + + )} + /> +
+
+ {!readonly && ( + + Save + {state?.status === 'error' && !state?.errors &&

{state.message}

} +
+ )} +
+
+ + ); } diff --git a/src/app/project/[projectId]/agents/details/environment/agent-env-var-edit-overlay.tsx b/src/app/project/[projectId]/agents/details/environment/agent-env-var-edit-overlay.tsx index 081967fd..25c14228 100644 --- a/src/app/project/[projectId]/agents/details/environment/agent-env-var-edit-overlay.tsx +++ b/src/app/project/[projectId]/agents/details/environment/agent-env-var-edit-overlay.tsx @@ -1,69 +1,83 @@ 'use client'; -import type { z } from "zod"; -import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { AgentEnvVarEditModel, agentEnvVarEditZodModel } from "@/shared/model/agent-config.model"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { saveAgentEnvVar } from "../configuration/actions"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useActionState, useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; +import type { z } from 'zod'; +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { AgentEnvVarEditModel, agentEnvVarEditZodModel } from '@/shared/model/agent-config.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { saveAgentEnvVar } from '../configuration/actions'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useActionState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; export default function AgentEnvVarEditOverlay({ agentId, existingName }: { agentId: string; existingName?: string }) { - const { closeDialog } = useDialogContext(); - const form = useForm, unknown, z.output>({ - resolver: zodResolver(agentEnvVarEditZodModel), - defaultValues: { name: existingName ?? '', value: '', originalName: existingName }, - }); - const [state, formAction] = useActionState( - (state: ServerActionResult, payload: AgentEnvVarEditModel) => saveAgentEnvVar(state, payload, agentId), - FormUtils.getInitialFormState(), - ); + const { closeDialog } = useDialogContext(); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(agentEnvVarEditZodModel), + defaultValues: { name: existingName ?? '', value: '', originalName: existingName } + }); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AgentEnvVarEditModel) => saveAgentEnvVar(state, payload, agentId), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - toast.success('Environment variable saved.', { description: 'Click "Deploy" to apply changes.' }); - closeDialog(); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [closeDialog, form, state]); + useEffect(() => { + if (state.status === 'success') { + toast.success('Environment variable saved.', { description: 'Click "Deploy" to apply changes.' }); + closeDialog(); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [closeDialog, form, state]); - return ( -
- form.handleSubmit((data) => formAction(data))()}> - - {existingName ? 'Edit Environment Variable' : 'Create Environment Variable'} - - {existingName - ? 'Update env value here to override existing value. For security reasons, existing value is not shown.' - : 'Values are encrypted and cannot be shown after saving.'} - - -
- ( - - Key - - - - )} /> - ( - - Value - - - - )} /> -

{state.status === 'error' && !state.errors ? state.message : ''}

-
- Save -
- - ); + return ( +
+ form.handleSubmit((data) => formAction(data))()}> + + {existingName ? 'Edit Environment Variable' : 'Create Environment Variable'} + + {existingName + ? 'Update env value here to override existing value. For security reasons, existing value is not shown.' + : 'Values are encrypted and cannot be shown after saving.'} + + +
+ ( + + Key + + + + + + )} + /> + ( + + Value + + + + + + )} + /> +

{state.status === 'error' && !state.errors ? state.message : ''}

+
+ + Save + +
+ + ); } diff --git a/src/app/project/[projectId]/agents/details/environment/agent-env-vars-card.tsx b/src/app/project/[projectId]/agents/details/environment/agent-env-vars-card.tsx index a7906e57..8bffe1dc 100644 --- a/src/app/project/[projectId]/agents/details/environment/agent-env-vars-card.tsx +++ b/src/app/project/[projectId]/agents/details/environment/agent-env-vars-card.tsx @@ -1,72 +1,90 @@ 'use client'; -import { Button } from "@/components/ui/button"; -import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from "@/components/custom/drawer-card"; -import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { EditIcon, Plus, TrashIcon } from "lucide-react"; -import { deleteAgentEnvVar } from "../configuration/actions"; -import AgentEnvVarEditOverlay from "./agent-env-var-edit-overlay"; +import { Button } from '@/components/ui/button'; +import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useConfirmDialog, useDialog } from '@/frontend/states/zustand.states'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { EditIcon, Plus, TrashIcon } from 'lucide-react'; +import { deleteAgentEnvVar } from '../configuration/actions'; +import AgentEnvVarEditOverlay from './agent-env-var-edit-overlay'; function getEnvironmentVariableNames(encryptedEnvVars: string | null | undefined): string[] { - if (!encryptedEnvVars) return []; - try { - const parsed = JSON.parse(encryptedEnvVars) as Array<{ name?: unknown }>; - return parsed.flatMap((envVar) => typeof envVar.name === 'string' ? [envVar.name] : []); - } catch { - return []; - } + if (!encryptedEnvVars) return []; + try { + const parsed = JSON.parse(encryptedEnvVars) as Array<{ name?: unknown }>; + return parsed.flatMap((envVar) => (typeof envVar.name === 'string' ? [envVar.name] : [])); + } catch { + return []; + } } export default function AgentEnvVarsCard({ agent, readonly }: { agent: AgentExtendedModel; readonly: boolean }) { - const { openConfirmDialog } = useConfirmDialog(); - const { openDialog } = useDialog(); - const environmentVariableNames = getEnvironmentVariableNames(agent.encryptedEnvVars); + const { openConfirmDialog } = useConfirmDialog(); + const { openDialog } = useDialog(); + const environmentVariableNames = getEnvironmentVariableNames(agent.encryptedEnvVars); - const openEditDialog = async (name?: string) => { - await openDialog(, { maxWidth: 'max-w-md' }); - }; - const deleteEnvironmentVariable = async (name: string) => { - const confirmed = await openConfirmDialog({ - title: 'Delete Environment Variable', - description: `Remove ${name}? This takes effect after deploying the agent.`, - okButton: 'Delete Environment Variable', - }); - if (confirmed) await Toast.fromAction(() => deleteAgentEnvVar(agent.id, name), 'Deleted Env Variable Successfully'); - }; + const openEditDialog = async (name?: string) => { + await openDialog(, { maxWidth: 'max-w-md' }); + }; + const deleteEnvironmentVariable = async (name: string) => { + const confirmed = await openConfirmDialog({ + title: 'Delete Environment Variable', + description: `Remove ${name}? This takes effect after deploying the agent.`, + okButton: 'Delete Environment Variable' + }); + if (confirmed) await Toast.fromAction(() => deleteAgentEnvVar(agent.id, name), 'Deleted Env Variable Successfully'); + }; - return ( - - - Environment Variables - - Values are encrypted at rest and never shown. Names starting with QS_ are reserved by QuickStack. - - - {environmentVariableNames.length > 0 && - - - {environmentVariableNames.length} Environment Variables - - Key - {!readonly && } - - - {environmentVariableNames.map((name) => - {name} - {!readonly && -
- - -
-
} -
)} -
-
-
} - {!readonly && } -
- ); + return ( + + + Environment Variables + + Values are encrypted at rest and never shown. Names starting with QS_ are reserved by + QuickStack. + + + {environmentVariableNames.length > 0 && ( + + + {environmentVariableNames.length} Environment Variables + + + Key + {!readonly && } + + + + {environmentVariableNames.map((name) => ( + + {name} + {!readonly && ( + +
+ + +
+
+ )} +
+ ))} +
+
+
+ )} + {!readonly && ( + + + + )} +
+ ); } diff --git a/src/app/project/[projectId]/agents/details/model/agent-model-configuration-card.tsx b/src/app/project/[projectId]/agents/details/model/agent-model-configuration-card.tsx index 5ffd7d0d..b36b93b6 100644 --- a/src/app/project/[projectId]/agents/details/model/agent-model-configuration-card.tsx +++ b/src/app/project/[projectId]/agents/details/model/agent-model-configuration-card.tsx @@ -1,48 +1,32 @@ 'use client'; import { Button } from '@/components/ui/button'; -import { - DrawerCard, - DrawerCardContent, - DrawerCardFooter, - DrawerCardHeader, - DrawerCardTitle, -} from '@/components/custom/drawer-card'; +import { DrawerCard, DrawerCardContent, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; import type { AgentExtendedModel } from '@/shared/model/agent-extended.model'; -export default function AgentModelConfigurationCard({ - agent, - readonly, - onEdit, -}: { - agent: AgentExtendedModel; - readonly: boolean; - onEdit: () => void; -}) { - return ( - - - LLM Gateway Configuration - - -
-

LLM Gateway

-

{agent.llmGateway.name}

-
-
-

Model Aliases

-

- {agent.modelAlias.length > 0 ? agent.modelAlias.join(', ') : 'Not configured'} -

-
-
- {!readonly && ( - - - - )} -
- ); +export default function AgentModelConfigurationCard({ agent, readonly, onEdit }: { agent: AgentExtendedModel; readonly: boolean; onEdit: () => void }) { + return ( + + + LLM Gateway Configuration + + +
+

LLM Gateway

+

{agent.llmGateway.name}

+
+
+

Model Aliases

+

{agent.modelAlias.length > 0 ? agent.modelAlias.join(', ') : 'Not configured'}

+
+
+ {!readonly && ( + + + + )} +
+ ); } diff --git a/src/app/project/[projectId]/agents/details/model/agent-model-configuration-editor.tsx b/src/app/project/[projectId]/agents/details/model/agent-model-configuration-editor.tsx index c12dda29..925248cf 100644 --- a/src/app/project/[projectId]/agents/details/model/agent-model-configuration-editor.tsx +++ b/src/app/project/[projectId]/agents/details/model/agent-model-configuration-editor.tsx @@ -1,172 +1,163 @@ 'use client'; -import type { z } from "zod"; -import { useActionState, useEffect, useState } from "react"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import SelectBox from "@/components/custom/multiselect-field"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { agentModelConfigurationZodModel, AgentModelConfigurationModel } from "@/shared/model/agent-config.model"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import { saveAgentModelConfiguration } from "../configuration/actions"; -import { getLlmGateways, getModelAliasesForGateway } from "@/app/project/[projectId]/actions"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { LlmGatewayModel } from "@/shared/model/llm-gateway.model"; -import { Loader2 } from "lucide-react"; +import type { z } from 'zod'; +import { useActionState, useEffect, useState } from 'react'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import SelectBox from '@/components/custom/multiselect-field'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { agentModelConfigurationZodModel, AgentModelConfigurationModel } from '@/shared/model/agent-config.model'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { saveAgentModelConfiguration } from '../configuration/actions'; +import { getLlmGateways, getModelAliasesForGateway } from '@/app/project/[projectId]/actions'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { LlmGatewayModel } from '@/shared/model/llm-gateway.model'; +import { Loader2 } from 'lucide-react'; -export function AgentModelConfigurationEditor({ agent, readonly }: { - agent: AgentExtendedModel; - readonly: boolean; -}) { - const router = useRouter(); - const [gateways, setGateways] = useState([]); - const [modelAliases, setModelAliases] = useState([]); - const [loadingAliases, setLoadingAliases] = useState(false); - const [selectedGatewayId, setSelectedGatewayId] = useState(agent.llmGatewayId || ''); +export function AgentModelConfigurationEditor({ agent, readonly }: { agent: AgentExtendedModel; readonly: boolean }) { + const router = useRouter(); + const [gateways, setGateways] = useState([]); + const [modelAliases, setModelAliases] = useState([]); + const [loadingAliases, setLoadingAliases] = useState(false); + const [selectedGatewayId, setSelectedGatewayId] = useState(agent.llmGatewayId || ''); - useEffect(() => { - getLlmGateways().then((r: any) => { - if (r?.data) setGateways(r.data); - }); - }, []); - - const form = useForm, unknown, z.output>({ - resolver: zodResolver(agentModelConfigurationZodModel), - defaultValues: { - llmGatewayId: agent.llmGatewayId || '', - modelAlias: agent.modelAlias || [], - }, - disabled: readonly, + useEffect(() => { + getLlmGateways().then((r: any) => { + if (r?.data) setGateways(r.data); }); + }, []); + + const form = useForm, unknown, z.output>({ + resolver: zodResolver(agentModelConfigurationZodModel), + defaultValues: { llmGatewayId: agent.llmGatewayId || '', modelAlias: agent.modelAlias || [] }, + disabled: readonly + }); - useEffect(() => { - if (!selectedGatewayId) { - setModelAliases([]); - return; - } - loadModelAliases(selectedGatewayId); - }, [selectedGatewayId]); + useEffect(() => { + if (!selectedGatewayId) { + setModelAliases([]); + return; + } + loadModelAliases(selectedGatewayId); + }, [selectedGatewayId]); - const loadModelAliases = async (gatewayId: string) => { - setLoadingAliases(true); - try { - const result = await getModelAliasesForGateway(gatewayId); - if (result?.data) { - setModelAliases(result.data as string[]); - } else if (Array.isArray(result)) { - setModelAliases(result as string[]); - } else { - setModelAliases([]); - } - } catch { - setModelAliases([]); - } finally { - setLoadingAliases(false); - } - }; + const loadModelAliases = async (gatewayId: string) => { + setLoadingAliases(true); + try { + const result = await getModelAliasesForGateway(gatewayId); + if (result?.data) { + setModelAliases(result.data as string[]); + } else if (Array.isArray(result)) { + setModelAliases(result as string[]); + } else { + setModelAliases([]); + } + } catch { + setModelAliases([]); + } finally { + setLoadingAliases(false); + } + }; - const [state, formAction] = useActionState( - (state: ServerActionResult, payload: AgentModelConfigurationModel) => - saveAgentModelConfiguration(state, payload, agent.id), - FormUtils.getInitialFormState(), - ); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AgentModelConfigurationModel) => saveAgentModelConfiguration(state, payload, agent.id), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - toast.success('Model configuration saved. Click "Deploy" to apply changes.'); - router.refresh(); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [form, router, state]); + useEffect(() => { + if (state.status === 'success') { + toast.success('Model configuration saved. Click "Deploy" to apply changes.'); + router.refresh(); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [form, router, state]); - return ( -
- form.handleSubmit((data) => formAction(data))()}> -
-
- ( - - LLM Gateway - - - - )} - /> + return ( + + form.handleSubmit((data) => formAction(data))()}> +
+
+ ( + + LLM Gateway + + + + )} + /> - ( - - Model Alias - {!selectedGatewayId ? ( -

Select a gateway first

- ) : loadingAliases ? ( -
- Loading model aliases... -
- ) : modelAliases.length === 0 ? ( -

No model aliases available

- ) : ( - - field.onChange(Array.isArray(value) ? value : [])} - options={modelAliases.map((alias) => ({ value: alias, label: alias }))} - placeholder="Select model aliases" - inputPlaceholder="Search model aliases..." - emptyPlaceholder="No model aliases found." - className={readonly ? "pointer-events-none opacity-50" : undefined} - /> - - )} - -
- )} - /> + ( + + Model Alias + {!selectedGatewayId ? ( +

Select a gateway first

+ ) : loadingAliases ? ( +
+ Loading model aliases...
- {!readonly && ( -
- Save Model Configuration - {state?.status === 'error' && !state?.errors && ( -

{state.message}

- )} -
- )} -
- - - ); + ) : modelAliases.length === 0 ? ( +

No model aliases available

+ ) : ( + + field.onChange(Array.isArray(value) ? value : [])} + options={modelAliases.map((alias) => ({ value: alias, label: alias }))} + placeholder="Select model aliases" + inputPlaceholder="Search model aliases..." + emptyPlaceholder="No model aliases found." + className={readonly ? 'pointer-events-none opacity-50' : undefined} + /> + + )} + + + )} + /> +
+ {!readonly && ( +
+ Save Model Configuration + {state?.status === 'error' && !state?.errors &&

{state.message}

} +
+ )} +
+ + + ); } diff --git a/src/app/project/[projectId]/agents/details/network/agent-network-policy-card.tsx b/src/app/project/[projectId]/agents/details/network/agent-network-policy-card.tsx index 82c8f4da..1f0bfeb2 100644 --- a/src/app/project/[projectId]/agents/details/network/agent-network-policy-card.tsx +++ b/src/app/project/[projectId]/agents/details/network/agent-network-policy-card.tsx @@ -1,142 +1,138 @@ 'use client'; -import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from "@/components/custom/drawer-card"; -import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; -import { CopyIcon, EditIcon, Plus, TrashIcon } from "lucide-react"; -import { toast } from "sonner"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; -import { useState } from "react"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { deleteAgentNetworkPolicyEgressRule, saveAgentNetworkPolicySettings } from "../configuration/actions"; -import AgentNetworkPolicyEgressRuleEditOverlay from "./agent-network-policy-egress-rule-edit-overlay"; -import { Tooltip, TooltipProvider, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { InternalHostnameUtils } from "@/shared/utils/internal-hostname.utils"; +import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { CopyIcon, EditIcon, Plus, TrashIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { useConfirmDialog, useDialog } from '@/frontend/states/zustand.states'; +import { useState } from 'react'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { deleteAgentNetworkPolicyEgressRule, saveAgentNetworkPolicySettings } from '../configuration/actions'; +import AgentNetworkPolicyEgressRuleEditOverlay from './agent-network-policy-egress-rule-edit-overlay'; +import { Tooltip, TooltipProvider, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { InternalHostnameUtils } from '@/shared/utils/internal-hostname.utils'; type AgentNetworkPolicyRule = NonNullable['rules'][number]; -export default function AgentNetworkPolicyCard({ agent, readonly }: { - agent: AgentExtendedModel; - readonly: boolean; -}) { - const { openConfirmDialog } = useConfirmDialog(); - const { openDialog } = useDialog(); - const rules = agent.agentNetworkPolicy?.rules ?? []; - const [allowInternetAccess, setAllowInternetAccess] = useState(agent.agentNetworkPolicy?.allowInternetAccess !== false); +export default function AgentNetworkPolicyCard({ agent, readonly }: { agent: AgentExtendedModel; readonly: boolean }) { + const { openConfirmDialog } = useConfirmDialog(); + const { openDialog } = useDialog(); + const rules = agent.agentNetworkPolicy?.rules ?? []; + const [allowInternetAccess, setAllowInternetAccess] = useState(agent.agentNetworkPolicy?.allowInternetAccess !== false); - const handleAllowInternetAccessChange = async (checked: boolean) => { - setAllowInternetAccess(checked); - try { - await Toast.fromAction(() => saveAgentNetworkPolicySettings( - undefined, - { allowInternetAccess: checked }, - agent.id, - ), 'Successfully updated internet access setting.'); - } catch { - setAllowInternetAccess(!checked); - } - }; + const handleAllowInternetAccessChange = async (checked: boolean) => { + setAllowInternetAccess(checked); + try { + await Toast.fromAction( + () => saveAgentNetworkPolicySettings(undefined, { allowInternetAccess: checked }, agent.id), + 'Successfully updated internet access setting.' + ); + } catch { + setAllowInternetAccess(!checked); + } + }; - const asyncDeleteRule = async (ruleId: string) => { - const confirm = await openConfirmDialog({ - title: "Delete Egress Rule", - description: "The egress rule will be removed. Are you sure you want to delete this rule?", - okButton: "Delete Rule", - }); - if (confirm) { - await Toast.fromAction(() => deleteAgentNetworkPolicyEgressRule(ruleId)); - } - }; + const asyncDeleteRule = async (ruleId: string) => { + const confirm = await openConfirmDialog({ + title: 'Delete Egress Rule', + description: 'The egress rule will be removed. Are you sure you want to delete this rule?', + okButton: 'Delete Rule' + }); + if (confirm) { + await Toast.fromAction(() => deleteAgentNetworkPolicyEgressRule(ruleId)); + } + }; - const openEditRuleDialog = async (rule?: AgentNetworkPolicyRule) => { - await openDialog(, { - maxWidth: 'max-w-xl', - }); - }; + const openEditRuleDialog = async (rule?: AgentNetworkPolicyRule) => { + await openDialog(, { maxWidth: 'max-w-xl' }); + }; - const copyInternalHostname = (rule: AgentNetworkPolicyRule) => { - const internalHostname = InternalHostnameUtils.getInternalBaseUrlForApp(rule.targetApp, rule.port); - navigator.clipboard.writeText(internalHostname); - toast.success('Copied internal hostname to clipboard'); - }; + const copyInternalHostname = (rule: AgentNetworkPolicyRule) => { + const internalHostname = InternalHostnameUtils.getInternalBaseUrlForApp(rule.targetApp, rule.port); + navigator.clipboard.writeText(internalHostname); + toast.success('Copied internal hostname to clipboard'); + }; - return <> - - - Network Policy - - Control whether this agent has general internet access and define specific egress rules to other apps. - - - -
-
- -

- Control whether this agent can reach the internet. -

-
- -
+ return ( + <> + + + Network Policy + Control whether this agent has general internet access and define specific egress rules to other apps. + + +
+
+ +

Control whether this agent can reach the internet.

+
+ +
- - {rules.length} Egress Rules - - - Project - App - Port - Protocol - Actions - - - - {rules.map(rule => ( - - {rule.targetApp.projectId} - {rule.targetApp.name} - {rule.port} - {rule.protocol} - -
- - - copyInternalHostname(rule)}> - - } /> - -

Copy internal hostname to clipboard

-
-
-
- {!readonly && <> - - - } -
-
-
- ))} -
-
-
- {!readonly && ( - - - - )} -
- ; + + {rules.length} Egress Rules + + + Project + App + Port + Protocol + Actions + + + + {rules.map((rule) => ( + + {rule.targetApp.projectId} + {rule.targetApp.name} + {rule.port} + {rule.protocol} + +
+ + + copyInternalHostname(rule)}> + + + } + /> + +

Copy internal hostname to clipboard

+
+
+
+ {!readonly && ( + <> + + + + )} +
+
+
+ ))} +
+
+
+ {!readonly && ( + + + + )} +
+ + ); } diff --git a/src/app/project/[projectId]/agents/details/network/agent-network-policy-egress-rule-edit-overlay.tsx b/src/app/project/[projectId]/agents/details/network/agent-network-policy-egress-rule-edit-overlay.tsx index 1ae8ddf8..dd3cab07 100644 --- a/src/app/project/[projectId]/agents/details/network/agent-network-policy-egress-rule-edit-overlay.tsx +++ b/src/app/project/[projectId]/agents/details/network/agent-network-policy-egress-rule-edit-overlay.tsx @@ -1,143 +1,129 @@ -'use client' +'use client'; -import { useActionState, useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import SelectFormField from "@/components/custom/select-form-field"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { - AgentNetworkPolicyEgressRuleEditModel, - agentNetworkPolicyEgressRuleEditZodModel, -} from "@/shared/model/agent-network-policy-edit.model"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { getAppsForAgentNetworkPolicy, saveAgentNetworkPolicyEgressRule } from "../configuration/actions"; -import { z } from "zod"; +import { useActionState, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import SelectFormField from '@/components/custom/select-form-field'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { AgentNetworkPolicyEgressRuleEditModel, agentNetworkPolicyEgressRuleEditZodModel } from '@/shared/model/agent-network-policy-edit.model'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { getAppsForAgentNetworkPolicy, saveAgentNetworkPolicyEgressRule } from '../configuration/actions'; +import { z } from 'zod'; type AgentNetworkPolicyRule = NonNullable['rules'][number]; -type ProjectWithApps = { - id: string; - name: string; - apps: { id: string; name: string }[]; -}; +type ProjectWithApps = { id: string; name: string; apps: { id: string; name: string }[] }; -const agentNetworkPolicyEgressRuleFormZodModel = agentNetworkPolicyEgressRuleEditZodModel.extend({ - projectId: z.string().optional(), -}); +const agentNetworkPolicyEgressRuleFormZodModel = agentNetworkPolicyEgressRuleEditZodModel.extend({ projectId: z.string().optional() }); -export default function AgentNetworkPolicyEgressRuleEditOverlay({ - existingRule, - agentId, -}: { - existingRule?: AgentNetworkPolicyRule; - agentId: string; -}) { - const { closeDialog } = useDialogContext(); - const [projects, setProjects] = useState([]); - const [isLoading, setIsLoading] = useState(true); +export default function AgentNetworkPolicyEgressRuleEditOverlay({ existingRule, agentId }: { existingRule?: AgentNetworkPolicyRule; agentId: string }) { + const { closeDialog } = useDialogContext(); + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - getAppsForAgentNetworkPolicy(agentId).then(result => { - if (result.status === 'success' && result.data) { - setProjects(result.data); - } else { - toast.error('An error occurred while fetching apps for this project.'); - } - setIsLoading(false); - }); - }, [agentId]); + useEffect(() => { + getAppsForAgentNetworkPolicy(agentId).then((result) => { + if (result.status === 'success' && result.data) { + setProjects(result.data); + } else { + toast.error('An error occurred while fetching apps for this project.'); + } + setIsLoading(false); + }); + }, [agentId]); - const existingProjectId = existingRule?.targetApp.projectId; + const existingProjectId = existingRule?.targetApp.projectId; - const form = useForm, unknown, z.output>({ - resolver: zodResolver(agentNetworkPolicyEgressRuleFormZodModel), - defaultValues: { - projectId: existingProjectId ?? '', - targetAppId: existingRule?.targetAppId ?? '', - port: existingRule?.port ?? undefined, - protocol: (existingRule?.protocol as 'TCP' | 'UDP') ?? 'TCP', - }, - }); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(agentNetworkPolicyEgressRuleFormZodModel), + defaultValues: { + projectId: existingProjectId ?? '', + targetAppId: existingRule?.targetAppId ?? '', + port: existingRule?.port ?? undefined, + protocol: (existingRule?.protocol as 'TCP' | 'UDP') ?? 'TCP' + } + }); - const [state, formAction] = useActionState( - (state: ServerActionResult, payload: AgentNetworkPolicyEgressRuleEditModel) => - saveAgentNetworkPolicyEgressRule(state, { ...payload, id: existingRule?.id }, agentId), - FormUtils.getInitialFormState(), - ); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AgentNetworkPolicyEgressRuleEditModel) => + saveAgentNetworkPolicyEgressRule(state, { ...payload, id: existingRule?.id }, agentId), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - form.reset(); - toast.success('Egress rule saved successfully', { description: 'Click "Deploy" to apply the changes.' }); - closeDialog(); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [closeDialog, form, state]); + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('Egress rule saved successfully', { description: 'Click "Deploy" to apply the changes.' }); + closeDialog(); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [closeDialog, form, state]); - const selectedProjectId = form.watch('projectId') ?? ''; - const appsForSelectedProject = projects.find(p => p.id === selectedProjectId)?.apps ?? []; + const selectedProjectId = form.watch('projectId') ?? ''; + const appsForSelectedProject = projects.find((p) => p.id === selectedProjectId)?.apps ?? []; - return ( - <> - - {existingRule ? 'Edit Egress Rule' : 'Add Egress Rule'} - - Allow this agent to connect to a specific app on a given port and protocol. - - -
- form.handleSubmit((data) => formAction(data))()} className="space-y-4"> - {isLoading ? ( -

Loading apps...

- ) : ( - <> - [p.id, p.name])} - onValueChange={() => form.setValue('targetAppId', '')} - /> - [app.id, app.name])} - /> - - )} - ( - - Port - - - - - - )} - /> - -

{state.message}

- Save - - - - ); + return ( + <> + + {existingRule ? 'Edit Egress Rule' : 'Add Egress Rule'} + Allow this agent to connect to a specific app on a given port and protocol. + +
+ form.handleSubmit((data) => formAction(data))()} className="space-y-4"> + {isLoading ? ( +

Loading apps...

+ ) : ( + <> + [p.id, p.name])} + onValueChange={() => form.setValue('targetAppId', '')} + /> + [app.id, app.name])} + /> + + )} + ( + + Port + + + + + + )} + /> + +

{state.message}

+ Save + + + + ); } diff --git a/src/app/project/[projectId]/agents/details/sandboxes/actions.ts b/src/app/project/[projectId]/agents/details/sandboxes/actions.ts index a656c99f..aa8fe1f7 100644 --- a/src/app/project/[projectId]/agents/details/sandboxes/actions.ts +++ b/src/app/project/[projectId]/agents/details/sandboxes/actions.ts @@ -1,57 +1,47 @@ -'use server' +'use server'; -import { simpleAction, isAuthorizedWriteForWorkload, isAuthorizedReadForWorkload } from "@/server/utils/action-wrapper.utils"; -import agentRuntimeService from "@/server/services/agent-runtime.service"; -import agentAccessService, { AgentAccessView } from "@/server/services/agent-access.service"; +import { simpleAction, isAuthorizedWriteForWorkload, isAuthorizedReadForWorkload } from '@/server/utils/action-wrapper.utils'; +import agentRuntimeService from '@/server/services/agent-runtime.service'; +import agentAccessService, { AgentAccessView } from '@/server/services/agent-access.service'; export const startSandbox = async (agentId: string, customTag?: string) => - simpleAction(async () => { - const session = await isAuthorizedWriteForWorkload(agentId); - return agentRuntimeService.startSandbox(agentId, session.userId, { customTag }); - }); + simpleAction(async () => { + const session = await isAuthorizedWriteForWorkload(agentId); + return agentRuntimeService.startSandbox(agentId, session.userId, { customTag }); + }); export const stopSandbox = async (agentId: string, sandboxName: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); - await agentRuntimeService.stopSandbox(agentId, sandboxName); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); + await agentRuntimeService.stopSandbox(agentId, sandboxName); + }); export const suspendSandbox = async (agentId: string, sandboxName: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); - await agentRuntimeService.suspendSandbox(agentId, sandboxName); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); + await agentRuntimeService.suspendSandbox(agentId, sandboxName); + }); export const resumeSandbox = async (agentId: string, sandboxName: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); - await agentRuntimeService.resumeSandbox(agentId, sandboxName); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); + await agentRuntimeService.resumeSandbox(agentId, sandboxName); + }); export const deleteAgentTag = async (agentId: string, customTag: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(agentId); - await agentRuntimeService.deleteTag(agentId, customTag); - }); + simpleAction(async () => { + await isAuthorizedWriteForWorkload(agentId); + await agentRuntimeService.deleteTag(agentId, customTag); + }); export const getSandboxes = async (agentId: string) => - simpleAction(async () => { - const session = await isAuthorizedWriteForWorkload(agentId); - return agentRuntimeService.listSandboxes(agentId, session.userId); - }); - -export const createAgentAccessUrl = async ( - agentId: string, - sandboxName: string, - view: AgentAccessView, - domainId: string, -) => simpleAction(async () => { + simpleAction(async () => { + const session = await isAuthorizedWriteForWorkload(agentId); + return agentRuntimeService.listSandboxes(agentId, session.userId); + }); + +export const createAgentAccessUrl = async (agentId: string, sandboxName: string, view: AgentAccessView, domainId: string) => + simpleAction(async () => { const session = await isAuthorizedReadForWorkload(agentId); - return agentAccessService.createAccessUrl({ - agentId, - sandboxName, - view, - domainId, - session, - }); -}); + return agentAccessService.createAccessUrl({ agentId, sandboxName, view, domainId, session }); + }); diff --git a/src/app/project/[projectId]/agents/details/sandboxes/agent-access-dialog.tsx b/src/app/project/[projectId]/agents/details/sandboxes/agent-access-dialog.tsx index f6b46eaa..f61eb6a2 100644 --- a/src/app/project/[projectId]/agents/details/sandboxes/agent-access-dialog.tsx +++ b/src/app/project/[projectId]/agents/details/sandboxes/agent-access-dialog.tsx @@ -1,96 +1,93 @@ 'use client'; - -import { useEffect, useState } from "react"; -import { DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { ExternalLink } from "lucide-react"; -import { createAgentAccessUrl } from "./actions"; -import { Spinner } from "@/components/ui/spinner"; -import { Actions } from "@/frontend/utils/nextjs-actions.utils"; +import { useEffect, useState } from 'react'; +import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { ExternalLink } from 'lucide-react'; +import { createAgentAccessUrl } from './actions'; +import { Spinner } from '@/components/ui/spinner'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; export default function AgentAccessDialogContent({ - agentId, - sandboxName, - view, - domainId, + agentId, + sandboxName, + view, + domainId }: { - agentId: string; - sandboxName: string; - view: 'agent' | 'files'; - domainId: string; + agentId: string; + sandboxName: string; + view: 'agent' | 'files'; + domainId: string; }) { - const { closeDialog } = useDialogContext(); - const [url, setUrl] = useState(); - const [error, setError] = useState(); - const viewLabel = view === 'agent' ? 'Agent UI' : 'File Browser'; + const { closeDialog } = useDialogContext(); + const [url, setUrl] = useState(); + const [error, setError] = useState(); + const viewLabel = view === 'agent' ? 'Agent UI' : 'File Browser'; - useEffect(() => { - let active = true; + useEffect(() => { + let active = true; - const createUrl = async () => { - setUrl(undefined); - setError(undefined); + const createUrl = async () => { + setUrl(undefined); + setError(undefined); - try { - const result = await Actions.run(() => createAgentAccessUrl(agentId, sandboxName, view, domainId)); - if (active) { - setUrl(result.url); - } - } catch (err: any) { - if (active) { - setError(err?.message || 'Could not open Agent access.'); - } - } - }; + try { + const result = await Actions.run(() => createAgentAccessUrl(agentId, sandboxName, view, domainId)); + if (active) { + setUrl(result.url); + } + } catch (err: any) { + if (active) { + setError(err?.message || 'Could not open Agent access.'); + } + } + }; - createUrl(); + createUrl(); - return () => { - active = false; - }; - }, [agentId, sandboxName, domainId, view]); + return () => { + active = false; + }; + }, [agentId, sandboxName, domainId, view]); - return ( - <> - - Open {viewLabel} - + return ( + <> + + Open {viewLabel} + -
- {!url && !error && ( -
- - Generating access link... -
- )} - {url && ( -

- The link is ready. Open the {viewLabel} in a new tab by clicking the button below. This link is valid for 20 seconds. -

- )} - {error && ( -

{error}

- )} -
+
+ {!url && !error && ( +
+ + Generating access link... +
+ )} + {url && ( +

+ The link is ready. Open the {viewLabel} in a new tab by clicking the button below. This link is valid for 20 seconds. +

+ )} + {error &&

{error}

} +
- - - - - - ); -} \ No newline at end of file + + + + + + ); +} diff --git a/src/app/project/[projectId]/agents/details/sandboxes/agent-sandboxes-card.tsx b/src/app/project/[projectId]/agents/details/sandboxes/agent-sandboxes-card.tsx index 95bc3c20..1bf50974 100644 --- a/src/app/project/[projectId]/agents/details/sandboxes/agent-sandboxes-card.tsx +++ b/src/app/project/[projectId]/agents/details/sandboxes/agent-sandboxes-card.tsx @@ -1,41 +1,43 @@ 'use client'; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { useConfirmDialog, useDialog, useInputDialog } from "@/frontend/states/zustand.states"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { DeploymentStatus } from "@/shared/model/deployment-info.model"; -import { Bot, ChevronDown, ExternalLink, Files, Filter, Logs, Pause, Play, PlayIcon, Search, Square, TagPlus, TagX, Terminal } from "lucide-react"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { startSandbox, stopSandbox, suspendSandbox, resumeSandbox, deleteAgentTag } from "./actions"; -import { ListUtils } from "@/shared/utils/list.utils"; -import { StreamUtils } from "@/shared/utils/stream.utils"; -import FullLoadingSpinner from "@/components/ui/full-loading-spinnter"; -import { LogsDialogContent } from "@/components/custom/logs-overlay"; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useConfirmDialog, useDialog, useInputDialog } from '@/frontend/states/zustand.states'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { DeploymentStatus } from '@/shared/model/deployment-info.model'; +import { Bot, ChevronDown, ExternalLink, Files, Filter, Logs, Pause, Play, PlayIcon, Search, Square, TagPlus, TagX, Terminal } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { startSandbox, stopSandbox, suspendSandbox, resumeSandbox, deleteAgentTag } from './actions'; +import { ListUtils } from '@/shared/utils/list.utils'; +import { StreamUtils } from '@/shared/utils/stream.utils'; +import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; +import { LogsDialogContent } from '@/components/custom/logs-overlay'; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; +import DeploymentStatusBadge from '@/app/project/workload-components/deployment-status-badge'; +import type { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { toast } from 'sonner'; import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty" -import DeploymentStatusBadge from "@/app/project/workload-components/deployment-status-badge"; -import type { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { toast } from "sonner"; -import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import AgentAccessDialogContent from "./agent-access-dialog"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { DrawerCard, DrawerCardDescription, DrawerCardHeader, DrawerCardTitle } from "@/components/custom/drawer-card"; -import { Input } from "@/components/ui/input"; -import { SplitButton } from "@/components/custom/split-button"; + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import AgentAccessDialogContent from './agent-access-dialog'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { DrawerCard, DrawerCardDescription, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; +import { Input } from '@/components/ui/input'; +import { SplitButton } from '@/components/custom/split-button'; interface SandboxInfo { - name: string; - status: DeploymentStatus; - statusText: string; - createdAt: string | null; - customTag?: string; + name: string; + status: DeploymentStatus; + statusText: string; + createdAt: string | null; + customTag?: string; } const SSE_RETRY_BASE_DELAY_MS = 1_000; @@ -43,469 +45,445 @@ const SSE_RETRY_MAX_DELAY_MS = 30_000; const SANDBOX_STATUSES: DeploymentStatus[] = ['DEPLOYED', 'DEPLOYING', 'BUILDING', 'PENDING', 'SUSPENDED', 'SHUTTING_DOWN', 'SHUTDOWN', 'ERROR', 'UNKNOWN']; const SANDBOX_STATUS_LABELS: Record = { - DEPLOYED: 'Deployed', - DEPLOYING: 'Deploying', - BUILDING: 'Building', - PENDING: 'Pending', - SUSPENDED: 'Suspended', - SHUTTING_DOWN: 'Stopping', - SHUTDOWN: 'Shutdown', - ERROR: 'Error', - UNKNOWN: 'Unknown', + DEPLOYED: 'Deployed', + DEPLOYING: 'Deploying', + BUILDING: 'Building', + PENDING: 'Pending', + SUSPENDED: 'Suspended', + SHUTTING_DOWN: 'Stopping', + SHUTDOWN: 'Shutdown', + ERROR: 'Error', + UNKNOWN: 'Unknown' }; -export default function AgentSandboxesCard({ - agent, - readonly, -}: { - agent: AgentExtendedModel; - readonly: boolean; -}) { - const { id: agentId, projectId: namespace, agentDomains, deployFileBrowser } = agent; - const { openDialog } = useDialog(); - const { openInputDialog } = useInputDialog(); - const { openConfirmDialog } = useConfirmDialog(); - const [sandboxes, setSandboxes] = useState([]); - const [loading, setLoading] = useState(false); - const [isConnected, setIsConnected] = useState(false); - const [expandedSandboxNames, setExpandedSandboxNames] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [statusFilter, setStatusFilter] = useState([]); - - // SSE stream for live sandbox updates - useEffect(() => { - const controller = new AbortController(); - let stopped = false; - let reader: ReadableStreamDefaultReader | null = null; - let retryTimeout: ReturnType | null = null; - let resolveRetry: (() => void) | null = null; - let retryAttempt = 0; - - const waitForRetry = (delayMs: number) => new Promise(resolve => { - resolveRetry = resolve; - retryTimeout = setTimeout(() => { - retryTimeout = null; - resolveRetry = null; - resolve(); - }, delayMs); - }); - - const connectSse = async () => { - while (!stopped) { - try { - const response = await fetch('/api/agent-sandboxes', { - method: 'POST', - headers: { 'Content-Type': 'text/event-stream' }, - body: JSON.stringify({ agentId }), - signal: controller.signal, - }); - - if (!response.ok || !response.body) { - throw new Error(`SSE request failed with status ${response.status}`); - } - - setIsConnected(true); - reader = response.body - .pipeThrough(new TextDecoderStream()) - .getReader(); - - let buffer = ''; - while (!stopped) { - const { value, done } = await reader.read(); - if (done) break; - - const parsed = StreamUtils.parseSseFrames(buffer, value); - buffer = parsed.buffer; - - for (const frame of parsed.frames) { - try { - const msg = JSON.parse(frame); - if (msg.type === 'FULL' && Array.isArray(msg.data)) { - setSandboxes(ListUtils.dedupByName(msg.data, 'name')); - } else if (msg.type === 'ADDED' && msg.sandbox) { - setSandboxes(prev => { - if (prev.some(i => i.name === msg.sandbox.name)) return prev; - return [...prev, msg.sandbox]; - }); - } else if (msg.type === 'MODIFIED' && msg.sandbox) { - setSandboxes(prev => prev.map(i => - i.name === msg.sandbox.name ? msg.sandbox : i - )); - } else if (msg.type === 'DELETED' && msg.sandbox?.name) { - setSandboxes(prev => prev.filter(i => - i.name !== msg.sandbox.name - )); - } - } catch { - // Ignore malformed SSE payloads and keep the stream alive. - } - } - } - } catch (err: any) { - if (err?.name !== 'AbortError' && !stopped) { - console.error('Agent sandboxes SSE error:', err); - } - } finally { - reader = null; - if (!stopped) { - setIsConnected(false); - } +export default function AgentSandboxesCard({ agent, readonly }: { agent: AgentExtendedModel; readonly: boolean }) { + const { id: agentId, projectId: namespace, agentDomains, deployFileBrowser } = agent; + const { openDialog } = useDialog(); + const { openInputDialog } = useInputDialog(); + const { openConfirmDialog } = useConfirmDialog(); + const [sandboxes, setSandboxes] = useState([]); + const [loading, setLoading] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const [expandedSandboxNames, setExpandedSandboxNames] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState([]); + + // SSE stream for live sandbox updates + useEffect(() => { + const controller = new AbortController(); + let stopped = false; + let reader: ReadableStreamDefaultReader | null = null; + let retryTimeout: ReturnType | null = null; + let resolveRetry: (() => void) | null = null; + let retryAttempt = 0; + + const waitForRetry = (delayMs: number) => + new Promise((resolve) => { + resolveRetry = resolve; + retryTimeout = setTimeout(() => { + retryTimeout = null; + resolveRetry = null; + resolve(); + }, delayMs); + }); + + const connectSse = async () => { + while (!stopped) { + try { + const response = await fetch('/api/agent-sandboxes', { + method: 'POST', + headers: { 'Content-Type': 'text/event-stream' }, + body: JSON.stringify({ agentId }), + signal: controller.signal + }); + + if (!response.ok || !response.body) { + throw new Error(`SSE request failed with status ${response.status}`); + } + + setIsConnected(true); + reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + while (!stopped) { + const { value, done } = await reader.read(); + if (done) break; + + const parsed = StreamUtils.parseSseFrames(buffer, value); + buffer = parsed.buffer; + + for (const frame of parsed.frames) { + try { + const msg = JSON.parse(frame); + if (msg.type === 'FULL' && Array.isArray(msg.data)) { + setSandboxes(ListUtils.dedupByName(msg.data, 'name')); + } else if (msg.type === 'ADDED' && msg.sandbox) { + setSandboxes((prev) => { + if (prev.some((i) => i.name === msg.sandbox.name)) return prev; + return [...prev, msg.sandbox]; + }); + } else if (msg.type === 'MODIFIED' && msg.sandbox) { + setSandboxes((prev) => prev.map((i) => (i.name === msg.sandbox.name ? msg.sandbox : i))); + } else if (msg.type === 'DELETED' && msg.sandbox?.name) { + setSandboxes((prev) => prev.filter((i) => i.name !== msg.sandbox.name)); } - - if (stopped) break; - - const exponentialDelay = Math.min( - SSE_RETRY_BASE_DELAY_MS * 2 ** retryAttempt, - SSE_RETRY_MAX_DELAY_MS, - ); - const jitteredDelay = exponentialDelay * (0.8 + Math.random() * 0.4); - retryAttempt += 1; - await waitForRetry(jitteredDelay); + } catch { + // Ignore malformed SSE payloads and keep the stream alive. + } } - }; - - void connectSse(); - - return () => { - stopped = true; - controller.abort(); - if (retryTimeout) { - clearTimeout(retryTimeout); - retryTimeout = null; - } - resolveRetry?.(); - void reader?.cancel(); - }; - }, [agentId]); - - const handleStartSandbox = async () => { - setLoading(true); - try { - await Toast.fromAction( - () => startSandbox(agentId), - 'Sandbox started', - 'Starting sandbox...', - ); - } finally { - setLoading(false); - // SSE will push updated list automatically - } - }; - - const handleStartSandboxWithCustomTag = async () => { - const customTag = await openInputDialog({ - title: 'Start sandbox with custom tag', - description: 'The tag identifies this sandbox and must be unique for this Sandbox Instance (1–63 characters).', - fieldName: 'Custom tag', - okButton: 'Start sandbox', - }); - if (!customTag) { - return; - } - - setLoading(true); - try { - await Toast.fromAction( - () => startSandbox(agentId, customTag), - 'Sandbox started', - 'Starting sandbox...', - ); - } finally { - setLoading(false); - } - }; - - const handleDeleteTagData = async () => { - const customTag = await openInputDialog({ - title: 'Delete Custom Tag data', - description: 'Deletes all stored data of this Custom Tag. The tag must not have a running sandbox. 1–63 characters.', - fieldName: 'Custom tag', - okButton: 'Continue', - }); - if (!customTag) { - return; - } - - const confirmed = await openConfirmDialog({ - title: 'Delete Custom Tag data', - description: `All stored data of Custom Tag "${customTag}" will be deleted permanently. This cannot be undone.`, - okButton: 'Delete data', - }); - if (!confirmed) { - return; - } - - await Toast.fromAction( - () => deleteAgentTag(agentId, customTag), - 'Custom Tag data deleted', - 'Deleting Custom Tag data...', - ); - }; - - const renderStartSandboxActions = () => ( - - void handleStartSandboxWithCustomTag()}> - Start with custom tag - - void handleDeleteTagData()}> - Delete custom tag data - - } - > - - Start sandbox - - ); - - const handleStopSandbox = async (sandboxName: string) => { - try { - await Toast.fromAction( - () => stopSandbox(agentId, sandboxName), - 'Sandbox stopped', - 'Stopping sandbox...', - ); - // SSE will push updated list automatically + } + } catch (err: any) { + if (err?.name !== 'AbortError' && !stopped) { + console.error('Agent sandboxes SSE error:', err); + } } finally { - // nothing to fetch — SSE handles it + reader = null; + if (!stopped) { + setIsConnected(false); + } } - }; - - const handleSuspendSandbox = async (sandboxName: string) => { - await Toast.fromAction( - () => suspendSandbox(agentId, sandboxName), - 'Sandbox suspended', - 'Suspending sandbox...', - ); - }; - const handleResumeSandbox = async (sandboxName: string) => { - await Toast.fromAction( - () => resumeSandbox(agentId, sandboxName), - 'Sandbox resumed', - 'Resuming sandbox...', - ); - }; - - const handleOpenTerminal = async () => { - // Terminal opening is delegated to parent component via callback - // For now, this is a placeholder — terminal per sandbox needs pod discovery - }; + if (stopped) break; - const toggleSandboxExpanded = (sandboxName: string, open: boolean) => { - setExpandedSandboxNames(current => open - ? [...new Set([...current, sandboxName])] - : current.filter(name => name !== sandboxName), - ); + const exponentialDelay = Math.min(SSE_RETRY_BASE_DELAY_MS * 2 ** retryAttempt, SSE_RETRY_MAX_DELAY_MS); + const jitteredDelay = exponentialDelay * (0.8 + Math.random() * 0.4); + retryAttempt += 1; + await waitForRetry(jitteredDelay); + } }; - const toggleStatusFilter = (status: DeploymentStatus, checked: boolean) => { - setStatusFilter(current => checked - ? [...current, status] - : current.filter(value => value !== status), - ); + void connectSse(); + + return () => { + stopped = true; + controller.abort(); + if (retryTimeout) { + clearTimeout(retryTimeout); + retryTimeout = null; + } + resolveRetry?.(); + void reader?.cancel(); }; - - const normalizedSearchQuery = searchQuery.trim().toLowerCase(); - const filteredSandboxes = sandboxes.filter(sandbox => { - const matchesSearch = normalizedSearchQuery.length === 0 - || sandbox.name.toLowerCase().includes(normalizedSearchQuery) - || sandbox.customTag?.toLowerCase().includes(normalizedSearchQuery); - const matchesStatus = statusFilter.length === 0 || statusFilter.includes(sandbox.status); - - return matchesSearch && matchesStatus; + }, [agentId]); + + const handleStartSandbox = async () => { + setLoading(true); + try { + await Toast.fromAction(() => startSandbox(agentId), 'Sandbox started', 'Starting sandbox...'); + } finally { + setLoading(false); + // SSE will push updated list automatically + } + }; + + const handleStartSandboxWithCustomTag = async () => { + const customTag = await openInputDialog({ + title: 'Start sandbox with custom tag', + description: 'The tag identifies this sandbox and must be unique for this Sandbox Instance (1–63 characters).', + fieldName: 'Custom tag', + okButton: 'Start sandbox' }); + if (!customTag) { + return; + } + + setLoading(true); + try { + await Toast.fromAction(() => startSandbox(agentId, customTag), 'Sandbox started', 'Starting sandbox...'); + } finally { + setLoading(false); + } + }; + + const handleDeleteTagData = async () => { + const customTag = await openInputDialog({ + title: 'Delete Custom Tag data', + description: 'Deletes all stored data of this Custom Tag. The tag must not have a running sandbox. 1–63 characters.', + fieldName: 'Custom tag', + okButton: 'Continue' + }); + if (!customTag) { + return; + } + + const confirmed = await openConfirmDialog({ + title: 'Delete Custom Tag data', + description: `All stored data of Custom Tag "${customTag}" will be deleted permanently. This cannot be undone.`, + okButton: 'Delete data' + }); + if (!confirmed) { + return; + } + + await Toast.fromAction(() => deleteAgentTag(agentId, customTag), 'Custom Tag data deleted', 'Deleting Custom Tag data...'); + }; + + const renderStartSandboxActions = () => ( + + void handleStartSandboxWithCustomTag()}> + Start with custom tag + + void handleDeleteTagData()}> + Delete custom tag data + + + } + > + + Start sandbox + + ); + + const handleStopSandbox = async (sandboxName: string) => { + try { + await Toast.fromAction(() => stopSandbox(agentId, sandboxName), 'Sandbox stopped', 'Stopping sandbox...'); + // SSE will push updated list automatically + } finally { + // nothing to fetch — SSE handles it + } + }; + + const handleSuspendSandbox = async (sandboxName: string) => { + await Toast.fromAction(() => suspendSandbox(agentId, sandboxName), 'Sandbox suspended', 'Suspending sandbox...'); + }; + + const handleResumeSandbox = async (sandboxName: string) => { + await Toast.fromAction(() => resumeSandbox(agentId, sandboxName), 'Sandbox resumed', 'Resuming sandbox...'); + }; + + const handleOpenTerminal = async () => { + // Terminal opening is delegated to parent component via callback + // For now, this is a placeholder — terminal per sandbox needs pod discovery + }; + + const toggleSandboxExpanded = (sandboxName: string, open: boolean) => { + setExpandedSandboxNames((current) => (open ? [...new Set([...current, sandboxName])] : current.filter((name) => name !== sandboxName))); + }; + + const toggleStatusFilter = (status: DeploymentStatus, checked: boolean) => { + setStatusFilter((current) => (checked ? [...current, status] : current.filter((value) => value !== status))); + }; + + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const filteredSandboxes = sandboxes.filter((sandbox) => { + const matchesSearch = + normalizedSearchQuery.length === 0 || + sandbox.name.toLowerCase().includes(normalizedSearchQuery) || + sandbox.customTag?.toLowerCase().includes(normalizedSearchQuery); + const matchesStatus = statusFilter.length === 0 || statusFilter.includes(sandbox.status); + + return matchesSearch && matchesStatus; + }); + + const handleOpenLogs = (sandboxName: string) => { + openDialog(, { maxWidth: '1300px' }); + }; + + const handleOpenAgentAccess = (sandboxName: string, view: 'agent' | 'files', domainId: string) => { + if (agentDomains.length === 0) { + toast.error('Configure an Agent access domain first.'); + return; + } + + openDialog(, { maxWidth: '440px' }); + }; + + const renderAccessButton = (sandboxName: string, view: 'agent' | 'files', showLabel = false) => { + const icon = view === 'agent' ? : ; + const label = view === 'agent' ? 'Open agent' : 'Files'; + const disabled = agentDomains.length === 0; + + if (agentDomains.length <= 1) { + return ( + + ); + } - const handleOpenLogs = (sandboxName: string) => { - openDialog(, { maxWidth: '1300px' }); - }; - - const handleOpenAgentAccess = (sandboxName: string, view: 'agent' | 'files', domainId: string) => { - if (agentDomains.length === 0) { - toast.error('Configure an Agent access domain first.'); - return; - } - - openDialog( - , - { maxWidth: '440px' } - ); - }; - - const renderAccessButton = (sandboxName: string, view: 'agent' | 'files', showLabel = false) => { - const icon = view === 'agent' - ? - : ; - const label = view === 'agent' ? 'Open agent' : 'Files'; - const disabled = agentDomains.length === 0; - - if (agentDomains.length <= 1) { - return ( - - ); - } - - return ( - - - {icon} - {showLabel && {label}} - } /> - - {agentDomains.map((domain) => ( - handleOpenAgentAccess(sandboxName, view, domain.id)}> - {domain.hostname} - + return ( + + + {icon} + {showLabel && {label}} + + } + /> + + {agentDomains.map((domain) => ( + handleOpenAgentAccess(sandboxName, view, domain.id)}> + {domain.hostname} + + ))} + + + ); + }; + + return ( + +
+ + Sandboxes + + {sandboxes.length === 0 + ? 'Start a sandbox to open an agent workspace.' + : `${sandboxes.length} sandbox${sandboxes.length === 1 ? '' : 'es'} · ${sandboxes.filter((sandbox) => sandbox.status === 'DEPLOYED').length} running`} + + + {!readonly && renderStartSandboxActions()} +
+ + {!isConnected ? ( + + ) : sandboxes.length === 0 ? ( + + + + + + No sandboxes yet + Start a sandbox to create an agent workspace. + + {!readonly && {renderStartSandboxActions()}} + + ) : ( + +
+
+
+ + setSearchQuery(event.target.value)} placeholder="Search name or tag" className="pl-9" /> +
+ + }> + + Filter{statusFilter.length > 0 && ` (${statusFilter.length})`} + + + + Status + + {SANDBOX_STATUSES.map((status) => ( + toggleStatusFilter(status, checked)} + > + {SANDBOX_STATUS_LABELS[status]} + ))} + - - ); - }; - - return ( - -
- - Sandboxes - - {sandboxes.length === 0 - ? 'Start a sandbox to open an agent workspace.' - : `${sandboxes.length} sandbox${sandboxes.length === 1 ? '' : 'es'} · ${sandboxes.filter(sandbox => sandbox.status === 'DEPLOYED').length} running`} - - - {!readonly && ( - renderStartSandboxActions() - )} +
- {!isConnected ? : ( - sandboxes.length === 0 ? ( - - - - No sandboxes yet - Start a sandbox to create an agent workspace. - - {!readonly && - {renderStartSandboxActions()} - } - - ) : ( - -
-
-
- - setSearchQuery(event.target.value)} - placeholder="Search name or tag" - className="pl-9" - /> -
- - }> - - Filter{statusFilter.length > 0 && ` (${statusFilter.length})`} - - - - Status - - {SANDBOX_STATUSES.map((status) => ( - toggleStatusFilter(status, checked)} - > - {SANDBOX_STATUS_LABELS[status]} - - ))} - - - -
- - {filteredSandboxes.length === 0 && ( -
- No sandboxes match the current search or filter. -
- )} - - {filteredSandboxes.map((sandbox) => ( - toggleSandboxExpanded(sandbox.name, open)} - className="overflow-hidden rounded-lg border bg-card" - > - }> - -
- {sandbox.name} -
- Tag: {sandbox.customTag ?? '—'} - · - Created {sandbox.createdAt ? new Date(sandbox.createdAt).toLocaleString() : '—'} -
-
- {sandbox.status} -
- -
- {sandbox.status === 'DEPLOYED' && <> - - - Open Agent UI - - {deployFileBrowser && - - Open Files - } - - - {!readonly && } - } - {sandbox.status === 'SUSPENDED' && !readonly && } - {!readonly && } -
-
-
- ))} -
-
- ) + {filteredSandboxes.length === 0 && ( +
+ No sandboxes match the current search or filter. +
)} -
- ); + + {filteredSandboxes.map((sandbox) => ( + toggleSandboxExpanded(sandbox.name, open)} + className="overflow-hidden rounded-lg border bg-card" + > + }> + +
+ {sandbox.name} +
+ + Tag: {sandbox.customTag ?? '—'} + + · + Created {sandbox.createdAt ? new Date(sandbox.createdAt).toLocaleString() : '—'} +
+
+ + {sandbox.status} + +
+ +
+ {sandbox.status === 'DEPLOYED' && ( + <> + + + Open Agent UI + + {deployFileBrowser && ( + + + Open Files + + )} + + + {!readonly && ( + + )} + + )} + {sandbox.status === 'SUSPENDED' && !readonly && ( + + )} + {!readonly && ( + + )} +
+
+
+ ))} +
+ + )} + + ); } diff --git a/src/app/project/[projectId]/agents/details/source/agent-source-card.tsx b/src/app/project/[projectId]/agents/details/source/agent-source-card.tsx index 015fac23..e0605135 100644 --- a/src/app/project/[projectId]/agents/details/source/agent-source-card.tsx +++ b/src/app/project/[projectId]/agents/details/source/agent-source-card.tsx @@ -1,144 +1,142 @@ 'use client'; -import { Button } from "@/components/ui/button"; -import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardHeader, DrawerCardTitle } from "@/components/custom/drawer-card"; -import { useDialog } from "@/frontend/states/zustand.states"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { Container, FileCode2, GitBranch, KeyRound, Link as LinkIcon, LockKeyhole, Package, Server } from "lucide-react"; -import { ReadonlyInfo } from "@/app/project/workload-components/source-wizard/readonly-info"; -import { defaultDockerfilePath, SourceType, sourceTypeLabels } from "@/app/project/workload-components/source-wizard/types"; -import { PublicDeployKeyDialog } from "@/app/project/workload-components/source-wizard/public-deploy-key-dialog"; -import { AgentSourceWizardDialog } from "./agent-source-wizard-dialog"; +import { Button } from '@/components/ui/button'; +import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; +import { useDialog } from '@/frontend/states/zustand.states'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { Container, FileCode2, GitBranch, KeyRound, Link as LinkIcon, LockKeyhole, Package, Server } from 'lucide-react'; +import { ReadonlyInfo } from '@/app/project/workload-components/source-wizard/readonly-info'; +import { defaultDockerfilePath, SourceType, sourceTypeLabels } from '@/app/project/workload-components/source-wizard/types'; +import { PublicDeployKeyDialog } from '@/app/project/workload-components/source-wizard/public-deploy-key-dialog'; +import { AgentSourceWizardDialog } from './agent-source-wizard-dialog'; -export default function AgentSourceCard({ agent, readonly }: { - agent: AgentExtendedModel; - readonly: boolean; -}) { - const { openDialog } = useDialog(); - const publicKey = agent.agentGitSshKey?.publicKey; - const configured = isConfiguredSource(agent); +export default function AgentSourceCard({ agent, readonly }: { agent: AgentExtendedModel; readonly: boolean }) { + const { openDialog } = useDialog(); + const publicKey = agent.agentGitSshKey?.publicKey; + const configured = isConfiguredSource(agent); - const openSourceWizard = () => { - openDialog( - , - { - width: 'calc(100vw - 2rem)', - maxWidth: '760px', - maxHeight: '90vh', - } - ); - }; + const openSourceWizard = () => { + openDialog(, { width: 'calc(100vw - 2rem)', maxWidth: '760px', maxHeight: '90vh' }); + }; - return ( - - -
- Source - Connect the source QuickStack should build or run. -
- {!readonly && configured && ( - - )} -
- - {!configured ? ( - - ) : ( - - )} - -
- ); + return ( + + +
+ Source + Connect the source QuickStack should build or run. +
+ {!readonly && configured && ( + + )} +
+ + {!configured ? ( + + ) : ( + + )} + +
+ ); } function EmptySourceState({ readonly, onConnect }: { readonly: boolean; onConnect: () => void }) { - return ( -
-
- -
-
-

No agent source connected

-

Connect a Git repository or container image before deploying this agent.

-
- {!readonly && ( - - )} -
- ); + return ( +
+
+ +
+
+

No agent source connected

+

Connect a Git repository or container image before deploying this agent.

+
+ {!readonly && ( + + )} +
+ ); } function ConfiguredSourceSummary({ agent, gitSshPublicKey }: { agent: AgentExtendedModel; gitSshPublicKey?: string }) { - const { openDialog } = useDialog(); - const sourceType = agent.sourceType as SourceType; - const isGitSource = sourceType === 'GIT' || sourceType === 'GIT_SSH'; - const Icon = sourceType === 'CONTAINER' ? Container : sourceType === 'GIT_SSH' ? KeyRound : GitBranch; + const { openDialog } = useDialog(); + const sourceType = agent.sourceType as SourceType; + const isGitSource = sourceType === 'GIT' || sourceType === 'GIT_SSH'; + const Icon = sourceType === 'CONTAINER' ? Container : sourceType === 'GIT_SSH' ? KeyRound : GitBranch; - return ( -
-
-
-
- -
-
-

{sourceTypeLabels[sourceType]}

-

- {isGitSource ? agent.gitUrl : agent.containerImageSource} -

-
-
-
-
- {isGitSource && ( - <> - - {sourceType === 'GIT' && ( - - )} - {sourceType === 'GIT_SSH' && ( - openDialog(, '680px')} - > - Show key - - ) : undefined} - /> - )} - - - - )} - {sourceType === 'CONTAINER' && ( - <> - - - - )} -
+ return ( +
+
+
+
+ +
+
+

{sourceTypeLabels[sourceType]}

+

{isGitSource ? agent.gitUrl : agent.containerImageSource}

+
- ); +
+
+ {isGitSource && ( + <> + + {sourceType === 'GIT' && ( + + )} + {sourceType === 'GIT_SSH' && ( + openDialog(, '680px')} + > + Show key + + ) : undefined + } + /> + )} + + + + )} + {sourceType === 'CONTAINER' && ( + <> + + + + )} +
+
+ ); } function isConfiguredSource(agent: AgentExtendedModel) { - if (agent.sourceType === 'GIT' || agent.sourceType === 'GIT_SSH') { - return !!agent.gitUrl?.trim() && !!agent.gitBranch?.trim() && !!agent.dockerfilePath?.trim(); - } - if (agent.sourceType === 'CONTAINER') { - return !!agent.containerImageSource?.trim(); - } - return false; + if (agent.sourceType === 'GIT' || agent.sourceType === 'GIT_SSH') { + return !!agent.gitUrl?.trim() && !!agent.gitBranch?.trim() && !!agent.dockerfilePath?.trim(); + } + if (agent.sourceType === 'CONTAINER') { + return !!agent.containerImageSource?.trim(); + } + return false; } diff --git a/src/app/project/[projectId]/agents/details/source/agent-source-wizard-dialog.tsx b/src/app/project/[projectId]/agents/details/source/agent-source-wizard-dialog.tsx index 302585a5..e19b147b 100644 --- a/src/app/project/[projectId]/agents/details/source/agent-source-wizard-dialog.tsx +++ b/src/app/project/[projectId]/agents/details/source/agent-source-wizard-dialog.tsx @@ -1,400 +1,362 @@ 'use client'; -import { Button } from "@/components/ui/button"; -import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { useConfirmDialog } from "@/frontend/states/zustand.states"; -import { Actions } from "@/frontend/utils/nextjs-actions.utils"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { AgentExtendedModel } from "@/shared/model/agent-extended.model"; -import { AgentDockerfileDetectionModel, AgentGitBranchesLookupModel, AgentSourceInfoInputModel } from "@/shared/model/agent-config.model"; -import { ChevronLeft, Loader2, Rocket, Save } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; +import { Button } from '@/components/ui/button'; +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { useConfirmDialog } from '@/frontend/states/zustand.states'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { AgentExtendedModel } from '@/shared/model/agent-extended.model'; +import { AgentDockerfileDetectionModel, AgentGitBranchesLookupModel, AgentSourceInfoInputModel } from '@/shared/model/agent-config.model'; +import { ChevronLeft, Loader2, Rocket, Save } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { - detectAgentDockerfilePath, - ensureAgentGitSshPublicKey, - generateOrRegenerateAgentGitSshKey, - getAgentGitBranches, - saveAgentSource, -} from "../configuration/actions"; -import { deployAgent } from "../agent-actions"; -import { PathBuilderUtils } from "@/shared/utils/path-builder.utils"; -import { ContainerImageStep } from "@/app/project/workload-components/source-wizard/container-image-step"; -import { DockerfilePathStep } from "@/app/project/workload-components/source-wizard/dockerfile-path-step"; -import { GitBranchStep } from "@/app/project/workload-components/source-wizard/git-branch-step"; -import { GitHttpsUrlStep } from "@/app/project/workload-components/source-wizard/git-https-url-step"; -import { GitSshUrlStep } from "@/app/project/workload-components/source-wizard/git-ssh-url-step"; -import { SourceSummaryStep } from "@/app/project/workload-components/source-wizard/source-summary-step"; -import { SourceTypeStep } from "@/app/project/workload-components/source-wizard/source-type-step"; -import { defaultDockerfilePath, SourceFormPatch, sourceTypeLabels, SourceType, StepId } from "@/app/project/workload-components/source-wizard/types"; -import { WizardProgress } from "@/app/project/workload-components/source-wizard/wizard-progress"; + detectAgentDockerfilePath, + ensureAgentGitSshPublicKey, + generateOrRegenerateAgentGitSshKey, + getAgentGitBranches, + saveAgentSource +} from '../configuration/actions'; +import { deployAgent } from '../agent-actions'; +import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; +import { ContainerImageStep } from '@/app/project/workload-components/source-wizard/container-image-step'; +import { DockerfilePathStep } from '@/app/project/workload-components/source-wizard/dockerfile-path-step'; +import { GitBranchStep } from '@/app/project/workload-components/source-wizard/git-branch-step'; +import { GitHttpsUrlStep } from '@/app/project/workload-components/source-wizard/git-https-url-step'; +import { GitSshUrlStep } from '@/app/project/workload-components/source-wizard/git-ssh-url-step'; +import { SourceSummaryStep } from '@/app/project/workload-components/source-wizard/source-summary-step'; +import { SourceTypeStep } from '@/app/project/workload-components/source-wizard/source-type-step'; +import { defaultDockerfilePath, SourceFormPatch, sourceTypeLabels, SourceType, StepId } from '@/app/project/workload-components/source-wizard/types'; +import { WizardProgress } from '@/app/project/workload-components/source-wizard/wizard-progress'; -export function AgentSourceWizardDialog({ agent, gitSshPublicKey }: { - agent: AgentExtendedModel; - gitSshPublicKey?: string; -}) { - const router = useRouter(); - const { closeDialog } = useDialogContext(); - const { openConfirmDialog } = useConfirmDialog(); - const [step, setStep] = useState('source'); - const [history, setHistory] = useState([]); - const [formData, setFormData] = useState(() => toSourceInput(agent)); - const [publicKey, setPublicKey] = useState(gitSshPublicKey); - const [branches, setBranches] = useState([]); - const [isLoadingBranches, setIsLoadingBranches] = useState(false); - const [branchError, setBranchError] = useState(null); - const [isEnsuringKey, setIsEnsuringKey] = useState(false); - const [isDetectingDockerfile, setIsDetectingDockerfile] = useState(false); - const [dockerfileDetectionResult, setDockerfileDetectionResult] = useState<'detected' | 'not-found' | null>(null); - const [showGitToken, setShowGitToken] = useState(false); - const [showRegistryPassword, setShowRegistryPassword] = useState(false); +export function AgentSourceWizardDialog({ agent, gitSshPublicKey }: { agent: AgentExtendedModel; gitSshPublicKey?: string }) { + const router = useRouter(); + const { closeDialog } = useDialogContext(); + const { openConfirmDialog } = useConfirmDialog(); + const [step, setStep] = useState('source'); + const [history, setHistory] = useState([]); + const [formData, setFormData] = useState(() => toSourceInput(agent)); + const [publicKey, setPublicKey] = useState(gitSshPublicKey); + const [branches, setBranches] = useState([]); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [branchError, setBranchError] = useState(null); + const [isEnsuringKey, setIsEnsuringKey] = useState(false); + const [isDetectingDockerfile, setIsDetectingDockerfile] = useState(false); + const [dockerfileDetectionResult, setDockerfileDetectionResult] = useState<'detected' | 'not-found' | null>(null); + const [showGitToken, setShowGitToken] = useState(false); + const [showRegistryPassword, setShowRegistryPassword] = useState(false); - const isGitSource = formData.sourceType === 'GIT' || formData.sourceType === 'GIT_SSH'; - const showGitCredentials = formData.sourceType === 'GIT' && !!formData.gitUrl?.trim(); - const showRegistryCredentials = formData.sourceType === 'CONTAINER' && !!formData.containerImageSource?.trim(); - const currentTitle = getStepTitle(step, formData.sourceType); + const isGitSource = formData.sourceType === 'GIT' || formData.sourceType === 'GIT_SSH'; + const showGitCredentials = formData.sourceType === 'GIT' && !!formData.gitUrl?.trim(); + const showRegistryCredentials = formData.sourceType === 'CONTAINER' && !!formData.containerImageSource?.trim(); + const currentTitle = getStepTitle(step, formData.sourceType); - const goTo = (nextStep: StepId) => { - setHistory((items) => [...items, step]); - setStep(nextStep); - }; + const goTo = (nextStep: StepId) => { + setHistory((items) => [...items, step]); + setStep(nextStep); + }; - const goBack = () => { - const previous = history[history.length - 1]; - if (!previous) return; - setHistory((items) => items.slice(0, -1)); - setStep(previous); - }; + const goBack = () => { + const previous = history[history.length - 1]; + if (!previous) return; + setHistory((items) => items.slice(0, -1)); + setStep(previous); + }; - const updateFormData = (patch: SourceFormPatch) => { - setFormData((current) => ({ ...current, ...patch, buildMethod: 'DOCKERFILE' })); - }; + const updateFormData = (patch: SourceFormPatch) => { + setFormData((current) => ({ ...current, ...patch, buildMethod: 'DOCKERFILE' })); + }; - const chooseSourceType = (sourceType: SourceType) => { - setBranches([]); - setBranchError(null); - setDockerfileDetectionResult(null); - setFormData((current) => resetForSourceType(current, sourceType)); - }; + const chooseSourceType = (sourceType: SourceType) => { + setBranches([]); + setBranchError(null); + setDockerfileDetectionResult(null); + setFormData((current) => resetForSourceType(current, sourceType)); + }; - const loadBranches = async () => { - if (!formData.gitUrl?.trim()) { - setBranchError('Enter a Git repository URL first.'); - return false; - } + const loadBranches = async () => { + if (!formData.gitUrl?.trim()) { + setBranchError('Enter a Git repository URL first.'); + return false; + } - const inputData: AgentGitBranchesLookupModel = formData.sourceType === 'GIT' - ? { - sourceType: 'GIT', - gitUrl: formData.gitUrl, - gitUsername: formData.gitUsername, - gitToken: formData.gitToken, - } - : { - sourceType: 'GIT_SSH', - gitUrl: formData.gitUrl, - }; + const inputData: AgentGitBranchesLookupModel = + formData.sourceType === 'GIT' + ? { sourceType: 'GIT', gitUrl: formData.gitUrl, gitUsername: formData.gitUsername, gitToken: formData.gitToken } + : { sourceType: 'GIT_SSH', gitUrl: formData.gitUrl }; - setIsLoadingBranches(true); - setBranchError(null); - try { - const result = await Actions.run(() => getAgentGitBranches(agent.id, inputData)); - setBranches(result ?? []); - if (formData.gitBranch && !result?.includes(formData.gitBranch)) { - updateFormData({ gitBranch: '' }); - } - return true; - } catch (error) { - setBranches([]); - setBranchError(error instanceof Error ? error.message : 'Branches could not be loaded.'); - return false; - } finally { - setIsLoadingBranches(false); - } - }; + setIsLoadingBranches(true); + setBranchError(null); + try { + const result = await Actions.run(() => getAgentGitBranches(agent.id, inputData)); + setBranches(result ?? []); + if (formData.gitBranch && !result?.includes(formData.gitBranch)) { + updateFormData({ gitBranch: '' }); + } + return true; + } catch (error) { + setBranches([]); + setBranchError(error instanceof Error ? error.message : 'Branches could not be loaded.'); + return false; + } finally { + setIsLoadingBranches(false); + } + }; - const ensureSshKey = async () => { - if (publicKey || isEnsuringKey) return; - setIsEnsuringKey(true); - try { - const key = await Actions.run(() => ensureAgentGitSshPublicKey(agent.id)); - setPublicKey(key); - } finally { - setIsEnsuringKey(false); - } - }; + const ensureSshKey = async () => { + if (publicKey || isEnsuringKey) return; + setIsEnsuringKey(true); + try { + const key = await Actions.run(() => ensureAgentGitSshPublicKey(agent.id)); + setPublicKey(key); + } finally { + setIsEnsuringKey(false); + } + }; - useEffect(() => { - if (step === 'ssh-url') { - ensureSshKey(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [step, formData.gitUrl, publicKey]); + useEffect(() => { + if (step === 'ssh-url') { + ensureSshKey(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step, formData.gitUrl, publicKey]); - const regenerateKey = async () => { - const confirmed = await openConfirmDialog({ - title: "Regenerate Deploy Key", - description: "This replaces the current agent SSH key. Update the deploy key in your Git provider before deploying again.", - okButton: "Regenerate", - }); - if (!confirmed) return; - const key = await Actions.run(() => generateOrRegenerateAgentGitSshKey(agent.id)); - setPublicKey(key); - toast.success('Deploy key regenerated.'); - }; + const regenerateKey = async () => { + const confirmed = await openConfirmDialog({ + title: 'Regenerate Deploy Key', + description: 'This replaces the current agent SSH key. Update the deploy key in your Git provider before deploying again.', + okButton: 'Regenerate' + }); + if (!confirmed) return; + const key = await Actions.run(() => generateOrRegenerateAgentGitSshKey(agent.id)); + setPublicKey(key); + toast.success('Deploy key regenerated.'); + }; - const copyPublicKey = () => { - if (!publicKey) return; - navigator.clipboard.writeText(publicKey); - toast.success('Copied to clipboard.'); - }; + const copyPublicKey = () => { + if (!publicKey) return; + navigator.clipboard.writeText(publicKey); + toast.success('Copied to clipboard.'); + }; - const runDockerfileDetection = async (selectedBranch = formData.gitBranch) => { - if (!isGitSource || !formData.gitUrl || !selectedBranch) return; + const runDockerfileDetection = async (selectedBranch = formData.gitBranch) => { + if (!isGitSource || !formData.gitUrl || !selectedBranch) return; - const inputData: AgentDockerfileDetectionModel = formData.sourceType === 'GIT' - ? { - sourceType: 'GIT', - gitUrl: formData.gitUrl, - gitBranch: selectedBranch, - gitUsername: formData.gitUsername, - gitToken: formData.gitToken, - } - : { - sourceType: 'GIT_SSH', - gitUrl: formData.gitUrl, - gitBranch: selectedBranch, - }; + const inputData: AgentDockerfileDetectionModel = + formData.sourceType === 'GIT' + ? { sourceType: 'GIT', gitUrl: formData.gitUrl, gitBranch: selectedBranch, gitUsername: formData.gitUsername, gitToken: formData.gitToken } + : { sourceType: 'GIT_SSH', gitUrl: formData.gitUrl, gitBranch: selectedBranch }; - setIsDetectingDockerfile(true); - try { - const dockerfilePath = await Actions.run(() => detectAgentDockerfilePath(agent.id, inputData)); - updateFormData({ dockerfilePath: dockerfilePath || defaultDockerfilePath }); - setDockerfileDetectionResult(dockerfilePath ? 'detected' : 'not-found'); - } finally { - setIsDetectingDockerfile(false); - } - }; + setIsDetectingDockerfile(true); + try { + const dockerfilePath = await Actions.run(() => detectAgentDockerfilePath(agent.id, inputData)); + updateFormData({ dockerfilePath: dockerfilePath || defaultDockerfilePath }); + setDockerfileDetectionResult(dockerfilePath ? 'detected' : 'not-found'); + } finally { + setIsDetectingDockerfile(false); + } + }; - const selectGitBranch = async (gitBranch: string) => { - updateFormData({ gitBranch, buildMethod: 'DOCKERFILE' }); - setDockerfileDetectionResult(null); - goTo('dockerfile'); - await runDockerfileDetection(gitBranch); - }; + const selectGitBranch = async (gitBranch: string) => { + updateFormData({ gitBranch, buildMethod: 'DOCKERFILE' }); + setDockerfileDetectionResult(null); + goTo('dockerfile'); + await runDockerfileDetection(gitBranch); + }; - const next = async () => { - if (step === 'source') { - goTo(formData.sourceType === 'GIT' ? 'git-url' : formData.sourceType === 'GIT_SSH' ? 'ssh-url' : 'container-image'); - return; - } - if (step === 'git-url' || step === 'ssh-url') { - if (await loadBranches()) { - goTo('branch'); - } - return; - } - if (step === 'dockerfile' || step === 'container-image') { - goTo('summary'); - } - }; + const next = async () => { + if (step === 'source') { + goTo(formData.sourceType === 'GIT' ? 'git-url' : formData.sourceType === 'GIT_SSH' ? 'ssh-url' : 'container-image'); + return; + } + if (step === 'git-url' || step === 'ssh-url') { + if (await loadBranches()) { + goTo('branch'); + } + return; + } + if (step === 'dockerfile' || step === 'container-image') { + goTo('summary'); + } + }; - const save = async (deployAfterSave: boolean) => { - await Toast.fromAction(() => saveAgentSource(null, formData, agent.id), 'Source saved', 'Saving source...'); - if (deployAfterSave) { - await Toast.fromAction(() => deployAgent(agent.id, true), 'Deployment started', 'Starting deployment...'); - closeDialog(true); - router.refresh(); - router.push(PathBuilderUtils.projectAgentDrawer(agent.projectId, agent.id, 'configuration')); - return; - } - closeDialog(true); - router.refresh(); - }; + const save = async (deployAfterSave: boolean) => { + await Toast.fromAction(() => saveAgentSource(null, formData, agent.id), 'Source saved', 'Saving source...'); + if (deployAfterSave) { + await Toast.fromAction(() => deployAgent(agent.id, true), 'Deployment started', 'Starting deployment...'); + closeDialog(true); + router.refresh(); + router.push(PathBuilderUtils.projectAgentDrawer(agent.projectId, agent.id, 'configuration')); + return; + } + closeDialog(true); + router.refresh(); + }; - const nextDisabled = getNextDisabled(step, formData, publicKey, isLoadingBranches, isEnsuringKey, isDetectingDockerfile); + const nextDisabled = getNextDisabled(step, formData, publicKey, isLoadingBranches, isEnsuringKey, isDetectingDockerfile); - return ( - <> - - {currentTitle} - - {step === 'summary' ? 'Review the agent source before saving.' : 'Connect a source with the details QuickStack needs to run this agent.'} - - + return ( + <> + + {currentTitle} + + {step === 'summary' ? 'Review the agent source before saving.' : 'Connect a source with the details QuickStack needs to run this agent.'} + + -
- - {step === 'source' && ( - - )} - {step === 'git-url' && ( - - )} - {step === 'ssh-url' && ( - - )} - {step === 'branch' && ( - - )} - {step === 'dockerfile' && ( - updateFormData({ dockerfilePath })} - onRetry={() => void runDockerfileDetection()} - /> - )} - {step === 'container-image' && ( - - )} - {step === 'summary' && ( - - )} -
+
+ + {step === 'source' && } + {step === 'git-url' && ( + + )} + {step === 'ssh-url' && ( + + )} + {step === 'branch' && } + {step === 'dockerfile' && ( + updateFormData({ dockerfilePath })} + onRetry={() => void runDockerfileDetection()} + /> + )} + {step === 'container-image' && ( + + )} + {step === 'summary' && ( + + )} +
- -
-
- {history.length > 0 && ( - - )} -
-
- {step === 'summary' ? ( - <> - - - - ) : step === 'branch' ? null : ( - - )} -
-
-
- - ); + +
+
+ {history.length > 0 && ( + + )} +
+
+ {step === 'summary' ? ( + <> + + + + ) : step === 'branch' ? null : ( + + )} +
+
+
+ + ); } function getStepTitle(step: StepId, sourceType: AgentSourceInfoInputModel['sourceType']) { - if (step === 'source') return 'Choose source'; - if (step === 'git-url') return 'Connect Git HTTPS'; - if (step === 'ssh-url') return 'Connect Git SSH'; - if (step === 'branch') return 'Choose Git Branch'; - if (step === 'dockerfile') return 'Confirm Dockerfile Path'; - if (step === 'container-image') return 'Connect Docker Container Image'; - if (step === 'summary') return `${sourceTypeLabels[sourceType as SourceType]} Summary`; - return 'Connect Agent Source'; + if (step === 'source') return 'Choose source'; + if (step === 'git-url') return 'Connect Git HTTPS'; + if (step === 'ssh-url') return 'Connect Git SSH'; + if (step === 'branch') return 'Choose Git Branch'; + if (step === 'dockerfile') return 'Confirm Dockerfile Path'; + if (step === 'container-image') return 'Connect Docker Container Image'; + if (step === 'summary') return `${sourceTypeLabels[sourceType as SourceType]} Summary`; + return 'Connect Agent Source'; } -function getNextDisabled(step: StepId, formData: AgentSourceInfoInputModel, publicKey: string | undefined, isLoadingBranches: boolean, isEnsuringKey: boolean, isDetectingDockerfile: boolean) { - if (isLoadingBranches || isEnsuringKey || isDetectingDockerfile) return true; - if (step === 'git-url') return !formData.gitUrl?.trim(); - if (step === 'ssh-url') return !formData.gitUrl?.trim() || !publicKey; - if (step === 'branch') return !formData.gitBranch; - if (step === 'dockerfile') return !formData.dockerfilePath?.trim(); - if (step === 'container-image') return !formData.containerImageSource?.trim(); - return false; +function getNextDisabled( + step: StepId, + formData: AgentSourceInfoInputModel, + publicKey: string | undefined, + isLoadingBranches: boolean, + isEnsuringKey: boolean, + isDetectingDockerfile: boolean +) { + if (isLoadingBranches || isEnsuringKey || isDetectingDockerfile) return true; + if (step === 'git-url') return !formData.gitUrl?.trim(); + if (step === 'ssh-url') return !formData.gitUrl?.trim() || !publicKey; + if (step === 'branch') return !formData.gitBranch; + if (step === 'dockerfile') return !formData.dockerfilePath?.trim(); + if (step === 'container-image') return !formData.containerImageSource?.trim(); + return false; } function resetForSourceType(current: AgentSourceInfoInputModel, sourceType: SourceType): AgentSourceInfoInputModel { - if (sourceType === current.sourceType) return current; - if (sourceType === 'GIT') { - return { - sourceType, - buildMethod: 'DOCKERFILE', - gitUrl: '', - gitBranch: '', - gitUsername: '', - gitToken: '', - dockerfilePath: defaultDockerfilePath, - }; - } - if (sourceType === 'GIT_SSH') { - return { - sourceType, - buildMethod: 'DOCKERFILE', - gitUrl: '', - gitBranch: '', - dockerfilePath: defaultDockerfilePath, - }; - } - return { - sourceType, - buildMethod: 'DOCKERFILE', - containerImageSource: '', - containerRegistryUsername: '', - containerRegistryPassword: '', - dockerfilePath: defaultDockerfilePath, - }; + if (sourceType === current.sourceType) return current; + if (sourceType === 'GIT') { + return { sourceType, buildMethod: 'DOCKERFILE', gitUrl: '', gitBranch: '', gitUsername: '', gitToken: '', dockerfilePath: defaultDockerfilePath }; + } + if (sourceType === 'GIT_SSH') { + return { sourceType, buildMethod: 'DOCKERFILE', gitUrl: '', gitBranch: '', dockerfilePath: defaultDockerfilePath }; + } + return { + sourceType, + buildMethod: 'DOCKERFILE', + containerImageSource: '', + containerRegistryUsername: '', + containerRegistryPassword: '', + dockerfilePath: defaultDockerfilePath + }; } function toSourceInput(agent: AgentExtendedModel): AgentSourceInfoInputModel { - return { - sourceType: agent.sourceType as SourceType, - buildMethod: 'DOCKERFILE', - containerImageSource: agent.containerImageSource ?? '', - containerRegistryUsername: agent.containerRegistryUsername ?? '', - containerRegistryPassword: agent.containerRegistryPassword ?? '', - gitUrl: agent.gitUrl ?? '', - gitBranch: agent.gitBranch ?? '', - gitUsername: agent.gitUsername ?? '', - gitToken: agent.gitToken ?? '', - dockerfilePath: agent.dockerfilePath ?? defaultDockerfilePath, - }; + return { + sourceType: agent.sourceType as SourceType, + buildMethod: 'DOCKERFILE', + containerImageSource: agent.containerImageSource ?? '', + containerRegistryUsername: agent.containerRegistryUsername ?? '', + containerRegistryPassword: agent.containerRegistryPassword ?? '', + gitUrl: agent.gitUrl ?? '', + gitBranch: agent.gitBranch ?? '', + gitUsername: agent.gitUsername ?? '', + gitToken: agent.gitToken ?? '', + dockerfilePath: agent.dockerfilePath ?? defaultDockerfilePath + }; } diff --git a/src/app/project/[projectId]/agents/details/volumes/agent-volume-edit-overlay.tsx b/src/app/project/[projectId]/agents/details/volumes/agent-volume-edit-overlay.tsx index c348bf3a..b8855b75 100644 --- a/src/app/project/[projectId]/agents/details/volumes/agent-volume-edit-overlay.tsx +++ b/src/app/project/[projectId]/agents/details/volumes/agent-volume-edit-overlay.tsx @@ -1,228 +1,221 @@ -'use client' +'use client'; -import type { z } from "zod"; -import { useActionState, useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { toast } from "sonner"; -import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { SubmitButton } from "@/components/custom/submit-button"; -import StorageClassCombobox from "@/components/custom/storage-class-combobox"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { AgentVolumeEditModel, agentVolumeEditZodModel } from "@/shared/model/volume-edit.model"; -import { useDialogContext } from "@/frontend/states/dialog-context"; -import { saveAgentVolume } from "../configuration/actions"; -import { StorageClassUtils } from "@/shared/utils/storage-class.utils"; -import { Constants } from "@/shared/utils/constants"; +import type { z } from 'zod'; +import { useActionState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { SubmitButton } from '@/components/custom/submit-button'; +import StorageClassCombobox from '@/components/custom/storage-class-combobox'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { AgentVolumeEditModel, agentVolumeEditZodModel } from '@/shared/model/volume-edit.model'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { saveAgentVolume } from '../configuration/actions'; +import { StorageClassUtils } from '@/shared/utils/storage-class.utils'; +import { Constants } from '@/shared/utils/constants'; export default function AgentVolumeEditOverlay({ - existingVolume, - agentId, - storageClasses + existingVolume, + agentId, + storageClasses }: { - existingVolume?: AgentVolumeEditModel & { id?: string; storageClassName: string }; - agentId: string; - storageClasses: string[]; + existingVolume?: AgentVolumeEditModel & { id?: string; storageClassName: string }; + agentId: string; + storageClasses: string[]; }) { - const { closeDialog } = useDialogContext(); - const defaultStorageClassName = existingVolume?.storageClassName || StorageClassUtils.getDefaultStorageClass(storageClasses); + const { closeDialog } = useDialogContext(); + const defaultStorageClassName = existingVolume?.storageClassName || StorageClassUtils.getDefaultStorageClass(storageClasses); - const form = useForm, unknown, z.output>({ - resolver: zodResolver(agentVolumeEditZodModel), - defaultValues: { - containerMountPath: existingVolume?.containerMountPath || '', - size: existingVolume?.size || 1024, - storageClassName: defaultStorageClassName, - volumeType: existingVolume?.volumeType || 'ALL', - accessMode: existingVolume?.accessMode || 'ReadWriteMany', - } as AgentVolumeEditModel, - }); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(agentVolumeEditZodModel), + defaultValues: { + containerMountPath: existingVolume?.containerMountPath || '', + size: existingVolume?.size || 1024, + storageClassName: defaultStorageClassName, + volumeType: existingVolume?.volumeType || 'ALL', + accessMode: existingVolume?.accessMode || 'ReadWriteMany' + } as AgentVolumeEditModel + }); - const volumeType = form.watch('volumeType'); + const volumeType = form.watch('volumeType'); - useEffect(() => { - if (volumeType === 'ALL') { - form.setValue('accessMode', 'ReadWriteMany'); - } - }, [form, volumeType]); + useEffect(() => { + if (volumeType === 'ALL') { + form.setValue('accessMode', 'ReadWriteMany'); + } + }, [form, volumeType]); - const [state, formAction] = useActionState( - (state: ServerActionResult, payload: AgentVolumeEditModel) => - saveAgentVolume(state, { ...payload, id: existingVolume?.id }, agentId), - FormUtils.getInitialFormState() - ); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AgentVolumeEditModel) => + saveAgentVolume(state, { ...payload, id: existingVolume?.id }, agentId), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - form.reset(); - toast.success('Volume saved successfully', { description: 'Click "Deploy" to apply the changes.' }); - closeDialog(); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [closeDialog, form, state]); + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('Volume saved successfully', { description: 'Click "Deploy" to apply the changes.' }); + closeDialog(); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [closeDialog, form, state]); - useEffect(() => { - if (existingVolume) { - form.reset({ - containerMountPath: existingVolume.containerMountPath || '', - size: existingVolume.size || 1024, - storageClassName: defaultStorageClassName, - volumeType: existingVolume.volumeType || 'ALL', - accessMode: existingVolume.accessMode || 'ReadWriteMany', - } as AgentVolumeEditModel); - } - }, [defaultStorageClassName, existingVolume, form]); + useEffect(() => { + if (existingVolume) { + form.reset({ + containerMountPath: existingVolume.containerMountPath || '', + size: existingVolume.size || 1024, + storageClassName: defaultStorageClassName, + volumeType: existingVolume.volumeType || 'ALL', + accessMode: existingVolume.accessMode || 'ReadWriteMany' + } as AgentVolumeEditModel); + } + }, [defaultStorageClassName, existingVolume, form]); - const storageClassOptions = Array.from(new Set([...storageClasses, existingVolume?.storageClassName].filter(Boolean) as string[])); + const storageClassOptions = Array.from(new Set([...storageClasses, existingVolume?.storageClassName].filter(Boolean) as string[])); - return <> - - {existingVolume ? 'Edit Volume' : 'Add Volume'} - - {existingVolume - ? 'Update the volume configuration.' - : 'Add a persistent volume to this workload.'} - - -
- form.handleSubmit((data) => formAction(data))()} - className="space-y-4" - > - ( - - Mount Path - - - - - - )} - /> - ( - - Size (MiB) - - - - - - )} - /> - ( - - Storage Class - - form.setValue("storageClassName", value)} - /> - - - This cannot be changed after creation. - - - - )} - /> - ( - - Volume Type - - - - - {volumeType === 'ALL' - ? 'Shared by every sandbox and persists after the sandbox is deleted.' - : volumeType === Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG - ? 'Private to each Custom Tag and persists across sandbox restarts, deletion and idle expiry.' - : 'Private to each sandbox and deleted with it.'} - {' '}This cannot be changed after creation. - - - - )} - /> - ( - - Access Mode - - - - - {volumeType === 'ALL' - ? 'Shared volumes require ReadWriteMany.' - : volumeType === Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG - ? 'Per-Custom-Tag volumes may use ReadWriteOnce or ReadWriteMany.' - : 'Per-sandbox volumes may use ReadWriteOnce.'} - {' '}This cannot be changed after creation. - - - - )} - /> - {state.status === 'error' &&

{state.message}

} - Save - - - ; + return ( + <> + + {existingVolume ? 'Edit Volume' : 'Add Volume'} + {existingVolume ? 'Update the volume configuration.' : 'Add a persistent volume to this workload.'} + +
+ form.handleSubmit((data) => formAction(data))()} className="space-y-4"> + ( + + Mount Path + + + + + + )} + /> + ( + + Size (MiB) + + + + + + )} + /> + ( + + Storage Class + + form.setValue('storageClassName', value)} + /> + + This cannot be changed after creation. + + + )} + /> + ( + + Volume Type + + + + + {volumeType === 'ALL' + ? 'Shared by every sandbox and persists after the sandbox is deleted.' + : volumeType === Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG + ? 'Private to each Custom Tag and persists across sandbox restarts, deletion and idle expiry.' + : 'Private to each sandbox and deleted with it.'}{' '} + This cannot be changed after creation. + + + + )} + /> + ( + + Access Mode + + + + + {volumeType === 'ALL' + ? 'Shared volumes require ReadWriteMany.' + : volumeType === Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG + ? 'Per-Custom-Tag volumes may use ReadWriteOnce or ReadWriteMany.' + : 'Per-sandbox volumes may use ReadWriteOnce.'}{' '} + This cannot be changed after creation. + + + + )} + /> + {state.status === 'error' &&

{state.message}

} + Save + + + + ); } diff --git a/src/app/project/[projectId]/agents/details/volumes/agent-volumes-card.tsx b/src/app/project/[projectId]/agents/details/volumes/agent-volumes-card.tsx index 1d67fd97..26f7717a 100644 --- a/src/app/project/[projectId]/agents/details/volumes/agent-volumes-card.tsx +++ b/src/app/project/[projectId]/agents/details/volumes/agent-volumes-card.tsx @@ -1,107 +1,124 @@ 'use client'; -import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from "@/components/custom/drawer-card"; -import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Button } from "@/components/ui/button"; -import { EditIcon, Plus, TrashIcon } from "lucide-react"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { useConfirmDialog, useDialog } from "@/frontend/states/zustand.states"; -import AgentVolumeEditOverlay from "./agent-volume-edit-overlay"; -import { AgentVolume } from "@prisma/client"; -import { AgentVolumeEditModel } from "@/shared/model/volume-edit.model"; -import { deleteAgentVolume } from "../configuration/actions"; -import { Constants } from "@/shared/utils/constants"; +import { DrawerCard, DrawerCardContent, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { EditIcon, Plus, TrashIcon } from 'lucide-react'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { useConfirmDialog, useDialog } from '@/frontend/states/zustand.states'; +import AgentVolumeEditOverlay from './agent-volume-edit-overlay'; +import { AgentVolume } from '@prisma/client'; +import { AgentVolumeEditModel } from '@/shared/model/volume-edit.model'; +import { deleteAgentVolume } from '../configuration/actions'; +import { Constants } from '@/shared/utils/constants'; function formatSize(mb: number): string { - if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GiB`; - return `${mb} MiB`; + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GiB`; + return `${mb} MiB`; } function volumeTypeLabel(volumeType: string): string { - switch (volumeType) { - case 'PER_SANDBOX': - return 'Per Sandbox'; - case Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG: - return 'Per Custom Tag'; - default: - return 'All (Shared)'; - } + switch (volumeType) { + case 'PER_SANDBOX': + return 'Per Sandbox'; + case Constants.QS_VOLUME_TYPE_PER_CUSTOM_TAG: + return 'Per Custom Tag'; + default: + return 'All (Shared)'; + } } -export default function AgentVolumesCard({ volumes, projectId, readonly, storageClasses }: { - volumes: AgentVolume[]; - projectId: string; - readonly: boolean; - storageClasses: string[]; +export default function AgentVolumesCard({ + volumes, + projectId, + readonly, + storageClasses +}: { + volumes: AgentVolume[]; + projectId: string; + readonly: boolean; + storageClasses: string[]; }) { - const { openConfirmDialog } = useConfirmDialog(); - const { openDialog } = useDialog(); + const { openConfirmDialog } = useConfirmDialog(); + const { openDialog } = useDialog(); - const asyncDeleteVolume = async (volumeId: string) => { - const confirm = await openConfirmDialog({ - title: "Delete Volume", - description: "The volume will be removed permanently. Are you sure you want to delete this volume?", - okButton: "Delete Volume" - }); - if (confirm) { - await Toast.fromAction(() => deleteAgentVolume(volumeId)); - } - }; + const asyncDeleteVolume = async (volumeId: string) => { + const confirm = await openConfirmDialog({ + title: 'Delete Volume', + description: 'The volume will be removed permanently. Are you sure you want to delete this volume?', + okButton: 'Delete Volume' + }); + if (confirm) { + await Toast.fromAction(() => deleteAgentVolume(volumeId)); + } + }; - const openEditVolumeDialog = async (volume?: AgentVolume) => { - await openDialog(, { - maxWidth: 'max-w-xl', - }); - }; + const openEditVolumeDialog = async (volume?: AgentVolume) => { + await openDialog( + , + { maxWidth: 'max-w-xl' } + ); + }; - return <> - - - Volumes - - Persistent storage volumes attached to this workload. - - - {volumes.length > 0 && - - {volumes.length} Volumes - - - Mount Path - Type - Access Mode - Size - Storage Class - {!readonly && } - - - - {volumes.map(volume => ( - - {volume.containerMountPath} - {volumeTypeLabel(volume.volumeType)} - {volume.accessMode} - {formatSize(volume.size)} - {volume.storageClassName} - {!readonly && -
- - -
-
} -
- ))} -
-
-
} - {!readonly && - - } -
- ; + return ( + <> + + + Volumes + Persistent storage volumes attached to this workload. + + {volumes.length > 0 && ( + + + {volumes.length} Volumes + + + Mount Path + Type + Access Mode + Size + Storage Class + {!readonly && } + + + + {volumes.map((volume) => ( + + {volume.containerMountPath} + {volumeTypeLabel(volume.volumeType)} + {volume.accessMode} + {formatSize(volume.size)} + {volume.storageClassName} + {!readonly && ( + +
+ + +
+
+ )} +
+ ))} +
+
+
+ )} + {!readonly && ( + + + + )} +
+ + ); } diff --git a/src/app/project/[projectId]/apps/create/create-template-app-setup-dialog.tsx b/src/app/project/[projectId]/apps/create/create-template-app-setup-dialog.tsx index 97d6bb15..e4c97268 100644 --- a/src/app/project/[projectId]/apps/create/create-template-app-setup-dialog.tsx +++ b/src/app/project/[projectId]/apps/create/create-template-app-setup-dialog.tsx @@ -1,133 +1,115 @@ -'use client' +'use client'; -import type { z } from "zod"; -import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" +import type { z } from 'zod'; +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; -import { Fragment, useActionState, useEffect } from "react"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model" -import { toast } from "sonner" -import { AppTemplateModel, appTemplateZodModel } from "@/shared/model/app-template.model" -import { createAppFromTemplate } from "../../actions" -import { ScrollArea } from "@/components/ui/scroll-area" -import { useDialogContext } from "@/frontend/states/dialog-context" -import { TemplateDetailsPanel } from "../../template-details-panel" +import { Fragment, useActionState, useEffect } from 'react'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { toast } from 'sonner'; +import { AppTemplateModel, appTemplateZodModel } from '@/shared/model/app-template.model'; +import { createAppFromTemplate } from '../../actions'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { TemplateDetailsPanel } from '../../template-details-panel'; -export default function CreateTemplateAppSetupDialog({ - appTemplate, - projectId -}: { - appTemplate: AppTemplateModel; - projectId: string; -}) { - const { closeDialog } = useDialogContext(); +export default function CreateTemplateAppSetupDialog({ appTemplate, projectId }: { appTemplate: AppTemplateModel; projectId: string }) { + const { closeDialog } = useDialogContext(); - const form = useForm, unknown, z.output>({ - resolver: zodResolver(appTemplateZodModel), - defaultValues: appTemplate - }); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(appTemplateZodModel), + defaultValues: appTemplate + }); - const [state, formAction] = useActionState((state: ServerActionResult, - payload: AppTemplateModel) => createAppFromTemplate(state, payload, projectId!), - FormUtils.getInitialFormState()); - - useEffect(() => { - if (state.status === 'success') { - form.reset(); - const appLabel = ((appTemplate?.templates.length ?? 0) > 1) ? 'Apps' : 'App'; - toast.success(`${appLabel} Created successfully`, { - description: `Click deploy to start the ${appLabel}.`, - }); - closeDialog(true); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [appTemplate?.templates.length, closeDialog, form, state]); - - useEffect(() => { - form.reset(appTemplate); - }, [appTemplate, form, projectId]); - - return ( - <> - - Create App "{appTemplate.name}" - Review the service settings before creating the application. - -
- form.handleSubmit((data) => formAction(data))()}> - -
-
-
-

Configuration

-

Adjust the names, images, and credentials required by this template.

-
-
- {appTemplate.templates.map((t, templateIndex) => ( - - {templateIndex > 0 &&
} - {appTemplate.templates.length > 1 && -

{t.appModel.name}

} - ( - - App Name - - - - - - )} - /> - {t.inputSettings.map((input, settingsIndex) => ( - ( - - {input.label} - - - - {input.randomGeneratedIfEmpty && - If left empty, a random value will be generated.} - - - )} - /> - ))} -
- ))} -
-
- -
-
- -

{state.message}

- Create app -
-
- - - ) + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AppTemplateModel) => createAppFromTemplate(state, payload, projectId!), + FormUtils.getInitialFormState() + ); + useEffect(() => { + if (state.status === 'success') { + form.reset(); + const appLabel = (appTemplate?.templates.length ?? 0) > 1 ? 'Apps' : 'App'; + toast.success(`${appLabel} Created successfully`, { description: `Click deploy to start the ${appLabel}.` }); + closeDialog(true); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [appTemplate?.templates.length, closeDialog, form, state]); + useEffect(() => { + form.reset(appTemplate); + }, [appTemplate, form, projectId]); + return ( + <> + + Create App "{appTemplate.name}" + Review the service settings before creating the application. + +
+ form.handleSubmit((data) => formAction(data))()}> + +
+
+
+

Configuration

+

Adjust the names, images, and credentials required by this template.

+
+
+ {appTemplate.templates.map((t, templateIndex) => ( + + {templateIndex > 0 &&
} + {appTemplate.templates.length > 1 &&

{t.appModel.name}

} + ( + + App Name + + + + + + )} + /> + {t.inputSettings.map((input, settingsIndex) => ( + ( + + {input.label} + + + + {input.randomGeneratedIfEmpty && If left empty, a random value will be generated.} + + + )} + /> + ))} +
+ ))} +
+
+ +
+
+ +

+ {state.message} +

+ Create app +
+
+ + + ); } diff --git a/src/app/project/[projectId]/apps/create/edit-app-dialog.tsx b/src/app/project/[projectId]/apps/create/edit-app-dialog.tsx index daaf7b8f..499daaca 100644 --- a/src/app/project/[projectId]/apps/create/edit-app-dialog.tsx +++ b/src/app/project/[projectId]/apps/create/edit-app-dialog.tsx @@ -1,48 +1,44 @@ -'use client' +'use client'; -import { Toast } from "@/frontend/utils/toast.utils"; -import { createApp } from "../../actions"; -import { useRouter } from "next/navigation"; -import { cloneElement, type MouseEvent, type ReactElement } from "react"; -import type { App } from "@prisma/client"; -import { useInputDialog } from "@/frontend/states/zustand.states"; -import { PathBuilderUtils } from "@/shared/utils/path-builder.utils"; +import { Toast } from '@/frontend/utils/toast.utils'; +import { createApp } from '../../actions'; +import { useRouter } from 'next/navigation'; +import { cloneElement, type MouseEvent, type ReactElement } from 'react'; +import type { App } from '@prisma/client'; +import { useInputDialog } from '@/frontend/states/zustand.states'; +import { PathBuilderUtils } from '@/shared/utils/path-builder.utils'; export function EditAppDialog({ - children, - projectId, - existingItem, - openAppAfterCreate = true + children, + projectId, + existingItem, + openAppAfterCreate = true }: { - children?: ReactElement<{ onClick?: (event: MouseEvent) => void }>; - projectId: string; - existingItem?: Pick; - openAppAfterCreate?: boolean; + children?: ReactElement<{ onClick?: (event: MouseEvent) => void }>; + projectId: string; + existingItem?: Pick; + openAppAfterCreate?: boolean; }) { + const router = useRouter(); + const { openInputDialog } = useInputDialog(); - const router = useRouter(); - const { openInputDialog } = useInputDialog(); + const createAppFunc = async () => { + const name = await openInputDialog({ title: 'Create App', description: 'Name your new App.', fieldName: 'Name', inputValue: existingItem?.name ?? '' }); + if (!name) { + return; + } + const result = await Toast.fromAction(() => createApp(name, projectId, existingItem?.id)); + if (result.status === 'success' && !existingItem && openAppAfterCreate) { + router.push(PathBuilderUtils.projectAppDrawer(projectId, result.data!.id, 'settings')); + } + }; - const createAppFunc = async () => { - const name = await openInputDialog({ - title: "Create App", - description: "Name your new App.", - fieldName: "Name", - inputValue: existingItem?.name ?? '' - }) - if (!name) { return; } - const result = await Toast.fromAction(() => createApp(name, projectId, existingItem?.id)); - if (result.status === "success" && !existingItem && openAppAfterCreate) { - router.push(PathBuilderUtils.projectAppDrawer(projectId, result.data!.id, 'settings')); - } - }; + if (!children) return null; - if (!children) return null; - - return cloneElement(children, { - onClick: (event) => { - children.props.onClick?.(event); - void createAppFunc(); - }, - }); + return cloneElement(children, { + onClick: (event) => { + children.props.onClick?.(event); + void createAppFunc(); + } + }); } diff --git a/src/app/project/[projectId]/apps/create/new-network-policy-explanation-dialog.tsx b/src/app/project/[projectId]/apps/create/new-network-policy-explanation-dialog.tsx index 19209049..932e32ef 100644 --- a/src/app/project/[projectId]/apps/create/new-network-policy-explanation-dialog.tsx +++ b/src/app/project/[projectId]/apps/create/new-network-policy-explanation-dialog.tsx @@ -8,60 +8,53 @@ import { Toast } from '@/frontend/utils/toast.utils'; import { acknowledgeNewNetworkPolicyExplanation } from '../../actions'; export default function NewNetworkPolicyExplanationDialog() { - const { closeDialog } = useDialogContext(); - const [saving, setSaving] = useState(false); - - const acknowledge = async () => { - setSaving(true); - try { - await Toast.fromAction( - acknowledgeNewNetworkPolicyExplanation, - 'Network policy information acknowledged.', - 'Saving...', - ); - closeDialog(true); - } finally { - setSaving(false); - } - }; - - return ( - <> - - New: Explicit connections between apps - - Network Policies now give you direct control over which apps can communicate. - - - - - -
-

- Connections between apps must now be created explicitly. The video shows how to connect two apps in the project network graph. -

-

- If you used QuickStack before this change, your existing Network Policies were migrated automatically. The migration may have created more connections than you need, so please review the graph and adjust them if necessary. -

-
- - - - - - ); + const { closeDialog } = useDialogContext(); + const [saving, setSaving] = useState(false); + + const acknowledge = async () => { + setSaving(true); + try { + await Toast.fromAction(acknowledgeNewNetworkPolicyExplanation, 'Network policy information acknowledged.', 'Saving...'); + closeDialog(true); + } finally { + setSaving(false); + } + }; + + return ( + <> + + New: Explicit connections between apps + Network Policies now give you direct control over which apps can communicate. + + + + +
+

Connections between apps must now be created explicitly. The video shows how to connect two apps in the project network graph.

+

+ If you used QuickStack before this change, your existing Network Policies were migrated automatically. The migration may have created more connections + than you need, so please review the graph and adjust them if necessary. +

+
+ + + + + + ); } diff --git a/src/app/project/[projectId]/apps/details/actions.ts b/src/app/project/[projectId]/apps/details/actions.ts index 9fba3445..1978bedb 100644 --- a/src/app/project/[projectId]/apps/details/actions.ts +++ b/src/app/project/[projectId]/apps/details/actions.ts @@ -1,38 +1,37 @@ -'use server' - -import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; -import appService from "@/server/services/app.service"; -import deploymentService from "@/server/services/deployment.service"; -import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils"; -import eventService from "@/server/services/event.service"; +'use server'; +import { SuccessActionResult } from '@/shared/model/server-action-error-return.model'; +import appService from '@/server/services/app.service'; +import deploymentService from '@/server/services/deployment.service'; +import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from '@/server/utils/action-wrapper.utils'; +import eventService from '@/server/services/event.service'; export const deploy = async (appId: string, forceBuild = false) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - await appService.buildAndDeploy(appId, forceBuild); - return new SuccessActionResult(undefined, 'Successfully started deployment.'); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + await appService.buildAndDeploy(appId, forceBuild); + return new SuccessActionResult(undefined, 'Successfully started deployment.'); + }); export const stopApp = async (appId: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - const app = await appService.getExtendedById(appId); - await deploymentService.setReplicasForDeployment(app.projectId, app.id, 0); - return new SuccessActionResult(undefined, 'Successfully stopped app.'); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + const app = await appService.getExtendedById(appId); + await deploymentService.setReplicasForDeployment(app.projectId, app.id, 0); + return new SuccessActionResult(undefined, 'Successfully stopped app.'); + }); export const startApp = async (appId: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - const app = await appService.getExtendedById(appId); - await deploymentService.setReplicasForDeployment(app.projectId, app.id, app.replicas); - return new SuccessActionResult(undefined, 'Successfully started app.'); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + const app = await appService.getExtendedById(appId); + await deploymentService.setReplicasForDeployment(app.projectId, app.id, app.replicas); + return new SuccessActionResult(undefined, 'Successfully started app.'); + }); export const getLatestAppEvents = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - const app = await appService.getById(appId); - return await eventService.getEventsForApp(app.projectId, app.id); - }); + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + const app = await appService.getById(appId); + return await eventService.getEventsForApp(app.projectId, app.id); + }); diff --git a/src/app/project/[projectId]/apps/details/advanced-network-settings.test.ts b/src/app/project/[projectId]/apps/details/advanced-network-settings.test.ts index 744a3de8..0eb38911 100644 --- a/src/app/project/[projectId]/apps/details/advanced-network-settings.test.ts +++ b/src/app/project/[projectId]/apps/details/advanced-network-settings.test.ts @@ -8,15 +8,19 @@ import { RolePermissionEnum } from '@/shared/model/role-extended.model'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; vi.mock('@/components/ui/drawer', () => ({ - Drawer: ({ children }: React.PropsWithChildren) => children, - DrawerContent: ({ children }: React.PropsWithChildren) => children, - DrawerDescription: ({ children }: React.PropsWithChildren) => children, - DrawerHeader: ({ children }: React.PropsWithChildren) => children, - DrawerTitle: ({ children }: React.PropsWithChildren) => children, + Drawer: ({ children }: React.PropsWithChildren) => children, + DrawerContent: ({ children }: React.PropsWithChildren) => children, + DrawerDescription: ({ children }: React.PropsWithChildren) => children, + DrawerHeader: ({ children }: React.PropsWithChildren) => children, + DrawerTitle: ({ children }: React.PropsWithChildren) => children })); vi.mock('@/components/ui/scroll-area', () => ({ ScrollArea: ({ children }: React.PropsWithChildren) => children })); -vi.mock('@/app/project/[projectId]/apps/details/network/ports/node-ports-card', () => ({ default: ({ app }: { app: AppExtendedModel }) => React.createElement('div', null, `Ports: ${app.appNodePorts.length}`) })); -vi.mock('@/app/project/[projectId]/apps/details/network/policy/basic-auth', () => ({ default: ({ app }: { app: AppExtendedModel }) => React.createElement('div', null, `Credentials: ${app.appBasicAuths.length}`) })); +vi.mock('@/app/project/[projectId]/apps/details/network/ports/node-ports-card', () => ({ + default: ({ app }: { app: AppExtendedModel }) => React.createElement('div', null, `Ports: ${app.appNodePorts.length}`) +})); +vi.mock('@/app/project/[projectId]/apps/details/network/policy/basic-auth', () => ({ + default: ({ app }: { app: AppExtendedModel }) => React.createElement('div', null, `Credentials: ${app.appBasicAuths.length}`) +})); vi.mock('@/app/project/[projectId]/apps/details/network/policy/actions', () => ({ saveHealthCheck: vi.fn() })); vi.mock('@/app/project/workload-components/health-check-settings', () => ({ default: () => null })); vi.mock('@/app/project/[projectId]/apps/details/network/policy/network-policy', () => ({ default: () => null })); @@ -32,16 +36,21 @@ vi.mock('./environment/app-details-environment', () => ({ DrawerEnvironment: () afterEach(cleanup); it('updates network settings in an open drawer when app data changes', () => { - const app = { id: 'app', appNodePorts: [], appBasicAuths: [], appDomains: [], appFileMounts: [] } as unknown as AppExtendedModel; - const view = (value: AppExtendedModel) => React.createElement(AppDrawerAppContext.Provider, { value }, - React.createElement(NestedDrawerProvider, null, - React.createElement(DrawerSettings, { app: value, role: RolePermissionEnum.READWRITE, storageClasses: [] }), - ), + const app = { id: 'app', appNodePorts: [], appBasicAuths: [], appDomains: [], appFileMounts: [] } as unknown as AppExtendedModel; + const view = (value: AppExtendedModel) => + React.createElement( + AppDrawerAppContext.Provider, + { value }, + React.createElement( + NestedDrawerProvider, + null, + React.createElement(DrawerSettings, { app: value, role: RolePermissionEnum.READWRITE, storageClasses: [] }) + ) ); - const { rerender } = render(view(app)); - fireEvent.click(screen.getByRole('button', { name: 'Advanced network settings' })); - expect(screen.getByText('Ports: 0')).toBeTruthy(); - rerender(view({ ...app, appNodePorts: [{}], appBasicAuths: [{}] } as AppExtendedModel)); - expect(screen.getByText('Ports: 1')).toBeTruthy(); - expect(screen.getByText('Credentials: 1')).toBeTruthy(); + const { rerender } = render(view(app)); + fireEvent.click(screen.getByRole('button', { name: 'Advanced network settings' })); + expect(screen.getByText('Ports: 0')).toBeTruthy(); + rerender(view({ ...app, appNodePorts: [{}], appBasicAuths: [{}] } as AppExtendedModel)); + expect(screen.getByText('Ports: 1')).toBeTruthy(); + expect(screen.getByText('Credentials: 1')).toBeTruthy(); }); diff --git a/src/app/project/[projectId]/apps/details/app-details-drawer.tsx b/src/app/project/[projectId]/apps/details/app-details-drawer.tsx index 81568319..1b9080de 100644 --- a/src/app/project/[projectId]/apps/details/app-details-drawer.tsx +++ b/src/app/project/[projectId]/apps/details/app-details-drawer.tsx @@ -2,39 +2,13 @@ import { type Ref } from 'react'; -import { - BarChart3, - Bot, - Boxes, - Key, - Rocket, - Logs as LogsIcon, - Pencil, - Settings, - X, - RotateCwClock, -} from 'lucide-react'; +import { BarChart3, Bot, Boxes, Key, Rocket, Logs as LogsIcon, Pencil, Settings, X, RotateCwClock } from 'lucide-react'; import { Button } from '@/components/ui/button'; - - - import { ScrollArea } from '@/components/ui/scroll-area'; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, -} from '@/components/ui/drawer'; +import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '@/components/ui/empty'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; import { cn } from '@/frontend/utils/utils'; import { AppSourceUtils } from '@/frontend/utils/app-source.utils'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; @@ -51,267 +25,216 @@ import { DrawerBackupsTab } from './volumes/backups/app-details-backups-tab'; import { EditAppDialog } from '@/app/project/[projectId]/apps/create/edit-app-dialog'; import type { S3Target } from '@prisma/client'; import type { VolumeBackupExtendedModel } from '@/shared/model/volume-backup-extended.model'; -import { - AppDrawerNavigationUtils, - type AppDrawerTab, -} from '@/shared/utils/app-drawer-navigation.utils'; +import { AppDrawerNavigationUtils, type AppDrawerTab } from '@/shared/utils/app-drawer-navigation.utils'; import DrawerDeploymentsTab from './deployments/app-details-deployments-tab'; -export type AppDetailsDrawerNode = { - id: string; - kind: string; - external?: boolean; - name: string; - caption?: string; -}; +export type AppDetailsDrawerNode = { id: string; kind: string; external?: boolean; name: string; caption?: string }; import { DrawerTabScrollArea } from '@/app/project/workload-components/drawer/drawer-tab-scroll-area'; import { AppStatusActions } from './app-status-actions'; export function AppDetailsDrawer({ - contentRef, - node, - app, - role, - s3Targets, - storageClasses, - volumeBackups, - gitSshPublicKey, - open, - onOpenChange, - onOpenChangeComplete, - requestedTab, - onTabChange, - openEnvironment, - onEnvironmentOpened, + contentRef, + node, + app, + role, + s3Targets, + storageClasses, + volumeBackups, + gitSshPublicKey, + open, + onOpenChange, + onOpenChangeComplete, + requestedTab, + onTabChange, + openEnvironment, + onEnvironmentOpened }: { - contentRef?: Ref; - node: AppDetailsDrawerNode; - app?: AppExtendedModel; - role?: RolePermissionEnum; - s3Targets: S3Target[]; - storageClasses: string[]; - volumeBackups: VolumeBackupExtendedModel[]; - gitSshPublicKey?: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onOpenChangeComplete: (open: boolean) => void; - requestedTab?: string | null; - onTabChange: (tab: AppDrawerTab) => void; - openEnvironment?: boolean; - onEnvironmentOpened?: () => void; + contentRef?: Ref; + node: AppDetailsDrawerNode; + app?: AppExtendedModel; + role?: RolePermissionEnum; + s3Targets: S3Target[]; + storageClasses: string[]; + volumeBackups: VolumeBackupExtendedModel[]; + gitSshPublicKey?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onOpenChangeComplete: (open: boolean) => void; + requestedTab?: string | null; + onTabChange: (tab: AppDrawerTab) => void; + openEnvironment?: boolean; + onEnvironmentOpened?: () => void; }) { - const isApp = node.kind === 'APP'; - const isExternalApp = isApp && node.external; - const needsSourceConfiguration = - app && - role === RolePermissionEnum.READWRITE && - !AppSourceUtils.isConfiguredSource(app); - const hasVolumes = (app?.appVolumes.length ?? 0) > 0; + const isApp = node.kind === 'APP'; + const isExternalApp = isApp && node.external; + const needsSourceConfiguration = app && role === RolePermissionEnum.READWRITE && !AppSourceUtils.isConfiguredSource(app); + const hasVolumes = (app?.appVolumes.length ?? 0) > 0; - const activeTab = AppDrawerNavigationUtils.resolveTab( - app?.appType, - requestedTab, - hasVolumes, - ); + const activeTab = AppDrawerNavigationUtils.resolveTab(app?.appType, requestedTab, hasVolumes); - const handleTabChange = (tab: string) => { - const nextTab = AppDrawerNavigationUtils.resolveTab(app?.appType, tab, hasVolumes); - onTabChange(nextTab); - }; + const handleTabChange = (tab: string) => { + const nextTab = AppDrawerNavigationUtils.resolveTab(app?.appType, tab, hasVolumes); + onTabChange(nextTab); + }; - return ( - - + + + + - - -
-
- {isApp ? ( - - ) : ( - - )} -
-
- - {node.name} - {app && role === RolePermissionEnum.READWRITE && ( - - - - )} - - - {node.caption ?? - (isApp ? 'App' : 'Agent sandbox')} - -
- {app && !needsSourceConfiguration && ( -
- -
- )} -
- {app && role && !needsSourceConfiguration ? ( - - - - - Deployments - - {app.appType !== 'APP' && ( - - - DB Access - - )} - - - Logs - - - - Stats - - {hasVolumes && ( - - - Backups - - )} - - - Settings - - - - ) : ( -
- )} -
- {isExternalApp ? ( - - - - - - Settings unavailable - - This app belongs to another project. Its settings can only be edited from that project. - - - - ) : needsSourceConfiguration ? ( - - - - ) : app && role && ( - <> - - - - - - - - - - - - - - {hasVolumes && ( - - - - - - )} - {app.appType !== 'APP' && ( - - -
- {role === RolePermissionEnum.READWRITE && ( - - )} - -
-
-
- )} - - -
- -
-
-
- - )} -
-
-
-
-
- ); + + Close + + + +
+
+ {isApp ? : } +
+
+ + {node.name} + {app && role === RolePermissionEnum.READWRITE && ( + + + + )} + + {node.caption ?? (isApp ? 'App' : 'Agent sandbox')} +
+ {app && !needsSourceConfiguration && ( +
+ +
+ )} +
+ {app && role && !needsSourceConfiguration ? ( + + + + + Deployments + + {app.appType !== 'APP' && ( + + + DB Access + + )} + + + Logs + + + + Stats + + {hasVolumes && ( + + + Backups + + )} + + + Settings + + + + ) : ( +
+ )} +
+ {isExternalApp ? ( + + + + + + Settings unavailable + This app belongs to another project. Its settings can only be edited from that project. + + + ) : needsSourceConfiguration ? ( + + + + ) : ( + app && + role && ( + <> + + + + + + + + + + + + + + {hasVolumes && ( + + + + + + )} + {app.appType !== 'APP' && ( + + +
+ {role === RolePermissionEnum.READWRITE && } + +
+
+
+ )} + + +
+ +
+
+
+ + ) + )} +
+ + + + + ); } diff --git a/src/app/project/[projectId]/apps/details/app-details-settings.tsx b/src/app/project/[projectId]/apps/details/app-details-settings.tsx index b4334913..2c0fc39b 100644 --- a/src/app/project/[projectId]/apps/details/app-details-settings.tsx +++ b/src/app/project/[projectId]/apps/details/app-details-settings.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - Boxes, - Globe2, - HardDrive, - Network, - SlidersHorizontal, - Zap, -} from 'lucide-react'; +import { Boxes, Globe2, HardDrive, Network, SlidersHorizontal, Zap } from 'lucide-react'; import { useCallback, useContext, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import BasicAuth from '@/app/project/[projectId]/apps/details/network/policy/basic-auth'; @@ -31,208 +24,157 @@ import { AppDrawerAppContext } from './app-drawer-app-context'; import { DrawerCard, DrawerCardDescription, DrawerCardFooter, DrawerCardHeader, DrawerCardTitle } from '@/components/custom/drawer-card'; export function DrawerSettings({ - app, - role, - storageClasses, - gitSshPublicKey, - openEnvironment = false, - onEnvironmentOpened, + app, + role, + storageClasses, + gitSshPublicKey, + openEnvironment = false, + onEnvironmentOpened }: { - app: AppExtendedModel; - role: RolePermissionEnum; - storageClasses: string[]; - gitSshPublicKey?: string; - openEnvironment?: boolean; - onEnvironmentOpened?: () => void; + app: AppExtendedModel; + role: RolePermissionEnum; + storageClasses: string[]; + gitSshPublicKey?: string; + openEnvironment?: boolean; + onEnvironmentOpened?: () => void; }) { - const readonly = role !== RolePermissionEnum.READWRITE; - const { openNestedDrawer } = useNestedDrawer(); - const openEnvironmentEditor = useCallback(() => { - openNestedDrawer({ - title: 'Environment variables', - description: <>Values are encrypted at rest and never shown. Names starting with QS_ are reserved by QuickStack., - content: , - }); - }, [app, openNestedDrawer, readonly]); + const readonly = role !== RolePermissionEnum.READWRITE; + const { openNestedDrawer } = useNestedDrawer(); + const openEnvironmentEditor = useCallback(() => { + openNestedDrawer({ + title: 'Environment variables', + description: ( + <> + Values are encrypted at rest and never shown. Names starting with QS_ are reserved by + QuickStack. + + ), + content: + }); + }, [app, openNestedDrawer, readonly]); - useEffect(() => { - if (!openEnvironment) return; - openEnvironmentEditor(); - onEnvironmentOpened?.(); - }, [onEnvironmentOpened, openEnvironment, openEnvironmentEditor]); + useEffect(() => { + if (!openEnvironment) return; + openEnvironmentEditor(); + onEnvironmentOpened?.(); + }, [onEnvironmentOpened, openEnvironment, openEnvironmentEditor]); - return ( -
-
- - - - - + return ( +
+
+ + + + + - - - Advanced Container Configuration - Configure the container command, arguments, isolation and other advanced settings. - - - - - - - - - - - - - - Network Policies - Configure network policies to control the traffic flow. - - - - - - - - Advanced Network Settings - Configure node ports and basic authentication. - - - - - - - - - - - - - - Health Checks - - Configure healthchecks so that k3s can automatically monitor when your application is fully started up and ready to receive traffic. - - - - - - - -
-
- ); + + + Advanced Container Configuration + Configure the container command, arguments, isolation and other advanced settings. + + + + + +
+ + + + + + + + Network Policies + Configure network policies to control the traffic flow. + + + + + + + + Advanced Network Settings + Configure node ports and basic authentication. + + + + + + + + + + + + + + Health Checks + + Configure healthchecks so that k3s can automatically monitor when your application is fully started up and ready to receive traffic. + + + + + + + +
+
+ ); } function AdvancedNetworkSettings({ readonly }: { readonly: boolean }) { - const app = useContext(AppDrawerAppContext); - if (!app) return null; + const app = useContext(AppDrawerAppContext); + if (!app) return null; - return
- - -
; + return ( +
+ + +
+ ); } diff --git a/src/app/project/[projectId]/apps/details/app-status-actions.tsx b/src/app/project/[projectId]/apps/details/app-status-actions.tsx index 0a88543a..a5bb1716 100644 --- a/src/app/project/[projectId]/apps/details/app-status-actions.tsx +++ b/src/app/project/[projectId]/apps/details/app-status-actions.tsx @@ -1,29 +1,9 @@ 'use client'; - -import { - ExternalLink, - Hammer, - Play, - Rocket, - Square, -} from 'lucide-react'; +import { ExternalLink, Hammer, Play, Rocket, Square } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; - - - +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import PodStatusIndicator from '@/components/custom/pod-status-indicator'; import { deploy, startApp, stopApp } from '@/app/project/[projectId]/apps/details/actions'; @@ -33,156 +13,131 @@ import { Toast } from '@/frontend/utils/toast.utils'; import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import { RolePermissionEnum } from '@/shared/model/role-extended.model'; +export function AppStatusActions({ app, role }: { app: AppExtendedModel; role?: RolePermissionEnum }) { + const deploymentStatus = usePodsStatus((state) => state.podsStatus.get(app.id)?.deploymentStatus ?? 'UNKNOWN'); + const lifecycle = AppLifecycleUtils.availability(app, role, deploymentStatus); + const openDomain = (domain: AppExtendedModel['appDomains'][number]) => { + const protocol = domain.useSsl ? 'https' : 'http'; + window.open(`${protocol}://${domain.hostname}`, '_blank'); + }; -export function AppStatusActions({ - app, - role, -}: { - app: AppExtendedModel; - role?: RolePermissionEnum; -}) { - const deploymentStatus = usePodsStatus( - (state) => state.podsStatus.get(app.id)?.deploymentStatus ?? 'UNKNOWN', - ); - const lifecycle = AppLifecycleUtils.availability(app, role, deploymentStatus); - const openDomain = (domain: AppExtendedModel['appDomains'][number]) => { - const protocol = domain.useSsl ? 'https' : 'http'; - - window.open(`${protocol}://${domain.hostname}`, '_blank'); - }; - - return ( -
- - {(lifecycle.canManage || app.appDomains.length > 0) && ( - -
- {lifecycle.canManage && ( - <> - - void Toast.fromAction(() => deploy(app.id))} - > - - Deploy - - } - /> - Deploy - - {lifecycle.supportsRebuild && ( - - void Toast.fromAction(() => deploy(app.id, true))} - > - - Rebuild - - } - /> - Rebuild - - )} - - void Toast.fromAction(() => startApp(app.id))} - > - - Start - - } - /> - Start - - - void Toast.fromAction(() => stopApp(app.id))} - > - - Stop - - } - /> - Stop - - - )} - {app.appDomains.length === 1 && ( - - openDomain(app.appDomains[0])} - > - - Open domain - - } - /> - Open domain - - )} - {app.appDomains.length > 1 && ( - - - - - } - /> - - {app.appDomains.map((domain) => ( - openDomain(domain)} - > - - {domain.hostname} - - ))} - - - )} -
-
+ return ( +
+ + {(lifecycle.canManage || app.appDomains.length > 0) && ( + +
+ {lifecycle.canManage && ( + <> + + void Toast.fromAction(() => deploy(app.id))} + > + + Deploy + + } + /> + Deploy + + {lifecycle.supportsRebuild && ( + + void Toast.fromAction(() => deploy(app.id, true))} + > + + Rebuild + + } + /> + Rebuild + + )} + + void Toast.fromAction(() => startApp(app.id))} + > + + Start + + } + /> + Start + + + void Toast.fromAction(() => stopApp(app.id))} + > + + Stop + + } + /> + Stop + + + )} + {app.appDomains.length === 1 && ( + + openDomain(app.appDomains[0])}> + + Open domain + + } + /> + Open domain + )} -
- ); + {app.appDomains.length > 1 && ( + + + + + } + /> + + {app.appDomains.map((domain) => ( + openDomain(domain)}> + + {domain.hostname} + + ))} + + + )} +
+ + )} +
+ ); } - diff --git a/src/app/project/[projectId]/apps/details/configuration/actions.ts b/src/app/project/[projectId]/apps/details/configuration/actions.ts index 5613c32a..3e1ba826 100644 --- a/src/app/project/[projectId]/apps/details/configuration/actions.ts +++ b/src/app/project/[projectId]/apps/details/configuration/actions.ts @@ -1,252 +1,247 @@ -'use server' - -import { AppRateLimitsModel, appRateLimitsZodModel } from "@/shared/model/app-rate-limits.model"; -import { appDockerfileDetectionZodModel, appFrameworkConfigurationZodModel, appGitBranchesLookupZodModel, appSourceInfoContainerZodModel, appSourceInfoGitSshZodModel, appSourceInfoGitZodModel, AppDockerfileDetectionModel, AppFrameworkConfigurationModel, AppGitBranchesLookupModel, AppSourceInfoInputModel } from "@/shared/model/app-source-info.model"; -import { FormValidationException } from "@/shared/model/form-validation-exception.model"; -import { ServiceException } from "@/shared/model/service.exception.model"; -import appService from "@/server/services/app.service"; -import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; -import { appContainerConfigZodModel } from "@/shared/model/app-container-config.model"; -import { AppContainerConfigInputModel } from "./app-container-config"; -import appGitSshKeyService from "@/server/services/app-git-ssh-key.service"; -import gitService from "@/server/services/git.service"; -import { ContainerCommangArgsUtils } from "@/shared/utils/container-command-args.utils"; +'use server'; + +import { AppRateLimitsModel, appRateLimitsZodModel } from '@/shared/model/app-rate-limits.model'; +import { + appDockerfileDetectionZodModel, + appFrameworkConfigurationZodModel, + appGitBranchesLookupZodModel, + appSourceInfoContainerZodModel, + appSourceInfoGitSshZodModel, + appSourceInfoGitZodModel, + AppDockerfileDetectionModel, + AppFrameworkConfigurationModel, + AppGitBranchesLookupModel, + AppSourceInfoInputModel +} from '@/shared/model/app-source-info.model'; +import { FormValidationException } from '@/shared/model/form-validation-exception.model'; +import { ServiceException } from '@/shared/model/service.exception.model'; +import appService from '@/server/services/app.service'; +import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from '@/server/utils/action-wrapper.utils'; +import { appContainerConfigZodModel } from '@/shared/model/app-container-config.model'; +import { AppContainerConfigInputModel } from './app-container-config'; +import appGitSshKeyService from '@/server/services/app-git-ssh-key.service'; +import gitService from '@/server/services/git.service'; +import { ContainerCommangArgsUtils } from '@/shared/utils/container-command-args.utils'; export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSourceInfoInputModel, appId: string) => { - return simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - const existingApp = await appService.getById(appId); - if (existingApp.appType !== 'APP' && inputData.sourceType !== 'CONTAINER') { - throw new ServiceException('Only application workloads can use Git sources.'); - } - - if (inputData.sourceType === 'GIT') { - const validatedFields = appSourceInfoGitZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); - } - const validatedData = validatedFields.data; - if (validatedData.buildMethod === 'DOCKERFILE' && !validatedData.dockerfilePath) { - throw new FormValidationException('Please correct the errors in the form.', { - dockerfilePath: ['Path to Dockerfile is required when using the Dockerfile build method.'], - }); - } - validateFrameworkSource(validatedData); - await appService.save({ - ...existingApp, - ...validatedData, - gitUsername: validatedData.gitUsername || null, - gitToken: validatedData.gitToken || null, - dockerfilePath: validatedData.buildMethod === 'DOCKERFILE' - ? validatedData.dockerfilePath ?? existingApp.dockerfilePath - : existingApp.dockerfilePath, - ...mapFrameworkFields(validatedData), - containerImageSource: null, - containerRegistryUsername: null, - containerRegistryPassword: null, - sourceType: 'GIT', - id: appId, - }); - return; - } - - if (inputData.sourceType === 'GIT_SSH') { - const validatedFields = appSourceInfoGitSshZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); - } - const validatedData = validatedFields.data; - if (validatedData.buildMethod === 'DOCKERFILE' && !validatedData.dockerfilePath) { - throw new FormValidationException('Please correct the errors in the form.', { - dockerfilePath: ['Path to Dockerfile is required when using the Dockerfile build method.'], - }); - } - validateFrameworkSource(validatedData); - const publicKey = await appGitSshKeyService.getPublicKey(appId); - if (!publicKey) { - throw new ServiceException('Generate SSH keys before saving a Git SSH source.'); - } - await appService.save({ - ...existingApp, - ...validatedData, - gitUsername: null, - gitToken: null, - dockerfilePath: validatedData.buildMethod === 'DOCKERFILE' - ? validatedData.dockerfilePath ?? existingApp.dockerfilePath - : existingApp.dockerfilePath, - ...mapFrameworkFields(validatedData), - containerImageSource: null, - containerRegistryUsername: null, - containerRegistryPassword: null, - sourceType: 'GIT_SSH', - id: appId, - }); - return; - } - - if (inputData.sourceType === 'CONTAINER') { - const validatedFields = appSourceInfoContainerZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); - } - const validatedData = validatedFields.data; - await appService.save({ - ...existingApp, - ...validatedData, - containerRegistryUsername: validatedData.containerRegistryUsername || null, - containerRegistryPassword: validatedData.containerRegistryPassword || null, - gitUrl: null, - gitBranch: null, - gitUsername: null, - gitToken: null, - ...mapFrameworkFields({}), - sourceType: 'CONTAINER', - id: appId, - }); - return; - } - - throw new ServiceException('Invalid Source Type'); - }); -}; - -function validateFrameworkSource(data: { buildMethod?: string; framework?: string | null; buildCommand?: string | null }) { - if (data.buildMethod !== 'FRAMEWORK') { - return; + return simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + const existingApp = await appService.getById(appId); + if (existingApp.appType !== 'APP' && inputData.sourceType !== 'CONTAINER') { + throw new ServiceException('Only application workloads can use Git sources.'); } - const errors: Record = {}; - if (!data.framework) { - errors.framework = ['Select a framework.']; + + if (inputData.sourceType === 'GIT') { + const validatedFields = appSourceInfoGitZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); + } + const validatedData = validatedFields.data; + if (validatedData.buildMethod === 'DOCKERFILE' && !validatedData.dockerfilePath) { + throw new FormValidationException('Please correct the errors in the form.', { + dockerfilePath: ['Path to Dockerfile is required when using the Dockerfile build method.'] + }); + } + validateFrameworkSource(validatedData); + await appService.save({ + ...existingApp, + ...validatedData, + gitUsername: validatedData.gitUsername || null, + gitToken: validatedData.gitToken || null, + dockerfilePath: validatedData.buildMethod === 'DOCKERFILE' ? (validatedData.dockerfilePath ?? existingApp.dockerfilePath) : existingApp.dockerfilePath, + ...mapFrameworkFields(validatedData), + containerImageSource: null, + containerRegistryUsername: null, + containerRegistryPassword: null, + sourceType: 'GIT', + id: appId + }); + return; } - if (!data.buildCommand?.trim()) { - errors.buildCommand = ['Build command is required when using the framework build method.']; + + if (inputData.sourceType === 'GIT_SSH') { + const validatedFields = appSourceInfoGitSshZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); + } + const validatedData = validatedFields.data; + if (validatedData.buildMethod === 'DOCKERFILE' && !validatedData.dockerfilePath) { + throw new FormValidationException('Please correct the errors in the form.', { + dockerfilePath: ['Path to Dockerfile is required when using the Dockerfile build method.'] + }); + } + validateFrameworkSource(validatedData); + const publicKey = await appGitSshKeyService.getPublicKey(appId); + if (!publicKey) { + throw new ServiceException('Generate SSH keys before saving a Git SSH source.'); + } + await appService.save({ + ...existingApp, + ...validatedData, + gitUsername: null, + gitToken: null, + dockerfilePath: validatedData.buildMethod === 'DOCKERFILE' ? (validatedData.dockerfilePath ?? existingApp.dockerfilePath) : existingApp.dockerfilePath, + ...mapFrameworkFields(validatedData), + containerImageSource: null, + containerRegistryUsername: null, + containerRegistryPassword: null, + sourceType: 'GIT_SSH', + id: appId + }); + return; } - if (Object.keys(errors).length > 0) { - throw new FormValidationException('Please correct the errors in the form.', errors); + + if (inputData.sourceType === 'CONTAINER') { + const validatedFields = appSourceInfoContainerZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please correct the errors in the form.', validatedFields.error.flatten().fieldErrors); + } + const validatedData = validatedFields.data; + await appService.save({ + ...existingApp, + ...validatedData, + containerRegistryUsername: validatedData.containerRegistryUsername || null, + containerRegistryPassword: validatedData.containerRegistryPassword || null, + gitUrl: null, + gitBranch: null, + gitUsername: null, + gitToken: null, + ...mapFrameworkFields({}), + sourceType: 'CONTAINER', + id: appId + }); + return; } + + throw new ServiceException('Invalid Source Type'); + }); +}; + +function validateFrameworkSource(data: { buildMethod?: string; framework?: string | null; buildCommand?: string | null }) { + if (data.buildMethod !== 'FRAMEWORK') { + return; + } + const errors: Record = {}; + if (!data.framework) { + errors.framework = ['Select a framework.']; + } + if (!data.buildCommand?.trim()) { + errors.buildCommand = ['Build command is required when using the framework build method.']; + } + if (Object.keys(errors).length > 0) { + throw new FormValidationException('Please correct the errors in the form.', errors); + } } -function mapFrameworkFields(data: Partial>) { - if (data.buildMethod !== 'FRAMEWORK') { - return { - framework: null, - installCommand: null, - buildCommand: null, - runCommand: null, - rootDirectory: null, - outputDirectory: null, - nodeVersion: null, - }; - } - return { - framework: data.framework || null, - installCommand: data.installCommand || null, - buildCommand: data.buildCommand || null, - runCommand: data.runCommand || null, - rootDirectory: data.rootDirectory || './', - outputDirectory: data.outputDirectory || null, - nodeVersion: data.nodeVersion || null, - }; +function mapFrameworkFields( + data: Partial< + Pick< + AppSourceInfoInputModel, + 'buildMethod' | 'framework' | 'installCommand' | 'buildCommand' | 'runCommand' | 'rootDirectory' | 'outputDirectory' | 'nodeVersion' + > + > +) { + if (data.buildMethod !== 'FRAMEWORK') { + return { framework: null, installCommand: null, buildCommand: null, runCommand: null, rootDirectory: null, outputDirectory: null, nodeVersion: null }; + } + return { + framework: data.framework || null, + installCommand: data.installCommand || null, + buildCommand: data.buildCommand || null, + runCommand: data.runCommand || null, + rootDirectory: data.rootDirectory || './', + outputDirectory: data.outputDirectory || null, + nodeVersion: data.nodeVersion || null + }; } export const ensureGitSshPublicKey = async (appId: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - return await appGitSshKeyService.ensurePublicKey(appId); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + return await appGitSshKeyService.ensurePublicKey(appId); + }); export const generateOrRegenerateGitSshKey = async (appId: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - return await appGitSshKeyService.generateOrRegenerate(appId); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + return await appGitSshKeyService.generateOrRegenerate(appId); + }); export const getGitBranches = async (appId: string, inputData: AppGitBranchesLookupModel) => - simpleAction(async () => { - const validatedFields = appGitBranchesLookupZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please make sure that you entered the correct Git credentials.', validatedFields.error.flatten().fieldErrors); - } - - await isAuthorizedWriteForApp(appId); - return await gitService.listRemoteBranches({ - id: appId, - ...validatedFields.data, - }); - }); + simpleAction(async () => { + const validatedFields = appGitBranchesLookupZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please make sure that you entered the correct Git credentials.', validatedFields.error.flatten().fieldErrors); + } + + await isAuthorizedWriteForApp(appId); + return await gitService.listRemoteBranches({ id: appId, ...validatedFields.data }); + }); export const detectDockerfilePath = async (appId: string, inputData: AppDockerfileDetectionModel) => - simpleAction(async () => { - const validatedFields = appDockerfileDetectionZodModel.safeParse(inputData); - if (!validatedFields.success) { - throw new FormValidationException('Please make sure that you entered the correct Git source information.', validatedFields.error.flatten().fieldErrors); - } - - await isAuthorizedWriteForApp(appId); - return await gitService.detectDockerfilePath({ - id: appId, - ...validatedFields.data, - }); - }); + simpleAction(async () => { + const validatedFields = appDockerfileDetectionZodModel.safeParse(inputData); + if (!validatedFields.success) { + throw new FormValidationException('Please make sure that you entered the correct Git source information.', validatedFields.error.flatten().fieldErrors); + } + + await isAuthorizedWriteForApp(appId); + return await gitService.detectDockerfilePath({ id: appId, ...validatedFields.data }); + }); export const saveGeneralAppRateLimits = async (prevState: any, inputData: AppRateLimitsModel, appId: string) => - saveFormAction(inputData, appRateLimitsZodModel, async (validatedData) => { - if (validatedData.replicas < 1) { - throw new ServiceException('Replica Count must be at least 1'); - } - await isAuthorizedWriteForApp(appId); - - const extendedApp = await appService.getExtendedById(appId); - if (extendedApp.appVolumes.some(v => v.accessMode === 'ReadWriteOnce') && validatedData.replicas > 1) { - throw new ServiceException('Replica Count must be 1 because you have at least one volume with access mode ReadWriteOnce.'); - } - - const existingApp = await appService.getById(appId); - await appService.save({ - ...existingApp, - ...validatedData, - id: appId, - }); - }); + saveFormAction(inputData, appRateLimitsZodModel, async (validatedData) => { + if (validatedData.replicas < 1) { + throw new ServiceException('Replica Count must be at least 1'); + } + await isAuthorizedWriteForApp(appId); + + const extendedApp = await appService.getExtendedById(appId); + if (extendedApp.appVolumes.some((v) => v.accessMode === 'ReadWriteOnce') && validatedData.replicas > 1) { + throw new ServiceException('Replica Count must be 1 because you have at least one volume with access mode ReadWriteOnce.'); + } + + const existingApp = await appService.getById(appId); + await appService.save({ ...existingApp, ...validatedData, id: appId }); + }); export const saveGeneralAppContainerConfig = async (prevState: any, inputData: AppContainerConfigInputModel, appId: string) => - saveFormAction(inputData, appContainerConfigZodModel, async (validatedData) => { - await isAuthorizedWriteForApp(appId); - const existingApp = await appService.getById(appId); - - const containerCommandJson = ContainerCommangArgsUtils.serializeContainerCommandItems(validatedData.containerCommand); - const containerArgsJson = validatedData.containerArgs && validatedData.containerArgs.length > 0 - ? JSON.stringify(validatedData.containerArgs.map(arg => arg.value)) - : null; - - await appService.save({ - ...existingApp, - containerCommand: containerCommandJson, - containerArgs: containerArgsJson, - securityContextRunAsUser: validatedData.securityContextRunAsUser ?? null, - securityContextRunAsGroup: validatedData.securityContextRunAsGroup ?? null, - securityContextFsGroup: validatedData.securityContextFsGroup ?? null, - securityContextPrivileged: validatedData.securityContextPrivileged ?? false, - id: appId, - }); + saveFormAction(inputData, appContainerConfigZodModel, async (validatedData) => { + await isAuthorizedWriteForApp(appId); + const existingApp = await appService.getById(appId); + + const containerCommandJson = ContainerCommangArgsUtils.serializeContainerCommandItems(validatedData.containerCommand); + const containerArgsJson = + validatedData.containerArgs && validatedData.containerArgs.length > 0 ? JSON.stringify(validatedData.containerArgs.map((arg) => arg.value)) : null; + + await appService.save({ + ...existingApp, + containerCommand: containerCommandJson, + containerArgs: containerArgsJson, + securityContextRunAsUser: validatedData.securityContextRunAsUser ?? null, + securityContextRunAsGroup: validatedData.securityContextRunAsGroup ?? null, + securityContextFsGroup: validatedData.securityContextFsGroup ?? null, + securityContextPrivileged: validatedData.securityContextPrivileged ?? false, + id: appId }); + }); export const saveFrameworkConfiguration = async (prevState: any, inputData: AppFrameworkConfigurationModel, appId: string) => - saveFormAction(inputData, appFrameworkConfigurationZodModel, async (validatedData) => { - await isAuthorizedWriteForApp(appId); - const existingApp = await appService.getById(appId); - if (existingApp.buildMethod !== 'FRAMEWORK') { - throw new ServiceException('Framework configuration is only available for framework builds.'); - } - - await appService.save({ - ...existingApp, - installCommand: validatedData.installCommand || null, - buildCommand: validatedData.buildCommand, - runCommand: validatedData.runCommand || null, - rootDirectory: validatedData.rootDirectory || './', - outputDirectory: validatedData.outputDirectory || null, - nodeVersion: validatedData.nodeVersion || null, - id: appId, - }); + saveFormAction(inputData, appFrameworkConfigurationZodModel, async (validatedData) => { + await isAuthorizedWriteForApp(appId); + const existingApp = await appService.getById(appId); + if (existingApp.buildMethod !== 'FRAMEWORK') { + throw new ServiceException('Framework configuration is only available for framework builds.'); + } + + await appService.save({ + ...existingApp, + installCommand: validatedData.installCommand || null, + buildCommand: validatedData.buildCommand, + runCommand: validatedData.runCommand || null, + rootDirectory: validatedData.rootDirectory || './', + outputDirectory: validatedData.outputDirectory || null, + nodeVersion: validatedData.nodeVersion || null, + id: appId }); + }); diff --git a/src/app/project/[projectId]/apps/details/configuration/app-container-config.tsx b/src/app/project/[projectId]/apps/details/configuration/app-container-config.tsx index b3494b36..2403eb74 100644 --- a/src/app/project/[projectId]/apps/details/configuration/app-container-config.tsx +++ b/src/app/project/[projectId]/apps/details/configuration/app-container-config.tsx @@ -1,216 +1,206 @@ 'use client'; -import { SubmitButton } from "@/components/custom/submit-button"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { saveGeneralAppContainerConfig } from "./actions"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { useActionState, useEffect, useId } from "react"; -import { toast } from "sonner"; -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { z } from "zod"; -import { appContainerConfigZodModel } from "@/shared/model/app-container-config.model"; -import ContainerCommandArgsFields, { LabelWithHint } from "@/components/custom/container-command-args-fields"; -import { NestedDrawerFooter } from "../../../../workload-components/drawer/nested-drawer"; -import { ContainerCommangArgsUtils } from "@/shared/utils/container-command-args.utils"; +import { SubmitButton } from '@/components/custom/submit-button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { saveGeneralAppContainerConfig } from './actions'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { useActionState, useEffect, useId } from 'react'; +import { toast } from 'sonner'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { z } from 'zod'; +import { appContainerConfigZodModel } from '@/shared/model/app-container-config.model'; +import ContainerCommandArgsFields, { LabelWithHint } from '@/components/custom/container-command-args-fields'; +import { NestedDrawerFooter } from '../../../../workload-components/drawer/nested-drawer'; +import { ContainerCommangArgsUtils } from '@/shared/utils/container-command-args.utils'; export type AppContainerConfigInputModel = z.infer; -export default function GeneralAppContainerConfig({ app, readonly }: { - app: AppExtendedModel; - readonly: boolean; -}) { - const formId = useId(); - const inputValue = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? value : ''; - // Parse containerArgs from JSON string to array - const form = useForm, unknown, z.output>({ - resolver: zodResolver(appContainerConfigZodModel), - defaultValues: { - containerCommand: ContainerCommangArgsUtils.parseStoredContainerCommandItems(app.containerCommand), - containerArgs: app.containerArgs - ? JSON.parse(app.containerArgs).map((arg: string) => ({ value: arg })) - : [], - securityContextRunAsUser: app.securityContextRunAsUser ?? undefined, - securityContextRunAsGroup: app.securityContextRunAsGroup ?? undefined, - securityContextFsGroup: app.securityContextFsGroup ?? undefined, - securityContextPrivileged: app.securityContextPrivileged ?? false, - }, - disabled: readonly, - }); +export default function GeneralAppContainerConfig({ app, readonly }: { app: AppExtendedModel; readonly: boolean }) { + const formId = useId(); + const inputValue = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? value : ''); + // Parse containerArgs from JSON string to array + const form = useForm, unknown, z.output>({ + resolver: zodResolver(appContainerConfigZodModel), + defaultValues: { + containerCommand: ContainerCommangArgsUtils.parseStoredContainerCommandItems(app.containerCommand), + containerArgs: app.containerArgs ? JSON.parse(app.containerArgs).map((arg: string) => ({ value: arg })) : [], + securityContextRunAsUser: app.securityContextRunAsUser ?? undefined, + securityContextRunAsGroup: app.securityContextRunAsGroup ?? undefined, + securityContextFsGroup: app.securityContextFsGroup ?? undefined, + securityContextPrivileged: app.securityContextPrivileged ?? false + }, + disabled: readonly + }); - const [state, formAction] = useActionState( - (state: ServerActionResult, payload: AppContainerConfigInputModel) => - saveGeneralAppContainerConfig(state, payload, app.id), - FormUtils.getInitialFormState() - ); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AppContainerConfigInputModel) => saveGeneralAppContainerConfig(state, payload, app.id), + FormUtils.getInitialFormState() + ); - useEffect(() => { - if (state.status === 'success') { - toast.success('Container Configuration Saved', { - description: "Click \"deploy\" to apply the changes to your app.", - }); - } - FormUtils.mapValidationErrorsToForm(state, form) - }, [form, state]); + useEffect(() => { + if (state.status === 'success') { + toast.success('Container Configuration Saved', { description: 'Click "deploy" to apply the changes to your app.' }); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [form, state]); - const values = form.watch(); + const values = form.watch(); - return ( -
- - form.handleSubmit((data) => { - return formAction(data); - })()}> -
-
-
-

Runtime

-

- Leave these fields empty to keep the command and arguments from the container image. -

-
+ return ( + + + + form.handleSubmit((data) => { + return formAction(data); + })() + } + > +
+
+
+

Runtime

+

Leave these fields empty to keep the command and arguments from the container image.

+
- -
+ +
- + -
-
-

Security Context

-

- Change these values only when your image, mounted volumes, or tooling require specific Linux permissions. -

-
+
+
+

Security Context

+

+ Change these values only when your image, mounted volumes, or tooling require specific Linux permissions. +

+
-
- ( - - - Run As User - - - field.onChange(e.target.value === '' ? null : Number(e.target.value))} - /> - - - - )} - /> - ( - - - Run As Group - - - field.onChange(e.target.value === '' ? null : Number(e.target.value))} - /> - - - - )} - /> - ( - - - FS Group - - - field.onChange(e.target.value === '' ? null : Number(e.target.value))} - /> - - - - )} - /> -
+
+ ( + + + Run As User + + + field.onChange(e.target.value === '' ? null : Number(e.target.value))} + /> + + + + )} + /> + ( + + + Run As Group + + + field.onChange(e.target.value === '' ? null : Number(e.target.value))} + /> + + + + )} + /> + ( + + + FS Group + + + field.onChange(e.target.value === '' ? null : Number(e.target.value))} + /> + + + + )} + /> +
- ( - -
- - - -
- -

- Removes most container isolation. The container gets all Linux capabilities, - access to host devices, and can interact with the node almost like a root - process on the host. -

-

- If the container is compromised, it can affect the Kubernetes node and - other workloads. Use this only for workloads such as Docker-in-Docker - or low-level system tooling. -

- - )} - > - Privileged Mode -
+ ( + +
+ + + +
+ +

+ Removes most container isolation. The container gets all Linux capabilities, access to host devices, and can interact with the + node almost like a root process on the host. +

+

+ If the container is compromised, it can affect the Kubernetes node and other workloads. Use this only for workloads such as + Docker-in-Docker or low-level system tooling. +

+ + } + > + Privileged Mode +
- {values.securityContextPrivileged && - - Enable this only if you fully understand the implications and risks. - - } -
- -
- -
- )} - /> -
+ {values.securityContextPrivileged && ( + + Enable this only if you fully understand the implications and risks. + + )} +
- {!readonly && ( - - Save -

{state?.message}

-
- )} - - - - ); + + + )} + /> +
+
+ {!readonly && ( + + + Save + +

{state?.message}

+
+ )} + + + + ); } diff --git a/src/app/project/[projectId]/apps/details/configuration/app-rate-limits.tsx b/src/app/project/[projectId]/apps/details/configuration/app-rate-limits.tsx index 6bc52457..be76c741 100644 --- a/src/app/project/[projectId]/apps/details/configuration/app-rate-limits.tsx +++ b/src/app/project/[projectId]/apps/details/configuration/app-rate-limits.tsx @@ -1,197 +1,216 @@ 'use client'; -import type { z } from "zod"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { CardContent, CardFooter } from "@/components/ui/card"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { saveGeneralAppRateLimits } from "./actions"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { Input } from "@/components/ui/input"; -import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"; -import { AppRateLimitsModel, appRateLimitsZodModel } from "@/shared/model/app-rate-limits.model"; -import { useActionState, useEffect, useState } from "react"; -import { toast } from "sonner"; -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { cn } from "@/frontend/utils/utils"; -import { getRessourceDataApp } from "../deployments/actions"; -import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model"; -import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { z } from 'zod'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { CardContent, CardFooter } from '@/components/ui/card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { saveGeneralAppRateLimits } from './actions'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { Input } from '@/components/ui/input'; +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@/components/ui/input-group'; +import { AppRateLimitsModel, appRateLimitsZodModel } from '@/shared/model/app-rate-limits.model'; +import { useActionState, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { cn } from '@/frontend/utils/utils'; +import { getRessourceDataApp } from '../deployments/actions'; +import { PodsResourceInfoModel } from '@/shared/model/pods-resource-info.model'; +import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +export default function GeneralAppRateLimits({ app, readonly }: { app: AppExtendedModel; readonly: boolean }) { + const form = useForm, unknown, z.output>({ + resolver: zodResolver(appRateLimitsZodModel), + defaultValues: app, + disabled: readonly + }); -export default function GeneralAppRateLimits({ app, readonly }: { - app: AppExtendedModel; - readonly: boolean; -}) { - const form = useForm, unknown, z.output>({ - resolver: zodResolver(appRateLimitsZodModel), - defaultValues: app, - disabled: readonly - }); + const [monitoringData, setMonitoringData] = useState(undefined); - const [monitoringData, setMonitoringData] = useState(undefined); - - useEffect(() => { - getRessourceDataApp(app.projectId, app.id).then((res) => { - if (res.status === 'success' && res.data) { - setMonitoringData(res.data); - } - }).catch(() => { /* pod may not be running, silently ignore */ }); - }, [app.id, app.projectId]); - - const suggestedMemoryMb = monitoringData && monitoringData.ramAbsolutBytes - ? Math.ceil(KubeSizeConverter.fromBytesToMegabytes(monitoringData.ramAbsolutBytes)) - : undefined; - const suggestedCpuMillicores = monitoringData && monitoringData.cpuAbsolutCores - ? Math.max(1, Math.round(monitoringData.cpuAbsolutCores * 1000)) - : undefined; - - const [state, formAction] = useActionState((state: ServerActionResult, payload: AppRateLimitsModel) => saveGeneralAppRateLimits(state, payload, app.id), FormUtils.getInitialFormState()); - useEffect(() => { - if (state.status === 'success') { - toast.success('Rate Limits Saved', { - description: "Click \"deploy\" to apply the changes to your app.", - }); + useEffect(() => { + getRessourceDataApp(app.projectId, app.id) + .then((res) => { + if (res.status === 'success' && res.data) { + setMonitoringData(res.data); } - FormUtils.mapValidationErrorsToForm(state, form); - }, [form, state]); - + }) + .catch(() => { + /* pod may not be running, silently ignore */ + }); + }, [app.id, app.projectId]); - return <> -
- -
- form.handleSubmit((data) => { - return formAction(data); - })()}> - -
+ const suggestedMemoryMb = + monitoringData && monitoringData.ramAbsolutBytes ? Math.ceil(KubeSizeConverter.fromBytesToMegabytes(monitoringData.ramAbsolutBytes)) : undefined; + const suggestedCpuMillicores = monitoringData && monitoringData.cpuAbsolutCores ? Math.max(1, Math.round(monitoringData.cpuAbsolutCores * 1000)) : undefined; - ( - - Replica Count - - - - - - )} - /> -
-
+ const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AppRateLimitsModel) => saveGeneralAppRateLimits(state, payload, app.id), + FormUtils.getInitialFormState() + ); + useEffect(() => { + if (state.status === 'success') { + toast.success('Rate Limits Saved', { description: 'Click "deploy" to apply the changes to your app.' }); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [form, state]); - ( - - Memory Limit - - - - MB - - - - - )} - /> + return ( + <> +
+ + + form.handleSubmit((data) => { + return formAction(data); + })() + } + > + +
+ ( + + Replica Count + + + + + + )} + /> +
+
+ ( + + Memory Limit + + + + + MB + + + + + + )} + /> - ( - - Memory Reservation - - - - MB - - - - {!readonly && suggestedMemoryMb !== undefined && ( - - - form.setValue('memoryReservation', suggestedMemoryMb)} - > - ~ {suggestedMemoryMb} MB - } /> - -

Suggestion based on current pod resource usage

-
-
-
- )} -
- )} + ( + + Memory Reservation + + + + + MB + + + + + {!readonly && suggestedMemoryMb !== undefined && ( + + + form.setValue('memoryReservation', suggestedMemoryMb)} + > + ~ {suggestedMemoryMb} MB + + } /> + +

Suggestion based on current pod resource usage

+
+
+
+ )} +
+ )} + /> - ( - - CPU Limit - - - - mCPU - - - - - )} - /> + ( + + CPU Limit + + + + + mCPU + + + + + + )} + /> - ( - - CPU Reservation - - - - mCPU - - - - {!readonly && suggestedCpuMillicores !== undefined && ( - - - form.setValue('cpuReservation', suggestedCpuMillicores)} - > - ~ {suggestedCpuMillicores} m - } /> - -

Suggestion based on current pod resource usage

-
-
-
- )} -
- )} + ( + + CPU Reservation + + + + + mCPU + + + + + {!readonly && suggestedCpuMillicores !== undefined && ( + + + form.setValue('cpuReservation', suggestedCpuMillicores)} + > + ~ {suggestedCpuMillicores} m + + } /> -
-
- {!readonly && - Save -

{state?.message}

-
} - - -
- - ; + +

Suggestion based on current pod resource usage

+
+ + + )} + + )} + /> +
+
+ {!readonly && ( + + Save +

{state?.message}

+
+ )} + + +
+ + ); } diff --git a/src/app/project/[projectId]/apps/details/configuration/app-source-wizard/build-method-step.tsx b/src/app/project/[projectId]/apps/details/configuration/app-source-wizard/build-method-step.tsx index 2fc9a656..9ddf8433 100644 --- a/src/app/project/[projectId]/apps/details/configuration/app-source-wizard/build-method-step.tsx +++ b/src/app/project/[projectId]/apps/details/configuration/app-source-wizard/build-method-step.tsx @@ -1,38 +1,37 @@ -import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; -import { AppBuildMethod } from "@/shared/model/app-source-info.model"; -import { Boxes, FileCode2, Package, type LucideIcon } from "lucide-react"; +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'; +import { AppBuildMethod } from '@/shared/model/app-source-info.model'; +import { Boxes, FileCode2, Package, type LucideIcon } from 'lucide-react'; -export function BuildMethodStep({ value, onChange }: { - value: AppBuildMethod; - onChange: (buildMethod: AppBuildMethod) => void; -}) { - const options: Array<{ value: AppBuildMethod; label: string; description: string; icon: LucideIcon }> = [ - { value: 'FRAMEWORK', label: 'Framework', description: 'Deploy popular JS frameworks like Next.js, Nuxt.js, Angular and more.', icon: Boxes }, - { value: 'RAILPACK', label: 'Railpack', description: 'Detect and build the app automatically.', icon: Package }, - { value: 'DOCKERFILE', label: 'Dockerfile', description: 'Build with a Dockerfile from the repository.', icon: FileCode2 }, - ]; +export function BuildMethodStep({ value, onChange }: { value: AppBuildMethod; onChange: (buildMethod: AppBuildMethod) => void }) { + const options: Array<{ value: AppBuildMethod; label: string; description: string; icon: LucideIcon }> = [ + { value: 'FRAMEWORK', label: 'Framework', description: 'Deploy popular JS frameworks like Next.js, Nuxt.js, Angular and more.', icon: Boxes }, + { value: 'RAILPACK', label: 'Railpack', description: 'Detect and build the app automatically.', icon: Package }, + { value: 'DOCKERFILE', label: 'Dockerfile', description: 'Build with a Dockerfile from the repository.', icon: FileCode2 } + ]; - return (
- - - - {options.map((option) => ( - onChange(option.value)} - > - -
-

{option.label}

-

{option.description}

-
-
- ))} -
-
-
-
); + return ( +
+ + + + {options.map((option) => ( + onChange(option.value)} + > + +
+

{option.label}

+

{option.description}

+
+
+ ))} +
+
+
+
+ ); } diff --git a/src/app/project/[projectId]/apps/details/credentials/actions.ts b/src/app/project/[projectId]/apps/details/credentials/actions.ts index cd3d62b9..3f133c06 100644 --- a/src/app/project/[projectId]/apps/details/credentials/actions.ts +++ b/src/app/project/[projectId]/apps/details/credentials/actions.ts @@ -1,82 +1,79 @@ -'use server' +'use server'; -import appService from "@/server/services/app.service"; -import dbGateService from "@/server/services/db-tool-services/dbgate.service"; -import pgAdminService from "@/server/services/db-tool-services/pgadmin.service"; -import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service"; -import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from "@/server/utils/action-wrapper.utils"; -import { AppTemplateUtils } from "@/server/utils/app-template.utils"; -import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model"; -import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model"; -import { ServiceException } from "@/shared/model/service.exception.model"; +import appService from '@/server/services/app.service'; +import dbGateService from '@/server/services/db-tool-services/dbgate.service'; +import pgAdminService from '@/server/services/db-tool-services/pgadmin.service'; +import phpMyAdminService from '@/server/services/db-tool-services/phpmyadmin.service'; +import { isAuthorizedReadForApp, isAuthorizedWriteForApp, simpleAction } from '@/server/utils/action-wrapper.utils'; +import { AppTemplateUtils } from '@/server/utils/app-template.utils'; +import { DatabaseTemplateInfoModel } from '@/shared/model/database-template-info.model'; +import { ServerActionResult, SuccessActionResult } from '@/shared/model/server-action-error-return.model'; +import { ServiceException } from '@/shared/model/service.exception.model'; export type DbToolIds = 'dbgate' | 'phpmyadmin' | 'pgadmin'; const dbToolClasses = new Map([ - ['dbgate', dbGateService], - ['phpmyadmin', phpMyAdminService], - ['pgadmin', pgAdminService] -]) + ['dbgate', dbGateService], + ['phpmyadmin', phpMyAdminService], + ['pgadmin', pgAdminService] +]); export const getDatabaseCredentials = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - const app = await appService.getExtendedById(appId); - const credentials = AppTemplateUtils.getDatabaseModelFromApp(app); - return new SuccessActionResult(credentials); - }) as Promise>; + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + const app = await appService.getExtendedById(appId); + const credentials = AppTemplateUtils.getDatabaseModelFromApp(app); + return new SuccessActionResult(credentials); + }) as Promise>; export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - if (!dbToolClasses.has(dbTool)) { - throw new ServiceException('Unknown db tool'); - } - const isActive = dbToolClasses.get(dbTool)!.isDbToolRunning(appId); - return new SuccessActionResult(isActive); - }) as Promise>; + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + if (!dbToolClasses.has(dbTool)) { + throw new ServiceException('Unknown db tool'); + } + const isActive = dbToolClasses.get(dbTool)!.isDbToolRunning(appId); + return new SuccessActionResult(isActive); + }) as Promise>; export const deployDbTool = async (appId: string, dbTool: DbToolIds) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); - const currentDbTool = dbToolClasses.get(dbTool); - if (!currentDbTool) { - throw new ServiceException('Unknown db tool'); - } - await currentDbTool.deploy(appId); - return new SuccessActionResult(); - - }) as Promise>; + const currentDbTool = dbToolClasses.get(dbTool); + if (!currentDbTool) { + throw new ServiceException('Unknown db tool'); + } + await currentDbTool.deploy(appId); + return new SuccessActionResult(); + }) as Promise>; export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: DbToolIds) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - - const currentDbTool = dbToolClasses.get(dbTool); - if (!currentDbTool) { - throw new ServiceException('Unknown db tool'); - } - return new SuccessActionResult(await currentDbTool.getLoginCredentialsForRunningDbGate(appId)); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); - }) as Promise>; + const currentDbTool = dbToolClasses.get(dbTool); + if (!currentDbTool) { + throw new ServiceException('Unknown db tool'); + } + return new SuccessActionResult(await currentDbTool.getLoginCredentialsForRunningDbGate(appId)); + }) as Promise>; export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: DbToolIds) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - - const currentDbTool = dbToolClasses.get(dbTool); - if (!currentDbTool) { - throw new ServiceException('Unknown db tool'); - } - await currentDbTool.deleteToolForAppIfExists(appId); - return new SuccessActionResult(); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); - }) as Promise>; + const currentDbTool = dbToolClasses.get(dbTool); + if (!currentDbTool) { + throw new ServiceException('Unknown db tool'); + } + await currentDbTool.deleteToolForAppIfExists(appId); + return new SuccessActionResult(); + }) as Promise>; export const downloadDbGateFilesForApp = async (appId: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - const url = await dbGateService.downloadDbGateFilesForApp(appId); - return new SuccessActionResult(url); - }) as Promise>; \ No newline at end of file + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + const url = await dbGateService.downloadDbGateFilesForApp(appId); + return new SuccessActionResult(url); + }) as Promise>; diff --git a/src/app/project/[projectId]/apps/details/credentials/db-credentials.tsx b/src/app/project/[projectId]/apps/details/credentials/db-credentials.tsx index e445a34a..b6a5b7ae 100644 --- a/src/app/project/[projectId]/apps/details/credentials/db-credentials.tsx +++ b/src/app/project/[projectId]/apps/details/credentials/db-credentials.tsx @@ -1,74 +1,63 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { useEffect, useState } from "react"; -import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model"; -import { Actions } from "@/frontend/utils/nextjs-actions.utils"; -import { getDatabaseCredentials } from "./actions"; -import CopyInputField from "@/components/custom/copy-input-field"; -import FullLoadingSpinner from "@/components/ui/full-loading-spinnter"; - -export default function DbCredentials({ - app -}: { - app: AppExtendedModel; -}) { - - const [databaseCredentials, setDatabaseCredentials] = useState(undefined); - - - const loadCredentials = async (appId: string) => { - const response = await Actions.run(() => getDatabaseCredentials(appId)); - setDatabaseCredentials(response); - } - - useEffect(() => { - loadCredentials(app.id); - return () => { - setDatabaseCredentials(undefined); - } - }, [app]); - - return <> - - - Database Credentials - Use these credentials to connect to your database from other apps within the same project. - - - {!databaseCredentials ? : <> -
- {!!databaseCredentials?.databaseName && <> - -
- } - - {!!databaseCredentials?.username && } - - {!!databaseCredentials?.password && } - - - - -
-
- -
- } -
-
- ; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { useEffect, useState } from 'react'; +import { DatabaseTemplateInfoModel } from '@/shared/model/database-template-info.model'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; +import { getDatabaseCredentials } from './actions'; +import CopyInputField from '@/components/custom/copy-input-field'; +import FullLoadingSpinner from '@/components/ui/full-loading-spinnter'; + +export default function DbCredentials({ app }: { app: AppExtendedModel }) { + const [databaseCredentials, setDatabaseCredentials] = useState(undefined); + + const loadCredentials = async (appId: string) => { + const response = await Actions.run(() => getDatabaseCredentials(appId)); + setDatabaseCredentials(response); + }; + + useEffect(() => { + loadCredentials(app.id); + return () => { + setDatabaseCredentials(undefined); + }; + }, [app]); + + return ( + <> + + + Database Credentials + Use these credentials to connect to your database from other apps within the same project. + + + {!databaseCredentials ? ( + + ) : ( + <> +
+ {!!databaseCredentials?.databaseName && ( + <> + {' '} + +
+ + )} + + {!!databaseCredentials?.username && } + + {!!databaseCredentials?.password && } + + + + +
+
+ +
+ + )} +
+
+ + ); } diff --git a/src/app/project/[projectId]/apps/details/credentials/db-gate-db-tool.tsx b/src/app/project/[projectId]/apps/details/credentials/db-gate-db-tool.tsx index e029b924..97405392 100644 --- a/src/app/project/[projectId]/apps/details/credentials/db-gate-db-tool.tsx +++ b/src/app/project/[projectId]/apps/details/credentials/db-gate-db-tool.tsx @@ -1,117 +1,142 @@ -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { useConfirmDialog } from "@/frontend/states/zustand.states"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { Actions } from "@/frontend/utils/nextjs-actions.utils"; -import { deleteDbToolDeploymentForAppIfExists, deployDbTool, downloadDbGateFilesForApp, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from "./actions"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Code } from "@/components/custom/code"; -import LoadingSpinner from "@/components/ui/loading-spinner"; -import { Download } from "lucide-react"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useConfirmDialog } from '@/frontend/states/zustand.states'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; +import { + deleteDbToolDeploymentForAppIfExists, + deployDbTool, + downloadDbGateFilesForApp, + getIsDbToolActive, + getLoginCredentialsForRunningDbTool +} from './actions'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Code } from '@/components/custom/code'; +import LoadingSpinner from '@/components/ui/loading-spinner'; +import { Download } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -export default function DbGateDbTool({ - app -}: { - app: AppExtendedModel; -}) { +export default function DbGateDbTool({ app }: { app: AppExtendedModel }) { + const { openConfirmDialog } = useConfirmDialog(); + const [isDbGateActive, setIsDbGateActive] = useState(undefined); + const [loading, setLoading] = useState(false); - const { openConfirmDialog } = useConfirmDialog(); - const [isDbGateActive, setIsDbGateActive] = useState(undefined); - const [loading, setLoading] = useState(false); + const loadIsDbGateActive = async (appId: string) => { + const response = await Actions.run(() => getIsDbToolActive(appId, 'dbgate')); + setIsDbGateActive(response); + }; - const loadIsDbGateActive = async (appId: string) => { - const response = await Actions.run(() => getIsDbToolActive(appId, 'dbgate')); - setIsDbGateActive(response); - } - - const downloadDbGateFilesForAppAsync = async () => { - try { - setLoading(true); - await Toast.fromAction(() => downloadDbGateFilesForApp(app.id)).then(x => { - if (x.status === 'success' && x.data) { - window.open('/api/volume-data-download?fileName=' + x.data); - } - }); - } finally { - setLoading(false); + const downloadDbGateFilesForAppAsync = async () => { + try { + setLoading(true); + await Toast.fromAction(() => downloadDbGateFilesForApp(app.id)).then((x) => { + if (x.status === 'success' && x.data) { + window.open('/api/volume-data-download?fileName=' + x.data); } + }); + } finally { + setLoading(false); } + }; - const openDbGateAsync = async () => { - try { - setLoading(true); - const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, 'dbgate')); - setLoading(false); - await openConfirmDialog({ - title: "Open DB Gate", - description: <> - DB Gate is ready and can be opened in a new tab.
- Use the following credentials to login: -
- -
{credentials.username}
-
-
- -
{credentials.password}
-
-
- -
- , - okButton: '', - cancelButton: "Close" - }); - } finally { - setLoading(false); - } + const openDbGateAsync = async () => { + try { + setLoading(true); + const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, 'dbgate')); + setLoading(false); + await openConfirmDialog({ + title: 'Open DB Gate', + description: ( + <> + DB Gate is ready and can be opened in a new tab.
+ Use the following credentials to login: +
+ +
+ {' '} + {credentials.username} +
+
+
+ +
+ {credentials.password} +
+
+
+ +
+ + ), + okButton: '', + cancelButton: 'Close' + }); + } finally { + setLoading(false); } + }; - useEffect(() => { - loadIsDbGateActive(app.id); - return () => { - setIsDbGateActive(undefined); - } - }, [app]); - - return <> -
-
- { - try { - setLoading(true); - if (checked) { - await Toast.fromAction(() => deployDbTool(app.id, 'dbgate'), 'DB Gate is now activated', 'Activating DB Gate...'); - } else { - await Toast.fromAction(() => deleteDbToolDeploymentForAppIfExists(app.id, 'dbgate'), 'DB Gate has been deactivated', 'Deactivating DB Gate...'); - } - await loadIsDbGateActive(app.id); - } finally { - setLoading(false); - } - }} /> - -
- {isDbGateActive && <> - + useEffect(() => { + loadIsDbGateActive(app.id); + return () => { + setIsDbGateActive(undefined); + }; + }, [app]); - - - - - - -

Download the "Files" folder from DB Gate.

-
-
-
- } - {(loading || isDbGateActive === undefined) && } + return ( + <> +
+
+ { + try { + setLoading(true); + if (checked) { + await Toast.fromAction(() => deployDbTool(app.id, 'dbgate'), 'DB Gate is now activated', 'Activating DB Gate...'); + } else { + await Toast.fromAction( + () => deleteDbToolDeploymentForAppIfExists(app.id, 'dbgate'), + 'DB Gate has been deactivated', + 'Deactivating DB Gate...' + ); + } + await loadIsDbGateActive(app.id); + } finally { + setLoading(false); + } + }} + /> +
- ; + {isDbGateActive && ( + <> + + + + + + + + +

Download the "Files" folder from DB Gate.

+
+
+
+ + )} + {(loading || isDbGateActive === undefined) && } +
+ + ); } diff --git a/src/app/project/[projectId]/apps/details/credentials/db-tools.tsx b/src/app/project/[projectId]/apps/details/credentials/db-tools.tsx index 23cc576a..a831dd48 100644 --- a/src/app/project/[projectId]/apps/details/credentials/db-tools.tsx +++ b/src/app/project/[projectId]/apps/details/credentials/db-tools.tsx @@ -1,30 +1,26 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import DbGateDbTool from "./db-gate-db-tool"; -import DbToolSwitch from "./phpmyadmin-db-tool"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import DbGateDbTool from './db-gate-db-tool'; +import DbToolSwitch from './phpmyadmin-db-tool'; -export default function DbToolsCard({ - app -}: { - app: AppExtendedModel; -}) { +export default function DbToolsCard({ app }: { app: AppExtendedModel }) { + if (app.appType === 'REDIS') { + return <>; + } - if (app.appType === 'REDIS') { - return <>; - } - - return <> - - - Database Access - Activate one of the following tools to access the database through your browser. - - - - {['MYSQL', 'MARIADB'].includes(app.appType) && } - {app.appType === 'POSTGRES' && } - - - ; + return ( + <> + + + Database Access + Activate one of the following tools to access the database through your browser. + + + + {['MYSQL', 'MARIADB'].includes(app.appType) && } + {app.appType === 'POSTGRES' && } + + + + ); } diff --git a/src/app/project/[projectId]/apps/details/credentials/phpmyadmin-db-tool.tsx b/src/app/project/[projectId]/apps/details/credentials/phpmyadmin-db-tool.tsx index a6e18131..b19656da 100644 --- a/src/app/project/[projectId]/apps/details/credentials/phpmyadmin-db-tool.tsx +++ b/src/app/project/[projectId]/apps/details/credentials/phpmyadmin-db-tool.tsx @@ -1,94 +1,111 @@ -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { useCallback, useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { useConfirmDialog } from "@/frontend/states/zustand.states"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { Actions } from "@/frontend/utils/nextjs-actions.utils"; -import { DbToolIds, deleteDbToolDeploymentForAppIfExists, deployDbTool, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from "./actions"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Code } from "@/components/custom/code"; -import LoadingSpinner from "@/components/ui/loading-spinner"; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useConfirmDialog } from '@/frontend/states/zustand.states'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; +import { DbToolIds, deleteDbToolDeploymentForAppIfExists, deployDbTool, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from './actions'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Code } from '@/components/custom/code'; +import LoadingSpinner from '@/components/ui/loading-spinner'; -export default function DbToolSwitch({ - app, - toolId, - toolNameString -}: { - app: AppExtendedModel; - toolId: DbToolIds; - toolNameString: string; -}) { +export default function DbToolSwitch({ app, toolId, toolNameString }: { app: AppExtendedModel; toolId: DbToolIds; toolNameString: string }) { + const { openConfirmDialog } = useConfirmDialog(); + const [isDbToolActive, setIsDbToolActive] = useState(undefined); + const [loading, setLoading] = useState(false); - const { openConfirmDialog } = useConfirmDialog(); - const [isDbToolActive, setIsDbToolActive] = useState(undefined); - const [loading, setLoading] = useState(false); + const loadIdDbToolActive = useCallback( + async (appId: string) => { + const response = await Actions.run(() => getIsDbToolActive(appId, toolId)); + setIsDbToolActive(response); + }, + [toolId] + ); - const loadIdDbToolActive = useCallback(async (appId: string) => { - const response = await Actions.run(() => getIsDbToolActive(appId, toolId)); - setIsDbToolActive(response); - }, [toolId]) - - const openDbTool = async () => { - try { - setLoading(true); - const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, toolId)); - setLoading(false); - await openConfirmDialog({ - title: "Open DB Tool", - description: <> - {toolNameString} is ready and can be opened in a new tab.
- Use the following credentials to login: -
- -
{credentials.username}
-
-
- -
{credentials.password}
-
-
- -
- , - okButton: '', - cancelButton: "Close" - }); - } finally { - setLoading(false); - } + const openDbTool = async () => { + try { + setLoading(true); + const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, toolId)); + setLoading(false); + await openConfirmDialog({ + title: 'Open DB Tool', + description: ( + <> + {toolNameString} is ready and can be opened in a new tab.
+ Use the following credentials to login: +
+ +
+ {' '} + {credentials.username} +
+
+
+ +
+ {credentials.password} +
+
+
+ +
+ + ), + okButton: '', + cancelButton: 'Close' + }); + } finally { + setLoading(false); } + }; - useEffect(() => { - loadIdDbToolActive(app.id); - return () => { - setIsDbToolActive(undefined); - } - }, [app, loadIdDbToolActive]); + useEffect(() => { + loadIdDbToolActive(app.id); + return () => { + setIsDbToolActive(undefined); + }; + }, [app, loadIdDbToolActive]); - return <> -
-
- { - try { - setLoading(true); - if (checked) { - await Toast.fromAction(() => deployDbTool(app.id, toolId), `${toolNameString} is now activated`, `activating ${toolNameString}...`); - } else { - await Toast.fromAction(() => deleteDbToolDeploymentForAppIfExists(app.id, toolId), `${toolNameString} has been deactivated`, `Deactivating ${toolNameString}...`); - } - await loadIdDbToolActive(app.id); - } finally { - setLoading(false); - } - }} /> - -
- {isDbToolActive && <> - - } - {(loading || isDbToolActive === undefined) && } + return ( + <> +
+
+ { + try { + setLoading(true); + if (checked) { + await Toast.fromAction(() => deployDbTool(app.id, toolId), `${toolNameString} is now activated`, `activating ${toolNameString}...`); + } else { + await Toast.fromAction( + () => deleteDbToolDeploymentForAppIfExists(app.id, toolId), + `${toolNameString} has been deactivated`, + `Deactivating ${toolNameString}...` + ); + } + await loadIdDbToolActive(app.id); + } finally { + setLoading(false); + } + }} + /> +
- ; + {isDbToolActive && ( + <> + + + )} + {(loading || isDbToolActive === undefined) && } +
+ + ); } diff --git a/src/app/project/[projectId]/apps/details/deployments/actions.ts b/src/app/project/[projectId]/apps/details/deployments/actions.ts index 115f3615..e45ddea0 100644 --- a/src/app/project/[projectId]/apps/details/deployments/actions.ts +++ b/src/app/project/[projectId]/apps/details/deployments/actions.ts @@ -1,58 +1,57 @@ -'use server' +'use server'; import appMonitoringHistoryQueryService from '@/server/services/standalone-services/app-monitoring-history.service'; -import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; -import appService from "@/server/services/app.service"; -import buildService from "@/server/services/build.service"; -import deploymentService from "@/server/services/deployment.service"; -import monitoringService from "@/server/services/monitoring.service"; -import podService from "@/server/services/pod.service"; -import { isAuthorizedReadForApp, isAuthorizedWriteForApp, isAuthorizedWriteForWorkload, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { SuccessActionResult } from '@/shared/model/server-action-error-return.model'; +import appService from '@/server/services/app.service'; +import buildService from '@/server/services/build.service'; +import deploymentService from '@/server/services/deployment.service'; +import monitoringService from '@/server/services/monitoring.service'; +import podService from '@/server/services/pod.service'; +import { isAuthorizedReadForApp, isAuthorizedWriteForApp, isAuthorizedWriteForWorkload, simpleAction } from '@/server/utils/action-wrapper.utils'; export const getDeploymentsAndBuildsForApp = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - const app = await appService.getExtendedById(appId); - return await deploymentService.getDeploymentHistory(app.projectId, appId); - }); + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + const app = await appService.getExtendedById(appId); + return await deploymentService.getDeploymentHistory(app.projectId, appId); + }); export const deleteBuild = async (buildName: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName)); - await buildService.deleteBuild(buildName); - return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.'); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(await buildService.getAppIdByBuildName(buildName)); + await buildService.deleteBuild(buildName); + return new SuccessActionResult(undefined, 'Successfully stopped and deleted build.'); + }); export const rollbackToDeployment = async (appId: string, deploymentId: string) => - simpleAction(async () => { - await isAuthorizedWriteForApp(appId); - await appService.rollbackToDeployment(appId, deploymentId); - return new SuccessActionResult(undefined, 'Successfully started rollback.'); - }); + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + await appService.rollbackToDeployment(appId, deploymentId); + return new SuccessActionResult(undefined, 'Successfully started rollback.'); + }); export const getPodsForApp = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - const app = await appService.getExtendedById(appId); - return await podService.getPodsForApp(app.projectId, appId); - }); + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + const app = await appService.getExtendedById(appId); + return await podService.getPodsForApp(app.projectId, appId); + }); export const getRessourceDataApp = async (projectId: string, appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - return await monitoringService.getMonitoringForApp(projectId, appId); - }); + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + return await monitoringService.getMonitoringForApp(projectId, appId); + }); export const createNewWebhookUrl = async (appId: string) => - simpleAction(async () => { - await isAuthorizedWriteForWorkload(appId); - return await appService.regenerateWebhookId(appId); - }); - + simpleAction(async () => { + await isAuthorizedWriteForWorkload(appId); + return await appService.regenerateWebhookId(appId); + }); export const getAppMonitoringHistory = async (appId: string) => - simpleAction(async () => { - await isAuthorizedReadForApp(appId); - return appMonitoringHistoryQueryService.getHistory([appId]); - }); + simpleAction(async () => { + await isAuthorizedReadForApp(appId); + return appMonitoringHistoryQueryService.getHistory([appId]); + }); diff --git a/src/app/project/[projectId]/apps/details/deployments/app-details-deployments-tab.tsx b/src/app/project/[projectId]/apps/details/deployments/app-details-deployments-tab.tsx index 4adbc38c..9334fae2 100644 --- a/src/app/project/[projectId]/apps/details/deployments/app-details-deployments-tab.tsx +++ b/src/app/project/[projectId]/apps/details/deployments/app-details-deployments-tab.tsx @@ -1,56 +1,42 @@ -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { RolePermissionEnum } from "@/shared/model/role-extended.model"; -import { useNestedDrawer } from "../../../../workload-components/drawer/nested-drawer"; -import { DeploymentInfoModel } from "@/shared/model/deployment-info.model"; -import { BuildLogsDialogContent } from "@/app/project/workload-components/build-logs-dialog-content"; -import BuildsTab from "@/app/project/[projectId]/apps/details/deployments/deployments"; -import { formatDateTime } from "@/frontend/utils/format.utils"; -import WebhookDeploymentInfo from "@/app/project/[projectId]/apps/details/deployments/webhook-deployment"; -import { Button } from "@/components/ui/button"; -import { Webhook } from "lucide-react"; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model'; +import { useNestedDrawer } from '../../../../workload-components/drawer/nested-drawer'; +import { DeploymentInfoModel } from '@/shared/model/deployment-info.model'; +import { BuildLogsDialogContent } from '@/app/project/workload-components/build-logs-dialog-content'; +import BuildsTab from '@/app/project/[projectId]/apps/details/deployments/deployments'; +import { formatDateTime } from '@/frontend/utils/format.utils'; +import WebhookDeploymentInfo from '@/app/project/[projectId]/apps/details/deployments/webhook-deployment'; +import { Button } from '@/components/ui/button'; +import { Webhook } from 'lucide-react'; -export default function DrawerDeploymentsTab({ - app, - role, -}: { - app: AppExtendedModel; - role: RolePermissionEnum; -}) { - const { openNestedDrawer } = useNestedDrawer(); +export default function DrawerDeploymentsTab({ app, role }: { app: AppExtendedModel; role: RolePermissionEnum }) { + const { openNestedDrawer } = useNestedDrawer(); - const showLogs = (deployment: DeploymentInfoModel) => { - openNestedDrawer({ - title: 'Deployment Logs', - description: `View the logs for the selected deployment ${formatDateTime(deployment.createdAt)}.`, - content: ( - - ), - }); - }; + const showLogs = (deployment: DeploymentInfoModel) => { + openNestedDrawer({ + title: 'Deployment Logs', + description: `View the logs for the selected deployment ${formatDateTime(deployment.createdAt)}.`, + content: + }); + }; - const showWebhookCard = () => { - openNestedDrawer({ - title: 'Webhook', - description: 'Use this webhook URL to trigger deployments from external services (for example GitHub Actions or GitLab Pipelines).', - content: ( - - ), - }); - } + const showWebhookCard = () => { + openNestedDrawer({ + title: 'Webhook', + description: 'Use this webhook URL to trigger deployments from external services (for example GitHub Actions or GitLab Pipelines).', + content: + }); + }; - return
-
-
Latest Deployments
- -
- -
; -} \ No newline at end of file + return ( +
+
+
Latest Deployments
+ +
+ +
+ ); +} diff --git a/src/app/project/[projectId]/apps/details/deployments/deployments-grid-view.tsx b/src/app/project/[projectId]/apps/details/deployments/deployments-grid-view.tsx index 7a72cb35..51b5afed 100644 --- a/src/app/project/[projectId]/apps/details/deployments/deployments-grid-view.tsx +++ b/src/app/project/[projectId]/apps/details/deployments/deployments-grid-view.tsx @@ -1,138 +1,116 @@ import { Boxes, EllipsisVertical, File, GitCommit, LucideTerminal, Rocket, RotateCcw } from 'lucide-react'; import ShortCommitHash from '@/components/custom/short-commit-hash'; import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Item, ItemContent, ItemTitle } from '@/components/ui/item'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { formatDateTime } from '@/frontend/utils/format.utils'; import type { DeploymentInfoModel } from '@/shared/model/deployment-info.model'; import { appBuildMethodLabels } from '@/shared/model/app-source-info.model'; import DeploymentStatusBadge from '../../../../workload-components/deployment-status-badge'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '@/components/ui/empty'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; type DeploymentsGridViewProps = { - deployments: DeploymentInfoModel[]; - canStopBuild: (deployment: DeploymentInfoModel) => boolean; - canRollback: (deployment: DeploymentInfoModel) => boolean; - onShowLogs: (deployment: DeploymentInfoModel) => void; - onStopBuild: (deployment: DeploymentInfoModel) => void; - onRollback: (deployment: DeploymentInfoModel) => void; + deployments: DeploymentInfoModel[]; + canStopBuild: (deployment: DeploymentInfoModel) => boolean; + canRollback: (deployment: DeploymentInfoModel) => boolean; + onShowLogs: (deployment: DeploymentInfoModel) => void; + onStopBuild: (deployment: DeploymentInfoModel) => void; + onRollback: (deployment: DeploymentInfoModel) => void; }; -export function DeploymentsGridView({ - deployments, - canStopBuild, - canRollback, - onShowLogs, - onStopBuild, - onRollback, -}: DeploymentsGridViewProps) { - if (deployments.length === 0) { - return ( - - - - - - No deployments available - - This app has no deployments yet. - - - - ); - } - +export function DeploymentsGridView({ deployments, canStopBuild, canRollback, onShowLogs, onStopBuild, onRollback }: DeploymentsGridViewProps) { + if (deployments.length === 0) { return ( -
- {deployments.map(deployment => ( - -
- - - {deployment.status} - {deployment.isRollback && ( - - Rollback - - )} - - {deployment.gitCommitMessage && ( -
- {deployment.gitCommitMessage.length > 200 - ? `${deployment.gitCommitMessage.slice(0, 200)}…` - : deployment.gitCommitMessage} -
- )} -
- - - onShowLogs(deployment)}> - - Show Deployment Logs - } /> - Show Deployment Logs - - - {(canStopBuild(deployment) || canRollback(deployment)) && - - - Deployment actions - } /> - - {canStopBuild(deployment) && ( - onStopBuild(deployment)} - > - Stop Build - - )} - {canRollback(deployment) && ( - onRollback(deployment)}> - - Rollback to this deployment - - )} - - } -
-
- {deployment.gitCommit &&
- - {deployment.gitCommit} -
} - {deployment.gitCommit && } - {deployment.buildMethod && <> - - {deployment.buildMethod === 'DOCKERFILE' && } - {deployment.buildMethod === 'FRAMEWORK' && } - {appBuildMethodLabels[deployment.buildMethod] ?? '-'} - - - } - {formatDateTime(deployment.createdAt)} -
-
- ))} -
+ + + + + + No deployments available + This app has no deployments yet. + + ); + } + + return ( +
+ {deployments.map((deployment) => ( + +
+ + + {deployment.status} + {deployment.isRollback && Rollback} + + {deployment.gitCommitMessage && ( +
+ {deployment.gitCommitMessage.length > 200 ? `${deployment.gitCommitMessage.slice(0, 200)}…` : deployment.gitCommitMessage} +
+ )} +
+ + + onShowLogs(deployment)}> + + Show Deployment Logs + + } + /> + Show Deployment Logs + + + {(canStopBuild(deployment) || canRollback(deployment)) && ( + + + + Deployment actions + + } + /> + + {canStopBuild(deployment) && ( + onStopBuild(deployment)}> + Stop Build + + )} + {canRollback(deployment) && ( + onRollback(deployment)}> + + Rollback to this deployment + + )} + + + )} +
+
+ {deployment.gitCommit && ( +
+ + {deployment.gitCommit} +
+ )} + {deployment.gitCommit && } + {deployment.buildMethod && ( + <> + + {deployment.buildMethod === 'DOCKERFILE' && } + {deployment.buildMethod === 'FRAMEWORK' && } + {appBuildMethodLabels[deployment.buildMethod] ?? '-'} + + + + )} + {formatDateTime(deployment.createdAt)} +
+
+ ))} +
+ ); } diff --git a/src/app/project/[projectId]/apps/details/deployments/deployments.tsx b/src/app/project/[projectId]/apps/details/deployments/deployments.tsx index 30fdc19f..f8835879 100644 --- a/src/app/project/[projectId]/apps/details/deployments/deployments.tsx +++ b/src/app/project/[projectId]/apps/details/deployments/deployments.tsx @@ -7,103 +7,91 @@ import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import type { DeploymentInfoModel } from '@/shared/model/deployment-info.model'; import { RolePermissionEnum } from '@/shared/model/role-extended.model'; import { GitHashUtils } from '@/shared/utils/git-hash.utils'; -import { - deleteBuild, - getDeploymentsAndBuildsForApp, - rollbackToDeployment, -} from './actions'; +import { deleteBuild, getDeploymentsAndBuildsForApp, rollbackToDeployment } from './actions'; import { DeploymentsGridView } from './deployments-grid-view'; export default function BuildsTab({ - app, - role, - onShowLogs, + app, + role, + onShowLogs }: { - app: AppExtendedModel; - role: RolePermissionEnum; - onShowLogs: (deployment: DeploymentInfoModel) => void; + app: AppExtendedModel; + role: RolePermissionEnum; + onShowLogs: (deployment: DeploymentInfoModel) => void; }) { - const { openConfirmDialog } = useConfirmDialog(); - const [appBuilds, setAppBuilds] = useState(); - const [, setError] = useState(); + const { openConfirmDialog } = useConfirmDialog(); + const [appBuilds, setAppBuilds] = useState(); + const [, setError] = useState(); - const updateBuilds = useCallback(async () => { - setError(undefined); - try { - const response = await getDeploymentsAndBuildsForApp(app.id); - if (response.status === 'success' && response.data) { - setAppBuilds(response.data); - } else { - console.error(response); - setError(response.message ?? 'An unknown error occurred.'); - } - } catch (error) { - console.error(error); - setError('An unknown error occurred.'); - } - }, [app.id]); + const updateBuilds = useCallback(async () => { + setError(undefined); + try { + const response = await getDeploymentsAndBuildsForApp(app.id); + if (response.status === 'success' && response.data) { + setAppBuilds(response.data); + } else { + console.error(response); + setError(response.message ?? 'An unknown error occurred.'); + } + } catch (error) { + console.error(error); + setError('An unknown error occurred.'); + } + }, [app.id]); - const stopBuild = async (deployment: DeploymentInfoModel) => { - if (!deployment.buildJobName) return; - const confirmed = await openConfirmDialog({ - title: 'Delete Build', - description: 'The build will be stopped and removed. Are you sure you want to stop this build?', - okButton: 'Stop & Remove Build', - }); - if (!confirmed) return; - await Toast.fromAction(() => deleteBuild(deployment.buildJobName!)); - await updateBuilds(); - }; + const stopBuild = async (deployment: DeploymentInfoModel) => { + if (!deployment.buildJobName) return; + const confirmed = await openConfirmDialog({ + title: 'Delete Build', + description: 'The build will be stopped and removed. Are you sure you want to stop this build?', + okButton: 'Stop & Remove Build' + }); + if (!confirmed) return; + await Toast.fromAction(() => deleteBuild(deployment.buildJobName!)); + await updateBuilds(); + }; - const rollback = async (deployment: DeploymentInfoModel) => { - const confirmed = await openConfirmDialog({ - title: 'Rollback Deployment', - description: `Roll back to git commit ${GitHashUtils.shortGitHash(deployment.gitCommit)}? The App will be redeployed with the code from this commit.`, - okButton: 'Rollback', - }); - if (!confirmed) return; - await Toast.fromAction(() => rollbackToDeployment(app.id, deployment.deploymentId)); - await updateBuilds(); - }; + const rollback = async (deployment: DeploymentInfoModel) => { + const confirmed = await openConfirmDialog({ + title: 'Rollback Deployment', + description: `Roll back to git commit ${GitHashUtils.shortGitHash(deployment.gitCommit)}? The App will be redeployed with the code from this commit.`, + okButton: 'Rollback' + }); + if (!confirmed) return; + await Toast.fromAction(() => rollbackToDeployment(app.id, deployment.deploymentId)); + await updateBuilds(); + }; - const canStopBuild = (deployment: DeploymentInfoModel) => - role === RolePermissionEnum.READWRITE - && !!deployment.buildJobName - && deployment.status === 'BUILDING'; - const canRollback = (deployment: DeploymentInfoModel) => - role === RolePermissionEnum.READWRITE - && !!deployment.replicasetName - && !!deployment.gitCommit - && deployment.status !== 'DEPLOYING' - && deployment.status !== 'DEPLOYED'; + const canStopBuild = (deployment: DeploymentInfoModel) => + role === RolePermissionEnum.READWRITE && !!deployment.buildJobName && deployment.status === 'BUILDING'; + const canRollback = (deployment: DeploymentInfoModel) => + role === RolePermissionEnum.READWRITE && + !!deployment.replicasetName && + !!deployment.gitCommit && + deployment.status !== 'DEPLOYING' && + deployment.status !== 'DEPLOYED'; - const currentBuildStatus = useBuildStatus(state => state.buildStatus.get(app.id)?.status); + const currentBuildStatus = useBuildStatus((state) => state.buildStatus.get(app.id)?.status); - useEffect(() => { - if (currentBuildStatus === undefined) { - return; - } - void updateBuilds(); - }, [currentBuildStatus, updateBuilds]); + useEffect(() => { + if (currentBuildStatus === undefined) { + return; + } + void updateBuilds(); + }, [currentBuildStatus, updateBuilds]); - usePolling(updateBuilds, { - intervalMs: 10000, - enabled: app.sourceType !== 'container', - runImmediately: true, - }); + usePolling(updateBuilds, { intervalMs: 10000, enabled: app.sourceType !== 'container', runImmediately: true }); - if (app.sourceType === 'container') return null; + if (app.sourceType === 'container') return null; - const viewProps = { - deployments: appBuilds ?? [], - canStopBuild, - canRollback, - onShowLogs, - onStopBuild: (deployment: DeploymentInfoModel) => void stopBuild(deployment), - onRollback: (deployment: DeploymentInfoModel) => void rollback(deployment), - }; + const viewProps = { + deployments: appBuilds ?? [], + canStopBuild, + canRollback, + onShowLogs, + onStopBuild: (deployment: DeploymentInfoModel) => void stopBuild(deployment), + onRollback: (deployment: DeploymentInfoModel) => void rollback(deployment) + }; - return !appBuilds - ? - : ; + return !appBuilds ? : ; } diff --git a/src/app/project/[projectId]/apps/details/deployments/history-actions.unit.spec.ts b/src/app/project/[projectId]/apps/details/deployments/history-actions.unit.spec.ts index 1270603c..4035d571 100644 --- a/src/app/project/[projectId]/apps/details/deployments/history-actions.unit.spec.ts +++ b/src/app/project/[projectId]/apps/details/deployments/history-actions.unit.spec.ts @@ -1,19 +1,20 @@ -vi.mock('@/server/services/standalone-services/app-monitoring-history.service', () => ({ - default: { getHistory: vi.fn().mockResolvedValue({ points: [] }) }, -})); +vi.mock('@/server/services/standalone-services/app-monitoring-history.service', () => ({ default: { getHistory: vi.fn().mockResolvedValue({ points: [] }) } })); vi.mock('@/server/services/app.service', () => ({ default: {} })); vi.mock('@/server/services/build.service', () => ({ default: {} })); vi.mock('@/server/services/deployment.service', () => ({ default: {} })); vi.mock('@/server/services/monitoring.service', () => ({ default: {} })); vi.mock('@/server/services/pod.service', () => ({ default: {} })); vi.mock('@/server/utils/action-wrapper.utils', () => ({ - isAuthorizedReadForApp: vi.fn(), - isAuthorizedWriteForApp: vi.fn(), - isAuthorizedWriteForWorkload: vi.fn(), - simpleAction: async (callback: () => Promise) => { - try { return { status: 'success', data: await callback() }; } - catch { return { status: 'error' }; } - }, + isAuthorizedReadForApp: vi.fn(), + isAuthorizedWriteForApp: vi.fn(), + isAuthorizedWriteForWorkload: vi.fn(), + simpleAction: async (callback: () => Promise) => { + try { + return { status: 'success', data: await callback() }; + } catch { + return { status: 'error' }; + } + } })); import history from '@/server/services/standalone-services/app-monitoring-history.service'; import { isAuthorizedReadForApp } from '@/server/utils/action-wrapper.utils'; @@ -22,16 +23,16 @@ import { getAppMonitoringHistory } from './actions'; beforeEach(() => vi.clearAllMocks()); it('returns history for an App when the user has read access', async () => { - vi.mocked(isAuthorizedReadForApp).mockResolvedValue({} as never); - const result = await getAppMonitoringHistory('allowed'); - expect(result.status).toBe('success'); - expect(isAuthorizedReadForApp).toHaveBeenCalledWith('allowed'); - expect(history.getHistory).toHaveBeenCalledWith(['allowed']); + vi.mocked(isAuthorizedReadForApp).mockResolvedValue({} as never); + const result = await getAppMonitoringHistory('allowed'); + expect(result.status).toBe('success'); + expect(isAuthorizedReadForApp).toHaveBeenCalledWith('allowed'); + expect(history.getHistory).toHaveBeenCalledWith(['allowed']); }); it('does not query history when the user cannot read the App', async () => { - vi.mocked(isAuthorizedReadForApp).mockRejectedValue(new Error('not authorized')); - const result = await getAppMonitoringHistory('denied'); - expect(result.status).toBe('error'); - expect(history.getHistory).not.toHaveBeenCalled(); + vi.mocked(isAuthorizedReadForApp).mockRejectedValue(new Error('not authorized')); + const result = await getAppMonitoringHistory('denied'); + expect(result.status).toBe('error'); + expect(history.getHistory).not.toHaveBeenCalled(); }); diff --git a/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx b/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx index f6493634..1ef775e8 100644 --- a/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx +++ b/src/app/project/[projectId]/apps/details/deployments/webhook-deployment.tsx @@ -1,59 +1,61 @@ -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { useEffect, useState } from "react"; -import { createNewWebhookUrl } from "./actions"; -import { Button } from "@/components/ui/button"; -import { useConfirmDialog } from "@/frontend/states/zustand.states"; -import { Toast } from "@/frontend/utils/toast.utils"; -import { ClipboardCopy } from "lucide-react"; -import { toast } from "sonner"; -import { RolePermissionEnum } from "@/shared/model/role-extended.model"; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { useEffect, useState } from 'react'; +import { createNewWebhookUrl } from './actions'; +import { Button } from '@/components/ui/button'; +import { useConfirmDialog } from '@/frontend/states/zustand.states'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { ClipboardCopy } from 'lucide-react'; +import { toast } from 'sonner'; +import { RolePermissionEnum } from '@/shared/model/role-extended.model'; -export default function WebhookDeploymentInfo({ - app, - role -}: { - app: AppExtendedModel; - role: RolePermissionEnum; -}) { - const { openConfirmDialog } = useConfirmDialog(); - const [webhookUrl, setWebhookUrl] = useState(undefined); +export default function WebhookDeploymentInfo({ app, role }: { app: AppExtendedModel; role: RolePermissionEnum }) { + const { openConfirmDialog } = useConfirmDialog(); + const [webhookUrl, setWebhookUrl] = useState(undefined); - const setWebhookId = (webhookId?: string | null | void) => { - if (webhookId) { - const hostname = window.location.hostname; - const port = window.location.port && ![80, 443].includes(Number(window.location.port)) ? `:${window.location.port}` : ''; - const protocol = window.location.protocol; - setWebhookUrl(`${protocol}//${hostname}${port}/api/v1/webhook/deploy?id=${webhookId}`); - } + const setWebhookId = (webhookId?: string | null | void) => { + if (webhookId) { + const hostname = window.location.hostname; + const port = window.location.port && ![80, 443].includes(Number(window.location.port)) ? `:${window.location.port}` : ''; + const protocol = window.location.protocol; + setWebhookUrl(`${protocol}//${hostname}${port}/api/v1/webhook/deploy?id=${webhookId}`); } + }; - useEffect(() => { - setWebhookId(app.webhookId); - }, [app, app.webhookId]); + useEffect(() => { + setWebhookId(app.webhookId); + }, [app, app.webhookId]); - const createNewWebhookUrlAsync = async () => { - if (!await openConfirmDialog({ - title: 'Generate new Webhook URL', - description: 'Are you sure you want to generate a new Webhook URL? The old URL will be invalidated.', - okButton: 'Generate new URL' - })) { - return; - } - const newWebhookId = await Toast.fromAction(() => createNewWebhookUrl(app.id), 'Webhook URL has been regenerated.'); - setWebhookId(newWebhookId.data); + const createNewWebhookUrlAsync = async () => { + if ( + !(await openConfirmDialog({ + title: 'Generate new Webhook URL', + description: 'Are you sure you want to generate a new Webhook URL? The old URL will be invalidated.', + okButton: 'Generate new URL' + })) + ) { + return; } + const newWebhookId = await Toast.fromAction(() => createNewWebhookUrl(app.id), 'Webhook URL has been regenerated.'); + setWebhookId(newWebhookId.data); + }; - const copyWebhookUrl = () => { - navigator.clipboard.writeText(webhookUrl!); - toast.success('Webhook URL has been copied to clipboard.'); - } + const copyWebhookUrl = () => { + navigator.clipboard.writeText(webhookUrl!); + toast.success('Webhook URL has been copied to clipboard.'); + }; - return ( -
- {webhookUrl && } - {role === RolePermissionEnum.READWRITE && } -
- ); + return ( +
+ {webhookUrl && ( + + )} + {role === RolePermissionEnum.READWRITE && ( + + )} +
+ ); } diff --git a/src/app/project/[projectId]/apps/details/environment/actions.ts b/src/app/project/[projectId]/apps/details/environment/actions.ts index a017b323..e59c9806 100644 --- a/src/app/project/[projectId]/apps/details/environment/actions.ts +++ b/src/app/project/[projectId]/apps/details/environment/actions.ts @@ -1,17 +1,12 @@ -'use server' - -import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/shared/model/env-edit.model"; -import appService from "@/server/services/app.service"; -import { isAuthorizedWriteForApp, saveFormAction } from "@/server/utils/action-wrapper.utils"; +'use server'; +import { AppEnvVariablesModel, appEnvVariablesZodModel } from '@/shared/model/env-edit.model'; +import appService from '@/server/services/app.service'; +import { isAuthorizedWriteForApp, saveFormAction } from '@/server/utils/action-wrapper.utils'; export const saveEnvVariables = async (prevState: any, inputData: AppEnvVariablesModel, appId: string) => - saveFormAction(inputData, appEnvVariablesZodModel, async (validatedData) => { - await isAuthorizedWriteForApp(appId); - const existingApp = await appService.getById(appId); - await appService.save({ - ...existingApp, - ...validatedData, - id: appId, - }); - }); \ No newline at end of file + saveFormAction(inputData, appEnvVariablesZodModel, async (validatedData) => { + await isAuthorizedWriteForApp(appId); + const existingApp = await appService.getById(appId); + await appService.save({ ...existingApp, ...validatedData, id: appId }); + }); diff --git a/src/app/project/[projectId]/apps/details/environment/app-details-environment.tsx b/src/app/project/[projectId]/apps/details/environment/app-details-environment.tsx index f1c9f383..cbb742cb 100644 --- a/src/app/project/[projectId]/apps/details/environment/app-details-environment.tsx +++ b/src/app/project/[projectId]/apps/details/environment/app-details-environment.tsx @@ -8,138 +8,102 @@ import type { AppExtendedModel } from '@/shared/model/app-extended.model'; import { EnvVarUtils } from '@/shared/utils/env-var.utils'; import { toast } from 'sonner'; -type EnvironmentVariable = { - name: string; - value: string; -}; +type EnvironmentVariable = { name: string; value: string }; -function VariableList({ - title, - variables, -}: { - title: string; - variables: EnvironmentVariable[]; -}) { - const [visibleValues, setVisibleValues] = useState>(new Set()); +function VariableList({ title, variables }: { title: string; variables: EnvironmentVariable[] }) { + const [visibleValues, setVisibleValues] = useState>(new Set()); - return ( -
-

{title}

- {variables.length === 0 ? ( -

None configured.

- ) : ( -
- {variables.map((variable, index) => { - const variableId = `${variable.name}-${index}`; - const isVisible = visibleValues.has(variableId); + return ( +
+

{title}

+ {variables.length === 0 ? ( +

None configured.

+ ) : ( +
+ {variables.map((variable, index) => { + const variableId = `${variable.name}-${index}`; + const isVisible = visibleValues.has(variableId); - return ( -
-
- - {variable.name} - - -
- - - { - try { - await navigator.clipboard.writeText(variable.value); - toast.success('Variable value copied'); - } catch { - toast.error('Failed to copy variable value'); - } - }} - /> - } - > - {isVisible ? variable.value : '••••••••'} - - Click to copy - - - -
- ); - })} + return ( +
+
+ {variable.name} +
- )} + + + { + try { + await navigator.clipboard.writeText(variable.value); + toast.success('Variable value copied'); + } catch { + toast.error('Failed to copy variable value'); + } + }} + /> + } + > + {isVisible ? variable.value : '••••••••'} + + Click to copy + + + +
+ ); + })}
- ); + )} +
+ ); } -export function DrawerEnvironment({ - app, - onEdit, -}: { - app: AppExtendedModel; - onEdit: () => void; -}) { - return ( -
- - - -
- ); +export function DrawerEnvironment({ app, onEdit }: { app: AppExtendedModel; onEdit: () => void }) { + return ( +
+ + + +
+ ); } diff --git a/src/app/project/[projectId]/apps/details/environment/env-edit.tsx b/src/app/project/[projectId]/apps/details/environment/env-edit.tsx index 19234d8c..156b9b7b 100644 --- a/src/app/project/[projectId]/apps/details/environment/env-edit.tsx +++ b/src/app/project/[projectId]/apps/details/environment/env-edit.tsx @@ -1,99 +1,97 @@ 'use client'; -import type { z } from "zod"; -import { SubmitButton } from "@/components/custom/submit-button"; -import { CardContent } from "@/components/ui/card"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { FormUtils } from "@/frontend/utils/form.utilts"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { saveEnvVariables } from "./actions"; -import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; -import { useActionState, useEffect, useId } from "react"; -import { toast } from "sonner"; -import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/shared/model/env-edit.model"; -import { Textarea } from "@/components/ui/textarea"; -import { AppExtendedModel } from "@/shared/model/app-extended.model"; -import { NestedDrawerFooter } from "@/app/project/workload-components/drawer/nested-drawer"; +import type { z } from 'zod'; +import { SubmitButton } from '@/components/custom/submit-button'; +import { CardContent } from '@/components/ui/card'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { FormUtils } from '@/frontend/utils/form.utilts'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { saveEnvVariables } from './actions'; +import { ServerActionResult } from '@/shared/model/server-action-error-return.model'; +import { useActionState, useEffect, useId } from 'react'; +import { toast } from 'sonner'; +import { AppEnvVariablesModel, appEnvVariablesZodModel } from '@/shared/model/env-edit.model'; +import { Textarea } from '@/components/ui/textarea'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { NestedDrawerFooter } from '@/app/project/workload-components/drawer/nested-drawer'; -export default function EnvEdit({ app, readonly }: { - app: AppExtendedModel; - readonly: boolean; -}) { - const formId = useId(); - const form = useForm, unknown, z.output>({ - resolver: zodResolver(appEnvVariablesZodModel), - defaultValues: app, - disabled: readonly, - }); +export default function EnvEdit({ app, readonly }: { app: AppExtendedModel; readonly: boolean }) { + const formId = useId(); + const form = useForm, unknown, z.output>({ + resolver: zodResolver(appEnvVariablesZodModel), + defaultValues: app, + disabled: readonly + }); - const [state, formAction] = useActionState((state: ServerActionResult, payload: AppEnvVariablesModel) => saveEnvVariables(state, payload, app.id), FormUtils.getInitialFormState()); - useEffect(() => { - if (state.status === 'success') { - toast.success('Environment Settings Saved', { - description: "Click \"deploy\" to apply the changes to your app. Build arguments apply on the next build.", - }); - } - FormUtils.mapValidationErrorsToForm(state, form); - }, [form, state]); + const [state, formAction] = useActionState( + (state: ServerActionResult, payload: AppEnvVariablesModel) => saveEnvVariables(state, payload, app.id), + FormUtils.getInitialFormState() + ); + useEffect(() => { + if (state.status === 'success') { + toast.success('Environment Settings Saved', { description: 'Click "deploy" to apply the changes to your app. Build arguments apply on the next build.' }); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [form, state]); - const buildArgsEnabled = app.appType === 'APP' && app.buildMethod === 'DOCKERFILE'; + const buildArgsEnabled = app.appType === 'APP' && app.buildMethod === 'DOCKERFILE'; - return <> -
- -
- form.handleSubmit((data) => { - return formAction(data); - })()}> - - ( - - Env Variables - -