Infrastructure & DevOps

Log Stream

A live-tailing log viewer with severity-tinted rows, filter chips, and pausing on hover or when scrolled offscreen.

Install

npx shadcn@latest add @paragon/log-stream

log-stream.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";

export type LogLevel = "debug" | "info" | "warn" | "error";

export interface LogLine {
  level: LogLevel;
  service: string;
  message: string;
}

export interface LogStreamProps extends React.ComponentProps<"div"> {
  /** Pool of lines the stream cycles through deterministically. */
  lines?: LogLine[];
  /** ms between appended lines. */
  interval?: number;
  /** Max lines kept in the viewport buffer. */
  bufferSize?: number;
  /** Disables the streaming ticker (renders a static buffer). */
  static?: boolean;
}

const LEVEL_STYLE: Record<
  LogLevel,
  { text: string; tint: string; label: string; dot: string }
> = {
  debug: {
    text: "text-muted-foreground",
    tint: "transparent",
    label: "DBG",
    dot: "var(--color-muted-foreground)",
  },
  info: {
    text: "text-foreground",
    tint: "transparent",
    label: "INF",
    dot: "var(--color-success)",
  },
  warn: {
    text: "text-warning",
    tint: "color-mix(in oklch, var(--color-warning) 8%, transparent)",
    label: "WRN",
    dot: "var(--color-warning)",
  },
  error: {
    text: "text-destructive",
    tint: "color-mix(in oklch, var(--color-destructive) 8%, transparent)",
    label: "ERR",
    dot: "var(--color-destructive)",
  },
};

const DEFAULT_LINES: LogLine[] = [
  { level: "info", service: "api", message: "GET /v1/orders 200 in 42ms" },
  { level: "info", service: "auth", message: "issued token for user_8823" },
  { level: "debug", service: "worker", message: "job queue depth=3" },
  { level: "warn", service: "redis", message: "connection pool at 82% capacity" },
  { level: "info", service: "api", message: "POST /v1/checkout 201 in 118ms" },
  { level: "error", service: "billing", message: "stripe webhook 503 — retrying (2/5)" },
  { level: "info", service: "cdn", message: "cache HIT edge-fra1 /assets/app.js" },
  { level: "debug", service: "scheduler", message: "next cron in 240s" },
  { level: "warn", service: "orders", message: "p95 latency 610ms exceeds SLO" },
  { level: "info", service: "auth", message: "GET /v1/session 200 in 11ms" },
];

const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];

interface Row {
  id: number;
  line: LogLine;
  ts: string;
}

export function LogStream({
  lines = DEFAULT_LINES,
  interval = 1100,
  bufferSize = 9,
  static: isStatic = false,
  className,
  ...props
}: LogStreamProps) {
  const reduced = useReducedMotion();
  // Deterministic initial buffer — id derived from a counter, never Date.now.
  const build = React.useCallback(
    (count: number): Row[] =>
      lines.slice(0, Math.min(count, lines.length)).map((line, i) => ({
        id: i,
        line,
        ts: fmtTime(i),
      })),
    [lines],
  );

  const [buffer, setBuffer] = React.useState<Row[]>(() =>
    build(Math.min(5, bufferSize)),
  );
  const [filters, setFilters] = React.useState<Set<LogLevel>>(new Set());
  const [hovered, setHovered] = React.useState(false);
  const [inView, setInView] = React.useState(true);
  const cursor = React.useRef(Math.min(5, bufferSize));
  const ref = React.useRef<HTMLDivElement>(null);

  // Reset the stream when the source pool changes (scenario switch / replay).
  React.useEffect(() => {
    cursor.current = Math.min(5, bufferSize);
    setBuffer(build(Math.min(5, bufferSize)));
  }, [build, bufferSize]);

  const paused = hovered || !inView || isStatic;

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), {
      threshold: 0.1,
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  React.useEffect(() => {
    if (paused) return;
    const t = setInterval(() => {
      setBuffer((prev) => {
        const c = cursor.current;
        const next = lines[c % lines.length];
        cursor.current = c + 1;
        return [...prev, { id: c, line: next, ts: fmtTime(c) }].slice(
          -bufferSize,
        );
      });
    }, interval);
    return () => clearInterval(t);
  }, [paused, interval, lines, bufferSize]);

  const visible = buffer.filter(
    (b) => filters.size === 0 || filters.has(b.line.level),
  );

  const toggle = (lvl: LogLevel) =>
    setFilters((prev) => {
      const next = new Set(prev);
      if (next.has(lvl)) next.delete(lvl);
      else next.add(lvl);
      return next;
    });

  const animate = !reduced && !isStatic;

  return (
    <div
      ref={ref}
      className={cn(
        "flex w-full max-w-xl flex-col overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-2 border-b border-border px-3 py-2">
        <span
          aria-hidden
          className="size-2 rounded-full transition-colors duration-(--duration-quick) ease-out"
          style={{
            background: paused
              ? "var(--color-muted-foreground)"
              : "var(--color-success)",
          }}
        />
        <span className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
          {paused ? <Pause className="size-3" /> : <Play className="size-3" />}
          {paused ? "Paused" : "Tailing"}
        </span>
        <div className="ml-auto flex gap-1">
          {LEVELS.map((lvl) => {
            const on = filters.has(lvl);
            return (
              <button
                key={lvl}
                type="button"
                onClick={() => toggle(lvl)}
                aria-pressed={on}
                aria-label={`Filter ${lvl}`}
                className={cn(
                  "pressable inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase outline-none transition-[background-color,color,opacity] duration-(--duration-quick) ease-out focus-visible:ring-2 focus-visible:ring-ring",
                  on
                    ? "bg-secondary text-secondary-foreground"
                    : "text-muted-foreground hover:bg-secondary/60",
                  filters.size > 0 && !on && "opacity-50",
                )}
              >
                <span
                  className="size-1.5 rounded-full"
                  style={{ background: LEVEL_STYLE[lvl].dot }}
                  aria-hidden
                />
                {LEVEL_STYLE[lvl].label}
              </button>
            );
          })}
        </div>
      </div>
      <div
        className="h-[240px] overflow-hidden font-mono text-xs"
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
      >
        {visible.length === 0 ? (
          <div className="flex h-full items-center justify-center text-muted-foreground">
            No lines match the active filters.
          </div>
        ) : (
          <div className="flex h-full flex-col justify-end">
            <AnimatePresence initial={false}>
              {visible.map((b) => {
                const s = LEVEL_STYLE[b.line.level];
                return (
                  <motion.div
                    key={b.id}
                    layout={animate ? "position" : false}
                    initial={
                      animate
                        ? { opacity: 0, y: 8, filter: "blur(4px)" }
                        : false
                    }
                    animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                    transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
                    className="flex items-baseline gap-2 px-3 py-1"
                    style={{ background: s.tint }}
                  >
                    <span className="shrink-0 tabular-nums text-muted-foreground/70">
                      {b.ts}
                    </span>
                    <span className={cn("w-7 shrink-0 font-semibold", s.text)}>
                      {s.label}
                    </span>
                    <span className="shrink-0 text-muted-foreground">
                      {b.line.service}
                    </span>
                    <span className={cn("truncate", s.text)}>
                      {b.line.message}
                    </span>
                  </motion.div>
                );
              })}
            </AnimatePresence>
          </div>
        )}
      </div>
    </div>
  );
}

function fmtTime(n: number) {
  // Deterministic HH:MM:SS derived from a counter — no Date.now().
  const base = 8 * 3600 + 41 * 60 + 3; // 08:41:03
  const t = base + n * 3;
  const hh = Math.floor(t / 3600) % 24;
  const mm = Math.floor((t % 3600) / 60);
  const ss = t % 60;
  return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}

function pad(n: number) {
  return n.toString().padStart(2, "0");
}