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(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 ( {t('logview.title')} {path}
setQuery(e.target.value)} /> {query.trim() !== '' && ( {t('logview.matches', { n: shown.length, total: all.length })} )}
          {body.trim() === '' ? t('logview.empty') : body}
        
{lastPull && {t('logview.updated', { time: lastPull })}}
); }