feat(snapshots): preserve stack dossiers with fleet snapshots (#1339)

* feat(snapshots): preserve stack dossiers with fleet snapshots

Fleet snapshots can now optionally capture each stack's Dossier notes
alongside its compose and .env files, so a recovery restores the
operational knowledge around a stack, not just its configuration.

- Opt-in global setting "snapshot_documentation" (default off), toggled
  from the renamed Fleet settings section.
- Capture reads local dossiers from the database and remote dossiers over
  the Distributed API proxy; only stacks with notes are recorded, and
  secret values are never included.
- Captured notes are stored encrypted at rest in a new fleet_snapshots
  column and surfaced in the snapshot detail view behind a badge.
- Cloud and downloaded archives gain a documentation.json (archive_version 2).
- Restore stays conservative: dossier notes are written back only when the
  operator explicitly opts in, on both single-stack and restore-all paths.
- Existing snapshots and archives remain valid; behavior is unchanged when
  the setting is off.

* fix(snapshots): harden dossier-notes restore against bad input and partial failures

Address review findings on the documentation-snapshots restore path:

- Parse `restoreNotes` strictly (=== true) on single-stack restore, matching
  restore-all, so a stray non-boolean can never opt in to overwriting notes.
- Guard findSnapshotDossier: require an array of stacks and real dossier
  content, so a malformed or all-blank entry can't clobber current notes.
- Make the dossier-notes write non-fatal relative to the file restore: a notes
  failure (e.g. a remote dossier PUT) is caught, reported via `notesError`, and
  no longer 500s the single restore or fails the stack in restore-all once the
  files are already written.
- Surface the partial outcome in the UI: a warning toast on single restore, a
  summary note on restore-all, and gate the "Documentation captured" badge and
  restore-all notes control on captured stacks while rendering capture warnings.

Adds tests for strict parsing, malformed/blank blobs, remote notes restore
(success + non-fatal failure, single and bulk), and scheduled capture-on.

* fix(snapshots): drop unused binding in restore-all remote notes test

The restore-all remote notes test destructured a node id it never uses
(restore-all is driven by snapshot id alone), tripping no-unused-vars and
failing the lint step. Bind only the snapshot id.
This commit is contained in:
Anso
2026-06-08 08:44:59 -04:00
committed by GitHub
parent 842ee7dd0c
commit 710647a44f
16 changed files with 1020 additions and 55 deletions
+158 -17
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import {
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw,
Cloud, CloudUpload, Download,
Cloud, CloudUpload, Download, BookText,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -31,6 +31,7 @@ interface FleetSnapshot {
skipped_nodes: string; // JSON string
skipped_stacks: string; // JSON string
created_at: number;
has_documentation?: number;
}
interface SnapshotStackFile {
@@ -49,10 +50,42 @@ interface SnapshotNode {
stacks: SnapshotStack[];
}
/** Operator-authored dossier fields preserved with the snapshot. */
type SnapshotDossierFields = Record<string, string>;
interface SnapshotDocumentationStack {
nodeId: number;
nodeName: string;
stackName: string;
dossier: SnapshotDossierFields;
}
interface SnapshotDocumentation {
generated_at: string;
stacks: SnapshotDocumentationStack[];
warnings: Array<{ nodeId: number; nodeName: string; stackName: string; reason: string }>;
}
interface FleetSnapshotDetail extends FleetSnapshot {
nodes: SnapshotNode[];
documentation?: SnapshotDocumentation;
}
// Ordered labels for the read-only dossier block; only non-empty fields render.
const DOSSIER_FIELD_LABELS: ReadonlyArray<[string, string]> = [
['purpose', 'Purpose'],
['owner', 'Owner'],
['access_urls', 'Access URLs'],
['static_ip', 'Static IP'],
['vlan', 'VLAN'],
['firewall_notes', 'Firewall'],
['reverse_proxy_notes', 'Reverse proxy'],
['backup_notes', 'Backup'],
['upgrade_notes', 'Upgrade'],
['recovery_notes', 'Recovery'],
['custom_notes', 'Notes'],
];
interface SkippedNode {
nodeId: number;
nodeName: string;
@@ -248,7 +281,7 @@ export default function FleetSnapshots() {
}
};
const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean) => {
const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => {
if (!selectedSnapshot) return;
const key = `${nodeId}:${stackName}`;
setRestoringStack(key);
@@ -256,11 +289,17 @@ export default function FleetSnapshots() {
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ nodeId, stackName, redeploy }),
body: JSON.stringify({ nodeId, stackName, redeploy, restoreNotes }),
});
if (res.ok) {
const data: { message: string; redeployed: boolean } = await res.json();
toast.success(data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.');
const data: { message: string; redeployed: boolean; notesRestored: boolean; notesError?: string } = await res.json();
const base = data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.';
if (data.notesError) {
// Files restored; only the optional notes write failed.
toast.warning(`${base} Documentation notes could not be restored.`);
} else {
toast.success(base + (data.notesRestored ? ' Documentation notes restored.' : ''));
}
} else {
const err = await res.json().catch(() => null);
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore stack.');
@@ -290,35 +329,40 @@ export default function FleetSnapshots() {
}
};
const handleRestoreAll = async (redeploy: boolean) => {
const handleRestoreAll = async (redeploy: boolean, restoreNotes: boolean) => {
if (!selectedSnapshot) return;
setRestoringAll(true);
try {
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore-all`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ redeploy }),
body: JSON.stringify({ redeploy, restoreNotes }),
});
if (res.ok) {
const data: {
restored: number;
failed: number;
redeploy: boolean;
results: Array<{ stackName: string; success: boolean; error?: string }>;
results: Array<{ stackName: string; success: boolean; error?: string; notesError?: string }>;
} = await res.json();
const noun = (n: number) => `${n} stack${n === 1 ? '' : 's'}`;
const firstFailed = data.results?.find(r => !r.success);
const failDetail = firstFailed
? ` First failure: ${firstFailed.stackName} · ${firstFailed.error || 'unknown error'}`
: '';
if (data.failed === 0) {
// Files restored but the optional notes write failed on some stacks.
const notesFailed = data.results?.filter(r => r.notesError).length ?? 0;
const notesSuffix = notesFailed > 0 ? ` Documentation notes could not be restored for ${noun(notesFailed)}.` : '';
if (data.failed === 0 && notesFailed === 0) {
toast.success(data.redeploy
? `Restored and redeployed ${noun(data.restored)}.`
: `Restored ${noun(data.restored)}.`);
} else if (data.restored === 0) {
toast.error(`Restore failed for ${noun(data.failed)}.${failDetail}`);
} else if (data.failed === 0) {
toast.warning(`Restored ${noun(data.restored)}.${notesSuffix}`);
} else {
toast.warning(`${data.restored} restored, ${data.failed} failed.${failDetail}`);
toast.warning(`${data.restored} restored, ${data.failed} failed.${failDetail}${notesSuffix}`);
}
} else {
const err = await res.json().catch(() => null);
@@ -412,7 +456,11 @@ export default function FleetSnapshots() {
</p>
</div>
{isAdmin && selectedSnapshot.nodes.length > 0 && (
<RestoreAllButton restoring={restoringAll} onRestoreAll={handleRestoreAll} />
<RestoreAllButton
restoring={restoringAll}
hasDocumentation={(selectedSnapshot.documentation?.stacks.length ?? 0) > 0}
onRestoreAll={handleRestoreAll}
/>
)}
</div>
<div className="flex items-center gap-2">
@@ -422,6 +470,12 @@ export default function FleetSnapshots() {
<Badge variant="secondary" className="font-mono tabular-nums">
{selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''}
</Badge>
{(selectedSnapshot.documentation?.stacks.length ?? 0) > 0 && (
<Badge variant="outline" className="gap-1">
<BookText className="w-3 h-3" strokeWidth={1.5} />
Documentation captured
</Badge>
)}
</div>
</div>
@@ -477,6 +531,33 @@ export default function FleetSnapshots() {
);
})()}
{/* Documentation capture warnings (notes that could not be fetched) */}
{(() => {
const warnings = selectedSnapshot.documentation?.warnings ?? [];
if (warnings.length === 0) return null;
return (
<div className="rounded-xl border border-warning/30 bg-warning/5 p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
<span className="text-sm font-medium text-warning">
Some stack documentation could not be captured:
</span>
</div>
<ul className="ml-6 space-y-1">
{warnings.map((w, i) => (
<li key={`${w.nodeId}:${w.stackName}:${i}`} className="text-sm text-muted-foreground">
<span className="font-medium">{w.nodeName}</span>
{' / '}
<span className="font-mono">{w.stackName}</span>
{' - '}
{w.reason}
</li>
))}
</ul>
</div>
);
})()}
{/* Node / Stack / File tree */}
<div className="space-y-2">
{selectedSnapshot.nodes.map(node => {
@@ -505,6 +586,8 @@ export default function FleetSnapshots() {
{node.stacks.map(stack => {
const stackKey = `${node.nodeId}:${stack.stackName}`;
const stackExpanded = expandedStacks.has(stackKey);
const dossier = selectedSnapshot.documentation?.stacks
.find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier;
return (
<div key={stackKey}>
<button
@@ -565,12 +648,16 @@ export default function FleetSnapshots() {
);
})}
{/* Preserved dossier notes (read-only) */}
{dossier && <DossierBlock dossier={dossier} />}
{/* Restore button (admin only) */}
{isAdmin && (
<RestoreButton
nodeId={node.nodeId}
nodeName={node.nodeName}
stackName={stack.stackName}
hasDossier={!!dossier}
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
onRestore={handleRestore}
/>
@@ -817,14 +904,16 @@ export default function FleetSnapshots() {
// --- Restore Button Sub-Component ---
function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, onRestore }: {
nodeId: number;
nodeName: string;
stackName: string;
hasDossier: boolean;
restoring: boolean;
onRestore: (nodeId: number, stackName: string, redeploy: boolean) => Promise<void>;
onRestore: (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => Promise<void>;
}) {
const [redeploy, setRedeploy] = useState(false);
const [restoreNotes, setRestoreNotes] = useState(false);
const [open, setOpen] = useState(false);
return (
@@ -852,7 +941,7 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
confirming={restoring}
onConfirm={async () => {
try {
await onRestore(nodeId, stackName, redeploy);
await onRestore(nodeId, stackName, redeploy, restoreNotes);
} finally {
setOpen(false);
}
@@ -874,18 +963,58 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
Redeploy stack after restore
</Label>
</div>
{hasDossier && (
<div className="flex items-center space-x-2 pt-1">
<Checkbox
id={`notes-${nodeId}-${stackName}`}
checked={restoreNotes}
onCheckedChange={(checked) => setRestoreNotes(checked === true)}
/>
<Label
htmlFor={`notes-${nodeId}-${stackName}`}
className="text-sm cursor-pointer"
>
Restore documentation notes (overwrites current notes)
</Label>
</div>
)}
</ConfirmModal>
</>
);
}
// --- Preserved Dossier Sub-Component ---
function DossierBlock({ dossier }: { dossier: SnapshotDossierFields }) {
const entries = DOSSIER_FIELD_LABELS.filter(([key]) => (dossier[key] ?? '').trim() !== '');
if (entries.length === 0) return null;
return (
<div className="ml-3 mt-1 rounded-lg border border-card-border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<BookText className="w-3 h-3 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="text-[10px] font-mono uppercase tracking-wide text-muted-foreground">Dossier notes</span>
</div>
<dl className="space-y-1">
{entries.map(([key, label]) => (
<div key={key} className="grid grid-cols-[7rem_1fr] gap-2">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="text-xs whitespace-pre-wrap break-words">{dossier[key]}</dd>
</div>
))}
</dl>
</div>
);
}
// --- Restore All Button Sub-Component ---
function RestoreAllButton({ restoring, onRestoreAll }: {
function RestoreAllButton({ restoring, hasDocumentation, onRestoreAll }: {
restoring: boolean;
onRestoreAll: (redeploy: boolean) => Promise<void>;
hasDocumentation: boolean;
onRestoreAll: (redeploy: boolean, restoreNotes: boolean) => Promise<void>;
}) {
const [redeploy, setRedeploy] = useState(false);
const [restoreNotes, setRestoreNotes] = useState(false);
const [open, setOpen] = useState(false);
return (
@@ -914,7 +1043,7 @@ function RestoreAllButton({ restoring, onRestoreAll }: {
confirming={restoring}
onConfirm={async () => {
try {
await onRestoreAll(redeploy);
await onRestoreAll(redeploy, restoreNotes);
} finally {
setOpen(false);
}
@@ -933,6 +1062,18 @@ function RestoreAllButton({ restoring, onRestoreAll }: {
Redeploy all stacks after restore
</Label>
</div>
{hasDocumentation && (
<div className="flex items-center space-x-2 pt-1">
<Checkbox
id="notes-all"
checked={restoreNotes}
onCheckedChange={(checked) => setRestoreNotes(checked === true)}
/>
<Label htmlFor="notes-all" className="text-sm cursor-pointer">
Restore documentation notes (overwrites current notes)
</Label>
</div>
)}
</ConfirmModal>
</>
);
@@ -25,10 +25,11 @@ function SectionSkeleton() {
);
}
type FleetMeshFields = Pick<PatchableSettings, 'mesh_auto_recreate'>;
type FleetMeshFields = Pick<PatchableSettings, 'mesh_auto_recreate' | 'snapshot_documentation'>;
const DEFAULT_FLEET_MESH: FleetMeshFields = {
mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate,
snapshot_documentation: DEFAULT_SETTINGS.snapshot_documentation,
};
export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
@@ -44,6 +45,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.mesh_auto_recreate !== baseline.mesh_auto_recreate) n++;
if (settings.snapshot_documentation !== baseline.snapshot_documentation) n++;
return n;
}, [settings]);
@@ -73,6 +75,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: FleetMeshFields = {
mesh_auto_recreate: (nodeData.mesh_auto_recreate as '0' | '1') ?? DEFAULT_SETTINGS.mesh_auto_recreate,
snapshot_documentation: (nodeData.snapshot_documentation as '0' | '1') ?? DEFAULT_SETTINGS.snapshot_documentation,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
@@ -103,7 +106,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Mesh settings saved.');
toast.success('Fleet settings saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
@@ -127,6 +130,18 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
</SettingsField>
</SettingsSection>
<SettingsSection title="Documentation snapshots">
<SettingsField
label="Capture stack documentation in snapshots"
helper="Preserve each stack's Dossier notes alongside its captured files. Restoring a stack never overwrites current notes unless you explicitly choose to. Off by default."
>
<TogglePill
checked={settings.snapshot_documentation === '1'}
onChange={(next) => onSettingChange('snapshot_documentation', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
@@ -84,13 +84,13 @@ describe('split section save payloads', () => {
expect(patchedKeys()).toEqual(['docker_janitor_gb', 'prune_on_update', 'reclaim_hero']);
});
it('FleetMeshSection patches only the mesh key', async () => {
it('FleetMeshSection patches only the fleet keys', async () => {
render(<FleetMeshSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getByRole('switch')); // mesh_auto_recreate
fireEvent.click(screen.getAllByRole('switch')[0]); // mesh_auto_recreate
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['mesh_auto_recreate']);
expect(patchedKeys()).toEqual(['mesh_auto_recreate', 'snapshot_documentation']);
});
it('DataRetentionSection patches only retention keys, never developer_mode', async () => {
+3 -3
View File
@@ -126,9 +126,9 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
{
id: 'fleet-mesh',
group: 'infrastructure',
label: 'Fleet Mesh',
description: 'Data-plane network behavior for the cross-node service mesh.',
keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh'],
label: 'Fleet',
description: 'Cross-node service-mesh data plane and fleet-snapshot documentation capture.',
keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh', 'snapshot', 'documentation', 'dossier'],
tier: null,
scope: 'node',
adminOnly: true,
@@ -14,6 +14,7 @@ export interface PatchableSettings {
scan_history_per_image_limit?: string;
prune_on_update?: '0' | '1';
reclaim_hero?: '0' | '1';
snapshot_documentation?: '0' | '1';
}
export const DEFAULT_SETTINGS: PatchableSettings = {
@@ -32,6 +33,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
scan_history_per_image_limit: '50',
prune_on_update: '1',
reclaim_hero: '1',
snapshot_documentation: '0',
};
export type SectionId =