mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user