feat(ui): ConfigPanel snapshot+diff modes; extract snapshotToForm helper

- Extract inline handleRestore mapping into snapshotToForm(snapshot, defaults) helper
- Export defaultForm from useDeploymentPageState for use in ConfigPanel
- Replace ConfigPanel stub with real read-only snapshot renderer + Snapshot/Diff toggle
- Add fieldDiff deep-equal field-walk helper with nested object + array support
- Forward optional currentForm prop through CheckpointDetailDrawer to ConfigPanel
- 13 new tests across diff.test.ts, snapshotToForm.test.ts, ConfigPanel.test.tsx (all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-23 13:38:22 +02:00
parent d1150e5dd8
commit 1a97e2146e
9 changed files with 417 additions and 53 deletions

View File

@@ -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(
<QueryClientProvider client={qc}>
<ThemeProvider>{ui}</ThemeProvider>
</QueryClientProvider>,
);
}
describe('ConfigPanel', () => {
it('renders sub-tabs in Snapshot mode', () => {
wrap(<ConfigPanel deployment={baseDep} appSlug="a" envSlug="e" archived={false} />);
// 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(<ConfigPanel deployment={baseDep} appSlug="a" envSlug="e" archived />);
expect(screen.queryByText(/diff vs current/i)).toBeNull();
});
it('hides the toggle when no currentForm provided', () => {
wrap(<ConfigPanel deployment={baseDep} appSlug="a" envSlug="e" archived={false} />);
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(<ConfigPanel deployment={baseDep} appSlug="a" envSlug="e" archived={false} currentForm={currentForm} />);
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(<ConfigPanel deployment={noSnap} appSlug="a" envSlug="e" archived={false} />);
expect(screen.getByText(/no config snapshot/i)).toBeInTheDocument();
});
});

View File

@@ -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 <div style={{ padding: 16, color: 'var(--text-muted)' }}>Config panel implemented in Task 11</div>;
type Mode = 'snapshot' | 'diff';
type SubTab = 'monitoring' | 'resources' | 'variables' | 'sensitive';
export function ConfigPanel({ deployment, archived, currentForm }: Props) {
const [mode, setMode] = useState<Mode>('snapshot');
const [subTab, setSubTab] = useState<SubTab>('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<SubTab, number> = { 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 <div className={styles.emptyState}>This deployment has no config snapshot.</div>;
}
const showToggle = !archived && !!currentForm;
return (
<div>
{showToggle && (
<SegmentedTabs
tabs={[
{ value: 'snapshot', label: 'Snapshot' },
{ value: 'diff', label: `Diff vs current${diff.length ? ` (${diff.length})` : ''}` },
]}
active={mode}
onChange={(m) => setMode(m as Mode)}
/>
)}
<Tabs
active={subTab}
onChange={(t) => 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})` : ''}` },
]}
/>
<div style={{ marginTop: 12 }}>
{mode === 'snapshot' && (
<>
{subTab === 'resources' && (
<ResourcesTab value={snapshotForm.resources} onChange={() => {}} disabled isProd={false} />
)}
{subTab === 'monitoring' && (
<MonitoringTab value={snapshotForm.monitoring} onChange={() => {}} disabled />
)}
{subTab === 'variables' && (
<VariablesTab value={snapshotForm.variables} onChange={() => {}} disabled />
)}
{subTab === 'sensitive' && (
<SensitiveKeysTab value={snapshotForm.sensitiveKeys} onChange={() => {}} disabled />
)}
</>
)}
{mode === 'diff' && (
<DiffView diffs={diff.filter((d) => filterTab(d.path, subTab))} />
)}
</div>
</div>
);
}
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 <div className={styles.emptyState}>No differences in this section.</div>;
}
return (
<div style={{ fontFamily: 'monospace', fontSize: 12 }}>
{diffs.map((d) => (
<div key={d.path} style={{ marginBottom: 8 }}>
<div style={{ color: 'var(--text-muted)' }}>{d.path}</div>
<div
style={{
background: 'var(--red-bg, rgba(239,68,68,0.15))',
borderLeft: '2px solid var(--red, #ef4444)',
padding: '2px 6px',
}}
>
- {JSON.stringify(d.oldValue)}
</div>
<div
style={{
background: 'var(--green-bg, rgba(34,197,94,0.15))',
borderLeft: '2px solid var(--green, #22c55e)',
padding: '2px 6px',
}}
>
+ {JSON.stringify(d.newValue)}
</div>
</div>
))}
</div>
);
}

View File

@@ -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' }]);
});
});

View File

@@ -0,0 +1,34 @@
export interface FieldDiff {
path: string;
oldValue: unknown;
newValue: unknown;
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
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 }];
}

View File

@@ -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<TabId>('logs');
const archived = !version;
@@ -74,7 +76,7 @@ export function CheckpointDetailDrawer({
<LogsPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} />
)}
{tab === 'config' && (
<ConfigPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} archived={archived} />
<ConfigPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} archived={archived} currentForm={currentForm} />
)}
</div>
</SideDrawer>

View File

@@ -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);
});
});

View File

@@ -0,0 +1,66 @@
import type { DeploymentPageFormState } from '../hooks/useDeploymentPageState';
interface DeployedConfigSnapshot {
jarVersionId: string;
agentConfig: Record<string, unknown> | null;
containerConfig: Record<string, unknown>;
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<string, string>).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,
},
};
}

View File

@@ -49,7 +49,7 @@ export interface DeploymentPageFormState {
sensitiveKeys: SensitiveKeysFormState;
}
const defaultForm: DeploymentPageFormState = {
export const defaultForm: DeploymentPageFormState = {
monitoring: {
engineLevel: 'REGULAR',
payloadCaptureMode: 'BOTH',

View File

@@ -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<string, string>).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 ───────────────────────────────────