From c8b8a8dbb4287bb7f0fa875b4345e783d6ac54ef Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 6 Sep 2026 02:09:51 +0200 Subject: [PATCH] web(sessions/dashboard/progress): every API call through fetchJson/fetchOk/fetchBlob (audit package 5, stage 3c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 33 raw authenticatedFetch sites under app/sessions, app/dashboard and app/progress are gone. Hooks that receive addNotification as a parameter report failures through notifyApiError (same rendering as notifyError, injected notifier); fail-soft reads that used to check res.ok read through .catch(nullOnApiError). The delete-cascade classifier accepts the ApiError of a refused DELETE next to the 202 Response, so the 409/503/404 branches keep their wording; the polling probe reads the status from either. Session and event reads name their wire types (GetSessionResponse, GetSessionEventsResponse, SearchSessionsResponse, SessionStatsResponse, the progress and rule-stats responses); the legacy list-shaped fallbacks on the single-session read are gone. SessionTimeBreakdownDto is the generated type. Ratchet baseline: 72 → 39 raw fetches, 67 → 40 parses, 36 → 25 token checks. Co-Authored-By: Claude Fable 5.1 --- .../dashboard/hooks/deleteSessionResponse.ts | 9 +- .../app/dashboard/hooks/useBlockDevice.ts | 39 ++++---- .../dashboard/hooks/useDashboardSessions.ts | 88 ++++++++---------- .../app/dashboard/hooks/useDashboardStats.ts | 53 +++++------ .../app/dashboard/hooks/useDeleteSession.ts | 45 ++++++---- .../app/dashboard/hooks/useTenantList.ts | 7 +- .../app/dashboard/page.tsx | 13 +-- .../app/progress/hooks/useProgressEvents.ts | 89 +++++++------------ .../app/progress/hooks/useProgressSearch.ts | 61 +++++-------- .../app/progress/page.tsx | 2 +- .../components/AnalysisResultsSection.tsx | 28 +++--- .../sessions/components/CollectLogsButton.tsx | 35 +++----- .../components/DeviceHistoryBanner.tsx | 9 +- .../components/SessionAnnotationsCard.tsx | 17 ++-- .../components/TimeAttributionLane.tsx | 21 +---- .../app/sessions/hooks/useSessionAnalysis.ts | 27 +++--- .../app/sessions/hooks/useSessionDetail.ts | 36 ++++---- .../app/sessions/hooks/useSessionEvents.ts | 40 ++++----- .../app/sessions/network-timeline/page.tsx | 20 ++--- .../app/sessions/page.tsx | 76 ++++------------ .../contexts/NotificationContext.tsx | 14 ++- .../lib/__tests__/apiClient.guard.baseline.ts | 44 +-------- .../autopilot-monitor-web/lib/apiClient.ts | 6 ++ 23 files changed, 304 insertions(+), 475 deletions(-) diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts index 2217e180..27619362 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts @@ -7,6 +7,7 @@ * helpers describe *what* should happen for a given backend response. */ import type { ApiErrorCode } from "@/lib/apiErrorCodes"; +import { ApiError } from "@/lib/apiClient"; /** * Backend body shape for the V2 cascade 202 response. @@ -53,11 +54,11 @@ export type DeleteResponseAction = * but the network may corrupt it). */ export async function classifyDeleteResponse( - response: Response, + response: Response | ApiError, sessionId: string, tenantId: string, ): Promise { - if (response.status === 202) { + if (!(response instanceof ApiError) && response.status === 202) { const body = await safeJson(response); return { kind: "queued", @@ -67,7 +68,9 @@ export async function classifyDeleteResponse( }; } - const errorBody = await safeJson(response); + // A refused DELETE arrives as the ApiError fetchOk threw (status + parsed envelope); a raw + // Response is still accepted for the 202 path and the classifier's own tests. + const errorBody = response instanceof ApiError ? (response.body as DeleteErrorBody | null) : await safeJson(response); const message = errorBody?.error ?? errorBody?.message ?? `HTTP ${response.status}`; if (response.status === 409) { diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts index 11edf73d..b5ec9e1a 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts @@ -1,9 +1,10 @@ "use client"; import { useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { TokenExpiredError } from "@/lib/authenticatedFetch"; import { NotificationType } from "@/contexts/NotificationContext"; import { BULK_CONCURRENCY, runWithConcurrency, summarizeBlockOutcomes, type BlockOutcome } from "./bulkActions"; +import { ApiError, fetchOk } from "@/lib/apiClient"; export interface BlockTarget { serialNumber: string; @@ -46,19 +47,21 @@ export function useBlockDevice( }; const blockOne = async (target: BlockTarget): Promise => { - const response = await authenticatedFetch(api.devices.block(), getAccessToken, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tenantId: target.tenantId, - serialNumber: target.serialNumber, - durationHours: 24, - reason: `Blocked from dashboard by Global Admin` - }) - }); - if (response.ok) return { ok: true }; - const data = await response.json().catch(() => ({})); - return { ok: false, message: data.message || `HTTP ${response.status}` }; + try { + await fetchOk(api.devices.block(), getAccessToken, { + method: 'POST', + body: JSON.stringify({ + tenantId: target.tenantId, + serialNumber: target.serialNumber, + durationHours: 24, + reason: `Blocked from dashboard by Global Admin` + }) + }); + return { ok: true }; + } catch (err) { + if (err instanceof ApiError) return { ok: false, message: err.message }; + throw err; + } }; const confirmBlock = async () => { @@ -102,12 +105,8 @@ export function useBlockDevice( const summary = summarizeBlockOutcomes(outcomes); addNotification(summary.type, summary.title, summary.message, 'device-bulk-block-summary'); } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } else { - console.error('Failed to block device:', error); - addNotification('error', 'Block failed', 'Could not block the device. Please try again.', 'device-block-error'); - } + console.error('Failed to block device:', error); + addNotification('error', 'Block failed', 'Could not block the device. Please try again.', 'device-block-error'); } finally { setBlockingDevice(false); } diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts index 7c131948..f65b2872 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts @@ -3,16 +3,17 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { useLatest } from "@/hooks/useLatest"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { extractContinuation } from "@/lib/paginationLink"; import { asGuidOrUndefined } from "@/utils/inputValidation"; import { boundTenantToDelegatedScope } from "@/utils/delegatedScope"; import { mergeSessionsById } from "@/lib/sessionSearchMerge"; import { isHomeTenantTarget } from "@/utils/homeTenantScope"; import { hasTenantReadScope } from "@/lib/tenantScope"; -import type { NotificationType } from "@/contexts/NotificationContext"; +import { type NotificationType, notifyApiError } from "@/contexts/NotificationContext"; import type { Session } from "../types"; import type { SignalRMessageName } from "@/lib/signalrMessages"; +import type { BlockedDeviceListResponse, SearchSessionsResponse, SessionListResponse } from "@/utils/wire-types.generated"; +import { ApiError, fetchJson, nullOnApiError } from "@/lib/apiClient"; const DEFAULT_PAGE_SIZE = 10; const MAX_PAGE_SIZE = 1000; @@ -203,8 +204,7 @@ export function useDashboardSessions({ const results = await Promise.allSettled( tenantIds.map((tid) => - authenticatedFetch(api.devices.blocked(tid), getAccessToken) - .then((res) => (res.ok ? res.json() : { blocked: [] })), + fetchJson(api.devices.blocked(tid), getAccessToken).catch(nullOnApiError), ), ); @@ -219,13 +219,9 @@ export function useDashboardSessions({ setBlockedDevicesSet(newSet); } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification("error", "Session Expired", error.message, "session-expired-error"); - } else { - console.error("Failed to fetch blocked devices:", error); - } + console.error("Failed to fetch blocked devices:", error); } - }, [getAccessToken, addNotification, setBlockedDevicesSet, adminModeRef, globalAdminModeRef, tenantIdRef]); + }, [getAccessToken, setBlockedDevicesSet, adminModeRef, globalAdminModeRef, tenantIdRef]); const getInitialPageSize = (): number => { // Pattern B2 default first-paint pageSize is 10; localStorage may override @@ -268,40 +264,31 @@ export function useDashboardSessions({ ? api.globalSessions.list(effectiveTenantFilter, undefined, opts) : api.sessions.list(tenantIdRef.current ?? undefined, undefined, opts); - const response = await authenticatedFetch(endpoint, getAccessToken); - - if (response.ok) { - const data = await response.json(); - const nextContinuation = extractContinuation(data.nextLink); - return { - sessions: data.sessions || [], - hasMore: !!nextContinuation, - nextContinuation, - }; - } else { + let data: SessionListResponse; + try { + data = await fetchJson(endpoint, getAccessToken); + } catch (err) { + if (!(err instanceof ApiError)) throw err; // Surface the backend's error reason (e.g. "Invalid continuation token (filter_mismatch).") - // instead of the generic statusText so a stale-token race is diagnosable from the UI. - let detail = response.statusText; - try { - const body = await response.json(); - if (body?.message) detail = body.message; - } catch { /* response body was not JSON — fall back to statusText */ } - console.error(`Failed to fetch sessions (${response.status}): ${detail}`); - addNotification("error", "Backend Error", `Failed to fetch sessions: ${detail}`, "backend-error"); + // so a stale-token race is diagnosable from the UI. + console.error(`Failed to fetch sessions (${err.status}): ${err.message}`); + notifyApiError(addNotification, "Backend Error", err, "backend-error", "Failed to fetch sessions."); return null; } + const nextContinuation = extractContinuation(data.nextLink); + return { + sessions: data.sessions || [], + hasMore: !!nextContinuation, + nextContinuation, + }; } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification("error", "Session Expired", error.message, "session-expired-error"); - } else { - console.error("Failed to fetch sessions:", error); - addNotification( - "error", - "Backend Not Reachable", - "Unable to connect to the backend API. Please ensure the backend server is running.", - "backend-unreachable", - ); - } + console.error("Failed to fetch sessions:", error); + addNotification( + "error", + "Backend Not Reachable", + "Unable to connect to the backend API. Please ensure the backend server is running.", + "backend-unreachable", + ); return null; } }, [getAccessToken, addNotification, globalAdminModeRef, tenantIdRef, isDelegatedScopeRef, delegatedTenantIdsRef]); @@ -437,15 +424,18 @@ export function useDashboardSessions({ const endpoint = useGlobal ? api.globalSessions.search(q, effectiveTenantFilter, { pageSize: SEARCH_PAGE_SIZE, continuation }) : api.sessions.search(q, { pageSize: SEARCH_PAGE_SIZE, continuation }); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (!response.ok) { - console.error(`Server-side session search failed (${response.status})`); + let data: SearchSessionsResponse; + try { + data = await fetchJson(endpoint, getAccessToken); + } catch (err) { + if (!(err instanceof ApiError)) throw err; + console.error(`Server-side session search failed (${err.status})`); break; } - const data = await response.json(); if (loadAllTokenRef.current !== myToken) break; - const found: Session[] = data.sessions || []; + // No `fields` projection on the search, so every hit is a complete session. + const found = (data.sessions || []) as Session[]; if (found.length > 0) { matches += found.length; setSessions((prev) => mergeSessionsById(prev, found)); @@ -456,17 +446,13 @@ export function useDashboardSessions({ continuation = next; } } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification("error", "Session Expired", error.message, "session-expired-error"); - } else { - console.error("Server-side session search failed:", error); - } + console.error("Server-side session search failed:", error); } finally { fetchLockRef.current = false; setLoadingMore(false); setLoadingAll(false); } - }, [getAccessToken, addNotification, loadAll, globalAdminModeRef, tenantIdRef, isDelegatedScopeRef, delegatedTenantIdsRef]); + }, [getAccessToken, loadAll, globalAdminModeRef, tenantIdRef, isDelegatedScopeRef, delegatedTenantIdsRef]); const removeSession = useCallback((sessionId: string) => { setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId)); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts index 35669f4b..9fbaf1a7 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts @@ -3,12 +3,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { useLatest } from "@/hooks/useLatest"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { asGuidOrUndefined } from "@/utils/inputValidation"; import { boundTenantToDelegatedScope } from "@/utils/delegatedScope"; import { isHomeTenantTarget } from "@/utils/homeTenantScope"; -import type { NotificationType } from "@/contexts/NotificationContext"; import type { SignalRMessageName } from "@/lib/signalrMessages"; +import type { SessionStatsResponse } from "@/utils/wire-types.generated"; +import { ApiError, fetchJson } from "@/lib/apiClient"; export interface DashboardStats { days: number; @@ -27,13 +27,6 @@ export interface DashboardStats { 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 @@ -56,7 +49,6 @@ interface UseDashboardStatsParams { delegatedTenantIds?: string[]; days?: number; getAccessToken: (forceRefresh?: boolean) => Promise; - addNotification: AddNotification; signalR: SignalRApi; /** * When true (e.g. user role not yet known or regular non-admin user), @@ -100,7 +92,6 @@ export function useDashboardStats({ delegatedTenantIds, days = DEFAULT_DAYS, getAccessToken, - addNotification, signalR, disabled = false, }: UseDashboardStatsParams): UseDashboardStatsReturn { @@ -156,39 +147,35 @@ export function useDashboardStats({ }) : api.sessions.stats({ days: 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 */ } - setError(detail); - return; + let body: SessionStatsResponse; + 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; + } + throw err; } - const body = await response.json(); - if (body?.success && body?.stats) { + // Stale fetch: a newer scope change won the race. Drop silently. + if (myGen !== fetchGenRef.current) return; + + if (body.success && body.stats) { setStats(body.stats as DashboardStats); } else { setError("Malformed stats response"); } } 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 dashboard stats:", e); - setError("Unable to load stats"); - } + console.error("Failed to fetch dashboard stats:", e); + setError("Unable to load stats"); } finally { if (myGen === fetchGenRef.current) setLoading(false); } - }, [getAccessToken, addNotification, disabledRef, globalAdminModeRef, tenantIdRef, + }, [getAccessToken, disabledRef, globalAdminModeRef, tenantIdRef, isDelegatedScopeRef, submittedFilterRef, delegatedTenantIdsRef, daysRef]); const refresh = useCallback(() => { diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDeleteSession.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDeleteSession.ts index 090a6bc3..225c9146 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDeleteSession.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDeleteSession.ts @@ -1,7 +1,8 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { TokenExpiredError } from "@/lib/authenticatedFetch"; +import { ApiError, apiErrorText, fetchOk } from "@/lib/apiClient"; import { trackEvent } from "@/lib/appInsights"; import { NotificationType } from "@/contexts/NotificationContext"; import { useSignalR } from "@/contexts/SignalRContext"; @@ -73,6 +74,16 @@ export function useDeleteSession( // groups but does not back-fill events. Every 60s we re-fetch each pending session and // treat a 404 as "cascade completed". The interval is conservative (≤ 5 reqs/min/user with // a busy admin), well under the rate limit. + /** The DELETE's outcome for the classifier: the 202 Response, or the ApiError of a refusal (409/503/404). */ + const deleteOutcome = useCallback( + (sessionId: string, tenantId: string): Promise => + fetchOk(api.sessions.delete(sessionId, tenantId), getAccessToken, { method: 'DELETE' }).catch((err: unknown) => { + if (err instanceof ApiError) return err; + throw err; + }), + [getAccessToken], + ); + useEffect(() => { if (pendingDeletions.size === 0) return; const intervalId = setInterval(async () => { @@ -80,8 +91,14 @@ export function useDeleteSession( const tenantId = pendingTenantsRef.current.get(sessionId); if (!tenantId) continue; try { - const r = await authenticatedFetch(api.sessions.get(sessionId, tenantId), getAccessToken, { method: 'GET' }); - if (classifyPollingResponse(r.status) === 'deleted') { + const status = await fetchOk(api.sessions.get(sessionId, tenantId), getAccessToken).then( + (r) => r.status, + (err: unknown) => { + if (err instanceof ApiError) return err.status; + throw err; + }, + ); + if (classifyPollingResponse(status) === 'deleted') { onSessionDeleted(sessionId); removePending(sessionId); } @@ -165,18 +182,12 @@ export function useDeleteSession( if (targets.length === 1) { const { sessionId, tenantId } = targets[0]; try { - const response = await authenticatedFetch(api.sessions.delete(sessionId, tenantId), getAccessToken, { - method: 'DELETE', - }); - applyDeleteAction(await classifyDeleteResponse(response, sessionId, tenantId), true); + const outcome = await deleteOutcome(sessionId, tenantId); + applyDeleteAction(await classifyDeleteResponse(outcome, sessionId, tenantId), true); } catch (error) { // Errors in the catch are network / auth failures, not HTTP-status branches. - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } else { - console.error('Failed to delete session:', error); - addNotification('error', 'Delete failed', 'Unable to reach the backend.', 'session-delete-network-error'); - } + console.error('Failed to delete session:', error); + addNotification('error', 'Delete failed', 'Unable to reach the backend.', 'session-delete-network-error'); } return; } @@ -190,14 +201,12 @@ export function useDeleteSession( const actions = await runWithConcurrency(targets, BULK_CONCURRENCY, async ({ sessionId, tenantId }): Promise => { if (abort.tokenExpired) return { kind: 'error', sessionId, message: abort.tokenExpired.message }; try { - const response = await authenticatedFetch(api.sessions.delete(sessionId, tenantId), getAccessToken, { - method: 'DELETE', - }); - return await classifyDeleteResponse(response, sessionId, tenantId); + const outcome = await deleteOutcome(sessionId, tenantId); + return await classifyDeleteResponse(outcome, sessionId, tenantId); } catch (error) { if (error instanceof TokenExpiredError) abort.tokenExpired = error; else console.error('Failed to delete session:', error); - return { kind: 'error', sessionId, message: error instanceof Error ? error.message : 'Unable to reach the backend.' }; + return { kind: 'error', sessionId, message: apiErrorText(error, 'Unable to reach the backend.') }; } }); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantList.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantList.ts index a84fdfa0..1f969dcd 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantList.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantList.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import { fetchJson } from "@/lib/apiClient"; export interface TenantListItem { tenantId: string; @@ -28,9 +28,8 @@ export function useTenantList( return; } try { - const res = await authenticatedFetch(api.config.all(), getAccessToken); - if (!res.ok || cancelled) return; - const configs: { tenantId?: string; domainName?: string }[] = await res.json(); + // config/all is a bare array of tenant configurations (deliberately untyped, D-043). + const configs = await fetchJson<{ tenantId?: string; domainName?: string }[]>(api.config.all(), getAccessToken); if (cancelled) return; setTenantList( configs diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index 5829daf6..8e3435d5 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -30,10 +30,11 @@ import { useDashboardFilters } from "./hooks/useDashboardFilters"; import { useDashboardSessions } from "./hooks/useDashboardSessions"; import { useDashboardStats } from "./hooks/useDashboardStats"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { formatDuration } from "@/lib/formatting"; import { hasTenantReadScope } from "@/lib/tenantScope"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; +import type { GetRuleHitSessionsResponse } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; export default function Home() { // useSearchParams() in HomeContent requires a Suspense boundary for static prerender. @@ -203,16 +204,11 @@ function HomeContent() { const fetchHits = async () => { const ids = new Set(); try { - const response = await authenticatedFetch( + const data = await fetchJson( api.metrics.ruleHitSessions(ruleFilter.ruleId, 14, initialTenantFilter || undefined), getAccessToken ); - if (response.ok) { - const data = await response.json(); - if (Array.isArray(data.sessionIds)) { - for (const id of data.sessionIds) ids.add(String(id)); - } - } + for (const id of data.sessionIds ?? []) ids.add(String(id)); } catch { // Fail-soft: empty set below. } @@ -248,7 +244,6 @@ function HomeContent() { isDelegatedScope: isDelegated && !hasGlobalScope, delegatedTenantIds: user?.delegatedTenantIds, getAccessToken, - addNotification, signalR, disabled: isRegularUser, }); diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressEvents.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressEvents.ts index 841ec756..a74aa445 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressEvents.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressEvents.ts @@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { createBurstScheduler, type BurstScheduler } from "@/lib/burstScheduler"; import { EnrollmentEvent, Session } from "@/types"; -import type { NotificationType } from "@/contexts/NotificationContext"; +import { type NotificationType, notifyApiError } from "@/contexts/NotificationContext"; +import type { ProgressGetSessionEventsResponse, ProgressLookupSessionResponse } from "@/utils/wire-types.generated"; +import { fetchJson, nullOnApiError } from "@/lib/apiClient"; // Live refetch coalescing on SignalR signals (see lib/burstScheduler). Each refresh is two // requests (summary lookup + events), hence a slightly wider window than the session page. @@ -76,47 +77,23 @@ export function useProgressEvents({ const fetchEvents = async () => { try { - const response = await authenticatedFetch( + const data = await fetchJson( api.progress.sessionEvents(session.sessionId, tenantId, session.serialNumber), getAccessToken, ); - if (response.ok) { - const data = await response.json(); - const fetched: EnrollmentEvent[] = data.events || []; - setEvents((prev) => { - if (prev.length === 0) return fetched; - const existingIds = new Set(prev.map((e) => e.eventId)); - const newEvents = fetched.filter((e) => !existingIds.has(e.eventId)); - if (newEvents.length === 0) return prev; - return [...prev, ...newEvents].sort( - (a, b) => a.sequence - b.sequence, - ); - }); - } else { - addNotification( - "error", - "Backend Error", - `Failed to load enrollment events: ${response.statusText}`, - "progress-events-error", + const fetched: EnrollmentEvent[] = data.events || []; + setEvents((prev) => { + if (prev.length === 0) return fetched; + const existingIds = new Set(prev.map((e) => e.eventId)); + const newEvents = fetched.filter((e) => !existingIds.has(e.eventId)); + if (newEvents.length === 0) return prev; + return [...prev, ...newEvents].sort( + (a, b) => a.sequence - b.sequence, ); - } + }); } 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 enrollment events. Please check your connection.", - "progress-events-error", - ); - } + console.error("Failed to fetch events:", error); + notifyApiError(addNotification, "Backend Error", error, "progress-events-error", "Unable to load enrollment events."); } }; await fetchEvents(); @@ -133,34 +110,28 @@ export function useProgressEvents({ // session row always matches exactly). Guard on sessionId: the lookup returns the // NEWEST session for the serial, which after a re-enrollment is a different session — // the page must keep showing the one the user selected. - const lookupResponse = await authenticatedFetch( + const lookupData = await fetchJson( api.progress.lookup(tenantId, currentSession.serialNumber), getAccessToken, - ); - if (lookupResponse.ok) { - const lookupData = await lookupResponse.json(); - const updated: Session | null = lookupData.found ? lookupData.session : null; - if (updated && updated.sessionId === currentSession.sessionId) { - setSession(updated); - } + ).catch(nullOnApiError); + const updated = lookupData?.found ? lookupData.session ?? null : null; + if (updated && updated.sessionId === currentSession.sessionId) { + setSession(updated); } - const eventsResponse = await authenticatedFetch( + const eventsData = await fetchJson( api.progress.sessionEvents(currentSession.sessionId, tenantId, currentSession.serialNumber), getAccessToken, ); - if (eventsResponse.ok) { - const eventsData = await eventsResponse.json(); - const fetched: EnrollmentEvent[] = eventsData.events || []; - setEvents((prev) => { - const existingIds = new Set(prev.map((e) => e.eventId)); - const newEvents = fetched.filter((e) => !existingIds.has(e.eventId)); - if (newEvents.length === 0) return prev; - return [...prev, ...newEvents].sort( - (a, b) => a.sequence - b.sequence, - ); - }); - } + const fetched: EnrollmentEvent[] = eventsData.events || []; + setEvents((prev) => { + const existingIds = new Set(prev.map((e) => e.eventId)); + const newEvents = fetched.filter((e) => !existingIds.has(e.eventId)); + if (newEvents.length === 0) return prev; + return [...prev, ...newEvents].sort( + (a, b) => a.sequence - b.sequence, + ); + }); } catch (error) { console.error("[Progress] Refetch failed:", error); } diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts index 6ffd73d5..7493e3b1 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts @@ -2,10 +2,11 @@ import { useCallback, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { trackEvent } from "@/lib/appInsights"; import { Session } from "@/types"; -import type { NotificationType } from "@/contexts/NotificationContext"; +import { type NotificationType, notifyApiError } from "@/contexts/NotificationContext"; +import type { ProgressLookupSessionResponse } from "@/utils/wire-types.generated"; +import { ApiError, fetchJson } from "@/lib/apiClient"; type AddNotification = ( type: NotificationType, @@ -67,47 +68,31 @@ export function useProgressSearch({ onBeforeSearch?.(); try { - const response = await authenticatedFetch( - api.progress.lookup(tenantId, serialInput.trim()), - getAccessToken, - ); + let data: ProgressLookupSessionResponse; + try { + data = await fetchJson(api.progress.lookup(tenantId, serialInput.trim()), getAccessToken); + } catch (err) { + if (!(err instanceof ApiError)) throw err; + notifyApiError(addNotification, "Backend Error", err, "progress-search-error", "Search failed."); + setNotFound(true); + return; + } + const found: Session | null = data.found ? data.session ?? null : null; - if (response.ok) { - const data = await response.json(); - const found: Session | null = data.found ? data.session : null; - - if (found) { - setSession(found); - setHeaderCollapsed(true); - } else { - setNotFound(true); - } + if (found) { + setSession(found); + setHeaderCollapsed(true); } else { - addNotification( - "error", - "Backend Error", - `Search failed: ${response.statusText}`, - "progress-search-error", - ); setNotFound(true); } } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification( - "error", - "Session Expired", - error.message, - "session-expired-error", - ); - } else { - console.error("Search failed:", error); - addNotification( - "error", - "Backend Not Reachable", - "Unable to search for device. Please check your connection.", - "progress-search-error", - ); - } + console.error("Search failed:", error); + addNotification( + "error", + "Backend Not Reachable", + "Unable to search for device. Please check your connection.", + "progress-search-error", + ); setNotFound(true); } finally { setSearching(false); diff --git a/src/Web/autopilot-monitor-web/app/progress/page.tsx b/src/Web/autopilot-monitor-web/app/progress/page.tsx index 122f6fec..f1b885df 100644 --- a/src/Web/autopilot-monitor-web/app/progress/page.tsx +++ b/src/Web/autopilot-monitor-web/app/progress/page.tsx @@ -2,7 +2,6 @@ import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; -import { useNotifications } from "../../contexts/NotificationContext"; import TruncatedLabel from "@/components/TruncatedLabel"; import { useSignalR } from "../../contexts/SignalRContext"; import { ProtectedRoute } from "../../components/ProtectedRoute"; @@ -12,6 +11,7 @@ import { useProgressSignalR } from "./hooks/useProgressSignalR"; import { useProgressDerivedData } from "./hooks/useProgressDerivedData"; import { DeviceStatusChips } from "./components/DeviceStatusChips"; import type { PresentationKind } from "./hooks/progressLayout"; +import { useNotifications } from "../../contexts/NotificationContext"; function formatDuration(ms: number): string { const seconds = Math.floor(ms / 1000); diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx index 0c04a7cb..d61b9c11 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx @@ -7,12 +7,13 @@ import { RuleResult } from "@/types"; import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { dashboardUrl } from "@/lib/routes"; import { recentWindowStartIso, sumRecentFires } from "@/lib/ruleRecentFires"; import { useAuth } from "@/contexts/AuthContext"; import { useTenant } from "@/contexts/TenantContext"; import { safeHttpUrl } from "@/lib/safeDocUrl"; +import type { RuleStatsResponse } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; interface AnalysisResultsSectionProps { analysisResults: RuleResult[]; @@ -70,22 +71,17 @@ export default function AnalysisResultsSection({ const url = crossTenant ? api.metrics.globalRuleStats(undefined, undefined, "analyze", sessionTenantId) : api.metrics.ruleStats(undefined, undefined, "analyze"); - const response = await authenticatedFetch(url, getAccessToken); - if (response.ok) { - const data = await response.json(); - const map: Record = {}; - const fires: Record = {}; - const windowStart = recentWindowStartIso(new Date(), 14); - if (data.rules && Array.isArray(data.rules)) { - for (const r of data.rules) { - if (r.hitRate > 0) map[r.ruleId] = r.hitRate; - const recent = sumRecentFires(r.trend, windowStart); - if (recent > 0) fires[r.ruleId] = recent; - } - } - setRuleHitRates(map); - setRuleRecentFires(fires); + const data = await fetchJson(url, getAccessToken); + const map: Record = {}; + const fires: Record = {}; + const windowStart = recentWindowStartIso(new Date(), 14); + for (const r of data.rules ?? []) { + if (r.hitRate > 0) map[r.ruleId] = r.hitRate; + const recent = sumRecentFires(r.trend, windowStart); + if (recent > 0) fires[r.ruleId] = recent; } + setRuleHitRates(map); + setRuleRecentFires(fires); } catch { // Non-critical } diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx index 178995e1..33da5f43 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { EnrollmentEvent } from "@/types"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { NotificationType } from "@/contexts/NotificationContext"; +import type { TenantConfiguration } from "@/utils/wire-types.generated"; import { CollectPhase, COLLECT_TIMEOUT_MS, @@ -71,24 +72,20 @@ export default function CollectLogsButton({ }); const notifyError = useCallback((err: unknown, fallback: string) => { - if (err instanceof TokenExpiredError) { - addNotification("error", "Session Expired", err.message, "session-expired-error"); - } else { - addNotification("error", "Collect Logs", fallback, "collect-logs"); - } + addNotification("error", "Collect Logs", fallback, "collect-logs"); }, [addNotification]); const queueAction = useCallback(async (type: string, reason: string): Promise => { - const res = await authenticatedFetch( - api.sessions.queueAction(sessionId, effectiveTenantId), - getAccessToken, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type, reason }), + return fetchOk(api.sessions.queueAction(sessionId, effectiveTenantId), getAccessToken, { + method: "POST", + body: JSON.stringify({ type, reason }), + }).then( + () => true, + (err: unknown) => { + if (err instanceof ApiError) return false; + throw err; }, ); - return res.ok; }, [sessionId, effectiveTenantId, getAccessToken]); const startCollect = useCallback(async () => { @@ -130,9 +127,7 @@ export default function CollectLogsButton({ if (!effectiveTenantId) return; setQuickConfigBusy(true); try { - const getRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken); - if (!getRes.ok) throw new Error(`Failed to load tenant configuration (${getRes.status})`); - const config = await getRes.json(); + const config = await fetchJson(api.config.tenant(effectiveTenantId), getAccessToken); const updated = { ...config, @@ -145,12 +140,10 @@ export default function CollectLogsButton({ : config.diagnosticsUploadMode, }; - const putRes = await authenticatedFetch(api.config.tenantCollectLogsQuickConfig(effectiveTenantId), getAccessToken, { + await fetchOk(api.config.tenantCollectLogsQuickConfig(effectiveTenantId), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(updated), }); - if (!putRes.ok) throw new Error(`Failed to save tenant configuration (${putRes.status})`); onDiagnosticsConfigured(); setShowQuickConfig(false); @@ -166,7 +159,7 @@ export default function CollectLogsButton({ } await startCollect(); } catch (err) { - notifyError(err, err instanceof Error ? err.message : "Quick configuration failed."); + notifyError(err, apiErrorText(err, "Quick configuration failed.")); } finally { setQuickConfigBusy(false); } diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceHistoryBanner.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceHistoryBanner.tsx index 484397c5..3fbbf2cc 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceHistoryBanner.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceHistoryBanner.tsx @@ -4,8 +4,8 @@ import { sessionUrl } from "@/lib/routes"; import { useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { selectPriorSessions, type DeviceSessionRefDto } from "./deviceHistoryPrior"; +import { fetchJson } from "@/lib/apiClient"; export type { DeviceSessionRefDto } from "./deviceHistoryPrior"; @@ -79,12 +79,7 @@ export default function DeviceHistoryBanner({ return; } try { - const response = await authenticatedFetch( - api.metrics.deviceHistory(serialNumber, sessionId, effectiveTenantId), - getAccessToken - ); - if (!response.ok) return; - const json = (await response.json()) as DeviceHistoryResponse; + const json = await fetchJson(api.metrics.deviceHistory(serialNumber, sessionId, effectiveTenantId), getAccessToken); if (!cancelled) setData(json); } catch { // fail-soft: no banner 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 361268ff..eec3e9e9 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,8 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminMode } from "@/hooks/useAdminMode"; import { ANNOTATION_MAX_NOTE_LENGTH, @@ -78,12 +79,7 @@ export default function SessionAnnotationsCard({ let cancelled = false; (async () => { try { - const response = await authenticatedFetch( - api.sessions.annotations(sessionId, effectiveTenantId), - getAccessToken - ); - if (!response.ok) return; - const json = (await response.json()) as AnnotationsResponse; + const json = await fetchJson(api.sessions.annotations(sessionId, effectiveTenantId), getAccessToken); if (cancelled) return; const byLane: Partial> = {}; for (const a of json.annotations ?? []) byLane[a.lane] = a; @@ -131,17 +127,14 @@ export default function SessionAnnotationsCard({ const { body, isClear } = buildPutBody(state.verdict, state.note); setLaneEdit(lane, { saving: true, saveResult: null }); try { - const res = await authenticatedFetch( + const json = await fetchJson<{ success: boolean; annotation?: SessionAnnotationDto }>( api.sessions.annotation(sessionId, lane, effectiveTenantId), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), } ); - if (!res.ok) throw new Error(`Failed to save annotation: ${res.statusText}`); - const json = (await res.json()) as { success: boolean; annotation?: SessionAnnotationDto }; setAnnotations((prev) => { const next = { ...prev }; if (isClear) delete next[lane]; @@ -153,7 +146,7 @@ export default function SessionAnnotationsCard({ if (err instanceof TokenExpiredError) console.error("Session expired while saving annotation"); setLaneEdit(lane, { saving: false, - saveResult: err instanceof Error ? err.message : "Failed to save annotation", + saveResult: apiErrorText(err, "Failed to save annotation"), }); } }; diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx index 8dab7a05..5e3454fa 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import type { SessionTimeBreakdown } from "@/utils/wire-types.generated"; /** * F1 time attribution (insights spec §F1): renders the PRE-COMPUTED breakdown row of a @@ -42,24 +43,8 @@ export interface SleepSpanDto { kind: string; } -export interface SessionTimeBreakdownDto { - tenantId: string; - sessionId: string; - attributionVersion: number; - wallClockSeconds: number; - segments: TimeAttributionSpanDto[]; - unattributedSeconds: number; - rebootSeconds: number; - rebootSpans: RebootSpanDto[]; - /** Optional: rows computed before AttributionVersion 3 lack these. */ - sleepSeconds?: number; - sleepSpans?: SleepSpanDto[]; - blockingApps: BlockingAppIntervalDto[]; - blockingAppCount: number; - espAppsOccupancySeconds: number | null; - /** Flags enum serialized as string, e.g. "None" or "PartialObservation, BlockingSetTruncated". */ - qualityFlags: string; -} +/** The session time breakdown (wire shape). */ +export type SessionTimeBreakdownDto = SessionTimeBreakdown; const SEGMENT_META: { key: string; label: string; color: string }[] = [ { key: "device_prep", label: "Device preparation", color: "bg-slate-400" }, diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionAnalysis.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionAnalysis.ts index 27e6f067..aa2e9607 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionAnalysis.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionAnalysis.ts @@ -1,9 +1,10 @@ "use client"; import { useState, useCallback, useEffect } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { RuleResult } from "@/types"; import { isGuid } from "@/utils/inputValidation"; +import type { GetRuleResultsResponse, GetVulnerabilityReportResponse } from "@/utils/wire-types.generated"; +import { fetchJson } from "@/lib/apiClient"; export function useSessionAnalysis( sessionId: string, @@ -23,20 +24,17 @@ export function useSessionAnalysis( try { setLoadingAnalysis(true); - const response = await authenticatedFetch( + const data = await fetchJson( api.sessions.analysis(sessionId, sessionTenantId, reanalyze || undefined), getAccessToken ); - if (response.ok) { - const data = await response.json(); - if (data.results) { - setAnalysisResults(data.results.sort((a: RuleResult, b: RuleResult) => b.confidenceScore - a.confidenceScore)); - } - // persistFailureRuleIds is null/undefined for the happy path and a string[] when one or - // more StoreRuleResultAsync calls returned false during the reanalyze loop. We always - // set it so a successful retry clears a previous warning. - setPersistFailureRuleIds(Array.isArray(data.persistFailureRuleIds) ? data.persistFailureRuleIds : []); + if (data.results) { + setAnalysisResults([...data.results].sort((a: RuleResult, b: RuleResult) => b.confidenceScore - a.confidenceScore)); } + // persistFailureRuleIds is null/undefined for the happy path and a string[] when one or + // more StoreRuleResultAsync calls returned false during the reanalyze loop. We always + // set it so a successful retry clears a previous warning. + setPersistFailureRuleIds(Array.isArray(data.persistFailureRuleIds) ? data.persistFailureRuleIds : []); } catch (error) { console.error("Failed to fetch analysis results:", error); } finally { @@ -47,14 +45,11 @@ export function useSessionAnalysis( const fetchVulnerabilityReport = useCallback(async (rescan = false) => { if (!sessionTenantId || !isGuid(sessionTenantId)) return; try { - const response = await authenticatedFetch( + const data = await fetchJson( api.sessions.vulnerabilityReport(sessionId, sessionTenantId, rescan || undefined), getAccessToken ); - if (response.ok) { - const data = await response.json(); - setVulnerabilityReport(data.report ?? null); - } + setVulnerabilityReport(data.report ?? null); } catch (error) { console.error("Failed to fetch vulnerability report:", error); } diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDetail.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDetail.ts index 8663281b..817dbc3b 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDetail.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDetail.ts @@ -2,10 +2,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { TokenExpiredError } from "@/lib/authenticatedFetch"; import { isGuid } from "@/utils/inputValidation"; import { Session } from "@/types"; -import type { NotificationType } from "@/contexts/NotificationContext"; +import { type NotificationType, notifyApiError } from "@/contexts/NotificationContext"; +import type { GetSessionResponse } from "@/utils/wire-types.generated"; +import { ApiError, fetchJson } from "@/lib/apiClient"; type AddNotification = ( type: NotificationType, @@ -105,23 +107,27 @@ export function useSessionDetail({ ? api.sessions.get(sessionId, knownTenantId) : api.sessions.get(sessionId); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (response.ok) { - const data = await response.json(); - // Accept only a session whose id matches the one we asked for — a misrouted or - // unexpected 200 must never hydrate the page (defense in depth behind the GUID gate). - const candidate: Session | undefined = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId); - const foundSession = candidate?.sessionId?.toLowerCase() === sessionId.toLowerCase() ? candidate : undefined; - if (foundSession) { - setSession(foundSession); - setSessionTenantId(foundSession.tenantId); + let data: GetSessionResponse; + try { + data = await fetchJson(endpoint, getAccessToken); + } catch (err) { + if (err instanceof ApiError) { + notifyApiError(addNotification, 'Backend Error', err, 'session-detail-fetch-error', 'Failed to load session details.'); + return; } - } else { - addNotification('error', 'Backend Error', `Failed to load session details: ${response.statusText}`, 'session-detail-fetch-error'); + throw err; + } + // Accept only a session whose id matches the one we asked for: a misrouted or + // unexpected 200 must never hydrate the page (defense in depth behind the GUID gate). + const candidate: Session | undefined = data.session; + const foundSession = candidate?.sessionId?.toLowerCase() === sessionId.toLowerCase() ? candidate : undefined; + if (foundSession) { + setSession(foundSession); + setSessionTenantId(foundSession.tenantId); } } catch (error) { if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); + notifyApiError(addNotification, 'Session Expired', error); return; } console.error("Failed to fetch session details:", error); diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionEvents.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionEvents.ts index 363b8733..971b0eea 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionEvents.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionEvents.ts @@ -2,13 +2,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { createBurstScheduler, type BurstScheduler } from "@/lib/burstScheduler"; import { extractContinuation, MAX_EAGER_PAGES } from "@/lib/paginationLink"; import { isGuid } from "@/utils/inputValidation"; import { isTerminalStatus } from "@/utils/sessionStatus"; import { EnrollmentEvent, Session } from "@/types"; -import type { NotificationType } from "@/contexts/NotificationContext"; +import { type NotificationType, notifyApiError } from "@/contexts/NotificationContext"; +import type { GetSessionEventsResponse } from "@/utils/wire-types.generated"; +import { ApiError, fetchJson } from "@/lib/apiClient"; const TIMELINE_PAGE_SIZE = 200; @@ -156,13 +157,16 @@ export function useSessionEvents({ const firstPageUrl = api.sessions.events(sessionId, effectiveTenantId, { pageSize: TIMELINE_PAGE_SIZE, }); - const firstResponse = await authenticatedFetch(firstPageUrl, getAccessToken); - if (!firstResponse.ok) { - addNotification('error', 'Backend Error', `Failed to load session events: ${firstResponse.statusText}`, 'session-events-fetch-error'); + let firstData: GetSessionEventsResponse; + try { + firstData = await fetchJson(firstPageUrl, getAccessToken); + } catch (err) { + if (!(err instanceof ApiError)) throw err; + notifyApiError(addNotification, 'Backend Error', err, 'session-events-fetch-error', 'Failed to load session events.'); return; } - const firstData = await firstResponse.json(); - const firstBatch: EnrollmentEvent[] = Array.isArray(firstData.events) ? firstData.events : []; + // No `fields` projection on the timeline read, so every event is complete. + const firstBatch = (firstData.events ?? []) as EnrollmentEvent[]; // Append-only merge instead of replace: the list stays monotonic so it // never shrinks-then-grows across the paged refresh (the old wipe-to-200 @@ -197,15 +201,15 @@ export function useSessionEvents({ pageSize: TIMELINE_PAGE_SIZE, continuation, }); - const resp = await authenticatedFetch(url, getAccessToken); - if (!resp.ok) { - console.warn( - `[SessionDetail] eager-fetch stopped at page ${pagesFetched + 1} (status=${resp.status})`, - ); + let pageData: GetSessionEventsResponse; + try { + pageData = await fetchJson(url, getAccessToken); + } catch (err) { + if (!(err instanceof ApiError)) throw err; + console.warn(`[SessionDetail] eager-fetch stopped at page ${pagesFetched + 1} (status=${err.status})`); break; } - const pageData = await resp.json(); - const batch: EnrollmentEvent[] = Array.isArray(pageData.events) ? pageData.events : []; + const batch = (pageData.events ?? []) as EnrollmentEvent[]; if (batch.length > 0) { setEvents(prev => mergeNewEvents(prev, batch)); if (!foundTerminalEvent && batch.some(isTerminalEvent)) { @@ -232,12 +236,8 @@ export function useSessionEvents({ } } catch (error) { aborted = true; - 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.', 'session-events-fetch-error'); - } + console.error("Failed to fetch events:", error); + addNotification('error', 'Backend Not Reachable', 'Unable to load session events. Please check your connection.', 'session-events-fetch-error'); } finally { setLoading(false); if (aborted) setIsStreamingMore(false); diff --git a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx index 3d6eff58..789577a8 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx @@ -18,12 +18,14 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'; import { useAuth } from '@/contexts/AuthContext'; import { isGuid } from '@/utils/inputValidation'; import { api } from '@/lib/api'; -import { authenticatedFetch } from '@/lib/authenticatedFetch'; import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink'; import { sessionUrl } from '@/lib/routes'; import NetworkBand from './NetworkBand'; import { buildNetworkModel, fmtDuration, NetworkModel } from './networkTimelineModel'; import type { EnrollmentEvent, Session } from '@/types'; +import type { GetSessionEventsResponse } from "@/utils/wire-types.generated"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import type { GetSessionResponse } from "@/utils/wire-types.generated"; // ── Data loading ───────────────────────────────────────────────────────────── @@ -39,11 +41,8 @@ function useSessionData(sessionId: string | null, tenantIdOverride: string | und setLoading(true); setError(null); try { - const sResp = await authenticatedFetch(api.sessions.get(sessionId, tenantIdOverride), getAccessToken); - if (!sResp.ok) throw new Error(`Failed to load session (${sResp.status})`); - const sData = await sResp.json(); - const found: Session | undefined = - sData.session ?? sData.sessions?.find((s: Session) => s.sessionId === sessionId); + const sData = await fetchJson(api.sessions.get(sessionId, tenantIdOverride), getAccessToken); + const found: Session | undefined = sData.session; if (!found) throw new Error('Session not found'); setSession(found); @@ -51,10 +50,9 @@ function useSessionData(sessionId: string | null, tenantIdOverride: string | und const all: EnrollmentEvent[] = []; let url = api.sessions.events(sessionId, tenantId, { pageSize: 200 }); for (let page = 0; page < MAX_EAGER_PAGES; page++) { - const resp = await authenticatedFetch(url, getAccessToken); - if (!resp.ok) throw new Error(`Failed to load events (${resp.status})`); - const data = await resp.json(); - if (Array.isArray(data.events)) all.push(...data.events); + const data = await fetchJson(url, getAccessToken); + // No `fields` projection here, so every event is complete. + all.push(...((data.events ?? []) as EnrollmentEvent[])); const cont = extractContinuation(data.nextLink); if (!cont) break; url = api.sessions.events(sessionId, tenantId, { pageSize: 200, continuation: cont }); @@ -62,7 +60,7 @@ function useSessionData(sessionId: string | null, tenantIdOverride: string | und all.sort((a, b) => a.sequence - b.sequence); setEvents(all); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + setError(apiErrorText(e)); } finally { setLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/page.tsx index 5946668d..eedfdef8 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/page.tsx @@ -17,7 +17,7 @@ import { useLatestVersions } from '@/lib/useLatestVersions'; import { useScriptDisplayNames } from '@/lib/scriptDisplayNames'; import { api } from "@/lib/api"; import { isGuid } from "@/utils/inputValidation"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { ApiError, fetchBlob, fetchJson, fetchOk } from "@/lib/apiClient"; import { useSessionAnalysis } from "./hooks/useSessionAnalysis"; import { useAutoScroll } from "./hooks/useAutoScroll"; @@ -48,6 +48,7 @@ import { trackEvent } from "@/lib/appInsights"; import { useAdminMode } from "@/hooks/useAdminMode"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import type { GetSessionTimeAttributionResponse } from "@/utils/wire-types.generated"; export default function SessionDetailPage() { // useSearchParams() in SessionDetailContent requires a Suspense boundary for static prerender. @@ -94,7 +95,7 @@ function SessionDetailContent() { const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR(); const { tenantId } = useTenant(); const { getAccessToken, user } = useAuth(); - const { addNotification } = useNotifications(); + const { addNotification, notifyError } = useNotifications(); const { latestAgentVersion, latestBootstrapVersion } = useLatestVersions(getAccessToken); // Session-scoped hooks (data/SignalR/derivations) @@ -151,11 +152,8 @@ function SessionDetailContent() { } try { const effectiveTenantId = sessionTenantId || tenantIdOverride || undefined; - const response = await authenticatedFetch( - api.sessions.timeAttribution(sessionId, effectiveTenantId), getAccessToken); - if (!response.ok) return; - const json = await response.json(); - if (!cancelled) setTimeBreakdown(json?.breakdown ?? null); + const json = await fetchJson(api.sessions.timeAttribution(sessionId, effectiveTenantId), getAccessToken); + if (!cancelled) setTimeBreakdown(json.breakdown ?? null); } catch { // fail-soft: no lane } @@ -237,17 +235,9 @@ function SessionDetailContent() { const confirmMarkFailed = async () => { const effectiveTenantId = sessionTenantId || tenantId; try { - const response = await authenticatedFetch( - api.sessions.markFailed(sessionId, effectiveTenantId), - getAccessToken, - { method: 'POST' } - ); - if (response.ok) { - setShowMarkFailedConfirm(false); - if (session) setSession({ ...session, status: 'Failed' }); - } else { - console.error('Failed to mark session as failed'); - } + await fetchOk(api.sessions.markFailed(sessionId, effectiveTenantId), getAccessToken, { method: 'POST' }); + setShowMarkFailedConfirm(false); + if (session) setSession({ ...session, status: 'Failed' }); } catch (error) { console.error('Error marking session as failed:', error); } @@ -256,17 +246,9 @@ function SessionDetailContent() { const confirmMarkSucceeded = async () => { const effectiveTenantId = sessionTenantId || tenantId; try { - const response = await authenticatedFetch( - api.sessions.markSucceeded(sessionId, effectiveTenantId), - getAccessToken, - { method: 'POST' } - ); - if (response.ok) { - setShowMarkSucceededConfirm(false); - if (session) setSession({ ...session, status: 'Succeeded' }); - } else { - console.error('Failed to mark session as succeeded'); - } + await fetchOk(api.sessions.markSucceeded(sessionId, effectiveTenantId), getAccessToken, { method: 'POST' }); + setShowMarkSucceededConfirm(false); + if (session) setSession({ ...session, status: 'Succeeded' }); } catch (error) { console.error('Error marking session as succeeded:', error); } @@ -292,12 +274,11 @@ function SessionDetailContent() { const sessionCsv = session ? generateSessionCsvExport(session) : ''; const ruleResultsCsv = generateRuleResultsCsvExport(analysisResults); - const response = await authenticatedFetch( + await fetchOk( api.sessions.report(sessionId, effectiveTenantId), getAccessToken, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tenantId: effectiveTenantId, sessionId, @@ -321,22 +302,12 @@ function SessionDetailContent() { } ); - if (response.ok) { - trackEvent("session_report_submitted", { sessionId }); - addNotification('success', 'Report Submitted', 'Session report has been submitted for analysis.', 'report-success'); - } else { - const data = await response.json().catch(() => null); - const message = data?.message || 'Failed to submit report.'; - addNotification('error', 'Report Failed', message, 'report-error'); - throw new Error(message); - } + trackEvent("session_report_submitted", { sessionId }); + addNotification('success', 'Report Submitted', 'Session report has been submitted for analysis.', 'report-success'); } catch (err: unknown) { - // Re-throw so the modal can show inline error feedback. - // Only log unexpected errors (not the ones we threw ourselves above). - const errMessage = err instanceof Error ? err.message : ''; - if (!errMessage.includes('Failed to submit report') && !errMessage.includes('Failed to get access token')) { - console.error('Error submitting report:', err); - } + // Re-throw so the modal can show inline error feedback; a backend refusal is also toasted. + if (err instanceof ApiError) notifyError('Report Failed', err, 'report-error', 'Failed to submit report.'); + else console.error('Error submitting report:', err); throw err; } finally { setReportSubmitting(false); @@ -488,23 +459,14 @@ function SessionDetailContent() {