1 MB (~6500 lines) fetched, split and re-rendered every second made the window lag. 512 KB (~3200 lines) keeps a useful backlog — double the original 256 KB — while staying responsive.
144 lines
6.6 KiB
TypeScript
144 lines
6.6 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
|
|
|
// LogViewer shows the tail of opslog.log and keeps it current while open.
|
|
//
|
|
// This exists because diagnosing a problem used to mean asking the operator to
|
|
// find a file in their data directory, open it in a text editor, and re-open it
|
|
// after every attempt. Half the round-trips in a bug report were spent on that.
|
|
//
|
|
// Three behaviours matter more than they look:
|
|
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
|
|
// moment they scroll up — otherwise the line they are reading is yanked away
|
|
// a second later, which defeats the one thing this window is for. Scrolling
|
|
// back to the bottom re-arms it, so following is never a dead end.
|
|
// • The filter keeps only matching LINES rather than highlighting inside a
|
|
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
|
// the subsystem trace you would have grepped for.
|
|
// • It polls only while open. A closed dialog costs nothing.
|
|
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
|
|
// the oldest fall out of the fetched text entirely, so a small buffer dropped
|
|
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
|
|
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
|
|
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
|
|
// useful backlog while staying smooth. The backend caps this at 4 MB.
|
|
const TAIL_BYTES = 512 * 1024;
|
|
const POLL_MS = 1000;
|
|
|
|
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
|
const { t } = useI18n();
|
|
const [text, setText] = useState('');
|
|
const [path, setPath] = useState('');
|
|
const [query, setQuery] = useState('');
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
// Shown next to the checkbox so a QUIET log still proves it is being polled —
|
|
// without it there is no way to tell 'nothing new' from 'window is dead'.
|
|
const [lastPull, setLastPull] = useState('');
|
|
const boxRef = useRef<HTMLPreElement>(null);
|
|
const autoRef = useRef(true);
|
|
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
|
|
|
|
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
|
|
// straight away is not enough while the dialog is still animating in: the box
|
|
// has no height yet, scrollHeight is wrong, and the scroll silently does
|
|
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
|
|
// at lines from hours ago, which looks exactly like a frozen window.
|
|
const stick = useCallback(() => {
|
|
if (!autoRef.current) return;
|
|
requestAnimationFrame(() => {
|
|
const el = boxRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
});
|
|
}, []);
|
|
|
|
const pull = useCallback(() => {
|
|
TailLogFile(TAIL_BYTES).then((s: any) => {
|
|
setText(String(s ?? ''));
|
|
setLastPull(new Date().toLocaleTimeString());
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setAutoScroll(true);
|
|
autoRef.current = true;
|
|
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
|
|
pull();
|
|
// Re-assert the bottom after the open animation has settled. A quiet log
|
|
// never changes, so the content-driven scroll below would never fire again
|
|
// and the first attempt would be the only one.
|
|
const t1 = window.setTimeout(stick, 120);
|
|
const t2 = window.setTimeout(stick, 450);
|
|
const id = window.setInterval(pull, POLL_MS);
|
|
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
|
|
}, [open, pull, stick]);
|
|
|
|
const all = useMemo(() => text.split('\n'), [text]);
|
|
const shown = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return all;
|
|
return all.filter((l) => l.toLowerCase().includes(q));
|
|
}, [all, query]);
|
|
|
|
// Stick to the bottom after each refresh, but only while auto-scroll is on.
|
|
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
|
|
|
|
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
|
|
function onScroll() {
|
|
const el = boxRef.current;
|
|
if (!el) return;
|
|
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
|
|
}
|
|
|
|
const body = shown.join('\n');
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-5xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('logview.title')}</DialogTitle>
|
|
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
|
|
onChange={(e) => setQuery(e.target.value)} />
|
|
{query.trim() !== '' && (
|
|
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
|
{t('logview.matches', { n: shown.length, total: all.length })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<pre ref={boxRef} onScroll={onScroll}
|
|
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
|
|
{body.trim() === '' ? t('logview.empty') : body}
|
|
</pre>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
|
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
|
|
const on = !!c;
|
|
setAutoScroll(on);
|
|
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
|
|
}} />
|
|
{t('logview.autoScroll')}
|
|
</label>
|
|
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
|
|
<div className="flex-1" />
|
|
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
|
|
{t('logview.copy')}
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
|
|
{t('logview.toEnd')}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|