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