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 ? (