mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
fix(memory): auto-check Qdrant health on mount and stop false-red badge
The Qdrant engine card on /dashboard/memory?tab=engine showed a red "Error" badge after every page refresh even when Qdrant was healthy: the badge derives its state from a health check, but the mount effect only fetched settings + embedding models — health started as null and the render treated `health?.ok` (undefined) as a failure. Clicking "Test connection" (which runs the same server-side /readyz check) immediately turned it green, proving the connection was fine. Two changes: - Auto-run the health check on mount once settings load and Qdrant is enabled, so a refreshed page reflects the real state (verified live: /api/settings/qdrant/health returns ok:true in ~2ms on a healthy compose deployment). - While health has not been checked yet (null), render a neutral gray "Testing..." state instead of red — red is now reserved for an actual failed health check. Regression test added (fails on the old code): with enabled settings and a healthy mock, the card must hit /api/settings/qdrant/health on mount and show statusActive, never statusError.
This commit is contained in:
@@ -123,6 +123,16 @@ export default function QdrantConfigCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
// connection button still drives the same check manually.
|
||||
useEffect(() => {
|
||||
if (!loading && qdrant.enabled && health === null) {
|
||||
void checkHealth();
|
||||
}
|
||||
}, [loading, qdrant.enabled, health, checkHealth]);
|
||||
|
||||
const runSearch = useCallback(async () => {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
@@ -185,18 +195,32 @@ export default function QdrantConfigCard() {
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium ${
|
||||
qdrant.enabled ? (health?.ok ? "text-emerald-500" : "text-red-500") : "text-text-muted"
|
||||
!qdrant.enabled
|
||||
? "text-text-muted"
|
||||
: health === null
|
||||
? "text-text-muted"
|
||||
: health.ok
|
||||
? "text-emerald-500"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full ${
|
||||
qdrant.enabled ? (health?.ok ? "bg-emerald-500" : "bg-red-500") : "bg-border"
|
||||
!qdrant.enabled
|
||||
? "bg-border"
|
||||
: health === null
|
||||
? "bg-border"
|
||||
: health.ok
|
||||
? "bg-emerald-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
{qdrant.enabled
|
||||
? health?.ok
|
||||
? t("qdrant.statusActive")
|
||||
: t("qdrant.statusError")
|
||||
? health === null
|
||||
? t("qdrant.testing")
|
||||
: health.ok
|
||||
? t("qdrant.statusActive")
|
||||
: t("qdrant.statusError")
|
||||
: t("qdrant.statusDisabled")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -37,8 +37,9 @@ function makeContainer(): HTMLElement {
|
||||
|
||||
describe("QdrantConfigCard", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url === "/api/settings/qdrant") {
|
||||
return Promise.resolve({
|
||||
@@ -68,9 +69,8 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
|
||||
it("renders after loading qdrant settings", async () => {
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const { default: QdrantConfigCard } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
@@ -110,9 +110,8 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const { default: QdrantConfigCard } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
@@ -123,7 +122,7 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
|
||||
const toggleBtn = container.querySelector(
|
||||
"[data-testid='qdrant-enabled-switch']",
|
||||
"[data-testid='qdrant-enabled-switch']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
@@ -135,9 +134,7 @@ describe("QdrantConfigCard", () => {
|
||||
|
||||
const putCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string, { method?: string }]) =>
|
||||
typeof c[0] === "string" &&
|
||||
c[0] === "/api/settings/qdrant" &&
|
||||
c[1]?.method === "PUT",
|
||||
typeof c[0] === "string" && c[0] === "/api/settings/qdrant" && c[1]?.method === "PUT"
|
||||
);
|
||||
expect(putCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -166,9 +163,8 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const { default: QdrantConfigCard } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
@@ -179,7 +175,7 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
|
||||
const testBtn = container.querySelector(
|
||||
"[data-testid='qdrant-test-connection']",
|
||||
"[data-testid='qdrant-test-connection']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(testBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
@@ -190,7 +186,7 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
|
||||
const healthCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health",
|
||||
(c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health"
|
||||
);
|
||||
expect(healthCalls.length).toBeGreaterThan(0);
|
||||
// Health OK result should be shown
|
||||
@@ -198,37 +194,36 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
|
||||
it("search test button calls /api/settings/qdrant/search and renders results", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string; body?: string }) => {
|
||||
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/search" && opts?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation((url: string, opts?: { method?: string; body?: string }) => {
|
||||
if (url === "/api/settings/qdrant") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
results: [
|
||||
{ id: "r1", score: 0.9876, payload: { content: "test content" } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
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/search" && opts?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
results: [{ id: "r1", score: 0.9876, payload: { content: "test content" } }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const { default: QdrantConfigCard } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
@@ -240,7 +235,7 @@ describe("QdrantConfigCard", () => {
|
||||
|
||||
// Set search query by manipulating the input
|
||||
const searchInputs = Array.from(container.querySelectorAll("input")).filter(
|
||||
(i) => i.type !== "password" && i.type !== "number",
|
||||
(i) => i.type !== "password" && i.type !== "number"
|
||||
);
|
||||
// The search query input is the one with the search placeholder
|
||||
const searchInput = searchInputs[searchInputs.length - 1] as HTMLInputElement | null;
|
||||
@@ -250,7 +245,7 @@ describe("QdrantConfigCard", () => {
|
||||
if (searchInput) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
"value"
|
||||
)?.set;
|
||||
nativeSetter?.call(searchInput, "test query");
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
@@ -258,7 +253,7 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
|
||||
const searchTestBtn = container.querySelector(
|
||||
"[data-testid='qdrant-search-test']",
|
||||
"[data-testid='qdrant-search-test']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(searchTestBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
@@ -272,7 +267,7 @@ describe("QdrantConfigCard", () => {
|
||||
(c: [string, { method?: string }]) =>
|
||||
typeof c[0] === "string" &&
|
||||
c[0] === "/api/settings/qdrant/search" &&
|
||||
c[1]?.method === "POST",
|
||||
c[1]?.method === "POST"
|
||||
);
|
||||
expect(searchCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -301,9 +296,8 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { default: QdrantConfigCard } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"
|
||||
);
|
||||
const { default: QdrantConfigCard } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
@@ -314,7 +308,7 @@ describe("QdrantConfigCard", () => {
|
||||
});
|
||||
|
||||
const cleanupBtn = container.querySelector(
|
||||
"[data-testid='qdrant-cleanup']",
|
||||
"[data-testid='qdrant-cleanup']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(cleanupBtn).toBeTruthy();
|
||||
await act(async () => {
|
||||
@@ -328,10 +322,58 @@ describe("QdrantConfigCard", () => {
|
||||
(c: [string, { method?: string }]) =>
|
||||
typeof c[0] === "string" &&
|
||||
c[0] === "/api/settings/qdrant/cleanup" &&
|
||||
c[1]?.method === "POST",
|
||||
c[1]?.method === "POST"
|
||||
);
|
||||
expect(cleanupCalls.length).toBeGreaterThan(0);
|
||||
// Shows cleanup success message
|
||||
expect(container.textContent).toContain("qdrant.cleanupSuccess");
|
||||
});
|
||||
|
||||
it("auto-checks health on mount when enabled (no red error after refresh)", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url === "/api/settings/qdrant") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ ...MOCK_QDRANT_SETTINGS, enabled: true }),
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/embedding-models") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ models: [] }),
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/qdrant/health") {
|
||||
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(<QdrantConfigCard />);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
// The health endpoint must be hit automatically on mount — no manual
|
||||
// "Test connection" click required. Regression: the badge used to render
|
||||
// red after a page refresh because health started as null and the mount
|
||||
// effect never checked it.
|
||||
const healthCalls = fetchMock.mock.calls.filter(
|
||||
(c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health"
|
||||
);
|
||||
expect(healthCalls.length).toBeGreaterThan(0);
|
||||
// Badge shows the real healthy state, not a red error.
|
||||
expect(container.textContent).toContain("qdrant.statusActive");
|
||||
expect(container.textContent).not.toContain("qdrant.statusError");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user