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
+3 -1
View File
@@ -462,11 +462,13 @@ async fn update_settings(
} }
/// Maps a `ProfileError` to a response status/message: `NotFound` -> 404, /// Maps a `ProfileError` to a response status/message: `NotFound` -> 404,
/// `DuplicateName` -> 400, `Io` -> 500. Mirrors `css_error_response` above. /// `DuplicateName`/`CannotDeleteDefault` -> 400, `Io` -> 500. Mirrors
/// `css_error_response` above.
fn profile_error_response(err: ProfileError) -> axum::response::Response { fn profile_error_response(err: ProfileError) -> axum::response::Response {
match err { match err {
ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
ProfileError::DuplicateName => (StatusCode::BAD_REQUEST, err.to_string()).into_response(), ProfileError::DuplicateName => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
ProfileError::CannotDeleteDefault => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
ProfileError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), ProfileError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
} }
} }
+7 -5
View File
@@ -61,11 +61,13 @@ async fn main() {
// could silently miss lines that raced ahead of the declarations). // could silently miss lines that raced ahead of the declarations).
{ {
let (active_profile_name, profiles_dir) = { let (active_profile_name, profiles_dir) = {
let settings_guard = settings.lock().unwrap(); let mut settings_guard = settings.lock().unwrap();
( let profiles_dir = profile::resolve_profiles_dir(&settings_guard, &settings_path);
settings_guard.profiles.active_profile.clone(), let mut store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
profile::resolve_profiles_dir(&settings_guard, &settings_path), store
) .ensure_default(&profile::profiles_store_path(&profiles_dir), &mut settings_guard, &settings_path)
.expect("failed to ensure the Default profile exists");
(settings_guard.profiles.active_profile.clone(), profiles_dir)
}; };
if let Some(profile_name) = active_profile_name { if let Some(profile_name) = active_profile_name {
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir)); let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
+122 -3
View File
@@ -45,6 +45,10 @@ use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, Eng
use crate::settings::Settings; use crate::settings::Settings;
use tokio::sync::oneshot; use tokio::sync::oneshot;
/// Name of the profile that always exists and can't be deleted (see
/// `ProfileStore::ensure_default`).
pub const DEFAULT_PROFILE_NAME: &str = "Default";
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so /// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so
/// profiles can round-trip through JSON. `MetricPrimitiveKind` itself only /// profiles can round-trip through JSON. `MetricPrimitiveKind` itself only
/// derives `Serialize` (it's an engine-internal type owned by tickets /// derives `Serialize` (it's an engine-internal type owned by tickets
@@ -311,6 +315,9 @@ pub enum ProfileError {
NotFound, NotFound,
/// A profile with the given name already exists. /// A profile with the given name already exists.
DuplicateName, DuplicateName,
/// The `Default` profile can't be deleted — there must always be at
/// least one profile to fall back to.
CannotDeleteDefault,
/// Underlying I/O failure reading/writing the profile store or settings file. /// Underlying I/O failure reading/writing the profile store or settings file.
Io(io::Error), Io(io::Error),
} }
@@ -320,6 +327,7 @@ impl fmt::Display for ProfileError {
match self { match self {
ProfileError::NotFound => write!(f, "no profile with that name"), ProfileError::NotFound => write!(f, "no profile with that name"),
ProfileError::DuplicateName => write!(f, "a profile with that name already exists"), ProfileError::DuplicateName => write!(f, "a profile with that name already exists"),
ProfileError::CannotDeleteDefault => write!(f, "the Default profile cannot be deleted"),
ProfileError::Io(err) => write!(f, "I/O error: {err}"), ProfileError::Io(err) => write!(f, "I/O error: {err}"),
} }
} }
@@ -411,11 +419,16 @@ impl ProfileStore {
self.profiles.iter().find(|p| p.name == name) self.profiles.iter().find(|p| p.name == name)
} }
/// Creates a new, empty profile named `name`, persists the store to /// Creates a new profile named `name`, persists the store to
/// `store_path`, and records the name in `settings.profiles.names` /// `store_path`, and records the name in `settings.profiles.names`
/// (persisting `settings` to `settings_path` too, keeping the two files in /// (persisting `settings` to `settings_path` too, keeping the two files in
/// sync). Fails with `ProfileError::DuplicateName` if a profile with that /// sync). Fails with `ProfileError::DuplicateName` if a profile with that
/// name already exists. /// name already exists.
///
/// The new profile starts as a copy of the currently active profile's
/// params/metrics/dashboards (or empty, if none is active) — a preset is
/// most useful as a variation on the one you were just using, not a
/// blank slate.
pub fn create( pub fn create(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -426,7 +439,13 @@ impl ProfileStore {
if self.get(&name).is_some() { if self.get(&name).is_some() {
return Err(ProfileError::DuplicateName); return Err(ProfileError::DuplicateName);
} }
self.profiles.push(Profile::new(name.clone())); let mut profile = Profile::new(name.clone());
if let Some(active) = settings.profiles.active_profile.as_deref().and_then(|n| self.get(n)) {
profile.params = active.params.clone();
profile.metrics = active.metrics.clone();
profile.dashboards = active.dashboards.clone();
}
self.profiles.push(profile);
self.save(store_path)?; self.save(store_path)?;
if !settings.profiles.names.contains(&name) { if !settings.profiles.names.contains(&name) {
@@ -440,7 +459,9 @@ impl ProfileStore {
/// and drops it from `settings.profiles.names` (persisting `settings` to /// and drops it from `settings.profiles.names` (persisting `settings` to
/// `settings_path` too). If the deleted profile was active, clears /// `settings_path` too). If the deleted profile was active, clears
/// `settings.profiles.active_profile`. Fails with `ProfileError::NotFound` /// `settings.profiles.active_profile`. Fails with `ProfileError::NotFound`
/// if no such profile exists. /// if no such profile exists, or `ProfileError::CannotDeleteDefault` for
/// the `Default` profile — there must always be at least one profile to
/// fall back to.
pub fn delete( pub fn delete(
&mut self, &mut self,
store_path: &Path, store_path: &Path,
@@ -448,6 +469,9 @@ impl ProfileStore {
settings_path: &Path, settings_path: &Path,
name: &str, name: &str,
) -> Result<(), ProfileError> { ) -> Result<(), ProfileError> {
if name == DEFAULT_PROFILE_NAME {
return Err(ProfileError::CannotDeleteDefault);
}
let index = self.profiles.iter().position(|p| p.name == name).ok_or(ProfileError::NotFound)?; let index = self.profiles.iter().position(|p| p.name == name).ok_or(ProfileError::NotFound)?;
self.profiles.remove(index); self.profiles.remove(index);
self.save(store_path)?; self.save(store_path)?;
@@ -460,6 +484,35 @@ impl ProfileStore {
Ok(()) Ok(())
} }
/// Guarantees a `Default` profile exists in this store (creating an empty
/// one if not), that it's tracked in `settings.profiles.names`, and that
/// `settings.profiles.active_profile` falls back to it when nothing else
/// is active — so the app always has at least one usable profile, even
/// on a first run with no settings file yet. Persists both the store and
/// `settings` if either changed.
///
/// Called once on startup (see `main.rs`) before anything else touches
/// profiles.
pub fn ensure_default(
&mut self,
store_path: &Path,
settings: &mut Settings,
settings_path: &Path,
) -> Result<(), ProfileError> {
if self.get(DEFAULT_PROFILE_NAME).is_none() {
self.profiles.push(Profile::new(DEFAULT_PROFILE_NAME.to_string()));
self.save(store_path)?;
}
if !settings.profiles.names.iter().any(|n| n == DEFAULT_PROFILE_NAME) {
settings.profiles.names.push(DEFAULT_PROFILE_NAME.to_string());
}
if settings.profiles.active_profile.is_none() {
settings.profiles.active_profile = Some(DEFAULT_PROFILE_NAME.to_string());
}
settings.save(settings_path)?;
Ok(())
}
/// Sets (or clears, with `name: None`) the active profile: updates /// Sets (or clears, with `name: None`) the active profile: updates
/// `settings.profiles.active_profile` and persists `settings` to /// `settings.profiles.active_profile` and persists `settings` to
/// `settings_path`. Fails with `ProfileError::NotFound` if `name` is /// `settings_path`. Fails with `ProfileError::NotFound` if `name` is
@@ -718,6 +771,72 @@ mod tests {
assert!(matches!(missing, Err(ProfileError::NotFound))); assert!(matches!(missing, Err(ProfileError::NotFound)));
} }
#[test]
fn ensure_default_creates_default_and_activates_it_once() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
assert!(store.get(DEFAULT_PROFILE_NAME).is_some());
assert!(settings.profiles.names.contains(&DEFAULT_PROFILE_NAME.to_string()));
assert_eq!(settings.profiles.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
// Idempotent: doesn't duplicate the profile, and doesn't clobber an
// already-active (non-Default) profile.
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
assert_eq!(store.list().iter().filter(|p| p.name == DEFAULT_PROFILE_NAME).count(), 1);
assert_eq!(settings.profiles.active_profile, Some("web".to_string()));
}
#[test]
fn default_profile_cannot_be_deleted() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.ensure_default(&store_path, &mut settings, &settings_path).unwrap();
let err = store.delete(&store_path, &mut settings, &settings_path, DEFAULT_PROFILE_NAME);
assert!(matches!(err, Err(ProfileError::CannotDeleteDefault)));
assert!(store.get(DEFAULT_PROFILE_NAME).is_some());
}
#[test]
fn create_copies_the_active_profile() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("profiles.json");
let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default();
let mut store = ProfileStore::load(&store_path);
store.create(&store_path, &mut settings, &settings_path, "web".to_string()).unwrap();
let param_req = param_request("level", r"level=(?P<level>\w+)", "level");
store.record_param_declaration(&store_path, "web", &param_req).unwrap();
store.create_dashboard(&store_path, "web", "Overview".to_string()).unwrap();
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
store.create(&store_path, &mut settings, &settings_path, "web-copy".to_string()).unwrap();
let copy = store.get("web-copy").unwrap();
assert_eq!(copy.params, store.get("web").unwrap().params);
assert_eq!(copy.dashboards, store.get("web").unwrap().dashboards);
// With no active profile, new profiles start empty as before.
store.set_active(&mut settings, &settings_path, None).unwrap();
store.create(&store_path, &mut settings, &settings_path, "blank".to_string()).unwrap();
let blank = store.get("blank").unwrap();
assert!(blank.params.is_empty());
assert!(blank.dashboards.is_empty());
}
#[test] #[test]
fn set_active_updates_and_persists_settings() { fn set_active_updates_and_persists_settings() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+7
View File
@@ -22,6 +22,13 @@ use serde::{Deserialize, Serialize};
pub enum ColorScheme { pub enum ColorScheme {
Light, Light,
Dark, Dark,
Grey,
Red,
Green,
Blue,
Yellow,
Pink,
Orange,
#[default] #[default]
System, System,
} }
-35
View File
@@ -1,35 +0,0 @@
# 001 — Param declarations
## Summary
Implement the `ParamDeclaration` slice of the primary index: users can declare how a
named parameter is derived from a log line (e.g. deriving a timestamp different from
the ingestion timestamp, or a log level), and the engine stores/serves those
declarations.
## Scope
- `IndexKey::ParamDeclaration { name }` and a matching `DataValue` variant for the
declaration body (a regex against the raw log text, plus how the capture maps to a
`MetricPrimitive` — reuse `RegexToMetricPrimitiveMatcher` / `MetricPrimitiveKind`
from `data_value.rs`, which already exist but are unused).
- Engine message(s) to add/list/remove a param declaration.
- Validate declared regexes at declaration time (reject invalid regex / unknown
primitive kind up front rather than failing per-log later).
- No derivation/backfill against existing logs yet — that's
[002](002-metric-declarations.md) / [003](003-metric-bucket-derivation.md), since
metrics are what actually get indexed into buckets. This ticket is just the
declaration storage + validation.
## Acceptance criteria
- Declaring a param with a valid regex + capture group succeeds and is retrievable.
- Declaring a param with an invalid regex or referencing a non-existent capture group
is rejected with a clear error, not silently stored.
- Declarations sit in their own contiguous range of the BTreeMap
(`ParamDeclaration` between `LogParamSplit` and `ParamMetricSplit`), per the
`IndexKey` ordering in the README.
## Depends on
None — this is foundational.
-35
View File
@@ -1,35 +0,0 @@
# 002 — Metric declarations
## Summary
Implement the `MetricDeclaration` slice of the index: a language/API for declaring a
metric — a regex that matches a log message plus params extracted from it — so that
matching logs can later be bucketed ([003](003-metric-bucket-derivation.md)).
## Scope
- `IndexKey::MetricDeclaration { name }` paired with `DataValue::MetricDeclaration`
(already sketched in `data_value.rs`: `message_match_regex` +
`Vec<RegexToMetricPrimitiveMatcher>`).
- Engine message(s) to add/list/remove a metric declaration.
- A metric declaration's params may reference params already declared via
[001](001-param-declarations.md) (e.g. label a bucket by an existing param) in
addition to inline capture groups on the metric's own regex — decide and document
which is supported for v1 (README implies inline capture-group derivation is the
primary mechanism: "buckets and parameters can be derived" from the metric
declaration itself).
- Validate the regex and capture-group → primitive-kind mapping at declaration time,
same as param declarations.
## Acceptance criteria
- Declaring a metric with a valid regex + param matchers succeeds and is retrievable.
- Invalid regex, or a matcher referencing a capture group the regex doesn't have, is
rejected at declaration time.
- Declarations sit in their own contiguous range of the BTreeMap
(`MetricDeclaration` between `ParamMetricSplit` and `MetricBucketSplit`).
## Depends on
- [001 — Param declarations](001-param-declarations.md) (shares matcher/primitive
validation code and the "derive a value from a regex capture" concept).
-37
View File
@@ -1,37 +0,0 @@
# 003 — Metric bucket derivation
## Summary
Apply metric declarations to log lines and populate `IndexKey::Bucket` /
`DataValue::BucketValue` entries, both retroactively (backfill already-ingested logs
when a metric is declared) and going forward (index each new log against all current
metric declarations as it's ingested).
## Scope
- On `MetricDeclaration` add: scan the existing `IngestedLog` range, run the metric's
regex against each log's text, and for each match compute the labeled `Bucket` key
(`metric_name` + `labels: BTreeMap<String, MetricPrimitive>`) and append
`(sequence, timestamp, value)` to that bucket's `BucketValue`.
- On new log ingest: run all currently-declared metrics' regexes against the line and
update buckets incrementally, same as backfill.
- Decide bucket cardinality behavior: a single log line can match multiple metrics: it
should update all matching buckets. Document how differing label sets from the same
metric become distinct `Bucket` keys (labels are part of the key, so this falls out
naturally from `BTreeMap` — just needs the insert-or-append logic).
- This is the piece that makes `MetricPrimitive`, `DataValue::BucketValue`, and the
`Bucket` index range (currently defined but never populated) actually load-bearing.
## Acceptance criteria
- Declaring a metric against a log stream that already has matching lines populates
buckets for those lines without needing new input.
- Ingesting a new log line after metrics are declared updates the relevant buckets
live.
- A log line matching zero metrics doesn't touch the bucket range.
- Bucket values preserve insertion order / are queryable by sequence and timestamp
range (needed by [004](004-metrics-api.md) and dashboards).
## Depends on
- [002 — Metric declarations](002-metric-declarations.md)
-31
View File
@@ -1,31 +0,0 @@
# 004 — Metrics API
## Summary
Expose param/metric declarations and bucket data over HTTP so the frontend (and the
filter/dashboard query engines) can declare metrics and read their values.
## Scope
- `POST /api/params` — declare a param ([001](001-param-declarations.md)).
- `POST /api/metrics` — declare a metric ([002](002-metric-declarations.md)).
- `GET /api/metrics` — list declared metrics (+ their declared params/labels).
- `GET /api/metrics/:name` — query bucket values for a metric, filtered by label
selector and time/sequence range, mirroring the existing `LogsParams`/
`TimelineParams` pattern in `dto.rs`.
- Follow the existing engine architecture: new `EngineQuery`/`EngineMessage` variants,
handled in `engine.rs`'s single-owner loop, DTOs added to `dto.rs`, routes added to
`api.rs`'s router — no new locking or shared state.
## Acceptance criteria
- Declaring a param/metric via the API round-trips through to engine state and shows
up in `GET /api/metrics`.
- Querying a metric's bucket values supports the same start/end and limit-style
params as the existing logs/timeline endpoints.
- Bad declarations (invalid regex etc.) return a 4xx with a useful error body, not a
500 or silent no-op.
## Depends on
- [003 — Metric bucket derivation](003-metric-bucket-derivation.md)
-37
View File
@@ -1,37 +0,0 @@
# 005 — FilterBar query language (pest grammar)
## Summary
Build the syntax-aware search language described in the README's FilterBar section,
using `pest`, supporting regex searches and metric comparisons combined with
boolean operators.
## Scope
- Add `pest` + `pest_derive` dependencies.
- Grammar covering:
- `regex("...")` — raw-text regex search over log lines.
- `metric_name[param] <op> value` comparisons (`<`, `>`, `<=`, `>=`, `==`, `!=`) —
`metric_name[param]<5` per the README example.
- `sequence` and `timestamp` as reserved metric names usable the same way
(`sequence<5`, `timestamp>12:00`).
- Boolean combinators `and` / `or` with standard precedence, e.g.
`regex("error") and sequence<5 or timestamp>12:00`.
- Parse into an AST (not directly into engine queries — that's
[007](007-filterbar-integration.md)).
- Surface parse errors with position info suitable for showing under the FilterBar
input.
## Acceptance criteria
- Grammar test cases cover: bare regex, single metric comparison, reserved-metric
comparison, `and`/`or` combinations with correct precedence, and at least one
malformed-input case producing a located error rather than a panic.
- Parser is a standalone module with no dependency on the engine/index types, so it
can be unit tested in isolation.
## Depends on
None directly, but the metric-comparison side of the grammar is only meaningful once
[002](002-metric-declarations.md) exists — build/test this ticket against metric
names as opaque strings.
-30
View File
@@ -1,30 +0,0 @@
# 006 — FilterBar datetime parsing
## Summary
Support the several datetime formats the README requires for timestamp comparisons
in the filter language: relative humantime, absolute time, and full dates.
## Scope
- Relative humantime: `5h`, `-3m`, etc. — resolved against "now" (or against the
query's reference time, if one is introduced later) at parse/eval time.
- Absolute time: e.g. `12:00` (time-of-day, as in the README's
`timestamp>12:00` example) — needs a defined rule for which day it resolves to.
- Full date: complete date(+time) literals.
- Produce a single normalized value (`Timestamp`/millis) regardless of input form, for
the filter evaluator ([007](007-filterbar-integration.md)) to compare against.
- Pick and document a crate (e.g. `humantime` for the relative case) rather than
hand-rolling a datetime parser.
## Acceptance criteria
- Each of the three formats parses to the correct absolute millis value in unit
tests, including edge cases (negative relative offsets, time-of-day near midnight).
- Ambiguous/invalid input produces a clear error rather than a silently wrong
timestamp.
## Depends on
None — standalone parsing module, consumed by
[007 — FilterBar integration](007-filterbar-integration.md).
-36
View File
@@ -1,36 +0,0 @@
# 007 — FilterBar query integration
## Summary
Wire the parsed filter AST into actual log/timeline queries against the engine, so
FilterBar's syntax (not just a naive substring match) drives what's shown in
LogPreview and LogTimeline.
## Scope
- Evaluator that walks the AST from [005](005-filterbar-pest-parser.md), resolving
`regex(...)` against log text and `metric_name[param] <op> value` against bucket
data ([004](004-metrics-api.md)), using datetime literals resolved via
[006](006-filterbar-datetime-parsing.md).
- New/extended `EngineQuery::Logs` (and `Timeline`) filter parameter that accepts the
compiled filter, evaluated inside the engine's single-owner loop (per the README's
"engine is sole owner of the btreemap" architecture — don't evaluate against a
copy).
- `sequence`/`timestamp` reserved-metric comparisons map directly to the
`IngestedLog` key fields rather than a declared metric lookup.
- API layer: accept the raw filter string, parse it (return 4xx on parse error),
compile to whatever the engine query needs, send to engine.
## Acceptance criteria
- `GET /api/logs?filter=...` and the timeline equivalent apply the full filter
language, not just substring matching.
- A filter combining a regex clause and a metric comparison with `and`/`or` returns
correct results against a seeded log+metric fixture.
- Malformed filter strings return a 4xx with the parser's error message.
## Depends on
- [004 — Metrics API](004-metrics-api.md)
- [005 — FilterBar pest parser](005-filterbar-pest-parser.md)
- [006 — FilterBar datetime parsing](006-filterbar-datetime-parsing.md)
-33
View File
@@ -1,33 +0,0 @@
# 008 — Settings model
## Summary
Implement the single, process-wide settings model described in the README's NavMenu
section: colour scheme, CSS path config, and the settings file location — settable
via CLI args, persisted at a sensible default OS location.
## Scope
- A `Settings` struct: colour scheme choice, list of imported/linked CSS entries
(see [010](010-css-management.md)), and whatever else profiles need to hang off of.
- Default settings file location using an OS-conventions crate (e.g. `directories`)
rather than a hardcoded path.
- `clap` arg(s) to override the settings file path at runtime (extend the existing
`Args` in `main.rs`, which today only has `--port`/`--static-dir`).
- Load-on-start / save-on-change semantics; "at runtime, there is only 1 settings" —
i.e. a single in-memory settings instance, not per-request state, analogous to how
the engine owns the index.
- Settings "owns" the profiles per the README — this ticket only needs to define
where profiles live under settings, not implement profile CRUD itself (that's
[009](009-presets-profiles.md)).
## Acceptance criteria
- Running with no args creates/reads a settings file at the documented default
location.
- `--settings-path` (or similar) overrides it.
- Settings persist across restarts (edit, restart, re-read matches).
## Depends on
None.
-34
View File
@@ -1,34 +0,0 @@
# 009 — Presets / profiles
## Summary
Implement presets ("profiles") that track parsing/indexing rules (param + metric
declarations) and stored dashboards, so restarting ottyl against a given CLI app
doesn't lose that configuration.
## Scope
- Profile = a named bundle of: param declarations ([001](001-param-declarations.md)),
metric declarations ([002](002-metric-declarations.md)), and saved dashboards
([014](014-dashboards-ui.md), once it exists — can stub the dashboard field until
then).
- Persisted under the settings storage location from
[008](008-settings-model.md); settings reference which profile(s) exist / which is
active.
- On startup, load the active profile's param/metric declarations into the engine
before ingestion starts (or apply-and-backfill if logs arrive before load
completes — define the ordering explicitly).
- CRUD for profiles, exposed later via NavMenu UI ([011](011-navmenu-ui.md)).
## Acceptance criteria
- Declaring params/metrics through a profile, restarting the process, and re-running
the same CLI command reproduces the same bucket derivation without re-declaring
anything.
- Multiple profiles can exist; switching the active profile changes which
declarations are loaded on next start.
## Depends on
- [008 — Settings model](008-settings-model.md)
- [002 — Metric declarations](002-metric-declarations.md)
-30
View File
@@ -1,30 +0,0 @@
# 010 — CSS import/management
## Summary
Let users import custom CSS (stored alongside settings/profiles, deletable) or link
to an external CSS path/URL (not deletable, just referenced).
## Scope
- `POST /api/settings/css` (import): accept raw CSS content, store it in the same
directory as settings/profiles ([008](008-settings-model.md)), return an id/handle.
- `DELETE /api/settings/css/:id`: remove an imported CSS file; reject deletion for
linked (non-imported) entries.
- `POST /api/settings/css/link`: register a path/URL reference without copying
content; these are never deletable via the delete endpoint (only "unlink"-able,
if that distinction matters — clarify in implementation).
- Settings model tracks the list of CSS entries (imported + linked) with enough
metadata to distinguish the two kinds.
- Serve imported CSS content back so the frontend can `<link>`/inline it.
## Acceptance criteria
- Importing CSS persists content under the settings directory and survives restart.
- Deleting an imported CSS entry removes the file and the settings reference.
- Attempting to delete a linked (non-imported) entry is rejected.
- Linked entries resolve to their original path/URL, not a copy.
## Depends on
- [008 — Settings model](008-settings-model.md)
-30
View File
@@ -1,30 +0,0 @@
# 011 — NavMenu UI
## Summary
Build out the NavMenu frontend component for managing settings, presets, and CSS,
per the README's block diagram (NavMenu occupies its own column alongside the rest
of the dashboard).
## Scope
- Settings panel: colour scheme picker, CSS import (file/text input) + list of
imported/linked CSS with delete where applicable.
- Presets panel: list/create/switch/delete profiles ([009](009-presets-profiles.md)),
showing each profile's param/metric declaration counts and saved dashboards.
- Wire to the settings/profile/CSS APIs from
[008](008-settings-model.md)/[009](009-presets-profiles.md)/[010](010-css-management.md).
- Apply the active colour scheme / imported CSS to the app shell at runtime.
## Acceptance criteria
- Switching active profile in the UI changes engine state (declared params/metrics)
without a manual restart, or clearly communicates that a restart is needed if
that's the chosen design from [009](009-presets-profiles.md).
- Importing/deleting CSS in the UI is reflected immediately in the rendered app.
## Depends on
- [008 — Settings model](008-settings-model.md)
- [009 — Presets / profiles](009-presets-profiles.md)
- [010 — CSS import/management](010-css-management.md)
-27
View File
@@ -1,27 +0,0 @@
# 012 — Metrics UI
## Summary
Replace the current "Coming Soon" Metrics placeholder with a real component: declare
metrics via the metric language, and view their derived values.
## Scope
- A form/editor for declaring a metric (regex + param matchers) that calls
`POST /api/metrics` ([004](004-metrics-api.md)), with inline validation errors
surfaced from the API's 4xx responses.
- List of currently-declared metrics with their labels.
- A view of a selected metric's bucket values over the active time range (reuse the
time-range plumbing already in `App.tsx`/`LogTimeline`).
## Acceptance criteria
- Declaring a metric in the UI immediately reflects newly-matching logs (existing
and new) in the metric's value view, per the backfill behavior from
[003](003-metric-bucket-derivation.md).
- Invalid metric definitions show the backend's validation error instead of failing
silently.
## Depends on
- [004 — Metrics API](004-metrics-api.md)
-30
View File
@@ -1,30 +0,0 @@
# 013 — Dashboards: PromQL query support
## Summary
Support PromQL-style queries over declared metrics to derive dashboard
visualizations, per the README's Dashboards section ("There should be support for
promql queries to derive the visualisation").
## Scope
- Evaluate a useful PromQL subset (instant/range vector selection by metric name +
label matchers, at minimum; consider `rate()`/`sum()`/`avg()` aggregations as
stretch) against `Bucket` data ([003](003-metric-bucket-derivation.md)).
- Decide whether to hand-roll the subset or bring in an existing PromQL parser
crate — document the choice.
- New `EngineQuery` variant for PromQL evaluation, following the existing
single-owner-evaluates-in-place pattern.
- API endpoint (e.g. `GET /api/query?promql=...`) returning a chart-ready series
format (labels + time-value pairs), shaped for bar/line/pie consumption by
[014](014-dashboards-ui.md).
## Acceptance criteria
- A basic instant/range query against a declared metric with a label matcher returns
correct series data against a seeded fixture.
- Malformed PromQL returns a 4xx with a useful error.
## Depends on
- [004 — Metrics API](004-metrics-api.md)
-30
View File
@@ -1,30 +0,0 @@
# 014 — Dashboards UI
## Summary
Replace the current "Coming Soon" Dashboards placeholder with a Grafana-style
dashboard builder: bar, line, and pie charts backed by PromQL queries, saved as part
of a profile.
## Scope
- Chart components for bar/line/pie (pick a charting lib — none is currently in
`webapp/package.json`).
- Dashboard = a named set of panels, each with a PromQL query
([013](013-dashboards-promql.md)) and a chart type.
- Save/load dashboards as part of the active profile
([009](009-presets-profiles.md)), so they persist across restarts.
- Dashboard panels respect the app's active time range, same as LogTimeline/
LogPreview.
## Acceptance criteria
- Creating a panel with a PromQL query against a real declared metric renders the
correct chart type with live data.
- Dashboards persist across restart via the active profile.
- Panels re-query and update when the global time range changes.
## Depends on
- [013 — Dashboards: PromQL query support](013-dashboards-promql.md)
- [009 — Presets / profiles](009-presets-profiles.md)
-28
View File
@@ -1,28 +0,0 @@
# 015 — LogPreview: selectable parameter columns
## Summary
The README's LogPreview spec says it should be possible to pick which parameters are
visible as columns, alongside the always-visible sequence/timestamp. Currently
LogPreview only shows sequence, timestamp, and raw text.
## Scope
- Extend the logs API/DTO ([004](004-metrics-api.md) territory, but specifically the
existing `GET /api/logs`) to include derived param values per log line, sourced
from [001](001-param-declarations.md)'s declarations applied to that line.
- Frontend: column picker UI in/near LogPreview; selected columns persist (localStorage
is fine for a per-viewer preference, or fold into settings if it should be shared —
pick localStorage unless product direction says otherwise).
## Acceptance criteria
- Declared params with a match on a given log line appear as selectable, populated
columns in LogPreview.
- Column selection persists across a page reload.
## Depends on
- [001 — Param declarations](001-param-declarations.md)
- [004 — Metrics API](004-metrics-api.md) (for the pattern of extending log query
responses)
-26
View File
@@ -1,26 +0,0 @@
# 016 — FilterBar UI integration
## Summary
Wire the FilterBar frontend component to the real filter query language instead of
the current naive client-side substring match in `App.tsx`
(`logs.filter((l) => l.text.includes(filter))`).
## Scope
- Send the raw filter string to `GET /api/logs`/`GET /api/timeline` via the new
`filter` param from [007](007-filterbar-integration.md), instead of filtering
client-side.
- Surface parse errors from the API (4xx + message) inline under the FilterBar input.
- Remove the now-dead client-side `visibleLogs` substring filter in `App.tsx`.
## Acceptance criteria
- Typing a valid filter expression (regex + metric comparisons combined with
and/or) narrows both LogPreview and LogTimeline via server-side evaluation.
- An invalid expression shows an inline error and does not clear/break the
currently-displayed logs.
## Depends on
- [007 — FilterBar query integration](007-filterbar-integration.md)
+29
View File
@@ -0,0 +1,29 @@
# 019 — Default port should be 0
## Summary
`--port` currently defaults to `8420` (`Args` in `main.rs`). The wishlist asks for a
default of `0`, i.e. let the OS assign an ephemeral free port rather than colliding
with whatever else is running on a fixed port.
## Scope
- Change `Args::port`'s default to `0`.
- After `TcpListener::bind` succeeds, read back the actual bound port (via
`listener.local_addr()`) since binding to `0` means the requested port and the
real one differ.
- Print the real bound port in the startup log line (it already prints
`args.port`, which would otherwise always read `0`).
- Consider whether the actual port needs to be surfaced anywhere else (e.g. a
`GET /api/stats`-style endpoint) — not required unless something already depends
on knowing it programmatically today; check before adding.
## Acceptance criteria
- Running `ottyl` with no `--port` binds an OS-assigned free port instead of a fixed
one, and the startup message prints that actual port.
- `--port <n>` still binds the requested fixed port as before.
## Depends on
None.
+39
View File
@@ -0,0 +1,39 @@
# 020 — File input for ingestion, with restart-safe tailing
## Summary
`ingest.rs` only reads stdin today. The wishlist asks for an optional file path as
an alternative input source, plus resilience to the log file being recreated (e.g.
the host process being tailed restarts and truncates/recreates its log file) —
ottyl should keep tailing rather than treating that as EOF-and-done.
## Scope
- New `clap` arg (e.g. `--file <path>`), mutually exclusive-in-spirit with stdin:
when given, ingest reads from the file instead of stdin; when absent, behavior is
unchanged (stdin, as today).
- File-mode ingestion must behave like `tail -f`: keep reading as new lines are
appended, don't exit on reaching current EOF.
- Detect file recreation (the common "log rotated / process restarted and reopened
the path" case) — e.g. by tracking the inode/file id and re-opening when it
changes, or noticing truncation (current read offset > file size) — and resume
tailing the new file from its start rather than exiting or getting stuck.
- Sequence numbers continue incrementing across a recreation event (per
`IndexKey::IngestedLog`, sequence is ottyl's own line counter, not tied to the
source file).
- Reuse the existing `IngestedLine` / `EngineMessage::Ingest` plumbing — this only
changes where lines come from, not how they're indexed.
## Acceptance criteria
- `ottyl --file some.log` ingests existing content and then picks up appended
lines live.
- Deleting and recreating the file at the same path (simulating a host restart)
does not kill ottyl or stop ingestion — new content from the recreated file
continues to be ingested.
- Running with no `--file` is unchanged: stdin ingestion as today.
## Depends on
None — parallels the existing `ingest::run`, doesn't require any of the metrics/
filter machinery.
+35
View File
@@ -0,0 +1,35 @@
# 021 — Contextual "?" help icons linking into Docs
## Summary
Every major UI section should have a "?" icon that opens the Docs panel scrolled
to (and highlighting) the relevant section, instead of the user having to find it
themselves in the Docs accordion.
## Scope
- A small reusable `HelpIcon`/`HelpLink` component: renders a "?" affordance,
accepts a target docs section id, and on click opens the Docs panel
(`NavMenu`'s `docs` panel / `Docs.tsx`) navigated to that section.
- Add one to each major component per the README's block diagram: `FilterBar`,
`LogTimeline`, `LogPreview`, `Metrics`, `Dashboards`, and NavMenu's own
Settings/Presets panels.
- Docs panel: support being opened with a target section id (not just opened to
the top/last-open accordion item) — expand the right accordion section and
scroll it into view.
- "Flash" behavior: when navigated to via a "?" icon, the target paragraph/section
visually flashes (e.g. a brief background-color pulse) so the user's eye finds it
immediately, then settles back to normal styling.
## Acceptance criteria
- Clicking a "?" icon on any of the listed components opens Docs, expands the
correct accordion section, scrolls it into view, and it visibly flashes.
- Clicking the same icon twice in a row re-triggers the flash (not just a no-op
because the panel is already open to that section).
- Docs opened normally (via the NavMenu Docs button, not a "?" icon) behaves as
today — no flash, no forced section.
## Depends on
None — builds on the existing `Docs.tsx` / NavMenu docs panel.
+53
View File
@@ -0,0 +1,53 @@
# 022 — Metrics rethink: counters as the primitive, histograms/gauges derived
## Summary
The wishlist calls for rethinking the metrics model: everything a metric declares
should fundamentally be a **counter** (an occurrence at a point in time, optionally
labeled). Histograms and gauges are not separate declaration kinds — they're
*derived views* computed from counter data (bucketing counter events into
histogram buckets; reducing counter series into a gauge-style "current value").
This changes how `MetricDeclaration`/`BucketValue` are modeled and how the
already-shipped PromQL-style query support (`promql.rs`) evaluates
`rate()`/`sum()`/histogram/gauge-shaped queries.
## Scope
- Re-express metric declarations as counter declarations: a match increments a
counter for the resolved label set at the matched timestamp. This is close to
today's `BucketValue{values: Vec<(Sequence, Timestamp, MetricPrimitive)>}` — audit
whether the existing bucket representation already is this, or whether captured
non-counter values (e.g. a captured `Number` used as a gauge reading today) need
to be reinterpreted as counter increments plus a separately-derived gauge.
Document the resulting semantics clearly since this is a model change to code
that already exists and has callers (`engine.rs`, `promql.rs`, `Metrics.tsx`,
`Dashboards.tsx`).
- Histogram derivation: given a counter's events over a time range, bucket them
(fixed-width or user-specified boundaries) and expose bucket counts — likely as
a new PromQL-evaluable form (e.g. `histogram(metric_name[param])`) or a query
option, consumed the same way existing series are.
- Gauge derivation: given a counter's events, derive a "last value" / "value at
time T" series from the most recent matching counter event — likely as another
derived query form (e.g. `gauge(metric_name[param])`), rather than a distinct
declaration/storage kind.
- Update `promql.rs`'s evaluator to support these derived forms without breaking
existing raw counter queries.
- Update `Metrics.tsx`'s declaration form/help text and `Dashboards.tsx`'s panel
config if the query surface changes.
## Acceptance criteria
- A declared metric behaves as a counter: each match is an increment/event, not an
arbitrary overwritten value.
- A histogram-derived query against a counter with several distinct captured
values buckets them correctly across a seeded fixture.
- A gauge-derived query against a counter returns the most-recent value at a given
point in the selected time range.
- Existing raw counter queries (sum/rate over a plain counter, as today) continue
to work unchanged.
## Depends on
None — this is a rework of the already-shipped metric/bucket/PromQL code, not new
foundational work. [023](023-duration-param.md), [025](025-default-dashboard-seed.md)
build on the resulting model.
+37
View File
@@ -0,0 +1,37 @@
# 023 — Duration-type parameter
## Summary
Support a `Duration` parameter kind: its value is the time elapsed since the
previous trigger of the *same* metric with the *same* labels/params, measured
using whichever timestamp field the user has configured (ingestion time by
default, or a param-derived timestamp).
## Scope
- New `MetricPrimitive`/param-kind variant for durations (e.g. milliseconds as an
`i64`, consistent with how `Timestamp` is already stored).
- Derivation rule: for a metric match with a given label set, look up that same
label set's previous counter event ([022](022-metrics-counters-rethink.md)) and
compute `this_event_time - previous_event_time` using the configured time
source (ingestion timestamp, or a declared timestamp param per the README's
"deriving a timestamp different from the ingested timestamp" note).
- First occurrence of a given label set has no previous event — define and
document the behavior (e.g. omit the duration value, or emit `null`/zero;
omitting is likely correct so it doesn't pollute histograms with a bogus zero).
- Expose duration values the same way other derived/counter data is queried
(bucket lookup, PromQL, Metrics UI) so it composes with existing dashboards.
## Acceptance criteria
- Two log lines matching the same metric+labels, seconds apart, produce a
duration value equal to that elapsed time on the second occurrence.
- The first occurrence of a given label set produces no duration value (not a
zero or garbage value).
- Duration values are queryable via the same bucket/PromQL surface as other
metric data.
## Depends on
- [022 — Metrics rethink](022-metrics-counters-rethink.md) (durations are computed
between successive counter events of the same metric).
+41
View File
@@ -0,0 +1,41 @@
# 024 — Time range controls (Grafana-style presets)
## Summary
Today the only way to set the active time range is dragging over `LogTimeline`
(`App.tsx`'s `range` state, passed to `LogPreview`/`Metrics`/`Dashboards` as
`startMs`/`endMs`). The wishlist wants explicit controls like Grafana's: an "All"
option and relative presets ("last 5 minutes", etc.), not just drag-to-select.
## Scope
- A time-range control component (dropdown or button row) offering at least:
"All" (no range — today's `range === null` default) and a handful of relative
presets (e.g. last 5m / 15m / 1h / 24h), placed near `LogTimeline` alongside the
existing drag-select interaction.
- Selecting a preset computes `{startMs, endMs}` relative to now and feeds the
same `range` state `App.tsx` already threads through to `LogTimeline`,
`LogPreview`, `Metrics`, and `Dashboards` — no new plumbing needed downstream,
since all four already accept/consume a range.
- Relative presets need to "move with time" while active (e.g. "last 5 minutes"
should mean *rolling* last 5 minutes, not a frozen window from selection time) —
decide and implement a refresh cadence (e.g. re-resolve the relative range on
the same interval the app already polls for new data) versus a one-shot
resolved range; document the choice.
- Manually dragging on `LogTimeline` after a preset is active should override it
back to a fixed (non-rolling) range, same as today.
## Acceptance criteria
- Selecting "All" clears the range the same way today's implicit default does.
- Selecting a relative preset narrows `LogPreview`, `LogTimeline`, `Metrics`, and
`Dashboards` to that window.
- A rolling preset's window visibly advances over time without user interaction,
if that's the chosen behavior (see Scope).
- Dragging on `LogTimeline` while a preset is active switches back to a fixed
range bounded by the drag.
## Depends on
None — the underlying range plumbing (`App.tsx` → `LogTimeline`/`LogPreview`/
`Metrics`/`Dashboards`) already exists; this only adds the control surface.
+39
View File
@@ -0,0 +1,39 @@
# 025 — Seed a default dashboard with 2 default panels
## Summary
`ProfileStore::ensure_default` creates the `Default` profile with no dashboards.
The wishlist wants every fresh Default profile to come with a starter dashboard:
a line chart of "error" vs. non-"error" log volume, and a pie chart of the same
ratio.
## Scope
- On `ensure_default` creating a brand-new `Default` profile (not on every
startup — only when the profile itself is newly created), seed one
`Dashboard` with two panels:
- Line chart: log message count over time, split into "contains `error`" vs.
"does not contain `error`" series.
- Pie chart: same split, as a ratio/total-count breakdown.
- Express both panels as PromQL-style queries against a counter
([022](022-metrics-counters-rethink.md)) — this likely requires an implicit
built-in "log matched regex X" counter concept (e.g. auto-declaring a metric
for `regex("error")` vs. its complement), or a raw-text regex count primitive
in the PromQL surface if one doesn't already exist. Decide and document which.
- Seeded dashboard/panels must persist through the normal profile save path (same
as any user-created dashboard) so it's editable/deletable like any other.
## Acceptance criteria
- A fresh install (no existing settings/profile store) starts up with a `Default`
profile that already has one dashboard containing the line and pie panels
described above, without any user action.
- Restarting with an already-existing `Default` profile does not re-seed or
duplicate the dashboard.
- The seeded panels render real data against a log stream containing a mix of
"error" and non-"error" lines.
## Depends on
- [022 — Metrics rethink](022-metrics-counters-rethink.md) (defines what a
"count of matching logs" counter/query looks like).
+34
View File
@@ -0,0 +1,34 @@
# 026 — Settings: maximum logs kept in memory
## Summary
ottyl currently keeps every ingested log forever in the in-memory `BTreeMap`. The
wishlist wants a settings-configurable cap on how many logs are retained, with no
cap (unlimited) as the default.
## Scope
- New `Settings` field (e.g. `max_logs: Option<usize>`), `None`/unset = unlimited,
matching the existing "by default it should be unlimited" requirement.
- Enforce the cap in the engine's ingest path: once the `IngestedLog` range
exceeds the configured max, evict the oldest entries (lowest `sequence`) to
bring it back under the cap.
- Decide and document what happens to data that references evicted logs:
- Params/metrics already derived into `Bucket` values from an evicted log are
**not** retroactively removed (buckets are aggregate data, not raw logs) —
state this explicitly so it's not treated as a bug later.
- `sequence` numbering does not reset/renumber on eviction.
- Expose the setting in the Settings API/NavMenu settings panel (a numeric input,
clearable back to "unlimited").
## Acceptance criteria
- With no cap configured, behavior is unchanged (unbounded retention, as today).
- Setting a cap and ingesting past it keeps memory usage bounded — oldest logs
are evicted, newest `max_logs` remain queryable via `GET /api/logs`.
- Changing the cap at runtime (via settings) takes effect on the next ingest
without requiring a restart.
## Depends on
None — extends the existing `Settings` model.
+33
View File
@@ -0,0 +1,33 @@
# 027 — Dashboards: remove manual save, autosave everything
## Summary
`Dashboards.tsx` currently has an explicit "Save dashboard" button
(`saveDashboard` called on click). The wishlist wants dashboards to always
autosave — no explicit save step, matching how settings/profile changes already
persist immediately elsewhere in the app.
## Scope
- Remove the "Save dashboard" button and the `saving`/dirty-draft gating around
it in `Dashboards.tsx`.
- Persist panel/name edits automatically: debounce edits (e.g. text field
keystrokes) so autosave doesn't fire on every keystroke, but don't require any
user action to commit a change.
- Cover all mutations that currently route through the save button: panel
add/remove/edit (title, PromQL, chart type), dashboard rename.
- Surface save failures unobtrusively (e.g. a small inline status/error near the
dashboard, not a blocking dialog) since there's no longer a discrete "Save"
click to attach an error to.
## Acceptance criteria
- Editing a panel's title, query, or chart type persists without clicking
anything, verified by reloading the page and seeing the change retained.
- Rapid typing in a text field doesn't spam the save endpoint on every keystroke.
- A failed autosave (e.g. simulated API error) surfaces to the user without
losing the in-progress edit.
## Depends on
None — modifies the existing `Dashboards.tsx` save path.
+1 -1
View File
@@ -1,6 +1,6 @@
export function ComingSoon({ title }: { title: string }) { export function ComingSoon({ title }: { title: string }) {
return ( 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 {title} — coming soon
</div> </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]) }, [panel.promql, panel.chart_type, range?.startMs, range?.endMs])
if (error) { 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) { 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} /> return <PanelChart series={series} chartType={panel.chart_type} />
} }
@@ -168,43 +168,43 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
if (!activeProfile) { if (!activeProfile) {
return ( 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. Dashboards — activate a profile (see the settings menu) to create and save dashboards.
</div> </div>
) )
} }
return ( return (
<div className="border-b border-neutral-800 p-3 text-sm"> <div className="border-b border-border p-3 text-sm">
<div className="mb-2 text-xs text-neutral-500">Dashboards — profile "{activeProfile}"</div> <div className="mb-2 text-xs text-text-subtle">Dashboards — profile "{activeProfile}"</div>
{error && ( {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="flex gap-4">
<div className="w-56 shrink-0 space-y-3"> <div className="w-56 shrink-0 space-y-3">
<form onSubmit={handleCreateDashboard} className="flex gap-1"> <form onSubmit={handleCreateDashboard} className="flex gap-1">
<input <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} value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)} onChange={(e) => setNewDashboardName(e.target.value)}
placeholder="New dashboard name" placeholder="New dashboard name"
/> />
<button <button
type="submit" 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 + Add
</button> </button>
</form> </form>
<div className="space-y-1"> <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) => ( {dashboards.map((d) => (
<div <div
key={d.id} key={d.id}
className={`flex items-center justify-between rounded border px-2 py-1 text-xs ${ className={`flex items-center justify-between rounded border px-2 py-1 text-xs ${
selectedId === d.id selectedId === d.id
? 'border-sky-500 bg-sky-500/10 text-sky-300' ? 'border-accent bg-accent/10 text-accent-text'
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800' : '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)}> <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 <button
type="button" type="button"
onClick={() => handleDeleteDashboard(d.id)} 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" title="Delete dashboard"
> >
× ×
@@ -225,12 +225,12 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{!draft ? ( {!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="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<input <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} value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/> />
@@ -238,7 +238,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
<button <button
type="button" type="button"
onClick={addPanel} 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 + Add panel
</button> </button>
@@ -246,7 +246,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
type="button" type="button"
onClick={handleSaveDraft} onClick={handleSaveDraft}
disabled={saving} 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'} {saving ? 'Saving…' : 'Save dashboard'}
</button> </button>
@@ -254,27 +254,27 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
</div> </div>
{draft.panels.length === 0 && ( {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"> <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{draft.panels.map((panel) => ( {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"> <div className="mb-2 flex items-center gap-1">
<input <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} value={panel.title}
onChange={(e) => updatePanel(panel.id, { title: e.target.value })} onChange={(e) => updatePanel(panel.id, { title: e.target.value })}
placeholder="Panel title" placeholder="Panel title"
/> />
<input <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} value={panel.promql}
onChange={(e) => updatePanel(panel.id, { promql: e.target.value })} onChange={(e) => updatePanel(panel.id, { promql: e.target.value })}
placeholder={String.raw`requests{path="/a"}`} placeholder={String.raw`requests{path="/a"}`}
/> />
<select <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} value={panel.chart_type}
onChange={(e) => updatePanel(panel.id, { chart_type: e.target.value as ChartType })} onChange={(e) => updatePanel(panel.id, { chart_type: e.target.value as ChartType })}
> >
@@ -287,12 +287,12 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
<button <button
type="button" type="button"
onClick={() => removePanel(panel.id)} 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> </button>
</div> </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} /> <PanelPreview panel={panel} range={range} />
</div> </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 ( return (
<form <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) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
onChange(draft) onChange(draft)
@@ -28,19 +28,19 @@ export function FilterBar({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <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' placeholder='regex("error") and sequence<5 or timestamp>12:00'
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
/> />
<button <button
type="submit" 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 Filter
</button> </button>
</div> </div>
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-danger-text">{error}</p>}
</form> </form>
) )
} }
+13 -13
View File
@@ -57,24 +57,24 @@ function ColumnPicker({
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} 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})` : ''} Columns{visible.size > 0 ? ` (${visible.size})` : ''}
</button> </button>
{open && ( {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 ? ( {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"> <ul className="flex max-h-64 flex-col gap-1 overflow-auto">
{paramNames.map((name) => ( {paramNames.map((name) => (
<li key={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 <input
type="checkbox" type="checkbox"
checked={visible.has(name)} checked={visible.has(name)}
onChange={() => onToggle(name)} onChange={() => onToggle(name)}
className="accent-neutral-400" className="accent-text-muted"
/> />
{name} {name}
</label> </label>
@@ -126,12 +126,12 @@ export function LogPreview({ logs }: { logs: LogEntry[] }) {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <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} /> <ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<table className="w-full border-collapse text-left text-xs"> <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> <tr>
<th className="w-16 px-3 py-1 font-normal">Seq</th> <th className="w-16 px-3 py-1 font-normal">Seq</th>
<th className="w-40 px-3 py-1 font-normal">Timestamp</th> <th className="w-40 px-3 py-1 font-normal">Timestamp</th>
@@ -145,25 +145,25 @@ export function LogPreview({ logs }: { logs: LogEntry[] }) {
</thead> </thead>
<tbody> <tbody>
{logs.map((log) => ( {logs.map((log) => (
<tr key={log.sequence} className="border-t border-neutral-900 hover:bg-neutral-900/60"> <tr key={log.sequence} className="border-t border-border-faint hover:bg-surface-1/60">
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">{log.sequence}</td> <td className="whitespace-nowrap px-3 py-1 text-text-subtle">{log.sequence}</td>
<td className="whitespace-nowrap px-3 py-1 text-neutral-500"> <td className="whitespace-nowrap px-3 py-1 text-text-subtle">
{new Date(log.timestamp_ms).toLocaleTimeString()} {new Date(log.timestamp_ms).toLocaleTimeString()}
</td> </td>
{columns.map((name) => { {columns.map((name) => {
const value = log.params[name] const value = log.params[name]
return ( 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) : '—'} {value ? metricPrimitiveToDisplayString(value) : '—'}
</td> </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> </tr>
))} ))}
{logs.length === 0 && ( {logs.length === 0 && (
<tr> <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. No logs yet — pipe something into ottyl.
</td> </td>
</tr> </tr>
+7 -7
View File
@@ -28,7 +28,7 @@ export function LogTimeline({
const rect = containerRef.current?.getBoundingClientRect() const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return domainStart if (!rect) return domainStart
const ratio = Math.min(1, Math.max(0, (x - rect.left) / rect.width)) 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) { function handleMouseDown(e: React.MouseEvent) {
@@ -57,18 +57,18 @@ export function LogTimeline({
: null : null
return ( return (
<div className="border-b border-neutral-800 p-3"> <div className="border-b border-border p-3">
<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>Timeline</span> <span>Timeline</span>
{range && ( {range && (
<button className="underline hover:text-neutral-300" onClick={() => onSelectRange(null)}> <button className="underline hover:text-text-secondary" onClick={() => onSelectRange(null)}>
Clear selection Clear selection
</button> </button>
)} )}
</div> </div>
<div <div
ref={containerRef} 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} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
@@ -81,7 +81,7 @@ export function LogTimeline({
{buckets.map((b) => ( {buckets.map((b) => (
<div <div
key={b.start_ms} 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}%` }} style={{ height: `${(b.count / maxCount) * 100}%` }}
title={`${new Date(b.start_ms).toLocaleTimeString()} — ${b.count}`} title={`${new Date(b.start_ms).toLocaleTimeString()} — ${b.count}`}
/> />
@@ -89,7 +89,7 @@ export function LogTimeline({
</div> </div>
{selRect && ( {selRect && (
<div <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 }} 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) const groups = groupByLabels(bucketValues)
return ( return (
<div className="border-b border-neutral-800 p-3 text-sm"> <div className="border-b border-border p-3 text-sm">
<div className="mb-2 text-xs text-neutral-500">Metrics</div> <div className="mb-2 text-xs text-text-subtle">Metrics</div>
<div className="flex gap-4"> <div className="flex gap-4">
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2"> <form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
<div> <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 <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} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="requests" placeholder="requests"
@@ -170,9 +170,9 @@ export function Metrics({ range }: { range: TimeRange | null }) {
/> />
</div> </div>
<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 <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} value={regex}
onChange={(e) => setRegex(e.target.value)} onChange={(e) => setRegex(e.target.value)}
placeholder={String.raw`request path=(?P<path>\S+) duration=(?P<duration>\d+)`} 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> <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> <span>Params</span>
<button <button
type="button" type="button"
onClick={addMatcher} 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 + Add
</button> </button>
@@ -194,19 +194,19 @@ export function Metrics({ range }: { range: TimeRange | null }) {
{matchers.map((matcher, i) => ( {matchers.map((matcher, i) => (
<div key={i} className="flex items-center gap-1"> <div key={i} className="flex items-center gap-1">
<input <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} value={matcher.param_name}
onChange={(e) => updateMatcher(i, { param_name: e.target.value })} onChange={(e) => updateMatcher(i, { param_name: e.target.value })}
placeholder="param_name" placeholder="param_name"
/> />
<input <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} value={matcher.capture_group}
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })} onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
placeholder="capture_group" placeholder="capture_group"
/> />
<select <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} value={matcher.kind}
onChange={(e) => updateMatcher(i, { kind: e.target.value as MetricParamMatcher['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" type="button"
onClick={() => removeMatcher(i)} onClick={() => removeMatcher(i)}
disabled={matchers.length === 1} 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> </button>
@@ -226,28 +226,28 @@ export function Metrics({ range }: { range: TimeRange | null }) {
))} ))}
</div> </div>
</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 <button
type="submit" type="submit"
disabled={submitting} 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'} {submitting ? 'Declaring…' : 'Declare metric'}
</button> </button>
</form> </form>
<div className="min-w-0 flex-1"> <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"> <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) => ( {metrics.map((m) => (
<button <button
key={m.name} key={m.name}
onClick={() => setSelected(m.name)} onClick={() => setSelected(m.name)}
className={`rounded border px-2 py-1 text-xs ${ className={`rounded border px-2 py-1 text-xs ${
selected === m.name selected === m.name
? 'border-sky-500 bg-sky-500/10 text-sky-300' ? 'border-accent bg-accent/10 text-accent-text'
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800' : 'border-border-soft text-text-secondary hover:bg-surface-2'
}`} }`}
title={m.message_match_regex} title={m.message_match_regex}
> >
@@ -258,15 +258,15 @@ export function Metrics({ range }: { range: TimeRange | null }) {
{selected && ( {selected && (
<div> <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 {selected} — {bucketValues.length} occurrence{bucketValues.length === 1 ? '' : 's'} in range
</div> </div>
{groups.length === 0 ? ( {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"> <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> <tr>
<th className="px-2 py-1 text-left font-normal">Labels</th> <th className="px-2 py-1 text-left font-normal">Labels</th>
<th className="px-2 py-1 text-right font-normal">Count</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> </thead>
<tbody> <tbody>
{groups.map((group) => ( {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"> <td className="px-2 py-1 font-mono">
{Object.entries(group.labels) {Object.entries(group.labels)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(', ') || '(no labels)'} .join(', ') || '(no labels)'}
</td> </td>
<td className="px-2 py-1 text-right">{group.count}</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> </tr>
))} ))}
</tbody> </tbody>
+90 -50
View File
@@ -2,10 +2,31 @@ import { useState } from 'react'
import type { Stats } from '../lib/api' import type { Stats } from '../lib/api'
import type { ColorScheme } from '../lib/api' import type { ColorScheme } from '../lib/api'
import { useSettingsPanel } from '../lib/useSettingsPanel' 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 }) { export function NavMenu({ stats }: { stats: Stats | null }) {
const [openPanel, setOpenPanel] = useState<PanelName>(null) const [openPanel, setOpenPanel] = useState<PanelName>(null)
@@ -17,16 +38,16 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
return ( return (
<div className="flex h-full"> <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>
<div className="mb-4 text-lg font-semibold text-neutral-100">ottyl</div> <div className="mb-4 text-lg font-semibold text-text">ottyl</div>
<nav className="flex flex-col gap-1 text-sm text-neutral-400"> <nav className="flex flex-col gap-1 text-sm text-text-muted">
<span className="rounded bg-neutral-800 px-2 py-1 text-neutral-100">Dashboard</span> <span className="rounded bg-surface-2 px-2 py-1 text-text">Dashboard</span>
<button <button
type="button" type="button"
onClick={() => togglePanel('presets')} onClick={() => togglePanel('presets')}
className={`rounded px-2 py-1 text-left hover:bg-neutral-800 ${ className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'presets' ? 'bg-neutral-800 text-neutral-100' : '' openPanel === 'presets' ? 'bg-surface-2 text-text' : ''
}`} }`}
> >
Presets Presets
@@ -34,15 +55,24 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
<button <button
type="button" type="button"
onClick={() => togglePanel('settings')} onClick={() => togglePanel('settings')}
className={`rounded px-2 py-1 text-left hover:bg-neutral-800 ${ className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'settings' ? 'bg-neutral-800 text-neutral-100' : '' openPanel === 'settings' ? 'bg-surface-2 text-text' : ''
}`} }`}
> >
Settings Settings
</button> </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> </nav>
</div> </div>
<div className="text-xs text-neutral-500"> <div className="text-xs text-text-subtle">
{stats ? ( {stats ? (
<> <>
<div>{stats.log_count} logs</div> <div>{stats.log_count} logs</div>
@@ -57,26 +87,30 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
</div> </div>
{openPanel && ( {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"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-neutral-100"> <h2 className="text-sm font-semibold text-text">{PANEL_TITLES[openPanel]}</h2>
{openPanel === 'settings' ? 'Settings' : 'Presets'}
</h2>
<button <button
type="button" type="button"
onClick={() => setOpenPanel(null)} 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" aria-label="Close panel"
> >
✕ ✕
</button> </button>
</div> </div>
{panel.error && ( {panel.error && openPanel !== 'docs' && (
<div className="mb-3 rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-300"> <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} {panel.error}
</div> </div>
)} )}
{openPanel === 'settings' ? <SettingsPanel panel={panel} /> : <PresetsPanel panel={panel} />} {openPanel === 'settings' && <SettingsPanel panel={panel} />}
{openPanel === 'presets' && <PresetsPanel panel={panel} />}
{openPanel === 'docs' && <DocsPanel />}
</div> </div>
)} )}
</div> </div>
@@ -103,56 +137,53 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
} }
return ( 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> <section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Colour scheme</h3> <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
<div className="flex gap-1"> <div className="flex flex-wrap gap-1">
{COLOR_SCHEMES.map((scheme) => ( {COLOR_SCHEMES.map((scheme) => (
<button <button
key={scheme} key={scheme}
type="button" type="button"
onClick={() => panel.setColorScheme(scheme)} 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 panel.settings?.color_scheme === scheme
? 'border-neutral-400 bg-neutral-800 text-neutral-100' ? 'border-border-strong bg-surface-2 text-text'
: 'border-neutral-700 text-neutral-400 hover:bg-neutral-800' : 'border-border-soft text-text-muted hover:bg-surface-2'
}`} }`}
> >
{scheme} {scheme}
</button> </button>
))} ))}
</div> </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>
<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"> <form onSubmit={handleImport} className="flex flex-col gap-2">
<input <input
type="file" type="file"
accept=".css,text/css" accept=".css,text/css"
onChange={handleFileChange} 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 <input
type="text" type="text"
placeholder="filename.css" placeholder="filename.css"
value={filename} value={filename}
onChange={(e) => setFilename(e.target.value)} 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 <textarea
placeholder="Paste CSS content here" placeholder="Paste CSS content here"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
rows={4} 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 <button
type="submit" 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 Import
</button> </button>
@@ -160,31 +191,31 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</section> </section>
<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 ? ( {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"> <ul className="flex flex-col gap-1">
{panel.cssEntries.map((entry) => ( {panel.cssEntries.map((entry) => (
<li <li
key={entry.id} 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="min-w-0">
<div className="truncate text-neutral-200">{entry.filename ?? entry.path_or_url}</div> <div className="truncate text-text">{entry.filename ?? entry.path_or_url}</div>
<div className="text-[10px] uppercase text-neutral-600">{entry.kind}</div> <div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
</div> </div>
{entry.kind === 'imported' ? ( {entry.kind === 'imported' ? (
<button <button
type="button" type="button"
onClick={() => panel.removeCssEntry(entry.id)} 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}`} aria-label={`Delete ${entry.filename}`}
> >
delete delete
</button> </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> </span>
)} )}
@@ -208,25 +239,25 @@ function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> })
} }
return ( 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"> <form onSubmit={handleCreate} className="flex gap-2">
<input <input
type="text" type="text"
placeholder="new profile name" placeholder="new profile name"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} 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 <button
type="submit" 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 Create
</button> </button>
</form> </form>
{panel.profiles.length === 0 ? ( {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"> <ul className="flex flex-col gap-1">
{panel.profiles.map((profile) => { {panel.profiles.map((profile) => {
@@ -235,7 +266,7 @@ function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> })
<li <li
key={profile.name} key={profile.name}
className={`rounded border px-2 py-1.5 text-xs ${ 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"> <div className="flex items-center justify-between">
@@ -244,23 +275,29 @@ function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> })
onClick={() => !isActive && panel.switchProfile(profile.name)} onClick={() => !isActive && panel.switchProfile(profile.name)}
disabled={isActive} disabled={isActive}
className={`truncate text-left font-medium ${ 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}`} title={isActive ? 'Active profile' : `Switch to ${profile.name}`}
> >
{profile.name} {profile.name}
{isActive && <span className="ml-1 text-[10px] uppercase text-neutral-400">active</span>} {isActive && <span className="ml-1 text-[10px] uppercase text-text-muted">active</span>}
</button> </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 <button
type="button" type="button"
onClick={() => panel.removeProfile(profile.name)} 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" 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}`} aria-label={`Delete ${profile.name}`}
> >
delete delete
</button> </button>
)}
</div> </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 {profile.param_count} params · {profile.metric_count} metrics · {profile.dashboard_count} dashboards
</div> </div>
</li> </li>
@@ -268,7 +305,10 @@ function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> })
})} })}
</ul> </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. Switching profiles hot-swaps the running engine's declared params/metrics immediately — no restart needed.
</p> </p>
</div> </div>
+12 -12
View File
@@ -77,7 +77,7 @@ function sumValue(series: PromqlSeries): number {
export function PanelChart({ series, chartType }: { series: PromqlSeries[]; chartType: ChartType }) { export function PanelChart({ series, chartType }: { series: PromqlSeries[]; chartType: ChartType }) {
if (series.length === 0) { 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') { if (chartType === 'line') {
@@ -85,17 +85,17 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
return ( return (
<ResponsiveContainer width="100%" height={220}> <ResponsiveContainer width="100%" height={220}>
<LineChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}> <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 <XAxis
dataKey="timestamp_ms" dataKey="timestamp_ms"
tickFormatter={formatTimestamp} tickFormatter={formatTimestamp}
stroke="#737373" stroke="var(--c-text-subtle)"
tick={{ fontSize: 11, fill: '#a3a3a3' }} 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 <Tooltip
labelFormatter={(ts) => formatTimestamp(Number(ts))} 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 }} /> <Legend wrapperStyle={{ fontSize: 11 }} />
{keys.map((key, i) => ( {keys.map((key, i) => (
@@ -119,10 +119,10 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
return ( return (
<ResponsiveContainer width="100%" height={220}> <ResponsiveContainer width="100%" height={220}>
<BarChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}> <BarChart 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="name" stroke="#737373" tick={{ fontSize: 11, fill: '#a3a3a3' }} /> <XAxis dataKey="name" 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 contentStyle={{ background: '#171717', border: '1px solid #404040', fontSize: 12 }} /> <Tooltip contentStyle={{ background: 'var(--c-surface-1)', border: '1px solid var(--c-border-soft)', fontSize: 12 }} />
<Bar dataKey="value" isAnimationActive={false}> <Bar dataKey="value" isAnimationActive={false}>
{rows.map((_, i) => ( {rows.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} /> <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 rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
const total = rows.reduce((sum, r) => sum + r.value, 0) const total = rows.reduce((sum, r) => sum + r.value, 0)
if (total === 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 ( return (
<ResponsiveContainer width="100%" height={220}> <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) const num = typeof value === 'number' ? value : Number(value ?? 0)
return `${num} (${((num / total) * 100).toFixed(1)}%)` 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 }} /> <Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart> </PieChart>
+154 -15
View File
@@ -1,7 +1,160 @@
@import "tailwindcss"; @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 { :root {
color-scheme: light dark; 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, html,
@@ -12,20 +165,6 @@ body,
} }
body { body {
@apply bg-neutral-950 text-neutral-200; @apply bg-surface-0 text-text;
font: 14px/1.5 ui-monospace, Consolas, monospace; 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
View File
@@ -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 { export interface Settings {
color_scheme: ColorScheme color_scheme: ColorScheme
+10 -8
View File
@@ -19,20 +19,22 @@ import {
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */ /** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
const INJECTED_CSS_ATTR = 'data-ottyl-css-id' 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 * Resolves `colorScheme` to an effective theme (following the OS preference
* OS preference for `system`) and reflects it onto `<html>` as both a class * for `system`, which only ever resolves to `light`/`dark`) and reflects it
* (`light`/`dark`) and a `data-color-scheme` attribute. The app's Tailwind * onto `<html>` as both a class (see `THEME_CLASSES`) and a
* classes are mostly hardcoded to a dark palette (see `index.css`) rather * `data-color-scheme` attribute. Every visual role the app uses is a CSS
* than using `dark:` variants, so "Light" mode is necessarily limited to * custom property keyed off that class (see `index.css`), so switching it
* whatever `index.css` explicitly overrides for `.light` — this is a known, * re-skins the whole app, not just the base page background.
* documented limitation rather than a full theme system.
*/ */
function applyColorScheme(colorScheme: ColorScheme) { function applyColorScheme(colorScheme: ColorScheme) {
const root = document.documentElement const root = document.documentElement
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
root.classList.remove('light', 'dark') root.classList.remove(...THEME_CLASSES)
root.classList.add(effective) root.classList.add(effective)
root.dataset.colorScheme = colorScheme root.dataset.colorScheme = colorScheme
} }