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:
8 files changed
+248
-10
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user