mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
0cd03c6f87
Gate Routing, Secrets, Host Console, and Mesh dashboard/settings surfaces on the existing useExperimental readiness flag so immature operator surfaces stay out of the default UI while paid and admin backend gates remain unchanged.
170 lines
7.0 KiB
TypeScript
170 lines
7.0 KiB
TypeScript
import { useState, useEffect } 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 { useExperimental } from '@/hooks/useExperimental';
|
|
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 { useSettingsDirty } from './useSettingsDirty';
|
|
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'>;
|
|
type SnapshotOnlyFields = Pick<PatchableSettings, '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 { experimental, experimentalReady } = useExperimental();
|
|
const showMesh = experimentalReady && experimental;
|
|
const readOnly = !isAdmin;
|
|
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<FleetMeshFields>({ ...DEFAULT_FLEET_MESH });
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
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,
|
|
};
|
|
reset(safe);
|
|
} catch (e) {
|
|
console.error('Failed to fetch fleet 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 () => {
|
|
// When Mesh discovery is off, never write mesh_auto_recreate: a failed
|
|
// settings read would otherwise push the default and overwrite a real
|
|
// Mesh config the operator cannot see.
|
|
const submitted: FleetMeshFields | SnapshotOnlyFields = showMesh
|
|
? { ...settings }
|
|
: { snapshot_documentation: settings.snapshot_documentation };
|
|
setIsSaving(true);
|
|
try {
|
|
const res = await apiFetch('/settings', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(submitted),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || 'Failed to save settings.');
|
|
return;
|
|
}
|
|
if (showMesh) {
|
|
markSaved(submitted as FleetMeshFields);
|
|
} else {
|
|
markSaved({
|
|
...settings,
|
|
snapshot_documentation: (submitted as SnapshotOnlyFields).snapshot_documentation,
|
|
});
|
|
}
|
|
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">
|
|
{showMesh && (
|
|
<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>
|
|
);
|
|
}
|