Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/15583-surface-failed-settings-loads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(compression):** the Compression Hub, the combos manager and the RTK page show a Retry when `GET /api/settings/compression` fails, and keep the controls that depend on the loaded settings off screen until a GET succeeds, so a failed load can no longer render defaults as the stored settings ([#15583](https://github.com/diegosouzapw/OmniRoute/pull/15583)) — thanks @woodsonl
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const ENGINE_INTENSITIES: Record<string, readonly string[]> = STACKED_PIPELINE_E

function NamedCombosManager() {
const t = useTranslations("contextCombos");
const tSettings = useTranslations("settings");
const tCommon = useTranslations("common");
const [combos, setCombos] = useState<CompressionCombo[]>([]);
const [routingCombos, setRoutingCombos] = useState<RoutingCombo[]>([]);
const [languagePacks, setLanguagePacks] = useState<LanguagePack[]>([]);
Expand All @@ -54,6 +56,12 @@ function NamedCombosManager() {
const [activeComboId, setActiveComboId] = useState<string | null>(null);
const [compressionEnabled, setCompressionEnabled] = useState(false);
const [error, setError] = useState<string | null>(null);
// The defaults (activeComboId null, compressionEnabled false) suppress the
// master-switch warning, hide the Active badge and disable the override selects,
// so a failed settings GET must show a retry instead. A retry re-runs the loads;
// answers that arrive for the run it replaced are ignored.
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);

const refresh = () => {
fetch("/api/context/combos")
Expand All @@ -63,23 +71,37 @@ function NamedCombosManager() {
};

useEffect(() => {
let ignore = false;
refresh();
fetch("/api/combos")
.then((res) => (res.ok ? res.json() : null))
.then((data) => setRoutingCombos(Array.isArray(data?.combos) ? data.combos : []))
.then((data) => {
if (!ignore) setRoutingCombos(Array.isArray(data?.combos) ? data.combos : []);
})
.catch(() => {});
fetch("/api/compression/language-packs")
.then((res) => (res.ok ? res.json() : null))
.then((data) => setLanguagePacks(Array.isArray(data?.packs) ? data.packs : []))
.then((data) => {
if (!ignore) setLanguagePacks(Array.isArray(data?.packs) ? data.packs : []);
})
.catch(() => {});
fetch("/api/settings/compression")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
setActiveComboId(data?.activeComboId ?? null);
setCompressionEnabled(Boolean(data?.enabled));
if (ignore) return;
if (data) {
setActiveComboId(data.activeComboId ?? null);
setCompressionEnabled(Boolean(data.enabled));
}
setLoadFailed(!data);
})
.catch(() => {});
}, []);
.catch(() => {
if (!ignore) setLoadFailed(true);
});
return () => {
ignore = true;
};
}, [loadAttempt]);

const resetForm = () => {
setEditingId(null);
Expand Down Expand Up @@ -180,6 +202,23 @@ function NamedCombosManager() {
);
};

if (loadFailed) {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-surface p-4">
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{tSettings("compressionTitle")}: {tCommon("failedToLoad")}
</p>
<button
type="button"
onClick={() => setLoadAttempt((attempt) => attempt + 1)}
className="shrink-0 rounded-lg border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg"
>
{tSettings("retry")}
</button>
</div>
);
}

return (
<div className="flex flex-col gap-4">
<div>
Expand Down
34 changes: 31 additions & 3 deletions src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,16 @@ function Toggle({

export default function CompressionHub() {
const t = useTranslations("contextCombos");
const tSettings = useTranslations("settings");
const tCommon = useTranslations("common");
const [settings, setSettings] = useState<CompressionSettings | null>(null);
const [combos, setCombos] = useState<NamedCombo[]>([]);
const [loading, setLoading] = useState(true);
// A failed settings load must not show the default-profile view: its select and
// Context Editing toggle would save the defaults over the stored row. A retry
// re-runs the load; answers that arrive for the run it replaced are ignored.
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [explainerOpen, setExplainerOpen] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -87,9 +94,8 @@ export default function CompressionHub() {
if (cancelled) return;
if (settingsData) {
setSettings(settingsData as CompressionSettings);
} else {
setSettings({ enabled: false, defaultMode: "off", contextEditing: { enabled: false } });
}
setLoadFailed(!settingsData);
if (Array.isArray(combosData?.combos)) {
setCombos(combosData.combos as NamedCombo[]);
}
Expand All @@ -99,7 +105,7 @@ export default function CompressionHub() {
return () => {
cancelled = true;
};
}, []);
}, [loadAttempt]);

// ── Settings mutations ───────────────────────────────────────────────────────
const saveSettings = useCallback(
Expand Down Expand Up @@ -140,6 +146,28 @@ export default function CompressionHub() {
);
}

if (loadFailed) {
return (
<section className="flex flex-col gap-5 rounded-xl border border-primary/30 bg-surface p-5">
<div className="flex items-center justify-between gap-4">
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{tSettings("compressionTitle")}: {tCommon("failedToLoad")}
</p>
<button
type="button"
onClick={() => {
setLoading(true);
setLoadAttempt((attempt) => attempt + 1);
}}
className="shrink-0 rounded-lg border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg"
>
{tSettings("retry")}
</button>
</div>
</section>
);
}

const activeCombo = combos.find((c) => c.id === settings?.activeComboId) ?? null;
const activePipelineText = activeCombo
? activeCombo.pipeline.map((s) => s.engine).join(" → ")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ function formatNumber(value: number | undefined): string {

export default function RtkContextPageClient() {
const t = useTranslations("contextRtk");
const tSettings = useTranslations("settings");
const tCommon = useTranslations("common");
const [filters, setFilters] = useState<RtkFilter[]>([]);
const [config, setConfig] = useState<RtkConfig | null>(null);
const [analytics, setAnalytics] = useState<AnalyticsSummary | null>(null);
Expand All @@ -71,13 +73,28 @@ export default function RtkContextPageClient() {
const [saving, setSaving] = useState(false);
const [viewMode, setViewMode] = useState<"simple" | "advanced">("simple");
const [masterEnabled, setMasterEnabled] = useState<boolean | null>(null);
// The stored master flag decides the "master switch is OFF" banner, so a failed
// settings GET must not read as off. A retry re-runs the load; answers that arrive
// for the run it replaced are ignored.
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);

useEffect(() => {
let ignore = false;
fetch("/api/settings/compression")
.then((res) => (res.ok ? res.json() : null))
.then((data) => setMasterEnabled(data?.enabled ?? false))
.catch(() => {});
}, []);
.then((data) => {
if (ignore) return;
if (data) setMasterEnabled(Boolean(data.enabled));
setLoadFailed(!data);
})
.catch(() => {
if (!ignore) setLoadFailed(true);
});
return () => {
ignore = true;
};
}, [loadAttempt]);

const loadFilters = () =>
fetch("/api/context/rtk/filters")
Expand Down Expand Up @@ -152,6 +169,25 @@ export default function RtkContextPageClient() {
[t("avgSavings"), `${rtkStats?.avgSavingsPct ?? analytics?.avgSavingsPct ?? 0}%`],
];

if (loadFailed) {
return (
<div className="mx-auto flex max-w-6xl flex-col gap-6">
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-surface p-4">
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{tSettings("compressionTitle")}: {tCommon("failedToLoad")}
</p>
<button
type="button"
onClick={() => setLoadAttempt((attempt) => attempt + 1)}
className="shrink-0 rounded-lg border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg"
>
{tSettings("retry")}
</button>
</div>
</div>
);
}

return (
<div className="mx-auto flex max-w-6xl flex-col gap-6">
<header className="flex flex-col gap-2">
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/ui/compressionHub.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,102 @@ describe("CompressionCombosPageClient", () => {
expect(text).toContain("Named combos");
});
});

describe("CompressionHub when the settings GET fails", () => {
// The settings GET fails as it does while the server restarts; the combos GET keeps
// answering. A failed load must offer a retry instead of the default-profile view,
// whose select and Context Editing toggle would save the defaults over the stored row.
const FAILURES = {
"a 500": async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 500 }),
"a network error": async () => {
throw new TypeError("Failed to fetch");
},
};

function failSettingsGet(failure: keyof typeof FAILURES) {
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("/api/settings/compression")) return FAILURES[failure]();
if (url.includes("/api/context/combos")) return json({ combos: [] });
return json({}, 404);
});
}

async function mountHub() {
const { default: CompressionHub } =
await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
let container!: HTMLElement;
await act(async () => {
container = mountInContainer(<CompressionHub />);
});
await flush();
return container;
}

function retryButton(container: HTMLElement) {
return Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Retry"
);
}

it.each(Object.keys(FAILURES) as Array<keyof typeof FAILURES>)(
"shows a retry in place of the default-profile view after %s",
{ timeout: 20000 },
async (failure) => {
failSettingsGet(failure);
const container = await mountHub();

expect(container.querySelector('[data-testid="active-profile-select"]')).toBeNull();
expect(container.querySelectorAll('[role="switch"]')).toHaveLength(0);
expect(container.textContent).toContain("Failed To Load");
expect(retryButton(container)).toBeTruthy();
}
);

it("returns with the stored settings once Retry answers", async () => {
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("/api/settings/compression")) {
return json({ enabled: true, defaultMode: "rtk", contextEditing: { enabled: true } });
}
if (url.includes("/api/context/combos")) {
return json({
combos: [{ id: "c1", name: "Alpha", pipeline: [{ engine: "rtk" }] }],
});
}
return json({}, 404);
});
// The Hub's first call is the settings GET; only it fails.
fetchSpy.mockImplementationOnce(
async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 500 })
);
const container = await mountHub();
expect(container.textContent).toContain("Failed To Load");

await act(async () => {
retryButton(container)!.click();
});
await flush();

expect(container.textContent).not.toContain("Failed To Load");
// The stored row has Context Editing on, which the defaults do not.
const toggle = container.querySelector('[role="switch"]');
expect(toggle?.getAttribute("aria-checked")).toBe("true");
const select = container.querySelector(
'[data-testid="active-profile-select"]'
) as HTMLSelectElement | null;
expect(Array.from(select?.options ?? []).some((option) => option.text === "Alpha")).toBe(true);
});
});
Loading
Loading