mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user