/engineering/logsLog 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.
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
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>
)
}Install
npx shadcn@latest add @vibra/engineering-logsNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }
}"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 } }
}"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>
)
}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
)
}