diff --git a/packages/dashboard/app/components/ReliabilityView.css b/packages/dashboard/app/components/ReliabilityView.css index 3ee48f84f4..5403ac7555 100644 --- a/packages/dashboard/app/components/ReliabilityView.css +++ b/packages/dashboard/app/components/ReliabilityView.css @@ -15,6 +15,31 @@ background: var(--card); } +.reliability-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-md); + padding: var(--space-2xl); + color: var(--text-muted); +} + +.reliability-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-md); + padding: var(--space-2xl); + text-align: center; +} + +.reliability-error p { + color: var(--text-muted); + margin: 0; +} + .reliability-headline-card { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/ReliabilityView.tsx b/packages/dashboard/app/components/ReliabilityView.tsx index 38c1f69777..a655402672 100644 --- a/packages/dashboard/app/components/ReliabilityView.tsx +++ b/packages/dashboard/app/components/ReliabilityView.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { AlertCircle, Loader2 } from "lucide-react"; import "./ReliabilityView.css"; type ReliabilityResponse = { @@ -42,18 +43,28 @@ function formatDateTime(value: string | null | undefined): string { export function ReliabilityView() { const { t } = useTranslation("app"); const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const [showEmptyDays, setShowEmptyDays] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); const [resetError, setResetError] = useState(null); const load = useCallback(async () => { - const response = await fetch("/api/health/reliability"); - if (!response.ok) { - throw new Error(`Failed to load reliability metrics (${response.status})`); + setIsLoading(true); + setError(null); + try { + const response = await fetch("/api/health/reliability"); + if (!response.ok) { + throw new Error(`Failed to load reliability metrics (${response.status})`); + } + const payload = (await response.json()) as ReliabilityResponse; + setData(payload); + } catch (loadError: unknown) { + setError(loadError instanceof Error ? loadError.message : t("reliability.failedToLoad", "Failed to load reliability metrics")); + } finally { + setIsLoading(false); } - const payload = (await response.json()) as ReliabilityResponse; - setData(payload); - }, []); + }, [t]); const resetStats = useCallback(async () => { setResetError(null); @@ -103,6 +114,24 @@ export function ReliabilityView() { ? formatDateTime(data.resetAt ?? new Date(Date.parse(data.generatedAt) - data.windowDays * 86_400_000).toISOString()) : "—"; + if (isLoading && data === null) { + return ( +
+ +

{t("reliability.loading", "Loading reliability data...")}

+
+ ); + } + + if (error !== null && data === null) { + return ( +
+ +

{error}

+
+ ); + } + return (
diff --git a/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx b/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx index 390c2a1bbd..8d17c0dd21 100644 --- a/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ReliabilityView } from "../ReliabilityView"; @@ -34,9 +34,54 @@ const baseResponse = { describe("ReliabilityView", () => { afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); + it("shows loading spinner while data is loading", () => { + vi.spyOn(globalThis, "fetch").mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByTestId("reliability-loading")).toBeInTheDocument(); + expect(screen.getByText("Loading reliability data...")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Reliability" })).not.toBeInTheDocument(); + }); + + it("shows error message when fetch fails", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network unavailable")); + + render(); + + await waitFor(() => expect(screen.getByTestId("reliability-error")).toBeInTheDocument()); + expect(screen.getByRole("alert")).toHaveTextContent("Network unavailable"); + expect(screen.queryByRole("heading", { name: "Reliability" })).not.toBeInTheDocument(); + }); + + it("shows data after successful load even if loading refresh is pending", async () => { + vi.useFakeTimers(); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce({ ok: true, json: async () => baseResponse } as Response) + .mockReturnValueOnce(new Promise(() => {})); + + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText("80.0%")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(60_000); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(screen.getByText("80.0%")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Reliability" })).toBeInTheDocument(); + expect(screen.queryByTestId("reliability-loading")).not.toBeInTheDocument(); + vi.useRealTimers(); + }); + it("renders headline percent and details disclosure", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response); render(); @@ -90,10 +135,10 @@ describe("ReliabilityView", () => { vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => ({ ...baseResponse, perDay: [] }) } as Response); const { container } = render(); - const root = container.querySelector(".reliability-view"); - expect(root).not.toBeNull(); + await waitFor(() => expect(container.querySelector(".reliability-view")).not.toBeNull()); + const root = container.querySelector(".reliability-view") as HTMLElement; - const computed = getComputedStyle(root as HTMLElement); + const computed = getComputedStyle(root); expect(computed.overflowY).toBe("auto"); expect(computed.height).not.toBe(""); });