feat(web): per-tenant health + roll-up on the fleet overview [Phase 3c]

Enriches the fleet grid with live health, so an MSP admin can triage "which of my
customers is burning" at a glance.

- useFleetSummaries: bounded (concurrency 6) subset fan-out over the Phase-2a
  single-tenant /global/stats/sessions?tenantId= endpoint — one server-aggregated
  summary per managed tenant, collected into a map. Client-orchestrated over the
  already-bounded per-tenant reads; one tenant's failure never sinks the view.
- fleetRollup: pure, testable roll-up. Success rate is WEIGHTED by session volume
  (sum succeeded / sum total), not an average of per-tenant rates. 4 unit tests.
- page: roll-up tiles (tenants / active / failed / weighted success) + per-tenant
  cards showing active, failed, success%. Cards are triage-ordered (most failures
  first; loading tenants last) and use red emphasis on failures / sub-80% success.

Web tsc clean; 480 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:24:33 +02:00
parent e2ce43a2d0
commit 7c2b5d10cb
4 changed files with 288 additions and 21 deletions
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import type { FleetSummary } from "../lib/fleetRollup";
export interface FleetSummariesState {
/** tenantId (verbatim) → summary. Absent while loading or if that tenant's fetch failed. */
summaries: Record<string, FleetSummary>;
loading: boolean;
}
// Bounded fan-out: an MSP may manage many tenants; cap concurrent stats requests so a large fleet does not
// open dozens of simultaneous connections. Each request hits the Phase-2a single-tenant `?tenantId=` path,
// which the backend bounds to the caller's scope.
const CONCURRENCY = 6;
/**
* Subset fan-out: fetch the per-tenant session-stats summary for each managed tenant and collect them into a
* map. Client-orchestrated over the already-bounded single-tenant endpoints — no all-tenants code path.
*
* @param tenantIds the managed tenant IDs to summarize.
* @param days lookback window passed to each per-tenant stats call.
*/
export function useFleetSummaries(tenantIds: string[], days: number): FleetSummariesState {
const { getAccessToken } = useAuth();
const [summaries, setSummaries] = useState<Record<string, FleetSummary>>({});
const [loading, setLoading] = useState(false);
// Stable dependency: the sorted tenant set. Re-fan-out only when the managed set or window changes.
const key = [...tenantIds].sort().join(",");
useEffect(() => {
if (tenantIds.length === 0) {
setSummaries({});
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
const run = async () => {
const queue = [...tenantIds];
const result: Record<string, FleetSummary> = {};
const worker = async () => {
while (queue.length > 0) {
const tenantId = queue.pop();
if (!tenantId) break;
try {
const response = await authenticatedFetch(
api.globalSessions.stats({ tenantId, days }),
getAccessToken
);
if (!response.ok) continue;
const body = await response.json();
if (body?.stats) result[tenantId] = body.stats as FleetSummary;
} catch (err) {
// One tenant's failure must not sink the whole fleet view — skip it; the card shows "—".
console.error(`Error fetching fleet summary for tenant ${tenantId}:`, err);
}
}
};
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, tenantIds.length) }, worker)
);
if (!cancelled) {
setSummaries(result);
setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, days, getAccessToken]);
return { summaries, loading };
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { computeFleetRollup, type FleetSummary } from "../fleetRollup";
function summary(p: Partial<FleetSummary>): FleetSummary {
return {
activeCount: 0,
totalLastNDays: 0,
succeededLastNDays: 0,
failedLastNDays: 0,
successRatePct: 0,
...p,
};
}
describe("computeFleetRollup", () => {
it("returns zeros for an empty fleet", () => {
const r = computeFleetRollup([]);
expect(r).toEqual({
tenantCount: 0,
activeCount: 0,
totalLastNDays: 0,
succeededLastNDays: 0,
failedLastNDays: 0,
successRatePct: 0,
});
});
it("sums per-tenant counts", () => {
const r = computeFleetRollup([
summary({ activeCount: 2, totalLastNDays: 10, succeededLastNDays: 8, failedLastNDays: 2 }),
summary({ activeCount: 3, totalLastNDays: 5, succeededLastNDays: 5, failedLastNDays: 0 }),
]);
expect(r.tenantCount).toBe(2);
expect(r.activeCount).toBe(5);
expect(r.totalLastNDays).toBe(15);
expect(r.succeededLastNDays).toBe(13);
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%.
const r = computeFleetRollup([
summary({ totalLastNDays: 1000, succeededLastNDays: 900 }),
summary({ totalLastNDays: 10, succeededLastNDays: 0 }),
]);
expect(r.successRatePct).toBeCloseTo(89.1, 1);
});
it("reports 0% success when the fleet has no sessions", () => {
const r = computeFleetRollup([summary({ activeCount: 1 }), summary({})]);
expect(r.totalLastNDays).toBe(0);
expect(r.successRatePct).toBe(0);
expect(r.tenantCount).toBe(2);
});
});
@@ -0,0 +1,48 @@
/**
* Per-tenant health summary for one managed tenant — the headline subset of the server-aggregated
* DashboardStats returned by `/global/stats/sessions?tenantId=`. Pure data; no React.
*/
export interface FleetSummary {
activeCount: number;
totalLastNDays: number;
succeededLastNDays: number;
failedLastNDays: number;
successRatePct: number;
}
/** Fleet-wide roll-up across the managed tenants that have reported a summary. */
export interface FleetRollup {
/** Number of tenants contributing to the roll-up (those with a loaded summary). */
tenantCount: number;
activeCount: number;
totalLastNDays: number;
succeededLastNDays: number;
failedLastNDays: number;
/** Weighted success rate across the fleet (sum succeeded / sum total), 1 decimal. 0 when no 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.
*/
export function computeFleetRollup(summaries: FleetSummary[]): FleetRollup {
const acc = summaries.reduce(
(a, s) => {
a.activeCount += s.activeCount;
a.totalLastNDays += s.totalLastNDays;
a.succeededLastNDays += s.succeededLastNDays;
a.failedLastNDays += s.failedLastNDays;
return a;
},
{ activeCount: 0, totalLastNDays: 0, succeededLastNDays: 0, failedLastNDays: 0 }
);
const successRatePct =
acc.totalLastNDays > 0
? Math.round((acc.succeededLastNDays / acc.totalLastNDays) * 1000) / 10
: 0;
return { tenantCount: summaries.length, ...acc, successRatePct };
}
@@ -2,18 +2,20 @@
import { useMemo } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useTenantList } from "@/hooks/useTenantList";
import { useTenantList, type TenantInfo } from "@/hooks/useTenantList";
import { useFleetSummaries } from "./hooks/useFleetSummaries";
import { computeFleetRollup, type FleetSummary } from "./lib/fleetRollup";
const DAYS = 30;
/**
* Fleet landing — the managed-tenant overview for a delegated ("MSP") admin.
*
* The tenant list comes from config/all, which the backend bounds to the caller's managed tenants
* (GlobalReadOrDelegatedSubset tier, Phase 2b). We additionally intersect with the JWT-surfaced
* delegatedTenantIds as client-side defense in depth, so the grid can never render a tenant outside scope
* even if a future endpoint change widened the response.
*
* Phase 3b renders the read-only overview grid. Per-tenant health metrics (3c) and drill-in into a managed
* tenant's dashboards (3d) build on this.
* The tenant list comes from config/all (backend-bounded to the caller's subset, Phase 2b) and is
* intersected with the JWT delegatedTenantIds as client-side defense in depth. Per-tenant health is fanned
* out (bounded) over the Phase-2a single-tenant `/global/stats/sessions?tenantId=` endpoint — no all-tenants
* code path. Cards are triage-ordered (most failures first). Drill-in into a managed tenant's dashboards is
* Phase 3d.
*/
export default function FleetPage() {
const { user } = useAuth();
@@ -28,37 +30,113 @@ export default function FleetPage() {
() => tenants.filter((t) => allowed.has(t.tenantId.toLowerCase())),
[tenants, allowed]
);
const tenantIds = useMemo(() => myTenants.map((t) => t.tenantId), [myTenants]);
const { summaries, loading } = useFleetSummaries(tenantIds, DAYS);
const rollup = useMemo(() => computeFleetRollup(Object.values(summaries)), [summaries]);
// Triage order: worst first (most failures, then lowest success rate). Tenants still loading sort last;
// within an equal bucket, by domain for stability.
const ordered = useMemo(() => {
return [...myTenants].sort((a, b) => {
const sa = summaries[a.tenantId];
const sb = summaries[b.tenantId];
if (sa && sb) {
if (sb.failedLastNDays !== sa.failedLastNDays) return sb.failedLastNDays - sa.failedLastNDays;
if (sa.successRatePct !== sb.successRatePct) return sa.successRatePct - sb.successRatePct;
} else if (sa && !sb) {
return -1;
} else if (!sa && sb) {
return 1;
}
return (a.domainName || a.tenantId).localeCompare(b.domainName || b.tenantId);
});
}, [myTenants, summaries]);
return (
<div className="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Fleet</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{myTenants.length} managed tenant{myTenants.length === 1 ? "" : "s"} · read-only monitoring
{myTenants.length} managed tenant{myTenants.length === 1 ? "" : "s"} · last {DAYS} days
{loading ? " · refreshing…" : ""}
</p>
</div>
{/* Roll-up */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<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)} />
</div>
{myTenants.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 p-10 text-center text-gray-500 dark:border-gray-700 dark:text-gray-400">
No managed tenants yet. Once tenants are delegated to you, they appear here.
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{myTenants.map((t) => (
<div
key={t.tenantId}
className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<div className="truncate text-base font-semibold text-gray-900 dark:text-white">
{t.domainName || "Unknown domain"}
</div>
<div className="mt-1 break-all font-mono text-xs text-gray-400 dark:text-gray-500">
{t.tenantId}
</div>
</div>
{ordered.map((t) => (
<FleetCard key={t.tenantId} tenant={t} summary={summaries[t.tenantId]} />
))}
</div>
)}
</div>
);
}
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";
return pct < 80 ? "danger" : "default";
}
function RollupTile({ label, value, tone = "default" }: { label: string; value: string; tone?: Tone }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">{label}</div>
<div className={`mt-1 text-2xl font-semibold ${tone === "danger" ? "text-red-600 dark:text-red-400" : "text-gray-900 dark:text-white"}`}>
{value}
</div>
</div>
);
}
function FleetCard({ tenant, summary }: { tenant: TenantInfo; summary?: FleetSummary }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="truncate text-base font-semibold text-gray-900 dark:text-white">
{tenant.domainName || "Unknown domain"}
</div>
<div className="mt-1 break-all font-mono text-xs text-gray-400 dark:text-gray-500">{tenant.tenantId}</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<Metric label="Active" value={summary ? String(summary.activeCount) : "—"} />
<Metric
label="Failed"
value={summary ? String(summary.failedLastNDays) : "—"}
danger={!!summary && summary.failedLastNDays > 0}
/>
<Metric
label="Success"
value={summary ? `${summary.successRatePct}%` : "—"}
danger={!!summary && successTone(summary.successRatePct, summary.totalLastNDays) === "danger"}
/>
</div>
</div>
);
}
function Metric({ label, value, danger = false }: { label: string; value: string; danger?: boolean }) {
return (
<div>
<div className={`text-lg font-semibold ${danger ? "text-red-600 dark:text-red-400" : "text-gray-900 dark:text-white"}`}>
{value}
</div>
<div className="text-[11px] uppercase tracking-wider text-gray-400 dark:text-gray-500">{label}</div>
</div>
);
}