From 879f62c19d97d5ff66db90651536003a7814b00a Mon Sep 17 00:00:00 2001 From: nghequyettien Date: Sun, 26 Jul 2026 10:14:48 +0700 Subject: [PATCH] fix(ui): thread errorKind through dead-letter panel and app overview The dead-letter-queue panel and the app overview page were the only two of six useApiResource/ErrorState call sites that never forwarded errorKind, so a network/timeout failure showed the generic "something went wrong" icon instead of the offline-specific one. Also add a missing onRetry handler to the overview's error state, matching the other call sites. --- .../site/dead-letter-queue-panel.test.tsx | 16 ++++ .../site/dead-letter-queue-panel.tsx | 1 + .../loopover-ui/src/routes/app.index.test.tsx | 82 ++++++++++++++++++- apps/loopover-ui/src/routes/app.index.tsx | 4 +- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx index f299d52f69..a55dfb58be 100644 --- a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx +++ b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx @@ -159,6 +159,22 @@ describe("DeadLetterQueuePanel", () => { ).toBeTruthy(); }); + it("REGRESSION (#8668): shows the WifiOff icon, not the generic AlertTriangle, when the fetch fails with a network errorKind", async () => { + apiFetch.mockResolvedValue({ ok: false, kind: "network", message: "fetch failed" }); + const { container } = render(); + await screen.findByText("Couldn't load the dead-letter queue"); + expect(container.querySelector(".lucide-wifi-off")).toBeTruthy(); + expect(container.querySelector(".lucide-triangle-alert")).toBeNull(); + }); + + it("keeps the generic AlertTriangle icon for a non-network (e.g. http) errorKind", async () => { + apiFetch.mockResolvedValue({ ok: false, kind: "http", message: "insufficient_role" }); + const { container } = render(); + await screen.findByText("Couldn't load the dead-letter queue"); + expect(container.querySelector(".lucide-triangle-alert")).toBeTruthy(); + expect(container.querySelector(".lucide-wifi-off")).toBeNull(); + }); + it("expands and collapses a truncated error message", async () => { const longError = "x".repeat(120); apiFetch.mockResolvedValue({ diff --git a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx index 63e4152c82..714992c44c 100644 --- a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx +++ b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx @@ -120,6 +120,7 @@ export function DeadLetterQueuePanel() { ({ + createFileRoute: () => () => ({}), + useNavigate: () => () => Promise.resolve(), + Link: ({ to, children }: { to: string; children: ReactNode }) => {children}, +})); + +const { useSession } = vi.hoisted(() => ({ useSession: vi.fn() })); +vi.mock("@/lib/api/session", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useSession: () => useSession() }; +}); + +const { useApiResource } = vi.hoisted(() => ({ useApiResource: vi.fn() })); +vi.mock("@/lib/api/use-api-resource", () => ({ + useApiResource: (...args: unknown[]) => useApiResource(...args), +})); // #6984: SparkStat's loading branch hand-rolled its own animate-pulse divs instead of the shared // Skeleton primitive every other loading placeholder in this app already uses. @@ -39,3 +57,61 @@ describe("SparkStat loading state (#6984)", () => { expect(screen.queryByRole("status", { name: "Loading Open PRs" })).toBeNull(); }); }); + +describe("AppOverview error state (#8668)", () => { + const SESSION = { + login: "octo", + roles: ["miner"] as const, + confirmed_miner: true, + }; + + it("REGRESSION: shows the WifiOff icon, not the generic AlertTriangle, when the overview fetch fails with a network errorKind", () => { + useSession.mockReturnValue({ session: SESSION }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "fetch failed", + errorKind: "network", + loadedAt: null, + reload: vi.fn(), + }); + + const { container } = render(); + expect(screen.getByText("App overview is unavailable right now")).toBeTruthy(); + expect(container.querySelector(".lucide-wifi-off")).toBeTruthy(); + expect(container.querySelector(".lucide-triangle-alert")).toBeNull(); + }); + + it("keeps the generic AlertTriangle icon for a non-network (e.g. http) errorKind", () => { + useSession.mockReturnValue({ session: SESSION }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "server exploded", + errorKind: "http", + loadedAt: null, + reload: vi.fn(), + }); + + const { container } = render(); + expect(container.querySelector(".lucide-triangle-alert")).toBeTruthy(); + expect(container.querySelector(".lucide-wifi-off")).toBeNull(); + }); + + it("REGRESSION: clicking retry on the overview error state re-triggers the fetch", () => { + const reload = vi.fn(); + useSession.mockReturnValue({ session: SESSION }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "fetch failed", + errorKind: "network", + loadedAt: null, + reload, + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: /try again/i })); + expect(reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/loopover-ui/src/routes/app.index.tsx b/apps/loopover-ui/src/routes/app.index.tsx index 687bf082d2..27b8acd83d 100644 --- a/apps/loopover-ui/src/routes/app.index.tsx +++ b/apps/loopover-ui/src/routes/app.index.tsx @@ -102,7 +102,7 @@ type AppOverviewResponse = { }>; }; -function AppOverview() { +export function AppOverview() { const { session } = useSession(); const { status, connection } = useApiStatus(); const overview = useApiResource("/v1/app/overview", "App overview"); @@ -175,6 +175,8 @@ function AppOverview() { className="col-span-full" title="App overview is unavailable right now" description={overview.error} + errorKind={overview.errorKind} + onRetry={overview.reload} /> )} {series.length === 0 ? (