feat(FN-4608): complete Step 4 — add reliability reset action
Fusion-Task-Id: FN-4608 Fusion-Task-Lineage: 6cabc509-38a1-4cb8-be6f-51c53d5753ff
This commit is contained in:
@@ -27,8 +27,7 @@ function formatDuration(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "—";
|
||||
}
|
||||
const minutes = value / 60_000;
|
||||
return `${minutes.toFixed(1)}m`;
|
||||
return `${(value / 60_000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
@@ -36,15 +35,14 @@ function formatDateTime(value: string | null | undefined): string {
|
||||
return "—";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export function ReliabilityView() {
|
||||
const [data, setData] = useState<ReliabilityResponse | 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");
|
||||
@@ -55,6 +53,16 @@ export function ReliabilityView() {
|
||||
setData(payload);
|
||||
}, []);
|
||||
|
||||
const resetStats = useCallback(async () => {
|
||||
setResetError(null);
|
||||
const response = await fetch("/api/health/reliability/reset", { method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to reset reliability metrics (${response.status})`);
|
||||
}
|
||||
await load();
|
||||
setShowResetConfirm(false);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const pollInterval = setInterval(() => {
|
||||
@@ -65,35 +73,18 @@ export function ReliabilityView() {
|
||||
|
||||
const headlineColorVar = useMemo(() => {
|
||||
const rate = data?.headline.inReviewFailureRate7d;
|
||||
if (rate === null || rate === undefined) {
|
||||
return "var(--text-muted)";
|
||||
}
|
||||
if (rate < 0.05) {
|
||||
return "var(--color-success)";
|
||||
}
|
||||
if (rate < 0.1) {
|
||||
return "var(--color-warning)";
|
||||
}
|
||||
if (rate === null || rate === undefined) return "var(--text-muted)";
|
||||
if (rate < 0.05) return "var(--color-success)";
|
||||
if (rate < 0.1) return "var(--color-warning)";
|
||||
return "var(--color-error)";
|
||||
}, [data]);
|
||||
|
||||
const totalEntered = useMemo(
|
||||
() => (data?.perDay ?? []).reduce((sum, row) => sum + row.tasksEnteredInReview, 0),
|
||||
[data?.perDay],
|
||||
);
|
||||
const totalBounced = useMemo(
|
||||
() => (data?.perDay ?? []).reduce((sum, row) => sum + row.tasksBouncedToInProgress, 0),
|
||||
[data?.perDay],
|
||||
);
|
||||
const totalEntered = useMemo(() => (data?.perDay ?? []).reduce((sum, row) => sum + row.tasksEnteredInReview, 0), [data?.perDay]);
|
||||
const totalBounced = useMemo(() => (data?.perDay ?? []).reduce((sum, row) => sum + row.tasksBouncedToInProgress, 0), [data?.perDay]);
|
||||
|
||||
const perDayRows = useMemo(() => {
|
||||
if (!data?.perDay) {
|
||||
return [];
|
||||
}
|
||||
if (showEmptyDays) {
|
||||
return data.perDay;
|
||||
}
|
||||
return data.perDay.filter((row) => row.hasSamples !== false);
|
||||
if (!data?.perDay) return [];
|
||||
return showEmptyDays ? data.perDay : data.perDay.filter((row) => row.hasSamples !== false);
|
||||
}, [data?.perDay, showEmptyDays]);
|
||||
|
||||
const mergeAttemptTaskCount = useMemo(
|
||||
@@ -101,20 +92,28 @@ export function ReliabilityView() {
|
||||
[data?.mergeAttempts.histogram],
|
||||
);
|
||||
|
||||
const windowStartLabel = data
|
||||
? formatDateTime(data.resetAt ?? new Date(Date.parse(data.generatedAt) - data.windowDays * 86_400_000).toISOString())
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<section className="reliability-view">
|
||||
<div className="card reliability-card reliability-headline-card">
|
||||
<h2>Reliability</h2>
|
||||
<div className="reliability-section-header">
|
||||
<h2>Reliability</h2>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setShowResetConfirm(true)}>Reset stats</button>
|
||||
</div>
|
||||
<div className="reliability-headline" style={{ color: headlineColorVar }}>
|
||||
{data?.headline.inReviewFailureRate7d === null || data?.headline.inReviewFailureRate7d === undefined
|
||||
? `Insufficient data — ${data?.headline.reason ?? "unknown"}`
|
||||
: formatPercent(data.headline.inReviewFailureRate7d)}
|
||||
</div>
|
||||
{data?.resetAt ? <div className="reliability-muted">{`Counting since ${formatDateTime(data.resetAt)}`}</div> : null}
|
||||
<details className="reliability-details">
|
||||
<summary>Details</summary>
|
||||
<div className="reliability-details-content">
|
||||
<div>{`${totalBounced} bounced / ${totalEntered} entered (last ${data?.windowDays ?? 7}d)`}</div>
|
||||
<div>{`Window: ${data ? formatDateTime(data.resetAt ?? new Date(Date.parse(data.generatedAt) - data.windowDays * 86_400_000).toISOString()) : "—"} → ${formatDateTime(data?.generatedAt)}`}</div>
|
||||
<div>{`Window: ${windowStartLabel} → ${formatDateTime(data?.generatedAt)}`}</div>
|
||||
{data?.resetAt ? <div>{`Reset baseline: ${formatDateTime(data.resetAt)}`}</div> : null}
|
||||
{data?.headline.reason ? <div>{`Reason: ${data.headline.reason}`}</div> : null}
|
||||
</div>
|
||||
@@ -130,20 +129,10 @@ export function ReliabilityView() {
|
||||
</button>
|
||||
</div>
|
||||
<table className="reliability-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Entered</th>
|
||||
<th>Bounced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>Date</th><th>Entered</th><th>Bounced</th></tr></thead>
|
||||
<tbody>
|
||||
{perDayRows.map((row) => (
|
||||
<tr key={row.date}>
|
||||
<td>{row.date}</td>
|
||||
<td>{row.tasksEnteredInReview}</td>
|
||||
<td>{row.tasksBouncedToInProgress}</td>
|
||||
</tr>
|
||||
<tr key={row.date}><td>{row.date}</td><td>{row.tasksEnteredInReview}</td><td>{row.tasksBouncedToInProgress}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -188,6 +177,29 @@ export function ReliabilityView() {
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showResetConfirm ? (
|
||||
<div className="modal-overlay open" role="presentation">
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="reliability-reset-title">
|
||||
<div className="modal-header"><h2 id="reliability-reset-title">Reset reliability stats?</h2></div>
|
||||
<p>This sets a new baseline for reliability statistics. Historical events older than the reset time are excluded from counts but are not deleted.</p>
|
||||
{resetError ? <div className="form-error">{resetError}</div> : null}
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setShowResetConfirm(false)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => {
|
||||
void resetStats().catch((error: unknown) => {
|
||||
setResetError(error instanceof Error ? error.message : "Failed to reset reliability stats");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,93 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ReliabilityView } from "../ReliabilityView";
|
||||
|
||||
const baseResponse = {
|
||||
windowDays: 7,
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
resetAt: null,
|
||||
headline: { inReviewFailureRate7d: 0.2 },
|
||||
perDay: [
|
||||
{
|
||||
date: "2026-05-12",
|
||||
tasksEnteredInReview: 0,
|
||||
tasksBouncedToInProgress: 0,
|
||||
postMergeAuditFailures: null,
|
||||
fileScopeInvariantFailures: null,
|
||||
recoverAlreadyMergedReviewTasksRecoveries: null,
|
||||
hasSamples: false,
|
||||
},
|
||||
{
|
||||
date: "2026-05-13",
|
||||
tasksEnteredInReview: 10,
|
||||
tasksBouncedToInProgress: 2,
|
||||
postMergeAuditFailures: null,
|
||||
fileScopeInvariantFailures: null,
|
||||
recoverAlreadyMergedReviewTasksRecoveries: null,
|
||||
hasSamples: true,
|
||||
},
|
||||
],
|
||||
duration: { p50Ms: 60_000, p95Ms: 120_000, sampleCount: 3 },
|
||||
mergeAttempts: { mean: 1.2, max: 2, histogram: { "1": 1 } },
|
||||
};
|
||||
|
||||
describe("ReliabilityView", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders headline percent and per-day row", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
windowDays: 7,
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
headline: { inReviewFailureRate7d: 0.2 },
|
||||
perDay: [
|
||||
{
|
||||
date: "2026-05-13",
|
||||
tasksEnteredInReview: 10,
|
||||
tasksBouncedToInProgress: 2,
|
||||
postMergeAuditFailures: null,
|
||||
fileScopeInvariantFailures: null,
|
||||
recoverAlreadyMergedReviewTasksRecoveries: null,
|
||||
},
|
||||
],
|
||||
duration: { p50Ms: 60_000, p95Ms: 120_000, sampleCount: 3 },
|
||||
mergeAttempts: { mean: 1.2, max: 2, histogram: { "1": 1 } },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
it("renders headline percent and details disclosure", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response);
|
||||
render(<ReliabilityView />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("20.0%")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText("Details"));
|
||||
expect(screen.getByText("2 bounced / 10 entered (last 7d)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides zero-sample days by default and reveals with toggle", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response);
|
||||
render(<ReliabilityView />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2026-05-13")).toBeInTheDocument());
|
||||
expect(screen.queryByText("2026-05-12")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show empty days" }));
|
||||
expect(screen.getByText("2026-05-12")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens reset modal and confirms reset with refetch", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => baseResponse } as Response)
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ resetAt: "2026-05-13T01:00:00.000Z" }) } as Response)
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ ...baseResponse, resetAt: "2026-05-13T01:00:00.000Z" }) } as Response);
|
||||
|
||||
render(<ReliabilityView />);
|
||||
await waitFor(() => expect(screen.getByText("20.0%")).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset stats" }));
|
||||
expect(screen.getByText("Reset reliability stats?")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm reset" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("20.0%")).toBeInTheDocument();
|
||||
expect(fetchSpy).toHaveBeenCalledWith("/api/health/reliability/reset", { method: "POST" });
|
||||
});
|
||||
expect(screen.getByText("2026-05-13")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText(/Counting since/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders duration more-stats raw metrics", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response);
|
||||
render(<ReliabilityView />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("20.0%")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getAllByText("More stats")[0] as HTMLElement);
|
||||
expect(screen.getByText("P50 raw: 60000 ms")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sample count: 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FN-4594: root container provides vertical scroll contract", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
windowDays: 7,
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
headline: { inReviewFailureRate7d: 0.2 },
|
||||
perDay: [],
|
||||
duration: { p50Ms: 60_000, p95Ms: 120_000, sampleCount: 3 },
|
||||
mergeAttempts: { mean: 1.2, max: 2, histogram: { "1": 1 } },
|
||||
}),
|
||||
} as Response);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => ({ ...baseResponse, perDay: [] }) } as Response);
|
||||
|
||||
const { container } = render(<ReliabilityView />);
|
||||
const root = container.querySelector(".reliability-view");
|
||||
@@ -64,19 +102,14 @@ describe("ReliabilityView", () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
windowDays: 7,
|
||||
generatedAt: "2026-05-13T00:00:00.000Z",
|
||||
...baseResponse,
|
||||
headline: { inReviewFailureRate7d: null, reason: "no-in-review-entries" },
|
||||
perDay: [],
|
||||
duration: { p50Ms: null, p95Ms: null, sampleCount: 0, reason: "insufficient-samples" },
|
||||
mergeAttempts: { mean: null, max: null, histogram: {}, reason: "no-audit-coverage" },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ReliabilityView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Insufficient data — no-in-review-entries")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("Insufficient data — no-in-review-entries")).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user