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
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,
+1
View File
@@ -9,6 +9,7 @@
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/users', label: 'Users' }
];
@@ -0,0 +1,302 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { Copy, Download, Activity } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createAdminDiagnosticsQuery,
createDiagnosticDevicesQuery,
createAdminUsersQuery,
updateUserDebugMode,
type AdminDiagnostic,
type DiagnosticsFilter
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
// Device diagnostics timeline (M9). The operator enables debug-mode on
// an account (remotely, here), then watches the account's device(s)
// stream connectivity / UPnP-sync / power events. The point of the page
// is the EXPORT: filter to the window in question and copy/download the
// slice as JSON to hand off for analysis.
const client = useQueryClient();
const KINDS = ['connectivity', 'upnp_sync', 'power', 'lifecycle', 'heartbeat', 'http'] as const;
function kindLabel(k: string): string {
switch (k) {
case 'connectivity': return 'Connectivity';
case 'upnp_sync': return 'UPnP sync';
case 'power': return 'Power';
case 'lifecycle': return 'Lifecycle';
case 'heartbeat': return 'Heartbeat';
case 'http': return 'HTTP';
default: return k;
}
}
// Filter state.
let accountId = $state('');
let clientId = $state('');
let kind = $state('');
let fromLocal = $state('');
let toLocal = $state('');
let limit = $state(500);
// datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts.
function toRfc(v: string): string | undefined {
if (!v) return undefined;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
const filter = $derived<DiagnosticsFilter>({
userId: accountId || undefined,
clientId: clientId || undefined,
kind: kind || undefined,
from: toRfc(fromLocal),
to: toRfc(toLocal),
limit
});
const usersStore = $derived(createAdminUsersQuery());
const usersQuery = $derived($usersStore);
const users = $derived(usersQuery.data ?? []);
const selectedUser = $derived(users.find((u) => u.id === accountId));
const devicesStore = $derived(createDiagnosticDevicesQuery(accountId || undefined));
const devicesQuery = $derived($devicesStore);
const devices = $derived(devicesQuery.data ?? []);
const diagStore = $derived(createAdminDiagnosticsQuery(filter));
const diagQuery = $derived($diagStore);
// API returns newest-first; reverse to chronological for a readable timeline.
const rows = $derived([...((diagQuery.data ?? []) as AdminDiagnostic[])].reverse());
function fmtTime(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
function payloadPreview(p: Record<string, unknown>): string {
const s = JSON.stringify(p);
return s.length > 120 ? s.slice(0, 120) + '…' : s;
}
// Toggle the selected account's debug-mode remotely.
let toggling = $state(false);
async function toggleDebug() {
if (!selectedUser) return;
toggling = true;
try {
await updateUserDebugMode(selectedUser.id, !selectedUser.debug_mode_enabled);
await client.invalidateQueries({ queryKey: qk.adminUsers() });
pushToast(
`Debug mode ${selectedUser.debug_mode_enabled ? 'disabled' : 'enabled'} for ${selectedUser.username}`
);
} catch (e: unknown) {
pushToast(`Toggle failed: ${errMessage(e)}`, 'error');
} finally {
toggling = false;
}
}
// Export the current (chronological) slice as an analysis-ready object.
function exportObject() {
return {
account: selectedUser?.username ?? accountId ?? 'all',
device: clientId || 'all',
kind: kind || 'all',
from: toRfc(fromLocal) ?? null,
to: toRfc(toLocal) ?? null,
count: rows.length,
events: rows.map((r) => ({
occurred_at: r.occurred_at,
received_at: r.received_at,
kind: r.kind,
client_id: r.client_id,
app_version: r.app_version,
os_version: r.os_version,
payload: r.payload
}))
};
}
async function onCopyJson() {
try {
await navigator.clipboard.writeText(JSON.stringify(exportObject(), null, 2));
pushToast(`Copied ${rows.length} events to clipboard`);
} catch (e: unknown) {
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
}
}
function onDownloadNdjson() {
const lines = exportObject().events.map((e) => JSON.stringify(e)).join('\n');
const blob = new Blob([lines], { type: 'application/x-ndjson' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const who = selectedUser?.username ?? 'all';
a.href = url;
a.download = `diagnostics-${who}-${Date.now()}.ndjson`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<svelte:head><title>{pageTitle('Admin · Diagnostics')}</title></svelte:head>
<div class="space-y-4">
<header>
<h1 class="font-display text-2xl font-medium text-text-primary">Device diagnostics</h1>
<p class="text-sm text-text-secondary">
Enable debug mode on an account to have its device(s) stream a timeseries
of connectivity, UPnP-sync, and power/Doze events here. Filter to the
window you care about, then copy or download the slice for analysis.
</p>
</header>
<!-- Account debug-mode control -->
<section class="rounded-md border border-border p-3">
<div class="flex flex-wrap items-end gap-3">
<label class="block text-xs text-text-secondary">
Account
<select
bind:value={accountId}
class="mt-1 block w-56 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="">All accounts</option>
{#each users as u (u.id)}
<option value={u.id}>{u.username}{u.debug_mode_enabled ? ' · debug on' : ''}</option>
{/each}
</select>
</label>
{#if selectedUser}
<button
type="button"
onclick={toggleDebug}
disabled={toggling}
class="rounded px-3 py-2 text-sm text-action-fg hover:opacity-90 disabled:opacity-50
{selectedUser.debug_mode_enabled ? 'bg-action-destructive' : 'bg-action-primary'}"
>
{selectedUser.debug_mode_enabled ? 'Disable debug mode' : 'Enable debug mode'}
</button>
<span class="inline-flex items-center gap-1 text-xs text-text-muted">
<Activity size={14} />
{selectedUser.debug_mode_enabled ? 'Reporting active' : 'Reporting off'}
</span>
{/if}
</div>
</section>
<!-- Filters -->
<section class="flex flex-wrap items-end gap-3">
<label class="block text-xs text-text-secondary">
Device
<select
bind:value={clientId}
class="mt-1 block w-48 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="">All devices</option>
{#each devices as d (d.client_id)}
<option value={d.client_id}>{d.client_id.slice(0, 12)} · {d.event_count}</option>
{/each}
</select>
</label>
<label class="block text-xs text-text-secondary">
Kind
<select
bind:value={kind}
class="mt-1 block w-40 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
>
<option value="">All kinds</option>
{#each KINDS as k (k)}
<option value={k}>{kindLabel(k)}</option>
{/each}
</select>
</label>
<label class="block text-xs text-text-secondary">
From
<input
type="datetime-local"
bind:value={fromLocal}
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="block text-xs text-text-secondary">
To
<input
type="datetime-local"
bind:value={toLocal}
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="block text-xs text-text-secondary">
Limit
<input
type="number"
min="1"
max="5000"
bind:value={limit}
class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<div class="ml-auto flex gap-2">
<button
type="button"
onclick={onCopyJson}
disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
<Copy size={15} /> Copy JSON
</button>
<button
type="button"
onclick={onDownloadNdjson}
disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
<Download size={15} /> Download
</button>
</div>
</section>
<!-- Timeline -->
{#if diagQuery.isError}
<p class="text-error">Couldn't load: {errMessage(diagQuery.error)}</p>
{:else if diagQuery.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0}
<p class="text-text-secondary">
No events for this filter. Enable debug mode on an account and have the
device reproduce the issue — events appear here within a minute.
</p>
{:else}
<div class="flex items-center justify-between">
<span class="text-xs text-text-muted">{rows.length} events (oldest first)</span>
</div>
<ul class="divide-y divide-border rounded-md border border-border font-mono text-xs">
{#each rows as r (r.id)}
<li class="flex items-start gap-3 px-3 py-2">
<span class="w-44 shrink-0 text-text-muted" title={`received ${fmtTime(r.received_at)}`}>
{fmtTime(r.occurred_at)}
</span>
<span
class="w-24 shrink-0 rounded bg-surface-hover px-1.5 py-0.5 text-center text-[10px] uppercase tracking-wide text-text-secondary"
>
{kindLabel(r.kind)}
</span>
<details class="min-w-0 flex-1">
<summary class="cursor-pointer truncate text-text-primary">
{payloadPreview(r.payload)}
</summary>
<pre class="mt-1 overflow-x-auto whitespace-pre-wrap text-text-secondary">{JSON.stringify(r.payload, null, 2)}</pre>
</details>
</li>
{/each}
</ul>
{/if}
</div>