mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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:
@@ -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!}>⏱️</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}>⏱️</span>}
|
||||
{isTimeout && <span title={failureReason || undefined} className="inline-flex items-center">⏱️</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user