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 ───────────────────────────────────