diff --git a/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card-model.ts b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card-model.ts new file mode 100644 index 0000000000..ca7c9ecabe --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card-model.ts @@ -0,0 +1,63 @@ +// Reversal-rate + auto-action health analytics card model (#2193). UI-only display slice: the card consumes an +// agent-health shape assumed present on the operator-dashboard payload (backend computation is computeAgentHealth +// in src/review/ops.ts). Types + the pure rate/band helpers live here (not in the .tsx) so the component file +// exports only components (react-refresh/only-export-components). + +import type { Status } from "@/components/site/control-primitives"; + +/** A bot auto-action a human overrode (a revert of a bot-merge / a reopen of a bot-close). Mirrors + * src/review/ops.ts ReversedTarget. Public-safe: PR number + repo + status, no scores/rewards. */ +export interface ReversedTarget { + number: number; + repo: string; + status: string; + eventType: string; +} + +/** The agent-health slice delivered on the operator-dashboard payload: how often humans reopened/reverted a bot + * auto-action over a rolling window. Public-safe counts only (mirrors the reversal fields of + * src/review/ops.ts AgentHealth). */ +export interface ReversalHealthReport { + /** Bot auto-actions a human overrode in the window. */ + reversals: number; + /** reversals / recentAutoActions; 0 when no auto-actions were taken. */ + reversalRate: number; + /** Share of terminal targets that took the manual (human) path rather than an auto-action. */ + manualRate: number; + /** Auto-actions taken in the window — the reversal-rate denominator. */ + recentAutoActions: number; + /** The specific overridden targets, for the detail list. */ + reversedTargets: ReversedTarget[]; + /** Rolling measurement window, in days. */ + windowDays: number; +} + +/** The card's derived view: the raw counts plus display percentages (a null rate when nothing was auto-actioned). */ +export interface ReversalHealthSummary { + reversals: number; + /** reversalRate as a percentage; null when the denominator (recentAutoActions) is 0 — nothing to be a rate of. */ + reversalRatePct: number | null; + manualRatePct: number; + recentAutoActions: number; + reversedCount: number; +} + +/** Pure fold: derive the display summary from the raw health counts. An empty denominator yields a null rate. */ +export function summarizeReversalHealth(report: ReversalHealthReport): ReversalHealthSummary { + return { + reversals: report.reversals, + reversalRatePct: report.recentAutoActions > 0 ? report.reversalRate * 100 : null, + manualRatePct: report.manualRate * 100, + recentAutoActions: report.recentAutoActions, + reversedCount: report.reversedTargets.length, + }; +} + +/** StatusPill quality band for reversal health: no auto-actions yet is informational (no signal); zero reversals + * over real auto-actions reads healthy; a reversal rate at or under 10% warns; above that blocks (humans are + * frequently overriding the bot). Mirrors the Status vocabulary in control-primitives.ts. */ +export function bandForReversalHealth(report: ReversalHealthReport): Status { + if (report.recentAutoActions === 0) return "info"; + if (report.reversals === 0) return "ready"; + return report.reversalRate <= 0.1 ? "warn" : "blocked"; +} diff --git a/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.test.tsx new file mode 100644 index 0000000000..ad26038778 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ReversalHealthCard } from "@/components/site/app-panels/reversal-health-card"; +import { + bandForReversalHealth, + summarizeReversalHealth, +} from "@/components/site/app-panels/reversal-health-card-model"; + +const base = { + reversals: 0, + reversalRate: 0, + manualRate: 0.2, + recentAutoActions: 10, + reversedTargets: [], + windowDays: 30, +}; + +describe("summarizeReversalHealth", () => { + it("derives the percentages and the reversed-target count", () => { + expect( + summarizeReversalHealth({ + ...base, + reversals: 2, + reversalRate: 0.2, + reversedTargets: [{ number: 1, repo: "o/r", status: "reverted", eventType: "auto-merge" }], + }), + ).toEqual({ + reversals: 2, + reversalRatePct: 20, + manualRatePct: 20, + recentAutoActions: 10, + reversedCount: 1, + }); + }); + + it("returns a null rate for an empty denominator (no auto-actions)", () => { + expect(summarizeReversalHealth({ ...base, recentAutoActions: 0 }).reversalRatePct).toBeNull(); + }); +}); + +describe("bandForReversalHealth", () => { + it("bands: no auto-actions info, zero reversals ready, <=10% warn, above blocked", () => { + expect(bandForReversalHealth({ ...base, recentAutoActions: 0 })).toBe("info"); + expect(bandForReversalHealth({ ...base, reversals: 0, recentAutoActions: 10 })).toBe("ready"); + expect( + bandForReversalHealth({ ...base, reversals: 1, reversalRate: 0.1, recentAutoActions: 10 }), + ).toBe("warn"); + expect( + bandForReversalHealth({ ...base, reversals: 5, reversalRate: 0.5, recentAutoActions: 10 }), + ).toBe("blocked"); + }); +}); + +describe("ReversalHealthCard", () => { + it("renders the reversal rate, counts, and the reversed-target list when present", () => { + render( + , + ); + expect(screen.getByText("Auto-action reversal health")).toBeTruthy(); + expect(screen.getByText("20%")).toBeTruthy(); + expect(screen.getByText("35%")).toBeTruthy(); + expect(screen.getByText("30-day window")).toBeTruthy(); + expect(screen.getByText("o/r#7 — auto-merge (reverted)")).toBeTruthy(); + }); + + it("renders '—' for the rate and an empty list state when there are no auto-actions", () => { + render(); + expect(screen.getByText("—")).toBeTruthy(); + expect(screen.getByText("No reversed auto-actions")).toBeTruthy(); + }); + + it("renders a graceful EmptyState when the health field is absent", () => { + render(); + expect(screen.getByText("Auto-action health not yet available")).toBeTruthy(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.tsx new file mode 100644 index 0000000000..09206b2c82 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/reversal-health-card.tsx @@ -0,0 +1,76 @@ +import { Stat, StatusPill } from "@/components/site/control-primitives"; +import { EmptyState } from "@/components/site/state-views"; + +import { + bandForReversalHealth, + summarizeReversalHealth, + type ReversalHealthReport, +} from "./reversal-health-card-model"; + +/** Self-host analytics card (#2193): auto-action reversal health — how often a human reopened or reverted a bot + * auto-action (computeAgentHealth, src/review/ops.ts). Reversal rate as a percentage plus reversals / manual-rate + * counts and the specific overridden targets. UI-only display slice; the health shape is assumed present on the + * operator-dashboard payload (backend computation is #1967), so absence renders a graceful "not yet available" + * EmptyState. */ +export function ReversalHealthCard({ health }: { health?: ReversalHealthReport }) { + if (!health) { + return ( + + ); + } + const summary = summarizeReversalHealth(health); + return ( + + + + Auto-action reversal health + + How often a human reopened or reverted a bot auto-action. Public-safe counts only. + + + {`${health.windowDays}-day window`} + + + reversals / auto-actions} + /> + auto-actions a human overrode} + /> + terminal targets taken manually} + /> + + {summary.reversedCount > 0 ? ( + + {health.reversedTargets.map((target) => ( + + {`${target.repo}#${target.number} — ${target.eventType} (${target.status})`} + + ))} + + ) : ( + + + + )} + + ); +} diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx index ec1d4b16b9..4b07a44dfa 100644 --- a/apps/gittensory-ui/src/routes/app.analytics.tsx +++ b/apps/gittensory-ui/src/routes/app.analytics.tsx @@ -13,6 +13,8 @@ import { GatePrecisionCard } from "@/components/site/app-panels/gate-precision-c import type { GateEvalReport } from "@/components/site/app-panels/gate-precision-card-model"; import { CycleTimeCard } from "@/components/site/app-panels/cycle-time-card"; import type { CycleTimeAggregate } from "@/components/site/app-panels/cycle-time-card-model"; +import { ReversalHealthCard } from "@/components/site/app-panels/reversal-health-card"; +import type { ReversalHealthReport } from "@/components/site/app-panels/reversal-health-card-model"; import { useApiResource } from "@/lib/api/use-api-resource"; export const Route = createFileRoute("/app/analytics")({ @@ -104,6 +106,7 @@ type OperatorDashboard = { upstreamDrift?: { status?: string; openReportCount?: number } | null; gateEval?: GateEvalReport; cycleTime?: CycleTimeAggregate; + agentHealth?: ReversalHealthReport; }; function ProductAnalytics() { @@ -188,6 +191,8 @@ function ProductAnalytics() { {data.cycleTime ? : null} + {data.agentHealth ? : null} + {data.usageSummary ? (
+ How often a human reopened or reverted a bot auto-action. Public-safe counts only. +