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

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

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

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

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

Address review findings on the documentation-snapshots restore path:

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

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

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

The restore-all remote notes test destructured a node id it never uses
(restore-all is driven by snapshot id alone), tripping no-unused-vars and
failing the lint step. Bind only the snapshot id.
This commit is contained in:
Anso
2026-06-08 08:44:59 -04:00
committed by GitHub
parent 842ee7dd0c
commit 710647a44f
16 changed files with 1020 additions and 55 deletions
@@ -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();
@@ -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());
@@ -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) ─────────────────────────────────────────
@@ -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>): 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<string, Partial<Response> & { 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<string, unknown>);
expect(f.purpose).toBe('p');
expect(f.owner).toBe('o');
expect(Object.keys(f)).toHaveLength(11);
expect((f as unknown as Record<string, unknown>).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');
});
});
+121 -8
View File
@@ -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<voi
if (!deployRes.ok) throw await remoteStackError('Failed to redeploy stack', deployRes);
}
// Looks up the dossier notes a snapshot preserved for one stack, or undefined
// when no usable dossier entry exists for that stack (no documentation, an
// unparseable blob, or no matching entry).
function findSnapshotDossier(snapshotId: number, nodeId: number, stackName: string): StackDossierFields | undefined {
const raw = DatabaseService.getInstance().getSnapshotDocumentation(snapshotId);
if (!raw) return undefined;
let doc: SnapshotDocumentation;
try {
doc = JSON.parse(raw) as SnapshotDocumentation;
} catch (e) {
console.error(`[Fleet Snapshot] Failed to parse documentation for snapshot ${snapshotId}:`, getErrorMessage(e, 'parse error'));
return undefined;
}
// Guard against a malformed-but-parseable blob: `stacks` must be an array, and
// only restore notes that actually carry content, so a blank or tampered entry
// can never silently clobber the operator's current notes with empty fields.
if (!Array.isArray(doc.stacks)) return undefined;
const entry = doc.stacks.find(s => 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<void> {
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<void> => {
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<void> => {
@@ -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') });
}
}
+2
View File
@@ -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();
+12 -2
View File
@@ -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<Buffer> {
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);
+39 -5
View File
@@ -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[] {
+9 -3
View File
@@ -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<string> {
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) {
+127 -7
View File
@@ -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<keyof StackDossierFields, true> 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<keyof StackDossierFields, true> = {
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<keyof StackDossierFields>;
/**
* 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<Record<keyof StackDossierFields, unknown>> | 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<SnapshotNodeData> {
export async function captureLocalNodeFiles(node: CaptureNode, captureDocs = false): Promise<SnapshotNodeData> {
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<Snapshot
warnings.push({ stackName, reason: `.env could not be read: ${(e as Error).message}; captured without it` });
}
}
stacks.push({ stackName, files });
let dossier: StackDossierFields | undefined;
if (captureDocs) {
try {
const fields = pickDossierFields(DatabaseService.getInstance().getStackDossier(node.id, stackName));
if (dossierHasContent(fields)) dossier = fields;
} catch (e) {
docWarnings.push({ stackName, reason: `dossier could not be read: ${(e as Error).message}` });
}
}
stacks.push({ stackName, files, dossier });
}
if (isDebugEnabled()) {
@@ -102,7 +180,7 @@ export async function captureLocalNodeFiles(node: CaptureNode): Promise<Snapshot
console.debug(`[Fleet:debug] Local capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s), ${warnings.length} warning(s) in ${Date.now() - start}ms`);
}
return { nodeId: node.id, nodeName: node.name, stacks, warnings };
return { nodeId: node.id, nodeName: node.name, stacks, warnings, docWarnings };
}
/**
@@ -111,7 +189,7 @@ export async function captureLocalNodeFiles(node: CaptureNode): Promise<Snapshot
* fetched is omitted from `stacks` and recorded in `warnings`, so a partial
* capture is never mistaken for complete.
*/
export async function captureRemoteNodeFiles(node: CaptureNode): Promise<SnapshotNodeData> {
export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = false): Promise<SnapshotNodeData> {
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<Snapsho
const stacks: SnapshotNodeData['stacks'] = [];
const warnings: SnapshotNodeData['warnings'] = [];
const docWarnings: SnapshotNodeData['docWarnings'] = [];
for (const stackName of stackNames) {
const files: Array<{ filename: string; content: string }> = [];
@@ -182,7 +261,25 @@ export async function captureRemoteNodeFiles(node: CaptureNode): Promise<Snapsho
} catch (e) {
warnings.push({ stackName, reason: `.env fetch error: ${(e as Error).message}; captured without it` });
}
stacks.push({ stackName, files });
let dossier: StackDossierFields | undefined;
if (captureDocs) {
try {
const dossierRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, {
headers,
signal: AbortSignal.timeout(15000),
});
if (dossierRes.ok) {
const fields = pickDossierFields(await dossierRes.json() as Record<string, unknown>);
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<Snapsho
console.debug(`[Fleet:debug] Remote capture "${node.name}": ${stacks.length} stack(s), ${fileCount} file(s), ${warnings.length} warning(s) in ${Date.now() - start}ms`);
}
return { nodeId: node.id, nodeName: node.name, stacks, warnings };
return { nodeId: node.id, nodeName: node.name, stacks, warnings, docWarnings };
}
/**
* Collapse captured per-node dossier notes and dossier-fetch warnings into a
* single snapshot documentation record. Returns `null` when nothing was
* captured (no stack carried notes and nothing failed), so the caller stores an
* empty documentation column and `has_documentation` stays 0.
*/
export function buildSnapshotDocumentation(capturedNodes: SnapshotNodeData[], generatedAt: string): SnapshotDocumentation | null {
const stacks: SnapshotDocumentationStack[] = [];
const warnings: SnapshotDocumentationWarning[] = [];
for (const node of capturedNodes) {
for (const stack of node.stacks) {
if (stack.dossier) {
stacks.push({ nodeId: node.nodeId, nodeName: node.nodeName, stackName: stack.stackName, dossier: stack.dossier });
}
}
for (const warning of node.docWarnings) {
warnings.push({ nodeId: node.nodeId, nodeName: node.nodeName, stackName: warning.stackName, reason: warning.reason });
}
}
if (stacks.length === 0 && warnings.length === 0) return null;
return { generated_at: generatedAt, stacks, warnings };
}
+12 -4
View File
@@ -30,6 +30,12 @@ If a remote node is offline or unreachable, it is **skipped gracefully**. The sn
Fleet snapshots can also be created automatically on a recurring schedule. Navigate to the **Schedules** view, create a new scheduled operation, and select **Fleet Snapshot** as the action type. Scheduled snapshots appear in the snapshot list with a "Scheduled snapshot" prefix in their description.
### Capturing stack documentation
Snapshots can also preserve each stack's [Dossier](/features/stack-dossier) notes (purpose, owner, access URLs, firewall and recovery notes, and the rest) alongside the captured files, so a recovery restores the operational knowledge around a stack, not just its configuration.
This is off by default. Turn it on in **Settings → Infrastructure → Fleet** with **Capture stack documentation in snapshots**. Once enabled, every manual and scheduled snapshot records the current Dossier notes for each captured stack. Stacks with no notes are simply omitted, and a stack's notes are never required. Secret values are never included: a Dossier only ever holds the notes an operator typed, never the contents of a `.env`.
## Browsing snapshots
The snapshot list shows each snapshot in a table with the following columns:
@@ -48,9 +54,9 @@ The snapshot list shows each snapshot in a table with the following columns:
Click **View** on any snapshot to open the detail view. Use **Back to Snapshots** in the top-left to return to the list.
The header shows the snapshot's title (or "Untitled Snapshot"), who created it, when, and badge counts for nodes and stacks captured.
The header shows the snapshot's title (or "Untitled Snapshot"), who created it, when, and badge counts for nodes and stacks captured. When the snapshot preserved Dossier notes, a **Documentation captured** badge appears alongside those counts.
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. 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 `<stack>-<filename>`, 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 `<stack>-<filename>`, for example `web-compose.yaml`).
<Frame>
<img src="/images/fleet-backups/snapshot-detail.png" alt="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.
<Frame>
<img src="/images/fleet-backups/restore-dialog.png" alt="Restore confirmation dialog showing the target stack and node name, a redeploy checkbox, and Cancel and Restore buttons" />
</Frame>
@@ -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
+158 -17
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import {
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw,
Cloud, CloudUpload, Download,
Cloud, CloudUpload, Download, BookText,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -31,6 +31,7 @@ interface FleetSnapshot {
skipped_nodes: string; // JSON string
skipped_stacks: string; // JSON string
created_at: number;
has_documentation?: number;
}
interface SnapshotStackFile {
@@ -49,10 +50,42 @@ interface SnapshotNode {
stacks: SnapshotStack[];
}
/** Operator-authored dossier fields preserved with the snapshot. */
type SnapshotDossierFields = Record<string, string>;
interface SnapshotDocumentationStack {
nodeId: number;
nodeName: string;
stackName: string;
dossier: SnapshotDossierFields;
}
interface SnapshotDocumentation {
generated_at: string;
stacks: SnapshotDocumentationStack[];
warnings: Array<{ nodeId: number; nodeName: string; stackName: string; reason: string }>;
}
interface FleetSnapshotDetail extends FleetSnapshot {
nodes: SnapshotNode[];
documentation?: SnapshotDocumentation;
}
// Ordered labels for the read-only dossier block; only non-empty fields render.
const DOSSIER_FIELD_LABELS: ReadonlyArray<[string, string]> = [
['purpose', 'Purpose'],
['owner', 'Owner'],
['access_urls', 'Access URLs'],
['static_ip', 'Static IP'],
['vlan', 'VLAN'],
['firewall_notes', 'Firewall'],
['reverse_proxy_notes', 'Reverse proxy'],
['backup_notes', 'Backup'],
['upgrade_notes', 'Upgrade'],
['recovery_notes', 'Recovery'],
['custom_notes', 'Notes'],
];
interface SkippedNode {
nodeId: number;
nodeName: string;
@@ -248,7 +281,7 @@ export default function FleetSnapshots() {
}
};
const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean) => {
const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => {
if (!selectedSnapshot) return;
const key = `${nodeId}:${stackName}`;
setRestoringStack(key);
@@ -256,11 +289,17 @@ export default function FleetSnapshots() {
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ nodeId, stackName, redeploy }),
body: JSON.stringify({ nodeId, stackName, redeploy, restoreNotes }),
});
if (res.ok) {
const data: { message: string; redeployed: boolean } = await res.json();
toast.success(data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.');
const data: { message: string; redeployed: boolean; notesRestored: boolean; notesError?: string } = await res.json();
const base = data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.';
if (data.notesError) {
// Files restored; only the optional notes write failed.
toast.warning(`${base} Documentation notes could not be restored.`);
} else {
toast.success(base + (data.notesRestored ? ' Documentation notes restored.' : ''));
}
} else {
const err = await res.json().catch(() => null);
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore stack.');
@@ -290,35 +329,40 @@ export default function FleetSnapshots() {
}
};
const handleRestoreAll = async (redeploy: boolean) => {
const handleRestoreAll = async (redeploy: boolean, restoreNotes: boolean) => {
if (!selectedSnapshot) return;
setRestoringAll(true);
try {
const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore-all`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ redeploy }),
body: JSON.stringify({ redeploy, restoreNotes }),
});
if (res.ok) {
const data: {
restored: number;
failed: number;
redeploy: boolean;
results: Array<{ stackName: string; success: boolean; error?: string }>;
results: Array<{ stackName: string; success: boolean; error?: string; notesError?: string }>;
} = await res.json();
const noun = (n: number) => `${n} stack${n === 1 ? '' : 's'}`;
const firstFailed = data.results?.find(r => !r.success);
const failDetail = firstFailed
? ` First failure: ${firstFailed.stackName} · ${firstFailed.error || 'unknown error'}`
: '';
if (data.failed === 0) {
// Files restored but the optional notes write failed on some stacks.
const notesFailed = data.results?.filter(r => r.notesError).length ?? 0;
const notesSuffix = notesFailed > 0 ? ` Documentation notes could not be restored for ${noun(notesFailed)}.` : '';
if (data.failed === 0 && notesFailed === 0) {
toast.success(data.redeploy
? `Restored and redeployed ${noun(data.restored)}.`
: `Restored ${noun(data.restored)}.`);
} else if (data.restored === 0) {
toast.error(`Restore failed for ${noun(data.failed)}.${failDetail}`);
} else if (data.failed === 0) {
toast.warning(`Restored ${noun(data.restored)}.${notesSuffix}`);
} else {
toast.warning(`${data.restored} restored, ${data.failed} failed.${failDetail}`);
toast.warning(`${data.restored} restored, ${data.failed} failed.${failDetail}${notesSuffix}`);
}
} else {
const err = await res.json().catch(() => null);
@@ -412,7 +456,11 @@ export default function FleetSnapshots() {
</p>
</div>
{isAdmin && selectedSnapshot.nodes.length > 0 && (
<RestoreAllButton restoring={restoringAll} onRestoreAll={handleRestoreAll} />
<RestoreAllButton
restoring={restoringAll}
hasDocumentation={(selectedSnapshot.documentation?.stacks.length ?? 0) > 0}
onRestoreAll={handleRestoreAll}
/>
)}
</div>
<div className="flex items-center gap-2">
@@ -422,6 +470,12 @@ export default function FleetSnapshots() {
<Badge variant="secondary" className="font-mono tabular-nums">
{selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''}
</Badge>
{(selectedSnapshot.documentation?.stacks.length ?? 0) > 0 && (
<Badge variant="outline" className="gap-1">
<BookText className="w-3 h-3" strokeWidth={1.5} />
Documentation captured
</Badge>
)}
</div>
</div>
@@ -477,6 +531,33 @@ export default function FleetSnapshots() {
);
})()}
{/* Documentation capture warnings (notes that could not be fetched) */}
{(() => {
const warnings = selectedSnapshot.documentation?.warnings ?? [];
if (warnings.length === 0) return null;
return (
<div className="rounded-xl border border-warning/30 bg-warning/5 p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
<span className="text-sm font-medium text-warning">
Some stack documentation could not be captured:
</span>
</div>
<ul className="ml-6 space-y-1">
{warnings.map((w, i) => (
<li key={`${w.nodeId}:${w.stackName}:${i}`} className="text-sm text-muted-foreground">
<span className="font-medium">{w.nodeName}</span>
{' / '}
<span className="font-mono">{w.stackName}</span>
{' - '}
{w.reason}
</li>
))}
</ul>
</div>
);
})()}
{/* Node / Stack / File tree */}
<div className="space-y-2">
{selectedSnapshot.nodes.map(node => {
@@ -505,6 +586,8 @@ export default function FleetSnapshots() {
{node.stacks.map(stack => {
const stackKey = `${node.nodeId}:${stack.stackName}`;
const stackExpanded = expandedStacks.has(stackKey);
const dossier = selectedSnapshot.documentation?.stacks
.find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier;
return (
<div key={stackKey}>
<button
@@ -565,12 +648,16 @@ export default function FleetSnapshots() {
);
})}
{/* Preserved dossier notes (read-only) */}
{dossier && <DossierBlock dossier={dossier} />}
{/* Restore button (admin only) */}
{isAdmin && (
<RestoreButton
nodeId={node.nodeId}
nodeName={node.nodeName}
stackName={stack.stackName}
hasDossier={!!dossier}
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
onRestore={handleRestore}
/>
@@ -817,14 +904,16 @@ export default function FleetSnapshots() {
// --- Restore Button Sub-Component ---
function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, onRestore }: {
nodeId: number;
nodeName: string;
stackName: string;
hasDossier: boolean;
restoring: boolean;
onRestore: (nodeId: number, stackName: string, redeploy: boolean) => Promise<void>;
onRestore: (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => Promise<void>;
}) {
const [redeploy, setRedeploy] = useState(false);
const [restoreNotes, setRestoreNotes] = useState(false);
const [open, setOpen] = useState(false);
return (
@@ -852,7 +941,7 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
confirming={restoring}
onConfirm={async () => {
try {
await onRestore(nodeId, stackName, redeploy);
await onRestore(nodeId, stackName, redeploy, restoreNotes);
} finally {
setOpen(false);
}
@@ -874,18 +963,58 @@ function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: {
Redeploy stack after restore
</Label>
</div>
{hasDossier && (
<div className="flex items-center space-x-2 pt-1">
<Checkbox
id={`notes-${nodeId}-${stackName}`}
checked={restoreNotes}
onCheckedChange={(checked) => setRestoreNotes(checked === true)}
/>
<Label
htmlFor={`notes-${nodeId}-${stackName}`}
className="text-sm cursor-pointer"
>
Restore documentation notes (overwrites current notes)
</Label>
</div>
)}
</ConfirmModal>
</>
);
}
// --- Preserved Dossier Sub-Component ---
function DossierBlock({ dossier }: { dossier: SnapshotDossierFields }) {
const entries = DOSSIER_FIELD_LABELS.filter(([key]) => (dossier[key] ?? '').trim() !== '');
if (entries.length === 0) return null;
return (
<div className="ml-3 mt-1 rounded-lg border border-card-border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<BookText className="w-3 h-3 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="text-[10px] font-mono uppercase tracking-wide text-muted-foreground">Dossier notes</span>
</div>
<dl className="space-y-1">
{entries.map(([key, label]) => (
<div key={key} className="grid grid-cols-[7rem_1fr] gap-2">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="text-xs whitespace-pre-wrap break-words">{dossier[key]}</dd>
</div>
))}
</dl>
</div>
);
}
// --- Restore All Button Sub-Component ---
function RestoreAllButton({ restoring, onRestoreAll }: {
function RestoreAllButton({ restoring, hasDocumentation, onRestoreAll }: {
restoring: boolean;
onRestoreAll: (redeploy: boolean) => Promise<void>;
hasDocumentation: boolean;
onRestoreAll: (redeploy: boolean, restoreNotes: boolean) => Promise<void>;
}) {
const [redeploy, setRedeploy] = useState(false);
const [restoreNotes, setRestoreNotes] = useState(false);
const [open, setOpen] = useState(false);
return (
@@ -914,7 +1043,7 @@ function RestoreAllButton({ restoring, onRestoreAll }: {
confirming={restoring}
onConfirm={async () => {
try {
await onRestoreAll(redeploy);
await onRestoreAll(redeploy, restoreNotes);
} finally {
setOpen(false);
}
@@ -933,6 +1062,18 @@ function RestoreAllButton({ restoring, onRestoreAll }: {
Redeploy all stacks after restore
</Label>
</div>
{hasDocumentation && (
<div className="flex items-center space-x-2 pt-1">
<Checkbox
id="notes-all"
checked={restoreNotes}
onCheckedChange={(checked) => setRestoreNotes(checked === true)}
/>
<Label htmlFor="notes-all" className="text-sm cursor-pointer">
Restore documentation notes (overwrites current notes)
</Label>
</div>
)}
</ConfirmModal>
</>
);
@@ -25,10 +25,11 @@ function SectionSkeleton() {
);
}
type FleetMeshFields = Pick<PatchableSettings, 'mesh_auto_recreate'>;
type FleetMeshFields = Pick<PatchableSettings, 'mesh_auto_recreate' | 'snapshot_documentation'>;
const DEFAULT_FLEET_MESH: FleetMeshFields = {
mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate,
snapshot_documentation: DEFAULT_SETTINGS.snapshot_documentation,
};
export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
@@ -44,6 +45,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.mesh_auto_recreate !== baseline.mesh_auto_recreate) n++;
if (settings.snapshot_documentation !== baseline.snapshot_documentation) n++;
return n;
}, [settings]);
@@ -73,6 +75,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: FleetMeshFields = {
mesh_auto_recreate: (nodeData.mesh_auto_recreate as '0' | '1') ?? DEFAULT_SETTINGS.mesh_auto_recreate,
snapshot_documentation: (nodeData.snapshot_documentation as '0' | '1') ?? DEFAULT_SETTINGS.snapshot_documentation,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
@@ -103,7 +106,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
return;
}
serverSettingsRef.current = { ...settings };
toast.success('Mesh settings saved.');
toast.success('Fleet settings saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
@@ -127,6 +130,18 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
</SettingsField>
</SettingsSection>
<SettingsSection title="Documentation snapshots">
<SettingsField
label="Capture stack documentation in snapshots"
helper="Preserve each stack's Dossier notes alongside its captured files. Restoring a stack never overwrites current notes unless you explicitly choose to. Off by default."
>
<TogglePill
checked={settings.snapshot_documentation === '1'}
onChange={(next) => onSettingChange('snapshot_documentation', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
@@ -84,13 +84,13 @@ describe('split section save payloads', () => {
expect(patchedKeys()).toEqual(['docker_janitor_gb', 'prune_on_update', 'reclaim_hero']);
});
it('FleetMeshSection patches only the mesh key', async () => {
it('FleetMeshSection patches only the fleet keys', async () => {
render(<FleetMeshSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getByRole('switch')); // mesh_auto_recreate
fireEvent.click(screen.getAllByRole('switch')[0]); // mesh_auto_recreate
fireEvent.click(save);
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
expect(patchedKeys()).toEqual(['mesh_auto_recreate']);
expect(patchedKeys()).toEqual(['mesh_auto_recreate', 'snapshot_documentation']);
});
it('DataRetentionSection patches only retention keys, never developer_mode', async () => {
+3 -3
View File
@@ -126,9 +126,9 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
{
id: 'fleet-mesh',
group: 'infrastructure',
label: 'Fleet Mesh',
description: 'Data-plane network behavior for the cross-node service mesh.',
keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh'],
label: 'Fleet',
description: 'Cross-node service-mesh data plane and fleet-snapshot documentation capture.',
keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh', 'snapshot', 'documentation', 'dossier'],
tier: null,
scope: 'node',
adminOnly: true,
@@ -14,6 +14,7 @@ export interface PatchableSettings {
scan_history_per_image_limit?: string;
prune_on_update?: '0' | '1';
reclaim_hero?: '0' | '1';
snapshot_documentation?: '0' | '1';
}
export const DEFAULT_SETTINGS: PatchableSettings = {
@@ -32,6 +33,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
scan_history_per_image_limit: '50',
prune_on_update: '1',
reclaim_hero: '1',
snapshot_documentation: '0',
};
export type SectionId =