mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(web): fleet tenant drill-in (switcher) for MSP admins [Phase 3d]
Make the fleet cards navigable into a per-tenant read-only overview at
/fleet/[tenantId]: header, stat tiles (active / sessions / failed / terminal-
only success rate) and a recent-session list. The tenant is an explicit route
param, so every read is a single-tenant /global/*?tenantId= call the backend
bounds to the caller's delegated scope (GlobalReadOrDelegatedSubset) - there is
no all-tenants path. As defense in depth the page refuses a tenantId the caller
does not manage (unless full platform scope) BEFORE issuing any fetch.
- app/fleet/[tenantId]/page.tsx: guarded drill-in; reuses useFleetSummaries for
the stat tiles (single-tenant) and a new useTenantSessions hook for the list.
- app/fleet/[tenantId]/hooks/useTenantSessions.ts: first-page session fetch via
globalSessions.list(tenantId); fail-soft, em-dash on error.
- components/SessionStatusBadge.tsx + utils/sessionFormat.ts: shared read-only
presentational primitives (new code consumes them; the existing inline copies
in dashboard/geographic are left untouched this phase - migration is a tidy-up
follow-up).
- app/fleet/page.tsx: FleetCard is now a Link to /fleet/[tenantId].
Deep session-detail drill-in is intentionally out of scope: it needs a
/api/global/sessions/{id} (+ /events) endpoint, since the tenant-scoped
/api/sessions/{id} detail path is MemberRead-only and unreachable for a
delegated, non-member caller. Rows are non-navigable until then.
tsc clean; 481/481 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<TenantSessionsState>({
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
|
||||
<Link
|
||||
href="/fleet"
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back to fleet
|
||||
</Link>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{domainName || "Managed tenant"}
|
||||
</h1>
|
||||
<p className="mt-1 break-all font-mono text-xs text-gray-400 dark:text-gray-500">{tenantId}</p>
|
||||
</div>
|
||||
|
||||
{!allowed ? (
|
||||
<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">
|
||||
You don't manage this tenant.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stat tiles — terminal-only success rate, matching the fleet cards. */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatTile label="Active sessions" value={summary ? String(summary.activeCount) : "—"} />
|
||||
<StatTile
|
||||
label={`Sessions · ${DAYS}d`}
|
||||
value={summary ? String(summary.totalLastNDays) : "—"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Failed"
|
||||
value={summary ? String(summary.failedLastNDays) : "—"}
|
||||
tone={!!summary && summary.failedLastNDays > 0 ? "danger" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Success rate"
|
||||
value={summary ? `${summary.successRatePct}%` : "—"}
|
||||
tone={
|
||||
!!summary &&
|
||||
successTone(summary.successRatePct, summary.succeededLastNDays + summary.failedLastNDays) ===
|
||||
"danger"
|
||||
? "danger"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent sessions */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Recent sessions · last {DAYS} days
|
||||
</h2>
|
||||
{loading && <span className="text-xs text-gray-400">Loading…</span>}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-red-600 dark:text-red-400">
|
||||
Couldn't load sessions for this tenant.
|
||||
</div>
|
||||
) : !loading && sessions.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No sessions in the last {DAYS} days.
|
||||
</div>
|
||||
) : (
|
||||
<SessionList sessions={sessions} hasMore={hasMore} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<number, string> = {
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
<th className="px-5 py-2.5">Device</th>
|
||||
<th className="px-5 py-2.5">Model</th>
|
||||
<th className="px-5 py-2.5">Status</th>
|
||||
<th className="px-5 py-2.5">Phase</th>
|
||||
<th className="px-5 py-2.5 text-right">Events</th>
|
||||
<th className="px-5 py-2.5 text-right">Duration</th>
|
||||
<th className="px-5 py-2.5">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700/60">
|
||||
{sessions.map((s) => (
|
||||
<tr key={s.sessionId} className="text-sm">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{s.deviceName || s.serialNumber || "—"}
|
||||
</div>
|
||||
{s.deviceName && s.serialNumber && (
|
||||
<div className="font-mono text-xs text-gray-400 dark:text-gray-500">{s.serialNumber}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-gray-600 dark:text-gray-300">
|
||||
{[s.manufacturer, s.model].filter(Boolean).join(" ") || "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<SessionStatusBadge status={s.status} failureReason={s.failureReason} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-gray-600 dark:text-gray-300">{phaseLabel(s.currentPhase)}</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums text-gray-600 dark:text-gray-300">
|
||||
{s.eventCount}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums text-gray-600 dark:text-gray-300">
|
||||
{formatDurationShort(s.durationSeconds)}
|
||||
</td>
|
||||
<td className="px-5 py-3 whitespace-nowrap text-gray-600 dark:text-gray-300">
|
||||
{formatDateTime(s.startedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{hasMore && (
|
||||
<div className="border-t border-gray-100 px-5 py-3 text-center text-xs text-gray-400 dark:border-gray-700 dark:text-gray-500">
|
||||
Showing the {sessions.length} most recent sessions. Older sessions are not shown.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<Link
|
||||
href={`/fleet/${encodeURIComponent(tenant.tenantId)}`}
|
||||
className="block rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-colors hover:border-blue-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-500"
|
||||
>
|
||||
<div className="truncate text-base font-semibold text-gray-900 dark:text-white">
|
||||
{tenant.domainName || "Unknown domain"}
|
||||
</div>
|
||||
@@ -130,7 +134,7 @@ function FleetCard({ tenant, summary }: { tenant: TenantInfo; summary?: FleetSum
|
||||
danger={!!summary && successTone(summary.successRatePct, summary.succeededLastNDays + summary.failedLastNDays) === "danger"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, { color: string; text: string }> = {
|
||||
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 (
|
||||
<span
|
||||
className={`px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full ${config.color}`}
|
||||
title={failureReason || undefined}
|
||||
>
|
||||
{config.text}
|
||||
{isTimeout && <span title={failureReason || undefined}>⏱️</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user