feat(perf): live runtime logs tab in Performance Probe (#946)

* feat(perf): live runtime logs tab in Performance Probe

Add a Logs tab that streams the backend runtime log ring buffer in-app, so the
stdout/stderr console (unreachable on Windows without exporting) can be read
live. The buffer now tracks a monotonic seq; a new tail_runtime_logs command
returns lines incrementally and get_logging_mode reports the current depth.

The tab has a depth switch (off/normal/debug) mirroring app settings, a line cap
(500-5000), pause/clear, auto-follow, and an ordered comma-separated word filter
where a plain word includes and a -word excludes, applied left to right as
layers (sequence matters).

* docs(changelog): note Performance Probe logs tab (PR #946)

Add CHANGELOG entry and credits line for the live runtime logs tab.

* fix(perf): pin log view position when scrolled up

Auto-scroll keeps the logs tab at the tail, but once the user scrolls up the
view now stays put — the previously-topmost line is re-pinned each tick while
the log keeps appending below for further scrolling. History under the viewport
is no longer trimmed while scrolled up (kept up to the ring-buffer ceiling); the
cap is re-applied when following resumes. Buffer overflow is shown in the status
line instead of an injected marker row.

* fix(perf): scope logs tab to its own internal scroll

The whole probe body scrolled (controls + filter + log) because the log
container sized via height:100%, which WebKitGTK does not resolve against the
flex body. Make the body a flex column with hidden overflow on the Logs tab and
let the log view flex-fill, so depth/keep/pause/clear and the filter stay fixed
while only the log lines scroll.
This commit is contained in:
cucadmuh
2026-06-02 11:40:36 +03:00
committed by GitHub
parent c6df05e576
commit 975bb6d9af
12 changed files with 666 additions and 17 deletions
+7
View File
@@ -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 5005000 **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
+71 -9
View File
@@ -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<VecDeque<String>> {
static LOG_BUFFER: OnceLock<Mutex<VecDeque<String>>> = 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<LogLine>,
/// 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<VecDeque<LogLine>> {
static LOG_BUFFER: OnceLock<Mutex<VecDeque<LogLine>>> = 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<u64>, 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<LogLine> = 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<usize, String> {
let snapshot = {
let buf = log_buffer().lock().unwrap();
if buf.is_empty() {
String::new()
} else {
let mut s = buf.iter().cloned().collect::<Vec<_>>().join("\n");
let mut s = buf.iter().map(|l| l.text.clone()).collect::<Vec<_>>().join("\n");
s.push('\n');
s
}
+2
View File
@@ -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,
@@ -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<usize, String> {
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<LogLineDto>,
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<u64>, max: Option<usize>) -> 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);
+2 -2
View File
@@ -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::{
+36
View File
@@ -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<RuntimeLogTail> {
return invoke<RuntimeLogTail>('tail_runtime_logs', {
afterSeq: afterSeq ?? null,
max,
});
}
/** Read the current backend logging mode (off | normal | debug). */
export async function getLoggingMode(): Promise<LoggingMode> {
const mode = await invoke<string>('get_logging_mode');
return (mode === 'off' || mode === 'debug') ? mode : 'normal';
}
@@ -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({
<SlidersHorizontal size={15} />
Toggles
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'logs'}
className={`sidebar-perf-modal__tab${tab === 'logs' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('logs')}
>
<ScrollText size={15} />
Logs
</button>
</div>
<div className="sidebar-perf-modal__body">
{tab === 'monitor' ? (
<SidebarPerfProbeMonitorTab />
) : (
<div className={`sidebar-perf-modal__body${tab === 'logs' ? ' sidebar-perf-modal__body--logs' : ''}`}>
{tab === 'monitor' && <SidebarPerfProbeMonitorTab />}
{tab === 'toggles' && (
<SidebarPerfProbeTogglesTab
perfFlags={perfFlags}
hotCacheEnabled={hotCacheEnabled}
@@ -104,6 +114,7 @@ export default function SidebarPerfProbeModal({
setLoggingMode={setLoggingMode}
/>
)}
{tab === 'logs' && <SidebarPerfProbeLogsTab />}
</div>
<div className="sidebar-perf-modal__actions">
@@ -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<RuntimeLogLine[]>([]);
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<number | null>(null);
const pausedRef = useRef(paused);
const lineCapRef = useRef(lineCap);
const followRef = useRef(follow);
const scrollRef = useRef<HTMLDivElement | null>(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<HTMLElement>(`[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 (
<div className="perf-logs">
<div className="perf-logs__controls">
<label className="perf-logs__control">
<span className="perf-logs__control-label">Depth</span>
<CustomSelect
value={loggingMode}
onChange={v => changeDepth(v as LoggingMode)}
options={DEPTH_OPTIONS}
/>
</label>
<label className="perf-logs__control">
<span className="perf-logs__control-label">Keep</span>
<CustomSelect
value={String(lineCap)}
onChange={v => setLineCap(Number(v))}
options={LINE_CAP_OPTIONS}
/>
</label>
<button
type="button"
className="perf-logs__btn"
onClick={() => setPaused(p => !p)}
aria-pressed={paused}
title={paused ? 'Resume live tail' : 'Pause live tail'}
>
{paused ? <Play size={14} /> : <Pause size={14} />}
{paused ? 'Resume' : 'Pause'}
</button>
<button type="button" className="perf-logs__btn" onClick={clear} title="Clear view">
<Trash2 size={14} />
Clear
</button>
</div>
<input
type="text"
className="perf-logs__filter"
placeholder="Filter: word to include, -word to exclude, comma-separated (order matters)"
value={filter}
onChange={e => setFilter(e.target.value)}
spellCheck={false}
/>
<div
className="perf-logs__view"
ref={scrollRef}
onScroll={onScroll}
role="log"
aria-live="off"
>
{visible.length === 0 ? (
<div className="perf-logs__empty">
{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.'}
</div>
) : (
visible.map(line => (
<div key={line.seq} data-seq={line.seq} className="perf-logs__line">
{line.text}
</div>
))
)}
</div>
<div className="perf-logs__status">
<span>
{visible.length.toLocaleString()} shown · {lines.length.toLocaleString()} buffered
{overflowed && ' · buffer overflowed (oldest dropped)'}
</span>
{!follow && (
<button
type="button"
className="perf-logs__jump"
onClick={jumpToLatest}
>
Jump to latest
</button>
)}
</div>
</div>
);
}
+1
View File
@@ -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)',
],
},
{
+127
View File
@@ -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;
+60
View File
@@ -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',
]);
});
});
+62
View File
@@ -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<LogFilterToken | null>(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<T extends { text: string }>(
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<boolean>(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]);
}