{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "logs-panel",
  "title": "Logs panel",
  "description": "A live log tail with level and source filter chips, search, and a Live or Paused stream that holds still while the pointer rests on it.",
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/theme.json",
    "https://sproutboat.com/ikada/r/button.json",
    "https://sproutboat.com/ikada/r/cn.json",
    "https://sproutboat.com/ikada/r/format.json",
    "https://sproutboat.com/ikada/r/icons.json",
    "https://sproutboat.com/ikada/r/input.json",
    "https://sproutboat.com/ikada/r/segmented-control.json",
    "https://sproutboat.com/ikada/r/status.json",
    "https://sproutboat.com/ikada/r/status-dot.json",
    "https://sproutboat.com/ikada/r/status-glyph.json"
  ],
  "files": [
    {
      "path": "registry/ikada/blocks/logs-panel/logs-panel.tsx",
      "content": "\"use client\";\n\n/**\n * A live log tail with level and source filter chips, search, and a Live or Paused stream that\n * holds still while the pointer rests on it. Lines are mono, newest first, with UTC timestamps.\n */\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ikada/button\";\nimport { Input } from \"@/components/ikada/input\";\nimport { SegmentedControl } from \"@/components/ikada/segmented-control\";\nimport { StatusDot } from \"@/components/ikada/status-dot\";\nimport { StatusGlyph } from \"@/components/ikada/status-glyph\";\nimport type { Status } from \"@/components/ikada/status\";\nimport { cn, focusRing, focusRingInset, pressMotion } from \"@/components/ikada/lib/cn\";\nimport { SearchIcon } from \"@/components/ikada/lib/icons\";\nimport { formatNumber } from \"@/components/ikada/lib/format\";\n\nexport type LogLevel = \"info\" | \"warn\" | \"error\";\n\nexport interface LogLine {\n  id: string;\n  /** Milliseconds since the epoch. */\n  t: number;\n  level: LogLevel;\n  /** Where the line came from: \"http\", \"console\", \"queue\", \"cron\". */\n  source: string;\n  message: string;\n  /** For request lines. */\n  method?: string;\n  path?: string;\n  status?: number;\n  durationMs?: number;\n}\n\nexport interface LogsPanelProps {\n  /** Initial lines, newest first. */\n  lines?: LogLine[];\n  /**\n   * Called on an interval while live to produce the next line. Defaults to a deterministic sample\n   * generator; pass `null` to stream nothing (wire your own source through `lines`).\n   */\n  next?: ((index: number, last: LogLine | undefined) => LogLine) | null;\n  /** Milliseconds between generated lines. Default 1400. */\n  interval?: number;\n  /** Lines kept in memory. Default 300. */\n  max?: number;\n  title?: string;\n  className?: string;\n}\n\n/* ---------------------------------------------------------------------------\n * Sample data: a small API on a weekday afternoon. Seeded, so every render matches.\n * ------------------------------------------------------------------------- */\n\nconst START = Date.UTC(2026, 10, 6, 14, 58, 0);\n\nfunction seeded(n: number): number {\n  let x = (n + 0x6d2b79f5) | 0;\n  x = Math.imul(x ^ (x >>> 15), x | 1);\n  x ^= x + Math.imul(x ^ (x >>> 7), x | 61);\n  return ((x ^ (x >>> 14)) >>> 0) / 4294967296;\n}\n\nconst ROUTES = [\n  { method: \"GET\", path: \"/api/notes\", ms: 18 },\n  { method: \"GET\", path: \"/api/notes/8f3a\", ms: 11 },\n  { method: \"POST\", path: \"/api/notes\", ms: 42 },\n  { method: \"GET\", path: \"/healthz\", ms: 2 },\n  { method: \"PUT\", path: \"/api/notes/2c91\", ms: 37 },\n  { method: \"GET\", path: \"/api/search?q=raft\", ms: 64 },\n  { method: \"DELETE\", path: \"/api/notes/77d0\", ms: 29 },\n];\n\nconst CONSOLE = [\n  { level: \"info\", message: \"cache warm: 412 keys in 38 ms\" },\n  { level: \"warn\", message: \"slow query on notes_by_tag (812 ms)\" },\n  { level: \"info\", message: \"session refreshed for user_19c\" },\n  { level: \"error\", message: \"TypeError: Cannot read properties of undefined (reading 'title')\" },\n] as const;\n\n/** One sample line. Mostly requests, some console output, the odd queue or cron run. */\nexport function sampleLine(index: number, last?: LogLine): LogLine {\n  const r = seeded(index);\n  const t = (last?.t ?? START) + 300 + Math.round(seeded(index + 7919) * 2200);\n  const id = `l${index}`;\n  if (r < 0.6) {\n    const route = ROUTES[Math.floor(seeded(index + 31) * ROUTES.length)] ?? ROUTES[0]!;\n    const roll = seeded(index + 97);\n    const status =\n      route.path === \"/healthz\" ? 200 : roll > 0.95 ? 500 : roll > 0.85 ? 404 : route.method === \"POST\" ? 201 : 200;\n    return {\n      id,\n      t,\n      level: status >= 500 ? \"error\" : status >= 400 ? \"warn\" : \"info\",\n      source: \"http\",\n      method: route.method,\n      path: route.path,\n      status,\n      durationMs: Math.round(route.ms * (0.6 + seeded(index + 13) * 1.2)),\n      message: status >= 500 ? \"upstream timed out\" : \"\",\n    };\n  }\n  if (r < 0.85) {\n    const line = CONSOLE[Math.floor(seeded(index + 53) * CONSOLE.length)] ?? CONSOLE[0];\n    return { id, t, level: line.level, source: \"console\", message: line.message };\n  }\n  if (r < 0.94) {\n    const n = 1 + Math.floor(seeded(index + 3) * 24);\n    return { id, t, level: \"info\", source: \"queue\", message: `emails: delivered batch of ${n}` };\n  }\n  return { id, t, level: \"info\", source: \"cron\", message: \"*/5 * * * * digest: 3 notes summarised\" };\n}\n\n/** Sample data: the last 40 lines, newest first. */\nexport const sampleLines: LogLine[] = (() => {\n  const out: LogLine[] = [];\n  for (let i = 0; i < 40; i++) out.push(sampleLine(i, out[out.length - 1]));\n  return out.reverse();\n})();\n\n/* ---------------------------------------------------------------------------\n * Pieces\n * ------------------------------------------------------------------------- */\n\nconst LEVELS: { value: LogLevel | \"all\"; label: string }[] = [\n  { value: \"all\", label: \"All\" },\n  { value: \"info\", label: \"Info\" },\n  { value: \"warn\", label: \"Warn\" },\n  { value: \"error\", label: \"Error\" },\n];\n\nconst levelStatus: Record<LogLevel, Status> = { info: \"neutral\", warn: \"warning\", error: \"danger\" };\nconst levelText: Record<LogLevel, string> = { info: \"text-fg-muted\", warn: \"text-warn-fg\", error: \"text-danger-fg\" };\n\n/** 14:58:21.402, always UTC. */\nconst clock = (t: number) => new Date(t).toISOString().slice(11, 23);\n\n/** A toggle chip: quiet outline when off, a soft filled pill when on. */\nfunction Chip({\n  pressed,\n  onClick,\n  children,\n  count,\n}: {\n  pressed: boolean;\n  onClick: () => void;\n  children: string;\n  count?: number;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={pressed}\n      onClick={onClick}\n      className={cn(\n        \"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-sb-sm pointer-coarse:h-11\",\n        pressed\n          ? \"bg-neutral-soft font-semibold text-fg\"\n          : \"text-fg-muted shadow-[inset_0_0_0_1px_var(--sb-hairline-strong)] hover:bg-hover hover:text-fg\",\n        pressMotion,\n        focusRing,\n      )}\n    >\n      {children}\n      {count !== undefined ? <span className=\"font-normal text-fg-muted tabular-nums\">{count}</span> : null}\n    </button>\n  );\n}\n\nconst ROW = \"@3xl:grid @3xl:grid-cols-[7.5rem_4.25rem_4.5rem_minmax(0,1fr)_4rem] @3xl:gap-x-3\";\n\nfunction Line({ line }: { line: LogLine }) {\n  const request = line.source === \"http\";\n  const statusTone =\n    (line.status ?? 0) >= 500\n      ? \"font-semibold text-danger-fg\"\n      : (line.status ?? 0) >= 400\n        ? \"text-warn-fg\"\n        : \"text-fg-muted\";\n  const body = (\n    <>\n      {request ? (\n        <>\n          <span className=\"text-fg-muted\">{line.method} </span>\n          <span className=\"text-fg\">{line.path}</span>\n          <span className={cn(\"ps-2 tabular-nums\", statusTone)}>{line.status}</span>\n        </>\n      ) : null}\n      {line.message ? (\n        <span className={cn(request && \"ps-2\", line.level === \"error\" ? \"text-danger-fg\" : \"text-fg\")}>\n          {line.message}\n        </span>\n      ) : null}\n    </>\n  );\n  return (\n    <li className={cn(\"px-4 py-1.5\", ROW, line.level === \"error\" ? \"bg-danger-soft/45\" : \"hover:bg-hover/50\")}>\n      <div className=\"flex flex-wrap items-center gap-x-3 @3xl:contents\">\n        <time className=\"text-fg-muted tabular-nums\" dateTime={new Date(line.t).toISOString()}>\n          {clock(line.t)}\n        </time>\n        <span\n          className={cn(\"inline-flex items-center gap-1.5 text-sb-xs font-semibold uppercase\", levelText[line.level])}\n        >\n          <StatusGlyph tone={levelStatus[line.level]} />\n          {line.level}\n        </span>\n        <span className=\"text-fg-muted\">{line.source}</span>\n        <span\n          className=\"hidden min-w-0 truncate @3xl:block\"\n          title={[line.path, line.message].filter(Boolean).join(\" \")}\n        >\n          {body}\n        </span>\n        <span className=\"text-fg-muted tabular-nums @3xl:text-end\">\n          {line.durationMs !== undefined ? `${formatNumber(line.durationMs)} ms` : \"\"}\n        </span>\n      </div>\n      <p className=\"mt-0.5 break-words @3xl:hidden\">{body}</p>\n    </li>\n  );\n}\n\n/* ---------------------------------------------------------------------------\n * The panel\n * ------------------------------------------------------------------------- */\n\n/**\n * A live log tail with level and source filter chips, search, and a Live or Paused stream that\n * holds still while the pointer rests on it.\n */\nexport function LogsPanel({\n  lines: initial = sampleLines,\n  next = sampleLine,\n  interval = 1400,\n  max = 300,\n  title = \"Logs\",\n  className,\n}: LogsPanelProps) {\n  const [lines, setLines] = useState(initial);\n  const [mode, setMode] = useState<\"live\" | \"paused\">(\"live\");\n  const [hovering, setHovering] = useState(false);\n  const [waiting, setWaiting] = useState<LogLine[]>([]);\n  const [level, setLevel] = useState<LogLevel | \"all\">(\"all\");\n  const [sources, setSources] = useState<ReadonlySet<string>>(new Set());\n  const [query, setQuery] = useState(\"\");\n  const counter = useRef(initial.length);\n  const newest = useRef<LogLine | undefined>(initial[0]);\n  const held = mode === \"paused\" || hovering;\n\n  useEffect(() => setLines(initial), [initial]);\n\n  // The stream keeps producing while held; lines queue and land on resume, so nothing moves under the pointer.\n  useEffect(() => {\n    if (!next) return;\n    const timer = setInterval(() => {\n      const line = next(counter.current++, newest.current);\n      newest.current = line;\n      setWaiting((w) => [line, ...w]);\n    }, interval);\n    return () => clearInterval(timer);\n  }, [next, interval]);\n\n  useEffect(() => {\n    if (held || waiting.length === 0) return;\n    setLines((current) => [...waiting, ...current].slice(0, max));\n    setWaiting([]);\n  }, [held, waiting, max]);\n\n  const allSources = useMemo(() => {\n    const counts = new Map<string, number>();\n    for (const l of lines) counts.set(l.source, (counts.get(l.source) ?? 0) + 1);\n    return [...counts.entries()].sort((a, b) => b[1] - a[1]);\n  }, [lines]);\n\n  const needle = query.trim().toLowerCase();\n  const shown = lines.filter(\n    (l) =>\n      (level === \"all\" || l.level === level) &&\n      (sources.size === 0 || sources.has(l.source)) &&\n      (!needle || `${l.method ?? \"\"} ${l.path ?? \"\"} ${l.status ?? \"\"} ${l.message}`.toLowerCase().includes(needle)),\n  );\n  const filtered = level !== \"all\" || sources.size > 0 || needle !== \"\";\n  const clear = () => {\n    setLevel(\"all\");\n    setSources(new Set());\n    setQuery(\"\");\n  };\n  const toggleSource = (s: string) =>\n    setSources((current) => {\n      const nextSet = new Set(current);\n      if (nextSet.has(s)) nextSet.delete(s);\n      else nextSet.add(s);\n      return nextSet;\n    });\n\n  const statusLabel = `${\n    mode === \"paused\"\n      ? `Paused${waiting.length ? ` · ${waiting.length} new ${waiting.length === 1 ? \"line\" : \"lines\"} waiting` : \"\"}`\n      : hovering\n        ? \"Held while you read\"\n        : \"Live · streaming\"\n  } · ${shown.length} ${shown.length === 1 ? \"line\" : \"lines\"}${filtered ? ` of ${lines.length}` : \"\"}`;\n\n  return (\n    <section\n      aria-labelledby=\"logs-panel-title\"\n      className={cn(\"@container flex min-w-0 flex-col rounded-xl bg-surface text-fg shadow-card\", className)}\n    >\n      <div className=\"flex flex-col gap-3 px-4 pt-4 pb-3\">\n        <div className=\"flex flex-wrap items-center gap-3\">\n          <h2 id=\"logs-panel-title\" className=\"me-auto min-w-0 truncate text-sb-md font-semibold\">\n            {title}\n          </h2>\n          <div className=\"relative order-last w-full @2xl:order-none @2xl:w-64\">\n            <SearchIcon className=\"pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-fg-muted\" />\n            <Input\n              type=\"search\"\n              aria-label=\"Search logs\"\n              placeholder=\"Path, status or message\"\n              value={query}\n              onChange={(event) => setQuery(event.target.value)}\n              className=\"ps-9\"\n            />\n          </div>\n          <SegmentedControl\n            aria-label=\"Stream\"\n            size=\"sm\"\n            className=\"w-40 shrink-0\"\n            value={mode}\n            options={[\n              { value: \"live\", label: \"Live\" },\n              { value: \"paused\", label: \"Paused\" },\n            ]}\n            onValueChange={setMode}\n          />\n        </div>\n        <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2\">\n          <div role=\"group\" aria-label=\"Level\" className=\"flex flex-wrap items-center gap-1.5\">\n            {LEVELS.map((l) => (\n              <Chip key={l.value} pressed={level === l.value} onClick={() => setLevel(l.value)}>\n                {l.label}\n              </Chip>\n            ))}\n          </div>\n          <span aria-hidden=\"true\" className=\"hidden h-5 w-px bg-hairline @md:block\" />\n          <div role=\"group\" aria-label=\"Source\" className=\"flex flex-wrap items-center gap-1.5\">\n            {allSources.map(([s, n]) => (\n              <Chip key={s} pressed={sources.has(s)} onClick={() => toggleSource(s)} count={n}>\n                {s}\n              </Chip>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <div\n        // A scroller must be reachable by keyboard so the log can be read without a mouse.\n        tabIndex={0}\n        role=\"region\"\n        aria-label=\"Log lines\"\n        className={cn(\n          \"h-[26rem] overflow-y-auto border-t border-hairline font-mono text-[13px] leading-5\",\n          focusRingInset,\n        )}\n        onPointerEnter={(event) => event.pointerType === \"mouse\" && setHovering(true)}\n        onPointerLeave={() => setHovering(false)}\n      >\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            \"sticky top-0 z-[1] hidden bg-surface px-4 py-2 font-sans text-sb-xs font-semibold text-fg-muted shadow-[inset_0_-1px_0_var(--sb-hairline)] @3xl:grid\",\n            ROW,\n          )}\n        >\n          <span>Time (UTC)</span>\n          <span>Level</span>\n          <span>Source</span>\n          <span>Message</span>\n          <span className=\"text-end\">Duration</span>\n        </div>\n        {shown.length === 0 ? (\n          <div className=\"flex h-[calc(100%-2.25rem)] flex-col items-center justify-center gap-2 px-6 text-center font-sans text-sb-base text-fg-muted\">\n            <p>{filtered ? \"No lines match these filters yet.\" : \"Waiting for the first line.\"}</p>\n            {filtered ? (\n              <Button size=\"sm\" variant=\"ghost\" onClick={clear}>\n                Clear filters\n              </Button>\n            ) : null}\n          </div>\n        ) : (\n          <ol aria-label=\"Log lines, newest first\" className=\"divide-y divide-hairline/60\">\n            {shown.map((line) => (\n              <Line key={line.id} line={line} />\n            ))}\n          </ol>\n        )}\n      </div>\n\n      <p className=\"flex min-h-11 items-center border-t border-hairline px-4 py-2.5 text-sb-sm text-fg-muted\">\n        <StatusDot tone={held ? \"neutral\" : \"success\"} live={!held} label={statusLabel} />\n      </p>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/ikada/blocks/logs-panel/logs-panel.tsx"
    }
  ],
  "categories": [
    "dashboard"
  ],
  "type": "registry:block"
}