diff --git a/changelog.d/fixes/10489-qdrant-health-badge.md b/changelog.d/fixes/10489-qdrant-health-badge.md index 56c1b21bf5..2a4789178d 100644 --- a/changelog.d/fixes/10489-qdrant-health-badge.md +++ b/changelog.d/fixes/10489-qdrant-health-badge.md @@ -1 +1 @@ -- **fix(memory):** auto-check Qdrant health on mount and stop the false-red status badge on `/dashboard/memory?tab=engine` — the badge treated "not yet checked" (`health === null`) as a failure, so a healthy Qdrant showed red after every page refresh until "Test connection" was clicked ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) +- **fix(memory):** auto-check Qdrant health on mount and stop the false-red status badge on `/dashboard/memory?tab=engine` — the badge treated "not yet checked" (`health === null`) as a failure, so a healthy Qdrant showed red after every page refresh until "Test connection" was clicked; settings changes now also invalidate the stale result and re-check after the save persists, so a health check racing the settings PUT can no longer keep the badge red until a manual re-test ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) diff --git a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx index b0a83dc92a..c05b5ac4ee 100644 --- a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx +++ b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; @@ -47,6 +47,12 @@ export default function QdrantConfigCard() { const [embeddingOptions, setEmbeddingOptions] = useState([]); const [loading, setLoading] = useState(true); + // Generation counter for health checks. Bumping it invalidates any in-flight + // or already-resolved check so a stale result (for example one that raced a + // settings save and read the pre-save configuration) can never be applied + // out of order. + const healthSeqRef = useRef(0); + useEffect(() => { Promise.all([ fetch("/api/settings/qdrant").then((r) => (r.ok ? r.json() : null)), @@ -65,10 +71,40 @@ export default function QdrantConfigCard() { .finally(() => setLoading(false)); }, []); + const checkHealth = useCallback(async () => { + const seq = ++healthSeqRef.current; + setChecking(true); + try { + const res = await fetch("/api/settings/qdrant/health"); + if (res.ok) { + const data = await res.json(); + // Drop the result if a newer save/check invalidated this one. + if (healthSeqRef.current !== seq) return; + setHealth(data); + } else { + if (healthSeqRef.current !== seq) return; + setHealth({ ok: false, latencyMs: 0, error: "HTTP error" }); + } + } catch (e) { + if (healthSeqRef.current !== seq) return; + setHealth({ + ok: false, + latencyMs: 0, + error: e instanceof Error ? e.message : String(e), + }); + } finally { + if (healthSeqRef.current === seq) setChecking(false); + } + }, []); + const save = useCallback( async (updates: Partial & { apiKey?: string }) => { const prev = qdrant; const next = { ...qdrant, ...updates }; + // Settings are changing, so any prior health result is stale: drop it and + // invalidate in-flight checks so they cannot overwrite the new state. + healthSeqRef.current += 1; + setHealth(null); setQdrant(next); setSaving(true); setSaveStatus(""); @@ -91,6 +127,16 @@ export default function QdrantConfigCard() { setQdrant(data); setApiKeyInput(""); setSaveStatus("saved"); + // A health check started during the optimistic window (enabled just + // flipped and health was null) can race the PUT and read the OLD + // persisted settings -> not_configured/failed. Invalidate it and + // schedule a fresh check against the just-persisted settings. This + // must be explicit: if health was still null the mount effect bails + // on the setHealth(null) no-op, so a healthy Qdrant would stay red + // until a manual test. + healthSeqRef.current += 1; + setHealth(null); + void checkHealth(); setTimeout(() => setSaveStatus(""), 2000); } else { setQdrant(prev); @@ -103,26 +149,9 @@ export default function QdrantConfigCard() { setSaving(false); } }, - [qdrant] + [qdrant, checkHealth] ); - const checkHealth = useCallback(async () => { - setChecking(true); - try { - const res = await fetch("/api/settings/qdrant/health"); - if (res.ok) setHealth(await res.json()); - else setHealth({ ok: false, latencyMs: 0, error: "HTTP error" }); - } catch (e) { - setHealth({ - ok: false, - latencyMs: 0, - error: e instanceof Error ? e.message : String(e), - }); - } finally { - setChecking(false); - } - }, []); - // Auto-check on mount once settings load: without this the status badge // renders red after a page refresh because `health` starts as null and the // old code treated "not checked yet" the same as "failed". The Test diff --git a/tests/unit/ui/qdrant-config-card.test.tsx b/tests/unit/ui/qdrant-config-card.test.tsx index 7ac859d514..846c11a8cc 100644 --- a/tests/unit/ui/qdrant-config-card.test.tsx +++ b/tests/unit/ui/qdrant-config-card.test.tsx @@ -376,4 +376,71 @@ describe("QdrantConfigCard", () => { expect(container.textContent).toContain("qdrant.statusActive"); expect(container.textContent).not.toContain("qdrant.statusError"); }); + + it("re-checks health after a successful save so a stale optimistic-window result cannot leave the badge red (enable ordering)", async () => { + let healthFetchCount = 0; + const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string }) => { + if (url === "/api/settings/qdrant" && opts?.method === "PUT") { + return Promise.resolve({ + ok: true, + json: async () => ({ ...MOCK_QDRANT_SETTINGS, enabled: true }), + }); + } + if (url === "/api/settings/qdrant") { + return Promise.resolve({ ok: true, json: async () => MOCK_QDRANT_SETTINGS }); + } + if (url === "/api/settings/qdrant/embedding-models") { + return Promise.resolve({ ok: true, json: async () => ({ models: [] }) }); + } + if (url === "/api/settings/qdrant/health") { + healthFetchCount += 1; + // The first GET races the settings PUT and sees the OLD persisted + // config (enabled=false) -> not_configured. Post-PUT checks see a + // healthy Qdrant. + if (healthFetchCount === 1) { + return Promise.resolve({ + ok: true, + json: async () => ({ ok: false, latencyMs: 0, error: "not configured" }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({ ok: true, latencyMs: 2 }) }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + globalThis.fetch = fetchMock; + + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // Enable Qdrant: save() optimistically flips the switch and starts the + // PUT while the mount effect immediately GETs health against the OLD + // persisted settings (returned as not_configured above). + const toggleBtn = container.querySelector( + "[data-testid='qdrant-enabled-switch']" + ) as HTMLButtonElement | null; + expect(toggleBtn).toBeTruthy(); + await act(async () => { + toggleBtn?.click(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // The stale first check must not be the last word: a fresh health GET must + // be scheduled after the PUT succeeds so the badge ends green. + const allHealthCalls = fetchMock.mock.calls.filter( + (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health" + ); + expect(allHealthCalls.length).toBeGreaterThanOrEqual(2); + expect(container.textContent).toContain("qdrant.statusActive"); + expect(container.textContent).not.toContain("qdrant.statusError"); + }); });