diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx
new file mode 100644
index 00000000..1cd68175
--- /dev/null
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ThemeProvider } from '@cameleer/design-system';
+import { ConfigPanel } from './ConfigPanel';
+import type { DeploymentPageFormState } from '../hooks/useDeploymentPageState';
+
+const baseDep: any = {
+ id: 'd1', appId: 'a', appVersionId: 'v', environmentId: 'e',
+ status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
+ replicaStates: [], deployStage: null, containerId: null, containerName: null,
+ errorMessage: null, deployedAt: '2026-04-23T10:35:00Z', stoppedAt: null,
+ createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
+ deployedConfigSnapshot: {
+ jarVersionId: 'v', agentConfig: { engineLevel: 'COMPLETE' },
+ containerConfig: { memoryLimitMb: 512, replicas: 3 }, sensitiveKeys: ['SECRET'],
+ },
+};
+
+function wrap(ui: React.ReactNode) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe('ConfigPanel', () => {
+ it('renders sub-tabs in Snapshot mode', () => {
+ wrap();
+ // Use role=tab to avoid matching text inside rendered tab content (e.g. hint text)
+ const tabs = screen.getAllByRole('tab');
+ const tabLabels = tabs.map((t) => t.textContent ?? '');
+ expect(tabLabels.some((l) => /resources/i.test(l))).toBe(true);
+ expect(tabLabels.some((l) => /monitoring/i.test(l))).toBe(true);
+ expect(tabLabels.some((l) => /variables/i.test(l))).toBe(true);
+ expect(tabLabels.some((l) => /sensitive keys/i.test(l))).toBe(true);
+ });
+
+ it('hides the Snapshot/Diff toggle when archived', () => {
+ wrap();
+ expect(screen.queryByText(/diff vs current/i)).toBeNull();
+ });
+
+ it('hides the toggle when no currentForm provided', () => {
+ wrap();
+ expect(screen.queryByText(/diff vs current/i)).toBeNull();
+ });
+
+ it('shows the toggle when currentForm is provided and not archived', () => {
+ const currentForm: DeploymentPageFormState = {
+ monitoring: {
+ engineLevel: 'REGULAR',
+ payloadCaptureMode: 'BOTH',
+ payloadSize: '4',
+ payloadUnit: 'KB',
+ applicationLogLevel: 'INFO',
+ agentLogLevel: 'INFO',
+ metricsEnabled: true,
+ metricsInterval: '60',
+ samplingRate: '1.0',
+ compressSuccess: false,
+ replayEnabled: true,
+ routeControlEnabled: true,
+ },
+ resources: {
+ memoryLimit: '256', memoryReserve: '', cpuRequest: '500', cpuLimit: '',
+ ports: [], appPort: '8080', replicas: '1', deployStrategy: 'blue-green',
+ stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '',
+ extraNetworks: [],
+ },
+ variables: { envVars: [] },
+ sensitiveKeys: { sensitiveKeys: [] },
+ };
+ wrap();
+ expect(screen.getByText(/snapshot/i)).toBeInTheDocument();
+ expect(screen.getByText(/diff vs current/i)).toBeInTheDocument();
+ });
+
+ it('renders empty-state when no snapshot', () => {
+ const noSnap = { ...baseDep, deployedConfigSnapshot: null };
+ wrap();
+ expect(screen.getByText(/no config snapshot/i)).toBeInTheDocument();
+ });
+});
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx
index 77596d5a..0cb81360 100644
--- a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx
@@ -1,13 +1,144 @@
-// ConfigPanel.tsx — stub for Task 10; Task 11 implements snapshot/diff modes
+import { useMemo, useState } from 'react';
+import { SegmentedTabs, Tabs } from '@cameleer/design-system';
import type { Deployment } from '../../../../api/queries/admin/apps';
+import { MonitoringTab } from '../ConfigTabs/MonitoringTab';
+import { ResourcesTab } from '../ConfigTabs/ResourcesTab';
+import { VariablesTab } from '../ConfigTabs/VariablesTab';
+import { SensitiveKeysTab } from '../ConfigTabs/SensitiveKeysTab';
+import { defaultForm, type DeploymentPageFormState } from '../hooks/useDeploymentPageState';
+import { snapshotToForm } from './snapshotToForm';
+import { fieldDiff, type FieldDiff } from './diff';
+import styles from './CheckpointDetailDrawer.module.css';
interface Props {
deployment: Deployment;
appSlug: string;
envSlug: string;
archived: boolean;
+ currentForm?: DeploymentPageFormState;
}
-export function ConfigPanel(_: Props) {
- return
Config panel — implemented in Task 11
;
+type Mode = 'snapshot' | 'diff';
+type SubTab = 'monitoring' | 'resources' | 'variables' | 'sensitive';
+
+export function ConfigPanel({ deployment, archived, currentForm }: Props) {
+ const [mode, setMode] = useState('snapshot');
+ const [subTab, setSubTab] = useState('resources');
+
+ const snapshot = deployment.deployedConfigSnapshot;
+ const snapshotForm = useMemo(
+ () => (snapshot ? snapshotToForm(snapshot, defaultForm) : defaultForm),
+ [snapshot],
+ );
+
+ const diff: FieldDiff[] = useMemo(
+ () => (mode === 'diff' && currentForm ? fieldDiff(snapshotForm, currentForm) : []),
+ [mode, snapshotForm, currentForm],
+ );
+
+ const counts = useMemo(() => {
+ const c: Record = { monitoring: 0, resources: 0, variables: 0, sensitive: 0 };
+ for (const d of diff) {
+ const root = d.path.split('.')[0].split('[')[0];
+ if (root === 'monitoring') c.monitoring++;
+ else if (root === 'resources') c.resources++;
+ else if (root === 'variables') c.variables++;
+ else if (root === 'sensitiveKeys') c.sensitive++;
+ }
+ return c;
+ }, [diff]);
+
+ if (!snapshot) {
+ return This deployment has no config snapshot.
;
+ }
+
+ const showToggle = !archived && !!currentForm;
+
+ return (
+
+ {showToggle && (
+
setMode(m as Mode)}
+ />
+ )}
+
+ setSubTab(t as SubTab)}
+ tabs={[
+ { value: 'resources', label: `Resources${counts.resources ? ` (${counts.resources})` : ''}` },
+ { value: 'monitoring', label: `Monitoring${counts.monitoring ? ` (${counts.monitoring})` : ''}` },
+ { value: 'variables', label: `Variables${counts.variables ? ` (${counts.variables})` : ''}` },
+ { value: 'sensitive', label: `Sensitive Keys${counts.sensitive ? ` (${counts.sensitive})` : ''}` },
+ ]}
+ />
+
+
+ {mode === 'snapshot' && (
+ <>
+ {subTab === 'resources' && (
+ {}} disabled isProd={false} />
+ )}
+ {subTab === 'monitoring' && (
+ {}} disabled />
+ )}
+ {subTab === 'variables' && (
+ {}} disabled />
+ )}
+ {subTab === 'sensitive' && (
+ {}} disabled />
+ )}
+ >
+ )}
+
+ {mode === 'diff' && (
+ filterTab(d.path, subTab))} />
+ )}
+
+
+ );
+}
+
+function filterTab(path: string, tab: SubTab): boolean {
+ const root = path.split('.')[0].split('[')[0];
+ if (tab === 'sensitive') return root === 'sensitiveKeys';
+ return root === tab;
+}
+
+function DiffView({ diffs }: { diffs: FieldDiff[] }) {
+ if (diffs.length === 0) {
+ return No differences in this section.
;
+ }
+ return (
+
+ {diffs.map((d) => (
+
+
{d.path}
+
+ - {JSON.stringify(d.oldValue)}
+
+
+ + {JSON.stringify(d.newValue)}
+
+
+ ))}
+
+ );
}
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts
new file mode 100644
index 00000000..a999eeb1
--- /dev/null
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { fieldDiff } from './diff';
+
+describe('fieldDiff', () => {
+ it('returns empty list for equal objects', () => {
+ expect(fieldDiff({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toEqual([]);
+ });
+
+ it('detects changed values', () => {
+ expect(fieldDiff({ a: 1 }, { a: 2 })).toEqual([{ path: 'a', oldValue: 1, newValue: 2 }]);
+ });
+
+ it('detects added keys', () => {
+ expect(fieldDiff({}, { a: 1 })).toEqual([{ path: 'a', oldValue: undefined, newValue: 1 }]);
+ });
+
+ it('detects removed keys', () => {
+ expect(fieldDiff({ a: 1 }, {})).toEqual([{ path: 'a', oldValue: 1, newValue: undefined }]);
+ });
+
+ it('walks nested objects', () => {
+ const diff = fieldDiff({ resources: { mem: 512 } }, { resources: { mem: 1024 } });
+ expect(diff).toEqual([{ path: 'resources.mem', oldValue: 512, newValue: 1024 }]);
+ });
+
+ it('compares arrays by position', () => {
+ const diff = fieldDiff({ keys: ['a', 'b'] }, { keys: ['a', 'c'] });
+ expect(diff).toEqual([{ path: 'keys[1]', oldValue: 'b', newValue: 'c' }]);
+ });
+});
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.ts b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.ts
new file mode 100644
index 00000000..970f1d51
--- /dev/null
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/diff.ts
@@ -0,0 +1,34 @@
+export interface FieldDiff {
+ path: string;
+ oldValue: unknown;
+ newValue: unknown;
+}
+
+function isPlainObject(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
+}
+
+export function fieldDiff(a: unknown, b: unknown, path = ''): FieldDiff[] {
+ if (Object.is(a, b)) return [];
+
+ if (isPlainObject(a) && isPlainObject(b)) {
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
+ const out: FieldDiff[] = [];
+ for (const k of keys) {
+ const sub = path ? `${path}.${k}` : k;
+ out.push(...fieldDiff(a[k], b[k], sub));
+ }
+ return out;
+ }
+
+ if (Array.isArray(a) && Array.isArray(b)) {
+ const len = Math.max(a.length, b.length);
+ const out: FieldDiff[] = [];
+ for (let i = 0; i < len; i++) {
+ out.push(...fieldDiff(a[i], b[i], `${path}[${i}]`));
+ }
+ return out;
+ }
+
+ return [{ path: path || '(root)', oldValue: a, newValue: b }];
+}
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx
index adfe2588..e82b7f96 100644
--- a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { SideDrawer } from '../../../../components/SideDrawer';
import { Tabs, Button, Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
+import type { DeploymentPageFormState } from '../hooks/useDeploymentPageState';
import { LogsPanel } from './LogsPanel';
import { ConfigPanel } from './ConfigPanel';
import { timeAgo } from '../../../../utils/format-utils';
@@ -15,12 +16,13 @@ interface Props {
appSlug: string;
envSlug: string;
onRestore: (deploymentId: string) => void;
+ currentForm?: DeploymentPageFormState;
}
type TabId = 'logs' | 'config';
export function CheckpointDetailDrawer({
- open, onClose, deployment, version, appSlug, envSlug, onRestore,
+ open, onClose, deployment, version, appSlug, envSlug, onRestore, currentForm,
}: Props) {
const [tab, setTab] = useState('logs');
const archived = !version;
@@ -74,7 +76,7 @@ export function CheckpointDetailDrawer({
)}
{tab === 'config' && (
-
+
)}
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.test.ts b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.test.ts
new file mode 100644
index 00000000..bb0328a1
--- /dev/null
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { snapshotToForm } from './snapshotToForm';
+import { defaultForm } from '../hooks/useDeploymentPageState';
+
+describe('snapshotToForm', () => {
+ it('overrides monitoring fields from agentConfig', () => {
+ const snapshot = {
+ jarVersionId: 'v1',
+ agentConfig: { engineLevel: 'COMPLETE', samplingRate: 0.5, compressSuccess: true },
+ containerConfig: {},
+ sensitiveKeys: null,
+ };
+ const result = snapshotToForm(snapshot, defaultForm);
+ expect(result.monitoring.engineLevel).toBe('COMPLETE');
+ expect(result.monitoring.samplingRate).toBe('0.5');
+ expect(result.monitoring.compressSuccess).toBe(true);
+ // fields not in snapshot fall back to defaults
+ expect(result.monitoring.payloadSize).toBe(defaultForm.monitoring.payloadSize);
+ expect(result.monitoring.replayEnabled).toBe(defaultForm.monitoring.replayEnabled);
+ });
+
+ it('overrides resources fields from containerConfig', () => {
+ const snapshot = {
+ jarVersionId: 'v1',
+ agentConfig: null,
+ containerConfig: {
+ memoryLimitMb: 1024,
+ replicas: 3,
+ deploymentStrategy: 'rolling',
+ customEnvVars: { FOO: 'bar', BAZ: 'qux' },
+ exposedPorts: [8080, 9090],
+ },
+ sensitiveKeys: ['SECRET_KEY'],
+ };
+ const result = snapshotToForm(snapshot, defaultForm);
+ expect(result.resources.memoryLimit).toBe('1024');
+ expect(result.resources.replicas).toBe('3');
+ expect(result.resources.deployStrategy).toBe('rolling');
+ expect(result.resources.ports).toEqual([8080, 9090]);
+ expect(result.variables.envVars).toEqual([
+ { key: 'FOO', value: 'bar' },
+ { key: 'BAZ', value: 'qux' },
+ ]);
+ expect(result.sensitiveKeys.sensitiveKeys).toEqual(['SECRET_KEY']);
+ });
+
+ it('falls back to defaults for missing fields', () => {
+ const snapshot = {
+ jarVersionId: 'v1',
+ agentConfig: null,
+ containerConfig: {},
+ sensitiveKeys: null,
+ };
+ const result = snapshotToForm(snapshot, defaultForm);
+ expect(result.resources.memoryLimit).toBe(defaultForm.resources.memoryLimit);
+ expect(result.resources.cpuRequest).toBe(defaultForm.resources.cpuRequest);
+ expect(result.variables.envVars).toEqual(defaultForm.variables.envVars);
+ expect(result.sensitiveKeys.sensitiveKeys).toEqual(defaultForm.sensitiveKeys.sensitiveKeys);
+ });
+});
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.ts b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.ts
new file mode 100644
index 00000000..bacc2506
--- /dev/null
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/snapshotToForm.ts
@@ -0,0 +1,66 @@
+import type { DeploymentPageFormState } from '../hooks/useDeploymentPageState';
+
+interface DeployedConfigSnapshot {
+ jarVersionId: string;
+ agentConfig: Record | null;
+ containerConfig: Record;
+ sensitiveKeys: string[] | null;
+}
+
+/**
+ * Maps a deployment snapshot to the page's form-state shape.
+ * Used by both the live page's "restore" action and the read-only ConfigPanel
+ * in the checkpoint detail drawer.
+ *
+ * Fields not represented in the snapshot fall back to `defaults`.
+ */
+export function snapshotToForm(
+ snapshot: DeployedConfigSnapshot,
+ defaults: DeploymentPageFormState,
+): DeploymentPageFormState {
+ const a = snapshot.agentConfig ?? {};
+ const c = snapshot.containerConfig ?? {};
+ return {
+ monitoring: {
+ engineLevel: (a.engineLevel as string) ?? defaults.monitoring.engineLevel,
+ payloadCaptureMode: (a.payloadCaptureMode as string) ?? defaults.monitoring.payloadCaptureMode,
+ payloadSize: defaults.monitoring.payloadSize,
+ payloadUnit: defaults.monitoring.payloadUnit,
+ applicationLogLevel: (a.applicationLogLevel as string) ?? defaults.monitoring.applicationLogLevel,
+ agentLogLevel: (a.agentLogLevel as string) ?? defaults.monitoring.agentLogLevel,
+ metricsEnabled: (a.metricsEnabled as boolean) ?? defaults.monitoring.metricsEnabled,
+ metricsInterval: defaults.monitoring.metricsInterval,
+ samplingRate: a.samplingRate !== undefined ? String(a.samplingRate) : defaults.monitoring.samplingRate,
+ compressSuccess: (a.compressSuccess as boolean) ?? defaults.monitoring.compressSuccess,
+ replayEnabled: defaults.monitoring.replayEnabled,
+ routeControlEnabled: defaults.monitoring.routeControlEnabled,
+ },
+ resources: {
+ memoryLimit: c.memoryLimitMb !== undefined ? String(c.memoryLimitMb) : defaults.resources.memoryLimit,
+ memoryReserve: c.memoryReserveMb != null ? String(c.memoryReserveMb) : defaults.resources.memoryReserve,
+ cpuRequest: c.cpuRequest !== undefined ? String(c.cpuRequest) : defaults.resources.cpuRequest,
+ cpuLimit: c.cpuLimit != null ? String(c.cpuLimit) : defaults.resources.cpuLimit,
+ ports: Array.isArray(c.exposedPorts) ? (c.exposedPorts as number[]) : defaults.resources.ports,
+ appPort: c.appPort !== undefined ? String(c.appPort) : defaults.resources.appPort,
+ replicas: c.replicas !== undefined ? String(c.replicas) : defaults.resources.replicas,
+ deployStrategy: (c.deploymentStrategy as string) ?? defaults.resources.deployStrategy,
+ stripPrefix: c.stripPathPrefix !== undefined ? (c.stripPathPrefix as boolean) : defaults.resources.stripPrefix,
+ sslOffloading: c.sslOffloading !== undefined ? (c.sslOffloading as boolean) : defaults.resources.sslOffloading,
+ runtimeType: (c.runtimeType as string) ?? defaults.resources.runtimeType,
+ customArgs: c.customArgs !== undefined ? String(c.customArgs ?? '') : defaults.resources.customArgs,
+ extraNetworks: Array.isArray(c.extraNetworks) ? (c.extraNetworks as string[]) : defaults.resources.extraNetworks,
+ },
+ variables: {
+ envVars: c.customEnvVars
+ ? Object.entries(c.customEnvVars as Record).map(([key, value]) => ({ key, value }))
+ : defaults.variables.envVars,
+ },
+ sensitiveKeys: {
+ sensitiveKeys: Array.isArray(snapshot.sensitiveKeys)
+ ? snapshot.sensitiveKeys
+ : Array.isArray(a.sensitiveKeys)
+ ? (a.sensitiveKeys as string[])
+ : defaults.sensitiveKeys.sensitiveKeys,
+ },
+ };
+}
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts b/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts
index 2a4eb6a4..a2924787 100644
--- a/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts
@@ -49,7 +49,7 @@ export interface DeploymentPageFormState {
sensitiveKeys: SensitiveKeysFormState;
}
-const defaultForm: DeploymentPageFormState = {
+export const defaultForm: DeploymentPageFormState = {
monitoring: {
engineLevel: 'REGULAR',
payloadCaptureMode: 'BOTH',
diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
index 4f202675..6c5d2054 100644
--- a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
+++ b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
@@ -31,6 +31,7 @@ import { DeploymentTab } from './DeploymentTab/DeploymentTab';
import { PrimaryActionButton, computeMode } from './PrimaryActionButton';
import { useDeploymentPageState } from './hooks/useDeploymentPageState';
import { useFormDirty } from './hooks/useFormDirty';
+import { snapshotToForm } from './CheckpointDetailDrawer/snapshotToForm';
import { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker';
import { deriveAppName } from './utils/deriveAppName';
import styles from './AppDeploymentPage.module.css';
@@ -337,53 +338,7 @@ export default function AppDeploymentPage() {
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,
- },
- };
- });
+ setForm((prev) => snapshotToForm(snap, prev));
}
// ── Primary button enabled logic ───────────────────────────────────