FN-6719: add reliability charts to Command Center
Adds visual reliability trends and distributions alongside the existing Command Center reliability tables. - Render an entered-vs-bounced line chart from the filtered reliability per-day rows. - Render a merge-attempts pie chart from the sorted histogram entries. - Add scoped chart layout styles and regression coverage for populated, empty, and filtered chart states. Files changed: .../dashboard/app/components/ReliabilityView.css | 37 +++++++++++ .../dashboard/app/components/ReliabilityView.tsx | 50 ++++++++++++-- .../components/__tests__/ReliabilityView.test.tsx | 76 +++++++++++++++++++++- 3 files changed, 158 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-6719 Fusion-Task-Lineage: 4b366f66-1fa2-4014-a51a-cb1c992447db
This commit is contained in:
@@ -72,6 +72,34 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Reliability 2026-06-19-00:00:
|
||||
Reliability charts use shared recharts wrappers, whose ResponsiveContainer needs a measurable parent block-size. Keep the scoped section token-sized so the new charts render while preserving the view's flex-fill scroll contract.
|
||||
*/
|
||||
.reliability-chart-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
min-inline-size: 0;
|
||||
margin-block-start: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.reliability-chart-section h4 {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reliability-chart-section .cc-recharts-chart,
|
||||
.reliability-chart-section .cc-recharts-empty {
|
||||
block-size: clamp(calc(var(--space-2xl) * 4), 34vh, calc(var(--space-2xl) * 7));
|
||||
}
|
||||
|
||||
.reliability-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -159,4 +187,13 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.reliability-chart-section {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.reliability-chart-section .cc-recharts-chart,
|
||||
.reliability-chart-section .cc-recharts-empty {
|
||||
block-size: clamp(calc(var(--space-2xl) * 3), 46vh, calc(var(--space-2xl) * 6));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { LineChart, PieChart } from "./command-center/charts/recharts";
|
||||
import type { LineChartSeries, PieChartDatum } from "./command-center/charts/recharts";
|
||||
import "./ReliabilityView.css";
|
||||
|
||||
type ReliabilityResponse = {
|
||||
@@ -102,14 +104,38 @@ export function ReliabilityView() {
|
||||
|
||||
const perDayRows = useMemo(() => {
|
||||
if (!data?.perDay) return [];
|
||||
return showEmptyDays ? data.perDay : data.perDay.filter((row) => row.hasSamples !== false);
|
||||
const filteredRows = showEmptyDays ? data.perDay : data.perDay.filter((row) => row.hasSamples !== false);
|
||||
return [...filteredRows].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}, [data?.perDay, showEmptyDays]);
|
||||
|
||||
const mergeAttemptTaskCount = useMemo(
|
||||
() => Object.values(data?.mergeAttempts.histogram ?? {}).reduce((sum, count) => sum + count, 0),
|
||||
/*
|
||||
FNXC:Reliability 2026-06-19-00:00:
|
||||
The in-review trend chart must reuse the same perDayRows source as the table, including the Show empty days filter, so visual and tabular reliability surfaces never disagree about which dates are represented.
|
||||
*/
|
||||
const flowChartSeries = useMemo<LineChartSeries[]>(() => {
|
||||
const hasFlowSamples = perDayRows.some((row) => row.tasksEnteredInReview > 0 || row.tasksBouncedToInProgress > 0);
|
||||
if (!hasFlowSamples) return [];
|
||||
return [
|
||||
{ label: t("reliability.flowChart.entered", "Entered"), values: perDayRows.map((row) => row.tasksEnteredInReview) },
|
||||
{ label: t("reliability.flowChart.bounced", "Bounced"), values: perDayRows.map((row) => row.tasksBouncedToInProgress) },
|
||||
];
|
||||
}, [perDayRows, t]);
|
||||
|
||||
const mergeAttemptsHistogramEntries = useMemo(
|
||||
() => Object.entries(data?.mergeAttempts.histogram ?? {}).sort(([left], [right]) => left.localeCompare(right, undefined, { numeric: true })),
|
||||
[data?.mergeAttempts.histogram],
|
||||
);
|
||||
|
||||
const mergeAttemptsChartData = useMemo<PieChartDatum[]>(
|
||||
() => mergeAttemptsHistogramEntries.map(([bucket, count]) => ({ label: bucket, value: count })),
|
||||
[mergeAttemptsHistogramEntries],
|
||||
);
|
||||
|
||||
const mergeAttemptTaskCount = useMemo(
|
||||
() => mergeAttemptsHistogramEntries.reduce((sum, [, count]) => sum + count, 0),
|
||||
[mergeAttemptsHistogramEntries],
|
||||
);
|
||||
|
||||
const windowStartLabel = data
|
||||
? formatDateTime(data.resetAt ?? new Date(Date.parse(data.generatedAt) - data.windowDays * 86_400_000).toISOString())
|
||||
: "—";
|
||||
@@ -166,6 +192,14 @@ export function ReliabilityView() {
|
||||
{showEmptyDays ? t("reliability.hideEmptyDays", "Hide empty days") : t("reliability.showEmptyDays", "Show empty days")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="reliability-chart-section" data-testid="reliability-flow-chart">
|
||||
<h4>{t("reliability.flowChart.heading", "Entered vs bounced trend")}</h4>
|
||||
<LineChart
|
||||
series={flowChartSeries}
|
||||
ariaLabel={t("reliability.flowChart.aria", "In-review entered vs bounced per day")}
|
||||
emptyLabel={t("reliability.flowChart.empty", "No in-review flow data")}
|
||||
/>
|
||||
</div>
|
||||
<table className="reliability-table">
|
||||
<thead><tr><th>{t("reliability.table.date", "Date")}</th><th>{t("reliability.table.entered", "Entered")}</th><th>{t("reliability.table.bounced", "Bounced")}</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -196,8 +230,16 @@ export function ReliabilityView() {
|
||||
<h3>{t("reliability.mergeAttempts.heading", "Merge attempts")}</h3>
|
||||
<div className="reliability-stat-row"><span>{t("reliability.mergeAttempts.mean", "Mean")}</span><strong>{data?.mergeAttempts.mean?.toFixed(2) ?? "—"}</strong></div>
|
||||
<div className="reliability-stat-row"><span>{t("reliability.mergeAttempts.max", "Max")}</span><strong>{data?.mergeAttempts.max ?? "—"}</strong></div>
|
||||
<div className="reliability-chart-section" data-testid="reliability-merge-attempts-chart">
|
||||
<h4>{t("reliability.mergeAttemptsChart.heading", "Attempts distribution")}</h4>
|
||||
<PieChart
|
||||
data={mergeAttemptsChartData}
|
||||
ariaLabel={t("reliability.mergeAttemptsChart.aria", "Merge attempts histogram")}
|
||||
emptyLabel={t("reliability.mergeAttemptsChart.empty", "No merge attempt data")}
|
||||
/>
|
||||
</div>
|
||||
<ul className="reliability-histogram">
|
||||
{Object.entries(data?.mergeAttempts.histogram ?? {}).map(([bucket, count]) => (
|
||||
{mergeAttemptsHistogramEntries.map(([bucket, count]) => (
|
||||
<li key={bucket}>
|
||||
<span>{bucket}</span>
|
||||
<div className="reliability-histogram-bar-wrap"><div className="reliability-histogram-bar" style={{ width: `${Math.min(count * 20, 100)}%` }} /></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ReliabilityView } from "../ReliabilityView";
|
||||
@@ -167,6 +167,80 @@ describe("ReliabilityView", () => {
|
||||
expect(screen.getByText("2026-05-12")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the in-review flow chart for populated reliability data", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response);
|
||||
render(<ReliabilityView />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "In-review entered vs bounced per day" })).toBeInTheDocument());
|
||||
expect(within(screen.getByTestId("reliability-flow-chart")).queryByText("No in-review flow data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the merge-attempts chart for populated reliability data", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => baseResponse } as Response);
|
||||
render(<ReliabilityView />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "Merge attempts histogram" })).toBeInTheDocument());
|
||||
expect(within(screen.getByTestId("reliability-merge-attempts-chart")).queryByText("No merge attempt data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chart empty states without throwing when reliability series are empty", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
...baseResponse,
|
||||
headline: { inReviewFailureRate7d: null, reason: "no-in-review-entries" },
|
||||
perDay: [],
|
||||
mergeAttempts: { mean: null, max: null, histogram: {}, reason: "no-audit-coverage" },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ReliabilityView />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "In-review entered vs bounced per day" })).toBeInTheDocument());
|
||||
expect(screen.getByRole("img", { name: "Merge attempts histogram" })).toBeInTheDocument();
|
||||
expect(screen.getByText("No in-review flow data")).toBeInTheDocument();
|
||||
expect(screen.getByText("No merge attempt data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the flow chart source consistent with the Show empty days table toggle", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
...baseResponse,
|
||||
perDay: [
|
||||
{
|
||||
date: "2026-05-12",
|
||||
tasksEnteredInReview: 4,
|
||||
tasksBouncedToInProgress: 1,
|
||||
postMergeAuditFailures: null,
|
||||
fileScopeInvariantFailures: null,
|
||||
recoverAlreadyMergedReviewTasksRecoveries: null,
|
||||
hasSamples: false,
|
||||
},
|
||||
{
|
||||
date: "2026-05-13",
|
||||
tasksEnteredInReview: 0,
|
||||
tasksBouncedToInProgress: 0,
|
||||
postMergeAuditFailures: null,
|
||||
fileScopeInvariantFailures: null,
|
||||
recoverAlreadyMergedReviewTasksRecoveries: null,
|
||||
hasSamples: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ReliabilityView />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2026-05-13")).toBeInTheDocument());
|
||||
expect(screen.queryByText("2026-05-12")).not.toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("reliability-flow-chart")).getByText("No in-review flow data")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show empty days" }));
|
||||
expect(screen.getByText("2026-05-12")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("reliability-flow-chart")).queryByText("No in-review flow data")).not.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)
|
||||
|
||||
Reference in New Issue
Block a user