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:
@@ -1,41 +1,96 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
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 { useEnvironmentStore } from '../../../api/environment-store';
|
||||||
import { useEnvironments } from '../../../api/queries/admin/environments';
|
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 { PageLoader } from '../../../components/PageLoader';
|
||||||
import { IdentitySection } from './IdentitySection';
|
import { IdentitySection } from './IdentitySection';
|
||||||
import { Checkpoints } from './Checkpoints';
|
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 { deriveAppName } from './utils/deriveAppName';
|
||||||
import styles from './AppDeploymentPage.module.css';
|
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() {
|
export default function AppDeploymentPage() {
|
||||||
const { appId } = useParams<{ appId?: string }>();
|
const { appId } = useParams<{ appId?: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
const { data: environments = [], isLoading: envLoading } = useEnvironments();
|
const { data: environments = [], isLoading: envLoading } = useEnvironments();
|
||||||
const { data: apps = [], isLoading: appsLoading } = useApps(selectedEnv);
|
const { data: apps = [], isLoading: appsLoading } = useApps(selectedEnv);
|
||||||
|
|
||||||
const isNetNew = location.pathname.endsWith('/apps/new');
|
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 env = environments.find((e) => e.slug === selectedEnv);
|
||||||
|
|
||||||
const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug);
|
const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug);
|
||||||
const currentVersion = versions.slice().sort((a, b) => b.version - a.version)[0] ?? null;
|
const currentVersion = versions.slice().sort((a, b) => b.version - a.version)[0] ?? null;
|
||||||
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
|
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
|
||||||
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null;
|
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
|
// Form state
|
||||||
|
const { form, setForm, reset, serverState } = useDeploymentPageState(
|
||||||
|
app,
|
||||||
|
agentConfig,
|
||||||
|
env?.defaultContainerConfig ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local UI state
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [stagedJar, setStagedJar] = useState<File | null>(null);
|
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>('');
|
const lastDerivedRef = useRef<string>('');
|
||||||
|
|
||||||
// Initialize name when app loads
|
// Initialize name from app when it loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (app) setName(app.displayName);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!stagedJar || app) return;
|
if (!stagedJar || app) return;
|
||||||
const derived = deriveAppName(stagedJar.name);
|
const derived = deriveAppName(stagedJar.name);
|
||||||
@@ -45,14 +100,300 @@ export default function AppDeploymentPage() {
|
|||||||
}
|
}
|
||||||
}, [stagedJar, app, name]);
|
}, [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 (envLoading || appsLoading) return <PageLoader />;
|
||||||
if (!env) return <div>Select an environment first.</div>;
|
if (!env) return <div>Select an environment first.</div>;
|
||||||
|
|
||||||
const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<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
|
<IdentitySection
|
||||||
mode={mode}
|
mode={mode}
|
||||||
environment={env}
|
environment={env}
|
||||||
@@ -62,19 +403,122 @@ export default function AppDeploymentPage() {
|
|||||||
onNameChange={setName}
|
onNameChange={setName}
|
||||||
stagedJar={stagedJar}
|
stagedJar={stagedJar}
|
||||||
onStagedJarChange={setStagedJar}
|
onStagedJarChange={setStagedJar}
|
||||||
deploying={false}
|
deploying={deploymentInProgress}
|
||||||
/>
|
/>
|
||||||
{mode === 'deployed' && (
|
|
||||||
|
{/* ── Checkpoints (deployed apps only) ── */}
|
||||||
|
{app && (
|
||||||
<Checkpoints
|
<Checkpoints
|
||||||
deployments={deployments}
|
deployments={deployments}
|
||||||
versions={versions}
|
versions={versions}
|
||||||
currentDeploymentId={currentDeployment?.id ?? null}
|
currentDeploymentId={currentDeployment?.id ?? null}
|
||||||
onRestore={(id) => {
|
onRestore={handleRestore}
|
||||||
// TODO: wired in Task 10.3 (restore hydrates form from snapshot)
|
|
||||||
console.info('restore checkpoint', id);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user