From b1bdb88ea4de4776d14830aeca55af947139c814 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:10:55 +0200 Subject: [PATCH] =?UTF-8?q?ui(deploy):=20compose=20page=20=E2=80=94=20save?= =?UTF-8?q?/redeploy/checkpoints=20wired=20end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../pages/AppsTab/AppDeploymentPage/index.tsx | 476 +++++++++++++++++- 1 file changed, 460 insertions(+), 16 deletions(-) diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx index a3ff1fb9..4713b41f 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx @@ -1,41 +1,96 @@ import { useState, useEffect, useRef } from 'react'; -import { useParams, useLocation } from 'react-router'; +import { useParams, useLocation, useNavigate } from 'react-router'; +import { AlertDialog, Button, Tabs, useToast } from '@cameleer/design-system'; import { useEnvironmentStore } from '../../../api/environment-store'; import { useEnvironments } from '../../../api/queries/admin/environments'; -import { useApps, useAppVersions, useDeployments } from '../../../api/queries/admin/apps'; +import { + useApps, + useCreateApp, + useDeleteApp, + useAppVersions, + useUploadJar, + useDeployments, + useCreateDeployment, + useStopDeployment, + useUpdateContainerConfig, + useDirtyState, +} from '../../../api/queries/admin/apps'; +import type { Deployment } from '../../../api/queries/admin/apps'; +import { useApplicationConfig, useUpdateApplicationConfig } from '../../../api/queries/commands'; import { PageLoader } from '../../../components/PageLoader'; import { IdentitySection } from './IdentitySection'; import { Checkpoints } from './Checkpoints'; +import { MonitoringTab } from './ConfigTabs/MonitoringTab'; +import { ResourcesTab } from './ConfigTabs/ResourcesTab'; +import { VariablesTab } from './ConfigTabs/VariablesTab'; +import { SensitiveKeysTab } from './ConfigTabs/SensitiveKeysTab'; +import { TracesTapsTab } from './ConfigTabs/TracesTapsTab'; +import { RouteRecordingTab } from './ConfigTabs/RouteRecordingTab'; +import { DeploymentTab } from './DeploymentTab/DeploymentTab'; +import { PrimaryActionButton, computeMode } from './PrimaryActionButton'; +import { useDeploymentPageState } from './hooks/useDeploymentPageState'; +import { useFormDirty } from './hooks/useFormDirty'; import { deriveAppName } from './utils/deriveAppName'; import styles from './AppDeploymentPage.module.css'; +type TabKey = 'monitoring' | 'resources' | 'variables' | 'sensitive-keys' | 'deployment' | 'traces' | 'recording'; + +function slugify(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100); +} + export default function AppDeploymentPage() { const { appId } = useParams<{ appId?: string }>(); const location = useLocation(); + const navigate = useNavigate(); + const { toast } = useToast(); const selectedEnv = useEnvironmentStore((s) => s.environment); const { data: environments = [], isLoading: envLoading } = useEnvironments(); const { data: apps = [], isLoading: appsLoading } = useApps(selectedEnv); const isNetNew = location.pathname.endsWith('/apps/new'); - const app = isNetNew ? null : apps.find((a) => a.slug === appId) ?? null; - + const app = isNetNew ? null : (apps.find((a) => a.slug === appId) ?? null); const env = environments.find((e) => e.slug === selectedEnv); + const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug); const currentVersion = versions.slice().sort((a, b) => b.version - a.version)[0] ?? null; const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug); const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null; + const activeDeployment = deployments.find((d) => d.status === 'STARTING') ?? null; + + const { data: agentConfig = null } = useApplicationConfig(app?.slug, selectedEnv); + const { data: dirtyState } = useDirtyState(selectedEnv, app?.slug); + + // Mutations + const createApp = useCreateApp(); + const deleteApp = useDeleteApp(); + const uploadJar = useUploadJar(); + const createDeployment = useCreateDeployment(); + const stopDeployment = useStopDeployment(); + const updateContainerConfig = useUpdateContainerConfig(); + const updateAgentConfig = useUpdateApplicationConfig(); // Form state + const { form, setForm, reset, serverState } = useDeploymentPageState( + app, + agentConfig, + env?.defaultContainerConfig ?? {}, + ); + + // Local UI state const [name, setName] = useState(''); const [stagedJar, setStagedJar] = useState(null); + const [tab, setTab] = useState('monitoring'); + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [stopTarget, setStopTarget] = useState(null); const lastDerivedRef = useRef(''); - // Initialize name when app loads + // Initialize name from app when it loads useEffect(() => { if (app) setName(app.displayName); - }, [app]); + }, [app?.displayName]); // eslint-disable-line react-hooks/exhaustive-deps - // Auto-derive from staged JAR (net-new mode only, don't overwrite manual edits) + // Auto-derive name from staged JAR (net-new only, don't overwrite manual edits) useEffect(() => { if (!stagedJar || app) return; const derived = deriveAppName(stagedJar.name); @@ -45,14 +100,300 @@ export default function AppDeploymentPage() { } }, [stagedJar, app, name]); + // Auto-switch to Deployment tab when a deployment starts + useEffect(() => { + if (activeDeployment) setTab('deployment'); + }, [!!activeDeployment]); // eslint-disable-line react-hooks/exhaustive-deps + + // Derived + const mode = app ? 'deployed' : 'net-new'; + const dirty = useFormDirty(form, serverState, stagedJar); + const serverDirtyAgainstDeploy = dirtyState?.dirty ?? false; + const deploymentInProgress = !!activeDeployment; + const primaryMode = computeMode({ + deploymentInProgress, + hasLocalEdits: dirty.anyLocalEdit, + serverDirtyAgainstDeploy, + }); + + // External URL (same formula as IdentitySection) + const externalUrl = (() => { + if (!env) return ''; + const slug = app?.slug ?? slugify(name); + const defaults = env.defaultContainerConfig ?? {}; + const domain = String(defaults.routingDomain ?? ''); + if (defaults.routingMode === 'subdomain' && domain) { + return `https://${slug || '...'}-${env.slug}.${domain}/`; + } + const base = domain ? `https://${domain}` : window.location.origin; + return `${base}/${env.slug}/${slug || '...'}/`; + })(); + + // ── Tabs definition ──────────────────────────────────────────────── + const tabs: { label: string; value: TabKey }[] = [ + { label: dirty.monitoring ? 'Monitoring *' : 'Monitoring', value: 'monitoring' }, + { label: dirty.resources ? 'Resources *' : 'Resources', value: 'resources' }, + { label: dirty.variables ? 'Variables *' : 'Variables', value: 'variables' }, + { label: dirty.sensitiveKeys ? 'Sensitive Keys *' : 'Sensitive Keys', value: 'sensitive-keys' }, + { label: 'Deployment', value: 'deployment' }, + ...(app + ? ([ + { label: '● Traces & Taps', value: 'traces' }, + { label: '● Route Recording', value: 'recording' }, + ] as { label: string; value: TabKey }[]) + : []), + ]; + + // ── Handlers ──────────────────────────────────────────────────────── + + async function handleSave() { + const envSlug = selectedEnv!; + try { + let targetApp = app; + + // 1. Create app if net-new + if (!targetApp) { + targetApp = await createApp.mutateAsync({ + envSlug, + slug: slugify(name), + displayName: name.trim(), + }); + } + + // 2. Upload JAR if staged + if (stagedJar) { + await uploadJar.mutateAsync({ envSlug, appSlug: targetApp.slug, file: stagedJar }); + } + + // 3. Save container config + const r = form.resources; + const containerConfig: Record = { + memoryLimitMb: r.memoryLimit ? parseInt(r.memoryLimit) : null, + memoryReserveMb: r.memoryReserve ? parseInt(r.memoryReserve) : null, + cpuRequest: r.cpuRequest ? parseInt(r.cpuRequest) : null, + cpuLimit: r.cpuLimit ? parseInt(r.cpuLimit) : null, + exposedPorts: r.ports, + customEnvVars: Object.fromEntries( + form.variables.envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value]), + ), + appPort: r.appPort ? parseInt(r.appPort) : 8080, + replicas: r.replicas ? parseInt(r.replicas) : 1, + deploymentStrategy: r.deployStrategy, + stripPathPrefix: r.stripPrefix, + sslOffloading: r.sslOffloading, + runtimeType: r.runtimeType, + customArgs: r.customArgs || null, + extraNetworks: r.extraNetworks, + }; + await updateContainerConfig.mutateAsync({ envSlug, appSlug: targetApp.slug, config: containerConfig }); + + // 4. Save agent config (staged — applied on next deploy) + const m = form.monitoring; + await updateAgentConfig.mutateAsync({ + config: { + application: targetApp.slug, + version: agentConfig?.version ?? 0, + engineLevel: m.engineLevel, + payloadCaptureMode: m.payloadCaptureMode, + applicationLogLevel: m.applicationLogLevel, + agentLogLevel: m.agentLogLevel, + metricsEnabled: m.metricsEnabled, + samplingRate: parseFloat(m.samplingRate) || 1.0, + compressSuccess: m.compressSuccess, + tracedProcessors: agentConfig?.tracedProcessors ?? {}, + taps: agentConfig?.taps ?? [], + tapVersion: agentConfig?.tapVersion ?? 0, + routeRecording: agentConfig?.routeRecording ?? {}, + sensitiveKeys: + form.sensitiveKeys.sensitiveKeys.length > 0 + ? form.sensitiveKeys.sensitiveKeys + : undefined, + }, + environment: envSlug, + apply: 'staged', + }); + + setStagedJar(null); + + if (!app) { + // Transition to the existing-app view + navigate(`/apps/${targetApp.slug}`); + } + } catch (e) { + toast({ + title: 'Save failed', + description: e instanceof Error ? e.message : 'Unknown error', + variant: 'error', + duration: 86_400_000, + }); + } + } + + async function handleRedeploy() { + if (!app) return; + const envSlug = selectedEnv!; + setTab('deployment'); + try { + let versionId: string; + + if (stagedJar) { + const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar }); + versionId = newVersion.id; + setStagedJar(null); + } else { + if (!currentVersion) { + toast({ + title: 'No JAR version available', + description: 'Upload a JAR before deploying.', + variant: 'error', + duration: 86_400_000, + }); + return; + } + versionId = currentVersion.id; + } + + await createDeployment.mutateAsync({ envSlug, appSlug: app.slug, appVersionId: versionId }); + } catch (e) { + toast({ + title: 'Redeploy failed', + description: e instanceof Error ? e.message : 'Unknown error', + variant: 'error', + duration: 86_400_000, + }); + } + } + + async function handleStop(deploymentId: string) { + setStopTarget(deploymentId); + } + + async function confirmStop() { + if (!stopTarget || !app) return; + const envSlug = selectedEnv!; + try { + await stopDeployment.mutateAsync({ envSlug, appSlug: app.slug, deploymentId: stopTarget }); + } catch (e) { + toast({ + title: 'Stop failed', + description: e instanceof Error ? e.message : 'Unknown error', + variant: 'error', + duration: 86_400_000, + }); + } finally { + setStopTarget(null); + } + } + + async function handleDelete() { + if (!app) return; + const envSlug = selectedEnv!; + try { + await deleteApp.mutateAsync({ envSlug, appSlug: app.slug }); + navigate('/apps'); + } catch (e) { + toast({ + title: 'Delete failed', + description: e instanceof Error ? e.message : 'Unknown error', + variant: 'error', + duration: 86_400_000, + }); + } + } + + function handleRestore(deploymentId: string) { + const deployment = deployments.find((d) => d.id === deploymentId) as + | (Deployment & { deployedConfigSnapshot?: { agentConfig?: Record; containerConfig?: Record } }) + | undefined; + if (!deployment) return; + const snap = deployment.deployedConfigSnapshot; + if (!snap) return; + + setForm((prev) => { + const a = snap.agentConfig ?? {}; + const c = snap.containerConfig ?? {}; + return { + monitoring: { + engineLevel: (a.engineLevel as string) ?? prev.monitoring.engineLevel, + payloadCaptureMode: (a.payloadCaptureMode as string) ?? prev.monitoring.payloadCaptureMode, + applicationLogLevel: (a.applicationLogLevel as string) ?? prev.monitoring.applicationLogLevel, + agentLogLevel: (a.agentLogLevel as string) ?? prev.monitoring.agentLogLevel, + metricsEnabled: (a.metricsEnabled as boolean) ?? prev.monitoring.metricsEnabled, + samplingRate: a.samplingRate !== undefined ? String(a.samplingRate) : prev.monitoring.samplingRate, + compressSuccess: (a.compressSuccess as boolean) ?? prev.monitoring.compressSuccess, + }, + resources: { + memoryLimit: c.memoryLimitMb !== undefined ? String(c.memoryLimitMb) : prev.resources.memoryLimit, + memoryReserve: c.memoryReserveMb != null ? String(c.memoryReserveMb) : prev.resources.memoryReserve, + cpuRequest: c.cpuRequest !== undefined ? String(c.cpuRequest) : prev.resources.cpuRequest, + cpuLimit: c.cpuLimit != null ? String(c.cpuLimit) : prev.resources.cpuLimit, + ports: Array.isArray(c.exposedPorts) ? (c.exposedPorts as number[]) : prev.resources.ports, + appPort: c.appPort !== undefined ? String(c.appPort) : prev.resources.appPort, + replicas: c.replicas !== undefined ? String(c.replicas) : prev.resources.replicas, + deployStrategy: (c.deploymentStrategy as string) ?? prev.resources.deployStrategy, + stripPrefix: c.stripPathPrefix !== undefined ? (c.stripPathPrefix as boolean) : prev.resources.stripPrefix, + sslOffloading: c.sslOffloading !== undefined ? (c.sslOffloading as boolean) : prev.resources.sslOffloading, + runtimeType: (c.runtimeType as string) ?? prev.resources.runtimeType, + customArgs: c.customArgs !== undefined ? String(c.customArgs ?? '') : prev.resources.customArgs, + extraNetworks: Array.isArray(c.extraNetworks) ? (c.extraNetworks as string[]) : prev.resources.extraNetworks, + }, + variables: { + envVars: c.customEnvVars + ? Object.entries(c.customEnvVars as Record).map(([key, value]) => ({ key, value })) + : prev.variables.envVars, + }, + sensitiveKeys: { + sensitiveKeys: Array.isArray(a.sensitiveKeys) + ? (a.sensitiveKeys as string[]) + : prev.sensitiveKeys.sensitiveKeys, + }, + }; + }); + } + + // ── Primary button enabled logic ─────────────────────────────────── + const primaryEnabled = (() => { + if (primaryMode === 'deploying') return false; + if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit); + return true; // redeploy always enabled + })(); + + // ── Loading guard ────────────────────────────────────────────────── if (envLoading || appsLoading) return ; if (!env) return
Select an environment first.
; - const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new'; - return (
-

{app ? app.displayName : 'Create Application'}

+ {/* ── Page header ── */} +
+

{app ? app.displayName : 'Create Application'}

+
+ {dirty.anyLocalEdit && ( + + )} + + {app && ( + + )} +
+
+ + {/* ── Identity & Artifact ── */} - {mode === 'deployed' && ( + + {/* ── Checkpoints (deployed apps only) ── */} + {app && ( { - // TODO: wired in Task 10.3 (restore hydrates form from snapshot) - console.info('restore checkpoint', id); - }} + onRestore={handleRestore} /> )} + + {/* ── Config tabs ── */} + setTab(v as TabKey)} + /> + +
+ {tab === 'monitoring' && ( + setForm((prev) => ({ ...prev, monitoring: next }))} + disabled={deploymentInProgress} + /> + )} + {tab === 'resources' && ( + setForm((prev) => ({ ...prev, resources: next }))} + disabled={deploymentInProgress} + isProd={env.production ?? false} + /> + )} + {tab === 'variables' && ( + setForm((prev) => ({ ...prev, variables: next }))} + disabled={deploymentInProgress} + /> + )} + {tab === 'sensitive-keys' && ( + setForm((prev) => ({ ...prev, sensitiveKeys: next }))} + disabled={deploymentInProgress} + /> + )} + {tab === 'deployment' && app && ( + { + // Re-deploy from a specific historical deployment's version + const d = deployments.find((dep) => dep.id === deploymentId); + if (d && selectedEnv && app) { + setTab('deployment'); + createDeployment.mutateAsync({ + envSlug: selectedEnv, + appSlug: app.slug, + appVersionId: d.appVersionId, + }).catch((e: unknown) => + toast({ + title: 'Start failed', + description: e instanceof Error ? e.message : 'Unknown error', + variant: 'error', + duration: 86_400_000, + }), + ); + } + }} + /> + )} + {tab === 'deployment' && !app && ( +
+ Save the app first to see deployment status. +
+ )} + {tab === 'traces' && app && ( + + )} + {tab === 'recording' && app && ( + + )} +
+ + {/* ── Stop confirmation dialog ── */} + setStopTarget(null)} + onConfirm={confirmStop} + title="Stop deployment?" + description="This will stop the running container. The app will be unavailable until redeployed." + confirmLabel="Stop" + variant="danger" + /> + + {/* ── Delete confirmation dialog ── */} + setDeleteConfirm(false)} + onConfirm={() => { + setDeleteConfirm(false); + handleDelete(); + }} + title={`Delete "${app?.displayName ?? ''}"?`} + description="This permanently removes the app, all versions, and all deployments. This cannot be undone." + confirmLabel="Delete" + variant="danger" + />
); }