diff --git a/src/Web/autopilot-monitor-web/app/fleet/hooks/useFleetSummaries.ts b/src/Web/autopilot-monitor-web/app/fleet/hooks/useFleetSummaries.ts new file mode 100644 index 00000000..1ec99fc8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet/hooks/useFleetSummaries.ts @@ -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; + 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>({}); + 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 = {}; + + 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 }; +} 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 new file mode 100644 index 00000000..8dfa7565 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet/lib/__tests__/fleetRollup.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { computeFleetRollup, type FleetSummary } from "../fleetRollup"; + +function summary(p: Partial): 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); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/fleet/lib/fleetRollup.ts b/src/Web/autopilot-monitor-web/app/fleet/lib/fleetRollup.ts new file mode 100644 index 00000000..ac3bf61b --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet/lib/fleetRollup.ts @@ -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 }; +} diff --git a/src/Web/autopilot-monitor-web/app/fleet/page.tsx b/src/Web/autopilot-monitor-web/app/fleet/page.tsx index 1ada77c9..10f05bd0 100644 --- a/src/Web/autopilot-monitor-web/app/fleet/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet/page.tsx @@ -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 (

Fleet

- {myTenants.length} managed tenant{myTenants.length === 1 ? "" : "s"} · read-only monitoring + {myTenants.length} managed tenant{myTenants.length === 1 ? "" : "s"} · last {DAYS} days + {loading ? " · refreshing…" : ""}

+ {/* Roll-up */} +
+ + + 0 ? "danger" : "default"} /> + +
+ {myTenants.length === 0 ? (
No managed tenants yet. Once tenants are delegated to you, they appear here.
) : (
- {myTenants.map((t) => ( -
-
- {t.domainName || "Unknown domain"} -
-
- {t.tenantId} -
-
+ {ordered.map((t) => ( + ))}
)}
); } + +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 ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function FleetCard({ tenant, summary }: { tenant: TenantInfo; summary?: FleetSummary }) { + return ( +
+
+ {tenant.domainName || "Unknown domain"} +
+
{tenant.tenantId}
+ +
+ + 0} + /> + +
+
+ ); +} + +function Metric({ label, value, danger = false }: { label: string; value: string; danger?: boolean }) { + return ( +
+
+ {value} +
+
{label}
+
+ ); +}