Developerui
Log viewer
Log lines with level filters, search, and follow mode.
9 / 9
info[build]Cloning github.com/acme/web (branch: main, commit: 3f2a91c)
info[build]Installing dependencies with bun install
debug[build]Resolved 741 packages in 1.4s
info[build]Running next build
warn[build]Using edge runtime on a page disables static generation for that page
info[build]Compiled successfully in 18.2s
error[runtime]GET /api/checkout 500 TypeError: Cannot read properties of undefined (reading 'priceId')
info[deploy]Uploaded 214 static files
info[deploy]Deployment ready at https://acme-web-3f2a91c.particular.app
Installation
npx shadcn@latest add @particular/log-viewernpm dependencies
- cn@^0.4.0
- lucide-react@^1.51.0
Installs these registry items
Source
"use client"
import * as React from "react"
import { ArrowDownIcon, SearchIcon } from "lucide-react"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { Toggle } from "@/components/ui/toggle"
const logLevels = ["debug", "info", "warn", "error"] as const
type LogLevel = (typeof logLevels)[number]
type LogLine = {
id: string
timestamp: Date | string | number
level: LogLevel
message: string
source?: string
}
function filterLogLines(lines: LogLine[], { query, levels }: { query: string; levels: ReadonlySet<LogLevel> }) {
const needle = query.trim().toLowerCase()
return lines.filter(
(line) =>
levels.has(line.level) &&
(needle === "" ||
line.message.toLowerCase().includes(needle) ||
(line.source?.toLowerCase().includes(needle) ?? false))
)
}
/** `HH:MM:SS.mmm` in UTC, so server and client render the same text. */
function formatLogTime(timestamp: LogLine["timestamp"]) {
return new Date(timestamp).toISOString().slice(11, 23)
}
const levelClass: Record<LogLevel, string> = {
debug: "text-muted-foreground",
info: "text-info",
warn: "text-warning",
error: "text-destructive",
}
function LogViewer({
lines,
follow: followProp,
defaultFollow = true,
onFollowChange,
toolbar = true,
emptyMessage = "No log lines yet.",
className,
...props
}: Omit<React.ComponentProps<"div">, "children"> & {
lines: LogLine[]
follow?: boolean
defaultFollow?: boolean
onFollowChange?: (follow: boolean) => void
toolbar?: boolean
emptyMessage?: React.ReactNode
}) {
const [query, setQuery] = React.useState("")
const [levels, setLevels] = React.useState<ReadonlySet<LogLevel>>(() => new Set(logLevels))
const [uncontrolledFollow, setUncontrolledFollow] = React.useState(defaultFollow)
const follow = followProp ?? uncontrolledFollow
const scrollRef = React.useRef<HTMLDivElement>(null)
const setFollow = React.useCallback(
(next: boolean) => {
if (followProp === undefined) setUncontrolledFollow(next)
onFollowChange?.(next)
},
[followProp, onFollowChange]
)
const visible = filterLogLines(lines, { query, levels })
React.useLayoutEffect(() => {
const element = scrollRef.current
if (follow && element) element.scrollTop = element.scrollHeight
}, [follow, visible.length])
const toggleLevel = (level: LogLevel, pressed: boolean) => {
setLevels((current) => {
const next = new Set(current)
if (pressed) next.add(level)
else next.delete(level)
return next
})
}
return (
<div
data-slot="log-viewer"
className={cn("flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card", className)}
{...props}
>
{toolbar && (
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/40 p-2">
<InputGroup className="h-7 w-full max-w-64 bg-background">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
aria-label="Filter logs"
placeholder="Filter logs…"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="text-xs"
/>
</InputGroup>
<div role="group" aria-label="Log levels" className="flex items-center gap-1">
{logLevels.map((level) => (
<Toggle
key={level}
size="sm"
variant="outline"
pressed={levels.has(level)}
onPressedChange={(pressed) => toggleLevel(level, pressed)}
className="h-7 px-2 font-mono text-xs capitalize"
>
<span className={cn("size-1.5 rounded-full bg-current", levelClass[level])} aria-hidden />
{level}
</Toggle>
))}
</div>
<span className="ml-auto font-mono text-xs text-muted-foreground tabular-nums">
{visible.length} / {lines.length}
</span>
</div>
)}
<div className="relative min-h-0 flex-1">
<div
ref={scrollRef}
role="log"
aria-live={follow ? "polite" : "off"}
tabIndex={0}
onScroll={(event) => {
const element = event.currentTarget
const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 8
if (atBottom !== follow) setFollow(atBottom)
}}
className="h-full overflow-auto py-2 font-mono text-xs leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
>
{visible.length === 0 ? (
<p className="px-4 py-6 text-center text-muted-foreground">
{lines.length === 0 ? emptyMessage : "No lines match the current filter."}
</p>
) : (
visible.map((line) => (
<div
key={line.id}
data-level={line.level}
className="grid grid-cols-[auto_auto_1fr] gap-3 px-4 hover:bg-muted/50 data-[level=error]:bg-destructive/5"
>
<time dateTime={new Date(line.timestamp).toISOString()} className="text-muted-foreground tabular-nums">
{formatLogTime(line.timestamp)}
</time>
<span className={cn("w-10 uppercase", levelClass[line.level])}>{line.level}</span>
<span className="whitespace-pre-wrap [overflow-wrap:anywhere]">
{line.source && <span className="mr-2 text-muted-foreground">[{line.source}]</span>}
{line.message}
</span>
</div>
))
)}
</div>
{!follow && visible.length > 0 && (
<Button
size="xs"
variant="secondary"
className="absolute right-3 bottom-3 shadow-md"
onClick={() => setFollow(true)}
>
<ArrowDownIcon data-icon="inline-start" />
Follow
</Button>
)}
</div>
</div>
)
}
export { LogViewer, filterLogLines, formatLogTime, logLevels, type LogLine, type LogLevel }