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