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
+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]);
}