ui(deploy): compose page — save/redeploy/checkpoints wired end-to-end

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 23:10:55 +02:00
parent 0e4166bd5f
commit b1bdb88ea4

View File

@@ -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<File | null>(null);
const [tab, setTab] = useState<TabKey>('monitoring');
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [stopTarget, setStopTarget] = useState<string | null>(null);
const lastDerivedRef = useRef<string>('');
// 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<string, unknown> = {
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<string, unknown>; containerConfig?: Record<string, unknown> } })
| 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<string, string>).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 <PageLoader />;
if (!env) return <div>Select an environment first.</div>;
const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new';
return (
<div className={styles.container}>
<h2>{app ? app.displayName : 'Create Application'}</h2>
{/* ── Page header ── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{dirty.anyLocalEdit && (
<Button
size="sm"
variant="ghost"
onClick={() => {
reset();
setStagedJar(null);
}}
>
Discard
</Button>
)}
<PrimaryActionButton
mode={primaryMode}
enabled={primaryEnabled}
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
/>
{app && (
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>
Delete App
</Button>
)}
</div>
</div>
{/* ── Identity & Artifact ── */}
<IdentitySection
mode={mode}
environment={env}
@@ -62,19 +403,122 @@ export default function AppDeploymentPage() {
onNameChange={setName}
stagedJar={stagedJar}
onStagedJarChange={setStagedJar}
deploying={false}
deploying={deploymentInProgress}
/>
{mode === 'deployed' && (
{/* ── Checkpoints (deployed apps only) ── */}
{app && (
<Checkpoints
deployments={deployments}
versions={versions}
currentDeploymentId={currentDeployment?.id ?? null}
onRestore={(id) => {
// TODO: wired in Task 10.3 (restore hydrates form from snapshot)
console.info('restore checkpoint', id);
}}
onRestore={handleRestore}
/>
)}
{/* ── Config tabs ── */}
<Tabs
tabs={tabs}
active={tab}
onChange={(v) => setTab(v as TabKey)}
/>
<div className={styles.section} style={{ flex: '1 1 auto', minHeight: 0 }}>
{tab === 'monitoring' && (
<MonitoringTab
value={form.monitoring}
onChange={(next) => setForm((prev) => ({ ...prev, monitoring: next }))}
disabled={deploymentInProgress}
/>
)}
{tab === 'resources' && (
<ResourcesTab
value={form.resources}
onChange={(next) => setForm((prev) => ({ ...prev, resources: next }))}
disabled={deploymentInProgress}
isProd={env.production ?? false}
/>
)}
{tab === 'variables' && (
<VariablesTab
value={form.variables}
onChange={(next) => setForm((prev) => ({ ...prev, variables: next }))}
disabled={deploymentInProgress}
/>
)}
{tab === 'sensitive-keys' && (
<SensitiveKeysTab
value={form.sensitiveKeys}
onChange={(next) => setForm((prev) => ({ ...prev, sensitiveKeys: next }))}
disabled={deploymentInProgress}
/>
)}
{tab === 'deployment' && app && (
<DeploymentTab
deployments={deployments}
versions={versions}
appSlug={app.slug}
envSlug={env.slug}
externalUrl={externalUrl}
onStop={handleStop}
onStart={(deploymentId) => {
// 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 && (
<div style={{ color: 'var(--text-muted)', fontSize: 14, padding: 16 }}>
Save the app first to see deployment status.
</div>
)}
{tab === 'traces' && app && (
<TracesTapsTab app={app} environment={env} />
)}
{tab === 'recording' && app && (
<RouteRecordingTab app={app} environment={env} />
)}
</div>
{/* ── Stop confirmation dialog ── */}
<AlertDialog
open={!!stopTarget}
onClose={() => 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 ── */}
<AlertDialog
open={deleteConfirm}
onClose={() => 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"
/>
</div>
);
}