Skip to content
Data Display

LogViewer

A log console: levels, filtering, search, ANSI colour, expandable rows and a tail that follows.

Walkthrough

1. Basic

Entries carry their own level, time and source. Click a row that has details to open them under it — an object as a JSON tree, a string (a stack trace, say) as a block. Rows stay virtualised while panels are open, so the same console handles ten lines or a hundred thousand.

9
09:14:02 AM info api server listening on :3000
09:14:05 AM info api GET /api/orders 200 in 24ms
09:14:08 AM info api POST /api/orders 201 in 96ms
09:14:11 AM info payments retrying payment intent pi_8f21 (1/3)
09:14:15 AM trace cache cache write orders:8f21 (ttl 60s)
<script lang="ts">	import { LogViewer, type LogEntry } from 'omaris';​	const start = new Date('2026-09-04T09:14:02');	const at = (s: number) => new Date(start.getTime() + s * 1000);​	const LINES: LogEntry[] = [		{ level: 'info', time: at(0), source: 'api', message: 'server listening on :3000' },		{			level: 'debug',			time: at(1),			source: 'db',			message: 'pool: 4 connections opened',			details: { host: 'db.internal', pool: { min: 2, max: 8, idle_ms: 30000 } }		},		{ level: 'info', time: at(3), source: 'api', message: 'GET /api/orders 200 in 24ms' },		{			level: 'warn',			time: at(5),			source: 'db',			message: 'slow query: orders.by_customer took 812ms',			details: {				query: 'orders.by_customer',				duration_ms: 812,				params: { customer: 'cus_41', limit: 50 },				plan: ['Seq Scan on orders', 'Filter: (customer_id = $1)']			}		},		{ level: 'info', time: at(6), source: 'api', message: 'POST /api/orders 201 in 96ms' },		{			level: 'error',			time: at(8),			source: 'payments',			message: 'payment gateway timed out after 5000ms',			details:				'GatewayTimeout: no response from https://gateway.example/v1/intents\n    at request (/app/lib/gateway.js:52:11)\n    at async createIntent (/app/lib/payments.js:118:15)\n    at async handler (/app/routes/orders.js:41:9)'		},		{			level: 'info',			time: at(9),			source: 'payments',			message: 'retrying payment intent pi_8f21 (1/3)'		},		{			level: 'info',			time: at(12),			source: 'payments',			message: 'payment intent pi_8f21 succeeded',			details: { intent: 'pi_8f21', amount: 24500, currency: 'IQD', attempts: 2 }		},		{ level: 'trace', time: at(13), source: 'cache', message: 'cache write orders:8f21 (ttl 60s)' }	];</script>​<LogViewer lines={LINES} height="18rem" class="w-full" />

2. Following a tail

follow sticks to the newest line while output arrives and lets go the moment you scroll up — the behaviour every terminal has. Scroll away and the "jump to newest" button appears; levels is bindable, so the chips are state you can read. Appending is incremental: a new line parses one line.

0 lines · following: yes · 6 levels shown
0

Nothing yet — press start.

<script lang="ts">	import { Button, LogViewer, Text, type LogEntry, type LogLevel } from 'omaris';​	const MESSAGES: [LogLevel, string, string][] = [		['info', 'api', 'GET /api/menu 200 in 12ms'],		['debug', 'cache', 'cache hit menu:erbil'],		['info', 'api', 'POST /api/orders 201 in 88ms'],		['warn', 'api', 'rate limit at 80% for 10.0.0.4'],		['error', 'receipts', 'upstream 502 from receipts-service'],		['info', 'worker-1', 'worker drained 12 jobs']	];​	let lines = $state<LogEntry[]>([]);	let follow = $state(true);	let levels = $state<LogLevel[]>(['trace', 'debug', 'info', 'warn', 'error', 'fatal']);	let timer: ReturnType<typeof setInterval> | null = null;​	function tick() {		const [level, source, message] = MESSAGES[Math.floor(Math.random() * MESSAGES.length)];		const details = level === 'error' ? { upstream: 'receipts-service', status: 502 } : undefined;		lines = [...lines, { level, source, time: new Date(), message, details }].slice(-500);	}​	function toggle() {		if (timer) {			clearInterval(timer);			timer = null;		} else {			timer = setInterval(tick, 600);		}	}​	$effect(() => () => {		if (timer) clearInterval(timer);	});</script>​<div class="flex w-full flex-col gap-3">	<div class="flex items-center gap-3">		<Button size="sm" variant="tonal" onclick={toggle}>Start / stop output</Button>		<Text variant="label-sm" tone="muted">			{lines.length} lines · following: {follow ? 'yes' : 'no'} · {levels.length} levels shown		</Text>	</div>​	<LogViewer		{lines}		bind:follow		bind:levels		height="14rem"		emptyText="Nothing yet — press start."	/></div>

3. Search and levels

Three hundred lines, mixed. Search narrows to the matching lines and marks each hit; Enter and Shift+Enter in the field (or the arrows beside it) step through them, and the current one scrolls into view. The chips carry counts, and a line with no level — the separators here — stays put whichever chips are off. ↑/↓ walk the rows, Enter opens one.

300
--- batch 1 ---
09:00:01 AM info api GET /api/menu 200 in 5ms
09:00:02 AM info worker-1 POST /api/receipts 200 in 6ms
09:00:03 AM debug api cache hit th:3
09:00:04 AM info worker-1 POST /api/orders 200 in 8ms
09:00:05 AM info api GET /api/menu 200 in 9ms
09:00:06 AM debug worker-1 cache miss receipts:6
09:00:07 AM warn api GET /health 429 in 11ms
09:00:08 AM info worker-1 POST /api/orders 200 in 12ms
<script lang="ts">	import { LogViewer, type LogEntry } from 'omaris';​	const ROUTES = ['/api/orders', '/api/menu', '/api/receipts', '/health'];	const start = new Date('2026-09-04T09:00:00');​	const LINES: (string | LogEntry)[] = Array.from({ length: 300 }, (_, i) => {		if (i % 25 === 0) return `--- batch ${i / 25 + 1} ---`;		const level = i % 17 === 0 ? 'error' : i % 7 === 0 ? 'warn' : i % 3 === 0 ? 'debug' : 'info';		const route = ROUTES[i % ROUTES.length];		const verb = i % 2 ? 'GET' : 'POST';		return {			time: new Date(start.getTime() + i * 1000),			level,			source: i % 2 ? 'api' : 'worker-1',			message:				level === 'error'					? `upstream 502 from receipts-service on ${route}`					: level === 'debug'						? `cache ${i % 6 ? 'hit' : 'miss'} ${route.slice(5) || 'health'}:${i}`						: `${verb} ${route} ${level === 'warn' ? '429' : '200'} in ${(i % 90) + 4}ms`,			details:				level === 'error'					? { route, status: 502, upstream: 'receipts-service', request: i }					: undefined		};	});</script>​<LogViewer lines={LINES} height="20rem" size="sm" class="w-full" />

4. Overridden

Plain strings work too — a level is read off the line where there is one, and ANSI colour comes through as theme tokens. height lives in a property rather than a class, so a max-h-* on classes.viewport genuinely replaces it, and the header can be stripped back to nothing with search, filters and wrappable off. Every part is reachable through classes.

$ bun run build
vite v6.0.0 building SSR bundle for production...
✓ 412 modules transformed.
warn chunk "docs" is larger than 500 kB after minification
✓ built in 3.42s
error prerender failed for /docs/components/nope (404)
done in 4.10s
<script lang="ts">	import { LogViewer } from 'omaris';​	const E = '\u001b';	const OUTPUT = [		'$ bun run build',		'vite v6.0.0 building SSR bundle for production...',		`${E}[32m✓${E}[0m 412 modules transformed.`,		'WARN  chunk "docs" is larger than 500 kB after minification',		`${E}[32m✓${E}[0m built in ${E}[1m3.42s${E}[0m`,		'ERROR  prerender failed for /docs/components/nope (404)',		'done in 4.10s'	];</script>​<LogViewer	lines={OUTPUT}	size="sm"	search={false}	filters={false}	wrappable={false}	timestamps={false}	lineNumbers={false}	height="auto"	classes={{		root: 'rounded-shape-lg border-none bg-surface-container-high',		viewport: 'max-h-none py-2',		message: 'text-body-sm',		row: 'data-[level=error]:bg-transparent data-[level=error]:text-destructive'	}}	class="w-full"/>