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
@@ -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>
);
}