mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user