"use client"; import { useEffect, useId, useMemo, useState, type CSSProperties, type KeyboardEvent, type ReactNode, } from "react"; import { AnimatePresence, motion } from "motion/react"; import { DescriptionList } from "../data/Records.tsx"; import { useReducedMotion } from "../motion/Preferences.tsx"; import { clamp, useAnnouncer, useControllable, useElementSize, useIsoLayoutEffect, useLatest, } from "./internal.ts"; export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLine = { id: string | number; /** Epoch milliseconds. */ time: number; level: LogLevel; message: string; source?: string; fields?: Record; }; type LineId = LogLine["id"]; const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"]; const ROW = 24; const OVERSCAN = 8; const BINS = 72; const pad = (n: number, width = 2) => String(n).padStart(width, "0"); function stamp(time: number, utc: boolean) { const d = new Date(time); const [h, m, s, ms] = utc ? [ d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds(), ] : [d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()]; return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(ms, 3)}`; } /** Splits `text` around case-insensitive matches of `query`, for highlighting. */ function marked(text: string, query: string): ReactNode { if (!query) return text; const out: ReactNode[] = []; const lower = text.toLowerCase(), q = query.toLowerCase(); let at = 0; for (let i = lower.indexOf(q); i >= 0; i = lower.indexOf(q, i + q.length)) { if (i > at) out.push(text.slice(at, i)); out.push({text.slice(i, i + q.length)}); at = i + q.length; } if (at < text.length) out.push(text.slice(at)); return out; } /** * A live log tail that only renders the lines in view. It follows new lines * until you scroll away, then counts what you missed; filter by level, * search with highlighted matches, and read any line in full. */ export function LogStream({ label, lines, selected, defaultSelected = null, onSelectedChange, loading = false, empty = "No log lines yet.", utc = false, className = "", style, }: { label: string; /** Oldest first. Append to it as lines arrive; trim it to bound memory. */ lines: readonly LogLine[]; selected?: LineId | null; defaultSelected?: LineId | null; onSelectedChange?: (id: LineId | null) => void; loading?: boolean; empty?: ReactNode; /** Show times in UTC rather than local time. */ utc?: boolean; className?: string; style?: CSSProperties; }) { const id = useId(); const reduce = useReducedMotion(); const [scrollerRef, { height }] = useElementSize(); const [current, setCurrent] = useControllable( selected, defaultSelected, onSelectedChange, ); const [levels, setLevels] = useState>( () => new Set(LEVELS), ); const [query, setQuery] = useState(""); const [match, setMatch] = useState(0); const [top, setTop] = useState(0); const [following, setFollowing] = useState(true); // Positions are kept as line ids, so trimming old lines never skews the counts. const lastId = lines.length ? lines[lines.length - 1]!.id : null; const [pausedAfter, setPausedAfter] = useState(null); const [freshAfter, setFreshAfter] = useState(lastId); const [message, announce] = useAnnouncer(); const shown = useMemo( () => lines.filter((l) => levels.has(l.level)), [lines, levels], ); const matches = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; const out: number[] = []; shown.forEach((l, i) => { if ( l.message.toLowerCase().includes(q) || l.source?.toLowerCase().includes(q) ) out.push(i); }); return out; }, [shown, query]); const counts = useMemo(() => { const c: Record = { debug: 0, info: 0, warn: 0, error: 0, }; for (const l of lines) c[l.level]++; return c; }, [lines]); // Overview: lines over time, stacked by level so a spike of errors stands out. const overview = useMemo(() => { if (lines.length < 2) return null; const start = lines[0]!.time, span = Math.max(1, lines[lines.length - 1]!.time - start); const bins = Array.from({ length: BINS }, () => ({ total: 0, warn: 0, error: 0, })); for (const l of lines) { const bin = bins[Math.min(BINS - 1, Math.floor(((l.time - start) / span) * BINS))]!; bin.total++; if (l.level === "warn" || l.level === "error") bin[l.level]++; } return { start, span, bins, most: Math.max(...bins.map((b) => b.total)) }; }, [lines]); const index = current === null ? -1 : shown.findIndex((l) => l.id === current); const first = Math.max(0, Math.floor(top / ROW) - OVERSCAN); const last = Math.min( shown.length, Math.ceil((top + height) / ROW) + OVERSCAN, ); const after = (lineId: LineId | null) => { if (lineId === null) return lines.length; for (let i = lines.length - 1; i >= 0; i--) if (lines[i]!.id === lineId) return lines.length - 1 - i; return lines.length; }; const unseen = following ? 0 : after(pausedAfter); const fresh = new Set( lines.slice(lines.length - after(freshAfter)).map((l) => l.id), ); const unseenRef = useLatest(unseen); // Follow the tail: new lines keep the newest in view, unless a line is open. useIsoLayoutEffect(() => { const el = scrollerRef.current; if (el && following && current === null) el.scrollTop = el.scrollHeight; }, [shown.length, following, height, current]); // New lines glow briefly as they arrive. useEffect(() => { if (lastId === freshAfter) return; const settle = setTimeout(() => setFreshAfter(lastId), 1200); return () => clearTimeout(settle); }, [lastId, freshAfter]); // While paused, missed lines are announced at most every five seconds. useEffect(() => { if (following) return; let said = 0; const every = setInterval(() => { const n = unseenRef.current; if (n && n !== said) { said = n; announce(`${n} new ${n === 1 ? "line" : "lines"}`); } }, 5000); return () => clearInterval(every); }, [following, announce, unseenRef]); const pause = () => { if (!following) return; setFollowing(false); setPausedAfter(lastId); }; const resume = () => { setFollowing(true); setFreshAfter(lastId); }; const reveal = (i: number) => { const el = scrollerRef.current; if (!el) return; const y = i * ROW; if (y < el.scrollTop) el.scrollTop = y; else if (y + ROW > el.scrollTop + el.clientHeight) el.scrollTop = y + ROW - el.clientHeight; }; // A line chosen elsewhere, say from a synchronised timeline, comes into view. useIsoLayoutEffect(() => { if (index >= 0) reveal(index); // Only when the selection itself changes. }, [current]); const choose = (i: number) => { const line = shown[clamp(i, 0, shown.length - 1)]; if (!line) return; setCurrent(line.id); pause(); reveal(shown.indexOf(line)); }; const jumpToMatch = (step: number) => { if (!matches.length) return; const next = (match + step + matches.length) % matches.length; setMatch(next); choose(matches[next]!); }; const onKey = (e: KeyboardEvent) => { const page = Math.max(1, Math.floor(height / ROW) - 1); const keys: Record void> = { ArrowDown: () => choose(index + 1), ArrowUp: () => choose(index < 0 ? shown.length - 1 : index - 1), PageDown: () => choose(index + page), PageUp: () => choose(Math.max(0, index - page)), Home: () => choose(0), End: () => { setCurrent(null); resume(); }, Escape: () => setCurrent(null), }; const action = keys[e.key]; if (!action) return; e.preventDefault(); action(); }; const line = index >= 0 ? shown[index] : undefined; const matchAt = matches.length ? matches[Math.min(match, matches.length - 1)] : -1; const rowId = (i: number) => `${id}-row-${i}`; const rendered = index >= first && index < last; return (
{LEVELS.map((level) => (