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:
co-authored by
Claude Fable 5
parent
4fa84a661a
commit
c5a5b02221
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user