diff --git a/src/Web/autopilot-monitor-web/app/fleet/lib/__tests__/fleetRollup.test.ts b/src/Web/autopilot-monitor-web/app/fleet/lib/__tests__/fleetRollup.test.ts index 8dfa7565..ace8a4d7 100644 --- a/src/Web/autopilot-monitor-web/app/fleet/lib/__tests__/fleetRollup.test.ts +++ b/src/Web/autopilot-monitor-web/app/fleet/lib/__tests__/fleetRollup.test.ts @@ -37,16 +37,28 @@ describe("computeFleetRollup", () => { expect(r.failedLastNDays).toBe(2); }); - it("weights the success rate by session volume, not by tenant", () => { - // A: 900/1000 = 90%, B: 0/10 = 0%. A naive average would be 45%; the weighted rate is 900/1010 ≈ 89.1%. + it("weights the success rate by terminal-session volume, not by tenant", () => { + // A: 900 succeeded / 100 failed = 90%, B: 0 succeeded / 10 failed = 0%. A naive average would be 45%; + // the weighted terminal rate is 900 / (900 + 100 + 10) = 900/1010 ≈ 89.1%. const r = computeFleetRollup([ - summary({ totalLastNDays: 1000, succeededLastNDays: 900 }), - summary({ totalLastNDays: 10, succeededLastNDays: 0 }), + summary({ succeededLastNDays: 900, failedLastNDays: 100, totalLastNDays: 1000 }), + summary({ succeededLastNDays: 0, failedLastNDays: 10, totalLastNDays: 10 }), ]); expect(r.successRatePct).toBeCloseTo(89.1, 1); }); - it("reports 0% success when the fleet has no sessions", () => { + it("excludes active/pending sessions from the success-rate denominator (terminal-only)", () => { + // Matches the per-tenant card / backend semantic: 90 succeeded, 10 failed, 900 active. The card shows + // 90/(90+10) = 90%; a total-based roll-up would wrongly show 90/1000 = 9%. Lock in terminal-only. + const r = computeFleetRollup([ + summary({ succeededLastNDays: 90, failedLastNDays: 10, activeCount: 900, totalLastNDays: 1000 }), + ]); + expect(r.successRatePct).toBe(90); + expect(r.activeCount).toBe(900); + expect(r.totalLastNDays).toBe(1000); + }); + + it("reports 0% success when the fleet has no terminal sessions", () => { const r = computeFleetRollup([summary({ activeCount: 1 }), summary({})]); expect(r.totalLastNDays).toBe(0); expect(r.successRatePct).toBe(0); diff --git a/src/Web/autopilot-monitor-web/app/fleet/lib/fleetRollup.ts b/src/Web/autopilot-monitor-web/app/fleet/lib/fleetRollup.ts index ac3bf61b..50dee9d7 100644 --- a/src/Web/autopilot-monitor-web/app/fleet/lib/fleetRollup.ts +++ b/src/Web/autopilot-monitor-web/app/fleet/lib/fleetRollup.ts @@ -18,14 +18,20 @@ export interface FleetRollup { totalLastNDays: number; succeededLastNDays: number; failedLastNDays: number; - /** Weighted success rate across the fleet (sum succeeded / sum total), 1 decimal. 0 when no sessions. */ + /** + * Weighted fleet success rate, 1 decimal. TERMINAL-ONLY: sum(succeeded) / (sum(succeeded) + sum(failed)) — + * matches the per-tenant card / backend DashboardStats semantic (succeeded over terminal sessions), so the + * roll-up and the cards never disagree. Active/pending sessions are NOT in the denominator. 0 when the + * fleet has no terminal sessions. + */ successRatePct: number; } /** - * Aggregates per-tenant summaries into a fleet roll-up. The success rate is WEIGHTED by session volume - * (sum of succeeded / sum of total) — not an average of per-tenant rates — so a tenant with 1000 sessions - * counts more than one with 3. Pure + testable. + * Aggregates per-tenant summaries into a fleet roll-up. The success rate is WEIGHTED by terminal-session + * volume (sum succeeded / sum terminal) — not an average of per-tenant rates — so a tenant with 1000 + * terminal sessions counts more than one with 3. Terminal-only matches the backend's per-tenant rate, so a + * tenant card and the roll-up tile use the same definition. Pure + testable. */ export function computeFleetRollup(summaries: FleetSummary[]): FleetRollup { const acc = summaries.reduce( @@ -39,10 +45,9 @@ export function computeFleetRollup(summaries: FleetSummary[]): FleetRollup { { activeCount: 0, totalLastNDays: 0, succeededLastNDays: 0, failedLastNDays: 0 } ); + const terminal = acc.succeededLastNDays + acc.failedLastNDays; const successRatePct = - acc.totalLastNDays > 0 - ? Math.round((acc.succeededLastNDays / acc.totalLastNDays) * 1000) / 10 - : 0; + terminal > 0 ? Math.round((acc.succeededLastNDays / terminal) * 1000) / 10 : 0; return { tenantCount: summaries.length, ...acc, successRatePct }; } diff --git a/src/Web/autopilot-monitor-web/app/fleet/page.tsx b/src/Web/autopilot-monitor-web/app/fleet/page.tsx index 10f05bd0..dbedb707 100644 --- a/src/Web/autopilot-monitor-web/app/fleet/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet/page.tsx @@ -68,7 +68,7 @@ export default function FleetPage() { 0 ? "danger" : "default"} /> - + {myTenants.length === 0 ? ( @@ -88,9 +88,13 @@ export default function FleetPage() { type Tone = "default" | "danger"; -/** Amber/red emphasis only once we actually have sessions to judge — an empty tenant isn't "failing". */ -function successTone(pct: number, total: number): Tone { - if (total === 0) return "default"; +/** + * Red emphasis only once there are TERMINAL sessions to judge — the success rate is terminal-only + * (succeeded / succeeded+failed), so a tenant with only active/pending sessions reads 0% but isn't + * "failing"; don't flag it. + */ +function successTone(pct: number, terminalCount: number): Tone { + if (terminalCount === 0) return "default"; return pct < 80 ? "danger" : "default"; } @@ -123,7 +127,7 @@ function FleetCard({ tenant, summary }: { tenant: TenantInfo; summary?: FleetSum