{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "log-stream",
  "title": "Log Stream",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A live log viewer with level filters and counts, search with highlighted matches, wrap, pause with a new-lines counter, follow-the-tail scrolling and copy.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/copy-button.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/log-stream.tsx",
      "content": "// Ballmac UI: Log Stream. https://ui.ballmac.com/components/log-stream\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, Pause, Play, Search, WrapText } from \"lucide-react\"\n\nimport { CopyButton } from \"@/components/ballmac/copy-button\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\"\n\ntype LogLine = {\n  /** Unique, stable id for the line. */\n  id: string | number\n  /** When it happened: an ISO string or epoch milliseconds. Shown as HH:MM:SS.mmm in UTC. */\n  time: string | number\n  /** Severity. */\n  level: LogLevel\n  /** The message. */\n  message: string\n  /** Where it came from, such as \"api\" or \"worker-2\". */\n  source?: string\n}\n\nconst LEVELS: LogLevel[] = [\"debug\", \"info\", \"warn\", \"error\"]\n\nconst LEVEL_META: Record<LogLevel, { label: string; bar: string; chip: string }> = {\n  debug: { label: \"DEBUG\", bar: \"bg-muted-foreground/40\", chip: \"bg-muted\" },\n  info: { label: \"INFO\", bar: \"bg-chart-1\", chip: \"bg-chart-1/10\" },\n  warn: { label: \"WARN\", bar: \"bg-chart-3\", chip: \"bg-chart-3/15\" },\n  error: { label: \"ERROR\", bar: \"bg-destructive\", chip: \"bg-destructive/10\" },\n}\n\nfunction clock(time: string | number) {\n  const d = new Date(time)\n  if (Number.isNaN(d.getTime())) return \"--:--:--.---\"\n  const p = (n: number, w = 2) => String(n).padStart(w, \"0\")\n  return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`\n}\n\nfunction isoOf(time: string | number) {\n  const d = new Date(time)\n  return Number.isNaN(d.getTime()) ? undefined : d.toISOString()\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  if (!query) return <>{text}</>\n  const parts: React.ReactNode[] = []\n  const lower = text.toLowerCase()\n  const q = query.toLowerCase()\n  let from = 0\n  for (let at = lower.indexOf(q); at !== -1; at = lower.indexOf(q, from)) {\n    if (at > from) parts.push(text.slice(from, at))\n    parts.push(\n      <mark key={at} className=\"rounded-[3px] bg-chart-3/40 px-px text-foreground\">\n        {text.slice(at, at + q.length)}\n      </mark>\n    )\n    from = at + q.length\n  }\n  parts.push(text.slice(from))\n  return <>{parts}</>\n}\n\ntype LogStreamProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** All lines so far, oldest first. Append to this array as the stream delivers. */\n  lines: LogLine[]\n  /** Heading in the toolbar. */\n  title?: string\n  /** Most lines kept on screen; older ones are dropped from view. */\n  maxLines?: number\n  /** Stick to the newest line while at the bottom. */\n  follow?: boolean\n  /** Height of the log area. */\n  height?: string\n  /** Wrap long messages instead of scrolling sideways. */\n  wrap?: boolean\n  /** Hide the source column. */\n  hideSource?: boolean\n  /** Adds a Clear button that calls this. */\n  onClear?: () => void\n  /** Shown when there are no lines yet. */\n  emptyText?: string\n}\n\nfunction LogStream({\n  lines,\n  title,\n  maxLines = 1000,\n  follow = true,\n  height = \"20rem\",\n  wrap: wrapProp = false,\n  hideSource = false,\n  onClear,\n  emptyText,\n  className,\n  ...props\n}: LogStreamProps) {\n  const msg = useMessages()\n  title ??= msg(\"log-stream.title\", \"Logs\")\n  emptyText ??= msg(\"log-stream.emptyText\", \"Waiting for output…\")\n  const [active, setActive] = React.useState<Set<LogLevel>>(new Set(LEVELS))\n  const [query, setQuery] = React.useState(\"\")\n  const [wrap, setWrap] = React.useState(wrapProp)\n  const [paused, setPaused] = React.useState(false)\n  const [pausedAt, setPausedAt] = React.useState(0)\n  const [atBottom, setAtBottom] = React.useState(true)\n  const scroller = React.useRef<HTMLDivElement>(null)\n\n  const source = paused ? lines.slice(0, pausedAt) : lines\n  const q = query.trim()\n  const visible = source\n    .filter((l) => active.has(l.level) && (!q || l.message.toLowerCase().includes(q.toLowerCase()) || l.source?.toLowerCase().includes(q.toLowerCase())))\n    .slice(-maxLines)\n  const counts = LEVELS.reduce((acc, level) => ({ ...acc, [level]: source.filter((l) => l.level === level).length }), {} as Record<LogLevel, number>)\n  const hiddenNew = paused ? lines.length - pausedAt : 0\n\n  const newestId = visible[visible.length - 1]?.id\n  React.useLayoutEffect(() => {\n    const el = scroller.current\n    if (el && follow && !paused && atBottom) el.scrollTop = el.scrollHeight\n  }, [newestId, visible.length, follow, paused, atBottom])\n\n  function onScroll() {\n    const el = scroller.current\n    if (!el) return\n    setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 24)\n  }\n\n  function jump() {\n    const el = scroller.current\n    if (el) el.scrollTop = el.scrollHeight\n    setAtBottom(true)\n    if (paused) setPaused(false)\n  }\n\n  const copyText = () =>\n    visible.map((l) => `${isoOf(l.time) ?? l.time} ${l.level.toUpperCase()}${l.source ? ` [${l.source}]` : \"\"} ${l.message}`).join(\"\\n\")\n\n  return (\n    <div\n      data-slot=\"log-stream\"\n      dir=\"ltr\"\n      className={cn(\"@container flex w-full flex-col overflow-hidden rounded-xl border bg-card text-card-foreground\", className)}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-center gap-2 border-b px-3 py-2\">\n        <h3 className=\"me-1 text-sm font-semibold text-foreground\">{title}</h3>\n        <div role=\"group\" aria-label={msg(\"log-stream.showLevels\", \"Show levels\")} className=\"flex items-center gap-1\">\n          {LEVELS.map((level) => (\n            <button\n              key={level}\n              type=\"button\"\n              aria-pressed={active.has(level)}\n              onClick={() =>\n                setActive((set) => {\n                  const next = new Set(set)\n                  if (next.has(level)) next.delete(level)\n                  else next.add(level)\n                  return next\n                })\n              }\n              className={cn(\n                \"inline-flex h-7 items-center gap-1.5 rounded-md border px-2 font-mono text-[11px] font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\",\n                active.has(level) ? cn(\"text-foreground\", LEVEL_META[level].chip) : \"border-dashed text-muted-foreground line-through hover:text-foreground\"\n              )}\n            >\n              <span aria-hidden=\"true\" className={cn(\"size-1.5 rounded-full\", LEVEL_META[level].bar)} />\n              {LEVEL_META[level].label}\n              <span className=\"text-muted-foreground tabular-nums\">{counts[level]}</span>\n            </button>\n          ))}\n        </div>\n        <div className=\"relative ms-auto min-w-36 flex-1 @xl:max-w-56 @xl:flex-none\">\n          <Search aria-hidden=\"true\" className=\"pointer-events-none absolute top-1/2 start-2 size-3.5 -translate-y-1/2 text-muted-foreground\" />\n          <input\n            type=\"search\"\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            aria-label={msg(\"log-stream.filterLogLines\", \"Filter log lines\")}\n            placeholder={msg(\"log-stream.filter\", \"Filter\")}\n            className=\"h-7 w-full rounded-md border bg-background pe-2 ps-7 text-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n          />\n        </div>\n        <div className=\"flex items-center gap-0.5\">\n          <button\n            type=\"button\"\n            aria-pressed={wrap}\n            aria-label={msg(\"log-stream.wrapLongLines\", \"Wrap long lines\")}\n            title={msg(\"log-stream.wrapLongLines\", \"Wrap long lines\")}\n            onClick={() => setWrap((w) => !w)}\n            className=\"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-accent aria-pressed:text-foreground\"\n          >\n            <WrapText aria-hidden=\"true\" className=\"size-3.5\" />\n          </button>\n          <button\n            type=\"button\"\n            aria-pressed={paused}\n            aria-label={paused ? msg(\"log-stream.resumeStream\", \"Resume stream\") : msg(\"log-stream.pauseStream\", \"Pause stream\")}\n            title={paused ? msg(\"log-stream.resumeStream\", \"Resume stream\") : msg(\"log-stream.pauseStream\", \"Pause stream\")}\n            onClick={() => {\n              setPausedAt(lines.length)\n              setPaused((p) => !p)\n            }}\n            className=\"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-accent aria-pressed:text-foreground\"\n          >\n            {paused ? <Play aria-hidden=\"true\" className=\"size-3.5\" /> : <Pause aria-hidden=\"true\" className=\"size-3.5\" />}\n          </button>\n          <CopyButton size=\"sm\" ariaLabel=\"Copy visible lines\" getValue={copyText} disabled={visible.length === 0} />\n          {onClear && (\n            <button\n              type=\"button\"\n              onClick={onClear}\n              className=\"ms-0.5 h-7 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n            >\n              {msg(\"log-stream.clear\", \"Clear\")}\n            </button>\n          )}\n        </div>\n      </div>\n\n      <div className=\"relative\">\n        <div\n          ref={scroller}\n          role=\"log\"\n          aria-label={msg(\"log-stream.lines\", \"{title}, {length} lines\", { title, length: visible.length })}\n          aria-live=\"off\"\n          tabIndex={0}\n          onScroll={onScroll}\n          style={{ height }}\n          className=\"overflow-auto bg-muted/20 py-1 font-mono text-xs leading-5 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\"\n        >\n          {visible.length === 0 ? (\n            <p className=\"px-4 py-6 text-center font-sans text-[13px] text-muted-foreground\">{lines.length === 0 ? emptyText : \"No lines match the filters.\"}</p>\n          ) : (\n            visible.map((l) => (\n              <div\n                key={l.id}\n                data-level={l.level}\n                className={cn(\n                  \"relative flex gap-3 py-px pe-4 ps-4 hover:bg-accent/60\",\n                  l.level === \"error\" && \"bg-destructive/5\",\n                  l.level === \"warn\" && \"bg-chart-3/5\",\n                  wrap ? \"\" : \"w-max min-w-full\"\n                )}\n              >\n                <span aria-hidden=\"true\" className={cn(\"absolute inset-y-0 start-0 w-0.5\", LEVEL_META[l.level].bar)} />\n                <time dateTime={isoOf(l.time)} className=\"shrink-0 text-muted-foreground tabular-nums\">\n                  {clock(l.time)}\n                </time>\n                <span className=\"w-11 shrink-0 font-medium text-foreground\">{LEVEL_META[l.level].label}</span>\n                {!hideSource && l.source && <span className=\"hidden w-16 shrink-0 truncate text-muted-foreground @xl:block\">{l.source}</span>}\n                <span className={cn(\"min-w-0 flex-1 text-foreground\", wrap ? \"break-words whitespace-pre-wrap\" : \"whitespace-pre\")}>\n                  <Highlight text={l.message} query={q} />\n                </span>\n              </div>\n            ))\n          )}\n        </div>\n        {(!atBottom || hiddenNew > 0) && visible.length > 0 && (\n          <button\n            type=\"button\"\n            onClick={jump}\n            className=\"absolute end-3 bottom-3 inline-flex h-8 items-center gap-1.5 rounded-full border bg-popover px-3 text-xs font-medium text-popover-foreground shadow-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n          >\n            <ArrowDown aria-hidden=\"true\" className=\"size-3.5\" />\n            {hiddenNew > 0 ? `${hiddenNew} new ${hiddenNew === 1 ? \"line\" : \"lines\"}` : \"Jump to latest\"}\n          </button>\n        )}\n      </div>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {paused ? \"Stream paused\" : \"\"}\n      </p>\n    </div>\n  )\n}\n\nexport { LogStream, clock, type LogStreamProps, type LogLine, type LogLevel }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/log-stream.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "logs",
      "stream",
      "console",
      "debug",
      "tail"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "lines is [{ id, time, level, message, source? }], oldest first; append as lines arrive. Follows the tail while you are at the bottom, shows 'N new lines' when paused or scrolled up. maxLines limits what is rendered.",
      "whenToUse": [
        "Deploy, build and server logs",
        "Streaming output from background jobs"
      ],
      "whenNotToUse": [
        "A terminal with input (terminal)",
        "Millions of lines (use a virtualized list)"
      ],
      "composesWith": [
        "terminal",
        "status-dot",
        "copy-button"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Reaches filters, search, wrap, pause, copy, then the scrollable log (role=log)"
        },
        {
          "keys": "Screen readers",
          "action": "The log is not announced line by line; pause state is announced; levels are words"
        },
        {
          "keys": "Color",
          "action": "Level is a word and a bar"
        }
      ],
      "customization": [
        "maxLines",
        "follow",
        "height",
        "wrap",
        "hideSource",
        "onClear"
      ]
    },
    "examples": [
      "log-stream-demo",
      "log-stream-static"
    ],
    "url": "https://ui.ballmac.com/components/log-stream"
  },
  "categories": [
    "developer"
  ],
  "type": "registry:ui"
}