feat(web): fleet session-list pagination + dedupe SessionStatusBadge [Phase 5 follow-up]

Fleet pagination:
- useTenantSessions now follows the continuation token: a "Load more" button on
  the managed-tenant drill-in fetches and appends the next page instead of
  capping at the first 100. A generation guard drops an in-flight loadMore if the
  tenant/days switch mid-flight; errors stop pagination (loaded rows stay).
- /fleet/[tenantId] replaces the static "older sessions not shown" footer with
  the Load more control (disabled while loading).

StatusBadge dedupe:
- Fold the two inline status-pill copies (dashboard SessionTable + geographic-
  performance sessions) into the shared components/SessionStatusBadge. Extended
  it with an optional adminMarkedAction prop (the dashboard's "manual" sub-badge);
  the bare pill is returned unchanged when absent, so the geographic + fleet
  output is identical. One definition now, dark-mode variants included.

web tsc clean; 481/481 web tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-26 00:08:53 +02:00
parent 104c70ae58
commit 106f075216
5 changed files with 140 additions and 105 deletions
@@ -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 (
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<StatusBadge status={session.status} failureReason={session.failureReason} adminMarkedAction={session.adminMarkedAction} />
<SessionStatusBadge status={session.status} failureReason={session.failureReason} adminMarkedAction={session.adminMarkedAction} />
{session.isHybridJoin && (
<span
className="px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-purple-100 text-purple-800"
@@ -1218,41 +1219,3 @@ function SortableHeader({
);
}
function StatusBadge({ status, failureReason, adminMarkedAction }: { status: string; failureReason?: string; adminMarkedAction?: string }) {
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Pending: { color: "bg-amber-100 text-amber-800", text: "Pending" },
Stalled: { color: "bg-orange-100 text-orange-800", text: "Stalled" },
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" },
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
const isTimeout = status === "Failed" && failureReason && failureReason.toLowerCase().includes("timed out");
return (
<span className="inline-flex items-center gap-1.5">
<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} className="inline-flex items-center">
⏱️
</span>
)}
</span>
{adminMarkedAction && (
<span
className="px-1.5 py-0.5 text-[10px] leading-4 font-semibold rounded border border-gray-300 bg-gray-50 text-gray-600"
title={`Manually marked as ${adminMarkedAction} by administrator`}
>
manual
</span>
)}
</span>
);
}
@@ -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<TenantSessionsState>({
sessions: [],
loading: true,
hasMore: false,
error: false,
});
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState(false);
const continuationRef = useRef<string | null>(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 };
}
@@ -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 (
<div className="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
@@ -128,7 +128,13 @@ export default function FleetTenantPage() {
No sessions in the last {DAYS} days.
</div>
) : (
<SessionList sessions={sessions} hasMore={hasMore} tenantId={tenantId} />
<SessionList
sessions={sessions}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMore}
tenantId={tenantId}
/>
)}
</div>
</>
@@ -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
</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 className="border-t border-gray-100 px-5 py-3 text-center dark:border-gray-700">
<button
onClick={onLoadMore}
disabled={loadingMore}
className="rounded-md border border-gray-300 px-4 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
</div>
)}
</div>
@@ -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<string, { color: string; text: string }> = {
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 (
<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!}>&#9201;&#65039;</span>}
</span>
);
}
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 || "—"}
</td>
<td className="px-4 py-3 text-sm">
<StatusBadge status={session.status} failureReason={session.failureReason} />
<SessionStatusBadge status={session.status} failureReason={session.failureReason} />
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{formatDuration(session.durationSeconds)}
@@ -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<string, { color: string; text: string }> = {
@@ -17,20 +18,37 @@ const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
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 = (
<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}>&#9201;&#65039;</span>}
{isTimeout && <span title={failureReason || undefined} className="inline-flex items-center">&#9201;&#65039;</span>}
</span>
);
if (!adminMarkedAction) return pill;
return (
<span className="inline-flex items-center gap-1.5">
{pill}
<span
className="px-1.5 py-0.5 text-[10px] leading-4 font-semibold rounded border border-gray-300 bg-gray-50 text-gray-600 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
title={`Manually marked as ${adminMarkedAction} by administrator`}
>
manual
</span>
</span>
);
}