feat(logs): opt-out toggle to bundle diagnostics into terminal paste

Settings > Developer Mode > View Logs now has a toggle (default ON,
persisted to localStorage as qbz-paste-bundle-diagnostics) that
prepends the full System Diagnostics snapshot plus a 10-second cast
scan to the terminal paste upload, so bug reporters share one link
instead of two. Console-tab uploads are unaffected.

- New service module diagnosticsSnapshot.ts centralises the snapshot
  payload (runtime + system + playback + qconnect + optional cast
  scan) so the panel and the paste flow don't drift apart.
- LogsModal shows "Collecting diagnostics..." while the scan is
  running, then "Uploading..." once it hands off to the paste server.

Also revert the auto-run cast scan on the diagnostics panel: bundling
into the paste covers the "one-shot bug report" use case, and the
panel opens instantly again.

cc
This commit is contained in:
vicrodh committed 2026-04-16 11:20:50 -06:00
1 parent d5e3fff45c
commit 321c87f353
8 files changed
+248 -10

No files matched your search

+2 -8
View File
@@ -380,14 +380,8 @@
function togglePanel() {
panelOpen = !panelOpen;
if (panelOpen) {
if (!diagnostics) loadDiagnostics();
// Auto-run a cast scan the first time the panel is opened. Don't
// block the diagnostics load on it — scan runs concurrently and
// fills its own section when done.
if (!castScan && !castScanning) {
void runCastScan();
}
if (panelOpen && !diagnostics) {
loadDiagnostics();
}
}
+76 -2
View File
@@ -3,9 +3,11 @@
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
import { t } from '$lib/i18n';
import Modal from './Modal.svelte';
import Toggle from './Toggle.svelte';
import { getConsoleLogsAsText } from '$lib/stores/consoleLogStore';
import { showToast } from '$lib/stores/toastStore';
import { LoaderCircle, Copy, Check } from 'lucide-svelte';
import { collectDiagnosticsText } from '$lib/services/diagnosticsSnapshot';
interface Props {
isOpen: boolean;
@@ -14,6 +16,22 @@
let { isOpen, onClose }: Props = $props();
// Opt-out toggle: when ON (default) the Terminal paste upload bundles the
// System Diagnostics snapshot + a 10s Chromecast/DLNA scan into the same
// paste, so bug reporters share ONE link instead of two.
// Persisted in localStorage.
const BUNDLE_KEY = 'qbz-paste-bundle-diagnostics';
let bundleDiagnostics = $state(true);
try {
const saved = localStorage.getItem(BUNDLE_KEY);
if (saved !== null) bundleDiagnostics = saved === 'true';
} catch { /* ignore */ }
function setBundleDiagnostics(value: boolean) {
bundleDiagnostics = value;
try { localStorage.setItem(BUNDLE_KEY, String(value)); } catch { /* ignore */ }
}
let activeTab = $state<'terminal' | 'console'>('terminal');
let terminalLogs = $state('');
let consoleLogs = $state('');
@@ -24,6 +42,7 @@
let consoleUrl = $state('');
let copiedTerminal = $state(false);
let copiedConsole = $state(false);
let bundleStage = $state<'idle' | 'collecting' | 'uploading'>('idle');
async function loadLogs() {
isLoading = true;
@@ -44,11 +63,28 @@
});
async function uploadTab(tab: 'terminal' | 'console') {
const content = tab === 'terminal' ? terminalLogs : consoleLogs;
let content = tab === 'terminal' ? terminalLogs : consoleLogs;
if (tab === 'terminal') isUploadingTerminal = true;
else isUploadingConsole = true;
try {
// Bundle the diagnostics snapshot + a 10s cast scan into the terminal
// paste when the toggle is on. Console pastes stay lean.
if (tab === 'terminal' && bundleDiagnostics) {
bundleStage = 'collecting';
try {
const diag = await collectDiagnosticsText({ includeCastScan: true, castScanMs: 10000 });
content = [
'===== QBZ DIAGNOSTICS =====',
diag,
'===== TERMINAL LOG =====',
content,
].join('\n\n');
} catch (e) {
console.warn('[LogsModal] Failed to collect diagnostics for bundle:', e);
}
}
bundleStage = 'uploading';
const url: string = await invoke('v2_upload_logs_to_paste', { content });
if (tab === 'terminal') terminalUrl = url;
else consoleUrl = url;
@@ -59,6 +95,7 @@
} finally {
if (tab === 'terminal') isUploadingTerminal = false;
else isUploadingConsole = false;
bundleStage = 'idle';
}
}
@@ -106,6 +143,18 @@
{#snippet footer()}
<div class="footer-content">
<div class="footer-left">
{#if activeTab === 'terminal'}
<div class="bundle-row">
<Toggle
enabled={bundleDiagnostics}
onchange={(v) => setBundleDiagnostics(v)}
/>
<div class="bundle-text">
<span class="bundle-label">{$t('settings.developer.bundleDiagnostics')}</span>
<small class="bundle-note">{$t('settings.developer.bundleDiagnosticsDesc')}</small>
</div>
</div>
{/if}
<button
class="upload-btn"
onclick={() => uploadTab(activeTab)}
@@ -113,7 +162,11 @@
>
{#if (activeTab === 'terminal' ? isUploadingTerminal : isUploadingConsole)}
<LoaderCircle size={14} class="spin" />
{$t('settings.developer.uploading')}
{#if activeTab === 'terminal' && bundleStage === 'collecting'}
{$t('settings.developer.collectingDiagnostics')}
{:else}
{$t('settings.developer.uploading')}
{/if}
{:else}
{$t('settings.developer.uploadTab', { values: { tab: activeTab === 'terminal' ? $t('settings.developer.tabTerminal') : $t('settings.developer.tabConsole') } })}
{/if}
@@ -223,6 +276,27 @@
gap: 8px;
}
.bundle-row {
display: flex;
align-items: center;
gap: 10px;
}
.bundle-text {
display: flex;
flex-direction: column;
}
.bundle-label {
font-size: 13px;
color: var(--text-primary);
}
.bundle-note {
font-size: 11px;
color: var(--text-muted);
}
.upload-btn {
display: flex;
align-items: center;
+3
View File
@@ -1355,6 +1355,9 @@
"uploadSuccess": "Log-URL in Zwischenablage kopiert",
"uploadError": "Fehler beim Hochladen der Logs",
"uploading": "Wird hochgeladen...",
"bundleDiagnostics": "Systemdiagnose einbeziehen",
"bundleDiagnosticsDesc": "Hängt einen Runtime-/System-Snapshot + einen 10s-Cast-Scan an das Terminal-Paste an, damit ein Link alles abdeckt.",
"collectingDiagnostics": "Diagnose wird erfasst...",
"bugReportHint": "Fehler melden? Laden Sie beide Tabs hoch und teilen Sie beide URLs — das hilft uns, Probleme schneller zu beheben. Danke!",
"verboseLogCapture": "Ausführliche Log-Erfassung",
"verboseLogCaptureDesc": "Alle Konsolen-Logs erfassen (log/info), nicht nur Fehler. Verwendet mehr Speicher.",
+3
View File
@@ -1361,6 +1361,9 @@
"uploadSuccess": "Log URL copied to clipboard",
"uploadError": "Failed to upload logs",
"uploading": "Uploading...",
"bundleDiagnostics": "Include system diagnostics",
"bundleDiagnosticsDesc": "Attach a runtime/system snapshot + a 10s cast scan to the terminal paste so one link covers everything.",
"collectingDiagnostics": "Collecting diagnostics...",
"bugReportHint": "Reporting a bug? Upload both tabs and share both URLs — it helps us fix things faster. Thanks!",
"verboseLogCapture": "Verbose Log Capture",
"verboseLogCaptureDesc": "Capture all console logs (log/info), not just errors. Uses more memory.",
+3
View File
@@ -1361,6 +1361,9 @@
"uploadSuccess": "URL del log copiada al portapapeles",
"uploadError": "Error al subir los logs",
"uploading": "Subiendo...",
"bundleDiagnostics": "Incluir diagnóstico del sistema",
"bundleDiagnosticsDesc": "Adjunta un snapshot de runtime/sistema + un escaneo de cast de 10s al paste del terminal para compartir todo con un solo enlace.",
"collectingDiagnostics": "Recopilando diagnóstico...",
"bugReportHint": "Si vas a reportar un bug, sube ambas pestanas y comparte las dos URLs — nos ayuda a resolver el problema mas rapido. Gracias!",
"verboseLogCapture": "Captura de logs detallada",
"verboseLogCaptureDesc": "Captura todos los logs de consola (log/info), no solo errores. Usa mas memoria.",
+3
View File
@@ -1355,6 +1355,9 @@
"uploadSuccess": "URL du log copiée dans le presse-papiers",
"uploadError": "Échec du téléversement des logs",
"uploading": "Téléversement...",
"bundleDiagnostics": "Inclure le diagnostic système",
"bundleDiagnosticsDesc": "Joint un instantané runtime/système + un scan cast de 10s au paste terminal pour tout partager en un seul lien.",
"collectingDiagnostics": "Collecte du diagnostic...",
"bugReportHint": "Vous signalez un bug ? Téléversez les deux onglets et partagez les deux URLs — cela nous aide a résoudre les problèmes plus vite. Merci !",
"verboseLogCapture": "Capture de logs détaillée",
"verboseLogCaptureDesc": "Capturer tous les logs de console (log/info), pas seulement les erreurs. Utilise plus de mémoire.",
+3
View File
@@ -1355,6 +1355,9 @@
"uploadSuccess": "URL do log copiada para a área de transferência",
"uploadError": "Falha ao enviar logs",
"uploading": "Enviando...",
"bundleDiagnostics": "Incluir diagnóstico do sistema",
"bundleDiagnosticsDesc": "Anexa um snapshot de runtime/sistema + um escaneamento cast de 10s ao paste do terminal para compartilhar tudo em um único link.",
"collectingDiagnostics": "Coletando diagnóstico...",
"bugReportHint": "Reportando um bug? Envie ambas as abas e compartilhe as duas URLs — isso nos ajuda a corrigir mais rápido. Obrigado!",
"verboseLogCapture": "Captura Detalhada de Logs",
"verboseLogCaptureDesc": "Capturar todos os logs do console (log/info), não apenas erros. Usa mais memória.",
+155
View File
@@ -0,0 +1,155 @@
/**
* diagnosticsSnapshot
*
* Collects the same payload the System Diagnostics panel exports, in a
* text-block format suited for attaching to a paste-site upload alongside
* the terminal log. Used by the "include diagnostics" toggle in the
* Developer Mode > View Logs modal so users can share one link instead
* of two.
*
* No sensitive data. Track fields exclude IDs/UUIDs; QConnect fields
* exclude endpoint URLs and renderer UUIDs; cast devices expose only
* friendly names (no hosts/ports).
*/
import { invoke } from '@tauri-apps/api/core';
import {
getCurrentTrack,
getIsPlaying,
getCurrentTime,
getDuration,
getVolume,
} from '$lib/stores/playerStore';
import type {
QconnectConnectionStatus,
QconnectSessionSnapshot,
} from '$lib/services/qconnectRuntime';
function redactIdLike(value: string | null | undefined): string | null {
if (!value) return null;
return value
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>')
.replace(/\b[0-9a-f]{32,}\b/gi, '<hex>');
}
function snapshotPlayback(): Record<string, unknown> {
const track = getCurrentTrack();
return {
isPlaying: getIsPlaying(),
volumePercent: Math.round(getVolume()),
positionSecs: Math.round(getCurrentTime()),
durationSecs: Math.round(getDuration()),
hasTrack: !!track,
trackTitle: track?.title ?? null,
trackArtist: track?.artist ?? null,
trackAlbum: track?.album ?? null,
trackQuality: track?.quality ?? null,
trackFormat: track?.format ?? null,
trackBitDepth: track?.bitDepth ?? null,
trackSamplingRate: track?.samplingRate ?? null,
trackIsLocal: track?.isLocal ?? null,
trackSource: track?.source ?? null,
};
}
async function snapshotQconnect(): Promise<Record<string, unknown>> {
try {
const status = await invoke<QconnectConnectionStatus>('v2_qconnect_get_status');
const session = await invoke<QconnectSessionSnapshot>('v2_qconnect_session_snapshot');
const activeId = session?.active_renderer_id ?? null;
const localId = session?.local_renderer_id ?? null;
const active = activeId != null
? session?.renderers?.find((r) => r.renderer_id === activeId)
: null;
let role: 'none' | 'controller' | 'local-renderer' | 'observer' = 'none';
if (activeId != null) {
role = activeId === localId ? 'local-renderer' : 'controller';
} else if (status.running || status.transport_connected) {
role = 'observer';
}
return {
running: !!status.running,
transport_connected: !!status.transport_connected,
hasEndpoint: !!status.endpoint_url,
lastError: status.last_error ? redactIdLike(String(status.last_error)) : null,
role,
activeRendererName: active?.friendly_name ?? null,
activeRendererBrand: active?.brand ?? null,
activeRendererModel: active?.model ?? null,
rendererCount: session?.renderers?.length ?? 0,
};
} catch {
return { unavailable: true };
}
}
/**
* Run a brief multicast scan and report the friendly-name list per protocol.
* Does NOT persist cast state; stops discovery before returning.
*/
async function scanCastDevices(durationMs: number): Promise<Record<string, unknown>> {
const started = Date.now();
try {
await Promise.allSettled([
invoke('v2_cast_start_discovery'),
invoke('v2_dlna_start_discovery'),
]);
} catch { /* ignore */ }
await new Promise((resolve) => setTimeout(resolve, durationMs));
let chromecast: { name?: string }[] = [];
let dlna: { name?: string }[] = [];
try {
const [cc, dl] = await Promise.allSettled([
invoke<{ name?: string }[]>('v2_cast_get_devices'),
invoke<{ name?: string }[]>('v2_dlna_get_devices'),
]);
if (cc.status === 'fulfilled') chromecast = cc.value ?? [];
if (dl.status === 'fulfilled') dlna = dl.value ?? [];
} catch { /* ignore */ }
try {
await Promise.allSettled([
invoke('v2_cast_stop_discovery'),
invoke('v2_dlna_stop_discovery'),
]);
} catch { /* ignore */ }
return {
chromecastCount: chromecast.length,
dlnaCount: dlna.length,
chromecastDevices: chromecast.map((d) => d.name ?? '<unnamed>'),
dlnaDevices: dlna.map((d) => d.name ?? '<unnamed>'),
durationMs: Date.now() - started,
};
}
export interface CollectOptions {
includeCastScan?: boolean;
castScanMs?: number;
}
/**
* Collect all diagnostic data and return it as a pretty-printed JSON block
* suitable for prepending to a paste upload.
*/
export async function collectDiagnosticsText(opts: CollectOptions = {}): Promise<string> {
const castPromise = opts.includeCastScan
? scanCastDevices(opts.castScanMs ?? 10000)
: Promise.resolve(null);
const [runtime, system, qconnect, cast] = await Promise.all([
invoke<unknown>('v2_get_runtime_diagnostics').catch((e) => ({ error: String(e) })),
invoke<unknown>('v2_get_system_info').catch((e) => ({ error: String(e) })),
snapshotQconnect(),
castPromise,
]);
const payload = {
exportedAt: new Date().toISOString(),
runtime,
system,
playback: snapshotPlayback(),
qconnect,
castScan: cast,
};
return JSON.stringify(payload, null, 2);
}