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
@@ -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>
);
}