From 51e577f01e5de10477958fb658fcf22fd365af4a Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 15:05:39 +0100 Subject: [PATCH] Docs, ticks, working app --- src/api.rs | 4 +- src/main.rs | 12 +- src/profile.rs | 125 ++++++++++++- src/settings.rs | 7 + tickets/001-param-declarations.md | 35 ---- tickets/002-metric-declarations.md | 35 ---- tickets/003-metric-bucket-derivation.md | 37 ---- tickets/004-metrics-api.md | 31 ---- tickets/005-filterbar-pest-parser.md | 37 ---- tickets/006-filterbar-datetime-parsing.md | 30 ---- tickets/007-filterbar-integration.md | 36 ---- tickets/008-settings-model.md | 33 ---- tickets/009-presets-profiles.md | 34 ---- tickets/010-css-management.md | 30 ---- tickets/011-navmenu-ui.md | 30 ---- tickets/012-metrics-ui.md | 27 --- tickets/013-dashboards-promql.md | 30 ---- tickets/014-dashboards-ui.md | 30 ---- tickets/015-logpreview-param-columns.md | 28 --- tickets/016-filterbar-ui-integration.md | 26 --- tickets/019-default-port-zero.md | 29 +++ tickets/020-file-input-ingestion.md | 39 +++++ tickets/021-docs-contextual-help.md | 35 ++++ tickets/022-metrics-counters-rethink.md | 53 ++++++ tickets/023-duration-param.md | 37 ++++ tickets/024-time-range-controls.md | 41 +++++ tickets/025-default-dashboard-seed.md | 39 +++++ tickets/026-max-logs-in-memory.md | 34 ++++ tickets/027-dashboards-autosave.md | 33 ++++ webapp/src/components/ComingSoon.tsx | 2 +- webapp/src/components/Dashboards.tsx | 46 ++--- webapp/src/components/Docs.tsx | 204 ++++++++++++++++++++++ webapp/src/components/FilterBar.tsx | 8 +- webapp/src/components/LogPreview.tsx | 26 +-- webapp/src/components/LogTimeline.tsx | 14 +- webapp/src/components/Metrics.tsx | 48 ++--- webapp/src/components/NavMenu.tsx | 154 ++++++++++------ webapp/src/components/PanelChart.tsx | 24 +-- webapp/src/index.css | 169 ++++++++++++++++-- webapp/src/lib/api.ts | 12 +- webapp/src/lib/useSettingsPanel.ts | 18 +- 41 files changed, 1039 insertions(+), 683 deletions(-) delete mode 100644 tickets/001-param-declarations.md delete mode 100644 tickets/002-metric-declarations.md delete mode 100644 tickets/003-metric-bucket-derivation.md delete mode 100644 tickets/004-metrics-api.md delete mode 100644 tickets/005-filterbar-pest-parser.md delete mode 100644 tickets/006-filterbar-datetime-parsing.md delete mode 100644 tickets/007-filterbar-integration.md delete mode 100644 tickets/008-settings-model.md delete mode 100644 tickets/009-presets-profiles.md delete mode 100644 tickets/010-css-management.md delete mode 100644 tickets/011-navmenu-ui.md delete mode 100644 tickets/012-metrics-ui.md delete mode 100644 tickets/013-dashboards-promql.md delete mode 100644 tickets/014-dashboards-ui.md delete mode 100644 tickets/015-logpreview-param-columns.md delete mode 100644 tickets/016-filterbar-ui-integration.md create mode 100644 tickets/019-default-port-zero.md create mode 100644 tickets/020-file-input-ingestion.md create mode 100644 tickets/021-docs-contextual-help.md create mode 100644 tickets/022-metrics-counters-rethink.md create mode 100644 tickets/023-duration-param.md create mode 100644 tickets/024-time-range-controls.md create mode 100644 tickets/025-default-dashboard-seed.md create mode 100644 tickets/026-max-logs-in-memory.md create mode 100644 tickets/027-dashboards-autosave.md create mode 100644 webapp/src/components/Docs.tsx diff --git a/src/api.rs b/src/api.rs index d534c9b..1b38bab 100644 --- a/src/api.rs +++ b/src/api.rs @@ -462,11 +462,13 @@ async fn update_settings( } /// 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 { match err { ProfileError::NotFound => (StatusCode::NOT_FOUND, 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(), } } diff --git a/src/main.rs b/src/main.rs index 51f9036..4fd644b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,11 +61,13 @@ async fn main() { // could silently miss lines that raced ahead of the declarations). { let (active_profile_name, profiles_dir) = { - let settings_guard = settings.lock().unwrap(); - ( - settings_guard.profiles.active_profile.clone(), - profile::resolve_profiles_dir(&settings_guard, &settings_path), - ) + let mut settings_guard = settings.lock().unwrap(); + let profiles_dir = profile::resolve_profiles_dir(&settings_guard, &settings_path); + let mut store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir)); + 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 { let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir)); diff --git a/src/profile.rs b/src/profile.rs index 13a6dca..62095d9 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -45,6 +45,10 @@ use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, Eng use crate::settings::Settings; 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 /// profiles can round-trip through JSON. `MetricPrimitiveKind` itself only /// derives `Serialize` (it's an engine-internal type owned by tickets @@ -311,6 +315,9 @@ pub enum ProfileError { NotFound, /// A profile with the given name already exists. 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. Io(io::Error), } @@ -320,6 +327,7 @@ impl fmt::Display for ProfileError { match self { ProfileError::NotFound => write!(f, "no profile with that name"), 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}"), } } @@ -411,11 +419,16 @@ impl ProfileStore { 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` /// (persisting `settings` to `settings_path` too, keeping the two files in /// sync). Fails with `ProfileError::DuplicateName` if a profile with that /// 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( &mut self, store_path: &Path, @@ -426,7 +439,13 @@ impl ProfileStore { if self.get(&name).is_some() { 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)?; if !settings.profiles.names.contains(&name) { @@ -440,7 +459,9 @@ impl ProfileStore { /// and drops it from `settings.profiles.names` (persisting `settings` to /// `settings_path` too). If the deleted profile was active, clears /// `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( &mut self, store_path: &Path, @@ -448,6 +469,9 @@ impl ProfileStore { settings_path: &Path, name: &str, ) -> 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)?; self.profiles.remove(index); self.save(store_path)?; @@ -460,6 +484,35 @@ impl ProfileStore { 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 /// `settings.profiles.active_profile` and persists `settings` to /// `settings_path`. Fails with `ProfileError::NotFound` if `name` is @@ -718,6 +771,72 @@ mod tests { 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\w+)", "level"); + store.record_param_declaration(&store_path, "web", ¶m_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] fn set_active_updates_and_persists_settings() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/settings.rs b/src/settings.rs index eae107e..b0c28f6 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -22,6 +22,13 @@ use serde::{Deserialize, Serialize}; pub enum ColorScheme { Light, Dark, + Grey, + Red, + Green, + Blue, + Yellow, + Pink, + Orange, #[default] System, } diff --git a/tickets/001-param-declarations.md b/tickets/001-param-declarations.md deleted file mode 100644 index 69d040e..0000000 --- a/tickets/001-param-declarations.md +++ /dev/null @@ -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. diff --git a/tickets/002-metric-declarations.md b/tickets/002-metric-declarations.md deleted file mode 100644 index c18e744..0000000 --- a/tickets/002-metric-declarations.md +++ /dev/null @@ -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`). -- 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). diff --git a/tickets/003-metric-bucket-derivation.md b/tickets/003-metric-bucket-derivation.md deleted file mode 100644 index d267584..0000000 --- a/tickets/003-metric-bucket-derivation.md +++ /dev/null @@ -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`) 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) diff --git a/tickets/004-metrics-api.md b/tickets/004-metrics-api.md deleted file mode 100644 index 11c9d6c..0000000 --- a/tickets/004-metrics-api.md +++ /dev/null @@ -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) diff --git a/tickets/005-filterbar-pest-parser.md b/tickets/005-filterbar-pest-parser.md deleted file mode 100644 index 3374abd..0000000 --- a/tickets/005-filterbar-pest-parser.md +++ /dev/null @@ -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] 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. diff --git a/tickets/006-filterbar-datetime-parsing.md b/tickets/006-filterbar-datetime-parsing.md deleted file mode 100644 index 8fe54f6..0000000 --- a/tickets/006-filterbar-datetime-parsing.md +++ /dev/null @@ -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). diff --git a/tickets/007-filterbar-integration.md b/tickets/007-filterbar-integration.md deleted file mode 100644 index 08b2382..0000000 --- a/tickets/007-filterbar-integration.md +++ /dev/null @@ -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] 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) diff --git a/tickets/008-settings-model.md b/tickets/008-settings-model.md deleted file mode 100644 index 0d0d20d..0000000 --- a/tickets/008-settings-model.md +++ /dev/null @@ -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. diff --git a/tickets/009-presets-profiles.md b/tickets/009-presets-profiles.md deleted file mode 100644 index 878c134..0000000 --- a/tickets/009-presets-profiles.md +++ /dev/null @@ -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) diff --git a/tickets/010-css-management.md b/tickets/010-css-management.md deleted file mode 100644 index b7ba135..0000000 --- a/tickets/010-css-management.md +++ /dev/null @@ -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 ``/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) diff --git a/tickets/011-navmenu-ui.md b/tickets/011-navmenu-ui.md deleted file mode 100644 index ec817c4..0000000 --- a/tickets/011-navmenu-ui.md +++ /dev/null @@ -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) diff --git a/tickets/012-metrics-ui.md b/tickets/012-metrics-ui.md deleted file mode 100644 index e9f3026..0000000 --- a/tickets/012-metrics-ui.md +++ /dev/null @@ -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) diff --git a/tickets/013-dashboards-promql.md b/tickets/013-dashboards-promql.md deleted file mode 100644 index bdea581..0000000 --- a/tickets/013-dashboards-promql.md +++ /dev/null @@ -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) diff --git a/tickets/014-dashboards-ui.md b/tickets/014-dashboards-ui.md deleted file mode 100644 index 45b7a43..0000000 --- a/tickets/014-dashboards-ui.md +++ /dev/null @@ -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) diff --git a/tickets/015-logpreview-param-columns.md b/tickets/015-logpreview-param-columns.md deleted file mode 100644 index b15da6f..0000000 --- a/tickets/015-logpreview-param-columns.md +++ /dev/null @@ -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) diff --git a/tickets/016-filterbar-ui-integration.md b/tickets/016-filterbar-ui-integration.md deleted file mode 100644 index c96f6b5..0000000 --- a/tickets/016-filterbar-ui-integration.md +++ /dev/null @@ -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) diff --git a/tickets/019-default-port-zero.md b/tickets/019-default-port-zero.md new file mode 100644 index 0000000..1f0bee9 --- /dev/null +++ b/tickets/019-default-port-zero.md @@ -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 ` still binds the requested fixed port as before. + +## Depends on + +None. diff --git a/tickets/020-file-input-ingestion.md b/tickets/020-file-input-ingestion.md new file mode 100644 index 0000000..3da2d02 --- /dev/null +++ b/tickets/020-file-input-ingestion.md @@ -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 `), 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. diff --git a/tickets/021-docs-contextual-help.md b/tickets/021-docs-contextual-help.md new file mode 100644 index 0000000..a9d6ec9 --- /dev/null +++ b/tickets/021-docs-contextual-help.md @@ -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. diff --git a/tickets/022-metrics-counters-rethink.md b/tickets/022-metrics-counters-rethink.md new file mode 100644 index 0000000..38cfcb1 --- /dev/null +++ b/tickets/022-metrics-counters-rethink.md @@ -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. diff --git a/tickets/023-duration-param.md b/tickets/023-duration-param.md new file mode 100644 index 0000000..06b3fa3 --- /dev/null +++ b/tickets/023-duration-param.md @@ -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). diff --git a/tickets/024-time-range-controls.md b/tickets/024-time-range-controls.md new file mode 100644 index 0000000..899ca66 --- /dev/null +++ b/tickets/024-time-range-controls.md @@ -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. diff --git a/tickets/025-default-dashboard-seed.md b/tickets/025-default-dashboard-seed.md new file mode 100644 index 0000000..df141a1 --- /dev/null +++ b/tickets/025-default-dashboard-seed.md @@ -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). diff --git a/tickets/026-max-logs-in-memory.md b/tickets/026-max-logs-in-memory.md new file mode 100644 index 0000000..95a5059 --- /dev/null +++ b/tickets/026-max-logs-in-memory.md @@ -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`), `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. diff --git a/tickets/027-dashboards-autosave.md b/tickets/027-dashboards-autosave.md new file mode 100644 index 0000000..0d7c054 --- /dev/null +++ b/tickets/027-dashboards-autosave.md @@ -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. diff --git a/webapp/src/components/ComingSoon.tsx b/webapp/src/components/ComingSoon.tsx index 694a738..0ac4071 100644 --- a/webapp/src/components/ComingSoon.tsx +++ b/webapp/src/components/ComingSoon.tsx @@ -1,6 +1,6 @@ export function ComingSoon({ title }: { title: string }) { return ( -
+
{title} — coming soon
) diff --git a/webapp/src/components/Dashboards.tsx b/webapp/src/components/Dashboards.tsx index 55c43d3..5d41c8a 100644 --- a/webapp/src/components/Dashboards.tsx +++ b/webapp/src/components/Dashboards.tsx @@ -61,10 +61,10 @@ function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null }, [panel.promql, panel.chart_type, range?.startMs, range?.endMs]) if (error) { - return
{error}
+ return
{error}
} if (loading && series.length === 0) { - return
Loading…
+ return
Loading…
} return } @@ -168,43 +168,43 @@ export function Dashboards({ range }: { range: TimeRange | null }) { if (!activeProfile) { return ( -
+
Dashboards — activate a profile (see the settings menu) to create and save dashboards.
) } return ( -
-
Dashboards — profile "{activeProfile}"
+
+
Dashboards — profile "{activeProfile}"
{error && ( -
{error}
+
{error}
)}
setNewDashboardName(e.target.value)} placeholder="New dashboard name" />
- {dashboards.length === 0 &&
No dashboards saved yet.
} + {dashboards.length === 0 &&
No dashboards saved yet.
} {dashboards.map((d) => (
@@ -246,7 +246,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) { type="button" onClick={handleSaveDraft} disabled={saving} - className="rounded border border-sky-700 bg-sky-500/10 px-2 py-1 text-xs text-sky-300 hover:bg-sky-500/20 disabled:opacity-50" + className="rounded border border-accent-soft bg-accent/10 px-2 py-1 text-xs text-accent-text hover:bg-accent/20 disabled:opacity-50" > {saving ? 'Saving…' : 'Save dashboard'} @@ -254,27 +254,27 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
{draft.panels.length === 0 && ( -
No panels yet — add one above.
+
No panels yet — add one above.
)}
{draft.panels.map((panel) => ( -
+
updatePanel(panel.id, { title: e.target.value })} placeholder="Panel title" /> updatePanel(panel.id, { promql: e.target.value })} placeholder={String.raw`requests{path="/a"}`} /> setDraft(e.target.value)} />
- {error &&

{error}

} + {error &&

{error}

} ) } diff --git a/webapp/src/components/LogPreview.tsx b/webapp/src/components/LogPreview.tsx index 1554c1d..439646f 100644 --- a/webapp/src/components/LogPreview.tsx +++ b/webapp/src/components/LogPreview.tsx @@ -57,24 +57,24 @@ function ColumnPicker({ {open && ( -
+
{paramNames.length === 0 ? ( -
No declared params yet.
+
No declared params yet.
) : (
    {paramNames.map((name) => (
  • -