From c5a5b02221d8d84c11b420530d3d010f8b0ff42e Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:53:27 -0400 Subject: [PATCH] feat(web): admin client-diagnostics viewer List/detail/download/delete for uploaded diagnostic reports at /admin/diagnostics, beside the Logs viewer: server-filter parity with the admin API, cursor pagination, manifest + device summary detail sheet, playback-session links into the filtered logs view, presign/stream download handling, and feature-state banner. Extracts a shared authenticated apiResponse() helper used by api() and apiDownload(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XppCCycoaskCsW7ja1fZct --- web/src/App.tsx | 2 + web/src/api/client.ts | 72 +- web/src/api/types.ts | 111 +++ .../AdminSectionCommandDialog.test.tsx | 10 + web/src/components/AdminSidebar.test.tsx | 7 + .../hooks/queries/admin/diagnostics.test.ts | 96 +++ web/src/hooks/queries/admin/diagnostics.ts | 122 +++ web/src/hooks/queries/keys.ts | 4 + web/src/lib/adminNavigation.ts | 8 + web/src/lib/documentTitle.test.ts | 1 + web/src/lib/documentTitle.ts | 1 + web/src/pages/AdminDiagnostics.tsx | 711 ++++++++++++++++++ 12 files changed, 1093 insertions(+), 52 deletions(-) create mode 100644 web/src/hooks/queries/admin/diagnostics.test.ts create mode 100644 web/src/hooks/queries/admin/diagnostics.ts create mode 100644 web/src/pages/AdminDiagnostics.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index cc77f592..31e7ee8f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -47,6 +47,7 @@ import RequestDetail from "@/pages/RequestDetail"; import AdminDashboard from "@/pages/AdminDashboard"; import AdminActivity from "@/pages/AdminActivity"; import AdminLogs from "@/pages/AdminLogs"; +import AdminDiagnostics from "@/pages/AdminDiagnostics"; import AdminAccessGroups from "@/pages/AdminAccessGroups"; import AdminUsers from "@/pages/AdminUsers"; import AdminRequests from "@/pages/AdminRequests"; @@ -412,6 +413,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a19ff289..77f93cf6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -341,6 +341,21 @@ export async function restoreUserSession({ } export async function api(path: string, options: RequestInit = {}): Promise { + const res = await apiResponse(path, options); + + // Handle empty successful responses. + if (res.status === 204 || res.status === 205) { + return undefined as T; + } + const text = await res.text(); + if (text.trim() === "") { + return undefined as T; + } + return JSON.parse(text) as T; +} + +/** Performs an authenticated API request while leaving the successful body unread. */ +export async function apiResponse(path: string, options: RequestInit = {}): Promise { const headers = buildApiHeaders(options); let res = await fetch(`/api/v1${path}`, { ...options, headers }); @@ -354,8 +369,8 @@ export async function api(path: string, options: RequestInit = {}): Promise(path: string, options: RequestInit = {}): Promise { @@ -421,26 +427,7 @@ export async function apiDownload( filename: string, options: RequestInit = {}, ): Promise { - let headers = buildApiHeaders(options); - let res = await fetch(`/api/v1${path}`, { ...options, headers }); - - if (res.status === 401 && getRefreshToken()) { - if (!refreshPromise) { - refreshPromise = attemptRefresh().finally(() => { - refreshPromise = null; - }); - } - const refreshed = await refreshPromise; - if (refreshed) { - headers = buildApiHeaders(options); - headers["Authorization"] = `Bearer ${accessToken}`; - res = await fetch(`/api/v1${path}`, { ...options, headers }); - } - } - - if (!res.ok) { - throw apiClientErrorFrom(res.status, await parseApiError(res)); - } + const res = await apiResponse(path, options); const blob = await res.blob(); const url = URL.createObjectURL(blob); @@ -458,26 +445,7 @@ export async function apiDownload( export const API_BLOB_MAX_BYTES = 512 * 1024 * 1024; export async function apiBlob(path: string, options: RequestInit = {}): Promise { - let headers = buildApiHeaders(options); - let res = await fetch(`/api/v1${path}`, { ...options, headers }); - - if (res.status === 401 && getRefreshToken()) { - if (!refreshPromise) { - refreshPromise = attemptRefresh().finally(() => { - refreshPromise = null; - }); - } - const refreshed = await refreshPromise; - if (refreshed) { - headers = buildApiHeaders(options); - headers["Authorization"] = `Bearer ${accessToken}`; - res = await fetch(`/api/v1${path}`, { ...options, headers }); - } - } - - if (!res.ok) { - throw apiClientErrorFrom(res.status, await parseApiError(res)); - } + const res = await apiResponse(path, options); // Reject oversized bodies up front instead of crashing the tab while // buffering them. When the header is absent, proceed; streaming byte counts diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 88d1784e..8c1b9801 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -2474,6 +2474,117 @@ export interface AuditLogListResponse { next_cursor?: string; } +export type DiagnosticAvailabilityStatus = "available" | "disabled" | "storage_unavailable"; + +export interface DiagnosticStatus { + status: DiagnosticAvailabilityStatus; + server_instance_id: string; + accepted_schema_versions: number[]; + max_bundle_bytes: number; + max_manifest_bytes: number; + retention_days: number; + consent_notice_version: number; +} + +export type DiagnosticReportState = "receiving" | "ready" | "failed"; +export type DiagnosticReportType = + | "crash" + | "anr" + | "native_crash" + | "hang" + | "abnormal_exit" + | "manual"; +export type DiagnosticPlatform = "android" | "android-tv" | "ios" | "tvos"; + +export interface ClientDiagnosticManifest { + schema_version: number; + report: { + type: DiagnosticReportType; + captured_at: string; + capture_session_id: string; + app_version: string; + app_build: string; + platform: DiagnosticPlatform; + os_version: string; + profile_id?: string; + [key: string]: unknown; + }; + destination: { + server_instance_id: string; + [key: string]: unknown; + }; + consent: { + mode: "prompt" | "always" | "manual"; + notice_version: number; + [key: string]: unknown; + }; + crash?: { + summary: string; + stack_excerpt?: string; + thread?: string; + foreground?: boolean; + source: "ueh" | "exit_info" | "metrickit" | "exit_sentinel"; + provenance: "pre_failure" | "post_restart" | "metric_reporting_period"; + occurred_at: string; + [key: string]: unknown; + }; + device_summary: { + manufacturer: string; + model: string; + os: string; + form_factor: string; + [key: string]: unknown; + }; + playback_session_ids: string[]; + log_summary: { + lines: number; + bytes_gz: number; + dropped_lines: number; + categories: string[]; + debug_logging: boolean; + [key: string]: unknown; + }; + archive: { + entries: string[]; + bytes: number; + uncompressed_bytes: number; + sha256: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface DiagnosticReport { + id: string; + short_id: string; + user_id: number; + profile_id?: string; + state: DiagnosticReportState; + captured_at: string; + received_at: string; + report_type: DiagnosticReportType; + platform: DiagnosticPlatform; + app_version: string; + crash_summary?: string; + manifest: ClientDiagnosticManifest; + playback_session_ids: string[]; + blob_bucket?: string; + blob_key?: string; + blob_bytes?: number; + uncompressed_bytes?: number; + blob_sha256?: string; +} + +export interface DiagnosticReportListResponse { + reports: DiagnosticReport[]; + next_cursor?: string; +} + +export interface DiagnosticDownloadResponse { + download_url: string; + expires_at: string; +} + export type AdminLogStream = "app" | "audit"; export interface AdminLogSnapshotMessage { diff --git a/web/src/components/AdminSectionCommandDialog.test.tsx b/web/src/components/AdminSectionCommandDialog.test.tsx index aa100808..e18cc673 100644 --- a/web/src/components/AdminSectionCommandDialog.test.tsx +++ b/web/src/components/AdminSectionCommandDialog.test.tsx @@ -121,6 +121,16 @@ describe("AdminSectionCommandDialog", () => { expect(screen.queryByRole("searchbox", { name: "Search admin sections" })).toBeNull(); }); + it("opens client diagnostics from admin search", async () => { + renderDialog(); + + const searchBox = await openDialog(); + await userEvent.type(searchBox, "client diagnostics"); + await userEvent.click(screen.getByRole("option", { name: /Diagnostics/ })); + + expect(screen.getByLabelText("Current path")).toHaveTextContent("/admin/diagnostics"); + }); + it("closes with Escape", async () => { renderDialog(); diff --git a/web/src/components/AdminSidebar.test.tsx b/web/src/components/AdminSidebar.test.tsx index f65c2a66..3a6b380c 100644 --- a/web/src/components/AdminSidebar.test.tsx +++ b/web/src/components/AdminSidebar.test.tsx @@ -92,6 +92,13 @@ describe("AdminSidebar", () => { expect(markup).toContain(">Sections<"); }); + it("includes Diagnostics next to the operational overview links", () => { + const markup = renderSidebar(); + + expect(markup).toContain('href="/admin/diagnostics"'); + expect(markup).toContain(">Diagnostics<"); + }); + it("includes a Maintenance link in the system navigation", () => { const markup = renderSidebar(); diff --git a/web/src/hooks/queries/admin/diagnostics.test.ts b/web/src/hooks/queries/admin/diagnostics.test.ts new file mode 100644 index 00000000..46b0147a --- /dev/null +++ b/web/src/hooks/queries/admin/diagnostics.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DiagnosticReport } from "@/api/types"; + +const mocks = vi.hoisted(() => ({ + apiResponse: vi.fn(), +})); + +vi.mock("@/api/client", () => ({ + api: vi.fn(), + apiResponse: mocks.apiResponse, +})); + +import { downloadDiagnosticReport } from "./diagnostics"; + +const report = { + id: "83fd3186-bd4f-42e1-8285-58107c503685", + short_id: "ABCDEF123456", +} as DiagnosticReport; + +describe("downloadDiagnosticReport", () => { + beforeEach(() => { + mocks.apiResponse.mockReset(); + }); + + it("opens a presigned download URL returned as JSON", async () => { + const downloadWindow = { + close: vi.fn(), + location: { href: "about:blank" }, + opener: window, + } as unknown as Window; + const open = vi.spyOn(window, "open").mockReturnValue(downloadWindow); + mocks.apiResponse.mockResolvedValue( + new Response( + JSON.stringify({ + download_url: "https://storage.example/report.tar.gz?signature=test", + expires_at: "2026-07-20T18:00:00Z", + }), + { headers: { "Content-Type": "application/json; charset=utf-8" } }, + ), + ); + + await downloadDiagnosticReport(report); + + expect(mocks.apiResponse).toHaveBeenCalledWith( + "/admin/diagnostics/reports/83fd3186-bd4f-42e1-8285-58107c503685/download", + ); + expect(open).toHaveBeenCalledWith("about:blank", "_blank"); + expect(downloadWindow.opener).toBeNull(); + expect(downloadWindow.location.href).toBe( + "https://storage.example/report.tar.gz?signature=test", + ); + open.mockRestore(); + }); + + it("downloads a streamed gzip fallback as a file", async () => { + const close = vi.fn(); + const open = vi.spyOn(window, "open").mockReturnValue({ close } as unknown as Window); + const objectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:diagnostic"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + mocks.apiResponse.mockResolvedValue( + new Response(new Blob(["bundle"]), { headers: { "Content-Type": "application/gzip" } }), + ); + + await downloadDiagnosticReport(report); + + expect(click).toHaveBeenCalledOnce(); + expect(objectURL).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(document.querySelector('a[download="silo-diagnostics-ABCDEF123456.tar.gz"]')).toBeNull(); + + await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:diagnostic"); + objectURL.mockRestore(); + revokeObjectURL.mockRestore(); + click.mockRestore(); + open.mockRestore(); + }); + + it("reports when a presigned download window is blocked", async () => { + const open = vi.spyOn(window, "open").mockReturnValue(null); + mocks.apiResponse.mockResolvedValue( + new Response(JSON.stringify({ download_url: "https://storage.example/report.tar.gz" }), { + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(downloadDiagnosticReport(report)).rejects.toThrow( + "The browser blocked the diagnostic download window.", + ); + open.mockRestore(); + }); +}); diff --git a/web/src/hooks/queries/admin/diagnostics.ts b/web/src/hooks/queries/admin/diagnostics.ts new file mode 100644 index 00000000..123ae107 --- /dev/null +++ b/web/src/hooks/queries/admin/diagnostics.ts @@ -0,0 +1,122 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { api, apiResponse } from "@/api/client"; +import type { + DiagnosticDownloadResponse, + DiagnosticReport, + DiagnosticReportListResponse, + DiagnosticStatus, +} from "@/api/types"; +import { adminKeys } from "@/hooks/queries/keys"; + +export interface AdminDiagnosticsQuery { + user_id?: number | string; + platform?: string; + report_type?: string; + from?: string; + to?: string; + short_id?: string; + limit?: number; + cursor?: string; +} + +function toQueryString(params: AdminDiagnosticsQuery) { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue; + search.set(key, String(value)); + } + return search.toString(); +} + +export function useDiagnosticsStatus() { + return useQuery({ + queryKey: adminKeys.diagnosticStatus(), + queryFn: () => api("/diagnostics/status"), + staleTime: 30_000, + }); +} + +export function useDiagnosticReports(params: AdminDiagnosticsQuery) { + const query = toQueryString(params); + return useQuery({ + queryKey: adminKeys.diagnosticReports({ ...params }), + queryFn: () => + api(`/admin/diagnostics/reports${query ? `?${query}` : ""}`), + staleTime: 5_000, + }); +} + +export function useDiagnosticReport(id?: string) { + return useQuery({ + queryKey: adminKeys.diagnosticReport(id), + queryFn: () => api(`/admin/diagnostics/reports/${encodeURIComponent(id!)}`), + enabled: Boolean(id), + }); +} + +export function useDeleteDiagnosticReport() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + api(`/admin/diagnostics/reports/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + onSuccess: (_result, id) => { + queryClient.removeQueries({ queryKey: adminKeys.diagnosticReport(id) }); + void queryClient.invalidateQueries({ + queryKey: ["admin", "diagnostics", "reports"], + }); + toast.success("Diagnostic report deleted"); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to delete diagnostic report"); + }, + }); +} + +export async function downloadDiagnosticReport(report: DiagnosticReport) { + const downloadWindow = window.open("about:blank", "_blank"); + if (downloadWindow) { + downloadWindow.opener = null; + } + + try { + const response = await apiResponse( + `/admin/diagnostics/reports/${encodeURIComponent(report.id)}/download`, + ); + const contentType = response.headers + .get("Content-Type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + + if (contentType === "application/json") { + const payload = (await response.json()) as Partial; + if (typeof payload.download_url !== "string" || !payload.download_url.trim()) { + throw new Error("The server returned an invalid diagnostic download response."); + } + if (!downloadWindow) { + throw new Error("The browser blocked the diagnostic download window."); + } + downloadWindow.location.href = payload.download_url; + return; + } + + downloadWindow?.close(); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `silo-diagnostics-${report.short_id || report.id}.tar.gz`; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + } catch (error) { + downloadWindow?.close(); + throw error; + } +} diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index ba4ea5d6..96f63df6 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -392,6 +392,10 @@ export const adminKeys = { ipUsers: (ip: string, days?: number) => ["admin", "ips", ip, days] as const, operationalLogs: (params: Record) => ["admin", "logs", "app", params] as const, auditLogs: (params: Record) => ["admin", "logs", "audit", params] as const, + diagnosticStatus: () => ["diagnostics", "status"] as const, + diagnosticReports: (params: Record) => + ["admin", "diagnostics", "reports", params] as const, + diagnosticReport: (id?: string) => ["admin", "diagnostics", "reports", id ?? "none"] as const, policyCapability: () => ["policy", "capability"] as const, policyVendor: () => ["admin", "policy", "vendor"] as const, policyDocuments: () => ["admin", "policy", "documents"] as const, diff --git a/web/src/lib/adminNavigation.ts b/web/src/lib/adminNavigation.ts index 465fce49..973bbfce 100644 --- a/web/src/lib/adminNavigation.ts +++ b/web/src/lib/adminNavigation.ts @@ -4,6 +4,7 @@ import { CalendarClock, Captions, Download, + FileWarning, History, KeyRound, LayoutDashboard, @@ -71,6 +72,13 @@ export const ADMIN_NAV_SECTIONS: AdminNavGroup[] = [ icon: ScrollText, href: "/admin/logs", }, + { + label: "Diagnostics", + description: "Uploaded client crash reports, device context, and debug bundles.", + keywords: ["client diagnostics", "crash reports", "debug bundles", "support"], + icon: FileWarning, + href: "/admin/diagnostics", + }, ], }, { diff --git a/web/src/lib/documentTitle.test.ts b/web/src/lib/documentTitle.test.ts index 083506eb..4acb4075 100644 --- a/web/src/lib/documentTitle.test.ts +++ b/web/src/lib/documentTitle.test.ts @@ -35,6 +35,7 @@ describe("resolveAdminDocumentTitle", () => { it("resolves major admin sections", () => { expect(resolveAdminDocumentTitle("/admin")).toBe("Admin"); expect(resolveAdminDocumentTitle("/admin/collections")).toBe("Admin Collections"); + expect(resolveAdminDocumentTitle("/admin/diagnostics")).toBe("Admin Client Diagnostics"); expect(resolveAdminDocumentTitle("/admin/tasks/refresh-metadata")).toBe("Admin Task"); }); diff --git a/web/src/lib/documentTitle.ts b/web/src/lib/documentTitle.ts index 874a92c0..437ca852 100644 --- a/web/src/lib/documentTitle.ts +++ b/web/src/lib/documentTitle.ts @@ -44,6 +44,7 @@ const ADMIN_TITLES: Record = { "api-keys": "Admin API Keys", collections: "Admin Collections", devices: "Admin Devices", + diagnostics: "Admin Client Diagnostics", history: "Admin Playback History", "history-import": "Admin History Import", "marker-history": "Admin Marker History", diff --git a/web/src/pages/AdminDiagnostics.tsx b/web/src/pages/AdminDiagnostics.tsx new file mode 100644 index 00000000..6c77de27 --- /dev/null +++ b/web/src/pages/AdminDiagnostics.tsx @@ -0,0 +1,711 @@ +import { useEffect, useMemo, useState } from "react"; +import type { FormEvent, KeyboardEvent } from "react"; +import { Link, useSearchParams } from "react-router"; +import { Bug, Download, ExternalLink, FilterX, Trash2, TriangleAlert } from "lucide-react"; +import { toast } from "sonner"; + +import type { DiagnosticReport, DiagnosticReportState } from "@/api/types"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useDateTimeFormat } from "@/hooks/useDateTimeFormat"; +import { + downloadDiagnosticReport, + useDeleteDiagnosticReport, + useDiagnosticReport, + useDiagnosticReports, + useDiagnosticsStatus, +} from "@/hooks/queries/admin/diagnostics"; +import { formatDateTime as formatPreferredDateTime } from "@/lib/datetime"; + +const PAGE_SIZE = 25; +const FILTER_KEYS = ["user_id", "platform", "report_type", "from", "to", "short_id"]; + +interface FilterDraft { + userID: string; + platform: string; + reportType: string; + from: string; + to: string; + shortID: string; +} + +export default function AdminDiagnostics() { + useDateTimeFormat(); + const [searchParams, setSearchParams] = useSearchParams(); + const appliedFilterKey = FILTER_KEYS.map((key) => searchParams.get(key) ?? "").join("\u0000"); + const [filters, setFilters] = useState(() => draftFromSearchParams(searchParams)); + const [pagination, setPagination] = useState<{ + filterKey: string; + cursor?: string; + cursorStack: string[]; + }>(() => ({ filterKey: appliedFilterKey, cursorStack: [] })); + const [selectedID, setSelectedID] = useState(); + const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); + const [downloading, setDownloading] = useState(false); + + useEffect(() => { + setFilters(draftFromSearchParams(searchParams)); + setPagination((current) => + current.filterKey === appliedFilterKey + ? current + : { filterKey: appliedFilterKey, cursorStack: [] }, + ); + setSelectedID(undefined); + }, [appliedFilterKey, searchParams]); + + const activeCursor = pagination.filterKey === appliedFilterKey ? pagination.cursor : undefined; + const activeCursorStack = pagination.filterKey === appliedFilterKey ? pagination.cursorStack : []; + + const query = useMemo(() => { + return { + user_id: searchParams.get("user_id") || undefined, + platform: searchParams.get("platform") || undefined, + report_type: searchParams.get("report_type") || undefined, + from: searchParams.get("from") || undefined, + to: searchParams.get("to") || undefined, + short_id: searchParams.get("short_id") || undefined, + limit: PAGE_SIZE, + cursor: activeCursor, + }; + }, [activeCursor, searchParams]); + + const status = useDiagnosticsStatus(); + const reports = useDiagnosticReports(query); + const selectedReport = useDiagnosticReport(selectedID); + const deleteReport = useDeleteDiagnosticReport(); + const hasAppliedFilters = FILTER_KEYS.some((key) => searchParams.has(key)); + + function setFilter(key: Key, value: FilterDraft[Key]) { + setFilters((current) => ({ ...current, [key]: value })); + } + + function applyFilters(event: FormEvent) { + event.preventDefault(); + const userID = normalizeUserID(filters.userID); + if (filters.userID.trim() && !userID) { + toast.error("User ID must be a positive whole number."); + return; + } + const next = new URLSearchParams(searchParams); + setOrDelete(next, "user_id", userID); + setOrDelete(next, "platform", filters.platform === "all" ? "" : filters.platform); + setOrDelete(next, "report_type", filters.reportType === "all" ? "" : filters.reportType); + setOrDelete(next, "from", toRFC3339(filters.from)); + setOrDelete(next, "to", toRFC3339(filters.to)); + setOrDelete(next, "short_id", filters.shortID); + setSearchParams(next, { replace: true }); + setSelectedID(undefined); + } + + function clearFilters() { + const next = new URLSearchParams(searchParams); + FILTER_KEYS.forEach((key) => next.delete(key)); + setSearchParams(next, { replace: true }); + setSelectedID(undefined); + } + + function goNext() { + if (!reports.data?.next_cursor) return; + setPagination({ + filterKey: appliedFilterKey, + cursor: reports.data.next_cursor, + cursorStack: [...activeCursorStack, activeCursor ?? ""], + }); + setSelectedID(undefined); + } + + function goPrevious() { + const next = [...activeCursorStack]; + const previous = next.pop(); + setPagination({ + filterKey: appliedFilterKey, + cursor: previous || undefined, + cursorStack: next, + }); + setSelectedID(undefined); + } + + async function handleDownload(report: DiagnosticReport) { + setDownloading(true); + try { + await downloadDiagnosticReport(report); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to download diagnostic report"); + } finally { + setDownloading(false); + } + } + + return ( +
+
+
+

Client Diagnostics

+

+ Review client crash reports, device context, and correlated playback sessions. +

+
+
+
+ Retention +
+
+ {status.data ? `${status.data.retention_days} days` : "Loading..."} +
+
+
+ + {status.data?.status !== undefined && status.data.status !== "available" && ( + + )} + +
+
+ + setFilter("userID", event.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + setFilter("from", event.target.value)} + /> +
+
+ + setFilter("to", event.target.value)} + /> +
+
+ + setFilter("shortID", event.target.value)} + /> +
+
+ + +
+
+ +
+ + + + Received + Short ID + User + Platform + Type + Summary + State + Bundle + + + + {reports.isLoading && ( + + + Loading diagnostic reports... + + + )} + {reports.isError && ( + + + {reports.error instanceof Error + ? reports.error.message + : "Failed to load diagnostic reports."} + + + )} + {!reports.isLoading && !reports.isError && reports.data?.reports.length === 0 && ( + + + + + + )} + {reports.data?.reports.map((report) => ( + + ))} + +
+
+ +
+ +
+ {reports.isFetching + ? "Refreshing..." + : reports.data?.next_cursor + ? `Page ${activeCursorStack.length + 1} · More reports` + : reports.data?.reports.length + ? `Page ${activeCursorStack.length + 1}` + : ""} +
+ +
+ + !open && setSelectedID(undefined)} + > + + + + {selectedReport.data?.short_id ?? "Report"} + + + {selectedReport.data + ? `${formatToken(selectedReport.data.report_type)} · ${formatDateTime(selectedReport.data.received_at)}` + : "Client diagnostic report details"} + + +
+ {selectedReport.isLoading && ( +

Loading report details...

+ )} + {selectedReport.isError && ( +

+ {selectedReport.error instanceof Error + ? selectedReport.error.message + : "Failed to load report details."} +

+ )} + {selectedReport.data && ( + void handleDownload(selectedReport.data!)} + onDelete={() => setDeleteConfirmationOpen(true)} + /> + )} +
+
+
+ + { + if (!selectedID) return; + deleteReport.mutate(selectedID, { + onSuccess: () => { + setDeleteConfirmationOpen(false); + setSelectedID(undefined); + }, + }); + }} + /> +
+ ); +} + +function DiagnosticReportRow({ + report, + onSelect, +}: { + report: DiagnosticReport; + onSelect: (id: string) => void; +}) { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(report.id); + } + } + + return ( + onSelect(report.id)} + onKeyDown={handleKeyDown} + aria-label={`Open diagnostic report ${report.short_id}`} + > + {formatDateTime(report.received_at)} + {report.short_id} + +
#{report.user_id}
+ {report.profile_id && ( +
+ {report.profile_id} +
+ )} +
+ +
{formatPlatform(report.platform)}
+
v{report.app_version}
+
+ + {formatToken(report.report_type)} + + +
+ {report.crash_summary || "—"} +
+
+ + + + + {formatBytes(report.blob_bytes)} + +
+ ); +} + +function DiagnosticReportDetail({ + report, + downloading, + deleting, + onDownload, + onDelete, +}: { + report: DiagnosticReport; + downloading: boolean; + deleting: boolean; + onDownload: () => void; + onDelete: () => void; +}) { + const device = report.manifest.device_summary; + const appBuild = report.manifest.report.app_build; + + return ( +
+
+ + + {report.state !== "ready" && ( + + Only ready reports can be downloaded. + + )} +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + {report.crash_summary && ( +
+

Crash summary

+

+ {report.crash_summary} +

+
+ )} + +
+

Playback sessions

+ {report.playback_session_ids.length > 0 ? ( +
+ {report.playback_session_ids.map((sessionID) => ( + + {sessionID} +
+ ) : ( +

No playback sessions were attached.

+ )} +
+ +
+ + Full manifest JSON + +
+          {JSON.stringify(report.manifest, null, 2)}
+        
+
+
+ ); +} + +function FeatureStatusBanner({ status }: { status: "disabled" | "storage_unavailable" }) { + const message = + status === "disabled" + ? "Client diagnostic uploads are currently disabled. Reports from when the feature was enabled may still be available below." + : "Client diagnostic storage is currently unavailable. Existing report metadata may still be available below."; + return ( +
+
+ ); +} + +function EmptyState({ filtered }: { filtered: boolean }) { + return ( +
+
+
+

No diagnostic reports

+

+ {filtered + ? "No reports match these filters. Clients create reports from their Diagnostics setting." + : "Users can enable diagnostics in the client settings, then send a crash or manual report."} +

+
+ ); +} + +function StateBadge({ state }: { state: DiagnosticReportState }) { + const className = + state === "ready" + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" + : state === "failed" + ? "border-destructive/30 bg-destructive/10 text-destructive" + : "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400"; + return ( + + {formatToken(state)} + + ); +} + +function DetailField({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function draftFromSearchParams(searchParams: URLSearchParams): FilterDraft { + return { + userID: searchParams.get("user_id") ?? "", + platform: searchParams.get("platform") ?? "all", + reportType: searchParams.get("report_type") ?? "all", + from: toLocalDateTimeInput(searchParams.get("from")), + to: toLocalDateTimeInput(searchParams.get("to")), + shortID: searchParams.get("short_id") ?? "", + }; +} + +function setOrDelete(params: URLSearchParams, key: string, value: string | undefined) { + const normalized = value?.trim(); + if (normalized) { + params.set(key, normalized); + } else { + params.delete(key); + } +} + +function normalizeUserID(value: string) { + const normalized = value.trim(); + if (!normalized) return undefined; + if (!/^[1-9]\d*$/.test(normalized)) return undefined; + const parsed = Number(normalized); + return Number.isSafeInteger(parsed) ? String(parsed) : undefined; +} + +function toRFC3339(value: string) { + if (!value.trim()) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function toLocalDateTimeInput(value: string | null) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function formatDateTime(value: string) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : formatPreferredDateTime(date); +} + +function formatBytes(value?: number) { + if (value === undefined || value === null) return "—"; + if (value < 1024) return `${value} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let size = value / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && size >= 1024; index += 1) { + size /= 1024; + unit = units[index]; + } + return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${unit}`; +} + +function formatPlatform(value: string) { + switch (value) { + case "android-tv": + return "Android TV"; + case "ios": + return "iOS"; + case "tvos": + return "tvOS"; + default: + return "Android"; + } +} + +function formatToken(value: string) { + return value + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +}