Logs explorer
NewA live log search screen: a query that colours its own syntax, a stacked histogram by level, streaming lines and a details panel.
logs-explorer/logs-explorer.tsxLogs
28,369 lines · Last 1 hour · newest first
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A screen for reading what a system is saying right now: recent lines, newest first, still arriving.
- Finding the lines behind a spike: the histogram shows when, the query narrows to what, the details show why.
- Readers who already think in queries like
level:error -healthcheck, and want the syntax coloured back to them. - Lines with structured fields and attributes, where one line is opened, read and compared with its neighbours.
- An incident review, with the incident’s window drawn over the volume it caused.
When not to use
- A few dozen events a day. A plain activity list or a table is easier to read than a stream.
- Metrics over time with no lines behind them. Use a chart dashboard instead.
- Readers who never write queries: give them filter chips or a records table with a search.
- One request followed across services. That is a trace view, with spans on a timeline.
- A phone as the main place to read logs: under 720 px the table scrolls sideways in its card.
You can add this component with the shadcn CLI or by hand:
Into components/logs-explorer of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/logs-explorer.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { LogsExplorer } from './logs-explorer';export function ErrorsPage() { return ( <LogsExplorer defaultQuery="service:checkout level:error" defaultRange="24h" defaultOpen={false} onQueryChange={(query) => console.log(query)} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A whole log explorer on a frosted frame. New lines stream in at the top while the histogram of the last hour fills its newest bar and slides on with each new bar. Type level:error or -healthcheck and both the bars and the table follow; hover a bar for its counts, and open a line to read its fields, attributes and resource, then step through its neighbours.
- Volume is kept as counts per bar per message template, 56 bars of 16 numbers, never as lines. A query is tested once per template, so the histogram and the total follow any query exactly without holding 28,000 lines; the table holds 360 real lines, each made from its template with its own ids and attributes.
- The query field is an input with transparent text and a visible caret, laid over a copy of the query in coloured spans: field keys in blue, negated terms in red. The copy follows the input’s horizontal scroll by a transform.
- The query language is words separated by spaces, all of which must hold: level:, service:, env:, host: and pattern: look at a field, a bare word searches the message, and a leading minus turns a term round. Clicking a legend entry sets or clears the level term.
- Live, a made-up clock runs on: every 900 ms one or two lines arrive, the newest bar fills and, with each new bar (about a minute for the hour), the oldest drops out. The rows slide down and the bars slide left through the Web Animations API, and readers scrolled down the list keep their place.
- Each bar is a column clipped by clip-path inset() with round top corners; its levels are stacked layers scaled with scaleY and moved with translateY. Height changes are transitions on transform and clip-path only, so the stream never lays the page out.
- Each range (15 min, 1 h, 24 h, 7 d) is split into 56 bars. Ticks go under the bar holding each round time, and the incident’s band covers the bars it overlaps.
- The details are fields, attributes and resource in definition lists, with comments such as // Gateway Timeout tinted by meaning. From 1100 px they sit in their own column; narrower, they slide over the list. Under 720 px the rail hides behind the breadcrumb button, the controls scroll sideways and the table scrolls inside its card.
- The stream runs only on screen and in a visible tab, and starts paused under reduced motion. While the component is on screen and untouched, a demo pointer hovers the incident’s bars, types level:error, opens a line and steps to the next. Any real input stops it.
- Every colour is a light-dark() token, so the screen follows the page’s color-scheme or the colorScheme prop. At night it is near-black on a vivid field; by day it is white on a pastel one.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultQuery | string | 'env:production -healthcheck' | The query at first. Keys: level, service, env, host, pattern; a bare word searches the message; a leading minus leaves matching lines out. |
defaultRange | '15m' | '1h' | '24h' | '7d' | '1h' | The time range at first; each draws 56 bars. |
defaultView | 'list' | 'visualize' | 'list' | The lines, or a breakdown of the volume by service and level. |
defaultLive | boolean | true | Whether new lines stream in at first. Under reduced motion it starts paused; the Live button runs it. |
defaultOpen | boolean | true | Opens the details of the checkout’s 504 at first, when the screen is at least 1100 px wide and the details have a column of their own. |
onQueryChange | (query: string) => void | — | Called with the query as the reader edits it. |
onOpenLine | (line: { id: number; time: number; level: Level; service: string; message: string }) => void | — | Called with the line whose details open. |
defaultBar | number | — | A histogram bar (0 the oldest, 55 the newest) drawn hovered at first, its counts in the tooltip, until the pointer or the keys move on. Turns the demo off. |
autoplay | boolean | true | While on screen and untouched, a demo pointer hovers the histogram, filters by level and opens a line. Any real input stops it. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s; without it, the page’s color-scheme. |
className | string | — | Classes for the root, the colour field around the frame. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode,} from 'react';import { BARS, DEFAULT_QUERY, INCIDENT, KEEP, LEVELS, LEVEL_LABEL, RANGES, SERVICES, SEVERITY,// Copyright (c) 2026 Bruno Delorence. MIT License./* * The logs explorer's invented world: four services (web, api, checkout, worker) writing lines * from sixteen message templates, an incident on the inventory service a quarter of an hour ago, * and the query language that filters them. * * - Volume is kept as counts per time bucket per template, never as lines: 56 buckets for every * range (about 16 s, 64 s, 26 min or 3 h each), 16 counts in each. Any query is tested once per * template, so the histogram and the total follow it exactly without holding 28,000 lines. * - The table holds a few hundred real lines, newest first, each made from its template with its * own ids, numbers and attributes. The first twelve are written out by hand. * - Everything is seeded, so the screen is the same at every load; the clock is a made-up one, * Oct 8, 16:16:31.902 CEST, that runs on while the stream is live. */export type Level = 'error' | 'warn' | 'info' | 'debug';/** From the top of a bar down: the stack is drawn the other way round. */export const LEVELS: Level[] = ['error', 'warn', 'info', 'debug'];export const LEVEL_LABEL: Record<Level, string> = { error: 'Error', warn: 'Warn', info: 'Info', debug: 'Debug',// Copyright (c) 2026 Bruno Delorence. MIT License./* * The explorer's icons: 24-unit line drawings in the text's colour, sized by the stylesheet * (the lab page would otherwise draw an unsized SVG at 16 px). All are decorative: the buttons * that hold them carry their own names. */const PATHS = { bot: 'M11.2 7.5V4.6h2.3M7.5 7.5h9a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2ZM3.5 12v2.5M20.5 12v2.5M10 12.2v1.3M14 12.2v1.3', home: 'M4.5 10.2 12 4l7.5 6.2V19a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1ZM12 12.6a1.7 1.7 0 1 0 0 3.4 1.7 1.7 0 0 0 0-3.4Z', sparkle: 'M10 4.5c.5 3.3 1.9 4.8 5.2 5.3-3.3.5-4.7 2-5.2 5.2-.5-3.2-2-4.7-5.2-5.2 3.2-.5 4.7-2 5.2-5.3ZM17.5 14.5v5M15 17h5', globe: 'M12 3.8a8.2 8.2 0 1 0 0 16.4 8.2 8.2 0 0 0 0-16.4ZM3.8 12h16.4M12 3.8c2.2 2.3 3.2 5 3.2 8.2s-1 5.9-3.2 8.2c-2.2-2.3-3.2-5-3.2-8.2s1-5.9 3.2-8.2Z', server: 'M6 4.5h12A1.5 1.5 0 0 1 19.5 6v3a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 9V6A1.5 1.5 0 0 1 6 4.5ZM6 13.5h12a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 18v-3A1.5 1.5 0 0 1 6 13.5ZM8 7.5h.01M8 16.5h.01', gauge: 'M4.6 16.5a8 8 0 1 1 14.8 0M12 13l3.2-3.6', logs: 'M4.5 6.5h15M4.5 11h8M4.5 15.5h6M15.7 17.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2ZM17.6 16.9l2.4 2.4', grid: 'M5 4.5h4.5a.5.5 0 0 1 .5.5v6.5a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5ZM14.5 4.5H19a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-4.5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5ZM14.5 11.5H19a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-4.5a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5ZM5 15h4.5a.5.5 0 0 1 .5.5V19a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5v-3.5A.5.5 0 0 1 5 15Z', database: 'M12 4c3.9 0 7 1.1 7 2.5S15.9 9 12 9 5 7.9 5 6.5 8.1 4 12 4ZM5 6.5v11C5 18.9 8.1 20 12 20s7-1.1 7-2.5v-11M5 12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5', bell: 'M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 1.5H5ZM10 20.5h4', flame:// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the * hand are HTML boxes that swap by their visibility: a change of style inside an SVG would lay the * SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input (never, for an infinite `idle`). `visible` and * `reduced` are handed out too: the live stream runs while the screen is visible, whoever drives * it. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false;/* Logs explorer (logs-explorer.tsx): a frosted frame on a soft colour field, holding an icon rail, a near-black panel (white by day) with the query, the histogram and the lines, and the details of a line on the frame's own glass. Sizes are the screen's own px; the frame's height follows the component's width between 640 and 824 px. The layout (data-layout, set from the component's width) puts the details in a column of their own from 1100 px, over the list below that, and hides the rail under 720 px. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Memory. The root is a layout boundary; the bars and the rows move by transforms, the bars' heights are transforms under a clip-path, and the colour field is one painted background, so the live stream never lays the page out or repaints it. */.root { --sans: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; --mono: 'IBM Plex Mono', JetBrains, ui-monospace, 'SF Mono', Menlo, Consolas, monospace; /* The colour field behind the frame. */ --field-a: #ffc9a1; --field-b: #e4e3a6; --field-c: #ffb994; --field-d: #9cc3ff;- The lines are an ARIA grid with one row in the tab order: Up and Down, Page Up, Page Down, Home and End move between rows, and Enter or Space opens a row’s details. The selected row has aria-selected.
- The histogram takes the focus: Left and Right step through the bars, and each is read out with its time span and its count per level. Hovering shows the same in a tooltip.
- The query has a label and a description of its keys; Slash from anywhere in the screen brings the focus to it, and Escape clears it. Escape elsewhere closes the details and returns the focus to the row.
- Range and view are radio groups; Live, the legend entries and the incident toggle are buttons with aria-pressed. Rail buttons are named, and the current section has aria-current.
- A polite live region says how many lines a new query or range finds, when the stream pauses or resumes, and when a line is copied. The stream itself is not announced.
- The Live button pauses the moving content, and the stream starts paused under reduced motion, which also turns off the rows’ slide, the bars’ slide and the panel’s entrance.
- Colours are not the only signal: levels are written in their badges, and the legend and tooltip name each colour. The demo pointer is hidden from assistive technology.