Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Logs explorer

New

A 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.tsx
Live · interactive
LogsExplorer

Logs

Keys: level, service, env, host, pattern. A minus before a term leaves its lines out. Press slash to come back here.

28,369 lines · Last 1 hour · newest first

Incident · inventory-service down
Time (CEST)LevelServiceMessage
16:16:31.902INFOwebGET /checkout 200 21ms
16:16:31.640ERRORcheckoutPOST /api/checkout 504 5012ms
16:16:31.207WARNcheckoutRetrying inventory-service request, attempt 2/3
16:16:30.951ERRORcheckoutTimeout after 5000ms calling inventory-service (POST /reserve)
16:16:30.688INFOapiGET /api/orders/ord_8f2K1x 200 61ms
16:16:30.412INFOcheckoutpayment.authorized order=ord_8f2K1x amount=129.00 currency=EUR
16:16:30.150WARNapiSlow query on products (842ms): SELECT * FROM products WHERE category = $1
16:16:29.873INFOworkerjob email.order_confirmation completed in 312ms
16:16:29.604ERRORcheckoutTimeout after 5000ms calling inventory-service (POST /reserve)
16:16:29.341INFOapiGET /api/products?category=sneakers 200 42ms
16:16:29.088INFOwebGET / 200 12ms
16:16:28.832INFOworkerPayment webhook received: charge.succeeded (evt_3Pa8Lm)
16:16:28.622INFOworkerPayment webhook received: charge.succeeded (evt_nn5ayb)
16:16:28.323INFOapiGET /api/orders/ord_SrKbSe 200 35ms
16:16:28.052DEBUGwebrender /checkout in 19ms (ssr)
16:16:27.816INFOwebGET / 200 38ms
16:16:27.642INFOworkerjob email.order_confirmation completed in 350ms
16:16:27.397INFOapiGET /api/orders/ord_zFMYXp 200 76ms
16:16:27.237INFOworkerPayment webhook received: refund.created (evt_4Grcfi)
16:16:27.066INFOwebGET /cart 200 38ms
16:16:26.916INFOcheckoutpayment.authorized order=ord_Se2stN amount=289.00 currency=EUR
16:16:26.605WARNapiSlow query on products (1238ms): SELECT * FROM products WHERE category = $1
16:16:26.439INFOapiGET /api/products?category=bags 200 41ms
16:16:26.250ERRORcheckoutPOST /api/checkout 504 5028ms
16:16:25.977ERRORcheckoutPOST /api/checkout 504 5013ms
16:16:25.742INFOapiGET /api/products?category=bags 200 54ms
16:16:25.489INFOapiGET /api/orders/ord_PdFPyH 200 71ms
16:16:25.161WARNworkerQueue email backlog at 1,970 jobs
16:16:24.870INFOworkerPayment webhook received: charge.succeeded (evt_9XmRMD)
16:16:24.697INFOwebGET / 200 46ms
16:16:24.544INFOwebGET /products/sneakers 200 34ms
16:16:24.375INFOworkerjob search.reindex completed in 507ms
16:16:24.111INFOapiGET /api/products?category=sneakers 200 49ms
16:16:23.819ERRORapiUpstream inventory-service returned 503 in 40ms
16:16:23.571WARNwebGET /api/cart 429 5ms
16:16:23.250INFOapiGET /api/orders/ord_WmWEA2 200 89ms
16:16:22.962INFOapiGET /api/products?category=sneakers 200 62ms
16:16:22.689INFOworkerjob email.order_confirmation completed in 363ms
16:16:22.503INFOcheckoutpayment.authorized order=ord_eb85sg amount=36.50 currency=EUR
16:16:22.305DEBUGworkercache hit ratio 0.86 for catalog:sneakers
16:16:22.143INFOapiGET /api/orders/ord_ws26Di 200 70ms
16:16:21.865INFOworkerjob search.reindex completed in 614ms
16:16:21.589ERRORcheckoutPOST /api/checkout 504 5024ms
16:16:21.402INFOcheckoutpayment.authorized order=ord_nCAe3m amount=304.50 currency=EUR
16:16:21.187INFOwebGET /account 200 21ms
16:16:20.956INFOworkerPayment webhook received: charge.succeeded (evt_KVEaSM)
16:16:20.433INFOwebGET /cart 200 24ms
16:16:20.130INFOwebGET /account 200 12ms
16:16:19.927ERRORcheckoutTimeout after 5000ms calling inventory-service (POST /reserve)
16:16:19.645WARNcheckoutRetrying inventory-service request, attempt 2/3
16:16:19.456ERRORapiUpstream inventory-service returned 503 in 78ms
16:16:19.173INFOapiGET /api/orders/ord_s3pGz5 200 43ms
16:16:18.890WARNapiSlow query on products (1040ms): SELECT * FROM products WHERE category = $1
16:16:18.681INFOworkerjob invoice.render completed in 545ms
16:16:18.523INFOapiGET /api/orders/ord_29AQTt 200 39ms
16:16:18.302WARNwebGET /api/cart 429 3ms
16:16:18.100ERRORcheckoutPOST /api/checkout 504 5008ms
16:16:17.883ERRORapiUpstream inventory-service returned 503 in 36ms
16:16:17.591INFOapiGET /api/products?category=jackets 200 68ms
16:16:17.441INFOwebGET /checkout 200 40ms
16:16:17.224INFOworkerjob search.reindex completed in 250ms
16:16:16.911WARNcheckoutRetrying inventory-service request, attempt 2/3
16:16:16.658INFOwebGET /account 200 33ms
16:16:16.059WARNapiSlow query on products (1063ms): SELECT * FROM products WHERE category = $1
16:16:15.729INFOwebGET /cart 200 19ms
16:16:15.571INFOapiGET /api/orders/ord_VRsvXJ 200 87ms
16:16:15.258ERRORapiUpstream inventory-service returned 503 in 51ms
16:16:15.071WARNwebGET /api/cart 429 2ms
16:16:14.776INFOapiGET /api/products?category=boots 200 58ms
16:16:14.507INFOwebGET /cart 200 34ms
16:16:14.205INFOcheckoutpayment.authorized order=ord_VfG2Gm amount=327.00 currency=EUR
16:16:14.018INFOapiGET /api/products?category=boots 200 37ms
16:16:13.812INFOapiGET /api/orders/ord_xmRvqU 200 94ms
16:16:13.593ERRORcheckoutTimeout after 5000ms calling inventory-service (POST /reserve)
16:16:13.408INFOwebGET /checkout 200 9ms
16:16:13.159INFOcheckoutpayment.authorized order=ord_QDRLGS amount=325.50 currency=EUR
16:16:12.803WARNcheckoutRetrying inventory-service request, attempt 3/3
16:16:12.378INFOworkerPayment webhook received: charge.succeeded (evt_5QYxGY)
16:16:12.093WARNcheckoutRetrying inventory-service request, attempt 2/3
16:16:11.807WARNapiSlow query on products (1301ms): SELECT * FROM products WHERE category = $1
16:16:11.544INFOworkerjob search.reindex completed in 222ms
16:16:11.210INFOworkerjob search.reindex completed in 169ms
16:16:10.972ERRORcheckoutPOST /api/checkout 504 5009ms
16:16:10.665ERRORapiUpstream inventory-service returned 503 in 70ms
16:16:10.386INFOworkerjob email.order_confirmation completed in 626ms
16:16:10.080INFOapiGET /api/orders/ord_X8EbDF 200 59ms
16:16:09.805WARNcheckoutRetrying inventory-service request, attempt 2/3
16:16:09.505WARNcheckoutRetrying inventory-service request, attempt 3/3
16:16:09.184INFOworkerjob email.order_confirmation completed in 265ms
16:16:08.948WARNcheckoutRetrying inventory-service request, attempt 2/3

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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/logs-explorer.json

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

errors-page.tsx
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.
PropTypeDefaultDescription
defaultQuerystring'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.
defaultLivebooleantrueWhether new lines stream in at first. Under reduced motion it starts paused; the Live button runs it.
defaultOpenbooleantrueOpens 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.
defaultBarnumber—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.
autoplaybooleantrueWhile 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.
classNamestring—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.

logs-explorer.tsx
// 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,
data.ts
// 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',
icons.tsx
// 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:
demo-pointer.tsx
// 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 = {
use-autoplay.ts
// 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.module.css
/* 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.