Docs, ticks, working app

This commit is contained in:
Hugh Kaznowski
2026-09-05 15:05:39 +01:00
parent 41362aa3d8
commit 51e577f01e
41 changed files with 1039 additions and 683 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
export function ComingSoon({ title }: { title: string }) {
return (
<div className="border-b border-neutral-800 p-3 text-xs text-neutral-600">
<div className="border-b border-border p-3 text-xs text-text-faint">
{title} — coming soon
</div>
)
+23 -23
View File
@@ -61,10 +61,10 @@ function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null
}, [panel.promql, panel.chart_type, range?.startMs, range?.endMs])
if (error) {
return <div className="rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{error}</div>
return <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
}
if (loading && series.length === 0) {
return <div className="flex h-48 items-center justify-center text-xs text-neutral-600">Loading…</div>
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">Loading…</div>
}
return <PanelChart series={series} chartType={panel.chart_type} />
}
@@ -168,43 +168,43 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
if (!activeProfile) {
return (
<div className="border-b border-neutral-800 p-3 text-xs text-neutral-600">
<div className="border-b border-border p-3 text-xs text-text-faint">
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
</div>
)
}
return (
<div className="border-b border-neutral-800 p-3 text-sm">
<div className="mb-2 text-xs text-neutral-500">Dashboards — profile "{activeProfile}"</div>
<div className="border-b border-border p-3 text-sm">
<div className="mb-2 text-xs text-text-subtle">Dashboards — profile "{activeProfile}"</div>
{error && (
<div className="mb-2 rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{error}</div>
<div className="mb-2 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
)}
<div className="flex gap-4">
<div className="w-56 shrink-0 space-y-3">
<form onSubmit={handleCreateDashboard} className="flex gap-1">
<input
className="w-full min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="w-full min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
placeholder="New dashboard name"
/>
<button
type="submit"
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary hover:bg-surface-2"
>
+ Add
</button>
</form>
<div className="space-y-1">
{dashboards.length === 0 && <div className="text-xs text-neutral-600">No dashboards saved yet.</div>}
{dashboards.length === 0 && <div className="text-xs text-text-faint">No dashboards saved yet.</div>}
{dashboards.map((d) => (
<div
key={d.id}
className={`flex items-center justify-between rounded border px-2 py-1 text-xs ${
selectedId === d.id
? 'border-sky-500 bg-sky-500/10 text-sky-300'
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800'
? 'border-accent bg-accent/10 text-accent-text'
: 'border-border-soft text-text-secondary hover:bg-surface-2'
}`}
>
<button type="button" className="min-w-0 flex-1 truncate text-left" onClick={() => setSelectedId(d.id)}>
@@ -213,7 +213,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
<button
type="button"
onClick={() => handleDeleteDashboard(d.id)}
className="ml-1 shrink-0 text-neutral-500 hover:text-red-400"
className="ml-1 shrink-0 text-text-subtle hover:text-danger-text"
title="Delete dashboard"
>
×
@@ -225,12 +225,12 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
<div className="min-w-0 flex-1">
{!draft ? (
<div className="text-xs text-neutral-600">Select or create a dashboard to edit its panels.</div>
<div className="text-xs text-text-faint">Select or create a dashboard to edit its panels.</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<input
className="w-64 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 focus:border-neutral-500 focus:outline-none"
className="w-64 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text focus:border-border-strong focus:outline-none"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
@@ -238,7 +238,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
<button
type="button"
onClick={addPanel}
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary hover:bg-surface-2"
>
+ Add panel
</button>
@@ -246,7 +246,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
type="button"
onClick={handleSaveDraft}
disabled={saving}
className="rounded border border-sky-700 bg-sky-500/10 px-2 py-1 text-xs text-sky-300 hover:bg-sky-500/20 disabled:opacity-50"
className="rounded border border-accent-soft bg-accent/10 px-2 py-1 text-xs text-accent-text hover:bg-accent/20 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save dashboard'}
</button>
@@ -254,27 +254,27 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
</div>
{draft.panels.length === 0 && (
<div className="text-xs text-neutral-600">No panels yet — add one above.</div>
<div className="text-xs text-text-faint">No panels yet — add one above.</div>
)}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{draft.panels.map((panel) => (
<div key={panel.id} className="rounded border border-neutral-800 p-2">
<div key={panel.id} className="rounded border border-border p-2">
<div className="mb-2 flex items-center gap-1">
<input
className="w-28 min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="w-28 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={panel.title}
onChange={(e) => updatePanel(panel.id, { title: e.target.value })}
placeholder="Panel title"
/>
<input
className="w-40 min-w-0 flex-[2] rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 font-mono text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="w-40 min-w-0 flex-[2] rounded border border-border-soft bg-surface-1 px-1.5 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={panel.promql}
onChange={(e) => updatePanel(panel.id, { promql: e.target.value })}
placeholder={String.raw`requests{path="/a"}`}
/>
<select
className="rounded border border-neutral-700 bg-neutral-900 px-1 py-1 text-xs text-neutral-100 focus:border-neutral-500 focus:outline-none"
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={panel.chart_type}
onChange={(e) => updatePanel(panel.id, { chart_type: e.target.value as ChartType })}
>
@@ -287,12 +287,12 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
<button
type="button"
onClick={() => removePanel(panel.id)}
className="rounded border border-neutral-700 px-1.5 py-1 text-xs text-neutral-400 hover:bg-neutral-800"
className="rounded border border-border-soft px-1.5 py-1 text-xs text-text-muted hover:bg-surface-2"
>
×
</button>
</div>
{panel.title && <div className="mb-1 text-xs text-neutral-500">{panel.title}</div>}
{panel.title && <div className="mb-1 text-xs text-text-subtle">{panel.title}</div>}
<PanelPreview panel={panel} range={range} />
</div>
))}
+204
View File
@@ -0,0 +1,204 @@
import { useState, type ReactNode } from 'react'
/// One accordion section: a title plus its body, rendered as plain JSX so
/// each page can mix prose, lists, and inline code without pulling in a
/// markdown renderer for what is currently five static pages.
interface DocSection {
id: string
title: string
body: ReactNode
}
function Code({ children }: { children: string }) {
return <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[11px] text-text">{children}</code>
}
const SECTIONS: DocSection[] = [
{
id: 'general',
title: 'General idea',
body: (
<div className="flex flex-col gap-2">
<p>
ottyl turns a CLI program's stdout into a Grafana-like dashboard: pipe a program's output into it, and it
indexes each line in memory as it arrives, then serves a webapp dashboard over the same process.
</p>
<p>
<Code>{'cli_command --verbose | ottyl'}</Code>
</p>
<p>
Everything — ingested log lines, declared params, declared metrics, and derived buckets — lives in one
in-memory index for the life of the process. Nothing is written to disk unless you explicitly save a{' '}
<em>profile</em> or change a <em>setting</em>.
</p>
<p className="font-semibold text-text">CLI arguments</p>
<ul className="list-disc pl-5">
<li>
<Code>--port</Code> — port to serve the API and webapp on (default <Code>8420</Code>).
</li>
<li>
<Code>--static-dir</Code> — directory containing the built webapp (default <Code>webapp/dist</Code>).
</li>
<li>
<Code>--settings-path</Code> — path to the settings file; defaults to a per-OS config location (e.g.{' '}
<Code>~/.config/ottyl/settings.json</Code> on Linux).
</li>
</ul>
<p className="font-semibold text-text">Settings</p>
<p>
There is exactly one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS,
and which profile is active. Settings "owns" profiles in that sense — it only tracks pointers (known
profile names, which one is active); the profiles themselves (params, metrics, dashboards) are stored
separately, next to the settings file by default.
</p>
<p className="font-semibold text-text">Profiles</p>
<p>
A profile is a named bundle of parsing/indexing rules for a particular CLI app — which params and metrics
to derive from its log output, plus any saved dashboards — so restarting ottyl against the same program
doesn't lose your setup. There is always a <Code>Default</Code> profile, and it can't be deleted. Creating
a new profile copies the currently active one (or starts empty if none is active), so variations build on
what you already have rather than starting from scratch. Switching profiles hot-swaps the running engine's
declared params/metrics immediately, with no restart needed.
</p>
</div>
),
},
{
id: 'filtering',
title: 'Filtering',
body: (
<div className="flex flex-col gap-2">
<p>
The filter bar above the timeline takes a syntax-aware query combining regex search over raw log text with
comparisons against declared metrics, joined by <Code>and</Code>/<Code>or</Code>:
</p>
<p>
<Code>{'regex("error") and sequence<5 or timestamp>12:00'}</Code>
</p>
<p>
<Code>{'metric_name[param]<5'}</Code> compares a metric's captured param value against a number, string, or
timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names — every ingested log
line has both, so they're always available to filter on without declaring anything.
</p>
<p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons accept several forms:</p>
<ul className="list-disc pl-5">
<li>
relative, humantime-style offsets from now — <Code>5h</Code>, <Code>-3m</Code>
</li>
<li>absolute clock times — e.g. 12:00</li>
<li>full dates</li>
</ul>
<p>
The same filter string is sent to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '}
<Code>filter</Code> query param and evaluated on the server; a parse error comes back from the server and
surfaces as inline text under the filter bar rather than crashing the page.
</p>
</div>
),
},
{
id: 'parameters',
title: 'Parameters',
body: (
<div className="flex flex-col gap-2">
<p>
A parameter declaration tells ottyl how to derive a named value from a raw log line: a regex that must
match the message, plus a capture group within it, plus a type (<Code>string</Code>, <Code>number</Code>,
or <Code>timestamp</Code>). Once declared, every already-ingested line gets retroactively indexed for that
param, and every new line is checked against it as it arrives.
</p>
<p>
Params are the building block metrics are made of (a metric declaration is really a message-match regex
plus a set of param matchers), and they're also what drives filtering and the log preview's column picker —
declaring a <Code>duration</Code> param, for instance, makes <Code>duration</Code> available as a column in
the log table and as a comparable value in the filter bar.
</p>
<p>
Declaring a param independent of a metric is supported by the API (<Code>POST /api/params</Code>), used
today via the Metrics panel's declaration form and read back by the log preview's column picker; there
isn't yet a standalone "declare a bare param" form in the UI.
</p>
</div>
),
},
{
id: 'metrics',
title: 'Metrics',
body: (
<div className="flex flex-col gap-2">
<p>
A metric declaration bundles a message-match regex with one or more named param matchers (each with its own
capture group and type). Every log line matching the regex produces a <em>bucket value</em>: a timestamped
occurrence, tagged with the labels captured from that line.
</p>
<p>
Declaring a metric backfills it against everything already ingested, then continues indexing new lines as
they arrive — there's no need to restart ingestion or re-declare after the fact.
</p>
<p>
The Metrics panel lets you declare a metric, pick one from the declared list, and see its bucket values
grouped by label combination over the active time range — occurrence count per group, plus a small trend
sparkline. That same data is what dashboard panels query via PromQL (see below).
</p>
</div>
),
},
{
id: 'dashboards',
title: 'Dashboards',
body: (
<div className="flex flex-col gap-2">
<p>
Dashboards work like Grafana: each one is a named set of panels, and each panel is a PromQL query paired
with a chart type — bar, line, or pie. Panels preview live as you edit the query, and a malformed query
shows an inline error instead of breaking the page.
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Line</span> — one series per label combination, time on the
x-axis.
</li>
<li>
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "where
things are now").
</li>
<li>
<span className="font-semibold text-text">Pie</span> — each series' share of the summed total across the
active range.
</li>
</ul>
<p>
Dashboards are saved per profile, so they travel with a profile when you switch or copy it, and require an
active profile to create or save — pick or create one from the settings menu first.
</p>
</div>
),
},
]
export function DocsPanel() {
const [openId, setOpenId] = useState<string | null>(SECTIONS[0].id)
return (
<div className="flex flex-col gap-1 text-sm text-text-secondary">
{SECTIONS.map((section) => {
const isOpen = openId === section.id
return (
<div key={section.id} className="rounded border border-border">
<button
type="button"
onClick={() => setOpenId(isOpen ? null : section.id)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-semibold text-text hover:bg-surface-2"
aria-expanded={isOpen}
>
{section.title}
<span className="text-text-muted">{isOpen ? '−' : '+'}</span>
</button>
{isOpen && <div className="border-t border-border px-3 py-3">{section.body}</div>}
</div>
)
})}
</div>
)
}
+4 -4
View File
@@ -20,7 +20,7 @@ export function FilterBar({
return (
<form
className="flex flex-col gap-1 border-b border-neutral-800 px-3 py-2"
className="flex flex-col gap-1 border-b border-border px-3 py-2"
onSubmit={(e) => {
e.preventDefault()
onChange(draft)
@@ -28,19 +28,19 @@ export function FilterBar({
>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
placeholder='regex("error") and sequence<5 or timestamp>12:00'
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<button
type="submit"
className="rounded border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800"
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2"
>
Filter
</button>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{error && <p className="text-xs text-danger-text">{error}</p>}
</form>
)
}
+13 -13
View File
@@ -57,24 +57,24 @@ function ColumnPicker({
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary hover:bg-surface-2"
>
Columns{visible.size > 0 ? ` (${visible.size})` : ''}
</button>
{open && (
<div className="absolute right-0 z-10 mt-1 w-56 rounded border border-neutral-700 bg-neutral-950 p-2 shadow-lg">
<div className="absolute right-0 z-10 mt-1 w-56 rounded border border-border-soft bg-surface-0 p-2 shadow-lg">
{paramNames.length === 0 ? (
<div className="px-1 py-1 text-xs text-neutral-500">No declared params yet.</div>
<div className="px-1 py-1 text-xs text-text-subtle">No declared params yet.</div>
) : (
<ul className="flex max-h-64 flex-col gap-1 overflow-auto">
{paramNames.map((name) => (
<li key={name}>
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs text-neutral-200 hover:bg-neutral-900">
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs text-text hover:bg-surface-1">
<input
type="checkbox"
checked={visible.has(name)}
onChange={() => onToggle(name)}
className="accent-neutral-400"
className="accent-text-muted"
/>
{name}
</label>
@@ -126,12 +126,12 @@ export function LogPreview({ logs }: { logs: LogEntry[] }) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex justify-end border-b border-neutral-900 px-3 py-1">
<div className="flex justify-end border-b border-border-faint px-3 py-1">
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
</div>
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse text-left text-xs">
<thead className="sticky top-0 bg-neutral-950 text-neutral-500">
<thead className="sticky top-0 bg-surface-0 text-text-subtle">
<tr>
<th className="w-16 px-3 py-1 font-normal">Seq</th>
<th className="w-40 px-3 py-1 font-normal">Timestamp</th>
@@ -145,25 +145,25 @@ export function LogPreview({ logs }: { logs: LogEntry[] }) {
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.sequence} className="border-t border-neutral-900 hover:bg-neutral-900/60">
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">{log.sequence}</td>
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">
<tr key={log.sequence} className="border-t border-border-faint hover:bg-surface-1/60">
<td className="whitespace-nowrap px-3 py-1 text-text-subtle">{log.sequence}</td>
<td className="whitespace-nowrap px-3 py-1 text-text-subtle">
{new Date(log.timestamp_ms).toLocaleTimeString()}
</td>
{columns.map((name) => {
const value = log.params[name]
return (
<td key={name} className="whitespace-nowrap px-3 py-1 text-neutral-400">
<td key={name} className="whitespace-nowrap px-3 py-1 text-text-muted">
{value ? metricPrimitiveToDisplayString(value) : '—'}
</td>
)
})}
<td className="px-3 py-1 text-neutral-200">{log.text}</td>
<td className="px-3 py-1 text-text">{log.text}</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={3 + columns.length} className="px-3 py-6 text-center text-neutral-600">
<td colSpan={3 + columns.length} className="px-3 py-6 text-center text-text-faint">
No logs yet — pipe something into ottyl.
</td>
</tr>
+7 -7
View File
@@ -28,7 +28,7 @@ export function LogTimeline({
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return domainStart
const ratio = Math.min(1, Math.max(0, (x - rect.left) / rect.width))
return domainStart + ratio * domainSpan
return Math.round(domainStart + ratio * domainSpan)
}
function handleMouseDown(e: React.MouseEvent) {
@@ -57,18 +57,18 @@ export function LogTimeline({
: null
return (
<div className="border-b border-neutral-800 p-3">
<div className="mb-1 flex items-center justify-between text-xs text-neutral-500">
<div className="border-b border-border p-3">
<div className="mb-1 flex items-center justify-between text-xs text-text-subtle">
<span>Timeline</span>
{range && (
<button className="underline hover:text-neutral-300" onClick={() => onSelectRange(null)}>
<button className="underline hover:text-text-secondary" onClick={() => onSelectRange(null)}>
Clear selection
</button>
)}
</div>
<div
ref={containerRef}
className="relative h-24 w-full cursor-crosshair select-none rounded bg-neutral-900"
className="relative h-24 w-full cursor-crosshair select-none rounded bg-surface-1"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@@ -81,7 +81,7 @@ export function LogTimeline({
{buckets.map((b) => (
<div
key={b.start_ms}
className="min-w-[2px] flex-1 rounded-t bg-sky-500/70"
className="min-w-[2px] flex-1 rounded-t bg-accent/70"
style={{ height: `${(b.count / maxCount) * 100}%` }}
title={`${new Date(b.start_ms).toLocaleTimeString()} — ${b.count}`}
/>
@@ -89,7 +89,7 @@ export function LogTimeline({
</div>
{selRect && (
<div
className="pointer-events-none absolute top-0 h-full border border-sky-400 bg-sky-400/20"
className="pointer-events-none absolute top-0 h-full border border-accent bg-accent/20"
style={{ left: selRect.left - (containerRef.current?.getBoundingClientRect().left ?? 0), width: selRect.width }}
/>
)}
+24 -24
View File
@@ -155,14 +155,14 @@ export function Metrics({ range }: { range: TimeRange | null }) {
const groups = groupByLabels(bucketValues)
return (
<div className="border-b border-neutral-800 p-3 text-sm">
<div className="mb-2 text-xs text-neutral-500">Metrics</div>
<div className="border-b border-border p-3 text-sm">
<div className="mb-2 text-xs text-text-subtle">Metrics</div>
<div className="flex gap-4">
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
<div>
<label className="mb-1 block text-xs text-neutral-500">Name</label>
<label className="mb-1 block text-xs text-text-subtle">Name</label>
<input
className="w-full rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="requests"
@@ -170,9 +170,9 @@ export function Metrics({ range }: { range: TimeRange | null }) {
/>
</div>
<div>
<label className="mb-1 block text-xs text-neutral-500">Message match regex</label>
<label className="mb-1 block text-xs text-text-subtle">Message match regex</label>
<input
className="w-full rounded border border-neutral-700 bg-neutral-900 px-2 py-1 font-mono text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={regex}
onChange={(e) => setRegex(e.target.value)}
placeholder={String.raw`request path=(?P<path>\S+) duration=(?P<duration>\d+)`}
@@ -180,12 +180,12 @@ export function Metrics({ range }: { range: TimeRange | null }) {
/>
</div>
<div>
<div className="mb-1 flex items-center justify-between text-xs text-neutral-500">
<div className="mb-1 flex items-center justify-between text-xs text-text-subtle">
<span>Params</span>
<button
type="button"
onClick={addMatcher}
className="rounded border border-neutral-700 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800"
className="rounded border border-border-soft px-2 py-0.5 text-text-secondary hover:bg-surface-2"
>
+ Add
</button>
@@ -194,19 +194,19 @@ export function Metrics({ range }: { range: TimeRange | null }) {
{matchers.map((matcher, i) => (
<div key={i} className="flex items-center gap-1">
<input
className="w-24 min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={matcher.param_name}
onChange={(e) => updateMatcher(i, { param_name: e.target.value })}
placeholder="param_name"
/>
<input
className="w-24 min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={matcher.capture_group}
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
placeholder="capture_group"
/>
<select
className="rounded border border-neutral-700 bg-neutral-900 px-1 py-1 text-xs text-neutral-100 focus:border-neutral-500 focus:outline-none"
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={matcher.kind}
onChange={(e) => updateMatcher(i, { kind: e.target.value as MetricParamMatcher['kind'] })}
>
@@ -218,7 +218,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
type="button"
onClick={() => removeMatcher(i)}
disabled={matchers.length === 1}
className="rounded border border-neutral-700 px-1.5 py-1 text-xs text-neutral-400 hover:bg-neutral-800 disabled:opacity-30"
className="rounded border border-border-soft px-1.5 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-30"
>
×
</button>
@@ -226,28 +226,28 @@ export function Metrics({ range }: { range: TimeRange | null }) {
))}
</div>
</div>
{submitError && <div className="rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{submitError}</div>}
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
<button
type="submit"
disabled={submitting}
className="rounded border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800 disabled:opacity-50"
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
{submitting ? 'Declaring…' : 'Declare metric'}
</button>
</form>
<div className="min-w-0 flex-1">
<div className="mb-1 text-xs text-neutral-500">Declared metrics</div>
<div className="mb-1 text-xs text-text-subtle">Declared metrics</div>
<div className="mb-3 flex flex-wrap gap-1">
{metrics.length === 0 && <span className="text-xs text-neutral-600">No metrics declared yet.</span>}
{metrics.length === 0 && <span className="text-xs text-text-faint">No metrics declared yet.</span>}
{metrics.map((m) => (
<button
key={m.name}
onClick={() => setSelected(m.name)}
className={`rounded border px-2 py-1 text-xs ${
selected === m.name
? 'border-sky-500 bg-sky-500/10 text-sky-300'
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800'
? 'border-accent bg-accent/10 text-accent-text'
: 'border-border-soft text-text-secondary hover:bg-surface-2'
}`}
title={m.message_match_regex}
>
@@ -258,15 +258,15 @@ export function Metrics({ range }: { range: TimeRange | null }) {
{selected && (
<div>
<div className="mb-1 text-xs text-neutral-500">
<div className="mb-1 text-xs text-text-subtle">
{selected} — {bucketValues.length} occurrence{bucketValues.length === 1 ? '' : 's'} in range
</div>
{groups.length === 0 ? (
<div className="text-xs text-neutral-600">No matching values in the active range.</div>
<div className="text-xs text-text-faint">No matching values in the active range.</div>
) : (
<div className="max-h-56 overflow-auto rounded border border-neutral-800">
<div className="max-h-56 overflow-auto rounded border border-border">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 bg-neutral-900 text-neutral-500">
<thead className="sticky top-0 bg-surface-1 text-text-subtle">
<tr>
<th className="px-2 py-1 text-left font-normal">Labels</th>
<th className="px-2 py-1 text-right font-normal">Count</th>
@@ -275,14 +275,14 @@ export function Metrics({ range }: { range: TimeRange | null }) {
</thead>
<tbody>
{groups.map((group) => (
<tr key={group.key} className="border-t border-neutral-800 text-neutral-300">
<tr key={group.key} className="border-t border-border text-text-secondary">
<td className="px-2 py-1 font-mono">
{Object.entries(group.labels)
.map(([k, v]) => `${k}=${v}`)
.join(', ') || '(no labels)'}
</td>
<td className="px-2 py-1 text-right">{group.count}</td>
<td className="px-2 py-1 font-mono text-sky-400">{sparkline(group.values, range)}</td>
<td className="px-2 py-1 font-mono text-accent-text">{sparkline(group.values, range)}</td>
</tr>
))}
</tbody>
+97 -57
View File
@@ -2,10 +2,31 @@ import { useState } from 'react'
import type { Stats } from '../lib/api'
import type { ColorScheme } from '../lib/api'
import { useSettingsPanel } from '../lib/useSettingsPanel'
import { DocsPanel } from './Docs'
type PanelName = 'settings' | 'presets' | null
type PanelName = 'settings' | 'presets' | 'docs' | null
const COLOR_SCHEMES: ColorScheme[] = ['light', 'dark', 'system']
const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
settings: 'Settings',
presets: 'Presets',
docs: 'Docs',
}
/** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */
const DEFAULT_PROFILE_NAME = 'Default'
const COLOR_SCHEMES: ColorScheme[] = [
'system',
'light',
'dark',
'grey',
'red',
'green',
'blue',
'yellow',
'pink',
'orange',
]
export function NavMenu({ stats }: { stats: Stats | null }) {
const [openPanel, setOpenPanel] = useState<PanelName>(null)
@@ -17,16 +38,16 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
return (
<div className="flex h-full">
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-neutral-800 p-3">
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3">
<div>
<div className="mb-4 text-lg font-semibold text-neutral-100">ottyl</div>
<nav className="flex flex-col gap-1 text-sm text-neutral-400">
<span className="rounded bg-neutral-800 px-2 py-1 text-neutral-100">Dashboard</span>
<div className="mb-4 text-lg font-semibold text-text">ottyl</div>
<nav className="flex flex-col gap-1 text-sm text-text-muted">
<span className="rounded bg-surface-2 px-2 py-1 text-text">Dashboard</span>
<button
type="button"
onClick={() => togglePanel('presets')}
className={`rounded px-2 py-1 text-left hover:bg-neutral-800 ${
openPanel === 'presets' ? 'bg-neutral-800 text-neutral-100' : ''
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'presets' ? 'bg-surface-2 text-text' : ''
}`}
>
Presets
@@ -34,15 +55,24 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
<button
type="button"
onClick={() => togglePanel('settings')}
className={`rounded px-2 py-1 text-left hover:bg-neutral-800 ${
openPanel === 'settings' ? 'bg-neutral-800 text-neutral-100' : ''
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'settings' ? 'bg-surface-2 text-text' : ''
}`}
>
Settings
</button>
<button
type="button"
onClick={() => togglePanel('docs')}
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'docs' ? 'bg-surface-2 text-text' : ''
}`}
>
Docs
</button>
</nav>
</div>
<div className="text-xs text-neutral-500">
<div className="text-xs text-text-subtle">
{stats ? (
<>
<div>{stats.log_count} logs</div>
@@ -57,26 +87,30 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
</div>
{openPanel && (
<div className="fixed inset-y-0 left-48 z-20 flex w-80 flex-col overflow-y-auto border-r border-neutral-800 bg-neutral-900 p-4 shadow-xl">
<div
className={`fixed inset-y-0 left-48 z-20 flex flex-col overflow-y-auto border-r border-border bg-surface-1 p-4 shadow-xl ${
openPanel === 'docs' ? 'w-[32rem]' : 'w-80'
}`}
>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-neutral-100">
{openPanel === 'settings' ? 'Settings' : 'Presets'}
</h2>
<h2 className="text-sm font-semibold text-text">{PANEL_TITLES[openPanel]}</h2>
<button
type="button"
onClick={() => setOpenPanel(null)}
className="rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
aria-label="Close panel"
>
✕
</button>
</div>
{panel.error && (
<div className="mb-3 rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-300">
{panel.error && openPanel !== 'docs' && (
<div className="mb-3 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text-strong">
{panel.error}
</div>
)}
{openPanel === 'settings' ? <SettingsPanel panel={panel} /> : <PresetsPanel panel={panel} />}
{openPanel === 'settings' && <SettingsPanel panel={panel} />}
{openPanel === 'presets' && <PresetsPanel panel={panel} />}
{openPanel === 'docs' && <DocsPanel />}
</div>
)}
</div>
@@ -103,56 +137,53 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
}
return (
<div className="flex flex-col gap-5 text-sm text-neutral-300">
<div className="flex flex-col gap-5 text-sm text-text-secondary">
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Colour scheme</h3>
<div className="flex gap-1">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
<div className="flex flex-wrap gap-1">
{COLOR_SCHEMES.map((scheme) => (
<button
key={scheme}
type="button"
onClick={() => panel.setColorScheme(scheme)}
className={`flex-1 rounded border px-2 py-1 text-xs capitalize ${
className={`rounded border px-2 py-1 text-xs capitalize ${
panel.settings?.color_scheme === scheme
? 'border-neutral-400 bg-neutral-800 text-neutral-100'
: 'border-neutral-700 text-neutral-400 hover:bg-neutral-800'
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
{scheme}
</button>
))}
</div>
<p className="mt-1 text-[11px] text-neutral-600">
"Light" only recolours the base page for now — most components still use fixed dark colours.
</p>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Import CSS</h3>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Import CSS</h3>
<form onSubmit={handleImport} className="flex flex-col gap-2">
<input
type="file"
accept=".css,text/css"
onChange={handleFileChange}
className="text-xs text-neutral-400 file:mr-2 file:rounded file:border file:border-neutral-700 file:bg-neutral-800 file:px-2 file:py-1 file:text-neutral-200"
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
/>
<input
type="text"
placeholder="filename.css"
value={filename}
onChange={(e) => setFilename(e.target.value)}
className="rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<textarea
placeholder="Paste CSS content here"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="resize-y rounded border border-neutral-700 bg-neutral-900 px-2 py-1 font-mono text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<button
type="submit"
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-200 hover:bg-neutral-800"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Import
</button>
@@ -160,31 +191,31 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Configured CSS</h3>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Configured CSS</h3>
{panel.cssEntries.length === 0 ? (
<div className="text-xs text-neutral-600">No CSS configured.</div>
<div className="text-xs text-text-faint">No CSS configured.</div>
) : (
<ul className="flex flex-col gap-1">
{panel.cssEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-neutral-800 px-2 py-1 text-xs"
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<div className="min-w-0">
<div className="truncate text-neutral-200">{entry.filename ?? entry.path_or_url}</div>
<div className="text-[10px] uppercase text-neutral-600">{entry.kind}</div>
<div className="truncate text-text">{entry.filename ?? entry.path_or_url}</div>
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
</div>
{entry.kind === 'imported' ? (
<button
type="button"
onClick={() => panel.removeCssEntry(entry.id)}
className="ml-2 shrink-0 rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-red-400"
className="ml-2 shrink-0 rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${entry.filename}`}
>
delete
</button>
) : (
<span className="ml-2 shrink-0 text-neutral-700" title="Linked CSS isn't deletable here">
<span className="ml-2 shrink-0 text-text-faintest" title="Linked CSS isn't deletable here">
—
</span>
)}
@@ -208,25 +239,25 @@ function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> })
}
return (
<div className="flex flex-col gap-4 text-sm text-neutral-300">
<div className="flex flex-col gap-4 text-sm text-text-secondary">
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
placeholder="new profile name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
className="flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<button
type="submit"
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-200 hover:bg-neutral-800"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Create
</button>
</form>
{panel.profiles.length === 0 ? (
<div className="text-xs text-neutral-600">No profiles yet.</div>
<div className="text-xs text-text-faint">No profiles yet.</div>
) : (
<ul className="flex flex-col gap-1">
{panel.profiles.map((profile) => {
@@ -235,7 +266,7 @@ function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> })
<li
key={profile.name}
className={`rounded border px-2 py-1.5 text-xs ${
isActive ? 'border-neutral-500 bg-neutral-800' : 'border-neutral-800'
isActive ? 'border-border-strong bg-surface-2' : 'border-border'
}`}
>
<div className="flex items-center justify-between">
@@ -244,23 +275,29 @@ function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> })
onClick={() => !isActive && panel.switchProfile(profile.name)}
disabled={isActive}
className={`truncate text-left font-medium ${
isActive ? 'cursor-default text-neutral-100' : 'text-neutral-200 hover:underline'
isActive ? 'cursor-default text-text' : 'text-text hover:underline'
}`}
title={isActive ? 'Active profile' : `Switch to ${profile.name}`}
>
{profile.name}
{isActive && <span className="ml-1 text-[10px] uppercase text-neutral-400">active</span>}
</button>
<button
type="button"
onClick={() => panel.removeProfile(profile.name)}
className="ml-2 shrink-0 rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-red-400"
aria-label={`Delete ${profile.name}`}
>
delete
{isActive && <span className="ml-1 text-[10px] uppercase text-text-muted">active</span>}
</button>
{profile.name === DEFAULT_PROFILE_NAME ? (
<span className="ml-2 shrink-0 text-text-faintest" title="The Default profile can't be deleted">
—
</span>
) : (
<button
type="button"
onClick={() => panel.removeProfile(profile.name)}
className="ml-2 shrink-0 rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${profile.name}`}
>
delete
</button>
)}
</div>
<div className="mt-1 text-[10px] text-neutral-500">
<div className="mt-1 text-[10px] text-text-subtle">
{profile.param_count} params · {profile.metric_count} metrics · {profile.dashboard_count} dashboards
</div>
</li>
@@ -268,7 +305,10 @@ function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> })
})}
</ul>
)}
<p className="text-[11px] text-neutral-600">
<p className="text-[11px] text-text-faint">
A new profile starts as a copy of the currently active one (or empty, if none is active).
</p>
<p className="text-[11px] text-text-faint">
Switching profiles hot-swaps the running engine's declared params/metrics immediately — no restart needed.
</p>
</div>
+12 -12
View File
@@ -77,7 +77,7 @@ function sumValue(series: PromqlSeries): number {
export function PanelChart({ series, chartType }: { series: PromqlSeries[]; chartType: ChartType }) {
if (series.length === 0) {
return <div className="flex h-48 items-center justify-center text-xs text-neutral-600">No data in range.</div>
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No data in range.</div>
}
if (chartType === 'line') {
@@ -85,17 +85,17 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#262626" />
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border)" />
<XAxis
dataKey="timestamp_ms"
tickFormatter={formatTimestamp}
stroke="#737373"
tick={{ fontSize: 11, fill: '#a3a3a3' }}
stroke="var(--c-text-subtle)"
tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }}
/>
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} width={48} />
<YAxis stroke="var(--c-text-subtle)" tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }} width={48} />
<Tooltip
labelFormatter={(ts) => formatTimestamp(Number(ts))}
contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }}
contentStyle={{ background: 'var(--c-surface-1)', border: '1px solid var(--c-border-soft)', fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
{keys.map((key, i) => (
@@ -119,10 +119,10 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
return (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#262626" />
<XAxis dataKey="name" stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} />
<YAxis stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} width={48} />
<Tooltip contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }} />
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border)" />
<XAxis dataKey="name" stroke="var(--c-text-subtle)" tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }} />
<YAxis stroke="var(--c-text-subtle)" tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }} width={48} />
<Tooltip contentStyle={{ background: 'var(--c-surface-1)', border: '1px solid var(--c-border-soft)', fontSize: 12 }} />
<Bar dataKey="value" isAnimationActive={false}>
{rows.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
@@ -137,7 +137,7 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
const total = rows.reduce((sum, r) => sum + r.value, 0)
if (total === 0) {
return <div className="flex h-48 items-center justify-center text-xs text-neutral-600">No non-zero values in range.</div>
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No non-zero values in range.</div>
}
return (
<ResponsiveContainer width="100%" height={220}>
@@ -152,7 +152,7 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
const num = typeof value === 'number' ? value : Number(value ?? 0)
return `${num} (${((num / total) * 100).toFixed(1)}%)`
}}
contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }}
contentStyle={{ background: 'var(--c-surface-1)', border: '1px solid var(--c-border-soft)', fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>