Log viewer
A tail of log lines with level filters, a search box, and a follow-the-bottom mode.
A client component. filterLogs is exported and pure: a line survives when its level is in the set and the trimmed, lower-cased query appears in its message or its source. The body renders at most 1,000 lines — the newest ones — and says so above the list when it drops any; past that it is the DOM, not the data, that slows a page down. follow sticks to the bottom as lines arrive, but only while the reader is already there: scrolling up parks the view, and it resumes on its own once you scroll back down. The list is a role="log" named with its visible count, announced politely only while following, since a static tail is not news. The source column is a fixed width so the messages beside it line up; sourceWidth says how many characters it holds, since only the caller knows how long its service names run. The level toggles are one labelled group, and each level takes its colour from the status tokens — debug muted, info info, warn warning, error danger. wrap sets the wrap toggle's starting position rather than driving it. Timestamps are formatted in the reader's own timezone and suppress the hydration warning that follows from it.
Install
npx shadcn@latest add @vibra/log-viewerNeeds the @vibra registry in your components.json — set it up once.
Examples
import { LogViewer, type LogLine } from "@/components/ui/log-viewer"
// A fixed start, so the timestamps are the same on the server and in the
// browser and the demo never re-renders into a different set of times.
const START = new Date("2026-09-04T14:20:11.000Z").getTime()
const at = (seconds: number) => new Date(START + seconds * 1000)
type Raw = [seconds: number, level: LogLine["level"], source: string, message: string]
const raw: Raw[] = [
[0, "info", "server", "vibra-api 4.2.1 starting, pid 1"],
[1, "debug", "config", "loaded 34 keys from /etc/vibra/config.toml"],
[1, "debug", "config", "DATABASE_URL resolved to pooler.iad1.vibra.example:6543"],
[2, "info", "db", "connection pool opened, min 4 max 20"],
[3, "info", "cache", "redis connected in 41ms"],
[4, "info", "server", "listening on 0.0.0.0:3000"],
[6, "debug", "router", "registered 118 routes"],
[8, "info", "http", "GET /api/health 200 in 3ms"],
[12, "info", "http", "POST /api/v1/sessions 201 in 88ms"],
[14, "debug", "auth", "issued access token for cus_QhT21f, ttl 3600s"],
[19, "info", "http", "GET /api/v1/projects 200 in 46ms"],
[23, "warn", "db", "slow query 812ms: select * from events where project_id = $1"],
[24, "debug", "db", "explain: seq scan on events, 1.2M rows"],
[27, "info", "http", "GET /api/v1/events 200 in 831ms"],
[31, "info", "worker", "picked up job export:csv#4192"],
[33, "debug", "worker", "streaming 41,208 rows to s3://vibra-exports/4192.csv"],
[45, "info", "worker", "job export:csv#4192 finished in 12.4s"],
[48, "warn", "cache", "eviction rate 18%/min, consider raising maxmemory"],
[52, "info", "http", "GET /api/v1/projects/vibra-web 200 in 22ms"],
[56, "error", "db", "ECONNREFUSED 10.0.0.4:5432 — replica unreachable"],
[56, "warn", "db", "failing over reads to the primary"],
[58, "info", "db", "read traffic on primary, 3 replicas remaining"],
[61, "debug", "health", "replica-2 probe failed 1/3"],
[64, "debug", "health", "replica-2 probe failed 2/3"],
[67, "error", "health", "replica-2 marked down after 3 failed probes"],
[70, "info", "http", "GET /api/v1/events 200 in 104ms"],
[74, "warn", "http", "rate limit hit for key vk_live_8fQ2, 100/min"],
[77, "info", "http", "POST /api/v1/webhooks 202 in 12ms"],
[80, "debug", "webhook", "queued invoice.payment_failed for evt_1P4kZ2Hs9"],
[83, "error", "webhook", "delivery to https://hooks.blueharbor.example/vibra failed: 502"],
[84, "info", "webhook", "retry 1 of 5 scheduled in 30s"],
[91, "info", "deploy", "build 4c1a7f2 promoted to production"],
[92, "debug", "deploy", "draining 4 old instances, grace 30s"],
[98, "info", "deploy", "12 of 12 instances healthy"],
[104, "info", "db", "replica-2 back online, rejoining the read pool"],
[108, "info", "http", "GET /api/v1/events 200 in 38ms"],
[113, "debug", "cache", "hit rate 94.2% over the last 5m"],
[119, "warn", "worker", "queue depth 1,204, above the 1,000 alert threshold"],
[126, "info", "worker", "scaled workers 4 → 8"],
[134, "info", "worker", "queue depth 218 and falling"],
]
const lines: LogLine[] = raw.map(([seconds, level, source, message], index) => ({
id: String(index),
timestamp: at(seconds),
level,
source,
message,
}))
export default function LogViewerDemo() {
return <LogViewer className="w-full" lines={lines} height={280} follow />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | { id?: string; timestamp?: Date | string; level: LogLevel; message: string; source?: string }[] | — | The lines, oldest first. An id keeps a line's identity as the tail moves. |
| height | number | 320 | Pixels the body scrolls inside. |
| filterable | boolean | true | Shows the level toggles, the search box, the wrap toggle, and the count. |
| wrap | boolean | false | The wrap toggle's starting position. |
| follow | boolean | false | Sticks to the bottom as lines arrive, unless the reader has scrolled up. |
| showTimestamps | boolean | true | Shows the HH:MM:SS column for lines that carry one. |
| sourceWidth | number | — | Width of the source column in characters — the body is monospace, so this is how many letters of a service name survive. Left off, the column holds about twelve. |
| emptyMessage | React.ReactNode | No output yet. | Shown when there are no lines at all; a filtered-empty view says so instead. |
| filterLogs | (lines: LogLine[], levels: Set<LogLevel>, query: string) => LogLine[] | — | The filter the toolbar drives, exported so a server can apply the same rule. |
| LOG_LEVELS | readonly LogLevel[] | — | The four levels, quietest first — the order the toolbar toggles appear in. |
| className | string | — | Merged onto the div root; the remaining div props are spread onto it too. |
Dependencies
Source
"use client"
import * as React from "react"
import { WrapTextIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
export type LogLevel = "debug" | "info" | "warn" | "error"
/** The four levels, quietest first — the order the toolbar toggles appear in. */
export const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"]
// Straight from the status tokens, so a warning in a log reads the same as a
// warning anywhere else on the page.
const LEVEL_COLORS: Record<LogLevel, string> = {
debug: "text-muted-foreground",
info: "text-info",
warn: "text-warning",
error: "text-danger",
}
// Past this the DOM, not the data, is what slows a page down. The newest lines
// are the ones kept, and the header says how many were dropped.
const MAX_RENDERED = 1000
export type LogLine = {
id?: string
timestamp?: Date | string
level: LogLevel
message: string
/** The subsystem that emitted the line — a service, a worker, a file. */
source?: string
}
/** Keeps the lines whose level is in `levels` and whose message or source contains `query`. */
export function filterLogs(lines: LogLine[], levels: Set<LogLevel>, query: string): LogLine[] {
const needle = query.trim().toLowerCase()
return lines.filter((line) => {
if (!levels.has(line.level)) return false
if (needle === "") return true
return (
line.message.toLowerCase().includes(needle) ||
(line.source?.toLowerCase().includes(needle) ?? false)
)
})
}
// hourCycle "h23", not hour12: false — the latter leaves the hour after
// midnight up to the engine, and some ICU builds render it as 24.
const TIME_FORMAT = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
})
function formatTimestamp(timestamp: Date | string): string {
const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
return Number.isNaN(date.getTime()) ? "--:--:--" : TIME_FORMAT.format(date)
}
export type LogViewerProps = React.ComponentProps<"div"> & {
lines: LogLine[]
/** Pixels the body scrolls inside. */
height?: number
/** Shows the level toggles, the search box, the wrap toggle, and the count. */
filterable?: boolean
/** The wrap toggle's starting position. */
wrap?: boolean
/** Sticks to the bottom as lines arrive, unless the reader has scrolled up. */
follow?: boolean
showTimestamps?: boolean
/** Width of the source column in characters. The body is monospace, so this is how many letters of a service name survive; left off, the column holds about twelve. */
sourceWidth?: number
emptyMessage?: React.ReactNode
}
/** A tail of log lines with level filters, a search box, and an optional follow-the-bottom mode. */
function LogViewer({
className,
lines,
height = 320,
filterable = true,
wrap = false,
follow = false,
showTimestamps = true,
sourceWidth,
emptyMessage = "No output yet.",
...props
}: LogViewerProps) {
const [levels, setLevels] = React.useState<LogLevel[]>(() => [...LOG_LEVELS])
const [query, setQuery] = React.useState("")
const [wrapped, setWrapped] = React.useState(wrap)
const levelSet = React.useMemo(() => new Set(levels), [levels])
const filtered = React.useMemo(
() => filterLogs(lines, levelSet, query),
[lines, levelSet, query]
)
const shown = filtered.length > MAX_RENDERED ? filtered.slice(-MAX_RENDERED) : filtered
const dropped = filtered.length - shown.length
const bodyRef = React.useRef<HTMLDivElement>(null)
// A reader who has scrolled up is reading; following would yank them back.
const atBottom = React.useRef(true)
React.useEffect(() => {
if (!follow) return
const body = bodyRef.current
if (!body || !atBottom.current) return
body.scrollTop = body.scrollHeight
}, [follow, lines.length])
const filteredEmpty = lines.length > 0 && filtered.length === 0
return (
<div
data-slot="log-viewer"
data-follow={follow || undefined}
data-wrap={wrapped || undefined}
className={cn("flex flex-col overflow-hidden rounded-lg border bg-muted/40", className)}
{...props}
>
{filterable ? (
<div
data-slot="log-viewer-toolbar"
className="flex flex-wrap items-center gap-2 border-b bg-background/60 p-2"
>
<ToggleGroup
multiple
// The primitive already sets role="group"; spelling it out here
// keeps the four toggles a named group even if it stops.
role="group"
aria-label="Log levels"
size="sm"
variant="outline"
spacing={0}
value={levels}
onValueChange={(next) => setLevels(next as LogLevel[])}
>
{LOG_LEVELS.map((level) => (
<ToggleGroupItem key={level} value={level} className="font-mono text-xs">
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full bg-current", LEVEL_COLORS[level])}
/>
{level}
</ToggleGroupItem>
))}
</ToggleGroup>
<Input
type="search"
aria-label="Filter log lines"
placeholder="Filter…"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="h-7 w-32 flex-1 sm:w-44 sm:flex-none"
/>
<ToggleGroup
multiple
size="sm"
variant="outline"
value={wrapped ? ["wrap"] : []}
onValueChange={(next) => setWrapped(next.includes("wrap"))}
>
<ToggleGroupItem value="wrap" aria-label="Wrap long lines">
<WrapTextIcon aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
<Badge variant="outline" className="ms-auto tabular-nums">
{`${formatNumber(filtered.length, { maximumFractionDigits: 0 })} of ${formatNumber(lines.length, { maximumFractionDigits: 0 })}`}
</Badge>
</div>
) : null}
{dropped > 0 ? (
<p
data-slot="log-viewer-truncation"
className="border-b px-3 py-1 text-xs text-muted-foreground"
>
{`Showing the last ${formatNumber(MAX_RENDERED, { maximumFractionDigits: 0 })} of ${formatNumber(filtered.length, { maximumFractionDigits: 0 })} lines.`}
</p>
) : null}
<div
ref={bodyRef}
data-slot="log-viewer-body"
role="log"
// A tail that is being followed is news; a static one is not, and
// announcing every line would bury everything else on the page.
aria-live={follow ? "polite" : "off"}
// The filtered count, not the rendered one, so the name and the
// toolbar's badge always agree; the cap is the truncation note's job.
aria-label={`Log output, ${formatNumber(filtered.length, { maximumFractionDigits: 0 })} ${
filtered.length === 1 ? "line" : "lines"
}`}
tabIndex={0}
onScroll={(event) => {
const el = event.currentTarget
atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8
}}
className="overflow-auto py-1 font-mono text-xs leading-5 focus-ring"
style={{ height: `${height}px` }}
>
{shown.length === 0 ? (
<p className="px-3 py-6 text-center text-muted-foreground">
{filteredEmpty ? "No lines match the current filters." : emptyMessage}
</p>
) : (
shown.map((line, index) => (
<div
key={line.id ?? index}
data-slot="log-viewer-line"
data-level={line.level}
className="flex gap-3 px-3 py-px hover:bg-accent/50"
>
{showTimestamps && line.timestamp !== undefined ? (
<span
data-slot="log-viewer-timestamp"
// A clock time is read in the reader's own zone, and a
// server rendering the same line is rarely in it.
suppressHydrationWarning
className="shrink-0 tabular-nums text-muted-foreground"
>
{formatTimestamp(line.timestamp)}
</span>
) : null}
<span
data-slot="log-viewer-level"
className={cn("w-10 shrink-0", LEVEL_COLORS[line.level])}
>
{line.level}
</span>
{line.source ? (
<span
data-slot="log-viewer-source"
// The column is fixed so the messages line up; how wide it
// has to be is a property of the names in it, which only the
// caller knows.
className={cn(
"shrink-0 truncate text-muted-foreground",
sourceWidth === undefined && "w-24"
)}
style={sourceWidth === undefined ? undefined : { width: `${sourceWidth}ch` }}
>
{line.source}
</span>
) : null}
<span
data-slot="log-viewer-message"
className={cn(
"min-w-0 text-foreground",
wrapped ? "break-words whitespace-pre-wrap" : "whitespace-pre"
)}
>
{line.message}
</span>
</div>
))
)}
</div>
</div>
)
}
export { LogViewer }