fix(web): fleet roll-up success rate is terminal-only, matching the cards [Phase 3c follow-up]

Addresses a Codex finding: the per-tenant cards show the backend success rate,
which is terminal-only (succeeded / succeeded+failed). The fleet roll-up instead
divided by total sessions (which includes active/pending), so the tile and the
cards disagreed — e.g. a tenant with 90 succeeded / 10 failed / 900 active shows
90% on its card but the roll-up showed 9%.

- computeFleetRollup: success rate now sum(succeeded) / (sum(succeeded)+sum(failed)),
  excluding active/pending from the denominator — same definition as DashboardStats.
- successTone: red emphasis now gates on terminal-session count (not total), so a
  tenant with only active sessions reads 0% but isn't flagged as failing.
- Tests: replaced the total-based weighted test with terminal-only weighting and
  added an explicit 90/10/900 case locking in 90% (not 9%).

Web tsc clean; 481 web tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-25 22:36:41 +02:00
parent 7c2b5d10cb
commit 8192ff1aa8
3 changed files with 38 additions and 17 deletions
@@ -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);
@@ -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 };
}
@@ -68,7 +68,7 @@ export default function FleetPage() {
<RollupTile label="Tenants" value={String(myTenants.length)} />
<RollupTile label="Active sessions" value={String(rollup.activeCount)} />
<RollupTile label="Failed" value={String(rollup.failedLastNDays)} tone={rollup.failedLastNDays > 0 ? "danger" : "default"} />
<RollupTile label="Success rate" value={`${rollup.successRatePct}%`} tone={successTone(rollup.successRatePct, rollup.totalLastNDays)} />
<RollupTile label="Success rate" value={`${rollup.successRatePct}%`} tone={successTone(rollup.successRatePct, rollup.succeededLastNDays + rollup.failedLastNDays)} />
</div>
{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
<Metric
label="Success"
value={summary ? `${summary.successRatePct}%` : "—"}
danger={!!summary && successTone(summary.successRatePct, summary.totalLastNDays) === "danger"}
danger={!!summary && successTone(summary.successRatePct, summary.succeededLastNDays + summary.failedLastNDays) === "danger"}
/>
</div>
</div>