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.
<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.
<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.
<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.
<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"/>