diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx index f742eaf4..32c1e2d5 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -1,12 +1,13 @@ "use client"; import { sessionUrl, deviceBlockUrl } from "@/lib/routes"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; import TruncatedLabel from "@/components/TruncatedLabel"; import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient"; -import { extractContinuation } from "@/lib/paginationLink"; +import type { CursorPage } from "@/lib/cursorPager"; +import { useCursorPager } from "@/hooks/useCursorPager"; import { extractSessionId, buildAutoReason } from "./opsEventSessionHelpers"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import type { OpsEventEntry, OpsEventListResponse } from "@/utils/wire-types.generated"; @@ -120,8 +121,6 @@ export function OpsEventsSection({ onSaveConfig, savingConfig, }: OpsEventsSectionProps) { - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(false); const [selectedEvent, setSelectedEvent] = useState(null); const [categoryFilter, setCategoryFilter] = useState(""); const [searchQuery, setSearchQuery] = useState(""); @@ -129,15 +128,8 @@ export function OpsEventsSection({ const [dateFromIso, setDateFromIso] = useState(defaultIsoDateFrom()); const [dateToIso, setDateToIso] = useState(defaultIsoDateTo()); - // Pattern B1 click-next replace state — backend pagination - const [continuation, setContinuation] = useState(null); - const [nextLink, setNextLink] = useState(null); - const [continuationStack, setContinuationStack] = useState>([]); - const [pageNumber, setPageNumber] = useState(1); - - const fetchEvents = useCallback(async (cursor: string | null) => { + const fetchEvents = async (cursor: string | null): Promise | null> => { try { - setLoading(true); // 404 = the table does not exist yet: no events, not an error. const data = await fetchJson( api.opsEvents.list(categoryFilter || undefined, { @@ -148,51 +140,24 @@ export function OpsEventsSection({ }), getAccessToken ).catch(nullOn404); - setEvents(data?.events ?? []); - setNextLink(data?.nextLink ?? null); + return { items: data?.events ?? [], nextLink: data?.nextLink ?? null }; } catch (err) { setError(apiErrorText(err, "Failed to load ops events")); - } finally { - setLoading(false); + return null; } - }, [categoryFilter, dateFromIso, dateToIso, getAccessToken, setError]); + }; + const pager = useCursorPager(fetchEvents); + const { reset } = pager; + const events = pager.items; + const loading = !pager.settled || pager.pending !== null; - // Reset pagination + refetch whenever the filter window or category changes. - // fetchEvents is intentionally excluded from deps: getAccessToken's identity - // churns on every MSAL accounts-array refresh, which happens after each - // authenticatedFetch — leaving fetchEvents in deps causes the effect to - // re-fire after every successful page-N click, race a page-1 fetch against - // it, and snap the user back to page 1. + // Back to page 1 whenever the filter window or category changes. useEffect(() => { const run = async () => { - setContinuation(null); - setContinuationStack([]); - setPageNumber(1); - await fetchEvents(null); + await reset(); }; void run(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [categoryFilter, dateFromIso, dateToIso]); - - const handleNextPage = () => { - const nextCont = extractContinuation(nextLink); - if (!nextCont) return; - setContinuationStack(stack => [...stack, continuation]); - setContinuation(nextCont); - setPageNumber(n => n + 1); - fetchEvents(nextCont); - }; - - const handlePrevPage = () => { - if (continuationStack.length === 0) return; - const prev = continuationStack[continuationStack.length - 1]; - setContinuationStack(stack => stack.slice(0, -1)); - setContinuation(prev ?? null); - setPageNumber(n => Math.max(1, n - 1)); - fetchEvents(prev ?? null); - }; - - const handleRefresh = () => fetchEvents(continuation); + }, [reset, categoryFilter, dateFromIso, dateToIso]); // Search filter operates on the current backend page only — Pattern B1 shows // one page at a time, so cross-page totals are intentionally not surfaced. @@ -244,7 +209,7 @@ export function OpsEventsSection({ Configure Alerts @@ -275,10 +246,10 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio
- {items.length} on page {pageNumber}{nextLink ? "" : " (last)"} + {items.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"}
- - + +
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx index dae76bd8..15fe409d 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx @@ -1,11 +1,12 @@ "use client"; -import { Suspense, useCallback, useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; import { apiErrorText, fetchJson, fetchOk, jsonBody, nullOn404 } from "@/lib/apiClient"; import TruncatedLabel from "@/components/TruncatedLabel"; -import { extractContinuation } from "@/lib/paginationLink"; +import type { CursorPage } from "@/lib/cursorPager"; +import { useCursorPager } from "@/hooks/useCursorPager"; import { isGuid } from "@/utils/inputValidation"; import { trackEvent } from "@/lib/appInsights"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; @@ -185,20 +186,36 @@ function SessionReportsSectionInner({ // Admin Note is GA-only (PATCH session-reports/{id}/note is GlobalAdminOnly). A read-only Global // Reader may view reports + notes but not edit them. const canMutate = useCanMutatePlatform(); - const [reports, setReports] = useState([]); - // true from the start: the fetch fires on mount, and a false initial value - // flashes the empty state for one paint. - const [loading, setLoading] = useState(true); const [selectedReport, setSelectedReport] = useState(null); const [downloadingBlob, setDownloadingBlob] = useState(null); // Pattern B1 click-next replace state — backend pagination const [tenantFilterInput, setTenantFilterInput] = useState(""); const [tenantFilterApplied, setTenantFilterApplied] = useState(undefined); - const [continuation, setContinuation] = useState(null); - const [nextLink, setNextLink] = useState(null); - const [continuationStack, setContinuationStack] = useState>([]); - const [pageNumber, setPageNumber] = useState(1); + + const fetchReports = async (cursor: string | null): Promise | null> => { + try { + // 404 = table/container does not exist yet: no reports submitted so far, not an error. + const data = await fetchJson( + api.reports.list({ + tenantId: tenantFilterApplied, + pageSize: PAGE_SIZE, + continuation: cursor ?? undefined, + }), + getAccessToken, + ).catch(nullOn404); + return { items: data?.reports ?? [], nextLink: data?.nextLink ?? null }; + } catch (err) { + setError(apiErrorText(err, "Failed to load reports")); + return null; + } + }; + const pager = useCursorPager(fetchReports); + const { reset, updateItems } = pager; + const reports = pager.items; + // Loading until the first fetch settles: the fetch fires on mount, and a false + // initial value flashes the empty state for one paint. + const loading = !pager.settled || pager.pending !== null; // Deep link from GA notifications: ?reportId=… auto-opens the report modal once // the first page has loaded (one-shot). Reports are listed newest-first, so a @@ -254,47 +271,16 @@ function SessionReportsSectionInner({ const handleNoteSaved = (updated: SessionReport) => { setSelectedReport(updated); - setReports(prev => prev.map(r => r.reportId === updated.reportId ? updated : r)); + updateItems(prev => prev.map(r => r.reportId === updated.reportId ? updated : r)); }; - const fetchReports = useCallback(async (cursor: string | null, filterTenantId: string | undefined) => { - try { - setLoading(true); - - // 404 = table/container does not exist yet: no reports submitted so far, not an error. - const data = await fetchJson( - api.reports.list({ - tenantId: filterTenantId, - pageSize: PAGE_SIZE, - continuation: cursor ?? undefined, - }), - getAccessToken, - ).catch(nullOn404); - setReports(data?.reports ?? []); - setNextLink(data?.nextLink ?? null); - } catch (err) { - setError(apiErrorText(err, "Failed to load reports")); - } finally { - setLoading(false); - } - }, [getAccessToken, setError]); - // Initial load + reload whenever the applied tenant filter changes. - // fetchReports is intentionally excluded from deps: getAccessToken's identity - // churns on every MSAL accounts-array refresh, which happens after each - // authenticatedFetch — leaving fetchReports in deps causes the effect to - // re-fire after every successful page-N click, race a page-1 fetch against - // it, and snap the user back to page 1. useEffect(() => { const run = async () => { - setContinuation(null); - setContinuationStack([]); - setPageNumber(1); - await fetchReports(null, tenantFilterApplied); + await reset(); }; void run(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tenantFilterApplied]); + }, [reset, tenantFilterApplied]); const handleApplyTenantFilter = () => { const trimmed = tenantFilterInput.trim(); @@ -311,26 +297,6 @@ function SessionReportsSectionInner({ setTenantFilterApplied(undefined); }; - const handleNextPage = () => { - const nextCont = extractContinuation(nextLink); - if (!nextCont) return; - setContinuationStack(stack => [...stack, continuation]); - setContinuation(nextCont); - setPageNumber(n => n + 1); - fetchReports(nextCont, tenantFilterApplied); - }; - - const handlePrevPage = () => { - if (continuationStack.length === 0) return; - const prev = continuationStack[continuationStack.length - 1]; - setContinuationStack(stack => stack.slice(0, -1)); - setContinuation(prev ?? null); - setPageNumber(n => Math.max(1, n - 1)); - fetchReports(prev ?? null, tenantFilterApplied); - }; - - const handleRefresh = () => fetchReports(continuation, tenantFilterApplied); - return (
{/* Section Header */} @@ -376,7 +342,7 @@ function SessionReportsSectionInner({ )}