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