"use client"; import { ArrowDownIcon, ChevronRightIcon, PauseIcon, PlayIcon, SearchIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type LogLevel = "debug" | "info" | "warn" | "error"; export interface LogLine { id: string; ts: number; level: LogLevel; service: string; message: string; } export interface LogStreamProps { initialLines?: LogLine[]; subscribe?: (push: (line: LogLine) => void) => () => void; limit?: number; foldAfter?: number; title?: string; className?: string; } const LEVELS: { id: LogLevel; label: string; short: string; text: string; dot: string }[] = [ { id: "debug", label: "Debug", short: "DBG", text: "text-muted-foreground", dot: "bg-muted-foreground/60" }, { id: "info", label: "Info", short: "INF", text: "text-info-foreground", dot: "bg-info" }, { id: "warn", label: "Warn", short: "WRN", text: "text-warning-foreground", dot: "bg-warning" }, { id: "error", label: "Error", short: "ERR", text: "text-destructive-foreground", dot: "bg-destructive" }, ]; const levelInfo = Object.fromEntries(LEVELS.map((l) => [l.id, l])) as Record; const template = (m: string) => m.replace(/\b[0-9a-f]{6,}\b/gi, "#").replace(/\d+(\.\d+)?/g, "#"); const pad = (n: number, w = 2) => String(n).padStart(w, "0"); const clock = (ts: number) => { const d = new Date(ts); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; }; function Mark({ text, q }: { text: string; q: string }) { if (!q) return <>{text}; const out: ReactNode[] = []; const lower = text.toLowerCase(); let at = 0; let hit = lower.indexOf(q); while (hit !== -1) { out.push(text.slice(at, hit)); out.push( {text.slice(hit, hit + q.length)} , ); at = hit + q.length; hit = lower.indexOf(q, at); } out.push(text.slice(at)); return <>{out}; } type Item = { kind: "line"; line: LogLine } | { kind: "group"; id: string; lines: LogLine[] }; function Row({ line, q, nested, fresh }: { line: LogLine; q: string; nested?: boolean; fresh: boolean }) { const reduce = useReducedMotion(); const lvl = levelInfo[line.level]; return ( {clock(line.ts)} {lvl.short} {line.service} ); } export function LogStream({ initialLines = [], subscribe, limit = 500, foldAfter = 3, title = "Logs", className }: LogStreamProps) { const reduce = useReducedMotion(); const [lines, setLines] = useState(initialLines); const [shownCount, setShownCount] = useState(initialLines.length); const [paused, setPaused] = useState(false); const [follow, setFollow] = useState(true); const [levels, setLevels] = useState>(new Set(["debug", "info", "warn", "error"])); const [query, setQuery] = useState(""); const [open, setOpen] = useState>(new Set()); const [rate, setRate] = useState(0); const listRef = useRef(null); const seen = useRef(new Set(initialLines.map((l) => l.id))); const pausedRef = useRef(paused); pausedRef.current = paused; useEffect(() => { if (!subscribe) return; setLines(initialLines); return subscribe((line) => { setLines((ls) => { const next = [...ls, line]; return next.length > limit ? next.slice(next.length - limit) : next; }); }); }, [subscribe, limit]); const linesRef = useRef(lines); linesRef.current = lines; useEffect(() => { const t = setInterval(() => { const now = Date.now(); const ls = linesRef.current; let n = 0; for (let i = ls.length - 1; i >= 0 && now - ls[i].ts < 5000; i--) n++; setRate(n / 5); }, 1000); return () => clearInterval(t); }, []); useEffect(() => { if (!paused) setShownCount(lines.length); }, [lines.length, paused]); const visible = paused ? lines.slice(0, Math.min(shownCount, lines.length)) : lines; const newWhilePaused = paused ? Math.max(0, lines.length - shownCount) : 0; const q = query.trim().toLowerCase(); const counts = useMemo(() => { const c: Record = { debug: 0, info: 0, warn: 0, error: 0 }; for (const l of visible) c[l.level]++; return c; }, [visible]); const filtered = useMemo( () => visible.filter((l) => levels.has(l.level) && (!q || l.message.toLowerCase().includes(q) || l.service.includes(q))), [visible, levels, q], ); const items = useMemo(() => { const out: Item[] = []; let i = 0; while (i < filtered.length) { const first = filtered[i]; const key = `${first.level}|${first.service}|${template(first.message)}`; let j = i + 1; while (j < filtered.length && `${filtered[j].level}|${filtered[j].service}|${template(filtered[j].message)}` === key) j++; if (j - i >= foldAfter) out.push({ kind: "group", id: first.id, lines: filtered.slice(i, j) }); else for (let k = i; k < j; k++) out.push({ kind: "line", line: filtered[k] }); i = j; } return out; }, [filtered, foldAfter]); const freshIds = useMemo(() => { const fresh = new Set(); for (const l of filtered) if (!seen.current.has(l.id)) fresh.add(l.id); return fresh; }, [filtered]); useEffect(() => { for (const id of freshIds) seen.current.add(id); }, [freshIds]); const [behind, setBehind] = useState(0); const lastLen = useRef(filtered.length); useLayoutEffect(() => { const el = listRef.current; const added = filtered.length - lastLen.current; lastLen.current = filtered.length; if (!el) return; if (follow && !paused) { el.scrollTop = el.scrollHeight; setBehind(0); } else if (added > 0 && !paused) setBehind((b) => b + added); }, [filtered.length, follow, paused, items.length]); const jump = () => { setPaused(false); setFollow(true); setBehind(0); requestAnimationFrame(() => { const el = listRef.current; if (el) el.scrollTo({ top: el.scrollHeight, behavior: reduce ? "auto" : "smooth" }); }); }; const toggleLevel = (lvl: LogLevel) => setLevels((s) => { const n = new Set(s); if (n.has(lvl) && n.size > 1) n.delete(lvl); else n.add(lvl); return n; }); const pending = paused ? newWhilePaused : behind; return (
{LEVELS.map((l) => { const on = levels.has(l.id); return ( ); })}
{ const el = e.currentTarget; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 12; if (atBottom !== follow) setFollow(atBottom); if (atBottom) setBehind(0); }} ref={listRef} role="log" tabIndex={0} > {items.length === 0 && (

{q ? `No lines match “${query.trim()}”` : "Waiting for logs…"}

{q ? "Try fewer words or turn on more levels." : "New lines show up here as they arrive."}

)} {items.map((item) => { if (item.kind === "line") return ; const isOpen = open.has(item.id); const last = item.lines[item.lines.length - 1]; const lvl = levelInfo[last.level]; const freshGroup = item.lines.some((l) => freshIds.has(l.id)); return (
{isOpen && ( {item.lines.map((l) => ( ))} )}
); })}
{pending > 0 && ( {pending} new {pending === 1 ? "line" : "lines"} )}
{!paused && } {paused ? "Paused · buffering" : follow ? "Live · following" : "Live · scrolled back"} · {rate.toFixed(1)} lines/s {q ? `${filtered.length} of ${visible.length}` : `${lines.length.toLocaleString("en-US")} lines`}
); }