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:
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user