From 02187f0f3c6793d31c5bd67dfbfd8adee5f518d6 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 6 Sep 2026 02:21:25 +0200 Subject: [PATCH] web: the last raw API call sites join the call layer; ratchet baseline for authenticatedFetch reaches zero (audit package 5, stage 3d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules pages, apps tabs, audit, delegations, diagnosis, fleet health, geographic, health check, SLA, usage metrics, the notification and SignalR contexts, the feedback bubble, global search and the shared hooks now call fetchJson/fetchOk; the activation page's hand-rolled Bearer fetch (no 401 retry, no telemetry) joins the layer too. The SignalR join/leave paths keep their visible-refusal semantics through ApiError, the delegation acceptance explains refusals by code, the health check keeps its 403 wording, and the local mirrors of SlaMetricsResponse and PlatformUsageMetrics give way to the generated types. No page, component, hook or context calls authenticatedFetch directly any more. What stays is documented per file in the baseline's PERMANENT map: the auth/me bootstrap, the SWA's own version.json, the public stats manifest, the delete-cascade classifier's 202 body, and seven TokenExpiredError branches where the UI must act differently from showing the message (route, rethrow, batch, retry flag). Ratchet baseline: 39 → 0 raw fetches, 40 → 7 parses, 25 → 8 token checks (from 228 / 221 / 140). Co-Authored-By: Claude Fable 5.1 --- .../app/activation/page.tsx | 19 +--- .../app/analyze-rules/page.tsx | 18 ++-- .../app/annotations/page.tsx | 12 ++- .../app/apps/components/InventoryTab.tsx | 17 ++-- .../apps/components/VulnerabilitiesTab.tsx | 19 ++-- .../app/apps/detail/page.tsx | 47 +++------- .../autopilot-monitor-web/app/audit/page.tsx | 27 ++---- .../app/delegations/accept/page.tsx | 37 ++++---- .../app/diagnosis/page.tsx | 70 +++++---------- .../app/fleet-health/hooks/useFleetHealth.ts | 50 ++++------- .../app/fleet-health/page.tsx | 21 ++--- .../app/fleet/hooks/useFleetSummaries.ts | 7 +- .../app/gather-rules/page.tsx | 10 +-- .../app/geographic-performance/page.tsx | 18 +--- .../geographic-performance/sessions/page.tsx | 20 ++--- .../app/health-check/page.tsx | 39 ++++---- .../components/SessionAnnotationsCard.tsx | 2 - .../autopilot-monitor-web/app/sla/page.tsx | 71 ++------------- .../app/usage-metrics/page.tsx | 90 ++----------------- .../components/DelegatedSlotPrompt.tsx | 24 ++--- .../components/FeedbackBubble.tsx | 17 ++-- .../components/GlobalSearch.tsx | 19 ++-- .../contexts/GlobalNotificationContext.tsx | 16 ++-- .../contexts/SignalRContext.tsx | 84 ++++++----------- .../contexts/TenantNotificationContext.tsx | 16 ++-- .../hooks/useDiagnosticsPathsCatalog.ts | 9 +- .../hooks/useTenantList.ts | 9 +- .../lib/__tests__/apiClient.guard.baseline.ts | 76 +++------------- 28 files changed, 234 insertions(+), 630 deletions(-) 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, };