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 -1
View File
@@ -59,7 +59,7 @@ function LiveOverlayPinnedMetric({
);
}
/** FPS + pipeline + pinned live metrics overlay (Performance Probe). */
/** FPS + pipeline + pinned live metrics overlay (PsyLab). */
export default function FpsOverlay() {
const overlayMode = usePerfOverlayMode();
const perfFlags = usePerfProbeFlags();
@@ -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>
);
}
+1 -1
View File
@@ -45,7 +45,7 @@ function useNeedCoverTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<str
);
}
/** Wires Ctrl+Shift+D probe modal and shared live metric polling. */
/** Wires Ctrl+Shift+D PsyLab modal and shared live metric polling. */
export function useSidebarPerfProbe(): Result {
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const livePins = usePerfLiveOverlayPins();
+30
View File
@@ -219,12 +219,27 @@
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11.5px;
line-height: 1.5;
user-select: text;
-webkit-user-select: text;
cursor: text;
}
.perf-logs__line {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-primary);
user-select: text;
-webkit-user-select: text;
}
.perf-tuning {
display: flex;
flex-direction: column;
gap: 12px;
}
.perf-tuning__hint {
margin: 0;
}
.perf-logs__line--marker {
@@ -265,6 +280,21 @@
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.perf-logs__context-menu {
position: fixed;
z-index: 10050;
min-width: 140px;
}
.perf-logs__context-item {
width: 100%;
border: none;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
}
.perf-monitor-empty {
display: flex;
flex-direction: column;
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { sanitizeLogLine } from './sanitizeLogLine';
describe('sanitizeLogLine', () => {
it('redacts Subsonic wire-auth query params', () => {
const line = 'GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi';
const out = sanitizeLogLine(line);
expect(out).toContain('t=REDACTED');
expect(out).toContain('s=REDACTED');
expect(out).toContain('p=REDACTED');
expect(out).not.toContain('abc');
});
it('masks remote hostnames but keeps LAN IPs', () => {
const remote = sanitizeLogLine('connect https://my-server.example.com:4533/rest/ping');
expect(remote).toContain('my****.example.com');
expect(remote).not.toContain('my-server.example.com');
const lan = sanitizeLogLine('connect http://192.168.1.42:4533/rest/ping');
expect(lan).toContain('192.168.1.42');
});
it('handles stream logs with em dashes (UTF-8 safe)', () => {
const line = '[stream] RangedHttpSource selected — total=15666KB, hint=Some("mp3")';
expect(() => sanitizeLogLine(line)).not.toThrow();
expect(sanitizeLogLine(line)).toContain('—');
});
it('redacts bearer tokens and password key/value pairs', () => {
const line = 'auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit';
const out = sanitizeLogLine(line);
expect(out).toContain('Bearer REDACTED');
expect(out).not.toContain('eyJhbGci');
expect(out).toContain('password=REDACTED');
expect(out).not.toContain('sekrit');
});
});
+235
View File
@@ -0,0 +1,235 @@
/**
* Redact secrets and partially mask remote server hostnames in PsyLab log lines.
* Mirrors `psysonic_core::log_sanitize` (defense in depth for lines already buffered).
*/
const SENSITIVE_QUERY_KEYS = new Set([
't', 's', 'p', 'token', 'password', 'passwd', 'secret', 'api_key', 'apikey',
'access_token', 'refresh_token', 'auth',
]);
const SENSITIVE_KV_KEYS = [
'password', 'passwd', 'token', 'secret', 'api_key', 'apikey',
'access_token', 'refresh_token', 'authorization', 'auth',
];
function isIpv4LanLiteral(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
const a = Number(parts[0]);
const b = Number(parts[1]);
if (!Number.isInteger(a) || !Number.isInteger(b)) return false;
return (
a === 127 ||
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
);
}
function isIpv6LanHostname(hostname: string): boolean {
const h = hostname.toLowerCase();
if (h === '::1') return true;
if (/^fe[89ab][0-9a-f]:/.test(h)) return true;
if (/^f[cd][0-9a-f]{2}:/.test(h)) return true;
const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h);
if (dotted) return isIpv4LanLiteral(dotted[1]!);
const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
if (hexMapped) {
const v1 = parseInt(hexMapped[1]!, 16);
const v2 = parseInt(hexMapped[2]!, 16);
const ipv4 = `${(v1 >> 8) & 0xff}.${v1 & 0xff}.${(v2 >> 8) & 0xff}.${v2 & 0xff}`;
return isIpv4LanLiteral(ipv4);
}
return false;
}
function isLanHost(host: string): boolean {
const stripped = host.replace(/^\[|\]$/g, '').trim().toLowerCase();
if (!stripped || stripped === 'localhost' || stripped.endsWith('.local')) return true;
if (stripped.includes(':')) return isIpv6LanHostname(stripped);
if (/^\d+\.\d+\.\d+\.\d+$/.test(stripped)) return isIpv4LanLiteral(stripped);
return false;
}
function maskPublicIpv4(ip: string): string {
const parts = ip.split('.');
if (parts.length !== 4) return '***';
return `${parts[0]}.*.*.${parts[3]}`;
}
function maskHostname(host: string): string {
const stripped = host.replace(/^\[|\]$/g, '');
if (isLanHost(stripped)) return host;
if (/^\d+\.\d+\.\d+\.\d+$/.test(stripped)) return maskPublicIpv4(stripped);
if (stripped.includes(':')) return '[ipv6-redacted]';
const parts = stripped.split('.');
if (parts.length === 0) return '***';
const first = parts[0]!;
const maskedFirst = first.length <= 2
? '*'.repeat(Math.max(1, first.length))
: `${first.slice(0, 2)}${'*'.repeat(Math.min(4, Math.max(1, first.length - 2)))}`;
return parts.length === 1 ? maskedFirst : `${maskedFirst}.${parts.slice(1).join('.')}`;
}
function splitHostPort(hostport: string): [string, string | null] {
if (hostport.startsWith('[')) {
const end = hostport.indexOf(']:');
if (end !== -1) return [hostport.slice(0, end + 1), hostport.slice(end + 2)];
return [hostport, null];
}
const colon = hostport.lastIndexOf(':');
if (colon > 0) {
const host = hostport.slice(0, colon);
const port = hostport.slice(colon + 1);
if (/^\d+$/.test(port) && !host.includes(':')) return [host, port];
}
return [hostport, null];
}
function splitHostPath(rest: string): [string, string] {
if (rest.startsWith('[')) {
const end = rest.indexOf(']');
if (end !== -1) return [rest.slice(0, end + 1), rest.slice(end + 1)];
}
const slash = rest.indexOf('/');
if (slash === -1) return [rest, ''];
return [rest.slice(0, slash), rest.slice(slash)];
}
function redactQueryString(query: string): string {
return query.split('&').map(pair => {
const eq = pair.indexOf('=');
const key = (eq === -1 ? pair : pair.slice(0, eq)).trim().toLowerCase();
if (SENSITIVE_QUERY_KEYS.has(key)) {
const rawKey = eq === -1 ? pair : pair.slice(0, eq);
return `${rawKey}=REDACTED`;
}
return pair;
}).join('&');
}
function splitTrailingPunct(raw: string): [string, string] {
let end = raw.length;
while (end > 0) {
const ch = raw[end - 1]!;
if (ch === ')' || ch === ']' || ch === ',') {
end -= 1;
continue;
}
break;
}
return [raw.slice(0, end), raw.slice(end)];
}
function redactUrl(raw: string): string {
const [url, suffix] = splitTrailingPunct(raw);
const schemeEnd = url.indexOf('://');
if (schemeEnd === -1) return raw;
let out = url.slice(0, schemeEnd + 3);
let rest = url.slice(schemeEnd + 3);
const at = rest.lastIndexOf('@');
if (at !== -1) {
out += '***@';
rest = rest.slice(at + 1);
}
const [hostport, path] = splitHostPath(rest);
const [host, port] = splitHostPort(hostport);
out += maskHostname(host);
if (port) out += `:${port}`;
const q = path.indexOf('?');
if (q === -1) {
out += path;
} else {
out += path.slice(0, q + 1);
out += redactQueryString(path.slice(q + 1));
}
return out + suffix;
}
function redactBearerTokens(line: string): string {
const marker = 'Bearer ';
let s = line;
let searchFrom = 0;
while (true) {
const idx = s.indexOf(marker, searchFrom);
if (idx === -1) break;
const start = idx + marker.length;
const tail = s.slice(start);
const endRel = tail.search(/[\s"')\]]/);
const end = endRel === -1 ? s.length : start + endRel;
if (end > start) {
s = `${s.slice(0, start)}REDACTED${s.slice(end)}`;
}
searchFrom = start + 'REDACTED'.length;
}
return s;
}
function redactSensitiveKeyValues(line: string): string {
let out = line;
for (const key of SENSITIVE_KV_KEYS) {
for (const sep of [':', '='] as const) {
const needle = `${key}${sep}`;
const lower = out.toLowerCase();
let searchFrom = 0;
while (true) {
const rel = lower.indexOf(needle, searchFrom);
if (rel === -1) break;
const idx = rel;
let valStart = idx + needle.length;
while (out[valStart] === ' ') valStart += 1;
const tail = out.slice(valStart);
const endRel = tail.search(/[\s&,;)]/);
const end = endRel === -1 ? out.length : valStart + endRel;
if (end > valStart) {
out = `${out.slice(0, valStart)}REDACTED${out.slice(end)}`;
}
searchFrom = valStart + 'REDACTED'.length;
if (searchFrom >= out.length) break;
}
}
}
return out;
}
function redactUrlsInText(line: string): string {
let out = '';
let i = 0;
while (i < line.length) {
const http = line.startsWith('http://', i);
const https = line.startsWith('https://', i);
const schemeLen = http ? 7 : https ? 8 : 0;
if (schemeLen > 0) {
const start = i;
i += schemeLen;
while (i < line.length) {
const c = line[i]!;
if (/\s/.test(c) || c === '"' || c === "'" || c === '>') break;
if ((c === ')' || c === ']' || c === ',') && i + 1 < line.length) {
const next = line[i + 1]!;
if (/\s/.test(next) || next === '"' || next === "'") break;
}
i += 1;
}
out += redactUrl(line.slice(start, i));
} else {
out += line[i];
i += 1;
}
}
return out;
}
export function sanitizeLogLine(line: string): string {
return redactUrlsInText(redactSensitiveKeyValues(redactBearerTokens(line)));
}
+1 -1
View File
@@ -3,7 +3,7 @@
* (`t` salt, `s` token hash, `p` password when present.)
*/
export function redactSubsonicUrlForLog(url: string): string {
if (!url || !url.includes('stream.view')) return url;
if (!url) return url;
try {
const u = new URL(url);
// Placeholder must stay URL-safe (no `<>` — URLSearchParams percent-encodes them).