mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user