import { useState, useEffect, useRef } from 'react'; import { useParams, useLocation, useNavigate } from 'react-router'; import { useQueryClient } from '@tanstack/react-query'; import { AlertDialog, Button, Tabs, useToast } from '@cameleer/design-system'; import { useEnvironmentStore } from '../../../api/environment-store'; import { useEnvironments } from '../../../api/queries/admin/environments'; 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 { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker'; 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 queryClient = useQueryClient(); 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 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, isLoading: dirtyLoading } = 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 from app when it loads useEffect(() => { if (app) setName(app.displayName); }, [app?.displayName]); // eslint-disable-line react-hooks/exhaustive-deps // Auto-derive name from staged JAR (net-new only, don't overwrite manual edits) useEffect(() => { if (!stagedJar || app) return; const derived = deriveAppName(stagedJar.name); if (!name || name === lastDerivedRef.current) { setName(derived); lastDerivedRef.current = derived; } }, [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 { dialogOpen: blockerOpen, confirm: blockerConfirm, cancel: blockerCancel } = useUnsavedChangesBlocker(dirty.anyLocalEdit); // Before the first dirty-state fetch resolves, default to "not dirty" so the // primary button shows `Save (disabled)` — not a stale `Redeploy`. Once loaded, // fall back to `true` if the endpoint failed entirely (fail-safe for the // redeploy path). const serverDirtyAgainstDeploy = app && dirtyLoading ? false : (dirtyState?.dirty ?? true); 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); toast({ title: 'Configuration saved', variant: 'success' }); // Invalidate dirty-state so the button reflects the new saved state await queryClient.invalidateQueries({ queryKey: ['apps', envSlug, targetApp.slug, 'dirty-state'] }); if (!app) { // Transition to the existing-app view — refetch apps first so the new app // is in the cache before the router renders the deployed view (prevents // the transient Save-disabled flash while useApps is loading). await queryClient.refetchQueries({ queryKey: ['apps', envSlug] }); 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; } 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 }); setStagedJar(null); // Invalidate dirty-state and versions so button recomputes after deploy queryClient.invalidateQueries({ queryKey: ['apps', envSlug, app.slug, 'dirty-state'] }); queryClient.invalidateQueries({ queryKey: ['apps', envSlug, app.slug, 'versions'] }); } 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); if (!deployment) return; const snap = deployment.deployedConfigSnapshot; if (!snap) { toast({ title: 'Cannot restore checkpoint', description: 'This checkpoint predates snapshotting and cannot be restored.', variant: 'warning', }); 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, payloadSize: prev.monitoring.payloadSize, payloadUnit: prev.monitoring.payloadUnit, applicationLogLevel: (a.applicationLogLevel as string) ?? prev.monitoring.applicationLogLevel, agentLogLevel: (a.agentLogLevel as string) ?? prev.monitoring.agentLogLevel, metricsEnabled: (a.metricsEnabled as boolean) ?? prev.monitoring.metricsEnabled, metricsInterval: prev.monitoring.metricsInterval, samplingRate: a.samplingRate !== undefined ? String(a.samplingRate) : prev.monitoring.samplingRate, compressSuccess: (a.compressSuccess as boolean) ?? prev.monitoring.compressSuccess, replayEnabled: prev.monitoring.replayEnabled, routeControlEnabled: prev.monitoring.routeControlEnabled, }, 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(snap.sensitiveKeys) ? snap.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.
; return (
{/* ── Page header ── */}

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

{dirty.anyLocalEdit && ( )} {app && ( )}
{/* ── Identity & Artifact ── */} {/* ── Checkpoints (deployed apps only) ── */} {app && ( )} {/* ── 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" /> {/* ── Unsaved changes navigation blocker ── */}
); }