import { toast } from "@unom/ui/toast"; import { Copy, Download, Pause, Play, Share2, Trash2 } from "lucide-react"; import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { useLogsGet } from "@/api/gen/logs/logs"; import type { LogEntry } from "@/api/gen/model/logEntry"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { m } from "@/paraglide/messages"; import { detectShareMode, downloadText, logFilename, logsToText, type ShareMode, shareLogs, } from "./export"; const LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const; type MinLevel = (typeof LEVELS)[number]; const RANK: Record = { TRACE: 0, DEBUG: 1, INFO: 2, WARN: 3, ERROR: 4, }; const LEVEL_CLASS: Record = { ERROR: "text-red-400", WARN: "text-amber-400", INFO: "text-sky-300", DEBUG: "text-muted-foreground", TRACE: "text-muted-foreground", }; const KEEP = 5_000; // accumulated entries (client memory bound) const SHOW = 1_000; // rendered rows (DOM bound) /** * Producer filter. The ring carries the host's own `tracing` events AND whatever the plugin runner * ships up (`POST /api/v1/plugins/logs`), the latter targeted `plugin:`. Without this the two * are interleaved with nothing but the target column to tell them apart, and "show me what my * plugin said" — the question that sends people to `journalctl` — means knowing to type `plugin:` * into the search box. */ const SOURCES = ["all", "host", "plugins"] as const; type Source = (typeof SOURCES)[number]; /** The target prefix the host stamps on every runner-shipped line. */ const PLUGIN_TARGET_PREFIX = "plugin:"; const matchesSource = (target: string, source: Source): boolean => source === "all" || (source === "plugins") === target.startsWith(PLUGIN_TARGET_PREFIX); const SOURCE_LABEL: Record string> = { all: () => m.logs_source_all(), host: () => m.logs_source_host(), plugins: () => m.logs_source_plugins(), }; /** * Container: cursor-paged log polling. A non-empty page advances the cursor — a new query key, * so the next page fetches immediately and a backlog drains fast; an empty page leaves the key * unchanged and `refetchInterval` paces the idle poll. Pausing (follow off) stops the interval. */ export const LogsSection: FC = () => { const [cursor, setCursor] = useState(0); const [entries, setEntries] = useState([]); const [follow, setFollow] = useState(true); const [dropped, setDropped] = useState(false); const [shareMode, setShareMode] = useState(null); // Set while a poll has failed and we have not yet re-read the ring from the start. const [resync, setResync] = useState(false); // Probed after mount: the server render has no `navigator`, and guessing there would mismatch // on hydration. Until then the share button is simply absent. useEffect(() => { setShareMode(detectShareMode()); }, []); const query = useLogsGet( { after: cursor > 0 ? cursor : undefined }, { query: { refetchInterval: follow ? 2_000 : false, // Pausing must actually pause. Stopping only the interval left React Query's default // focus/reconnect refetches landing, and the append effect consumed them // unconditionally — so tabbing away and back evicted the lines the operator had // paused on, from behind the pause button. refetchOnWindowFocus: follow, refetchOnReconnect: follow, }, }, ); // Resync after the host goes away and comes back. // // The host's log ring restarts at seq 1 on every restart, while our cursor stays wherever it // got to. `GET /logs?after=8000` against a fresh ring is not an error — it is a permanently // EMPTY page (`next` echoes `after`), so the page would poll forever showing stale lines with // no error, no dropped badge and no way back short of a full reload. The console's own update // flow restarts the host, so this was reachable from two clicks away. // // A restart always breaks the poll first, so a failed query is the trigger: on the next success // we re-read from the start of the ring once and let the effect below decide whether the // sequence actually regressed. const failed = query.isError; useEffect(() => { if (failed) setResync(true); }, [failed]); useEffect(() => { if (resync && cursor !== 0) setCursor(0); }, [resync, cursor]); const data = query.data; useEffect(() => { if (!data || data.entries.length === 0) return; setEntries((prev) => { const lastSeq = prev.at(-1)?.seq ?? -1; // A page whose newest entry is OLDER than what we already hold can only mean the host's // sequence restarted underneath us — the buffer describes a host that no longer exists, // so replace it wholesale rather than filtering every new line away as "already seen". const newest = data.entries.at(-1)?.seq ?? -1; if (newest < lastSeq) return data.entries.slice(-KEEP); // Otherwise append only what's newer — dedup by the monotonic `seq`. Guards a // double-invoked mount effect (React StrictMode, or `data` warm in cache) from appending // the same page twice (duplicate rows + duplicate React keys), and makes the post-resync // re-read from 0 a no-op when the host did NOT restart. const fresh = data.entries.filter((e) => e.seq > lastSeq); return fresh.length ? [...prev, ...fresh].slice(-KEEP) : prev; }); setDropped((d) => d || data.dropped); setCursor(data.next); setResync(false); }, [data]); // The card hands back the entries its filters currently match, so an export carries exactly what // the viewer shows — never the DOM-bounded tail of it. return ( { setEntries([]); setDropped(false); }} onDownload={(shown) => downloadText(logsToText(shown), logFilename(new Date())) } onShare={async (shown) => { const outcome = await shareLogs( logsToText(shown), logFilename(new Date()), ); if (outcome === "copied") toast.success(m.logs_copied()); else if (outcome === "failed") toast.error(m.logs_share_failed()); }} shareMode={shareMode} dropped={dropped} error={query.error} isLoading={query.isLoading} onRetry={() => query.refetch()} /> ); }; /** * Pure log viewer: level/min filter + text search (local UI state), follow, clear, and export. * Export is the filters' full result, not the rendered tail — the `SHOW` cap is a DOM budget and * has no business truncating a file destined for a bug report. */ export const LogsCard: FC<{ entries: LogEntry[]; follow: boolean; onFollow: (follow: boolean) => void; onClear: () => void; onDownload: (shown: LogEntry[]) => void; onShare: (shown: LogEntry[]) => void; shareMode: ShareMode | null; dropped: boolean; /** The poll's failure, if any — without it a broken /logs is indistinguishable from a quiet host. */ error?: unknown; isLoading?: boolean; onRetry?: () => void; }> = ({ entries, follow, onFollow, onClear, onDownload, onShare, shareMode, dropped, error, isLoading, onRetry, }) => { const [minLevel, setMinLevel] = useState("DEBUG"); const [source, setSource] = useState("all"); const [search, setSearch] = useState(""); const listRef = useRef(null); const matched = useMemo(() => { const min = RANK[minLevel] ?? 0; const q = search.trim().toLowerCase(); return entries.filter( (e) => (RANK[e.level] ?? 0) >= min && matchesSource(e.target, source) && (q === "" || e.msg.toLowerCase().includes(q) || e.target.toLowerCase().includes(q)), ); }, [entries, minLevel, source, search]); const visible = useMemo(() => matched.slice(-SHOW), [matched]); const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy(); // Keep the tail in view while following. // // Keyed on the newest RENDERED seq, not on `visible.length`: `visible` is `matched.slice(-SHOW)`, // so once the filter matches SHOW rows its length is pinned at SHOW forever. The effect then // stopped re-running and follow-mode quietly stopped following — exactly when the log is busy // enough to need it. The newest seq keeps changing for as long as lines arrive. const newestVisible = visible.at(-1)?.seq ?? -1; // NOTE: biome flags `newestVisible` as an unnecessary dependency (it is not read in the body) and // offers to remove it. Do NOT take that fix — it is a TRIGGER, the signal that new lines arrived. // Removing it reinstates the bug this replaced: the effect stops re-running and follow-mode // quietly stops following. The same warning was here before, on `visible.length`. useEffect(() => { if (!follow) return; const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [follow, newestVisible]); return ( {/* No CardHeader here, and that no longer needs saying: CardContent keeps its top inset unless something precedes it. This card used to restore it by hand at both breakpoints. */}
{LEVELS.map((l) => ( ))}
{SOURCES.map((s) => ( ))}
setSearch(e.target.value)} placeholder={m.logs_search()} className="max-w-xs" />
{dropped && {m.logs_dropped()}} {shareMode && ( )}
{/* A failing poll while lines are already on screen keeps them there — during a host restart the last lines before it went away are the interesting ones — but says so, instead of letting a frozen view read as a quiet host. */} {error != null && entries.length > 0 && (

{m.logs_stalled()}

)}
{visible.length === 0 ? ( // An empty list has three quite different causes and used to render one sentence // for all of them: the host is quiet, the request failed, or it hasn't answered yet.
{error ? (

{m.common_error()}

{onRetry && ( )}
) : (

{isLoading ? m.common_loading() : // "No plugin output" has a specific, actionable cause that the generic // "adjust the filter" line actively misdirects from: the runner is a // separate service and is opt-in on Linux, so the usual reason for an // empty Plugins view is that it simply isn't running. source === "plugins" ? m.logs_empty_plugins() : m.logs_empty()}

)}
) : ( visible.map((e) => (
{fmtTime(e.ts_ms)}{" "} {e.level.padEnd(5)}{" "} {e.target} {e.msg}
)) )}
); }; const fmtTime = (ts: number): string => { const d = new Date(ts); const p = (n: number, w = 2) => String(n).padStart(w, "0"); return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`; };