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