diff --git a/CHANGELOG.md b/CHANGELOG.md index 227e8ac5..0195d579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -217,6 +217,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Ghost badge** on browse routes when scope is cleared — one click restores page-only mode; query text is preserved. * Album browse text search uses title-only FTS in the local index; Tracks hides discovery chrome while searching; session stash restores query and scroll after back from detail. +### Performance Probe — live runtime logs tab + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#946](https://github.com/Psychotoxical/psysonic/pull/946)** + +* New **Logs** tab streams the backend runtime log buffer live inside the app, so the stdout/stderr console — unreachable on Windows without exporting a file — can be read online. The buffer tags each line with a monotonic seq and a new `tail_runtime_logs` command tails it incrementally. +* Includes an off/normal/debug **depth switch** (mirrors app Settings), a 500–5000 **line cap**, pause/clear, auto-follow, and an ordered comma-separated **word filter** where a plain word includes and `-word` excludes, applied left to right as layers (sequence matters). + ## Changed diff --git a/src-tauri/crates/psysonic-core/src/logging.rs b/src-tauri/crates/psysonic-core/src/logging.rs index 469f8696..b387b700 100644 --- a/src-tauri/crates/psysonic-core/src/logging.rs +++ b/src-tauri/crates/psysonic-core/src/logging.rs @@ -8,7 +8,7 @@ use std::collections::VecDeque; use std::io::Write; use std::sync::{Mutex, OnceLock}; -use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] @@ -21,8 +21,31 @@ pub enum LoggingMode { static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8); const LOG_BUFFER_MAX_LINES: usize = 20_000; -fn log_buffer() -> &'static Mutex> { - static LOG_BUFFER: OnceLock>> = OnceLock::new(); +/// Monotonic sequence assigned to each appended line; lets the UI tail +/// incrementally (request only lines newer than the last seq it has seen). +static LOG_SEQ: AtomicU64 = AtomicU64::new(0); + +/// A single buffered log line plus its monotonic sequence number. +#[derive(Clone, Debug)] +pub struct LogLine { + pub seq: u64, + pub text: String, +} + +/// Result of an incremental tail request. +#[derive(Clone, Debug, Default)] +pub struct LogTail { + pub lines: Vec, + /// Sequence to pass back on the next request (highest seq known, even if no + /// new lines were returned). + pub last_seq: u64, + /// True when the caller's `after_seq` predates the retained window, i.e. some + /// lines were dropped from the ring buffer before they could be delivered. + pub dropped: bool, +} + +fn log_buffer() -> &'static Mutex> { + static LOG_BUFFER: OnceLock>> = OnceLock::new(); LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES))) } @@ -52,6 +75,15 @@ pub fn set_logging_mode_from_str(mode: &str) -> Result<(), String> { Ok(()) } +/// Current logging mode as a stable lowercase string for the UI. +pub fn current_mode_str() -> &'static str { + match current_mode() { + LoggingMode::Off => "off", + LoggingMode::Normal => "normal", + LoggingMode::Debug => "debug", + } +} + fn current_mode() -> LoggingMode { match LOGGING_MODE.load(Ordering::Acquire) { 0 => LoggingMode::Off, @@ -69,12 +101,14 @@ pub fn should_log_debug() -> bool { } pub fn append_log_line(line: String) { - let mut buf = log_buffer().lock().unwrap(); - if buf.len() >= LOG_BUFFER_MAX_LINES { - buf.pop_front(); + let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1; + { + let mut buf = log_buffer().lock().unwrap(); + if buf.len() >= LOG_BUFFER_MAX_LINES { + buf.pop_front(); + } + buf.push_back(LogLine { seq, text: line.clone() }); } - buf.push_back(line.clone()); - drop(buf); let path = cli_log_channel_path(); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); @@ -84,13 +118,41 @@ pub fn append_log_line(line: String) { } } +/// Return retained log lines with `seq > after_seq`, capped to `max` (most +/// recent kept). Pass `after_seq = None` to fetch the latest `max` lines. +pub fn tail_logs(after_seq: Option, max: usize) -> LogTail { + let max = max.clamp(1, LOG_BUFFER_MAX_LINES); + let buf = log_buffer().lock().unwrap(); + let last_seq = buf.back().map(|l| l.seq).unwrap_or(0); + let earliest_seq = buf.front().map(|l| l.seq).unwrap_or(0); + + let after = after_seq.unwrap_or(0); + // A gap occurred if the caller already saw `after` lines but the buffer no + // longer holds the line right after it (it scrolled out of the window). + let dropped = after_seq.is_some() + && after > 0 + && earliest_seq > 0 + && after + 1 < earliest_seq; + + let mut lines: Vec = buf + .iter() + .filter(|l| l.seq > after) + .cloned() + .collect(); + if lines.len() > max { + lines.drain(0..lines.len() - max); + } + + LogTail { lines, last_seq, dropped } +} + pub fn export_logs_to_file(path: &str) -> Result { let snapshot = { let buf = log_buffer().lock().unwrap(); if buf.is_empty() { String::new() } else { - let mut s = buf.iter().cloned().collect::>().join("\n"); + let mut s = buf.iter().map(|l| l.text.clone()).collect::>().join("\n"); s.push('\n'); s } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4eb124cf..5d77cc39 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -612,6 +612,8 @@ pub fn run() { linux_wayland_text_render_settings_available, set_linux_wayland_text_render_profile, set_logging_mode, + get_logging_mode, + tail_runtime_logs, export_runtime_logs, frontend_debug_log, performance_cpu_snapshot, diff --git a/src-tauri/src/lib_commands/app_api/core.rs b/src-tauri/src/lib_commands/app_api/core.rs index aae531cd..19f81fc5 100644 --- a/src-tauri/src/lib_commands/app_api/core.rs +++ b/src-tauri/src/lib_commands/app_api/core.rs @@ -24,11 +24,48 @@ pub(crate) fn set_logging_mode(mode: String) -> Result<(), String> { crate::logging::set_logging_mode_from_str(&mode) } +#[tauri::command] +pub(crate) fn get_logging_mode() -> String { + crate::logging::current_mode_str().to_string() +} + #[tauri::command] pub(crate) fn export_runtime_logs(path: String) -> Result { crate::logging::export_logs_to_file(&path) } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LogLineDto { + pub seq: u64, + pub text: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LogTailDto { + pub lines: Vec, + pub last_seq: u64, + 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 +/// the initial fetch of the most recent `max` lines). +#[tauri::command] +pub(crate) fn tail_runtime_logs(after_seq: Option, max: Option) -> LogTailDto { + let tail = crate::logging::tail_logs(after_seq, max.unwrap_or(2000)); + LogTailDto { + lines: tail + .lines + .into_iter() + .map(|l| LogLineDto { seq: l.seq, text: l.text }) + .collect(), + last_seq: tail.last_seq, + dropped: tail.dropped, + } +} + #[tauri::command] pub(crate) fn frontend_debug_log(scope: String, message: String) -> Result<(), String> { crate::app_deprintln!("[frontend][{}] {}", scope, message); diff --git a/src-tauri/src/lib_commands/app_api/mod.rs b/src-tauri/src/lib_commands/app_api/mod.rs index 59246d46..29cdcbfd 100644 --- a/src-tauri/src/lib_commands/app_api/mod.rs +++ b/src-tauri/src/lib_commands/app_api/mod.rs @@ -16,8 +16,8 @@ pub(crate) use cli_bridge::{ cli_publish_server_list, }; pub(crate) use core::{ - exit_app, export_runtime_logs, frontend_debug_log, greet, set_logging_mode, - set_subsonic_wire_user_agent, + exit_app, export_runtime_logs, frontend_debug_log, get_logging_mode, greet, set_logging_mode, + set_subsonic_wire_user_agent, tail_runtime_logs, }; pub(crate) use perf::performance_cpu_snapshot; pub(crate) use platform::{ diff --git a/src/api/runtimeLogs.ts b/src/api/runtimeLogs.ts new file mode 100644 index 00000000..358df416 --- /dev/null +++ b/src/api/runtimeLogs.ts @@ -0,0 +1,36 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { LoggingMode } from '../store/authStoreTypes'; + +export interface RuntimeLogLine { + seq: number; + text: string; +} + +export interface RuntimeLogTail { + lines: RuntimeLogLine[]; + lastSeq: number; + dropped: boolean; +} + +/** + * Incremental tail of the backend runtime log ring buffer. + * + * @param afterSeq highest seq already held by the caller; omit/`null` for the + * initial fetch of the most recent `max` lines. + * @param max cap on returned lines (most recent kept). + */ +export async function tailRuntimeLogs( + afterSeq: number | null, + max: number, +): Promise { + return invoke('tail_runtime_logs', { + afterSeq: afterSeq ?? null, + max, + }); +} + +/** Read the current backend logging mode (off | normal | debug). */ +export async function getLoggingMode(): Promise { + const mode = await invoke('get_logging_mode'); + return (mode === 'off' || mode === 'debug') ? mode : 'normal'; +} diff --git a/src/components/sidebar/SidebarPerfProbeModal.tsx b/src/components/sidebar/SidebarPerfProbeModal.tsx index 91aa4cb8..6b20a20e 100644 --- a/src/components/sidebar/SidebarPerfProbeModal.tsx +++ b/src/components/sidebar/SidebarPerfProbeModal.tsx @@ -1,14 +1,15 @@ import { useState } from 'react'; -import { Activity, SlidersHorizontal, X } from 'lucide-react'; +import { Activity, ScrollText, SlidersHorizontal, X } from 'lucide-react'; import { createPortal } from 'react-dom'; import SidebarPerfProbeMonitorTab from './perfProbe/SidebarPerfProbeMonitorTab'; import SidebarPerfProbeTogglesTab from './perfProbe/SidebarPerfProbeTogglesTab'; +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'; +type TabId = 'monitor' | 'toggles' | 'logs'; interface Props { open: boolean; @@ -88,12 +89,21 @@ export default function SidebarPerfProbeModal({ Toggles + -
- {tab === 'monitor' ? ( - - ) : ( +
+ {tab === 'monitor' && } + {tab === 'toggles' && ( )} + {tab === 'logs' && }
diff --git a/src/components/sidebar/perfProbe/SidebarPerfProbeLogsTab.tsx b/src/components/sidebar/perfProbe/SidebarPerfProbeLogsTab.tsx new file mode 100644 index 00000000..f784a8f9 --- /dev/null +++ b/src/components/sidebar/perfProbe/SidebarPerfProbeLogsTab.tsx @@ -0,0 +1,244 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Pause, Play, Trash2 } from 'lucide-react'; +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'; + +const POLL_MS = 750; +const BOTTOM_EPSILON = 24; +// Hard ceiling for the in-view buffer while the user has scrolled up (so history +// they are reading is not trimmed away). Matches the backend ring buffer size. +const MAX_BUFFER = 20_000; +const LINE_CAP_OPTIONS = [ + { value: '500', label: '500 lines' }, + { value: '1000', label: '1000 lines' }, + { value: '2000', label: '2000 lines' }, + { value: '5000', label: '5000 lines' }, +]; +const DEPTH_OPTIONS: { value: LoggingMode; label: string }[] = [ + { value: 'off', label: 'Off' }, + { value: 'normal', label: 'Normal' }, + { value: 'debug', label: 'Debug' }, +]; + +/** + * Live view of the backend runtime log buffer (the stdout/stderr lines that are + * otherwise only visible in the launching terminal — unreachable on Windows). + * Polls the ring buffer incrementally, with a depth switch, line cap, and an + * ordered include/exclude word filter. + */ +export default function SidebarPerfProbeLogsTab() { + const loggingMode = useAuthStore(s => s.loggingMode); + const setLoggingMode = useAuthStore(s => s.setLoggingMode); + + const [lines, setLines] = useState([]); + const [paused, setPaused] = useState(false); + const [filter, setFilter] = useState(''); + const [lineCap, setLineCap] = useState(1000); + const [follow, setFollow] = useState(true); + const [overflowed, setOverflowed] = useState(false); + + const lastSeqRef = useRef(null); + const pausedRef = useRef(paused); + const lineCapRef = useRef(lineCap); + const followRef = useRef(follow); + const scrollRef = useRef(null); + // Topmost visible line to re-pin against while the user is scrolled up, so the + // view stays put even as new lines append below or old ones scroll out. + const anchorRef = useRef<{ seq: number; offset: number } | null>(null); + pausedRef.current = paused; + lineCapRef.current = lineCap; + followRef.current = follow; + + // Keep the backend mode readout in sync with reality on open. + useEffect(() => { + void getLoggingMode().then(mode => { + if (mode !== loggingMode) setLoggingMode(mode); + }).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + let cancelled = false; + let timer: number | undefined; + + const tick = async () => { + if (!pausedRef.current) { + try { + // While following, request only the visible cap; while scrolled up, + // pull up to the hard ceiling so read-back history is preserved. + const fetchMax = followRef.current ? lineCapRef.current : MAX_BUFFER; + const tail = await tailRuntimeLogs(lastSeqRef.current, fetchMax); + if (!cancelled && tail.dropped) setOverflowed(true); + if (!cancelled && tail.lines.length > 0) { + lastSeqRef.current = tail.lastSeq; + setLines(prev => { + const next = [...prev, ...tail.lines]; + // 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; + return next.length > cap ? next.slice(next.length - cap) : next; + }); + } else if (!cancelled) { + lastSeqRef.current = tail.lastSeq; + } + } catch { + /* transient; retry next tick */ + } + } + if (!cancelled) timer = window.setTimeout(() => void tick(), POLL_MS); + }; + + void tick(); + return () => { + cancelled = true; + if (timer != null) window.clearTimeout(timer); + }; + }, []); + + const visible = useMemo(() => filterLogLines(lines, filter), [lines, filter]); + + // When following resumes (or the cap shrinks), trim retained history to the cap. + useEffect(() => { + if (!follow) return; + setLines(prev => (prev.length > lineCap ? prev.slice(prev.length - lineCap) : prev)); + }, [follow, lineCap]); + + // Keep the view pinned: stick to the bottom while following, otherwise re-pin + // the previously-topmost line so the reader's position holds as lines append. + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + if (follow) { + el.scrollTop = el.scrollHeight; + return; + } + const anchor = anchorRef.current; + if (!anchor) return; + const node = el.querySelector(`[data-seq="${anchor.seq}"]`); + if (node) el.scrollTop = node.offsetTop - anchor.offset; + }, [visible, follow]); + + const captureAnchor = (el: HTMLElement) => { + const top = el.scrollTop; + for (const child of Array.from(el.children) as HTMLElement[]) { + if (child.dataset.seq == null) continue; + if (child.offsetTop + child.offsetHeight > top + 1) { + anchorRef.current = { seq: Number(child.dataset.seq), offset: child.offsetTop - top }; + return; + } + } + }; + + const onScroll = () => { + const el = scrollRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_EPSILON; + if (!atBottom) captureAnchor(el); + if (atBottom !== followRef.current) setFollow(atBottom); + }; + + const jumpToLatest = () => { + anchorRef.current = null; + setFollow(true); + }; + + const changeDepth = (mode: LoggingMode) => { + setLoggingMode(mode); + void invoke('set_logging_mode', { mode }).catch(() => {}); + }; + + const clear = () => { + setLines([]); + setOverflowed(false); + }; + + return ( +
+
+ + + + +
+ + setFilter(e.target.value)} + spellCheck={false} + /> + +
+ {visible.length === 0 ? ( +
+ {loggingMode === 'off' + ? 'Logging is Off — set depth to Normal or Debug to capture lines.' + : lines.length === 0 + ? 'Waiting for log lines…' + : 'No lines match the current filter.'} +
+ ) : ( + visible.map(line => ( +
+ {line.text} +
+ )) + )} +
+ +
+ + {visible.length.toLocaleString()} shown · {lines.length.toLocaleString()} buffered + {overflowed && ' · buffer overflowed (oldest dropped)'} + + {!follow && ( + + )} +
+
+ ); +} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index f3b549d0..4300e9e9 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -147,6 +147,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Cover backfill: disk-free idle gate, snapshot-diff worklist, live-tunable parallelism, transient-error retries, and memoized offline & cache stats to stop idle CPU spin (PR #943)', 'Cover art: fix per-song cover over-fetch on Navidrome — only genuine per-disc artwork expands, collapsing ~520k per-track fetches to one cover per album (PR #944)', 'Performance Probe: cover pipeline covers-per-minute (cpm) throughput, the cover analogue of analysis tpm (PR #945)', + 'Performance Probe: live runtime logs tab with depth switch, line cap, and ordered include/exclude word filter (PR #946)', ], }, { diff --git a/src/styles/components/modal.css b/src/styles/components/modal.css index d4bd43a5..357e98fd 100644 --- a/src/styles/components/modal.css +++ b/src/styles/components/modal.css @@ -138,6 +138,133 @@ padding-right: 2px; } +/* Logs tab manages its own internal scroll: the body becomes a flex column with + hidden overflow so only the log view scrolls, not the controls/filter. */ +.sidebar-perf-modal__body--logs { + display: flex; + flex-direction: column; + overflow: hidden; + padding-right: 0; +} + +.perf-logs { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + gap: 8px; +} + +.perf-logs__controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; + flex: 0 0 auto; +} + +.perf-logs__control { + display: flex; + flex-direction: column; + gap: 3px; +} + +.perf-logs__control-label { + font-size: 11px; + color: var(--text-muted); +} + +.perf-logs__btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + font-size: 12px; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--text-muted) 30%, transparent); + background: transparent; + color: var(--text-primary); + cursor: pointer; +} + +.perf-logs__btn:hover { + background: color-mix(in srgb, var(--text-muted) 16%, transparent); +} + +.perf-logs__btn[aria-pressed='true'] { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + color: var(--accent); +} + +.perf-logs__filter { + width: 100%; + flex: 0 0 auto; + padding: 6px 9px; + font-size: 12px; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--text-muted) 28%, transparent); + background: var(--bg-secondary, rgba(0, 0, 0, 0.2)); + color: var(--text-primary); +} + +.perf-logs__view { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 8px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--text-muted) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11.5px; + line-height: 1.5; +} + +.perf-logs__line { + white-space: pre-wrap; + word-break: break-word; + color: var(--text-primary); +} + +.perf-logs__line--marker { + color: var(--accent); + opacity: 0.8; + text-align: center; + padding: 2px 0; +} + +.perf-logs__empty { + color: var(--text-muted); + font-size: 12px; + padding: 1.5rem 0.5rem; + text-align: center; +} + +.perf-logs__status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex: 0 0 auto; + font-size: 11px; + color: var(--text-muted); +} + +.perf-logs__jump { + padding: 3px 9px; + font-size: 11px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); + background: transparent; + color: var(--accent); + cursor: pointer; +} + +.perf-logs__jump:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + .perf-monitor-empty { display: flex; flex-direction: column; diff --git a/src/utils/perf/filterLogLines.test.ts b/src/utils/perf/filterLogLines.test.ts new file mode 100644 index 00000000..2bb304fd --- /dev/null +++ b/src/utils/perf/filterLogLines.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { filterLogLines, parseLogFilter } from './filterLogLines'; + +const L = (text: string) => ({ text }); +const lines = [ + L('[10:00] cover error: timeout'), + L('[10:01] cover ok album=Discovery'), + L('[10:02] analysis warn: slow'), + L('[10:03] cover error spam noise'), +]; + +const texts = (rows: { text: string }[]) => rows.map(r => r.text); + +describe('parseLogFilter', () => { + it('classifies include and exclude tokens, trims, drops empties', () => { + expect(parseLogFilter(' cover , -spam ,, - , error')).toEqual([ + { kind: 'include', word: 'cover' }, + { kind: 'exclude', word: 'spam' }, + { kind: 'include', word: 'error' }, + ]); + }); +}); + +describe('filterLogLines', () => { + it('returns all lines when filter is empty', () => { + expect(filterLogLines(lines, ' ')).toHaveLength(4); + }); + + it('include-only narrows to matching lines (union of includes)', () => { + expect(texts(filterLogLines(lines, 'error, warn'))).toEqual([ + '[10:00] cover error: timeout', + '[10:02] analysis warn: slow', + '[10:03] cover error spam noise', + ]); + }); + + it('exclude-first starts from all and removes matches', () => { + expect(texts(filterLogLines(lines, '-cover'))).toEqual([ + '[10:02] analysis warn: slow', + ]); + }); + + it('respects sequence: include then exclude', () => { + expect(texts(filterLogLines(lines, 'cover, -spam'))).toEqual([ + '[10:00] cover error: timeout', + '[10:01] cover ok album=Discovery', + ]); + }); + + it('layering order matters: later layer overrides earlier', () => { + expect(filterLogLines(lines, 'error, -error')).toHaveLength(0); + expect(filterLogLines(lines, '-error, error')).toHaveLength(4); + }); + + it('is case-insensitive', () => { + expect(texts(filterLogLines(lines, 'DISCOVERY'))).toEqual([ + '[10:01] cover ok album=Discovery', + ]); + }); +}); diff --git a/src/utils/perf/filterLogLines.ts b/src/utils/perf/filterLogLines.ts new file mode 100644 index 00000000..2823e6e6 --- /dev/null +++ b/src/utils/perf/filterLogLines.ts @@ -0,0 +1,62 @@ +/** + * Ordered include/exclude log filter. + * + * The filter string is a comma-separated list of tokens applied left to right + * as layers — sequence matters: + * - a plain word `foo` → INCLUDE: lines containing `foo` are shown. + * - a word with a leading `-` (`-foo`) → EXCLUDE: lines containing `foo` + * are hidden. + * + * Layering model (paint order): + * - If the first token is an exclude, the baseline is "all lines visible"; + * otherwise the baseline is "nothing visible" (include-only narrows down). + * - Each include unions in the matching lines; each exclude removes matching + * lines from what is currently visible. A later layer overrides an earlier + * one, so `error, -error` shows nothing while `-error, error` shows all. + * + * Matching is case-insensitive and substring-based. + */ +export type LogFilterToken = { + kind: 'include' | 'exclude'; + word: string; +}; + +export function parseLogFilter(filter: string): LogFilterToken[] { + return filter + .split(',') + .map(raw => raw.trim()) + .filter(raw => raw.length > 0) + .map(raw => { + if (raw.startsWith('-')) { + const word = raw.slice(1).trim().toLowerCase(); + return word.length > 0 ? { kind: 'exclude', word } : null; + } + return { kind: 'include', word: raw.toLowerCase() }; + }) + .filter((t): t is LogFilterToken => t !== null); +} + +export function filterLogLines( + lines: readonly T[], + filter: string, +): T[] { + const tokens = parseLogFilter(filter); + if (tokens.length === 0) return [...lines]; + + const haystacks = lines.map(l => l.text.toLowerCase()); + const visible = new Array(lines.length); + + // Baseline: include-first starts hidden; exclude-first starts visible. + const startVisible = tokens[0].kind === 'exclude'; + visible.fill(startVisible); + + for (const token of tokens) { + for (let i = 0; i < lines.length; i += 1) { + const matches = haystacks[i].includes(token.word); + if (!matches) continue; + visible[i] = token.kind === 'include'; + } + } + + return lines.filter((_, i) => visible[i]); +}