fix(settings): toast and block save when node settings fail to load (#1654)

* fix(settings): toast and block save when node settings fail to load

Failed GET /settings no longer seeds defaults as the dirty baseline. Shared
loader pins node identity, shows an error callout until an authoritative load
succeeds, and keeps Save disabled across all eight node-scoped sections.

* test(settings): cover load-failure toast and App Store refresh pin

Remove the incorrect settings-load-failure docs image. Assert toast ownership for current vs stale/aborted loads, and pin App Store cache refresh to the PATCH node across a mid-save switch.

* fix(settings): reject malformed settings bodies and skip pre-node bootstrap load

Treat non-object 200 /settings payloads as load failures so null or array bodies cannot seed editable defaults. Do not fetch or toast while the active node id is still undefined, so hard refresh settles to a single failure toast.
This commit is contained in:
Anso
2026-07-20 21:40:35 -04:00
committed by GitHub
parent 090a0d73ac
commit ad00517a0e
15 changed files with 1086 additions and 217 deletions
+2
View File
@@ -51,6 +51,8 @@ Click **Filter** at the top of the sidebar, or press `Ctrl+K` / `⌘K` while the
For node-scoped sections the masthead replaces the **SCOPE** pill with a **NODE** pill showing the active node name. Switch the active node from the top bar's **Switch node** button to edit a different node's per-node settings.
If settings for the active node fail to load, Sencho shows an error and keeps Save unavailable until an authoritative load for that node succeeds.
<Frame>
<img src="/images/settings/settings-node-scope.png" alt="Host Alerts section showing the NODE pill in the masthead" />
</Frame>
@@ -5,10 +5,13 @@ import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import { useNodes } from '@/context/NodeContext';
import { RefreshCw } from 'lucide-react';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
function SectionSkeleton() {
return (
@@ -21,33 +24,30 @@ function SectionSkeleton() {
export function AppStoreSection() {
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const [templateRegistryUrl, setTemplateRegistryUrl] = useState('');
const serverUrl = useRef('');
const [isLoading, setIsLoading] = useState(false);
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSavingRegistry, setIsSavingRegistry] = useState(false);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const res = await apiFetch('/settings');
if (res.ok) {
const data: Record<string, string> = await res.json();
const url = data.template_registry_url ?? '';
setTemplateRegistryUrl(url);
serverUrl.current = url;
}
} catch (e) {
console.error('Failed to fetch app store settings', e);
} finally {
setIsLoading(false);
}
let cancelled = false;
setIsSavingRegistry(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const url = nodeData.template_registry_url ?? '';
setTemplateRegistryUrl(url);
serverUrl.current = url;
})();
return () => {
cancelled = true;
};
fetchSettings();
}, []);
}, [activeNode?.id, load]);
const saveRegistrySettings = async () => {
const saveGuard = captureSaveGuard();
const trimmedUrl = templateRegistryUrl.trim();
if (trimmedUrl && !/^https?:\/\/./.test(trimmedUrl)) {
toast.error('Registry URL must start with http:// or https://');
@@ -57,26 +57,38 @@ export function AppStoreSection() {
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify({ template_registry_url: trimmedUrl }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save registry settings.');
if (isSaveOwner(saveGuard)) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save registry settings.');
}
return;
}
// Always refresh the node that received the PATCH, even after a switch.
const refresh = await apiFetch('/templates/refresh-cache', {
method: 'POST',
nodeId: saveGuard.nodeId,
});
if (!isSaveOwner(saveGuard)) return;
if (!refresh.ok) {
toast.error('Registry saved, but refreshing the App Store cache failed.');
return;
}
serverUrl.current = templateRegistryUrl;
await apiFetch('/templates/refresh-cache', { method: 'POST' });
toast.success('Registry saved. App Store will reload from the new source.');
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Failed to save registry settings.');
} finally {
setIsSavingRegistry(false);
if (isSaveOwner(saveGuard)) setIsSavingRegistry(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Default registry">
<SettingsField
@@ -110,11 +122,11 @@ export function AppStoreSection() {
variant="outline"
size="sm"
onClick={() => setTemplateRegistryUrl('')}
disabled={isSavingRegistry || !templateRegistryUrl}
disabled={isSavingRegistry || !templateRegistryUrl || !isCurrentNodeLoaded}
>
Reset to default
</Button>
<SettingsPrimaryButton onClick={saveRegistrySettings} disabled={isSavingRegistry}>
<SettingsPrimaryButton onClick={saveRegistrySettings} disabled={isSavingRegistry || !isCurrentNodeLoaded}>
{isSavingRegistry ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -129,5 +141,6 @@ export function AppStoreSection() {
</SettingsActions>
</SettingsSection>
</fieldset>
</SettingsLoadGate>
);
}
@@ -13,6 +13,8 @@ import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
interface ContainerAlertsSectionProps {
onDirtyChange?: (dirty: boolean) => void;
@@ -37,15 +39,17 @@ export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSection
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<ContainerAlertFields>({ ...DEFAULT_CONTAINER_ALERTS });
const [isLoading, setIsLoading] = useState(false);
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
isLoading
!isCurrentNodeLoaded
? null
: [
{
@@ -57,37 +61,36 @@ export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSection
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: ContainerAlertFields = {
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch container alert settings', e);
} finally {
setIsLoading(false);
}
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const safe: ContainerAlertFields = {
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
};
reset(safe);
})();
return () => {
cancelled = true;
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
}, [activeNode?.id, load, reset]);
const onSettingChange = <K extends keyof ContainerAlertFields>(key: K, value: ContainerAlertFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const saveGuard = captureSaveGuard();
const submitted = { ...settings };
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify({ global_crash: submitted.global_crash }),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
@@ -96,15 +99,15 @@ export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSection
markSaved(submitted);
toast.success('Container alert settings saved.');
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Container crash & health alerts">
<SettingsField
@@ -120,7 +123,7 @@ export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSection
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -133,5 +136,6 @@ export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSection
)}
</SettingsActions>
</fieldset>
</SettingsLoadGate>
);
}
@@ -16,6 +16,8 @@ import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
import { TogglePill } from '@/components/ui/toggle-pill';
interface DataRetentionSectionProps {
@@ -48,15 +50,17 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<DataRetentionFields>({ ...DEFAULT_DATA_RETENTION });
const [isLoading, setIsLoading] = useState(false);
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
isLoading
!isCurrentNodeLoaded
? null
: [
{
@@ -68,34 +72,31 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DataRetentionFields = {
metrics_retention_hours: nodeData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: nodeData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: nodeData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: nodeData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit,
prune_orphaned_scans: (nodeData.prune_orphaned_scans as '0' | '1') ?? DEFAULT_SETTINGS.prune_orphaned_scans,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch data retention settings', e);
} finally {
setIsLoading(false);
}
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const safe: DataRetentionFields = {
metrics_retention_hours: nodeData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: nodeData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
audit_retention_days: nodeData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
scan_history_per_image_limit: nodeData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit,
prune_orphaned_scans: (nodeData.prune_orphaned_scans as '0' | '1') ?? DEFAULT_SETTINGS.prune_orphaned_scans,
};
reset(safe);
})();
return () => {
cancelled = true;
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
}, [activeNode?.id, load, reset]);
const onSettingChange = <K extends keyof DataRetentionFields>(key: K, value: DataRetentionFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const saveGuard = captureSaveGuard();
const submitted = { ...settings };
const payload: DataRetentionFields = {
metrics_retention_hours: submitted.metrics_retention_hours,
@@ -114,8 +115,10 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify(payload),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
@@ -127,15 +130,15 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
detail: { changedKeys: Object.keys(payload) },
}));
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Retention windows">
<SettingsField
@@ -221,7 +224,7 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -234,5 +237,6 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
)}
</SettingsActions>
</fieldset>
</SettingsLoadGate>
);
}
@@ -15,6 +15,8 @@ import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
interface DeveloperSectionProps {
onDirtyChange?: (dirty: boolean) => void;
@@ -39,15 +41,17 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
const { activeNode } = useNodes();
const readOnly = !isAdmin;
const { settings, setSettings, hasChanges, reset, markSaved } = useSettingsDirty<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const [isLoading, setIsLoading] = useState(false);
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
isLoading
!isCurrentNodeLoaded
? null
: [
{
@@ -59,30 +63,27 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch developer settings', e);
} finally {
setIsLoading(false);
}
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const safe: DeveloperFields = {
developer_mode: (nodeData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
};
reset(safe);
})();
return () => {
cancelled = true;
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
}, [activeNode?.id, load, reset]);
const onSettingChange = <K extends keyof DeveloperFields>(key: K, value: DeveloperFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const saveGuard = captureSaveGuard();
const submitted = { ...settings };
const payload = {
developer_mode: submitted.developer_mode,
@@ -91,8 +92,10 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify(payload),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
@@ -104,15 +107,15 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
detail: { changedKeys: Object.keys(payload) },
}));
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Diagnostics">
<SettingsField
@@ -129,7 +132,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? 'unsaved changes' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -142,5 +145,6 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
)}
</SettingsActions>
</fieldset>
</SettingsLoadGate>
);
}
@@ -12,6 +12,8 @@ import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
import { TogglePill } from '@/components/ui/toggle-pill';
import { NumberChip } from './SystemControls';
@@ -42,15 +44,17 @@ export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProp
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<DockerStorageFields>({ ...DEFAULT_DOCKER_STORAGE });
const [isLoading, setIsLoading] = useState(false);
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
isLoading
!isCurrentNodeLoaded
? null
: [
{
@@ -62,39 +66,38 @@ export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProp
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: DockerStorageFields = {
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
prune_on_update: (nodeData.prune_on_update as '0' | '1') ?? DEFAULT_SETTINGS.prune_on_update,
reclaim_hero: (nodeData.reclaim_hero as '0' | '1') ?? DEFAULT_SETTINGS.reclaim_hero,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch Docker & storage settings', e);
} finally {
setIsLoading(false);
}
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const safe: DockerStorageFields = {
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
prune_on_update: (nodeData.prune_on_update as '0' | '1') ?? DEFAULT_SETTINGS.prune_on_update,
reclaim_hero: (nodeData.reclaim_hero as '0' | '1') ?? DEFAULT_SETTINGS.reclaim_hero,
};
reset(safe);
})();
return () => {
cancelled = true;
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
}, [activeNode?.id, load, reset]);
const onSettingChange = <K extends keyof DockerStorageFields>(key: K, value: DockerStorageFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const saveGuard = captureSaveGuard();
const submitted = { ...settings };
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify(submitted),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
@@ -103,15 +106,15 @@ export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProp
markSaved(submitted);
toast.success('Docker & storage settings saved.');
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Storage alerts">
<SettingsField
@@ -152,7 +155,7 @@ export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProp
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -165,5 +168,6 @@ export function DockerStorageSection({ onDirtyChange }: DockerStorageSectionProp
)}
</SettingsActions>
</fieldset>
</SettingsLoadGate>
);
}
@@ -13,6 +13,8 @@ import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
import { TogglePill } from '@/components/ui/toggle-pill';
interface FleetMeshSectionProps {
@@ -42,15 +44,17 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
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 { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
isLoading
!isCurrentNodeLoaded
? null
: [
{
@@ -62,25 +66,21 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
);
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);
}
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
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);
})();
return () => {
cancelled = true;
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
}, [activeNode?.id, load, reset]);
const onSettingChange = <K extends keyof FleetMeshFields>(key: K, value: FleetMeshFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
@@ -90,6 +90,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
// 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 saveGuard = captureSaveGuard();
const submitted: FleetMeshFields | SnapshotOnlyFields = showMesh
? { ...settings }
: { snapshot_documentation: settings.snapshot_documentation };
@@ -97,8 +98,10 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify(submitted),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
@@ -114,15 +117,15 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
}
toast.success('Fleet settings saved.');
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
{showMesh && (
<SettingsSection title="Mesh data plane">
@@ -152,7 +155,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -165,5 +168,6 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) {
)}
</SettingsActions>
</fieldset>
</SettingsLoadGate>
);
}
@@ -12,6 +12,8 @@ import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
import { TogglePill } from '@/components/ui/toggle-pill';
import { NumberChip } from './SystemControls';
@@ -45,15 +47,17 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<HostAlertFields>({ ...DEFAULT_HOST_ALERTS });
const [isLoading, setIsLoading] = useState(false);
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
isLoading
!isCurrentNodeLoaded
? null
: [
{
@@ -65,41 +69,40 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: HostAlertFields = {
host_alerts_enabled: (nodeData.host_alerts_enabled as '0' | '1') ?? DEFAULT_SETTINGS.host_alerts_enabled,
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: nodeData.host_alert_suppression_mins ?? DEFAULT_SETTINGS.host_alert_suppression_mins,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch host alert settings', e);
} finally {
setIsLoading(false);
}
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const safe: HostAlertFields = {
host_alerts_enabled: (nodeData.host_alerts_enabled as '0' | '1') ?? DEFAULT_SETTINGS.host_alerts_enabled,
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
host_alert_suppression_mins: nodeData.host_alert_suppression_mins ?? DEFAULT_SETTINGS.host_alert_suppression_mins,
};
reset(safe);
})();
return () => {
cancelled = true;
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
}, [activeNode?.id, load, reset]);
const onSettingChange = <K extends keyof HostAlertFields>(key: K, value: HostAlertFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveSettings = async () => {
const saveGuard = captureSaveGuard();
const submitted = { ...settings };
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify(submitted),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
@@ -108,15 +111,15 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
markSaved(submitted);
toast.success('Host alert settings saved.');
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
if (isLoading) return <SectionSkeleton />;
return (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<SectionSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Host thresholds">
<SettingsField
@@ -187,7 +190,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -200,5 +203,6 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
)}
</SettingsActions>
</fieldset>
</SettingsLoadGate>
);
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import type { NodeSettingsLoadPhase } from './useNodeSettingsLoad';
/** Persistent error state when node settings failed to load for the active node. */
export function SettingsLoadError() {
return (
<SettingsCallout
tone="error"
icon={<AlertTriangle className="h-4 w-4" />}
title="Could not load settings"
subtitle="Settings for this node could not be loaded. Save stays unavailable until loading succeeds."
/>
);
}
/**
* Renders content only after an authoritative load for the active node.
* Mid-switch / in-flight loads show `skeleton`; a failed load shows SettingsLoadError.
*/
export function SettingsLoadGate({
phase,
isCurrentNodeLoaded,
skeleton,
children,
}: {
phase: NodeSettingsLoadPhase;
isCurrentNodeLoaded: boolean;
skeleton: ReactNode;
children: ReactNode;
}) {
if (isCurrentNodeLoaded) return children;
if (phase === 'error') return <SettingsLoadError />;
return skeleton;
}
@@ -17,6 +17,8 @@ import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { useSettingsDirty } from './useSettingsDirty';
import { useNodeSettingsLoad } from './useNodeSettingsLoad';
import { SettingsLoadGate } from './SettingsLoadError';
import { TogglePill } from '@/components/ui/toggle-pill';
import { NumberChip } from './SystemControls';
@@ -59,15 +61,17 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<GuardrailFields>({ ...DEFAULT_GUARDRAILS });
const [isLoading, setIsLoading] = useState(false);
const { phase, isCurrentNodeLoaded, load, isSaveOwner, captureSaveGuard } = useNodeSettingsLoad(activeNode?.id);
const [isSaving, setIsSaving] = useState(false);
const reportDirty = isCurrentNodeLoaded && hasChanges;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
onDirtyChange?.(reportDirty);
}, [reportDirty, onDirtyChange]);
useMastheadStats(
isLoading
!isCurrentNodeLoaded
? null
: [
{
@@ -79,40 +83,39 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const nodeRes = await apiFetch('/settings');
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const safe: GuardrailFields = {
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
auto_create_missing_external_networks: (nodeData.auto_create_missing_external_networks as '0' | '1') ?? DEFAULT_SETTINGS.auto_create_missing_external_networks,
};
reset(safe);
} catch (e) {
console.error('Failed to fetch deploy guardrail settings', e);
} finally {
setIsLoading(false);
}
let cancelled = false;
setIsSaving(false);
void (async () => {
const nodeData = await load();
if (cancelled || !nodeData) return;
const safe: GuardrailFields = {
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
auto_create_missing_external_networks: (nodeData.auto_create_missing_external_networks as '0' | '1') ?? DEFAULT_SETTINGS.auto_create_missing_external_networks,
};
reset(safe);
})();
return () => {
cancelled = true;
};
fetchSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNode?.id]);
}, [activeNode?.id, load, reset]);
const onGuardrailChange = <K extends keyof GuardrailFields>(key: K, value: GuardrailFields[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveGuardrails = async () => {
const saveGuard = captureSaveGuard();
const submitted = { ...settings };
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: saveGuard.nodeId,
body: JSON.stringify(submitted),
});
if (!isSaveOwner(saveGuard)) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
@@ -121,9 +124,10 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
markSaved(submitted);
toast.success('Deploy guardrail settings saved.');
} catch (e: unknown) {
if (!isSaveOwner(saveGuard)) return;
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
if (isSaveOwner(saveGuard)) setIsSaving(false);
}
};
@@ -187,9 +191,7 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
saved to this browser only · every device remembers its own choice
</p>
{isLoading ? (
<GuardrailSkeleton />
) : (
<SettingsLoadGate phase={phase} isCurrentNodeLoaded={isCurrentNodeLoaded} skeleton={<GuardrailSkeleton />}>
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
<SettingsSection title="Deploy Guardrails" kicker="this node">
<p className="pb-2 text-sm leading-relaxed text-stat-subtitle">
@@ -238,7 +240,7 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
{!readOnly && (
<SettingsPrimaryButton onClick={saveGuardrails} disabled={isSaving || !hasChanges}>
<SettingsPrimaryButton onClick={saveGuardrails} disabled={isSaving || !hasChanges || !isCurrentNodeLoaded}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -251,7 +253,7 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
)}
</SettingsActions>
</fieldset>
)}
</SettingsLoadGate>
</div>
);
}
@@ -0,0 +1,388 @@
/**
* Load-failure and node-ownership guards for node-scoped settings sections.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import type { ComponentType } from 'react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true }));
vi.mock('@/hooks/useExperimental', () => ({
useExperimental: () => useExperimentalMock(),
}));
const activeNodeState = { id: 1 as number };
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ activeNode: { id: activeNodeState.id } }),
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { HostAlertsSection } from '../HostAlertsSection';
import { ContainerAlertsSection } from '../ContainerAlertsSection';
import { DockerStorageSection } from '../DockerStorageSection';
import { FleetMeshSection } from '../FleetMeshSection';
import { DataRetentionSection } from '../DataRetentionSection';
import { DeveloperSection } from '../DeveloperSection';
import { StacksSection } from '../StacksSection';
import { AppStoreSection } from '../AppStoreSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn>; success: ReturnType<typeof vi.fn> };
const FULL_SETTINGS: Record<string, string> = {
host_cpu_limit: '90',
host_ram_limit: '90',
host_disk_limit: '90',
host_alert_suppression_mins: '60',
host_alerts_enabled: '1',
global_crash: '1',
docker_janitor_gb: '5',
prune_on_update: '1',
reclaim_hero: '1',
mesh_auto_recreate: '0',
snapshot_documentation: '0',
metrics_retention_hours: '24',
log_retention_days: '30',
audit_retention_days: '90',
scan_history_per_image_limit: '50',
prune_orphaned_scans: '1',
developer_mode: '0',
health_gate_enabled: '1',
health_gate_window_seconds: '90',
env_block_deploy_on_missing_required: '0',
auto_create_missing_external_networks: '0',
template_registry_url: 'https://example.com/templates.json',
};
function okSettings(extra: Record<string, string> = {}) {
return { ok: true, json: async () => ({ ...FULL_SETTINGS, ...extra }) };
}
function failSettings(status = 502) {
return { ok: false, status, json: async () => ({ error: 'unavailable' }) };
}
function patchCalls() {
return mockedFetch.mock.calls.filter((c) => c[1]?.method === 'PATCH');
}
function refreshCacheCalls() {
return mockedFetch.mock.calls.filter(
(c) => typeof c[0] === 'string' && c[0].includes('refresh-cache'),
);
}
function expectFailedLoadToast() {
expect(mockedToast.error).toHaveBeenCalledTimes(1);
expect(mockedToast.error).toHaveBeenCalledWith('Failed to load settings.');
}
async function waitForActiveNodeFetch(nodeId: number) {
await waitFor(() => {
expect(mockedFetch.mock.calls.some((c) => c[1]?.nodeId === nodeId && c[1]?.method !== 'PATCH')).toBe(true);
});
}
beforeEach(() => {
activeNodeState.id = 1;
mockedFetch.mockReset();
mockedToast.error.mockReset();
mockedToast.success.mockReset();
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
interface DirtyCase {
name: string;
Section: ComponentType<{ onDirtyChange?: (d: boolean) => void }>;
saveName: RegExp;
edit: () => void;
}
const dirtyCases: DirtyCase[] = [
{
name: 'HostAlertsSection',
Section: HostAlertsSection,
saveName: /save alerts/i,
edit: () => {
fireEvent.click(screen.getAllByRole('button', { name: /90\s*%/i })[0]);
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '95' } });
fireEvent.blur(screen.getByRole('spinbutton'));
},
},
{
name: 'ContainerAlertsSection',
Section: ContainerAlertsSection,
saveName: /save settings/i,
edit: () => fireEvent.click(screen.getByRole('switch')),
},
{
name: 'DockerStorageSection',
Section: DockerStorageSection,
saveName: /save settings/i,
edit: () => fireEvent.click(screen.getAllByRole('switch')[0]),
},
{
name: 'FleetMeshSection',
Section: FleetMeshSection,
saveName: /save settings/i,
edit: () => fireEvent.click(screen.getAllByRole('switch')[0]),
},
{
name: 'DataRetentionSection',
Section: DataRetentionSection,
saveName: /save settings/i,
edit: () => fireEvent.change(screen.getAllByRole('spinbutton')[0], { target: { value: '48' } }),
},
{
name: 'DeveloperSection',
Section: DeveloperSection,
saveName: /save settings/i,
edit: () => fireEvent.click(screen.getByRole('switch')),
},
{
name: 'StacksSection',
Section: StacksSection,
saveName: /save settings/i,
edit: () => fireEvent.click(screen.getAllByRole('switch')[0]),
},
];
describe('settings load failures (dirty sections)', () => {
for (const c of dirtyCases) {
it(`${c.name}: failed load shows error, toasts once, blocks save, and never PATCHes`, async () => {
mockedFetch.mockResolvedValue(failSettings());
const onDirty = vi.fn();
render(<c.Section onDirtyChange={onDirty} />);
await waitFor(() => {
expect(screen.getByText(/could not load settings/i)).toBeTruthy();
});
expectFailedLoadToast();
expect(screen.queryByRole('button', { name: c.saveName })).toBeNull();
expect(patchCalls()).toHaveLength(0);
expect(onDirty).not.toHaveBeenCalledWith(true);
});
it(`${c.name}: successful load recovers and allows save after edit`, async () => {
mockedFetch.mockResolvedValue(okSettings());
render(<c.Section />);
const save = await screen.findByRole('button', { name: c.saveName });
expect(save).toBeDisabled();
c.edit();
await waitFor(() => expect(save).not.toBeDisabled());
fireEvent.click(save);
await waitFor(() => expect(patchCalls().length).toBeGreaterThan(0));
const opts = patchCalls()[0][1] as { nodeId?: number | null };
expect(opts.nodeId).toBe(1);
});
}
it('HostAlertsSection: malformed 200 body fails closed instead of seeding defaults', async () => {
mockedFetch.mockResolvedValue({ ok: true, json: async () => null });
const onDirty = vi.fn();
render(<HostAlertsSection onDirtyChange={onDirty} />);
await waitFor(() => {
expect(screen.getByText(/could not load settings/i)).toBeTruthy();
});
expectFailedLoadToast();
expect(screen.queryByRole('button', { name: /save alerts/i })).toBeNull();
expect(screen.queryByRole('spinbutton')).toBeNull();
expect(patchCalls()).toHaveLength(0);
expect(onDirty).not.toHaveBeenCalledWith(true);
});
});
describe('AppStoreSection load failures', () => {
it('failed load shows error, toasts once, and keeps Save disabled with no PATCH', async () => {
mockedFetch.mockResolvedValue(failSettings());
render(<AppStoreSection />);
await waitFor(() => {
expect(screen.getByText(/could not load settings/i)).toBeTruthy();
});
expectFailedLoadToast();
expect(screen.queryByRole('button', { name: /save & refresh/i })).toBeNull();
expect(patchCalls()).toHaveLength(0);
});
it('successful load shows registry URL and pins save nodeId', async () => {
mockedFetch.mockImplementation((url: string, opts?: { method?: string; nodeId?: number | null }) => {
if (opts?.method === 'PATCH') {
return Promise.resolve({ ok: true, json: async () => ({}) });
}
if (typeof url === 'string' && url.includes('refresh-cache')) {
return Promise.resolve({ ok: true, json: async () => ({}) });
}
return Promise.resolve(okSettings());
});
render(<AppStoreSection />);
const input = await screen.findByLabelText(/registry url/i);
expect((input as HTMLInputElement).value).toBe('https://example.com/templates.json');
fireEvent.change(input, { target: { value: 'https://example.com/other.json' } });
const save = screen.getByRole('button', { name: /save & refresh/i });
fireEvent.click(save);
await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect((patchCalls()[0][1] as { nodeId?: number | null }).nodeId).toBe(1);
});
it('refreshes App Store cache on the PATCH node after a mid-save switch', async () => {
let resolvePatch: ((v: unknown) => void) | undefined;
mockedFetch.mockImplementation((url: string, opts?: { method?: string; nodeId?: number | null }) => {
if (opts?.method === 'PATCH') {
return new Promise((resolve) => {
resolvePatch = resolve;
});
}
if (typeof url === 'string' && url.includes('refresh-cache')) {
return Promise.resolve({ ok: true, json: async () => ({}) });
}
if (opts?.nodeId === 2) {
return Promise.resolve(okSettings({ template_registry_url: 'https://b.example.com/templates.json' }));
}
return Promise.resolve(okSettings());
});
const { rerender } = render(<AppStoreSection />);
const input = await screen.findByLabelText(/registry url/i);
fireEvent.change(input, { target: { value: 'https://example.com/other.json' } });
fireEvent.click(screen.getByRole('button', { name: /save & refresh/i }));
await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect((patchCalls()[0][1] as { nodeId?: number | null }).nodeId).toBe(1);
activeNodeState.id = 2;
rerender(<AppStoreSection />);
await waitForActiveNodeFetch(2);
mockedToast.success.mockClear();
mockedToast.error.mockClear();
await act(async () => {
resolvePatch?.({ ok: true, json: async () => ({}) });
});
await waitFor(() => expect(refreshCacheCalls()).toHaveLength(1));
expect((refreshCacheCalls()[0][1] as { nodeId?: number | null }).nodeId).toBe(1);
expect(mockedToast.success).not.toHaveBeenCalled();
// Node B's form must not adopt A's edited URL from the stale save completion.
const bInput = await screen.findByLabelText(/registry url/i);
expect((bInput as HTMLInputElement).value).toBe('https://b.example.com/templates.json');
});
});
describe('node ownership races', () => {
it('does not attribute node A dirty state to node B after B load fails', async () => {
let resolveA: ((v: unknown) => void) | undefined;
mockedFetch.mockImplementation((_url: string, opts?: { nodeId?: number | null }) => {
if (opts?.nodeId === 1) {
return new Promise((resolve) => {
resolveA = resolve;
});
}
return Promise.resolve(failSettings());
});
const onDirty = vi.fn();
const { rerender } = render(<HostAlertsSection onDirtyChange={onDirty} />);
// Finish A successfully, then dirty it.
await act(async () => {
resolveA?.(okSettings({ host_cpu_limit: '70' }));
});
const saveA = await screen.findByRole('button', { name: /save alerts/i });
fireEvent.click(screen.getAllByRole('button', { name: /70\s*%/i })[0]);
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '75' } });
fireEvent.blur(screen.getByRole('spinbutton'));
await waitFor(() => expect(onDirty).toHaveBeenCalledWith(true));
expect(saveA).not.toBeDisabled();
activeNodeState.id = 2;
onDirty.mockClear();
rerender(<HostAlertsSection onDirtyChange={onDirty} />);
await waitFor(() => {
expect(screen.getByText(/could not load settings/i)).toBeTruthy();
});
expect(screen.queryByRole('button', { name: /save alerts/i })).toBeNull();
expect(screen.queryByDisplayValue('75')).toBeNull();
expect(onDirty).not.toHaveBeenCalledWith(true);
expect(patchCalls()).toHaveLength(0);
});
it('keeps node B loading while a stale node A response settles', async () => {
let resolveA: ((v: unknown) => void) | undefined;
let resolveB: ((v: unknown) => void) | undefined;
mockedFetch.mockImplementation((_url: string, opts?: { nodeId?: number | null }) => {
if (opts?.nodeId === 1) {
return new Promise((resolve) => {
resolveA = resolve;
});
}
return new Promise((resolve) => {
resolveB = resolve;
});
});
const { rerender } = render(<HostAlertsSection />);
// Switch before A resolves.
activeNodeState.id = 2;
rerender(<HostAlertsSection />);
// Stale A finishes with success; B must still be loading (skeleton, no form/error yet).
await act(async () => {
resolveA?.(okSettings({ host_cpu_limit: '11' }));
});
expect(screen.queryByRole('button', { name: /save alerts/i })).toBeNull();
expect(screen.queryByText(/could not load settings/i)).toBeNull();
await act(async () => {
resolveB?.(okSettings({ host_cpu_limit: '22' }));
});
await screen.findByRole('button', { name: /save alerts/i });
// Form shows B's value, not A's.
expect(screen.getAllByRole('button', { name: /22\s*%/i }).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: /11\s*%/i })).toBeNull();
});
it('suppresses stale save toasts after switching nodes', async () => {
let resolvePatch: ((v: unknown) => void) | undefined;
mockedFetch.mockImplementation((_url: string, opts?: { method?: string; nodeId?: number | null }) => {
if (opts?.method === 'PATCH') {
return new Promise((resolve) => {
resolvePatch = resolve;
});
}
if (opts?.nodeId === 2) {
return Promise.resolve(okSettings({ host_cpu_limit: '50' }));
}
return Promise.resolve(okSettings({ host_cpu_limit: '90' }));
});
const { rerender } = render(<DeveloperSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
fireEvent.click(screen.getByRole('switch'));
fireEvent.click(save);
activeNodeState.id = 2;
rerender(<DeveloperSection />);
// Allow node B's load effect to run; do not require a dirty Save button.
await waitForActiveNodeFetch(2);
mockedToast.success.mockClear();
await act(async () => {
resolvePatch?.({ ok: true, json: async () => ({}) });
});
expect(mockedToast.success).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { fetchNodeSettings } from '../fetchNodeSettings';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockedFetch.mockReset();
mockedToast.error.mockReset();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('fetchNodeSettings', () => {
it('returns settings on success and passes nodeId', async () => {
const settings = { host_cpu_limit: '80' };
mockedFetch.mockResolvedValue({ ok: true, json: async () => settings });
const result = await fetchNodeSettings(7);
expect(result).toEqual({ ok: true, settings });
expect(mockedFetch).toHaveBeenCalledWith('/settings', expect.objectContaining({ nodeId: 7 }));
expect(mockedToast.error).not.toHaveBeenCalled();
});
it('treats a successful empty object as success', async () => {
mockedFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
const result = await fetchNodeSettings(1);
expect(result).toEqual({ ok: true, settings: {} });
expect(mockedToast.error).not.toHaveBeenCalled();
});
it.each([null, [], 'nope', 42, true])(
'returns ok false without toasting for malformed 200 body %j',
async (body) => {
mockedFetch.mockResolvedValue({ ok: true, json: async () => body });
const result = await fetchNodeSettings(1);
expect(result).toEqual({ ok: false });
expect(mockedToast.error).not.toHaveBeenCalled();
},
);
it('returns ok false without toasting on non-ok response', async () => {
mockedFetch.mockResolvedValue({ ok: false, status: 502, json: async () => ({}) });
const result = await fetchNodeSettings(2);
expect(result).toEqual({ ok: false });
expect(mockedToast.error).not.toHaveBeenCalled();
});
it('returns ok false without toasting when fetch rejects', async () => {
mockedFetch.mockRejectedValue(new Error('network down'));
const result = await fetchNodeSettings(null);
expect(result).toEqual({ ok: false });
expect(mockedToast.error).not.toHaveBeenCalled();
});
it('returns ok false without toasting when JSON parsing fails', async () => {
mockedFetch.mockResolvedValue({
ok: true,
json: async () => {
throw new SyntaxError('bad json');
},
});
const result = await fetchNodeSettings(3);
expect(result).toEqual({ ok: false });
expect(mockedToast.error).not.toHaveBeenCalled();
});
it('returns ok false without toast when aborted', async () => {
const controller = new AbortController();
mockedFetch.mockImplementation((_url: string, opts?: { signal?: AbortSignal }) => {
return new Promise((_resolve, reject) => {
opts?.signal?.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
});
});
});
const pending = fetchNodeSettings(4, controller.signal);
controller.abort();
await expect(pending).resolves.toEqual({ ok: false });
expect(mockedToast.error).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,175 @@
/**
* Toast ownership and generation guards for useNodeSettingsLoad.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
const fetchNodeSettingsMock = vi.fn();
vi.mock('../fetchNodeSettings', () => ({
fetchNodeSettings: (...args: unknown[]) => fetchNodeSettingsMock(...args),
}));
import { toast } from '@/components/ui/toast-store';
import { useNodeSettingsLoad } from '../useNodeSettingsLoad';
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
beforeEach(() => {
fetchNodeSettingsMock.mockReset();
mockedToast.error.mockReset();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useNodeSettingsLoad toast ownership', () => {
it('toasts once on a current non-OK load failure', async () => {
fetchNodeSettingsMock.mockResolvedValue({ ok: false });
const { result } = renderHook(() => useNodeSettingsLoad(1));
const settings = await act(() => result.current.load());
expect(settings).toBeNull();
expect(result.current.phase).toBe('error');
expect(mockedToast.error).toHaveBeenCalledTimes(1);
expect(mockedToast.error).toHaveBeenCalledWith('Failed to load settings.');
});
it('does not toast when a newer load supersedes a failing one', async () => {
const first = deferred<unknown>();
let call = 0;
fetchNodeSettingsMock.mockImplementation(() => {
call += 1;
if (call === 1) return first.promise;
return Promise.resolve({ ok: true, settings: { developer_mode: '0' } });
});
const { result } = renderHook(() => useNodeSettingsLoad(1));
let firstPromise: Promise<Record<string, string> | null>;
act(() => {
firstPromise = result.current.load();
});
await act(async () => {
await result.current.load();
});
mockedToast.error.mockClear();
await act(async () => {
first.resolve({ ok: false });
await firstPromise!;
});
expect(mockedToast.error).not.toHaveBeenCalled();
expect(result.current.phase).toBe('ready');
});
it('does not toast when the current load was aborted', async () => {
fetchNodeSettingsMock.mockImplementation((_nodeId: number | null, signal: AbortSignal) => {
return new Promise((resolve) => {
const finish = () => resolve({ ok: false });
if (signal.aborted) {
finish();
return;
}
signal.addEventListener('abort', finish, { once: true });
});
});
const { result, unmount } = renderHook(() => useNodeSettingsLoad(1));
let loadPromise: Promise<Record<string, string> | null>;
act(() => {
loadPromise = result.current.load();
});
unmount();
await act(async () => {
await loadPromise!;
});
expect(mockedToast.error).not.toHaveBeenCalled();
});
it('does not toast when a stale failure settles after a node switch', async () => {
const loadA = deferred<unknown>();
fetchNodeSettingsMock.mockImplementation((nodeId: number | null) => {
if (nodeId === 1) return loadA.promise;
return Promise.resolve({ ok: true, settings: {} });
});
const { result, rerender } = renderHook(
({ id }: { id: number | undefined }) => useNodeSettingsLoad(id),
{ initialProps: { id: 1 as number | undefined } },
);
let promiseA: Promise<Record<string, string> | null>;
act(() => {
promiseA = result.current.load();
});
rerender({ id: 2 });
await act(async () => {
await result.current.load();
});
mockedToast.error.mockClear();
await act(async () => {
loadA.resolve({ ok: false });
await promiseA!;
});
expect(mockedToast.error).not.toHaveBeenCalled();
await waitFor(() => expect(result.current.phase).toBe('ready'));
});
it('does not fetch or toast while the active node id is still undefined', async () => {
const { result } = renderHook(() => useNodeSettingsLoad(undefined));
const settings = await act(() => result.current.load());
expect(settings).toBeNull();
expect(result.current.phase).toBe('loading');
expect(fetchNodeSettingsMock).not.toHaveBeenCalled();
expect(mockedToast.error).not.toHaveBeenCalled();
});
it('toasts only once when bootstrap settles from undefined to a failing node', async () => {
fetchNodeSettingsMock.mockResolvedValue({ ok: false });
const { result, rerender } = renderHook(
({ id }: { id: number | undefined }) => useNodeSettingsLoad(id),
{ initialProps: { id: undefined as number | undefined } },
);
await act(async () => {
await result.current.load();
});
expect(fetchNodeSettingsMock).not.toHaveBeenCalled();
expect(mockedToast.error).not.toHaveBeenCalled();
rerender({ id: 1 });
await act(async () => {
await result.current.load();
});
expect(fetchNodeSettingsMock).toHaveBeenCalledTimes(1);
expect(mockedToast.error).toHaveBeenCalledTimes(1);
expect(mockedToast.error).toHaveBeenCalledWith('Failed to load settings.');
expect(result.current.phase).toBe('error');
});
});
@@ -0,0 +1,46 @@
import { apiFetch } from '@/lib/api';
export type FetchNodeSettingsResult =
| { ok: true; settings: Record<string, string> }
| { ok: false };
function isAbort(err: unknown, signal?: AbortSignal): boolean {
return Boolean(signal?.aborted)
|| (err instanceof DOMException && err.name === 'AbortError');
}
/** Authoritative /settings bodies are plain objects; null and arrays must not seed defaults. */
export function isSettingsRecord(value: unknown): value is Record<string, string> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/**
* Load node-scoped settings for an explicitly captured node.
* Does not toast: callers that own UI generation (useNodeSettingsLoad) decide
* whether a failure is still current before notifying the operator.
* Abort returns `{ ok: false }` without logging as a hard failure.
*/
export async function fetchNodeSettings(
nodeId: number | null,
signal?: AbortSignal,
): Promise<FetchNodeSettingsResult> {
try {
const res = await apiFetch('/settings', { nodeId, signal });
if (signal?.aborted) return { ok: false };
if (!res.ok) {
console.error('Failed to load settings:', res.status);
return { ok: false };
}
const body: unknown = await res.json();
if (signal?.aborted) return { ok: false };
if (!isSettingsRecord(body)) {
console.error('Failed to load settings: malformed body');
return { ok: false };
}
return { ok: true, settings: body };
} catch (err) {
if (isAbort(err, signal)) return { ok: false };
console.error('Failed to load settings:', err);
return { ok: false };
}
}
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from '@/components/ui/toast-store';
import { fetchNodeSettings } from './fetchNodeSettings';
export type NodeSettingsLoadPhase = 'loading' | 'ready' | 'error';
export interface SettingsSaveGuard {
nodeId: number | null;
gen: number;
}
/**
* Race-safe load ownership for node-scoped settings sections.
* Clears ownership on node switch, ignores stale responses for value adoption
* and loading finalization, and exposes whether the active node has an
* authoritative load.
*/
export function useNodeSettingsLoad(activeNodeId: number | undefined) {
const [phase, setPhase] = useState<NodeSettingsLoadPhase>('loading');
const [loadedNodeId, setLoadedNodeId] = useState<number | null | undefined>(undefined);
const genRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const activeNodeIdRef = useRef(activeNodeId);
activeNodeIdRef.current = activeNodeId;
const captureSaveGuard = useCallback((): SettingsSaveGuard => ({
nodeId: activeNodeIdRef.current ?? null,
gen: genRef.current,
}), []);
const isCurrentNodeLoaded =
loadedNodeId !== undefined && loadedNodeId === (activeNodeId ?? null);
const isSaveOwner = useCallback((guard: SettingsSaveGuard): boolean => {
return (activeNodeIdRef.current ?? null) === guard.nodeId
&& genRef.current === guard.gen;
}, []);
const load = useCallback(async (): Promise<Record<string, string> | null> => {
// Hard refresh boots with activeNode=null; a fetch for that frame is not
// authoritative and would toast again once the real node id settles.
if (activeNodeIdRef.current === undefined) {
abortRef.current?.abort();
setLoadedNodeId(undefined);
setPhase('loading');
return null;
}
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
const gen = ++genRef.current;
const captured = activeNodeIdRef.current ?? null;
setLoadedNodeId(undefined);
setPhase('loading');
const result = await fetchNodeSettings(captured, ac.signal);
// Stale generation: leave the newer load's phase alone; do not toast.
if (genRef.current !== gen) return null;
if (!result.ok) {
if (ac.signal.aborted) return null;
toast.error('Failed to load settings.');
setPhase('error');
return null;
}
setLoadedNodeId(captured);
setPhase('ready');
return result.settings;
}, []);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
return {
phase,
isCurrentNodeLoaded,
loadedNodeId,
load,
isSaveOwner,
captureSaveGuard,
};
}