'use client' import { useMemo, useRef, useState, type ReactNode } from 'react' import { cn } from '../../lib/cn' import { useIsomorphicLayoutEffect } from '../../lib/layout-effect' import { Input } from '../Input' import { Switch } from '../Switch' import { CopyIcon, SearchIcon } from '../internal/icons' export type LogViewerLevel = 'debug' | 'info' | 'warn' | 'error' export interface LogViewerLine { /** Stable key. Defaults to the position. */ id?: string | number timestamp: Date | number | string level: LogViewerLevel message: string /** Service or logger name, shown before the message and searched. */ source?: string } export interface LogViewerProps { /** Every line, oldest first. Append to stream. */ lines: LogViewerLine[] /** Names the log region. */ label?: string /** Height of the scrolling log, in pixels. */ height?: number /** Levels shown at first. */ defaultLevels?: LogViewerLevel[] /** Start pinned to the newest line. */ defaultFollow?: boolean /** Start with long lines wrapped. */ defaultWrap?: boolean /** Merged last, so it wins. */ className?: string } const LEVELS: LogViewerLevel[] = ['debug', 'info', 'warn', 'error'] const CHIP: Record = { debug: 'bg-surface-muted text-ink-soft', info: 'bg-accent-soft text-ink', warn: 'bg-[color-mix(in_oklab,var(--color-warning)_26%,transparent)] text-ink', error: 'bg-[color-mix(in_oklab,var(--color-danger)_18%,transparent)] text-danger', } const ROW = 22 const OVERSCAN = 12 /** Wrapped lines have no fixed height to window by, so wrapping shows the newest this many. */ const WRAP_LIMIT = 1000 const pad = (value: number, size = 2) => String(value).padStart(size, '0') const clock = (stamp: LogViewerLine['timestamp']) => { const date = stamp instanceof Date ? stamp : new Date(stamp) return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` } function highlight(text: string, needle: string): ReactNode { if (!needle) return text const lower = text.toLowerCase() const parts: ReactNode[] = [] let from = 0 for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, from)) { parts.push(text.slice(from, at), {text.slice(at, at + needle.length)}) from = at + needle.length } parts.push(text.slice(from)) return parts } /** * A log tail: timestamps, level chips, search, and a follow mode that knows * when to stop. * * Following is the default and the trap. A viewer that keeps jumping to the * newest line makes it impossible to read the error that just scrolled past, * so scrolling up pauses following, the footer counts the lines that arrived * since, and one button jumps back. Nothing re-pins itself behind the reader. * * Unwrapped lines are one fixed height, so only the rows in view are rendered * and five thousand lines cost what forty do. Wrapping gives up the fixed * height, so it shows the newest thousand matching lines and says so, rather * than pretending to window rows it cannot measure. Search filters to matching * lines and marks each match; level toggles show their counts. */ export function LogViewer({ lines, label = 'Logs', height = 360, defaultLevels = LEVELS, defaultFollow = true, defaultWrap = false, className, }: LogViewerProps) { const scroller = useRef(null) const [levels, setLevels] = useState(() => new Set(defaultLevels)) const [query, setQuery] = useState('') const [follow, setFollow] = useState(defaultFollow) const [wrap, setWrap] = useState(defaultWrap) const [scrollTop, setScrollTop] = useState(0) const [pausedAt, setPausedAt] = useState(0) const [announcement, setAnnouncement] = useState('') const needle = query.trim().toLowerCase() const counts = useMemo(() => { const tally: Record = { debug: 0, info: 0, warn: 0, error: 0 } lines.forEach((line) => tally[line.level]++) return tally }, [lines]) const shown = useMemo( () => lines .map((line, index) => ({ line, index })) .filter(({ line }) => levels.has(line.level) && (!needle || `${line.source ?? ''} ${line.message}`.toLowerCase().includes(needle))), [lines, levels, needle], ) const rows = wrap ? shown.slice(-WRAP_LIMIT) : shown useIsomorphicLayoutEffect(() => { const node = scroller.current if (!follow || !node) return node.scrollTop = node.scrollHeight setScrollTop(node.scrollTop) }, [follow, shown.length, wrap]) const onScroll = () => { const node = scroller.current if (!node) return setScrollTop(node.scrollTop) const fromBottom = node.scrollHeight - node.scrollTop - node.clientHeight if (follow && fromBottom > ROW * 2) { setFollow(false) setPausedAt(shown.length) } } const setFollowing = (on: boolean) => { setFollow(on) if (!on) setPausedAt(shown.length) } const copy = async (line: LogViewerLine, number: number) => { const text = `${clock(line.timestamp)} ${line.level.toUpperCase()} ${line.source ? `[${line.source}] ` : ''}${line.message}` try { await navigator.clipboard.writeText(text) setAnnouncement(`Copied line ${number}`) } catch { setAnnouncement('Copy failed') } } const first = wrap ? 0 : Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN) const last = wrap ? rows.length : Math.min(rows.length, Math.ceil((scrollTop + height) / ROW) + OVERSCAN) const fresh = follow ? 0 : shown.length - pausedAt return (
setQuery(event.target.value)} leading={} containerClassName="min-w-[160px] flex-1" />
{LEVELS.map((level) => ( ))}
{rows.slice(first, last).map(({ line, index }, offset) => { const position = first + offset return (
{index + 1} {clock(line.timestamp)} {line.level} {line.source && [{highlight(line.source, needle)}] } {highlight(line.message, needle)}
) })}
{shown.length === 0 && (

{lines.length === 0 ? 'Waiting for logs' : 'No lines match'}

)}
{needle || levels.size < LEVELS.length ? `${shown.length.toLocaleString()} of ${lines.length.toLocaleString()} lines` : `${lines.length.toLocaleString()} lines`} {wrap && shown.length > WRAP_LIMIT && ` · wrapping shows the newest ${WRAP_LIMIT.toLocaleString()}`} {!follow && ( )} {announcement}
) }