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.
+
+
+ {loadingMore ? "Loading…" : "Load more"}
+
)}
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
+
);
}