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`). Snapshot detail view with a node expanded, showing a stack expanded with a compose file, Preview button, and Restore button @@ -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. + Restore confirmation dialog showing the target stack and node name, a redeploy checkbox, and Cancel and Restore buttons @@ -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 (
+ {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 =