Commit Graph
22 Commits
Author SHA1 Message Date
Hugh Kaznowski 0b6442a348 Merge ticket 045: timeline/log ordering by declared param 2026-09-06 02:03:53 +01:00
Hugh KaznowskiandClaude Sonnet 5 7a38d008fd Merge ticket 049 (inline profiles into settings.json) with auto-derived-param promotion
worktree-agent-a19b9ef959dcda6b2 (ticket 049) was branched from before "Auto
derived format" (1a481d3) landed on main, so its synchronous
persist_metric_declaration(_update) and ProfileStore::record_metric_declaration(_update)
didn't know about promoting auto-derived engine params into the persisted
profile. This merge reconciles the two:

- src/api.rs: kept the async persist_metric_declaration/_update methods and
  promoted_param_records helper from "Auto derived format", but switched their
  persistence from ProfileStore::load/record_metric_declaration to the new
  Settings-based settings.record_metric_declaration(_update), matching ticket
  049's single-settings.json model. Dropped the now-unused
  active_profile_location helper in favor of locking self.settings directly.
- src/profile.rs: removed the standalone ProfileStore struct/impl entirely
  (049 folds profiles into Settings), keeping load_legacy_profile_store for
  migrating a pre-049 profiles.json. Extended record_metric_declaration and
  record_metric_declaration_update (now methods on Settings) to keep taking
  promoted_params: &[ParamDeclarationRecord], persisting promoted auto-derived
  params alongside the metric via Settings::save. Moved the ticket 037/038
  legacy-shape migration (migrate_legacy_metric_params /
  migrate_duration_value_params) out of the removed ProfileStore::load into a
  new pub(crate) migrate_legacy_profile_shapes(profiles), invoked from
  Settings::load (via a new Settings::migrate_legacy_profile_shapes) so
  profiles inlined in settings.json still get migrated forward on every load.
  Rewrote the ProfileStore-based tests (legacy-shape migration tests, the
  no-op-migration test, and the auto-derived-param promotion round-trip test)
  to exercise Settings directly, including a write_legacy_settings_json helper
  that replaces the old write_legacy_profiles_json.
- src/api.rs tests: rewrote declaring_metric_against_auto_derived_param_promotes_it_into_the_profile
  to create/activate the profile via settings_handle.lock() and reload via
  Settings::load instead of ProfileStore.

Also fixed one clippy::collapsible_if warning introduced by the new
Settings::migrate_legacy_profile_shapes.

cargo build, cargo test (222 passed, 0 failed, 0 ignored), and
cargo clippy --all-targets all pass; no new clippy warnings beyond
pre-existing ones in files untouched by this merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-06 02:03:22 +01:00
Hugh KaznowskiandClaude Sonnet 5 3d2652e95b Add sort_by ordering for the log viewer, with a missing-param fallback/drop toggle (ticket 045)
/api/logs now accepts sort_by (an already-declared param name) and
sort_missing_fallback: lines carrying that param are ordered by its value,
while lines missing it are either appended in ottyl_seq order (fallback,
the default) or dropped from the response. Default ottyl_ts-tracking
order is unchanged when sort_by is omitted.

The log viewer (both the inline panel and its popout) exposes this as a
"Sort by" control next to the column picker, persisted to localStorage
under one shared key (ottyl.logPreview.sort) — the same global-viewer-
setting convention already used for visible param columns and the log
viewer's height, since there's no per-dashboard scope for this to divide
by.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-06 01:58:30 +01:00
Hugh KaznowskiandClaude Sonnet 5 e8baf2b004 Inline profile storage into settings.json (ticket 049)
Fold ProfileStore's separate profiles.json into Settings itself, so
settings.json is the single source of truth instead of two files kept
in sync by hand. Settings now carries `profiles: Vec<Profile>` and
`active_profile: Option<String>` directly; every former ProfileStore
mutation (create, delete, set_active, record_param_declaration,
record_metric_declaration, dashboard CRUD, ensure_default) is now a
method on Settings that persists via Settings::save.

A custom Deserialize (via SettingsOnDisk) keeps an old settings.json
(with the removed Profiles pointer struct) loading cleanly, and
Settings::load runs a one-time migration: if profiles is empty and a
legacy <settings_dir>/profiles/profiles.json exists, its contents are
merged in, settings.json is resaved, and the old file is renamed so it
isn't re-imported.

Updated all call sites in api.rs and main.rs to the single-path API,
and ported profile.rs's ProfileStore tests to exercise Settings
directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-06 01:54:04 +01:00
Hugh Kaznowski 1a481d374b Auto derived format 2026-09-06 01:20:17 +01:00
Hugh KaznowskiandClaude Sonnet 5 5e30e02a78 Make elapsed-since-last an explicit param extractor, not a Duration-kind special case (ticket 038)
Previously a Value-role param with kind: Duration silently meant "elapsed
time since the previous same-labeled event" instead of parsed text — the
same MetricPrimitiveKind::Duration variant meant two unrelated things
depending on which list (label_params vs value_param) referenced it.

Add ParamDeclaration::elapsed_since_last, a third extraction source
alongside capture_group/json_field_path: when set, the param is computed
(via a new resolve_param_value, used uniformly for label_params and
value_param) rather than text-extracted, and declare_param validates it's
paired with kind: Duration. apply_metric_to_log no longer branches on
(role, kind) — it dispatches purely on what each resolved param declares.

Migrate existing profiles: migrate_duration_value_params converts any
Duration-kind param referenced as a metric's value_param into the new
extractor, and resolve_or_create_legacy_param (ticket 037's pre-existing
migration) now carries the same meaning forward for pre-037 inline
Value-role Duration matchers.

Params.tsx offers "Elapsed since previous event" as its own Extraction
option (hides the now-irrelevant message-match-regex/capture-group
fields and forces kind to duration); Metrics.tsx labels such params
accordingly wherever they're listed as label/value param choices.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 23:01:32 +01:00
Hugh KaznowskiandClaude Sonnet 5 e397f9fbdb Unify param declarations: metrics reference params by name (ticket 037)
Collapse the Params-panel ParamDeclaration and the metric-embedded
RegexToMetricPrimitiveMatcher into a single ParamDeclaration type
(name, regex/capture-group-or-json-field-path, kind). MetricDeclaration
now references params by name via label_params: Vec<String> and
value_param: Option<String> instead of embedding its own extractor per
param, so a param declared once in the Params panel can be reused as a
label or value by any number of metrics. Declaring a metric that
references an undeclared param name is now rejected at declare-time.

Backend:
- src/data_value.rs: unified ParamDeclaration struct; MetricDeclaration
  data value now carries label_params/value_param instead of a Vec of
  inline matchers.
- src/engine.rs: DeclareParamRequest is now an alias for
  ParamDeclaration; DeclareMetricRequest carries label_params/
  value_param; validate_metric_request checks referenced param names
  against the declared-param store; apply_metric_to_log resolves
  label_params/value_param against a per-ingest resolved-param map
  instead of reading an inline matcher list.
- src/profile.rs: MetricDeclarationRecord carries label_params/
  value_param plus a private, serialize-skipped legacy_params field
  that only ever gets populated by deserializing a pre-037 profile
  file's old "params" key. ProfileStore::load migrates any such legacy
  metric into the unified shape (reusing an existing same-shaped bare
  param by name, or creating one implicitly named
  "<metric_name>.<param_name>"), then persists the migrated store so
  the on-disk file only carries the legacy shape once. Migration is
  idempotent (deterministic naming) and covered by dedicated tests.
- src/api.rs / src/dto.rs: MetricDeclarationDto and the declare/update
  metric response bodies updated to the new shape.

Frontend:
- webapp/src/lib/api.ts: MetricDeclaration type now has label_params/
  value_param; MetricParamMatcher/ParamRole types removed.
- webapp/src/components/Metrics.tsx: the declaration form now fetches
  declared params and lets you check off label params / pick a value
  param from that list, instead of re-entering a regex/capture-group/
  kind per metric param.

Leaves ticket 038 (duration as a derived param kind) and 039 (param
query resolving declared params) untouched, as scoped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 22:41:14 +01:00
Hugh KaznowskiandClaude Sonnet 5 5aa7c1bae9 Fix regex help icon click-toggle and add PromQL by()/sum(rate()) grouping
RegexHelpIcon previously used a single `open` boolean driven by both hover
and click, so clicking to pin the popover open was undone by the next
mouseleave. Split into separate `pinned` (click) and `hovering` (mouse-over)
states so a click-toggle actually persists until clicked again.

The seeded `log_category` dashboard's line panel plotted the bare metric
name, since `sum()`/`rate()` always collapsed all label combinations into
one series and PromQL syntax had no way to preserve a label dimension
through an aggregation. Added `by (label, ...)` grouping and nested
`sum(rate(metric[range]))`/`avg(rate(...))` parsing, and made `rate()`
compute a real windowed time series instead of a single trailing point, so
a query like `sum(rate(log_category[1m])) by (category)` now renders one
line per category.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 20:17:50 +01:00
Hugh Kaznowski f60e0e208b Merge ticket/022-edit-existing-metric-declarations 2026-09-05 19:41:59 +01:00
Hugh KaznowskiandClaude Sonnet 5 47586bf31a Merge ticket/015-json-field-param-metric-declaration
Resolves conflicts with ticket 018 (boolean param kind) in
src/engine.rs, webapp/src/lib/api.ts, and Params.tsx: boolean-kind
params and JSON-field-path extraction now coexist as independent
extraction modes alongside regex capture groups.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 19:41:36 +01:00
Hugh KaznowskiandClaude Sonnet 5 b3e0272544 Add JSON field path extraction for params/metrics (ticket 015)
RegexToMetricPrimitiveMatcher and the standalone param declaration
(DeclareParamRequest/DataValue::ParamDeclaration) gain an optional
json_field_path, an alternative to capture_group that reads a
dot-separated field path (e.g. "foo.bar") out of lines that parse as
JSON objects. It's additive and serde-defaulted, so existing
regex-based params/metrics are unaffected. Params.tsx now offers a
"JSON field" extraction option when the message match regex looks
like a JSON object.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 19:38:39 +01:00
Hugh KaznowskiandClaude Sonnet 5 56da086c7a Allow editing an existing metric declaration in place (ticket 022)
Clicking a declared metric badge in Metrics.tsx now loads its regex/params
into the declaration form for editing (in addition to selecting it for the
bucket-values view), and shows those properties inline once selected rather
than only on hover. The backend gains PUT /api/metrics/{name}, backed by a
new engine::update_metric that reuses declare_metric's validation, requires
the metric to already exist, supports renaming, and re-backfills bucket data
from the new regex/params instead of leaving stale buckets from the old one.
Submitting the form while editing calls the update endpoint in place rather
than creating a second declaration; declaring a brand-new metric is unchanged.

Note: tickets/022-edit-existing-metric-declarations.md does not exist on this
worktree's branch (it was added to tickets/ on main after this branch was
created), so there is nothing to delete here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 19:38:00 +01:00
Hugh KaznowskiandClaude Sonnet 5 a9855de3ad Add Boolean param kind that is always present true/false (ticket 018)
MetricPrimitiveKind gains Boolean, serialized as "boolean". A param
declared with kind Boolean skips capture-group validation and, instead
of being omitted when message_match_regex doesn't match, is present on
every log line as true/false based purely on whether the regex matches
(case-insensitively or per the regex's own flags). Params.tsx lets you
pick boolean as a kind and disables the now-unused capture group input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 19:35:02 +01:00
Hugh KaznowskiandClaude Sonnet 5 00c21c8032 Trim comments across Rust and webapp source to direct one-liners (ticket 030)
Pass over src/*.rs, webapp/src/**, and the in-app "?" docs prose:
condensed multi-paragraph doc comments to single lines that state the
non-obvious why, deleted comments that only restated adjacent code, and
tightened the Docs.tsx help-panel wording. No logic, structure, or code
formatting changed — verified via cargo test (143 passed) and
tsc -b && vite build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 17:02:33 +01:00
Hugh KaznowskiandClaude Sonnet 5 dd3320812c Add alert rules with FilterBar-based triggers and ntfy delivery (tickets 017-019)
Adds AlertRule/AlertCondition/AlertSink models with CRUD persisted on
Settings (mirroring CssEntry's shape), per-line trigger evaluation hooked
into the engine's ingest path (Immediate/Threshold/NewValue/Ratio
conditions), and best-effort ntfy delivery spawned off the ingest hot path.

Design notes:
- Alert queries reuse FilterBar syntax but only support the
  regex()/sequence/timestamp subset (no metric[param] comparisons), since
  resolving those would require a full bucket rescan per alert per ingested
  line rather than cost proportional to new logs.
- Ratio's zero-denominator case skips evaluation for that line rather than
  treating the ratio as infinite.
- NewValue's seen-value set is in-memory only and does not survive a
  restart, matching the engine's other non-persisted runtime state.

engine::run now takes a SettingsHandle (moved from api.rs to settings.rs so
engine.rs can depend on it without a circular import) to read the current
alert rules on each ingest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 16:30:14 +01:00
Hugh KaznowskiandClaude Sonnet 5 1da00e3586 Seed a default dashboard with 2 starter panels (ticket 025)
ensure_default now seeds a brand-new Default profile (not one that
already existed) with a "log_category" counter metric and an
"Overview" dashboard containing a line and a pie panel, both querying
that metric so each renders both series from a single PromQL query
like any user-created panel.

The counter classifies every line into exactly "category=error" or
"category=other" via two new fixed-label fields on metric
declarations: DeclareMetricRequest::matched_labels/unmatched_labels.
These apply literal label values instead of parsing captured text,
which is what keeps a case-insensitive match (e.g. "ERROR" vs "Error")
collapsed into one clean series instead of fragmenting by source
casing, and is also the only way to express the "line does NOT match"
side at all: plain regex alternation can't do it without lookaround,
which the `regex` crate doesn't support (a catch-all alternation
branch always wins at the search's leftmost position). Both fields
default to None and are backward compatible with existing profiles.

Seeded panels are ordinary editable/deletable dashboard state,
persisted through the normal profile save path; ensure_default never
re-seeds or duplicates them on a later call once the profile exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 15:38:27 +01:00
Hugh KaznowskiandClaude Sonnet 5 8faf12f819 Add Duration-type metric param: elapsed time since previous same-label match (ticket 023)
MetricPrimitiveKind::Duration/MetricPrimitive::Duration(i64 ms) is a
new primitive kind. On a metric's Value-role param (ticket 022), kind
Duration is special-cased in apply_metric_to_log: instead of parsing
the captured text, the value is derived as elapsed time since the
previous counter event with the same label set, using the ingestion
timestamp. That "previous event" lookup is tracked via a new
IndexKey::LastMetricEvent/DataValue::LastEventTimestamp bookkeeping
entry rather than Bucket.values itself, because a label set's first
occurrence has nothing to diff against and pushes no Bucket entry at
all (per spec: omit, don't emit a bogus zero) — but it still needs to
be recorded so the *next* occurrence has something to diff against.

Duration values compose with the existing bucket/PromQL surface
unchanged (coerce_to_f64, filter comparisons, histogram()/gauge()).
On any other param (Label-role metric param, or a standalone
ParamDeclaration), Duration kind instead parses captured text as a
literal i64 millisecond count, same as Number. Profile round-trip
(ProfileMetricPrimitiveKind) and the Metrics.tsx declaration form are
updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 15:24:28 +01:00
Hugh KaznowskiandClaude Sonnet 5 0c57ed2953 Metrics rethink: counters as the primitive, histogram()/gauge() derived (ticket 022)
Every metric declaration is now explicitly documented and modeled as
a counter: a match is an occurrence/increment, optionally labeled.
The bucket representation (BucketValue { values: Vec<(Sequence,
Timestamp, MetricPrimitive)> }) already was this; what it lacked was
any way to capture a *value* distinct from the occurrence-count-of-1
marker. Adds RegexToMetricPrimitiveMatcher::role (ParamRole::Label,
the default, or ParamRole::Value — at most one per metric, enforced
at declare time) so a metric can designate one param's captured
number as its per-event value instead of a label.

promql.rs/engine.rs add two new PromQL-evaluable forms built on top
of that value, both consumed as regular PromqlSeriesDto series like
sum()/avg()/rate():
- histogram(metric_name[width]): buckets matching events' values into
  fixed-width buckets, one series per bucket labeled `le`.
- gauge(metric_name): the most-recent value per label combination in
  the queried range.

Existing raw counter queries (metrics with only Label-role params,
sum/avg/rate) are unchanged. Profile files saved before this ticket
still load (role defaults to Label via #[serde(default)]). Metrics.tsx
gains a label/value role picker per param, and the Docs panel explains
the model and the two derived query forms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 15:18:25 +01:00
Hugh Kaznowski 51e577f01e Docs, ticks, working app 2026-09-05 15:05:39 +01:00
Hugh KaznowskiandClaude Sonnet 5 41362aa3d8 Add Dashboards UI: PromQL-backed bar/line/pie panels saved per profile (ticket 014)
Backend: replace Profile.dashboards' opaque JSON stub with typed
Dashboard/Panel structs, add ProfileStore CRUD (create/save/list/delete
dashboard) and matching /api/settings/profiles/:name/dashboards[/:id] routes,
with tests covering the HTTP round trip and persistence across a simulated
restart.

Frontend: add recharts (lightweight, React 19-compatible) and a Dashboards
component with a dashboard builder (create dashboards, add/remove/edit panels
with a PromQL query + chart type), live per-panel previews that respect the
active time range and show malformed-query errors inline instead of crashing,
and load/save wired to the new profile-backed persistence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 13:29:45 +01:00
Hugh KaznowskiandClaude Sonnet 5 86a1a40243 Wire FilterBar query AST into logs/timeline queries (ticket 007)
Adds a filter evaluator in engine.rs that compiles a parsed FilterExpr
against the current index once per query (regex compiled once,
sequence/timestamp compared directly against IngestedLog fields,
other metric comparisons resolved to a HashSet<u64> of matching
sequences via a bucket scan) and evaluates it per log line with no
further index access. Wires this into EngineQuery::Logs/Timeline via
a new LogsParams::filter/TimelineParams::filter field, and adds
EngineResponse::Error so a malformed filter string surfaces as a
query-level error (mapped to 400 in api.rs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 13:10:50 +01:00
Hugh KaznowskiandClaude Sonnet 5 4c8918ec8c Add profile (preset) CRUD and startup replay (ticket 009)
Introduces `src/profile.rs`: a `Profile` bundles param/metric declarations
plus a placeholder dashboards field, and `ProfileStore` persists all
profiles as a single profiles.json file, resolved relative to the settings
file's directory (same convention as ticket 010's CSS storage). Adds CRUD
(create/list/get/delete/set_active) that keeps Settings.profiles.names and
active_profile in sync, plus record_param_declaration/record_metric_declaration
hooks that ticket 004's HTTP API will call to persist newly-declared
params/metrics into the active profile.

Wires main.rs to replay the active profile's declarations into the engine
before spawning the ingest task, so backfill/live-derivation semantics
apply uniformly to every ingested line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 12:57:20 +01:00