mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-26 06:43:15 +08:00
fix(graph-ui): surface indexing-job failures instead of silently completing
The backend /api/index-status reports status:"error" plus an error message for failed indexing jobs, but IndexProgress treated any non-"indexing" state as successful completion: the spinner vanished with no feedback (e.g. after an OOM-killed indexer subprocess), the project never appeared, and no error was shown. Render a visible error banner (path + error text) with a Dismiss button instead, and keep the success flow unchanged. Beyond the original PR: - Restore the empty-jobs guard the PR dropped: the backend keeps finished jobs listed as "done"/"error" (handle_index_status only skips idle slots), so an empty list mid-index only occurs on transient state loss and must not be treated as completion. - Route the new user-facing strings through the i18n system (projects.indexingFailed, common.dismiss; EN + zh entries). - Cover error banner + dismiss, success flow and the empty-jobs guard with vitest cases on the now-exported IndexProgress. Distilled from #549. Refs #524 Co-authored-by: sahil-mangla <manglasahil2017@gmail.com> Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
co-authored by
sahil-mangla
parent
09148ab121
commit
cfc73f8d0b
@@ -59,3 +59,6 @@ soak-results/
|
||||
|
||||
# LSP originality-check reference cache (scripts/check-lsp-originality.sh)
|
||||
.lsp-refs/
|
||||
|
||||
# Local npm cache
|
||||
graph-ui/.npm-cache-local/
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { StatsTab } from "./StatsTab";
|
||||
import { fireEvent, render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StatsTab, IndexProgress } from "./StatsTab";
|
||||
import { messages } from "../lib/i18n";
|
||||
|
||||
function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response | undefined) {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
@@ -93,3 +94,143 @@ describe("StatsTab index modal", () => {
|
||||
expect(screen.getByRole("button", { name: "Browse D:/" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexProgress", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("polls and shows indexing in progress when active", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve([
|
||||
{ slot: 1, status: "indexing", path: "/path/to/project1" }
|
||||
])
|
||||
} as unknown as Response)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDone = vi.fn();
|
||||
render(<IndexProgress onDone={onDone} />);
|
||||
|
||||
// Fast-forward initial poll
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/index-status");
|
||||
expect(screen.getByText(messages.en.projects.indexingInProgress)).toBeInTheDocument();
|
||||
expect(screen.getByText("/path/to/project1")).toBeInTheDocument();
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops polling and calls onDone when indexing finishes successfully", async () => {
|
||||
// Backend keeps finished jobs listed with status "done" (src/ui/http_server.c
|
||||
// handle_index_status only skips idle slots) — success is a "done" entry,
|
||||
// not an empty list.
|
||||
let mockData = [
|
||||
{ slot: 1, status: "indexing", path: "/path/to/project" }
|
||||
];
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve(mockData)
|
||||
} as unknown as Response)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDone = vi.fn();
|
||||
render(<IndexProgress onDone={onDone} />);
|
||||
|
||||
// First poll returns active
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
|
||||
// Indexing finishes
|
||||
mockData = [
|
||||
{ slot: 1, status: "done", path: "/path/to/project" }
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps waiting and does NOT call onDone while the jobs list is empty", async () => {
|
||||
// An empty list mid-index means the job is not visible (e.g. transient
|
||||
// backend state loss) — it must NOT be treated as successful completion.
|
||||
let mockData: { slot: number; status: string; path: string }[] = [];
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve(mockData)
|
||||
} as unknown as Response)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDone = vi.fn();
|
||||
render(<IndexProgress onDone={onDone} />);
|
||||
|
||||
// Two empty polls: still waiting, no premature completion
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Job becomes visible and finishes — now completion fires
|
||||
mockData = [{ slot: 1, status: "done", path: "/path/to/project" }];
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders error banner and does NOT call onDone when indexing fails with error status", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve([
|
||||
{ slot: 1, status: "error", path: "/path/to/failed-project", error: "OOM Error" }
|
||||
])
|
||||
} as unknown as Response)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDone = vi.fn();
|
||||
render(<IndexProgress onDone={onDone} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
// Error banner should show up
|
||||
expect(screen.getByText(messages.en.projects.indexingFailed)).toBeInTheDocument();
|
||||
expect(screen.getByText("/path/to/failed-project")).toBeInTheDocument();
|
||||
expect(screen.getByText("OOM Error")).toBeInTheDocument();
|
||||
|
||||
// onDone should not be called automatically
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
|
||||
// Click Dismiss button
|
||||
const dismissBtn = screen.getByRole("button", { name: messages.en.common.dismiss });
|
||||
expect(dismissBtn).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
// onDone should be called after manual dismissal
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,21 +378,39 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
|
||||
|
||||
/* ── Index Progress ─────────────────────────────────────── */
|
||||
|
||||
function IndexProgress({ onDone }: { onDone: () => void }) {
|
||||
export function IndexProgress({ onDone }: { onDone: () => void }) {
|
||||
const t = useUiMessages();
|
||||
const [jobs, setJobs] = useState<{ slot: number; status: string; path: string }[]>([]);
|
||||
const [jobs, setJobs] = useState<{ slot: number; status: string; path: string; error?: string }[]>([]);
|
||||
const [hasActive, setHasActive] = useState(true);
|
||||
useEffect(() => {
|
||||
if (!hasActive) return;
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const data = await (await fetch("/api/index-status")).json();
|
||||
setJobs(data);
|
||||
if (data.length > 0 && data.every((j: { status: string }) => j.status !== "indexing")) onDone();
|
||||
} catch { /* */ }
|
||||
const stillIndexing = data.some((j: { status: string }) => j.status === "indexing");
|
||||
/* Empty list = job not visible: the backend keeps finished jobs listed
|
||||
as "done"/"error", so [] mid-index only happens on transient state
|
||||
loss (e.g. server restart) — keep polling, don't treat as done. */
|
||||
if (data.length > 0 && !stillIndexing) {
|
||||
setHasActive(false);
|
||||
const hasErrors = data.some((j: { status: string }) => j.status === "error");
|
||||
if (!hasErrors) {
|
||||
onDone();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[IndexProgress] Poll failed:", error);
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(poll);
|
||||
}, [onDone]);
|
||||
}, [onDone, hasActive]);
|
||||
|
||||
const active = jobs.filter((j) => j.status === "indexing");
|
||||
if (active.length === 0) return null;
|
||||
const errors = jobs.filter((j) => j.status === "error");
|
||||
|
||||
if (active.length === 0 && errors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4 mb-6">
|
||||
{active.map((j) => (
|
||||
@@ -404,6 +422,26 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{errors.map((j) => (
|
||||
<div key={j.slot} className="flex items-start gap-3 mt-3 first:mt-0 p-3 rounded-lg border border-destructive/20 bg-destructive/5 text-destructive">
|
||||
<span className="text-[14px]">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12px] font-semibold">{t.projects.indexingFailed}</p>
|
||||
<p className="text-[11px] font-mono truncate">{j.path}</p>
|
||||
{j.error && <p className="text-[10px] opacity-75 mt-1 font-mono">{j.error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{errors.length > 0 && (
|
||||
<div className="flex justify-end mt-3">
|
||||
<button
|
||||
onClick={onDone}
|
||||
className="px-3 py-1 rounded bg-destructive/10 hover:bg-destructive/20 text-destructive text-[11px] font-medium transition-all"
|
||||
>
|
||||
{t.common.dismiss}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const messages = {
|
||||
saving: "Saving...",
|
||||
delete: "Delete",
|
||||
noMatches: "No matches",
|
||||
dismiss: "Dismiss",
|
||||
},
|
||||
graph: {
|
||||
selectedLabel: "Graph",
|
||||
@@ -37,6 +38,7 @@ export const messages = {
|
||||
healthCorrupt: "Database unhealthy",
|
||||
healthChecking: "Checking...",
|
||||
indexingInProgress: "Indexing in progress",
|
||||
indexingFailed: "Indexing failed",
|
||||
},
|
||||
index: {
|
||||
newIndex: "New Index",
|
||||
@@ -86,6 +88,7 @@ export const messages = {
|
||||
saving: "保存中...",
|
||||
delete: "删除",
|
||||
noMatches: "无匹配结果",
|
||||
dismiss: "关闭",
|
||||
},
|
||||
graph: {
|
||||
selectedLabel: "图谱",
|
||||
@@ -106,6 +109,7 @@ export const messages = {
|
||||
healthCorrupt: "数据库异常",
|
||||
healthChecking: "检查中...",
|
||||
indexingInProgress: "正在索引",
|
||||
indexingFailed: "索引失败",
|
||||
},
|
||||
index: {
|
||||
newIndex: "新建索引",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
@@ -10,6 +11,10 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
assetsDir: "assets",
|
||||
|
||||
Reference in New Issue
Block a user