4d42e298dd
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
303 lines
10 KiB
Svelte
303 lines
10 KiB
Svelte
<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>
|