diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx
index 5e7e460e..a7aae0bb 100644
--- a/docs/reference/settings.mdx
+++ b/docs/reference/settings.mdx
@@ -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.
+
diff --git a/frontend/src/components/settings/AppStoreSection.tsx b/frontend/src/components/settings/AppStoreSection.tsx
index 8780e6af..4f69e03a 100644
--- a/frontend/src/components/settings/AppStoreSection.tsx
+++ b/frontend/src/components/settings/AppStoreSection.tsx
@@ -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 = 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 ;
-
return (
+ }>
+
);
}
diff --git a/frontend/src/components/settings/ContainerAlertsSection.tsx b/frontend/src/components/settings/ContainerAlertsSection.tsx
index ef58e3e6..53bf90a0 100644
--- a/frontend/src/components/settings/ContainerAlertsSection.tsx
+++ b/frontend/src/components/settings/ContainerAlertsSection.tsx
@@ -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({ ...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 = 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 = (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 ;
-
return (
+ }>
+
);
}
diff --git a/frontend/src/components/settings/DataRetentionSection.tsx b/frontend/src/components/settings/DataRetentionSection.tsx
index 9e964187..9d850f76 100644
--- a/frontend/src/components/settings/DataRetentionSection.tsx
+++ b/frontend/src/components/settings/DataRetentionSection.tsx
@@ -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({ ...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 = 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 = (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 ;
-
return (
+ }>
+
);
}
diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx
index 235a9f26..411e0055 100644
--- a/frontend/src/components/settings/DeveloperSection.tsx
+++ b/frontend/src/components/settings/DeveloperSection.tsx
@@ -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({ ...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 = 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 = (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 ;
-
return (
+ }>
+
);
}
diff --git a/frontend/src/components/settings/DockerStorageSection.tsx b/frontend/src/components/settings/DockerStorageSection.tsx
index 0aa008fb..2e397344 100644
--- a/frontend/src/components/settings/DockerStorageSection.tsx
+++ b/frontend/src/components/settings/DockerStorageSection.tsx
@@ -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({ ...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 = 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 = (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 ;
-
return (
+ }>
+
);
}
diff --git a/frontend/src/components/settings/FleetMeshSection.tsx b/frontend/src/components/settings/FleetMeshSection.tsx
index 3120f634..0afa63db 100644
--- a/frontend/src/components/settings/FleetMeshSection.tsx
+++ b/frontend/src/components/settings/FleetMeshSection.tsx
@@ -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({ ...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 = 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 = (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 ;
-
return (
+ }>
+
);
}
diff --git a/frontend/src/components/settings/HostAlertsSection.tsx b/frontend/src/components/settings/HostAlertsSection.tsx
index 4a82cefa..eca51693 100644
--- a/frontend/src/components/settings/HostAlertsSection.tsx
+++ b/frontend/src/components/settings/HostAlertsSection.tsx
@@ -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({ ...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 = 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 = (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 ;
-
return (
+ }>
+
);
}
diff --git a/frontend/src/components/settings/SettingsLoadError.tsx b/frontend/src/components/settings/SettingsLoadError.tsx
new file mode 100644
index 00000000..05a92090
--- /dev/null
+++ b/frontend/src/components/settings/SettingsLoadError.tsx
@@ -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 (
+ }
+ 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 ;
+ return skeleton;
+}
diff --git a/frontend/src/components/settings/StacksSection.tsx b/frontend/src/components/settings/StacksSection.tsx
index de5cd603..7cf9b979 100644
--- a/frontend/src/components/settings/StacksSection.tsx
+++ b/frontend/src/components/settings/StacksSection.tsx
@@ -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({ ...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 = 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 = (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
- {isLoading ? (
-
- ) : (
+ }>
- )}
+
);
}
diff --git a/frontend/src/components/settings/__tests__/SettingsLoadFailures.test.tsx b/frontend/src/components/settings/__tests__/SettingsLoadFailures.test.tsx
new file mode 100644
index 00000000..7918bce7
--- /dev/null
+++ b/frontend/src/components/settings/__tests__/SettingsLoadFailures.test.tsx
@@ -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;
+const mockedToast = toast as unknown as { error: ReturnType; success: ReturnType };
+
+const FULL_SETTINGS: Record = {
+ 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 = {}) {
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+
+ // 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();
+
+ 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();
+ // Switch before A resolves.
+ activeNodeState.id = 2;
+ rerender();
+
+ // 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();
+ const save = await screen.findByRole('button', { name: /save settings/i });
+ fireEvent.click(screen.getByRole('switch'));
+ fireEvent.click(save);
+
+ activeNodeState.id = 2;
+ rerender();
+ // 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();
+ });
+});
diff --git a/frontend/src/components/settings/__tests__/fetchNodeSettings.test.ts b/frontend/src/components/settings/__tests__/fetchNodeSettings.test.ts
new file mode 100644
index 00000000..8f12a8cb
--- /dev/null
+++ b/frontend/src/components/settings/__tests__/fetchNodeSettings.test.ts
@@ -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;
+const mockedToast = toast as unknown as { error: ReturnType };
+
+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();
+ });
+});
diff --git a/frontend/src/components/settings/__tests__/useNodeSettingsLoad.test.ts b/frontend/src/components/settings/__tests__/useNodeSettingsLoad.test.ts
new file mode 100644
index 00000000..ec7b4252
--- /dev/null
+++ b/frontend/src/components/settings/__tests__/useNodeSettingsLoad.test.ts
@@ -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 };
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((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();
+ 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 | 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 | 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();
+ 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 | 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');
+ });
+});
diff --git a/frontend/src/components/settings/fetchNodeSettings.ts b/frontend/src/components/settings/fetchNodeSettings.ts
new file mode 100644
index 00000000..71c42823
--- /dev/null
+++ b/frontend/src/components/settings/fetchNodeSettings.ts
@@ -0,0 +1,46 @@
+import { apiFetch } from '@/lib/api';
+
+export type FetchNodeSettingsResult =
+ | { ok: true; settings: Record }
+ | { 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 {
+ 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 {
+ 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 };
+ }
+}
diff --git a/frontend/src/components/settings/useNodeSettingsLoad.ts b/frontend/src/components/settings/useNodeSettingsLoad.ts
new file mode 100644
index 00000000..cd2bf3ab
--- /dev/null
+++ b/frontend/src/components/settings/useNodeSettingsLoad.ts
@@ -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('loading');
+ const [loadedNodeId, setLoadedNodeId] = useState(undefined);
+ const genRef = useRef(0);
+ const abortRef = useRef(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 | 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,
+ };
+}