feat(psylab): rename probe, Tuning tab, log tools, and safe log sanitization (#1027)

* feat(psylab): rename probe UI, add Tuning tab and log copy/export

PsyLab (Ctrl+Shift+D): cover backfill threads move to Tuning; logs gain
selectable text, toolbar copy/export, and a selection-only context menu.

* fix(logging): redact secrets and mask remote hosts in runtime logs

Sanitize lines at append time (buffer, CLI tail, export) and in PsyLab:
Subsonic/auth query params, bearer tokens, password fields, URL userinfo;
remote hostnames partially starred, LAN/localhost left readable.

* fix(logging): UTF-8-safe log sanitization — unbreak playback on em dash

Byte-indexed URL scanning panicked on multi-byte chars (e.g. "—" in stream
logs), killing tokio workers and aborting playback. Iterate by char boundary;
add infallible wrapper on the append hot path.

* docs(changelog): PsyLab UI and safe log sanitization (PR #1027)
This commit is contained in:
cucadmuh
2026-06-08 02:29:23 +03:00
committed by GitHub
parent 32832246c0
commit 086c7e43b4
16 changed files with 875 additions and 17 deletions
@@ -1,15 +1,16 @@
import { useState } from 'react';
import { Activity, ScrollText, SlidersHorizontal, X } from 'lucide-react';
import { Activity, ScrollText, SlidersHorizontal, Wrench, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import SidebarPerfProbeMonitorTab from './perfProbe/SidebarPerfProbeMonitorTab';
import SidebarPerfProbeTogglesTab from './perfProbe/SidebarPerfProbeTogglesTab';
import SidebarPerfProbeTuningTab from './perfProbe/SidebarPerfProbeTuningTab';
import SidebarPerfProbeLogsTab from './perfProbe/SidebarPerfProbeLogsTab';
import { resetPerfProbeFlags, type PerfProbeFlags } from '../../utils/perf/perfFlags';
import { clearPerfLiveOverlayPins } from '../../utils/perf/perfOverlayPins';
import { resetPerfOverlayAppearance } from '../../utils/perf/perfOverlayAppearance';
import { resetPerfOverlayMode } from '../../utils/perf/perfOverlayMode';
type TabId = 'monitor' | 'toggles' | 'logs';
type TabId = 'monitor' | 'toggles' | 'tuning' | 'logs';
interface Props {
open: boolean;
@@ -51,7 +52,7 @@ export default function SidebarPerfProbeModal({
onClick={() => onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="perf-probe-title"
aria-labelledby="psylab-title"
>
<div
className="modal-content sidebar-perf-modal"
@@ -62,13 +63,13 @@ export default function SidebarPerfProbeModal({
</button>
<header className="sidebar-perf-modal__header">
<h3 id="perf-probe-title" className="modal-title">Performance Probe</h3>
<h3 id="psylab-title" className="modal-title">PsyLab</h3>
<p className="sidebar-perf-modal__hint">
Live metrics with optional on-screen overlays, plus diagnostic disable toggles.
Live metrics with optional on-screen overlays, runtime tuning, and diagnostic disable toggles.
</p>
</header>
<div className="sidebar-perf-modal__tabs" role="tablist" aria-label="Performance probe sections">
<div className="sidebar-perf-modal__tabs" role="tablist" aria-label="PsyLab sections">
<button
type="button"
role="tab"
@@ -89,6 +90,16 @@ export default function SidebarPerfProbeModal({
<SlidersHorizontal size={15} />
Toggles
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'tuning'}
className={`sidebar-perf-modal__tab${tab === 'tuning' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('tuning')}
>
<Wrench size={15} />
Tuning
</button>
<button
type="button"
role="tab"
@@ -103,6 +114,7 @@ export default function SidebarPerfProbeModal({
<div className={`sidebar-perf-modal__body${tab === 'logs' ? ' sidebar-perf-modal__body--logs' : ''}`}>
{tab === 'monitor' && <SidebarPerfProbeMonitorTab />}
{tab === 'tuning' && <SidebarPerfProbeTuningTab />}
{tab === 'toggles' && (
<SidebarPerfProbeTogglesTab
perfFlags={perfFlags}
@@ -10,7 +10,7 @@ const COVER_THREADS_MIN = 1;
const COVER_THREADS_MAX = 16;
/**
* Perf-probe-only knob for cover backfill concurrency (download + encode pools
* PsyLab tuning knob for cover backfill concurrency (download + encode pools
* move together). Deliberately not surfaced in app Settings — it is a live
* diagnostics/experiment control. The value is process-local and resets to the
* backend default on app restart.
@@ -1,11 +1,27 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Pause, Play, Trash2 } from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { Copy, Download, Pause, Play, Trash2 } from 'lucide-react';
import { createPortal } from 'react-dom';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs';
import { getLoggingMode, tailRuntimeLogs, type RuntimeLogLine } from '../../../api/runtimeLogs';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../../../store/authStore';
import type { LoggingMode } from '../../../store/authStoreTypes';
import CustomSelect from '../../CustomSelect';
import { filterLogLines } from '../../../utils/perf/filterLogLines';
import { sanitizeLogLine } from '../../../utils/perf/sanitizeLogLine';
function formatLogLinesText(lines: RuntimeLogLine[]): string {
return lines.map(line => line.text).join('\n');
}
const POLL_MS = 750;
const BOTTOM_EPSILON = 24;
@@ -40,6 +56,10 @@ export default function SidebarPerfProbeLogsTab() {
const [lineCap, setLineCap] = useState(1000);
const [follow, setFollow] = useState(true);
const [overflowed, setOverflowed] = useState(false);
const [copyState, setCopyState] = useState<'idle' | 'ok' | 'error'>('idle');
const [exporting, setExporting] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const contextMenuRef = useRef<HTMLDivElement | null>(null);
const lastSeqRef = useRef<number | null>(null);
const pausedRef = useRef(paused);
@@ -76,7 +96,13 @@ export default function SidebarPerfProbeLogsTab() {
if (!cancelled && tail.lines.length > 0) {
lastSeqRef.current = tail.lastSeq;
setLines(prev => {
const next = [...prev, ...tail.lines];
const next = [
...prev,
...tail.lines.map(line => ({
...line,
text: sanitizeLogLine(line.text),
})),
];
// Only trim from the top while following; otherwise keep history
// under the reader's viewport up to the hard ceiling.
const cap = followRef.current ? lineCapRef.current : MAX_BUFFER;
@@ -156,6 +182,80 @@ export default function SidebarPerfProbeLogsTab() {
setOverflowed(false);
};
const selectedLogText = useCallback(() => {
const el = scrollRef.current;
const sel = window.getSelection();
if (!el || !sel || sel.isCollapsed || sel.rangeCount === 0) return '';
const range = sel.getRangeAt(0);
if (!el.contains(range.commonAncestorContainer)) return '';
return sel.toString();
}, []);
const copyText = async (text: string, feedback = true) => {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
if (feedback) setCopyState('ok');
return true;
} catch {
if (feedback) setCopyState('error');
return false;
} finally {
if (feedback) window.setTimeout(() => setCopyState('idle'), 1600);
}
};
const copyAllShown = () => copyText(formatLogLinesText(visible));
const copySelection = () => copyText(selectedLogText(), false);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
useEffect(() => {
if (!contextMenu) return;
const onDown = (e: MouseEvent) => {
if (contextMenuRef.current?.contains(e.target as Node)) return;
closeContextMenu();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [contextMenu, closeContextMenu]);
const onLogContextMenu = (e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const selected = selectedLogText().trim();
if (!selected) return;
setContextMenu({ x: e.clientX, y: e.clientY });
};
const exportVisible = async () => {
if (visible.length === 0 || exporting) return;
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const selected = await saveDialog({
defaultPath: `psysonic-psylab-logs-${stamp}.log`,
filters: [{ name: 'Log files', extensions: ['log', 'txt'] }],
title: 'Export shown log lines',
});
if (!selected || Array.isArray(selected)) return;
setExporting(true);
try {
const bytes = new TextEncoder().encode(`${formatLogLinesText(visible)}\n`);
await writeFile(selected, bytes);
} catch {
/* user cancelled or write failed */
} finally {
setExporting(false);
}
};
return (
<div className="perf-logs">
<div className="perf-logs__controls">
@@ -189,6 +289,26 @@ export default function SidebarPerfProbeLogsTab() {
<Trash2 size={14} />
Clear
</button>
<button
type="button"
className="perf-logs__btn"
onClick={() => void copyAllShown()}
disabled={visible.length === 0}
title="Copy all lines currently shown in the log view"
>
<Copy size={14} />
{copyState === 'ok' ? 'Copied' : copyState === 'error' ? 'Copy failed' : 'Copy'}
</button>
<button
type="button"
className="perf-logs__btn"
onClick={() => void exportVisible()}
disabled={visible.length === 0 || exporting}
title="Export shown lines to a file"
>
<Download size={14} />
{exporting ? 'Exporting…' : 'Export'}
</button>
</div>
<input
@@ -204,8 +324,10 @@ export default function SidebarPerfProbeLogsTab() {
className="perf-logs__view"
ref={scrollRef}
onScroll={onScroll}
onContextMenu={onLogContextMenu}
role="log"
aria-live="off"
data-selectable
>
{visible.length === 0 ? (
<div className="perf-logs__empty">
@@ -239,6 +361,28 @@ export default function SidebarPerfProbeLogsTab() {
</button>
)}
</div>
{contextMenu && createPortal(
<div
ref={contextMenuRef}
className="context-menu perf-logs__context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={e => e.preventDefault()}
>
<button
type="button"
className="context-menu-item perf-logs__context-item"
onClick={() => {
void copySelection();
closeContextMenu();
}}
>
<Copy size={14} />
Copy
</button>
</div>,
document.body,
)}
</div>
);
}
@@ -12,7 +12,6 @@ import PerfProbeMetricCard, { PerfProbeMetricSection } from './PerfProbeMetricCa
import PerfOverlayAppearanceControls from './PerfOverlayAppearanceControls';
import PerfOverlayModeControls from './PerfOverlayModeControls';
import PerfLivePollControls from './PerfLivePollControls';
import PerfCoverThreadsControl from './PerfCoverThreadsControl';
function memoryBarPct(rssKb: number, maxKb: number): number {
if (maxKb <= 0) return 0;
@@ -61,7 +60,6 @@ export default function SidebarPerfProbeMonitorTab() {
<PerfOverlayModeControls />
<PerfOverlayAppearanceControls />
<PerfLivePollControls />
<PerfCoverThreadsControl />
<PerfProbeMetricSection title="Pipeline overlays" hint="Rust / UI queues">
<PerfProbeMetricCard
label="FPS"
@@ -0,0 +1,12 @@
import PerfCoverThreadsControl from './PerfCoverThreadsControl';
export default function SidebarPerfProbeTuningTab() {
return (
<div className="perf-tuning">
<p className="sidebar-perf-modal__hint perf-tuning__hint">
Live runtime knobs for experiments not persisted in Settings. Values reset on restart unless noted.
</p>
<PerfCoverThreadsControl />
</div>
);
}