mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user