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
+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 {
|
||||
|
||||
Reference in New Issue
Block a user