9d14e63613
Add an Application Logs window backed by a bounded persistent main log file. The viewer loads history from lanspread.log, subscribes to live INFO/WARN/ERROR log events, supports filtering/copy/pause controls, and keeps the menu/window routing separate from the unpack log viewer. The backend sink now owns serialized access to the log file. History reads and append-time trimming use the same sink lock, so opening the logs window cannot race with a concurrent write and rewrite away a freshly appended line. The sink also keeps a persistent file handle instead of reopening the file for each captured event. Live log events carry sink-local sequence ids. The frontend uses the history watermark plus returned history line counts to suppress live events that were already included in the history response, while preserving buffered rows that were trimmed out of the history file. Auto-scroll now follows the last visible row identity, so it continues following after the in-memory cap keeps the row count stable. No timestamp code change was needed. On the Linux dev host, a temporary probe showed time::OffsetDateTime::now_local() returning +02:00 while UTC was +00:00, matching the host CEST offset. Test Plan: - just fmt - just frontend-test - just test - just clippy - just build - git diff --cached --check - temporary Linux probe of OffsetDateTime::now_local() showed local +02:00 Refs: none
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { listen } from '@tauri-apps/api/event';
|
|
|
|
import { SegmentedRadio } from './components/SegmentedRadio';
|
|
import {
|
|
capLogRows,
|
|
consumeLoadedHistoryRow,
|
|
dedupeBufferedRows,
|
|
formatCount,
|
|
LEVEL_FILTER_MIN,
|
|
LEVEL_FILTER_OPTIONS,
|
|
LEVEL_ORDER,
|
|
lineCountsFromRows,
|
|
type LevelFilter,
|
|
type MainLogHistoryPayload,
|
|
type MainLogLinePayload,
|
|
type MainLogRow,
|
|
rowFromPayload,
|
|
rowsFromHistory,
|
|
} from './lib/mainLogs';
|
|
|
|
import './MainLogsWindow.css';
|
|
|
|
export const isMainLogsView = (): boolean =>
|
|
new URLSearchParams(window.location.search).get('view') === 'main-logs';
|
|
|
|
export const MainLogsWindow = () => {
|
|
const [logs, setLogs] = useState<MainLogRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [regexInput, setRegexInput] = useState('');
|
|
const [levelFilter, setLevelFilter] = useState<LevelFilter>('all');
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
const [paused, setPaused] = useState(false);
|
|
const [pausedBufferCount, setPausedBufferCount] = useState(0);
|
|
const [copyStatus, setCopyStatus] = useState<string | null>(null);
|
|
|
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
const historyLoadedRef = useRef(false);
|
|
const initialBufferRef = useRef<MainLogRow[]>([]);
|
|
const pausedBufferRef = useRef<MainLogRow[]>([]);
|
|
const pausedRef = useRef(false);
|
|
const lastHistorySequenceRef = useRef(0);
|
|
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
|
|
|
|
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
|
|
setLogs(current => capLogRows([...current, ...rows]));
|
|
}, []);
|
|
|
|
const bufferPausedRows = useCallback((rows: MainLogRow[]) => {
|
|
pausedBufferRef.current = capLogRows([...pausedBufferRef.current, ...rows]);
|
|
setPausedBufferCount(pausedBufferRef.current.length);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
const handleIncomingRow = (row: MainLogRow) => {
|
|
if (!historyLoadedRef.current) {
|
|
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
|
|
return;
|
|
}
|
|
if (
|
|
consumeLoadedHistoryRow(
|
|
historyLineCountsRef.current,
|
|
row,
|
|
lastHistorySequenceRef.current,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (pausedRef.current) {
|
|
bufferPausedRows([row]);
|
|
return;
|
|
}
|
|
appendVisibleRows([row]);
|
|
};
|
|
|
|
const setup = async () => {
|
|
try {
|
|
unlisten = await listen<MainLogLinePayload>('main-log-line', event => {
|
|
handleIncomingRow(rowFromPayload(event.payload));
|
|
});
|
|
|
|
const history = await invoke<MainLogHistoryPayload>('get_main_logs');
|
|
if (cancelled) return;
|
|
|
|
lastHistorySequenceRef.current = history.lastSequence;
|
|
const historyRows = rowsFromHistory(history.contents);
|
|
const historyLineCounts = lineCountsFromRows(historyRows);
|
|
const liveRows = dedupeBufferedRows(
|
|
historyLineCounts,
|
|
initialBufferRef.current,
|
|
lastHistorySequenceRef.current,
|
|
);
|
|
initialBufferRef.current = [];
|
|
historyLineCountsRef.current = historyLineCounts;
|
|
historyLoadedRef.current = true;
|
|
|
|
if (pausedRef.current) {
|
|
setLogs(capLogRows(historyRows));
|
|
bufferPausedRows(liveRows);
|
|
} else {
|
|
setLogs(capLogRows([...historyRows, ...liveRows]));
|
|
}
|
|
setLoadError(null);
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
historyLoadedRef.current = true;
|
|
setLoadError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void setup();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
historyLoadedRef.current = false;
|
|
initialBufferRef.current = [];
|
|
lastHistorySequenceRef.current = 0;
|
|
historyLineCountsRef.current = new Map();
|
|
unlisten?.();
|
|
};
|
|
}, [appendVisibleRows, bufferPausedRows]);
|
|
|
|
const { regex, regexError } = useMemo(() => {
|
|
if (!regexInput) {
|
|
return { regex: null as RegExp | null, regexError: null as string | null };
|
|
}
|
|
try {
|
|
return { regex: new RegExp(regexInput, 'i'), regexError: null };
|
|
} catch (e) {
|
|
return { regex: null, regexError: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}, [regexInput]);
|
|
|
|
const filteredRows = useMemo(() => {
|
|
const minLevel = LEVEL_FILTER_MIN[levelFilter];
|
|
return logs.filter(row => {
|
|
if (LEVEL_ORDER[row.level] < minLevel) return false;
|
|
return regex ? regex.test(row.line) : true;
|
|
});
|
|
}, [levelFilter, logs, regex]);
|
|
|
|
const lastVisibleRow = filteredRows.length > 0 ? filteredRows[filteredRows.length - 1] : null;
|
|
|
|
useEffect(() => {
|
|
if (!autoScroll) return;
|
|
const viewport = viewportRef.current;
|
|
if (!viewport) return;
|
|
|
|
requestAnimationFrame(() => {
|
|
viewport.scrollTop = viewport.scrollHeight;
|
|
});
|
|
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
|
|
|
|
const flushPausedRows = useCallback(() => {
|
|
const buffered = pausedBufferRef.current;
|
|
if (buffered.length === 0) return;
|
|
pausedBufferRef.current = [];
|
|
setPausedBufferCount(0);
|
|
appendVisibleRows(buffered);
|
|
}, [appendVisibleRows]);
|
|
|
|
const togglePaused = useCallback(() => {
|
|
if (paused) {
|
|
pausedRef.current = false;
|
|
setPaused(false);
|
|
flushPausedRows();
|
|
return;
|
|
}
|
|
|
|
pausedRef.current = true;
|
|
setPaused(true);
|
|
}, [flushPausedRows, paused]);
|
|
|
|
const clearLogs = useCallback(() => {
|
|
setLogs([]);
|
|
initialBufferRef.current = [];
|
|
pausedBufferRef.current = [];
|
|
setPausedBufferCount(0);
|
|
}, []);
|
|
|
|
const copyFilteredLogs = useCallback(async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n'));
|
|
setCopyStatus('Copied');
|
|
} catch {
|
|
setCopyStatus('Copy failed');
|
|
}
|
|
window.setTimeout(() => setCopyStatus(null), 1600);
|
|
}, [filteredRows]);
|
|
|
|
return (
|
|
<main className="main-log-window">
|
|
<div className="main-log-header">
|
|
<h1>Application Logs</h1>
|
|
<div className="main-log-controls">
|
|
<label className="main-log-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoScroll}
|
|
onChange={(e) => setAutoScroll(e.target.checked)}
|
|
/>
|
|
Auto-scroll
|
|
</label>
|
|
<button className="settings-button" onClick={togglePaused}>
|
|
{paused ? 'Resume' : 'Pause'}
|
|
</button>
|
|
<button className="settings-button" onClick={clearLogs} disabled={logs.length === 0}>
|
|
Clear
|
|
</button>
|
|
<button
|
|
className="settings-button"
|
|
onClick={() => void copyFilteredLogs()}
|
|
disabled={filteredRows.length === 0}
|
|
>
|
|
Copy
|
|
</button>
|
|
{copyStatus && <span className="main-log-copy-status">{copyStatus}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="main-log-filter-row">
|
|
<div className="main-log-level-filter">
|
|
<SegmentedRadio
|
|
value={levelFilter}
|
|
options={LEVEL_FILTER_OPTIONS}
|
|
onChange={setLevelFilter}
|
|
/>
|
|
</div>
|
|
<input
|
|
className={`unpack-log-regex main-log-regex ${regexError ? 'invalid' : ''}`}
|
|
type="text"
|
|
placeholder="Filter lines by regex (case-insensitive)..."
|
|
value={regexInput}
|
|
onChange={(e) => setRegexInput(e.target.value)}
|
|
title={regexError ?? ''}
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
|
|
{regexError && (
|
|
<div className="unpack-log-regex-error">regex error: {regexError}</div>
|
|
)}
|
|
{loadError && (
|
|
<div className="main-log-load-error">load error: {loadError}</div>
|
|
)}
|
|
|
|
<div className="main-log-stats">
|
|
{loading ? 'loading' : `showing ${formatCount(filteredRows.length, 'line')} of ${formatCount(logs.length, 'line')}`}
|
|
{paused && pausedBufferCount > 0 && ` - ${formatCount(pausedBufferCount, 'paused line')}`}
|
|
</div>
|
|
|
|
<section ref={viewportRef} className="main-log-viewport" aria-live={paused ? 'off' : 'polite'}>
|
|
{filteredRows.length === 0 ? (
|
|
<div className="main-log-empty">
|
|
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
|
|
</div>
|
|
) : filteredRows.map(row => (
|
|
<div
|
|
key={row.id}
|
|
className={`main-log-line level-${row.level.toLowerCase()}`}
|
|
>
|
|
{row.line}
|
|
</div>
|
|
))}
|
|
</section>
|
|
</main>
|
|
);
|
|
};
|