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