web(sessions/dashboard/progress): every API call through fetchJson/fetchOk/fetchBlob (audit package 5, stage 3c)

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-06 02:09:51 +02:00
parent 9e4c5f11c5
commit c8b8a8dbb4
23 changed files with 304 additions and 475 deletions
@@ -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<DeleteResponseAction> {
if (response.status === 202) {
if (!(response instanceof ApiError) && response.status === 202) {
const body = await safeJson<DeleteQueuedBody>(response);
return {
kind: "queued",
@@ -67,7 +68,9 @@ export async function classifyDeleteResponse(
};
}
const errorBody = await safeJson<DeleteErrorBody>(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<DeleteErrorBody>(response);
const message = errorBody?.error ?? errorBody?.message ?? `HTTP ${response.status}`;
if (response.status === 409) {
@@ -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<BlockOutcome> => {
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);
}
@@ -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<BlockedDeviceListResponse>(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<SessionListResponse>(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<SearchSessionsResponse>(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));
@@ -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<string | null>;
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<SessionStatsResponse>(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(() => {
@@ -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<Response | ApiError> =>
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<DeleteResponseAction> => {
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.') };
}
});
@@ -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
@@ -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<string>();
try {
const response = await authenticatedFetch(
const data = await fetchJson<GetRuleHitSessionsResponse>(
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,
});
@@ -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<ProgressGetSessionEventsResponse>(
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<ProgressLookupSessionResponse>(
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<ProgressGetSessionEventsResponse>(
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);
}
@@ -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<ProgressLookupSessionResponse>(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);
@@ -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);
@@ -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<string, number> = {};
const fires: Record<string, number> = {};
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<RuleStatsResponse>(url, getAccessToken);
const map: Record<string, number> = {};
const fires: Record<string, number> = {};
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
}
@@ -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<boolean> => {
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<TenantConfiguration>(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);
}
@@ -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<DeviceHistoryResponse>(api.metrics.deviceHistory(serialNumber, sessionId, effectiveTenantId), getAccessToken);
if (!cancelled) setData(json);
} catch {
// fail-soft: no banner
@@ -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<AnnotationsResponse>(api.sessions.annotations(sessionId, effectiveTenantId), getAccessToken);
if (cancelled) return;
const byLane: Partial<Record<string, SessionAnnotationDto>> = {};
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"),
});
}
};
@@ -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" },
@@ -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<GetRuleResultsResponse>(
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<GetVulnerabilityReportResponse>(
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);
}
@@ -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<GetSessionResponse>(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);
@@ -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<GetSessionEventsResponse>(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<GetSessionEventsResponse>(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);
@@ -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<GetSessionResponse>(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<GetSessionEventsResponse>(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);
}
@@ -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<GetSessionTimeAttributionResponse>(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() {
<button
onClick={async () => {
try {
const res = await authenticatedFetch(
api.diagnostics.downloadUrl(session.tenantId, session.diagnosticsBlobName!),
getAccessToken
);
if (!res.ok) throw new Error('Failed to download diagnostics package');
const blob = await res.blob();
const blob = await fetchBlob(api.diagnostics.downloadUrl(session.tenantId, session.diagnosticsBlobName!), getAccessToken);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = session.diagnosticsBlobName!;
a.click();
URL.revokeObjectURL(a.href);
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
} else {
console.error('Diagnostics download failed:', err);
}
console.error('Diagnostics download failed:', err);
}
}}
className="px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-md hover:bg-gray-50 transition-colors flex items-center gap-2 text-sm"
@@ -17,6 +17,15 @@ export interface Notification {
reference?: string; // Optional short correlation id (backend failures) rendered as "Ref …"
}
/** The addNotification signature, for hooks that receive it as a parameter instead of reading the context. */
export type AddNotification = (type: NotificationType, title: string, message: string, key?: string, href?: string, reference?: string) => void;
/** notifyError for code that holds an addNotification function rather than the context (hooks with injected notifiers). */
export function notifyApiError(add: AddNotification, title: string, err: unknown, key?: string, fallback?: string): void {
const n = apiErrorNotification(title, err, key, fallback);
add(n.type, n.title, n.message, n.key, undefined, n.reference);
}
interface NotificationContextType {
notifications: Notification[];
unreadCount: number;
@@ -107,10 +116,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
}
}, []);
const notifyError = useCallback((title: string, err: unknown, key?: string, fallback?: string) => {
const n = apiErrorNotification(title, err, key, fallback);
addNotification(n.type, n.title, n.message, n.key, undefined, n.reference);
}, [addNotification]);
const notifyError = useCallback((title: string, err: unknown, key?: string, fallback?: string) => notifyApiError(addNotification, title, err, key, fallback), [addNotification]);
const markAsRead = useCallback((id: string) => {
setNotifications(prev =>
@@ -18,12 +18,6 @@ export const AUTHENTICATEDFETCH_BASELINE: Record<string, number> = {
"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/useDashboardSessions.ts": 3,
"app/dashboard/hooks/useDashboardStats.ts": 1,
"app/dashboard/hooks/useDeleteSession.ts": 3,
"app/dashboard/hooks/useTenantList.ts": 1,
"app/dashboard/page.tsx": 1,
"app/delegations/accept/page.tsx": 2,
"app/diagnosis/page.tsx": 3,
"app/fleet-health/hooks/useFleetHealth.ts": 1,
@@ -33,17 +27,6 @@ export const AUTHENTICATEDFETCH_BASELINE: Record<string, number> = {
"app/geographic-performance/page.tsx": 1,
"app/geographic-performance/sessions/page.tsx": 1,
"app/health-check/page.tsx": 2,
"app/progress/hooks/useProgressEvents.ts": 3,
"app/progress/hooks/useProgressSearch.ts": 1,
"app/sessions/components/AnalysisResultsSection.tsx": 1,
"app/sessions/components/CollectLogsButton.tsx": 3,
"app/sessions/components/DeviceHistoryBanner.tsx": 1,
"app/sessions/components/SessionAnnotationsCard.tsx": 2,
"app/sessions/hooks/useSessionAnalysis.ts": 2,
"app/sessions/hooks/useSessionDetail.ts": 1,
"app/sessions/hooks/useSessionEvents.ts": 2,
"app/sessions/network-timeline/page.tsx": 2,
"app/sessions/page.tsx": 5,
"app/sla/page.tsx": 1,
"app/usage-metrics/page.tsx": 1,
"components/DelegatedSlotPrompt.tsx": 1,
@@ -68,11 +51,6 @@ export const JSONPARSE_BASELINE: Record<string, number> = {
"app/apps/detail/page.tsx": 2,
"app/audit/page.tsx": 1,
"app/dashboard/hooks/deleteSessionResponse.ts": 1,
"app/dashboard/hooks/useBlockDevice.ts": 1,
"app/dashboard/hooks/useDashboardSessions.ts": 4,
"app/dashboard/hooks/useDashboardStats.ts": 2,
"app/dashboard/hooks/useTenantList.ts": 1,
"app/dashboard/page.tsx": 1,
"app/delegations/accept/page.tsx": 3,
"app/diagnosis/page.tsx": 3,
"app/fleet-health/hooks/useFleetHealth.ts": 2,
@@ -82,17 +60,6 @@ export const JSONPARSE_BASELINE: Record<string, number> = {
"app/geographic-performance/page.tsx": 1,
"app/geographic-performance/sessions/page.tsx": 1,
"app/health-check/page.tsx": 3,
"app/progress/hooks/useProgressEvents.ts": 3,
"app/progress/hooks/useProgressSearch.ts": 1,
"app/sessions/components/AnalysisResultsSection.tsx": 1,
"app/sessions/components/CollectLogsButton.tsx": 1,
"app/sessions/components/DeviceHistoryBanner.tsx": 1,
"app/sessions/components/SessionAnnotationsCard.tsx": 2,
"app/sessions/hooks/useSessionAnalysis.ts": 2,
"app/sessions/hooks/useSessionDetail.ts": 1,
"app/sessions/hooks/useSessionEvents.ts": 2,
"app/sessions/network-timeline/page.tsx": 2,
"app/sessions/page.tsx": 2,
"app/sla/page.tsx": 1,
"app/usage-metrics/page.tsx": 1,
"components/DelegatedSlotPrompt.tsx": 1,
@@ -112,10 +79,8 @@ export const TOKENEXPIRED_BASELINE: Record<string, number> = {
"app/apps/components/VulnerabilitiesTab.tsx": 1,
"app/apps/detail/page.tsx": 2,
"app/audit/page.tsx": 1,
"app/dashboard/hooks/useBlockDevice.ts": 2,
"app/dashboard/hooks/useDashboardSessions.ts": 3,
"app/dashboard/hooks/useDashboardStats.ts": 1,
"app/dashboard/hooks/useDeleteSession.ts": 2,
"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,
@@ -124,13 +89,8 @@ export const TOKENEXPIRED_BASELINE: Record<string, number> = {
"app/geographic-performance/page.tsx": 1,
"app/geographic-performance/sessions/page.tsx": 1,
"app/health-check/page.tsx": 1,
"app/progress/hooks/useProgressEvents.ts": 1,
"app/progress/hooks/useProgressSearch.ts": 1,
"app/sessions/components/CollectLogsButton.tsx": 1,
"app/sessions/components/SessionAnnotationsCard.tsx": 1,
"app/sessions/hooks/useSessionDetail.ts": 1,
"app/sessions/hooks/useSessionEvents.ts": 1,
"app/sessions/page.tsx": 1,
"app/settings/TenantConfigContext.tsx": 2,
"app/settings/components/OffboardingSection.tsx": 1,
"app/sla/page.tsx": 1,
@@ -86,6 +86,12 @@ export function nullOn404(err: unknown): null {
throw err;
}
/** `.catch(nullOnApiError)`: any backend refusal degrades to null (the old `if (!res.ok) return` sites); a token expiry or network failure still throws. */
export function nullOnApiError(err: unknown): null {
if (err instanceof ApiError) return null;
throw err;
}
/** A string body without an explicit Content-Type is JSON — every backend route deserialises JSON. */
function withJsonContentType(init: RequestInit | undefined): RequestInit | undefined {
if (typeof init?.body !== "string") return init;