Every built-in theme now exposes three accents (--c-accent/-2/-3) instead
of one, so PanelChart's series palette draws distinct colours per theme
via color-mix() rather than a fixed, theme-independent hex list. Existing
presets keep their current base surface/border/text/accent look — the two
new accent vars are purely additive. Adds a new "Light Green" scheme
(light-surfaced, same green/magenta hue family as "Green") selectable
alongside the existing built-ins, both in ColorScheme (frontend + backend)
and the settings picker.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
/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
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
Ticket 044: `sequence` and `timestamp` collided with ordinary user-declared
param names and didn't signal they're ottyl-owned, so they're renamed
(breaking change, no back-compat alias) to `ottyl_seq`/`ottyl_ts` in the
filter grammar (src/filter_parser.pest, src/filter_parser.rs), alert query
restriction/evaluation (src/alerts.rs), and the filter-bar docs/placeholder
in the webapp.
Also adds a new reserved param `ottyl_fmt` exposing which format a line was
auto-detected as ("json"/"logfmt"/"none") via a new
`crate::logfmt::detect_format`, wired into the same filter grammar and alert
restriction list as `ottyl_seq`/`ottyl_ts`. A mixed-format fixture test in
engine.rs verifies `ottyl_fmt` reflects the actual detected format per line.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
compile_metric_comparison used to look up `param` by reading whatever key
happened to be present in a bucket's `labels` map at query time, so a typo
or a param never declared as a label on that metric silently matched
nothing instead of being rejected. Now it looks up the target metric's
MetricDeclaration (ticket 037's unified label_params/value_param shape) and
rejects the filter up front with an error naming the metric and its actual
declared label params when `param` isn't among them.
Metric names with no declaration at all are left unvalidated here, matching
prior behavior (bucket_values simply yields nothing for them).
Added tests: metric[nonexistent_param] is rejected with an error naming the
metric and its real label params; existing metric[real_param] filters still
match as before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
Fix one test call site to unwrap the Result now returned by
evaluate_promql (introduced by ticket 041). Full suite (194 tests),
clippy, and webapp build all pass after merging all of 037, 040, 041,
042, and 043.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
The 041 branch was built against the pre-037 ParamRole/inline-params shape;
rebase the string-kind lookup and test helper onto the unified
ParamDeclaration/label_params/value_param model from ticket 037.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
Extract the trailing-window rate/count implementation into a new src/rate.rs,
shared by PromQL's rate() (engine::evaluate_promql, via a thin i64<->Timestamp
adapter) and alert Threshold/Ratio conditions (alerts::evaluate_condition, via
the new incremental RateWindow), replacing alerts' bespoke VecDeque+prune loop.
Both now agree on what "windowed rate" means for the same data, verified by a
new regression test (engine::tests::dashboard_rate_and_alert_threshold_agree_on_same_windowed_data).
Behavior change: alert Threshold/Ratio windows are now backfilled once, on
first evaluation, from already-ingested logs within their own trailing window
(bounded cost, not a full history rescan) - a rule created mid-session, or a
process that replays existing log content (e.g. --file tailing, which rereads
the whole file from the start on every process start), no longer starts with
a silently empty window. A bare restart with no replayed history (e.g. stdin
ingestion) still starts empty, since there is genuinely no log history to
backfill from in that case - alert state itself remains in-memory only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
merge_series_exact required an exact-millisecond timestamp match to combine
sub-series, so sum(requests) by (path)-style queries over raw occurrence
counts produced one point per underlying (label, timestamp) pair instead of
a meaningful combined total, since real occurrence timestamps across
different label groups essentially never coincide. Added a regression test
confirming the sparse behavior, then replaced the merge with
merge_series_windowed: a single-sub-series group is passed through
unchanged (preserving existing single-label-group resolution exactly), and
groups with multiple sub-series are bucketed into fixed 1s windows before
summing/averaging, mirroring the fixed-width bucketing rate() already uses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
`coerce_to_f64` (src/engine.rs) mapped every MetricPrimitive::String to a
flat 1.0, so sum()/avg() over a string-kind metric silently became an
occurrence count with no signal that the aggregate didn't mean what it
looked like it meant.
evaluate_promql now looks up the target metric's declared value kind (the
kind of its Value-role param, defaulting to Number when it has none) and
rejects sum()/avg() with a clear, actionable error when that kind is
String. sum(rate(...))/avg(rate(...)) are exempt since rate() only counts
occurrences per window and never reads the coerced value. Also add a real
count() PromQL aggregation (parser + evaluator) so occurrence-counting is
something a user opts into by name, matching the ticket's preferred
"count() or numeric metric" error message and the existing
sum()/avg()/rate()/histogram()/gauge() aggregation design.
Audited existing fixtures/tests for sum()/avg() over string-kind metrics
that this would newly reject: none found — the repo's only sum()/avg()
usage in tests and docs is over the "requests" occurrence-counter metric,
which has no Value-role param and so is Number-kind, unaffected by this
change.
EngineResponse::Promql already carried Result<Vec<PromqlSeriesDto>,
String> for parser errors, so evaluate_promql now returns that same
Result and the new error flows through the existing 400 Bad Request path
in run_promql_query with no API changes needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
sum/avg/rate already grouped series by the query's by_labels, but
histogram() collapsed every label combination into one histogram
regardless of by(), silently discarding the grouping. evaluate_promql's
Histogram branch now groups series by by_labels first (reusing the same
project_labels mechanism as sum/avg), then buckets each group's values
independently, so histogram(metric) by (label) yields one histogram per
label group carrying that group's labels alongside `le`. An empty
by_labels still collapses to a single histogram, matching prior
behavior exactly, so sum/avg/rate and unqualified histogram() queries
are unaffected.
The dashboard panel UI needed no change: PromQL is entered as free
text and PanelChart's seriesLabel()/legend rendering already renders
arbitrary label maps generically, so grouped histogram series display
correctly out of the box.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
Removes the "Use as theme" checkbox/is_theme distinction from
NavMenu.tsx's CSS import and link forms — every imported/linked
stylesheet is now always validated against the canonical --c-*
variable set and becomes a selectable theme, same as today's
is_theme:true path. Drops is_theme entirely from the backend
(CssEntry, ImportCssRequest, LinkCssRequest, CssEntryDto) and from
the frontend CssEntry type; an existing settings.json with a stored
is_theme:false entry still deserializes fine (the field is simply
unknown/ignored) and that entry becomes selectable like any other,
so no migration step is needed. Also drops the now-dead
always-on-overlay CSS injection path from useSettingsPanel.ts, and
changes the CSS filename placeholder from "name.css" to "enter name".
Implemented directly against main rather than merging the dispatched
worktree branch for this ticket, since that branch's base predated
ticket 012 (the custom-themes/imported-CSS consolidation) and its
NavMenu structure no longer matched current main.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
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
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
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
Extends the FilterBar query language with a json[field] <op> value
comparison, parsed by filter_parser.pest/rs alongside metric[param],
and evaluated in engine.rs by parsing each line as JSON at query time
(lines that aren't valid JSON, or lack the field, simply don't match).
Also wired into alerts.rs since json[field], like regex/sequence/
timestamp, only needs the line's own text.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
"grey" and "dark" rendered identically apart from accent colour, so
drop "grey" as a selectable colour scheme everywhere in the frontend
(index.css, api.ts's ColorScheme type, the NavMenu picker, the
downloadable BUILTIN_THEMES table) and keep "dark" as the single
equivalent theme.
Existing settings.json files saved with color_scheme: "grey" keep
loading correctly: the backend ColorScheme enum aliases the legacy
"grey" serde value onto Dark, so old configs transparently migrate to
"dark" without any manual intervention.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
Tracks a running total of ingested log bytes (approximated as each
log's raw UTF-8 text length), incremented on ingest and decremented
in evict_excess_logs when a log is dropped. Exposed via StatsDto's
new approx_bytes_total field on GET /api/stats, and displayed in
NavMenu's stats panel formatted with toLocaleString() (comma
separators).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
--settings-path's help text now names the actual resolved default path
(computed once via LazyLock) instead of only describing where to look for
one. Also adds an optional --metrics-url flag that polls a Prometheus/
OpenMetrics-compatible endpoint on an interval, pushing samples straight
into a metric's bucket (bypassing log-line regex matching) so they're
queryable via PromQL/dashboards alongside log-derived metrics.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
Alert rules no longer carry their own ntfy topic/server; Settings now
holds one process-wide ntfy sink (with its own "send test notification"),
and the Alerts panel is left to just define rule conditions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
Themes download as a small `:root { --c-*: ...; }` CSS file (full
canonical variable set); uploading validates that set is complete and
registers the file as a CssEntry the theme picker can select. A new
`is_theme` flag on CssEntry distinguishes theme entries from arbitrary
imported/linked CSS so they don't leak into the page unconditionally
like the existing "Configured CSS" list does — a selected custom
theme's CSS is injected only while active, with built-in html.<scheme>
classes removed so its :root override isn't outranked in the cascade.
Linked (URL) custom themes reuse the existing CSS link mechanism and
inherit its non-deletable semantics.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
New Alerts panel: lists rules with condition summaries and an enabled
toggle, and a condition-type-specific editor (Immediate/Threshold/NewValue/
Ratio) with client-side validation before save, reusing FilterBar's query
input styling for query-based conditions. Adds a test-notification endpoint
(POST /api/settings/alerts/test) so an ntfy topic/server can be verified
without saving a rule first, plus a docs section and help icons matching
ticket 021's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
ottyl was requiring webapp/dist to be shipped alongside the binary and
served via ServeDir/ServeFile pointed at a --static-dir CLI arg. Embed
the built webapp at compile time instead, so ottyl is a single
self-contained executable with no runtime static directory dependency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
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
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
Settings.max_logs (default None = unlimited) is pushed to the engine
via a new EngineMessage::SetMaxLogs, enforced on every ingest and
whenever the setting changes at runtime (no restart required).
Eviction drops the oldest (lowest-sequence) logs; sequence numbering
isn't renumbered and buckets already derived from an evicted log are
left in place. Exposed via PATCH /api/settings (max_logs: number |
null) and a NavMenu numeric field (blank = unlimited).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
ingest::run_file tails a path like tail -f: reads existing content,
follows appended lines, and detects file recreation/truncation (via
inode on unix, or a shrinking size) to resume tailing the new
instance from its start instead of exiting or stalling. Sequence
numbers keep incrementing across a recreation event since they're
ottyl's own line counter, not tied to the source file. stdin ingest
is unchanged and remains the default when --file is omitted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
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
Adds thin HTTP routes for profile CRUD and colour-scheme updates
(GET/PATCH /api/settings, GET/POST /api/settings/profiles,
DELETE /api/settings/profiles/:name, POST /api/settings/profiles/:name/activate)
following the existing CSS-route pattern in api.rs, with activate hot-swapping
the running engine's declared params/metrics so switching profiles needs no
restart. Builds out the NavMenu frontend with Settings (colour scheme picker,
CSS import/list/delete) and Presets (list/create/switch/delete profiles with
declaration counts) panels, and a useSettingsPanel hook that applies the
active colour scheme and imported CSS to the document live.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o