mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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:
@@ -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