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
+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 {