Particular.Tech
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-viewer
npm dependencies
  • cn@^0.4.0
  • lucide-react@^1.51.0
Installs these registry items

Source

ui/log-viewer.tsx
"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 }