feat(android+web/diagnostics): on-device debug reporter + admin timeline (M9)
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:
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user