Skip to content
6

Log Viewer

Streaming log panel with severity-colored lines, timestamps, sources and tail-follow that unpins when you scroll up. Ships composable parts.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/log-viewer.json

Code

components/blocks/log-viewer.tsx
'use client';

import * as React from 'react';

import { cn } from '@/lib/utils';

export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';

const levelText: Record<LogLevel, string> = {
  debug: 'text-muted-foreground',
  info: 'text-info',
  warn: 'text-warning',
  error: 'text-destructive',
  success: 'text-success',
};

const levelLabel: Record<LogLevel, string> = {
  debug: 'DBG',
  info: 'INF',
  warn: 'WRN',
  error: 'ERR',
  success: 'OK ',
};

interface LogViewerProps extends React.ComponentProps<'div'> {
  /** Keep the view pinned to the newest line as content grows (tail -f). */
  follow?: boolean;
}

const LogViewer = ({ follow = true, className, children, ...props }: LogViewerProps) => {
  const ref = React.useRef<HTMLDivElement>(null);
  const pinnedRef = React.useRef(true);

  const onScroll = React.useCallback(() => {
    const el = ref.current;
    if (!el) return;
    pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
  }, []);

  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el || !follow || !pinnedRef.current) return;
    el.scrollTop = el.scrollHeight;
  });

  return (
    <div
      ref={ref}
      data-slot="log-viewer"
      onScroll={onScroll}
      className={cn(
        'max-h-72 overflow-auto rounded-lg border border-border bg-card p-3 font-mono text-xs leading-relaxed',
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
};

interface LogLineProps extends React.ComponentProps<'div'> {
  level?: LogLevel;
  time?: React.ReactNode;
  source?: React.ReactNode;
}

const LogLine = ({ level = 'info', time, source, className, children, ...props }: LogLineProps) => {
  return (
    <div
      data-slot="log-line"
      data-level={level}
      className={cn('flex gap-2 whitespace-pre-wrap break-words px-1 py-0.5', 'hover:bg-muted/50', className)}
      {...props}
    >
      {time ? <span className="shrink-0 text-muted-foreground tabular-nums">{time}</span> : null}
      <span className={cn('shrink-0 select-none font-medium', levelText[level])} aria-label={level}>
        {levelLabel[level]}
      </span>
      {source ? <span className="shrink-0 text-muted-foreground/80">{source}</span> : null}
      <span className="min-w-0 text-foreground">{children}</span>
    </div>
  );
};

export { LogViewer, LogLine };

const LogViewerBlock = () => {
  return (
    <section data-slot="log-viewer-block" className="flex w-full justify-center bg-background p-6 sm:p-10">
      <div className="w-full max-w-2xl" dir="ltr">
        <LogViewer>
          <LogLine time="12:04:01" level="info" source="[api]">
            listening on :8080
          </LogLine>
          <LogLine time="12:04:01" level="debug" source="[db]">
            pool acquired (8 idle, 2 active)
          </LogLine>
          <LogLine time="12:04:02" level="success" source="[api]">
            GET /health 200 3ms
          </LogLine>
          <LogLine time="12:04:03" level="info" source="[worker]">
            job 4821 started (resize-image)
          </LogLine>
          <LogLine time="12:04:03" level="warn" source="[cache]">
            key eviction: memory at 82%
          </LogLine>
          <LogLine time="12:04:04" level="info" source="[api]">
            POST /v1/deploy 202 41ms
          </LogLine>
          <LogLine time="12:04:05" level="error" source="[worker]">
            job 4821 failed: upstream timeout after 30s
          </LogLine>
          <LogLine time="12:04:05" level="warn" source="[worker]">
            retry 1/3 scheduled in 2s
          </LogLine>
          <LogLine time="12:04:07" level="success" source="[worker]">
            job 4821 recovered on retry
          </LogLine>
          <LogLine time="12:04:08" level="debug" source="[db]">
            slow query 214ms: SELECT * FROM events
          </LogLine>
        </LogViewer>
      </div>
    </section>
  );
};

export default LogViewerBlock;