Docs, ticks, working app
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+154
-15
@@ -1,7 +1,160 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Theme system: every visual role the app uses (surfaces, borders, text,
|
||||
* danger banners, accent highlights) is a plain CSS custom property here,
|
||||
* bridged into real Tailwind utilities (bg-surface-0, text-text-muted,
|
||||
* border-accent, ...) via `@theme inline`. Components use those utilities
|
||||
* instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...),
|
||||
* so re-declaring the `--c-*` variables under a `html.<theme>` selector
|
||||
* re-skins the whole app with no per-component changes.
|
||||
*
|
||||
* `dark` is the baseline defined on :root; every other theme only needs to
|
||||
* override what differs. `light`/`grey` swap the full surface+text scale;
|
||||
* the seven colour themes (red/green/blue/yellow/pink/orange/grey... ) keep
|
||||
* `dark`'s neutral surfaces and swap only the accent trio, since accent is
|
||||
* the only place the app uses a non-neutral colour (selection states, the
|
||||
* timeline histogram, focus/active highlights).
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--c-surface-0: #0a0a0a;
|
||||
--c-surface-1: #171717;
|
||||
--c-surface-2: #262626;
|
||||
|
||||
--c-border: #262626;
|
||||
--c-border-soft: #404040;
|
||||
--c-border-faint: #171717;
|
||||
--c-border-strong: #a3a3a3;
|
||||
|
||||
--c-text: #f5f5f5;
|
||||
--c-text-secondary: #d4d4d4;
|
||||
--c-text-muted: #a3a3a3;
|
||||
--c-text-subtle: #737373;
|
||||
--c-text-faint: #525252;
|
||||
--c-text-faintest: #404040;
|
||||
|
||||
--c-danger-bg: #450a0a;
|
||||
--c-danger-border: #7f1d1d;
|
||||
--c-danger-text: #f87171;
|
||||
--c-danger-text-strong: #fca5a5;
|
||||
|
||||
--c-accent: #0ea5e9;
|
||||
--c-accent-soft: #0369a1;
|
||||
--c-accent-text: #7dd3fc;
|
||||
}
|
||||
|
||||
html.light {
|
||||
--c-surface-0: #fafaf9;
|
||||
--c-surface-1: #f5f5f4;
|
||||
--c-surface-2: #e7e5e4;
|
||||
|
||||
--c-border: #d6d3d1;
|
||||
--c-border-soft: #a8a29e;
|
||||
--c-border-faint: #e7e5e4;
|
||||
--c-border-strong: #57534e;
|
||||
|
||||
--c-text: #1c1917;
|
||||
--c-text-secondary: #44403c;
|
||||
--c-text-muted: #78716c;
|
||||
--c-text-subtle: #a8a29e;
|
||||
--c-text-faint: #d6d3d1;
|
||||
--c-text-faintest: #d6d3d1;
|
||||
|
||||
--c-danger-bg: #fee2e2;
|
||||
--c-danger-border: #fca5a5;
|
||||
--c-danger-text: #b91c1c;
|
||||
--c-danger-text-strong: #991b1b;
|
||||
|
||||
--c-accent: #0284c7;
|
||||
--c-accent-soft: #7dd3fc;
|
||||
--c-accent-text: #0369a1;
|
||||
}
|
||||
|
||||
html.grey {
|
||||
--c-surface-0: #020617;
|
||||
--c-surface-1: #0f172a;
|
||||
--c-surface-2: #1e293b;
|
||||
|
||||
--c-border: #1e293b;
|
||||
--c-border-soft: #334155;
|
||||
--c-border-faint: #0f172a;
|
||||
--c-border-strong: #94a3b8;
|
||||
|
||||
--c-text: #f1f5f9;
|
||||
--c-text-secondary: #cbd5e1;
|
||||
--c-text-muted: #94a3b8;
|
||||
--c-text-subtle: #64748b;
|
||||
--c-text-faint: #475569;
|
||||
--c-text-faintest: #334155;
|
||||
|
||||
--c-accent: #38bdf8;
|
||||
--c-accent-soft: #0369a1;
|
||||
--c-accent-text: #7dd3fc;
|
||||
}
|
||||
|
||||
html.red {
|
||||
--c-accent: #f87171;
|
||||
--c-accent-soft: #b91c1c;
|
||||
--c-accent-text: #fca5a5;
|
||||
}
|
||||
|
||||
html.green {
|
||||
--c-accent: #4ade80;
|
||||
--c-accent-soft: #15803d;
|
||||
--c-accent-text: #86efac;
|
||||
}
|
||||
|
||||
html.blue {
|
||||
--c-accent: #60a5fa;
|
||||
--c-accent-soft: #1d4ed8;
|
||||
--c-accent-text: #93c5fd;
|
||||
}
|
||||
|
||||
html.yellow {
|
||||
--c-accent: #facc15;
|
||||
--c-accent-soft: #a16207;
|
||||
--c-accent-text: #fde047;
|
||||
}
|
||||
|
||||
html.pink {
|
||||
--c-accent: #f472b6;
|
||||
--c-accent-soft: #be185d;
|
||||
--c-accent-text: #f9a8d4;
|
||||
}
|
||||
|
||||
html.orange {
|
||||
--c-accent: #fb923c;
|
||||
--c-accent-soft: #c2410c;
|
||||
--c-accent-text: #fdba74;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-0: var(--c-surface-0);
|
||||
--color-surface-1: var(--c-surface-1);
|
||||
--color-surface-2: var(--c-surface-2);
|
||||
|
||||
--color-border: var(--c-border);
|
||||
--color-border-soft: var(--c-border-soft);
|
||||
--color-border-faint: var(--c-border-faint);
|
||||
--color-border-strong: var(--c-border-strong);
|
||||
|
||||
--color-text: var(--c-text);
|
||||
--color-text-secondary: var(--c-text-secondary);
|
||||
--color-text-muted: var(--c-text-muted);
|
||||
--color-text-subtle: var(--c-text-subtle);
|
||||
--color-text-faint: var(--c-text-faint);
|
||||
--color-text-faintest: var(--c-text-faintest);
|
||||
|
||||
--color-danger-bg: var(--c-danger-bg);
|
||||
--color-danger-border: var(--c-danger-border);
|
||||
--color-danger-text: var(--c-danger-text);
|
||||
--color-danger-text-strong: var(--c-danger-text-strong);
|
||||
|
||||
--color-accent: var(--c-accent);
|
||||
--color-accent-soft: var(--c-accent-soft);
|
||||
--color-accent-text: var(--c-accent-text);
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -12,20 +165,6 @@ body,
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-neutral-950 text-neutral-200;
|
||||
@apply bg-surface-0 text-text;
|
||||
font: 14px/1.5 ui-monospace, Consolas, monospace;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ticket 011: colour-scheme picker support. Most of the app's Tailwind
|
||||
* classes are hardcoded to a dark palette (e.g. `bg-neutral-950` above)
|
||||
* rather than using `dark:`-variant classes, so there isn't a full light
|
||||
* theme to switch to yet. This override (keyed off the `light` class
|
||||
* `useSettingsPanel` toggles on `<html>`) gives "Light" a real, if minimal,
|
||||
* visible effect on the base page colours; a proper light theme would need
|
||||
* the rest of the app's components reworked to use theme-aware classes.
|
||||
*/
|
||||
html.light body {
|
||||
background-color: #f5f5f4;
|
||||
color: #1c1917;
|
||||
}
|
||||
|
||||
+11
-1
@@ -75,7 +75,17 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type ColorScheme = 'light' | 'dark' | 'system'
|
||||
export type ColorScheme =
|
||||
| 'light'
|
||||
| 'dark'
|
||||
| 'system'
|
||||
| 'grey'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'yellow'
|
||||
| 'pink'
|
||||
| 'orange'
|
||||
|
||||
export interface Settings {
|
||||
color_scheme: ColorScheme
|
||||
|
||||
@@ -19,20 +19,22 @@ import {
|
||||
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
|
||||
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
|
||||
|
||||
/** Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`. */
|
||||
const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
||||
|
||||
/**
|
||||
* Resolves `colorScheme` to an effective `light`/`dark` value (following the
|
||||
* OS preference for `system`) and reflects it onto `<html>` as both a class
|
||||
* (`light`/`dark`) and a `data-color-scheme` attribute. The app's Tailwind
|
||||
* classes are mostly hardcoded to a dark palette (see `index.css`) rather
|
||||
* than using `dark:` variants, so "Light" mode is necessarily limited to
|
||||
* whatever `index.css` explicitly overrides for `.light` — this is a known,
|
||||
* documented limitation rather than a full theme system.
|
||||
* Resolves `colorScheme` to an effective theme (following the OS preference
|
||||
* for `system`, which only ever resolves to `light`/`dark`) and reflects it
|
||||
* onto `<html>` as both a class (see `THEME_CLASSES`) and a
|
||||
* `data-color-scheme` attribute. Every visual role the app uses is a CSS
|
||||
* custom property keyed off that class (see `index.css`), so switching it
|
||||
* re-skins the whole app, not just the base page background.
|
||||
*/
|
||||
function applyColorScheme(colorScheme: ColorScheme) {
|
||||
const root = document.documentElement
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
|
||||
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.remove(...THEME_CLASSES)
|
||||
root.classList.add(effective)
|
||||
root.dataset.colorScheme = colorScheme
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user