diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index 1d2951f3..b7feb61a 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -6,6 +6,7 @@ import { Session } from "../types"; import { trackEvent } from "@/lib/appInsights"; import { fuzzyContains } from "@/utils/fuzzy"; import { buildUniqueValuesByField } from "./uniqueValuesByField"; +import { SessionStatusBadge } from "@/components/SessionStatusBadge"; // Column definition for the session table interface ColumnDef { @@ -923,7 +924,7 @@ function SessionCell({ return (
- + {session.isHybridJoin && ( - - {config.text} - {isTimeout && ( - - ⏱️ - - )} - - {adminMarkedAction && ( - - manual - - )} - - ); -} 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 index 718e2e52..efa75ea7 100644 --- a/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/hooks/useTenantSessions.ts +++ b/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/hooks/useTenantSessions.ts @@ -1,52 +1,70 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import { extractContinuation } from "@/lib/paginationLink"; import type { Session } from "@/types/session"; export interface TenantSessionsState { sessions: Session[]; + /** First-page load. */ loading: boolean; - /** A first page was returned but the server signalled more rows (nextLink present). */ + /** A subsequent page (loadMore) is in flight. */ + loadingMore: boolean; + /** The server has more rows beyond what's loaded (a continuation token is held). */ hasMore: boolean; - /** Set if the fetch failed (network or non-2xx); the page renders an inline error. */ + /** Set if a fetch failed (network or non-2xx); the page renders an inline error. */ error: boolean; + /** Fetch and append the next page. No-op while one is in flight or when there's nothing more. */ + loadMore: () => void; } -// 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. +// Page size for the managed-tenant session list — server-bounded by `days` on top of this. 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. + * `/api/global/sessions?tenantId=` endpoint, with continuation-token pagination (Load more). 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. + * @param days lookback window passed to the list endpoint. */ export function useTenantSessions(tenantId: string, days: number): TenantSessionsState { const { getAccessToken } = useAuth(); - const [state, setState] = useState({ - sessions: [], - loading: true, - hasMore: false, - error: false, - }); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(false); + + const continuationRef = useRef(null); + // Generation guard: bumped on every (tenant, days) change so an in-flight loadMore from a previous + // tenant can't append into the new tenant's list after a fast switch. + const genRef = useRef(0); + const loadingMoreRef = useRef(false); useEffect(() => { + const myGen = ++genRef.current; + continuationRef.current = null; + if (!tenantId) { - setState({ sessions: [], loading: false, hasMore: false, error: false }); + setSessions([]); + setLoading(false); + setHasMore(false); + setError(false); return; } - let cancelled = false; // Clear rows on every new tenant fetch — otherwise navigating A→B briefly renders A's sessions under // B's heading until B's request lands (both in scope, so not a leak, but misleading in the drill-in). - setState({ sessions: [], loading: true, hasMore: false, error: false }); + setSessions([]); + setLoading(true); + setHasMore(false); + setError(false); const run = async () => { try { @@ -54,29 +72,63 @@ export function useTenantSessions(tenantId: string, days: number): TenantSession api.globalSessions.list(tenantId, days, { pageSize: PAGE_SIZE }), getAccessToken ); + if (myGen !== genRef.current) return; // a newer tenant/days took over if (!response.ok) { - if (!cancelled) setState({ sessions: [], loading: false, hasMore: false, error: true }); + setError(true); + setLoading(false); return; } const data = await response.json(); - if (cancelled) return; - setState({ - sessions: (data.sessions as Session[]) || [], - loading: false, - hasMore: !!data.nextLink, - error: false, - }); + if (myGen !== genRef.current) return; + const cont = extractContinuation(data.nextLink); + continuationRef.current = cont; + setSessions((data.sessions as Session[]) || []); + setHasMore(!!cont); + setLoading(false); } catch (err) { console.error(`Error fetching sessions for tenant ${tenantId}:`, err); - if (!cancelled) setState({ sessions: [], loading: false, hasMore: false, error: true }); + if (myGen === genRef.current) { + setError(true); + setLoading(false); + } } }; run(); - return () => { - cancelled = true; - }; }, [tenantId, days, getAccessToken]); - return state; + const loadMore = useCallback(async () => { + const cont = continuationRef.current; + if (!cont || loadingMoreRef.current || !tenantId) return; + + const myGen = genRef.current; + loadingMoreRef.current = true; + setLoadingMore(true); + try { + const response = await authenticatedFetch( + api.globalSessions.list(tenantId, days, { pageSize: PAGE_SIZE, continuation: cont }), + getAccessToken + ); + if (myGen !== genRef.current) return; // tenant/days switched mid-flight — drop this page + if (!response.ok) { + // Stop paginating on error rather than spamming a broken token; the loaded rows stay. + setHasMore(false); + return; + } + const data = await response.json(); + if (myGen !== genRef.current) return; + const next = extractContinuation(data.nextLink); + continuationRef.current = next; + setSessions((prev) => [...prev, ...((data.sessions as Session[]) || [])]); + setHasMore(!!next); + } catch (err) { + console.error(`Error loading more sessions for tenant ${tenantId}:`, err); + if (myGen === genRef.current) setHasMore(false); + } finally { + loadingMoreRef.current = false; + if (myGen === genRef.current) setLoadingMore(false); + } + }, [tenantId, days, getAccessToken]); + + return { sessions, loading, loadingMore, hasMore, error, loadMore }; } diff --git a/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/page.tsx b/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/page.tsx index 86139230..1cccce8d 100644 --- a/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet/[tenantId]/page.tsx @@ -60,7 +60,7 @@ export default function FleetTenantPage() { const { summaries } = useFleetSummaries(effectiveTenantId ? [effectiveTenantId] : [], DAYS); const summary: FleetSummary | undefined = summaries[effectiveTenantId]; - const { sessions, loading, hasMore, error } = useTenantSessions(effectiveTenantId, DAYS); + const { sessions, loading, loadingMore, hasMore, error, loadMore } = useTenantSessions(effectiveTenantId, DAYS); return (
@@ -128,7 +128,13 @@ export default function FleetTenantPage() { No sessions in the last {DAYS} days.
) : ( - + )}
@@ -180,7 +186,19 @@ function phaseLabel(phase: number): string { /** Read-only session table for the drill-in. Rows open the full session-detail page for the managed tenant * via `?tenantId=` — the backend serves the reads (MemberRead + delegated scope) and the detail page renders * read-only for a delegated viewer (write actions hidden). */ -function SessionList({ sessions, hasMore, tenantId }: { sessions: Session[]; hasMore: boolean; tenantId: string }) { +function SessionList({ + sessions, + hasMore, + loadingMore, + onLoadMore, + tenantId, +}: { + sessions: Session[]; + hasMore: boolean; + loadingMore: boolean; + onLoadMore: () => void; + tenantId: string; +}) { const router = useRouter(); const open = (sessionId: string) => router.push(`/sessions/${sessionId}?tenantId=${encodeURIComponent(tenantId)}`); @@ -235,8 +253,14 @@ function SessionList({ sessions, hasMore, tenantId }: { sessions: Session[]; has {hasMore && ( -
- Showing the {sessions.length} most recent sessions. Older sessions are not shown. +
+
)}
diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx index 7fcbae62..04e786dd 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx @@ -8,6 +8,7 @@ import { useAuth } from "../../../contexts/AuthContext"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useAdminMode } from "@/hooks/useAdminMode"; +import { SessionStatusBadge } from "@/components/SessionStatusBadge"; interface SessionSummary { sessionId: string; @@ -50,29 +51,6 @@ interface LocationSessionsResponse { totalCount: number; } -const statusConfig: Record = { - InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" }, - Pending: { color: "bg-amber-100 text-amber-800", text: "Pending" }, - Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" }, - Failed: { color: "bg-red-100 text-red-800", text: "Failed" }, - Unknown: { color: "bg-gray-100 text-gray-800", text: "Unknown" }, -}; - -function StatusBadge({ status, failureReason }: { status: string; failureReason?: string | null }) { - const config = statusConfig[status] || statusConfig.Unknown; - const isTimeout = status === "Failed" && failureReason?.toLowerCase().includes("timed out"); - - return ( - - {config.text} - {isTimeout && ⏱️} - - ); -} - function formatDuration(seconds: number | null): string { if (!seconds || seconds <= 0) return "—"; const mins = Math.round(seconds / 60); @@ -320,7 +298,7 @@ function LocationSessionsContent() { {session.model || "—"} - + {formatDuration(session.durationSeconds)} diff --git a/src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx b/src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx index cfbfe18a..52fdb7eb 100644 --- a/src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx +++ b/src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx @@ -1,8 +1,9 @@ /** - * 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. + * Read-only session status pill. The single source for the inline copies that used to live in the dashboard + * SessionTable and the geographic-performance sessions page. Pure presentational — colors mirror the backend + * SessionStatus enum (Succeeded/Failed/InProgress/Pending/Stalled; anything else → Unknown). A "timed out" + * failure gets a small ⏱️ affordance. When {@link adminMarkedAction} is set (an administrator manually flipped + * the terminal state), a small "manual" badge is appended — otherwise the bare pill is returned unchanged. */ const STATUS_CONFIG: Record = { @@ -17,20 +18,37 @@ const STATUS_CONFIG: Record = { export function SessionStatusBadge({ status, failureReason, + adminMarkedAction, }: { status: string; failureReason?: string | null; + /** Set when an admin manually marked the session ("Succeeded" | "Failed") — appends a "manual" badge. */ + adminMarkedAction?: string | null; }) { const config = STATUS_CONFIG[status] || STATUS_CONFIG.Unknown; const isTimeout = status === "Failed" && !!failureReason?.toLowerCase().includes("timed out"); - return ( + const pill = ( {config.text} - {isTimeout && ⏱️} + {isTimeout && ⏱️} + + ); + + if (!adminMarkedAction) return pill; + + return ( + + {pill} + + manual + ); }