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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XppCCycoaskCsW7ja1fZct
This commit is contained in:
Quick104
2026-07-20 13:53:27 -04:00
co-authored by Claude Fable 5
parent 4fa84a661a
commit c5a5b02221
12 changed files with 1093 additions and 52 deletions
+2
View File
@@ -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() {
<Route index element={<AdminDashboard />} />
<Route path="activity" element={<AdminActivity />} />
<Route path="logs" element={<AdminLogs />} />
<Route path="diagnostics" element={<AdminDiagnostics />} />
<Route path="libraries" element={<AdminLibraries />} />
<Route path="maintenance" element={<AdminMaintenance />} />
<Route path="collections" element={<AdminCollections />} />
+20 -52
View File
@@ -341,6 +341,21 @@ export async function restoreUserSession<TUser>({
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
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<Response> {
const headers = buildApiHeaders(options);
let res = await fetch(`/api/v1${path}`, { ...options, headers });
@@ -354,8 +369,8 @@ export async function api<T>(path: string, options: RequestInit = {}): Promise<T
}
const refreshed = await refreshPromise;
if (refreshed) {
headers["Authorization"] = `Bearer ${accessToken}`;
res = await fetch(`/api/v1${path}`, { ...options, headers });
const refreshedHeaders = buildApiHeaders(options);
res = await fetch(`/api/v1${path}`, { ...options, headers: refreshedHeaders });
}
}
@@ -367,16 +382,7 @@ export async function api<T>(path: string, options: RequestInit = {}): Promise<T
}
throw apiClientErrorFrom(res.status, parsed);
}
// 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;
return res;
}
function buildApiHeaders(options: RequestInit = {}): Record<string, string> {
@@ -421,26 +427,7 @@ export async function apiDownload(
filename: string,
options: RequestInit = {},
): Promise<void> {
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<Blob> {
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
+111
View File
@@ -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 {
@@ -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();
+7
View File
@@ -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();
@@ -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();
});
});
+122
View File
@@ -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<DiagnosticStatus>("/diagnostics/status"),
staleTime: 30_000,
});
}
export function useDiagnosticReports(params: AdminDiagnosticsQuery) {
const query = toQueryString(params);
return useQuery({
queryKey: adminKeys.diagnosticReports({ ...params }),
queryFn: () =>
api<DiagnosticReportListResponse>(`/admin/diagnostics/reports${query ? `?${query}` : ""}`),
staleTime: 5_000,
});
}
export function useDiagnosticReport(id?: string) {
return useQuery({
queryKey: adminKeys.diagnosticReport(id),
queryFn: () => api<DiagnosticReport>(`/admin/diagnostics/reports/${encodeURIComponent(id!)}`),
enabled: Boolean(id),
});
}
export function useDeleteDiagnosticReport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
api<void>(`/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<DiagnosticDownloadResponse>;
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;
}
}
+4
View File
@@ -392,6 +392,10 @@ export const adminKeys = {
ipUsers: (ip: string, days?: number) => ["admin", "ips", ip, days] as const,
operationalLogs: (params: Record<string, unknown>) => ["admin", "logs", "app", params] as const,
auditLogs: (params: Record<string, unknown>) => ["admin", "logs", "audit", params] as const,
diagnosticStatus: () => ["diagnostics", "status"] as const,
diagnosticReports: (params: Record<string, unknown>) =>
["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,
+8
View File
@@ -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",
},
],
},
{
+1
View File
@@ -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");
});
+1
View File
@@ -44,6 +44,7 @@ const ADMIN_TITLES: Record<string, string> = {
"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",
+711
View File
@@ -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<FilterDraft>(() => draftFromSearchParams(searchParams));
const [pagination, setPagination] = useState<{
filterKey: string;
cursor?: string;
cursorStack: string[];
}>(() => ({ filterKey: appliedFilterKey, cursorStack: [] }));
const [selectedID, setSelectedID] = useState<string>();
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 extends keyof FilterDraft>(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 (
<div className="space-y-6">
<div className="page-header gap-5">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Client Diagnostics</h1>
<p className="page-subtitle text-sm sm:text-base">
Review client crash reports, device context, and correlated playback sessions.
</p>
</div>
<div className="text-right">
<div className="text-muted-foreground text-xs font-medium tracking-[0.2em] uppercase">
Retention
</div>
<div className="text-sm">
{status.data ? `${status.data.retention_days} days` : "Loading..."}
</div>
</div>
</div>
{status.data?.status !== undefined && status.data.status !== "available" && (
<FeatureStatusBanner status={status.data.status} />
)}
<form
className="surface-panel-subtle grid gap-3 rounded-2xl p-4 md:grid-cols-2 xl:grid-cols-[110px_150px_170px_1fr_1fr_180px_auto] xl:items-end"
onSubmit={applyFilters}
>
<div className="space-y-2">
<Label htmlFor="diagnostics-user">User</Label>
<Input
id="diagnostics-user"
type="number"
min={1}
step={1}
inputMode="numeric"
placeholder="42"
value={filters.userID}
onChange={(event) => setFilter("userID", event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-platform">Platform</Label>
<Select value={filters.platform} onValueChange={(value) => setFilter("platform", value)}>
<SelectTrigger id="diagnostics-platform" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All platforms</SelectItem>
<SelectItem value="android">Android</SelectItem>
<SelectItem value="android-tv">Android TV</SelectItem>
<SelectItem value="ios">iOS</SelectItem>
<SelectItem value="tvos">tvOS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-type">Report type</Label>
<Select
value={filters.reportType}
onValueChange={(value) => setFilter("reportType", value)}
>
<SelectTrigger id="diagnostics-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="crash">Crash</SelectItem>
<SelectItem value="anr">ANR</SelectItem>
<SelectItem value="native_crash">Native crash</SelectItem>
<SelectItem value="hang">Hang</SelectItem>
<SelectItem value="abnormal_exit">Abnormal exit</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-from">From</Label>
<Input
id="diagnostics-from"
type="datetime-local"
value={filters.from}
onChange={(event) => setFilter("from", event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-to">To</Label>
<Input
id="diagnostics-to"
type="datetime-local"
value={filters.to}
onChange={(event) => setFilter("to", event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-short-id">Short ID</Label>
<Input
id="diagnostics-short-id"
className="font-mono text-xs uppercase"
placeholder="Exact ID"
value={filters.shortID}
onChange={(event) => setFilter("shortID", event.target.value)}
/>
</div>
<div className="flex gap-2 md:col-span-2 xl:col-span-1">
<Button type="submit">Apply</Button>
<Button
type="button"
variant="outline"
onClick={clearFilters}
disabled={!hasAppliedFilters}
aria-label="Clear diagnostic report filters"
>
<FilterX />
Clear
</Button>
</div>
</form>
<div className="surface-panel overflow-hidden rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Received</TableHead>
<TableHead>Short ID</TableHead>
<TableHead>User</TableHead>
<TableHead>Platform</TableHead>
<TableHead>Type</TableHead>
<TableHead>Summary</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Bundle</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.isLoading && (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground py-10 text-center">
Loading diagnostic reports...
</TableCell>
</TableRow>
)}
{reports.isError && (
<TableRow>
<TableCell colSpan={8} className="text-destructive py-10 text-center">
{reports.error instanceof Error
? reports.error.message
: "Failed to load diagnostic reports."}
</TableCell>
</TableRow>
)}
{!reports.isLoading && !reports.isError && reports.data?.reports.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="py-14 text-center">
<EmptyState filtered={hasAppliedFilters} />
</TableCell>
</TableRow>
)}
{reports.data?.reports.map((report) => (
<DiagnosticReportRow key={report.id} report={report} onSelect={setSelectedID} />
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
disabled={activeCursorStack.length === 0 || reports.isFetching}
onClick={goPrevious}
>
Previous
</Button>
<div className="text-muted-foreground text-xs">
{reports.isFetching
? "Refreshing..."
: reports.data?.next_cursor
? `Page ${activeCursorStack.length + 1} · More reports`
: reports.data?.reports.length
? `Page ${activeCursorStack.length + 1}`
: ""}
</div>
<Button
type="button"
variant="outline"
disabled={!reports.data?.next_cursor || reports.isFetching}
onClick={goNext}
>
Next
</Button>
</div>
<Sheet
open={selectedID !== undefined}
onOpenChange={(open) => !open && setSelectedID(undefined)}
>
<SheetContent className="w-full sm:max-w-3xl">
<SheetHeader>
<SheetTitle className="font-mono">
{selectedReport.data?.short_id ?? "Report"}
</SheetTitle>
<SheetDescription>
{selectedReport.data
? `${formatToken(selectedReport.data.report_type)} · ${formatDateTime(selectedReport.data.received_at)}`
: "Client diagnostic report details"}
</SheetDescription>
</SheetHeader>
<div className="overflow-y-auto px-4 pb-8">
{selectedReport.isLoading && (
<p className="text-muted-foreground py-8 text-sm">Loading report details...</p>
)}
{selectedReport.isError && (
<p className="text-destructive py-8 text-sm">
{selectedReport.error instanceof Error
? selectedReport.error.message
: "Failed to load report details."}
</p>
)}
{selectedReport.data && (
<DiagnosticReportDetail
report={selectedReport.data}
downloading={downloading}
deleting={deleteReport.isPending}
onDownload={() => void handleDownload(selectedReport.data!)}
onDelete={() => setDeleteConfirmationOpen(true)}
/>
)}
</div>
</SheetContent>
</Sheet>
<ConfirmDialog
open={deleteConfirmationOpen}
onOpenChange={setDeleteConfirmationOpen}
title="Delete diagnostic report?"
description={`This permanently deletes report ${selectedReport.data?.short_id ?? ""} and its uploaded bundle.`}
confirmLabel="Delete report"
variant="destructive"
isPending={deleteReport.isPending}
onConfirm={() => {
if (!selectedID) return;
deleteReport.mutate(selectedID, {
onSuccess: () => {
setDeleteConfirmationOpen(false);
setSelectedID(undefined);
},
});
}}
/>
</div>
);
}
function DiagnosticReportRow({
report,
onSelect,
}: {
report: DiagnosticReport;
onSelect: (id: string) => void;
}) {
function handleKeyDown(event: KeyboardEvent<HTMLTableRowElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(report.id);
}
}
return (
<TableRow
className="cursor-pointer"
tabIndex={0}
onClick={() => onSelect(report.id)}
onKeyDown={handleKeyDown}
aria-label={`Open diagnostic report ${report.short_id}`}
>
<TableCell className="whitespace-nowrap">{formatDateTime(report.received_at)}</TableCell>
<TableCell className="font-mono text-xs font-medium">{report.short_id}</TableCell>
<TableCell>
<div>#{report.user_id}</div>
{report.profile_id && (
<div className="text-muted-foreground max-w-[120px] truncate text-xs">
{report.profile_id}
</div>
)}
</TableCell>
<TableCell>
<div>{formatPlatform(report.platform)}</div>
<div className="text-muted-foreground text-xs">v{report.app_version}</div>
</TableCell>
<TableCell>
<Badge variant="outline">{formatToken(report.report_type)}</Badge>
</TableCell>
<TableCell>
<div className="max-w-[360px] truncate" title={report.crash_summary}>
{report.crash_summary || "—"}
</div>
</TableCell>
<TableCell>
<StateBadge state={report.state} />
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatBytes(report.blob_bytes)}
</TableCell>
</TableRow>
);
}
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 (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
onClick={onDownload}
disabled={report.state !== "ready" || downloading}
>
<Download />
{downloading ? "Preparing..." : "Download bundle"}
</Button>
<Button type="button" variant="destructive" onClick={onDelete} disabled={deleting}>
<Trash2 />
Delete
</Button>
{report.state !== "ready" && (
<span className="text-muted-foreground text-xs">
Only ready reports can be downloaded.
</span>
)}
</div>
<div className="surface-panel-subtle grid gap-4 rounded-xl p-4 sm:grid-cols-3">
<DetailField
label="Device"
value={[device.manufacturer, device.model].filter(Boolean).join(" ") || "—"}
/>
<DetailField label="OS" value={device.os || report.manifest.report.os_version || "—"} />
<DetailField
label="App"
value={`${report.app_version}${appBuild ? ` (${appBuild})` : ""}`}
/>
</div>
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<DetailField label="State" value={formatToken(report.state)} />
<DetailField label="User" value={`#${report.user_id}`} />
<DetailField label="Profile" value={report.profile_id || "—"} mono />
<DetailField label="Captured" value={formatDateTime(report.captured_at)} />
<DetailField label="Received" value={formatDateTime(report.received_at)} />
<DetailField label="Form factor" value={formatToken(device.form_factor)} />
<DetailField label="Compressed" value={formatBytes(report.blob_bytes)} />
<DetailField label="Uncompressed" value={formatBytes(report.uncompressed_bytes)} />
<DetailField label="Report ID" value={report.id} mono />
</div>
{report.crash_summary && (
<div>
<h3 className="mb-2 text-sm font-semibold">Crash summary</h3>
<p className="bg-muted rounded-lg p-3 text-sm leading-6 whitespace-pre-wrap">
{report.crash_summary}
</p>
</div>
)}
<div>
<h3 className="mb-2 text-sm font-semibold">Playback sessions</h3>
{report.playback_session_ids.length > 0 ? (
<div className="flex flex-col items-start gap-2">
{report.playback_session_ids.map((sessionID) => (
<Link
key={sessionID}
className="text-primary inline-flex items-center gap-1.5 font-mono text-xs hover:underline"
to={`/admin/logs?playback_session_id=${encodeURIComponent(sessionID)}&focus=playback`}
>
{sessionID}
<ExternalLink className="size-3" aria-hidden="true" />
</Link>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">No playback sessions were attached.</p>
)}
</div>
<details className="group border-border overflow-hidden rounded-xl border">
<summary className="bg-muted/40 hover:bg-muted/70 cursor-pointer px-4 py-3 text-sm font-semibold transition-colors">
Full manifest JSON
</summary>
<pre className="bg-muted/20 max-h-[520px] overflow-auto border-t p-4 font-mono text-xs leading-5 break-all whitespace-pre-wrap">
{JSON.stringify(report.manifest, null, 2)}
</pre>
</details>
</div>
);
}
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 (
<div className="border-border bg-muted/30 text-muted-foreground flex items-start gap-3 rounded-xl border px-4 py-3 text-sm">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{message}</p>
</div>
);
}
function EmptyState({ filtered }: { filtered: boolean }) {
return (
<div className="mx-auto flex max-w-md flex-col items-center gap-2">
<div className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-xl">
<Bug className="size-5" aria-hidden="true" />
</div>
<h2 className="font-semibold">No diagnostic reports</h2>
<p className="text-muted-foreground text-sm">
{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."}
</p>
</div>
);
}
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 (
<Badge variant="outline" className={className}>
{formatToken(state)}
</Badge>
);
}
function DetailField({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="min-w-0">
<div className="text-muted-foreground mb-1 text-xs">{label}</div>
<div className={mono ? "font-mono text-xs break-all" : "text-sm break-words"}>{value}</div>
</div>
);
}
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(" ");
}