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:
Martin Vogel
2026-07-03 20:28:27 +02:00
co-authored by sahil-mangla
parent 09148ab121
commit cfc73f8d0b
5 changed files with 200 additions and 9 deletions
+3
View File
@@ -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/
+144 -3
View File
@@ -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();
});
});
+44 -6
View File
@@ -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>
);
}
+4
View File
@@ -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: "新建索引",
+5
View File
@@ -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",