Skip to contentVibraUI
Part of the Engineering dashboardinstalls at /engineering/logs

Log console

A tail of the platform's log with level and service filters, the saved queries a log is actually read through, a live dot the reader controls, and an export of exactly the lines on screen.

Open the live page

The page is a server component inside AppShell: it reads the newest 500 entries of db.logEntries, oldest first, and hands them to one client island. LogViewer's own toolbar is turned off — the page owns the filters, and a second set of level toggles would disagree with the first — so the level and service menus, the search box and the saved queries all edit one filter state, and picking a saved query shows up in the chips rather than hiding somewhere the reader cannot undo. The live dot is state the reader controls, not a clock: it parks the follow-the-bottom behaviour and says so, and no timer runs behind it. Export writes exactly the lines on screen as CSV or JSON. Composes AppShell, PageHeader, QuickFilters, LivePulse, ExportMenu, FilterBar, FilterMenu and LogViewer.

Preview

Install

npx shadcn@latest add @vibra/engineering-logs

Needs the @vibra registry in your components.json — set it up once.

Source

app/engineering/logs/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { LogsView } from "./components/logs-view"
import { currentUser, logRows, shellNotifications, streamSummary } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/engineering/nav"

/**
 * The log console. The page is a server component: it reads the tail through
 * `db` and hands it to one client island, which owns the saved queries, the
 * filters and whether the view is following the bottom of the stream.
 */
export default function LogsPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.logs}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Logs"
        description="Everything the platform has written, newest at the bottom."
        meta={streamSummary()}
      />

      <LogsView rows={logRows()} />
    </AppShell>
  )
}
app/engineering/logs/data.ts
/**
 * What this page reads. Every line comes from `db.logEntries` — a real stream
 * of a couple of thousand entries over a fortnight — and the page tails the
 * newest of them, the way a log viewer does. The window, the level counts and
 * the copy under the title are all counted off those rows against
 * `REFERENCE_DATE`; nothing here reads a clock, and no number is written out.
 */
import { formatNumber, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type LogEntry, type Member } from "@/lib/sample-data"

const ENTRIES = db.logEntries.all()

const DAY_MS = 86_400_000

/**
 * How much of the stream the viewer holds. A tail, not the whole log: past
 * this the DOM is what slows the page down, and nobody reads two thousand
 * lines from the top.
 */
export const TAIL_LINES = 500

export type LogLevel = LogEntry["level"]

export type LogRow = {
  id: string
  at: Date
  level: LogLevel
  service: string
  message: string
  requestId?: string
  durationMs?: number
}

/** The newest `TAIL_LINES` entries, oldest first — the order a tail scrolls in. */
export function logRows(): LogRow[] {
  return [...ENTRIES]
    .sort((a, b) => a.at.getTime() - b.at.getTime())
    .slice(-TAIL_LINES)
    .map(({ id, at, level, service, message, requestId, durationMs }) => ({
      id,
      at,
      level,
      service,
      message,
      requestId,
      durationMs,
    }))
}

/** The line under the title: how much log there is, and how much of it is loud. */
export function streamSummary(): string {
  const oldest = Math.min(...ENTRIES.map((line) => line.at.getTime()))
  const days = Math.round((REFERENCE_DATE.getTime() - oldest) / DAY_MS)
  const errors = ENTRIES.filter((line) => line.level === "error").length
  const total = formatNumber(ENTRIES.length, { maximumFractionDigits: 0 })
  return `${total} lines over the last ${days} days · ${errors} errors · tailing the newest ${TAIL_LINES}`
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
app/engineering/logs/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/engineering/logs/components/logs-view.tsx
"use client"

import * as React from "react"
import { PauseIcon, PlayIcon } from "lucide-react"

import { formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { ExportMenu, type ExportFormat } from "@/components/ui/export-menu"
import { FilterBar, FilterMenu } from "@/components/ui/filter-bar"
import { Input } from "@/components/ui/input"
import { LivePulse } from "@/components/ui/live-pulse"
import { LogViewer } from "@/components/ui/log-viewer"
import { QuickFilters } from "@/components/ui/quick-filters"

import { type LogLevel, type LogRow } from "../data"

import {
  applyFilters,
  matchesQuery,
  NO_FILTERS,
  SAVED_QUERIES,
  type LogFilters,
} from "./saved-queries"

// The four levels, quietest first — the order the log viewer's own toolbar
// used before this page took the filters over.
const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"]

const EXPORT_FORMATS: ExportFormat[] = ["csv", "json"]

const COLUMNS = ["timestamp", "level", "service", "message", "request_id", "duration_ms"] as const

// A structured line, the way the emitting service wrote it: the message, then
// the fields worth grepping for.
function lineText(row: LogRow): string {
  const fields = [
    row.requestId ? `request_id=${row.requestId}` : null,
    row.durationMs === undefined ? null : `duration_ms=${row.durationMs}`,
  ].filter(Boolean)
  return fields.length > 0 ? `${row.message} ${fields.join(" ")}` : row.message
}

/**
 * The lines on screen, as a file. Exported for its own test: the CSV header and
 * every row have to stay the same shape, and a message carrying a comma or a
 * quote must not be able to split a row in two.
 */
export function serialise(rows: LogRow[], format: ExportFormat): { text: string; mime: string } {
  if (format === "json") {
    return { text: JSON.stringify(rows, null, 2), mime: "application/json" }
  }
  const escape = (value: string) => `"${value.replace(/"/g, '""')}"`
  const body = rows.map((row) =>
    [
      row.at.toISOString(),
      row.level,
      row.service,
      escape(row.message),
      row.requestId ?? "",
      row.durationMs ?? "",
    ].join(",")
  )
  return { text: [COLUMNS.join(","), ...body].join("\n"), mime: "text/csv" }
}

// Real, not a stub: the reader gets the lines they are looking at. Guarded
// because a sandboxed frame is allowed to withhold object URLs entirely.
function download(name: string, mime: string, text: string) {
  if (typeof URL.createObjectURL !== "function") return
  const url = URL.createObjectURL(new Blob([text], { type: mime }))
  const anchor = document.createElement("a")
  anchor.href = url
  anchor.download = name
  anchor.click()
  // Deferred a tick: some browsers start reading the object URL after the
  // click returns, and revoking in the same tick cancels the download.
  setTimeout(() => URL.revokeObjectURL(url), 0)
}

export type LogsViewProps = { rows: LogRow[] }

/**
 * The log console. The stream is a fixed tail read on the server, so "live" is
 * a state the reader controls rather than a clock this page reads: it parks
 * the follow-the-bottom behaviour and says so, and no timer runs behind it.
 */
export function LogsView({ rows }: LogsViewProps) {
  const [filters, setFilters] = React.useState<LogFilters>(NO_FILTERS)
  const [live, setLive] = React.useState(true)

  const visible = React.useMemo(() => applyFilters(rows, filters), [rows, filters])
  const lines = React.useMemo(
    () =>
      visible.map((row) => ({
        id: row.id,
        timestamp: row.at,
        level: row.level,
        source: row.service,
        message: lineText(row),
      })),
    [visible]
  )

  const activeCount =
    filters.levels.length + filters.services.length + (filters.search.trim() === "" ? 0 : 1)
  const query = SAVED_QUERIES.find((saved) => matchesQuery(filters, saved))

  const services = React.useMemo(
    () => [...new Set(rows.map((row) => row.service))].sort(),
    [rows]
  )

  const countBy = (predicate: (row: LogRow) => boolean) => rows.filter(predicate).length

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center justify-between gap-3">
        <QuickFilters
          aria-label="Saved queries"
          size="sm"
          value={query?.value ?? ""}
          onValueChange={(next) => {
            const saved = SAVED_QUERIES.find((option) => option.value === next)
            if (saved) setFilters(saved.filters)
          }}
          options={SAVED_QUERIES.map((saved) => ({
            value: saved.value,
            label: saved.label,
            count: applyFilters(rows, saved.filters).length,
          }))}
        />

        <div className="flex items-center gap-2">
          <LivePulse
            tone={live ? "success" : "neutral"}
            pulsing={live}
            label={live ? "Live" : "Paused"}
          />
          <Button
            type="button"
            variant="outline"
            size="sm"
            onClick={() => setLive((current) => !current)}
          >
            {live ? <PauseIcon aria-hidden="true" /> : <PlayIcon aria-hidden="true" />}
            {live ? "Pause" : "Resume"}
          </Button>
          <ExportMenu
            size="sm"
            formats={EXPORT_FORMATS}
            onExport={(format) => {
              const { text, mime } = serialise(visible, format)
              download(`logs.${format}`, mime, text)
            }}
          />
        </div>
      </div>

      <FilterBar
        role="group"
        aria-label="Log filters"
        activeCount={activeCount}
        onClearAll={() => setFilters(NO_FILTERS)}
      >
        <FilterMenu
          label="Level"
          size="sm"
          searchable={false}
          options={LEVELS.map((level) => ({
            value: level,
            label: level,
            count: countBy((row) => row.level === level),
          }))}
          value={filters.levels}
          onValueChange={(levels) =>
            setFilters((current) => ({ ...current, levels: levels as LogLevel[] }))
          }
        />
        <FilterMenu
          label="Service"
          size="sm"
          options={services.map((service) => ({
            value: service,
            label: service,
            count: countBy((row) => row.service === service),
          }))}
          value={filters.services}
          onValueChange={(next) => setFilters((current) => ({ ...current, services: next }))}
        />
        <Input
          type="search"
          aria-label="Search log lines"
          placeholder="Search messages, services, request ids…"
          value={filters.search}
          onChange={(event) =>
            setFilters((current) => ({ ...current, search: event.target.value }))
          }
          className="h-8 w-full sm:w-72"
        />
      </FilterBar>

      <LogViewer
        lines={lines}
        // Half of these service names read "notification…" at the default
        // width. The body scrolls horizontally on its own, so a wider column
        // costs the message nothing.
        sourceWidth={20}
        // The page owns the filters, so the viewer's own toolbar would be a
        // second set of level toggles disagreeing with the first.
        filterable={false}
        // The tail is read once on the server, so no line ever arrives on its
        // own: `follow` here buys the scroll-to-bottom and, with it, a polite
        // live region over the whole body. That is right while the reader is
        // watching an unfiltered tail and wrong the moment they start
        // filtering, when every keystroke would re-announce hundreds of lines
        // they are reading rather than being told about. Off while filtering,
        // off while paused.
        follow={live && activeCount === 0}
        height={520}
        emptyMessage="No lines match these filters."
      />

      <p className="text-xs tabular-nums text-muted-foreground">
        {`${formatNumber(visible.length, { maximumFractionDigits: 0 })} of ${formatNumber(rows.length, { maximumFractionDigits: 0 })} lines in the tail.`}
      </p>
    </div>
  )
}
app/engineering/logs/components/saved-queries.ts
import { type LogLevel, type LogRow } from "../data"

/**
 * The two or three views this log is actually read through. Filter vocabulary,
 * not data: each one is a named set of the same level, service and text
 * filters the bar below offers, so picking one shows up in the chips rather
 * than hiding somewhere the reader cannot see or undo.
 */

export type LogFilters = {
  levels: LogLevel[]
  services: string[]
  search: string
}

export const NO_FILTERS: LogFilters = { levels: [], services: [], search: "" }

export type SavedQuery = { value: string; label: string; filters: LogFilters }

export const SAVED_QUERIES: SavedQuery[] = [
  { value: "all", label: "Everything", filters: NO_FILTERS },
  { value: "errors", label: "Errors", filters: { ...NO_FILTERS, levels: ["error"] } },
  {
    value: "loud",
    label: "Warnings and above",
    filters: { ...NO_FILTERS, levels: ["warn", "error"] },
  },
  {
    value: "gateway",
    label: "Gateway",
    filters: { ...NO_FILTERS, services: ["api-gateway"] },
  },
  { value: "timeouts", label: "Timeouts", filters: { ...NO_FILTERS, search: "timeout" } },
]

/** Keeps the lines a set of filters leaves. */
export function applyFilters(rows: LogRow[], filters: LogFilters): LogRow[] {
  const needle = filters.search.trim().toLowerCase()
  return rows.filter((row) => {
    if (filters.levels.length > 0 && !filters.levels.includes(row.level)) return false
    if (filters.services.length > 0 && !filters.services.includes(row.service)) return false
    if (needle === "") return true
    return (
      row.message.toLowerCase().includes(needle) ||
      row.service.toLowerCase().includes(needle) ||
      (row.requestId?.toLowerCase().includes(needle) ?? false)
    )
  })
}

/** True when the bar is showing exactly what a saved query asks for. */
export function matchesQuery(filters: LogFilters, query: SavedQuery): boolean {
  const same = (a: string[], b: string[]) =>
    a.length === b.length && [...a].sort().join() === [...b].sort().join()
  return (
    same(filters.levels, query.filters.levels) &&
    same(filters.services, query.filters.services) &&
    filters.search.trim() === query.filters.search
  )
}