feat(android+web/diagnostics): on-device debug reporter + admin timeline (M9)
test-web / test (push) Failing after 11s
android / Build + lint + test (push) Failing after 1m19s

Android: a gated DiagnosticsReporter taps connectivity, server-health,
UPnP drops/player-state/route, power (Doze/battery-opt/screen), and
app fg/bg, plus a heartbeat snapshotting Sonos-vs-local position — the
locked-phone desync signal. Events buffer in a Room ring buffer
(deliberately NOT the MutationQueue: high-volume best-effort telemetry
that must survive the dead zone being debugged) and DiagnosticsUploader
drains them on a tick / health-recovery / sign-in.

Gating: the account flag (users.debug_mode_enabled) reaches the device
via a new /api/me refresh in AuthController; a per-device local OFF
switch lives in Settings. Reporter runs only when enabled && !optOut;
disabling drops the unsent buffer.

Web admin: /admin/diagnostics — pick account+device+kind+time-window,
see a chronological timeline, flip an account's debug mode remotely, and
Copy-JSON / Download-NDJSON the slice for analysis.

Room schema 6→7 (new diagnostic_events table + auth_session.diagnosticsOptOut;
pre-v1 destructive fallback).

Refs Scribe M9 (#119), tasks #1174 #1175 #1176 #1177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
This commit is contained in:
2026-06-29 18:56:36 -04:00
co-authored by Claude Opus 4.8
parent 4ed831d9c3
commit 4d42e298dd
20 changed files with 1039 additions and 3 deletions
+86
View File
@@ -391,6 +391,7 @@ export type AdminUser = {
display_name: string | null;
is_admin: boolean;
auto_approve_requests: boolean;
debug_mode_enabled: boolean;
created_at: string;
};
@@ -451,6 +452,12 @@ export async function updateUserAutoApprove(id: string, autoApprove: boolean): P
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
}
// Flip an account's diagnostics/debug-reporting opt-in (M9). Admin-set;
// the client obeys it (with a local per-device OFF switch).
export async function updateUserDebugMode(id: string, enabled: boolean): Promise<AdminUser> {
return api.put<AdminUser>(`/api/admin/users/${id}/debug-mode`, { enabled });
}
export function createAdminUsersQuery() {
return createQuery({
queryKey: qk.adminUsers(),
@@ -497,3 +504,82 @@ export function createSMTPConfigQuery() {
staleTime: 60_000
});
}
// Device diagnostics (M9) ---------------------------------------------------
// Coarse event category. The finer event sub-type lives inside `payload`.
export type DiagnosticKind =
| 'connectivity'
| 'upnp_sync'
| 'power'
| 'lifecycle'
| 'heartbeat'
| 'http';
export type AdminDiagnostic = {
id: string;
user_id: string;
username: string;
client_id: string;
app_version?: string;
os_version?: string;
kind: DiagnosticKind | string;
payload: Record<string, unknown>;
occurred_at: string;
received_at: string;
};
export type AdminDiagnosticDevice = {
client_id: string;
user_id: string;
username: string;
app_version: string;
os_version: string;
last_seen: string;
event_count: number;
};
export type DiagnosticsFilter = {
userId?: string;
clientId?: string;
kind?: string;
from?: string; // RFC3339
to?: string; // RFC3339
limit?: number;
};
export async function listAdminDiagnostics(f: DiagnosticsFilter): Promise<AdminDiagnostic[]> {
const params = new URLSearchParams();
if (f.userId) params.set('user_id', f.userId);
if (f.clientId) params.set('client_id', f.clientId);
if (f.kind) params.set('kind', f.kind);
if (f.from) params.set('from', f.from);
if (f.to) params.set('to', f.to);
if (f.limit !== undefined) params.set('limit', String(f.limit));
const qs = params.toString();
return api.get<AdminDiagnostic[]>(qs ? `/api/admin/diagnostics?${qs}` : '/api/admin/diagnostics');
}
export async function listDiagnosticDevices(userId?: string): Promise<AdminDiagnosticDevice[]> {
const qs = userId ? `?user_id=${userId}` : '';
return api.get<AdminDiagnosticDevice[]>(`/api/admin/diagnostics/devices${qs}`);
}
export function createAdminDiagnosticsQuery(f: DiagnosticsFilter) {
return createQuery({
queryKey: qk.adminDiagnostics(f as Record<string, string | number | undefined>),
queryFn: () => listAdminDiagnostics(f),
// The operator enables debug then watches events stream in; a short
// poll keeps the timeline live without manual refresh.
refetchInterval: 10_000,
staleTime: 5_000
});
}
export function createDiagnosticDevicesQuery(userId?: string) {
return createQuery({
queryKey: qk.adminDiagnosticDevices(userId),
queryFn: () => listDiagnosticDevices(userId),
staleTime: 15_000
});
}
+4
View File
@@ -50,6 +50,10 @@ export const qk = {
coverProviders: () => ['coverProviders'] as const,
adminUsers: () => ['adminUsers'] as const,
adminInvites: () => ['adminInvites'] as const,
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
['adminDiagnostics', f] as const,
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,