{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "logs-viewer",
  "title": "LogsViewer",
  "description": "Virtualized, follow-tailing log pane with ANSI colors, level filters, search, and a retention cap.",
  "dependencies": [
    "@hugeicons/react",
    "@hugeicons/core-free-icons",
    "anser"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/neon-tokens.json",
    "https://ui.neon.com/r/select.json"
  ],
  "files": [
    {
      "path": "src/components/logs-viewer/logs-viewer.tsx",
      "content": "\"use client\";\n\nimport {\n  Alert02Icon,\n  ArrowDown01Icon,\n  Cancel01Icon,\n  Clock01Icon,\n  Search01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport Anser from \"anser\";\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { UIEvent } from \"react\";\n\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\ntype LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\ntype TimeRange = \"all\" | \"5m\" | \"15m\" | \"1h\" | \"24h\";\n\ninterface LogLine {\n  id: string;\n  message: string;\n  level?: LogLevel;\n  timestamp?: string;\n  at?: string;\n  source?: string;\n}\n\ninterface LogsViewerProps {\n  lines: LogLine[];\n  title?: string;\n  maxLines?: number;\n  rowHeight?: number;\n  visibleRows?: number;\n  follow?: boolean;\n  defaultFollow?: boolean;\n  onFollowChange?: (follow: boolean) => void;\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  range?: TimeRange;\n  defaultRange?: TimeRange;\n  onRangeChange?: (range: TimeRange) => void;\n  levels?: LogLevel[];\n  defaultLevels?: LogLevel[];\n  onLevelsChange?: (levels: LogLevel[]) => void;\n  isStreaming?: boolean;\n  isLoading?: boolean;\n  error?: Error | string | null;\n  className?: string;\n}\n\nconst LEVELS: { label: string; value: LogLevel; className: string }[] = [\n  { className: \"text-muted-foreground\", label: \"Debug\", value: \"debug\" },\n  { className: \"text-[var(--status-active)]\", label: \"Info\", value: \"info\" },\n  { className: \"text-[var(--status-scaling)]\", label: \"Warn\", value: \"warn\" },\n  { className: \"text-destructive\", label: \"Error\", value: \"error\" },\n];\n\nconst LEVEL_META = new Map(LEVELS.map((level) => [level.value, level]));\n\nconst ANSI_COLORS: Record<string, string> = {\n  \"ansi-black\": \"text-foreground\",\n  \"ansi-blue\": \"text-[var(--status-active)]\",\n  \"ansi-bright-black\": \"text-muted-foreground\",\n  \"ansi-bright-blue\": \"text-[var(--status-active)]\",\n  \"ansi-bright-cyan\": \"text-[var(--status-active)]\",\n  \"ansi-bright-green\": \"text-[var(--status-active)]\",\n  \"ansi-bright-magenta\": \"text-primary\",\n  \"ansi-bright-red\": \"text-destructive\",\n  \"ansi-bright-white\": \"text-foreground\",\n  \"ansi-bright-yellow\": \"text-[var(--status-scaling)]\",\n  \"ansi-cyan\": \"text-[var(--status-active)]\",\n  \"ansi-green\": \"text-[var(--status-active)]\",\n  \"ansi-magenta\": \"text-primary\",\n  \"ansi-red\": \"text-destructive\",\n  \"ansi-white\": \"text-foreground\",\n  \"ansi-yellow\": \"text-[var(--status-scaling)]\",\n};\n\nconst stripAnsi = (value: string) => Anser.ansiToText(value);\n\nconst AnsiText = ({ value }: { value: string }) => {\n  const parts = useMemo(\n    () => Anser.ansiToJson(value, { json: true, use_classes: true }),\n    [value]\n  );\n\n  return (\n    <>\n      {parts.map((part, index) => {\n        if (!part.content) {\n          return null;\n        }\n        const color = part.fg ? ANSI_COLORS[part.fg] : undefined;\n        const decorations = part.decorations ?? [];\n        return (\n          <span\n            className={cn(\n              color,\n              decorations.includes(\"bold\") && \"font-semibold\",\n              decorations.includes(\"dim\") && \"opacity-70\",\n              decorations.includes(\"italic\") && \"italic\",\n              decorations.includes(\"underline\") && \"underline\"\n            )}\n            key={`${index}-${part.content}`}\n          >\n            {part.content}\n          </span>\n        );\n      })}\n    </>\n  );\n};\n\nconst LevelFilter = ({\n  onToggle,\n  value,\n}: {\n  onToggle: (level: LogLevel) => void;\n  value: LogLevel[];\n}) => (\n  <fieldset className=\"flex shrink-0 items-center gap-0.5 self-start rounded-md border border-input p-0.5 sm:self-auto\">\n    <legend className=\"sr-only\">Filter log levels</legend>\n    {LEVELS.map((level) => {\n      const active = value.includes(level.value);\n      return (\n        <button\n          aria-pressed={active}\n          className={cn(\n            \"h-6 rounded-sm px-2 font-medium text-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n            active\n              ? cn(\"bg-muted\", level.className)\n              : \"text-muted-foreground/70 hover:text-foreground\"\n          )}\n          key={level.value}\n          onClick={() => onToggle(level.value)}\n          type=\"button\"\n        >\n          {level.label}\n        </button>\n      );\n    })}\n  </fieldset>\n);\n\nconst StreamState = ({ streaming }: { streaming: boolean }) => (\n  <span className=\"flex items-center gap-1.5 text-[10px] text-muted-foreground\">\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"size-1.5 rounded-full\",\n        streaming\n          ? \"animate-pulse bg-[var(--status-active)] motion-reduce:animate-none\"\n          : \"bg-muted-foreground/40\"\n      )}\n    />\n    {streaming ? \"Streaming\" : \"Paused\"}\n  </span>\n);\n\nconst LogRow = ({ line, rowHeight }: { line: LogLine; rowHeight: number }) => {\n  const meta = line.level ? LEVEL_META.get(line.level) : undefined;\n  return (\n    <div\n      className=\"flex items-start gap-2 px-3 hover:bg-muted/30\"\n      style={{ height: rowHeight }}\n    >\n      {line.timestamp ? (\n        <span className=\"shrink-0 text-muted-foreground/60 tabular-nums\">\n          {line.timestamp}\n        </span>\n      ) : null}\n      {meta ? (\n        <span\n          className={cn(\"w-10 shrink-0 font-medium uppercase\", meta.className)}\n        >\n          {line.level}\n        </span>\n      ) : null}\n      {line.source ? (\n        <span className=\"shrink-0 text-muted-foreground/70\">{line.source}</span>\n      ) : null}\n      <span className=\"whitespace-pre text-foreground\">\n        <AnsiText value={line.message} />\n      </span>\n    </div>\n  );\n};\n\nconst LogsEmpty = ({ filtered }: { filtered: boolean }) => (\n  <div className=\"flex h-full flex-col items-center justify-center gap-1 p-6 text-center\">\n    <p className=\"font-medium text-sm\">\n      {filtered ? \"No matching lines\" : \"No logs yet\"}\n    </p>\n    <p className=\"max-w-64 text-muted-foreground text-xs\">\n      {filtered\n        ? \"Try a different search or enable more levels.\"\n        : \"Lines appear here as the compute writes them.\"}\n    </p>\n  </div>\n);\n\nconst LogsLoading = ({ rows }: { rows: number }) => (\n  <output aria-label=\"Loading logs\" className=\"block space-y-1.5 p-3\">\n    {Array.from({ length: rows }, (_, index) => (\n      <div\n        className=\"h-3 animate-pulse rounded-xs bg-muted motion-reduce:animate-none\"\n        key={index}\n        style={{ width: `${45 + ((index * 17) % 50)}%` }}\n      />\n    ))}\n  </output>\n);\n\nconst SearchField = ({\n  onChange,\n  value,\n}: {\n  onChange: (value: string) => void;\n  value: string;\n}) => (\n  <label className=\"relative block min-w-0 flex-1\">\n    <span className=\"sr-only\">Search logs</span>\n    <HugeiconsIcon\n      aria-hidden=\"true\"\n      className=\"-translate-y-1/2 absolute top-1/2 left-2.5 size-3.5 text-muted-foreground\"\n      icon={Search01Icon}\n      strokeWidth={1.75}\n    />\n    <input\n      className=\"h-8 w-full rounded-md border border-input bg-transparent pr-8 pl-8 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:border-ring focus:ring-3 focus:ring-ring/20\"\n      onChange={(event) => onChange(event.target.value)}\n      placeholder=\"Search logs...\"\n      type=\"search\"\n      value={value}\n    />\n    {value ? (\n      <button\n        aria-label=\"Clear search\"\n        className=\"-translate-y-1/2 absolute top-1/2 right-2 flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50\"\n        onClick={() => onChange(\"\")}\n        type=\"button\"\n      >\n        <HugeiconsIcon\n          aria-hidden=\"true\"\n          className=\"size-3\"\n          icon={Cancel01Icon}\n          strokeWidth={2}\n        />\n      </button>\n    ) : null}\n  </label>\n);\n\nconst LogsHeader = ({\n  cappedCount,\n  isStreaming,\n  maxLines,\n  title,\n  totalCount,\n  visibleCount,\n}: {\n  cappedCount: number;\n  isStreaming: boolean;\n  maxLines: number;\n  title: string;\n  totalCount: number;\n  visibleCount: number;\n}) => (\n  <header className=\"flex min-w-0 flex-col gap-2 border-border/60 border-b px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between\">\n    <div className=\"flex min-w-0 items-center gap-2.5\">\n      <h2 className=\"font-semibold text-sm\">{title}</h2>\n      <StreamState streaming={isStreaming} />\n    </div>\n    <span className=\"text-[10px] text-muted-foreground tabular-nums\">\n      {visibleCount.toLocaleString()} of {cappedCount.toLocaleString()} lines\n      {totalCount > maxLines ? ` (capped at ${maxLines.toLocaleString()})` : \"\"}\n    </span>\n  </header>\n);\n\nconst LogsError = ({\n  message,\n  minHeight,\n}: {\n  message: string;\n  minHeight: number;\n}) => (\n  <div\n    className=\"flex items-center justify-center gap-2 p-6 text-destructive text-sm\"\n    role=\"alert\"\n    style={{ minHeight }}\n  >\n    <HugeiconsIcon\n      aria-hidden=\"true\"\n      className=\"size-4\"\n      icon={Alert02Icon}\n      strokeWidth={1.75}\n    />\n    {message}\n  </div>\n);\n\nconst RANGES: {\n  label: string;\n  short: string;\n  value: TimeRange;\n  ms: number;\n}[] = [\n  { label: \"Last 5 minutes\", ms: 5 * 60_000, short: \"5m\", value: \"5m\" },\n  { label: \"Last 15 minutes\", ms: 15 * 60_000, short: \"15m\", value: \"15m\" },\n  { label: \"Last hour\", ms: 60 * 60_000, short: \"1h\", value: \"1h\" },\n  { label: \"Last 24 hours\", ms: 24 * 60 * 60_000, short: \"24h\", value: \"24h\" },\n  {\n    label: \"All time\",\n    ms: Number.POSITIVE_INFINITY,\n    short: \"All time\",\n    value: \"all\",\n  },\n];\n\nconst RANGE_SHORT = new Map(RANGES.map((range) => [range.value, range.short]));\n\nconst RANGE_MS = new Map(RANGES.map((range) => [range.value, range.ms]));\n\nconst RangeFilter = ({\n  onChange,\n  value,\n}: {\n  onChange: (range: TimeRange) => void;\n  value: TimeRange;\n}) => (\n  <Select\n    onValueChange={(next) => {\n      if (next !== null) {\n        onChange(next as TimeRange);\n      }\n    }}\n    value={value}\n  >\n    <SelectTrigger\n      aria-label=\"Time range\"\n      className=\"h-8 w-full shrink-0 gap-2 rounded-md border-input text-xs sm:w-auto\"\n      size=\"sm\"\n    >\n      <HugeiconsIcon\n        aria-hidden=\"true\"\n        className=\"size-3.5 text-muted-foreground\"\n        icon={Clock01Icon}\n        strokeWidth={1.75}\n      />\n      <SelectValue>{() => RANGE_SHORT.get(value) ?? \"All time\"}</SelectValue>\n    </SelectTrigger>\n    <SelectContent>\n      {RANGES.map((range) => (\n        <SelectItem className=\"text-xs\" key={range.value} value={range.value}>\n          {range.label}\n        </SelectItem>\n      ))}\n    </SelectContent>\n  </Select>\n);\n\nconst LogsControls = ({\n  hasTimestamps,\n  levels,\n  onLevelToggle,\n  onQueryChange,\n  onRangeChange,\n  query,\n  range,\n}: {\n  hasTimestamps: boolean;\n  levels: LogLevel[];\n  onLevelToggle: (level: LogLevel) => void;\n  onQueryChange: (value: string) => void;\n  onRangeChange: (range: TimeRange) => void;\n  query: string;\n  range: TimeRange;\n}) => (\n  <div className=\"flex min-w-0 flex-col gap-2 border-border/60 border-b p-3 sm:flex-row sm:items-center\">\n    <SearchField onChange={onQueryChange} value={query} />\n    <LevelFilter onToggle={onLevelToggle} value={levels} />\n    {hasTimestamps ? (\n      <RangeFilter onChange={onRangeChange} value={range} />\n    ) : null}\n  </div>\n);\n\nconst OVERSCAN = 12;\nconst ALL_LEVELS: LogLevel[] = [\"debug\", \"info\", \"warn\", \"error\"];\n\nconst LogsBody = ({\n  follow,\n  levelsFiltered,\n  query,\n  rowHeight,\n  setFollow,\n  title,\n  viewportHeight,\n  visible,\n}: {\n  follow: boolean;\n  levelsFiltered: boolean;\n  query: string;\n  rowHeight: number;\n  setFollow: (follow: boolean) => void;\n  title: string;\n  viewportHeight: number;\n  visible: LogLine[];\n}) => {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const [scrollTop, setScrollTop] = useState(0);\n  const [pendingCount, setPendingCount] = useState(0);\n  const previousCount = useRef(visible.length);\n\n  const totalHeight = visible.length * rowHeight;\n  const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);\n  const endIndex = Math.min(\n    visible.length,\n    Math.ceil((scrollTop + viewportHeight) / rowHeight) + OVERSCAN\n  );\n  const rows = visible.slice(startIndex, endIndex);\n\n  const scrollToBottom = useCallback(() => {\n    const node = scrollRef.current;\n    if (!node) {\n      return;\n    }\n    node.scrollTop = node.scrollHeight;\n    setScrollTop(node.scrollTop);\n    setPendingCount(0);\n  }, []);\n\n  useLayoutEffect(() => {\n    const added = visible.length - previousCount.current;\n    previousCount.current = visible.length;\n\n    if (follow) {\n      scrollToBottom();\n      return;\n    }\n    if (added > 0) {\n      setPendingCount((current) => current + added);\n    }\n  }, [follow, scrollToBottom, visible.length]);\n\n  const handleScroll = (event: UIEvent<HTMLDivElement>) => {\n    const node = event.currentTarget;\n    setScrollTop(node.scrollTop);\n    const atBottom =\n      node.scrollHeight - node.scrollTop - node.clientHeight < rowHeight;\n    if (atBottom) {\n      setPendingCount(0);\n      if (!follow) {\n        setFollow(true);\n      }\n      return;\n    }\n    if (follow) {\n      setFollow(false);\n    }\n  };\n\n  return (\n    <div className=\"relative\">\n      <div\n        aria-label={`${title} output`}\n        className={cn(\n          \"neon-scroll-fade overflow-auto font-mono text-[11px] leading-5\",\n          follow && \"neon-scroll-pinned\"\n        )}\n        onScroll={handleScroll}\n        ref={scrollRef}\n        role=\"log\"\n        style={{ height: viewportHeight }}\n        // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- a scrollable log region must be reachable by keyboard\n        tabIndex={0}\n      >\n        {visible.length === 0 ? (\n          <LogsEmpty filtered={Boolean(query) || levelsFiltered} />\n        ) : (\n          <div style={{ height: totalHeight, position: \"relative\" }}>\n            <div\n              style={{\n                left: 0,\n                position: \"absolute\",\n                right: 0,\n                top: startIndex * rowHeight,\n              }}\n            >\n              {rows.map((line) => (\n                <LogRow key={line.id} line={line} rowHeight={rowHeight} />\n              ))}\n            </div>\n          </div>\n        )}\n      </div>\n      {follow || visible.length === 0 ? null : (\n        <button\n          className=\"-translate-x-1/2 fade-in-0 slide-in-from-bottom-1 absolute bottom-3 left-1/2 flex animate-in items-center gap-1.5 rounded-full border border-border/70 bg-background px-3 py-1.5 font-medium text-[10px] shadow-sm duration-150 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 motion-reduce:animate-none\"\n          onClick={() => {\n            setFollow(true);\n            scrollToBottom();\n          }}\n          type=\"button\"\n        >\n          <HugeiconsIcon\n            aria-hidden=\"true\"\n            className=\"size-3\"\n            icon={ArrowDown01Icon}\n            strokeWidth={2}\n          />\n          {pendingCount > 0\n            ? `${pendingCount.toLocaleString()} new ${pendingCount === 1 ? \"line\" : \"lines\"}`\n            : \"Follow output\"}\n        </button>\n      )}\n    </div>\n  );\n};\n\nconst useLogsViewerState = ({\n  controlledFollow,\n  controlledLevels,\n  controlledQuery,\n  controlledRange,\n  defaultFollow,\n  defaultLevels,\n  defaultQuery,\n  defaultRange,\n  error,\n  onFollowChange,\n  onLevelsChange,\n  onQueryChange,\n  onRangeChange,\n}: {\n  controlledFollow?: boolean;\n  controlledLevels?: LogLevel[];\n  controlledQuery?: string;\n  controlledRange?: TimeRange;\n  defaultFollow: boolean;\n  defaultLevels: LogLevel[];\n  defaultQuery: string;\n  defaultRange: TimeRange;\n  error?: Error | string | null;\n  onFollowChange?: (follow: boolean) => void;\n  onLevelsChange?: (levels: LogLevel[]) => void;\n  onQueryChange?: (query: string) => void;\n  onRangeChange?: (range: TimeRange) => void;\n}) => {\n  const [internalFollow, setInternalFollow] = useState(defaultFollow);\n  const [internalQuery, setInternalQuery] = useState(defaultQuery);\n  const [internalLevels, setInternalLevels] =\n    useState<LogLevel[]>(defaultLevels);\n  const [internalRange, setInternalRange] = useState<TimeRange>(defaultRange);\n\n  const setFollow = useCallback(\n    (next: boolean) => {\n      if (controlledFollow === undefined) {\n        setInternalFollow(next);\n      }\n      onFollowChange?.(next);\n    },\n    [controlledFollow, onFollowChange]\n  );\n\n  const handleQueryChange = (next: string) => {\n    if (controlledQuery === undefined) {\n      setInternalQuery(next);\n    }\n    onQueryChange?.(next);\n  };\n\n  const handleRangeChange = (next: TimeRange) => {\n    if (controlledRange === undefined) {\n      setInternalRange(next);\n    }\n    onRangeChange?.(next);\n  };\n\n  const handleLevelToggle = (level: LogLevel) => {\n    const apply = (current: LogLevel[]) => {\n      const next = current.includes(level)\n        ? current.filter((item) => item !== level)\n        : [...current, level];\n      onLevelsChange?.(next);\n      return next;\n    };\n\n    if (controlledLevels === undefined) {\n      setInternalLevels(apply);\n      return;\n    }\n    apply(controlledLevels);\n  };\n\n  return {\n    errorMessage: error instanceof Error ? error.message : (error ?? undefined),\n    follow: controlledFollow ?? internalFollow,\n    handleLevelToggle,\n    handleQueryChange,\n    handleRangeChange,\n    levels: controlledLevels ?? internalLevels,\n    query: controlledQuery ?? internalQuery,\n    range: controlledRange ?? internalRange,\n    setFollow,\n  };\n};\n\nconst LogsViewer = ({\n  lines,\n  title = \"Logs\",\n  maxLines = 5000,\n  rowHeight = 20,\n  visibleRows = 18,\n  follow: controlledFollow,\n  defaultFollow = true,\n  onFollowChange,\n  query: controlledQuery,\n  defaultQuery = \"\",\n  onQueryChange,\n  range: controlledRange,\n  defaultRange = \"all\",\n  onRangeChange,\n  levels: controlledLevels,\n  defaultLevels = ALL_LEVELS,\n  onLevelsChange,\n  isStreaming = false,\n  isLoading = false,\n  error,\n  className,\n}: LogsViewerProps) => {\n  const {\n    errorMessage,\n    follow,\n    handleLevelToggle,\n    handleQueryChange,\n    handleRangeChange,\n    levels,\n    query,\n    range,\n    setFollow,\n  } = useLogsViewerState({\n    controlledFollow,\n    controlledLevels,\n    controlledQuery,\n    controlledRange,\n    defaultFollow,\n    defaultLevels,\n    defaultQuery,\n    defaultRange,\n    error,\n    onFollowChange,\n    onLevelsChange,\n    onQueryChange,\n    onRangeChange,\n  });\n\n  const newestAt = useMemo(() => {\n    let newest = 0;\n    for (const line of lines) {\n      if (line.at) {\n        const value = new Date(line.at).getTime();\n        if (value > newest) {\n          newest = value;\n        }\n      }\n    }\n    return newest;\n  }, [lines]);\n  const hasTimestamps = newestAt > 0;\n\n  const capped = useMemo(\n    () => (lines.length > maxLines ? lines.slice(-maxLines) : lines),\n    [lines, maxLines]\n  );\n\n  const visible = useMemo(() => {\n    const needle = query.trim().toLocaleLowerCase();\n    const window = RANGE_MS.get(range) ?? Number.POSITIVE_INFINITY;\n    const oldest = Number.isFinite(window) ? newestAt - window : 0;\n\n    return capped.filter((line) => {\n      if (line.level && !levels.includes(line.level)) {\n        return false;\n      }\n      if (line.at && oldest > 0 && new Date(line.at).getTime() < oldest) {\n        return false;\n      }\n      if (!needle) {\n        return true;\n      }\n      return `${line.timestamp ?? \"\"} ${line.source ?? \"\"} ${stripAnsi(line.message)}`\n        .toLocaleLowerCase()\n        .includes(needle);\n    });\n  }, [capped, levels, newestAt, query, range]);\n\n  const viewportHeight = rowHeight * visibleRows;\n  return (\n    <section\n      className={cn(\n        \"w-full min-w-0 overflow-hidden rounded-lg border border-border/70 bg-card text-card-foreground shadow-xs\",\n        className\n      )}\n      data-slot=\"logs-viewer\"\n    >\n      <LogsHeader\n        cappedCount={capped.length}\n        isStreaming={isStreaming}\n        maxLines={maxLines}\n        totalCount={lines.length}\n        visibleCount={visible.length}\n        title={title}\n      />\n      <LogsControls\n        hasTimestamps={hasTimestamps}\n        levels={levels}\n        onLevelToggle={handleLevelToggle}\n        onQueryChange={handleQueryChange}\n        onRangeChange={handleRangeChange}\n        query={query}\n        range={range}\n      />\n      {errorMessage ? (\n        <LogsError message={errorMessage} minHeight={viewportHeight} />\n      ) : null}\n      {isLoading && !errorMessage ? <LogsLoading rows={visibleRows} /> : null}\n      {isLoading || errorMessage ? null : (\n        <LogsBody\n          follow={follow}\n          levelsFiltered={levels.length < ALL_LEVELS.length}\n          query={query}\n          rowHeight={rowHeight}\n          setFollow={setFollow}\n          viewportHeight={viewportHeight}\n          visible={visible}\n          title={title}\n        />\n      )}\n    </section>\n  );\n};\n\nexport { LogsViewer };\nexport type { LogLevel, LogLine, LogsViewerProps, TimeRange };\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/logs-viewer/use-logs-stream.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport type { LogLine } from \"@/components/logs-viewer/logs-viewer\";\n\nexport interface UseLogsStreamOptions {\n  endpoint?: string;\n  maxLines?: number;\n  enabled?: boolean;\n}\n\n/**\n * Reads a server-sent events endpoint that emits one JSON log line per message.\n * The server owns retention and authorization; this hook only keeps the tail.\n */\nexport const useLogsStream = ({\n  endpoint = \"/api/logs/stream\",\n  maxLines = 5000,\n  enabled = true,\n}: UseLogsStreamOptions = {}) => {\n  const [lines, setLines] = useState<LogLine[]>([]);\n  const [isStreaming, setIsStreaming] = useState(false);\n  const [streamError, setStreamError] = useState<Error | null>(null);\n  const sourceRef = useRef<EventSource | null>(null);\n\n  const clear = useCallback(() => setLines([]), []);\n\n  useEffect(() => {\n    if (!enabled) {\n      return;\n    }\n\n    const source = new EventSource(endpoint);\n    sourceRef.current = source;\n\n    const handleOpen = () => {\n      setIsStreaming(true);\n      setStreamError(null);\n    };\n\n    const handleMessage = (event: MessageEvent<string>) => {\n      try {\n        const line = JSON.parse(event.data) as LogLine;\n        setLines((current) => {\n          const next = [...current, line];\n          return next.length > maxLines ? next.slice(-maxLines) : next;\n        });\n      } catch {\n        setStreamError(new Error(\"Received a malformed log line.\"));\n      }\n    };\n\n    const handleError = () => {\n      setIsStreaming(false);\n      setStreamError(new Error(\"Log stream disconnected.\"));\n    };\n\n    source.addEventListener(\"open\", handleOpen);\n    source.addEventListener(\"message\", handleMessage);\n    source.addEventListener(\"error\", handleError);\n\n    return () => {\n      source.removeEventListener(\"open\", handleOpen);\n      source.removeEventListener(\"message\", handleMessage);\n      source.removeEventListener(\"error\", handleError);\n      source.close();\n      sourceRef.current = null;\n    };\n  }, [enabled, endpoint, maxLines]);\n\n  return {\n    clear,\n    error: streamError,\n    isStreaming,\n    lines,\n    setLines,\n  };\n};\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:component"
}