mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
710647a44f
* 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.
162 lines
6.4 KiB
TypeScript
162 lines
6.4 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { RefreshCw } from 'lucide-react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { DEFAULT_SETTINGS } from './types';
|
|
import type { PatchableSettings } from './types';
|
|
import { SettingsSection } from './SettingsSection';
|
|
import { SettingsField } from './SettingsField';
|
|
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
|
|
import { useMastheadStats } from './MastheadStatsContext';
|
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
|
|
|
interface FleetMeshSectionProps {
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
}
|
|
|
|
function SectionSkeleton() {
|
|
return (
|
|
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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) {
|
|
const { activeNode } = useNodes();
|
|
const { isAdmin } = useAuth();
|
|
const readOnly = !isAdmin;
|
|
const [settings, setSettings] = useState<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
|
|
const serverSettingsRef = useRef<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const dirtyCount = useMemo(() => {
|
|
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]);
|
|
|
|
const hasChanges = dirtyCount > 0;
|
|
|
|
useEffect(() => {
|
|
onDirtyChange?.(hasChanges);
|
|
}, [hasChanges, onDirtyChange]);
|
|
|
|
useMastheadStats(
|
|
isLoading
|
|
? null
|
|
: [
|
|
{
|
|
label: 'EDITED',
|
|
value: hasChanges ? `${dirtyCount} pending` : 'saved',
|
|
tone: hasChanges ? 'warn' : 'value',
|
|
},
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const fetchSettings = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const nodeRes = await apiFetch('/settings');
|
|
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 };
|
|
} catch (e) {
|
|
console.error('Failed to fetch fleet mesh settings', e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
fetchSettings();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeNode?.id]);
|
|
|
|
const onSettingChange = <K extends keyof FleetMeshFields>(key: K, value: FleetMeshFields[K]) => {
|
|
setSettings(prev => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const saveSettings = async () => {
|
|
setIsSaving(true);
|
|
try {
|
|
const res = await apiFetch('/settings', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(settings),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || 'Failed to save settings.');
|
|
return;
|
|
}
|
|
serverSettingsRef.current = { ...settings };
|
|
toast.success('Fleet settings saved.');
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || 'Something went wrong.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) return <SectionSkeleton />;
|
|
|
|
return (
|
|
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
|
|
<SettingsSection title="Mesh data plane">
|
|
<SettingsField
|
|
label="Auto-recreate mesh network"
|
|
helper="If sencho_mesh is removed at runtime, rebuild it at the same subnet on the next 10s tick. Off by default; leave off and restart Sencho manually for the safest path."
|
|
>
|
|
<TogglePill
|
|
checked={settings.mesh_auto_recreate === '1'}
|
|
onChange={(next) => onSettingChange('mesh_auto_recreate', next ? '1' : '0')}
|
|
/>
|
|
</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}>
|
|
{isSaving ? (
|
|
<>
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
Saving
|
|
</>
|
|
) : (
|
|
'Save settings'
|
|
)}
|
|
</SettingsPrimaryButton>
|
|
)}
|
|
</SettingsActions>
|
|
</fieldset>
|
|
);
|
|
}
|