diff --git a/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/hooks/useTenantSessions.ts b/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/hooks/useTenantSessions.ts new file mode 100644 index 00000000..c4902b09 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/hooks/useTenantSessions.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { api } from "@/lib/api"; +import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import type { Session } from "@/types/session"; + +export interface TenantSessionsState { + sessions: Session[]; + loading: boolean; + /** A first page was returned but the server signalled more rows (nextLink present). */ + hasMore: boolean; + /** Set if the fetch failed (network or non-2xx); the page renders an inline error. */ + error: boolean; +} + +// First page only — the Fleet drill-in is a recent-activity overview, not a full pager. The server caps +// the window via `days`; we cap the row count here. Deep pagination/filtering is a follow-up. +const PAGE_SIZE = 100; + +/** + * Fetches the recent session list for ONE managed tenant via the Phase-2a single-tenant + * `/api/global/sessions?tenantId=` endpoint. The backend bounds this read to the caller's delegated + * scope (GlobalReadOrDelegatedSubset); the page also guards the tenantId against delegatedTenantIds as + * defense in depth before mounting this hook. + * + * @param tenantId managed tenant to load (verbatim, as it appears in the route/delegated list). + * @param days lookback window passed to the stats/list endpoints. + */ +export function useTenantSessions(tenantId: string, days: number): TenantSessionsState { + const { getAccessToken } = useAuth(); + const [state, setState] = useState({ + sessions: [], + loading: true, + hasMore: false, + error: false, + }); + + useEffect(() => { + if (!tenantId) { + setState({ sessions: [], loading: false, hasMore: false, error: false }); + return; + } + + let cancelled = false; + setState((s) => ({ ...s, loading: true, error: false })); + + const run = async () => { + try { + const response = await authenticatedFetch( + api.globalSessions.list(tenantId, days, { pageSize: PAGE_SIZE }), + getAccessToken + ); + if (!response.ok) { + if (!cancelled) setState({ sessions: [], loading: false, hasMore: false, error: true }); + return; + } + const data = await response.json(); + if (cancelled) return; + setState({ + sessions: (data.sessions as Session[]) || [], + loading: false, + hasMore: !!data.nextLink, + error: false, + }); + } catch (err) { + console.error(`Error fetching sessions for tenant ${tenantId}:`, err); + if (!cancelled) setState({ sessions: [], loading: false, hasMore: false, error: true }); + } + }; + + run(); + return () => { + cancelled = true; + }; + }, [tenantId, days, getAccessToken]); + + return state; +} diff --git a/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/page.tsx b/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/page.tsx new file mode 100644 index 00000000..b581d91a --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTenantList } from "@/hooks/useTenantList"; +import { SessionStatusBadge } from "@/components/SessionStatusBadge"; +import { formatDateTime, formatDurationShort } from "@/utils/sessionFormat"; +import type { Session } from "@/types/session"; +import { useFleetSummaries } from "../hooks/useFleetSummaries"; +import { useTenantSessions } from "./hooks/useTenantSessions"; +import type { FleetSummary } from "../lib/fleetRollup"; + +const DAYS = 30; + +function ArrowLeftIcon({ className = "h-4 w-4" }: { className?: string }) { + return ( + + ); +} + +/** + * Fleet drill-in — one managed tenant's read-only overview for a delegated ("MSP") admin. + * + * The tenant is an explicit route param, so every read is a single-tenant `/global/*?tenantId=` call + * (Phase 2a) the backend bounds to the caller's delegated scope (GlobalReadOrDelegatedSubset). There is no + * all-tenants path here. As client-side defense in depth we also refuse a tenantId the caller doesn't + * manage (unless they hold full platform scope) BEFORE issuing any fetch. + * + * This is a recent-activity overview (stats + session list); deep session-detail drill-in is a follow-up — + * it needs a `/api/global/sessions/{id}` (+ /events) endpoint, since the tenant-scoped `/api/sessions/{id}` + * detail path is MemberRead-only and not reachable for a delegated, non-member caller. + */ +export default function FleetTenantPage() { + const params = useParams<{ tenantId: string }>(); + const tenantId = decodeURIComponent(params.tenantId); + + const { user, hasGlobalScope, hasFleetScope } = useAuth(); + + const allowed = useMemo(() => { + if (hasGlobalScope) return true; // GA / Global Reader may view any tenant + const managed = new Set((user?.delegatedTenantIds ?? []).map((t) => t.toLowerCase())); + return managed.has(tenantId.toLowerCase()); + }, [hasGlobalScope, user?.delegatedTenantIds, tenantId]); + + // Gate every data fetch on `allowed` — an empty id makes the hooks no-op, so an unmanaged tenant never + // triggers a request even though the backend would reject it anyway. + const effectiveTenantId = allowed ? tenantId : ""; + + const tenants = useTenantList(hasFleetScope); + const domainName = useMemo( + () => tenants.find((t) => t.tenantId.toLowerCase() === tenantId.toLowerCase())?.domainName || "", + [tenants, tenantId] + ); + + const { summaries } = useFleetSummaries(effectiveTenantId ? [effectiveTenantId] : [], DAYS); + const summary: FleetSummary | undefined = summaries[effectiveTenantId]; + + const { sessions, loading, hasMore, error } = useTenantSessions(effectiveTenantId, DAYS); + + return ( +
+ + + Back to fleet + + +
+

+ {domainName || "Managed tenant"} +

+

{tenantId}

+
+ + {!allowed ? ( +
+ You don't manage this tenant. +
+ ) : ( + <> + {/* Stat tiles — terminal-only success rate, matching the fleet cards. */} +
+ + + 0 ? "danger" : "default"} + /> + +
+ + {/* Recent sessions */} +
+
+

+ Recent sessions · last {DAYS} days +

+ {loading && Loading…} +
+ + {error ? ( +
+ Couldn't load sessions for this tenant. +
+ ) : !loading && sessions.length === 0 ? ( +
+ No sessions in the last {DAYS} days. +
+ ) : ( + + )} +
+ + )} +
+ ); +} + +type Tone = "default" | "danger"; + +/** Red emphasis only once there are TERMINAL sessions to judge (success rate is terminal-only). */ +function successTone(pct: number, terminalCount: number): Tone { + if (terminalCount === 0) return "default"; + return pct < 80 ? "danger" : "default"; +} + +function StatTile({ label, value, tone = "default" }: { label: string; value: string; tone?: Tone }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +const PHASE_LABELS: Record = { + 0: "Setup start", + 1: "Device prep", + 2: "Device setup", + 3: "Apps (device)", + 4: "Account setup", + 5: "Apps (user)", + 6: "Finalizing", + 7: "Complete", +}; + +function phaseLabel(phase: number): string { + return PHASE_LABELS[phase] ?? "—"; +} + +/** Read-only session table for the drill-in. Rows are intentionally non-navigable until a global + * session-detail endpoint exists (see page-level note). */ +function SessionList({ sessions, hasMore }: { sessions: Session[]; hasMore: boolean }) { + return ( +
+ + + + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + + + + ))} + +
DeviceModelStatusPhaseEventsDurationStarted
+
+ {s.deviceName || s.serialNumber || "—"} +
+ {s.deviceName && s.serialNumber && ( +
{s.serialNumber}
+ )} +
+ {[s.manufacturer, s.model].filter(Boolean).join(" ") || "—"} + + + {phaseLabel(s.currentPhase)} + {s.eventCount} + + {formatDurationShort(s.durationSeconds)} + + {formatDateTime(s.startedAt)} +
+ + {hasMore && ( +
+ Showing the {sessions.length} most recent sessions. Older sessions are not shown. +
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/fleet/page.tsx b/src/Web/autopilot-monitor-web/app/fleet/page.tsx index dbedb707..0f77293b 100644 --- a/src/Web/autopilot-monitor-web/app/fleet/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo } from "react"; +import Link from "next/link"; import { useAuth } from "@/contexts/AuthContext"; import { useTenantList, type TenantInfo } from "@/hooks/useTenantList"; import { useFleetSummaries } from "./hooks/useFleetSummaries"; @@ -111,7 +112,10 @@ function RollupTile({ label, value, tone = "default" }: { label: string; value: function FleetCard({ tenant, summary }: { tenant: TenantInfo; summary?: FleetSummary }) { return ( -
+
{tenant.domainName || "Unknown domain"}
@@ -130,7 +134,7 @@ function FleetCard({ tenant, summary }: { tenant: TenantInfo; summary?: FleetSum danger={!!summary && successTone(summary.successRatePct, summary.succeededLastNDays + summary.failedLastNDays) === "danger"} />
- + ); } diff --git a/src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx b/src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx new file mode 100644 index 00000000..cfbfe18a --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx @@ -0,0 +1,36 @@ +/** + * Read-only session status pill. Extracted from the inline copies in the dashboard SessionTable and the + * geographic-performance sessions page so new read-only views (Fleet drill-in) share one definition. + * Pure presentational — colors mirror the backend SessionStatus enum (Succeeded/Failed/InProgress/ + * Pending/Stalled; anything else → Unknown). A "timed out" failure gets a small ⏱️ affordance. + */ + +const STATUS_CONFIG: Record = { + InProgress: { color: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300", text: "In Progress" }, + Pending: { color: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", text: "Pending" }, + Stalled: { color: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", text: "Stalled" }, + Succeeded: { color: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300", text: "Succeeded" }, + Failed: { color: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300", text: "Failed" }, + Unknown: { color: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", text: "Unknown" }, +}; + +export function SessionStatusBadge({ + status, + failureReason, +}: { + status: string; + failureReason?: string | null; +}) { + const config = STATUS_CONFIG[status] || STATUS_CONFIG.Unknown; + const isTimeout = status === "Failed" && !!failureReason?.toLowerCase().includes("timed out"); + + return ( + + {config.text} + {isTimeout && ⏱️} + + ); +} diff --git a/src/Web/autopilot-monitor-web/utils/sessionFormat.ts b/src/Web/autopilot-monitor-web/utils/sessionFormat.ts new file mode 100644 index 00000000..1046c75b --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/sessionFormat.ts @@ -0,0 +1,25 @@ +// Small, dependency-free formatters for session rows. Mirror of the page-local copies in +// app/geographic-performance/sessions/page.tsx (kept inline there to avoid touching that page); +// new read-only views (Fleet drill-in) consume these shared helpers instead of adding a third copy. + +/** Duration as "N min" / "Hh Mm"; em-dash for missing/zero. */ +export function formatDurationShort(seconds: number | null | undefined): string { + if (!seconds || seconds <= 0) return "—"; + const mins = Math.round(seconds / 60); + if (mins < 60) return `${mins} min`; + const hrs = Math.floor(mins / 60); + const remainMins = mins % 60; + return `${hrs}h ${remainMins}m`; +} + +/** Absolute local date + time, e.g. "Jun 25, 2026 14:03". Em-dash for missing/invalid. */ +export function formatDateTime(dateStr: string | null | undefined): string { + if (!dateStr) return "—"; + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return "—"; + return ( + d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) + + " " + + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) + ); +}