Compare commits

...
63 Commits
Author SHA1 Message Date
Hugh Kaznowski 738e90c45c Tickets for FE and actor split 2026-09-07 14:54:21 +01:00
Hugh KaznowskiandClaude Sonnet 5 920629dbad Add a deterministic profile-actor wait primitive for tests (ticket 108)
Ticket 108 anticipated a large test-harness migration (primary-only/
profile-only helpers, migrating ~150 tests in engine/tests.rs), but
that turned out unnecessary: the profile actor reuses EngineSnapshot
as its own snapshot type (see ticket 104), so every existing
EngineSnapshot unit test already exercises primary-shaped and
profile-shaped behavior unchanged -- zero migration needed, confirmed
by the full suite passing untouched through tickets 100-107.

What was still a real gap: tests asserting on profile-actor-owned data
(declared metrics, alerts) after ingesting had no deterministic way to
wait for the profile actor to catch up -- EngineHandle::barrier() only
confirms the primary's queue drained, not that the profile actor has
processed what it published. They were passing on scheduler luck on
the single-threaded test runtime, not a real guarantee.

Added ProfileHandle::processed_sequence, a permanent (not test-only)
watch channel tracking the profile actor's watermark, mirrored onto
EngineHandle as profile_processed_sequence, with a
wait_for_profile_sequence(sequence) test helper built on it. This
replaces the ad hoc on_advance/spawn_observed mechanism ticket 103
added just to prove the wake loop worked -- that test now uses the
same real primitive. Retrofitted it into
promql_query_returns_series_for_seeded_fixture to eliminate its
latent race.

Not done: a dedicated test for the accepted eviction/lag tradeoff
(primary evicts on schedule regardless of profile-actor progress, per
idea 0001's resolved decision) -- deterministically forcing that race
without new test-injection hooks proved more involved than the
remaining scope justified; left as a known gap rather than shipping a
flaky or synthetic test for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 14:04:26 +01:00
Hugh KaznowskiandClaude Sonnet 5 9322aac7e1 Route bare ottyl_seq/ottyl_ts Promql queries to the primary (ticket 107)
Audited every EngineQuery variant for the cross-snapshot join ticket
107 anticipated: Logs/Timeline/Stats/ParamDeclarations only ever read
primary data (raw text + param declarations, which never left the
primary), and MetricDeclarations/BucketValues/Promql only ever read
profile data -- no variant actually needs both, so no join/fan-out
layer is needed after all.

One real gap surfaced by that audit: a bare ottyl_seq/ottyl_ts Promql
query is evaluated by iterating self.logs_range() (evaluate_promql's
reserved-metric branch), but such queries were routed to the profile
actor's snapshot alongside real declared metrics -- and the profile
actor stores no raw log rows, so these queries silently returned empty
series instead of real data. EngineHandle::ask now parses the query
just far enough to check its metric name and routes ottyl_seq/ottyl_ts
to the primary instead, where evaluate_promql already works correctly
against real log data. Added an actor-level integration test
(promql_bare_ottyl_seq_works_through_the_real_actor_pipeline) that
fails without this fix -- verified by temporarily disabling the
routing check and confirming the test catches it.

Also deduplicated config/profile.rs's local `ask` test helper, which
had already once diverged from api::EngineHandle::ask's routing logic
and caused ticket 104 test failures (fixed then, but the duplication
itself was the root cause) -- it now delegates to the real one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 14:01:29 +01:00
Hugh KaznowskiandClaude Sonnet 5 3ee6dddb0e Document EngineHandle's already-realized primary/profile routing (ticket 106)
Ticket 106 asked for a real way for API handlers to address the
primary vs. profile actor. That routing was already built out of
necessity while landing tickets 104/105 (metric queries had to reach
the profile actor's snapshot for those tickets' own tests to pass) --
EngineHandle ended up as one combined handle (option (b) from the
ticket's own scope note) that internally proxies metric/alert writes
to the profile actor and routes MetricDeclarations/BucketValues/Promql
reads to its snapshot, with no changes needed to any API handler,
reload_engine_declarations/activate_profile, or Profile::load_into_engine
-- they already just send EngineMessages the same way they did before
the split. mcp_server.rs talks to the HTTP API rather than EngineHandle
directly, so it needed no changes either. This commit documents that
resolution in handle.rs so a future reader doesn't go looking for
routing logic that isn't there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 13:56:59 +01:00
Hugh KaznowskiandClaude Sonnet 5 3751508de8 Move alert evaluation to the profile actor (ticket 105)
Alert rules are profile-scoped and read metric buckets, which now live
on the profile actor (ticket 104) rather than the primary. alert_states
and the per-line evaluate_alerts/deliver_for_rule calls move from the
primary's Ingest arm to the profile actor's per-line loop, evaluated
against its own snapshot right after that line's apply_all_metrics
publishes -- matching the primary's original per-message publish-then-
evaluate ordering. The primary's run() no longer needs a SettingsHandle
at all now that alerts are gone from its Ingest arm; settings (for
alert rules and ntfy config, read fresh per line) moves to
engine::profile::spawn instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 13:55:51 +01:00
Hugh KaznowskiandClaude Sonnet 5 ce3e741cb0 Move metric declarations and bucket computation to the profile actor (ticket 104)
Metric declare/update/remove, backfill, and per-line apply_all_metrics
now run on the profile actor (engine::profile) instead of inline in the
primary's ingest closure, per idea 0001's resolved decision that the
primary handles params only. The profile actor reuses EngineSnapshot as
its own snapshot type -- declare_metric/update_metric/apply_all_metrics
work unchanged -- and mirrors the primary's param declarations into its
own snapshot on every wake (and synchronously before handling a
DeclareMetric/UpdateMetric message, since there's no ordering guarantee
between the watch-wake and message branches in tokio::select!) so
metric label/value resolution keeps working without storing a second
copy of raw log text; backfill reads lines through its cloned primary
IndexReader instead.

EngineHandle gained a profile_reader field so MetricDeclarations/
BucketValues/Promql queries route to the profile actor's snapshot
instead of the primary's -- without it, metrics could be declared but
never queried, since the primary no longer computes or stores them.
DeclareMetric/UpdateMetric/RemoveMetricDeclaration messages are still
sent to the primary's mpsc channel and proxied to the profile actor by
handle.rs's run() loop; EngineHandle's write-side API is otherwise
unchanged (ticket 106 gives API handlers a direct route). Alert
evaluation still reads the primary's (now metric-less) snapshot
directly -- an accepted gap until ticket 105 moves it, per idea 0001's
resolved decision that alerts move with metric computation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 13:53:06 +01:00
Hugh KaznowskiandClaude Sonnet 5 033e8bbab5 Stand up the profile actor skeleton and primary->profile watch signaling (ticket 103)
Adds a second actor task with its own left_right-backed ProfileSnapshot
and a tokio::sync::watch the primary's IndexWriter bumps after every
publish, so the profile actor wakes and re-reads newly-visible primary
lines through its own cloned IndexReader instead of polling. No metric
computation, alert evaluation, or query routing moves yet -- this is
pure plumbing, proven with a test that ingests a line on the primary
and observes the profile actor's watermark advance in response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 13:39:19 +01:00
Hugh KaznowskiandClaude Sonnet 5 2c1aaa6e9c Break ticket 102's actor split into reviewable sub-tickets (103-108)
Ticket 102 (splitting the engine into reader/primary/profile actors)
was a single multi-thousand-line ticket covering a new actor
topology, an EngineHandle API redesign, a query fan-out layer, moving
alerts, and a full test-harness rewrite, with some design decisions
deliberately left open. Splitting it into six sequenced tickets
(profile actor skeleton + signaling, move metric computation, move
alert evaluation, split the handle API, query fan-out, test harness
rework) lets each land as its own reviewable commit instead of one
large speculative rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 13:33:01 +01:00
Hugh KaznowskiandClaude Sonnet 5 756e054527 Extract param extraction into its own EngineSnapshot::ingest_params stage (ticket 101)
Raw storage, log-stream header derivation, and auto-param detection
were inlined directly in the ingest actor's write closure alongside
metric computation. Pulling them into their own method gives the
ingest arm two clearly separated, independently-callable stages —
param extraction (ingest_params) and metric computation
(apply_all_metrics) — with no behavior change, in preparation for
splitting these onto separate actors (ticket 102).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 13:27:49 +01:00
Hugh KaznowskiandClaude Sonnet 5 f43445b280 Arc-ify stored log text to remove per-line double clones (ticket 100)
DataValue::LogLiteral and IngestedLine now carry an Arc<str> instead
of an owned String, so the two clones the ingest path paid per line
(once to move text into the write closure, once to store it) become
cheap refcount bumps instead of full string copies. Also cuts the
per-string-content cost of left_right's full-EngineSnapshot deep clone
on each reconciliation sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 13:26:03 +01:00
Hugh Kaznowski 5fee6909f2 Update actor split idea file 2026-09-07 13:14:19 +01:00
Hugh Kaznowski c7173cf9dc Update agents from repowise 2026-09-07 12:37:07 +01:00
Hugh KaznowskiandClaude Sonnet 5 dbe2ea9961 Add histogram_quantile() PromQL query for percentiles
The PromQL layer only had a fixed-width histogram() (per-bucket
counts), with no way to ask for p50/p95/p99. Adds
histogram_quantile(p, metric[range]), interpolating over each
series's retained raw sample values (linear interpolation between
closest ranks) rather than building a separate pre-aggregated
bucketed-histogram representation — exact for the data actually kept.
An optional bracketed range narrows the computation to a trailing
window, mirroring rate()'s range selector.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 12:34:00 +01:00
Hugh KaznowskiandClaude Sonnet 5 4a0fe92ce8 Add gauge/counter semantics to metric declarations (ticket 097)
Metrics previously only declared a data type (String/Number/...), so
rate() could be applied to a point-in-time gauge reading and sum()/avg()
could be applied directly to a cumulative counter reading with no guard
against either. Adds a MetricSemantics enum (Gauge, default; Counter)
threaded through DeclareMetricRequest, the engine's MetricDeclaration
data value, profile persistence, the API/MCP declaration routes, and the
Metrics panel's declaration form, then uses it in evaluate_promql to
reject rate() on a declared gauge and bare sum()/avg() on a declared
counter (suggesting rate() first). The guard only applies to metrics
with a captured value_param — bare occurrence-count metrics are always
safe under both operations, so existing dashboards are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 12:30:06 +01:00
Hugh KaznowskiandClaude Sonnet 5 c3108677ce Adopt rustfmt and clean up clippy warnings
Formats the whole backend to rustfmt defaults and fixes all 23
outstanding clippy warnings (collapsible ifs, clone-to-slice-ref,
type-complexity, single-arm match, unnecessary is_none check), then
wires cargo fmt --check and -D warnings into make lint so both stay
enforced going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 12:21:38 +01:00
Hugh KaznowskiandClaude Sonnet 5 2cc8ac8138 Default JSON log-line viewer nodes to collapsed
A log line whose text parses as JSON was rendering every nested node
fully expanded, dumping large payloads into the timeline instead of a
collapsed summary the user opts into expanding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 12:19:47 +01:00
Hugh Kaznowski 49bfd6b020 Add tickets and ideas 2026-09-07 12:16:36 +01:00
Hugh KaznowskiandClaude Sonnet 5 0c41048e0f Split lib/api.ts by REST domain and add buildSearchParams helper
api.ts had grown to 35 exported symbols in one 432-line file covering
every backend endpoint. Split into webapp/src/lib/api/{shared,logs,
params,metrics,dashboards,alerts,settings}.ts, re-exported from
lib/api/index.ts as a barrel so none of the ~10 importing files needed
their import paths changed (TS/bundler module resolution falls back to
a directory's index.ts when the sibling .ts file is gone).

Also added buildSearchParams to replace fetchLogs/fetchTimeline's
repeated hand-written `if (x !== undefined) search.set(...)` blocks.
Left fetchMetricBucketValues/fetchPromqlSeries's query-building
untouched (byte-for-byte) since the ticket scoped the helper to
fetchLogs/fetchTimeline only.

Reimplemented directly against current main (ticket 089) after the
worktree agent's branch was based on a stale pre-theme-removal state
and reintroduced CSS-upload endpoints main had deliberately deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:54:47 +01:00
Hugh Kaznowski f323847748 Merge ticket branch t092 2026-09-07 10:47:01 +01:00
Hugh Kaznowski fc2f511d38 Merge ticket branch t090 2026-09-07 10:47:01 +01:00
Hugh KaznowskiandClaude Sonnet 5 8b2d512c94 Extract shared useRangeSelection hook from App.tsx's three page variants
StandaloneRanged, StandaloneLog, and FullApp each reimplemented the same
range/preset state machine (range/activePreset state, a rangeRef synced
via effect, selectPreset/handleManualRangeSelect, and rolling-preset
re-resolution at the top of the poll loop). Move that bookkeeping into
webapp/src/lib/useRangeSelection.ts; each component keeps only its own
polling logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:46:30 +01:00
Hugh KaznowskiandClaude Sonnet 5 28689b1b8d Extract paramBadgeSuffix helper from nested ternary chain
Replace the 4-deep nested ternary building each declared-param badge's
suffix text with a small if/else-if helper (paramBadgeSuffix), called
from the badge JSX. No behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:44:24 +01:00
Hugh Kaznowski 1014aeaf68 Merge ticket branch t087 2026-09-07 10:41:34 +01:00
Hugh KaznowskiandClaude Sonnet 5 8008e5611e Extract shared SectionHeader and FormFooter components (ticket 087)
Params/Metrics/Alerts/Dashboards each hand-assembled the same collapsible-
section header wiring (role/tabIndex/aria-expanded/onClick/onKeyDown around
CollapseToggle+HelpIcon+PopOutButton), and Dashboards duplicated it a second
time for its no-active-profile branch. Params/Metrics/Alerts also duplicated
the declare/edit form footer (editing banner, submit label toggle, Cancel
button). Added SectionHeader and FormFooter to CollapsibleSection.tsx and
adopted them at all five call sites; pure extraction, no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:41:17 +01:00
Hugh Kaznowski 000efa5166 Merge ticket branch t088 2026-09-07 10:36:17 +01:00
Hugh Kaznowski 73627090a1 Merge ticket branch t091 2026-09-07 10:36:17 +01:00
Hugh KaznowskiandClaude Sonnet 5 c0174963d8 Extract useClickOutside hook, dedupe popover close-on-outside-click logic
RegexHelpIcon and LogPreview's ColumnPicker each reimplemented the same
mousedown-outside-ref-closes-popover effect; share it via a small hook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:35:41 +01:00
Hugh KaznowskiandClaude Sonnet 5 4cc9205abf Move TimeRange types to lib/timeRange to break import cycle
CollapsibleSection.tsx and LogTimeline.tsx imported from each other
(TimeRange/TimeRangePresetId one way, sectionHeaderClassName the
other). Move the range types/helpers into a dependency-free
webapp/src/lib/timeRange.ts and repoint all importers there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:35:05 +01:00
Hugh KaznowskiandClaude Sonnet 5 a47f771087 Extract shared declare/update-metric backfill and auto-param insertion helpers
declare_metric and update_metric duplicated the resolve-params +
backfill_metric + insert-MetricDeclaration sequence; extracted into
resolve_backfill_and_store_metric so the declare-vs-rename difference
is expressed only in caller-side logic. Also extracted
auto_declare_params's per-candidate insertion body into
try_insert_auto_param so the candidate-building and insertion steps
read as separate concerns.

Reimplemented directly against current main (ticket 085) after the
original worktree agent's branch was based on a stale pre-consolidation
snapshot module layout that no longer matches main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:32:27 +01:00
Hugh Kaznowski cdd781c904 Merge ticket branch t083 2026-09-07 10:31:23 +01:00
Hugh Kaznowski d987663aaa Merge ticket branch t084 2026-09-07 10:31:23 +01:00
Hugh Kaznowski 157b4e7b1d Merge ticket branch t086 2026-09-07 10:31:23 +01:00
Hugh Kaznowski 94c52f8a17 Merge ticket branch t082 2026-09-07 10:31:23 +01:00
Hugh KaznowskiandClaude Sonnet 5 f67cfc5d94 Extract filter-compile and sort-by-param helpers out of handle_query_inner
The Logs and Timeline arms of handle_query_inner each duplicated the same
"compile filter, bail out on error" block; the Logs arm also inlined its
sort-helper-index walk. Pull both into named helpers (compile_optional_filter,
logs_sorted_by_param) so the Logs arm reads as "compile filter, then dispatch
to sorted or unsorted." Pure decomposition, no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:30:37 +01:00
Hugh KaznowskiandClaude Sonnet 5 564a0e8b2a Extract filter_value_as_f64 helper in compare_json_value
Flattens the Number arm's nested target-coercion match into a
one-line comparison, matching the shape of the String arm. No
behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:29:28 +01:00
Hugh KaznowskiandClaude Sonnet 5 1b6c50075a Split cli::run() into named startup-phase functions
run() bundled settings loading, engine spawn, active-profile resolve/load,
ingest spawn, and session registration inline. Extract each into a named
function (spawn_engine, load_active_profile, spawn_ingest,
register_session_and_log) so run() reads as a sequence of steps. No
behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:28:51 +01:00
Hugh KaznowskiandClaude Sonnet 5 4398acfa47 Flatten run_metrics_poll's nested match pyramid
Extract fetch_metrics_body(client, url) -> Result<String, String> to
handle the request/error_for_status/text chain with `?`, centralizing
the three warning messages. run_metrics_poll now matches once on the
result instead of nesting three match arms deep on the happy path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 10:28:13 +01:00
Hugh Kaznowski f96de8f38d Compress impls 2026-09-07 02:04:36 +01:00
Hugh Kaznowski 55820982b1 Simplify test helper 2026-09-07 01:53:16 +01:00
Hugh KaznowskiandClaude Sonnet 5 d0ce524e95 Move approx_log_bytes into a flavour::LogLiteralHelper trait
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 01:24:06 +01:00
Hugh Kaznowski 82fa0dfeb4 Remove uploading css functionality. Themes are hard-coded. Change the code 2026-09-07 01:02:41 +01:00
Hugh Kaznowski 901dcf0a3f Makefile cleans profiling 2026-09-07 00:43:52 +01:00
Hugh KaznowskiandClaude Sonnet 5 1b2f690278 Consolidate EngineSnapshot's impl blocks into snapshot/mod.rs
They were scattered across filter/metrics/params/query/sort.rs by an
earlier file-splitting pass. Files can be split as much as needed, but
a single type's impl should never be split across files — so all 15
impl EngineSnapshot blocks now live in mod.rs, while each submodule
keeps its own types, free helper functions, and tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 00:43:38 +01:00
Hugh KaznowskiandClaude Sonnet 5 7df6026a21 Remove tickets 079/080 (no profiling evidence to justify the fix)
Both tickets explicitly scope themselves to "confirm via profiling
before implementing" (079: a trigram/token pre-filter for free-text
regex search; 080: caching parsed JSON per log line for JSON-field
filters). Neither has profiling data showing it's the dominant cost
once ticket 077's range-narrowing lands, and building either
speculatively risks unbounded memory (079) or penalizing the more
common non-JSON case (080) for no measured benefit. Revisit if
`samply` profiling against a realistic log volume shows these are
actually hot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-06 20:51:29 +01:00
Hugh KaznowskiandClaude Sonnet 5 946fd50d53 Seek log/metric-bucket queries by key range instead of scanning
Log reads (logs_since, the unsorted Logs query path) now seek the
IngestedLog range directly from since_sequence/before_sequence instead
of scanning from IndexKey::Start and filtering every prior entry out
in a closure. Metric-comparison filters and bucket_values now seek to
a single metric's Bucket key range instead of scanning every declared
metric's buckets. evaluate_promql sits on top of bucket_values, so its
per-poll dashboard evaluation is narrowed by the same fix without
further changes; caching prior evaluations across polls is left for a
follow-up if profiling ever shows it's needed (per ticket 081's own
scope, not built speculatively).

Resolves tickets 077, 078, 081.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-06 20:51:24 +01:00
Hugh Kaznowski 65330d37e0 Add tickets, ideas and samply ignore 2026-09-06 20:45:33 +01:00
Hugh Kaznowski d44c14a83b Improve ingestion performance 2026-09-06 19:02:44 +01:00
Hugh KaznowskiandClaude Sonnet 5 f19fa50a25 Split src/engine/mod.rs into handle/message/snapshot submodules
Pure code-motion refactor, no logic changes. Follows the repo convention
of one struct/enum + its impl per module file:

- engine/handle.rs: the left-right write/read plumbing (EngineOp,
  IndexWriter, IndexReadHandle, IndexReader, EngineHandle) and the actor
  loop (spawn/run) that owns EngineSnapshot.
- engine/message.rs: EngineQuery/EngineMessage/EngineResponse.
- engine/snapshot/: EngineSnapshot itself, split by concern into
  params.rs (declaration + auto-detect), metrics.rs (declare/apply/
  backfill), filter.rs (search-bar filter compilation/eval), sort.rs
  (sort-helper indexes), and query.rs (handle_query_inner + PromQL
  evaluation), with mod.rs holding the struct and the accessors shared
  across all of them.
- engine/tests.rs: the inline test module, kept as one file (per the
  task's own escape hatch) since most tests exercise ingest -> declare
  -> query across several of the new submodules at once rather than one
  in isolation.

Visibility was narrowed per call site: pub(super) where only sibling
snapshot submodules need an item, pub(crate) where engine::handle or
engine::tests (outside the snapshot subtree) also need it.

cargo build --all-targets and cargo test are green (291 passed, 1
ignored, same as before the split).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 18:36:53 +01:00
Hugh Kaznowski 3b3ace146a Increase mcp scope 2026-09-06 17:34:36 +01:00
Hugh KaznowskiandClaude Sonnet 5 a810ecf396 Split src/ into an api/cli/engine/config module hierarchy
- src/api/: HTTP router + DTOs + the MCP server (the HTTP/MCP-facing
  surface): mod.rs (formerly api.rs), dto.rs, mcp_server.rs.
- src/cli/: argument parsing and process entry (formerly inline in
  main.rs) plus session registry: mod.rs, sessions.rs. main.rs is now
  a thin entry point that just declares modules and calls cli::run().
- src/engine/: the actor + everything it depends on to parse/index/
  query log lines: mod.rs (formerly engine.rs), alerts.rs, promql.rs,
  rate.rs, ingest.rs, logfmt.rs, filter_parser.rs/.pest,
  filter_datetime.rs, index_key.rs, data_value.rs, metric_primitive.rs,
  timestamp.rs.
- src/config/: persisted configuration: mod.rs (formerly settings.rs),
  profile.rs.
- src/consts.rs stays at the crate root, used from every module above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 17:15:39 +01:00
Hugh KaznowskiandClaude Sonnet 5 311713986a Wrap the left-right index in IndexWriter/IndexReader types
The engine actor and API handlers previously called left_right's
WriteHandle/ReadHandleFactory methods (append/publish/enter/handle)
directly. IndexWriter now exposes mutate()/peek() and IndexReader
exposes handle() (returning an IndexReadHandle with its own enter()),
so how the single-writer/multi-reader index is accessed is named and
scoped to these two types instead of being raw left_right calls
scattered through engine.rs/api.rs/profile.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 17:10:23 +01:00
Hugh KaznowskiandClaude Sonnet 5 3c2995acc1 Convert remaining clean-receiver free functions to methods
Profile (legacy-shape migration, load_into_engine, load_legacy_store),
MetricDeclarationRecord/Dashboard seed constructors, PromqlQuery::parse,
LogStreamHeader (detect/split_row/column_pattern/message_pattern), and
api.rs's EngineHandle::ask/*Error::into_response/ParamDeclarationDto::reserved
all had a single obvious receiver and are now methods on it instead of
free functions taking that type as a parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 17:08:29 +01:00
Hugh KaznowskiandClaude Sonnet 5 529f79e9da Convert engine.rs index-manipulating functions into EngineSnapshot methods
Free functions that took the engine's index/sort-helper-index state as
a parameter now live on EngineSnapshot (or, for a handful of clean
single-receiver cases, on MetricPrimitive/CompareOp/CompiledFilter/
EngineQuery). Route handlers in api.rs and alert evaluation in
alerts.rs are updated to call the new methods; alerts.rs now threads
an &EngineSnapshot through instead of a raw &BTreeMap so the per-line
alert backfill path doesn't clone the whole index just to call a method.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 17:03:19 +01:00
Hugh KaznowskiandClaude Sonnet 5 b6401c1c3c Strip all comments outside src/consts.rs
Removes doc comments, module comments, and inline comments across
the backend so the only comments left in the codebase are the brief
ones on the constants they document.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 16:34:42 +01:00
Hugh KaznowskiandClaude Sonnet 5 284e3562a2 Move scattered magic constants into src/consts.rs
Centralizes engine tuning values, poll intervals, logfmt column
regex fragments, and app/settings identity strings that were
previously defined ad hoc across engine.rs, ingest.rs, logfmt.rs,
profile.rs, settings.rs, and main.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 16:29:03 +01:00
Hugh Kaznowski 1c635bc530 Left-right single-writer, multi-reader index 2026-09-06 16:05:04 +01:00
Hugh KaznowskiandClaude Sonnet 5 9fc7540a5b Resolve tickets 071-074, 076: log-stream contamination, sort, ottyl_fmt column, ingest lag
- 076: gate auto-declared log-stream column/message params
  (`ParamDeclaration::log_stream_gated`) behind `is_log_stream_line` at
  extraction time, since their fixed-offset regex has no content anchor
  and previously extracted garbage from continuation lines and the
  header row itself (the regex crate doesn't support lookaround, so
  this couldn't be baked into the regex text).
- 074: end-to-end regression test confirms sorting a real `log stream`
  capture by a log-stream column (e.g. PID) now actually reorders
  results, since 076's fix removes the garbage values that were
  corrupting the sort helper index; also add a "Building sort
  index…" indicator in the timeline UI for a slow first-time sort.
- 072: attach the reserved `ottyl_fmt` pseudo-param to each
  `LogEntryDto`'s params so it can be picked as a Log Preview column
  (previously listed as pickable but always blank).
- 073: track a rolling 5s high-watermark of ingestion-to-engine-processing
  lag and surface it in the nav stats popover next to approx_bytes_total.
- 071: group the regex(...) help button back with the filter bar's other
  controls, and restructure the Filtering docs section into scannable
  per-feature subsections (also fixes stale log_type/process/pid mention).

Tickets 070 (CLI scoping) and 075 (API unresponsiveness investigation)
are left in place per their own scope — both are explicitly
conversation/investigation tasks, not implementation tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 15:12:07 +01:00
Hugh Kaznowski f34dbba6c7 Update skill and tickets 2026-09-06 14:37:03 +01:00
Hugh Kaznowski fc8de1a5b0 Remove wishlist 2026-09-06 14:00:05 +01:00
Hugh KaznowskiandClaude Sonnet 5 c77404ba4e Resolve tickets 067-069: named sessions, MCP server, embedded SKILL.md
Adds a sessions/<name>.json registry (068) so a running ottyl instance can
be discovered by name; an `ottyl mcp --session <name>` subcommand (067)
that starts a short-lived MCP server over stdio (via the official rmcp
SDK), proxying query_logs/declare_param/declare_metric/create_dashboard/
add_panel/get_dashboard tool calls onto the existing HTTP API; and a
SKILL.md (069), embedded into the binary and served at GET /SKILL.md, that
documents how an agent connects to and drives a running instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 13:57:30 +01:00
Hugh KaznowskiandClaude Sonnet 5 de9ba6f01b Resolve tickets 059-061: generic log-stream columns + sort helper indexes
- 059: log stream header/column detection is now generic
  whitespace-columnar splitting (2+ spaces, single tab) instead of one
  fixed six-column regex. Declared params come from whatever columns
  the header actually names, plus a trailing unlabelled message field
  that's never swallowed by the last named column.
- 060: timeline sort-by-property now walks an incrementally-maintained
  per-sort-key helper index (BTreeMap keyed so "has the param, ordered
  by value" sorts before "missing it, ordered by ottyl_seq") instead of
  materializing and re-sorting the full filtered range on every query.
- 061: helper indexes are swept for staleness on ingest and dropped
  after 5 minutes unused; a purged (or never-built) index transparently
  rebuilds via the same lazy one-time scan on the next sorted query.
- Also invalidate a param's helper index on manual re-declaration,
  removal, or ClearParams, so a redefined regex can't leave a sort
  helper index serving values derived under the old declaration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 13:23:10 +01:00
Hugh KaznowskiandClaude Sonnet 5 4ace654019 Resolve tickets 058, 062-066: frontend UI fixes
- 058: poll loop now refetches param declarations so auto-declared
  params appear live without a page reload
- 062: browser tab title says "ottyl" instead of "webapp"
- 063: declared params list omits the redundant capture-group suffix
  when it matches the param name
- 064: dashboard autosave no longer clobbers in-flight PromQL edits;
  reconciliation is guarded against edits made after the save request
  was sent
- 065: filter bar placeholder/help icons lead with filter-bar syntax
  instead of regex
- 066: filter bar surfaces the active time-range preset/sort order and
  resyncs its draft when the value prop changes externally

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 13:04:26 +01:00
Hugh Kaznowski a03ec1fb93 Create tickets 2026-09-06 13:00:21 +01:00
102 changed files with 14581 additions and 9310 deletions
+3
View File
@@ -1,3 +1,6 @@
/target
target/
.claude/worktrees/
profile.json.gz
.codex
+88
View File
@@ -0,0 +1,88 @@
# AGENTS.md
<!-- Add your project instructions above or below the Repowise section. Repowise only updates the managed section between markers. -->
<!-- REPOWISE_AGENTS:START — Do not edit below this line. Auto-generated by Repowise. -->
## Codebase Intelligence for ottyl (Repowise)
Indexed by [Repowise](https://repowise.dev). Last indexed: 2026-09-07 (commit 920629d). Confidence: 100%.
### How to work in this repo
- **Trust the index.** `verified: true` and `_meta.complete` mean the bytes were checked against the live tree, so never re-read them. Re-read only what `bounds: "approximate"` or `_meta.stale_warning` names. `confidence` rates the prose, not the evidence: on `low` read the `fallback_targets` or `best_guesses` the reply names, and run `repowise update` and ask again if `_meta.hint` says the index is behind HEAD. `index_behind: true` alone is informational.
- **A zero carries its basis.** An empty `callers`/`callees`/`used_by` comes with a `*_basis` saying how much of that language's calls the graph resolved, so read it before concluding nothing calls a symbol. `_meta.scope_hint` names the areas the answer did not touch.
- **Pre-edit, not instead-of-edit.** These tools decide *which* files to read and edit. Reading a file before you edit it is correct and expected.
- **Noisy commands** (tests, builds, `git log`/`diff`, searches, listings): prefer `repowise distill <cmd>`, the same command with its exit code preserved and errors-first output. A `[repowise#<ref>: N lines omitted]` marker is recoverable via `repowise expand <ref>` (add `-q <regex>` to filter); never re-run the command to see omitted output.
- **Recording a decision** you had to reason out: `repowise decision add --title T --decision D` records it without prompting and prints the id (`--format json` to parse it back). It lands `proposed`, for a person to confirm.
### Tools
| Tool | When and why |
|------|--------------|
| `get_answer(question)` | First call for any how/where/why question. Cite `confidence: "high"` or `grounding: "extracted"` directly; `degraded` means judge by `retrieval_quality`. `symbol_bodies` has live bodies. |
| `get_context(targets=[...])` | Triage card for files/modules/symbols: docs, signatures, hotspot, fix history. No source bytes — `include=["skeleton"]` for the whole file verified, `["callers"|"decisions"]` for depth. Batch targets. |
| `get_symbol(id, depth?)` | **Follow-up, not an entry point** — one verified body for an id a prior response named (`path.py::Name`, `path.py:140-180`, `repowise#<hex>`). Never walk a file symbol by symbol; Read it. |
| `search_codebase(query)` | Hybrid search, auto-routed by query shape; force with `mode=symbol|path|concept|hybrid`. A hit whose `sources` are `[fts]` only has no semantic agreement, so verify it. |
| `get_why(query, targets?)` | Why the code is shaped this way: decision records, git archaeology, rationale comments. Call before a refactor or a pattern divergence. |
| `get_risk(targets, changed_files?, include?)` | File history and structural reach. PR mode leads with `directive`; its 0-10 structural heuristic is uncalibrated, not a probability. Read typed test recommendations and coverage state first. |
| `get_change_risk(revspec?, extensions?, exclude_patterns?)` | Deterministic live-diff review signal for a commit or range. Lead with benchmarked percentile/classification; the 0-10 diff-shape score is supporting, not a probability. `get_risk` scores paths. |
| `get_health(targets?, include?)` | Defect / maintainability / performance scores and findings. Self-check the files you touched before finishing. |
| `get_dead_code(tier?, min_confidence?, safe_only?)` | Confidence-tiered unreachable files / unused exports / zombie packages. For cleanup sweeps, not targeted fixes. |
| `get_overview()` | Architecture map. Call once, first, in an unfamiliar repo; skip it after that. |
### Architecture
**Files:** 81 | **Lines:** 18237 | **Import cycles:** 1
ottyl is a rust codebase of 81 files. Execution starts at src/main.rs, webapp/src/App.tsx, webapp/src/main.tsx. ---
*Built from the code's structure. It states what is there, not why it is that
way.
### Key modules
- `webapp/src` — webapp/src · webapp/src/components · webapp/src/lib · webapp/src/lib/api
**Language:** typescript | **Files:** 30 | **Public symbols:** 97…
- `webapp/src/lib` — webapp/src/lib · webapp/src/lib/api
**Language:** typescript | **Files:** 14 | **Public symbols:** 67 / 74
Covers the 14 source files in 2…
- `src/engine` — src/engine
**Language:** rust | **Files:** 14 | **Public symbols:** 122 / 531
Covers the 14 source files in src/engine
- `webapp/src/components` — webapp/src/components
**Language:** typescript | **Files:** 14 | **Public symbols:** 29 / 94
Covers the 14 source files in…
- `src` — src · src/api · src/cli · src/config · src/flavour
**Language:** rust | **Files:** 10 | **Public symbols:** 300 / 598
Covers the 10 source…
- `src/engine/snapshot` — src/engine/snapshot
**Language:** rust | **Files:** 6 | **Public symbols:** 0 / 139
Covers the 6 source files in src/engine/snapshot
- `webapp` — webapp · webapp/src
**Language:** typescript | **Files:** 3 | **Public symbols:** 1 / 19
Covers the 3 source files in 2 directories under…
### Entry points
- `src/main.rs`
- `webapp/src/main.tsx`
- `webapp/src/App.tsx`
### Files that need care (bug-fix history first, then churn — check `get_risk` before editing)
- `webapp/src/App.tsx` — 3 bug fixes, last fix today; 22 commits/90d
- `webapp/src/components/Dashboards.tsx` — 3 bug fixes, last fix yesterday; 17 commits/90d
- `webapp/src/components/Params.tsx` — 2 bug fixes, last fix yesterday; 15 commits/90d
- `webapp/src/components/NavMenu.tsx` — 1 bug fix, last fix today; 22 commits/90d
- `webapp/src/components/Docs.tsx` — 1 bug fix, last fix today; 17 commits/90d
### Code health
Three co-equal signals: defect risk 5.83/10 avg, hotspot health 4.96/10 (stable), worst `src/engine/snapshot/mod.rs` at 2.0/10 · maintainability 7.4/10 · performance risk 16 open static I/O-in-loop / N+1 findings. Detail: `get_health()`.
Critical files:
- `webapp/src/components/LogPreview.tsx` — untested hotspot — impact −2.0
- `webapp/src/lib/useSettingsPanel.ts` — untested hotspot — impact −2.0
- `webapp/src/components/NavMenu.tsx` — change entropy — impact −1.3
- `src/main.rs` — change entropy — impact −1.3
- `webapp/src/App.tsx` — change entropy — impact −1.3
### Commands
- Build: `make build`
- Test: `make test`
- Lint: `make lint`
- Dev: `make dev`
<!-- REPOWISE_AGENTS:END -->
+3 -1
View File
@@ -27,7 +27,7 @@ Backend: Rust (axum + tokio), single binary, in `src/`. Frontend: React 19 + Typ
**Data flow:** `main.rs` wires everything together at startup — loads `Settings`, spawns the engine actor task, replays the active profile's param/metric declarations into it (before ingest starts, since there's no way to reprocess earlier lines later), then starts ingest (stdin/file tailing and/or Prometheus polling) and the axum HTTP server.
- **`engine.rs`** (largest file, ~3400 lines) — the actor owning all live state: ingested log lines, declared params/metrics, computed metric buckets, alert evaluation. Runs as a single tokio task; all access is through `EngineHandle` sending `EngineMessage` over an mpsc channel and awaiting a response via oneshot — there is no shared mutable engine state accessed directly from request handlers. This is the file to read first to understand how declarations, filtering, and bucketing actually work.
- **`engine/`** — owns all live state, split across two actor tasks under `engine/actor/`: `actor/primary.rs` (ingested log lines, param declarations) and `actor/profile.rs` (metric declarations, computed metric buckets, alert evaluation — moved off the primary per `ideas/0001-actor-split.md`). Both are plain `tokio::spawn`ed loops; `actor/mod.rs` holds the `left_right`-based `IndexReader`/`IndexReadHandle` read plumbing they share. All access is through `EngineHandle` (defined in `actor/primary.rs`, re-exported from `engine/mod.rs`) sending `EngineMessage` over an mpsc channel and awaiting a response via oneshot — there is no shared mutable engine state accessed directly from request handlers. `engine/snapshot/` is the state (`EngineSnapshot`) both actors mutate; this is the place to start reading to understand how declarations, filtering, and bucketing actually work.
- **`api.rs`** — axum routes and HTTP-facing `ApiState` (holds `EngineHandle` + `SettingsHandle`). Translates HTTP requests into `EngineMessage`s and DTOs back into JSON. Also serves the embedded webapp.
- **`settings.rs` / `profile.rs`** — persisted configuration. A **profile** is per-app: parsing rules (param/metric declarations) and dashboards, saved so they're there again next time that app runs through ottyl (see README's "Profiles"). Declarations are persisted here so the engine can replay them on restart; `api.rs` handlers that mutate engine state (e.g. `declare_metric`) call a corresponding persist method here so the two stay in sync — this dual-write is a recurring pattern (see `ApiState::persist_param_declaration` / `persist_metric_declaration` in `api.rs`), and adding a new declaration-mutating endpoint means updating both sides plus the load-time replay path.
- **`filter_parser.rs`** (grammar in `filter_parser.pest`, via `pest`) + **`filter_datetime.rs`** — parse the search/filter bar syntax (regex matches, metric comparisons like `latency_ms[value]<200`, relative/absolute datetimes).
@@ -43,3 +43,5 @@ Frontend structure: `webapp/src/App.tsx` is the shell; `webapp/src/components/`
- **`tickets/`** holds scoped, written-up work items (problem statement, pointers to exact call sites with `file.rs:line` references, and an explicit scope section) — check here for planned/in-flight work before starting something that might overlap. Tickets are removed from this directory once implemented (see recent commit history — e.g. "Remove ticket 047 (implemented: ...)").
- Declaration-related features (params, metrics) consistently need three coordinated changes: an `EngineMessage` variant + handler in `engine.rs`, a persistence method in `settings.rs`/`profile.rs`, and an `api.rs` route wiring them together (plus a webapp UI affordance). Grep for an existing similar declaration (e.g. how metric declaration is wired) as the template before adding a new one.
- Doc comments in this codebase tend to explain *why* a non-obvious choice was made (e.g. ordering constraints, a workaround, a past ticket's rationale) rather than restating what the code does — match that style rather than adding descriptive comments.
- All markdown files (`*.md`) in this repo should follow one-sentence-per-line: each sentence starts on its own line, even within the same paragraph.
Generated
+421
View File
@@ -70,6 +70,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -200,6 +206,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -265,6 +272,46 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "darling"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.5",
]
[[package]]
name = "darling_macro"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core",
"quote",
"syn 3.0.5",
]
[[package]]
name = "directories"
version = "6.0.0"
@@ -297,6 +344,18 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@@ -355,6 +414,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "futures"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
@@ -362,6 +436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -370,6 +445,40 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -382,12 +491,32 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "generator"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -415,6 +544,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
@@ -639,6 +774,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -679,6 +820,18 @@ dependencies = [
"quote",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -708,6 +861,23 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "left-right"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee"
dependencies = [
"crossbeam-utils",
"loom",
"slab",
]
[[package]]
name = "libc"
version = "0.2.189"
@@ -750,12 +920,34 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
@@ -801,6 +993,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -832,6 +1033,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
name = "ottyl"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"clap",
@@ -840,11 +1042,14 @@ dependencies = [
"http-body-util",
"humantime",
"include_dir",
"left-right",
"nom",
"pest",
"pest_derive",
"petname",
"regex",
"reqwest",
"rmcp",
"serde",
"serde_json",
"tempfile",
@@ -875,6 +1080,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -923,6 +1134,27 @@ dependencies = [
"pest",
]
[[package]]
name = "petname"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb4653a82ec48e6d324c788597aadf46449bfc7b8e76d2a03b513a944325c88"
dependencies = [
"petname-macros",
"rand",
]
[[package]]
name = "petname-macros"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c406fb544296f598ccf69b82bcf1b12440c8f23ebf75946105f86304f16eb2"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "phf"
version = "0.11.3"
@@ -1082,6 +1314,26 @@ dependencies = [
"thiserror",
]
[[package]]
name = "ref-cast"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -1163,6 +1415,41 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmcp"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5"
dependencies = [
"chrono",
"futures",
"indexmap",
"pastey",
"pin-project-lite",
"rmcp-macros",
"schemars",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
name = "rmcp-macros"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda"
dependencies = [
"darling",
"proc-macro2",
"quote",
"serde_json",
"syn 3.0.5",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1238,6 +1525,38 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schemars"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.5",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1274,6 +1593,17 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
@@ -1310,6 +1640,15 @@ dependencies = [
"serde",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -1447,6 +1786,15 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -1510,6 +1858,20 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -1564,9 +1926,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -1574,6 +1948,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -1624,6 +2028,23 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "walkdir"
version = "2.5.0"
+5 -1
View File
@@ -18,7 +18,11 @@ humantime = "2"
directories = "6.0.0"
fs-embed = "0.2.4"
include_dir = "0.7.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
petname = { version = "3.2.0", default-features = false, features = ["default-rng", "default-words"] }
rmcp = { version = "3.2.0", default-features = false, features = ["server", "macros", "transport-io"] }
anyhow = "1"
left-right = "0.11.8"
[dev-dependencies]
tempfile = "3.27.0"
+4 -1
View File
@@ -23,7 +23,8 @@ test-fe:
lint: lint-be lint-fe
lint-be:
cargo clippy --all-targets
cargo fmt -- --check
cargo clippy --all-targets -- -D warnings
lint-fe:
cd webapp && npm run lint
@@ -40,3 +41,5 @@ dev:
clean:
cargo clean
rm -rf webapp/dist webapp/node_modules
rm profile.json.gz
+152
View File
@@ -0,0 +1,152 @@
---
name: ottyl
description: Query and configure a running ottyl observability instance (logs, params, metrics, dashboards, settings, alerts, profiles) via its MCP server.
---
# ottyl
ottyl turns a piped command's stdout into a searchable log timeline plus ad-hoc metrics and
dashboards, served from a single process. This doc covers how an agent connects to and drives a
*running* instance.
Everything the web UI can do to change the running instance's state is exposed as an MCP tool —
declarations, dashboards, settings, alerts, profiles. Chart panels aren't screenshotted: use
`render_panel` / `render_dashboard` to get a chart's underlying series data back as JSON, so you
can reason about it directly instead of needing a browser.
## Find a session
Each `ottyl` process registers itself under a name (random, e.g. `comic-gazelle`, unless started
with `--name <name>`). List known sessions:
```
ottyl sessions
# comic-gazelle port=51423 pid=8421
```
## Connect an MCP client
Start the MCP server (a separate short-lived process) pointed at that session, and register it
with your MCP client (e.g. Claude Code's `.mcp.json`):
```
ottyl mcp --session comic-gazelle
# or, if you already know the port:
ottyl mcp --port 51423
```
This is a thin proxy onto the target instance's HTTP API — each tool below maps to one or more
`/api/...` routes.
## Get the latest SKILL.md
This file is compiled into the `ottyl` binary, so a copy you have on disk can lag behind the
version a running instance actually serves (e.g. after an upgrade). A running instance always
serves its own current copy read-only over HTTP — fetch it directly rather than trusting a stale
local file:
```
curl http://localhost:<port>/SKILL.md
```
Use the port from `ottyl sessions` (or the `--port`/`--session` you passed to `ottyl mcp`). There
is no CLI subcommand for this and no way to update it at runtime — the served content only changes
when the binary is rebuilt with a new `SKILL.md`.
## Tools
Most tools that operate on a profile take an optional `profile` argument; omit it to use the
instance's active profile.
### Logs, timeline, stats
- **`query_logs`** — count or read log lines matching a FilterBar query, with time/sequence range
and sort options.
```json
{ "filter": "regex(\"error\")", "count_only": true }
```
- **`get_timeline`** — bucketed log-count histogram matching an optional filter (the data behind
the Log Timeline chart).
- **`get_stats`** — overall ingest stats: log count, sequence/timestamp bounds, ingest lag.
### Params
- **`list_params`** — list declared params (including auto-derived ones).
- **`declare_param`** — extract a named value from matching log lines.
```json
{ "name": "status_code", "message_match_regex": "status=(\\d+)", "capture_group": "1", "kind": "number" }
```
- **`clear_params`** — clear all declared params and re-derive auto params from retained logs.
### Metrics
- **`list_metrics`** — list declared metrics.
- **`declare_metric`** — bucket event counts/values by label, derived from matching log lines.
```json
{ "name": "errors_total", "message_match_regex": "level=error", "label_params": ["status_code"] }
```
- **`update_metric`** — update an existing metric declaration in place (`name` plus the same
fields as `declare_metric`).
- **`delete_metric`** — delete a declared metric by name.
- **`get_metric_bucket_values`** — read a metric's raw bucket values, optionally filtered by
label (`labels: "status_code=500"`) and time/sequence range.
### PromQL
- **`run_promql_query`** — evaluate a PromQL-style query directly, returning raw series data
(`labels` + timestamped `points`) — the same evaluation dashboards use.
```json
{ "promql": "rate(errors_total[5m])" }
```
### Dashboards & panels
- **`list_dashboards`** — list dashboards saved in a profile.
- **`create_dashboard`** — create an empty dashboard.
```json
{ "name": "Errors" }
```
- **`get_dashboard`** — read a dashboard's panel definitions (promql/chart_type per panel, not
rendered data).
- **`delete_dashboard`** — delete a dashboard.
- **`add_panel`** — add a panel, or update one in place by `panel_id`.
```json
{ "dashboard_id": "<id>", "title": "Errors over time", "promql": "rate(errors_total[5m])", "chart_type": "line" }
```
- **`delete_panel`** — remove a panel from a dashboard by `panel_id`.
- **`render_panel`** — resolve one panel's promql, evaluate it, and return `{ panel, series }` as
JSON — this is how to "see" a chart without a browser.
```json
{ "dashboard_id": "<id>", "panel_id": "<id>" }
```
- **`render_dashboard`** — same as `render_panel` but for every panel in a dashboard at once,
returning `{ dashboard, panels: [{ panel, series }, ...] }`.
### Settings & theming
- **`get_settings`** / **`update_settings`** — read/update the selected theme, active profile,
`max_logs`, ntfy topic/server.
- **`list_themes`** — list the names of the themes embedded in this build.
- **`get_theme_content`** — read a theme's raw CSS content by name.
### Alerts
- **`list_alert_rules`** — list configured alert rules.
- **`create_alert_rule`** / **`update_alert_rule`** — `condition` is one of:
- `{"kind":"immediate","query":"..."}`
- `{"kind":"threshold","query":"...","op":"gt|lt|ge|le|eq|ne","threshold":N,"window_ms":N}`
- `{"kind":"newvalue","field_regex":"...","capture_group":"..."}`
- `{"kind":"ratio","query_a":"...","query_b":"...","op":"...","ratio":N,"window_ms":N}`
- **`delete_alert_rule`** — delete a rule by id.
- **`test_alert_notification`** — send a test ntfy notification to verify delivery config.
### Profiles
- **`list_profiles`** — list saved profiles with summary counts.
- **`create_profile`** — create a new, empty profile.
```json
{ "name": "my-service" }
```
- **`delete_profile`** — delete a profile by name.
- **`activate_profile`** — activate a profile, swapping in its declarations and dashboards as the
running instance's live state.
View File
+317
View File
@@ -0,0 +1,317 @@
# Idea: split the engine's single actor into a log-ingestion worker + per-profile worker(s)
## Original note
> This needs to be fleshed out with ai.
>
> The general idea is that the primary engine ingesting worker is
> doing too much. It should only parse logs to extract params.
>
> Since profiles carry additional (non-derived) params, metrics,
> dashboards, and panels - those should be stored separately and can
> be handled by a different worker. The worker would have read access
> to the primary log sequence and derived params index, but would
> construct it's own profile perspective. It has write access and
> then
>
> The profiles need to be informed of new messages to index.
>
> What if message needs to be different format? Thats per-profile.
>
> System param suppressions?
>
> Regex indexing? We probably want text indexing on the primary while
> also
>
> Values in the primary index should be arcs, to prevent cloning
> allocations.
## Resolved decisions (2026-09-07)
Clarified interactively; supersedes the open questions raised below where
they conflict.
- **No profiling gate.** Proceeding on the code-reading evidence already in
this doc (double/triple string clones per line, full-`EngineSnapshot`
deep-clone on every `left_right` reconciliation) rather than waiting on a
`samply` profile first.
- **Three actors, not two.** The split is: **reader actor** (tails
stdin/file, splits into lines, timestamps, wraps each line in an `Arc`,
sends downstream) → **primary actor** (ingests timestamped `Arc` lines,
extracts declared + auto params only, maintains the `param=value` index —
no metrics, no profile logic) → **profile actor** (holds its own
`left_right` reader over the primary's index; builds metrics, custom
params, timeseries, and PromQL evaluation for the one active profile).
This resolves the "per-profile message format" question below: there is
no per-profile parsing fork on the primary — format/shape parsing of an
already-classified line is entirely the profile actor's concern, on top
of the shared raw text.
- **Still exactly one active profile at a time.** No concurrent
multi-profile indexing; the split changes *how* the one active profile's
data is computed relative to the primary, not the user-facing
single-active-profile model.
- **Alerts move to the profile actor.** Alert rules are profile-scoped and
read metric buckets, so they move with metric computation rather than
staying on the primary.
- **Primary→profile signaling:** the primary actor notifies subscribers
(e.g. a `tokio::sync::watch` carrying the latest processed
position/index) right after each `left_right` sync completes. The
profile actor wakes on that signal and re-reads whatever's newly visible
through its own `left_right` reader — no busy-polling, no second copy of
the data in transit.
- **Eviction wins over profile-actor lag.** If the profile actor is
behind (e.g. mid-backfill on a newly declared metric) when the primary
evicts old lines (`SetMaxLogs`), the primary evicts on schedule
regardless — the profile actor silently misses data for the evicted
range. Simplest option, matches today's single-actor retention model.
- **System param suppressions: dropped from scope.** Not needed right now;
revisit as a separate idea if it becomes a real requirement.
- **Migration path: incremental**, in this order:
1. Arc-ify stored values (`Arc<str>`/`Arc<String>` for log text and
metric-captured strings) — independent win, and the load-bearing
precondition for cheap cross-actor reads in steps 2–3.
2. Extract metric computation into a separately-callable stage, still
within the single actor.
3. Split into the reader/primary/profile actors described above.
## Current architecture (confirmed in code)
- **One process, one actor, today.** `spawn_engine`
(`src/cli/mod.rs:95`) calls `engine::spawn(...)` exactly once per
server run. `load_active_profile` (`src/cli/mod.rs:105-124`) loads
*one* active profile's param/metric declarations into that single
engine at startup, before ingest starts, with an explicit comment
explaining why: "there's no way to reprocess earlier lines later, so
this must happen before `spawn_ingest`" (`src/cli/mod.rs:104-106`).
All other `engine::spawn` call sites (`src/api/mod.rs:853,1459`,
several in `src/config/profile.rs`) are test harnesses, not a second
production engine instance.
- The actor loop is `run()`, `src/engine/handle.rs:130-365` — a single
`while let Some(message) = rx.recv().await` over one
`mpsc::Receiver<EngineMessage>` (`:141`). Every mutation (`Ingest`,
`IngestMetricSample`, `DeclareParam`/`RemoveParamDeclaration`,
`ClearParams`, `DeclareMetric`/`UpdateMetric`/
`RemoveMetricDeclaration`, `SetMaxLogs`, `EnsureSortHelperIndex`,
`Barrier` — full list `src/engine/message.rs:30-63`) funnels through
this one channel and one task.
- **Queries already bypass this actor.** `EngineQuery`
(`src/engine/message.rs:12-27`) doesn't go through the mpsc channel
— `EngineHandle::ask` reads directly off a `left_right`
`IndexReader`/`ReadHandle` (`src/engine/handle.rs:49-73`, entry point
`src/api/mod.rs:218-219`, evaluated by `handle_query_ro`,
`src/engine/snapshot/mod.rs:856-858`). So read-side scaling is
already decoupled from the actor loop today — this idea is entirely
about the **write/mutation** path.
- Per-line ingest (`EngineMessage::Ingest` arm,
`src/engine/handle.rs:143-219`) does, synchronously, in one
`left_right` write op, per line: ingest-lag/watermark bookkeeping,
log-stream header detection, storing the line as
`DataValue::LogLiteral`, auto-param detection
(`auto_declare_params`), running **every declared metric's regex**
against the line (`apply_all_metrics` →
`apply_metric_to_log`/`resolve_param_value`,
`src/engine/snapshot/mod.rs:409-460,821-851`), sort-helper-index
maintenance, log eviction, and then — after publish —
`alerts::evaluate_alerts` against the resulting snapshot
(`handle.rs:199-219`), which reads metric buckets that were just
computed in the same message.
- Module size: `src/engine/` is ~5.5k lines excluding tests (`snapshot/
mod.rs` alone is 1100 lines — the whole `EngineSnapshot` impl per its
own header comment).
- **No `Arc`, everywhere owned data.** The index is one flat
`BTreeMap<IndexKey, DataValue>` (`snapshot/mod.rs:43`) of owned
`String`s (`DataValue::LogLiteral { text: String }`,
`src/engine/data_value.rs:81`). Per ingested line, the raw text is
cloned at least twice for storage alone (`handle.rs:175,184-186`),
and every metric that matches the line clones its own copy of the
label/value strings it captures (`apply_metric_to_log`,
`mod.rs:426,439,444`) — with N metrics referencing overlapping
params, one line produces N independent owned-String allocations of
essentially the same substrings.
- **`left_right`'s reconciliation deep-clones everything.** After a
batch of writes, `left_right::Absorb::sync_with` clones the *entire*
`EngineSnapshot` (`*self = first.clone()`, `handle.rs:29-31`) to
reconcile the read-side copy — i.e. the "values should be Arc" note
isn't just about avoiding per-line clones, it's also about avoiding
a full-`BTreeMap`-of-Strings deep clone on every reconciliation.
- **No text/regex index exists.** `CompiledFilter::Regex(re) =>
re.is_match(text)` (`snapshot/filter.rs:159`) runs per line at query
time; `IndexKey` (`src/engine/index_key.rs:8-32`) has no
inverted/full-text structure. Every filter-bar query re-scans and
re-matches the whole retained range.
- **Backfill is a full synchronous history replay today, in the same
actor.** `declare_metric`/`update_metric`
(`snapshot/mod.rs:330-391`) → `backfill_metric` (`:481-509`) collects
**all** retained lines into a `Vec` and replays
`apply_metric_to_log` over every one, inside the single actor's
message handling — blocking ingest/alerts/other declarations for the
duration. `load_into_engine` (`src/config/profile.rs:332-384`) does
this once per metric, awaiting each response, so loading a profile
with N metrics against L retained lines costs O(N·L) synchronous
scan time. This is also the mechanism [[0004-profile-switch-reindexing-cost]]
describes for *runtime* profile switching via the `activate_profile`
API handler (`src/api/mod.rs:712-768`, which removes then
re-declares via the same messages) — worth reconciling that this
research pass separately characterized profile activation at
startup as one-shot ("no way to reprocess earlier lines later"),
while the earlier research for ticket 099/idea 0004 confirmed a
*runtime* reload path exists via `activate_profile`. Both are true:
startup load is one-shot by necessity (ingest hasn't started yet),
and the runtime reload path reuses the same declare/backfill
machinery after the fact — but this is worth double-checking before
design work starts, since the two research passes framed it
differently.
- **Alerts are coupled to the single actor's local state.** Alert
evaluation runs directly in the ingest arm against the just-mutated
snapshot (`handle.rs:199-211`, `alerts::evaluate_alerts`,
`src/engine/alerts.rs:211`), reading `alert_states:
HashMap<String, AlertRuntimeState>` that lives on the single actor
task's own stack frame (`handle.rs:137`) — there's no mechanism today
for one actor to read another actor's in-memory state.
- **Existing multi-task precedent is producer-side only.**
`src/engine/ingest.rs`'s stdin/file-tailing and Prometheus-polling
tasks each hold a cloned `EngineHandle` and push into the *same*
actor's mpsc channel (spawned at `src/cli/mod.rs:136-145`) — a
plausible pattern for "many producers, one consumer," but there's no
existing precedent for multiple *consuming* actors each owning their
own `left_right` write side that need to stay coherent with each
other or with a shared upstream.
## Implications and things to consider
### Performance
- The stated goal — "the primary worker should only parse logs to
extract params" — would remove per-line metric-regex evaluation
(`apply_all_metrics`) from the primary's critical path, which is
real work today (N metrics × regex match per line). That's a
genuine win *if* profile workers can consume the primary's output
without re-paying an equivalent cost themselves — but since a
profile worker still has to run its own metrics' regexes against
each line to build its own buckets, the total CPU work done across
the process doesn't shrink; what changes is that it's no longer all
serialized on one task, so it can run concurrently with the primary
(a real win on a multi-core box) and a slow/backfilling profile
worker no longer blocks raw ingestion.
- The "values in the primary index should be Arcs" change is
independently valuable regardless of whether the actor split
happens — it directly addresses the confirmed double/triple string
cloning per line (storage clone + per-metric label/value clones) and
the full-snapshot deep-clone on every `left_right` reconciliation.
Worth sequencing as a prerequisite: switching to `Arc<str>` (or
`Arc<String>`) for stored text means a profile worker reading "the
primary log sequence" can cheaply hold references to the same
underlying bytes instead of needing its own copies — this is likely
a load-bearing precondition for the split being worth it at all, not
a nice-to-have alongside it.
- A profile worker needing "read access to the primary log sequence
and derived params index" implies either (a) a second `left_right`
reader over the primary's structures (cheap, if values are Arc'd —
see above) or (b) a message-replay feed (the primary notifies
profile workers of new lines, per the note's "profiles need to be
informed of new messages to index"). Option (b) reintroduces a
second data copy per profile worker unless the message itself only
carries an `Arc`/index reference rather than owned data — otherwise
this recreates the same N-copies-per-line problem this idea is
trying to remove, just distributed across processes/tasks instead of
within one.
- Backfill cost (O(N metrics × L retained lines), see [[0004-profile-switch-reindexing-cost]])
doesn't disappear with a split — it moves to whichever worker owns
the profile. The upside is it no longer blocks the *primary's*
ingest/query-serving while it happens, which is the main perf
argument for doing this at all, per that idea's "Performance"
section.
- Full-text/regex indexing on the primary (per the note's "Regex
indexing?") is a materially different performance investment from
the Arc/split work — an inverted or trigram index trades ingest-time
CPU and memory for query-time speed on filter-bar searches, and is
worth scoping as its own effort (see [[0005-additional-read-indexes]])
rather than folded into this split, since it doesn't depend on the
actor topology.
### Code structure
- This is the single largest structural change on the idea list —
`src/engine/` (~5.5k lines) currently assumes one writer owning one
`left_right` pair. Splitting introduces: a defined "primary
perspective" (raw logs + derived/auto params only, no
profile-specific metrics) as its own bounded module, a
"profile perspective" abstraction for the one active profile, a
notification/subscription mechanism from primary to the profile actor
(the note's "profiles need to be informed of new messages to index" —
doesn't exist in any form today; the closest analogue, `ingest.rs`'s
producer tasks feeding one consumer, runs the opposite direction).
**Resolved** (see "Resolved decisions"): the primary signals via e.g. a
`tokio::sync::watch` after each `left_right` sync completes, and the
profile actor re-reads through its own `left_right` reader rather than
the primary pushing data. A query-fan-out/merge layer is still needed
for any request
that needs both raw logs and profile-derived data in one response
(today `Logs`/`Timeline` queries read both from a single
`EngineSnapshot` in one pass, e.g. `query.rs:154`'s
`log_entry_params` call against `compiled_params` from the same
snapshot — splitting the data sources means this becomes a real join
across two snapshots/workers instead of one borrow).
- Alert evaluation (`handle.rs:199-211`) currently depends on running
immediately after metric bucketing in the same message, against the
same snapshot. **Resolved** (see "Resolved decisions"): alerts move
with metric computation into the profile actor, since alert rules are
profile-scoped and read the metric buckets computed there.
- `EngineHandle`'s current API (`handle.rs:49-73` for reads,
the mpsc `EngineMessage` send for writes) would need to become two
APIs, or one handle type parameterized by "primary" vs. "profile" —
worth deciding whether `api.rs`'s handlers (which currently hold one
`EngineHandle` in `ApiState`) address a single combined handle that
internally fans out, or explicitly juggle a primary handle plus an
active-profile handle.
- Given `CLAUDE.md`'s existing note that `engine.rs` is "the file to
read first" and already the largest/highest-churn file in the repo
(13 commits/90d per the repowise summary), this split is also a
legibility opportunity, not just a performance one — today a reader
has to understand ingest, metrics, alerts, and backfill as one
intertwined concern to understand any one of them.
### Functionality
- **Per-profile message format** — **resolved**, see "Resolved decisions"
above: there's no per-profile parsing fork on the primary. Log-stream
header detection and auto-param extraction stay a one-time, primary-side,
profile-agnostic concern (`derive_log_stream_params_from_row`,
`auto_declare_params`); any further message-shape interpretation the
profile actor needs happens independently, on top of the shared raw
`Arc` text, in the profile actor itself.
- **System param suppressions** — **dropped from scope** (see "Resolved
decisions"). Revisit as a separate idea if it becomes a real need.
- **Concurrency** — **resolved**: still strictly one active profile at a
time. The split changes *how* that one profile's data is computed
relative to the primary, not the user-facing model — no multi-profile
concurrent indexing.
### Not yet considered
- **Failure/restart semantics** — partially resolved: the profile actor
falling behind the primary during a backfill is expected and accepted
(see "Resolved decisions" — eviction wins over lag, silent gaps for the
evicted range). Still open: what happens if the profile actor *crashes*
outright (not just lags) — does it restart and replay from whatever the
primary currently retains, or is there a persisted checkpoint? Not yet
decided; likely fine to punt until the split is actually implemented,
since "restart and rebuild from currently-retained history" is a
reasonable default consistent with the accepted-gaps decision above.
- **Retention/eviction coordination** — **resolved**, see "Resolved
decisions": no low-water-mark. The primary evicts on its own schedule
(`evict_excess_logs`, `handle.rs:195`, `SetMaxLogs`) regardless of the
profile actor's progress; a lagging profile actor silently misses data
for the evicted range rather than blocking primary eviction.
- **Testing story**: `src/engine/tests.rs` (2348 lines) and the
`test_handle` pattern (see ticket 099) are built entirely around one
actor with one channel — a split roughly doubles the kinds of
integration tests needed (primary alone, profile worker alone,
primary+profile interaction) and the existing test harness would
need real rework, not just extension. Still open — no resolution yet.
- **Migration path** — **resolved**, see "Resolved decisions": incremental,
in three steps (Arc-ify stored values → extract metric computation into
a still-single-actor stage → split into reader/primary/profile actors).
- **Whether this is worth it at all without profiling evidence** —
**resolved**, see "Resolved decisions": proceeding without a `samply`
profile, on the code-reading evidence already documented in this file.
@@ -0,0 +1,158 @@
# Idea: dependent/nested param extraction and jq-style JSON extraction
## Original note
1. A param currently auto-detects or is declared via a regex against
the raw log line — that's the baseline, already true today.
2. Semantics for "has a value": if the message has a non-empty or
missing capture for the target group, treat that as the param
having a value (as opposed to today's behavior — needs
confirmation, see Open questions).
3. Extract a param from *another param's already-captured string*,
applying a second regex on top of it, rather than always
re-matching the raw line.
4. When a string param (or the whole message) is JSON, extract fields
via `jq` syntax rather than the current plain dot-path.
## Current state (confirmed in code)
- `ParamDeclaration` (`src/engine/data_value.rs:6-20`) supports exactly
one regex + one named capture group per param, or one JSON
dot-path, or the computed `elapsed_since_last` — these three modes
are mutually exclusive (validated in
`src/engine/snapshot/mod.rs:618-633`).
- `extract_param_raw` (`src/engine/snapshot/params.rs:85-99`) *always*
operates on the raw ingested line's text — there is no "source
param" concept anywhere in `ParamDeclaration`/
`ParamDeclarationRecord`/`ParamDeclarationDto`. Every param
independently re-parses the same raw line; none can chain off
another param's captured value.
- JSON extraction (`extract_json_field`, `src/engine/data_value.rs:22-39`)
requires the whole line to parse as a JSON object, and walks a plain
dot-separated path through nested objects only — no array indexing,
no jq filters/pipes/functions. Frontend surface:
`webapp/src/components/Params.tsx:24-33` (gates the "JSON field"
option on `looksLikeJsonObject`), free-text path input, no
path-builder/autocomplete.
- No `jq` crate or jq-like dependency exists in the codebase today.
## Why this is an idea, not a ticket yet
This is a real feature addition with several open design questions
that change its shape and scope significantly — it's not a
well-bounded bug fix or mechanical change.
## Open questions
- **Chaining model**: should a param declaration gain an optional
"source param" field (extract from another param's value instead of
the raw line)? If so, declarations form a small dependency graph —
what happens on a cycle, or when the source param has no value for a
given line (does the dependent param also have no value, silently)?
Does extraction order need to become topological instead of the
current independent-per-declaration model?
- **Missing/empty capture semantics**: what should currently happen
when a regex matches but the named group didn't participate (e.g. an
optional group in an alternation)? Worth checking
`extract_param_raw`'s exact behavior before assuming this needs a
change — it may already do the right thing.
- **jq scope**: is full jq syntax the actual goal, or would extending
the existing dot-path extractor with array indexing (`items[0].id`)
cover most real cases far more cheaply than pulling in a jq
implementation (e.g. the `jaq` crate) as a new dependency? jq's
filter language (pipes, `select()`, `map()`) is a much bigger surface
to expose safely in a declaration UI than a path string.
- **UI shape**: `Params.tsx`'s declaration form is built around one
regex/one capture group or one JSON path; a chained/jq model needs a
materially different UI (e.g. "extract from: [raw message ▾] or
[param: session_id ▾]" plus a jq expression box with live preview
against a sample line).
## Suggested next step
Worth a short design pass (e.g. `ce-brainstorm` or a quick
conversation) to settle the chaining model and jq-vs-extended-dot-path
question before this gets broken into tickets — the two features (2/3
and 4) could also ship independently rather than as one big change.
## Implications and things to consider
### Performance
- Chained extraction means a line can now do N regex/JSON passes
instead of 1 per param, and a dependent param's cost is paid *after*
its source param already ran — worst case, a profile with a long
dependency chain multiplies per-line ingest cost by chain depth.
This lands directly in the single actor's per-line ingest path (see
[[0001-actor-split]] — if that split happens first, param extraction
is exactly the workload it isolates, so it's worth sequencing this
after that decision rather than before).
- A `jq`-style engine (even a Rust one like `jaq`) is meaningfully
more expensive per invocation than a compiled `regex::Regex` capture
— evaluating a jq filter against a parsed `serde_json::Value` for
*every* ingested line for *every* JSON-extraction param is a
different cost profile than today's simple dot-path walk. Worth
benchmarking before committing to full jq rather than assuming it's
free.
- Topological ordering of dependent params (if declarations form a
DAG) adds a per-declaration-change cost (recompute order) but not a
per-line cost, as long as the order is cached after each
add/remove/edit rather than recomputed per line.
### Code structure
- `extract_param_raw` (`src/engine/snapshot/params.rs:85-99`) currently
has a flat signature `(text: &str, declaration) -> Option<String>`.
Chaining requires either passing in the full set of already-extracted
param values for the current line (so a declaration can look up its
source param's value) or restructuring extraction as a fold over
declarations in dependency order — a real shape change to this
function and its caller, not a param-by-param tweak.
- The "three mutually exclusive modes" validation in
`src/engine/snapshot/mod.rs:618-633` needs a fourth mode (or an
orthogonal "source" field crossed with the existing regex/JSON
modes) — worth deciding whether source-param and JSON-field are
independent axes (e.g. "JSON-extract from param X" as well as from
the raw line) since that combination is implied by note #3.
- This is exactly the shape of change flagged in this repo's
`CLAUDE.md` as needing three coordinated updates (engine message +
handler, settings/profile persistence, api.rs route) plus a frontend
affordance — touches more surface area than the "Current state"
section alone suggests.
### Functionality
- Cycle detection isn't optional once chaining exists — a user could
declare param B from param A and (accidentally, via UI) param A from
param B. Needs either a hard validation error at declare-time or a
documented "first N declarations in insertion order" fallback.
- Auto-derived params (JSON auto-flattening,
`flatten_json_object`/`flatten_json_object_into`,
`src/engine/data_value.rs:41-60+`) aren't user declarations — decide
whether a chained/jq param can source from an *auto-derived* param,
which would tie this feature to auto-detection internals that
weren't designed to be a stable "source" API.
- Backfill: declaring a new dependent param triggers the same
`backfill_metric`-style history replay as any param declaration
(see [[0004-profile-switch-reindexing-cost]] for the general
backfill-cost concern) — a chain of N dependent params means N
passes over retained history on declare, not just N passes per new
line going forward.
### Not yet considered
- Error/partial-failure UX: what does the UI show for a param whose
jq expression is syntactically valid but fails on a particular
line's JSON shape (e.g. missing key, type mismatch) — silent
no-value, or a visible per-line extraction error surfaced somewhere?
- Whether jq's more powerful operations (`map`, `select`, arithmetic)
should be allowed at all in a param *value* context, where the
result is expected to be a single scalar — most of jq's power is
about reshaping/filtering collections, which doesn't map cleanly
onto "this param has one value per log line."
- No investigation yet into how this interacts with `sort_by`
(`SortHelperKey`) or PromQL — do chained/jq-derived params get the
same downstream treatment (sortable, usable in metric declarations,
usable in dashboard queries) as directly-extracted params, or are
there gaps once the extraction pipeline is no longer "one regex
against the raw line"?
+117
View File
@@ -0,0 +1,117 @@
# Idea: timeline ordering — confirm/harden the sequence-is-primary invariant
## Original note
"The timeline is time ordered. That is always derived from the
sequence ordering. That means the primary sequence is simply sequence
ordered." — reads as a statement of intent/invariant rather than a bug
report, so it's unclear whether this needs a code change or is just
confirming existing behavior.
## Current state (confirmed in code)
- `IndexKey::IngestedLog { sequence, timestamp }`
(`src/engine/index_key.rs:10-13`) derives `Ord` with `sequence` as
the primary field and `timestamp` only as a (practically unused)
tie-breaker, since `sequence` is already unique.
- `sequence` is a strictly monotonic counter incremented per ingested
line (`src/engine/ingest.rs:13,52`); `timestamp` is a separate
wall-clock `Timestamp::now()` read at the same point in the ingest
loop (`src/engine/ingest.rs:18,93`) — not derived from `sequence`.
- The default log listing path (`logs_in_sequence_range`,
`src/engine/snapshot/mod.rs:180-208`, used by the `Logs` query in
`src/engine/snapshot/query.rs:118-172`) is explicitly sequence-order,
with an existing comment documenting this
(`src/engine/snapshot/mod.rs:176-179`).
- The only other ordering mode is `sort_by` (sort by a declared param's
value, sequence as tie-break, via `SortHelperKey`,
`src/engine/snapshot/sort.rs`) — there's no "sort by timestamp" mode
distinct from sequence order.
- **The one place ordering is genuinely timestamp-driven**: the
`Timeline` query (`src/engine/snapshot/query.rs:173-198`) buckets
logs by `timestamp.as_millis() / bucket_ms`
(`:191`), not by sequence.
## Why this is an idea, not a ticket
The invariant the note describes already appears to hold for the
default log listing — sequence is the primary and effectively only
ordering key there. The one place `timestamp` (not `sequence`) drives
grouping is the `Timeline` bucketing, and `sequence`/`timestamp` are
captured independently rather than one being derived from the other.
In the current single-threaded ingest loop they're expected to
co-vary, but nothing guards against a backward wall-clock step (NTP
correction, clock skew) between two `Timestamp::now()` calls — if that
ever happened, sequence-ordered views and timestamp-bucketed views
could disagree on relative order for the affected lines. This has not
been observed as an actual bug; it's a latent gap worth a decision.
## Open question
Is the ask just "confirm this holds" (in which case: it does, for the
default listing — no code change needed), or do you want the
`Timeline` bucketing hardened so it can never diverge from sequence
order even under clock skew (e.g. clamp each line's timestamp to be
`>= previous line's timestamp`, or derive bucket assignment from
sequence position rather than raw wall-clock read)? The fix is small
either way, but scoping it as a ticket needs that decision first.
## Implications and things to consider
### Performance
- Clamping `timestamp` to be monotonic (`max(this_line_ts,
previous_line_ts)`) is O(1) per line — negligible cost, just one
extra comparison in the existing ingest loop
(`src/engine/ingest.rs:13-18,52,93`). Deriving bucket assignment from
sequence instead of wall-clock would be a larger change (see below)
but isn't inherently more expensive at ingest time.
- No performance concern either way — this is a correctness/consistency
question, not a hot-path cost question.
### Code structure
- If bucket assignment moves from wall-clock `timestamp` to something
sequence-derived, `Timeline`'s bucketing
(`src/engine/snapshot/query.rs:173-198`) still fundamentally needs a
*time value* per bucket (users read timeline buckets as "logs between
10:32:00 and 10:32:05", not "logs 100-150") — sequence can't replace
timestamp as the bucketing axis, only guard it. The realistic fix is
the monotonic-clamp approach, not a switch to sequence-based
bucketing.
- Clamping at ingest time (`ingest.rs`) is the natural single place to
fix this, rather than patching every read path that consumes
`timestamp` — confirm there's exactly one ingest entry point today
(there appear to be two: stdin/file tailing and Prometheus metrics
polling, per `src/engine/ingest.rs` — a clock-skew guard needs to
apply to both, and they'd need to share one "last observed timestamp"
reference point to clamp against consistently).
### Functionality
- A clamped timestamp is no longer "true" wall-clock time for that
line — if any UI surface displays a log's literal ingest timestamp
(as opposed to just using it for bucketing), clamping would make a
displayed timestamp lie slightly during a clock step-back event. Low
probability, but worth deciding whether clamping is silent or
surfaced (e.g. a flag on the log entry noting its timestamp was
adjusted).
- This only matters at all during an actual backward clock step, which
in most deployments (a piped CLI process, not a long-running server
through NTP corrections) may never occur in practice — worth weighing
whether this is worth any code change versus documenting it as an
accepted limitation.
### Not yet considered
- No investigation into whether the same two independently-captured
timestamp/sequence pattern exists anywhere else besides ingest (e.g.
alert evaluation timestamps, or the Prometheus-polling ingest path
specifically) — the research so far only confirmed the stdin/file
ingest loop.
- No check on how a forward clock jump (not just backward) would
interact with `Timeline` bucketing — a large forward jump would
create a sparse/empty bucket range, which is a different (probably
more common) failure mode than the backward-step case this idea
focuses on, and might be worth addressing in the same pass if one
happens.
@@ -0,0 +1,142 @@
# Idea: profile switching triggers a full synchronous re-index per metric
## Original note
"Metrics and dashboards are tied with the profile. However, they are
indexed on the engine. Switching profiles means re-indexing." — a
description of a real coupling; unclear whether it's being flagged as
a performance problem to fix or just an architectural note to be aware
of.
## Current state (confirmed in code)
- `Profile` (`src/config/profile.rs:309-318`) is pure config — params,
metrics, dashboards — with no live engine state of its own.
`Settings::set_active_profile` (`src/config/profile.rs:489-502`)
only flips which profile is active and persists to disk; it doesn't
touch the engine.
- The actual switch side effect lives in the API layer:
`activate_profile` (`src/api/mod.rs:746-768`) calls
`reload_engine_declarations` (`src/api/mod.rs:712-744`), which:
1. Removes every current param/metric declaration from the engine
(`EngineMessage::RemoveParamDeclaration`/`RemoveMetricDeclaration`,
skipping auto-derived params).
2. Calls `profile.load_into_engine(engine)`
(`src/config/profile.rs:332-384`), re-declaring every param/metric
in the newly-activated profile.
- There is no dedicated `EngineMessage` for "switch profile" — it's
entirely remove-everything-then-redeclare-everything via the
existing declare/remove messages
(`src/engine/message.rs:30-63` has no such variant).
- Each metric redeclare triggers a full rebuild: `declare_metric`
(`src/engine/snapshot/mod.rs:330-335`) →
`resolve_backfill_and_store_metric` → `backfill_metric`
(`:481-509`), which replays **all retained log history**
(`self.logs_range()`) through `apply_metric_to_log` for that metric.
Raw ingested lines themselves are never cleared on switch — only
metric/param declarations and their derived buckets are dropped and
rebuilt.
- This all happens synchronously, one metric at a time, inside the
engine's single-writer actor loop
(`src/engine/handle.rs:303-338`) — so switching profiles blocks that
loop for a duration that scales with retained log volume × number of
metrics in the newly-activated profile.
## Why this is an idea, not a ticket
Whether this is worth fixing depends on facts not available from
reading code alone: typical retained log volume in real usage, typical
metric count per profile, and how often users actually switch profiles
mid-session. Without knowing the current switch latency is actually
noticeable, a fix would be speculative optimization.
## Options if this turns out to matter
- Diff-based reload: only remove/re-add metrics whose *definition*
actually changed between the old and new profile, instead of
removing everything and redeclaring everything (a metric present
unchanged in both profiles currently still gets a full remove +
backfill for no reason).
- Make the removal/backfill sequence async relative to the HTTP
response, with the UI showing a "reindexing…" state rather than
blocking the request (and the engine loop) until complete.
- Lazily backfill only the time range currently being viewed, deferring
the rest — a bigger change, and only worth it if the switch cost is
dominated by backfill work the user doesn't immediately look at
anyway.
## Suggested next step
Before scoping a ticket: confirm with a profile that has a realistic
number of metrics and log volume whether switch latency is actually
perceptible. If it is, the diff-based reload (first option) is the
cheapest fix and a reasonable first ticket.
## Implications and things to consider
### Performance
- Cost today is O(retained lines × metrics in new profile), done
synchronously in the single-writer engine actor
(`src/engine/handle.rs:303-338`) — every other engine message
(ingest, queries, alert evaluation) queues up behind a profile switch
until it finishes, so the blast radius isn't just "the switch feels
slow", it's "the whole app appears frozen" for that duration,
including live ingestion of new lines arriving mid-switch.
- The diff-based reload option only helps when profiles share metrics
— if switching to a profile with a mostly-disjoint metric set (the
common case for genuinely different apps/profiles, which is the
whole point of profiles), it doesn't reduce the backfill cost much.
It's a bigger win for "tweak one metric in the currently active
profile and reactivate" workflows than for switching between
unrelated profiles.
- If [[0001-actor-split]] happens, this problem's shape changes
significantly — a per-profile worker rebuilding its own perspective
from a shared primary index could do that rebuild without blocking
ingestion or query-serving on the primary at all, which addresses the
"whole app freezes" part of this problem as a side effect, even if
the total backfill work stays the same. Worth reading that idea
alongside this one.
### Code structure
- The remove-then-readd flow lives entirely in the API layer
(`src/api/mod.rs:712-768`), not the engine — a diff-based reload
would need `reload_engine_declarations` to compute a diff (metrics
present in old profile but not new → remove; new but not old →
declare; present in both — compare full declaration equality, not
just name, to decide if a changed regex/threshold needs a rebuild)
before sending any engine messages. That diff logic is new code, not
a variant of existing code.
- Async/non-blocking reload would need a new `EngineMessage` variant
(today there is none for "switch profile" — it's just existing
declare/remove messages) if the goal is to let the engine
acknowledge the switch request immediately and do backfill work
in the background while still serving other queries — this is a
bigger structural change than diff-based reload, since the engine's
single-actor model currently has no notion of "background work
interleaved with message handling."
### Functionality
- If reload becomes async/background, the UI needs a defined
"reindexing in progress" state — what does a dashboard panel show
for a metric that's declared but not yet backfilled? An empty chart
is misleading (looks like "no data") versus a genuinely no-data
metric.
- Partial-failure semantics: if backfill for metric 3 of 5 fails or is
interrupted (e.g. app restart mid-switch), what state is left behind?
Today's synchronous approach has no partial-state problem because it
either completes or the whole process is still running; async reload
introduces a new failure mode that doesn't exist today.
### Not yet considered
- No data gathered yet on real profile sizes/log volumes in actual
usage (flagged already in "Why this is an idea") — this entire idea
is speculative until that's known.
- No consideration of whether profile switching happens automatically
(e.g. auto-detected based on the piped command) or is always a
manual user action — if it's always manual and rare, the UX cost of
a blocking-but-rare operation may be perfectly acceptable and not
worth any of the above complexity.
+128
View File
@@ -0,0 +1,128 @@
# Idea: additional indexes to speed up reads
## Original note
"Ask AI to come up with additional indexes that will help speed up
reads." — an open-ended research request rather than a scoped
change; this note captures what's already known so a future deep-dive
doesn't start from zero.
## Current index surface (confirmed in code)
- `IndexKey` (`src/engine/index_key.rs`) — the engine's indexing key
space includes at least `IngestedLog { sequence, timestamp }`,
`Bucket` (metric bucket data), and `LastMetricEvent`. Ordering is
primary-`sequence` for logs (see
[[0003-timeline-ordering-invariant]] for detail).
- `SortHelperKey` (`src/engine/snapshot/sort.rs`) — a secondary index
keyed by a declared param's value (plus sequence as tie-break),
built to support `sort_by` queries; has explicit staleness handling
(`purge_stale_sort_helper_indexes`, referenced from ticket 081's
history) tied to new bucket ingestion.
- `.claude/CLAUDE.md`'s repowise health summary already flags "19 open
static I/O-in-loop / N+1 findings" and calls out `src/engine.rs`'s
`handle_query` as a function/brain-method hotspot — likely overlaps
with whatever a fresh performance pass on read paths would surface,
worth checking those findings first (`get_health()` /
`get_risk(['src/engine.rs'])` via the repowise MCP tools, once the
repo's index is initialized — it wasn't available this session, see
note below) rather than starting from a blank slate.
- Related, already-ticketed read-path work referenced in prior ticket
history: ticket 077/078 (metric bucket range narrowing — "narrow the
range, don't rescan everything") and ticket 081 (PromQL dashboard
queries rebuild full series from scratch per poll) — both were
framed around the same "avoid full rescans" principle this idea is
chasing generally.
## Why this is an idea, not a ticket
This is explicitly a "come up with ideas" ask, not a pre-scoped
change — it needs a dedicated investigation (likely with actual
profiling under a representative workload, per this project's
`samply`-based profiling convention) rather than guesses from reading
code structure alone. Ticket 081's history is a useful precedent here:
079/080 (also performance tickets) were removed for lacking "profiling
evidence to justify the fix" — so a fix in this space should follow
`samply record` evidence, not speculation.
## Note
The repowise MCP tools reported no index for this repo this session
("This repository has no repowise index yet") despite
`.claude/CLAUDE.md` describing one as current — worth running `repowise
init --yes` (or checking why the existing index isn't being picked up)
before the next performance-focused session, since `get_risk`/
`get_health`/`get_change_risk` would materially speed up this kind of
investigation.
## Suggested next step
Profile a representative workload with `samply` against the read paths
(`Logs`, `Timeline`, dashboard PromQL panel queries) to find actual
bottlenecks, then propose specific new index structures (or narrowings
of existing ones) grounded in what the flamegraph shows — rather than
guessing at indexes with no evidence they'd matter.
## Implications and things to consider
### Performance
- Any new index is a write-time cost paid on every ingested line in
exchange for a read-time win — worth stating explicitly per proposed
index, since the engine's ingest path is already doing param
extraction + metric bucketing + alert evaluation per line
(see [[0001-actor-split]]'s findings on how much work already happens
synchronously per line); an expensive new index (e.g. a full-text
inverted index for regex/text search) could turn ingest into the new
bottleneck instead of read.
- A text/regex index in particular is a different kind of structure
than the existing `IndexKey`/`SortHelperKey` model — those are
ordered key→value indexes; full-text search typically wants an
inverted index (token → line positions) or a trigram index, which is
a new subsystem, not a new `IndexKey` variant. Worth scoping as its
own investigation rather than bundling into "additional indexes"
generally.
- Memory cost matters as much as CPU cost here — this is a
for-the-life-of-the-process in-memory tool (per `CLAUDE.md`'s
description), so any new index's memory footprint competes directly
with how much log history a user can retain before running out of
RAM. A `samply` CPU profile alone won't surface this — a memory
profile of retained-line-count vs. RSS would be needed too.
### Code structure
- New indexes need to hook into the same lifecycle the existing ones
do: built incrementally at ingest, invalidated/pruned when retention
drops old lines, and rebuilt on backfill (declare/profile-switch)
the same way `SortHelperKey`'s `purge_stale_sort_helper_indexes`
does. A proposal that only covers "how to build it" without covering
eviction/backfill parity with existing indexes is incomplete.
- If [[0001-actor-split]] happens first, new indexes would likely be
scoped to whichever worker owns the data they index (e.g. a
profile-specific index lives with that profile's worker, not the
primary log worker) — worth sequencing this investigation after that
architectural decision rather than before, so new indexes aren't
designed against a data-ownership model that's about to change.
### Functionality
- Read-path speedups are invisible to users unless something is
currently slow enough to notice — without a concrete complaint or
profiling evidence of an actual slow query, this risks becoming
effort spent on indexes nobody was blocked on. The `samply`-first
approach in "Suggested next step" is meant to guard against exactly
this.
### Not yet considered
- No investigation yet into what "reads" actually means for the
intended scope — dashboard panel polling (already covered by ticket
081's history), the log timeline's default view, ad-hoc filter-bar
queries, and the MCP server's query surface (`src/api/mcp_server.rs`)
are all "reads" with potentially different bottlenecks; a single
"additional indexes" effort may need to be split once profiling
shows which of these is actually slow.
- No check on whether retention limits already bound the problem —
if there's a hard cap on retained log lines, some read-path costs may
already be bounded in practice regardless of indexing, which changes
how much this is worth investing in.
+206
View File
@@ -0,0 +1,206 @@
use crate::config::Settings;
use crate::config::profile::{Panel, Profile};
use crate::engine::alerts::AlertCondition;
use crate::engine::data_value::{MetricPrimitiveKind, MetricSemantics};
use crate::engine::metric_primitive::MetricPrimitive;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize)]
pub struct LogEntryDto {
pub sequence: u64,
pub timestamp_ms: i64,
pub text: String,
pub params: BTreeMap<String, MetricPrimitive>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ParamDeclarationDto {
pub name: String,
pub message_match_regex: String,
pub capture_group: String,
pub json_field_path: Option<String>,
pub kind: MetricPrimitiveKind,
pub elapsed_since_last: bool,
pub auto_derived: bool,
#[serde(default)]
pub reserved: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct MetricDeclarationDto {
pub name: String,
pub message_match_regex: String,
pub label_params: Vec<String>,
pub value_param: Option<String>,
pub matched_labels: Option<BTreeMap<String, String>>,
pub unmatched_labels: Option<BTreeMap<String, String>>,
pub semantics: MetricSemantics,
}
#[derive(Debug, Clone, Serialize)]
pub struct TimelineBucketDto {
pub start_ms: i64,
pub count: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatsDto {
pub log_count: u64,
pub first_sequence: Option<u64>,
pub last_sequence: Option<u64>,
pub first_timestamp_ms: Option<i64>,
pub last_timestamp_ms: Option<i64>,
pub approx_bytes_total: u64,
pub ingest_lag_high_watermark_ms: i64,
}
#[derive(Debug, Deserialize)]
pub struct LogsParams {
pub since_sequence: Option<u64>,
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub limit: Option<usize>,
pub filter: Option<String>,
pub sort_by: Option<String>,
pub sort_missing_fallback: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct TimelineParams {
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub bucket_ms: Option<i64>,
pub filter: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BucketValueDto {
pub labels: BTreeMap<String, MetricPrimitive>,
pub sequence: u64,
pub timestamp_ms: i64,
pub value: MetricPrimitive,
}
#[derive(Debug, Deserialize)]
pub struct BucketValuesParams {
pub labels: Option<String>,
pub since_sequence: Option<u64>,
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct PromqlSeriesDto {
pub labels: BTreeMap<String, String>,
pub points: Vec<(i64, f64)>,
}
#[derive(Debug, Deserialize)]
pub struct PromqlParams {
pub promql: String,
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct ErrorDto {
pub error: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SettingsDto {
pub theme: String,
pub active_profile: Option<String>,
pub max_logs: Option<usize>,
pub ntfy_topic: Option<String>,
pub ntfy_server: Option<String>,
}
impl From<&Settings> for SettingsDto {
fn from(settings: &Settings) -> Self {
SettingsDto {
theme: settings.theme.clone(),
active_profile: settings.active_profile.clone(),
max_logs: settings.max_logs,
ntfy_topic: settings.ntfy_topic.clone(),
ntfy_server: settings.ntfy_server.clone(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest {
pub theme: Option<String>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub max_logs: Option<Option<usize>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub ntfy_topic: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub ntfy_server: Option<Option<String>>,
}
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
#[derive(Debug, Deserialize)]
pub struct CreateAlertRuleRequest {
pub name: String,
pub enabled: bool,
pub condition: AlertCondition,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAlertRuleRequest {
pub name: String,
pub enabled: bool,
pub condition: AlertCondition,
}
#[derive(Debug, Deserialize)]
pub struct TestAlertNotificationRequest {
pub topic: String,
pub server: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProfileSummaryDto {
pub name: String,
pub param_count: usize,
pub metric_count: usize,
pub dashboard_count: usize,
}
impl From<&Profile> for ProfileSummaryDto {
fn from(profile: &Profile) -> Self {
ProfileSummaryDto {
name: profile.name.clone(),
param_count: profile.params.len(),
metric_count: profile.metrics.len(),
dashboard_count: profile.dashboards.len(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateProfileRequest {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateDashboardRequest {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct SaveDashboardRequest {
pub name: String,
#[serde(default)]
pub panels: Vec<Panel>,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
pub mod sessions;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
use crate::config::Settings;
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
format!(
"Path to the settings file. Defaults to {}.",
crate::config::default_settings_path()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "a per-OS config location".to_string())
)
});
#[derive(Parser, Debug)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
#[arg(short, long, default_value_t = 0)]
port: u16,
#[arg(short, long, help = SETTINGS_PATH_HELP.as_str())]
settings_path: Option<PathBuf>,
#[arg(short, long)]
file: Option<PathBuf>,
#[arg(short, long)]
metrics_url: Option<String>,
#[arg(short, long)]
name: Option<String>,
}
#[derive(Subcommand, Debug)]
enum Command {
Mcp {
#[arg(short, long)]
session: Option<String>,
#[arg(short, long)]
port: Option<u16>,
},
Sessions,
}
pub async fn run() {
let args = Args::parse();
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
crate::config::default_settings_path()
.expect("could not determine a default settings path; pass --settings-path")
});
match args.command {
Some(Command::Mcp { session, port }) => {
run_mcp(&settings_path, session, port).await;
return;
}
Some(Command::Sessions) => {
print_sessions(&settings_path);
return;
}
None => {}
}
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
let engine_tx = spawn_engine(&settings).await;
load_active_profile(&settings, &settings_path, &engine_tx).await;
spawn_ingest(&args, &engine_tx);
let app = crate::api::router(engine_tx, settings, settings_path.clone());
let listener = TcpListener::bind((crate::consts::DEFAULT_BIND_HOST, args.port))
.await
.expect("failed to bind listener");
let bound_port = listener
.local_addr()
.expect("failed to read back bound address")
.port();
register_session_and_log(&settings_path, &args, bound_port);
axum::serve(listener, app).await.expect("server error");
}
async fn spawn_engine(settings: &Arc<Mutex<Settings>>) -> crate::engine::EngineHandle {
let engine_tx = crate::engine::spawn(settings.clone(), crate::consts::ENGINE_CHANNEL_CAPACITY);
let max_logs = settings.lock().unwrap().max_logs;
let _ = engine_tx
.tx
.send(crate::engine::EngineMessage::SetMaxLogs(max_logs))
.await;
engine_tx
}
/// Resolves the active profile (creating the Default profile if none exists yet) and, if
/// found, replays its declarations into the engine before ingest starts — there's no way to
/// reprocess earlier lines later, so this must happen before `spawn_ingest`.
async fn load_active_profile(
settings: &Arc<Mutex<Settings>>,
settings_path: &std::path::Path,
engine_tx: &crate::engine::EngineHandle,
) {
let (active_profile_name, active_profile) = {
let mut settings_guard = settings.lock().unwrap();
settings_guard
.ensure_default_profile(settings_path)
.expect("failed to ensure the Default profile exists");
let active_profile_name = settings_guard.active_profile.clone();
let active_profile = active_profile_name
.as_deref()
.and_then(|name| settings_guard.profile(name).cloned());
(active_profile_name, active_profile)
};
match (active_profile_name, active_profile) {
(Some(_), Some(active_profile)) => {
active_profile.load_into_engine(engine_tx).await;
}
(Some(profile_name), None) => {
eprintln!(
"warning: active profile '{profile_name}' not found in {}; skipping",
settings_path.display()
);
}
(None, _) => {}
}
}
fn spawn_ingest(args: &Args, engine_tx: &crate::engine::EngineHandle) {
match args.file.clone() {
Some(path) => {
tokio::spawn(crate::engine::ingest::run_file(engine_tx.clone(), path));
}
None => {
tokio::spawn(crate::engine::ingest::run(engine_tx.clone()));
}
}
if let Some(metrics_url) = args.metrics_url.clone() {
tokio::spawn(crate::engine::ingest::run_metrics_poll(
engine_tx.clone(),
metrics_url,
));
}
}
fn register_session_and_log(settings_path: &std::path::Path, args: &Args, bound_port: u16) {
let source = match &args.file {
Some(path) => format!("tailing {}", path.display()),
None => "reading logs from stdin".to_string(),
};
let session_name = args.name.clone().unwrap_or_else(sessions::generate_name);
match sessions::register_session(settings_path, &session_name, bound_port) {
Ok(_) => eprintln!(
"ottyl serving on http://localhost:{} ({}), session name: {}",
bound_port, source, session_name
),
Err(err) => eprintln!(
"ottyl serving on http://localhost:{} ({}); warning: failed to register session '{}': {}",
bound_port, source, session_name, err
),
}
}
async fn run_mcp(settings_path: &std::path::Path, session: Option<String>, port: Option<u16>) {
use rmcp::ServiceExt;
let port = match (port, session) {
(Some(port), _) => port,
(None, Some(name)) => match sessions::resolve_session(settings_path, &name) {
Some(info) => info.port,
None => {
eprintln!("error: no running ottyl session named '{name}'");
std::process::exit(1);
}
},
(None, None) => {
eprintln!("error: `ottyl mcp` requires --session <name> or --port <port>");
std::process::exit(1);
}
};
let base_url = format!("http://localhost:{port}");
let service = crate::api::mcp_server::OttylMcp::new(base_url)
.serve(rmcp::transport::stdio())
.await
.expect("failed to start MCP server");
service.waiting().await.expect("MCP server error");
}
fn print_sessions(settings_path: &std::path::Path) {
let dir = sessions::sessions_dir(settings_path);
let sessions = sessions::list_sessions(&dir);
if sessions.is_empty() {
println!("no known ottyl sessions");
return;
}
for session in sessions {
println!(
"{}\tport={}\tpid={}",
session.name, session.port, session.pid
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_path_defaults_to_none() {
let args = Args::parse_from(["ottyl"]);
assert_eq!(args.settings_path, None);
}
#[test]
fn settings_path_flag_overrides_default() {
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
assert_eq!(
args.settings_path,
Some(PathBuf::from("/tmp/custom-settings.json"))
);
}
}
+162
View File
@@ -0,0 +1,162 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionInfo {
pub name: String,
pub port: u16,
pub pid: u32,
}
pub fn sessions_dir(settings_path: &Path) -> PathBuf {
settings_path
.parent()
.map(|parent| parent.join("sessions"))
.unwrap_or_else(|| PathBuf::from("sessions"))
}
fn session_file_path(dir: &Path, name: &str) -> PathBuf {
dir.join(format!("{name}.json"))
}
#[cfg(unix)]
fn is_pid_alive(pid: u32) -> bool {
process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(process::Stdio::null())
.stderr(process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(true)
}
#[cfg(not(unix))]
fn is_pid_alive(_pid: u32) -> bool {
true
}
pub fn cleanup_stale(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Ok(contents) = fs::read_to_string(&path) else {
continue;
};
let Ok(info) = serde_json::from_str::<SessionInfo>(&contents) else {
continue;
};
if !is_pid_alive(info.pid) {
let _ = fs::remove_file(&path);
}
}
}
pub fn generate_name() -> String {
petname::petname(2, "-").unwrap_or_else(|| format!("session-{}", process::id()))
}
pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Result<PathBuf> {
let dir = sessions_dir(settings_path);
fs::create_dir_all(&dir)?;
cleanup_stale(&dir);
let info = SessionInfo {
name: name.to_string(),
port,
pid: process::id(),
};
let path = session_file_path(&dir, name);
fs::write(&path, serde_json::to_string_pretty(&info)?)?;
Ok(path)
}
pub fn resolve_session(settings_path: &Path, name: &str) -> Option<SessionInfo> {
let dir = sessions_dir(settings_path);
let contents = fs::read_to_string(session_file_path(&dir, name)).ok()?;
let info: SessionInfo = serde_json::from_str(&contents).ok()?;
is_pid_alive(info.pid).then_some(info)
}
pub fn list_sessions(dir: &Path) -> Vec<SessionInfo> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut sessions: Vec<SessionInfo> = entries
.flatten()
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json"))
.filter_map(|entry| fs::read_to_string(entry.path()).ok())
.filter_map(|contents| serde_json::from_str(&contents).ok())
.collect();
sessions.sort_by(|a, b| a.name.cmp(&b.name));
sessions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_session_writes_correct_port() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let path = register_session(&settings_path, "foo", 4242).unwrap();
assert!(path.exists());
let info = resolve_session(&settings_path, "foo").unwrap();
assert_eq!(info.port, 4242);
assert_eq!(info.name, "foo");
assert_eq!(info.pid, process::id());
}
#[test]
fn resolve_session_returns_none_for_unknown_name() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
assert!(resolve_session(&settings_path, "does-not-exist").is_none());
}
#[test]
fn stale_session_does_not_block_reregistration_under_same_name() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let sessions_dir_path = sessions_dir(&settings_path);
fs::create_dir_all(&sessions_dir_path).unwrap();
let stale = SessionInfo {
name: "foo".to_string(),
port: 1111,
pid: 999_999,
};
fs::write(
session_file_path(&sessions_dir_path, "foo"),
serde_json::to_string(&stale).unwrap(),
)
.unwrap();
register_session(&settings_path, "foo", 5555).unwrap();
let info = resolve_session(&settings_path, "foo").unwrap();
assert_eq!(info.port, 5555);
assert_eq!(info.pid, process::id());
}
#[test]
fn list_sessions_returns_all_entries_sorted_by_name() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
register_session(&settings_path, "zeta", 1).unwrap();
register_session(&settings_path, "alpha", 2).unwrap();
let sessions = list_sessions(&sessions_dir(&settings_path));
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "zeta"]);
}
}
+347
View File
@@ -0,0 +1,347 @@
pub mod profile;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::config::profile::Profile;
use crate::engine::alerts::AlertRule;
pub type SettingsHandle = Arc<Mutex<Settings>>;
/// Name of the selected theme file in `themes/` (see `THEMES` in `src/api/mod.rs`), or
/// `"system"` to follow the OS light/dark preference between the `dark`/`light` themes.
fn default_theme() -> String {
"system".to_string()
}
#[derive(Debug, Clone, Deserialize, Default)]
struct LegacyProfilesPointer {
#[serde(default)]
active_profile: Option<String>,
#[serde(default)]
#[allow(dead_code)]
profiles_dir: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum ProfilesOnDisk {
Inline(Vec<Profile>),
Legacy(LegacyProfilesPointer),
}
impl Default for ProfilesOnDisk {
fn default() -> Self {
ProfilesOnDisk::Inline(Vec::new())
}
}
#[derive(Debug, Clone, Deserialize)]
struct SettingsOnDisk {
#[serde(default)]
theme: Option<String>,
// Pre-theme-directory settings stored the selection as `color_scheme` (a fixed enum); a
// separate `custom_theme_id` pointed at an uploaded/linked CSS entry (both removed) and can't
// be mapped onto a theme file, so it's left out here and just ignored by serde on load.
#[serde(default)]
color_scheme: Option<String>,
#[serde(default)]
profiles: ProfilesOnDisk,
#[serde(default)]
active_profile: Option<String>,
#[serde(default)]
max_logs: Option<usize>,
#[serde(default)]
alert_rules: Vec<AlertRule>,
#[serde(default)]
ntfy_topic: Option<String>,
#[serde(default)]
ntfy_server: Option<String>,
}
impl From<SettingsOnDisk> for Settings {
fn from(on_disk: SettingsOnDisk) -> Self {
let (profiles, legacy_active_profile) = match on_disk.profiles {
ProfilesOnDisk::Inline(profiles) => (profiles, None),
ProfilesOnDisk::Legacy(pointer) => (Vec::new(), pointer.active_profile),
};
let theme = on_disk
.theme
.unwrap_or_else(|| match on_disk.color_scheme.as_deref() {
Some("grey") => "dark".to_string(),
Some(scheme) => scheme.to_string(),
None => default_theme(),
});
Settings {
theme,
profiles,
active_profile: on_disk.active_profile.or(legacy_active_profile),
max_logs: on_disk.max_logs,
alert_rules: on_disk.alert_rules,
ntfy_topic: on_disk.ntfy_topic,
ntfy_server: on_disk.ntfy_server,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(from = "SettingsOnDisk")]
pub struct Settings {
#[serde(default = "default_theme")]
pub theme: String,
pub profiles: Vec<Profile>,
pub active_profile: Option<String>,
#[serde(default)]
pub max_logs: Option<usize>,
#[serde(default)]
pub alert_rules: Vec<AlertRule>,
#[serde(default)]
pub ntfy_topic: Option<String>,
#[serde(default)]
pub ntfy_server: Option<String>,
}
impl Default for Settings {
fn default() -> Self {
Settings {
theme: default_theme(),
profiles: Vec::new(),
active_profile: None,
max_logs: None,
alert_rules: Vec::new(),
ntfy_topic: None,
ntfy_server: None,
}
}
}
pub fn default_settings_path() -> Option<PathBuf> {
ProjectDirs::from(
crate::consts::APP_QUALIFIER,
crate::consts::APP_ORGANIZATION,
crate::consts::APP_NAME,
)
.map(|dirs| dirs.config_dir().join(crate::consts::SETTINGS_FILE_NAME))
}
impl Settings {
pub fn load(path: &Path) -> Settings {
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str::<Settings>(&contents) {
Ok(mut settings) => {
settings.migrate_legacy_profiles_file(path);
settings.migrate_legacy_profile_shapes(path);
settings
}
Err(err) => {
eprintln!(
"warning: failed to parse settings file at {}: {err}; using defaults",
path.display()
);
Settings::default()
}
},
Err(err) if err.kind() == io::ErrorKind::NotFound => {
let mut settings = Settings::default();
settings.migrate_legacy_profiles_file(path);
if let Err(save_err) = settings.save(path) {
eprintln!(
"warning: failed to create default settings file at {}: {save_err}",
path.display()
);
}
settings
}
Err(err) => {
eprintln!(
"warning: failed to read settings file at {}: {err}; using defaults",
path.display()
);
Settings::default()
}
}
}
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(self)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
fs::write(path, contents)
}
fn migrate_legacy_profiles_file(&mut self, settings_path: &Path) {
if !self.profiles.is_empty() {
return;
}
let legacy_path = settings_path
.parent()
.map(|parent| parent.join("profiles"))
.unwrap_or_else(|| PathBuf::from("profiles"))
.join("profiles.json");
let Some(profiles) = crate::config::profile::Profile::load_legacy_store(&legacy_path)
else {
return;
};
self.profiles = profiles;
if let Err(err) = self.save(settings_path) {
eprintln!(
"warning: failed to persist profiles migrated from {}: {err}",
legacy_path.display()
);
return;
}
let migrated_path = legacy_path.with_extension("json.migrated");
if let Err(err) = fs::rename(&legacy_path, &migrated_path) {
eprintln!(
"warning: migrated profiles from {} into settings.json, but failed to rename the old file: {err}",
legacy_path.display()
);
}
}
fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) {
if crate::config::profile::migrate_legacy_profile_shapes(&mut self.profiles)
&& let Err(err) = self.save(settings_path)
{
eprintln!(
"warning: failed to persist migrated profile shapes to {}: {err}",
settings_path.display()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_settings_path_ends_with_settings_json() {
if let Some(path) = default_settings_path() {
assert_eq!(path.file_name().unwrap(), "settings.json");
assert!(path.parent().is_some());
}
}
#[test]
fn load_creates_default_when_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("settings.json");
assert!(!path.exists());
let settings = Settings::load(&path);
assert_eq!(settings, Settings::default());
assert!(path.exists(), "load() should persist a default file");
let reloaded = Settings::load(&path);
assert_eq!(reloaded, settings);
}
#[test]
fn legacy_grey_color_scheme_migrates_to_dark_theme() {
let settings: Settings = serde_json::from_str(
r#"{"color_scheme":"grey","css_entries":[],"custom_theme_id":null,"profiles":{"names":[],"active_profile":null,"profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
)
.unwrap();
assert_eq!(settings.theme, "dark");
}
#[test]
fn legacy_profiles_pointer_shape_deserializes_with_empty_inline_profiles() {
let settings: Settings = serde_json::from_str(
r#"{"color_scheme":"dark","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
)
.unwrap();
assert!(settings.profiles.is_empty());
assert_eq!(settings.active_profile, Some("web".to_string()));
}
#[test]
fn round_trips_through_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let mut settings = Settings {
theme: "dark".to_string(),
..Settings::default()
};
settings
.profiles
.push(crate::config::profile::Profile::new("default".to_string()));
settings.active_profile = Some("default".to_string());
settings.alert_rules.push(crate::engine::alerts::AlertRule {
id: "rule1".to_string(),
name: "error spike".to_string(),
enabled: true,
condition: crate::engine::alerts::AlertCondition::Immediate {
query: r#"regex("error")"#.to_string(),
},
});
settings.ntfy_topic = Some("alerts".to_string());
settings.save(&path).unwrap();
let loaded = Settings::load(&path);
assert_eq!(loaded, settings);
}
#[test]
fn save_creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a").join("b").join("settings.json");
let settings = Settings::default();
settings.save(&path).unwrap();
assert!(path.exists());
}
#[test]
fn load_migrates_legacy_profiles_file_into_inline_settings() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let profiles_dir = dir.path().join("profiles");
fs::create_dir_all(&profiles_dir).unwrap();
let legacy_path = profiles_dir.join("profiles.json");
fs::write(
&legacy_path,
r#"[{"name":"web","params":[],"metrics":[],"dashboards":[]}]"#,
)
.unwrap();
fs::write(
&settings_path,
r#"{"color_scheme":"system","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
)
.unwrap();
let settings = Settings::load(&settings_path);
assert_eq!(settings.profiles.len(), 1);
assert_eq!(settings.profiles[0].name, "web");
assert_eq!(settings.active_profile, Some("web".to_string()));
assert!(!legacy_path.exists());
let migrated_path = legacy_path.with_extension("json.migrated");
assert!(migrated_path.exists());
let reloaded = Settings::load(&settings_path);
assert_eq!(reloaded.profiles.len(), 1);
assert_eq!(reloaded.profiles[0].name, "web");
}
#[test]
fn load_without_legacy_file_leaves_profiles_empty() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let settings = Settings::load(&settings_path);
assert!(settings.profiles.is_empty());
assert!(!dir.path().join("profiles").exists());
}
}
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
use std::time::Duration;
// mpsc channel capacity between ingest/API callers and the engine actor task.
pub const ENGINE_CHANNEL_CAPACITY: usize = 1024;
// Hard ceiling on how many params `auto_declare_params` will ever create automatically over the
// life of the engine, so one unusual input can't inflate the declared-param set forever.
pub const MAX_AUTO_DECLARED_PARAMS: usize = 64;
// Per-line ceiling on how many *new* params a single ingested line may contribute to the
// auto-derived set, so one unusually wide line can't burn the whole per-run budget at once.
pub const MAX_NEW_AUTO_PARAMS_PER_LINE: usize = 8;
// How long a sort helper index may sit unused before it's purged and left to rebuild on demand.
pub const SORT_HELPER_INDEX_PURGE_AFTER: Duration = Duration::from_secs(300);
// Window and capacity for the rolling ingestion-to-engine-processing lag high watermark.
pub const INGEST_LAG_WATERMARK_WINDOW_MS: i64 = 5000;
pub const INGEST_LAG_WATERMARK_MAX_ENTRIES: usize = 5;
// Bucket width used to merge multiple sub-series into one combined time series.
pub const MERGE_WINDOW_MS: i64 = 1_000;
// Default timeline query bucket width when the caller doesn't specify one.
pub const DEFAULT_TIMELINE_BUCKET_MS: i64 = 1_000;
// Reserved param names usable without declaring anything (ottyl-synthesized columns).
pub const OTTYL_RESERVED_PARAM_NAMES: [&str; 3] = ["ottyl_seq", "ottyl_ts", "ottyl_fmt"];
// Idle poll cadence while tailing stdin/a file for new log lines.
pub const STDIN_POLL_INTERVAL: Duration = Duration::from_millis(200);
// Poll cadence for a configured Prometheus/OpenMetrics `--metrics-url`.
pub const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(15);
// Column-splitting regex fragments for the fixed-width logfmt column parser.
pub const COLUMN_SEPARATOR_PATTERN: &str = r"(?:\t|[ \t]{2,})";
pub const COLUMN_VALUE_PATTERN: &str = r"\S+(?: \S+)*";
// Name of the always-present profile every Settings file is seeded with.
pub const DEFAULT_PROFILE_NAME: &str = "Default";
// Name/label of the metric the Default profile is seeded with.
pub const SEEDED_METRIC_NAME: &str = "log_category";
pub const SEEDED_METRIC_LABEL: &str = "category";
// Host the HTTP server binds to; port is user-configurable, host is not.
pub const DEFAULT_BIND_HOST: &str = "0.0.0.0";
// App identity used to resolve the OS-conventional settings directory, and the settings file name.
pub const APP_QUALIFIER: &str = "";
pub const APP_ORGANIZATION: &str = "";
pub const APP_NAME: &str = "ottyl";
pub const SETTINGS_FILE_NAME: &str = "settings.json";
-154
View File
@@ -1,154 +0,0 @@
use crate::metric_primitive::MetricPrimitive;
use crate::timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// A named, standalone parameter: how to pull a value out of a matching log line, and what kind it
/// parses as. Ticket 037 unified this with the metric-declaration-embedded matcher that used to
/// exist alongside it (`RegexToMetricPrimitiveMatcher`) — a metric now references params by name
/// (`MetricDeclaration::label_params`/`value_param`) instead of redeclaring its own extractor per
/// param, so a param declared once (e.g. via the Params panel) can be reused as a label by one
/// metric and as the value by another without re-entering its regex/capture-group/kind.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParamDeclaration {
pub name: String,
pub message_match_regex: String,
pub capture_group: String,
/// Alternative extraction source to `capture_group`: a dot-separated JSON field path (e.g.
/// `foo.bar`), read from lines that parse as a JSON object instead of via the regex capture
/// group. When present and non-empty, this takes precedence over `capture_group`.
/// `#[serde(default)]` so profile files/requests saved before this field existed still load
/// as plain regex-capture-group params.
#[serde(default)]
pub json_field_path: Option<String>,
pub kind: MetricPrimitiveKind,
/// A third extraction source, alongside `capture_group`/`json_field_path`: when set, this
/// param's value is never parsed from text at all — it's computed as elapsed time since the
/// previous event that produced the same label set (ticket 038). Takes precedence over
/// `json_field_path`/`capture_group` when set, and `message_match_regex`/`capture_group`/
/// `json_field_path` are simply unused in that case (`declare_param` doesn't validate them for
/// this shape). Only meaningful paired with `kind == Duration`, since elapsed time is the only
/// primitive this computation can produce — `declare_param` enforces that pairing.
/// `#[serde(default)]` so params declared before this field existed keep parsing as plain
/// regex/JSON-field extraction.
#[serde(default)]
pub elapsed_since_last: bool,
/// Set when this param was discovered by `crate::engine::auto_declare_params` (a nom-based
/// `key=value` scan run on every ingested line) rather than declared through `POST /api/params`.
/// `declare_param` refuses to overwrite an auto-derived param by name, and it's never persisted
/// to a profile — see `crate::engine::declare_param` and `crate::api::reload_engine_declarations`.
/// `#[serde(default)]` so profiles/requests saved before this field existed still load as
/// ordinary (non-auto) params.
#[serde(default)]
pub auto_derived: bool,
}
/// Reads a value out of a JSON object by dot-separated field path (e.g. `"foo.bar"`), for
/// [`ParamDeclaration::json_field_path`]. Returns `None` when `text` doesn't parse as a JSON
/// object, the path doesn't resolve to a value, or the resolved value isn't a scalar
/// (string/number/bool) — there's no sensible flat text form of a nested object/array to feed into
/// `parse_metric_primitive`.
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
let root: serde_json::Value = serde_json::from_str(text).ok()?;
let mut current = root.as_object()?;
let mut segments = field_path.split('.').peekable();
loop {
let segment = segments.next()?;
let next_value = current.get(segment)?;
if segments.peek().is_none() {
return match next_value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
};
}
current = next_value.as_object()?;
}
}
/// Flattens a JSON object into dot-separated field paths mapped to each leaf scalar's raw text form
/// (string/number/bool), for [`crate::engine::auto_declare_params`] when a line is JSON
/// (`crate::logfmt::detect_format`) rather than logfmt — the JSON equivalent of
/// `crate::logfmt::extract_pairs`'s `key=value` scan. Nested objects recurse, dot-joining the path
/// (`"foo": {"bar": 1}` yields `"foo.bar"`); arrays and null are skipped since there's no single leaf
/// scalar to declare a param against, matching `extract_json_field`'s own restriction to scalars.
/// Returns an empty vec for anything that isn't a JSON object, same as `extract_pairs` returns empty
/// for a line with no `key=value` pairs.
pub fn flatten_json_object(text: &str) -> Vec<(String, String)> {
let Ok(serde_json::Value::Object(root)) = serde_json::from_str::<serde_json::Value>(text) else {
return Vec::new();
};
let mut results = Vec::new();
flatten_json_object_into(&root, "", &mut results);
results
}
fn flatten_json_object_into(
object: &serde_json::Map<String, serde_json::Value>,
prefix: &str,
results: &mut Vec<(String, String)>,
) {
for (key, value) in object {
let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
match value {
serde_json::Value::String(s) => results.push((path, s.clone())),
serde_json::Value::Number(n) => results.push((path, n.to_string())),
serde_json::Value::Bool(b) => results.push((path, b.to_string())),
serde_json::Value::Object(nested) => flatten_json_object_into(nested, &path, results),
serde_json::Value::Null | serde_json::Value::Array(_) => {}
}
}
}
/// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MetricPrimitiveKind {
String,
Number,
Timestamp,
/// A literal i64 millisecond count. Note this is unrelated to
/// `ParamDeclaration::elapsed_since_last`, which is a separate, independent extraction source —
/// a param using that source always parses/produces `Duration`, but not every `Duration`-kind
/// param is computed that way (one can also just parse a literal duration out of captured text,
/// like any other kind).
Duration,
/// A standalone param declared with this kind doesn't require (or use) a capture group: its value is
/// simply whether `message_match_regex` matches the line, case-insensitively or per the regex's own
/// flags — see `crate::engine::derive_log_params`. Unlike other kinds it's present as `true`/`false`
/// on every line rather than omitted when unmatched.
Boolean,
}
/// Value type paired with [`crate::index_key::IndexKey`] in the engine's primary index.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub enum DataValue {
/// Paired with `IndexKey::IngestedLog`.
LogLiteral { text: String },
/// Paired with `IndexKey::ParamDeclaration`.
ParamDeclaration(ParamDeclaration),
/// Paired with `IndexKey::MetricDeclaration`. `label_params`/`value_param` reference standalone
/// `ParamDeclaration`s by name (resolved against the engine's param store in
/// `crate::engine::apply_metric_to_log`) rather than embedding their own extractor.
MetricDeclaration {
message_match_regex: String,
label_params: Vec<String>,
/// At most one value param per metric; its primitive becomes the event's bucket value instead of a label.
value_param: Option<String>,
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
matched_labels: Option<BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
unmatched_labels: Option<BTreeMap<String, String>>,
},
/// Paired with `IndexKey::Bucket`.
BucketValue {
values: Vec<(u64, Timestamp, MetricPrimitive)>,
},
/// Paired with `IndexKey::LastMetricEvent`.
LastEventTimestamp {
timestamp: Timestamp,
},
}
-364
View File
@@ -1,364 +0,0 @@
use crate::alerts::AlertCondition;
use crate::data_value::MetricPrimitiveKind;
use crate::metric_primitive::MetricPrimitive;
use crate::profile::{ChartType, Dashboard, Panel, Profile};
use crate::settings::{ColorScheme, CssEntry, Settings};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize)]
pub struct LogEntryDto {
pub sequence: u64,
pub timestamp_ms: i64,
pub text: String,
/// Only params whose regex matched (and parsed as the declared kind) are present — omitted rather than sent as null, so the frontend can render a blank for anything missing.
pub params: BTreeMap<String, MetricPrimitive>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ParamDeclarationDto {
pub name: String,
pub message_match_regex: String,
pub capture_group: String,
/// See `crate::data_value::ParamDeclaration::json_field_path`.
pub json_field_path: Option<String>,
pub kind: MetricPrimitiveKind,
/// See `crate::data_value::ParamDeclaration::elapsed_since_last`.
pub elapsed_since_last: bool,
/// See `crate::data_value::ParamDeclaration::auto_derived`. The frontend uses this to disable
/// editing — `POST /api/params` also rejects a request naming an auto-derived param.
pub auto_derived: bool,
/// Set only for the built-in `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` names
/// (`crate::engine::OTTYL_RESERVED_PARAM_NAMES`), synthesized into the `GET /api/params`
/// response by `crate::api::list_params` rather than backed by any index entry — distinct from
/// `auto_derived` (which *is* a real, if machine-discovered, `ParamDeclaration`). Always
/// `false` for every other entry. There is no removal path for these at all (not just a
/// disabled one), so the frontend should treat `reserved` as non-deletable/non-editable.
#[serde(default)]
pub reserved: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct MetricDeclarationDto {
pub name: String,
pub message_match_regex: String,
/// Names of standalone `ParamDeclaration`s used as this metric's bucket labels.
pub label_params: Vec<String>,
/// Name of the standalone `ParamDeclaration` (if any) whose primitive becomes this metric's per-event bucket value instead of a label.
pub value_param: Option<String>,
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
pub matched_labels: Option<BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
pub unmatched_labels: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TimelineBucketDto {
pub start_ms: i64,
pub count: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatsDto {
pub log_count: u64,
pub first_sequence: Option<u64>,
pub last_sequence: Option<u64>,
pub first_timestamp_ms: Option<i64>,
pub last_timestamp_ms: Option<i64>,
/// Ticket 011: a running, *approximate* estimate of the in-memory heap
/// held by currently-ingested logs, in bytes. "Approximate" because it
/// only counts each `IngestedLine::text`'s UTF-8 byte length — it does
/// not account for `String`'s allocator overhead/capacity slack, the
/// `BTreeMap`/`IndexKey` node overhead the text is stored under, or any
/// derived data (bucket values, params) the line produces elsewhere in
/// the index. That's judged the best cheap proxy for "how much memory is
/// this log's text costing us": it's O(1) to compute per log (`text.len()`,
/// already an interned byte count for UTF-8 `String`), moves in lockstep
/// with the dominant cost (raw log text, which is usually much larger
/// than the fixed per-entry bookkeeping), and needs no per-entry
/// bookkeeping beyond what eviction already tracks.
pub approx_bytes_total: u64,
}
#[derive(Debug, Deserialize)]
pub struct LogsParams {
pub since_sequence: Option<u64>,
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub limit: Option<usize>,
/// Raw FilterBar query string, e.g. `regex("error") and ottyl_seq<5`.
pub filter: Option<String>,
/// Ticket 045: an already-declared param name to order the returned entries by, in place of the
/// default ingest order (which tracks `ottyl_ts`). `None` (the default) leaves ordering
/// unchanged.
pub sort_by: Option<String>,
/// Ticket 045: only meaningful alongside `sort_by`. `true` (the default) orders lines missing
/// that param among themselves by `ottyl_seq` and appends them after the sorted lines; `false`
/// drops those lines from the response entirely.
pub sort_missing_fallback: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct TimelineParams {
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub bucket_ms: Option<i64>,
/// Same FilterBar query string as `LogsParams::filter`; only matching logs are counted into timeline buckets.
pub filter: Option<String>,
}
/// One (sequence, timestamp, value) occurrence within a metric's bucket for a given label combination.
#[derive(Debug, Clone, Serialize)]
pub struct BucketValueDto {
pub labels: BTreeMap<String, MetricPrimitive>,
pub sequence: u64,
pub timestamp_ms: i64,
pub value: MetricPrimitive,
}
/// `labels` is a comma-separated `key=value` list (not repeated params) since axum's `Query` extractor needs a fixed set of field names.
#[derive(Debug, Deserialize)]
pub struct BucketValuesParams {
pub labels: Option<String>,
pub since_sequence: Option<u64>,
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub limit: Option<usize>,
}
/// `labels` uses plain `String` (already Display-formatted) since charts just need something to show; `value` is always `f64` for plottability.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct PromqlSeriesDto {
pub labels: BTreeMap<String, String>,
pub points: Vec<(i64, f64)>,
}
#[derive(Debug, Deserialize)]
pub struct PromqlParams {
pub promql: String,
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
}
/// JSON error body returned by API handlers on a 4xx/5xx response.
#[derive(Debug, Serialize)]
pub struct ErrorDto {
pub error: String,
}
/// Every imported CSS entry is a selectable theme (ticket 020) — there's no separate
/// always-on-overlay mode to opt into.
#[derive(Debug, Deserialize)]
pub struct ImportCssRequest {
pub filename: String,
pub content: String,
}
/// A reference to an external CSS path/URL that ottyl doesn't copy or own.
#[derive(Debug, Deserialize)]
pub struct LinkCssRequest {
pub path_or_url: String,
}
/// `id` is the real id for `Imported` entries or a stable id derived from the path/URL for `Linked` ones, so the frontend can key both kinds uniformly (only `Imported` entries are deletable/content-fetchable).
#[derive(Debug, Clone, Serialize)]
pub struct CssEntryDto {
pub id: String,
pub kind: CssEntryKind,
pub filename: Option<String>,
pub path_or_url: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CssEntryKind {
Imported,
Linked,
}
impl From<&CssEntry> for CssEntryDto {
fn from(entry: &CssEntry) -> Self {
match entry {
CssEntry::Imported { id, filename } => CssEntryDto {
id: id.clone(),
kind: CssEntryKind::Imported,
filename: Some(filename.clone()),
path_or_url: None,
},
CssEntry::Linked { path_or_url } => CssEntryDto {
id: crate::settings::css_entry_id(entry),
kind: CssEntryKind::Linked,
filename: None,
path_or_url: Some(path_or_url.clone()),
},
}
}
}
/// `css_entries` is omitted here since it has its own `GET /api/settings/css` endpoint.
#[derive(Debug, Clone, Serialize)]
pub struct SettingsDto {
pub color_scheme: ColorScheme,
pub active_profile: Option<String>,
/// `None` means unlimited.
pub max_logs: Option<usize>,
/// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
pub custom_theme_id: Option<String>,
/// Ntfy topic every alert rule delivers to; `None` until configured.
pub ntfy_topic: Option<String>,
/// Ntfy server override; `None` means `https://ntfy.sh`.
pub ntfy_server: Option<String>,
}
impl From<&Settings> for SettingsDto {
fn from(settings: &Settings) -> Self {
SettingsDto {
color_scheme: settings.color_scheme,
active_profile: settings.active_profile.clone(),
max_logs: settings.max_logs,
custom_theme_id: settings.custom_theme_id.clone(),
ntfy_topic: settings.ntfy_topic.clone(),
ntfy_server: settings.ntfy_server.clone(),
}
}
}
/// `max_logs`/`custom_theme_id`/`ntfy_topic`/`ntfy_server` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`.
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest {
pub color_scheme: Option<ColorScheme>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub max_logs: Option<Option<usize>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub custom_theme_id: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub ntfy_topic: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub ntfy_server: Option<Option<String>>,
}
/// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option<Option<T>>` can't do alone.
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
#[derive(Debug, Deserialize)]
pub struct CreateAlertRuleRequest {
pub name: String,
pub enabled: bool,
pub condition: AlertCondition,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAlertRuleRequest {
pub name: String,
pub enabled: bool,
pub condition: AlertCondition,
}
/// `server` defaults to `https://ntfy.sh` when absent.
#[derive(Debug, Deserialize)]
pub struct TestAlertNotificationRequest {
pub topic: String,
pub server: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProfileSummaryDto {
pub name: String,
pub param_count: usize,
pub metric_count: usize,
pub dashboard_count: usize,
}
impl From<&Profile> for ProfileSummaryDto {
fn from(profile: &Profile) -> Self {
ProfileSummaryDto {
name: profile.name.clone(),
param_count: profile.params.len(),
metric_count: profile.metrics.len(),
dashboard_count: profile.dashboards.len(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateProfileRequest {
pub name: String,
}
/// Mirrors `profile::Panel`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelDto {
pub id: String,
pub title: String,
pub promql: String,
pub chart_type: ChartType,
}
impl From<&Panel> for PanelDto {
fn from(panel: &Panel) -> Self {
PanelDto {
id: panel.id.clone(),
title: panel.title.clone(),
promql: panel.promql.clone(),
chart_type: panel.chart_type,
}
}
}
impl From<&PanelDto> for Panel {
fn from(dto: &PanelDto) -> Self {
Panel {
id: dto.id.clone(),
title: dto.title.clone(),
promql: dto.promql.clone(),
chart_type: dto.chart_type,
}
}
}
/// Mirrors `profile::Dashboard`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardDto {
pub id: String,
pub name: String,
pub panels: Vec<PanelDto>,
}
impl From<&Dashboard> for DashboardDto {
fn from(dashboard: &Dashboard) -> Self {
DashboardDto {
id: dashboard.id.clone(),
name: dashboard.name.clone(),
panels: dashboard.panels.iter().map(PanelDto::from).collect(),
}
}
}
impl From<&DashboardDto> for Dashboard {
fn from(dto: &DashboardDto) -> Self {
Dashboard {
id: dto.id.clone(),
name: dto.name.clone(),
panels: dto.panels.iter().map(Panel::from).collect(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateDashboardRequest {
pub name: String,
}
/// The dashboard `id` in the URL is authoritative; any `id` in the body is ignored.
#[derive(Debug, Deserialize)]
pub struct SaveDashboardRequest {
pub name: String,
#[serde(default)]
pub panels: Vec<PanelDto>,
}
-4147
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
//! The two actor tasks that own all live engine state, plus the `left_right` read plumbing they
//! share.
//!
//! [`primary`] owns ingested log lines and param declarations; [`profile`] owns metric
//! declarations, bucket data, and alert evaluation (moved off the primary per tickets 103-105,
//! see `profile`'s module doc). Each is a `tokio::spawn`ed loop reading from its own `mpsc`
//! channel -- there is no shared mutable state accessed directly by callers, only the handles
//! and read-only [`IndexReader`]s below.
use crate::engine::snapshot::EngineSnapshot;
pub(crate) mod primary;
pub(crate) mod profile;
/// A read-only view over one actor's [`EngineSnapshot`], obtained via [`IndexReader::handle`].
/// Cheap to acquire per-query; the actual zero-copy read guard comes from `enter()`.
pub struct IndexReadHandle(left_right::ReadHandle<EngineSnapshot>);
impl IndexReadHandle {
pub(crate) fn enter(&self) -> Option<left_right::ReadGuard<'_, EngineSnapshot>> {
self.0.enter()
}
}
/// A cloneable factory for [`IndexReadHandle`]s over one actor's `EngineSnapshot` -- every
/// actor (primary and profile) publishes through its own `left_right::WriteHandle` but hands
/// out reads through one of these. Not generic over the `left_right::Absorb` operation type,
/// since `left_right::ReadHandle` itself isn't -- only the write side needs to know how a
/// snapshot gets mutated.
pub struct IndexReader {
pub(crate) factory: left_right::ReadHandleFactory<EngineSnapshot>,
}
impl Clone for IndexReader {
fn clone(&self) -> Self {
IndexReader {
factory: self.factory.clone(),
}
}
}
impl IndexReader {
pub(crate) fn handle(&self) -> IndexReadHandle {
IndexReadHandle(self.factory.handle())
}
}
+426
View File
@@ -0,0 +1,426 @@
//! The primary actor: owns ingested log lines and param declarations, and the `EngineHandle`
//! other tasks use to talk to it (and, by proxy, to `super::profile`).
//!
//! Ticket 106 (giving API handlers a route to both the primary and profile actors) resolved to
//! option (b) from its own scope note: one combined `EngineHandle`, not two separate handle
//! types. Callers never pick a target explicitly -- `EngineHandle::tx` accepts every
//! `EngineMessage` variant, and `run()` below internally proxies the metric/alert-related ones
//! to the profile actor (`super::profile::ProfileMessage`) rather than mutating the primary's
//! own snapshot; `EngineHandle::profile_reader` similarly gives read access to the profile
//! actor's snapshot for the query variants it owns (routed in `api::EngineHandle::ask`). This
//! means `reload_engine_declarations`/`activate_profile`/`Profile::load_into_engine` needed no
//! changes at all to correctly split their remove-then-redeclare sequence across both actors --
//! they already just send `EngineMessage`s the same way they did before the split.
use super::IndexReader;
use crate::config::SettingsHandle;
use crate::consts::{INGEST_LAG_WATERMARK_MAX_ENTRIES, INGEST_LAG_WATERMARK_WINDOW_MS};
use crate::engine::index_key::IndexKey;
use crate::engine::message::EngineMessage;
use crate::engine::metric_primitive::{MetricPrimitive, Number};
use crate::engine::snapshot::EngineSnapshot;
use crate::engine::snapshot::params::log_stream_header_transition;
use crate::engine::timestamp::Timestamp;
use crate::engine::{ClearParamsResult, IngestedLine};
use left_right::Absorb;
use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot, watch};
struct EngineOp(Box<dyn FnMut(&mut EngineSnapshot) + Send + 'static>);
impl Absorb<EngineOp> for EngineSnapshot {
fn absorb_first(&mut self, operation: &mut EngineOp, _other: &Self) {
(operation.0)(self);
}
fn drop_first(self: Box<Self>) {}
fn sync_with(&mut self, first: &Self) {
*self = first.clone();
}
}
struct IndexWriter {
handle: left_right::WriteHandle<EngineSnapshot, EngineOp>,
/// Bumped and re-sent on every publish so the profile actor (`super::profile`) can wake on
/// change rather than polling; the value itself carries no meaning beyond "something new is
/// visible; go re-read what you need through your own `IndexReader`."
version: u64,
version_tx: watch::Sender<u64>,
}
impl IndexWriter {
fn mutate(&mut self, op: impl FnMut(&mut EngineSnapshot) + Send + 'static) {
self.handle.append(EngineOp(Box::new(op)));
self.handle.publish();
self.version += 1;
let _ = self.version_tx.send(self.version);
}
fn peek(&mut self) -> Option<left_right::ReadGuard<'_, EngineSnapshot>> {
self.handle.enter()
}
}
pub struct EngineHandle {
pub tx: mpsc::Sender<EngineMessage>,
pub reader: IndexReader,
/// Read-only view of the profile actor's snapshot (`super::profile`), which owns metric
/// declarations and bucket data (ticket 104) — `MetricDeclarations`/`BucketValues`/`Promql`
/// queries read through this instead of `reader`. Not part of the write path; the profile
/// actor's own `mpsc` channel (proxied through `EngineMessage`'s metric variants) is that.
pub(crate) profile_reader: IndexReader,
/// The profile actor's own processed-sequence watermark (`ProfileHandle::processed_sequence`)
/// — see `wait_for_profile_sequence` below.
pub(crate) profile_processed_sequence: watch::Receiver<Option<u64>>,
}
impl Clone for EngineHandle {
fn clone(&self) -> Self {
EngineHandle {
tx: self.tx.clone(),
reader: self.reader.clone(),
profile_reader: self.profile_reader.clone(),
profile_processed_sequence: self.profile_processed_sequence.clone(),
}
}
}
impl EngineHandle {
pub async fn barrier(&self) {
let (respond_to, response) = oneshot::channel();
if self
.tx
.send(EngineMessage::Barrier(respond_to))
.await
.is_ok()
{
let _ = response.await;
}
}
/// Waits until the profile actor has finished processing primary sequence `sequence` (metrics
/// computed, alerts evaluated) — ticket 108's deterministic alternative to a sleep for tests
/// that assert on profile-actor-owned data after ingesting; `barrier()` alone only confirms
/// the *primary*'s queue drained, not that the profile actor has caught up to what it published.
#[cfg(test)]
pub(crate) async fn wait_for_profile_sequence(&self, sequence: u64) {
let mut rx = self.profile_processed_sequence.clone();
while *rx.borrow() < Some(sequence) {
if rx.changed().await.is_err() {
return; // profile actor gone
}
}
}
}
/// Builds the primary actor's sender/receiver/reader/writer/version-receiver, shared by `spawn`
/// and, in tests, by callers that want the raw receiver without an actor loop draining it.
/// Stops short of building `EngineHandle` itself, since that also needs a profile-actor reader
/// that doesn't exist until the caller spawns (or fakes, see `test_handle`) the profile actor.
fn new_channel_and_handle(
channel_capacity: usize,
) -> (
mpsc::Sender<EngineMessage>,
mpsc::Receiver<EngineMessage>,
IndexReader,
IndexWriter,
watch::Receiver<u64>,
) {
let (tx, rx) = mpsc::channel(channel_capacity);
let (write, read) = left_right::new::<EngineSnapshot, EngineOp>();
let reader = IndexReader {
factory: read.factory(),
};
let (version_tx, version_rx) = watch::channel(0u64);
(
tx,
rx,
reader,
IndexWriter {
handle: write,
version: 0,
version_tx,
},
version_rx,
)
}
pub fn spawn(settings: SettingsHandle, channel_capacity: usize) -> EngineHandle {
let (tx, rx, reader, write, version_rx) = new_channel_and_handle(channel_capacity);
let profile = super::profile::spawn(reader.clone(), version_rx, settings);
let handle = EngineHandle {
tx,
reader,
profile_reader: profile.reader.clone(),
profile_processed_sequence: profile.processed_sequence.clone(),
};
tokio::spawn(run(rx, write, profile));
handle
}
#[cfg(test)]
pub(crate) fn test_handle(
channel_capacity: usize,
) -> (EngineHandle, mpsc::Receiver<EngineMessage>) {
let (tx, rx, reader, _write, _version_rx) = new_channel_and_handle(channel_capacity);
// No actor loop is spawned here at all, so nothing ever publishes -- an inert reader over
// an always-empty snapshot is all a query through this handle could see anyway.
let (_processed_tx, processed_rx) = watch::channel(None);
let handle = EngineHandle {
tx,
reader,
profile_reader: super::profile::inert_reader(),
profile_processed_sequence: processed_rx,
};
(handle, rx)
}
/// The primary actor's loop: extracts declared + auto params only from each ingested line (per
/// `ideas/0001-actor-split.md`'s resolved decision) -- no metrics, no alert evaluation, both of
/// which live on `super::profile` and read this actor's publishes via `IndexReader`/`watch`.
async fn run(
mut rx: mpsc::Receiver<EngineMessage>,
mut write: IndexWriter,
profile: crate::engine::actor::profile::ProfileHandle,
) {
let mut max_logs: Option<usize> = None;
let mut metric_sample_sequence: u64 = 0;
let mut log_stream_header: Option<crate::engine::logfmt::LogStreamHeader> = None;
let mut ingest_lag_samples: Vec<(Timestamp, i64)> = Vec::new();
while let Some(message) = rx.recv().await {
match message {
EngineMessage::Ingest(line) => {
let IngestedLine {
sequence,
timestamp,
text,
} = line;
let processed_at = Timestamp::now();
record_ingest_lag(
&mut ingest_lag_samples,
processed_at,
(processed_at.as_millis() - timestamp.as_millis()).max(0),
);
let ingest_lag_high_watermark_ms =
ingest_lag_high_watermark_ms(&ingest_lag_samples, processed_at);
let is_new_header = crate::engine::logfmt::LogStreamHeader::detect(&text).is_some();
log_stream_header = log_stream_header_transition(&log_stream_header, &text);
let row_header = if is_new_header {
None
} else {
log_stream_header.clone()
};
let text_for_op = Arc::clone(&text);
write.mutate(move |snap: &mut EngineSnapshot| {
// Primary/param extraction only (ticket 101) -- metric computation
// (ticket 104) and alert evaluation (ticket 105) now happen on the
// profile actor, which wakes on this publish and reads the
// newly-visible line itself.
snap.ingest_params(sequence, timestamp, &text_for_op, row_header.as_ref());
snap.update_sort_helper_indexes_on_ingest(sequence, timestamp, &text_for_op);
snap.purge_stale_sort_helper_indexes();
snap.evict_excess_logs(max_logs);
snap.ingest_lag_high_watermark_ms = ingest_lag_high_watermark_ms;
});
}
EngineMessage::IngestMetricSample(sample) => {
let labels: BTreeMap<String, MetricPrimitive> = sample
.labels
.into_iter()
.map(|(name, value)| (name, MetricPrimitive::String(value)))
.collect();
let metric_name = sample.metric_name;
let sequence = metric_sample_sequence;
let timestamp = sample.timestamp;
let value = MetricPrimitive::Number(Number::Float(sample.value));
write.mutate(move |snap: &mut EngineSnapshot| {
snap.push_bucket_value(
&metric_name,
labels.clone(),
sequence,
timestamp,
value.clone(),
);
});
metric_sample_sequence += 1;
}
EngineMessage::SetMaxLogs(new_max) => {
max_logs = new_max;
write.mutate(move |snap: &mut EngineSnapshot| {
snap.evict_excess_logs(new_max);
});
}
EngineMessage::DeclareParam {
request,
respond_to,
} => {
let result = write
.peek()
.map(|guard| guard.validate_param_declaration(&request))
.unwrap_or_else(|| Err("engine is shutting down".to_string()));
let succeeded = result.is_ok();
let name = request.name.clone();
let request_for_op = request.clone();
write.mutate(move |snap: &mut EngineSnapshot| {
snap.sort_helper_indexes.remove(&name);
if succeeded {
snap.insert_param_declaration(request_for_op.clone());
}
});
let _ = respond_to.send(result);
}
EngineMessage::RemoveParamDeclaration { name, respond_to } => {
let existed = write.peek().is_some_and(|guard| {
guard
.index
.contains_key(&IndexKey::ParamDeclaration { name: name.clone() })
});
let name_for_op = name.clone();
write.mutate(move |snap: &mut EngineSnapshot| {
snap.index.remove(&IndexKey::ParamDeclaration {
name: name_for_op.clone(),
});
snap.sort_helper_indexes.remove(&name_for_op);
snap.param_regex_cache.remove(&name_for_op);
});
let _ = respond_to.send(existed);
}
EngineMessage::ClearParams { respond_to } => {
let mut scratch = EngineSnapshot::from_index(
write
.peek()
.map(|guard| guard.index.clone())
.unwrap_or_default(),
);
let removed = scratch.clear_all_param_declarations();
let (reextracted, logs_considered) = scratch.reextract_params_from_retained_logs();
write.mutate(move |snap: &mut EngineSnapshot| {
snap.clear_all_param_declarations();
snap.reextract_params_from_retained_logs();
snap.sort_helper_indexes.clear();
});
let _ = respond_to.send(ClearParamsResult {
removed,
reextracted,
logs_considered,
});
}
EngineMessage::DeclareMetric {
request,
respond_to,
} => {
// Metric declarations live on the profile actor (ticket 104), not here --
// proxy the message rather than mutating the primary's snapshot.
let _ = profile
.tx
.send(crate::engine::actor::profile::ProfileMessage::DeclareMetric {
request,
respond_to,
})
.await;
}
EngineMessage::UpdateMetric {
name,
request,
respond_to,
} => {
let _ = profile
.tx
.send(crate::engine::actor::profile::ProfileMessage::UpdateMetric {
name,
request,
respond_to,
})
.await;
}
EngineMessage::RemoveMetricDeclaration { name, respond_to } => {
let _ = profile
.tx
.send(
crate::engine::actor::profile::ProfileMessage::RemoveMetricDeclaration {
name,
respond_to,
},
)
.await;
}
EngineMessage::EnsureSortHelperIndex {
param_name,
respond_to,
} => {
write.mutate(move |snap: &mut EngineSnapshot| {
snap.ensure_sort_helper_index_built(&param_name);
});
let _ = respond_to.send(());
}
EngineMessage::Barrier(respond_to) => {
let _ = respond_to.send(());
}
}
}
}
pub(crate) fn record_ingest_lag(samples: &mut Vec<(Timestamp, i64)>, now: Timestamp, lag_ms: i64) {
samples.retain(|(recorded_at, _)| {
now.as_millis() - recorded_at.as_millis() < INGEST_LAG_WATERMARK_WINDOW_MS
});
if samples.len() < INGEST_LAG_WATERMARK_MAX_ENTRIES {
samples.push((now, lag_ms));
return;
}
let Some((min_index, &(_, min_lag))) =
samples.iter().enumerate().min_by_key(|(_, (_, lag))| *lag)
else {
return;
};
if lag_ms > min_lag {
samples[min_index] = (now, lag_ms);
}
}
pub(crate) fn ingest_lag_high_watermark_ms(samples: &[(Timestamp, i64)], now: Timestamp) -> i64 {
samples
.iter()
.filter(|(recorded_at, _)| {
now.as_millis() - recorded_at.as_millis() < INGEST_LAG_WATERMARK_WINDOW_MS
})
.map(|(_, lag)| *lag)
.max()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Settings;
use std::sync::Mutex;
use std::time::Duration;
/// Proves ticket 103's wake/read loop end-to-end: the profile actor wakes on the primary's
/// publish signal and reads the newly-visible line through its own primary `IndexReader`,
/// without the primary ever being told the profile actor exists.
#[tokio::test]
async fn profile_actor_observes_newly_ingested_lines_via_the_primary_watch_signal() {
let settings: SettingsHandle = Arc::new(Mutex::new(Settings::default()));
let engine = spawn(settings, 8);
engine
.tx
.send(EngineMessage::Ingest(IngestedLine {
sequence: 0,
timestamp: Timestamp::from_millis(0),
text: "hello".into(),
}))
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(2), engine.wait_for_profile_sequence(0))
.await
.expect("timed out waiting for the profile actor to observe the new line");
}
}
+348
View File
@@ -0,0 +1,348 @@
//! The profile actor (tickets 103-105). Owns metric declarations, bucket computation, and alert
//! evaluation, moved off the primary per `ideas/0001-actor-split.md`'s resolved decision that
//! the primary "extracts declared + auto params only... no metrics, no profile logic" and that
//! alerts move with metric computation since they read the buckets it produces. Reuses
//! `EngineSnapshot` itself as this actor's own snapshot type — `declare_metric`/`update_metric`/
//! `remove_metric_declaration`/`apply_all_metrics` all work unchanged — rather than inventing a
//! parallel type, since metric evaluation needs param declarations present in the same snapshot
//! it reads (`resolved_param_declarations`); this actor mirrors the primary's param declarations
//! into its own snapshot on every wake so that stays true, but stores no raw log text of its
//! own, reading it through `primary_reader` (a clone of the primary's `IndexReader`) instead.
//! `EngineHandle`'s public API is unchanged by this ticket — `DeclareMetric`/`UpdateMetric`/
//! `RemoveMetricDeclaration` are still sent to the primary's `mpsc` channel and proxied here by
//! `super::primary`'s `run()` loop; ticket 106 gives API handlers a direct route.
use super::IndexReader;
use crate::config::SettingsHandle;
use crate::engine::DeclareMetricRequest;
use crate::engine::alerts::{self, AlertRuntimeState};
use crate::engine::data_value::{DataValue, ParamDeclaration};
use crate::engine::index_key::IndexKey;
use crate::engine::snapshot::EngineSnapshot;
use crate::engine::timestamp::Timestamp;
use left_right::Absorb;
use regex::Regex;
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot, watch};
struct ProfileOp(Box<dyn FnMut(&mut EngineSnapshot) + Send + 'static>);
impl Absorb<ProfileOp> for EngineSnapshot {
fn absorb_first(&mut self, operation: &mut ProfileOp, _other: &Self) {
(operation.0)(self);
}
fn drop_first(self: Box<Self>) {}
fn sync_with(&mut self, first: &Self) {
*self = first.clone();
}
}
/// Metric-declaration messages proxied to the profile actor by the primary's `run()` loop.
pub(crate) enum ProfileMessage {
DeclareMetric {
request: DeclareMetricRequest,
respond_to: oneshot::Sender<Result<(), String>>,
},
UpdateMetric {
name: String,
request: DeclareMetricRequest,
respond_to: oneshot::Sender<Result<(), String>>,
},
RemoveMetricDeclaration {
name: String,
respond_to: oneshot::Sender<bool>,
},
}
pub(crate) struct ProfileHandle {
pub(crate) tx: mpsc::Sender<ProfileMessage>,
/// Read-only view of this actor's own snapshot (metric declarations, bucket data) --
/// `MetricDeclarations`/`BucketValues`/`Promql` queries read through this (see
/// `EngineHandle::profile_reader` in `super::primary`) instead of the primary's reader.
pub(crate) reader: IndexReader,
/// The highest primary log sequence this actor has finished processing (metrics computed,
/// alerts evaluated), or `None` before it's processed anything -- ticket 108's deterministic
/// alternative to a sleep for tests that need to wait until the profile actor has caught up
/// to a specific point, mirroring what `EngineHandle::barrier` gives the primary.
pub(crate) processed_sequence: watch::Receiver<Option<u64>>,
}
/// A read-only view over a fresh, never-written `EngineSnapshot` -- used by `primary::test_handle`,
/// which spawns no actor loop at all (so nothing would ever publish to a real profile reader
/// anyway), just to give `EngineHandle::profile_reader` a valid value.
#[cfg(test)]
pub(crate) fn inert_reader() -> IndexReader {
let (_write, read) = left_right::new::<EngineSnapshot, ProfileOp>();
IndexReader {
factory: read.factory(),
}
}
/// Spawns the profile actor. `primary_reader` is a clone of the primary's `IndexReader`, giving
/// this actor its own read-only view of the primary's index (raw log text, param declarations);
/// `primary_version` is bumped by the primary's `IndexWriter::mutate` after every `left_right`
/// publish, so this actor blocks on `changed()` between primary writes rather than polling.
/// `settings` is used to read alert rules and ntfy config fresh for each evaluated line (ticket
/// 105 -- alert evaluation moved here from the primary alongside the metric buckets it reads).
pub(crate) fn spawn(
primary_reader: IndexReader,
primary_version: watch::Receiver<u64>,
settings: SettingsHandle,
) -> ProfileHandle {
let (tx, rx) = mpsc::channel(crate::consts::ENGINE_CHANNEL_CAPACITY);
let (write, read) = left_right::new::<EngineSnapshot, ProfileOp>();
let reader = IndexReader {
factory: read.factory(),
};
let (processed_tx, processed_rx) = watch::channel(None);
tokio::spawn(run(
primary_reader,
primary_version,
rx,
write,
settings,
processed_tx,
));
ProfileHandle {
tx,
reader,
processed_sequence: processed_rx,
}
}
async fn run(
primary_reader: IndexReader,
mut primary_version: watch::Receiver<u64>,
mut rx: mpsc::Receiver<ProfileMessage>,
mut write: left_right::WriteHandle<EngineSnapshot, ProfileOp>,
settings: SettingsHandle,
processed_tx: watch::Sender<Option<u64>>,
) {
let mut last_seen_sequence: Option<u64> = None;
let mut alert_states: HashMap<String, AlertRuntimeState> = HashMap::new();
loop {
tokio::select! {
changed = primary_version.changed() => {
if changed.is_err() {
break; // primary actor gone, shut down
}
let primary_handle = primary_reader.handle();
let Some(primary) = primary_handle.enter() else {
continue;
};
mirror_param_declarations(&primary, &mut write);
let range_start = last_seen_sequence.map_or(0, |sequence| sequence.saturating_add(1));
let new_lines: Vec<(u64, Timestamp, String)> = primary
.logs_in_sequence_range(range_start..)
.map(|(sequence, timestamp, text)| (sequence, timestamp, text.to_string()))
.collect();
drop(primary);
for (sequence, timestamp, text) in new_lines {
let text_for_op = text.clone();
write.append(ProfileOp(Box::new(move |snap: &mut EngineSnapshot| {
snap.apply_all_metrics(sequence, timestamp, &text_for_op);
})));
write.publish();
last_seen_sequence = Some(sequence);
let (rules, ntfy_topic, ntfy_server) = {
let settings = settings.lock().unwrap();
(
settings.alert_rules.clone(),
settings.ntfy_topic.clone(),
settings.ntfy_server.clone(),
)
};
let fired = write
.enter()
.map(|guard| {
alerts::evaluate_alerts(
&rules,
&mut alert_states,
&guard,
sequence,
timestamp,
&text,
)
})
.unwrap_or_default();
for (rule, message) in fired {
tokio::spawn(alerts::deliver_for_rule(
rule.name,
message,
ntfy_topic.clone(),
ntfy_server.clone(),
));
}
}
if last_seen_sequence.is_some() {
processed_tx.send_replace(last_seen_sequence);
}
}
message = rx.recv() => {
let Some(message) = message else {
break; // primary actor dropped its ProfileHandle, shut down
};
handle_message(message, &primary_reader, &mut write);
}
}
}
}
fn handle_message(
message: ProfileMessage,
primary_reader: &IndexReader,
write: &mut left_right::WriteHandle<EngineSnapshot, ProfileOp>,
) {
match message {
ProfileMessage::DeclareMetric {
request,
respond_to,
} => {
// Freshen the param mirror synchronously rather than relying on the watch-wake
// loop having already caught up -- a param declared just before this metric (e.g.
// `Profile::load_into_engine` declaring params then metrics in sequence) may not
// have been mirrored yet when this message is handled, since there's no ordering
// guarantee between the wake branch and this one in `tokio::select!`.
if let Some(primary) = primary_reader.handle().enter() {
mirror_param_declarations(&primary, write);
}
let result = write
.enter()
.map(|guard| guard.validate_metric_request(&request).map(|_| ()))
.unwrap_or_else(|| Err("engine is shutting down".to_string()));
let succeeded = result.is_ok();
let lines = primary_owned_lines(primary_reader);
let request_for_op = request.clone();
write.append(ProfileOp(Box::new(move |snap: &mut EngineSnapshot| {
if succeeded {
let _ = snap.declare_metric_with_lines(request_for_op.clone(), &lines);
}
})));
write.publish();
let _ = respond_to.send(result);
}
ProfileMessage::UpdateMetric {
name,
request,
respond_to,
} => {
if let Some(primary) = primary_reader.handle().enter() {
mirror_param_declarations(&primary, write);
}
let result = write
.enter()
.map(|guard| guard.validate_update_metric(&name, &request).map(|_| ()))
.unwrap_or_else(|| Err("engine is shutting down".to_string()));
let succeeded = result.is_ok();
let lines = primary_owned_lines(primary_reader);
let existing_name = name.clone();
let request_for_op = request.clone();
write.append(ProfileOp(Box::new(move |snap: &mut EngineSnapshot| {
if succeeded {
let _ = snap.update_metric_with_lines(
&existing_name,
request_for_op.clone(),
&lines,
);
}
})));
write.publish();
let _ = respond_to.send(result);
}
ProfileMessage::RemoveMetricDeclaration { name, respond_to } => {
let existed = write.enter().is_some_and(|guard| {
guard
.index
.contains_key(&IndexKey::MetricDeclaration { name: name.clone() })
});
let name_for_op = name.clone();
write.append(ProfileOp(Box::new(move |snap: &mut EngineSnapshot| {
snap.remove_metric_declaration(&name_for_op);
})));
write.publish();
let _ = respond_to.send(existed);
}
}
}
/// Reads all currently-retained primary lines through `primary_reader`, for backfilling a
/// newly declared/updated metric — the profile actor's own snapshot holds no raw log text, so
/// this is its only source of backfill history (see `EngineSnapshot::declare_metric_with_lines`).
fn primary_owned_lines(primary_reader: &IndexReader) -> Vec<(u64, Timestamp, String)> {
let primary_handle = primary_reader.handle();
primary_handle
.enter()
.map(|guard| {
guard
.logs_range()
.map(|(sequence, timestamp, text)| (sequence, timestamp, text.to_string()))
.collect()
})
.unwrap_or_default()
}
/// Replaces the profile snapshot's mirrored `ParamDeclaration` entries and `param_regex_cache`
/// with the primary's current ones, so metric label/value resolution
/// (`EngineSnapshot::resolved_param_declarations`) sees the same params the primary has —
/// cheap enough to do unconditionally on every wake since declared params are capped
/// (`MAX_AUTO_DECLARED_PARAMS`).
fn mirror_param_declarations(
primary: &EngineSnapshot,
write: &mut left_right::WriteHandle<EngineSnapshot, ProfileOp>,
) {
let declarations: Vec<ParamDeclaration> = primary
.param_declarations_range()
.map(
|(
name,
message_match_regex,
capture_group,
json_field_path,
kind,
elapsed_since_last,
auto_derived,
log_stream_gated,
)| ParamDeclaration {
name: name.to_string(),
message_match_regex: message_match_regex.to_string(),
capture_group: capture_group.to_string(),
json_field_path: json_field_path.map(str::to_string),
kind,
elapsed_since_last,
auto_derived,
log_stream_gated,
},
)
.collect();
write.append(ProfileOp(Box::new(move |snap: &mut EngineSnapshot| {
let stale: Vec<IndexKey> = snap
.index
.range(IndexKey::LogParamSplit..IndexKey::ParamMetricSplit)
.map(|(key, _)| key.clone())
.collect();
for key in stale {
snap.index.remove(&key);
}
snap.param_regex_cache.clear();
for declaration in &declarations {
if let Ok(re) = Regex::new(&declaration.message_match_regex) {
snap.param_regex_cache.insert(declaration.name.clone(), re);
}
snap.index.insert(
IndexKey::ParamDeclaration {
name: declaration.name.clone(),
},
DataValue::ParamDeclaration(declaration.clone()),
);
}
})));
write.publish();
}
+271 -129
View File
@@ -1,16 +1,5 @@
//! Alert rules: definitions, trigger evaluation, and ntfy delivery.
//!
//! Alert queries are restricted to regex/ottyl_seq/ottyl_ts/ottyl_fmt/json[field] — a metric[param] comparison would require a full bucket rescan per line, breaking the proportional-cost guarantee. json[field] is fine here since, like regex/ottyl_seq/ottyl_ts/ottyl_fmt, it only inspects the line's own text/fields, no index lookup.
//!
//! `Threshold`/`Ratio` windows are maintained via `crate::rate::RateWindow` — the same windowed-rate
//! implementation PromQL's `rate()` uses (`crate::engine::evaluate_promql`) — rather than a
//! bespoke `VecDeque`+prune loop, so the two agree on what "windowed rate" means (ticket 040). A
//! window is also backfilled once, on first evaluation, from already-ingested logs within its own
//! trailing window (see `backfill_window`): a rule created mid-session (or a process that replays
//! existing log content, e.g. `--file` tailing) no longer starts with a silently empty window.
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::io;
@@ -20,14 +9,12 @@ use std::time::{SystemTime, UNIX_EPOCH};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::data_value::DataValue;
use crate::engine::{self, apply_compare_op, eval_json_comparison};
use crate::filter_datetime;
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
use crate::index_key::IndexKey;
use crate::rate::RateWindow;
use crate::settings::Settings;
use crate::timestamp::Timestamp;
use crate::config::Settings;
use crate::engine::filter_datetime;
use crate::engine::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
use crate::engine::rate::RateWindow;
use crate::engine::timestamp::Timestamp;
use crate::engine::{self, eval_json_comparison};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlertRule {
@@ -40,20 +27,19 @@ pub struct AlertRule {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum AlertCondition {
Immediate { query: String },
/// Fires when `query`'s match count within a rolling `window_ms` crosses `threshold` (via `op`).
Immediate {
query: String,
},
Threshold {
query: String,
op: CompareOp,
threshold: f64,
window_ms: i64,
},
/// Seen values live only in memory — a restart forgets them, so previously-seen values can fire again; a deliberate simplicity tradeoff, not an oversight.
NewValue {
field_regex: String,
capture_group: String,
},
/// A zero `count_b` skips evaluation for that line rather than treating the ratio as infinite.
Ratio {
query_a: String,
query_b: String,
@@ -86,7 +72,6 @@ impl From<io::Error> for AlertError {
}
}
/// Hashes the name and current time rather than pulling in a `uuid` dependency.
fn generate_alert_rule_id(name: &str) -> String {
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
@@ -106,7 +91,12 @@ impl Settings {
enabled: bool,
condition: AlertCondition,
) -> Result<AlertRule, AlertError> {
let rule = AlertRule { id: generate_alert_rule_id(&name), name, enabled, condition };
let rule = AlertRule {
id: generate_alert_rule_id(&name),
name,
enabled,
condition,
};
self.alert_rules.push(rule.clone());
self.save(settings_path)?;
Ok(rule)
@@ -145,13 +135,6 @@ impl Settings {
}
}
/// Per-rule in-memory evaluation state; lives only for the process's lifetime — not persisted
/// across a restart with no replayed log history. Each `RateWindow` is backfilled once (see
/// `backfill_window`) from whatever matching history is already in the index at first evaluation,
/// so — unlike before ticket 040 — a rule doesn't start blind to matches ingested before it was
/// declared, or before this evaluation loop last started, as long as that history is still in the
/// in-memory log index (e.g. `--file` tailing replays existing file content from the start on
/// every process start).
#[derive(Debug, Default)]
pub struct AlertRuntimeState {
threshold_window: RateWindow,
@@ -163,63 +146,83 @@ pub struct AlertRuntimeState {
seen_values: HashSet<String>,
}
/// Builds a `RateWindow` seeded from already-ingested logs matching `query`, bounded to
/// `window_ms` before `now` (not the full log history) so the cost stays proportional to the
/// window rather than everything ever ingested. `before_sequence` excludes the line currently
/// being evaluated, which the caller pushes separately if it matches.
fn backfill_window(
index: &BTreeMap<IndexKey, DataValue>,
snapshot: &engine::EngineSnapshot,
query: &str,
before_sequence: u64,
now: Timestamp,
window_ms: i64,
) -> RateWindow {
let since_ms = now.as_millis() - window_ms;
let matching_timestamps =
engine::logs_since(index, before_sequence, since_ms, now.as_millis()).filter_map(
|(sequence, timestamp, text)| {
(eval_alert_query(query, sequence, timestamp, text) == Ok(true)).then_some(timestamp)
},
);
let matching_timestamps = snapshot
.logs_since(before_sequence, since_ms, now.as_millis())
.filter_map(|(sequence, timestamp, text)| {
(eval_alert_query(query, sequence, timestamp, text) == Ok(true)).then_some(timestamp)
});
RateWindow::backfill(matching_timestamps, now, window_ms)
}
/// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc; json[field] is supported since it's also line-local).
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
fn eval_alert_query(
query: &str,
sequence: u64,
timestamp: Timestamp,
text: &str,
) -> Result<bool, String> {
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
eval_alert_expr(&expr, sequence, timestamp, text)
}
fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
fn eval_alert_expr(
expr: &FilterExpr,
sequence: u64,
timestamp: Timestamp,
text: &str,
) -> Result<bool, String> {
match expr {
FilterExpr::Regex(pattern) => {
let re = Regex::new(pattern).map_err(|err| format!("invalid regex in alert query: {err}"))?;
let re = Regex::new(pattern)
.map_err(|err| format!("invalid regex in alert query: {err}"))?;
Ok(re.is_match(text))
}
FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_seq" => {
FilterExpr::Comparison {
metric,
param: None,
op,
value,
} if metric == "ottyl_seq" => {
let target = match value {
FilterValue::Number(n) => *n,
FilterValue::Raw(raw) => raw
.parse::<f64>()
.map_err(|_| format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}"))?,
FilterValue::Raw(raw) => raw.parse::<f64>().map_err(|_| {
format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}")
})?,
};
Ok(apply_compare_op((sequence as f64).total_cmp(&target), *op))
Ok(op.matches((sequence as f64).total_cmp(&target)))
}
FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_ts" => {
FilterExpr::Comparison {
metric,
param: None,
op,
value,
} if metric == "ottyl_ts" => {
let target_ms = match value {
FilterValue::Number(n) => *n as i64,
FilterValue::Raw(raw) => filter_datetime::parse_datetime(raw, Timestamp::now())
.map_err(|err| err.to_string())?
.as_millis(),
};
Ok(apply_compare_op(timestamp.as_millis().cmp(&target_ms), *op))
Ok(op.matches(timestamp.as_millis().cmp(&target_ms)))
}
FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_fmt" => {
FilterExpr::Comparison {
metric,
param: None,
op,
value,
} if metric == "ottyl_fmt" => {
let target = match value {
FilterValue::Number(n) => n.to_string(),
FilterValue::Raw(raw) => raw.clone(),
};
Ok(apply_compare_op(crate::logfmt::detect_format(text).cmp(target.as_str()), *op))
Ok(op.matches(crate::engine::logfmt::detect_format(text).cmp(target.as_str())))
}
FilterExpr::Comparison { metric, .. } => Err(format!(
"alert queries only support regex()/ottyl_seq/ottyl_ts/ottyl_fmt (not metric comparisons like \
@@ -228,20 +231,17 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
FilterExpr::JsonComparison { field, op, value } => {
Ok(eval_json_comparison(text, field, *op, value))
}
FilterExpr::And(lhs, rhs) => {
Ok(eval_alert_expr(lhs, sequence, timestamp, text)? && eval_alert_expr(rhs, sequence, timestamp, text)?)
}
FilterExpr::Or(lhs, rhs) => {
Ok(eval_alert_expr(lhs, sequence, timestamp, text)? || eval_alert_expr(rhs, sequence, timestamp, text)?)
}
FilterExpr::And(lhs, rhs) => Ok(eval_alert_expr(lhs, sequence, timestamp, text)?
&& eval_alert_expr(rhs, sequence, timestamp, text)?),
FilterExpr::Or(lhs, rhs) => Ok(eval_alert_expr(lhs, sequence, timestamp, text)?
|| eval_alert_expr(rhs, sequence, timestamp, text)?),
}
}
/// A malformed query (bad regex, unsupported metric comparison) is logged and simply never matches, rather than panicking.
pub fn evaluate_alerts(
rules: &[AlertRule],
states: &mut HashMap<String, AlertRuntimeState>,
index: &BTreeMap<IndexKey, DataValue>,
snapshot: &engine::EngineSnapshot,
sequence: u64,
timestamp: Timestamp,
text: &str,
@@ -252,9 +252,15 @@ pub fn evaluate_alerts(
continue;
}
let state = states.entry(rule.id.clone()).or_default();
if let Some(message) =
evaluate_condition(&rule.condition, state, index, sequence, timestamp, text, &rule.name)
{
if let Some(message) = evaluate_condition(
&rule.condition,
state,
snapshot,
sequence,
timestamp,
text,
&rule.name,
) {
fired.push((rule.clone(), message));
}
}
@@ -264,31 +270,39 @@ pub fn evaluate_alerts(
fn evaluate_condition(
condition: &AlertCondition,
state: &mut AlertRuntimeState,
index: &BTreeMap<IndexKey, DataValue>,
snapshot: &engine::EngineSnapshot,
sequence: u64,
timestamp: Timestamp,
text: &str,
rule_name: &str,
) -> Option<String> {
match condition {
AlertCondition::Immediate { query } => match eval_alert_query(query, sequence, timestamp, text) {
Ok(true) => Some(format!("{rule_name}: matched line: {text}")),
Ok(false) => None,
Err(err) => {
eprintln!("warning: alert '{rule_name}' query error: {err}");
None
AlertCondition::Immediate { query } => {
match eval_alert_query(query, sequence, timestamp, text) {
Ok(true) => Some(format!("{rule_name}: matched line: {text}")),
Ok(false) => None,
Err(err) => {
eprintln!("warning: alert '{rule_name}' query error: {err}");
None
}
}
},
AlertCondition::Threshold { query, op, threshold, window_ms } => {
}
AlertCondition::Threshold {
query,
op,
threshold,
window_ms,
} => {
if !state.threshold_backfilled {
state.threshold_window = backfill_window(index, query, sequence, timestamp, *window_ms);
state.threshold_window =
backfill_window(snapshot, query, sequence, timestamp, *window_ms);
state.threshold_backfilled = true;
}
match eval_alert_query(query, sequence, timestamp, text) {
Ok(true) => {
state.threshold_window.push(timestamp, *window_ms);
let count = state.threshold_window.count() as f64;
apply_compare_op(count.total_cmp(threshold), *op).then(|| {
op.matches(count.total_cmp(threshold)).then(|| {
format!("{rule_name}: match count {count} {op:?} threshold {threshold} (window {window_ms}ms)")
})
}
@@ -299,7 +313,10 @@ fn evaluate_condition(
}
}
}
AlertCondition::NewValue { field_regex, capture_group } => {
AlertCondition::NewValue {
field_regex,
capture_group,
} => {
let re = match Regex::new(field_regex) {
Ok(re) => re,
Err(err) => {
@@ -314,7 +331,13 @@ fn evaluate_condition(
.insert(value.clone())
.then(|| format!("{rule_name}: new value seen: {value} (line: {text})"))
}
AlertCondition::Ratio { query_a, query_b, op, ratio, window_ms } => {
AlertCondition::Ratio {
query_a,
query_b,
op,
ratio,
window_ms,
} => {
let matched_a = eval_alert_query(query_a, sequence, timestamp, text);
let matched_b = eval_alert_query(query_b, sequence, timestamp, text);
let (matched_a, matched_b) = match (matched_a, matched_b) {
@@ -328,11 +351,13 @@ fn evaluate_condition(
return None;
}
if !state.ratio_a_backfilled {
state.ratio_a_window = backfill_window(index, query_a, sequence, timestamp, *window_ms);
state.ratio_a_window =
backfill_window(snapshot, query_a, sequence, timestamp, *window_ms);
state.ratio_a_backfilled = true;
}
if !state.ratio_b_backfilled {
state.ratio_b_window = backfill_window(index, query_b, sequence, timestamp, *window_ms);
state.ratio_b_window =
backfill_window(snapshot, query_b, sequence, timestamp, *window_ms);
state.ratio_b_backfilled = true;
}
if matched_a {
@@ -344,11 +369,10 @@ fn evaluate_condition(
let count_a = state.ratio_a_window.count() as f64;
let count_b = state.ratio_b_window.count() as f64;
if count_b == 0.0 {
// Zero denominator: skip rather than treat the ratio as infinite.
return None;
}
let observed = count_a / count_b;
apply_compare_op(observed.total_cmp(ratio), *op).then(|| {
op.matches(observed.total_cmp(ratio)).then(|| {
format!(
"{rule_name}: ratio {observed:.3} ({count_a}/{count_b}) {op:?} {ratio} (window {window_ms}ms)"
)
@@ -357,10 +381,12 @@ fn evaluate_condition(
}
}
/// Delivers to the single, process-wide ntfy sink configured in Settings. Errors are only logged
/// (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
/// A silently no-op'd delivery (no topic configured) is expected — a rule can be defined before the sink is.
pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<String>, server: Option<String>) {
pub async fn deliver_for_rule(
rule_name: String,
message: String,
topic: Option<String>,
server: Option<String>,
) {
let Some(topic) = topic else {
return;
};
@@ -370,7 +396,6 @@ pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<
}
}
/// Standalone and `AlertRule`-free so a "send test notification" endpoint can call it directly.
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
let response = reqwest::Client::new()
@@ -399,7 +424,9 @@ mod tests {
id: "r1".to_string(),
name: "test rule".to_string(),
enabled: true,
condition: AlertCondition::Immediate { query: query.to_string() },
condition: AlertCondition::Immediate {
query: query.to_string(),
},
}
}
@@ -414,7 +441,9 @@ mod tests {
&settings_path,
"spike".to_string(),
true,
AlertCondition::Immediate { query: r#"regex("error")"#.to_string() },
AlertCondition::Immediate {
query: r#"regex("error")"#.to_string(),
},
)
.unwrap();
assert_eq!(settings.alert_rules.len(), 1);
@@ -428,14 +457,18 @@ mod tests {
&created.id,
"renamed".to_string(),
false,
AlertCondition::Immediate { query: r#"regex("warn")"#.to_string() },
AlertCondition::Immediate {
query: r#"regex("warn")"#.to_string(),
},
)
.unwrap();
assert_eq!(updated.id, created.id);
assert_eq!(updated.name, "renamed");
assert!(!updated.enabled);
settings.remove_alert_rule(&settings_path, &created.id).unwrap();
settings
.remove_alert_rule(&settings_path, &created.id)
.unwrap();
assert!(settings.alert_rules.is_empty());
let reloaded = Settings::load(&settings_path);
assert!(reloaded.alert_rules.is_empty());
@@ -452,7 +485,9 @@ mod tests {
"does-not-exist",
"x".to_string(),
true,
AlertCondition::Immediate { query: r#"regex("x")"#.to_string() },
AlertCondition::Immediate {
query: r#"regex("x")"#.to_string(),
},
);
assert!(matches!(result, Err(AlertError::NotFound)));
@@ -464,7 +499,7 @@ mod tests {
fn immediate_fires_on_matching_line_only() {
let rules = vec![immediate_rule(r#"regex("error")"#)];
let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
let index = engine::EngineSnapshot::default();
let (seq, ts, text) = line("all good", 0, 0);
assert!(evaluate_alerts(&rules, &mut states, &index, seq, ts, text).is_empty());
@@ -480,7 +515,7 @@ mod tests {
let mut rule = immediate_rule(r#"regex("error")"#);
rule.enabled = false;
let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
let index = engine::EngineSnapshot::default();
let (seq, ts, text) = line("error!", 0, 0);
assert!(evaluate_alerts(&[rule], &mut states, &index, seq, ts, text).is_empty());
}
@@ -499,15 +534,38 @@ mod tests {
},
};
let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
let index = engine::EngineSnapshot::default();
for (seq, ms) in [(0u64, 0i64), (1, 1000)] {
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, seq, Timestamp::from_millis(ms), "error");
assert!(fired.is_empty(), "should not fire before threshold is crossed");
let fired = evaluate_alerts(
std::slice::from_ref(&rule),
&mut states,
&index,
seq,
Timestamp::from_millis(ms),
"error",
);
assert!(
fired.is_empty(),
"should not fire before threshold is crossed"
);
}
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2000), "error");
let fired = evaluate_alerts(
std::slice::from_ref(&rule),
&mut states,
&index,
2,
Timestamp::from_millis(2000),
"error",
);
assert_eq!(fired.len(), 1);
// Stays quiet for a non-matching line even though the window is full.
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(2100), "fine");
let fired = evaluate_alerts(
&[rule],
&mut states,
&index,
3,
Timestamp::from_millis(2100),
"fine",
);
assert!(fired.is_empty());
}
@@ -525,10 +583,29 @@ mod tests {
},
};
let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
// Second match arrives well after the first has aged out of the window.
assert!(evaluate_alerts(&[rule], &mut states, &index, 1, Timestamp::from_millis(5_000), "error").is_empty());
let index = engine::EngineSnapshot::default();
assert!(
evaluate_alerts(
std::slice::from_ref(&rule),
&mut states,
&index,
0,
Timestamp::from_millis(0),
"error"
)
.is_empty()
);
assert!(
evaluate_alerts(
&[rule],
&mut states,
&index,
1,
Timestamp::from_millis(5_000),
"error"
)
.is_empty()
);
}
#[test]
@@ -543,12 +620,33 @@ mod tests {
},
};
let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "agent=curl");
let index = engine::EngineSnapshot::default();
let fired = evaluate_alerts(
std::slice::from_ref(&rule),
&mut states,
&index,
0,
Timestamp::from_millis(0),
"agent=curl",
);
assert_eq!(fired.len(), 1);
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "agent=curl");
let fired = evaluate_alerts(
std::slice::from_ref(&rule),
&mut states,
&index,
1,
Timestamp::from_millis(1),
"agent=curl",
);
assert!(fired.is_empty());
let fired = evaluate_alerts(&[rule], &mut states, &index, 2, Timestamp::from_millis(2), "agent=wget");
let fired = evaluate_alerts(
&[rule],
&mut states,
&index,
2,
Timestamp::from_millis(2),
"agent=wget",
);
assert_eq!(fired.len(), 1);
}
@@ -567,17 +665,44 @@ mod tests {
},
};
let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
// One request: ratio 1/1 = 1.0 >= 0.5 -> fires.
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "request");
let index = engine::EngineSnapshot::default();
assert!(
evaluate_alerts(
std::slice::from_ref(&rule),
&mut states,
&index,
0,
Timestamp::from_millis(0),
"error"
)
.is_empty()
);
let fired = evaluate_alerts(
std::slice::from_ref(&rule),
&mut states,
&index,
1,
Timestamp::from_millis(1),
"request",
);
assert_eq!(fired.len(), 1);
// A second request: ratio 1/2 = 0.5 still >= 0.5 -> fires again.
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2), "request");
let fired = evaluate_alerts(
std::slice::from_ref(&rule),
&mut states,
&index,
2,
Timestamp::from_millis(2),
"request",
);
assert_eq!(fired.len(), 1);
// A third request with no more errors: ratio drops to 1/3 -> no longer fires.
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(3), "request");
let fired = evaluate_alerts(
&[rule],
&mut states,
&index,
3,
Timestamp::from_millis(3),
"request",
);
assert!(fired.is_empty());
}
@@ -585,10 +710,10 @@ mod tests {
fn ottyl_fmt_alert_query_matches_detected_format() {
let rule = immediate_rule("ottyl_fmt==json");
let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
let index = engine::EngineSnapshot::default();
let fired = evaluate_alerts(
&[rule.clone()],
std::slice::from_ref(&rule),
&mut states,
&index,
0,
@@ -597,7 +722,14 @@ mod tests {
);
assert_eq!(fired.len(), 1);
let fired = evaluate_alerts(&[rule], &mut states, &index, 1, Timestamp::from_millis(1), "plain text");
let fired = evaluate_alerts(
&[rule],
&mut states,
&index,
1,
Timestamp::from_millis(1),
"plain text",
);
assert!(fired.is_empty());
}
@@ -605,17 +737,23 @@ mod tests {
fn metric_comparison_in_alert_query_is_rejected_not_panicking() {
let rule = immediate_rule("some_metric[param]==5");
let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
// Should log a warning and simply not fire, never panic.
let fired = evaluate_alerts(&[rule], &mut states, &index, 0, Timestamp::from_millis(0), "anything");
let index = engine::EngineSnapshot::default();
let fired = evaluate_alerts(
&[rule],
&mut states,
&index,
0,
Timestamp::from_millis(0),
"anything",
);
assert!(fired.is_empty());
}
#[tokio::test]
async fn deliver_ntfy_posts_message_to_server_topic_path() {
use axum::Router;
use axum::extract::State;
use axum::routing::post;
use axum::Router;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
@@ -632,7 +770,9 @@ mod tests {
}
let captured = Captured::default();
let app = Router::new().route("/{topic}", post(capture)).with_state(captured.clone());
let app = Router::new()
.route("/{topic}", post(capture))
.with_state(captured.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
@@ -640,7 +780,9 @@ mod tests {
});
let server = format!("http://{addr}");
deliver_ntfy(&server, "alerts", "hello world").await.unwrap();
deliver_ntfy(&server, "alerts", "hello world")
.await
.unwrap();
let (topic, body) = captured.0.lock().unwrap().clone().unwrap();
assert_eq!(topic, "alerts");
+119
View File
@@ -0,0 +1,119 @@
use crate::engine::metric_primitive::MetricPrimitive;
use crate::engine::timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParamDeclaration {
pub name: String,
pub message_match_regex: String,
pub capture_group: String,
#[serde(default)]
pub json_field_path: Option<String>,
pub kind: MetricPrimitiveKind,
#[serde(default)]
pub elapsed_since_last: bool,
#[serde(default)]
pub auto_derived: bool,
#[serde(default)]
pub log_stream_gated: bool,
}
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
let root: serde_json::Value = serde_json::from_str(text).ok()?;
let mut current = root.as_object()?;
let mut segments = field_path.split('.').peekable();
loop {
let segment = segments.next()?;
let next_value = current.get(segment)?;
if segments.peek().is_none() {
return match next_value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
};
}
current = next_value.as_object()?;
}
}
pub fn flatten_json_object(text: &str) -> Vec<(String, String)> {
let Ok(serde_json::Value::Object(root)) = serde_json::from_str::<serde_json::Value>(text)
else {
return Vec::new();
};
let mut results = Vec::new();
flatten_json_object_into(&root, "", &mut results);
results
}
fn flatten_json_object_into(
object: &serde_json::Map<String, serde_json::Value>,
prefix: &str,
results: &mut Vec<(String, String)>,
) {
for (key, value) in object {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match value {
serde_json::Value::String(s) => results.push((path, s.clone())),
serde_json::Value::Number(n) => results.push((path, n.to_string())),
serde_json::Value::Bool(b) => results.push((path, b.to_string())),
serde_json::Value::Object(nested) => flatten_json_object_into(nested, &path, results),
serde_json::Value::Null | serde_json::Value::Array(_) => {}
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MetricPrimitiveKind {
String,
Number,
Timestamp,
Duration,
Boolean,
}
/// Whether a declared metric's value is a point-in-time reading (`Gauge`, e.g. queue depth) or a
/// monotonically-increasing total (`Counter`, e.g. requests served) — orthogonal to
/// `MetricPrimitiveKind`, which describes the value's data type rather than how it accumulates.
/// `Gauge` is the default so existing declarations (persisted before this field existed) keep
/// behaving the way bare selectors and the `gauge()` PromQL wrapper already did.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MetricSemantics {
#[default]
Gauge,
Counter,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub enum DataValue {
LogLiteral {
text: Arc<str>,
},
ParamDeclaration(ParamDeclaration),
MetricDeclaration {
message_match_regex: String,
label_params: Vec<String>,
value_param: Option<String>,
matched_labels: Option<BTreeMap<String, String>>,
unmatched_labels: Option<BTreeMap<String, String>>,
semantics: MetricSemantics,
},
BucketValue {
values: Vec<(u64, Timestamp, MetricPrimitive)>,
},
LastEventTimestamp {
timestamp: Timestamp,
},
}
@@ -1,21 +1,12 @@
//! Datetime parsing for the filter query language's timestamp comparisons: relative
//! humantime, time-of-day (resolved against the UTC calendar day of `now`), or a
//! full date/datetime literal.
// `#![allow(dead_code)]` stays since most of this module's surface is still only
// exercised by its own unit tests below.
#![allow(dead_code)]
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
use crate::timestamp::Timestamp;
use crate::engine::timestamp::Timestamp;
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateTimeParseError {
/// The input matched a supported format's shape but its value was invalid, e.g. `25:99`.
Invalid { input: String, reason: String },
/// The input didn't match any supported format at all.
Unparseable(String),
}
@@ -37,8 +28,6 @@ impl std::fmt::Display for DateTimeParseError {
impl std::error::Error for DateTimeParseError {}
/// Parses a filter datetime literal into an absolute [`Timestamp`]; `now` is a parameter
/// (rather than calling `Timestamp::now()` internally) so callers can pass a fixed value in tests.
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
@@ -60,8 +49,6 @@ pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTime
Err(DateTimeParseError::Unparseable(input.to_string()))
}
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`);
/// returns `None` if it doesn't look like one at all, so the caller can try other formats.
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
let (sign, rest) = match input.strip_prefix('-') {
Some(rest) => (-1i128, rest),
@@ -71,11 +58,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
},
};
// Require a digit followed by an alphabetic unit so date-like inputs (e.g. "2024-01-15") fall through to the date parser instead of erroring here as an invalid duration.
let looks_like_duration = rest
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
let looks_like_duration = rest.chars().next().is_some_and(|c| c.is_ascii_digit())
&& rest.chars().any(|c| c.is_ascii_alphabetic())
&& !rest.contains('-')
&& !rest.contains(':');
@@ -97,9 +80,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
}
}
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
// Only attempt this path for inputs shaped like a date, e.g. "2024-01-15".
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
if !looks_like_date {
return None;
@@ -123,11 +104,7 @@ fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError
}
}
/// Tries to interpret `input` as a bare time-of-day, resolved against the UTC calendar day of `now`.
fn try_time_of_day(
input: &str,
now: Timestamp,
) -> Option<Result<Timestamp, DateTimeParseError>> {
fn try_time_of_day(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
if !input.contains(':') {
return None;
}
@@ -146,10 +123,14 @@ fn try_time_of_day(
};
let now_millis = now.as_millis();
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
// Fall back to the epoch date; shouldn't happen since `Timestamp` is always a valid instant.
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
});
let now_dt = Utc
.timestamp_millis_opt(now_millis)
.single()
.unwrap_or_else(|| {
Utc.timestamp_millis_opt(0)
.single()
.expect("epoch is valid")
});
let today = now_dt.date_naive();
let dt = Utc.from_utc_datetime(&today.and_time(time));
Some(Ok(timestamp_from_datetime(dt)))
+30
View File
@@ -0,0 +1,30 @@
WHITESPACE = _{ " " | "\t" | "\n" | "\r" }
file = { SOI ~ or_expr ~ EOI }
or_expr = { and_expr ~ (or_kw ~ and_expr)* }
and_expr = { term ~ (and_kw ~ term)* }
term = _{ regex_expr | comparison | "(" ~ or_expr ~ ")" }
and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") }
or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") }
regex_expr = { "regex" ~ "(" ~ string_literal ~ ")" }
string_literal = ${ "\"" ~ inner_str ~ "\"" }
inner_str = @{ (escaped_char | !("\"" | "\\") ~ ANY)* }
escaped_char = @{ "\\" ~ ANY }
comparison = _{ json_comparison | metric_comparison }
json_comparison = { "json" ~ "[" ~ param ~ "]" ~ op ~ value }
metric_comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value }
metric = @{ ident }
param = @{ ident }
ident = @{ (ASCII_ALPHANUMERIC | "_")+ }
op = { "<=" | ">=" | "==" | "!=" | "<" | ">" }
value = @{ (!(WHITESPACE | ")") ~ ANY)+ }
@@ -1,5 +1,3 @@
//! FilterBar query language: grammar (see `src/filter_parser.pest`), AST and parser.
//! Evaluating the AST against real log data is left to `engine.rs`.
#![allow(dead_code)]
use pest::Parser;
@@ -8,23 +6,18 @@ use pest_derive::Parser as PestParser;
use serde::{Deserialize, Serialize};
#[derive(PestParser)]
#[grammar = "filter_parser.pest"]
#[grammar = "engine/filter_parser.pest"]
struct FilterGrammar;
/// The parsed AST for a FilterBar query.
#[derive(Debug, Clone, PartialEq)]
pub enum FilterExpr {
/// `regex("...")` — raw-text regex search over log lines.
Regex(String),
/// `metric[param] <op> value` — a single metric comparison.
Comparison {
metric: String,
param: Option<String>,
op: CompareOp,
value: FilterValue,
},
/// `json[field] <op> value` — a comparison against a field of the line's parsed JSON.
/// Lines that aren't valid JSON (or lack the field) simply don't match.
JsonComparison {
field: String,
op: CompareOp,
@@ -34,8 +27,6 @@ pub enum FilterExpr {
Or(Box<FilterExpr>, Box<FilterExpr>),
}
/// Comparison operators for metric comparisons; also reused by `crate::alerts::AlertCondition`
/// for threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CompareOp {
@@ -47,15 +38,12 @@ pub enum CompareOp {
Ne,
}
/// The right-hand side of a comparison: values that parse as a number become
/// [`FilterValue::Number`], everything else is kept as an opaque [`FilterValue::Raw`] string.
#[derive(Debug, Clone, PartialEq)]
pub enum FilterValue {
Number(f64),
Raw(String),
}
/// A located, human-readable parse error.
#[derive(Debug, Clone, PartialEq)]
pub struct FilterParseError {
message: String,
@@ -79,12 +67,10 @@ impl std::error::Error for FilterParseError {}
impl From<pest::error::Error<Rule>> for FilterParseError {
fn from(err: pest::error::Error<Rule>) -> Self {
// pest's Display already renders a located message, so use it and add a short prefix.
FilterParseError::new(format!("failed to parse filter expression: {err}"))
}
}
/// Parse a FilterBar query string into a [`FilterExpr`] AST.
pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
let mut pairs = FilterGrammar::parse(Rule::file, input)?;
@@ -102,10 +88,7 @@ pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
// `or_kw` is atomic so it's not silent, showing up as a sibling pair between `and_expr` operands — skip it rather than assuming a fixed alternating structure.
let mut and_exprs = pair
.into_inner()
.filter(|p| p.as_rule() != Rule::or_kw);
let mut and_exprs = pair.into_inner().filter(|p| p.as_rule() != Rule::or_kw);
let first = and_exprs
.next()
.ok_or_else(|| FilterParseError::new("malformed 'or' expression"))?;
@@ -119,10 +102,7 @@ fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
debug_assert_eq!(pair.as_rule(), Rule::and_expr);
// Same reasoning as `build_or_expr` for `and_kw`.
let mut terms = pair
.into_inner()
.filter(|p| p.as_rule() != Rule::and_kw);
let mut terms = pair.into_inner().filter(|p| p.as_rule() != Rule::and_kw);
let first = terms
.next()
.ok_or_else(|| FilterParseError::new("malformed 'and' expression"))?;
@@ -198,9 +178,9 @@ fn build_comparison(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
fn build_json_comparison(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
let mut inner = pair.into_inner();
let field_pair = inner
.next()
.ok_or_else(|| FilterParseError::new("malformed json[...] comparison: missing field name"))?;
let field_pair = inner.next().ok_or_else(|| {
FilterParseError::new("malformed json[...] comparison: missing field name")
})?;
let field = field_pair.as_str().to_string();
let op_pair = inner
@@ -237,7 +217,6 @@ fn parse_value(s: &str) -> FilterValue {
}
}
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, `\x` -> `x`).
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
@@ -372,8 +351,8 @@ mod tests {
#[test]
fn parses_and_or_with_and_binding_tighter() {
let expr = parse(r#"regex("error") and ottyl_seq<5 or ottyl_ts>12:00"#)
.expect("should parse");
let expr =
parse(r#"regex("error") and ottyl_seq<5 or ottyl_ts>12:00"#).expect("should parse");
let expected = FilterExpr::Or(
Box::new(FilterExpr::And(
@@ -398,8 +377,8 @@ mod tests {
#[test]
fn parenthesized_expression_overrides_precedence() {
let expr = parse(r#"regex("error") and (ottyl_seq<5 or ottyl_ts>12:00)"#)
.expect("should parse");
let expr =
parse(r#"regex("error") and (ottyl_seq<5 or ottyl_ts>12:00)"#).expect("should parse");
let expected = FilterExpr::And(
Box::new(FilterExpr::Regex("error".to_string())),
+32
View File
@@ -0,0 +1,32 @@
use crate::engine::metric_primitive::MetricPrimitive;
use crate::engine::timestamp::Timestamp;
use serde::Serialize;
use std::collections::BTreeMap;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum IndexKey {
Start,
IngestedLog {
sequence: u64,
timestamp: Timestamp,
},
LogParamSplit,
ParamDeclaration {
name: String,
},
ParamMetricSplit,
MetricDeclaration {
name: String,
},
MetricBucketSplit,
Bucket {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
LastMetricEvent {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
End,
}
+77 -74
View File
@@ -1,20 +1,13 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::engine::{EngineMessage, IngestedLine, MetricSample};
use crate::timestamp::Timestamp;
use crate::consts::{METRICS_POLL_INTERVAL, STDIN_POLL_INTERVAL as POLL_INTERVAL};
use crate::engine::timestamp::Timestamp;
use crate::engine::{EngineHandle, EngineMessage, IngestedLine, MetricSample};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
const POLL_INTERVAL: Duration = Duration::from_millis(200);
const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(15);
/// Reads stdin line by line, sequences and timestamps each line, and forwards it
/// to the engine task.
pub async fn run(engine: mpsc::Sender<EngineMessage>) {
pub async fn run(engine: EngineHandle) {
let stdin = tokio::io::stdin();
let mut lines = BufReader::new(stdin).lines();
let mut sequence: u64 = 0;
@@ -23,17 +16,16 @@ pub async fn run(engine: mpsc::Sender<EngineMessage>) {
let line = IngestedLine {
sequence,
timestamp: Timestamp::now(),
text,
text: text.into(),
};
sequence += 1;
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
if engine.tx.send(EngineMessage::Ingest(line)).await.is_err() {
break;
}
}
}
/// Identifies a specific file instance on disk, so a recreated path can be told apart from the same file simply growing.
#[derive(PartialEq, Eq, Clone, Copy)]
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
@@ -56,10 +48,7 @@ async fn open(path: &Path) -> Option<(File, FileId)> {
Some((file, file_id(&meta)))
}
/// Tails `path` like `tail -f`, resuming from the start if the file is deleted/recreated or
/// truncated in place rather than exiting or getting stuck; `sequence` keeps incrementing
/// across a recreation since it's ottyl's own line counter, not tied to the source file.
pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
pub async fn run_file(engine: EngineHandle, path: PathBuf) {
let mut sequence: u64 = 0;
'reopen: loop {
@@ -78,18 +67,12 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
let mut chunk = String::new();
match reader.read_line(&mut chunk).await {
Ok(0) => {
// EOF: decide whether the file was truncated/recreated or just hasn't grown yet.
match tokio::fs::metadata(&path).await {
Ok(meta) => {
let new_id = file_id(&meta);
if new_id != id || meta.len() < offset {
continue 'reopen;
}
id = new_id;
}
Err(_) => {
// Path gone (mid-recreation); wait for it to reappear and reopen fresh.
if let Ok(meta) = tokio::fs::metadata(&path).await {
let new_id = file_id(&meta);
if new_id != id || meta.len() < offset {
continue 'reopen;
}
id = new_id;
}
tokio::time::sleep(POLL_INTERVAL).await;
}
@@ -97,23 +80,20 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
offset += n as u64;
pending.push_str(&chunk);
if pending.ends_with('\n') {
let text = pending
.trim_end_matches(['\n', '\r'])
.to_string();
let text = pending.trim_end_matches(['\n', '\r']).to_string();
pending.clear();
let line = IngestedLine {
sequence,
timestamp: Timestamp::now(),
text,
text: text.into(),
};
sequence += 1;
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
if engine.tx.send(EngineMessage::Ingest(line)).await.is_err() {
return;
}
}
// else: partial line at EOF (write in progress) — keep buffering.
}
Err(_) => continue 'reopen,
}
@@ -121,39 +101,48 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
}
}
/// Polls `url` on a fixed interval for a Prometheus/OpenMetrics-format response, pushing every parsed
/// sample straight into the engine. Errors (unreachable endpoint, non-2xx response) are only logged —
/// polling keeps retrying on the next tick rather than exiting.
pub async fn run_metrics_poll(engine: mpsc::Sender<EngineMessage>, url: String) {
async fn fetch_metrics_body(client: &reqwest::Client, url: &str) -> Result<String, String> {
let response = client
.get(url)
.send()
.await
.map_err(|err| format!("warning: failed to poll metrics endpoint {url}: {err}"))?;
let response = response
.error_for_status()
.map_err(|err| format!("warning: metrics endpoint {url} returned an error: {err}"))?;
response
.text()
.await
.map_err(|err| format!("warning: failed to read metrics response from {url}: {err}"))
}
pub async fn run_metrics_poll(engine: EngineHandle, url: String) {
let client = reqwest::Client::new();
loop {
match client.get(&url).send().await {
Ok(response) => match response.error_for_status() {
Ok(response) => match response.text().await {
Ok(body) => {
let now = Timestamp::now();
for sample in parse_openmetrics(&body, now) {
if engine.send(EngineMessage::IngestMetricSample(sample)).await.is_err() {
return;
}
}
match fetch_metrics_body(&client, &url).await {
Ok(body) => {
let now = Timestamp::now();
for sample in parse_openmetrics(&body, now) {
if engine
.tx
.send(EngineMessage::IngestMetricSample(sample))
.await
.is_err()
{
return;
}
Err(err) => eprintln!("warning: failed to read metrics response from {url}: {err}"),
},
Err(err) => eprintln!("warning: metrics endpoint {url} returned an error: {err}"),
},
Err(err) => eprintln!("warning: failed to poll metrics endpoint {url}: {err}"),
}
}
Err(warning) => eprintln!("{warning}"),
}
tokio::time::sleep(METRICS_POLL_INTERVAL).await;
}
}
/// Parses the Prometheus text exposition / OpenMetrics format: one sample per non-comment,
/// non-empty line, as `metric_name{label="value",...} value [timestamp_ms]` (labels and the
/// timestamp are both optional). A line that doesn't parse is skipped rather than aborting the
/// whole scrape.
fn parse_openmetrics(body: &str, now: Timestamp) -> Vec<MetricSample> {
body.lines().filter_map(|line| parse_openmetrics_line(line, now)).collect()
body.lines()
.filter_map(|line| parse_openmetrics_line(line, now))
.collect()
}
fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
@@ -174,7 +163,10 @@ fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
let (metric_name, labels) = match name_and_labels.find('{') {
Some(open) => {
let close = name_and_labels.rfind('}')?;
(name_and_labels[..open].to_string(), parse_openmetrics_labels(&name_and_labels[open + 1..close]))
(
name_and_labels[..open].to_string(),
parse_openmetrics_labels(&name_and_labels[open + 1..close]),
)
}
None => (name_and_labels.to_string(), BTreeMap::new()),
};
@@ -182,15 +174,23 @@ fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
return None;
}
Some(MetricSample { metric_name, labels, value, timestamp })
Some(MetricSample {
metric_name,
labels,
value,
timestamp,
})
}
/// Splits on commas without handling an escaped comma inside a quoted label value — fine for the
/// common case this targets, not a full OpenMetrics-grammar parser.
fn parse_openmetrics_labels(raw: &str) -> BTreeMap<String, String> {
raw.split(',')
.filter_map(|pair| pair.split_once('='))
.map(|(name, value)| (name.trim().to_string(), value.trim().trim_matches('"').to_string()))
.map(|(name, value)| {
(
name.trim().to_string(),
value.trim().trim_matches('"').to_string(),
)
})
.collect()
}
@@ -212,10 +212,8 @@ mod openmetrics_tests {
#[test]
fn parses_labels_and_explicit_timestamp() {
let now = Timestamp::from_millis(1000);
let samples = parse_openmetrics(
r#"http_requests_total{method="GET",path="/a"} 7 5000"#,
now,
);
let samples =
parse_openmetrics(r#"http_requests_total{method="GET",path="/a"} 7 5000"#, now);
assert_eq!(samples.len(), 1);
let sample = &samples[0];
assert_eq!(sample.metric_name, "http_requests_total");
@@ -238,7 +236,9 @@ mod openmetrics_tests {
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::test_handle;
use std::io::Write;
use std::time::Duration;
use tokio::sync::mpsc;
async fn recv_text(rx: &mut mpsc::Receiver<EngineMessage>) -> String {
@@ -247,7 +247,7 @@ mod tests {
.expect("timed out waiting for ingested line")
.expect("engine channel closed")
{
EngineMessage::Ingest(line) => line.text,
EngineMessage::Ingest(line) => line.text.to_string(),
_ => panic!("expected Ingest"),
}
}
@@ -258,12 +258,15 @@ mod tests {
let path = dir.path().join("app.log");
std::fs::write(&path, "first\n").unwrap();
let (tx, mut rx) = mpsc::channel(16);
let (tx, mut rx) = test_handle(16);
tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "first");
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
writeln!(f, "second").unwrap();
assert_eq!(recv_text(&mut rx).await, "second");
}
@@ -274,7 +277,7 @@ mod tests {
let path = dir.path().join("app.log");
std::fs::write(&path, "before-restart\n").unwrap();
let (tx, mut rx) = mpsc::channel(16);
let (tx, mut rx) = test_handle(16);
tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "before-restart");
@@ -291,7 +294,7 @@ mod tests {
let path = dir.path().join("app.log");
std::fs::write(&path, "long line before truncation\n").unwrap();
let (tx, mut rx) = mpsc::channel(16);
let (tx, mut rx) = test_handle(16);
tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "long line before truncation");
@@ -312,7 +315,7 @@ mod tests {
let path = dir.path().join("app.log");
std::fs::write(&path, "one\n").unwrap();
let (tx, mut rx) = mpsc::channel(16);
let (tx, mut rx) = test_handle(16);
tokio::spawn(run_file(tx, path.clone()));
let first = match tokio::time::timeout(Duration::from_secs(2), rx.recv())
+584
View File
@@ -0,0 +1,584 @@
use nom::IResult;
use nom::branch::alt;
use nom::bytes::complete::{escaped, tag, take_until, take_while1};
use nom::character::complete::{char, none_of, one_of};
use nom::error::{Error, ErrorKind};
use nom::sequence::delimited;
use regex::Regex;
use std::sync::LazyLock;
use crate::consts::{COLUMN_SEPARATOR_PATTERN, COLUMN_VALUE_PATTERN};
fn key(input: &str) -> IResult<&str, &str> {
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')(input)
}
fn quoted_value(input: &str) -> IResult<&str, &str> {
delimited(
char('"'),
alt((
escaped(none_of("\\\""), '\\', one_of("\"\\nt")),
nom::combinator::success(""),
)),
char('"'),
)(input)
}
fn bareword_value(input: &str) -> IResult<&str, &str> {
take_while1(|c: char| !c.is_whitespace())(input)
}
fn looks_like_base64_padding_artifact(value: &str) -> bool {
value.starts_with('=')
}
fn pair(input: &str) -> IResult<&str, (&str, &str, bool)> {
let (input, k) = key(input)?;
let (input, _) = char('=')(input)?;
if let Ok((rest, v)) = quoted_value(input) {
Ok((rest, (k, v, true)))
} else {
let (rest, v) = bareword_value(input)?;
if looks_like_base64_padding_artifact(v) {
return Err(nom::Err::Error(Error::new(input, ErrorKind::Verify)));
}
Ok((rest, (k, v, false)))
}
}
pub fn extract_pairs(line: &str) -> Vec<(&str, &str, bool)> {
let mut results = Vec::new();
let mut rest = line;
while !rest.is_empty() {
match pair(rest) {
Ok((remaining, found)) => {
results.push(found);
rest = remaining;
}
Err(_) => {
let mut chars = rest.chars();
chars.next();
rest = chars.as_str();
}
}
}
results
}
static LOG_STREAM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+[^\s:]+:\s",
)
.expect("static log_stream regex must compile")
});
pub fn is_log_stream_line(line: &str) -> bool {
LOG_STREAM_RE.is_match(line)
}
fn bounded_column_value_pattern(width: usize) -> String {
if width <= 1 {
r"\S".to_string()
} else {
format!(r"\S(?:.{{0,{}}}\S)?", width - 2)
}
}
fn is_separator_run(run: &str) -> bool {
run.len() >= 2 || run.contains('\t')
}
static WHITESPACE_RUN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[ \t]+").expect("static regex must compile"));
fn split_columns_with_offsets(line: &str, max_columns: Option<usize>) -> Vec<(usize, &str)> {
let mut columns = Vec::new();
let mut start = 0usize;
let mut consumed = 0usize;
for m in WHITESPACE_RUN.find_iter(line) {
if max_columns.is_some_and(|max| consumed + 1 >= max) {
break;
}
let run = &line[m.start()..m.end()];
if is_separator_run(run) {
columns.push((start, &line[start..m.start()]));
start = m.end();
consumed += 1;
}
}
columns.push((start, &line[start..]));
columns
}
fn split_columns(line: &str, max_columns: Option<usize>) -> Vec<&str> {
split_columns_with_offsets(line, max_columns)
.into_iter()
.map(|(_, s)| s)
.collect()
}
static COLUMN_NAME: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9_]*$").expect("static regex must compile"));
#[derive(Clone)]
pub struct LogStreamColumn {
pub name: String,
start: usize,
}
#[derive(Clone)]
pub struct LogStreamHeader {
pub columns: Vec<LogStreamColumn>,
tab_delimited: bool,
}
fn char_slice(s: &str, start: usize, end: Option<usize>) -> &str {
if end.is_some_and(|end| end <= start) {
return "";
}
let mut indices = s.char_indices();
let byte_start = indices.nth(start).map(|(i, _)| i).unwrap_or(s.len());
let byte_end = match end {
Some(end) => s.char_indices().nth(end).map(|(i, _)| i).unwrap_or(s.len()),
None => s.len(),
};
&s[byte_start..byte_end.max(byte_start)]
}
impl LogStreamHeader {
pub fn detect(line: &str) -> Option<LogStreamHeader> {
if is_log_stream_line(line) {
return None;
}
let mut columns = Vec::new();
for (byte_start, slice) in split_columns_with_offsets(line, None) {
let trimmed = slice.trim();
if trimmed.is_empty() {
continue;
}
let leading_ws = slice.len() - slice.trim_start().len();
let start = line[..byte_start + leading_ws].chars().count();
columns.push(LogStreamColumn {
name: trimmed.to_string(),
start,
});
}
if columns.len() < 2 || !columns.iter().all(|c| COLUMN_NAME.is_match(&c.name)) {
return None;
}
let tab_delimited = WHITESPACE_RUN
.find_iter(line)
.any(|m| m.as_str().contains('\t'));
Some(LogStreamHeader {
columns,
tab_delimited,
})
}
pub fn split_row<'a>(&self, line: &'a str) -> Vec<&'a str> {
if self.tab_delimited {
return split_columns(line, Some(self.columns.len() + 1));
}
let mut result = Vec::with_capacity(self.columns.len() + 1);
for (i, column) in self.columns.iter().enumerate() {
match self.columns.get(i + 1) {
Some(next) => result.push(char_slice(line, column.start, Some(next.start)).trim()),
None => {
let tail = char_slice(line, column.start, None);
let pieces = split_columns(tail, Some(2));
result.push(pieces[0].trim());
if let Some(message) = pieces.get(1) {
result.push(message);
}
}
}
}
result
}
pub fn column_pattern(&self, index: usize, capture_group: &str) -> String {
if self.tab_delimited {
let mut pattern = String::from("^");
for _ in 0..index {
pattern.push_str(COLUMN_VALUE_PATTERN);
pattern.push_str(COLUMN_SEPARATOR_PATTERN);
}
pattern.push_str(&format!("(?P<{capture_group}>{COLUMN_VALUE_PATTERN})"));
return pattern;
}
let start = self.columns[index].start;
let value_pattern = match self.columns.get(index + 1) {
Some(next) => bounded_column_value_pattern(next.start.saturating_sub(start)),
None => COLUMN_VALUE_PATTERN.to_string(),
};
format!("^.{{{start}}}(?P<{capture_group}>{value_pattern})")
}
pub fn message_pattern(&self, capture_group: &str) -> String {
if self.tab_delimited {
let mut pattern = String::from("^");
for _ in 0..self.columns.len() {
pattern.push_str(COLUMN_VALUE_PATTERN);
pattern.push_str(COLUMN_SEPARATOR_PATTERN);
}
pattern.push_str(&format!("(?P<{capture_group}>.*)$"));
return pattern;
}
let start = self.columns.last().map(|c| c.start).unwrap_or(0);
format!(
"^.{{{start}}}{COLUMN_VALUE_PATTERN}{COLUMN_SEPARATOR_PATTERN}(?P<{capture_group}>.*)$"
)
}
}
fn go_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str)> {
let (input, _) = tag("{\"time\":\"")(input)?;
let (input, time) = take_until("\"")(input)?;
let (input, _) = tag("\",\"level\":\"")(input)?;
let (input, level) = take_until("\"")(input)?;
let (input, _) = tag("\",\"msg\":\"")(input)?;
let (input, msg) = take_until("\"")(input)?;
let (input, _) = char('"')(input)?;
Ok((input, (time, level, msg)))
}
pub fn is_go_log_line(text: &str) -> bool {
match go_log_prefix(text) {
Ok((rest, _)) => rest.trim_end().ends_with('}'),
Err(_) => false,
}
}
fn py_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str, &str)> {
let (input, date) = take_while1(|c: char| c.is_ascii_digit() || c == '-')(input)?;
let (input, _) = char(' ')(input)?;
let (input, time) = take_while1(|c: char| c.is_ascii_digit() || c == ':' || c == ',')(input)?;
let (input, _) = char(' ')(input)?;
let (input, level) = take_while1(|c: char| c.is_ascii_uppercase())(input)?;
let (input, _) = char(' ')(input)?;
let (input, logger) =
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')(
input,
)?;
let (input, _) = char(' ')(input)?;
Ok((input, (date, time, level, logger)))
}
pub fn is_py_log_line(text: &str) -> bool {
match py_log_prefix(text) {
Ok((rest, _)) => !extract_pairs(rest).is_empty(),
Err(_) => false,
}
}
pub fn detect_format(text: &str) -> &'static str {
if is_log_stream_line(text) {
"log_stream"
} else if is_go_log_line(text) {
"go-log"
} else if is_py_log_line(text) {
"py-log"
} else if serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_object()) {
"json"
} else if !extract_pairs(text).is_empty() {
"logfmt"
} else {
"none"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_quoted_value() {
let pairs = extract_pairs(r#"msg="entity_moved""#);
assert_eq!(pairs, vec![("msg", "entity_moved", true)]);
}
#[test]
fn extracts_unquoted_bareword_value() {
let pairs = extract_pairs("x1=-4");
assert_eq!(pairs, vec![("x1", "-4", false)]);
}
#[test]
fn extracts_mixed_quoted_and_unquoted_pairs_in_one_line() {
let pairs = extract_pairs(r#"ts=1788648105773 msg="entity_moved" x1=-4 y1=34"#);
assert_eq!(
pairs,
vec![
("ts", "1788648105773", false),
("msg", "entity_moved", true),
("x1", "-4", false),
("y1", "34", false),
]
);
}
#[test]
fn returns_empty_for_lines_with_no_pairs() {
assert!(extract_pairs("just some plain prose here").is_empty());
assert!(extract_pairs(r#"{"foo": {"bar": 42}}"#).is_empty());
}
#[test]
fn does_not_panic_on_empty_value_at_end_of_line() {
assert_eq!(extract_pairs("key="), Vec::<(&str, &str, bool)>::new());
assert_eq!(extract_pairs(r#"key="""#), vec![("key", "", true)]);
}
#[test]
fn keeps_escaped_quote_inside_quoted_value() {
let pairs = extract_pairs(r#"msg="he said \"hi\"""#);
assert_eq!(pairs, vec![("msg", r#"he said \"hi\""#, true)]);
}
#[test]
fn detects_json_object_lines_as_json() {
assert_eq!(detect_format(r#"{"level":"error","code":500}"#), "json");
}
#[test]
fn detects_key_value_lines_as_logfmt() {
assert_eq!(
detect_format(r#"ts=1788648105773 msg="entity_moved" x1=-4"#),
"logfmt"
);
}
#[test]
fn detects_plain_prose_as_none() {
assert_eq!(detect_format("just some plain prose here"), "none");
}
#[test]
fn a_bare_json_array_or_scalar_is_not_treated_as_json() {
assert_eq!(detect_format("[1,2,3]"), "none");
assert_eq!(detect_format("42"), "none");
}
#[test]
fn rejects_base64_padding_fragment_as_a_pair() {
let pairs = extract_pairs(
"qname: <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>, qtype: A, client pid: 12260 (firefox)",
);
assert!(
pairs.iter().all(|(k, _, _)| *k != "rKTEtG7JmnThhoKPbZiVrA"),
"base64 padding fragment must not be extracted as a pair: {pairs:?}"
);
}
const LOG_STREAM_FIXTURE: &str = include_str!("../fixtures/log_stream_sample.txt");
#[test]
fn detects_log_stream_shaped_lines() {
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
assert_eq!(detect_format(data_line), "log_stream");
assert!(is_log_stream_line(data_line));
}
#[test]
fn header_row_is_not_detected_as_log_stream() {
let header = LOG_STREAM_FIXTURE.lines().next().unwrap();
assert!(!is_log_stream_line(header));
}
fn column_names(header: &LogStreamHeader) -> Vec<&str> {
header.columns.iter().map(|c| c.name.as_str()).collect()
}
#[test]
fn detects_log_stream_header_columns_generically() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = LogStreamHeader::detect(header_line).expect("fixture header must be detected");
assert_eq!(
column_names(&header),
vec!["Timestamp", "Thread", "Type", "Activity", "PID", "TTL"]
);
assert!(!header.tab_delimited);
}
#[test]
fn a_log_stream_data_line_is_never_detected_as_its_own_header() {
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
assert!(LogStreamHeader::detect(data_line).is_none());
}
#[test]
fn plain_prose_is_not_detected_as_a_log_stream_header() {
assert!(LogStreamHeader::detect("just some plain prose here").is_none());
assert!(LogStreamHeader::detect("ts=1788648105773 msg=\"entity_moved\"").is_none());
}
#[test]
fn splits_log_stream_row_into_named_columns_plus_trailing_message() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = LogStreamHeader::detect(header_line).unwrap();
let data_line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let row = header.split_row(data_line);
assert_eq!(row.len(), header.columns.len() + 1);
assert_eq!(row[0], "2026-09-06 01:48:54.518117+0100");
assert_eq!(row[1], "0x4dfed2");
assert_eq!(row[2], "Default");
assert_eq!(row[3], "0x0");
assert_eq!(row[4], "606");
assert_eq!(row[5], "0");
assert_eq!(
row[6],
"WindowServer: (HSTouchHIDService) something happened"
);
}
#[test]
fn a_single_tab_separates_columns_exactly_like_a_run_of_spaces() {
let header = LogStreamHeader::detect("Timestamp\tThread\tType").unwrap();
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type"]);
assert!(header.tab_delimited);
let spaced = header.split_row("2026-09-06 01:48:54\t0x4dfed2\t\tDefault trailing message");
let tabbed = header.split_row("2026-09-06 01:48:54\t0x4dfed2\tDefault trailing message");
assert_eq!(spaced[0], "2026-09-06 01:48:54");
assert_eq!(spaced[1], "0x4dfed2");
assert_eq!(spaced[2], "Default trailing message");
assert_eq!(tabbed[0], "2026-09-06 01:48:54");
assert_eq!(tabbed[1], "0x4dfed2");
assert_eq!(tabbed[2], "Default trailing message");
}
#[test]
fn column_and_message_patterns_capture_the_same_fields_split_log_stream_row_finds() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = LogStreamHeader::detect(header_line).unwrap();
let line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let type_re = Regex::new(&header.column_pattern(2, "Type")).unwrap();
assert_eq!(
type_re
.captures(line)
.unwrap()
.name("Type")
.unwrap()
.as_str(),
"Default"
);
let pid_re = Regex::new(&header.column_pattern(4, "PID")).unwrap();
assert_eq!(
pid_re.captures(line).unwrap().name("PID").unwrap().as_str(),
"606"
);
let message_re = Regex::new(&header.message_pattern("message")).unwrap();
assert_eq!(
message_re
.captures(line)
.unwrap()
.name("message")
.unwrap()
.as_str(),
"WindowServer: (HSTouchHIDService) something happened"
);
let timestamp_re = Regex::new(&header.column_pattern(0, "Timestamp")).unwrap();
assert_eq!(
timestamp_re
.captures(line)
.unwrap()
.name("Timestamp")
.unwrap()
.as_str(),
"2026-09-06 01:48:54.518117+0100"
);
}
#[test]
fn is_log_stream_line_rejects_non_columnar_lines_that_a_fixed_offset_column_regex_would_still_match()
{
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = LogStreamHeader::detect(header_line).unwrap();
let timestamp_re = Regex::new(&header.column_pattern(0, "Timestamp")).unwrap();
let continuation_line = "so_gencnt: 4042707 so_state: 0x0";
assert!(
timestamp_re.captures(continuation_line).is_some(),
"sanity: regex alone still matches garbage"
);
assert!(!is_log_stream_line(continuation_line));
assert!(!is_log_stream_line(header_line));
assert!(!is_log_stream_line("}"));
}
#[test]
fn a_wifi_stat_dump_line_is_still_recognized_as_log_stream_despite_embedded_key_value_pairs() {
let line = LOG_STREAM_FIXTURE
.lines()
.find(|line| line.contains("txRTSFrm="))
.expect("fixture must contain the WiFi stat dump line");
assert_eq!(detect_format(line), "log_stream");
assert!(!extract_pairs(line).is_empty());
}
const GO_LOG_FIXTURE: &str = include_str!("../fixtures/go_log_sample.txt");
const PY_LOG_FIXTURE: &str = include_str!("../fixtures/py_log_sample.txt");
#[test]
fn detects_go_log_shaped_lines() {
for line in GO_LOG_FIXTURE.lines() {
assert!(
is_go_log_line(line),
"expected go-log line to match: {line}"
);
assert_eq!(detect_format(line), "go-log");
}
}
#[test]
fn detects_py_log_shaped_lines() {
for line in PY_LOG_FIXTURE.lines() {
assert!(
is_py_log_line(line),
"expected py-log line to match: {line}"
);
assert_eq!(detect_format(line), "py-log");
}
}
#[test]
fn a_generic_json_object_is_not_detected_as_go_log() {
let line = r#"{"level":"error","code":500}"#;
assert!(!is_go_log_line(line));
assert_eq!(detect_format(line), "json");
}
#[test]
fn a_generic_logfmt_line_is_not_detected_as_py_log() {
let line = r#"ts=1788648105773 msg="entity_moved" x1=-4"#;
assert!(!is_py_log_line(line));
assert_eq!(detect_format(line), "logfmt");
}
#[test]
fn go_log_prefix_extracts_time_level_and_msg() {
let (rest, (time, level, msg)) =
go_log_prefix(r#"{"time":"2026-09-06T10:35:34.499921+01:00","level":"WARN","msg":"eventC","labelC":1}"#)
.unwrap();
assert_eq!(time, "2026-09-06T10:35:34.499921+01:00");
assert_eq!(level, "WARN");
assert_eq!(msg, "eventC");
assert_eq!(rest, r#","labelC":1}"#);
}
#[test]
fn py_log_prefix_extracts_date_time_level_and_logger() {
let (rest, (date, time, level, logger)) =
py_log_prefix("2026-09-06 10:35:55,217 INFO delete-py-log event=eventC labelC=1")
.unwrap();
assert_eq!(date, "2026-09-06");
assert_eq!(time, "10:35:55,217");
assert_eq!(level, "INFO");
assert_eq!(logger, "delete-py-log");
assert_eq!(rest, "event=eventC labelC=1");
}
}
+90
View File
@@ -0,0 +1,90 @@
//! The engine actor's inbound message/query types and its outbound response type.
use crate::api::dto::{
BucketValueDto, LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto,
PromqlSeriesDto, StatsDto, TimelineBucketDto, TimelineParams,
};
use crate::engine::{
ClearParamsResult, DeclareMetricRequest, DeclareParamRequest, IngestedLine, MetricSample,
};
use std::collections::BTreeMap;
use tokio::sync::oneshot;
#[allow(dead_code)]
pub enum EngineQuery {
Logs(LogsParams),
Timeline(TimelineParams),
Stats,
ParamDeclarations,
MetricDeclarations,
BucketValues {
metric_name: String,
labels_filter: Option<BTreeMap<String, String>>,
start_ms: Option<i64>,
end_ms: Option<i64>,
since_sequence: Option<u64>,
limit: Option<usize>,
},
Promql {
query: String,
start_ms: Option<i64>,
end_ms: Option<i64>,
},
}
#[allow(dead_code)]
pub enum EngineMessage {
Ingest(IngestedLine),
IngestMetricSample(MetricSample),
DeclareParam {
request: DeclareParamRequest,
respond_to: oneshot::Sender<Result<(), String>>,
},
RemoveParamDeclaration {
name: String,
respond_to: oneshot::Sender<bool>,
},
ClearParams {
respond_to: oneshot::Sender<ClearParamsResult>,
},
DeclareMetric {
request: DeclareMetricRequest,
respond_to: oneshot::Sender<Result<(), String>>,
},
UpdateMetric {
name: String,
request: DeclareMetricRequest,
respond_to: oneshot::Sender<Result<(), String>>,
},
RemoveMetricDeclaration {
name: String,
respond_to: oneshot::Sender<bool>,
},
SetMaxLogs(Option<usize>),
EnsureSortHelperIndex {
param_name: String,
respond_to: oneshot::Sender<()>,
},
Barrier(oneshot::Sender<()>),
}
#[allow(dead_code)]
pub enum EngineResponse {
Logs(Vec<LogEntryDto>),
Timeline(Vec<TimelineBucketDto>),
Stats(StatsDto),
ParamDeclarations(Vec<ParamDeclarationDto>),
MetricDeclarations(Vec<MetricDeclarationDto>),
BucketValues(Vec<BucketValueDto>),
Promql(Result<Vec<PromqlSeriesDto>, String>),
Error(String),
}
impl EngineQuery {
pub(crate) fn sort_by(&self) -> Option<&str> {
match self {
EngineQuery::Logs(params) => params.sort_by.as_deref(),
_ => None,
}
}
}
@@ -1,18 +1,15 @@
use crate::timestamp::Timestamp;
use crate::engine::timestamp::Timestamp;
use serde::Serialize;
use std::cmp::Ordering;
use std::fmt;
/// A simple, self-describing value used by metrics/labels without pulling in JSON.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub enum MetricPrimitive {
String(String),
Number(Number),
Timestamp(Timestamp),
/// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form.
Duration(i64),
/// A `Boolean`-kind param's value: whether `message_match_regex` matched the line. See `MetricPrimitiveKind::Boolean`.
Boolean(bool),
}
@@ -51,7 +48,6 @@ impl Ord for MetricPrimitive {
}
}
/// Textual form used to compare label filters (query-string values) against a formatted `MetricPrimitive` rather than parsing them into one.
impl fmt::Display for MetricPrimitive {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -65,7 +61,6 @@ impl fmt::Display for MetricPrimitive {
}
}
/// Numbers are kept as a tagged int/float pair so exact integers stay exact.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize)]
pub enum Number {
@@ -74,7 +69,6 @@ pub enum Number {
}
impl Number {
/// Widens to `f64` for cross-kind numeric comparisons; `pub(crate)` since the filter evaluator in `engine.rs` needs it.
pub(crate) fn as_f64(&self) -> f64 {
match self {
Number::Int(v) => *v as f64,
+67
View File
@@ -0,0 +1,67 @@
pub mod alerts;
pub(crate) mod actor;
pub mod data_value;
pub mod filter_datetime;
pub mod filter_parser;
pub mod index_key;
pub mod ingest;
pub mod logfmt;
pub mod message;
pub mod metric_primitive;
pub mod promql;
pub mod rate;
pub mod snapshot;
pub mod timestamp;
#[cfg(test)]
mod tests;
#[cfg(test)]
pub(crate) use actor::primary::test_handle;
pub use actor::primary::{EngineHandle, spawn};
pub use message::{EngineMessage, EngineQuery, EngineResponse};
pub(crate) use snapshot::{EngineSnapshot, eval_json_comparison};
use crate::engine::data_value::{MetricSemantics, ParamDeclaration};
use crate::engine::timestamp::Timestamp;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::sync::Arc;
pub struct IngestedLine {
pub sequence: u64,
pub timestamp: Timestamp,
pub text: Arc<str>,
}
pub struct MetricSample {
pub metric_name: String,
pub labels: BTreeMap<String, String>,
pub value: f64,
pub timestamp: Timestamp,
}
pub type DeclareParamRequest = ParamDeclaration;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ClearParamsResult {
pub removed: usize,
pub reextracted: usize,
pub logs_considered: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeclareMetricRequest {
pub name: String,
pub message_match_regex: String,
#[serde(default)]
pub label_params: Vec<String>,
#[serde(default)]
pub value_param: Option<String>,
#[serde(default)]
pub matched_labels: Option<BTreeMap<String, String>>,
#[serde(default)]
pub unmatched_labels: Option<BTreeMap<String, String>>,
#[serde(default)]
pub semantics: MetricSemantics,
}
+199 -84
View File
@@ -1,22 +1,14 @@
//! Hand-rolled parser for a small PromQL subset: metric name + label matchers, an optional `[5m]` range, and a top-level `sum()`/`avg()`/`count()`/`rate()`/`histogram()`/`gauge()` wrapper.
//! Only exact-match (`=`) label matchers are supported; `rate()` requires a duration range selector, while `histogram(metric[width])` reuses that `[...]` slot for a bare bucket width instead.
//! `sum()`/`avg()` may additionally wrap a `rate(...)` call directly (e.g. `sum(rate(requests[5m]))`), and either may be followed by a `by (label, ...)` clause to preserve one series per distinct value of the named labels instead of collapsing to a single series.
//! `count()` is a plain vector selector wrapper only (no `rate()` nesting) — it counts occurrences per group regardless of the metric's value, which is the deliberate opt-in for occurrence counting that `sum()`/`avg()` no longer fall back to for a `String`-kind metric (see `crate::engine::evaluate_promql`).
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AggOp {
Sum,
Avg,
/// Occurrence count per group, ignoring the metric's stored value entirely — the explicit,
/// opt-in-by-name alternative to summing a `String`-kind metric's coerced-to-1.0 values.
Count,
Rate,
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
Histogram,
/// The most-recent counter value per label combination.
Gauge,
Quantile,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -30,41 +22,48 @@ pub struct PromqlQuery {
pub agg: Option<AggOp>,
pub metric_name: String,
pub matchers: Vec<LabelMatcher>,
/// Duration range selector, e.g. `[5m]`; only meaningful for `rate()`.
pub range: Option<Duration>,
/// Bare-number range selector, e.g. `[10]`; only meaningful for `histogram()`, where it's the bucket width.
pub bucket_width: Option<f64>,
/// Set when `agg` is `Sum`/`Avg` and it wraps an inner `rate(metric[range])` rather than a bare vector
/// selector, e.g. `sum(rate(requests[5m]))`. `range` above carries the inner `rate()`'s duration in that case.
/// The `p` in `histogram_quantile(p, metric[range])`, e.g. `0.95` for p95. Only set when
/// `agg` is `Some(AggOp::Quantile)`.
pub quantile: Option<f64>,
pub inner_rate: bool,
/// Labels named in a trailing `by (...)` clause; empty means "collapse to a single series" (the historical
/// behavior of `sum()`/`avg()`). When non-empty, one series is kept per distinct combination of these labels.
pub by_labels: Vec<String>,
}
/// What a `[...]` range selector turned out to hold: a duration, or a bare width.
enum RangeSelector {
Duration(Duration),
BareWidth(f64),
}
/// Parses a PromQL query string, returning a human-readable error message on malformed input.
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
let mut parser = Parser::new(input);
let query = parser.parse_query()?;
parser.skip_whitespace();
if !parser.is_at_end() {
return Err(format!("unexpected trailing input: '{}'", parser.remaining()));
impl PromqlQuery {
pub fn parse(input: &str) -> Result<PromqlQuery, String> {
let mut parser = Parser::new(input);
let query = parser.parse_query()?;
parser.skip_whitespace();
if !parser.is_at_end() {
return Err(format!(
"unexpected trailing input: '{}'",
parser.remaining()
));
}
if query.agg == Some(AggOp::Rate) && query.range.is_none() {
return Err("rate() requires a range selector, e.g. rate(my_metric[5m])".to_string());
}
if query.agg == Some(AggOp::Histogram) && query.bucket_width.is_none() {
return Err(
"histogram() requires a bucket width, e.g. histogram(my_metric[10])".to_string(),
);
}
if query.agg == Some(AggOp::Quantile) && query.quantile.is_none() {
return Err(
"histogram_quantile() requires a quantile between 0 and 1 and a metric, e.g. \
histogram_quantile(0.95, latency_ms)"
.to_string(),
);
}
Ok(query)
}
if query.agg == Some(AggOp::Rate) && query.range.is_none() {
return Err("rate() requires a range selector, e.g. rate(my_metric[5m])".to_string());
}
if query.agg == Some(AggOp::Histogram) && query.bucket_width.is_none() {
return Err(
"histogram() requires a bucket width, e.g. histogram(my_metric[10])".to_string(),
);
}
Ok(query)
}
struct Parser<'a> {
@@ -128,7 +127,9 @@ impl<'a> Parser<'a> {
fn parse_quoted_string(&mut self) -> Result<String, String> {
self.expect_char('"')?;
let rest = self.remaining();
let end = rest.find('"').ok_or("unterminated string literal (missing closing '\"')")?;
let end = rest
.find('"')
.ok_or("unterminated string literal (missing closing '\"')")?;
let value = &rest[..end];
if value.contains('\\') {
return Err("escape sequences in label values are not supported".to_string());
@@ -177,7 +178,6 @@ impl<'a> Parser<'a> {
Ok(matchers)
}
/// Parses `[<digits><unit>]` (a duration, e.g. `[5m]`) or a bare `[<digits>]` (a bucket width).
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
self.skip_whitespace();
if self.peek() != Some('[') {
@@ -201,7 +201,9 @@ impl<'a> Parser<'a> {
return Ok(Some(RangeSelector::BareWidth(digits as f64)));
}
let unit = self.peek().ok_or("range selector is missing a duration unit or ']'")?;
let unit = self
.peek()
.ok_or("range selector is missing a duration unit or ']'")?;
let seconds_per_unit: i64 = match unit {
's' => 1,
'm' => 60,
@@ -234,12 +236,42 @@ impl<'a> Parser<'a> {
matchers,
range,
bucket_width,
quantile: None,
inner_rate: false,
by_labels: Vec::new(),
})
}
/// Parses a trailing `by (label, ...)` clause, if present; returns an empty vec otherwise.
/// Parses a decimal number (e.g. `0`, `1`, `0.95`) — used for `histogram_quantile(p, ...)`'s
/// `p` argument, which isn't an integer like the range-selector widths elsewhere in this grammar.
fn parse_float(&mut self) -> Result<f64, String> {
self.skip_whitespace();
let rest = self.remaining();
let mut end = 0;
let mut seen_digit = false;
let mut seen_dot = false;
for (idx, c) in rest.char_indices() {
if c.is_ascii_digit() {
seen_digit = true;
end = idx + c.len_utf8();
} else if c == '.' && !seen_dot {
seen_dot = true;
end = idx + c.len_utf8();
} else {
break;
}
}
if !seen_digit {
return Err("expected a number".to_string());
}
let text = &rest[..end];
let value: f64 = text
.parse()
.map_err(|_| format!("'{text}' is not a valid number"))?;
self.pos += end;
Ok(value)
}
fn parse_by_clause(&mut self) -> Result<Vec<String>, String> {
self.skip_whitespace();
let checkpoint = self.pos;
@@ -286,18 +318,33 @@ impl<'a> Parser<'a> {
"rate" => Some(AggOp::Rate),
"histogram" => Some(AggOp::Histogram),
"gauge" => Some(AggOp::Gauge),
"histogram_quantile" => Some(AggOp::Quantile),
_ => None,
};
self.skip_whitespace();
// `sum by (label) (metric)`: a `by (...)` clause may also precede the aggregation's
// parenthesized argument, not just follow it.
let prefix_by_labels = if agg.is_some() { self.parse_by_clause()? } else { Vec::new() };
let prefix_by_labels = if agg.is_some() {
self.parse_by_clause()?
} else {
Vec::new()
};
self.skip_whitespace();
if agg.is_some() && self.peek() == Some('(') {
self.expect_char('(')?;
self.skip_whitespace();
// `sum()`/`avg()` may wrap `rate(metric[range])` directly instead of a bare vector selector.
let mut inner = if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
let mut inner = if agg == Some(AggOp::Quantile) {
let quantile = self.parse_float()?;
if !(0.0..=1.0).contains(&quantile) {
return Err(format!(
"histogram_quantile() quantile must be between 0 and 1, got {quantile}"
));
}
self.skip_whitespace();
self.expect_char(',')?;
self.skip_whitespace();
let mut vector = self.parse_vector_selector()?;
vector.quantile = Some(quantile);
vector
} else if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
let inner_checkpoint = self.pos;
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
if took_inner_rate {
@@ -334,11 +381,13 @@ impl<'a> Parser<'a> {
.to_string(),
);
}
inner.by_labels =
if prefix_by_labels.is_empty() { trailing_by_labels } else { prefix_by_labels };
inner.by_labels = if prefix_by_labels.is_empty() {
trailing_by_labels
} else {
prefix_by_labels
};
Ok(inner)
} else {
// Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
self.pos = checkpoint;
self.parse_vector_selector()
}
@@ -351,7 +400,7 @@ mod tests {
#[test]
fn parses_bare_metric_name() {
let query = parse_query("requests").unwrap();
let query = PromqlQuery::parse("requests").unwrap();
assert_eq!(query.agg, None);
assert_eq!(query.metric_name, "requests");
assert!(query.matchers.is_empty());
@@ -360,17 +409,20 @@ mod tests {
#[test]
fn parses_metric_with_single_label_matcher() {
let query = parse_query(r#"requests{path="/a"}"#).unwrap();
let query = PromqlQuery::parse(r#"requests{path="/a"}"#).unwrap();
assert_eq!(query.metric_name, "requests");
assert_eq!(
query.matchers,
vec![LabelMatcher { name: "path".to_string(), value: "/a".to_string() }]
vec![LabelMatcher {
name: "path".to_string(),
value: "/a".to_string()
}]
);
}
#[test]
fn parses_metric_with_multiple_label_matchers() {
let query = parse_query(r#"requests{path="/a", method="GET"}"#).unwrap();
let query = PromqlQuery::parse(r#"requests{path="/a", method="GET"}"#).unwrap();
assert_eq!(query.matchers.len(), 2);
assert_eq!(query.matchers[0].name, "path");
assert_eq!(query.matchers[1].name, "method");
@@ -378,68 +430,67 @@ mod tests {
#[test]
fn parses_range_selector() {
let query = parse_query("requests[5m]").unwrap();
let query = PromqlQuery::parse("requests[5m]").unwrap();
assert_eq!(query.range, Some(Duration::from_secs(300)));
}
#[test]
fn parses_label_matchers_and_range_together() {
let query = parse_query(r#"requests{path="/a"}[1h]"#).unwrap();
let query = PromqlQuery::parse(r#"requests{path="/a"}[1h]"#).unwrap();
assert_eq!(query.matchers.len(), 1);
assert_eq!(query.range, Some(Duration::from_secs(3600)));
}
#[test]
fn parses_sum_aggregation() {
let query = parse_query(r#"sum(requests{path="/a"})"#).unwrap();
let query = PromqlQuery::parse(r#"sum(requests{path="/a"})"#).unwrap();
assert_eq!(query.agg, Some(AggOp::Sum));
assert_eq!(query.metric_name, "requests");
}
#[test]
fn parses_avg_aggregation() {
let query = parse_query("avg(requests)").unwrap();
let query = PromqlQuery::parse("avg(requests)").unwrap();
assert_eq!(query.agg, Some(AggOp::Avg));
}
#[test]
fn parses_rate_with_range() {
let query = parse_query("rate(requests[5m])").unwrap();
let query = PromqlQuery::parse("rate(requests[5m])").unwrap();
assert_eq!(query.agg, Some(AggOp::Rate));
assert_eq!(query.range, Some(Duration::from_secs(300)));
}
#[test]
fn rejects_rate_without_range() {
let err = parse_query("rate(requests)").unwrap_err();
let err = PromqlQuery::parse("rate(requests)").unwrap_err();
assert!(err.contains("range selector"));
}
#[test]
fn rejects_empty_input() {
assert!(parse_query("").is_err());
assert!(PromqlQuery::parse("").is_err());
}
#[test]
fn rejects_unclosed_brace() {
assert!(parse_query(r#"requests{path="/a""#).is_err());
assert!(PromqlQuery::parse(r#"requests{path="/a""#).is_err());
}
#[test]
fn rejects_unclosed_bracket() {
assert!(parse_query("requests[5m").is_err());
assert!(PromqlQuery::parse("requests[5m").is_err());
}
#[test]
fn rejects_unknown_aggregation() {
// `median` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`.
let err = parse_query("median(requests)").unwrap_err();
let err = PromqlQuery::parse("median(requests)").unwrap_err();
assert!(err.contains("unexpected trailing input"));
}
#[test]
fn parses_count_aggregation() {
let query = parse_query("count(requests)").unwrap();
let query = PromqlQuery::parse("count(requests)").unwrap();
assert_eq!(query.agg, Some(AggOp::Count));
assert_eq!(query.metric_name, "requests");
assert!(!query.inner_rate);
@@ -447,37 +498,37 @@ mod tests {
#[test]
fn parses_count_with_by_clause() {
let query = parse_query("count(requests) by (path)").unwrap();
let query = PromqlQuery::parse("count(requests) by (path)").unwrap();
assert_eq!(query.agg, Some(AggOp::Count));
assert_eq!(query.by_labels, vec!["path".to_string()]);
}
#[test]
fn rejects_regex_label_matcher() {
let err = parse_query(r#"requests{path=~"/a"}"#).unwrap_err();
let err = PromqlQuery::parse(r#"requests{path=~"/a"}"#).unwrap_err();
assert!(err.contains("regex label matchers"));
}
#[test]
fn rejects_missing_metric_name() {
assert!(parse_query(r#"{path="/a"}"#).is_err());
assert!(PromqlQuery::parse(r#"{path="/a"}"#).is_err());
}
#[test]
fn rejects_unknown_duration_unit() {
let err = parse_query("requests[5x]").unwrap_err();
let err = PromqlQuery::parse("requests[5x]").unwrap_err();
assert!(err.contains("duration unit"));
}
#[test]
fn rejects_trailing_garbage() {
let err = parse_query("requests extra").unwrap_err();
let err = PromqlQuery::parse("requests extra").unwrap_err();
assert!(err.contains("unexpected trailing input"));
}
#[test]
fn parses_histogram_with_bare_width() {
let query = parse_query("histogram(latency_ms[10])").unwrap();
let query = PromqlQuery::parse("histogram(latency_ms[10])").unwrap();
assert_eq!(query.agg, Some(AggOp::Histogram));
assert_eq!(query.metric_name, "latency_ms");
assert_eq!(query.bucket_width, Some(10.0));
@@ -486,27 +537,33 @@ mod tests {
#[test]
fn rejects_histogram_without_width() {
let err = parse_query("histogram(latency_ms)").unwrap_err();
let err = PromqlQuery::parse("histogram(latency_ms)").unwrap_err();
assert!(err.contains("bucket width"));
}
#[test]
fn parses_gauge_with_no_range_selector() {
let query = parse_query(r#"gauge(connections{host="a"})"#).unwrap();
let query = PromqlQuery::parse(r#"gauge(connections{host="a"})"#).unwrap();
assert_eq!(query.agg, Some(AggOp::Gauge));
assert_eq!(query.matchers, vec![LabelMatcher { name: "host".to_string(), value: "a".to_string() }]);
assert_eq!(
query.matchers,
vec![LabelMatcher {
name: "host".to_string(),
value: "a".to_string()
}]
);
}
#[test]
fn rate_range_selector_is_still_a_duration_not_a_bare_width() {
let query = parse_query("rate(requests[5m])").unwrap();
let query = PromqlQuery::parse("rate(requests[5m])").unwrap();
assert_eq!(query.range, Some(Duration::from_secs(300)));
assert_eq!(query.bucket_width, None);
}
#[test]
fn parses_sum_of_rate() {
let query = parse_query("sum(rate(requests[1m]))").unwrap();
let query = PromqlQuery::parse("sum(rate(requests[1m]))").unwrap();
assert_eq!(query.agg, Some(AggOp::Sum));
assert!(query.inner_rate);
assert_eq!(query.metric_name, "requests");
@@ -516,21 +573,27 @@ mod tests {
#[test]
fn parses_avg_of_rate_with_label_matchers() {
let query = parse_query(r#"avg(rate(requests{path="/a"}[5m]))"#).unwrap();
let query = PromqlQuery::parse(r#"avg(rate(requests{path="/a"}[5m]))"#).unwrap();
assert_eq!(query.agg, Some(AggOp::Avg));
assert!(query.inner_rate);
assert_eq!(query.matchers, vec![LabelMatcher { name: "path".to_string(), value: "/a".to_string() }]);
assert_eq!(
query.matchers,
vec![LabelMatcher {
name: "path".to_string(),
value: "/a".to_string()
}]
);
}
#[test]
fn rejects_sum_of_rate_without_range() {
let err = parse_query("sum(rate(requests))").unwrap_err();
let err = PromqlQuery::parse("sum(rate(requests))").unwrap_err();
assert!(err.contains("range selector"));
}
#[test]
fn parses_sum_with_by_clause() {
let query = parse_query("sum(requests) by (path)").unwrap();
let query = PromqlQuery::parse("sum(requests) by (path)").unwrap();
assert_eq!(query.agg, Some(AggOp::Sum));
assert!(!query.inner_rate);
assert_eq!(query.by_labels, vec!["path".to_string()]);
@@ -538,44 +601,96 @@ mod tests {
#[test]
fn parses_sum_of_rate_with_by_clause_multiple_labels() {
let query = parse_query("sum(rate(requests[1m])) by (path, method)").unwrap();
let query = PromqlQuery::parse("sum(rate(requests[1m])) by (path, method)").unwrap();
assert!(query.inner_rate);
assert_eq!(query.by_labels, vec!["path".to_string(), "method".to_string()]);
assert_eq!(
query.by_labels,
vec!["path".to_string(), "method".to_string()]
);
}
#[test]
fn sum_without_by_clause_has_empty_by_labels() {
let query = parse_query("sum(requests)").unwrap();
let query = PromqlQuery::parse("sum(requests)").unwrap();
assert!(query.by_labels.is_empty());
}
#[test]
fn rejects_empty_by_clause() {
let err = parse_query("sum(requests) by ()").unwrap_err();
let err = PromqlQuery::parse("sum(requests) by ()").unwrap_err();
assert!(err.contains("at least one label"));
}
#[test]
fn rejects_unterminated_by_clause() {
assert!(parse_query("sum(requests) by (path").is_err());
assert!(PromqlQuery::parse("sum(requests) by (path").is_err());
}
#[test]
fn parses_sum_with_prefix_by_clause() {
let query = parse_query("sum by (path) (requests)").unwrap();
let query = PromqlQuery::parse("sum by (path) (requests)").unwrap();
assert_eq!(query.agg, Some(AggOp::Sum));
assert_eq!(query.by_labels, vec!["path".to_string()]);
}
#[test]
fn parses_sum_with_prefix_by_clause_multiple_labels() {
let query = parse_query("sum by (path, method) (requests)").unwrap();
assert_eq!(query.by_labels, vec!["path".to_string(), "method".to_string()]);
let query = PromqlQuery::parse("sum by (path, method) (requests)").unwrap();
assert_eq!(
query.by_labels,
vec!["path".to_string(), "method".to_string()]
);
}
#[test]
fn rejects_by_clause_both_before_and_after_aggregation() {
let err = parse_query("sum by (path) (requests) by (method)").unwrap_err();
let err = PromqlQuery::parse("sum by (path) (requests) by (method)").unwrap_err();
assert!(err.contains("only appear once"));
}
#[test]
fn parses_histogram_quantile() {
let query = PromqlQuery::parse("histogram_quantile(0.95, latency_ms)").unwrap();
assert_eq!(query.agg, Some(AggOp::Quantile));
assert_eq!(query.metric_name, "latency_ms");
assert_eq!(query.quantile, Some(0.95));
assert_eq!(query.range, None);
}
#[test]
fn parses_histogram_quantile_with_range_and_matchers() {
let query =
PromqlQuery::parse(r#"histogram_quantile(0.5, latency_ms{path="/a"}[5m])"#).unwrap();
assert_eq!(query.quantile, Some(0.5));
assert_eq!(query.range, Some(Duration::from_secs(300)));
assert_eq!(
query.matchers,
vec![LabelMatcher {
name: "path".to_string(),
value: "/a".to_string()
}]
);
}
#[test]
fn parses_histogram_quantile_with_by_clause() {
let query = PromqlQuery::parse("histogram_quantile(0.99, latency_ms) by (path)").unwrap();
assert_eq!(query.by_labels, vec!["path".to_string()]);
}
#[test]
fn rejects_histogram_quantile_missing_comma() {
assert!(PromqlQuery::parse("histogram_quantile(0.95 latency_ms)").is_err());
}
#[test]
fn rejects_histogram_quantile_out_of_range() {
let err = PromqlQuery::parse("histogram_quantile(1.5, latency_ms)").unwrap_err();
assert!(err.contains("between 0 and 1"));
}
#[test]
fn rejects_histogram_quantile_without_number() {
assert!(PromqlQuery::parse("histogram_quantile(latency_ms)").is_err());
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::collections::VecDeque;
use crate::engine::timestamp::Timestamp;
fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize {
let now_ms = now.as_millis();
let window_start = now_ms - window_ms;
points
.iter()
.filter(|(ts, _)| {
let ms = ts.as_millis();
ms > window_start && ms <= now_ms
})
.count()
}
pub fn rate_at(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> f64 {
let window_seconds = window_ms as f64 / 1000.0;
count_in_window(points, now, window_ms) as f64 / window_seconds
}
pub fn windowed_rate_series(points: &[(Timestamp, f64)], window_ms: i64) -> Vec<(Timestamp, f64)> {
points
.iter()
.map(|(ts, _)| (*ts, rate_at(points, *ts, window_ms)))
.collect()
}
#[derive(Debug, Default, Clone)]
pub struct RateWindow {
timestamps: VecDeque<Timestamp>,
}
impl RateWindow {
pub fn backfill(
timestamps: impl IntoIterator<Item = Timestamp>,
now: Timestamp,
window_ms: i64,
) -> Self {
let mut window = Self::default();
for ts in timestamps {
window.push(ts, window_ms);
}
window.prune(now, window_ms);
window
}
pub fn push(&mut self, timestamp: Timestamp, window_ms: i64) {
self.timestamps.push_back(timestamp);
self.prune(timestamp, window_ms);
}
fn prune(&mut self, now: Timestamp, window_ms: i64) {
let window_start = now.as_millis() - window_ms;
while let Some(&front) = self.timestamps.front() {
if front.as_millis() <= window_start {
self.timestamps.pop_front();
} else {
break;
}
}
}
pub fn count(&self) -> usize {
self.timestamps.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_at_matches_hand_computed_window() {
let points = vec![
(Timestamp::from_millis(0), 1.0),
(Timestamp::from_millis(1000), 1.0),
(Timestamp::from_millis(2000), 1.0),
];
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 1000), 1.0);
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 2000), 1.0);
}
#[test]
fn windowed_rate_series_matches_rate_at_at_each_point() {
let points = vec![
(Timestamp::from_millis(0), 1.0),
(Timestamp::from_millis(500), 1.0),
(Timestamp::from_millis(1000), 1.0),
];
let series = windowed_rate_series(&points, 1000);
for (ts, rate) in &series {
assert_eq!(*rate, rate_at(&points, *ts, 1000));
}
}
#[test]
fn rate_window_incremental_count_matches_batch_count_in_window() {
let mut window = RateWindow::default();
let millis = [0, 200, 400, 1200];
for ms in millis {
window.push(Timestamp::from_millis(ms), 1000);
}
let points: Vec<(Timestamp, f64)> = millis
.iter()
.map(|ms| (Timestamp::from_millis(*ms), 1.0))
.collect();
assert_eq!(
window.count(),
count_in_window(&points, Timestamp::from_millis(1200), 1000)
);
}
#[test]
fn rate_window_backfill_seeds_from_history_within_the_window() {
let history = [0, 500, 2000, 2500].map(Timestamp::from_millis);
let window = RateWindow::backfill(history, Timestamp::from_millis(2500), 1000);
assert_eq!(window.count(), 2);
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Compiling and evaluating the search-bar filter syntax (`filter_parser`) against log lines
//! and buckets: regex matches, the `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` pseudo-metrics, per-param
//! metric-bucket comparisons, and ad-hoc JSON field comparisons.
use crate::engine::filter_datetime;
use crate::engine::filter_parser::{CompareOp, FilterValue};
use crate::engine::metric_primitive::MetricPrimitive;
use crate::engine::timestamp::Timestamp;
use regex::Regex;
use std::cmp::Ordering;
use std::collections::HashSet;
pub(super) enum CompiledFilter {
Regex(Regex),
Sequence {
op: CompareOp,
target: f64,
},
Timestamp {
op: CompareOp,
target_ms: i64,
},
Fmt {
op: CompareOp,
target: String,
},
MetricMatch(HashSet<u64>),
Json {
field: String,
op: CompareOp,
value: FilterValue,
},
And(Box<CompiledFilter>, Box<CompiledFilter>),
Or(Box<CompiledFilter>, Box<CompiledFilter>),
}
impl CompiledFilter {
pub(super) fn from_seq_comparison(op: CompareOp, value: &FilterValue) -> Result<Self, String> {
let target = match value {
FilterValue::Number(n) => *n,
FilterValue::Raw(raw) => raw.parse::<f64>().map_err(|_| {
format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}")
})?,
};
Ok(CompiledFilter::Sequence { op, target })
}
pub(super) fn from_ts_comparison(
op: CompareOp,
value: &FilterValue,
now: Timestamp,
) -> Result<Self, String> {
let target_ms = match value {
FilterValue::Number(n) => *n as i64,
FilterValue::Raw(raw) => filter_datetime::parse_datetime(raw, now)
.map_err(|err| err.to_string())?
.as_millis(),
};
Ok(CompiledFilter::Timestamp { op, target_ms })
}
pub(super) fn from_fmt_comparison(op: CompareOp, value: &FilterValue) -> Self {
let target = match value {
FilterValue::Number(n) => n.to_string(),
FilterValue::Raw(raw) => raw.clone(),
};
CompiledFilter::Fmt { op, target }
}
}
impl MetricPrimitive {
pub(super) fn compare(
&self,
op: CompareOp,
value: &FilterValue,
now: Timestamp,
) -> Result<bool, String> {
let ordering = match (self, value) {
(MetricPrimitive::Number(n), FilterValue::Number(v)) => n.as_f64().partial_cmp(v),
(MetricPrimitive::Number(n), FilterValue::Raw(raw)) => {
let v: f64 = raw
.parse()
.map_err(|_| format!("expected a numeric value, got {raw:?}"))?;
n.as_f64().partial_cmp(&v)
}
(MetricPrimitive::Timestamp(ts), FilterValue::Number(v)) => {
ts.as_millis().partial_cmp(&(*v as i64))
}
(MetricPrimitive::Timestamp(ts), FilterValue::Raw(raw)) => {
let target =
filter_datetime::parse_datetime(raw, now).map_err(|err| err.to_string())?;
ts.as_millis().partial_cmp(&target.as_millis())
}
(MetricPrimitive::String(s), FilterValue::Raw(raw)) => {
s.as_str().partial_cmp(raw.as_str())
}
(MetricPrimitive::String(s), FilterValue::Number(n)) => {
s.as_str().partial_cmp(n.to_string().as_str())
}
(MetricPrimitive::Duration(ms), FilterValue::Number(v)) => (*ms as f64).partial_cmp(v),
(MetricPrimitive::Duration(ms), FilterValue::Raw(raw)) => {
let v: f64 = raw
.parse()
.map_err(|_| format!("expected a numeric value, got {raw:?}"))?;
(*ms as f64).partial_cmp(&v)
}
(MetricPrimitive::Boolean(b), FilterValue::Raw(raw)) => {
let v: bool = raw
.parse()
.map_err(|_| format!("expected true or false, got {raw:?}"))?;
b.partial_cmp(&v)
}
(MetricPrimitive::Boolean(_), FilterValue::Number(_)) => None,
};
Ok(ordering.is_some_and(|ordering| op.matches(ordering)))
}
}
pub(crate) fn eval_json_comparison(
text: &str,
field: &str,
op: CompareOp,
value: &FilterValue,
) -> bool {
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(text) else {
return false;
};
let Some(field_value) = parsed.as_object().and_then(|obj| obj.get(field)) else {
return false;
};
compare_json_value(field_value, op, value)
}
fn filter_value_as_f64(value: &FilterValue) -> Option<f64> {
match value {
FilterValue::Number(v) => Some(*v),
FilterValue::Raw(raw) => raw.parse::<f64>().ok(),
}
}
fn compare_json_value(field_value: &serde_json::Value, op: CompareOp, value: &FilterValue) -> bool {
match field_value {
serde_json::Value::Number(n) => matches!(
(n.as_f64(), filter_value_as_f64(value)),
(Some(f), Some(v)) if f.partial_cmp(&v).is_some_and(|ordering| op.matches(ordering))
),
serde_json::Value::String(s) => {
let target = match value {
FilterValue::Raw(raw) => raw.clone(),
FilterValue::Number(n) => n.to_string(),
};
s.as_str()
.partial_cmp(target.as_str())
.is_some_and(|ordering| op.matches(ordering))
}
serde_json::Value::Bool(b) => {
let FilterValue::Raw(raw) = value else {
return false;
};
match raw.parse::<bool>() {
Ok(v) => match op {
CompareOp::Eq => *b == v,
CompareOp::Ne => *b != v,
_ => false,
},
Err(_) => false,
}
}
serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
false
}
}
}
impl CompareOp {
pub(crate) fn matches(&self, ordering: Ordering) -> bool {
match self {
CompareOp::Lt => ordering == Ordering::Less,
CompareOp::Gt => ordering == Ordering::Greater,
CompareOp::Le => ordering != Ordering::Greater,
CompareOp::Ge => ordering != Ordering::Less,
CompareOp::Eq => ordering == Ordering::Equal,
CompareOp::Ne => ordering != Ordering::Equal,
}
}
}
impl CompiledFilter {
pub(super) fn eval(&self, sequence: u64, timestamp: Timestamp, text: &str) -> bool {
match self {
CompiledFilter::Regex(re) => re.is_match(text),
CompiledFilter::Sequence { op, target } => {
op.matches((sequence as f64).total_cmp(target))
}
CompiledFilter::Timestamp { op, target_ms } => {
op.matches(timestamp.as_millis().cmp(target_ms))
}
CompiledFilter::Fmt { op, target } => {
op.matches(crate::engine::logfmt::detect_format(text).cmp(target.as_str()))
}
CompiledFilter::MetricMatch(sequences) => sequences.contains(&sequence),
CompiledFilter::Json { field, op, value } => {
eval_json_comparison(text, field, *op, value)
}
CompiledFilter::And(lhs, rhs) => {
lhs.eval(sequence, timestamp, text) && rhs.eval(sequence, timestamp, text)
}
CompiledFilter::Or(lhs, rhs) => {
lhs.eval(sequence, timestamp, text) || rhs.eval(sequence, timestamp, text)
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Metric declaration and application: validating/storing declarations, applying them to
//! ingested lines to populate metric buckets, and backfilling a newly (re)declared metric
//! against already-ingested logs.
use crate::engine::data_value::MetricPrimitiveKind;
use crate::engine::metric_primitive::{MetricPrimitive, Number};
use crate::engine::timestamp::Timestamp;
pub(super) fn parse_metric_primitive(
raw: &str,
kind: MetricPrimitiveKind,
) -> Option<MetricPrimitive> {
match kind {
MetricPrimitiveKind::String => Some(MetricPrimitive::String(raw.to_string())),
MetricPrimitiveKind::Number => raw
.parse::<i64>()
.map(Number::Int)
.or_else(|_| raw.parse::<f64>().map(Number::Float))
.ok()
.map(MetricPrimitive::Number),
MetricPrimitiveKind::Timestamp => {
crate::engine::filter_datetime::parse_datetime(raw, Timestamp::now())
.ok()
.map(MetricPrimitive::Timestamp)
}
MetricPrimitiveKind::Duration => raw.parse::<i64>().ok().map(MetricPrimitive::Duration),
MetricPrimitiveKind::Boolean => raw.parse::<bool>().ok().map(MetricPrimitive::Boolean),
}
}
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
//! Param declaration: validating and storing explicit declarations, and the auto-detection
//! logic that derives declarations from logfmt/json/log-stream-shaped lines as they're ingested.
#[cfg(test)]
use crate::engine::DeclareParamRequest;
use crate::engine::data_value::MetricPrimitiveKind;
#[cfg(test)]
use crate::engine::snapshot::EngineSnapshot;
use regex::Regex;
#[cfg(test)]
pub(crate) fn declare_param(
snapshot: &mut EngineSnapshot,
request: DeclareParamRequest,
) -> Result<(), String> {
snapshot.validate_param_declaration(&request)?;
snapshot.insert_param_declaration(request);
Ok(())
}
pub(crate) fn sanitize_group_name(key: &str) -> String {
let mut group: String = key
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
if group.chars().next().is_none_or(|c| c.is_ascii_digit()) {
group.insert(0, '_');
}
group
}
pub(crate) fn infer_kind(sample: &str) -> MetricPrimitiveKind {
if sample.parse::<i64>().is_ok() || sample.parse::<f64>().is_ok() {
MetricPrimitiveKind::Number
} else {
MetricPrimitiveKind::String
}
}
pub(crate) fn build_auto_param_pattern(key: &str, group: &str, quoted: bool) -> String {
let escaped_key = regex::escape(key);
if quoted {
format!(r#"{escaped_key}="(?P<{group}>[^"]*)""#)
} else {
format!(r"{escaped_key}=(?P<{group}>\S+)")
}
}
pub(crate) struct AutoParamCandidate {
pub(crate) name: String,
pub(crate) value: String,
pub(crate) message_match_regex: String,
pub(crate) capture_group: String,
pub(crate) json_field_path: Option<String>,
}
pub(crate) fn log_stream_header_transition(
current: &Option<crate::engine::logfmt::LogStreamHeader>,
text: &str,
) -> Option<crate::engine::logfmt::LogStreamHeader> {
crate::engine::logfmt::LogStreamHeader::detect(text).or_else(|| current.clone())
}
#[cfg(test)]
pub(crate) fn auto_declare_log_stream_params(
snapshot: &mut EngineSnapshot,
header: &mut Option<crate::engine::logfmt::LogStreamHeader>,
text: &str,
) {
let is_new_header = crate::engine::logfmt::LogStreamHeader::detect(text).is_some();
*header = log_stream_header_transition(header, text);
if is_new_header {
return;
}
if let Some(header) = header.clone() {
snapshot.derive_log_stream_params_from_row(&header, text);
}
}
pub(super) struct ResolvedParam {
pub(super) regex: Regex,
pub(super) capture_group: String,
pub(super) json_field_path: Option<String>,
pub(super) kind: MetricPrimitiveKind,
pub(super) elapsed_since_last: bool,
pub(super) log_stream_gated: bool,
}
pub(crate) fn extract_param_raw(
text: &str,
regex: &Regex,
capture_group: &str,
json_field_path: Option<&str>,
log_stream_gated: bool,
) -> Option<String> {
if log_stream_gated && !crate::engine::logfmt::is_log_stream_line(text) {
return None;
}
match json_field_path.filter(|path| !path.is_empty()) {
Some(field_path) => crate::engine::data_value::extract_json_field(text, field_path),
None => regex
.captures(text)?
.name(capture_group)
.map(|m| m.as_str().to_string()),
}
}
+477
View File
@@ -0,0 +1,477 @@
//! Answering `EngineQuery`s: log/timeline listing (including the sort-helper-index path),
//! stats, declaration listing, bucket-value listing, and PromQL evaluation.
use crate::api::dto::{
BucketValueDto, LogEntryDto, MetricDeclarationDto, ParamDeclarationDto, StatsDto,
TimelineBucketDto,
};
use crate::consts::{DEFAULT_TIMELINE_BUCKET_MS, MERGE_WINDOW_MS};
use crate::engine::data_value::DataValue;
use crate::engine::index_key::IndexKey;
use crate::engine::message::{EngineQuery, EngineResponse};
use crate::engine::metric_primitive::{MetricPrimitive, Number};
use crate::engine::promql::{LabelMatcher, PromqlQuery};
use crate::engine::rate;
use crate::engine::snapshot::EngineSnapshot;
use crate::engine::snapshot::filter::CompiledFilter;
#[cfg(test)]
use crate::engine::snapshot::sort::SortHelperIndexEntry;
use crate::engine::snapshot::sort::{
CompiledParamDeclaration, SortHelperAccess, SortHelperKey, log_entry_params,
};
use crate::engine::timestamp::Timestamp;
use std::collections::BTreeMap;
#[cfg(test)]
use std::collections::HashMap;
#[cfg(test)]
pub(crate) fn handle_query(
snapshot: &EngineSnapshot,
query: EngineQuery,
sort_helper_indexes: &mut HashMap<String, SortHelperIndexEntry>,
) -> EngineResponse {
handle_query_inner(
snapshot,
query,
SortHelperAccess::Mutable(sort_helper_indexes),
)
}
/// Compiles `filter` against `snapshot`, or returns `Ok(None)` when there's no filter string
/// to compile. Shared by the `Logs` and `Timeline` query arms, which both bail out early with
/// `EngineResponse::Error` on a compile failure.
fn compile_optional_filter(
snapshot: &EngineSnapshot,
filter: Option<&str>,
now: Timestamp,
) -> Result<Option<CompiledFilter>, String> {
filter
.map(|filter| snapshot.compile_filter_query(filter, now))
.transpose()
}
/// Walks the sort-helper index for `sort_by` — rather than `snapshot.logs_range()`, which is
/// ordered by sequence, not by the param's value — collecting up to `limit` matching entries
/// in sort-by-param order.
///
/// Not range-bounded by since_sequence/start_ms the way the unsorted path is: `SortHelperKey::
/// WithParam` sorts by the param's value first (that's the point — it's what gives sort-by-param
/// order), so sequence/time bounds can't narrow a BTreeMap range seek here.
#[allow(clippy::too_many_arguments)]
fn logs_sorted_by_param(
snapshot: &EngineSnapshot,
sort_by: &str,
sort_helper_access: SortHelperAccess<'_>,
compiled_filter: Option<&CompiledFilter>,
compiled_params: &[CompiledParamDeclaration],
since_sequence: Option<u64>,
start_ms: Option<i64>,
end_ms: Option<i64>,
sort_missing_fallback: bool,
limit: usize,
) -> Vec<LogEntryDto> {
let index = &snapshot.index;
static EMPTY_HELPER: std::sync::LazyLock<BTreeMap<SortHelperKey, Timestamp>> =
std::sync::LazyLock::new(BTreeMap::new);
let helper_index = sort_helper_access
.resolve(index, sort_by)
.unwrap_or(&EMPTY_HELPER);
let mut entries = Vec::new();
for (helper_key, timestamp) in helper_index.iter() {
let sequence = match helper_key {
SortHelperKey::WithParam(_, sequence) => *sequence,
SortHelperKey::WithoutParam(sequence) => {
if !sort_missing_fallback {
continue;
}
*sequence
}
};
if !(since_sequence.is_none_or(|since| sequence > since)
&& start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& end_ms.is_none_or(|end| timestamp.as_millis() <= end))
{
continue;
}
let Some(DataValue::LogLiteral { text }) = index.get(&IndexKey::IngestedLog {
sequence,
timestamp: *timestamp,
}) else {
continue;
};
if !compiled_filter.is_none_or(|filter| filter.eval(sequence, *timestamp, text)) {
continue;
}
entries.push(LogEntryDto {
sequence,
timestamp_ms: timestamp.as_millis(),
text: text.to_string(),
params: log_entry_params(text, compiled_params),
});
if entries.len() >= limit {
break;
}
}
entries
}
pub(super) fn handle_query_inner(
snapshot: &EngineSnapshot,
query: EngineQuery,
sort_helper_access: SortHelperAccess<'_>,
) -> EngineResponse {
let approx_bytes_total = snapshot.approx_bytes_total;
let ingest_lag_high_watermark_ms = snapshot.ingest_lag_high_watermark_ms;
match query {
EngineQuery::Logs(params) => {
let compiled_filter =
match compile_optional_filter(snapshot, params.filter.as_deref(), Timestamp::now())
{
Ok(compiled) => compiled,
Err(err) => return EngineResponse::Error(err),
};
if let Some(sort_by) = params.sort_by.as_deref()
&& !snapshot
.param_declarations_range()
.any(|(name, ..)| name == sort_by)
{
return EngineResponse::Error(format!("unknown param '{sort_by}' for sort_by"));
}
let limit = params.limit.unwrap_or(500).min(10_000);
let compiled_params = snapshot.compiled_param_declarations();
let since_bound = match params.since_sequence {
Some(since) => std::ops::Bound::Excluded(since),
None => std::ops::Bound::Unbounded,
};
let filtered = snapshot
.logs_in_sequence_range((since_bound, std::ops::Bound::Unbounded))
.filter(|(_, timestamp, _)| {
params
.start_ms
.is_none_or(|start| timestamp.as_millis() >= start)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
})
.filter(|(sequence, timestamp, text)| {
compiled_filter
.as_ref()
.is_none_or(|filter| filter.eval(*sequence, *timestamp, text))
});
let entries: Vec<LogEntryDto> = match params.sort_by.as_deref() {
None => filtered
.take(limit)
.map(|(sequence, timestamp, text)| LogEntryDto {
sequence,
timestamp_ms: timestamp.as_millis(),
text: text.to_string(),
params: log_entry_params(text, &compiled_params),
})
.collect(),
Some(sort_by) => logs_sorted_by_param(
snapshot,
sort_by,
sort_helper_access,
compiled_filter.as_ref(),
&compiled_params,
params.since_sequence,
params.start_ms,
params.end_ms,
params.sort_missing_fallback.unwrap_or(true),
limit,
),
};
let entries: Vec<LogEntryDto> = entries.into_iter().take(limit).collect();
EngineResponse::Logs(entries)
}
EngineQuery::Timeline(params) => {
let compiled_filter =
match compile_optional_filter(snapshot, params.filter.as_deref(), Timestamp::now())
{
Ok(compiled) => compiled,
Err(err) => return EngineResponse::Error(err),
};
let bucket_ms = params
.bucket_ms
.unwrap_or(DEFAULT_TIMELINE_BUCKET_MS)
.max(1);
let mut buckets: BTreeMap<i64, u64> = BTreeMap::new();
for (sequence, timestamp, text) in snapshot.logs_range().filter(|(_, timestamp, _)| {
params
.start_ms
.is_none_or(|start| timestamp.as_millis() >= start)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
}) {
if compiled_filter
.as_ref()
.is_some_and(|filter| !filter.eval(sequence, timestamp, text))
{
continue;
}
let bucket_start = (timestamp.as_millis() / bucket_ms) * bucket_ms;
*buckets.entry(bucket_start).or_insert(0) += 1;
}
let out = buckets
.into_iter()
.map(|(start_ms, count)| TimelineBucketDto { start_ms, count })
.collect();
EngineResponse::Timeline(out)
}
EngineQuery::Stats => {
let mut log_count = 0u64;
let mut first_sequence = None;
let mut last_sequence = None;
let mut first_timestamp_ms = None;
let mut last_timestamp_ms = None;
for (sequence, timestamp, _) in snapshot.logs_range() {
log_count += 1;
first_sequence.get_or_insert(sequence);
last_sequence = Some(sequence);
first_timestamp_ms.get_or_insert(timestamp.as_millis());
last_timestamp_ms = Some(timestamp.as_millis());
}
EngineResponse::Stats(StatsDto {
log_count,
first_sequence,
last_sequence,
first_timestamp_ms,
last_timestamp_ms,
approx_bytes_total,
ingest_lag_high_watermark_ms,
})
}
EngineQuery::ParamDeclarations => {
let declarations = snapshot
.param_declarations_range()
.map(
|(
name,
message_match_regex,
capture_group,
json_field_path,
kind,
elapsed_since_last,
auto_derived,
_,
)| {
ParamDeclarationDto {
name: name.to_string(),
message_match_regex: message_match_regex.to_string(),
capture_group: capture_group.to_string(),
json_field_path: json_field_path.map(|path| path.to_string()),
kind,
elapsed_since_last,
auto_derived,
reserved: false,
}
},
)
.collect();
EngineResponse::ParamDeclarations(declarations)
}
EngineQuery::MetricDeclarations => {
let declarations = snapshot
.metric_declarations_range()
.map(
|(
name,
message_match_regex,
label_params,
value_param,
matched_labels,
unmatched_labels,
semantics,
)| {
MetricDeclarationDto {
name: name.to_string(),
message_match_regex: message_match_regex.to_string(),
label_params: label_params.to_vec(),
value_param: value_param.map(|s| s.to_string()),
matched_labels: matched_labels.cloned(),
unmatched_labels: unmatched_labels.cloned(),
semantics,
}
},
)
.collect();
EngineResponse::MetricDeclarations(declarations)
}
EngineQuery::BucketValues {
metric_name,
labels_filter,
start_ms,
end_ms,
since_sequence,
limit,
} => {
let limit = limit.unwrap_or(500).min(10_000);
let values: Vec<BucketValueDto> = snapshot
.bucket_values(&metric_name, None)
.filter(|(labels, _)| {
labels_filter.as_ref().is_none_or(|filter| {
filter.iter().all(|(name, expected)| {
labels
.get(name)
.is_some_and(|value| &value.to_string() == expected)
})
})
})
.filter(|(_, (sequence, timestamp, _))| {
since_sequence.is_none_or(|since| *sequence > since)
&& start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& end_ms.is_none_or(|end| timestamp.as_millis() <= end)
})
.take(limit)
.map(|(labels, (sequence, timestamp, value))| BucketValueDto {
labels: labels.clone(),
sequence: *sequence,
timestamp_ms: timestamp.as_millis(),
value: value.clone(),
})
.collect();
EngineResponse::BucketValues(values)
}
EngineQuery::Promql {
query,
start_ms,
end_ms,
} => {
let response = PromqlQuery::parse(&query)
.and_then(|parsed| snapshot.evaluate_promql(&parsed, start_ms, end_ms));
EngineResponse::Promql(response)
}
}
}
pub(crate) fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
match value {
MetricPrimitive::Number(n) => match n {
Number::Int(v) => *v as f64,
Number::Float(v) => *v,
},
MetricPrimitive::Timestamp(ts) => ts.as_millis() as f64,
MetricPrimitive::Duration(ms) => *ms as f64,
MetricPrimitive::String(_) => 1.0,
MetricPrimitive::Boolean(b) => {
if *b {
1.0
} else {
0.0
}
}
}
}
pub(crate) fn matches_label_matchers(
labels: &BTreeMap<String, MetricPrimitive>,
matchers: &[LabelMatcher],
) -> bool {
matchers.iter().all(|matcher| {
labels
.get(&matcher.name)
.is_some_and(|value| value.to_string() == matcher.value)
})
}
pub(crate) fn project_labels(
labels: &BTreeMap<String, String>,
by_labels: &[String],
) -> BTreeMap<String, String> {
by_labels
.iter()
.filter_map(|name| labels.get(name).map(|value| (name.clone(), value.clone())))
.collect()
}
pub(crate) fn windowed_rate_series(
points: &[(i64, f64)],
range_ms: i64,
start_ms: Option<i64>,
end_ms: Option<i64>,
) -> Vec<(i64, f64)> {
let ts_points: Vec<(Timestamp, f64)> = points
.iter()
.map(|(ms, value)| (Timestamp::from_millis(*ms), *value))
.collect();
let mut series: Vec<(i64, f64)> = rate::windowed_rate_series(&ts_points, range_ms)
.into_iter()
.map(|(ts, value)| (ts.as_millis(), value))
.collect();
if let Some(end) = end_ms
&& series.last().is_none_or(|(ts, _)| *ts < end)
{
let value = rate::rate_at(&ts_points, Timestamp::from_millis(end), range_ms);
series.push((end, value));
}
if let Some(start) = start_ms
&& series.first().is_none_or(|(ts, _)| *ts > start)
{
let value = rate::rate_at(&ts_points, Timestamp::from_millis(start), range_ms);
series.insert(0, (start, value));
}
series
}
pub(crate) fn merge_series_windowed(
sub_series: &[Vec<(i64, f64)>],
is_avg: bool,
) -> Vec<(i64, f64)> {
if sub_series.len() <= 1 {
return sub_series.first().cloned().unwrap_or_default();
}
let mut grouped: BTreeMap<i64, Vec<f64>> = BTreeMap::new();
for points in sub_series {
for (timestamp_ms, value) in points {
let window_start = timestamp_ms.div_euclid(MERGE_WINDOW_MS) * MERGE_WINDOW_MS;
grouped.entry(window_start).or_default().push(*value);
}
}
grouped
.into_iter()
.map(|(timestamp_ms, values)| {
let sum: f64 = values.iter().sum();
let value = if is_avg {
sum / values.len() as f64
} else {
sum
};
(timestamp_ms, value)
})
.collect()
}
pub(crate) fn merge_series_locf(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64, f64)> {
let mut all_timestamps: std::collections::BTreeSet<i64> = std::collections::BTreeSet::new();
for points in sub_series {
all_timestamps.extend(points.iter().map(|(ts, _)| *ts));
}
let mut cursors = vec![0usize; sub_series.len()];
let mut last_values = vec![0.0f64; sub_series.len()];
all_timestamps
.into_iter()
.map(|ts| {
for (i, points) in sub_series.iter().enumerate() {
while cursors[i] < points.len() && points[cursors[i]].0 <= ts {
last_values[i] = points[cursors[i]].1;
cursors[i] += 1;
}
}
let sum: f64 = last_values.iter().sum();
let value = if is_avg {
sum / last_values.len() as f64
} else {
sum
};
(ts, value)
})
.collect()
}
pub(crate) fn ottyl_reserved_numeric_value_fn(metric_name: &str) -> Option<fn(u64, i64) -> f64> {
match metric_name {
"ottyl_seq" => Some(|sequence, _timestamp_ms| sequence as f64),
"ottyl_ts" => Some(|_sequence, timestamp_ms| timestamp_ms as f64),
_ => None,
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Sort-helper indexes: a per-param `sort_by` lets the logs view return results ordered by a
//! declared param's value instead of ingest order. Building that ordering from scratch is
//! O(n log n) over every log, so it's memoized in `EngineSnapshot::sort_helper_indexes` and
//! kept incrementally up to date on ingest, rather than rebuilt per query.
use crate::engine::data_value::DataValue;
use crate::engine::data_value::MetricPrimitiveKind;
use crate::engine::index_key::IndexKey;
use crate::engine::metric_primitive::MetricPrimitive;
#[cfg(test)]
use crate::engine::snapshot::EngineSnapshot;
use crate::engine::snapshot::metrics::parse_metric_primitive;
use crate::engine::timestamp::Timestamp;
use regex::Regex;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum SortHelperKey {
WithParam(MetricPrimitive, u64),
WithoutParam(u64),
}
#[derive(Debug, Clone)]
pub(crate) struct SortHelperIndexEntry {
pub(crate) index: BTreeMap<SortHelperKey, Timestamp>,
pub(crate) last_accessed: std::time::Instant,
}
pub(super) type CompiledParamDeclaration = (
String,
Regex,
String,
Option<String>,
MetricPrimitiveKind,
bool,
);
pub(crate) fn derive_single_param(
text: &str,
declarations: &[CompiledParamDeclaration],
param_name: &str,
) -> Option<MetricPrimitive> {
let (_, regex, capture_group, json_field_path, kind, log_stream_gated) =
declarations.iter().find(|(name, ..)| name == param_name)?;
if *log_stream_gated && !crate::engine::logfmt::is_log_stream_line(text) {
return None;
}
if *kind == MetricPrimitiveKind::Boolean {
return Some(MetricPrimitive::Boolean(regex.is_match(text)));
}
let captures = regex.captures(text)?;
let raw: String = match json_field_path.as_deref().filter(|path| !path.is_empty()) {
Some(field_path) => crate::engine::data_value::extract_json_field(text, field_path)?,
None => captures.name(capture_group)?.as_str().to_string(),
};
parse_metric_primitive(&raw, *kind)
}
pub(crate) fn derive_log_params(
text: &str,
declarations: &[CompiledParamDeclaration],
) -> BTreeMap<String, MetricPrimitive> {
let mut params = BTreeMap::new();
for (name, regex, capture_group, json_field_path, kind, log_stream_gated) in declarations {
if *log_stream_gated && !crate::engine::logfmt::is_log_stream_line(text) {
continue;
}
if *kind == MetricPrimitiveKind::Boolean {
params.insert(name.clone(), MetricPrimitive::Boolean(regex.is_match(text)));
continue;
}
let Some(captures) = regex.captures(text) else {
continue;
};
let raw: String = match json_field_path.as_deref().filter(|path| !path.is_empty()) {
Some(field_path) => {
match crate::engine::data_value::extract_json_field(text, field_path) {
Some(value) => value,
None => continue,
}
}
None => match captures.name(capture_group) {
Some(m) => m.as_str().to_string(),
None => continue,
},
};
let Some(value) = parse_metric_primitive(&raw, *kind) else {
continue;
};
params.insert(name.clone(), value);
}
params
}
pub(super) fn log_entry_params(
text: &str,
declarations: &[CompiledParamDeclaration],
) -> BTreeMap<String, MetricPrimitive> {
let mut params = derive_log_params(text, declarations);
params.insert(
"ottyl_fmt".to_string(),
MetricPrimitive::String(crate::engine::logfmt::detect_format(text).to_string()),
);
params
}
pub(super) enum SortHelperAccess<'a> {
#[cfg(test)]
Mutable(&'a mut HashMap<String, SortHelperIndexEntry>),
Immutable(&'a HashMap<String, SortHelperIndexEntry>),
}
impl<'a> SortHelperAccess<'a> {
#[cfg_attr(not(test), allow(unused_variables))]
pub(super) fn resolve(
self,
index: &BTreeMap<IndexKey, DataValue>,
sort_by: &str,
) -> Option<&'a BTreeMap<SortHelperKey, Timestamp>> {
match self {
#[cfg(test)]
SortHelperAccess::Mutable(map) => {
if !map.contains_key(sort_by) {
let built =
EngineSnapshot::from_index(index.clone()).build_sort_helper_index(sort_by);
map.insert(
sort_by.to_string(),
SortHelperIndexEntry {
index: built,
last_accessed: std::time::Instant::now(),
},
);
}
let entry = map
.get_mut(sort_by)
.expect("just inserted above if missing");
entry.last_accessed = std::time::Instant::now();
map.get(sort_by).map(|entry| &entry.index)
}
SortHelperAccess::Immutable(map) => map.get(sort_by).map(|entry| &entry.index),
}
}
}
+3274
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,11 +1,15 @@
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
/// Wall-clock instant stored as nanoseconds since the Unix epoch (not `std::time::Instant`, which has no epoch and can't be serialized).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Timestamp(i64);
impl Timestamp {
/// The smallest representable timestamp — used as a synthetic sub-key when seeking
/// `IndexKey::IngestedLog { sequence, timestamp }` by `sequence` alone, since it sorts
/// below any real timestamp recorded for that sequence.
pub const MIN: Timestamp = Timestamp(i64::MIN);
pub fn now() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
-49
View File
@@ -1,49 +0,0 @@
// Grammar for the FilterBar query language.
//
// Supports:
// - regex("...") raw-text regex search over log lines
// - metric_name[param] <op> value metric comparisons
// - json[field] <op> value JSON field comparisons (line parsed as JSON)
// - ottyl_seq <op> value reserved metric name (no [param])
// - ottyl_ts <op> value reserved metric name (no [param])
// - ottyl_fmt <op> value reserved metric name (no [param])
// - `and` / `or` boolean combinators, with `and` binding tighter than `or`
// - parenthesized sub-expressions
WHITESPACE = _{ " " | "\t" | "\n" | "\r" }
file = { SOI ~ or_expr ~ EOI }
or_expr = { and_expr ~ (or_kw ~ and_expr)* }
and_expr = { term ~ (and_kw ~ term)* }
term = _{ regex_expr | comparison | "(" ~ or_expr ~ ")" }
// These must be atomic (`@`): a non-atomic rule would let pest's implicit
// whitespace insertion slip in between the keyword and its own boundary
// check below, which breaks the lookahead (it would see the first
// non-whitespace character *after* skipping the separating space, instead
// of the character immediately following the keyword).
and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") }
or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") }
regex_expr = { "regex" ~ "(" ~ string_literal ~ ")" }
string_literal = ${ "\"" ~ inner_str ~ "\"" }
inner_str = @{ (escaped_char | !("\"" | "\\") ~ ANY)* }
escaped_char = @{ "\\" ~ ANY }
comparison = _{ json_comparison | metric_comparison }
// `json[field] <op> value` must be tried before `metric_comparison` since "json" is
// otherwise a syntactically valid (if never declared) metric name.
json_comparison = { "json" ~ "[" ~ param ~ "]" ~ op ~ value }
metric_comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value }
metric = @{ ident }
param = @{ ident }
ident = @{ (ASCII_ALPHANUMERIC | "_")+ }
op = { "<=" | ">=" | "==" | "!=" | "<" | ">" }
value = @{ (!(WHITESPACE | ")") ~ ANY)+ }
+12
View File
@@ -0,0 +1,12 @@
pub trait LogLiteralHelper {
fn approx_log_bytes(&self) -> u64;
}
impl<T> LogLiteralHelper for T
where
T: AsRef<str>,
{
fn approx_log_bytes(&self) -> u64 {
self.as_ref().len() as u64
}
}
-39
View File
@@ -1,39 +0,0 @@
use crate::metric_primitive::MetricPrimitive;
use crate::timestamp::Timestamp;
use serde::Serialize;
use std::collections::BTreeMap;
/// Key type for the engine's primary index. Variant order matters: derived `Ord` compares the discriminant first, carving the map into contiguous ranges in declaration order (logs, params, metric declarations, buckets).
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum IndexKey {
/// Marker for the start of the whole range. Never inserted, only navigated to.
Start,
IngestedLog {
sequence: u64,
timestamp: Timestamp,
},
/// Marker between the log range and the param range.
LogParamSplit,
ParamDeclaration {
name: String,
},
/// Marker between the param range and the metric declaration range.
ParamMetricSplit,
MetricDeclaration {
name: String,
},
/// Marker between metric declarations and their buckets.
MetricBucketSplit,
Bucket {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
/// Internal bookkeeping tracking the last event timestamp per metric+label, so a `Duration` param's first occurrence (which pushes no `Bucket`) still lets the next occurrence compute elapsed time.
LastMetricEvent {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
/// Marker for the end of the whole range. Nothing sorts after this.
End,
}
-369
View File
@@ -1,369 +0,0 @@
//! Fast, tolerant `key=value`/`key="value"` tokenizer (logfmt-ish), used by
//! `crate::engine::auto_declare_params` to discover param names as lines are ingested. Scans
//! anywhere in the line rather than requiring the whole line to be logfmt, so it works on lines
//! with an arbitrary non-logfmt prefix/suffix (e.g. `ts=... msg="..." x1=-4 y1=34`).
use nom::branch::alt;
use nom::bytes::complete::{escaped, tag, take_until, take_while1};
use nom::character::complete::{char, none_of, one_of};
use nom::error::{Error, ErrorKind};
use nom::sequence::delimited;
use nom::IResult;
use regex::Regex;
use std::sync::LazyLock;
fn key(input: &str) -> IResult<&str, &str> {
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')(input)
}
fn quoted_value(input: &str) -> IResult<&str, &str> {
delimited(
char('"'),
alt((escaped(none_of("\\\""), '\\', one_of("\"\\nt")), nom::combinator::success(""))),
char('"'),
)(input)
}
fn bareword_value(input: &str) -> IResult<&str, &str> {
take_while1(|c: char| !c.is_whitespace())(input)
}
/// A bareword value starting with a bare `=` means the raw text actually had `key==...`/`key===...` —
/// never a real logfmt pair. This is exactly what `pair` would otherwise produce when it lands
/// mid-way through a base64/hash blob whose padding (`=`/`==`) happens to sit right after a run of
/// alnum chars that `key()` greedily consumed — e.g. the `huig_dump.txt` fixture's
/// `<mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>,`: `key()` eats `rKTEtG7JmnThhoKPbZiVrA`, the first `=` is
/// consumed as the pair's `=`, leaving a bareword value of `='>,` (ticket 050). Rejected here rather
/// than post-filtered by a caller so `extract_pairs` never reports the false positive to anyone.
fn looks_like_base64_padding_artifact(value: &str) -> bool {
value.starts_with('=')
}
/// One `key=value` pair found on a line: the key, the raw captured value (unescaped as-is, quotes
/// stripped but any inner `\"`/`\\` left literal since callers only need it for numeric-kind
/// inference), and whether the value was quoted.
fn pair(input: &str) -> IResult<&str, (&str, &str, bool)> {
let (input, k) = key(input)?;
let (input, _) = char('=')(input)?;
if let Ok((rest, v)) = quoted_value(input) {
Ok((rest, (k, v, true)))
} else {
let (rest, v) = bareword_value(input)?;
if looks_like_base64_padding_artifact(v) {
return Err(nom::Err::Error(Error::new(input, ErrorKind::Verify)));
}
Ok((rest, (k, v, false)))
}
}
/// Finds every `key=value`/`key="value"` pair anywhere in `line`. A position that doesn't start a
/// pair is skipped one char at a time, so this never fails outright — a line with no pairs at all
/// (plain prose, a JSON blob) just returns an empty vec.
pub fn extract_pairs(line: &str) -> Vec<(&str, &str, bool)> {
let mut results = Vec::new();
let mut rest = line;
while !rest.is_empty() {
match pair(rest) {
Ok((remaining, found)) => {
results.push(found);
rest = remaining;
}
Err(_) => {
let mut chars = rest.chars();
chars.next();
rest = chars.as_str();
}
}
}
results
}
/// Fixed-column shape of macOS `log stream` output (ticket 050/054): a line starts with
/// `Timestamp Thread Type Activity PID TTL` (date, time+tz, a hex thread id, a bareword type, a hex
/// activity id, a decimal pid, a decimal ttl) followed by a free-text `process: message` tail — see
/// `huig_dump.txt`'s header row and body. Anchored at the very start of the line and requires the
/// hex/decimal shape of each column, so it won't accidentally fire on ordinary logfmt/JSON/prose that
/// merely resembles it in one column. Checked ahead of JSON/logfmt in `detect_format` since it's the
/// most specific of the three (an all-columns-plus-hex-ids match is a much stronger signal than an
/// incidental `key=value` substring), and is deliberately a *format label* rather than a dedicated
/// `ottyl_fmt` value change to JSON/logfmt detection — a `log stream` line's message tail can still
/// separately contain real `key=value` pairs (see the WiFi/radio stat lines in the fixture), which
/// `crate::engine::auto_declare_params` keeps discovering via `extract_pairs` regardless of this
/// line's `ottyl_fmt` label.
static LOG_STREAM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+[^\s:]+:\s",
)
.expect("static log_stream regex must compile")
});
pub fn is_log_stream_line(line: &str) -> bool {
LOG_STREAM_RE.is_match(line)
}
/// `message_match_regex` patterns `crate::engine::auto_declare_log_stream_params` uses to declare
/// `log_type`/`process`/`pid` as standalone params the first time a `log stream`-shaped line is seen.
/// Each isolates exactly one named capture group (matching `derive_log_params`'s one-group-per-param
/// convention) over the same column layout `LOG_STREAM_RE` detects.
pub const LOG_STREAM_TYPE_PATTERN: &str =
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+(?P<log_type>\S+)\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+[^\s:]+:\s";
pub const LOG_STREAM_PROCESS_PATTERN: &str =
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+(?P<process>[^\s:]+):\s";
pub const LOG_STREAM_PID_PATTERN: &str =
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+(?P<pid>\d+)\s+\d+\s+[^\s:]+:\s";
/// Go `log/slog` `NewJSONHandler` output (the `go-log` label): a single-line JSON object whose first
/// three fields are always `time`, `level`, `msg` in that fixed order (slog's `JSONHandler` writes the
/// built-in attrs before any caller-supplied ones), followed by zero or more arbitrary attrs and a
/// closing `}`. Parsed with nom rather than a full JSON decode since only that fixed prefix shape
/// needs verifying; the variable tail is deliberately not parsed field-by-field the way
/// `crate::engine::auto_declare_params` will separately pick up JSON leaf fields from the line.
/// Checked ahead of the generic JSON object check in `detect_format` since it's the more specific of
/// the two (see that function's doc comment for the general ordering rationale).
fn go_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str)> {
let (input, _) = tag("{\"time\":\"")(input)?;
let (input, time) = take_until("\"")(input)?;
let (input, _) = tag("\",\"level\":\"")(input)?;
let (input, level) = take_until("\"")(input)?;
let (input, _) = tag("\",\"msg\":\"")(input)?;
let (input, msg) = take_until("\"")(input)?;
let (input, _) = char('"')(input)?;
Ok((input, (time, level, msg)))
}
pub fn is_go_log_line(text: &str) -> bool {
match go_log_prefix(text) {
Ok((rest, _)) => rest.trim_end().ends_with('}'),
Err(_) => false,
}
}
/// Python `logging` module output in the shape `"%(asctime)s %(levelname)s %(name)s %(message)s"`
/// (the `py-log` label): a `YYYY-MM-DD HH:MM:SS,mmm` timestamp, an uppercase level word, a logger
/// name, then a message tail that (for `delete-py-log`, and any caller using `logger.info("event=%s
/// %s=%s", ...)`-style calls) is itself logfmt `key=value` pairs. Only the fixed prefix is parsed with
/// nom; the tail is handed to `extract_pairs` both to confirm the shape (a py-log line with no pairs
/// at all wouldn't be distinguishable from plain prose after this prefix) and because that's the same
/// extraction `crate::engine::auto_declare_params` needs anyway. Checked ahead of the generic logfmt
/// check in `detect_format` since it's the more specific of the two.
fn py_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str, &str)> {
let (input, date) = take_while1(|c: char| c.is_ascii_digit() || c == '-')(input)?;
let (input, _) = char(' ')(input)?;
let (input, time) = take_while1(|c: char| c.is_ascii_digit() || c == ':' || c == ',')(input)?;
let (input, _) = char(' ')(input)?;
let (input, level) = take_while1(|c: char| c.is_ascii_uppercase())(input)?;
let (input, _) = char(' ')(input)?;
let (input, logger) =
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')(input)?;
let (input, _) = char(' ')(input)?;
Ok((input, (date, time, level, logger)))
}
pub fn is_py_log_line(text: &str) -> bool {
match py_log_prefix(text) {
Ok((rest, _)) => !extract_pairs(rest).is_empty(),
Err(_) => false,
}
}
/// The value of the `ottyl_fmt` reserved param (`crate::engine::compile_filter`/`eval_alert_expr`):
/// which format a line was recognized as when parsed. Checked most-specific-first: `log stream`'s
/// fixed-column shape (see `LOG_STREAM_RE`'s doc comment for why it's the most specific), then the two
/// named-tool shapes `go-log` (a `time`/`level`/`msg`-prefixed JSON object) and `py-log` (a
/// timestamp/level/logger prefix over logfmt pairs) — each is a strict subset of the generic JSON/logfmt
/// case checked right after it, so they'd otherwise never be reached. JSON is checked next since a JSON
/// object can incidentally contain `key=value`-shaped substrings that `extract_pairs` would otherwise
/// pick up; only a top-level object counts (arrays/scalars aren't what `crate::engine::eval_json_comparison`
/// treats as JSON either). Anything else falls back to logfmt-style pair extraction, and a line with
/// none of the above is `"none"`.
pub fn detect_format(text: &str) -> &'static str {
if is_log_stream_line(text) {
"log_stream"
} else if is_go_log_line(text) {
"go-log"
} else if is_py_log_line(text) {
"py-log"
} else if serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_object()) {
"json"
} else if !extract_pairs(text).is_empty() {
"logfmt"
} else {
"none"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_quoted_value() {
let pairs = extract_pairs(r#"msg="entity_moved""#);
assert_eq!(pairs, vec![("msg", "entity_moved", true)]);
}
#[test]
fn extracts_unquoted_bareword_value() {
let pairs = extract_pairs("x1=-4");
assert_eq!(pairs, vec![("x1", "-4", false)]);
}
#[test]
fn extracts_mixed_quoted_and_unquoted_pairs_in_one_line() {
let pairs = extract_pairs(r#"ts=1788648105773 msg="entity_moved" x1=-4 y1=34"#);
assert_eq!(
pairs,
vec![
("ts", "1788648105773", false),
("msg", "entity_moved", true),
("x1", "-4", false),
("y1", "34", false),
]
);
}
#[test]
fn returns_empty_for_lines_with_no_pairs() {
assert!(extract_pairs("just some plain prose here").is_empty());
assert!(extract_pairs(r#"{"foo": {"bar": 42}}"#).is_empty());
}
#[test]
fn does_not_panic_on_empty_value_at_end_of_line() {
assert_eq!(extract_pairs("key="), Vec::<(&str, &str, bool)>::new());
assert_eq!(extract_pairs(r#"key="""#), vec![("key", "", true)]);
}
#[test]
fn keeps_escaped_quote_inside_quoted_value() {
let pairs = extract_pairs(r#"msg="he said \"hi\"""#);
assert_eq!(pairs, vec![("msg", r#"he said \"hi\""#, true)]);
}
#[test]
fn detects_json_object_lines_as_json() {
assert_eq!(detect_format(r#"{"level":"error","code":500}"#), "json");
}
#[test]
fn detects_key_value_lines_as_logfmt() {
assert_eq!(detect_format(r#"ts=1788648105773 msg="entity_moved" x1=-4"#), "logfmt");
}
#[test]
fn detects_plain_prose_as_none() {
assert_eq!(detect_format("just some plain prose here"), "none");
}
#[test]
fn a_bare_json_array_or_scalar_is_not_treated_as_json() {
// Not an object, so falls through to logfmt/none like `eval_json_comparison` would treat it.
assert_eq!(detect_format("[1,2,3]"), "none");
assert_eq!(detect_format("42"), "none");
}
#[test]
fn rejects_base64_padding_fragment_as_a_pair() {
// The exact false-positive shape from ticket 050's fixture: `key()` greedily consumes the
// tail of the base64 blob as if it were a key, landing right before the `==` padding.
let pairs = extract_pairs(
"qname: <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>, qtype: A, client pid: 12260 (firefox)",
);
assert!(
pairs.iter().all(|(k, _, _)| *k != "rKTEtG7JmnThhoKPbZiVrA"),
"base64 padding fragment must not be extracted as a pair: {pairs:?}"
);
}
const LOG_STREAM_FIXTURE: &str = include_str!("fixtures/log_stream_sample.txt");
#[test]
fn detects_log_stream_shaped_lines() {
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
assert_eq!(detect_format(data_line), "log_stream");
assert!(is_log_stream_line(data_line));
}
#[test]
fn header_row_is_not_detected_as_log_stream() {
let header = LOG_STREAM_FIXTURE.lines().next().unwrap();
assert!(!is_log_stream_line(header));
}
#[test]
fn log_stream_patterns_capture_type_process_and_pid() {
let line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let type_re = Regex::new(LOG_STREAM_TYPE_PATTERN).unwrap();
let process_re = Regex::new(LOG_STREAM_PROCESS_PATTERN).unwrap();
let pid_re = Regex::new(LOG_STREAM_PID_PATTERN).unwrap();
assert_eq!(type_re.captures(line).unwrap().name("log_type").unwrap().as_str(), "Default");
assert_eq!(process_re.captures(line).unwrap().name("process").unwrap().as_str(), "WindowServer");
assert_eq!(pid_re.captures(line).unwrap().name("pid").unwrap().as_str(), "606");
}
#[test]
fn a_wifi_stat_dump_line_is_still_recognized_as_log_stream_despite_embedded_key_value_pairs() {
let line = LOG_STREAM_FIXTURE
.lines()
.find(|line| line.contains("txRTSFrm="))
.expect("fixture must contain the WiFi stat dump line");
assert_eq!(detect_format(line), "log_stream");
// The embedded key=value pairs are still there for auto_declare_params to see.
assert!(!extract_pairs(line).is_empty());
}
const GO_LOG_FIXTURE: &str = include_str!("fixtures/go_log_sample.txt");
const PY_LOG_FIXTURE: &str = include_str!("fixtures/py_log_sample.txt");
#[test]
fn detects_go_log_shaped_lines() {
for line in GO_LOG_FIXTURE.lines() {
assert!(is_go_log_line(line), "expected go-log line to match: {line}");
assert_eq!(detect_format(line), "go-log");
}
}
#[test]
fn detects_py_log_shaped_lines() {
for line in PY_LOG_FIXTURE.lines() {
assert!(is_py_log_line(line), "expected py-log line to match: {line}");
assert_eq!(detect_format(line), "py-log");
}
}
#[test]
fn a_generic_json_object_is_not_detected_as_go_log() {
let line = r#"{"level":"error","code":500}"#;
assert!(!is_go_log_line(line));
assert_eq!(detect_format(line), "json");
}
#[test]
fn a_generic_logfmt_line_is_not_detected_as_py_log() {
let line = r#"ts=1788648105773 msg="entity_moved" x1=-4"#;
assert!(!is_py_log_line(line));
assert_eq!(detect_format(line), "logfmt");
}
#[test]
fn go_log_prefix_extracts_time_level_and_msg() {
let (rest, (time, level, msg)) =
go_log_prefix(r#"{"time":"2026-09-06T10:35:34.499921+01:00","level":"WARN","msg":"eventC","labelC":1}"#)
.unwrap();
assert_eq!(time, "2026-09-06T10:35:34.499921+01:00");
assert_eq!(level, "WARN");
assert_eq!(msg, "eventC");
assert_eq!(rest, r#","labelC":1}"#);
}
#[test]
fn py_log_prefix_extracts_date_time_level_and_logger() {
let (rest, (date, time, level, logger)) =
py_log_prefix("2026-09-06 10:35:55,217 INFO delete-py-log event=eventC labelC=1").unwrap();
assert_eq!(date, "2026-09-06");
assert_eq!(time, "10:35:55,217");
assert_eq!(level, "INFO");
assert_eq!(logger, "delete-py-log");
assert_eq!(rest, "event=eventC labelC=1");
}
}
+5 -151
View File
@@ -1,157 +1,11 @@
mod alerts;
mod api;
mod data_value;
mod dto;
mod cli;
mod config;
mod consts;
mod engine;
mod filter_datetime;
mod filter_parser;
mod index_key;
mod ingest;
mod logfmt;
mod metric_primitive;
mod profile;
mod promql;
mod rate;
mod settings;
mod timestamp;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use clap::Parser;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use settings::Settings;
/// The resolved default settings path, rendered into `--help` so `--settings-path`'s help text
/// names a real, concrete location instead of only describing where to look for one.
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
format!(
"Path to the settings file. Defaults to {}.",
settings::default_settings_path()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "a per-OS config location".to_string())
)
});
/// Observability dashboard from terminal output.
#[derive(Parser, Debug)]
struct Args {
/// Port to serve the API and webapp on. Defaults to 0, letting the OS assign
/// an ephemeral free port.
#[arg(long, default_value_t = 0)]
port: u16,
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
settings_path: Option<PathBuf>,
/// File to tail for log lines, instead of reading from stdin. Behaves like `tail -f`.
#[arg(long)]
file: Option<PathBuf>,
/// URL of a Prometheus/OpenMetrics-compatible endpoint to poll for metrics, in addition to log ingestion.
#[arg(long)]
metrics_url: Option<String>,
}
pub(crate) mod flavour;
#[tokio::main]
async fn main() {
let args = Args::parse();
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
settings::default_settings_path()
.expect("could not determine a default settings path; pass --settings-path")
});
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
let (engine_tx, engine_rx) = mpsc::channel(1024);
tokio::spawn(engine::run(engine_rx, settings.clone()));
{
let max_logs = settings.lock().unwrap().max_logs;
let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
}
// Reload the active profile's declarations before ingest starts: there's no way to
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
{
let (active_profile_name, active_profile) = {
let mut settings_guard = settings.lock().unwrap();
settings_guard
.ensure_default_profile(&settings_path)
.expect("failed to ensure the Default profile exists");
let active_profile_name = settings_guard.active_profile.clone();
let active_profile = active_profile_name
.as_deref()
.and_then(|name| settings_guard.profile(name).cloned());
(active_profile_name, active_profile)
};
match (active_profile_name, active_profile) {
(Some(_), Some(active_profile)) => {
profile::load_into_engine(&engine_tx, &active_profile).await;
}
(Some(profile_name), None) => {
eprintln!(
"warning: active profile '{profile_name}' not found in {}; skipping",
settings_path.display()
);
}
(None, _) => {}
}
}
match args.file.clone() {
Some(path) => {
tokio::spawn(ingest::run_file(engine_tx.clone(), path));
}
None => {
tokio::spawn(ingest::run(engine_tx.clone()));
}
}
if let Some(metrics_url) = args.metrics_url.clone() {
tokio::spawn(ingest::run_metrics_poll(engine_tx.clone(), metrics_url));
}
let app = api::router(engine_tx, settings, settings_path);
let listener = TcpListener::bind(("0.0.0.0", args.port))
.await
.expect("failed to bind listener");
let bound_port = listener
.local_addr()
.expect("failed to read back bound address")
.port();
let source = match &args.file {
Some(path) => format!("tailing {}", path.display()),
None => "reading logs from stdin".to_string(),
};
eprintln!(
"ottyl serving on http://localhost:{} ({})",
bound_port, source
);
axum::serve(listener, app)
.await
.expect("server error");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_path_defaults_to_none() {
let args = Args::parse_from(["ottyl"]);
assert_eq!(args.settings_path, None);
}
#[test]
fn settings_path_flag_overrides_default() {
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
assert_eq!(
args.settings_path,
Some(PathBuf::from("/tmp/custom-settings.json"))
);
}
cli::run().await;
}
-143
View File
@@ -1,143 +0,0 @@
//! Ticket 040: the single windowed-rate/count implementation in the codebase, shared by
//! PromQL's `rate()` (`crate::engine::evaluate_promql`, via the `windowed_rate_series` adapter
//! there) and alert `Threshold`/`Ratio` conditions (`crate::alerts`). Previously each computed
//! its own trailing window independently — PromQL scanning indexed bucket data, alerts scanning
//! raw log-line matches with a hand-rolled `VecDeque` — and could disagree for equivalent-looking
//! rules simply because the window boundary was implemented twice.
//!
//! `count_in_window` is the one place that decides what "falls in the trailing window" means;
//! everything else in this module is built on top of it, either by batch-rescanning a full point
//! series (`rate_at`/`windowed_rate_series`, fine for PromQL's historical/one-shot evaluation) or
//! incrementally (`RateWindow`, for alerts, which see one point at a time and need cost
//! proportional to the window rather than a full rescan per ingested line).
use std::collections::VecDeque;
use crate::timestamp::Timestamp;
/// How many `points` fall in the trailing `(now - window_ms, now]` window. Points are occurrence
/// markers rather than a continuous reading, so only their timestamps matter — the boundary is
/// exclusive on the old end and inclusive of `now` itself, matching the original
/// `windowed_rate_series` behavior this replaces.
fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize {
let now_ms = now.as_millis();
let window_start = now_ms - window_ms;
points
.iter()
.filter(|(ts, _)| {
let ms = ts.as_millis();
ms > window_start && ms <= now_ms
})
.count()
}
/// Trailing-window occurrence rate (points per second) ending at `now`: how many `points` fall in
/// `(now - window_ms, now]`, divided by the window width in seconds.
pub fn rate_at(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> f64 {
let window_seconds = window_ms as f64 / 1000.0;
count_in_window(points, now, window_ms) as f64 / window_seconds
}
/// Evaluates `rate_at` at every one of `points`' own timestamps, turning an occurrence series
/// into a windowed rate series — used by PromQL's `rate()` so it plots as a real line rather than
/// one trailing point.
pub fn windowed_rate_series(points: &[(Timestamp, f64)], window_ms: i64) -> Vec<(Timestamp, f64)> {
points.iter().map(|(ts, _)| (*ts, rate_at(points, *ts, window_ms))).collect()
}
/// Incrementally maintained trailing window: for a caller that sees points arrive one at a time
/// (alert `Threshold`/`Ratio` conditions, evaluated per ingested line) and needs cost proportional
/// to the window rather than `count_in_window`'s full rescan per call. Assumes points are pushed
/// in non-decreasing timestamp order, true for ingested log lines.
#[derive(Debug, Default, Clone)]
pub struct RateWindow {
timestamps: VecDeque<Timestamp>,
}
impl RateWindow {
/// Builds a window pre-populated from already-seen history (e.g. currently-ingested logs
/// matching an alert's query), so a freshly-created window starts with whatever of that
/// history already falls in the trailing window instead of empty.
pub fn backfill(timestamps: impl IntoIterator<Item = Timestamp>, now: Timestamp, window_ms: i64) -> Self {
let mut window = Self::default();
for ts in timestamps {
window.push(ts, window_ms);
}
window.prune(now, window_ms);
window
}
/// Records one occurrence at `timestamp` and drops anything that has aged out of the
/// resulting trailing window.
pub fn push(&mut self, timestamp: Timestamp, window_ms: i64) {
self.timestamps.push_back(timestamp);
self.prune(timestamp, window_ms);
}
/// Same boundary as `count_in_window`: drops entries at or before `now - window_ms`.
fn prune(&mut self, now: Timestamp, window_ms: i64) {
let window_start = now.as_millis() - window_ms;
while let Some(&front) = self.timestamps.front() {
if front.as_millis() <= window_start {
self.timestamps.pop_front();
} else {
break;
}
}
}
pub fn count(&self) -> usize {
self.timestamps.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_at_matches_hand_computed_window() {
let points = vec![
(Timestamp::from_millis(0), 1.0),
(Timestamp::from_millis(1000), 1.0),
(Timestamp::from_millis(2000), 1.0),
];
// (1000, 2000]: only the point at 2000.
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 1000), 1.0);
// (0, 2000]: points at 1000 and 2000 -> 2 points / 2s.
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 2000), 1.0);
}
#[test]
fn windowed_rate_series_matches_rate_at_at_each_point() {
let points = vec![
(Timestamp::from_millis(0), 1.0),
(Timestamp::from_millis(500), 1.0),
(Timestamp::from_millis(1000), 1.0),
];
let series = windowed_rate_series(&points, 1000);
for (ts, rate) in &series {
assert_eq!(*rate, rate_at(&points, *ts, 1000));
}
}
#[test]
fn rate_window_incremental_count_matches_batch_count_in_window() {
let mut window = RateWindow::default();
let millis = [0, 200, 400, 1200];
for ms in millis {
window.push(Timestamp::from_millis(ms), 1000);
}
let points: Vec<(Timestamp, f64)> =
millis.iter().map(|ms| (Timestamp::from_millis(*ms), 1.0)).collect();
assert_eq!(window.count(), count_in_window(&points, Timestamp::from_millis(1200), 1000));
}
#[test]
fn rate_window_backfill_seeds_from_history_within_the_window() {
let history = [0, 500, 2000, 2500].map(Timestamp::from_millis);
// now=2500ms, window=1000ms -> window (1500, 2500]: only 2000 and 2500 qualify.
let window = RateWindow::backfill(history, Timestamp::from_millis(2500), 1000);
assert_eq!(window.count(), 2);
}
}
-677
View File
@@ -1,677 +0,0 @@
//! Process-wide settings model: colour scheme, CSS entries, and profiles (ticket 049 folded
//! profile storage inline here instead of a separate `profiles.json`), persisted as JSON.
use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::alerts::AlertRule;
use crate::profile::Profile;
/// Shared, process-wide settings handle. Cheap to clone.
pub type SettingsHandle = Arc<Mutex<Settings>>;
/// User's preferred colour scheme for the webapp.
///
/// `Dark` accepts the legacy `"grey"` serde value (ticket 004: "grey" was a
/// near-duplicate of "dark" and has been removed as a selectable option) so a
/// settings file saved before that change still loads cleanly instead of
/// falling back to `System`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ColorScheme {
Light,
#[serde(alias = "grey")]
Dark,
Red,
Green,
Blue,
Yellow,
Pink,
Orange,
/// Brighter, light-surfaced counterpart to `Green` (ticket 048).
#[serde(rename = "light_green")]
LightGreen,
/// Brighter, light-surfaced counterpart to `Red` (ticket 056).
#[serde(rename = "light_red")]
LightRed,
/// Brighter, light-surfaced counterpart to `Blue` (ticket 056).
#[serde(rename = "light_blue")]
LightBlue,
/// Brighter, light-surfaced counterpart to `Yellow` (ticket 056).
#[serde(rename = "light_yellow")]
LightYellow,
/// Brighter, light-surfaced counterpart to `Pink` (ticket 056).
#[serde(rename = "light_pink")]
LightPink,
/// Brighter, light-surfaced counterpart to `Orange` (ticket 056).
#[serde(rename = "light_orange")]
LightOrange,
#[default]
System,
}
/// A single CSS entry: content ottyl stores itself ("imported") or a reference to an external
/// path/URL ("linked", not deletable via the API). Every entry is always a selectable
/// colour-scheme theme (ticket 020 removed the "use as theme"/always-on-overlay distinction);
/// an entry persisted before that change with a stored `is_theme: false` still loads fine (the
/// field is simply ignored) and becomes selectable like any other entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum CssEntry {
Imported { id: String, filename: String },
Linked { path_or_url: String },
}
/// Legacy on-disk shape of the (pre-049) `Profiles` pointer struct: only names/active-profile/dir
/// pointers, with the actual profile content living in a separate `profiles.json`. Kept only so an
/// old `settings.json` still parses; see `ProfilesOnDisk` and `Settings::migrate_legacy_profiles_file`.
#[derive(Debug, Clone, Deserialize, Default)]
struct LegacyProfilesPointer {
// `names` is intentionally not captured: it was always redundant with the legacy
// `profiles.json`'s actual profile list, which is what migration reads instead.
#[serde(default)]
active_profile: Option<String>,
/// Directory (relative to the settings file's directory, or absolute) the legacy `profiles.json`
/// lived under. Every version of this codebase ever wrote `"profiles"` here (no API ever let a
/// user change it), so migration doesn't bother re-deriving a custom value from this field.
#[serde(default)]
#[allow(dead_code)]
profiles_dir: Option<String>,
}
/// On-disk shape of the `profiles` field: either the new inline `Vec<Profile>` (ticket 049) or the
/// old pointer-struct shape, so a `settings.json` written before this ticket still deserializes.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum ProfilesOnDisk {
Inline(Vec<Profile>),
Legacy(LegacyProfilesPointer),
}
impl Default for ProfilesOnDisk {
fn default() -> Self {
ProfilesOnDisk::Inline(Vec::new())
}
}
/// Raw on-disk shape of `Settings`, used only to support deserializing an old `settings.json` whose
/// `profiles` field was the pointer struct and had no top-level `active_profile` — see `Settings`'s
/// `#[serde(from = ...)]`.
#[derive(Debug, Clone, Deserialize)]
struct SettingsOnDisk {
color_scheme: ColorScheme,
#[serde(default)]
css_entries: Vec<CssEntry>,
#[serde(default)]
custom_theme_id: Option<String>,
#[serde(default)]
profiles: ProfilesOnDisk,
#[serde(default)]
active_profile: Option<String>,
#[serde(default)]
max_logs: Option<usize>,
#[serde(default)]
alert_rules: Vec<AlertRule>,
#[serde(default)]
ntfy_topic: Option<String>,
#[serde(default)]
ntfy_server: Option<String>,
}
impl From<SettingsOnDisk> for Settings {
fn from(on_disk: SettingsOnDisk) -> Self {
let (profiles, legacy_active_profile) = match on_disk.profiles {
ProfilesOnDisk::Inline(profiles) => (profiles, None),
ProfilesOnDisk::Legacy(pointer) => (Vec::new(), pointer.active_profile),
};
Settings {
color_scheme: on_disk.color_scheme,
css_entries: on_disk.css_entries,
custom_theme_id: on_disk.custom_theme_id,
profiles,
active_profile: on_disk.active_profile.or(legacy_active_profile),
max_logs: on_disk.max_logs,
alert_rules: on_disk.alert_rules,
ntfy_topic: on_disk.ntfy_topic,
ntfy_server: on_disk.ntfy_server,
}
}
}
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
///
/// `profiles`/`active_profile` used to be a separate `Profiles` pointer struct referencing a
/// standalone `profiles.json` (see `crate::profile::ProfileStore`, removed by ticket 049); profile
/// data now lives inline here, as the single source of truth. `Deserialize` goes through
/// `SettingsOnDisk` so a `settings.json` written before this ticket still loads (see
/// `migrate_legacy_profiles_file`, which then imports the old `profiles.json`, if any, on `load`).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "SettingsOnDisk")]
pub struct Settings {
pub color_scheme: ColorScheme,
pub css_entries: Vec<CssEntry>,
/// Id of the selected custom theme entry, if any; `None` means `color_scheme` alone determines the active palette.
#[serde(default)]
pub custom_theme_id: Option<String>,
/// Every known profile, inline (ticket 049).
pub profiles: Vec<Profile>,
/// Name of the currently active profile, if any.
pub active_profile: Option<String>,
/// Maximum number of ingested logs kept in memory; `None` means unlimited.
#[serde(default)]
pub max_logs: Option<usize>,
/// Alert rule definitions.
#[serde(default)]
pub alert_rules: Vec<AlertRule>,
/// Ntfy topic every alert rule delivers to; `None` until configured in Settings.
#[serde(default)]
pub ntfy_topic: Option<String>,
/// Ntfy server override; `None` means `https://ntfy.sh`.
#[serde(default)]
pub ntfy_server: Option<String>,
}
/// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
pub fn default_settings_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json"))
}
impl Settings {
/// Loads settings from `path`, creating and persisting defaults if missing. Any I/O or parse error on an existing file is logged and treated as defaults, without overwriting the unreadable file. Also runs the one-time legacy `profiles.json` migration (see `migrate_legacy_profiles_file`).
pub fn load(path: &Path) -> Settings {
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str::<Settings>(&contents) {
Ok(mut settings) => {
settings.migrate_legacy_profiles_file(path);
settings.migrate_legacy_profile_shapes(path);
settings
}
Err(err) => {
eprintln!(
"warning: failed to parse settings file at {}: {err}; using defaults",
path.display()
);
Settings::default()
}
},
Err(err) if err.kind() == io::ErrorKind::NotFound => {
let mut settings = Settings::default();
settings.migrate_legacy_profiles_file(path);
if let Err(save_err) = settings.save(path) {
eprintln!(
"warning: failed to create default settings file at {}: {save_err}",
path.display()
);
}
settings
}
Err(err) => {
eprintln!(
"warning: failed to read settings file at {}: {err}; using defaults",
path.display()
);
Settings::default()
}
}
}
/// Persists these settings to `path` as pretty-printed JSON, creating any missing parent directories first.
pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(self)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
fs::write(path, contents)
}
/// One-time migration (ticket 049): if `self.profiles` is empty (a fresh `Settings`, or one
/// parsed from a pre-049 `settings.json` whose profiles lived in a separate file) and a legacy
/// `<settings_path's parent>/profiles/profiles.json` exists, reads it in, merges it into
/// `self.profiles`, persists `self`, and — only once that save succeeds — renames the old file
/// (`.migrated` suffix) so it isn't re-imported on a later load. Every prior version of this
/// codebase always used the fixed `"profiles"` subdirectory name (no API ever let it be
/// reconfigured), so that's the only location checked. Best-effort: any failure is logged and
/// left for a future load to retry rather than panicking.
fn migrate_legacy_profiles_file(&mut self, settings_path: &Path) {
if !self.profiles.is_empty() {
return;
}
let legacy_path = settings_path
.parent()
.map(|parent| parent.join("profiles"))
.unwrap_or_else(|| PathBuf::from("profiles"))
.join("profiles.json");
let Some(profiles) = crate::profile::load_legacy_profile_store(&legacy_path) else {
return;
};
self.profiles = profiles;
if let Err(err) = self.save(settings_path) {
eprintln!(
"warning: failed to persist profiles migrated from {}: {err}",
legacy_path.display()
);
return;
}
let migrated_path = legacy_path.with_extension("json.migrated");
if let Err(err) = fs::rename(&legacy_path, &migrated_path) {
eprintln!(
"warning: migrated profiles from {} into settings.json, but failed to rename the old file: {err}",
legacy_path.display()
);
}
}
/// Migrates any pre-037/pre-038 metric or param shapes still present in `self.profiles` into
/// their current form (see `crate::profile::migrate_legacy_profile_shapes`), persisting `self`
/// if anything changed. Runs on every load — previously done inside the now-removed
/// `ProfileStore::load` (ticket 049 folded profile storage into `settings.json`) — so a profile
/// saved by an older version of ottyl, whether loaded inline here or just merged in by
/// `migrate_legacy_profiles_file`, still gets migrated forward.
fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) {
if crate::profile::migrate_legacy_profile_shapes(&mut self.profiles)
&& let Err(err) = self.save(settings_path)
{
eprintln!(
"warning: failed to persist migrated profile shapes to {}: {err}",
settings_path.display()
);
}
}
}
/// Error returned by CSS-entry mutations and lookups.
#[derive(Debug)]
pub enum CssError {
/// No entry with the given id exists.
NotFound,
/// The entry is a `Linked` entry, not `Imported` — ottyl doesn't own a linked entry's content, so there's nothing to delete or serve back.
NotImported,
/// Underlying I/O failure: writing/reading/removing the CSS file, or persisting `settings.json`.
Io(io::Error),
}
impl fmt::Display for CssError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CssError::NotFound => write!(f, "no CSS entry with that id"),
CssError::NotImported => write!(f, "CSS entry is linked, not imported"),
CssError::Io(err) => write!(f, "I/O error: {err}"),
}
}
}
impl std::error::Error for CssError {}
impl From<io::Error> for CssError {
fn from(err: io::Error) -> Self {
CssError::Io(err)
}
}
/// Computes the id used to reference a `CssEntry` over the API: the stored id for `Imported`, or a hash of `path_or_url` for `Linked` (which has no stored id), so the frontend can key both the same way.
pub fn css_entry_id(entry: &CssEntry) -> String {
match entry {
CssEntry::Imported { id, .. } => id.clone(),
CssEntry::Linked { path_or_url, .. } => linked_entry_id(path_or_url),
}
}
fn linked_entry_id(path_or_url: &str) -> String {
let mut hasher = DefaultHasher::new();
path_or_url.hash(&mut hasher);
format!("linked-{:016x}", hasher.finish())
}
/// Generates an id for a newly-imported CSS entry by hashing filename, content, and time, avoiding a `uuid` dependency for what's otherwise an opaque string.
fn generate_imported_css_id(filename: &str, content: &str) -> String {
let mut hasher = DefaultHasher::new();
filename.hash(&mut hasher);
content.hash(&mut hasher);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
nanos.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
css_dir.join(format!("{id}.css"))
}
impl Settings {
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css`, records a `CssEntry::Imported` in `css_entries`, and persists settings. Returns the generated id. `css_dir` is expected to be the settings file's parent directory.
pub fn import_css(
&mut self,
settings_path: &Path,
css_dir: &Path,
filename: String,
content: &str,
) -> Result<String, CssError> {
fs::create_dir_all(css_dir)?;
let id = generate_imported_css_id(&filename, content);
fs::write(imported_css_file_path(css_dir, &id), content)?;
self.css_entries.push(CssEntry::Imported {
id: id.clone(),
filename,
});
self.save(settings_path)?;
Ok(id)
}
/// Registers a reference to an external CSS path/URL without copying its content, and persists settings.
pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> {
self.css_entries.push(CssEntry::Linked { path_or_url });
self.save(settings_path)?;
Ok(())
}
/// Removes an imported CSS entry identified by `id`: deletes its file under `css_dir`, drops it from `css_entries`, and persists settings. Fails with `NotFound` or `NotImported` (linked entries aren't deletable this way).
pub fn remove_css(
&mut self,
settings_path: &Path,
css_dir: &Path,
id: &str,
) -> Result<(), CssError> {
let index = self
.css_entries
.iter()
.position(|entry| css_entry_id(entry) == id);
match index.map(|i| &self.css_entries[i]) {
None => Err(CssError::NotFound),
Some(CssEntry::Linked { .. }) => Err(CssError::NotImported),
Some(CssEntry::Imported { .. }) => {
let idx = index.unwrap();
self.css_entries.remove(idx);
// Best-effort: the settings entry is already removed even if the file is gone.
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
self.save(settings_path)?;
Ok(())
}
}
}
/// Reads back the raw content of an imported CSS entry identified by `id`. Fails with `NotFound` or `NotImported` (linked entries have no local content — the frontend fetches `path_or_url` directly).
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
None => Err(CssError::NotFound),
Some(CssEntry::Linked { .. }) => Err(CssError::NotImported),
Some(CssEntry::Imported { .. }) => {
Ok(fs::read_to_string(imported_css_file_path(css_dir, id))?)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_settings_path_ends_with_settings_json() {
// Can't assert the exact OS path without touching real config dirs, so just check the shape.
if let Some(path) = default_settings_path() {
assert_eq!(path.file_name().unwrap(), "settings.json");
assert!(path.parent().is_some());
}
}
#[test]
fn load_creates_default_when_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("settings.json");
assert!(!path.exists());
let settings = Settings::load(&path);
assert_eq!(settings, Settings::default());
assert!(path.exists(), "load() should persist a default file");
let reloaded = Settings::load(&path);
assert_eq!(reloaded, settings);
}
#[test]
fn light_variant_color_schemes_round_trip_through_serde() {
for (scheme, rename) in [
(ColorScheme::LightGreen, "light_green"),
(ColorScheme::LightRed, "light_red"),
(ColorScheme::LightBlue, "light_blue"),
(ColorScheme::LightYellow, "light_yellow"),
(ColorScheme::LightPink, "light_pink"),
(ColorScheme::LightOrange, "light_orange"),
] {
let json = serde_json::to_string(&scheme).unwrap();
assert_eq!(json, format!("\"{rename}\""));
let deserialized: ColorScheme = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, scheme);
}
}
#[test]
fn legacy_grey_color_scheme_deserializes_as_dark() {
let settings: Settings = serde_json::from_str(
r#"{"color_scheme":"grey","css_entries":[],"custom_theme_id":null,"profiles":{"names":[],"active_profile":null,"profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
)
.unwrap();
assert_eq!(settings.color_scheme, ColorScheme::Dark);
}
/// A `settings.json` written before ticket 049 (nested `Profiles` pointer struct, no top-level
/// `active_profile`) still parses: the pointer's `active_profile` carries over, and `profiles`
/// starts empty (the real content lived in a separate `profiles.json`, migrated separately by
/// `Settings::load`, not by plain deserialization).
#[test]
fn legacy_profiles_pointer_shape_deserializes_with_empty_inline_profiles() {
let settings: Settings = serde_json::from_str(
r#"{"color_scheme":"dark","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
)
.unwrap();
assert!(settings.profiles.is_empty());
assert_eq!(settings.active_profile, Some("web".to_string()));
}
#[test]
fn round_trips_through_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let mut settings = Settings::default();
settings.color_scheme = ColorScheme::Dark;
settings.css_entries.push(CssEntry::Imported {
id: "abc123".to_string(),
filename: "theme.css".to_string(),
});
settings.css_entries.push(CssEntry::Linked {
path_or_url: "https://example.com/style.css".to_string(),
});
settings.profiles.push(crate::profile::Profile::new("default".to_string()));
settings.active_profile = Some("default".to_string());
settings.alert_rules.push(crate::alerts::AlertRule {
id: "rule1".to_string(),
name: "error spike".to_string(),
enabled: true,
condition: crate::alerts::AlertCondition::Immediate {
query: r#"regex("error")"#.to_string(),
},
});
settings.ntfy_topic = Some("alerts".to_string());
settings.save(&path).unwrap();
let loaded = Settings::load(&path);
assert_eq!(loaded, settings);
}
#[test]
fn save_creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a").join("b").join("settings.json");
let settings = Settings::default();
settings.save(&path).unwrap();
assert!(path.exists());
}
#[test]
fn import_css_persists_file_and_entry_across_reload() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let css_dir = dir.path().to_path_buf();
let mut settings = Settings::load(&settings_path);
let id = settings
.import_css(
&settings_path,
&css_dir,
"theme.css".to_string(),
"body { color: red; }",
)
.unwrap();
let css_file = css_dir.join(format!("{id}.css"));
assert!(css_file.exists());
assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }");
// Drop and reload settings from disk (simulates a restart).
drop(settings);
let reloaded = Settings::load(&settings_path);
assert!(reloaded.css_entries.iter().any(|entry| matches!(
entry,
CssEntry::Imported { id: entry_id, filename, .. } if entry_id == &id && filename == "theme.css"
)));
assert!(css_file.exists());
assert_eq!(
reloaded.css_content(&css_dir, &id).unwrap(),
"body { color: red; }"
);
}
#[test]
fn remove_css_deletes_file_and_entry() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let css_dir = dir.path().to_path_buf();
let mut settings = Settings::load(&settings_path);
let id = settings
.import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}")
.unwrap();
let css_file = css_dir.join(format!("{id}.css"));
assert!(css_file.exists());
settings.remove_css(&settings_path, &css_dir, &id).unwrap();
assert!(!css_file.exists());
assert!(!settings.css_entries.iter().any(|e| css_entry_id(e) == id));
let reloaded = Settings::load(&settings_path);
assert!(!reloaded.css_entries.iter().any(|e| css_entry_id(e) == id));
}
#[test]
fn remove_css_rejects_linked_entry() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let css_dir = dir.path().to_path_buf();
let mut settings = Settings::load(&settings_path);
settings
.link_css(&settings_path, "https://example.com/style.css".to_string())
.unwrap();
let linked_id = css_entry_id(&settings.css_entries[0]);
let result = settings.remove_css(&settings_path, &css_dir, &linked_id);
assert!(matches!(result, Err(CssError::NotImported)));
// Entry must still be there — rejection shouldn't silently drop it.
assert_eq!(settings.css_entries.len(), 1);
}
#[test]
fn remove_css_rejects_unknown_id() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let css_dir = dir.path().to_path_buf();
let mut settings = Settings::load(&settings_path);
let result = settings.remove_css(&settings_path, &css_dir, "does-not-exist");
assert!(matches!(result, Err(CssError::NotFound)));
}
#[test]
fn css_content_rejects_linked_entry() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let css_dir = dir.path().to_path_buf();
let mut settings = Settings::load(&settings_path);
settings
.link_css(&settings_path, "https://example.com/style.css".to_string())
.unwrap();
let linked_id = css_entry_id(&settings.css_entries[0]);
let result = settings.css_content(&css_dir, &linked_id);
assert!(matches!(result, Err(CssError::NotImported)));
}
/// Acceptance criterion (ticket 049): a pre-existing legacy `profiles_dir/profiles.json` is
/// merged into `settings.json` on the next load, and the old file is renamed (not re-imported on
/// a subsequent load).
#[test]
fn load_migrates_legacy_profiles_file_into_inline_settings() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let profiles_dir = dir.path().join("profiles");
fs::create_dir_all(&profiles_dir).unwrap();
let legacy_path = profiles_dir.join("profiles.json");
fs::write(
&legacy_path,
r#"[{"name":"web","params":[],"metrics":[],"dashboards":[]}]"#,
)
.unwrap();
// No settings.json yet: simulates upgrading a pre-049 install that never wrote one either,
// or one that did but whose nested pointer already carried an active_profile name.
fs::write(
&settings_path,
r#"{"color_scheme":"system","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
)
.unwrap();
let settings = Settings::load(&settings_path);
assert_eq!(settings.profiles.len(), 1);
assert_eq!(settings.profiles[0].name, "web");
assert_eq!(settings.active_profile, Some("web".to_string()));
// The legacy file is gone (renamed), so it isn't re-imported.
assert!(!legacy_path.exists());
let migrated_path = legacy_path.with_extension("json.migrated");
assert!(migrated_path.exists());
// Reload from disk: the migrated settings.json now carries profiles inline directly.
let reloaded = Settings::load(&settings_path);
assert_eq!(reloaded.profiles.len(), 1);
assert_eq!(reloaded.profiles[0].name, "web");
}
/// A fresh install (no legacy file at all) never creates `profiles.json` — it's not even looked
/// for beyond the one migration check, and `profiles` simply stays empty.
#[test]
fn load_without_legacy_file_leaves_profiles_empty() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let settings = Settings::load(&settings_path);
assert!(settings.profiles.is_empty());
assert!(!dir.path().join("profiles").exists());
}
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(210 32% 10%);
--c-surface-1: hsl(210 28% 16%);
--c-surface-2: hsl(210 26% 22%);
--c-border: hsl(210 24% 24%);
--c-border-soft: hsl(210 22% 33%);
--c-border-faint: hsl(210 28% 16%);
--c-border-strong: hsl(210 15% 62%);
--c-text: hsl(210 15% 94%);
--c-text-secondary: hsl(210 12% 82%);
--c-text-muted: hsl(210 10% 65%);
--c-text-subtle: hsl(210 8% 50%);
--c-text-faint: hsl(210 8% 38%);
--c-text-faintest: hsl(210 10% 28%);
--c-danger-bg: #450a0a;
--c-danger-border: #7f1d1d;
--c-danger-text: #f87171;
--c-danger-text-strong: #fca5a5;
--c-accent: hsl(30 70% 62%);
--c-accent-soft: hsl(0 55% 34%);
--c-accent-text: hsl(30 75% 80%);
--c-accent-2: hsl(300 65% 68%);
--c-accent-3: hsl(120 65% 55%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--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;
--c-accent-2: #a78bfa;
--c-accent-3: #4ade80;
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(140 32% 10%);
--c-surface-1: hsl(140 28% 16%);
--c-surface-2: hsl(140 26% 22%);
--c-border: hsl(140 24% 24%);
--c-border-soft: hsl(140 22% 33%);
--c-border-faint: hsl(140 28% 16%);
--c-border-strong: hsl(140 15% 62%);
--c-text: hsl(140 15% 94%);
--c-text-secondary: hsl(140 12% 82%);
--c-text-muted: hsl(140 10% 65%);
--c-text-subtle: hsl(140 8% 50%);
--c-text-faint: hsl(140 8% 38%);
--c-text-faintest: hsl(140 10% 28%);
--c-danger-bg: #450a0a;
--c-danger-border: #7f1d1d;
--c-danger-text: #f87171;
--c-danger-text-strong: #fca5a5;
--c-accent: hsl(320 70% 62%);
--c-accent-soft: hsl(290 55% 34%);
--c-accent-text: hsl(320 75% 80%);
--c-accent-2: hsl(230 65% 62%);
--c-accent-3: hsl(50 65% 58%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: #fafafa;
--c-surface-1: #f5f5f5;
--c-surface-2: #e5e5e5;
--c-border: #e5e5e5;
--c-border-soft: #d4d4d4;
--c-border-faint: #f5f5f5;
--c-border-strong: #525252;
--c-text: #171717;
--c-text-secondary: #404040;
--c-text-muted: #525252;
--c-text-subtle: #737373;
--c-text-faint: #a3a3a3;
--c-text-faintest: #d4d4d4;
--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;
--c-accent-2: #7c3aed;
--c-accent-3: #16a34a;
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(210 40% 96%);
--c-surface-1: hsl(210 35% 92%);
--c-surface-2: hsl(210 30% 85%);
--c-border: hsl(210 25% 80%);
--c-border-soft: hsl(210 22% 70%);
--c-border-faint: hsl(210 35% 92%);
--c-border-strong: hsl(210 20% 40%);
--c-text: hsl(210 30% 12%);
--c-text-secondary: hsl(210 20% 25%);
--c-text-muted: hsl(210 15% 40%);
--c-text-subtle: hsl(210 10% 50%);
--c-text-faint: hsl(210 10% 65%);
--c-text-faintest: hsl(210 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(30 70% 40%);
--c-accent-soft: hsl(0 60% 85%);
--c-accent-text: hsl(30 75% 28%);
--c-accent-2: hsl(300 70% 45%);
--c-accent-3: hsl(120 70% 38%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(140 40% 96%);
--c-surface-1: hsl(140 35% 92%);
--c-surface-2: hsl(140 30% 85%);
--c-border: hsl(140 25% 80%);
--c-border-soft: hsl(140 22% 70%);
--c-border-faint: hsl(140 35% 92%);
--c-border-strong: hsl(140 20% 40%);
--c-text: hsl(140 30% 12%);
--c-text-secondary: hsl(140 20% 25%);
--c-text-muted: hsl(140 15% 40%);
--c-text-subtle: hsl(140 10% 50%);
--c-text-faint: hsl(140 10% 65%);
--c-text-faintest: hsl(140 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(320 70% 40%);
--c-accent-soft: hsl(290 60% 85%);
--c-accent-text: hsl(320 75% 28%);
--c-accent-2: hsl(230 70% 45%);
--c-accent-3: hsl(50 70% 38%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(30 40% 96%);
--c-surface-1: hsl(30 35% 92%);
--c-surface-2: hsl(30 30% 85%);
--c-border: hsl(30 25% 80%);
--c-border-soft: hsl(30 22% 70%);
--c-border-faint: hsl(30 35% 92%);
--c-border-strong: hsl(30 20% 40%);
--c-text: hsl(30 30% 12%);
--c-text-secondary: hsl(30 20% 25%);
--c-text-muted: hsl(30 15% 40%);
--c-text-subtle: hsl(30 10% 50%);
--c-text-faint: hsl(30 10% 65%);
--c-text-faintest: hsl(30 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(210 70% 40%);
--c-accent-soft: hsl(180 60% 85%);
--c-accent-text: hsl(210 75% 28%);
--c-accent-2: hsl(120 70% 45%);
--c-accent-3: hsl(300 70% 38%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(330 40% 96%);
--c-surface-1: hsl(330 35% 92%);
--c-surface-2: hsl(330 30% 85%);
--c-border: hsl(330 25% 80%);
--c-border-soft: hsl(330 22% 70%);
--c-border-faint: hsl(330 35% 92%);
--c-border-strong: hsl(330 20% 40%);
--c-text: hsl(330 30% 12%);
--c-text-secondary: hsl(330 20% 25%);
--c-text-muted: hsl(330 15% 40%);
--c-text-subtle: hsl(330 10% 50%);
--c-text-faint: hsl(330 10% 65%);
--c-text-faintest: hsl(330 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(150 70% 40%);
--c-accent-soft: hsl(120 60% 85%);
--c-accent-text: hsl(150 75% 28%);
--c-accent-2: hsl(60 70% 45%);
--c-accent-3: hsl(240 70% 38%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(0 40% 96%);
--c-surface-1: hsl(0 35% 92%);
--c-surface-2: hsl(0 30% 85%);
--c-border: hsl(0 25% 80%);
--c-border-soft: hsl(0 22% 70%);
--c-border-faint: hsl(0 35% 92%);
--c-border-strong: hsl(0 20% 40%);
--c-text: hsl(0 30% 12%);
--c-text-secondary: hsl(0 20% 25%);
--c-text-muted: hsl(0 15% 40%);
--c-text-subtle: hsl(0 10% 50%);
--c-text-faint: hsl(0 10% 65%);
--c-text-faintest: hsl(0 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(180 70% 40%);
--c-accent-soft: hsl(150 60% 85%);
--c-accent-text: hsl(180 75% 28%);
--c-accent-2: hsl(90 70% 45%);
--c-accent-3: hsl(270 70% 38%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(50 40% 96%);
--c-surface-1: hsl(50 35% 92%);
--c-surface-2: hsl(50 30% 85%);
--c-border: hsl(50 25% 80%);
--c-border-soft: hsl(50 22% 70%);
--c-border-faint: hsl(50 35% 92%);
--c-border-strong: hsl(50 20% 40%);
--c-text: hsl(50 30% 12%);
--c-text-secondary: hsl(50 20% 25%);
--c-text-muted: hsl(50 15% 40%);
--c-text-subtle: hsl(50 10% 50%);
--c-text-faint: hsl(50 10% 65%);
--c-text-faintest: hsl(50 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(230 70% 40%);
--c-accent-soft: hsl(200 60% 85%);
--c-accent-text: hsl(230 75% 28%);
--c-accent-2: hsl(140 70% 45%);
--c-accent-3: hsl(320 70% 38%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(30 32% 10%);
--c-surface-1: hsl(30 28% 16%);
--c-surface-2: hsl(30 26% 22%);
--c-border: hsl(30 24% 24%);
--c-border-soft: hsl(30 22% 33%);
--c-border-faint: hsl(30 28% 16%);
--c-border-strong: hsl(30 15% 62%);
--c-text: hsl(30 15% 94%);
--c-text-secondary: hsl(30 12% 82%);
--c-text-muted: hsl(30 10% 65%);
--c-text-subtle: hsl(30 8% 50%);
--c-text-faint: hsl(30 8% 38%);
--c-text-faintest: hsl(30 10% 28%);
--c-danger-bg: #450a0a;
--c-danger-border: #7f1d1d;
--c-danger-text: #f87171;
--c-danger-text-strong: #fca5a5;
--c-accent: hsl(210 70% 62%);
--c-accent-soft: hsl(180 55% 34%);
--c-accent-text: hsl(210 75% 80%);
--c-accent-2: hsl(120 65% 55%);
--c-accent-3: hsl(300 65% 68%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(330 32% 10%);
--c-surface-1: hsl(330 28% 16%);
--c-surface-2: hsl(330 26% 22%);
--c-border: hsl(330 24% 24%);
--c-border-soft: hsl(330 22% 33%);
--c-border-faint: hsl(330 28% 16%);
--c-border-strong: hsl(330 15% 62%);
--c-text: hsl(330 15% 94%);
--c-text-secondary: hsl(330 12% 82%);
--c-text-muted: hsl(330 10% 65%);
--c-text-subtle: hsl(330 8% 50%);
--c-text-faint: hsl(330 8% 38%);
--c-text-faintest: hsl(330 10% 28%);
--c-danger-bg: #450a0a;
--c-danger-border: #7f1d1d;
--c-danger-text: #f87171;
--c-danger-text-strong: #fca5a5;
--c-accent: hsl(150 70% 62%);
--c-accent-soft: hsl(120 55% 34%);
--c-accent-text: hsl(150 75% 80%);
--c-accent-2: hsl(60 65% 58%);
--c-accent-3: hsl(240 65% 68%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(0 32% 10%);
--c-surface-1: hsl(0 28% 16%);
--c-surface-2: hsl(0 26% 22%);
--c-border: hsl(0 24% 24%);
--c-border-soft: hsl(0 22% 33%);
--c-border-faint: hsl(0 28% 16%);
--c-border-strong: hsl(0 15% 62%);
--c-text: hsl(0 15% 94%);
--c-text-secondary: hsl(0 12% 82%);
--c-text-muted: hsl(0 10% 65%);
--c-text-subtle: hsl(0 8% 50%);
--c-text-faint: hsl(0 8% 38%);
--c-text-faintest: hsl(0 10% 28%);
--c-danger-bg: #450a0a;
--c-danger-border: #7f1d1d;
--c-danger-text: #f87171;
--c-danger-text-strong: #fca5a5;
--c-accent: hsl(180 70% 62%);
--c-accent-soft: hsl(150 55% 34%);
--c-accent-text: hsl(180 75% 80%);
--c-accent-2: hsl(90 65% 58%);
--c-accent-3: hsl(270 65% 68%);
}
+28
View File
@@ -0,0 +1,28 @@
:root {
--c-surface-0: hsl(50 32% 10%);
--c-surface-1: hsl(50 28% 16%);
--c-surface-2: hsl(50 26% 22%);
--c-border: hsl(50 24% 24%);
--c-border-soft: hsl(50 22% 33%);
--c-border-faint: hsl(50 28% 16%);
--c-border-strong: hsl(50 15% 62%);
--c-text: hsl(50 15% 94%);
--c-text-secondary: hsl(50 12% 82%);
--c-text-muted: hsl(50 10% 65%);
--c-text-subtle: hsl(50 8% 50%);
--c-text-faint: hsl(50 8% 38%);
--c-text-faintest: hsl(50 10% 28%);
--c-danger-bg: #450a0a;
--c-danger-border: #7f1d1d;
--c-danger-text: #f87171;
--c-danger-text-strong: #fca5a5;
--c-accent: hsl(230 70% 62%);
--c-accent-soft: hsl(200 55% 34%);
--c-accent-text: hsl(230 75% 80%);
--c-accent-2: hsl(140 65% 55%);
--c-accent-3: hsl(320 65% 68%);
}
@@ -0,0 +1,72 @@
# 109 — Stop binding `ottyl mcp` to one session for its whole lifetime
## Summary
`ottyl mcp --session <name>` resolves the session name to a port
once, at process startup, and then serves stdio JSON-RPC for the
life of the process against that fixed URL. In practice this means
every ottyl session an agent wants to talk to needs its own
registered MCP server entry (e.g. `ottyl-hygienic-gyrfalcon`), and
switching which running `ottyl` instance a client is looking at
requires killing that entry and re-registering a new one — which,
for MCP clients that only read server config at startup, means the
whole host process needs restarting before the new tools are even
callable. This got hit directly: an agent needed to switch from an
old dead session (`enormous-warthog`) to a new one
(`hygienic-gyrfalcon`) mid-conversation and had no way to do it
without editing MCP config and waiting for a restart.
## Where
- `src/cli/mod.rs:43-49` — `Command::Mcp { session: Option<String>,
port: Option<u16> }`, the CLI surface.
- `src/cli/mod.rs:171-195` (`run_mcp`) — resolves `session` to a port
via `sessions::resolve_session` exactly once, builds one
`OttylMcp::new(base_url)`, then blocks forever on
`service.waiting()`. There is no re-resolution after startup.
- `src/api/mcp_server.rs:254-282` — `OttylMcp` stores `base_url:
String` as a plain field set once in `new()`; every `#[tool]`
method builds request URLs via `self.url(path)`
(`mcp_server.rs:282-284`), so the fixed base URL is threaded
through every single tool call.
- `src/cli/sessions.rs` — `SessionInfo { name, port, pid }` and
`resolve_session`/`list_sessions`, the registry this would need to
re-query if the bound session can change after startup.
## Direction
Two ways to remove the "pick a session forever at startup" behavior;
pick one during implementation rather than building both:
1. **Request/response CLI, no long-running process.** Add a CLI
subcommand (e.g. `ottyl call <session> <tool> <json-args>`) that
resolves the session, makes the one HTTP call, prints the result,
and exits — no `rmcp`/stdio JSON-RPC server at all. Simplest fix
for the "MCP config never changes, session does" mismatch, but
loses whatever the MCP client gives you over a bare CLI call
(tool discovery/schema, a persistent connection) — check whether
that matters to how this is actually invoked before going this
route.
2. **Long-running MCP server, session switchable at runtime.** Keep
`ottyl mcp` as a stdio server, but stop fixing `base_url` at
construction: either (a) accept a `session` (or `port`) argument
on every tool call, resolved against `sessions::resolve_session`
per-call instead of once in `run_mcp`, with the constructor's
`--session`/`--port` becoming just a default; or (b) add an
explicit `switch_session` tool that mutates the stored target
(`base_url` would need to move from a plain field to something
mutable, e.g. `Mutex<String>` or resolved fresh from the registry
on every call from a stored session name instead of a
pre-resolved URL).
## Scope
- Land one of the two directions above for `ottyl mcp` /
`src/api/mcp_server.rs`; out of scope to build both.
- If going with direction 2, update `SKILL.md`'s "Connect an MCP
client" section, since it currently documents the
resolve-once-at-startup model.
- Out of scope: changing anything about the underlying `/api/...`
HTTP routes themselves, or the session registry format in
`src/cli/sessions.rs` — this is purely about how the MCP-facing
process picks and holds (or doesn't hold) its target session.
@@ -0,0 +1,72 @@
# 110 — Clicking a legend entry should focus that series, fading the rest
## Summary
`PanelChart`'s line and pie charts render a recharts `<Legend>`
(`webapp/src/components/PanelChart.tsx:134`, `:190`) but it's purely
decorative — clicking an entry does nothing. Bar charts have no
`<Legend>` at all (`:151-168`), since each bar is its own labeled
category on the x-axis rather than a separate series.
Add click-to-focus: clicking a legend entry marks that series
"focused"; any focused series stay at full opacity/stroke while every
other series fades. Clicking the same entry again (or clicking empty
space in the legend, per whatever recharts exposes) clears focus and
returns everyone to equal opacity — i.e. "no focused series" means
"all at the same opacity", not "all faded".
## Where
- `webapp/src/components/PanelChart.tsx:116-148` — line chart. Series
are recharts `<Line>` elements keyed by `keys` (from `toLineData`,
`:60-78`), colored via `COLORS[i % COLORS.length]` (`:140`). Fading
a line means varying `strokeOpacity` (or swapping `stroke` for a
`color-mix`'d faded variant) per key based on focus state.
- `webapp/src/components/PanelChart.tsx:170-193` — pie chart. Slices
are `<Cell>` elements (`:179-181`) keyed by row index into `rows`
(from `squashRows`, `:50-57`); fading a slice means varying its
`fillOpacity`.
- `webapp/src/components/PanelChart.tsx:134`, `:190` — the two
`<Legend>` usages. recharts' `<Legend>` takes an `onClick` handler
on its payload entries (`(entry) => void`, where `entry.dataKey` /
`entry.value` identifies which series was clicked) — this is the
hook point rather than building a custom legend from scratch.
- Bar charts (`:151-168`) have no legend to click, since bars aren't
named series here — out of scope for this ticket unless the
direction chosen below turns out to need bar-chart parity (see
Scope).
## Direction
- Add focus state as a `Set<string>` (or single nullable string, if
only one series can be focused at a time — decide during
implementation) of series keys, local to `PanelChart` via
`useState`. Reset it when `series` changes identity to avoid stale
focus surviving a query change onto a chart with different series.
- Wire the `<Legend>`'s `onClick` to toggle membership in that set.
- Compute per-series opacity from the set: full opacity if the set is
empty or the series is a member, faded (e.g. `0.25`) otherwise.
Thread that into `<Line stroke=...>` / `<Cell fill=...>` via
`strokeOpacity`/`fillOpacity` rather than swapping the base color,
so the existing `COLORS` palette and its `color-mix` theming stay
untouched.
- The legend entries themselves should visually reflect focus too
(e.g. dim the faded entries' swatch/text) so it's clear what's
focused without hunting across the plot — recharts' legend
`formatter` prop can restyle each entry's label.
## Scope
- Line and pie charts only, matching where `<Legend>` already
renders. Bar charts stay as-is — no legend, no per-bar fade — unless
investigation shows recharts' bar `<Legend>` support is trivial to
add alongside this; if so, note it as a follow-up rather than
folding scope creep into this ticket.
- Out of scope: persisting focus state across re-renders driven by
polling (`Dashboards.tsx`'s `PanelPreview`, `:46-92`, re-fetches on
every poll tick) — focus should survive a data refresh as long as
the series identity (legend key) is unchanged, but there's no need
to persist it to `localStorage` or across a page reload.
- Out of scope: multi-select "compare exactly these two series" UX
beyond simple toggle-membership — keep it to click-to-add/click-to-
remove from the focused set.
+51
View File
@@ -0,0 +1,51 @@
# 111 — Legend must scroll instead of overflowing the panel
## Summary
`PanelChart`'s recharts `<Legend>` (`webapp/src/components/
PanelChart.tsx:134`, `:190`) has no height constraint or scroll
container. A panel with many series (e.g. a high-cardinality PromQL
query, or the post-`squashRows` `MAX_KEPT_SERIES` = 7 kept rows plus
"Other") wraps the legend across as many lines as it needs, growing
the panel's rendered height and pushing surrounding dashboard layout
around rather than staying within the chart's fixed footprint.
## Where
- `webapp/src/components/PanelChart.tsx:117`, `:154`, `:176` — each
chart type wraps its `<ResponsiveContainer>` at a fixed `height={220}`,
but the `<Legend>` inside it (`:134`, `:190`) isn't given its own
bounded, scrollable region — recharts lays it out in the space left
after the plot area, which can push the container's effective
rendered height past 220 when the legend wraps to multiple rows.
- `webapp/src/components/Dashboards.tsx:46-92` (`PanelPreview`) is the
only caller of `PanelChart` today — worth checking that whatever
fixed-height/scroll wrapper gets added here doesn't fight with
`PanelPreview`'s own loading/error states (`:85-90`), which render
outside `PanelChart` at a different fixed height (`h-48`).
## Direction
- recharts' `<Legend>` supports a `wrapperStyle` prop (already used
for `fontSize`, `:134`/`:190`) — extending it with a fixed
`maxHeight` and `overflowY: 'auto'` is the natural fix and avoids
building a custom legend renderer just for this.
- Verify this doesn't conflict with the click-to-focus work in
[[110-legend-click-to-focus-series]] — that ticket also touches the
`<Legend>` props (`onClick`, `formatter`) on these same two call
sites, so land whichever lands second by rebasing onto the other's
`<Legend>` props rather than both independently restructuring it.
- Decide a concrete max height (e.g. two or three legend rows' worth)
that keeps the panel's total footprint predictable in a dashboard
grid; check `Dashboards.tsx`'s panel grid/card layout for whatever
existing panel height convention should inform the number.
## Scope
- Line and pie charts only, matching where `<Legend>` already
renders — same boundary as [[110-legend-click-to-focus-series]].
- Out of scope: virtualizing the legend list for very large series
counts — `squashRows` (`PanelChart.tsx:50-57`) already caps bar/pie
series at `MAX_KEPT_SERIES` (7) + "Other", and line charts have no
equivalent cap today, but adding one is a separate concern from
making the legend scrollable.
+80
View File
@@ -0,0 +1,80 @@
# 112 — Let a single dashboard panel pop out into its own window
## Summary
There's already a pop-out mechanism for whole primary sections (log,
params, metrics, dashboards) — `PopOutButton` opens a new tab at
`?section=<id>` and `App.tsx` reads that query param at startup to
render a standalone, full-page version of just that section (see
`webapp/src/components/CollapsibleSection.tsx:193-232` and
`webapp/src/App.tsx:129-140`, `:372-376`). That grain is a whole
section, not a single panel — the Dashboards section can only be
popped out in its entirety (all panels of the active dashboard, or
all dashboards stacked via `AllDashboardsView`,
`Dashboards.tsx:107-135`). This ticket adds the finer-grained version:
popping out one panel from one dashboard into its own window,
independent of the rest of that dashboard.
## Where
- `webapp/src/components/Dashboards.tsx:46-92` (`PanelPreview`) is
the component that would need to render standalone, full-page, the
same way `StandaloneLog`/`StandaloneParams`/`StandaloneRanged` do
today for whole sections (referenced in `App.tsx:373-376`, exact
component locations elsewhere in `Dashboards.tsx`/`Metrics.tsx` per
the skeleton read during this ticket's writeup — re-read
`Dashboards.tsx` in full before implementing, since it was read via
repowise's skeleton view here and several ranges (`:1-45`, `:136-
434`) were elided).
- `webapp/src/components/CollapsibleSection.tsx:193-206`
(`PopOutSectionId`, `popOutUrl`) — the existing section-grain
pop-out routing. A panel-grain pop-out needs to identify a specific
`(dashboardId, panelId)` pair in the URL, not just a section name —
either extend this query-param scheme with `dashboardId`/`panelId`
params alongside `section=dashboards`, or give panels their own
distinct query shape if reusing `PopOutSectionId` doesn't fit
cleanly.
- `webapp/src/App.tsx:129-140` (`parsePopOutQuery`), `:372-376` (the
dispatch on parsed `section`) — where the new panel-grain case needs
a branch, reading whatever new params are added above.
- Each `Panel` (`webapp/src/lib/api.ts`'s `Panel` type, imported at
`Dashboards.tsx:19`) needs a stable identifier already, or one to
add, so a popped-out URL can point back at "this exact panel of
this exact dashboard" — check whether `Panel` already carries an
`id` field before inventing a new addressing scheme.
## Direction
- Mirror the existing section pop-out exactly: a small pop-out button
per panel (using or extending `PopOutButton`,
`CollapsibleSection.tsx:209-232`) that opens `?section=dashboards&
dashboardId=<id>&panelId=<id>`, plus range params the same way
`popOutUrl` already carries over a rolling preset or fixed range
(`CollapsibleSection.tsx:196-206`).
- The standalone page for this case renders just that one panel's
`PanelPreview` (or a thin wrapper around it) full-page — no
dashboard chrome, no other panels — analogous to how
`StandaloneRanged` (`App.tsx:179-...`) drives `LogTimeline`'s
picker UI on its own poll cadence for the metrics/dashboards
standalone views.
- The popped-out panel window should keep polling independently
(same `POLL_MS` pattern `PanelPreview` already uses,
`Dashboards.tsx:52-55`) rather than freezing at open time.
## Scope
- One panel popping into a new browser tab/window, matching the
existing section pop-out's mechanism (new tab via `window.open`,
not an in-app floating/detached window widget) — stay consistent
with the pattern already established for log/params/metrics/
dashboards rather than introducing a second, different pop-out
mechanism.
- Out of scope: editing a panel's query/chart type from the popped-out
view — read-only preview only, matching what pop-out gives you for
the other sections today.
- Out of scope: [[110-legend-click-to-focus-series]] and
[[111-scrollable-panel-legend]] — this ticket is purely about the
window/routing mechanism; the popped-out panel should get those
legend behaviors for free once they land in `PanelChart`, since
it's the same component, but implementing them isn't this ticket's
job.
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webapp</title>
<title>ottyl</title>
</head>
<body>
<div id="root"></div>
+99 -119
View File
@@ -3,15 +3,12 @@ import { NavMenu } from './components/NavMenu'
import { FilterBar } from './components/FilterBar'
import {
LogTimeline,
TIME_RANGE_PRESETS,
computeBucketMs,
computePresetRange,
loadLogSortSettings,
saveLogSortSettings,
type LogSortSettings,
type TimeRange,
type TimeRangePresetId,
} from './components/LogTimeline'
import { TIME_RANGE_PRESETS, type TimeRange, type TimeRangePresetId } from './lib/timeRange'
import { LogPreview } from './components/LogPreview'
import { Params } from './components/Params'
import { Metrics } from './components/Metrics'
@@ -29,6 +26,7 @@ import {
type TimelineBucket,
} from './lib/api'
import { DocsNavProvider } from './lib/docsNav'
import { useRangeSelection } from './lib/useRangeSelection'
import { useSettingsPanel } from './lib/useSettingsPanel'
import { POLL_MS } from './lib/polling'
@@ -69,6 +67,23 @@ function saveLogViewerHeight(px: number) {
}
}
// Ticket 074: a sort helper index's one-time full-scan build (`build_sort_helper_index`) runs
// inline in the engine actor, so a `sort_by` poll can occasionally take much longer than the usual
// sub-poll-interval round trip — without this, that stall looks exactly like "sorting is broken"
// rather than "still indexing". Only flips `setBuilding(true)` if the fetch is still in flight after
// `thresholdMs`, so an ordinary fast poll (helper index already built/incrementally maintained)
// never flashes the indicator.
const SORT_BUILDING_INDICATOR_THRESHOLD_MS = 400
function trackSlowSortedFetch<T>(sortBy: string | null, setBuilding: (building: boolean) => void, promise: Promise<T>): Promise<T> {
if (!sortBy) return promise
const timer = setTimeout(() => setBuilding(true), SORT_BUILDING_INDICATOR_THRESHOLD_MS)
return promise.finally(() => {
clearTimeout(timer)
setBuilding(false)
})
}
/// Thin drag handle between the log viewer and the sections below it. Reports each pointer-move
/// delta via `onResize` (letting the caller clamp/apply it) and fires `onResizeEnd` on release so
/// the final size can be persisted once, not on every pixel of movement.
@@ -172,47 +187,18 @@ function StandaloneRanged({
initialFixedRange: TimeRange | null
}) {
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(
initialPresetId ? computePresetRange(initialPresetId) : initialFixedRange,
)
// No preset and no fixed range means the popout was opened with nothing carried over (e.g. a bare URL) — default to 'all', matching FullApp's own default.
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>(
initialPresetId ?? (initialFixedRange ? null : 'all'),
)
const rangeRef = useRef(range)
useEffect(() => {
rangeRef.current = range
}, [range])
function selectPreset(id: TimeRangePresetId) {
setActivePreset(id)
setRange(computePresetRange(id))
}
function handleManualRangeSelect(next: TimeRange | null) {
if (next === null) {
selectPreset('all')
} else {
setActivePreset(null)
setRange(next)
}
}
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
preset: initialPresetId ?? (initialFixedRange ? null : 'all'),
range: initialFixedRange,
})
useEffect(() => {
let cancelled = false
async function poll() {
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time — this is what makes a carried-over preset independently slide rather than track the source tab.
const effectiveRange =
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
if (activePreset && activePreset !== 'all') {
setRange((current) =>
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
? current
: effectiveRange,
)
}
const effectiveRange = resolveEffectiveRange()
try {
const nextBuckets = await fetchTimeline({
startMs: effectiveRange?.startMs,
@@ -254,27 +240,47 @@ function StandaloneRanged({
)
}
/// Ticket 058: only replaces `params` state when the declaration list actually changed, so a param
/// list refetched on every poll tick doesn't force `Params.tsx` to re-render when nothing changed.
function updateParamsIfChanged(
setParams: (updater: (current: ParamDeclaration[]) => ParamDeclaration[]) => void,
next: ParamDeclaration[],
) {
setParams((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next))
}
/// Ticket 066: text shown under the filter bar when the user hasn't typed a manual filter, so an active preset/sort order is still visible somewhere near the filter input instead of leaving it looking empty/stale.
function describeActiveState(activePreset: TimeRangePresetId | null, sortSettings: LogSortSettings): string | null {
const parts: string[] = []
if (activePreset && activePreset !== 'all') {
const preset = TIME_RANGE_PRESETS.find((p) => p.id === activePreset)
parts.push(`last ${preset?.label ?? activePreset}`)
}
if (sortSettings.sortBy) {
parts.push(`sorted by ${sortSettings.sortBy}`)
}
return parts.length > 0 ? parts.join(', ') : null
}
/// The log viewer has its own `LogTimeline`, so it needs its own copy of App's log/timeline polling state instead of a carried-over range.
function StandaloneLog() {
const [logs, setLogs] = useState<LogEntry[]>([])
const [params, setParams] = useState<ParamDeclaration[]>([])
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(null)
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
preset: 'all',
range: null,
})
const [filter, setFilter] = useState('')
const [filterError, setFilterError] = useState<string | null>(null)
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
const [sortIndexBuilding, setSortIndexBuilding] = useState(false)
function handleSortSettingsChange(next: LogSortSettings) {
setSortSettings(next)
saveLogSortSettings(next)
}
const rangeRef = useRef(range)
useEffect(() => {
rangeRef.current = range
}, [range])
useEffect(() => {
fetchParamDeclarations()
.then(setParams)
@@ -283,35 +289,13 @@ function StandaloneLog() {
})
}, [])
function selectPreset(id: TimeRangePresetId) {
setActivePreset(id)
setRange(computePresetRange(id))
}
function handleManualRangeSelect(next: TimeRange | null) {
if (next === null) {
selectPreset('all')
} else {
setActivePreset(null)
setRange(next)
}
}
useEffect(() => {
let cancelled = false
async function poll() {
const effectiveRange =
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
if (activePreset && activePreset !== 'all') {
setRange((current) =>
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
? current
: effectiveRange,
)
}
const effectiveRange = resolveEffectiveRange()
try {
const [nextBuckets, nextLogs] = await Promise.all([
const [nextBuckets, nextLogs, nextParams] = await Promise.all([
fetchTimeline({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
@@ -319,18 +303,25 @@ function StandaloneLog() {
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
filter: filter || undefined,
}),
fetchLogs({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
limit: 1000,
filter: filter || undefined,
sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}),
trackSlowSortedFetch(
sortSettings.sortBy,
setSortIndexBuilding,
fetchLogs({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
limit: 1000,
filter: filter || undefined,
sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}),
),
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
fetchParamDeclarations(),
])
if (!cancelled) {
setBuckets(nextBuckets)
setLogs(nextLogs)
updateParamsIfChanged(setParams, nextParams)
setFilterError(null)
}
} catch (err) {
@@ -352,7 +343,12 @@ function StandaloneLog() {
return (
<StandalonePage>
<div className="flex h-full min-h-0 flex-col">
<FilterBar value={filter} onChange={setFilter} error={filterError} />
<FilterBar
value={filter}
onChange={setFilter}
error={filterError}
activeSummary={describeActiveState(activePreset, sortSettings)}
/>
<div className="flex min-h-0 flex-1 flex-col">
<LogTimeline
buckets={buckets}
@@ -363,6 +359,7 @@ function StandaloneLog() {
paramNames={params.map((p) => p.name)}
sortSettings={sortSettings}
onSortSettingsChange={handleSortSettingsChange}
sortIndexBuilding={sortIndexBuilding}
/>
<LogPreview logs={logs} params={params} />
</div>
@@ -386,13 +383,16 @@ function FullApp() {
const [logs, setLogs] = useState<LogEntry[]>([])
const [params, setParams] = useState<ParamDeclaration[]>([])
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(null)
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
preset: 'all',
range: null,
})
const [filter, setFilter] = useState('')
const [filterError, setFilterError] = useState<string | null>(null)
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
const [sortIndexBuilding, setSortIndexBuilding] = useState(false)
function handleSortSettingsChange(next: LogSortSettings) {
setSortSettings(next)
@@ -410,12 +410,6 @@ function FullApp() {
})
}
// Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick.
const rangeRef = useRef(range)
useEffect(() => {
rangeRef.current = range
}, [range])
async function loadParams() {
try {
setParams(await fetchParamDeclarations())
@@ -428,37 +422,14 @@ function FullApp() {
loadParams()
}, [])
function selectPreset(id: TimeRangePresetId) {
setActivePreset(id)
setRange(computePresetRange(id))
}
// A drag always yields a fixed (non-rolling) range, overriding any active preset.
function handleManualRangeSelect(next: TimeRange | null) {
if (next === null) {
selectPreset('all')
} else {
setActivePreset(null)
setRange(next)
}
}
useEffect(() => {
let cancelled = false
async function poll() {
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time.
const effectiveRange =
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
if (activePreset && activePreset !== 'all') {
setRange((current) =>
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
? current
: effectiveRange,
)
}
const effectiveRange = resolveEffectiveRange()
try {
const [nextStats, nextBuckets, nextLogs] = await Promise.all([
const [nextStats, nextBuckets, nextLogs, nextParams] = await Promise.all([
fetchStats(),
fetchTimeline({
startMs: effectiveRange?.startMs,
@@ -467,19 +438,26 @@ function FullApp() {
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
filter: filter || undefined,
}),
fetchLogs({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
limit: 1000,
filter: filter || undefined,
sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}),
trackSlowSortedFetch(
sortSettings.sortBy,
setSortIndexBuilding,
fetchLogs({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
limit: 1000,
filter: filter || undefined,
sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}),
),
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
fetchParamDeclarations(),
])
if (!cancelled) {
setStats(nextStats)
setBuckets(nextBuckets)
setLogs(nextLogs)
updateParamsIfChanged(setParams, nextParams)
setFilterError(null)
}
} catch (err) {
@@ -508,6 +486,7 @@ function FullApp() {
value={filter}
onChange={setFilter}
error={filterError}
activeSummary={describeActiveState(activePreset, sortSettings)}
collapseToggle={
<>
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
@@ -532,6 +511,7 @@ function FullApp() {
paramNames={params.map((p) => p.name)}
sortSettings={sortSettings}
onSortSettingsChange={handleSortSettingsChange}
sortIndexBuilding={sortIndexBuilding}
/>
<LogPreview logs={logs} params={params} />
</div>
+9 -37
View File
@@ -9,13 +9,7 @@ import {
type AlertRule,
type CompareOp,
} from '../lib/api'
import {
CollapseToggle,
handleSectionHeaderKeyDown,
sectionHeaderInteractiveClassName,
sectionHeaderLeftClassName,
useSectionCollapsed,
} from './CollapsibleSection'
import { FormFooter, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
import { RegexHelpIcon } from './RegexHelpIcon'
import { queryInputClassName } from './FilterBar'
@@ -238,22 +232,7 @@ export function Alerts() {
return (
<div className="border-b border-border text-sm">
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
>
<div className={sectionHeaderLeftClassName}>
Alerts
<HelpIcon sectionId="alerts" label="Help: Alerts" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
</div>
</div>
<SectionHeader title="Alerts" helpSectionId="alerts" collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
<div hidden={collapsed}>
<p className="px-3 pt-3 text-xs text-text-faint">
Every rule delivers to the ntfy sink configured in Settings.
@@ -420,20 +399,13 @@ export function Alerts() {
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
<div className="flex gap-2">
<button
type="submit"
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
{submitting ? 'Saving…' : editingId ? 'Save rule' : 'Create rule'}
</button>
{editingId && (
<button type="button" onClick={startCreate} className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2">
Cancel
</button>
)}
</div>
<FormFooter
editing={editingId !== null}
submitting={submitting}
idleLabel="Create rule"
editingLabel="Save rule"
onCancel={startCreate}
/>
</form>
<div className="min-w-0 flex-1">
+96 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
import { HelpIcon } from './HelpIcon'
/// Shared look for a primary section's title bar: shaded so it reads as a
/// distinct band rather than blending into the section body below it.
@@ -95,6 +96,100 @@ export function handleSectionHeaderKeyDown(onToggle: () => void) {
}
}
/// The header row every settings panel (Params/Metrics/Alerts/Dashboards) opens with: a click/keyboard-toggleable
/// title bar pairing a title + `HelpIcon` on the left with `CollapseToggle` (and, for panels with a standalone
/// popped-out view, a `PopOutButton`) on the right. Assembles `sectionHeaderInteractiveClassName` /
/// `handleSectionHeaderKeyDown` / `sectionHeaderLeftClassName` so callers don't each re-wire the same
/// `role="button"`/`tabIndex`/`aria-expanded` boilerplate by hand.
export function SectionHeader({
title,
helpSectionId,
helpLabel,
collapsed,
onToggle,
popOut,
}: {
title: string
helpSectionId: string
helpLabel?: string
collapsed: boolean
onToggle: () => void
popOut?: React.ReactNode
}) {
return (
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={onToggle}
onKeyDown={handleSectionHeaderKeyDown(onToggle)}
>
<div className={sectionHeaderLeftClassName}>
{title}
<HelpIcon sectionId={helpSectionId} label={helpLabel ?? `Help: ${title}`} />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
{popOut}
</div>
</div>
)
}
/// The declare/edit form footer shared by `Params.tsx`/`Metrics.tsx`/`Alerts.tsx`: a submit button whose label
/// swaps between create/edit and idle/in-flight variants, plus a Cancel button that only appears while editing.
/// Doesn't render the "Editing X" banner some callers show above this — that banner's placement relative to
/// `submitError` differs per caller, so it stays theirs to render; this covers only the part that's identical
/// everywhere. Label text stays a per-caller prop (e.g. "Declare param" vs "Declare metric") rather than
/// hardcoded here, since that's the one part of this footer each panel's copy actually differs on.
export function FormFooter({
editing,
submitting,
idleLabel,
editingLabel,
submittingLabel = 'Saving…',
editingSubmittingLabel,
onCancel,
}: {
editing: boolean
submitting: boolean
idleLabel: string
editingLabel: string
submittingLabel?: string
editingSubmittingLabel?: string
onCancel: () => void
}) {
const label = submitting
? editing
? (editingSubmittingLabel ?? submittingLabel)
: submittingLabel
: editing
? editingLabel
: idleLabel
return (
<div className="flex items-center gap-2">
<button
type="submit"
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
{label}
</button>
{editing && (
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
Cancel
</button>
)}
</div>
)
}
/// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads.
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
+37 -47
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
import { PanelChart } from './PanelChart'
import {
ApiError,
@@ -15,15 +15,7 @@ import {
type PromqlSeries,
} from '../lib/api'
import { POLL_MS } from '../lib/polling'
import {
CollapseToggle,
handleSectionHeaderKeyDown,
PopOutButton,
sectionHeaderInteractiveClassName,
sectionHeaderLeftClassName,
useSectionCollapsed,
} from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
import { PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
@@ -153,6 +145,9 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
const [saveError, setSaveError] = useState<string | null>(null)
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
const lastSavedRef = useRef<string | null>(null)
// Ticket 064: latest `draft`, read inside the autosave timeout's async callback so a save response can tell
// whether the user kept typing after the request went out, instead of unconditionally overwriting `draft`.
const draftRef = useRef<Dashboard | null>(draft)
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
async function loadDashboards(profileName: string, selectAfterLoad?: string) {
@@ -181,11 +176,21 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
draftRef.current = draft
}, [draft])
// Deliberately keyed only on `selectedId`, not `dashboards` — this effect's job is to load a *newly selected*
// dashboard's content into `draft`. Keying it on `dashboards` too meant every autosave's own `setDashboards`
// call (below) re-ran this effect and clobbered whatever the user had typed since the save was kicked off
// (ticket 064). Reading `dashboards` from the closure here still sees the just-loaded array, since
// `setSelectedId`/`setDashboards` from `loadDashboards` land in the same render.
useEffect(() => {
const found = dashboards.find((d) => d.id === selectedId) ?? null
setDraft(found)
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
}, [selectedId, dashboards])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId])
// Skips entirely once the draft matches what's already persisted — covers both re-selecting an unchanged dashboard and this effect's own post-save re-render.
useEffect(() => {
@@ -198,9 +203,14 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
setSaveStatus('saving')
try {
const saved = await saveDashboard(activeProfile, dashboardId, dashboardSaveShape(draft))
lastSavedRef.current = JSON.stringify(dashboardSaveShape(saved))
lastSavedRef.current = snapshot
setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
setDraft(saved)
// Only reconcile `saved` into `draft` if nothing newer was typed while the request was in flight —
// otherwise this stale response would overwrite those in-progress keystrokes (ticket 064).
const current = draftRef.current
if (current && current.id === dashboardId && JSON.stringify(dashboardSaveShape(current)) === snapshot) {
setDraft(saved)
}
setSaveStatus('saved')
setSaveError(null)
} catch (err) {
@@ -264,23 +274,13 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
if (!activeProfile) {
return (
<div className="border-b border-border text-sm">
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
>
<div className={sectionHeaderLeftClassName}>
Dashboards
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="dashboards" range={range} activePreset={activePreset} />
</div>
</div>
<SectionHeader
title="Dashboards"
helpSectionId="dashboards"
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
popOut={<PopOutButton section="dashboards" range={range} activePreset={activePreset} />}
/>
<div className="p-3 text-xs text-text-faint">
Activate a profile (see the settings menu) to create and save dashboards.
</div>
@@ -290,23 +290,13 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
return (
<div className="border-b border-border text-sm">
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
>
<div className={sectionHeaderLeftClassName}>
Dashboards
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="dashboards" range={range} activePreset={activePreset} />
</div>
</div>
<SectionHeader
title="Dashboards"
helpSectionId="dashboards"
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
popOut={<PopOutButton section="dashboards" range={range} activePreset={activePreset} />}
/>
{error && !collapsed && (
<div className="mx-3 mt-3 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
)}
+108 -66
View File
@@ -78,75 +78,108 @@ const SECTIONS: DocSection[] = [
id: 'filtering',
title: 'Filtering',
body: (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<p>
The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
by <Code>and</Code>/<Code>or</Code>. Examples:
by <Code>and</Code>/<Code>or</Code>. The server evaluates the filter string; a parse error shows as inline
text under the filter bar.
</p>
<ul className="list-disc pl-5">
<li>
<Code>{'regex("error")'}</Code> — lines containing "error".
</li>
<li>
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5.
</li>
<li>
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
<Code>duration</Code> param over 100.
</li>
<li>
<Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes.
</li>
<li>
<Code>{'ottyl_fmt==json'}</Code> — lines auto-detected as JSON.
</li>
</ul>
<p>
<Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, and <Code>ottyl_fmt</Code> are reserved metric names present
on every log line, so they're always filterable without declaring anything. <Code>ottyl_fmt</Code> holds the
auto-detected format of the line's text — <Code>log_stream</Code> (macOS <Code>log stream</Code> output),{' '}
<Code>json</Code>, <Code>logfmt</Code>, or <Code>none</Code>. A <Code>log_stream</Code> line also gets{' '}
<Code>log_type</Code>, <Code>process</Code>, and <Code>pid</Code> params derived automatically from its
fixed-column header. All three reserved names also appear (flagged <Code>reserved</Code>) in{' '}
<Code>GET /api/params</Code> for discoverability, though there's no UI to edit or remove them — see also{' '}
<em>Dashboards</em> below for using <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> in a panel query.
</p>
<p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons accept three forms:</p>
<ul className="list-disc pl-5">
<li>
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
</li>
<li>
absolute clock times — <Code>12:00</Code>, <Code>09:30:00</Code>
</li>
<li>
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
</li>
</ul>
<p>
The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.
</p>
<p className="font-semibold text-text">Worked examples</p>
<ul className="list-disc pl-5">
<li>
Only lines mentioning "timeout" in the last 15 minutes:{' '}
<Code>{'regex("timeout") and ottyl_ts>-15m'}</Code>
</li>
<li>
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
<Code>duration</Code> param declared: <Code>{'requests[duration]>200'}</Code>
</li>
<li>
5xx responses on one path, given a <Code>requests</Code> metric with a <Code>path</Code> label param —
note that <Code>[param]</Code> comparisons only work against a <em>declared metric's</em> labels, not a
bare param declared on its own, and values are unquoted (no surrounding <Code>"..."</Code>):{' '}
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code>
</li>
<li>
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
</li>
</ul>
<div>
<p className="font-semibold text-text">Regex match</p>
<ul className="list-disc pl-5">
<li>
<Code>{'regex("error")'}</Code> — lines containing "error". See the <Code>@</Code> icon next to the
filter bar for the exact regex syntax supported (Rust's <Code>regex</Code> crate — no lookaround or
backreferences).
</li>
</ul>
</div>
<div>
<p className="font-semibold text-text">Metric/param comparisons</p>
<ul className="list-disc pl-5">
<li>
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
<Code>duration</Code> param over 100. <Code>[param]</Code> comparisons only work against a{' '}
<em>declared metric's</em> labels, not a bare param declared on its own, and values are unquoted (no
surrounding <Code>"..."</Code>).
</li>
</ul>
</div>
<div>
<p className="font-semibold text-text">Reserved names (always filterable)</p>
<p>
<Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, and <Code>ottyl_fmt</Code> are present on every log line
without declaring anything, and also appear (flagged <Code>reserved</Code>) in{' '}
<Code>GET /api/params</Code> for discoverability, though there's no UI to edit or remove them.
</p>
<ul className="list-disc pl-5">
<li>
<Code>{'ottyl_seq<5'}</Code> — ingest sequence number under 5.
</li>
<li>
<Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes (see Date/time formats below).
</li>
<li>
<Code>{'ottyl_fmt==json'}</Code> — the line's auto-detected text format:{' '}
<Code>log_stream</Code> (macOS <Code>log stream</Code> output — its columns are also auto-declared as
their own named params, e.g. <Code>PID</Code>/<Code>Thread</Code>, from that line's own header), <Code>json</Code>,{' '}
<Code>logfmt</Code>, or <Code>none</Code>.
</li>
</ul>
</div>
<div>
<p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons (e.g. against <Code>ottyl_ts</Code>) accept three forms:</p>
<ul className="list-disc pl-5">
<li>
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
</li>
<li>
absolute clock times — <Code>12:00</Code>, <Code>09:30:00</Code>
</li>
<li>
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
</li>
</ul>
</div>
<div>
<p className="font-semibold text-text">Combining clauses</p>
<ul className="list-disc pl-5">
<li>
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5.
</li>
<li>
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code> — 5xx responses on one path, given a{' '}
<Code>requests</Code> metric with a <Code>path</Code> label param.
</li>
</ul>
</div>
<div>
<p className="font-semibold text-text">Worked examples</p>
<ul className="list-disc pl-5">
<li>
Only lines mentioning "timeout" in the last 15 minutes:{' '}
<Code>{'regex("timeout") and ottyl_ts>-15m'}</Code>
</li>
<li>
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
<Code>duration</Code> param declared: <Code>{'requests[duration]>200'}</Code>
</li>
<li>
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
</li>
</ul>
<p className="mt-1 text-text-subtle">
See also <em>Dashboards</em> below for using <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> in a panel
query.
</p>
</div>
</div>
),
},
@@ -348,6 +381,15 @@ const SECTIONS: DocSection[] = [
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
latest reading.
</li>
<li>
<span className="font-semibold text-text">
<Code>histogram_quantile(p, metric_name)</Code>
</span>{' '}
— the <Code>p</Code>th quantile (<Code>0</Code>–<Code>1</Code>, e.g. <Code>0.95</Code> for p95) of each
label combination's retained values, interpolated over the raw samples rather than pre-aggregated
buckets. Add a bracketed range to narrow to a trailing window instead of the whole active range, e.g.{' '}
<Code>histogram_quantile(0.95, durations[5m])</Code>.
</li>
</ul>
<p className="font-semibold text-text">Worked example</p>
<p>
+20 -4
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { HelpIcon } from './HelpIcon'
import { RegexHelpIcon } from './RegexHelpIcon'
@@ -13,14 +13,22 @@ export function FilterBar({
onChange,
error,
collapseToggle,
activeSummary,
}: {
value: string
onChange: (value: string) => void
error?: string | null
collapseToggle?: ReactNode
/** Ticket 066: a read-only textual summary of the active time-range preset / sort order, shown below the input when the user hasn't typed a manual filter — the filter text itself stays the single source of truth for what's actually sent to the server. */
activeSummary?: string | null
}) {
const [draft, setDraft] = useState(value)
// Resync when `value` changes externally (e.g. a programmatic clear) rather than only reading it once on mount.
useEffect(() => {
setDraft(value)
}, [value])
return (
<form
className="flex flex-col gap-1 border-b border-border px-3 py-2"
@@ -30,12 +38,19 @@ export function FilterBar({
}}
>
<div className="flex items-center gap-2">
<HelpIcon sectionId="filtering" label="Help: Filtering" />
<RegexHelpIcon label="Help: regex(...) syntax" />
<span className="flex items-center gap-1">
<HelpIcon sectionId="filtering" label="Help: filter syntax" />
<span className="text-xs text-text-subtle">Filter syntax</span>
<span className="mx-0.5 h-4 w-px bg-border-soft" aria-hidden="true" />
<RegexHelpIcon label="Help: regex(...) syntax" />
<span className="text-xs text-text-faint" title="Cheat sheet for regex(...) clauses specifically">
regex(...)
</span>
</span>
{collapseToggle}
<input
className={queryInputClassName}
placeholder='regex("error") and ottyl_seq<5 or ottyl_ts>12:00'
placeholder='ottyl_ts>-15m and requests[duration]>200 or regex("error")'
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
@@ -47,6 +62,7 @@ export function FilterBar({
</button>
</div>
{error && <p className="text-xs text-danger-text">{error}</p>}
{!error && !draft && activeSummary && <p className="text-xs text-text-subtle">Active: {activeSummary}</p>}
</form>
)
}
+3 -9
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
import { useClickOutside } from '../lib/useClickOutside'
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
@@ -37,14 +38,7 @@ function ColumnPicker({
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
useClickOutside(ref, () => setOpen(false), open)
return (
<div ref={ref} className="relative">
@@ -119,7 +113,7 @@ function JsonNode({
entries: ReadonlyArray<readonly [string, unknown]>
bracket: [string, string]
}) {
const [expanded, setExpanded] = useState(true)
const [expanded, setExpanded] = useState(false)
if (entries.length === 0) {
return (
+10 -26
View File
@@ -1,34 +1,9 @@
import { useRef, useState } from 'react'
import type { TimelineBucket } from '../lib/api'
import { TIME_RANGE_PRESETS, type TimeRange, type TimeRangePresetId } from '../lib/timeRange'
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
export interface TimeRange {
startMs: number
endMs: number
}
// `'all'` clears the range (the implicit default); the rest resolve to a window ending "now" at the moment they're (re-)computed — see `computePresetRange`.
export type TimeRangePresetId = 'all' | '1m' | '2m' | '5m' | '15m' | '1h' | '24h'
export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [
{ id: 'all', label: 'All', ms: null },
{ id: '1m', label: '1m', ms: 60 * 1000 },
{ id: '2m', label: '2m', ms: 2 * 60 * 1000 },
{ id: '5m', label: '5m', ms: 5 * 60 * 1000 },
{ id: '15m', label: '15m', ms: 15 * 60 * 1000 },
{ id: '1h', label: '1h', ms: 60 * 60 * 1000 },
{ id: '24h', label: '24h', ms: 24 * 60 * 60 * 1000 },
]
// Callers re-invoke this on their existing poll cadence (see `App.tsx`) to keep a rolling preset's window moving with time, rather than freezing it at selection time.
export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
const preset = TIME_RANGE_PRESETS.find((p) => p.id === id)
if (!preset || preset.ms == null) return null
const now = Date.now()
return { startMs: now - preset.ms, endMs: now }
}
/// Buckets a fixed range into this many slots, so e.g. a rolling `5m` preset always requests a resolution fine enough to visibly slide instead of a handful of coarse, jumpy bars.
const TIMELINE_TARGET_BUCKETS = 60
@@ -141,6 +116,7 @@ export function LogTimeline({
paramNames,
sortSettings,
onSortSettingsChange,
sortIndexBuilding,
}: {
buckets: TimelineBucket[]
range: TimeRange | null
@@ -151,6 +127,9 @@ export function LogTimeline({
paramNames?: string[]
sortSettings?: LogSortSettings
onSortSettingsChange?: (next: LogSortSettings) => void
/** Ticket 074: set by the caller when a `sort_by` fetch has been in flight long enough that it's
* likely the engine is running `build_sort_helper_index`'s one-time full scan, not just stalled. */
sortIndexBuilding?: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
const [dragStartX, setDragStartX] = useState<number | null>(null)
@@ -224,6 +203,11 @@ export function LogTimeline({
{sortSettings && onSortSettingsChange && (
<SortPicker paramNames={paramNames ?? []} settings={sortSettings} onChange={onSortSettingsChange} />
)}
{sortIndexBuilding && (
<span className="text-text-muted" title="First sort by this param scans every ingested line once; later polls reuse the built index.">
Building sort index…
</span>
)}
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
{TIME_RANGE_PRESETS.map((preset) => (
+43 -46
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
import {
ApiError,
declareMetric,
@@ -11,17 +11,10 @@ import {
updateMetric,
type BucketValue,
type MetricDeclaration,
type MetricSemantics,
type ParamDeclaration,
} from '../lib/api'
import {
CollapseToggle,
handleSectionHeaderKeyDown,
PopOutButton,
sectionHeaderInteractiveClassName,
sectionHeaderLeftClassName,
useSectionCollapsed,
} from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
import { FormFooter, PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
import { RegexHelpIcon } from './RegexHelpIcon'
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
@@ -90,6 +83,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
// (ticket 037) rather than re-entering a regex/capture-group/kind per param.
const [labelParams, setLabelParams] = useState<string[]>([])
const [valueParam, setValueParam] = useState<string>('')
const [semantics, setSemantics] = useState<MetricSemantics>('gauge')
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
@@ -149,6 +143,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
setRegex('')
setLabelParams([])
setValueParam('')
setSemantics('gauge')
setSubmitError(null)
}
@@ -159,6 +154,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
setRegex(metric.message_match_regex)
setLabelParams(metric.label_params)
setValueParam(metric.value_param ?? '')
setSemantics(metric.semantics ?? 'gauge')
setSubmitError(null)
}
@@ -190,6 +186,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
message_match_regex: regex,
label_params: labelParams,
value_param: valueParam || null,
semantics,
}
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
resetForm()
@@ -207,23 +204,13 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
return (
<div className="border-b border-border text-sm">
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
>
<div className={sectionHeaderLeftClassName}>
Metrics
<HelpIcon sectionId="metrics" label="Help: Metrics" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="metrics" range={range} activePreset={activePreset} />
</div>
</div>
<SectionHeader
title="Metrics"
helpSectionId="metrics"
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
popOut={<PopOutButton section="metrics" range={range} activePreset={activePreset} />}
/>
<div className="flex gap-4 p-3" hidden={collapsed}>
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
<div>
@@ -294,31 +281,37 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
))}
</select>
</div>
<div>
<label
className="mb-1 block text-xs text-text-subtle"
title="Gauge: a point-in-time reading (e.g. queue depth) — rate() over it is rejected. Counter: a monotonically-increasing total (e.g. requests served) — sum()/avg() directly on it is rejected; wrap it in rate() first."
>
Semantics
</label>
<select
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={semantics}
onChange={(e) => setSemantics(e.target.value as MetricSemantics)}
>
<option value="gauge">Gauge (point-in-time reading)</option>
<option value="counter">Counter (monotonically increasing)</option>
</select>
</div>
{editingName && (
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
Editing "{editingName}"
</div>
)}
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
<div className="flex items-center gap-2">
<button
type="submit"
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
{submitting ? (editingName ? 'Saving…' : 'Declaring…') : editingName ? 'Save changes' : 'Declare metric'}
</button>
{editingName && (
<button
type="button"
onClick={resetForm}
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
Cancel
</button>
)}
</div>
<FormFooter
editing={editingName !== null}
submitting={submitting}
idleLabel="Declare metric"
editingLabel="Save changes"
submittingLabel="Declaring…"
editingSubmittingLabel="Saving…"
onCancel={resetForm}
/>
</form>
<div className="min-w-0 flex-1">
@@ -373,6 +366,10 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
<span className="text-text-subtle">Value param: </span>
<span className="font-mono">{selectedMetric.value_param ?? <span className="text-text-faint">none</span>}</span>
</div>
<div>
<span className="text-text-subtle">Semantics: </span>
<span className="font-mono">{selectedMetric.semantics ?? 'gauge'}</span>
</div>
</div>
)}
{groups.length === 0 ? (
+44 -209
View File
@@ -1,11 +1,10 @@
import { useEffect, useState } from 'react'
import type { Stats } from '../lib/api'
import { cssContentUrl, sendTestAlertNotification, type ColorScheme, type CssEntry } from '../lib/api'
import { sendTestAlertNotification } from '../lib/api'
import { useSettingsPanel } from '../lib/useSettingsPanel'
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
import { DocsPanel } from './Docs'
import { HelpIcon } from './HelpIcon'
import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes'
import ottylIcon from '../assets/ottyl-icon.svg'
type PanelName = 'settings' | 'docs' | null
@@ -18,27 +17,19 @@ const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
/** 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',
'red',
'green',
'blue',
'yellow',
'pink',
'orange',
'light_green',
'light_red',
'light_blue',
'light_yellow',
'light_pink',
'light_orange',
]
/** Display label for a theme name: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
function themeLabel(theme: string): string {
return theme.replace(/_/g, ' ')
}
/** Display label for a `ColorScheme`: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
function schemeLabel(scheme: ColorScheme): string {
return scheme.replace(/_/g, ' ')
/** Ticket 073: plain `Xms` under a second (this stat is normally near-zero, so that's the common case), a humantime-style `Xs`/`Xm` above it — mirrors the backend's own humantime-duration inputs (see `Docs.tsx`'s window-parsing note) without pulling in a full duration-formatting library for one stat. */
function formatIngestLag(ms: number): string {
if (ms < 1000) return `${ms}ms`
const totalSeconds = ms / 1000
if (totalSeconds < 60) return `${totalSeconds % 1 === 0 ? totalSeconds : totalSeconds.toFixed(1)}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.round(totalSeconds % 60)
return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`
}
export function NavMenu({ stats }: { stats: Stats | null }) {
@@ -102,6 +93,9 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
</div>
<div>{stats.approx_bytes_total.toLocaleString()} bytes</div>
<div title="Highest ingestion-to-engine-processing lag seen in the last 5s">
lag {formatIngestLag(stats.ingest_lag_high_watermark_ms)}
</div>
</>
) : (
<div>connecting…</div>
@@ -200,10 +194,6 @@ function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel>
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const [maxLogsInput, setMaxLogsInput] = useState('')
const [cssFilename, setCssFilename] = useState('')
const [cssContent, setCssContent] = useState('')
const [cssUploadError, setCssUploadError] = useState<string | null>(null)
const [cssLinkUrl, setCssLinkUrl] = useState('')
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
const [ntfyServerInput, setNtfyServerInput] = useState('')
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
@@ -259,198 +249,43 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
panel.setMaxLogs(Math.floor(parsed))
}
async function handleCssFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setCssFilename(file.name)
setCssContent(await file.text())
setCssUploadError(null)
}
// Every imported/linked stylesheet is always a selectable style, listed alongside the built-in
// colour schemes (ticket 020/047), so it's always validated against the canonical `--c-*`
// variable set before it's sent — that's a floor, not a ceiling: the stylesheet is free to also
// carry rules for any other selector, up to and including a full layout redesign.
async function handleCssImport(e: React.FormEvent) {
e.preventDefault()
setCssUploadError(null)
if (!cssFilename.trim() || !cssContent.trim()) return
const { missing } = parseThemeCss(cssContent)
if (missing.length > 0) {
setCssUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
return
}
await panel.importCssEntry(cssFilename.trim(), cssContent)
setCssFilename('')
setCssContent('')
}
async function handleCssLink(e: React.FormEvent) {
e.preventDefault()
if (!cssLinkUrl.trim()) return
await panel.linkCssEntry(cssLinkUrl.trim())
setCssLinkUrl('')
}
function downloadBuiltinTheme(name: string) {
const vars = BUILTIN_THEMES[name]
if (!vars) return
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
}
async function downloadCssEntry(entry: CssEntry) {
if (entry.kind === 'imported') {
const res = await fetch(cssContentUrl(entry.id))
const text = await res.text()
downloadThemeFile(entry.filename ?? `${entry.id}.css`, text)
} else if (entry.path_or_url) {
window.open(entry.path_or_url, '_blank', 'noopener')
}
}
return (
<div className="flex flex-col gap-5 text-sm text-text-secondary">
<section>
<div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Style</h3>
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Theme</h3>
<span className="text-[11px] text-text-faint">
one list of interchangeable styles — a built-in palette or your own imported/linked stylesheet. Any of
them can be a simple colour swap or a full layout redesign; only one is active at a time.
each theme is a complete stylesheet (colours, spacing, fonts, sizes, ...); only one is active at a time.
To add your own, drop a `.css` file in `themes/` and rebuild.
</span>
</div>
<div className="mb-3 flex flex-wrap gap-1">
{COLOR_SCHEMES.map((scheme) => (
<div key={scheme} className="flex items-center">
<button
type="button"
onClick={() => panel.setColorScheme(scheme)}
className={`rounded-l border px-2 py-1 text-xs capitalize ${
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
{schemeLabel(scheme)}
</button>
{scheme !== 'system' && (
<button
type="button"
onClick={() => downloadBuiltinTheme(scheme)}
className="rounded-r border border-l-0 border-border-soft px-1.5 py-1 text-xs text-text-faint hover:bg-surface-2 hover:text-text"
aria-label={`Download ${schemeLabel(scheme)} theme`}
title="Download as .css"
>
↓
</button>
)}
</div>
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => panel.setTheme('system')}
className={`rounded border px-2 py-1 text-xs capitalize ${
panel.settings?.theme === 'system'
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
System
</button>
{panel.themes.map((theme) => (
<button
key={theme}
type="button"
onClick={() => panel.setTheme(theme)}
className={`rounded border px-2 py-1 text-xs capitalize ${
panel.settings?.theme === theme
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
{themeLabel(theme)}
</button>
))}
</div>
{panel.cssEntries.length === 0 ? (
<div className="mb-2 text-xs text-text-faint">No styles imported or linked yet.</div>
) : (
<ul className="mb-2 flex flex-col gap-1">
{panel.cssEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<div className="min-w-0 flex-1">
<button
type="button"
onClick={() => panel.setCustomTheme(entry.id)}
className={`block w-full truncate text-left capitalize ${
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
}`}
>
{entry.filename ?? entry.path_or_url}
</button>
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => downloadCssEntry(entry)}
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
title="Download as .css"
>
↓
</button>
{entry.kind === 'imported' ? (
<button
type="button"
onClick={() => panel.removeCssEntry(entry.id)}
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${entry.filename}`}
>
delete
</button>
) : (
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
—
</span>
)}
</div>
</li>
))}
</ul>
)}
<p className="mb-2 text-[11px] text-text-faint">
Add your own style below — a stylesheet, uploaded or linked by URL/path. It must at least define every{' '}
<code>--c-*</code> variable the built-ins do, but nothing stops it from also targeting any other selector
in the app (layout, spacing, fonts, anything) — it isn't limited to palette overrides.
</p>
<form onSubmit={handleCssImport} className="flex flex-col gap-2">
<input
type="file"
accept=".css,text/css"
onChange={handleCssFileChange}
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
type="text"
placeholder="enter name"
value={cssFilename}
onChange={(e) => setCssFilename(e.target.value)}
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
placeholder="Paste CSS content here"
value={cssContent}
onChange={(e) => setCssContent(e.target.value)}
rows={4}
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"
/>
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Import
</button>
</form>
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
<input
type="text"
placeholder="https://example.com/style.css"
value={cssLinkUrl}
onChange={(e) => setCssLinkUrl(e.target.value)}
className="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"
/>
<div className="flex items-center gap-2">
<button
type="submit"
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Link
</button>
</div>
</form>
</section>
<section>
+1 -1
View File
@@ -14,7 +14,7 @@ import {
YAxis,
} from 'recharts'
import type { ChartType, PromqlSeries } from '../lib/api'
import type { TimeRange } from './LogTimeline'
import type { TimeRange } from '../lib/timeRange'
/// Renders a label combination as a series key/legend string, e.g. `{"path":"/a"}` -> `path=/a`; no labels renders as `value`.
export function seriesLabel(labels: Record<string, string>): string {
+28 -50
View File
@@ -1,14 +1,6 @@
import { useMemo, useState } from 'react'
import { ApiError, clearParams, declareParam, type ParamDeclaration } from '../lib/api'
import {
CollapseToggle,
handleSectionHeaderKeyDown,
PopOutButton,
sectionHeaderInteractiveClassName,
sectionHeaderLeftClassName,
useSectionCollapsed,
} from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
import { FormFooter, PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
import { RegexHelpIcon } from './RegexHelpIcon'
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
@@ -19,6 +11,16 @@ const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
// sharing the same label set (ticket 038).
type ExtractionSource = 'regex' | 'json_field' | 'elapsed_since_last'
// The parenthesized suffix shown after a declared-param badge's kind, e.g. "(string, group path)" —
// one of five possible forms depending on how the param's value is produced.
function paramBadgeSuffix(p: ParamDeclaration): string {
if (p.elapsed_since_last) return ', elapsed since previous'
if (p.kind === 'boolean') return ''
if (p.json_field_path) return `, json field ${p.json_field_path}`
if (p.capture_group !== p.name) return `, group ${p.capture_group}`
return ''
}
/// A JSON *object* (not an array/scalar) parses cleanly and pretty-prints with braces — the same
/// shape a JSON field path can be read from, so this is the "line looks like JSON" gate that
/// unlocks the "JSON field" extraction option.
@@ -155,23 +157,13 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
return (
<div className="border-b border-border text-sm">
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
>
<div className={sectionHeaderLeftClassName}>
Params
<HelpIcon sectionId="parameters" label="Help: Params" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="params" />
</div>
</div>
<SectionHeader
title="Params"
helpSectionId="parameters"
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
popOut={<PopOutButton section="params" />}
/>
<div className="flex gap-4 p-3" hidden={collapsed}>
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
<div>
@@ -276,25 +268,15 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
</div>
)}
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
<div className="flex items-center gap-2">
<button
type="submit"
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
{submitting ? (editingName ? 'Saving…' : 'Declaring…') : editingName ? 'Save changes' : 'Declare param'}
</button>
{editingName && (
<button
type="button"
onClick={resetForm}
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
Cancel
</button>
)}
</div>
<FormFooter
editing={editingName !== null}
submitting={submitting}
idleLabel="Declare param"
editingLabel="Save changes"
submittingLabel="Declaring…"
editingSubmittingLabel="Saving…"
onCancel={resetForm}
/>
</form>
<div className="min-w-0 flex-1">
@@ -329,11 +311,7 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
title={p.elapsed_since_last ? undefined : p.message_match_regex}
>
{p.name} ({p.kind}
{p.elapsed_since_last
? ', elapsed since previous'
: p.kind === 'boolean'
? ''
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
{paramBadgeSuffix(p)}
)
</button>
))}
+3 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import { useClickOutside } from '../lib/useClickOutside'
// Regex syntax cheat sheet, scoped to what the Rust `regex` crate actually supports (used server-side
// for message-match/field-match regexes and the filter bar's `regex(...)`) — no PCRE-only lookaround
@@ -30,14 +31,7 @@ export function RegexHelpIcon({ label }: { label?: string }) {
const open = pinned || hovering
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!pinned) return
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setPinned(false)
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [pinned])
useClickOutside(ref, () => setPinned(false), pinned)
return (
<div
+9 -411
View File
@@ -12,36 +12,16 @@
* 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.
* instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...).
*
* Derivation: `dark` uses one true black-based neutral (S=0) scale, `light`
* is its exact lightness mirror — these two stay grey throughout. (There
* used to be a separate `grey` theme sharing `dark`'s neutral scale with
* only its accent trio differing; it was a near-duplicate of `dark` and was
* removed in favour of a single `dark` theme — ticket 004. Settings saved
* with `color_scheme: "grey"` still load fine: the backend aliases it to
* `dark`, see `ColorScheme` in `src/settings.rs`.)
* Each named theme (red/green/blue/yellow/pink/orange) instead tints its
* whole surface/border/text scale in its own hue at low saturation (so the
* chrome itself reads as "a shade of red", not grey-with-a-red-light), and
* pulls its accent trio from two *other* hues on the wheel: --c-accent and
* --c-accent-text share the base hue's complement (base+180°), while
* --c-accent-soft sits at a split-complement (~base+150°/-150°) — a
* "composite" accent distinct from the straight complement. To add a new
* themed variant: pick a base hue H, a complement C = H+180, and a
* composite K = H+150 (mod 360), then fill --c-surface-, --c-border-,
* --c-text- as hsl(H, ...) at the saturation/lightness steps below, accent
* + accent-text as hsl(C, ...), and accent-soft as hsl(K, ...).
*
* --c-accent (base look, unchanged above) is joined by two more accent
* colours (ticket 048) so charts/highlights can draw from a 3-colour set
* instead of one hue: --c-accent-2 sits at H+90 and --c-accent-3 at H+270 —
* a quadrature pair straddling the base hue, distinct from both the
* complement (accent) and the split-complement (accent-soft). These are
* purely additive: nothing that already reads --c-accent/-soft/-text
* changes look.
* The actual theme files live in `themes/` at the repo root (embedded into
* the binary at build time — see `THEMES` in `src/api/mod.rs`), one CSS file
* per theme, each free to redeclare `--c-*` under `:root` and/or add any
* other rules (spacing, fonts, layout, ...) it wants. The `:root` block below
* is only the pre-theme-load fallback (identical to `themes/dark.css`) so
* there's no flash of unstyled content before the selected theme's
* stylesheet is injected — see `useSettingsPanel.ts`. To add a new theme,
* drop another file in `themes/` and rebuild; no code changes needed.
*/
:root {
color-scheme: light dark;
@@ -74,388 +54,6 @@
--c-accent-3: #4ade80;
}
/* Exact lightness mirror of :root's neutral-950..50 scale (Tailwind neutral,
steps flipped: 950<->50, 900<->100, 800<->200, 700<->300, 600<->400). */
html.light {
--c-surface-0: #fafafa;
--c-surface-1: #f5f5f5;
--c-surface-2: #e5e5e5;
--c-border: #e5e5e5;
--c-border-soft: #d4d4d4;
--c-border-faint: #f5f5f5;
--c-border-strong: #525252;
--c-text: #171717;
--c-text-secondary: #404040;
--c-text-muted: #525252;
--c-text-subtle: #737373;
--c-text-faint: #a3a3a3;
--c-text-faintest: #d4d4d4;
--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;
--c-accent-2: #7c3aed;
--c-accent-3: #16a34a;
}
/* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180
(cyan), accent-soft composite 150 (spring green) */
html.red {
--c-surface-0: hsl(0 32% 10%);
--c-surface-1: hsl(0 28% 16%);
--c-surface-2: hsl(0 26% 22%);
--c-border: hsl(0 24% 24%);
--c-border-soft: hsl(0 22% 33%);
--c-border-faint: hsl(0 28% 16%);
--c-border-strong: hsl(0 15% 62%);
--c-text: hsl(0 15% 94%);
--c-text-secondary: hsl(0 12% 82%);
--c-text-muted: hsl(0 10% 65%);
--c-text-subtle: hsl(0 8% 50%);
--c-text-faint: hsl(0 8% 38%);
--c-text-faintest: hsl(0 10% 28%);
--c-accent: hsl(180 70% 62%);
--c-accent-soft: hsl(150 55% 34%);
--c-accent-text: hsl(180 75% 80%);
--c-accent-2: hsl(90 65% 58%);
--c-accent-3: hsl(270 65% 68%);
}
/* base hue 140 (green): surfaces/borders/text tinted green; accent complement
320 (magenta), accent-soft composite 290 (purple) */
html.green {
--c-surface-0: hsl(140 32% 10%);
--c-surface-1: hsl(140 28% 16%);
--c-surface-2: hsl(140 26% 22%);
--c-border: hsl(140 24% 24%);
--c-border-soft: hsl(140 22% 33%);
--c-border-faint: hsl(140 28% 16%);
--c-border-strong: hsl(140 15% 62%);
--c-text: hsl(140 15% 94%);
--c-text-secondary: hsl(140 12% 82%);
--c-text-muted: hsl(140 10% 65%);
--c-text-subtle: hsl(140 8% 50%);
--c-text-faint: hsl(140 8% 38%);
--c-text-faintest: hsl(140 10% 28%);
--c-accent: hsl(320 70% 62%);
--c-accent-soft: hsl(290 55% 34%);
--c-accent-text: hsl(320 75% 80%);
--c-accent-2: hsl(230 65% 62%);
--c-accent-3: hsl(50 65% 58%);
}
/* base hue 210 (blue): surfaces/borders/text tinted blue; accent complement
30 (orange), accent-soft composite 0 (red) */
html.blue {
--c-surface-0: hsl(210 32% 10%);
--c-surface-1: hsl(210 28% 16%);
--c-surface-2: hsl(210 26% 22%);
--c-border: hsl(210 24% 24%);
--c-border-soft: hsl(210 22% 33%);
--c-border-faint: hsl(210 28% 16%);
--c-border-strong: hsl(210 15% 62%);
--c-text: hsl(210 15% 94%);
--c-text-secondary: hsl(210 12% 82%);
--c-text-muted: hsl(210 10% 65%);
--c-text-subtle: hsl(210 8% 50%);
--c-text-faint: hsl(210 8% 38%);
--c-text-faintest: hsl(210 10% 28%);
--c-accent: hsl(30 70% 62%);
--c-accent-soft: hsl(0 55% 34%);
--c-accent-text: hsl(30 75% 80%);
--c-accent-2: hsl(300 65% 68%);
--c-accent-3: hsl(120 65% 55%);
}
/* base hue 50 (yellow): surfaces/borders/text tinted yellow; accent
complement 230 (blue), accent-soft composite 200 (azure) */
html.yellow {
--c-surface-0: hsl(50 32% 10%);
--c-surface-1: hsl(50 28% 16%);
--c-surface-2: hsl(50 26% 22%);
--c-border: hsl(50 24% 24%);
--c-border-soft: hsl(50 22% 33%);
--c-border-faint: hsl(50 28% 16%);
--c-border-strong: hsl(50 15% 62%);
--c-text: hsl(50 15% 94%);
--c-text-secondary: hsl(50 12% 82%);
--c-text-muted: hsl(50 10% 65%);
--c-text-subtle: hsl(50 8% 50%);
--c-text-faint: hsl(50 8% 38%);
--c-text-faintest: hsl(50 10% 28%);
--c-accent: hsl(230 70% 62%);
--c-accent-soft: hsl(200 55% 34%);
--c-accent-text: hsl(230 75% 80%);
--c-accent-2: hsl(140 65% 55%);
--c-accent-3: hsl(320 65% 68%);
}
/* base hue 330 (pink): surfaces/borders/text tinted pink; accent complement
150 (spring green), accent-soft composite 120 (green) */
html.pink {
--c-surface-0: hsl(330 32% 10%);
--c-surface-1: hsl(330 28% 16%);
--c-surface-2: hsl(330 26% 22%);
--c-border: hsl(330 24% 24%);
--c-border-soft: hsl(330 22% 33%);
--c-border-faint: hsl(330 28% 16%);
--c-border-strong: hsl(330 15% 62%);
--c-text: hsl(330 15% 94%);
--c-text-secondary: hsl(330 12% 82%);
--c-text-muted: hsl(330 10% 65%);
--c-text-subtle: hsl(330 8% 50%);
--c-text-faint: hsl(330 8% 38%);
--c-text-faintest: hsl(330 10% 28%);
--c-accent: hsl(150 70% 62%);
--c-accent-soft: hsl(120 55% 34%);
--c-accent-text: hsl(150 75% 80%);
--c-accent-2: hsl(60 65% 58%);
--c-accent-3: hsl(240 65% 68%);
}
/* base hue 30 (orange): surfaces/borders/text tinted orange; accent
complement 210 (blue), accent-soft composite 180 (cyan) */
html.orange {
--c-surface-0: hsl(30 32% 10%);
--c-surface-1: hsl(30 28% 16%);
--c-surface-2: hsl(30 26% 22%);
--c-border: hsl(30 24% 24%);
--c-border-soft: hsl(30 22% 33%);
--c-border-faint: hsl(30 28% 16%);
--c-border-strong: hsl(30 15% 62%);
--c-text: hsl(30 15% 94%);
--c-text-secondary: hsl(30 12% 82%);
--c-text-muted: hsl(30 10% 65%);
--c-text-subtle: hsl(30 8% 50%);
--c-text-faint: hsl(30 8% 38%);
--c-text-faintest: hsl(30 10% 28%);
--c-accent: hsl(210 70% 62%);
--c-accent-soft: hsl(180 55% 34%);
--c-accent-text: hsl(210 75% 80%);
--c-accent-2: hsl(120 65% 55%);
--c-accent-3: hsl(300 65% 68%);
}
/* Brighter, light-surfaced counterpart to `green` (ticket 048): same base
hue (140) and accent hues as `green`, but with a `light`-style inverted
surface/border/text scale (high lightness instead of low) so it reads as
a distinctly brighter theme, not a re-tint of the dark `green`. Danger
colours are `light`'s (hue-neutral), matching how `light` itself doesn't
tint danger either. */
html.light_green {
--c-surface-0: hsl(140 40% 96%);
--c-surface-1: hsl(140 35% 92%);
--c-surface-2: hsl(140 30% 85%);
--c-border: hsl(140 25% 80%);
--c-border-soft: hsl(140 22% 70%);
--c-border-faint: hsl(140 35% 92%);
--c-border-strong: hsl(140 20% 40%);
--c-text: hsl(140 30% 12%);
--c-text-secondary: hsl(140 20% 25%);
--c-text-muted: hsl(140 15% 40%);
--c-text-subtle: hsl(140 10% 50%);
--c-text-faint: hsl(140 10% 65%);
--c-text-faintest: hsl(140 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(320 70% 40%);
--c-accent-soft: hsl(290 60% 85%);
--c-accent-text: hsl(320 75% 28%);
--c-accent-2: hsl(230 70% 45%);
--c-accent-3: hsl(50 70% 38%);
}
/* Brighter, light-surfaced counterpart to `red` (ticket 056): same base hue
(0) and accent hues as `red`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_red {
--c-surface-0: hsl(0 40% 96%);
--c-surface-1: hsl(0 35% 92%);
--c-surface-2: hsl(0 30% 85%);
--c-border: hsl(0 25% 80%);
--c-border-soft: hsl(0 22% 70%);
--c-border-faint: hsl(0 35% 92%);
--c-border-strong: hsl(0 20% 40%);
--c-text: hsl(0 30% 12%);
--c-text-secondary: hsl(0 20% 25%);
--c-text-muted: hsl(0 15% 40%);
--c-text-subtle: hsl(0 10% 50%);
--c-text-faint: hsl(0 10% 65%);
--c-text-faintest: hsl(0 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(180 70% 40%);
--c-accent-soft: hsl(150 60% 85%);
--c-accent-text: hsl(180 75% 28%);
--c-accent-2: hsl(90 70% 45%);
--c-accent-3: hsl(270 70% 38%);
}
/* Brighter, light-surfaced counterpart to `blue` (ticket 056): same base hue
(210) and accent hues as `blue`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_blue {
--c-surface-0: hsl(210 40% 96%);
--c-surface-1: hsl(210 35% 92%);
--c-surface-2: hsl(210 30% 85%);
--c-border: hsl(210 25% 80%);
--c-border-soft: hsl(210 22% 70%);
--c-border-faint: hsl(210 35% 92%);
--c-border-strong: hsl(210 20% 40%);
--c-text: hsl(210 30% 12%);
--c-text-secondary: hsl(210 20% 25%);
--c-text-muted: hsl(210 15% 40%);
--c-text-subtle: hsl(210 10% 50%);
--c-text-faint: hsl(210 10% 65%);
--c-text-faintest: hsl(210 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(30 70% 40%);
--c-accent-soft: hsl(0 60% 85%);
--c-accent-text: hsl(30 75% 28%);
--c-accent-2: hsl(300 70% 45%);
--c-accent-3: hsl(120 70% 38%);
}
/* Brighter, light-surfaced counterpart to `yellow` (ticket 056): same base
hue (50) and accent hues as `yellow`, with `light`'s inverted
surface/border/text scale and `light`'s hue-neutral danger colours. */
html.light_yellow {
--c-surface-0: hsl(50 40% 96%);
--c-surface-1: hsl(50 35% 92%);
--c-surface-2: hsl(50 30% 85%);
--c-border: hsl(50 25% 80%);
--c-border-soft: hsl(50 22% 70%);
--c-border-faint: hsl(50 35% 92%);
--c-border-strong: hsl(50 20% 40%);
--c-text: hsl(50 30% 12%);
--c-text-secondary: hsl(50 20% 25%);
--c-text-muted: hsl(50 15% 40%);
--c-text-subtle: hsl(50 10% 50%);
--c-text-faint: hsl(50 10% 65%);
--c-text-faintest: hsl(50 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(230 70% 40%);
--c-accent-soft: hsl(200 60% 85%);
--c-accent-text: hsl(230 75% 28%);
--c-accent-2: hsl(140 70% 45%);
--c-accent-3: hsl(320 70% 38%);
}
/* Brighter, light-surfaced counterpart to `pink` (ticket 056): same base hue
(330) and accent hues as `pink`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_pink {
--c-surface-0: hsl(330 40% 96%);
--c-surface-1: hsl(330 35% 92%);
--c-surface-2: hsl(330 30% 85%);
--c-border: hsl(330 25% 80%);
--c-border-soft: hsl(330 22% 70%);
--c-border-faint: hsl(330 35% 92%);
--c-border-strong: hsl(330 20% 40%);
--c-text: hsl(330 30% 12%);
--c-text-secondary: hsl(330 20% 25%);
--c-text-muted: hsl(330 15% 40%);
--c-text-subtle: hsl(330 10% 50%);
--c-text-faint: hsl(330 10% 65%);
--c-text-faintest: hsl(330 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(150 70% 40%);
--c-accent-soft: hsl(120 60% 85%);
--c-accent-text: hsl(150 75% 28%);
--c-accent-2: hsl(60 70% 45%);
--c-accent-3: hsl(240 70% 38%);
}
/* Brighter, light-surfaced counterpart to `orange` (ticket 056): same base
hue (30) and accent hues as `orange`, with `light`'s inverted
surface/border/text scale and `light`'s hue-neutral danger colours. */
html.light_orange {
--c-surface-0: hsl(30 40% 96%);
--c-surface-1: hsl(30 35% 92%);
--c-surface-2: hsl(30 30% 85%);
--c-border: hsl(30 25% 80%);
--c-border-soft: hsl(30 22% 70%);
--c-border-faint: hsl(30 35% 92%);
--c-border-strong: hsl(30 20% 40%);
--c-text: hsl(30 30% 12%);
--c-text-secondary: hsl(30 20% 25%);
--c-text-muted: hsl(30 15% 40%);
--c-text-subtle: hsl(30 10% 50%);
--c-text-faint: hsl(30 10% 65%);
--c-text-faintest: hsl(30 12% 80%);
--c-danger-bg: #fee2e2;
--c-danger-border: #fca5a5;
--c-danger-text: #b91c1c;
--c-danger-text-strong: #991b1b;
--c-accent: hsl(210 70% 40%);
--c-accent-soft: hsl(180 60% 85%);
--c-accent-text: hsl(210 75% 28%);
--c-accent-2: hsl(120 70% 45%);
--c-accent-3: hsl(300 70% 38%);
}
@theme inline {
--color-surface-0: var(--c-surface-0);
--color-surface-1: var(--c-surface-1);
-469
View File
@@ -1,469 +0,0 @@
// Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds).
export type MetricPrimitive =
| { String: string }
| { Number: { Int: number } | { Float: number } }
| { Timestamp: number }
| { Duration: number }
| { Boolean: boolean }
export function metricPrimitiveToDisplayString(value: MetricPrimitive): string {
if ('String' in value) return value.String
if ('Timestamp' in value) return String(value.Timestamp)
if ('Duration' in value) return `${value.Duration}ms`
if ('Boolean' in value) return String(value.Boolean)
if ('Int' in value.Number) return String(value.Number.Int)
return String(value.Number.Float)
}
export interface LogEntry {
sequence: number
timestamp_ms: number
text: string
// A param with no match on this line is simply absent from the map.
params: Record<string, MetricPrimitive>
}
export interface ParamDeclaration {
name: string
message_match_regex: string
capture_group: string
/** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */
json_field_path?: string | null
kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean'
/** A third extraction source, distinct from `capture_group`/`json_field_path`: when true, this param's value is never parsed from text — it's computed as elapsed time since the previous event sharing the same label set. Only meaningful paired with `kind: 'duration'`. */
elapsed_since_last?: boolean
/** Set only for the built-in `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` names, synthesized into `GET /api/params` for discoverability — they're usable in the filter bar (and `ottyl_seq`/`ottyl_ts` in dashboard PromQL queries) without ever being declared, and can't be edited or removed. */
reserved?: boolean
}
export interface TimelineBucket {
start_ms: number
count: number
}
export interface Stats {
log_count: number
first_sequence: number | null
last_sequence: number | null
first_timestamp_ms: number | null
last_timestamp_ms: number | null
/** Ticket 011: running approximate byte size of all currently-ingested logs. */
approx_bytes_total: number
}
export interface MetricDeclaration {
name: string
message_match_regex: string
/** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */
label_params: string[]
/** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */
value_param?: string | null
/** Fixed labels applied on a match/non-match, on top of any captured params. */
matched_labels?: Record<string, string> | null
unmatched_labels?: Record<string, string> | null
}
export interface BucketValue {
labels: Record<string, MetricPrimitive>
sequence: number
timestamp_ms: number
value: MetricPrimitive
}
// Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend).
export interface ApiErrorBody {
error: string
}
export class ApiError extends Error {
constructor(message: string) {
super(message)
this.name = 'ApiError'
}
}
export type ColorScheme =
| 'light'
| 'dark'
| 'system'
| 'red'
| 'green'
| 'blue'
| 'yellow'
| 'pink'
| 'orange'
| 'light_green'
| 'light_red'
| 'light_blue'
| 'light_yellow'
| 'light_pink'
| 'light_orange'
export interface Settings {
color_scheme: ColorScheme
active_profile: string | null
/** Maximum ingested logs kept in memory; `null` means unlimited. */
max_logs: number | null
/** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */
custom_theme_id: string | null
/** Ntfy topic every alert rule delivers to; `null` until configured. */
ntfy_topic: string | null
/** Ntfy server override; `null` means `https://ntfy.sh`. */
ntfy_server: string | null
}
export type CssEntryKind = 'imported' | 'linked'
export interface CssEntry {
id: string
kind: CssEntryKind
filename: string | null
path_or_url: string | null
}
export interface ProfileSummary {
name: string
param_count: number
metric_count: number
dashboard_count: number
}
// One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp.
export interface PromqlSeries {
labels: Record<string, string>
points: [number, number][]
}
export type ChartType = 'bar' | 'line' | 'pie'
export interface Panel {
id: string
title: string
promql: string
chart_type: ChartType
}
export interface Dashboard {
id: string
name: string
panels: Panel[]
}
export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
// Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend.
export type AlertCondition =
| { kind: 'immediate'; query: string }
| { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number }
| { kind: 'newvalue'; field_regex: string; capture_group: string }
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
export interface AlertRule {
id: string
name: string
enabled: boolean
condition: AlertCondition
}
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const body: unknown = await res.json()
if (body && typeof body === 'object' && 'error' in body && typeof (body as { error: unknown }).error === 'string') {
message = (body as { error: string }).error
}
} catch {
// body wasn't JSON (or had no `error` field) — fall back to the generic message
}
throw new Error(message)
}
return res.json() as Promise<T>
}
// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const errorBody = (await res.json()) as Partial<ApiErrorBody>
if (errorBody && typeof errorBody.error === 'string') {
message = errorBody.error
}
} catch {
// response body wasn't JSON; fall back to the generic message
}
throw new ApiError(message)
}
return res.json() as Promise<T>
}
// Same error handling as `postJson` (throws `ApiError` with the parsed message) but for a PUT — used where updating in place, rather than creating, is the operation.
async function putJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const errorBody = (await res.json()) as Partial<ApiErrorBody>
if (errorBody && typeof errorBody.error === 'string') {
message = errorBody.error
}
} catch {
// response body wasn't JSON; fall back to the generic message
}
throw new ApiError(message)
}
return res.json() as Promise<T>
}
async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const data = await res.json()
if (data?.error) message = data.error
} catch {
// response body wasn't JSON (or was empty) — fall back to the generic message
}
throw new Error(message)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export function fetchLogs(params: {
sinceSequence?: number
startMs?: number
endMs?: number
limit?: number
filter?: string
/** Ticket 045: an already-declared param name to order entries by, instead of the default (`ottyl_ts`-tracking) order. */
sortBy?: string
/** Ticket 045: only meaningful alongside `sortBy`. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them. */
sortMissingFallback?: boolean
}): Promise<LogEntry[]> {
const search = new URLSearchParams()
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
if (params.limit !== undefined) search.set('limit', String(params.limit))
if (params.filter) search.set('filter', params.filter)
if (params.sortBy) search.set('sort_by', params.sortBy)
if (params.sortMissingFallback !== undefined) search.set('sort_missing_fallback', String(params.sortMissingFallback))
return getJson(`/api/logs?${search.toString()}`)
}
export function fetchTimeline(params: {
startMs?: number
endMs?: number
bucketMs?: number
filter?: string
}): Promise<TimelineBucket[]> {
const search = new URLSearchParams()
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
if (params.bucketMs !== undefined) search.set('bucket_ms', String(params.bucketMs))
if (params.filter) search.set('filter', params.filter)
return getJson(`/api/timeline?${search.toString()}`)
}
export function fetchStats(): Promise<Stats> {
return getJson('/api/stats')
}
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
return getJson('/api/params')
}
export function declareParam(request: ParamDeclaration): Promise<ParamDeclaration> {
return postJson('/api/params', request)
}
export interface ClearParamsResult {
/** How many param declarations (manual + auto-derived) were removed. */
removed: number
/** How many params were immediately re-derived from log text still retained by the engine. */
reextracted: number
/** How many retained log lines were re-scanned — lines already evicted by `max_logs` before the clear can't regain their params, so this can be smaller than the total lines ever ingested. */
logs_considered: number
}
// Clears every declared param (ticket 052) and re-derives whatever the engine's still-retained log
// text supports — see `ClearParamsResult`'s fields for what came back.
export function clearParams(): Promise<ClearParamsResult> {
return sendJson('/api/params', 'DELETE')
}
export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
return getJson('/api/metrics')
}
export function declareMetric(request: MetricDeclaration): Promise<MetricDeclaration> {
return postJson('/api/metrics', request)
}
// Updates the metric declaration named `existingName` in place (`existingName` may differ from `request.name` to rename it), rather than declaring a second metric or failing on a duplicate-name conflict.
export function updateMetric(existingName: string, request: MetricDeclaration): Promise<MetricDeclaration> {
return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request)
}
export function deleteMetric(name: string): Promise<void> {
return sendJson(`/api/metrics/${encodeURIComponent(name)}`, 'DELETE')
}
export function fetchMetricBucketValues(
name: string,
params: {
labels?: string
sinceSequence?: number
startMs?: number
endMs?: number
limit?: number
} = {},
): Promise<BucketValue[]> {
const search = new URLSearchParams()
if (params.labels !== undefined) search.set('labels', params.labels)
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
if (params.limit !== undefined) search.set('limit', String(params.limit))
return getJson(`/api/metrics/${encodeURIComponent(name)}?${search.toString()}`)
}
export function fetchSettings(): Promise<Settings> {
return getJson('/api/settings')
}
export function updateSettings(update: {
colorScheme?: ColorScheme
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
maxLogs?: number | null
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
customThemeId?: string | null
/** Omit to leave unchanged; `null` clears the configured topic. */
ntfyTopic?: string | null
/** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */
ntfyServer?: string | null
}): Promise<Settings> {
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
if ('maxLogs' in update) body.max_logs = update.maxLogs
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic
if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer
return sendJson('/api/settings', 'PATCH', body)
}
export function fetchCssEntries(): Promise<CssEntry[]> {
return getJson('/api/settings/css')
}
export function importCss(filename: string, content: string): Promise<CssEntry> {
return sendJson('/api/settings/css', 'POST', { filename, content })
}
export function linkCss(pathOrUrl: string): Promise<CssEntry> {
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl })
}
export function deleteCssEntry(id: string): Promise<void> {
return sendJson(`/api/settings/css/${encodeURIComponent(id)}`, 'DELETE')
}
export function cssContentUrl(id: string): string {
return `/api/settings/css/${encodeURIComponent(id)}/content`
}
export function fetchProfiles(): Promise<ProfileSummary[]> {
return getJson('/api/settings/profiles')
}
export function createProfile(name: string): Promise<ProfileSummary> {
return sendJson('/api/settings/profiles', 'POST', { name })
}
export function deleteProfile(name: string): Promise<void> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
}
export function activateProfile(name: string): Promise<Settings> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
}
// Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page.
export function fetchPromqlSeries(params: {
promql: string
startMs?: number
endMs?: number
}): Promise<PromqlSeries[]> {
const search = new URLSearchParams()
search.set('promql', params.promql)
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
return getJson(`/api/query?${search.toString()}`)
}
export function fetchDashboards(profileName: string): Promise<Dashboard[]> {
return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`)
}
export function createDashboard(profileName: string, name: string): Promise<Dashboard> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name })
}
export function saveDashboard(
profileName: string,
dashboardId: string,
update: { name: string; panels: Panel[] },
): Promise<Dashboard> {
return sendJson(
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
'PUT',
update,
)
}
export function deleteDashboard(profileName: string, dashboardId: string): Promise<void> {
return sendJson(
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
'DELETE',
)
}
export function fetchAlertRules(): Promise<AlertRule[]> {
return getJson('/api/settings/alerts')
}
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise<AlertRule> {
return postJson('/api/settings/alerts', rule)
}
export function updateAlertRule(
id: string,
rule: { name: string; enabled: boolean; condition: AlertCondition },
): Promise<AlertRule> {
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
}
export function deleteAlertRule(id: string): Promise<void> {
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE')
}
// Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline.
export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> {
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
}
+41
View File
@@ -0,0 +1,41 @@
import { getJson, postJson, sendJson } from './shared'
export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
// Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend.
export type AlertCondition =
| { kind: 'immediate'; query: string }
| { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number }
| { kind: 'newvalue'; field_regex: string; capture_group: string }
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
export interface AlertRule {
id: string
name: string
enabled: boolean
condition: AlertCondition
}
export function fetchAlertRules(): Promise<AlertRule[]> {
return getJson('/api/settings/alerts')
}
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise<AlertRule> {
return postJson('/api/settings/alerts', rule)
}
export function updateAlertRule(
id: string,
rule: { name: string; enabled: boolean; condition: AlertCondition },
): Promise<AlertRule> {
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
}
export function deleteAlertRule(id: string): Promise<void> {
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE')
}
// Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline.
export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> {
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
}
+62
View File
@@ -0,0 +1,62 @@
import { getJson, sendJson } from './shared'
// One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp.
export interface PromqlSeries {
labels: Record<string, string>
points: [number, number][]
}
export type ChartType = 'bar' | 'line' | 'pie'
export interface Panel {
id: string
title: string
promql: string
chart_type: ChartType
}
export interface Dashboard {
id: string
name: string
panels: Panel[]
}
// Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page.
export function fetchPromqlSeries(params: {
promql: string
startMs?: number
endMs?: number
}): Promise<PromqlSeries[]> {
const search = new URLSearchParams()
search.set('promql', params.promql)
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
return getJson(`/api/query?${search.toString()}`)
}
export function fetchDashboards(profileName: string): Promise<Dashboard[]> {
return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`)
}
export function createDashboard(profileName: string, name: string): Promise<Dashboard> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name })
}
export function saveDashboard(
profileName: string,
dashboardId: string,
update: { name: string; panels: Panel[] },
): Promise<Dashboard> {
return sendJson(
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
'PUT',
update,
)
}
export function deleteDashboard(profileName: string, dashboardId: string): Promise<void> {
return sendJson(
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
'DELETE',
)
}
+7
View File
@@ -0,0 +1,7 @@
export * from './shared'
export * from './logs'
export * from './params'
export * from './metrics'
export * from './dashboards'
export * from './alerts'
export * from './settings'
+85
View File
@@ -0,0 +1,85 @@
import { buildSearchParams, getJson } from './shared'
// Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds).
export type MetricPrimitive =
| { String: string }
| { Number: { Int: number } | { Float: number } }
| { Timestamp: number }
| { Duration: number }
| { Boolean: boolean }
export function metricPrimitiveToDisplayString(value: MetricPrimitive): string {
if ('String' in value) return value.String
if ('Timestamp' in value) return String(value.Timestamp)
if ('Duration' in value) return `${value.Duration}ms`
if ('Boolean' in value) return String(value.Boolean)
if ('Int' in value.Number) return String(value.Number.Int)
return String(value.Number.Float)
}
export interface LogEntry {
sequence: number
timestamp_ms: number
text: string
// A param with no match on this line is simply absent from the map.
params: Record<string, MetricPrimitive>
}
export interface TimelineBucket {
start_ms: number
count: number
}
export interface Stats {
log_count: number
first_sequence: number | null
last_sequence: number | null
first_timestamp_ms: number | null
last_timestamp_ms: number | null
/** Ticket 011: running approximate byte size of all currently-ingested logs. */
approx_bytes_total: number
/** Ticket 073: highest ingestion-to-engine-processing lag (ms) seen within the trailing 5s. */
ingest_lag_high_watermark_ms: number
}
export function fetchLogs(params: {
sinceSequence?: number
startMs?: number
endMs?: number
limit?: number
filter?: string
/** Ticket 045: an already-declared param name to order entries by, instead of the default (`ottyl_ts`-tracking) order. */
sortBy?: string
/** Ticket 045: only meaningful alongside `sortBy`. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them. */
sortMissingFallback?: boolean
}): Promise<LogEntry[]> {
const search = buildSearchParams({
since_sequence: params.sinceSequence,
start_ms: params.startMs,
end_ms: params.endMs,
limit: params.limit,
filter: params.filter,
sort_by: params.sortBy,
sort_missing_fallback: params.sortMissingFallback,
})
return getJson(`/api/logs?${search.toString()}`)
}
export function fetchTimeline(params: {
startMs?: number
endMs?: number
bucketMs?: number
filter?: string
}): Promise<TimelineBucket[]> {
const search = buildSearchParams({
start_ms: params.startMs,
end_ms: params.endMs,
bucket_ms: params.bucketMs,
filter: params.filter,
})
return getJson(`/api/timeline?${search.toString()}`)
}
export function fetchStats(): Promise<Stats> {
return getJson('/api/stats')
}
+61
View File
@@ -0,0 +1,61 @@
import { getJson, postJson, putJson, sendJson } from './shared'
import type { MetricPrimitive } from './logs'
export type MetricSemantics = 'gauge' | 'counter'
export interface MetricDeclaration {
name: string
message_match_regex: string
/** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */
label_params: string[]
/** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */
value_param?: string | null
/** Fixed labels applied on a match/non-match, on top of any captured params. */
matched_labels?: Record<string, string> | null
unmatched_labels?: Record<string, string> | null
/** Point-in-time reading ('gauge', e.g. queue depth) vs. monotonically-increasing total ('counter', e.g. requests served); guards which PromQL aggregations are valid. Defaults to 'gauge'. */
semantics?: MetricSemantics
}
export interface BucketValue {
labels: Record<string, MetricPrimitive>
sequence: number
timestamp_ms: number
value: MetricPrimitive
}
export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
return getJson('/api/metrics')
}
export function declareMetric(request: MetricDeclaration): Promise<MetricDeclaration> {
return postJson('/api/metrics', request)
}
// Updates the metric declaration named `existingName` in place (`existingName` may differ from `request.name` to rename it), rather than declaring a second metric or failing on a duplicate-name conflict.
export function updateMetric(existingName: string, request: MetricDeclaration): Promise<MetricDeclaration> {
return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request)
}
export function deleteMetric(name: string): Promise<void> {
return sendJson(`/api/metrics/${encodeURIComponent(name)}`, 'DELETE')
}
export function fetchMetricBucketValues(
name: string,
params: {
labels?: string
sinceSequence?: number
startMs?: number
endMs?: number
limit?: number
} = {},
): Promise<BucketValue[]> {
const search = new URLSearchParams()
if (params.labels !== undefined) search.set('labels', params.labels)
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
if (params.limit !== undefined) search.set('limit', String(params.limit))
return getJson(`/api/metrics/${encodeURIComponent(name)}?${search.toString()}`)
}
+37
View File
@@ -0,0 +1,37 @@
import { getJson, postJson, sendJson } from './shared'
export interface ParamDeclaration {
name: string
message_match_regex: string
capture_group: string
/** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */
json_field_path?: string | null
kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean'
/** A third extraction source, distinct from `capture_group`/`json_field_path`: when true, this param's value is never parsed from text — it's computed as elapsed time since the previous event sharing the same label set. Only meaningful paired with `kind: 'duration'`. */
elapsed_since_last?: boolean
/** Set only for the built-in `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` names, synthesized into `GET /api/params` for discoverability — they're usable in the filter bar (and `ottyl_seq`/`ottyl_ts` in dashboard PromQL queries) without ever being declared, and can't be edited or removed. */
reserved?: boolean
}
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
return getJson('/api/params')
}
export function declareParam(request: ParamDeclaration): Promise<ParamDeclaration> {
return postJson('/api/params', request)
}
export interface ClearParamsResult {
/** How many param declarations (manual + auto-derived) were removed. */
removed: number
/** How many params were immediately re-derived from log text still retained by the engine. */
reextracted: number
/** How many retained log lines were re-scanned — lines already evicted by `max_logs` before the clear can't regain their params, so this can be smaller than the total lines ever ingested. */
logs_considered: number
}
// Clears every declared param (ticket 052) and re-derives whatever the engine's still-retained log
// text supports — see `ClearParamsResult`'s fields for what came back.
export function clearParams(): Promise<ClearParamsResult> {
return sendJson('/api/params', 'DELETE')
}
+67
View File
@@ -0,0 +1,67 @@
import { getJson, sendJson } from './shared'
/** Name of a theme embedded in this build (see `GET /api/themes`), or `"system"` to follow the OS light/dark preference. */
export type Theme = string
export interface Settings {
theme: Theme
active_profile: string | null
/** Maximum ingested logs kept in memory; `null` means unlimited. */
max_logs: number | null
/** Ntfy topic every alert rule delivers to; `null` until configured. */
ntfy_topic: string | null
/** Ntfy server override; `null` means `https://ntfy.sh`. */
ntfy_server: string | null
}
export interface ProfileSummary {
name: string
param_count: number
metric_count: number
dashboard_count: number
}
export function fetchSettings(): Promise<Settings> {
return getJson('/api/settings')
}
export function updateSettings(update: {
theme?: Theme
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
maxLogs?: number | null
/** Omit to leave unchanged; `null` clears the configured topic. */
ntfyTopic?: string | null
/** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */
ntfyServer?: string | null
}): Promise<Settings> {
const body: Record<string, unknown> = { theme: update.theme }
if ('maxLogs' in update) body.max_logs = update.maxLogs
if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic
if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer
return sendJson('/api/settings', 'PATCH', body)
}
/** Names of the themes embedded in this build (see `themes/` at the repo root). */
export function fetchThemes(): Promise<string[]> {
return getJson('/api/themes')
}
export function themeCssUrl(name: string): string {
return `/api/themes/${encodeURIComponent(name)}`
}
export function fetchProfiles(): Promise<ProfileSummary[]> {
return getJson('/api/settings/profiles')
}
export function createProfile(name: string): Promise<ProfileSummary> {
return sendJson('/api/settings/profiles', 'POST', { name })
}
export function deleteProfile(name: string): Promise<void> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
}
export function activateProfile(name: string): Promise<Settings> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
}
+103
View File
@@ -0,0 +1,103 @@
// Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend).
export interface ApiErrorBody {
error: string
}
export class ApiError extends Error {
constructor(message: string) {
super(message)
this.name = 'ApiError'
}
}
// Builds a `URLSearchParams` from an object of optional query fields, skipping any that are
// `undefined` (falsy strings like `''` are also skipped, matching the hand-written `if` checks
// this replaces).
export function buildSearchParams(fields: Record<string, string | number | boolean | undefined>): URLSearchParams {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined && value !== '') search.set(key, String(value))
}
return search
}
export async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const body: unknown = await res.json()
if (body && typeof body === 'object' && 'error' in body && typeof (body as { error: unknown }).error === 'string') {
message = (body as { error: string }).error
}
} catch {
// body wasn't JSON (or had no `error` field) — fall back to the generic message
}
throw new Error(message)
}
return res.json() as Promise<T>
}
// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
export async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const errorBody = (await res.json()) as Partial<ApiErrorBody>
if (errorBody && typeof errorBody.error === 'string') {
message = errorBody.error
}
} catch {
// response body wasn't JSON; fall back to the generic message
}
throw new ApiError(message)
}
return res.json() as Promise<T>
}
// Same error handling as `postJson` (throws `ApiError` with the parsed message) but for a PUT — used where updating in place, rather than creating, is the operation.
export async function putJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const errorBody = (await res.json()) as Partial<ApiErrorBody>
if (errorBody && typeof errorBody.error === 'string') {
message = errorBody.error
}
} catch {
// response body wasn't JSON; fall back to the generic message
}
throw new ApiError(message)
}
return res.json() as Promise<T>
}
export async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const data = await res.json()
if (data?.error) message = data.error
} catch {
// response body wasn't JSON (or was empty) — fall back to the generic message
}
throw new Error(message)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
-412
View File
@@ -1,412 +0,0 @@
// A user-supplied style is any CSS file that defines every variable in `THEME_CSS_VARS` somewhere in
// a `:root { --c-*: ...; }` block (ticket 020/047: styles are selectable on equal footing with the
// built-ins, and there's no requirement that this block be the *only* thing the file contains — a
// style is free to also target any other selector, e.g. to restyle layout, spacing, or fonts, not
// just the palette). `useSettingsPanel` applies the selected one as an unscoped stylesheet only
// while no built-in `html.<scheme>` class is present, so it wins the cascade without rewriting the
// selector.
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
export const THEME_CSS_VARS = [
'--c-surface-0',
'--c-surface-1',
'--c-surface-2',
'--c-border',
'--c-border-soft',
'--c-border-faint',
'--c-border-strong',
'--c-text',
'--c-text-secondary',
'--c-text-muted',
'--c-text-subtle',
'--c-text-faint',
'--c-text-faintest',
'--c-danger-bg',
'--c-danger-border',
'--c-danger-text',
'--c-danger-text-strong',
'--c-accent',
'--c-accent-soft',
'--c-accent-text',
'--c-accent-2',
'--c-accent-3',
] as const
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
// Fully-resolved variable values for each built-in theme, including inherited fallbacks, so a downloaded file is always complete; keep in sync with `index.css` by hand, there's no build step linking the two.
export const BUILTIN_THEMES: Record<string, ThemeVars> = {
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',
'--c-accent-2': '#a78bfa',
'--c-accent-3': '#4ade80',
},
light: {
'--c-surface-0': '#fafafa',
'--c-surface-1': '#f5f5f5',
'--c-surface-2': '#e5e5e5',
'--c-border': '#e5e5e5',
'--c-border-soft': '#d4d4d4',
'--c-border-faint': '#f5f5f5',
'--c-border-strong': '#525252',
'--c-text': '#171717',
'--c-text-secondary': '#404040',
'--c-text-muted': '#525252',
'--c-text-subtle': '#737373',
'--c-text-faint': '#a3a3a3',
'--c-text-faintest': '#d4d4d4',
'--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',
'--c-accent-2': '#7c3aed',
'--c-accent-3': '#16a34a',
},
red: {
'--c-surface-0': 'hsl(0 32% 10%)',
'--c-surface-1': 'hsl(0 28% 16%)',
'--c-surface-2': 'hsl(0 26% 22%)',
'--c-border': 'hsl(0 24% 24%)',
'--c-border-soft': 'hsl(0 22% 33%)',
'--c-border-faint': 'hsl(0 28% 16%)',
'--c-border-strong': 'hsl(0 15% 62%)',
'--c-text': 'hsl(0 15% 94%)',
'--c-text-secondary': 'hsl(0 12% 82%)',
'--c-text-muted': 'hsl(0 10% 65%)',
'--c-text-subtle': 'hsl(0 8% 50%)',
'--c-text-faint': 'hsl(0 8% 38%)',
'--c-text-faintest': 'hsl(0 10% 28%)',
'--c-danger-bg': '#450a0a',
'--c-danger-border': '#7f1d1d',
'--c-danger-text': '#f87171',
'--c-danger-text-strong': '#fca5a5',
'--c-accent': 'hsl(180 70% 62%)',
'--c-accent-soft': 'hsl(150 55% 34%)',
'--c-accent-text': 'hsl(180 75% 80%)',
'--c-accent-2': 'hsl(90 65% 58%)',
'--c-accent-3': 'hsl(270 65% 68%)',
},
green: {
'--c-surface-0': 'hsl(140 32% 10%)',
'--c-surface-1': 'hsl(140 28% 16%)',
'--c-surface-2': 'hsl(140 26% 22%)',
'--c-border': 'hsl(140 24% 24%)',
'--c-border-soft': 'hsl(140 22% 33%)',
'--c-border-faint': 'hsl(140 28% 16%)',
'--c-border-strong': 'hsl(140 15% 62%)',
'--c-text': 'hsl(140 15% 94%)',
'--c-text-secondary': 'hsl(140 12% 82%)',
'--c-text-muted': 'hsl(140 10% 65%)',
'--c-text-subtle': 'hsl(140 8% 50%)',
'--c-text-faint': 'hsl(140 8% 38%)',
'--c-text-faintest': 'hsl(140 10% 28%)',
'--c-danger-bg': '#450a0a',
'--c-danger-border': '#7f1d1d',
'--c-danger-text': '#f87171',
'--c-danger-text-strong': '#fca5a5',
'--c-accent': 'hsl(320 70% 62%)',
'--c-accent-soft': 'hsl(290 55% 34%)',
'--c-accent-text': 'hsl(320 75% 80%)',
'--c-accent-2': 'hsl(230 65% 62%)',
'--c-accent-3': 'hsl(50 65% 58%)',
},
blue: {
'--c-surface-0': 'hsl(210 32% 10%)',
'--c-surface-1': 'hsl(210 28% 16%)',
'--c-surface-2': 'hsl(210 26% 22%)',
'--c-border': 'hsl(210 24% 24%)',
'--c-border-soft': 'hsl(210 22% 33%)',
'--c-border-faint': 'hsl(210 28% 16%)',
'--c-border-strong': 'hsl(210 15% 62%)',
'--c-text': 'hsl(210 15% 94%)',
'--c-text-secondary': 'hsl(210 12% 82%)',
'--c-text-muted': 'hsl(210 10% 65%)',
'--c-text-subtle': 'hsl(210 8% 50%)',
'--c-text-faint': 'hsl(210 8% 38%)',
'--c-text-faintest': 'hsl(210 10% 28%)',
'--c-danger-bg': '#450a0a',
'--c-danger-border': '#7f1d1d',
'--c-danger-text': '#f87171',
'--c-danger-text-strong': '#fca5a5',
'--c-accent': 'hsl(30 70% 62%)',
'--c-accent-soft': 'hsl(0 55% 34%)',
'--c-accent-text': 'hsl(30 75% 80%)',
'--c-accent-2': 'hsl(300 65% 68%)',
'--c-accent-3': 'hsl(120 65% 55%)',
},
yellow: {
'--c-surface-0': 'hsl(50 32% 10%)',
'--c-surface-1': 'hsl(50 28% 16%)',
'--c-surface-2': 'hsl(50 26% 22%)',
'--c-border': 'hsl(50 24% 24%)',
'--c-border-soft': 'hsl(50 22% 33%)',
'--c-border-faint': 'hsl(50 28% 16%)',
'--c-border-strong': 'hsl(50 15% 62%)',
'--c-text': 'hsl(50 15% 94%)',
'--c-text-secondary': 'hsl(50 12% 82%)',
'--c-text-muted': 'hsl(50 10% 65%)',
'--c-text-subtle': 'hsl(50 8% 50%)',
'--c-text-faint': 'hsl(50 8% 38%)',
'--c-text-faintest': 'hsl(50 10% 28%)',
'--c-danger-bg': '#450a0a',
'--c-danger-border': '#7f1d1d',
'--c-danger-text': '#f87171',
'--c-danger-text-strong': '#fca5a5',
'--c-accent': 'hsl(230 70% 62%)',
'--c-accent-soft': 'hsl(200 55% 34%)',
'--c-accent-text': 'hsl(230 75% 80%)',
'--c-accent-2': 'hsl(140 65% 55%)',
'--c-accent-3': 'hsl(320 65% 68%)',
},
pink: {
'--c-surface-0': 'hsl(330 32% 10%)',
'--c-surface-1': 'hsl(330 28% 16%)',
'--c-surface-2': 'hsl(330 26% 22%)',
'--c-border': 'hsl(330 24% 24%)',
'--c-border-soft': 'hsl(330 22% 33%)',
'--c-border-faint': 'hsl(330 28% 16%)',
'--c-border-strong': 'hsl(330 15% 62%)',
'--c-text': 'hsl(330 15% 94%)',
'--c-text-secondary': 'hsl(330 12% 82%)',
'--c-text-muted': 'hsl(330 10% 65%)',
'--c-text-subtle': 'hsl(330 8% 50%)',
'--c-text-faint': 'hsl(330 8% 38%)',
'--c-text-faintest': 'hsl(330 10% 28%)',
'--c-danger-bg': '#450a0a',
'--c-danger-border': '#7f1d1d',
'--c-danger-text': '#f87171',
'--c-danger-text-strong': '#fca5a5',
'--c-accent': 'hsl(150 70% 62%)',
'--c-accent-soft': 'hsl(120 55% 34%)',
'--c-accent-text': 'hsl(150 75% 80%)',
'--c-accent-2': 'hsl(60 65% 58%)',
'--c-accent-3': 'hsl(240 65% 68%)',
},
orange: {
'--c-surface-0': 'hsl(30 32% 10%)',
'--c-surface-1': 'hsl(30 28% 16%)',
'--c-surface-2': 'hsl(30 26% 22%)',
'--c-border': 'hsl(30 24% 24%)',
'--c-border-soft': 'hsl(30 22% 33%)',
'--c-border-faint': 'hsl(30 28% 16%)',
'--c-border-strong': 'hsl(30 15% 62%)',
'--c-text': 'hsl(30 15% 94%)',
'--c-text-secondary': 'hsl(30 12% 82%)',
'--c-text-muted': 'hsl(30 10% 65%)',
'--c-text-subtle': 'hsl(30 8% 50%)',
'--c-text-faint': 'hsl(30 8% 38%)',
'--c-text-faintest': 'hsl(30 10% 28%)',
'--c-danger-bg': '#450a0a',
'--c-danger-border': '#7f1d1d',
'--c-danger-text': '#f87171',
'--c-danger-text-strong': '#fca5a5',
'--c-accent': 'hsl(210 70% 62%)',
'--c-accent-soft': 'hsl(180 55% 34%)',
'--c-accent-text': 'hsl(210 75% 80%)',
'--c-accent-2': 'hsl(120 65% 55%)',
'--c-accent-3': 'hsl(300 65% 68%)',
},
light_green: {
'--c-surface-0': 'hsl(140 40% 96%)',
'--c-surface-1': 'hsl(140 35% 92%)',
'--c-surface-2': 'hsl(140 30% 85%)',
'--c-border': 'hsl(140 25% 80%)',
'--c-border-soft': 'hsl(140 22% 70%)',
'--c-border-faint': 'hsl(140 35% 92%)',
'--c-border-strong': 'hsl(140 20% 40%)',
'--c-text': 'hsl(140 30% 12%)',
'--c-text-secondary': 'hsl(140 20% 25%)',
'--c-text-muted': 'hsl(140 15% 40%)',
'--c-text-subtle': 'hsl(140 10% 50%)',
'--c-text-faint': 'hsl(140 10% 65%)',
'--c-text-faintest': 'hsl(140 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(320 70% 40%)',
'--c-accent-soft': 'hsl(290 60% 85%)',
'--c-accent-text': 'hsl(320 75% 28%)',
'--c-accent-2': 'hsl(230 70% 45%)',
'--c-accent-3': 'hsl(50 70% 38%)',
},
light_red: {
'--c-surface-0': 'hsl(0 40% 96%)',
'--c-surface-1': 'hsl(0 35% 92%)',
'--c-surface-2': 'hsl(0 30% 85%)',
'--c-border': 'hsl(0 25% 80%)',
'--c-border-soft': 'hsl(0 22% 70%)',
'--c-border-faint': 'hsl(0 35% 92%)',
'--c-border-strong': 'hsl(0 20% 40%)',
'--c-text': 'hsl(0 30% 12%)',
'--c-text-secondary': 'hsl(0 20% 25%)',
'--c-text-muted': 'hsl(0 15% 40%)',
'--c-text-subtle': 'hsl(0 10% 50%)',
'--c-text-faint': 'hsl(0 10% 65%)',
'--c-text-faintest': 'hsl(0 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(180 70% 40%)',
'--c-accent-soft': 'hsl(150 60% 85%)',
'--c-accent-text': 'hsl(180 75% 28%)',
'--c-accent-2': 'hsl(90 70% 45%)',
'--c-accent-3': 'hsl(270 70% 38%)',
},
light_blue: {
'--c-surface-0': 'hsl(210 40% 96%)',
'--c-surface-1': 'hsl(210 35% 92%)',
'--c-surface-2': 'hsl(210 30% 85%)',
'--c-border': 'hsl(210 25% 80%)',
'--c-border-soft': 'hsl(210 22% 70%)',
'--c-border-faint': 'hsl(210 35% 92%)',
'--c-border-strong': 'hsl(210 20% 40%)',
'--c-text': 'hsl(210 30% 12%)',
'--c-text-secondary': 'hsl(210 20% 25%)',
'--c-text-muted': 'hsl(210 15% 40%)',
'--c-text-subtle': 'hsl(210 10% 50%)',
'--c-text-faint': 'hsl(210 10% 65%)',
'--c-text-faintest': 'hsl(210 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(30 70% 40%)',
'--c-accent-soft': 'hsl(0 60% 85%)',
'--c-accent-text': 'hsl(30 75% 28%)',
'--c-accent-2': 'hsl(300 70% 45%)',
'--c-accent-3': 'hsl(120 70% 38%)',
},
light_yellow: {
'--c-surface-0': 'hsl(50 40% 96%)',
'--c-surface-1': 'hsl(50 35% 92%)',
'--c-surface-2': 'hsl(50 30% 85%)',
'--c-border': 'hsl(50 25% 80%)',
'--c-border-soft': 'hsl(50 22% 70%)',
'--c-border-faint': 'hsl(50 35% 92%)',
'--c-border-strong': 'hsl(50 20% 40%)',
'--c-text': 'hsl(50 30% 12%)',
'--c-text-secondary': 'hsl(50 20% 25%)',
'--c-text-muted': 'hsl(50 15% 40%)',
'--c-text-subtle': 'hsl(50 10% 50%)',
'--c-text-faint': 'hsl(50 10% 65%)',
'--c-text-faintest': 'hsl(50 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(230 70% 40%)',
'--c-accent-soft': 'hsl(200 60% 85%)',
'--c-accent-text': 'hsl(230 75% 28%)',
'--c-accent-2': 'hsl(140 70% 45%)',
'--c-accent-3': 'hsl(320 70% 38%)',
},
light_pink: {
'--c-surface-0': 'hsl(330 40% 96%)',
'--c-surface-1': 'hsl(330 35% 92%)',
'--c-surface-2': 'hsl(330 30% 85%)',
'--c-border': 'hsl(330 25% 80%)',
'--c-border-soft': 'hsl(330 22% 70%)',
'--c-border-faint': 'hsl(330 35% 92%)',
'--c-border-strong': 'hsl(330 20% 40%)',
'--c-text': 'hsl(330 30% 12%)',
'--c-text-secondary': 'hsl(330 20% 25%)',
'--c-text-muted': 'hsl(330 15% 40%)',
'--c-text-subtle': 'hsl(330 10% 50%)',
'--c-text-faint': 'hsl(330 10% 65%)',
'--c-text-faintest': 'hsl(330 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(150 70% 40%)',
'--c-accent-soft': 'hsl(120 60% 85%)',
'--c-accent-text': 'hsl(150 75% 28%)',
'--c-accent-2': 'hsl(60 70% 45%)',
'--c-accent-3': 'hsl(240 70% 38%)',
},
light_orange: {
'--c-surface-0': 'hsl(30 40% 96%)',
'--c-surface-1': 'hsl(30 35% 92%)',
'--c-surface-2': 'hsl(30 30% 85%)',
'--c-border': 'hsl(30 25% 80%)',
'--c-border-soft': 'hsl(30 22% 70%)',
'--c-border-faint': 'hsl(30 35% 92%)',
'--c-border-strong': 'hsl(30 20% 40%)',
'--c-text': 'hsl(30 30% 12%)',
'--c-text-secondary': 'hsl(30 20% 25%)',
'--c-text-muted': 'hsl(30 15% 40%)',
'--c-text-subtle': 'hsl(30 10% 50%)',
'--c-text-faint': 'hsl(30 10% 65%)',
'--c-text-faintest': 'hsl(30 12% 80%)',
'--c-danger-bg': '#fee2e2',
'--c-danger-border': '#fca5a5',
'--c-danger-text': '#b91c1c',
'--c-danger-text-strong': '#991b1b',
'--c-accent': 'hsl(210 70% 40%)',
'--c-accent-soft': 'hsl(180 60% 85%)',
'--c-accent-text': 'hsl(210 75% 28%)',
'--c-accent-2': 'hsl(120 70% 45%)',
'--c-accent-3': 'hsl(300 70% 38%)',
},
}
export function generateThemeCss(vars: ThemeVars): string {
const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`)
return `:root {\n${lines.join('\n')}\n}\n`
}
export interface ParsedTheme {
vars: ThemeVars
missing: string[]
}
// Parses `--c-*: value;` declarations from the first `:root { ... }` block (or the whole file, tolerant of a dropped selector); reports missing canonical variables so callers can reject an incomplete upload.
export function parseThemeCss(content: string): ParsedTheme {
const rootMatch = content.match(/:root\s*\{([^}]*)\}/)
const body = rootMatch ? rootMatch[1] : content
const found: Record<string, string> = {}
const declRegex = /(--c-[a-z0-9-]+)\s*:\s*([^;]+);/gi
let match: RegExpExecArray | null
while ((match = declRegex.exec(body))) {
found[match[1]] = match[2].trim()
}
const missing = THEME_CSS_VARS.filter((name) => !(name in found))
const vars = Object.fromEntries(THEME_CSS_VARS.map((name) => [name, found[name] ?? ''])) as ThemeVars
return { vars, missing }
}
export function downloadThemeFile(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/css' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
+25
View File
@@ -0,0 +1,25 @@
export interface TimeRange {
startMs: number
endMs: number
}
// `'all'` clears the range (the implicit default); the rest resolve to a window ending "now" at the moment they're (re-)computed — see `computePresetRange`.
export type TimeRangePresetId = 'all' | '1m' | '2m' | '5m' | '15m' | '1h' | '24h'
export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [
{ id: 'all', label: 'All', ms: null },
{ id: '1m', label: '1m', ms: 60 * 1000 },
{ id: '2m', label: '2m', ms: 2 * 60 * 1000 },
{ id: '5m', label: '5m', ms: 5 * 60 * 1000 },
{ id: '15m', label: '15m', ms: 15 * 60 * 1000 },
{ id: '1h', label: '1h', ms: 60 * 60 * 1000 },
{ id: '24h', label: '24h', ms: 24 * 60 * 60 * 1000 },
]
// Callers re-invoke this on their existing poll cadence (see `App.tsx`) to keep a rolling preset's window moving with time, rather than freezing it at selection time.
export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
const preset = TIME_RANGE_PRESETS.find((p) => p.id === id)
if (!preset || preset.ms == null) return null
const now = Date.now()
return { startMs: now - preset.ms, endMs: now }
}
+14
View File
@@ -0,0 +1,14 @@
import { useEffect, type RefObject } from 'react'
// Shared by popover-style UI (RegexHelpIcon, LogPreview's ColumnPicker) that closes itself when a
// mousedown lands outside its container ref, while `active`.
export function useClickOutside(ref: RefObject<HTMLElement | null>, onOutside: () => void, active: boolean) {
useEffect(() => {
if (!active) return
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) onOutside()
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [active, ref, onOutside])
}

Some files were not shown because too many files have changed in this diff Show More