diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index 26c3d882..e33d850c 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -380,9 +380,11 @@ function TenantManagementSectionInner({ tenantId: tenant.tenantId, target, force, - reason: data.reason ?? `http-${response.status}`, + reason: data.code ?? data.reason ?? `http-${response.status}`, }); - throw new Error(appHomingErrorMessage(data.reason, response.statusText, data.probe?.missingRoles)); + // `code` is the error-envelope field (AppHomingDeniedResponse); `reason` is the pre-envelope + // name, read until the backend deploy that precedes this web build is everywhere. + throw new Error(appHomingErrorMessage(data.code ?? data.reason, response.statusText, data.probe?.missingRoles)); } trackEvent("app_homing_manual_flip", { tenantId: tenant.tenantId, diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx index 741d79a2..13fc25a4 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx @@ -8,6 +8,7 @@ import { useAuth } from "../../../contexts/AuthContext"; import { useNotifications } from "../../../contexts/NotificationContext"; import { scopedApi } from "@/lib/scopedApi"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorFromResponse, describeApiError } from "@/lib/scopedFetch"; import { formatBytes, formatDuration } from "@/lib/formatting"; import DoBreakdownBar from "@/components/DoBreakdownBar"; import { CalculatingInline } from "@/components/CalculatingCard"; @@ -119,7 +120,9 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { setData((await listRes.json()) as AppsListResponse); succeeded = true; } else { - addNotification("error", "Backend Error", `Failed to load apps: ${listRes.statusText}`, "apps-list-error"); + // Error envelope: the backend's message plus the correlation id as a quotable reference. + const { message, reference } = describeApiError(await apiErrorFromResponse(listRes)); + addNotification("error", "Backend Error", `Failed to load apps: ${message}`, "apps-list-error", undefined, reference ?? undefined); } if (metricsRes.ok) { const m = (await metricsRes.json()) as AppMetricsResponse; diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts index 9e614e06..2217e180 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/deleteSessionResponse.ts @@ -6,6 +6,7 @@ * The hook itself runs the side-effects (toast, SignalR group join, row removal); these * helpers describe *what* should happen for a given backend response. */ +import type { ApiErrorCode } from "@/lib/apiErrorCodes"; /** * Backend body shape for the V2 cascade 202 response. @@ -18,18 +19,29 @@ export interface DeleteQueuedBody { message?: string; } -/** Backend body shape for 4xx/5xx responses — same producer outcome contract. */ +/** + * Backend body shape for 4xx/5xx responses: the error envelope (`SessionDeletionRejectedResponse` + * — error, code, correlationId + the lock diagnostics). `message`/`hint` are the pre-envelope + * names, still read during the backend → web deploy window. + */ export interface DeleteErrorBody { - success?: boolean; - message?: string; + error?: string; + code?: string; + correlationId?: string; deletionState?: string; manifestId?: string; + /** @deprecated pre-envelope name of `error` */ + message?: string; + /** @deprecated pre-envelope name of `code` */ hint?: string; } +/** Envelope codes of the 409 arms (Constants.ApiErrorCodes) plus their pre-envelope `hint` spellings. */ +const POISONED_CODES: ReadonlySet = new Set(["CascadePoisonedUseRestore" satisfies ApiErrorCode, "cascade_poisoned_use_restore"]); + export type DeleteResponseAction = | { kind: "queued"; sessionId: string; tenantId: string; manifestId: string | null } - | { kind: "conflict"; sessionId: string; title: string; message: string; hint: string | null } + | { kind: "conflict"; sessionId: string; title: string; message: string; code: string | null } | { kind: "unavailable"; sessionId: string; message: string } | { kind: "notFound"; sessionId: string; message: string } | { kind: "error"; sessionId: string; message: string }; @@ -56,14 +68,14 @@ export async function classifyDeleteResponse( } const errorBody = await safeJson(response); - const message = errorBody?.message ?? `HTTP ${response.status}`; + const message = errorBody?.error ?? errorBody?.message ?? `HTTP ${response.status}`; if (response.status === 409) { - const hint = errorBody?.hint ?? null; - const title = hint === "cascade_poisoned_use_restore" + const code = errorBody?.code ?? errorBody?.hint ?? null; + const title = code !== null && POISONED_CODES.has(code) ? "Cascade poisoned" : "Cascade already in flight"; - return { kind: "conflict", sessionId, title, message, hint }; + return { kind: "conflict", sessionId, title, message, code }; } if (response.status === 503) { diff --git a/src/Web/autopilot-monitor-web/app/error.tsx b/src/Web/autopilot-monitor-web/app/error.tsx index dd5416a9..74595389 100644 --- a/src/Web/autopilot-monitor-web/app/error.tsx +++ b/src/Web/autopilot-monitor-web/app/error.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { isChunkLoadError, tryRecoverFromChunkError } from "@/utils/chunkReloadRecovery"; +import { trackEvent } from "@/lib/appInsights"; /** * Route-level error boundary — catches unhandled exceptions within page @@ -25,7 +26,11 @@ export default function Error({ useEffect(() => { if (isChunkLoadError(error)) { tryRecoverFromChunkError("error-boundary"); + return; } + // The digest is Next's stable hash of the error (also in the server log) — the only + // reference a user can quote for a client-side crash; the event makes it searchable. + trackEvent("client_error_boundary", { digest: error.digest ?? "", name: error.name }); }, [error]); return ( @@ -36,6 +41,9 @@ export default function Error({

Your session may have expired. Try reloading the page or signing in again.

+ {error.digest && ( +

Ref {error.digest}

+ )}