From 82068f0b5950a65bbe2256cfa2bd14e19104eca8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 19 Jun 2026 08:06:38 -0700 Subject: [PATCH] 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 --- .../app/components/ReliabilityView.css | 37 +++++++++ .../app/components/ReliabilityView.tsx | 50 +++++++++++- .../__tests__/ReliabilityView.test.tsx | 76 ++++++++++++++++++- 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/packages/dashboard/app/components/ReliabilityView.css b/packages/dashboard/app/components/ReliabilityView.css index 7338802f96..c45d2dd601 100644 --- a/packages/dashboard/app/components/ReliabilityView.css +++ b/packages/dashboard/app/components/ReliabilityView.css @@ -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)); + } } diff --git a/packages/dashboard/app/components/ReliabilityView.tsx b/packages/dashboard/app/components/ReliabilityView.tsx index a655402672..43c63c9012 100644 --- a/packages/dashboard/app/components/ReliabilityView.tsx +++ b/packages/dashboard/app/components/ReliabilityView.tsx @@ -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(() => { + 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( + () => 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")} +
+

{t("reliability.flowChart.heading", "Entered vs bounced trend")}

+ +
@@ -196,8 +230,16 @@ export function ReliabilityView() {

{t("reliability.mergeAttempts.heading", "Merge attempts")}

{t("reliability.mergeAttempts.mean", "Mean")}{data?.mergeAttempts.mean?.toFixed(2) ?? "—"}
{t("reliability.mergeAttempts.max", "Max")}{data?.mergeAttempts.max ?? "—"}
+
+

{t("reliability.mergeAttemptsChart.heading", "Attempts distribution")}

+ +
    - {Object.entries(data?.mergeAttempts.histogram ?? {}).map(([bucket, count]) => ( + {mergeAttemptsHistogramEntries.map(([bucket, count]) => (
  • {bucket}
    diff --git a/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx b/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx index daf99a8151..6576f5204d 100644 --- a/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ReliabilityView.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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)
{t("reliability.table.date", "Date")}{t("reliability.table.entered", "Entered")}{t("reliability.table.bounced", "Bounced")}