diff --git a/src/Web/autopilot-monitor-web/app/activation/page.tsx b/src/Web/autopilot-monitor-web/app/activation/page.tsx index b262f1e9..e7a8fb1e 100644 --- a/src/Web/autopilot-monitor-web/app/activation/page.tsx +++ b/src/Web/autopilot-monitor-web/app/activation/page.tsx @@ -4,6 +4,7 @@ import { useAuth } from "../../contexts/AuthContext"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { api } from "@/lib/api"; +import { apiErrorText, fetchOk } from "@/lib/apiClient"; import { DOCS_URL } from "@/utils/config"; import { BrandMark } from "../../components/BrandMark"; @@ -117,29 +118,15 @@ export default function ActivationPage() { setEmailStatus("saving"); setEmailError(""); - const token = await getAccessToken(); - if (!token) { - throw new Error("Not authenticated"); - } - - const response = await fetch(api.preview.notificationEmail(), { + await fetchOk(api.preview.notificationEmail(), getAccessToken, { method: "PUT", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json", - }, body: JSON.stringify({ email }), }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || "Failed to save email"); - } - setEmailStatus("saved"); markAddressDecided(); } catch (err) { - setEmailError(err instanceof Error ? err.message : "Failed to save email"); + setEmailError(apiErrorText(err, "Failed to save email")); setEmailStatus("error"); } }; diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 8d010215..84a82ba5 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -4,7 +4,6 @@ import { useEffect, useState, useCallback } from "react"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { trackEvent } from "@/lib/appInsights"; import { downloadAsJson, stripInternalFields, bumpVersion } from "@/lib/rulePageHelpers"; import { StatCard } from "@/components/rules/StatCard"; @@ -34,6 +33,8 @@ import TemplateConfigModal from "./components/TemplateConfigModal"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; import { DocsLink } from "@/components/DocsLink"; +import type { RuleStatsResponse, TenantConfiguration } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; export default function AnalyzeRulesPage() { const { user, getAccessToken } = useAuth(); @@ -133,9 +134,8 @@ export default function AnalyzeRulesPage() { const statsUrl = scope.routeGlobal ? api.metrics.globalRuleStats(undefined, undefined, "analyze", effectiveTenantId) : api.metrics.ruleStats(undefined, undefined, "analyze"); - const response = await authenticatedFetch(statsUrl, getAccessToken); - if (response.ok) { - const data = await response.json(); + const data = await fetchJson(statsUrl, getAccessToken); + { const map: Record = {}; @@ -175,15 +175,7 @@ export default function AnalyzeRulesPage() { if (!effectiveTenantId) return; const fetchChannels = async () => { try { - const response = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken); - if (!response.ok) return; - const data = await response.json(); - const cfg = (data?.config ?? data) as { - notificationChannelsJson?: string; - webhookUrl?: string; - webhookProviderType?: number; - teamsWebhookUrl?: string; - }; + const cfg = await fetchJson(api.config.tenant(effectiveTenantId), getAccessToken); if (cfg?.notificationChannelsJson) { try { const parsed = JSON.parse(cfg.notificationChannelsJson); diff --git a/src/Web/autopilot-monitor-web/app/annotations/page.tsx b/src/Web/autopilot-monitor-web/app/annotations/page.tsx index 6c4dd4a1..26787cde 100644 --- a/src/Web/autopilot-monitor-web/app/annotations/page.tsx +++ b/src/Web/autopilot-monitor-web/app/annotations/page.tsx @@ -5,7 +5,6 @@ import Link from "next/link"; import { sessionUrl } from "@/lib/routes"; import { scopedApi } from "@/lib/scopedApi"; import { API_BASE_URL } from "@/utils/config"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { ProtectedRoute } from "@/components/ProtectedRoute"; import { useAuth } from "@/contexts/AuthContext"; import { useAdminMode, useAggregatedAdminScope } from "@/hooks"; @@ -23,6 +22,7 @@ import { } from "../sessions/components/sessionAnnotationLogic"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import { fetchJson, nullOn404 } from "@/lib/apiClient"; /** * Annotations overview: every annotated session in one list, so a judged session can be @@ -92,16 +92,14 @@ export default function AnnotationsPage() { const fetchPage = useCallback( async (url: string, append: boolean) => { try { - const res = await authenticatedFetch(url, getAccessToken); - if (res.status === 404) { - // Deploy skew: a backend without the route yet. Reads as "nothing annotated", - // never as an error — the empty state below carries the call to action. + // Deploy skew: a backend without the route yet (404) reads as "nothing annotated", + // never as an error; the empty state below carries the call to action. + const json = await fetchJson(url, getAccessToken).catch(nullOn404); + if (!json) { if (!append) setRows([]); setNextLink(null); return; } - if (!res.ok) throw new Error(res.statusText); - const json = (await res.json()) as AnnotationsListResponse; setRows((prev) => (append ? [...prev, ...(json.annotations ?? [])] : (json.annotations ?? []))); setNextLink(json.nextLink ?? null); } catch { diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx index 8e50c6a6..6333ac59 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx @@ -5,8 +5,9 @@ import { useTenant } from "../../../contexts/TenantContext"; import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import type { SoftwareTabScope } from "./types"; +import { ApiError, fetchJson } from "@/lib/apiClient"; +import { notifyApiError } from "@/contexts/NotificationContext"; interface InventoryItem { displayName: string; @@ -59,18 +60,14 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) { const url = routeGlobal ? api.vulnerability.softwareInventory(selectedTenantId) : api.metrics.softwareInventory(); - const res = await authenticatedFetch(url, getAccessToken); + const data = await fetchJson(url, getAccessToken); if (cancelled) return; - if (res.ok) { - setResp((await res.json()) as InventoryResponse); - setPage(0); - } else { - addNotification("error", "Backend Error", `Failed to load inventory: ${res.statusText}`, "inventory-error"); - } + setResp(data); + setPage(0); } catch (err) { if (cancelled) return; - if (err instanceof TokenExpiredError) { - addNotification("error", "Session Expired", err.message, "session-expired-error"); + if (err instanceof ApiError) { + notifyApiError(addNotification, "Backend Error", err, "inventory-error", "Failed to load inventory."); } else { console.error("Failed to fetch software inventory", err); addNotification("error", "Backend Not Reachable", "Unable to load software inventory.", "inventory-error"); diff --git a/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx index fb7ad323..ee9a9186 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx @@ -4,17 +4,18 @@ import { useEffect, useState } from "react"; import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; import { scopedApi } from "@/lib/scopedApi"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import VulnerabilityExposurePanel, { type VulnerabilitySummary } from "@/components/VulnerabilityExposurePanel"; import type { SoftwareTabScope, TimeRange } from "./types"; import { rangeToDays } from "./types"; +import { ApiError, fetchJson } from "@/lib/apiClient"; +import { notifyApiError } from "@/contexts/NotificationContext"; const TOP_N = 20; export default function VulnerabilitiesTab({ scope, timeRange }: { scope: SoftwareTabScope; timeRange: TimeRange }) { const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); - const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope; + const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope; const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); @@ -31,20 +32,16 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa try { setLoading(true); const url = scopedApi.vulnerability(scope, days, TOP_N); - const res = await authenticatedFetch(url, getAccessToken); + const summary = await fetchJson(url, getAccessToken); if (cancelled) return; - if (res.ok) { - setSummary((await res.json()) as VulnerabilitySummary); - } else { - addNotification("error", "Backend Error", `Failed to load vulnerability exposure: ${res.statusText}`, "vuln-exposure-error"); - } + setSummary(summary); } catch (err) { if (cancelled) return; - if (err instanceof TokenExpiredError) { - addNotification("error", "Session Expired", err.message, "session-expired-error"); + if (err instanceof ApiError) { + notifyApiError(addNotification, "Backend Error", err, "vuln-exposure-error", "Failed to load vulnerability exposure."); } else { console.error("Failed to fetch vulnerability exposure", err); - addNotification("error", "Backend Not Reachable", "Unable to load vulnerability exposure.", "vuln-exposure-error"); + notifyApiError(addNotification, "Backend Not Reachable", err, "vuln-exposure-error", "Unable to load vulnerability exposure."); } } finally { if (!cancelled) setLoading(false); diff --git a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx index 245cfb3f..0f18a40b 100644 --- a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx @@ -9,7 +9,6 @@ import { useTenant } from "../../../contexts/TenantContext"; import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; import { scopedApi } from "@/lib/scopedApi"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; import { trackEvent } from "@/lib/appInsights"; // Static import is safe: appReportData is pure data prep with no jsPDF dependency — @@ -21,6 +20,7 @@ import { TenantScopeSelector } from "@/components/TenantScopeSelector"; import { CalculatingInline } from "@/components/CalculatingCard"; import { useFetchProgress } from "@/hooks/useFetchProgress"; import { chartColors } from "../../../components/charts/chartTheme"; +import { fetchJson } from "@/lib/apiClient"; // A cross-tenant analytics aggregation can take tens of seconds server-side; the default 30s // fetch timeout would abort it client-side while the server keeps computing. @@ -324,32 +324,16 @@ function AppDetailContent() { setLoading(true); progressBegin(); const url = scopedApi.appAnalytics(scope, appName, days); - const response = await authenticatedFetch(url, getAccessToken, { - signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS), - }); - if (response.ok) { - setAnalytics((await response.json()) as AnalyticsResponse); - succeeded = true; - } else { - addNotification( - "error", - "Backend Error", - `Failed to load app analytics: ${response.statusText}`, - "app-analytics-error" - ); - } + setAnalytics(await fetchJson(url, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) })); + succeeded = true; } catch (err) { - if (err instanceof TokenExpiredError) { - addNotification("error", "Session Expired", err.message, "session-expired-error"); - } else { - console.error("Failed to fetch app analytics", err); - addNotification( - "error", - "Backend Not Reachable", - "Unable to load app analytics.", - "app-analytics-error" - ); - } + console.error("Failed to fetch app analytics", err); + addNotification( + "error", + "Backend Not Reachable", + "Unable to load app analytics.", + "app-analytics-error" + ); } finally { progressFinish(succeeded); setLoading(false); @@ -362,16 +346,9 @@ function AppDetailContent() { try { setSessionsLoading(true); const url = scopedApi.appSessions(scope, appName, days, status, offset, SESSIONS_PAGE_SIZE); - const response = await authenticatedFetch(url, getAccessToken, { - signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS), - }); - if (response.ok) { - setSessions((await response.json()) as SessionsResponse); - } + setSessions(await fetchJson(url, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) })); } catch (err) { - if (!(err instanceof TokenExpiredError)) { - console.error("Failed to fetch sessions", err); - } + console.error("Failed to fetch sessions", err); } finally { setSessionsLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/audit/page.tsx b/src/Web/autopilot-monitor-web/app/audit/page.tsx index 307aca47..4c28f834 100644 --- a/src/Web/autopilot-monitor-web/app/audit/page.tsx +++ b/src/Web/autopilot-monitor-web/app/audit/page.tsx @@ -6,13 +6,15 @@ import { useAuth } from '../../contexts/AuthContext'; import { useNotifications } from '../../contexts/NotificationContext'; import { ProtectedRoute } from '../../components/ProtectedRoute'; import { scopedApi } from "@/lib/scopedApi"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { extractContinuation } from "@/lib/paginationLink"; import { useAggregatedAdminScope } from "@/hooks"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import type { AuditLogListResponse } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; +import { notifyApiError } from "@/contexts/NotificationContext"; interface AuditLogEntry { @@ -66,7 +68,7 @@ function dateInputToIsoEnd(value: string): string { export default function AuditPage() { const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); - + const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -117,30 +119,17 @@ export default function AuditPage() { // tenant for a delegated caller, validated server-side). Own-tenant member — including a delegated // caller viewing their HOME tenant (routeGlobal false): the tenant-scoped logs. const endpoint = scopedApi.auditLogs({ routeGlobal, selectedTenantId, effectiveTenantId }, opts); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (!response.ok) { - addNotification('error', 'Backend Error', `Failed to load audit logs: ${response.statusText}`, 'audit-fetch-error'); - return; - } - const data = await response.json(); - if (!data.success) { - addNotification('error', 'Backend Error', data.message || 'Failed to load audit logs', 'audit-fetch-error'); - return; - } + const data = await fetchJson(endpoint, getAccessToken); setLogs(data.logs || []); setNextLink(data.nextLink ?? null); } catch (err) { - if (err instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', err.message, 'session-expired-error'); - } else { - console.error('Error fetching audit logs:', err); - addNotification('error', 'Backend Not Reachable', 'Unable to load audit logs. Please check your connection.', 'audit-fetch-error'); - } + console.error('Error fetching audit logs:', err); + notifyApiError(addNotification, 'Backend Error', err, 'audit-fetch-error', 'Unable to load audit logs.'); } finally { setLoading(false); setRefreshing(false); } - }, [addNotification, dateFromIso, dateToIso, getAccessToken, routeGlobal, selectedTenantId, effectiveTenantId, excludeDeletions]); + }, [dateFromIso, dateToIso, getAccessToken, routeGlobal, selectedTenantId, effectiveTenantId, excludeDeletions, addNotification]); // Initial / window-change fetch resets pagination state. // fetchPage is intentionally excluded from deps: its identity churns whenever diff --git a/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx b/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx index ff34fc29..53220b24 100644 --- a/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx +++ b/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx @@ -4,10 +4,10 @@ import { Suspense, useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { api } from "@/lib/api"; import { describeDelegationError, invitationStatusLabel } from "@/lib/delegations"; import type { AcceptDelegationInvitationResponse, DelegationAcceptPreviewResponse } from "@/utils/wire-types.generated"; +import { ApiError, apiErrorText, fetchJson } from "@/lib/apiClient"; export default function AcceptDelegationPage() { // useSearchParams needs a Suspense boundary for the static prerender (query-string route). @@ -32,12 +32,15 @@ function AcceptDelegationInner() { const [accepting, setAccepting] = useState(false); const [done, setDone] = useState(null); - const explain = useCallback(async (response: Response, fallback: string) => { - const data = await response.json().catch(() => ({})); - if (response.status === 403) { - return "Only a tenant administrator of your tenant can accept an invitation. Ask an administrator to open this link."; + /** The user-facing text for a refused invitation call; other failures render through apiErrorText. */ + const explain = useCallback((err: unknown, fallback: string) => { + if (err instanceof ApiError) { + if (err.status === 403) { + return "Only a tenant administrator of your tenant can accept an invitation. Ask an administrator to open this link."; + } + return describeDelegationError(err.code, err.message || fallback); } - return describeDelegationError(data.code, data.error || fallback); + return apiErrorText(err, fallback); }, []); useEffect(() => { @@ -45,16 +48,12 @@ function AcceptDelegationInner() { let cancelled = false; const run = async () => { try { - const response = await authenticatedFetch(api.delegations.acceptPreview(token), getAccessToken); + const preview = await fetchJson(api.delegations.acceptPreview(token), getAccessToken); if (cancelled) return; - if (!response.ok) { - setError(await explain(response, `Could not read the invitation (${response.status}).`)); - return; - } - setPreview((await response.json()) as DelegationAcceptPreviewResponse); + setPreview(preview); } catch (err) { if (cancelled) return; - setError(err instanceof TokenExpiredError ? "Session expired. Please refresh the page." : err instanceof Error ? err.message : "Could not read the invitation."); + setError(explain(err, "Could not read the invitation.")); } finally { if (!cancelled) setLoadingState(false); } @@ -69,18 +68,12 @@ function AcceptDelegationInner() { setAccepting(true); setError(null); try { - const response = await authenticatedFetch(api.delegations.accept(), getAccessToken, { + setDone(await fetchJson(api.delegations.accept(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), - }); - if (!response.ok) { - setError(await explain(response, `Could not accept the invitation (${response.status}).`)); - return; - } - setDone((await response.json()) as AcceptDelegationInvitationResponse); + })); } catch (err) { - setError(err instanceof Error ? err.message : "Could not accept the invitation."); + setError(explain(err, "Could not accept the invitation.")); } finally { setAccepting(false); setConfirming(false); diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx index 2e3f8a7c..3750eed2 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx @@ -11,7 +11,6 @@ import { ProtectedRoute } from "../../components/ProtectedRoute"; import { api } from "@/lib/api"; import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { ConfidenceBadge, SeverityBadge } from "./components/DiagnosisBadges"; import { Session, EnrollmentEvent, RuleResult } from "@/types"; import { useAdminMode } from "@/hooks/useAdminMode"; @@ -19,6 +18,9 @@ import { isGuid } from "@/utils/inputValidation"; import { safeHttpUrl } from "@/lib/safeDocUrl"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import type { GetRuleResultsResponse, GetSessionEventsResponse, GetSessionResponse } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; +import { notifyApiError } from "@/contexts/NotificationContext"; export default function DiagnosisPage() { // useSearchParams() in DiagnosisContent requires a Suspense boundary for static prerender. @@ -65,7 +67,7 @@ function DiagnosisContent() { const { tenantId } = useTenant(); const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); - + const { globalAdminMode } = useAdminMode(); // Declared ahead of the effects that call them (react-hooks/immutability: @@ -80,64 +82,38 @@ function DiagnosisContent() { knownTenantId && isGuid(knownTenantId) ? api.sessions.get(sessionId, knownTenantId) : api.sessions.get(sessionId); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (response.ok) { - const data = await response.json(); - const found = - data.session ?? - data.sessions?.find((s: Session) => s.sessionId === sessionId); - if (found) { - setSession(found); - setSessionTenantId(found.tenantId); - } - } else { - addNotification('error', 'Backend Error', `Failed to load session: ${response.statusText}`, 'diagnosis-fetch-error'); + const data = await fetchJson(endpoint, getAccessToken); + const found = data.session; + if (found) { + setSession(found); + setSessionTenantId(found.tenantId); } } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } else { - console.error("Failed to fetch session details:", error); - addNotification('error', 'Backend Not Reachable', 'Unable to load session details. Please check your connection.', 'diagnosis-fetch-error'); - } + console.error("Failed to fetch session details:", error); + notifyApiError(addNotification, 'Backend Error', error, 'diagnosis-fetch-error', 'Unable to load session details.'); } finally { sessionFetchDone.current = true; finishLoadingWhenSettled(); } - }, [sessionId, sessionTenantId, tenantId, globalAdminMode, getAccessToken, addNotification, finishLoadingWhenSettled]); + }, [sessionId, sessionTenantId, tenantId, globalAdminMode, getAccessToken, finishLoadingWhenSettled, addNotification]); const fetchEvents = useCallback(async () => { const effectiveTenantId = sessionTenantId || tenantId; try { - const response = await authenticatedFetch( - api.sessions.events(sessionId, effectiveTenantId), - getAccessToken - ); - if (response.ok) { - const data = await response.json(); - setEvents(data.events || []); - } else { - addNotification('error', 'Backend Error', `Failed to load events: ${response.statusText}`, 'diagnosis-events-error'); - } + const data = await fetchJson(api.sessions.events(sessionId, effectiveTenantId), getAccessToken); + // No `fields` projection on this read, so every event is complete. + setEvents((data.events || []) as EnrollmentEvent[]); } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } else { - console.error("Failed to fetch events:", error); - addNotification('error', 'Backend Not Reachable', 'Unable to load session events. Please check your connection.', 'diagnosis-events-error'); - } + console.error("Failed to fetch events:", error); + notifyApiError(addNotification, 'Backend Error', error, 'diagnosis-events-error', 'Unable to load session events.'); } }, [sessionId, sessionTenantId, tenantId, getAccessToken, addNotification]); const fetchAnalysisResults = useCallback(async () => { const effectiveTenantId = sessionTenantId || tenantId; try { - const response = await authenticatedFetch( - api.sessions.analysis(sessionId, effectiveTenantId), - getAccessToken - ); - if (response.ok) { - const data = await response.json(); + const data = await fetchJson(api.sessions.analysis(sessionId, effectiveTenantId), getAccessToken); + { if (data.results) { // Resolved findings (no longer detected by a later evaluation) are audit-only — // the diagnosis flow must not present them as the probable cause. @@ -152,16 +128,12 @@ function DiagnosisContent() { } } } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } else { - console.error("Failed to fetch analysis results:", error); - } + console.error("Failed to fetch analysis results:", error); } finally { analysisFetchDone.current = true; finishLoadingWhenSettled(); } - }, [sessionId, sessionTenantId, tenantId, getAccessToken, addNotification, finishLoadingWhenSettled]); + }, [sessionId, sessionTenantId, tenantId, getAccessToken, finishLoadingWhenSettled]); useEffect(() => { if (!sessionId) return; diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts b/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts index 7a675316..4c48bf16 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts +++ b/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts @@ -3,9 +3,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { scopedApi } from "@/lib/scopedApi"; import { useLatest } from "@/hooks/useLatest"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; -import type { NotificationType } from "@/contexts/NotificationContext"; import type { SignalRMessageName } from "@/lib/signalrMessages"; +import { ApiError, fetchJson } from "@/lib/apiClient"; // Wire shape of the server-aggregated Fleet Health payload (camelCase of the // backend FleetHealthMetrics DTO). Presentation-only derivations (bar maxima, @@ -64,13 +63,6 @@ export interface FleetHealthData { computedAt: string; } -type AddNotification = ( - type: NotificationType, - title: string, - message: string, - key?: string, - href?: string, -) => void; interface SignalRApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -98,7 +90,6 @@ interface UseFleetHealthParams { scopeKey: string; days: number; getAccessToken: (forceRefresh?: boolean) => Promise; - addNotification: AddNotification; signalR: SignalRApi; } @@ -127,7 +118,6 @@ export function useFleetHealth({ scopeKey, days, getAccessToken, - addNotification, signalR, }: UseFleetHealthParams): UseFleetHealthReturn { const [data, setData] = useState(null); @@ -161,24 +151,22 @@ export function useFleetHealth({ daysRef.current, ); - const response = await authenticatedFetch(url, getAccessToken); - - // Stale fetch — a newer scope change won the race. Drop silently. - if (myGen !== fetchGenRef.current) return; - - if (!response.ok) { - let detail = response.statusText; - try { - const body = await response.json(); - if (body?.message) detail = body.message; - } catch { - /* not JSON */ + let body: FleetHealthData; + try { + body = await fetchJson(url, getAccessToken); + } catch (err) { + // Stale fetch: a newer scope change won the race. Drop silently. + if (myGen !== fetchGenRef.current) return; + if (err instanceof ApiError) { + setError(err.message); + return; } - setError(detail); - return; + throw err; } - const body = (await response.json()) as FleetHealthData; + // Stale fetch: a newer scope change won the race. Drop silently. + if (myGen !== fetchGenRef.current) return; + if (body?.success) { setData(body); } else { @@ -186,16 +174,12 @@ export function useFleetHealth({ } } catch (e) { if (myGen !== fetchGenRef.current) return; - if (e instanceof TokenExpiredError) { - addNotification("error", "Session Expired", e.message, "session-expired-error"); - } else { - console.error("Failed to fetch fleet health:", e); - setError("Unable to load fleet health data"); - } + console.error("Failed to fetch fleet health:", e); + setError("Unable to load fleet health data"); } finally { if (myGen === fetchGenRef.current) setLoading(false); } - }, [getAccessToken, addNotification, routeGlobalRef, selectedTenantIdRef, tenantIdRef, daysRef]); + }, [getAccessToken, routeGlobalRef, selectedTenantIdRef, tenantIdRef, daysRef]); const refresh = useCallback(() => { if (debounceTimerRef.current) { diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index adc4fad0..11f5612d 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -11,7 +11,6 @@ import { useAuth } from "../../contexts/AuthContext"; import { useNotifications } from "../../contexts/NotificationContext"; import { scopedApi } from "@/lib/scopedApi"; import { fetchJson } from "@/lib/apiClient"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import FleetStatCard from "./components/FleetStatCard"; import TimeAttributionSection, { TimeAttributionResponseDto } from "./components/TimeAttributionSection"; import DeviceJourneySection, { DeviceJourneyResponseDto } from "./components/DeviceJourneySection"; @@ -26,6 +25,7 @@ import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; import { formatDuration } from "@/lib/formatting"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import { notifyApiError } from "@/contexts/NotificationContext"; interface AppMetric { appName: string; @@ -59,7 +59,7 @@ export default function FleetHealthPage() { const { tenantId } = useTenant(); const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); - + // Global admin tenant scope (aggregated-capable): tenant list, selection ("" = all tenants), // scope flags, and effectiveTenantId (empty in aggregated mode → skips the SignalR group). const scope = useAggregatedAdminScope(); @@ -78,7 +78,6 @@ export default function FleetHealthPage() { scopeKey, days, getAccessToken, - addNotification, signalR: { on, off, isConnected }, }); @@ -86,20 +85,10 @@ export default function FleetHealthPage() { try { const d = range === "7d" ? 7 : range === "30d" ? 30 : 90; const endpoint = scopedApi.appMetrics(scope, d); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (response.ok) { - const json = await response.json(); - setAppMetrics(json); - } else { - addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error'); - } + setAppMetrics(await fetchJson(endpoint, getAccessToken)); } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } else { - console.error("Failed to fetch app metrics:", error); - addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error'); - } + console.error("Failed to fetch app metrics:", error); + notifyApiError(addNotification, 'Backend Error', error, 'fleet-health-metrics-error', 'Unable to load app metrics.'); } }; diff --git a/src/Web/autopilot-monitor-web/app/fleet/hooks/useFleetSummaries.ts b/src/Web/autopilot-monitor-web/app/fleet/hooks/useFleetSummaries.ts index 7220ac99..7a5997af 100644 --- a/src/Web/autopilot-monitor-web/app/fleet/hooks/useFleetSummaries.ts +++ b/src/Web/autopilot-monitor-web/app/fleet/hooks/useFleetSummaries.ts @@ -3,9 +3,10 @@ import { useEffect, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { isHomeTenantTarget } from "@/utils/homeTenantScope"; import type { FleetSummary } from "../lib/fleetRollup"; +import type { SessionStatsResponse } from "@/utils/wire-types.generated"; +import { fetchJson, nullOnApiError } from "@/lib/apiClient"; export interface FleetSummariesState { /** tenantId (verbatim) → summary. Absent while loading or if that tenant's fetch failed. */ @@ -58,9 +59,7 @@ export function useFleetSummaries(tenantIds: string[], days: number, homeTenantI const url = isHomeTenantTarget(tenantId, homeTenantId) ? api.sessions.stats({ days }) : api.globalSessions.stats({ tenantId, days }); - const response = await authenticatedFetch(url, getAccessToken); - if (!response.ok) continue; - const body = await response.json(); + const body = await fetchJson(url, getAccessToken).catch(nullOnApiError); if (body?.stats) result[tenantId] = body.stats as FleetSummary; } catch (err) { // One tenant's failure must not sink the whole fleet view — skip it; the card shows "—". diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx index d0109f45..07866cba 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx @@ -4,7 +4,6 @@ import { useEffect, useState, useCallback } from "react"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { trackEvent } from "@/lib/appInsights"; import { downloadAsJson, stripInternalFields, bumpVersion } from "@/lib/rulePageHelpers"; import { StatCard } from "@/components/rules/StatCard"; @@ -20,6 +19,8 @@ import { GatherRuleCard } from "./components/GatherRuleCard"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; import { DocsLink } from "@/components/DocsLink"; +import type { TenantFeatureFlagsResponse } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; export default function GatherRulesPage() { const { user, getAccessToken } = useAuth(); @@ -111,11 +112,8 @@ export default function GatherRulesPage() { // Reset first so a failed/forbidden fetch never carries the previous tenant's mode over. setUnrestrictedMode(false); try { - const response = await authenticatedFetch(api.config.featureFlags(effectiveTenantId), getAccessToken); - if (!stale && response.ok) { - const data = await response.json(); - setUnrestrictedMode(data.unrestrictedMode ?? false); - } + const data = await fetchJson(api.config.featureFlags(effectiveTenantId), getAccessToken); + if (!stale) setUnrestrictedMode(data.unrestrictedMode ?? false); } catch { // Silently default to restricted mode } diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx index 4bac956a..ffd5c725 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx @@ -7,7 +7,6 @@ import { ProtectedRoute } from "../../components/ProtectedRoute"; import TruncatedLabel from "@/components/TruncatedLabel"; import { useAuth } from "../../contexts/AuthContext"; import { scopedApi } from "@/lib/scopedApi"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useAggregatedAdminScope } from "@/hooks"; import { useFetchProgress } from "@/hooks/useFetchProgress"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; @@ -28,6 +27,7 @@ import { type MapColorModeId, } from "./mapColorModes"; import { MapLegend } from "./MapLegend"; +import { fetchJson } from "@/lib/apiClient"; // A cross-tenant geo aggregation can take tens of seconds server-side; the default 30s fetch // timeout would abort it client-side while the server keeps computing. @@ -88,20 +88,10 @@ export default function GeographicPerformancePage() { progressBegin(); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const endpoint = scopedApi.geographic({ routeGlobal, selectedTenantId, effectiveTenantId }, days, group); - const response = await authenticatedFetch(endpoint, getAccessToken, { - signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS), - }); - if (response.ok) { - const data = await response.json(); - setGeoMetrics(data); - succeeded = true; - } + setGeoMetrics(await fetchJson(endpoint, getAccessToken, { signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS) })); + succeeded = true; } catch (error) { - if (error instanceof TokenExpiredError) { - console.error("Session expired:", error.message); - } else { - console.error("Failed to fetch geographic metrics:", error); - } + console.error("Failed to fetch geographic metrics:", error); } finally { progressFinish(succeeded); setLoading(false); 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 7b605314..2be50d87 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 @@ -7,7 +7,6 @@ import { ProtectedRoute } from "../../../components/ProtectedRoute"; import { useTenant } from "../../../contexts/TenantContext"; 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"; import { GlobalAdminBanner } from "@/components/GlobalAdminBanner"; @@ -15,7 +14,8 @@ import { boundTenantToDelegatedScope } from "@/utils/delegatedScope"; import { isHomeTenantTarget } from "@/utils/homeTenantScope"; import { useFetchProgress } from "@/hooks/useFetchProgress"; import { CalculatingCard } from "@/components/CalculatingCard"; -import type { LocationSessionRow } from "@/utils/wire-types.generated"; +import type { GeographicLocationSessionsResponse, LocationSessionRow } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; // A cross-tenant drilldown can take tens of seconds server-side; the default 30s fetch // timeout would abort it client-side while the server keeps computing. @@ -107,20 +107,10 @@ function LocationSessionsContent() { const endpoint = crossTenant ? api.metrics.globalGeographicSessions(Number(days), groupBy, locationKey, boundedTenantId) : api.metrics.geographicSessions(tenantId, Number(days), groupBy, locationKey); - const response = await authenticatedFetch(endpoint, getAccessToken, { - signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS), - }); - if (response.ok) { - const result = await response.json(); - setData(result); - succeeded = true; - } + setData(await fetchJson(endpoint, getAccessToken, { signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS) })); + succeeded = true; } catch (error) { - if (error instanceof TokenExpiredError) { - console.error("Session expired:", error.message); - } else { - console.error("Failed to fetch location sessions:", error); - } + console.error("Failed to fetch location sessions:", error); } finally { progressFinish(succeeded); setLoading(false); diff --git a/src/Web/autopilot-monitor-web/app/health-check/page.tsx b/src/Web/autopilot-monitor-web/app/health-check/page.tsx index 7709f822..8e6f525b 100644 --- a/src/Web/autopilot-monitor-web/app/health-check/page.tsx +++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx @@ -8,11 +8,12 @@ import { useGlobalAdminUi } from '@/hooks/useGlobalAdminUi'; import { useState, useEffect, useCallback } from 'react'; import * as signalR from '@microsoft/signalr'; import { api } from '@/lib/api'; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { isUrlDetail, visibleHealthChecks, visibleHealthDetails } from '@/lib/healthCheckView'; import type { DetailedHealthCheckResponse, HealthCheck, McpHealthCheckResponse } from '@/utils/wire-types.generated'; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import { ApiError, fetchJson } from "@/lib/apiClient"; +import { notifyApiError } from "@/contexts/NotificationContext"; function formatDetailValue(value: unknown): string { return Array.isArray(value) ? value.join(', ') : typeof value === 'object' ? JSON.stringify(value) : String(value); @@ -88,26 +89,22 @@ export default function HealthCheckPage() { const performHealthCheck = useCallback(async () => { setLoading(true); try { - const response = await authenticatedFetch(api.health.detailed(), getAccessToken); - - if (!response.ok) { - if (response.status === 403) { + let data: DetailedHealthCheckResponse; + try { + data = await fetchJson(api.health.detailed(), getAccessToken); + } catch (err) { + if (!(err instanceof ApiError)) throw err; + if (err.status === 403) { addNotification('error', 'Access Denied', 'You do not have permission to access health checks', 'health-check-forbidden'); } else { - addNotification('error', 'Health Check Failed', `Status: ${response.status}`, 'health-check-failed'); + notifyApiError(addNotification, 'Health Check Failed', err, 'health-check-failed'); } return; } - - const data = (await response.json()) as DetailedHealthCheckResponse; setHealthResult(data); } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } else { - console.error('Health check error:', error); - addNotification('error', 'Health Check Error', error instanceof Error ? error.message : 'Unknown error', 'health-check-error'); - } + console.error('Health check error:', error); + addNotification('error', 'Health Check Error', error instanceof Error ? error.message : 'Unknown error', 'health-check-error'); } finally { setLoading(false); setHasRun(true); @@ -121,19 +118,21 @@ export default function HealthCheckPage() { const performMcpCheck = useCallback(async () => { setMcpLoading(true); try { - const response = await authenticatedFetch(api.health.mcp(), getAccessToken); - if (!response.ok) { + let data: McpHealthCheckResponse; + try { + data = await fetchJson(api.health.mcp(), getAccessToken); + } catch (err) { + if (!(err instanceof ApiError)) throw err; setMcpCheck({ name: 'MCP Server', description: 'AI query interface availability', - status: response.status === 403 ? 'unknown' : 'unhealthy', - message: response.status === 403 + status: err.status === 403 ? 'unknown' : 'unhealthy', + message: err.status === 403 ? 'Not permitted to view the MCP server status' - : `MCP status check failed (HTTP ${response.status})`, + : `MCP status check failed (HTTP ${err.status})`, }); return; } - const data = (await response.json()) as McpHealthCheckResponse; if (data?.check) setMcpCheck(data.check); } catch (error) { // Network/token errors: surface on the card itself, never as a blocking page error. diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx index eec3e9e9..0fd180be 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { TokenExpiredError } from "@/lib/authenticatedFetch"; import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminMode } from "@/hooks/useAdminMode"; import { @@ -143,7 +142,6 @@ export default function SessionAnnotationsCard({ }); setLaneEdit(lane, { saving: false, saveResult: "saved" }); } catch (err) { - if (err instanceof TokenExpiredError) console.error("Session expired while saving annotation"); setLaneEdit(lane, { saving: false, saveResult: apiErrorText(err, "Failed to save annotation"), diff --git a/src/Web/autopilot-monitor-web/app/sla/page.tsx b/src/Web/autopilot-monitor-web/app/sla/page.tsx index 88e3cc71..a1dee765 100644 --- a/src/Web/autopilot-monitor-web/app/sla/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sla/page.tsx @@ -8,7 +8,6 @@ import { useNotifications } from '../../contexts/NotificationContext'; import TruncatedLabel from '@/components/TruncatedLabel'; import { ProtectedRoute } from '../../components/ProtectedRoute'; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import dynamic from "next/dynamic"; import { SlaGauge } from "@/components/charts/SlaGauge"; import { chartColors } from "@/components/charts/chartTheme"; @@ -31,65 +30,19 @@ import { SegmentedControl } from "@/components/SegmentedControl"; import Link from "next/link"; import { CardSkeleton } from "@/components/skeletons/PageSkeleton"; -interface SlaSnapshot { - week: string; - totalCompleted: number; - succeeded: number; - failed: number; - successRate: number; - avgDurationMinutes: number; - p95DurationMinutes: number; - durationViolationCount: number; - successRateMet: boolean; - durationTargetMet: boolean; -} -interface SlaWeeklyTrend { - week: string; - successRate: number; - p95DurationMinutes: number; - appInstallSuccessRate: number; - totalCompleted: number; - successRateMet: boolean; - durationTargetMet: boolean; - appInstallTargetMet: boolean; -} // Wire type of the SLA endpoint — note status is a NUMBER there (the C# SessionStatus // enum serialized as int; rendered via statusLabels below), unlike the string status // on SessionSummary. -import type { SlaViolatorSession } from "@/utils/wire-types.generated"; +import type { SlaMetricsResponse } from "@/utils/wire-types.generated"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import { fetchJson } from "@/lib/apiClient"; +import { notifyApiError } from "@/contexts/NotificationContext"; -interface TopFailingApp { - appName: string; - failCount: number; - totalCount: number; - successRate: number; -} -interface AppInstallSlaSnapshot { - totalInstalls: number; - succeeded: number; - failed: number; - successRate: number; - targetMet: boolean; - topFailingApps: TopFailingApp[]; -} -interface SlaMetricsResponse { - targetSuccessRate: number | null; - targetMaxDurationMinutes: number | null; - targetAppInstallSuccessRate: number | null; - currentWeek: SlaSnapshot; - weeklyTrend: SlaWeeklyTrend[]; - violators: SlaViolatorSession[]; - appInstallSla: AppInstallSlaSnapshot | null; - computedAt: string; - fromCache: boolean; - computeDurationMs: number; -} function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); @@ -107,7 +60,7 @@ export default function SlaPage() { const { tenantId } = useTenant(); const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); - + const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -140,23 +93,13 @@ export default function SlaPage() { ? api.metrics.globalSla(effectiveTenantId, months, useFresh) : api.metrics.sla(effectiveTenantId, months, useFresh); - const response = await authenticatedFetch(url, getAccessToken); - if (!isCurrent()) return; - if (!response.ok) { - addNotification('error', 'Error', `Failed to load SLA metrics: ${response.statusText}`, 'sla-fetch-error'); - return; - } - const data = await response.json(); + const data = await fetchJson(url, getAccessToken); if (!isCurrent()) return; setMetrics(data); } catch (err) { if (!isCurrent()) return; - if (err instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', err.message, 'session-expired-error'); - } else { - console.error('Error loading SLA metrics:', err); - addNotification('error', 'Error', 'Failed to load SLA metrics', 'sla-fetch-error'); - } + console.error('Error loading SLA metrics:', err); + notifyApiError(addNotification, 'Error', err, 'sla-fetch-error', 'Failed to load SLA metrics'); trackEvent('sla_load_failed', { months, error: err instanceof Error ? err.message : 'unknown', diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx index b2655625..22615758 100644 --- a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx @@ -5,93 +5,29 @@ import { useAuth } from '../../contexts/AuthContext'; import { useNotifications } from '../../contexts/NotificationContext'; import { ProtectedRoute } from '../../components/ProtectedRoute'; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useGlobalAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; import { CardSkeleton } from "@/components/skeletons/PageSkeleton"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import type { PlatformUsageMetrics } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; +import { notifyApiError } from "@/contexts/NotificationContext"; -interface SessionMetrics { - total: number; - totalAllTime?: number; - today: number; - last7Days: number; - last30Days: number; - succeeded: number; - failed: number; - inProgress: number; - incomplete: number; - successRate: number; -} -interface TenantMetrics { - total: number; - active7Days: number; - active30Days: number; -} -interface UserMetrics { - total: number; - dailyLogins: number; - active7Days: number; - active30Days: number; - note: string; -} -interface PerformanceMetrics { - avgDurationMinutes: number; - medianDurationMinutes: number; - p95DurationMinutes: number; - p99DurationMinutes: number; -} -interface HardwareCount { - name: string; - count: number; - percentage: number; -} -interface HardwareMetrics { - topManufacturers: HardwareCount[]; - topModels: HardwareCount[]; -} -interface DeploymentTypeMetrics { - userDriven: number; - whiteGlove: number; - userDrivenPercentage: number; - whiteGlovePercentage: number; -} -interface AppScriptMetrics { - avgAppsPerSession: number; - totalUniqueApps: number; - avgPlatformScriptsPerSession: number; - avgRemediationScriptsPerSession: number; - totalPlatformScripts: number; - totalRemediationScripts: number; -} -interface PlatformUsageMetrics { - sessions: SessionMetrics; - tenants: TenantMetrics; - users: UserMetrics; - performance: PerformanceMetrics; - hardware: HardwareMetrics; - deploymentTypes: DeploymentTypeMetrics; - appScripts?: AppScriptMetrics; - windowDays?: number; - computedAt: string; - computeDurationMs: number; - fromCache: boolean; -} export default function UsageMetricsPage() { const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); - const [metrics, setMetrics] = useState(null); + const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -120,25 +56,13 @@ export default function UsageMetricsPage() { ? api.metrics.globalUsage(effectiveTenantId) : api.metrics.usage(effectiveTenantId); - const response = await authenticatedFetch(url, getAccessToken); - if (!isCurrent()) return; - - if (!response.ok) { - addNotification('error', 'Backend Error', `Failed to load usage metrics: ${response.statusText}`, 'usage-metrics-fetch-error'); - return; - } - - const data = await response.json(); + const data = await fetchJson(url, getAccessToken); if (!isCurrent()) return; setMetrics(data); } catch (err) { if (!isCurrent()) return; - if (err instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', err.message, 'session-expired-error'); - } else { - console.error('Error fetching usage metrics:', err); - addNotification('error', 'Backend Not Reachable', 'Unable to load usage metrics. Please check your connection.', 'usage-metrics-fetch-error'); - } + console.error('Error fetching usage metrics:', err); + notifyApiError(addNotification, 'Backend Error', err, 'usage-metrics-fetch-error', 'Unable to load usage metrics.'); } finally { // A superseded request must not clear the newer request's loading/refreshing state. if (isCurrent()) { diff --git a/src/Web/autopilot-monitor-web/components/DelegatedSlotPrompt.tsx b/src/Web/autopilot-monitor-web/components/DelegatedSlotPrompt.tsx index d547c769..4d92e87f 100644 --- a/src/Web/autopilot-monitor-web/components/DelegatedSlotPrompt.tsx +++ b/src/Web/autopilot-monitor-web/components/DelegatedSlotPrompt.tsx @@ -1,8 +1,8 @@ "use client"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { nextSlotLimit, slotTenantLabel, type SlotLimitError } from "@/lib/delegatedSlots"; +import { ApiError, fetchOk } from "@/lib/apiClient"; /** * Raises a managing tenant's delegated slot override via PATCH config/{tenantId}/plan (GlobalAdminOnly). @@ -14,17 +14,19 @@ export async function raiseDelegatedSlotLimit( homeTenantId: string, newLimit: number, ): Promise { - const response = await authenticatedFetch(api.config.plan(homeTenantId), getAccessToken, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ maxDelegatedTenants: newLimit }), - }); - if (response.ok) return null; - if (response.status === 404) { - return "The managing tenant is not onboarded yet (no tenant configuration) — onboard it first, then raise its slot limit."; + try { + await fetchOk(api.config.plan(homeTenantId), getAccessToken, { + method: "PATCH", + body: JSON.stringify({ maxDelegatedTenants: newLimit }), + }); + return null; + } catch (err) { + if (!(err instanceof ApiError)) throw err; + if (err.status === 404) { + return "The managing tenant is not onboarded yet (no tenant configuration) — onboard it first, then raise its slot limit."; + } + return err.message || "Failed to raise the slot limit."; } - const data = await response.json().catch(() => ({})); - return data.error || `Failed to raise the slot limit: ${response.statusText}`; } interface DelegatedSlotPromptProps { diff --git a/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx b/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx index 7856d829..53cc68ea 100644 --- a/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx +++ b/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import type { FeedbackEligibilityResponse } from "@/utils/wire-types.generated"; +import { fetchJson, fetchOk, nullOnApiError } from "@/lib/apiClient"; type Phase = "loading" | "bubble" | "form" | "thankyou" | "hidden"; @@ -27,14 +28,10 @@ export default function FeedbackBubble() { const checkEligibility = async () => { try { - const response = await authenticatedFetch( - api.feedback.status(), - getAccessToken - ); + const data = await fetchJson(api.feedback.status(), getAccessToken).catch(nullOnApiError); if (cancelled) return; - if (response.ok) { - const data = await response.json(); + if (data) { if (data.eligible) { // Show bubble after 3s delay setTimeout(() => { @@ -58,9 +55,8 @@ export default function FeedbackBubble() { const handleDismiss = useCallback(async () => { setPhase("hidden"); try { - await authenticatedFetch(api.feedback.submit(), getAccessToken, { + await fetchOk(api.feedback.submit(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dismissed: true }), }); } catch { @@ -73,9 +69,8 @@ export default function FeedbackBubble() { setSubmitting(true); try { - await authenticatedFetch(api.feedback.submit(), getAccessToken, { + await fetchOk(api.feedback.submit(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rating, comment: comment.trim() || null, dismissed: false }), }); diff --git a/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx b/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx index 783db6cd..66d663e9 100644 --- a/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx +++ b/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx @@ -4,12 +4,13 @@ import { sessionUrl } from "@/lib/routes"; import { useState, useRef, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; -import { authenticatedFetch } from '@/lib/authenticatedFetch'; import { api } from '@/lib/api'; import { trackEvent } from '@/lib/appInsights'; // Wire type of the typeahead endpoint — matchedField is "sessionId" | "serialNumber" | // "deviceName" on the wire but typed string; fieldLabel handles unknown values anyway. import type { QuickSearchResult } from '@/utils/wire-types.generated'; +import type { QuickSearchSessionsResponse } from "@/utils/wire-types.generated"; +import { ApiError, fetchJson } from "@/lib/apiClient"; const fieldLabel = (field: string) => { switch (field) { @@ -163,19 +164,7 @@ export default function GlobalSearch() { setLoading(true); try { - const response = await authenticatedFetch( - api.sessions.quickSearch(q), - getAccessToken, - { signal: controller.signal }, - ); - - if (!response.ok) { - setResults([]); - setShowDropdown(true); - return; - } - - const data = await response.json(); + const data = await fetchJson(api.sessions.quickSearch(q), getAccessToken, { signal: controller.signal }); setResults(data.results ?? []); setShowDropdown(true); setSelectedIndex(-1); @@ -183,6 +172,8 @@ export default function GlobalSearch() { } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; setResults([]); + // A refused search still opens the (empty) dropdown so the user sees the query ran. + if (err instanceof ApiError) setShowDropdown(true); } finally { setLoading(false); } diff --git a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx index 5e29e6dc..0486a808 100644 --- a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx @@ -4,8 +4,8 @@ import React, { createContext, useContext, useState, useCallback, useEffect, use import { useAuth } from './AuthContext'; import { useSignalR } from './SignalRContext'; import { api } from '@/lib/api'; -import { authenticatedFetch } from '@/lib/authenticatedFetch'; import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated'; +import { fetchJson, fetchOk } from "@/lib/apiClient"; // Wire type is generated from the backend DTO; the alias keeps the established local name. export type GlobalNotification = GlobalNotificationDto; @@ -41,14 +41,8 @@ export function GlobalNotificationProvider({ children }: { children: React.React fetchingRef.current = true; try { - const response = await authenticatedFetch( - api.notifications.list(), - getAccessToken, - ); - if (response.ok) { - const data = (await response.json()) as NotificationListResponse; - setNotifications(data.notifications ?? []); - } + const data = await fetchJson(api.notifications.list(), getAccessToken); + setNotifications(data.notifications ?? []); } catch { // Silently ignore — notifications are best-effort } finally { @@ -129,7 +123,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React setNotifications(prev => prev.filter(n => n.id !== id)); try { - await authenticatedFetch( + await fetchOk( api.notifications.dismiss(id), getAccessToken, { method: 'POST' }, @@ -144,7 +138,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React setNotifications([]); try { - await authenticatedFetch( + await fetchOk( api.notifications.dismissAll(), getAccessToken, { method: 'POST' }, diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx index e8c7e681..1c20a7e6 100644 --- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -3,10 +3,10 @@ import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'; import * as signalR from '@microsoft/signalr'; import { api } from '@/lib/api'; -import { authenticatedFetch } from '@/lib/authenticatedFetch'; import { trackEvent } from '@/lib/appInsights'; import type { SignalRMessageName } from '@/lib/signalrMessages'; import { useAuth } from './AuthContext'; +import { ApiError, fetchOk } from "@/lib/apiClient"; // Hub payloads are untyped JSON; mirror @microsoft/signalr's own callback signature so // consumer handlers keep their narrower parameter types without laundering here. @@ -140,28 +140,18 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { for (const groupName of previousGroups) { try { - const response = await authenticatedFetch( - api.realtime.joinGroup(), - getAccessToken, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - // Re-present the stored serial proof for session groups — a roleless - // Progress-Portal user's rejoin is refused without it. - body: JSON.stringify({ - connectionId, - groupName, - serialNumber: joinSerialsRef.current.get(groupName), - }) - } - ); - - if (response.ok) { - joinedGroupsRef.current.add(groupName); - syncJoinedGroups(); - } else { - console.warn(`[SignalR] Failed to rejoin group ${groupName} after reconnect (status ${response.status})`); - } + await fetchOk(api.realtime.joinGroup(), getAccessToken, { + method: 'POST', + // Re-present the stored serial proof for session groups: a roleless + // Progress-Portal user's rejoin is refused without it. + body: JSON.stringify({ + connectionId, + groupName, + serialNumber: joinSerialsRef.current.get(groupName), + }), + }); + joinedGroupsRef.current.add(groupName); + syncJoinedGroups(); } catch (error) { console.warn(`[SignalR] Error rejoining group ${groupName} after reconnect:`, error); } @@ -290,29 +280,25 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { return; } - const response = await authenticatedFetch( - api.realtime.joinGroup(), - getAccessToken, - { + try { + await fetchOk(api.realtime.joinGroup(), getAccessToken, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connectionId, groupName, serialNumber: options?.serialNumber, - }) - } - ); - - if (!response.ok) { - // Remove from Set if API call failed (so we can retry). A refused join must be + }), + }); + } catch (err) { + if (!(err instanceof ApiError)) throw err; + // Remove from Set if the join was refused (so we can retry). A refused join must be // VISIBLE: a silently swallowed 403 here historically left the page looking frozen - // (no live updates, no error) — see the c4dabeee regression. - console.warn(`[SignalR] Failed to join group ${groupName} (status ${response.status})`); + // (no live updates, no error): see the c4dabeee regression. + console.warn(`[SignalR] Failed to join group ${groupName} (status ${err.status})`); joinedGroupsRef.current.delete(groupName); joinSerialsRef.current.delete(groupName); syncJoinedGroups(); - options?.onDenied?.(response.status); + options?.onDenied?.(err.status); } } catch (error) { console.error(`[SignalR] Error joining group ${groupName}:`, error); @@ -343,24 +329,12 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { return; } - const response = await authenticatedFetch( - api.realtime.leaveGroup(), - getAccessToken, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connectionId, groupName }) - } - ); - - if (!response.ok) { - // Re-add to Set if API call failed (so we can retry) - joinedGroupsRef.current.add(groupName); - syncJoinedGroups(); - } else { - // Confirmed leave — drop the stored serial proof for this group. - joinSerialsRef.current.delete(groupName); - } + await fetchOk(api.realtime.leaveGroup(), getAccessToken, { + method: 'POST', + body: JSON.stringify({ connectionId, groupName }), + }); + // Confirmed leave: drop the stored serial proof for this group (a refusal lands in the catch, which re-adds the group). + joinSerialsRef.current.delete(groupName); } catch (error) { console.error(`[SignalR] Error leaving group ${groupName}:`, error); // Re-add to Set if API call failed (so we can retry) diff --git a/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx index 9e005260..d7dde410 100644 --- a/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx @@ -5,8 +5,8 @@ import { useAuth } from './AuthContext'; import { useSignalR } from './SignalRContext'; import { canFetchTenantNotifications } from './tenantNotificationsGate'; import { api } from '@/lib/api'; -import { authenticatedFetch } from '@/lib/authenticatedFetch'; import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated'; +import { fetchJson, fetchOk } from "@/lib/apiClient"; // Both notification endpoints share one backend DTO; the alias keeps the established local name. export type TenantNotification = GlobalNotificationDto; @@ -44,14 +44,8 @@ export function TenantNotificationProvider({ children }: { children: React.React fetchingRef.current = true; try { - const response = await authenticatedFetch( - api.notifications.tenantList(), - getAccessToken, - ); - if (response.ok) { - const data = (await response.json()) as NotificationListResponse; - setTenantNotifications(data.notifications ?? []); - } + const data = await fetchJson(api.notifications.tenantList(), getAccessToken); + setTenantNotifications(data.notifications ?? []); } catch { // Silently ignore — notifications are best-effort } finally { @@ -132,7 +126,7 @@ export function TenantNotificationProvider({ children }: { children: React.React setTenantNotifications(prev => prev.filter(n => n.id !== id)); try { - await authenticatedFetch( + await fetchOk( api.notifications.tenantDismiss(id), getAccessToken, { method: 'POST' }, @@ -146,7 +140,7 @@ export function TenantNotificationProvider({ children }: { children: React.React setTenantNotifications([]); try { - await authenticatedFetch( + await fetchOk( api.notifications.tenantDismissAll(), getAccessToken, { method: 'POST' }, diff --git a/src/Web/autopilot-monitor-web/hooks/useDiagnosticsPathsCatalog.ts b/src/Web/autopilot-monitor-web/hooks/useDiagnosticsPathsCatalog.ts index 84f151c0..5c0879b3 100644 --- a/src/Web/autopilot-monitor-web/hooks/useDiagnosticsPathsCatalog.ts +++ b/src/Web/autopilot-monitor-web/hooks/useDiagnosticsPathsCatalog.ts @@ -3,8 +3,8 @@ import { useEffect, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import type { DiagnosticsBuiltInSection, DiagnosticsLogPath, DiagnosticsPathsCatalog } from "@/types/diagnostics"; +import { fetchJson } from "@/lib/apiClient"; export interface DiagnosticsPathsCatalogState { /** Sections compiled into the agent — what every diagnostics package collects first. */ @@ -34,12 +34,7 @@ export function useDiagnosticsPathsCatalog(): DiagnosticsPathsCatalogState { let cancelled = false; const run = async () => { try { - const res = await authenticatedFetch(api.diagnostics.paths(), getAccessToken); - if (!res.ok) { - if (!cancelled) setState((s) => ({ ...s, loading: false })); - return; - } - const data = (await res.json()) as Partial; + const data = await fetchJson>(api.diagnostics.paths(), getAccessToken); if (!cancelled) { setState({ builtIn: Array.isArray(data.builtIn) ? data.builtIn : [], diff --git a/src/Web/autopilot-monitor-web/hooks/useTenantList.ts b/src/Web/autopilot-monitor-web/hooks/useTenantList.ts index de4820fe..745c58ca 100644 --- a/src/Web/autopilot-monitor-web/hooks/useTenantList.ts +++ b/src/Web/autopilot-monitor-web/hooks/useTenantList.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import { fetchJson } from "@/lib/apiClient"; export interface TenantInfo { tenantId: string; @@ -33,10 +33,9 @@ export function useTenantList(enabled: boolean): TenantInfo[] { let cancelled = false; const loadTenants = async () => { try { - const response = await authenticatedFetch(api.config.all(), getAccessToken); - if (!response.ok) return; - const data = await response.json(); - const mapped: TenantInfo[] = (data as Array<{ tenantId: string; domainName?: string }>).map((t) => ({ + // config/all is a bare array of tenant configurations (deliberately untyped, D-043). + const data = await fetchJson>(api.config.all(), getAccessToken); + const mapped: TenantInfo[] = data.map((t) => ({ tenantId: t.tenantId, domainName: t.domainName || "", })); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts index 71942134..24a70477 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts @@ -5,94 +5,38 @@ /** Files that keep a raw site for a stated reason; they never leave the baseline. */ export const PERMANENT: Record = { - "contexts/AuthContext.tsx": "auth/me bootstrap runs before the auth context exists — raw fetch by design", + "contexts/AuthContext.tsx": "auth/me bootstrap runs before the auth context exists: raw fetch by design", "components/landing/StatsBand.tsx": "unauthenticated public blob manifest, not the API", "app/health-check/page.tsx": "/version.json of the SWA itself, not the API", - "app/settings/TenantConfigContext.tsx": "consent return path routes on TokenExpiredError (router.replace) — the one non-message handling", + "app/dashboard/hooks/deleteSessionResponse.ts": "safeJson on the 202 Response of the delete-cascade classifier (refusals arrive as ApiError)", + "app/settings/TenantConfigContext.tsx": "consent return path routes on TokenExpiredError (router.replace); the access check rethrows it as a non-outcome", + "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": "auxiliary banner: only a token expiry is surfaced, backend refusals stay silent", + "app/dashboard/hooks/useBlockDevice.ts": "bulk runner rethrows the expiry so it is toasted once, not per device", + "app/dashboard/hooks/useDeleteSession.ts": "bulk runner collects the expiry and toasts once after the batch", + "app/dashboard/hooks/useTenantSecurityConfig.ts": "fail-soft banner data; the expiry is the one failure the user must hear about", + "app/sessions/hooks/useSessionDetail.ts": "a network failure keeps the retry flag, an expiry must not", + "app/settings/components/OffboardingSection.tsx": "expiry message tells the user the offboarding continues in the background", }; export const AUTHENTICATEDFETCH_BASELINE: Record = { - "app/analyze-rules/page.tsx": 2, - "app/annotations/page.tsx": 1, - "app/apps/components/InventoryTab.tsx": 1, - "app/apps/components/VulnerabilitiesTab.tsx": 1, - "app/apps/detail/page.tsx": 2, - "app/audit/page.tsx": 1, - "app/delegations/accept/page.tsx": 2, - "app/diagnosis/page.tsx": 3, - "app/fleet-health/hooks/useFleetHealth.ts": 1, - "app/fleet-health/page.tsx": 1, - "app/fleet/hooks/useFleetSummaries.ts": 1, - "app/gather-rules/page.tsx": 1, - "app/geographic-performance/page.tsx": 1, - "app/geographic-performance/sessions/page.tsx": 1, - "app/health-check/page.tsx": 2, - "app/sla/page.tsx": 1, - "app/usage-metrics/page.tsx": 1, - "components/DelegatedSlotPrompt.tsx": 1, - "components/FeedbackBubble.tsx": 3, - "components/GlobalSearch.tsx": 1, - "contexts/GlobalNotificationContext.tsx": 3, - "contexts/SignalRContext.tsx": 3, - "contexts/TenantNotificationContext.tsx": 3, - "hooks/useDiagnosticsPathsCatalog.ts": 1, - "hooks/useTenantList.ts": 1, }; export const DEDUPEDAUTHFETCH_BASELINE: Record = { }; export const JSONPARSE_BASELINE: Record = { - "app/activation/page.tsx": 1, - "app/analyze-rules/page.tsx": 2, - "app/annotations/page.tsx": 1, - "app/apps/components/InventoryTab.tsx": 1, - "app/apps/components/VulnerabilitiesTab.tsx": 1, - "app/apps/detail/page.tsx": 2, - "app/audit/page.tsx": 1, "app/dashboard/hooks/deleteSessionResponse.ts": 1, - "app/delegations/accept/page.tsx": 3, - "app/diagnosis/page.tsx": 3, - "app/fleet-health/hooks/useFleetHealth.ts": 2, - "app/fleet-health/page.tsx": 1, - "app/fleet/hooks/useFleetSummaries.ts": 1, - "app/gather-rules/page.tsx": 1, - "app/geographic-performance/page.tsx": 1, - "app/geographic-performance/sessions/page.tsx": 1, - "app/health-check/page.tsx": 3, - "app/sla/page.tsx": 1, - "app/usage-metrics/page.tsx": 1, - "components/DelegatedSlotPrompt.tsx": 1, - "components/FeedbackBubble.tsx": 1, - "components/GlobalSearch.tsx": 1, + "app/health-check/page.tsx": 1, "components/landing/StatsBand.tsx": 2, "contexts/AuthContext.tsx": 3, - "contexts/GlobalNotificationContext.tsx": 1, - "contexts/TenantNotificationContext.tsx": 1, - "hooks/useDiagnosticsPathsCatalog.ts": 1, - "hooks/useTenantList.ts": 1, }; export const TOKENEXPIRED_BASELINE: Record = { "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 1, - "app/apps/components/InventoryTab.tsx": 1, - "app/apps/components/VulnerabilitiesTab.tsx": 1, - "app/apps/detail/page.tsx": 2, - "app/audit/page.tsx": 1, "app/dashboard/hooks/useBlockDevice.ts": 1, "app/dashboard/hooks/useDeleteSession.ts": 1, "app/dashboard/hooks/useTenantSecurityConfig.ts": 1, - "app/delegations/accept/page.tsx": 1, - "app/diagnosis/page.tsx": 3, - "app/fleet-health/hooks/useFleetHealth.ts": 1, - "app/fleet-health/page.tsx": 1, - "app/geographic-performance/page.tsx": 1, - "app/geographic-performance/sessions/page.tsx": 1, - "app/health-check/page.tsx": 1, - "app/sessions/components/SessionAnnotationsCard.tsx": 1, "app/sessions/hooks/useSessionDetail.ts": 1, "app/settings/TenantConfigContext.tsx": 2, "app/settings/components/OffboardingSection.tsx": 1, - "app/sla/page.tsx": 1, - "app/usage-metrics/page.tsx": 1, };