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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user