Files
psysonic/src/utils/orbitDiag.ts
T
Frank Stellmacher a702a5dd5b feat(orbit): in-app diagnostics popover with copyable event log (#524)
* feat(orbit): in-app diagnostics popover with copyable event log

Multiple users on Discord report Orbit guests stopping after the first
song with no errors anywhere — Settings → Debug → Export Logs is too
buried for non-technical reporters, and the relevant code branches
have no logging at all (silent fail). This adds a one-click "Copy log"
path right inside the Orbit session bar.

The new Activity-icon button next to Help opens a popover with:

- Live mini-display: role, host vs. guest track id + position, drift,
  age of the host's last state write — all updating once a second.
- Scrolling event log textarea fed by an in-memory ring (200 events).
- Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and
  drops them on the clipboard — paste straight into a Discord report.

Instrumentation lands at the previously-silent decision points:

- Guest pull tick: full snapshot of host vs. guest state on every read.
- Each branch of the divergence detection in `useOrbitGuest.ts` logs
  which path it took and why (initial / track-change-followed /
  track-change-diverged / play-pause-flip), making the
  "stuck after first song" symptom diagnosable from the buffer alone.
- Host pushes log track id, isPlaying, queue length, guest count.

Events are also bridged to the existing `frontend_debug_log` Tauri
command when Settings → Logging is on Debug, so power users still get
the same data in `psysonic-logs-*.log` for offline triage.

i18n: full `orbit.diag.*` namespace in all eight locales. EN + DE are
native; ES / FR / NB / NL / RU / ZH are first-pass and may want a
polish from native speakers later.

* docs(changelog): add orbit diagnostics popover entry
2026-05-09 21:49:17 +02:00

97 lines
3.5 KiB
TypeScript

/**
* Orbit — diagnostics ring buffer.
*
* In-memory log of recent Orbit events for users who can't reach DevTools or
* Settings → Debug → Export. The Diagnostics popover in the Orbit bar reads
* this buffer live and offers a one-click clipboard copy so a Discord user
* can paste the buffer straight into a bug-report channel.
*
* Events are also bridged to `frontend_debug_log` when the user has Debug
* logging on in Settings, so the same data lands in `psysonic-logs-*.log`
* for power users who want a persistent file.
*/
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
/** Hard cap so a long session doesn't spend memory unbounded. ~200 entries. */
const MAX_EVENTS = 200;
export interface OrbitDiagEvent {
/** Wall-clock ms when the event was pushed. */
ts: number;
/** Short tag, e.g. `pull`, `divergence`, `audio:ended`, `host:state`. */
scope: string;
/** Already-stringified message body. Whitespace + line breaks preserved. */
message: string;
}
const buffer: OrbitDiagEvent[] = [];
const subscribers = new Set<() => void>();
/** Frozen snapshot for `useSyncExternalStore`. Replaced on every mutation;
* identical between mutations so React doesn't keep re-rendering. */
let snapshot: readonly OrbitDiagEvent[] = [];
function notify() {
snapshot = buffer.slice();
for (const sub of subscribers) {
try { sub(); } catch { /* never let one bad listener kill the rest */ }
}
}
/**
* Append an event to the ring. Also fires `frontend_debug_log` so the same
* line shows up in the runtime log file when Debug mode is enabled.
*
* Safe to call from anywhere — never throws, never blocks.
*/
export function pushOrbitEvent(scope: string, message: string | Record<string, unknown>): void {
const text = typeof message === 'string' ? message : safeStringify(message);
const evt: OrbitDiagEvent = { ts: Date.now(), scope, message: text };
buffer.push(evt);
if (buffer.length > MAX_EVENTS) buffer.splice(0, buffer.length - MAX_EVENTS);
notify();
// Bridge to the existing Rust log buffer when Debug mode is on, so
// Settings → Debug → Export still works for the same data.
if (useAuthStore.getState().loggingMode === 'debug') {
void invoke('frontend_debug_log', {
scope: `orbit:${scope}`,
message: text,
}).catch(() => { /* best-effort */ });
}
}
function safeStringify(obj: Record<string, unknown>): string {
try { return JSON.stringify(obj); }
catch { return '[unserialisable]'; }
}
/** Snapshot of all currently-buffered events, oldest first.
* Returns the SAME reference between mutations so `useSyncExternalStore`
* can detect "nothing changed" and skip the render. */
export function getOrbitEvents(): readonly OrbitDiagEvent[] {
return snapshot;
}
/** Wipe the buffer. Used by the Clear button in the Diagnostics popover. */
export function clearOrbitEvents(): void {
buffer.length = 0;
notify();
}
/** Subscribe to buffer mutations. Returns the unsubscribe function. */
export function subscribeOrbitEvents(listener: () => void): () => void {
subscribers.add(listener);
return () => { subscribers.delete(listener); };
}
/** Format the buffer as a copy-pasteable plain-text block. */
export function formatOrbitEvents(events: readonly OrbitDiagEvent[]): string {
if (events.length === 0) return '';
const lines = events.map(e => {
const stamp = new Date(e.ts).toISOString();
return `[${stamp}] [${e.scope}] ${e.message}`;
});
return lines.join('\n');
}