mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user