FN-6097: fix reliability view loading and error rendering

Improve ReliabilityView so pending and failed fetches render explicit UI states.

- add dedicated loading and error states to ReliabilityView while preserving existing data during background refreshes
- style the new reliability loading and error containers in the dashboard view CSS
- extend ReliabilityView tests to cover loading, fetch failure, and refresh behavior alongside the existing layout assertion

Files changed:
 packages/dashboard/app/components/ReliabilityView.css  | 25 ++++++++++
 packages/dashboard/app/components/ReliabilityView.tsx  | 41 ++++++++++++++---
 packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx | 53 ++++++++++++++++++++--
 3 files changed, 109 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-6097

Fusion-Task-Lineage: c6949cc6-4383-4e53-8467-5656a101199e
This commit is contained in:
gsxdsm
2026-06-09 10:22:19 -07:00
parent 55ba994ae9
commit 14bed7d897
3 changed files with 109 additions and 10 deletions

View File

@@ -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;

View File

@@ -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<ReliabilityResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showEmptyDays, setShowEmptyDays] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [resetError, setResetError] = useState<string | null>(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 (
<div className="reliability-loading" data-testid="reliability-loading">
<Loader2 size={24} className="spin" />
<p>{t("reliability.loading", "Loading reliability data...")}</p>
</div>
);
}
if (error !== null && data === null) {
return (
<div className="reliability-error" data-testid="reliability-error" role="alert">
<AlertCircle size={24} />
<p>{error}</p>
</div>
);
}
return (
<section className="reliability-view">
<div className="card reliability-card reliability-headline-card">

View File

@@ -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<Response>(() => {}));
render(<ReliabilityView />);
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(<ReliabilityView />);
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<Response>(() => {}));
render(<ReliabilityView />);
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(<ReliabilityView />);
@@ -90,10 +135,10 @@ describe("ReliabilityView", () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => ({ ...baseResponse, perDay: [] }) } as Response);
const { container } = render(<ReliabilityView />);
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("");
});