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
+10
View File
@@ -107,6 +107,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### PsyLab — Performance Probe rename, Tuning tab, and log tools
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1027](https://github.com/Psychotoxical/psysonic/pull/1027)**
* **Ctrl+Shift+D** opens **PsyLab** (formerly Performance Probe). Cover backfill thread tuning moved to a new **Tuning** tab.
* **Logs** tab: selectable text, toolbar copy/export, and a context-menu **Copy** for the current selection.
* Runtime log lines are sanitized before they enter the buffer — Subsonic/auth tokens and remote hostnames are redacted or partially masked; LAN and localhost addresses stay readable.
## Fixed
### Servers — complete border on the active server card
@@ -5,6 +5,7 @@
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod cover_cache_layout;
pub mod log_sanitize;
pub mod media_layout;
pub mod logging;
pub mod ports;
@@ -0,0 +1,378 @@
//! Redact secrets and partially mask remote server hostnames before log lines
//! are stored or exported (PsyLab / Settings log export).
const SENSITIVE_QUERY_KEYS: &[&str] = &[
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
"access_token", "refresh_token", "auth",
];
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
];
/// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line);
out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
}
/// Never panic on the logging hot path — fall back to the raw line if needed.
pub fn sanitize_log_line_infallible(line: &str) -> String {
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
}
fn redact_bearer_tokens(line: &str) -> String {
let marker = "Bearer ";
let mut s = line.to_string();
let mut search_from = 0;
while let Some(rel) = s[search_from..].find(marker) {
let idx = search_from + rel;
let start = idx + marker.len();
let end = s[start..]
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
.map(|i| start + i)
.unwrap_or(s.len());
if end > start {
s.replace_range(start..end, "REDACTED");
}
search_from = start + "REDACTED".len();
}
s
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
for sep in [':', '='] {
let needle = format!("{key}{sep}");
let lower = out.to_ascii_lowercase();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find(&needle) {
let idx = search_from + rel;
let val_start = idx + needle.len();
let slice = &out[val_start..];
let trimmed = slice.trim_start();
let ws = slice.len().saturating_sub(trimmed.len());
let val_start = val_start + ws;
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
.unwrap_or(trimmed.len());
if end > 0 {
out.replace_range(val_start..val_start + end, "REDACTED");
}
search_from = val_start + "REDACTED".len();
if search_from >= out.len() {
break;
}
}
}
}
out
}
fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
return true;
}
if ch == ')' || ch == ']' || ch == ',' {
if let Some(next) = s[byte_off..].chars().nth(1) {
return next.is_whitespace() || next == '"' || next == '\'';
}
}
false
}
fn redact_urls_in_text(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut cursor = 0;
while cursor < line.len() {
let slice = &line[cursor..];
let rel = match (slice.find("http://"), slice.find("https://")) {
(Some(h), Some(s)) => Some(h.min(s)),
(Some(h), None) => Some(h),
(None, Some(s)) => Some(s),
(None, None) => None,
};
let Some(rel) = rel else {
out.push_str(slice);
break;
};
out.push_str(&slice[..rel]);
let url_start = cursor + rel;
let url_slice = &line[url_start..];
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
let mut url_end = scheme_len;
for (off, ch) in url_slice[scheme_len..].char_indices() {
let abs = scheme_len + off;
if url_char_ends_url(ch, url_slice, abs) {
break;
}
url_end = abs + ch.len_utf8();
}
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
cursor = url_start + url_end;
}
out
}
fn redact_url(raw: &str) -> String {
let (url, suffix) = split_trailing_punct(raw);
let mut out = String::new();
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
out.push_str(&url[..scheme_end]);
let mut rest = &url[scheme_end..];
if let Some(at) = rest.rfind('@') {
// Drop userinfo entirely.
out.push_str("***@");
rest = &rest[at + 1..];
}
let (hostport, path) = split_host_path(rest);
let (host, port) = split_host_port(&hostport);
let masked_host = mask_hostname(&host);
out.push_str(&masked_host);
if let Some(p) = port {
out.push(':');
out.push_str(&p);
}
if let Some((path_only, query)) = path.split_once('?') {
out.push_str(path_only);
out.push('?');
out.push_str(&redact_query_string(query));
} else {
out.push_str(&path);
}
format!("{out}{suffix}")
}
fn split_trailing_punct(raw: &str) -> (&str, &str) {
let mut end = raw.len();
while end > 0 {
let ch = raw.as_bytes()[end - 1] as char;
if ch == ')' || ch == ']' || ch == ',' {
end -= 1;
continue;
}
break;
}
(&raw[..end], &raw[end..])
}
fn split_host_path(rest: &str) -> (String, String) {
if rest.starts_with('[') {
if let Some(end) = rest.find(']') {
let hostport = &rest[..=end];
return (hostport.to_string(), rest[end + 1..].to_string());
}
}
if let Some(slash) = rest.find('/') {
(rest[..slash].to_string(), rest[slash..].to_string())
} else {
(rest.to_string(), String::new())
}
}
fn split_host_port(hostport: &str) -> (String, Option<String>) {
if hostport.starts_with('[') {
if let Some(end) = hostport.find("]:") {
return (
hostport[..=end].to_string(),
Some(hostport[end + 2..].to_string()),
);
}
return (hostport.to_string(), None);
}
if let Some((h, p)) = hostport.rsplit_once(':') {
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
return (h.to_string(), Some(p.to_string()));
}
}
(hostport.to_string(), None)
}
fn redact_query_string(query: &str) -> String {
query
.split('&')
.map(|pair| {
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
if is_sensitive_query_key(k) {
format!("{k}=REDACTED")
} else {
pair.to_string()
}
})
.collect::<Vec<_>>()
.join("&")
}
fn is_sensitive_query_key(key: &str) -> bool {
let k = key.trim().to_ascii_lowercase();
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
}
fn is_lan_ipv4(ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
let Ok(a) = parts[0].parse::<u8>() else { return false };
let Ok(b) = parts[1].parse::<u8>() else { return false };
a == 127
|| a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
}
fn is_lan_ipv6(host: &str) -> bool {
let h = host.to_ascii_lowercase();
if h == "::1" {
return true;
}
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
return true;
}
if h.starts_with("fc") || h.starts_with("fd") {
return true;
}
if let Some(rest) = h.strip_prefix("::ffff:") {
if rest.contains('.') {
return is_lan_ipv4(rest);
}
if let Some((a, b)) = rest.split_once(':') {
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
let ip = format!(
"{}.{}.{}.{}",
(v1 >> 8) & 0xff,
v1 & 0xff,
(v2 >> 8) & 0xff,
v2 & 0xff
);
return is_lan_ipv4(&ip);
}
}
}
false
}
fn is_lan_host(host: &str) -> bool {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
let lower = stripped.to_ascii_lowercase();
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
return true;
}
if stripped.contains(':') {
return is_lan_ipv6(stripped);
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return is_lan_ipv4(stripped);
}
false
}
fn mask_label_prefix(label: &str) -> String {
let mut chars = label.chars();
let c1 = chars.next();
let c2 = chars.next();
match (c1, c2) {
(None, _) => "*".to_string(),
(Some(a), None) => a.to_string(),
(Some(a), Some(b)) => {
let rest = label.chars().count().saturating_sub(2);
let stars = rest.clamp(1, 4);
format!("{a}{b}{}", "*".repeat(stars))
}
}
}
fn mask_public_ipv4(ip: &str) -> String {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return "***".to_string();
}
format!("{}.*.*.{}", parts[0], parts[3])
}
fn mask_hostname(host: &str) -> String {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
if is_lan_host(stripped) {
return host.to_string();
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return mask_public_ipv4(stripped);
}
if stripped.contains(':') {
return "[ipv6-redacted]".to_string();
}
let parts: Vec<&str> = stripped.split('.').collect();
if parts.is_empty() {
return "***".to_string();
}
let masked_first = mask_label_prefix(parts[0]);
if parts.len() == 1 {
masked_first
} else {
format!("{}.{}", masked_first, parts[1..].join("."))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_subsonic_wire_auth_params() {
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
let out = sanitize_log_line(line);
assert!(out.contains("t=REDACTED"));
assert!(out.contains("s=REDACTED"));
assert!(out.contains("p=REDACTED"));
assert!(!out.contains("abc"));
}
#[test]
fn masks_remote_hostname_keeps_lan_ip() {
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
assert!(remote.contains("my****.example.com"));
assert!(!remote.contains("my-server.example.com"));
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
assert!(lan.contains("192.168.1.42"));
}
#[test]
fn redacts_bearer_and_password_kv() {
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
let out = sanitize_log_line(line);
assert!(out.contains("Bearer REDACTED"));
assert!(!out.contains("eyJhbGci"));
assert!(out.contains("password=REDACTED"));
assert!(!out.contains("sekrit"));
}
#[test]
fn strips_url_userinfo() {
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
let out = sanitize_log_line(line);
assert!(out.contains("***@10.0.0.5"));
assert!(!out.contains("user:pass"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
let out = sanitize_log_line(line);
assert!(out.contains('—'));
assert!(out.contains("RangedHttpSource"));
}
}
@@ -101,6 +101,7 @@ pub fn should_log_debug() -> bool {
}
pub fn append_log_line(line: String) {
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
{
let mut buf = log_buffer().lock().unwrap();
+2 -2
View File
@@ -49,8 +49,8 @@ pub(crate) struct LogTailDto {
pub dropped: bool,
}
/// Incremental tail of the in-memory runtime log buffer for the Performance
/// Probe Logs tab. `after_seq` is the highest seq the UI already has (omit for
/// Incremental tail of the in-memory runtime log buffer for the PsyLab Logs tab.
/// `after_seq` is the highest seq the UI already has (omit for
/// the initial fetch of the most recent `max` lines).
#[tauri::command]
pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> LogTailDto {
+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).