From fcb1770731114ef82176e55f311736a3d8eb40a1 Mon Sep 17 00:00:00 2001 From: hurryup52 Date: Sun, 26 Jul 2026 09:09:00 +0200 Subject: [PATCH] fix(ui): thread errorKind through dead-letter-queue and overview error states dead-letter-queue-panel.tsx and app.index.tsx's overview metrics both call ErrorState/StateBoundary without passing errorKind, so a network/timeout outage always rendered the generic AlertTriangle treatment instead of the WifiOff "can't reach the server" one already used by four sibling call sites. app.index.tsx's overview error also had no retry action at all. Closes #8668 --- .../site/dead-letter-queue-panel.test.tsx | 20 +++++ .../site/dead-letter-queue-panel.tsx | 1 + .../loopover-ui/src/routes/app.index.test.tsx | 77 ++++++++++++++++++- apps/loopover-ui/src/routes/app.index.tsx | 4 +- 4 files changed, 99 insertions(+), 3 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..b56e60a277 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 @@ -150,6 +150,26 @@ describe("DeadLetterQueuePanel", () => { expect(screen.getByText("insufficient_role")).toBeTruthy(); }); + // #8668: the panel's own errorTitle/errorDescription are fixed strings that always win over + // ErrorState's network-aware copy defaults, so a network-kind failure is only observable via the + // icon swap (WifiOff vs AlertTriangle) -- errorKind previously wasn't threaded through at all, so + // this rendered the generic icon even when the API was completely unreachable. + it("shows the offline (WifiOff) icon for a network-kind failure, not the generic one", async () => { + apiFetch.mockResolvedValue({ ok: false, message: "fetch failed", kind: "network" }); + 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 failure", async () => { + apiFetch.mockResolvedValue({ ok: false, message: "insufficient_role", kind: "http" }); + 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("shows an error state when the response is malformed", async () => { apiFetch.mockResolvedValue({ ok: true, data: { generatedAt: "x" } }); render(); 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() { ({ apiFetch: vi.fn() })); +vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); +vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" })); + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => () => ({}), + Link: ({ to, children }: { to: string; children: ReactNode }) => {children}, + useNavigate: () => () => Promise.resolve(), +})); + +import { AppOverview } from "./app.index"; + +function mockOverviewFetch( + overview: { ok: true } | { ok: false; kind?: "network" | "timeout" | "http" }, +) { + apiFetch.mockImplementation((url: string) => { + if (url.endsWith("/v1/auth/session")) { + return Promise.resolve({ + ok: true, + data: { + status: "authenticated", + login: "test-user", + roles: ["miner"], + confirmed_miner: false, + }, + }); + } + if (url.endsWith("/v1/app/overview")) { + return overview.ok + ? Promise.resolve({ ok: true, data: { metrics: [], recentRuns: [] } }) + : Promise.resolve({ ok: false, message: "fetch failed", kind: overview.kind }); + } + return Promise.resolve({ ok: false, message: "unhandled in test" }); + }); +} + // #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. describe("SparkStat loading state (#6984)", () => { @@ -39,3 +76,39 @@ describe("SparkStat loading state (#6984)", () => { expect(screen.queryByRole("status", { name: "Loading Open PRs" })).toBeNull(); }); }); + +// #8668: the overview metrics ErrorState passed only title/description (both fixed strings), never +// errorKind or onRetry -- so a network outage always rendered the generic AlertTriangle treatment +// with no retry action, even though `overview.errorKind`/`overview.reload` were both already +// available from useApiResource. +describe("AppOverview metrics error state (#8668)", () => { + it("shows the offline (WifiOff) icon for a network-kind overview failure, not the generic one", async () => { + mockOverviewFetch({ ok: false, kind: "network" }); + const { container } = render(); + await screen.findByText("App overview is unavailable right now"); + expect(container.querySelector(".lucide-wifi-off")).toBeTruthy(); + expect(container.querySelector(".lucide-triangle-alert")).toBeNull(); + }); + + it("keeps the generic (AlertTriangle) icon for a non-network overview failure", async () => { + mockOverviewFetch({ ok: false, kind: "http" }); + const { container } = render(); + await screen.findByText("App overview is unavailable right now"); + expect(container.querySelector(".lucide-triangle-alert")).toBeTruthy(); + expect(container.querySelector(".lucide-wifi-off")).toBeNull(); + }); + + it("retrying the overview error re-fetches /v1/app/overview", async () => { + mockOverviewFetch({ ok: false, kind: "network" }); + render(); + await screen.findByText("App overview is unavailable right now"); + + apiFetch.mockClear(); + mockOverviewFetch({ ok: false, kind: "network" }); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + await waitFor(() => + expect(apiFetch).toHaveBeenCalledWith("https://api.test/v1/app/overview", expect.any(Object)), + ); + }); +}); 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 ? (