Docs, ticks, working app
This commit is contained in:
+3
-1
@@ -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
@@ -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
@@ -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", ¶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]
|
#[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();
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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).
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
|
||||||
@@ -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).
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/// One accordion section: a title plus its body, rendered as plain JSX so
|
||||||
|
/// each page can mix prose, lists, and inline code without pulling in a
|
||||||
|
/// markdown renderer for what is currently five static pages.
|
||||||
|
interface DocSection {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
body: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function Code({ children }: { children: string }) {
|
||||||
|
return <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[11px] text-text">{children}</code>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: DocSection[] = [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
title: 'General idea',
|
||||||
|
body: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>
|
||||||
|
ottyl turns a CLI program's stdout into a Grafana-like dashboard: pipe a program's output into it, and it
|
||||||
|
indexes each line in memory as it arrives, then serves a webapp dashboard over the same process.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Code>{'cli_command --verbose | ottyl'}</Code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Everything — ingested log lines, declared params, declared metrics, and derived buckets — lives in one
|
||||||
|
in-memory index for the life of the process. Nothing is written to disk unless you explicitly save a{' '}
|
||||||
|
<em>profile</em> or change a <em>setting</em>.
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-text">CLI arguments</p>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
<li>
|
||||||
|
<Code>--port</Code> — port to serve the API and webapp on (default <Code>8420</Code>).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Code>--static-dir</Code> — directory containing the built webapp (default <Code>webapp/dist</Code>).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Code>--settings-path</Code> — path to the settings file; defaults to a per-OS config location (e.g.{' '}
|
||||||
|
<Code>~/.config/ottyl/settings.json</Code> on Linux).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="font-semibold text-text">Settings</p>
|
||||||
|
<p>
|
||||||
|
There is exactly one <em>settings</em> file per machine, holding the colour scheme, any imported/linked CSS,
|
||||||
|
and which profile is active. Settings "owns" profiles in that sense — it only tracks pointers (known
|
||||||
|
profile names, which one is active); the profiles themselves (params, metrics, dashboards) are stored
|
||||||
|
separately, next to the settings file by default.
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-text">Profiles</p>
|
||||||
|
<p>
|
||||||
|
A profile is a named bundle of parsing/indexing rules for a particular CLI app — which params and metrics
|
||||||
|
to derive from its log output, plus any saved dashboards — so restarting ottyl against the same program
|
||||||
|
doesn't lose your setup. There is always a <Code>Default</Code> profile, and it can't be deleted. Creating
|
||||||
|
a new profile copies the currently active one (or starts empty if none is active), so variations build on
|
||||||
|
what you already have rather than starting from scratch. Switching profiles hot-swaps the running engine's
|
||||||
|
declared params/metrics immediately, with no restart needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'filtering',
|
||||||
|
title: 'Filtering',
|
||||||
|
body: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>
|
||||||
|
The filter bar above the timeline takes a syntax-aware query combining regex search over raw log text with
|
||||||
|
comparisons against declared metrics, joined by <Code>and</Code>/<Code>or</Code>:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Code>{'regex("error") and sequence<5 or timestamp>12:00'}</Code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Code>{'metric_name[param]<5'}</Code> compares a metric's captured param value against a number, string, or
|
||||||
|
timestamp. <Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names — every ingested log
|
||||||
|
line has both, so they're always available to filter on without declaring anything.
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-text">Date/time formats</p>
|
||||||
|
<p>Timestamp comparisons accept several forms:</p>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
<li>
|
||||||
|
relative, humantime-style offsets from now — <Code>5h</Code>, <Code>-3m</Code>
|
||||||
|
</li>
|
||||||
|
<li>absolute clock times — e.g. 12:00</li>
|
||||||
|
<li>full dates</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
The same filter string is sent to both <Code>GET /api/logs</Code> and <Code>GET /api/timeline</Code> as the{' '}
|
||||||
|
<Code>filter</Code> query param and evaluated on the server; a parse error comes back from the server and
|
||||||
|
surfaces as inline text under the filter bar rather than crashing the page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'parameters',
|
||||||
|
title: 'Parameters',
|
||||||
|
body: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>
|
||||||
|
A parameter declaration tells ottyl how to derive a named value from a raw log line: a regex that must
|
||||||
|
match the message, plus a capture group within it, plus a type (<Code>string</Code>, <Code>number</Code>,
|
||||||
|
or <Code>timestamp</Code>). Once declared, every already-ingested line gets retroactively indexed for that
|
||||||
|
param, and every new line is checked against it as it arrives.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Params are the building block metrics are made of (a metric declaration is really a message-match regex
|
||||||
|
plus a set of param matchers), and they're also what drives filtering and the log preview's column picker —
|
||||||
|
declaring a <Code>duration</Code> param, for instance, makes <Code>duration</Code> available as a column in
|
||||||
|
the log table and as a comparable value in the filter bar.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Declaring a param independent of a metric is supported by the API (<Code>POST /api/params</Code>), used
|
||||||
|
today via the Metrics panel's declaration form and read back by the log preview's column picker; there
|
||||||
|
isn't yet a standalone "declare a bare param" form in the UI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'metrics',
|
||||||
|
title: 'Metrics',
|
||||||
|
body: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>
|
||||||
|
A metric declaration bundles a message-match regex with one or more named param matchers (each with its own
|
||||||
|
capture group and type). Every log line matching the regex produces a <em>bucket value</em>: a timestamped
|
||||||
|
occurrence, tagged with the labels captured from that line.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Declaring a metric backfills it against everything already ingested, then continues indexing new lines as
|
||||||
|
they arrive — there's no need to restart ingestion or re-declare after the fact.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The Metrics panel lets you declare a metric, pick one from the declared list, and see its bucket values
|
||||||
|
grouped by label combination over the active time range — occurrence count per group, plus a small trend
|
||||||
|
sparkline. That same data is what dashboard panels query via PromQL (see below).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dashboards',
|
||||||
|
title: 'Dashboards',
|
||||||
|
body: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>
|
||||||
|
Dashboards work like Grafana: each one is a named set of panels, and each panel is a PromQL query paired
|
||||||
|
with a chart type — bar, line, or pie. Panels preview live as you edit the query, and a malformed query
|
||||||
|
shows an inline error instead of breaking the page.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">Line</span> — one series per label combination, time on the
|
||||||
|
x-axis.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "where
|
||||||
|
things are now").
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">Pie</span> — each series' share of the summed total across the
|
||||||
|
active range.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Dashboards are saved per profile, so they travel with a profile when you switch or copy it, and require an
|
||||||
|
active profile to create or save — pick or create one from the settings menu first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function DocsPanel() {
|
||||||
|
const [openId, setOpenId] = useState<string | null>(SECTIONS[0].id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 text-sm text-text-secondary">
|
||||||
|
{SECTIONS.map((section) => {
|
||||||
|
const isOpen = openId === section.id
|
||||||
|
return (
|
||||||
|
<div key={section.id} className="rounded border border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenId(isOpen ? null : section.id)}
|
||||||
|
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-semibold text-text hover:bg-surface-2"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
<span className="text-text-muted">{isOpen ? '−' : '+'}</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && <div className="border-t border-border px-3 py-3">{section.body}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export function FilterBar({
|
|||||||
|
|
||||||
return (
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
type="button"
|
|
||||||
onClick={() => panel.removeProfile(profile.name)}
|
|
||||||
className="ml-2 shrink-0 rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-red-400"
|
|
||||||
aria-label={`Delete ${profile.name}`}
|
|
||||||
>
|
|
||||||
delete
|
|
||||||
</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
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.removeProfile(profile.name)}
|
||||||
|
className="ml-2 shrink-0 rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
||||||
|
aria-label={`Delete ${profile.name}`}
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user