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.jsonCode
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;