From 710647a44fa0aff703cf4aed6f3b069ba5bba26f Mon Sep 17 00:00:00 2001
From: Anso
Date: Mon, 8 Jun 2026 08:44:59 -0400
Subject: [PATCH] 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.
---
.../__tests__/cloud-backup-service.test.ts | 47 ++-
.../__tests__/fleet-snapshot-routes.test.ts | 282 ++++++++++++++++++
.../src/__tests__/scheduler-service.test.ts | 34 +++
.../src/__tests__/snapshot-capture.test.ts | 153 ++++++++++
backend/src/routes/fleet.ts | 129 +++++++-
backend/src/routes/settings.ts | 2 +
backend/src/services/CloudBackupService.ts | 14 +-
backend/src/services/DatabaseService.ts | 44 ++-
backend/src/services/SchedulerService.ts | 12 +-
backend/src/utils/snapshot-capture.ts | 134 ++++++++-
docs/features/fleet-backups.mdx | 16 +-
frontend/src/components/FleetSnapshots.tsx | 175 +++++++++--
.../components/settings/FleetMeshSection.tsx | 19 +-
.../__tests__/SectionSavePayloads.test.tsx | 6 +-
frontend/src/components/settings/registry.ts | 6 +-
frontend/src/components/settings/types.ts | 2 +
16 files changed, 1020 insertions(+), 55 deletions(-)
diff --git a/backend/src/__tests__/cloud-backup-service.test.ts b/backend/src/__tests__/cloud-backup-service.test.ts
index 4139c17a..a94324b6 100644
--- a/backend/src/__tests__/cloud-backup-service.test.ts
+++ b/backend/src/__tests__/cloud-backup-service.test.ts
@@ -190,7 +190,10 @@ describe('CloudBackupService — uploadSnapshot', () => {
const parsed = JSON.parse(meta!.content);
expect(parsed.id).toBe(snapshotId);
expect(parsed.instance_id).toBe('test-instance-id');
- expect(parsed.archive_version).toBe(1);
+ expect(parsed.archive_version).toBe(2);
+ expect(parsed.has_documentation).toBe(false);
+ // No dossier metadata was captured, so the archive omits documentation.json.
+ expect(entries.find(e => e.name === 'documentation.json')).toBeUndefined();
// Content is encrypted at rest but the archive must carry plaintext so a
// downloaded snapshot restores on any instance (portability contract).
@@ -202,6 +205,48 @@ describe('CloudBackupService — uploadSnapshot', () => {
expect(CloudBackupService.getInstance().getUploadStatus(snapshotId).status).toBe('success');
});
+ it('includes documentation.json when the snapshot captured dossier metadata', async () => {
+ seedCustomProvider();
+ const db = DatabaseService.getInstance();
+
+ const docJson = JSON.stringify({
+ generated_at: '2026-01-01T00:00:00Z',
+ stacks: [{ nodeId: 1, nodeName: 'gateway', stackName: 'web', dossier: { purpose: 'edge proxy' } }],
+ warnings: [],
+ });
+ const snapshotId = db.createSnapshot('Documented backup', 'admin', 1, 1, '[]', '[]', docJson);
+ db.insertSnapshotFiles(snapshotId, [
+ { nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: 'compose.yaml', content: 'services: {}\n' },
+ ]);
+
+ sentSpy.mockResolvedValue({});
+ await CloudBackupService.getInstance().uploadSnapshot(snapshotId);
+
+ const putCall = sentSpy.mock.calls.find(c => c[0].name === 'PutObjectCommand');
+ const input = putCall![0].input as { Body: Buffer };
+ const decompressed = zlib.gunzipSync(input.Body);
+ const entries: Array<{ name: string; content: string }> = await new Promise((resolve, reject) => {
+ const extract = tar.extract();
+ const list: Array<{ name: string; content: string }> = [];
+ extract.on('entry', (header, stream, next) => {
+ const chunks: Buffer[] = [];
+ stream.on('data', (c: Buffer) => chunks.push(c));
+ stream.on('end', () => { list.push({ name: header.name, content: Buffer.concat(chunks).toString('utf-8') }); next(); });
+ stream.resume();
+ });
+ extract.on('finish', () => resolve(list));
+ extract.on('error', reject);
+ Readable.from(decompressed).pipe(extract);
+ });
+
+ const meta = JSON.parse(entries.find(e => e.name === 'metadata.json')!.content);
+ expect(meta.has_documentation).toBe(true);
+ const docEntry = entries.find(e => e.name === 'documentation.json');
+ expect(docEntry).toBeDefined();
+ // The archive carries plaintext dossier metadata for portability.
+ expect(JSON.parse(docEntry!.content).stacks[0].dossier.purpose).toBe('edge proxy');
+ });
+
it('records failure status when upload throws', async () => {
seedCustomProvider();
const db = DatabaseService.getInstance();
diff --git a/backend/src/__tests__/fleet-snapshot-routes.test.ts b/backend/src/__tests__/fleet-snapshot-routes.test.ts
index 78a11b26..6b6a39c0 100644
--- a/backend/src/__tests__/fleet-snapshot-routes.test.ts
+++ b/backend/src/__tests__/fleet-snapshot-routes.test.ts
@@ -17,6 +17,7 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic
let CryptoService: typeof import('../services/CryptoService').CryptoService;
let ComposeService: typeof import('../services/ComposeService').ComposeService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
+let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
let adminCookie: string;
let viewerCookie: string;
let snapshotId: number;
@@ -25,12 +26,19 @@ const VIEWER_USER = 'viewer-snap';
const VIEWER_PASS = 'viewer-pass-123';
const ENV_SECRET = 'DB_PASSWORD=s3cr3t-value\n';
+// Every operator-authored dossier field, blank, for building test dossiers.
+const BLANK_FIELDS = {
+ purpose: '', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '',
+ reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
+};
+
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ CryptoService } = await import('../services/CryptoService'));
({ ComposeService } = await import('../services/ComposeService'));
({ LicenseService } = await import('../services/LicenseService'));
+ ({ NodeRegistry } = await import('../services/NodeRegistry'));
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
@@ -232,6 +240,280 @@ describe('Single-stack snapshot restore (behavior lock)', () => {
});
});
+describe('Snapshot documentation capture (persistence)', () => {
+ const docJson = JSON.stringify({
+ generated_at: '2026-01-01T00:00:00Z',
+ stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'doc-web', dossier: { ...BLANK_FIELDS, purpose: 'edge', owner: 'ops' } }],
+ warnings: [],
+ });
+
+ it('stores documentation encrypted at rest and flags has_documentation', () => {
+ const db = DatabaseService.getInstance();
+ const id = db.createSnapshot('doc-snap', 'admin', 1, 1, '[]', '[]', docJson);
+ expect(db.getSnapshot(id)!.has_documentation).toBe(1);
+ const raw = db.getDb().prepare('SELECT documentation FROM fleet_snapshots WHERE id = ?').get(id) as { documentation: string };
+ expect(CryptoService.getInstance().isEncrypted(raw.documentation)).toBe(true);
+ expect(raw.documentation).not.toContain('edge');
+ expect(db.getSnapshotDocumentation(id)).toBe(docJson);
+ });
+
+ it('leaves documentation empty and has_documentation 0 when none captured', () => {
+ const db = DatabaseService.getInstance();
+ const id = db.createSnapshot('no-doc', 'admin', 1, 1, '[]', '[]');
+ expect(db.getSnapshot(id)!.has_documentation).toBe(0);
+ expect(db.getSnapshotDocumentation(id)).toBe('');
+ });
+
+ it('GET detail surfaces the documentation object for an admin', async () => {
+ const db = DatabaseService.getInstance();
+ const id = db.createSnapshot('doc-detail', 'admin', 1, 1, '[]', '[]', docJson);
+ db.insertSnapshotFiles(id, [
+ { nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'doc-web', filename: 'compose.yaml', content: 'services: {}\n' },
+ ]);
+ const res = await request(app).get(`/api/fleet/snapshots/${id}`).set('Cookie', adminCookie);
+ expect(res.status).toBe(200);
+ expect(res.body.has_documentation).toBe(1);
+ expect(res.body.documentation.stacks[0]).toMatchObject({ nodeId: LOCAL_NODE_ID, stackName: 'doc-web', dossier: { purpose: 'edge' } });
+ });
+
+ it('detail degrades to no documentation (still 200) when the blob is unparseable', async () => {
+ const db = DatabaseService.getInstance();
+ const id = db.createSnapshot('doc-corrupt', 'admin', 1, 1, '[]', '[]', JSON.stringify({ generated_at: 'x', stacks: [], warnings: [] }));
+ // Overwrite the encrypted column with non-JSON plaintext to simulate a
+ // corrupt or tampered blob; getSnapshotDocumentation returns it verbatim
+ // (decrypt passes non-ciphertext through) and the route's JSON.parse fails.
+ db.getDb().prepare('UPDATE fleet_snapshots SET documentation = ? WHERE id = ?').run('not-json', id);
+ const res = await request(app).get(`/api/fleet/snapshots/${id}`).set('Cookie', adminCookie);
+ expect(res.status).toBe(200);
+ expect(res.body.documentation).toBeUndefined();
+ });
+});
+
+describe('Snapshot restore: dossier notes opt-in', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ function snapWithNotes(stackName: string, purpose: string): number {
+ const db = DatabaseService.getInstance();
+ const docJson = JSON.stringify({
+ generated_at: 'x',
+ stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName, dossier: { ...BLANK_FIELDS, purpose } }],
+ warnings: [],
+ });
+ const id = db.createSnapshot(`notes-${stackName}`, 'admin', 1, 1, '[]', '[]', docJson);
+ db.insertSnapshotFiles(id, [
+ { nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName, filename: 'compose.yaml', content: 'services: {}\n' },
+ ]);
+ seedStackDir(stackName);
+ return id;
+ }
+
+ it('does NOT overwrite current notes when restoreNotes is omitted', async () => {
+ const db = DatabaseService.getInstance();
+ db.upsertStackDossier(LOCAL_NODE_ID, 'notes-keep', { ...BLANK_FIELDS, purpose: 'current' });
+ const id = snapWithNotes('notes-keep', 'snapshot-version');
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore`)
+ .set('Cookie', adminCookie)
+ .send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-keep' });
+ expect(res.status).toBe(200);
+ expect(res.body.notesRestored).toBe(false);
+ expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-keep')?.purpose).toBe('current');
+ });
+
+ it('restores notes when restoreNotes is true', async () => {
+ const db = DatabaseService.getInstance();
+ db.upsertStackDossier(LOCAL_NODE_ID, 'notes-overwrite', { ...BLANK_FIELDS, purpose: 'current' });
+ const id = snapWithNotes('notes-overwrite', 'snapshot-version');
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore`)
+ .set('Cookie', adminCookie)
+ .send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-overwrite', restoreNotes: true });
+ expect(res.status).toBe(200);
+ expect(res.body.notesRestored).toBe(true);
+ expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-overwrite')?.purpose).toBe('snapshot-version');
+ });
+
+ it('reports notesRestored false when the snapshot has no documentation', async () => {
+ const db = DatabaseService.getInstance();
+ const id = db.createSnapshot('notes-none', 'admin', 1, 1, '[]', '[]');
+ db.insertSnapshotFiles(id, [
+ { nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-none-web', filename: 'compose.yaml', content: 'services: {}\n' },
+ ]);
+ seedStackDir('notes-none-web');
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore`)
+ .set('Cookie', adminCookie)
+ .send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-none-web', restoreNotes: true });
+ expect(res.status).toBe(200);
+ expect(res.body.notesRestored).toBe(false);
+ });
+
+ it('ignores a non-boolean restoreNotes (string "false") and leaves notes untouched', async () => {
+ const db = DatabaseService.getInstance();
+ db.upsertStackDossier(LOCAL_NODE_ID, 'notes-strict', { ...BLANK_FIELDS, purpose: 'current' });
+ const id = snapWithNotes('notes-strict', 'snapshot-version');
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore`)
+ .set('Cookie', adminCookie)
+ .send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-strict', restoreNotes: 'false' });
+ expect(res.status).toBe(200);
+ expect(res.body.notesRestored).toBe(false);
+ expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-strict')?.purpose).toBe('current');
+ });
+
+ it('does not restore notes from a malformed documentation blob', async () => {
+ const db = DatabaseService.getInstance();
+ db.upsertStackDossier(LOCAL_NODE_ID, 'notes-malformed', { ...BLANK_FIELDS, purpose: 'current' });
+ const id = db.createSnapshot('notes-bad-doc', 'admin', 1, 1, '[]', '[]', JSON.stringify({ generated_at: 'x', stacks: null, warnings: [] }));
+ db.insertSnapshotFiles(id, [
+ { nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-malformed', filename: 'compose.yaml', content: 'services: {}\n' },
+ ]);
+ seedStackDir('notes-malformed');
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore`)
+ .set('Cookie', adminCookie)
+ .send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-malformed', restoreNotes: true });
+ expect(res.status).toBe(200);
+ expect(res.body.notesRestored).toBe(false);
+ expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-malformed')?.purpose).toBe('current');
+ });
+
+ it('does not overwrite current notes with an all-blank captured dossier', async () => {
+ const db = DatabaseService.getInstance();
+ db.upsertStackDossier(LOCAL_NODE_ID, 'notes-blank', { ...BLANK_FIELDS, purpose: 'current' });
+ const docJson = JSON.stringify({
+ generated_at: 'x',
+ stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-blank', dossier: { ...BLANK_FIELDS } }],
+ warnings: [],
+ });
+ const id = db.createSnapshot('notes-blank-doc', 'admin', 1, 1, '[]', '[]', docJson);
+ db.insertSnapshotFiles(id, [
+ { nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'notes-blank', filename: 'compose.yaml', content: 'services: {}\n' },
+ ]);
+ seedStackDir('notes-blank');
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore`)
+ .set('Cookie', adminCookie)
+ .send({ nodeId: LOCAL_NODE_ID, stackName: 'notes-blank', restoreNotes: true });
+ expect(res.status).toBe(200);
+ expect(res.body.notesRestored).toBe(false);
+ expect(db.getStackDossier(LOCAL_NODE_ID, 'notes-blank')?.purpose).toBe('current');
+ });
+
+ it('restore-all restores notes only for stacks the snapshot documented', async () => {
+ const db = DatabaseService.getInstance();
+ // Only 'all-a' carries dossier notes; 'all-b' has files but no notes.
+ const docJson = JSON.stringify({
+ generated_at: 'x',
+ stacks: [{ nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-a', dossier: { ...BLANK_FIELDS, purpose: 'documented' } }],
+ warnings: [],
+ });
+ const id = db.createSnapshot('restore-all-notes', 'admin', 1, 2, '[]', '[]', docJson);
+ db.insertSnapshotFiles(id, [
+ { nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-a', filename: 'compose.yaml', content: 'services: {}\n' },
+ { nodeId: LOCAL_NODE_ID, nodeName: 'local', stackName: 'allnotes-b', filename: 'compose.yaml', content: 'services: {}\n' },
+ ]);
+ seedStackDir('allnotes-a');
+ seedStackDir('allnotes-b');
+
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore-all`)
+ .set('Cookie', adminCookie)
+ .send({ restoreNotes: true });
+
+ expect(res.status).toBe(200);
+ expect(res.body.restored).toBe(2);
+ const results = res.body.results as Array<{ stackName: string; notesRestored: boolean }>;
+ expect(results.find(r => r.stackName === 'allnotes-a')?.notesRestored).toBe(true);
+ expect(results.find(r => r.stackName === 'allnotes-b')?.notesRestored).toBe(false);
+ expect(db.getStackDossier(LOCAL_NODE_ID, 'allnotes-a')?.purpose).toBe('documented');
+ expect(db.getStackDossier(LOCAL_NODE_ID, 'allnotes-b')).toBeUndefined();
+ });
+});
+
+describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+
+ function remoteDocSnapshot(stackName: string, purpose: string): { id: number; remoteId: number } {
+ const db = DatabaseService.getInstance();
+ const remoteId = db.addNode({ name: `remote-${stackName}`, type: 'remote', api_url: 'http://remote:1852', api_token: 'tok', compose_dir: '/app/compose', is_default: false });
+ const docJson = JSON.stringify({
+ generated_at: 'x',
+ stacks: [{ nodeId: remoteId, nodeName: `remote-${stackName}`, stackName, dossier: { ...BLANK_FIELDS, purpose } }],
+ warnings: [],
+ });
+ const id = db.createSnapshot(`remote-notes-${stackName}`, 'admin', 1, 1, '[]', '[]', docJson);
+ db.insertSnapshotFiles(id, [
+ { nodeId: remoteId, nodeName: `remote-${stackName}`, stackName, filename: 'compose.yaml', content: 'services: {}\n' },
+ ]);
+ return { id, remoteId };
+ }
+
+ it('writes notes to a remote node via the proxy dossier PUT when opted in', async () => {
+ const { id, remoteId } = remoteDocSnapshot('rweb', 'documented');
+ vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
+ const calls: Array<{ url: string; method?: string }> = [];
+ vi.stubGlobal('fetch', vi.fn(async (url: string, opts?: { method?: string }) => {
+ calls.push({ url, method: opts?.method });
+ return { ok: true, status: 200, text: async () => '' } as unknown as Response;
+ }));
+
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore`)
+ .set('Cookie', adminCookie)
+ .send({ nodeId: remoteId, stackName: 'rweb', restoreNotes: true });
+
+ expect(res.status).toBe(200);
+ expect(res.body.notesRestored).toBe(true);
+ expect(res.body.notesError).toBeUndefined();
+ expect(calls.some(c => /\/dossier$/.test(c.url) && c.method === 'PUT')).toBe(true);
+ });
+
+ it('reports a non-fatal notesError when the remote dossier PUT fails but files restored', async () => {
+ const { id, remoteId } = remoteDocSnapshot('rweb2', 'documented');
+ vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
+ return { ok: true, status: 200, text: async () => '' } as unknown as Response;
+ }));
+
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore`)
+ .set('Cookie', adminCookie)
+ .send({ nodeId: remoteId, stackName: 'rweb2', restoreNotes: true });
+
+ // File restore succeeded; only the optional notes write failed.
+ expect(res.status).toBe(200);
+ expect(res.body.notesRestored).toBe(false);
+ expect(res.body.notesError).toBeTruthy();
+ });
+
+ it('restore-all records a per-row notesError but keeps the stack success when the remote notes PUT fails', async () => {
+ // restore-all is driven by snapshot id; the target node is resolved from
+ // the snapshot's stored files, so the returned remoteId is not needed here.
+ const { id } = remoteDocSnapshot('rweb3', 'documented');
+ vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
+ return { ok: true, status: 200, text: async () => '' } as unknown as Response;
+ }));
+
+ const res = await request(app)
+ .post(`/api/fleet/snapshots/${id}/restore-all`)
+ .set('Cookie', adminCookie)
+ .send({ restoreNotes: true });
+
+ expect(res.status).toBe(200);
+ expect(res.body.restored).toBe(1);
+ expect(res.body.failed).toBe(0);
+ const row = (res.body.results as Array<{ stackName: string; success: boolean; notesRestored: boolean; notesError?: string }>)
+ .find(r => r.stackName === 'rweb3');
+ expect(row?.success).toBe(true);
+ expect(row?.notesRestored).toBe(false);
+ expect(row?.notesError).toBeTruthy();
+ });
+});
+
describe('Restore-all', () => {
afterEach(() => vi.restoreAllMocks());
diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts
index d81658c7..2a53093b 100644
--- a/backend/src/__tests__/scheduler-service.test.ts
+++ b/backend/src/__tests__/scheduler-service.test.ts
@@ -10,6 +10,7 @@ import type { ScheduledTask } from '../services/DatabaseService';
const {
mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun,
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
+ mockGetGlobalSettings, mockGetStackDossier,
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
mockGetTier, mockGetProxyHeaders,
@@ -36,6 +37,8 @@ const {
mockGetScheduledTask: vi.fn(),
mockGetNodes: vi.fn().mockReturnValue([]),
mockGetNode: vi.fn().mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' }),
+ mockGetGlobalSettings: vi.fn().mockReturnValue({}),
+ mockGetStackDossier: vi.fn().mockReturnValue(undefined),
mockCreateSnapshot: vi.fn().mockReturnValue(1),
mockInsertSnapshotFiles: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
@@ -80,6 +83,8 @@ vi.mock('../services/DatabaseService', () => ({
getScheduledTask: mockGetScheduledTask,
getNodes: mockGetNodes,
getNode: mockGetNode,
+ getGlobalSettings: mockGetGlobalSettings,
+ getStackDossier: mockGetStackDossier,
createSnapshot: mockCreateSnapshot,
insertSnapshotFiles: mockInsertSnapshotFiles,
clearStackUpdateStatus: mockClearStackUpdateStatus,
@@ -201,6 +206,10 @@ beforeEach(() => {
mockGetTier.mockReturnValue('paid');
mockGetNode.mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' });
mockGetProxyTarget.mockReturnValue(null);
+ // Documentation capture is opt-in and off by default; reset so a test that
+ // enables it does not leak into later snapshot tests.
+ mockGetGlobalSettings.mockReturnValue({});
+ mockGetStackDossier.mockReturnValue(undefined);
// Default: the scan-policy gate allows. Individual tests override to a block.
mockEnforcePolicyPreDeploy.mockResolvedValue({ ok: true, bypassed: false, violations: [] });
(SchedulerService as any).instance = undefined;
@@ -1391,6 +1400,7 @@ describe('SchedulerService - executeSnapshot', () => {
1,
expect.any(String),
expect.any(String),
+ '',
);
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
1,
@@ -1423,6 +1433,30 @@ describe('SchedulerService - executeSnapshot', () => {
expect.objectContaining({ status: 'success' })
);
});
+
+ it('captures dossier documentation when the snapshot_documentation setting is on', async () => {
+ mockGetScheduledTask.mockReturnValue({
+ id: 77,
+ name: 'documented-snapshot',
+ action: 'snapshot',
+ target_type: 'fleet',
+ cron_expression: '0 3 * * *',
+ enabled: true,
+ created_by: 'admin',
+ last_status: null,
+ });
+ mockGetNodes.mockReturnValue([{ id: 1, name: 'local', type: 'local' }]);
+ mockGetStacks.mockResolvedValue(['app1']);
+ mockGetStackContent.mockResolvedValue('services: {}\n');
+ mockGetGlobalSettings.mockReturnValue({ snapshot_documentation: '1' });
+ mockGetStackDossier.mockReturnValue({ purpose: 'documented', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '' });
+
+ await SchedulerService.getInstance().triggerTask(77);
+
+ const docArg = mockCreateSnapshot.mock.calls.at(-1)?.[6] as string;
+ expect(docArg).toBeTruthy();
+ expect(JSON.parse(docArg).stacks[0]).toMatchObject({ stackName: 'app1', dossier: { purpose: 'documented' } });
+ });
});
// ── executeUpdateRemote (T4) ─────────────────────────────────────────
diff --git a/backend/src/__tests__/snapshot-capture.test.ts b/backend/src/__tests__/snapshot-capture.test.ts
index 0b4b34bb..131baf26 100644
--- a/backend/src/__tests__/snapshot-capture.test.ts
+++ b/backend/src/__tests__/snapshot-capture.test.ts
@@ -10,6 +10,7 @@ const mockGetStacks = vi.fn();
const mockGetStackContent = vi.fn();
const mockGetEnvContent = vi.fn();
const mockGetProxyTarget = vi.fn();
+const mockGetStackDossier = vi.fn();
vi.mock('../services/FileSystemService', () => ({
FileSystemService: { getInstance: () => ({
@@ -23,11 +24,29 @@ vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: { getInstance: () => ({ getProxyTarget: mockGetProxyTarget }) },
}));
+vi.mock('../services/DatabaseService', () => ({
+ DatabaseService: { getInstance: () => ({ getStackDossier: mockGetStackDossier }) },
+}));
+
import {
captureLocalNodeFiles,
captureRemoteNodeFiles,
+ buildSnapshotDocumentation,
+ pickDossierFields,
+ dossierHasContent,
MAX_SNAPSHOT_FILE_BYTES,
+ type SnapshotNodeData,
} from '../utils/snapshot-capture';
+import type { StackDossierFields } from '../services/DatabaseService';
+
+const BLANK_DOSSIER: StackDossierFields = {
+ purpose: '', owner: '', access_urls: '', static_ip: '', vlan: '', firewall_notes: '',
+ reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
+};
+
+function dossier(partial: Partial): StackDossierFields {
+ return { ...BLANK_DOSSIER, ...partial };
+}
const localNode = { id: 1, name: 'local', mode: 'proxy' as const };
const remoteNode = { id: 2, name: 'remote', mode: 'proxy' as const };
@@ -221,3 +240,137 @@ describe('captureRemoteNodeFiles', () => {
expect(result.warnings[0].reason).toContain('fetch error');
});
});
+
+describe('documentation capture', () => {
+ function mockFetchRoutes(routes: Record & { jsonValue?: unknown; textValue?: string; xEnvExists?: string }>) {
+ const fetchMock = vi.fn(async (url: string) => {
+ const match = Object.keys(routes).find(k => url.endsWith(k));
+ const r = match ? routes[match] : { ok: false, status: 404 };
+ return {
+ ok: r.ok ?? true,
+ status: r.status ?? 200,
+ headers: { get: (name: string) => name.toLowerCase() === 'x-env-exists' ? ((r as { xEnvExists?: string }).xEnvExists ?? null) : null },
+ json: async () => (r as { jsonValue?: unknown }).jsonValue,
+ text: async () => (r as { textValue?: string }).textValue ?? '',
+ } as unknown as Response;
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+ }
+
+ it('captures local dossier notes when captureDocs is on and the stack has content', async () => {
+ mockGetStacks.mockResolvedValue(['web']);
+ mockGetStackContent.mockResolvedValue('services: {}\n');
+ mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
+ mockGetStackDossier.mockReturnValue(dossier({ purpose: 'edge proxy', owner: 'ops' }));
+
+ const result = await captureLocalNodeFiles(localNode, true);
+
+ expect(result.stacks[0].dossier).toMatchObject({ purpose: 'edge proxy', owner: 'ops' });
+ expect(result.docWarnings).toHaveLength(0);
+ });
+
+ it('omits the local dossier when every field is blank', async () => {
+ mockGetStacks.mockResolvedValue(['web']);
+ mockGetStackContent.mockResolvedValue('services: {}\n');
+ mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
+ mockGetStackDossier.mockReturnValue(undefined);
+
+ const result = await captureLocalNodeFiles(localNode, true);
+
+ expect(result.stacks[0].dossier).toBeUndefined();
+ });
+
+ it('does not read dossiers when captureDocs is off (default)', async () => {
+ mockGetStacks.mockResolvedValue(['web']);
+ mockGetStackContent.mockResolvedValue('services: {}\n');
+ mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
+
+ const result = await captureLocalNodeFiles(localNode);
+
+ expect(result.stacks[0].dossier).toBeUndefined();
+ expect(mockGetStackDossier).not.toHaveBeenCalled();
+ });
+
+ it('captures dossier notes from the remote dossier endpoint', async () => {
+ mockFetchRoutes({
+ '/api/stacks': { ok: true, jsonValue: ['web'] },
+ '/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
+ '/api/stacks/web/env': { ok: false, status: 404 },
+ '/api/stacks/web/dossier': { ok: true, jsonValue: dossier({ purpose: 'edge' }) },
+ });
+
+ const result = await captureRemoteNodeFiles(remoteNode, true);
+
+ expect(result.stacks[0].dossier).toMatchObject({ purpose: 'edge' });
+ expect(result.docWarnings).toHaveLength(0);
+ });
+
+ it('records a doc warning when the remote dossier fetch fails (non-404)', async () => {
+ mockFetchRoutes({
+ '/api/stacks': { ok: true, jsonValue: ['web'] },
+ '/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
+ '/api/stacks/web/env': { ok: false, status: 404 },
+ '/api/stacks/web/dossier': { ok: false, status: 500 },
+ });
+
+ const result = await captureRemoteNodeFiles(remoteNode, true);
+
+ expect(result.stacks).toHaveLength(1);
+ expect(result.stacks[0].dossier).toBeUndefined();
+ expect(result.docWarnings[0].reason).toContain('HTTP 500');
+ });
+
+ it('treats a 404 dossier as absent without warning', async () => {
+ mockFetchRoutes({
+ '/api/stacks': { ok: true, jsonValue: ['web'] },
+ '/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
+ '/api/stacks/web/env': { ok: false, status: 404 },
+ '/api/stacks/web/dossier': { ok: false, status: 404 },
+ });
+
+ const result = await captureRemoteNodeFiles(remoteNode, true);
+
+ expect(result.stacks[0].dossier).toBeUndefined();
+ expect(result.docWarnings).toHaveLength(0);
+ });
+
+ it('pickDossierFields keeps only the eleven string fields and drops extras', () => {
+ const f = pickDossierFields({ purpose: 'p', owner: 'o', node_id: 5, source_hash: 'x' } as Record);
+ expect(f.purpose).toBe('p');
+ expect(f.owner).toBe('o');
+ expect(Object.keys(f)).toHaveLength(11);
+ expect((f as unknown as Record).node_id).toBeUndefined();
+ });
+
+ it('dossierHasContent is false for all-blank or whitespace, true for any real value', () => {
+ expect(dossierHasContent(BLANK_DOSSIER)).toBe(false);
+ expect(dossierHasContent(dossier({ vlan: ' ' }))).toBe(false);
+ expect(dossierHasContent(dossier({ purpose: 'x' }))).toBe(true);
+ });
+
+ it('buildSnapshotDocumentation returns null when nothing was captured', () => {
+ const nodes: SnapshotNodeData[] = [
+ { nodeId: 1, nodeName: 'a', stacks: [{ stackName: 'web', files: [] }], warnings: [], docWarnings: [] },
+ ];
+ expect(buildSnapshotDocumentation(nodes, 'now')).toBeNull();
+ });
+
+ it('buildSnapshotDocumentation aggregates dossiers and dossier warnings', () => {
+ const nodes: SnapshotNodeData[] = [{
+ nodeId: 1,
+ nodeName: 'a',
+ stacks: [{ stackName: 'web', files: [], dossier: dossier({ purpose: 'p' }) }],
+ warnings: [],
+ docWarnings: [{ stackName: 'db', reason: 'boom' }],
+ }];
+
+ const doc = buildSnapshotDocumentation(nodes, '2026-01-01T00:00:00Z');
+
+ expect(doc).not.toBeNull();
+ expect(doc!.stacks).toHaveLength(1);
+ expect(doc!.stacks[0]).toMatchObject({ nodeId: 1, nodeName: 'a', stackName: 'web' });
+ expect(doc!.warnings[0]).toMatchObject({ nodeId: 1, stackName: 'db', reason: 'boom' });
+ expect(doc!.generated_at).toBe('2026-01-01T00:00:00Z');
+ });
+});
diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts
index 2045a152..f8f78f70 100644
--- a/backend/src/routes/fleet.ts
+++ b/backend/src/routes/fleet.ts
@@ -3,7 +3,7 @@ import path from 'path';
import semver from 'semver';
import si from 'systeminformation';
import type Dockerode from 'dockerode';
-import { DatabaseService, type Node } from '../services/DatabaseService';
+import { DatabaseService, type Node, type StackDossierFields } from '../services/DatabaseService';
import { ControlIdentityMismatchError, FleetSyncService, StaleSyncPushError } from '../services/FleetSyncService';
import { MAX_SYNC_ROWS, SYNC_ERROR_CODES } from '../services/fleetSyncConstants';
import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPDATE_TIMEOUT_MS, UPDATE_TIMEOUT_MSG, TERMINAL_TTL_MS } from '../services/FleetUpdateTrackerService';
@@ -17,7 +17,7 @@ import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates';
import { scheduleLocalUpdate } from './license';
import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate';
-import { captureLocalNodeFiles, captureRemoteNodeFiles, type SnapshotNodeData } from '../utils/snapshot-capture';
+import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture';
import { getLatestVersion } from '../utils/version-check';
import { isValidStackName } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
@@ -1629,14 +1629,15 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const username = req.user?.username || 'admin';
+ const captureDocs = db.getGlobalSettings().snapshot_documentation === '1';
const captureStart = Date.now();
const results = await Promise.allSettled(
nodes.map(async (node) => {
if (node.type === 'remote') {
- return captureRemoteNodeFiles(node);
+ return captureRemoteNodeFiles(node, captureDocs);
}
- return captureLocalNodeFiles(node);
+ return captureLocalNodeFiles(node, captureDocs);
}),
);
@@ -1683,6 +1684,10 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
}
}
+ const documentation = captureDocs
+ ? buildSnapshotDocumentation(capturedNodes, new Date().toISOString())
+ : null;
+
const snapshotId = db.createSnapshot(
description,
username,
@@ -1690,6 +1695,7 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
totalStacks,
JSON.stringify(skippedNodes),
JSON.stringify(skippedStacks),
+ documentation ? JSON.stringify(documentation) : '',
);
if (allFiles.length > 0) {
@@ -1785,8 +1791,30 @@ fleetRouter.get('/snapshots/:id', authMiddleware, async (req: Request, res: Resp
})),
}));
+ // Surface the captured dossier metadata when present so the detail view can
+ // render notes and offer to restore them. The list payload stays lean (only
+ // the has_documentation flag); the blob is decrypted here on demand.
+ let documentation: SnapshotDocumentation | undefined;
+ if (snapshot.has_documentation) {
+ const raw = db.getSnapshotDocumentation(id);
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw) as SnapshotDocumentation;
+ // Re-project each dossier through pickDossierFields so the client
+ // receives the same shape restore writes back, and never any field
+ // outside the eleven operator notes.
+ documentation = {
+ ...parsed,
+ stacks: parsed.stacks.map(s => ({ ...s, dossier: pickDossierFields(s.dossier) })),
+ };
+ } catch (e) {
+ console.error(`[Fleet Snapshot] Failed to parse documentation for snapshot ${id}:`, getErrorMessage(e, 'parse error'));
+ }
+ }
+ }
+
if (isDebugEnabled()) console.debug('[Fleet:debug] Snapshot detail:', id, files.length, 'files');
- res.json({ ...snapshot, nodes });
+ res.json({ ...snapshot, nodes, documentation });
} catch (error) {
console.error('[Fleet Snapshot] Detail error:', error);
res.status(500).json({ error: 'Failed to fetch snapshot details' });
@@ -1905,6 +1933,48 @@ async function redeploySnapshotStack(node: Node, stackName: string): Promise s?.nodeId === nodeId && s?.stackName === stackName);
+ if (!entry) return undefined;
+ const fields = pickDossierFields(entry.dossier);
+ return dossierHasContent(fields) ? fields : undefined;
+}
+
+// Writes captured dossier notes back to a stack: local nodes upsert into the
+// DB, remote nodes receive them over the proxy. Only ever called when the
+// operator explicitly opted in to restoring notes.
+async function restoreSnapshotStackDossier(node: Node, stackName: string, fields: StackDossierFields): Promise {
+ if (node.type === 'local') {
+ DatabaseService.getInstance().upsertStackDossier(node.id, stackName, fields);
+ return;
+ }
+ const ctx = buildRemoteProxyContext(node);
+ if (!ctx) throw new SnapshotProxyTargetError(formatNoTargetError(node));
+ const putRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, {
+ method: 'PUT',
+ headers: ctx.headers,
+ body: JSON.stringify(fields),
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!putRes.ok) throw await remoteStackError('Failed to restore dossier notes', putRes);
+}
+
fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, res: Response): Promise => {
if (!requireAdmin(req, res)) return;
@@ -1912,6 +1982,9 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID');
if (snapshotId === null) return;
const { nodeId, stackName, redeploy = false } = req.body;
+ // Strict boolean: only an explicit `true` opts in, so a stray `"false"` or
+ // truthy value can never trigger an overwrite of the operator's notes.
+ const restoreNotes: boolean = req.body?.restoreNotes === true;
if (!nodeId || !stackName) {
res.status(400).json({ error: 'nodeId and stackName are required' });
@@ -1948,6 +2021,25 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
await applySnapshotStackFiles(node, stackName, files);
+ // Dossier notes are restored only on explicit opt-in, so a routine file
+ // restore never clobbers the operator's current notes. The note write is
+ // best-effort relative to the file restore: the files are already on disk,
+ // so a notes failure (e.g. a remote dossier PUT) is reported, not fatal.
+ let notesRestored = false;
+ let notesError: string | undefined;
+ if (restoreNotes) {
+ const fields = findSnapshotDossier(snapshotId, nodeId, stackName);
+ if (fields) {
+ try {
+ await restoreSnapshotStackDossier(node, stackName, fields);
+ notesRestored = true;
+ } catch (e) {
+ notesError = getErrorMessage(e, 'Failed to restore documentation notes');
+ console.error(`[Fleet Snapshot] Note restore failed for stack "${sanitizeForLog(stackName)}":`, notesError);
+ }
+ }
+ }
+
if (redeploy) {
// Local deploys are gated centrally here; remote deploys are gated by the
// remote node's own deploy endpoint.
@@ -1956,7 +2048,7 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
}
console.log('[Fleet] Snapshot restore: snapshot=%s node=%s stack=%s', snapshotId, sanitizeForLog(nodeId), sanitizeForLog(stackName));
- res.json({ message: 'Stack restored successfully', redeployed: redeploy });
+ res.json({ message: 'Stack restored successfully', redeployed: redeploy, notesRestored, notesError });
} catch (error) {
if (error instanceof SnapshotProxyTargetError) {
res.status(503).json({ error: error.message });
@@ -1975,7 +2067,10 @@ interface SnapshotRestoreResult {
stackName: string;
success: boolean;
redeployed: boolean;
+ notesRestored: boolean;
error?: string;
+ /** A non-fatal documentation-notes restore failure; files still restored. */
+ notesError?: string;
}
fleetRouter.post('/snapshots/:id/restore-all', authMiddleware, async (req: Request, res: Response): Promise => {
@@ -1985,6 +2080,7 @@ fleetRouter.post('/snapshots/:id/restore-all', authMiddleware, async (req: Reque
const snapshotId = parseIntParam(req, res, 'id', 'snapshot ID');
if (snapshotId === null) return;
const redeploy: boolean = req.body?.redeploy === true;
+ const restoreNotes: boolean = req.body?.restoreNotes === true;
const db = DatabaseService.getInstance();
const snapshot = db.getSnapshot(snapshotId);
@@ -2024,15 +2120,32 @@ fleetRouter.post('/snapshots/:id/restore-all', authMiddleware, async (req: Reque
await applySnapshotStackFiles(node, group.stackName, group.files);
+ // Files are restored; a notes failure is recorded but does not fail the
+ // stack (and must not block the redeploy below).
+ let notesRestored = false;
+ let notesError: string | undefined;
+ if (restoreNotes) {
+ const fields = findSnapshotDossier(snapshotId, group.nodeId, group.stackName);
+ if (fields) {
+ try {
+ await restoreSnapshotStackDossier(node, group.stackName, fields);
+ notesRestored = true;
+ } catch (e) {
+ notesError = getErrorMessage(e, 'Failed to restore documentation notes');
+ console.error(`[Fleet Snapshot] Note restore failed for stack "${sanitizeForLog(group.stackName)}":`, notesError);
+ }
+ }
+ }
+
let redeployed = false;
if (redeploy) {
if (node.type === 'local') await assertPolicyGateAllows(group.stackName, node.id, policyOptions);
await redeploySnapshotStack(node, group.stackName);
redeployed = true;
}
- results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: true, redeployed });
+ results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: true, redeployed, notesRestored, notesError });
} catch (e) {
- results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: false, redeployed: false, error: getErrorMessage(e, 'Restore failed') });
+ results.push({ nodeId: group.nodeId, nodeName: group.nodeName, stackName: group.stackName, success: false, redeployed: false, notesRestored: false, error: getErrorMessage(e, 'Restore failed') });
}
}
diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts
index 1ed24369..e6b33dc3 100644
--- a/backend/src/routes/settings.ts
+++ b/backend/src/routes/settings.ts
@@ -26,6 +26,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'scan_history_per_image_limit',
'prune_on_update',
'reclaim_hero',
+ 'snapshot_documentation',
]);
// Keys whose write requires a paid license, not just an admin role.
@@ -50,6 +51,7 @@ const SettingsPatchSchema = z.object({
scan_history_per_image_limit: z.coerce.number().int().min(5).max(1000).transform(String),
prune_on_update: z.enum(['0', '1']),
reclaim_hero: z.enum(['0', '1']),
+ snapshot_documentation: z.enum(['0', '1']),
}).partial();
export const settingsRouter = Router();
diff --git a/backend/src/services/CloudBackupService.ts b/backend/src/services/CloudBackupService.ts
index 7c46b6b7..12ac72a0 100644
--- a/backend/src/services/CloudBackupService.ts
+++ b/backend/src/services/CloudBackupService.ts
@@ -252,11 +252,12 @@ export class CloudBackupService {
const snapshot = db.getSnapshot(snapshotId);
if (!snapshot) throw new Error(`Snapshot ${snapshotId} not found.`);
const files = db.getSnapshotFiles(snapshotId);
+ const documentation = db.getSnapshotDocumentation(snapshotId);
const objectKey = this.buildObjectKey(cfg, snapshot.id, snapshot.description, snapshot.created_at);
this.setStatus(snapshotId, { status: 'uploading', objectKey, updatedAt: Date.now() });
try {
- const archive = await this.buildArchive(snapshot, files);
+ const archive = await this.buildArchive(snapshot, files, documentation);
const { client, sdk } = await this.buildS3Client(cfg);
await client.send(new sdk.PutObjectCommand({
Bucket: cfg.bucket,
@@ -364,6 +365,7 @@ export class CloudBackupService {
private async buildArchive(
snapshot: { id: number; description: string; created_by: string; node_count: number; stack_count: number; skipped_nodes: string; created_at: number },
files: FleetSnapshotFile[],
+ documentation = '',
): Promise {
const pack = tar.pack();
const metadata = {
@@ -374,11 +376,19 @@ export class CloudBackupService {
node_count: snapshot.node_count,
stack_count: snapshot.stack_count,
skipped_nodes: safeParseJson(snapshot.skipped_nodes, []),
+ has_documentation: documentation !== '',
instance_id: this.getInstanceId(),
- archive_version: 1,
+ // Version 2 adds the optional documentation.json entry; readers of
+ // version 1 archives simply will not find that file.
+ archive_version: 2,
};
pack.entry({ name: 'metadata.json' }, JSON.stringify(metadata, null, 2));
+ // Captured Stack Dossier metadata travels with the archive when present.
+ if (documentation !== '') {
+ pack.entry({ name: 'documentation.json' }, documentation);
+ }
+
for (const file of files) {
const safeNodeName = sanitizePathSegment(file.node_name);
const safeStackName = sanitizePathSegment(file.stack_name);
diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts
index 15cc3cc3..8cdac8c5 100644
--- a/backend/src/services/DatabaseService.ts
+++ b/backend/src/services/DatabaseService.ts
@@ -278,6 +278,10 @@ export interface FleetSnapshot {
skipped_nodes: string; // JSON: Array<{ nodeId; nodeName; reason }>
skipped_stacks: string; // JSON: Array<{ nodeId; nodeName; stackName; reason }>
created_at: number;
+ /** 1 when the snapshot captured Stack Dossier metadata, 0 otherwise. The
+ * encrypted blob itself is never projected into list/detail rows; read it
+ * with getSnapshotDocumentation(). */
+ has_documentation: number;
}
export interface FleetSnapshotFile {
@@ -867,6 +871,7 @@ export class DatabaseService {
stack_count INTEGER NOT NULL,
skipped_nodes TEXT NOT NULL DEFAULT '[]',
skipped_stacks TEXT NOT NULL DEFAULT '[]',
+ documentation TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
@@ -1280,6 +1285,8 @@ export class DatabaseService {
// Fleet snapshot per-stack capture warnings (partial-capture surfacing)
maybeAddCol('fleet_snapshots', 'skipped_stacks', "TEXT NOT NULL DEFAULT '[]'");
+ // Captured Stack Dossier metadata (opt-in documentation snapshots)
+ maybeAddCol('fleet_snapshots', 'documentation', "TEXT NOT NULL DEFAULT ''");
// Scheduled operations migrations
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
@@ -3084,10 +3091,14 @@ export class DatabaseService {
// --- Fleet Snapshots ---
- public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string, skippedStacks = '[]'): number {
+ public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string, skippedStacks = '[]', documentation = ''): number {
+ // Dossier metadata can carry operational notes (static IPs, firewall
+ // rules); encrypt it at rest with the same instance key as the file
+ // bodies. An empty string means the snapshot captured no documentation.
+ const storedDocs = documentation === '' ? '' : CryptoService.getInstance().encrypt(documentation);
const result = this.db.prepare(
- 'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
- ).run(description, createdBy, nodeCount, stackCount, skippedNodes, skippedStacks, Date.now());
+ 'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, documentation, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
+ ).run(description, createdBy, nodeCount, stackCount, skippedNodes, skippedStacks, storedDocs, Date.now());
return result.lastInsertRowid as number;
}
@@ -3108,14 +3119,37 @@ export class DatabaseService {
insertMany(files);
}
+ // The encrypted `documentation` blob is deliberately excluded from these
+ // projections (it can be large and is decrypted only on demand). Callers
+ // get a cheap `has_documentation` flag; read the blob with
+ // getSnapshotDocumentation().
+ private static readonly SNAPSHOT_COLUMNS =
+ "id, description, created_by, node_count, stack_count, skipped_nodes, skipped_stacks, created_at, (documentation != '') AS has_documentation";
+
public getSnapshots(limit = 50, offset = 0): FleetSnapshot[] {
return this.db.prepare(
- 'SELECT * FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?'
+ `SELECT ${DatabaseService.SNAPSHOT_COLUMNS} FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(limit, offset) as FleetSnapshot[];
}
public getSnapshot(id: number): FleetSnapshot | undefined {
- return this.db.prepare('SELECT * FROM fleet_snapshots WHERE id = ?').get(id) as FleetSnapshot | undefined;
+ return this.db.prepare(`SELECT ${DatabaseService.SNAPSHOT_COLUMNS} FROM fleet_snapshots WHERE id = ?`).get(id) as FleetSnapshot | undefined;
+ }
+
+ /** Decrypted Stack Dossier metadata JSON captured with the snapshot, or '' when none. */
+ public getSnapshotDocumentation(id: number): string {
+ const row = this.db.prepare('SELECT documentation FROM fleet_snapshots WHERE id = ?').get(id) as { documentation: string } | undefined;
+ if (!row || row.documentation === '') return '';
+ try {
+ // decrypt() returns non-ciphertext input unchanged, mirroring the file path.
+ return CryptoService.getInstance().decrypt(row.documentation);
+ } catch (e) {
+ // A corrupt blob or a key rotation must not break the primary backup
+ // flows: documentation is an optional side payload, so degrade to
+ // "no documentation" rather than failing upload/detail/restore.
+ console.error(`[DatabaseService] Failed to decrypt documentation for snapshot ${id}:`, (e as Error).message);
+ return '';
+ }
}
public getSnapshotFiles(snapshotId: number): FleetSnapshotFile[] {
diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts
index 3c886eab..d2cf29cf 100644
--- a/backend/src/services/SchedulerService.ts
+++ b/backend/src/services/SchedulerService.ts
@@ -11,7 +11,7 @@ import type { ImageCheckResult } from './ImageUpdateService';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
-import { captureLocalNodeFiles, captureRemoteNodeFiles, type SnapshotNodeData } from '../utils/snapshot-capture';
+import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, type SnapshotNodeData } from '../utils/snapshot-capture';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService';
@@ -533,13 +533,14 @@ export class SchedulerService {
private async executeSnapshot(task: ScheduledTask): Promise {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
+ const captureDocs = db.getGlobalSettings().snapshot_documentation === '1';
const results = await Promise.allSettled(
nodes.map(async (node) => {
if (node.type === 'remote') {
- return captureRemoteNodeFiles(node);
+ return captureRemoteNodeFiles(node, captureDocs);
}
- return captureLocalNodeFiles(node);
+ return captureLocalNodeFiles(node, captureDocs);
})
);
@@ -585,6 +586,10 @@ export class SchedulerService {
}
}
+ const documentation = captureDocs
+ ? buildSnapshotDocumentation(capturedNodes, new Date().toISOString())
+ : null;
+
const description = `Scheduled snapshot: ${task.name}`;
const snapshotId = db.createSnapshot(
description,
@@ -593,6 +598,7 @@ export class SchedulerService {
totalStacks,
JSON.stringify(skippedNodes),
JSON.stringify(skippedStacks),
+ documentation ? JSON.stringify(documentation) : '',
);
if (allFiles.length > 0) {
diff --git a/backend/src/utils/snapshot-capture.ts b/backend/src/utils/snapshot-capture.ts
index c4fc36ed..2aed6e3d 100644
--- a/backend/src/utils/snapshot-capture.ts
+++ b/backend/src/utils/snapshot-capture.ts
@@ -3,18 +3,79 @@
* and the SchedulerService for fleet-wide snapshot operations.
*/
-import type { NodeMode } from '../services/DatabaseService';
+import type { NodeMode, StackDossierFields } from '../services/DatabaseService';
+import { DatabaseService } from '../services/DatabaseService';
import { FileSystemService } from '../services/FileSystemService';
import { NodeRegistry } from '../services/NodeRegistry';
import { formatNoTargetError } from './remoteTarget';
import { isDebugEnabled } from './debug';
+// Presence map over every operator-authored dossier field. Typing it as
+// Record makes the build fail if a field is
+// added to StackDossierFields without being listed here, so capture can never
+// silently start omitting a new field.
+const DOSSIER_FIELD_PRESENCE: Record = {
+ purpose: true, owner: true, access_urls: true, static_ip: true, vlan: true,
+ firewall_notes: true, reverse_proxy_notes: true, backup_notes: true,
+ upgrade_notes: true, recovery_notes: true, custom_notes: true,
+};
+const DOSSIER_FIELD_KEYS = Object.keys(DOSSIER_FIELD_PRESENCE) as Array;
+
+/**
+ * Project an arbitrary object (a DB row or a remote JSON payload) down to the
+ * eleven operator-authored dossier fields, coercing anything non-string to ''.
+ * Never carries identity, hashes, or timestamps into the snapshot.
+ */
+export function pickDossierFields(src: Partial> | null | undefined): StackDossierFields {
+ const out = {} as StackDossierFields;
+ for (const key of DOSSIER_FIELD_KEYS) {
+ const value = src?.[key];
+ out[key] = typeof value === 'string' ? value : '';
+ }
+ return out;
+}
+
+/** True when the operator typed at least one non-blank dossier field. */
+export function dossierHasContent(fields: StackDossierFields): boolean {
+ return DOSSIER_FIELD_KEYS.some(key => fields[key].trim() !== '');
+}
+
+/** A single stack's preserved dossier notes inside a snapshot. */
+export interface SnapshotDocumentationStack {
+ nodeId: number;
+ nodeName: string;
+ stackName: string;
+ dossier: StackDossierFields;
+}
+
+/** A non-fatal problem fetching a stack's dossier during capture. */
+export interface SnapshotDocumentationWarning {
+ nodeId: number;
+ nodeName: string;
+ stackName: string;
+ reason: string;
+}
+
+/**
+ * Stack Dossier metadata preserved alongside a fleet snapshot's files. Captured
+ * only when the `snapshot_documentation` setting is on; absent (and the column
+ * left empty) otherwise, so existing snapshots stay byte-for-byte unchanged.
+ */
+export interface SnapshotDocumentation {
+ generated_at: string;
+ stacks: SnapshotDocumentationStack[];
+ warnings: SnapshotDocumentationWarning[];
+}
+
export interface SnapshotNodeData {
nodeId: number;
nodeName: string;
stacks: Array<{
stackName: string;
files: Array<{ filename: string; content: string }>;
+ /** Operator dossier notes for this stack, present only when documentation
+ * capture is on and the stack has at least one non-blank field. */
+ dossier?: StackDossierFields;
}>;
/**
* Per-stack capture problems that did not fail the whole node: a stack whose
@@ -24,6 +85,12 @@ export interface SnapshotNodeData {
* for a complete backup.
*/
warnings: Array<{ stackName: string; reason: string }>;
+ /**
+ * Per-stack dossier-fetch problems during documentation capture. Distinct
+ * from `warnings`: the stack's files captured fine, only its notes did not.
+ * Empty unless documentation capture was requested.
+ */
+ docWarnings: Array<{ stackName: string; reason: string }>;
}
/**
@@ -53,12 +120,13 @@ export interface CaptureNode {
* A stack whose compose file cannot be read is omitted from `stacks` and
* recorded in `warnings`, so a partial capture is never mistaken for complete.
*/
-export async function captureLocalNodeFiles(node: CaptureNode): Promise {
+export async function captureLocalNodeFiles(node: CaptureNode, captureDocs = false): Promise {
const start = Date.now();
const fsService = FileSystemService.getInstance(node.id);
const stackNames = await fsService.getStacks();
const stacks: SnapshotNodeData['stacks'] = [];
const warnings: SnapshotNodeData['warnings'] = [];
+ const docWarnings: SnapshotNodeData['docWarnings'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
@@ -94,7 +162,17 @@ export async function captureLocalNodeFiles(node: CaptureNode): Promise {
+export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = false): Promise {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) {
throw new Error(formatNoTargetError(node));
@@ -131,6 +209,7 @@ export async function captureRemoteNodeFiles(node: CaptureNode): Promise = [];
@@ -182,7 +261,25 @@ export async function captureRemoteNodeFiles(node: CaptureNode): Promise);
+ if (dossierHasContent(fields)) dossier = fields;
+ } else if (dossierRes.status !== 404) {
+ docWarnings.push({ stackName, reason: `dossier fetch failed (HTTP ${dossierRes.status})` });
+ }
+ } catch (e) {
+ docWarnings.push({ stackName, reason: `dossier fetch error: ${(e as Error).message}` });
+ }
+ }
+ stacks.push({ stackName, files, dossier });
}
if (isDebugEnabled()) {
@@ -190,5 +287,28 @@ export async function captureRemoteNodeFiles(node: CaptureNode): Promise-`, for example `web-compose.yaml`).
+Below the header, each node appears as a collapsible card. Expand a node to see its stacks, then expand a stack to see individual files. If documentation was captured for a stack, its preserved Dossier notes are shown read-only beneath the file list. Each file has a **Preview** button that renders the full file contents inline in a scrollable panel, and a **Download** button that saves that single file to your machine (named `-`, for example `web-compose.yaml`).
@@ -65,9 +71,11 @@ Admins can restore individual stacks from any snapshot:
1. Open a snapshot's detail view
2. Expand the node and stack you want to restore
3. Click **Restore** below the stack's file list
-4. A confirmation dialog appears. Optionally check **Redeploy stack after restore** to immediately apply the restored configuration.
+4. A confirmation dialog appears. Optionally check **Redeploy stack after restore** to immediately apply the restored configuration. When the snapshot preserved Dossier notes for this stack, a **Restore documentation notes** option also appears.
5. Click **Restore** to confirm
+By default, restoring a stack only writes back its files and leaves the stack's current Dossier notes untouched. Notes are restored only when you explicitly check **Restore documentation notes**, which overwrites the current notes with the captured ones. The same option appears on **Restore all** when the snapshot has documentation.
+
@@ -133,7 +141,7 @@ To upload a single snapshot on demand, open the **Snapshots** tab in Fleet View.
### Browsing and downloading cloud snapshots
-The **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack.
+The **Cloud Snapshots** panel in **Settings → Infrastructure → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack. When the snapshot preserved Dossier notes, the archive also includes a `documentation.json` with those notes.
### Restoring from a cloud snapshot
diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx
index 309996d9..bde11bb8 100644
--- a/frontend/src/components/FleetSnapshots.tsx
+++ b/frontend/src/components/FleetSnapshots.tsx
@@ -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;
+
+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() {
{isAdmin && selectedSnapshot.nodes.length > 0 && (
-
+ 0}
+ onRestoreAll={handleRestoreAll}
+ />
)}
@@ -422,6 +470,12 @@ export default function FleetSnapshots() {
{selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''}
+ {(selectedSnapshot.documentation?.stacks.length ?? 0) > 0 && (
+
+
+ Documentation captured
+
+ )}
@@ -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 (
+
+
+
+
+ Some stack documentation could not be captured:
+
+
+
+ {warnings.map((w, i) => (
+ -
+ {w.nodeName}
+ {' / '}
+ {w.stackName}
+ {' - '}
+ {w.reason}
+
+ ))}
+
+
+ );
+ })()}
+
{/* Node / Stack / File tree */}
{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 (
}
+
{/* Restore button (admin only) */}
{isAdmin && (
@@ -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;
+ onRestore: (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => Promise;
}) {
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
+ {hasDossier && (
+
+ setRestoreNotes(checked === true)}
+ />
+
+
+ )}
>
);
}
+// --- 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 (
+
+
+
+ Dossier notes
+
+
+ {entries.map(([key, label]) => (
+
+
- {label}
+ - {dossier[key]}
+
+ ))}
+
+
+ );
+}
+
// --- Restore All Button Sub-Component ---
-function RestoreAllButton({ restoring, onRestoreAll }: {
+function RestoreAllButton({ restoring, hasDocumentation, onRestoreAll }: {
restoring: boolean;
- onRestoreAll: (redeploy: boolean) => Promise
;
+ hasDocumentation: boolean;
+ onRestoreAll: (redeploy: boolean, restoreNotes: boolean) => Promise;
}) {
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
+ {hasDocumentation && (
+
+ setRestoreNotes(checked === true)}
+ />
+
+
+ )}
>
);
diff --git a/frontend/src/components/settings/FleetMeshSection.tsx b/frontend/src/components/settings/FleetMeshSection.tsx
index 75155d04..6beb3c1a 100644
--- a/frontend/src/components/settings/FleetMeshSection.tsx
+++ b/frontend/src/components/settings/FleetMeshSection.tsx
@@ -25,10 +25,11 @@ function SectionSkeleton() {
);
}
-type FleetMeshFields = Pick;
+type FleetMeshFields = Pick;
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 = 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) {
+
+
+ onSettingChange('snapshot_documentation', next ? '1' : '0')}
+ />
+
+
+
{!readOnly && (
diff --git a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx
index d223c369..95d06368 100644
--- a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx
+++ b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx
@@ -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();
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 () => {
diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts
index 7bee792b..bb804b20 100644
--- a/frontend/src/components/settings/registry.ts
+++ b/frontend/src/components/settings/registry.ts
@@ -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,
diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts
index acb3d4ac..cf7b2d8c 100644
--- a/frontend/src/components/settings/types.ts
+++ b/frontend/src/components/settings/types.ts
@@ -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 =