Compare commits
81
Commits
d17cfe9b83
...
main
@@ -1,3 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
target/
|
target/
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
profile.json.gz
|
||||||
|
.codex
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
@@ -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.
|
**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.
|
- **`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.
|
- **`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).
|
- **`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: ...)").
|
- **`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.
|
- 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.
|
- 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
@@ -70,6 +70,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -200,6 +206,7 @@ dependencies = [
|
|||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -265,6 +272,46 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "directories"
|
name = "directories"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -297,6 +344,18 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"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]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -355,6 +414,21 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -362,6 +436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -370,6 +445,40 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
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]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -382,12 +491,32 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -415,6 +544,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -639,6 +774,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -679,6 +820,18 @@ dependencies = [
|
|||||||
"quote",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -708,6 +861,23 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.189"
|
||||||
@@ -750,12 +920,34 @@ version = "0.4.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
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]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
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]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -801,6 +993,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -832,6 +1033,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
|||||||
name = "ottyl"
|
name = "ottyl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -840,11 +1042,14 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"humantime",
|
"humantime",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
"left-right",
|
||||||
"nom",
|
"nom",
|
||||||
"pest",
|
"pest",
|
||||||
"pest_derive",
|
"pest_derive",
|
||||||
|
"petname",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rmcp",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -875,6 +1080,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -923,6 +1134,27 @@ dependencies = [
|
|||||||
"pest",
|
"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]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -1082,6 +1314,26 @@ dependencies = [
|
|||||||
"thiserror",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -1163,6 +1415,41 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -1238,6 +1525,38 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1274,6 +1593,17 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"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]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.151"
|
version = "1.0.151"
|
||||||
@@ -1310,6 +1640,15 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1447,6 +1786,15 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -1510,6 +1858,20 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1564,9 +1926,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
@@ -1574,6 +1948,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"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]]
|
[[package]]
|
||||||
@@ -1624,6 +2028,23 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
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]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|||||||
+5
-1
@@ -18,7 +18,11 @@ humantime = "2"
|
|||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
fs-embed = "0.2.4"
|
fs-embed = "0.2.4"
|
||||||
include_dir = "0.7.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]
|
[dev-dependencies]
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ test-fe:
|
|||||||
lint: lint-be lint-fe
|
lint: lint-be lint-fe
|
||||||
|
|
||||||
lint-be:
|
lint-be:
|
||||||
cargo clippy --all-targets
|
cargo fmt -- --check
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
lint-fe:
|
lint-fe:
|
||||||
cd webapp && npm run lint
|
cd webapp && npm run lint
|
||||||
@@ -40,3 +41,5 @@ dev:
|
|||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
rm -rf webapp/dist webapp/node_modules
|
rm -rf webapp/dist webapp/node_modules
|
||||||
|
rm profile.json.gz
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"?
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
+753
-273
File diff suppressed because it is too large
Load Diff
+230
@@ -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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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";
|
||||||
@@ -1,120 +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()?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
-356
@@ -1,356 +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,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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>,
|
|
||||||
}
|
|
||||||
-3443
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶m_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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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::hash_map::DefaultHasher;
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -20,14 +9,12 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::data_value::DataValue;
|
use crate::config::Settings;
|
||||||
use crate::engine::{self, apply_compare_op, eval_json_comparison};
|
use crate::engine::filter_datetime;
|
||||||
use crate::filter_datetime;
|
use crate::engine::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
|
||||||
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
|
use crate::engine::rate::RateWindow;
|
||||||
use crate::index_key::IndexKey;
|
use crate::engine::timestamp::Timestamp;
|
||||||
use crate::rate::RateWindow;
|
use crate::engine::{self, eval_json_comparison};
|
||||||
use crate::settings::Settings;
|
|
||||||
use crate::timestamp::Timestamp;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AlertRule {
|
pub struct AlertRule {
|
||||||
@@ -40,20 +27,19 @@ pub struct AlertRule {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum AlertCondition {
|
pub enum AlertCondition {
|
||||||
Immediate { query: String },
|
Immediate {
|
||||||
/// Fires when `query`'s match count within a rolling `window_ms` crosses `threshold` (via `op`).
|
query: String,
|
||||||
|
},
|
||||||
Threshold {
|
Threshold {
|
||||||
query: String,
|
query: String,
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
window_ms: i64,
|
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 {
|
NewValue {
|
||||||
field_regex: String,
|
field_regex: String,
|
||||||
capture_group: String,
|
capture_group: String,
|
||||||
},
|
},
|
||||||
/// A zero `count_b` skips evaluation for that line rather than treating the ratio as infinite.
|
|
||||||
Ratio {
|
Ratio {
|
||||||
query_a: String,
|
query_a: String,
|
||||||
query_b: 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 {
|
fn generate_alert_rule_id(name: &str) -> String {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
name.hash(&mut hasher);
|
name.hash(&mut hasher);
|
||||||
@@ -106,7 +91,12 @@ impl Settings {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
condition: AlertCondition,
|
condition: AlertCondition,
|
||||||
) -> Result<AlertRule, AlertError> {
|
) -> 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.alert_rules.push(rule.clone());
|
||||||
self.save(settings_path)?;
|
self.save(settings_path)?;
|
||||||
Ok(rule)
|
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)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AlertRuntimeState {
|
pub struct AlertRuntimeState {
|
||||||
threshold_window: RateWindow,
|
threshold_window: RateWindow,
|
||||||
@@ -163,63 +146,83 @@ pub struct AlertRuntimeState {
|
|||||||
seen_values: HashSet<String>,
|
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(
|
fn backfill_window(
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
snapshot: &engine::EngineSnapshot,
|
||||||
query: &str,
|
query: &str,
|
||||||
before_sequence: u64,
|
before_sequence: u64,
|
||||||
now: Timestamp,
|
now: Timestamp,
|
||||||
window_ms: i64,
|
window_ms: i64,
|
||||||
) -> RateWindow {
|
) -> RateWindow {
|
||||||
let since_ms = now.as_millis() - window_ms;
|
let since_ms = now.as_millis() - window_ms;
|
||||||
let matching_timestamps =
|
let matching_timestamps = snapshot
|
||||||
engine::logs_since(index, before_sequence, since_ms, now.as_millis()).filter_map(
|
.logs_since(before_sequence, since_ms, now.as_millis())
|
||||||
|(sequence, timestamp, text)| {
|
.filter_map(|(sequence, timestamp, text)| {
|
||||||
(eval_alert_query(query, sequence, timestamp, text) == Ok(true)).then_some(timestamp)
|
(eval_alert_query(query, sequence, timestamp, text) == Ok(true)).then_some(timestamp)
|
||||||
},
|
});
|
||||||
);
|
|
||||||
RateWindow::backfill(matching_timestamps, now, window_ms)
|
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(
|
||||||
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
|
query: &str,
|
||||||
|
sequence: u64,
|
||||||
|
timestamp: Timestamp,
|
||||||
|
text: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
|
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
|
||||||
eval_alert_expr(&expr, sequence, timestamp, text)
|
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 {
|
match expr {
|
||||||
FilterExpr::Regex(pattern) => {
|
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))
|
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 {
|
let target = match value {
|
||||||
FilterValue::Number(n) => *n,
|
FilterValue::Number(n) => *n,
|
||||||
FilterValue::Raw(raw) => raw
|
FilterValue::Raw(raw) => raw.parse::<f64>().map_err(|_| {
|
||||||
.parse::<f64>()
|
format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}")
|
||||||
.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 {
|
let target_ms = match value {
|
||||||
FilterValue::Number(n) => *n as i64,
|
FilterValue::Number(n) => *n as i64,
|
||||||
FilterValue::Raw(raw) => filter_datetime::parse_datetime(raw, Timestamp::now())
|
FilterValue::Raw(raw) => filter_datetime::parse_datetime(raw, Timestamp::now())
|
||||||
.map_err(|err| err.to_string())?
|
.map_err(|err| err.to_string())?
|
||||||
.as_millis(),
|
.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 {
|
let target = match value {
|
||||||
FilterValue::Number(n) => n.to_string(),
|
FilterValue::Number(n) => n.to_string(),
|
||||||
FilterValue::Raw(raw) => raw.clone(),
|
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!(
|
FilterExpr::Comparison { metric, .. } => Err(format!(
|
||||||
"alert queries only support regex()/ottyl_seq/ottyl_ts/ottyl_fmt (not metric comparisons like \
|
"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 } => {
|
FilterExpr::JsonComparison { field, op, value } => {
|
||||||
Ok(eval_json_comparison(text, field, *op, value))
|
Ok(eval_json_comparison(text, field, *op, value))
|
||||||
}
|
}
|
||||||
FilterExpr::And(lhs, rhs) => {
|
FilterExpr::And(lhs, rhs) => Ok(eval_alert_expr(lhs, sequence, timestamp, text)?
|
||||||
Ok(eval_alert_expr(lhs, sequence, timestamp, text)? && eval_alert_expr(rhs, sequence, timestamp, text)?)
|
&& eval_alert_expr(rhs, sequence, timestamp, text)?),
|
||||||
}
|
FilterExpr::Or(lhs, rhs) => Ok(eval_alert_expr(lhs, sequence, timestamp, text)?
|
||||||
FilterExpr::Or(lhs, rhs) => {
|
|| eval_alert_expr(rhs, sequence, timestamp, text)?),
|
||||||
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(
|
pub fn evaluate_alerts(
|
||||||
rules: &[AlertRule],
|
rules: &[AlertRule],
|
||||||
states: &mut HashMap<String, AlertRuntimeState>,
|
states: &mut HashMap<String, AlertRuntimeState>,
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
snapshot: &engine::EngineSnapshot,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
text: &str,
|
text: &str,
|
||||||
@@ -252,9 +252,15 @@ pub fn evaluate_alerts(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let state = states.entry(rule.id.clone()).or_default();
|
let state = states.entry(rule.id.clone()).or_default();
|
||||||
if let Some(message) =
|
if let Some(message) = evaluate_condition(
|
||||||
evaluate_condition(&rule.condition, state, index, sequence, timestamp, text, &rule.name)
|
&rule.condition,
|
||||||
{
|
state,
|
||||||
|
snapshot,
|
||||||
|
sequence,
|
||||||
|
timestamp,
|
||||||
|
text,
|
||||||
|
&rule.name,
|
||||||
|
) {
|
||||||
fired.push((rule.clone(), message));
|
fired.push((rule.clone(), message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,31 +270,39 @@ pub fn evaluate_alerts(
|
|||||||
fn evaluate_condition(
|
fn evaluate_condition(
|
||||||
condition: &AlertCondition,
|
condition: &AlertCondition,
|
||||||
state: &mut AlertRuntimeState,
|
state: &mut AlertRuntimeState,
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
snapshot: &engine::EngineSnapshot,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
text: &str,
|
text: &str,
|
||||||
rule_name: &str,
|
rule_name: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
match condition {
|
match condition {
|
||||||
AlertCondition::Immediate { query } => match eval_alert_query(query, sequence, timestamp, text) {
|
AlertCondition::Immediate { query } => {
|
||||||
|
match eval_alert_query(query, sequence, timestamp, text) {
|
||||||
Ok(true) => Some(format!("{rule_name}: matched line: {text}")),
|
Ok(true) => Some(format!("{rule_name}: matched line: {text}")),
|
||||||
Ok(false) => None,
|
Ok(false) => None,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("warning: alert '{rule_name}' query error: {err}");
|
eprintln!("warning: alert '{rule_name}' query error: {err}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
AlertCondition::Threshold { query, op, threshold, window_ms } => {
|
}
|
||||||
|
AlertCondition::Threshold {
|
||||||
|
query,
|
||||||
|
op,
|
||||||
|
threshold,
|
||||||
|
window_ms,
|
||||||
|
} => {
|
||||||
if !state.threshold_backfilled {
|
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;
|
state.threshold_backfilled = true;
|
||||||
}
|
}
|
||||||
match eval_alert_query(query, sequence, timestamp, text) {
|
match eval_alert_query(query, sequence, timestamp, text) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
state.threshold_window.push(timestamp, *window_ms);
|
state.threshold_window.push(timestamp, *window_ms);
|
||||||
let count = state.threshold_window.count() as f64;
|
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)")
|
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) {
|
let re = match Regex::new(field_regex) {
|
||||||
Ok(re) => re,
|
Ok(re) => re,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -314,7 +331,13 @@ fn evaluate_condition(
|
|||||||
.insert(value.clone())
|
.insert(value.clone())
|
||||||
.then(|| format!("{rule_name}: new value seen: {value} (line: {text})"))
|
.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_a = eval_alert_query(query_a, sequence, timestamp, text);
|
||||||
let matched_b = eval_alert_query(query_b, sequence, timestamp, text);
|
let matched_b = eval_alert_query(query_b, sequence, timestamp, text);
|
||||||
let (matched_a, matched_b) = match (matched_a, matched_b) {
|
let (matched_a, matched_b) = match (matched_a, matched_b) {
|
||||||
@@ -328,11 +351,13 @@ fn evaluate_condition(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if !state.ratio_a_backfilled {
|
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;
|
state.ratio_a_backfilled = true;
|
||||||
}
|
}
|
||||||
if !state.ratio_b_backfilled {
|
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;
|
state.ratio_b_backfilled = true;
|
||||||
}
|
}
|
||||||
if matched_a {
|
if matched_a {
|
||||||
@@ -344,11 +369,10 @@ fn evaluate_condition(
|
|||||||
let count_a = state.ratio_a_window.count() as f64;
|
let count_a = state.ratio_a_window.count() as f64;
|
||||||
let count_b = state.ratio_b_window.count() as f64;
|
let count_b = state.ratio_b_window.count() as f64;
|
||||||
if count_b == 0.0 {
|
if count_b == 0.0 {
|
||||||
// Zero denominator: skip rather than treat the ratio as infinite.
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let observed = count_a / count_b;
|
let observed = count_a / count_b;
|
||||||
apply_compare_op(observed.total_cmp(ratio), *op).then(|| {
|
op.matches(observed.total_cmp(ratio)).then(|| {
|
||||||
format!(
|
format!(
|
||||||
"{rule_name}: ratio {observed:.3} ({count_a}/{count_b}) {op:?} {ratio} (window {window_ms}ms)"
|
"{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
|
pub async fn deliver_for_rule(
|
||||||
/// (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
|
rule_name: String,
|
||||||
/// A silently no-op'd delivery (no topic configured) is expected — a rule can be defined before the sink is.
|
message: String,
|
||||||
pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<String>, server: Option<String>) {
|
topic: Option<String>,
|
||||||
|
server: Option<String>,
|
||||||
|
) {
|
||||||
let Some(topic) = topic else {
|
let Some(topic) = topic else {
|
||||||
return;
|
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> {
|
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
|
||||||
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
|
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
@@ -399,7 +424,9 @@ mod tests {
|
|||||||
id: "r1".to_string(),
|
id: "r1".to_string(),
|
||||||
name: "test rule".to_string(),
|
name: "test rule".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
condition: AlertCondition::Immediate { query: query.to_string() },
|
condition: AlertCondition::Immediate {
|
||||||
|
query: query.to_string(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +441,9 @@ mod tests {
|
|||||||
&settings_path,
|
&settings_path,
|
||||||
"spike".to_string(),
|
"spike".to_string(),
|
||||||
true,
|
true,
|
||||||
AlertCondition::Immediate { query: r#"regex("error")"#.to_string() },
|
AlertCondition::Immediate {
|
||||||
|
query: r#"regex("error")"#.to_string(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(settings.alert_rules.len(), 1);
|
assert_eq!(settings.alert_rules.len(), 1);
|
||||||
@@ -428,14 +457,18 @@ mod tests {
|
|||||||
&created.id,
|
&created.id,
|
||||||
"renamed".to_string(),
|
"renamed".to_string(),
|
||||||
false,
|
false,
|
||||||
AlertCondition::Immediate { query: r#"regex("warn")"#.to_string() },
|
AlertCondition::Immediate {
|
||||||
|
query: r#"regex("warn")"#.to_string(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(updated.id, created.id);
|
assert_eq!(updated.id, created.id);
|
||||||
assert_eq!(updated.name, "renamed");
|
assert_eq!(updated.name, "renamed");
|
||||||
assert!(!updated.enabled);
|
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());
|
assert!(settings.alert_rules.is_empty());
|
||||||
let reloaded = Settings::load(&settings_path);
|
let reloaded = Settings::load(&settings_path);
|
||||||
assert!(reloaded.alert_rules.is_empty());
|
assert!(reloaded.alert_rules.is_empty());
|
||||||
@@ -452,7 +485,9 @@ mod tests {
|
|||||||
"does-not-exist",
|
"does-not-exist",
|
||||||
"x".to_string(),
|
"x".to_string(),
|
||||||
true,
|
true,
|
||||||
AlertCondition::Immediate { query: r#"regex("x")"#.to_string() },
|
AlertCondition::Immediate {
|
||||||
|
query: r#"regex("x")"#.to_string(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
assert!(matches!(result, Err(AlertError::NotFound)));
|
assert!(matches!(result, Err(AlertError::NotFound)));
|
||||||
|
|
||||||
@@ -464,7 +499,7 @@ mod tests {
|
|||||||
fn immediate_fires_on_matching_line_only() {
|
fn immediate_fires_on_matching_line_only() {
|
||||||
let rules = vec![immediate_rule(r#"regex("error")"#)];
|
let rules = vec![immediate_rule(r#"regex("error")"#)];
|
||||||
let mut states = HashMap::new();
|
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);
|
let (seq, ts, text) = line("all good", 0, 0);
|
||||||
assert!(evaluate_alerts(&rules, &mut states, &index, seq, ts, text).is_empty());
|
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")"#);
|
let mut rule = immediate_rule(r#"regex("error")"#);
|
||||||
rule.enabled = false;
|
rule.enabled = false;
|
||||||
let mut states = HashMap::new();
|
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);
|
let (seq, ts, text) = line("error!", 0, 0);
|
||||||
assert!(evaluate_alerts(&[rule], &mut states, &index, seq, ts, text).is_empty());
|
assert!(evaluate_alerts(&[rule], &mut states, &index, seq, ts, text).is_empty());
|
||||||
}
|
}
|
||||||
@@ -499,15 +534,38 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
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)] {
|
for (seq, ms) in [(0u64, 0i64), (1, 1000)] {
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, seq, Timestamp::from_millis(ms), "error");
|
let fired = evaluate_alerts(
|
||||||
assert!(fired.is_empty(), "should not fire before threshold is crossed");
|
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);
|
assert_eq!(fired.len(), 1);
|
||||||
// Stays quiet for a non-matching line even though the window is full.
|
let fired = evaluate_alerts(
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(2100), "fine");
|
&[rule],
|
||||||
|
&mut states,
|
||||||
|
&index,
|
||||||
|
3,
|
||||||
|
Timestamp::from_millis(2100),
|
||||||
|
"fine",
|
||||||
|
);
|
||||||
assert!(fired.is_empty());
|
assert!(fired.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,10 +583,29 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
let index = engine::EngineSnapshot::default();
|
||||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
|
assert!(
|
||||||
// Second match arrives well after the first has aged out of the window.
|
evaluate_alerts(
|
||||||
assert!(evaluate_alerts(&[rule], &mut states, &index, 1, Timestamp::from_millis(5_000), "error").is_empty());
|
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]
|
#[test]
|
||||||
@@ -543,12 +620,33 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
let index = engine::EngineSnapshot::default();
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "agent=curl");
|
let fired = evaluate_alerts(
|
||||||
|
std::slice::from_ref(&rule),
|
||||||
|
&mut states,
|
||||||
|
&index,
|
||||||
|
0,
|
||||||
|
Timestamp::from_millis(0),
|
||||||
|
"agent=curl",
|
||||||
|
);
|
||||||
assert_eq!(fired.len(), 1);
|
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());
|
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);
|
assert_eq!(fired.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,17 +665,44 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
let index = engine::EngineSnapshot::default();
|
||||||
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
|
assert!(
|
||||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
|
evaluate_alerts(
|
||||||
// One request: ratio 1/1 = 1.0 >= 0.5 -> fires.
|
std::slice::from_ref(&rule),
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "request");
|
&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);
|
assert_eq!(fired.len(), 1);
|
||||||
// A second request: ratio 1/2 = 0.5 still >= 0.5 -> fires again.
|
let fired = evaluate_alerts(
|
||||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2), "request");
|
std::slice::from_ref(&rule),
|
||||||
|
&mut states,
|
||||||
|
&index,
|
||||||
|
2,
|
||||||
|
Timestamp::from_millis(2),
|
||||||
|
"request",
|
||||||
|
);
|
||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
// A third request with no more errors: ratio drops to 1/3 -> no longer fires.
|
let fired = evaluate_alerts(
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(3), "request");
|
&[rule],
|
||||||
|
&mut states,
|
||||||
|
&index,
|
||||||
|
3,
|
||||||
|
Timestamp::from_millis(3),
|
||||||
|
"request",
|
||||||
|
);
|
||||||
assert!(fired.is_empty());
|
assert!(fired.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,10 +710,10 @@ mod tests {
|
|||||||
fn ottyl_fmt_alert_query_matches_detected_format() {
|
fn ottyl_fmt_alert_query_matches_detected_format() {
|
||||||
let rule = immediate_rule("ottyl_fmt==json");
|
let rule = immediate_rule("ottyl_fmt==json");
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
let index = engine::EngineSnapshot::default();
|
||||||
|
|
||||||
let fired = evaluate_alerts(
|
let fired = evaluate_alerts(
|
||||||
&[rule.clone()],
|
std::slice::from_ref(&rule),
|
||||||
&mut states,
|
&mut states,
|
||||||
&index,
|
&index,
|
||||||
0,
|
0,
|
||||||
@@ -597,7 +722,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(fired.len(), 1);
|
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());
|
assert!(fired.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,17 +737,23 @@ mod tests {
|
|||||||
fn metric_comparison_in_alert_query_is_rejected_not_panicking() {
|
fn metric_comparison_in_alert_query_is_rejected_not_panicking() {
|
||||||
let rule = immediate_rule("some_metric[param]==5");
|
let rule = immediate_rule("some_metric[param]==5");
|
||||||
let mut states = HashMap::new();
|
let mut states = HashMap::new();
|
||||||
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
let index = engine::EngineSnapshot::default();
|
||||||
// Should log a warning and simply not fire, never panic.
|
let fired = evaluate_alerts(
|
||||||
let fired = evaluate_alerts(&[rule], &mut states, &index, 0, Timestamp::from_millis(0), "anything");
|
&[rule],
|
||||||
|
&mut states,
|
||||||
|
&index,
|
||||||
|
0,
|
||||||
|
Timestamp::from_millis(0),
|
||||||
|
"anything",
|
||||||
|
);
|
||||||
assert!(fired.is_empty());
|
assert!(fired.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deliver_ntfy_posts_message_to_server_topic_path() {
|
async fn deliver_ntfy_posts_message_to_server_topic_path() {
|
||||||
|
use axum::Router;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::routing::post;
|
use axum::routing::post;
|
||||||
use axum::Router;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
@@ -632,7 +770,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let captured = Captured::default();
|
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 listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -640,7 +780,9 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let server = format!("http://{addr}");
|
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();
|
let (topic, body) = captured.0.lock().unwrap().clone().unwrap();
|
||||||
assert_eq!(topic, "alerts");
|
assert_eq!(topic, "alerts");
|
||||||
@@ -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)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum DateTimeParseError {
|
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 },
|
Invalid { input: String, reason: String },
|
||||||
/// The input didn't match any supported format at all.
|
|
||||||
Unparseable(String),
|
Unparseable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +28,6 @@ impl std::fmt::Display for DateTimeParseError {
|
|||||||
|
|
||||||
impl std::error::Error 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> {
|
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
|
||||||
let trimmed = input.trim();
|
let trimmed = input.trim();
|
||||||
if trimmed.is_empty() {
|
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()))
|
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>> {
|
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||||
let (sign, rest) = match input.strip_prefix('-') {
|
let (sign, rest) = match input.strip_prefix('-') {
|
||||||
Some(rest) => (-1i128, rest),
|
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.chars().any(|c| c.is_ascii_alphabetic())
|
||||||
&& !rest.contains('-')
|
&& !rest.contains('-')
|
||||||
&& !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>> {
|
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'-';
|
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
|
||||||
if !looks_like_date {
|
if !looks_like_date {
|
||||||
return None;
|
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(':') {
|
if !input.contains(':') {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -146,9 +123,13 @@ fn try_time_of_day(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now_millis = now.as_millis();
|
let now_millis = now.as_millis();
|
||||||
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
|
let now_dt = Utc
|
||||||
// Fall back to the epoch date; shouldn't happen since `Timestamp` is always a valid instant.
|
.timestamp_millis_opt(now_millis)
|
||||||
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
|
.single()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
Utc.timestamp_millis_opt(0)
|
||||||
|
.single()
|
||||||
|
.expect("epoch is valid")
|
||||||
});
|
});
|
||||||
let today = now_dt.date_naive();
|
let today = now_dt.date_naive();
|
||||||
let dt = Utc.from_utc_datetime(&today.and_time(time));
|
let dt = Utc.from_utc_datetime(&today.and_time(time));
|
||||||
@@ -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)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use pest::Parser;
|
use pest::Parser;
|
||||||
@@ -8,23 +6,18 @@ use pest_derive::Parser as PestParser;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(PestParser)]
|
#[derive(PestParser)]
|
||||||
#[grammar = "filter_parser.pest"]
|
#[grammar = "engine/filter_parser.pest"]
|
||||||
struct FilterGrammar;
|
struct FilterGrammar;
|
||||||
|
|
||||||
/// The parsed AST for a FilterBar query.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum FilterExpr {
|
pub enum FilterExpr {
|
||||||
/// `regex("...")` — raw-text regex search over log lines.
|
|
||||||
Regex(String),
|
Regex(String),
|
||||||
/// `metric[param] <op> value` — a single metric comparison.
|
|
||||||
Comparison {
|
Comparison {
|
||||||
metric: String,
|
metric: String,
|
||||||
param: Option<String>,
|
param: Option<String>,
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
value: FilterValue,
|
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 {
|
JsonComparison {
|
||||||
field: String,
|
field: String,
|
||||||
op: CompareOp,
|
op: CompareOp,
|
||||||
@@ -34,8 +27,6 @@ pub enum FilterExpr {
|
|||||||
Or(Box<FilterExpr>, Box<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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CompareOp {
|
pub enum CompareOp {
|
||||||
@@ -47,15 +38,12 @@ pub enum CompareOp {
|
|||||||
Ne,
|
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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum FilterValue {
|
pub enum FilterValue {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
Raw(String),
|
Raw(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A located, human-readable parse error.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct FilterParseError {
|
pub struct FilterParseError {
|
||||||
message: String,
|
message: String,
|
||||||
@@ -79,12 +67,10 @@ impl std::error::Error for FilterParseError {}
|
|||||||
|
|
||||||
impl From<pest::error::Error<Rule>> for FilterParseError {
|
impl From<pest::error::Error<Rule>> for FilterParseError {
|
||||||
fn from(err: pest::error::Error<Rule>) -> Self {
|
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}"))
|
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> {
|
pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
|
||||||
let mut pairs = FilterGrammar::parse(Rule::file, input)?;
|
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> {
|
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||||
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
|
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
|
let first = and_exprs
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| FilterParseError::new("malformed 'or' expression"))?;
|
.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> {
|
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||||
debug_assert_eq!(pair.as_rule(), Rule::and_expr);
|
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
|
let first = terms
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| FilterParseError::new("malformed 'and' expression"))?;
|
.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> {
|
fn build_json_comparison(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||||
let mut inner = pair.into_inner();
|
let mut inner = pair.into_inner();
|
||||||
|
|
||||||
let field_pair = inner
|
let field_pair = inner.next().ok_or_else(|| {
|
||||||
.next()
|
FilterParseError::new("malformed json[...] comparison: missing field name")
|
||||||
.ok_or_else(|| FilterParseError::new("malformed json[...] comparison: missing field name"))?;
|
})?;
|
||||||
let field = field_pair.as_str().to_string();
|
let field = field_pair.as_str().to_string();
|
||||||
|
|
||||||
let op_pair = inner
|
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 {
|
fn unescape(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
let mut chars = s.chars();
|
let mut chars = s.chars();
|
||||||
@@ -372,8 +351,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_and_or_with_and_binding_tighter() {
|
fn parses_and_or_with_and_binding_tighter() {
|
||||||
let expr = parse(r#"regex("error") and ottyl_seq<5 or ottyl_ts>12:00"#)
|
let expr =
|
||||||
.expect("should parse");
|
parse(r#"regex("error") and ottyl_seq<5 or ottyl_ts>12:00"#).expect("should parse");
|
||||||
|
|
||||||
let expected = FilterExpr::Or(
|
let expected = FilterExpr::Or(
|
||||||
Box::new(FilterExpr::And(
|
Box::new(FilterExpr::And(
|
||||||
@@ -398,8 +377,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parenthesized_expression_overrides_precedence() {
|
fn parenthesized_expression_overrides_precedence() {
|
||||||
let expr = parse(r#"regex("error") and (ottyl_seq<5 or ottyl_ts>12:00)"#)
|
let expr =
|
||||||
.expect("should parse");
|
parse(r#"regex("error") and (ottyl_seq<5 or ottyl_ts>12:00)"#).expect("should parse");
|
||||||
|
|
||||||
let expected = FilterExpr::And(
|
let expected = FilterExpr::And(
|
||||||
Box::new(FilterExpr::Regex("error".to_string())),
|
Box::new(FilterExpr::Regex("error".to_string())),
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::engine::{EngineMessage, IngestedLine, MetricSample};
|
use crate::consts::{METRICS_POLL_INTERVAL, STDIN_POLL_INTERVAL as POLL_INTERVAL};
|
||||||
use crate::timestamp::Timestamp;
|
use crate::engine::timestamp::Timestamp;
|
||||||
|
use crate::engine::{EngineHandle, EngineMessage, IngestedLine, MetricSample};
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
const POLL_INTERVAL: Duration = Duration::from_millis(200);
|
pub async fn run(engine: EngineHandle) {
|
||||||
|
|
||||||
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>) {
|
|
||||||
let stdin = tokio::io::stdin();
|
let stdin = tokio::io::stdin();
|
||||||
let mut lines = BufReader::new(stdin).lines();
|
let mut lines = BufReader::new(stdin).lines();
|
||||||
let mut sequence: u64 = 0;
|
let mut sequence: u64 = 0;
|
||||||
@@ -23,17 +16,16 @@ pub async fn run(engine: mpsc::Sender<EngineMessage>) {
|
|||||||
let line = IngestedLine {
|
let line = IngestedLine {
|
||||||
sequence,
|
sequence,
|
||||||
timestamp: Timestamp::now(),
|
timestamp: Timestamp::now(),
|
||||||
text,
|
text: text.into(),
|
||||||
};
|
};
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
|
|
||||||
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
|
if engine.tx.send(EngineMessage::Ingest(line)).await.is_err() {
|
||||||
break;
|
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)]
|
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||||
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
|
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)))
|
Some((file, file_id(&meta)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tails `path` like `tail -f`, resuming from the start if the file is deleted/recreated or
|
pub async fn run_file(engine: EngineHandle, path: PathBuf) {
|
||||||
/// 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) {
|
|
||||||
let mut sequence: u64 = 0;
|
let mut sequence: u64 = 0;
|
||||||
|
|
||||||
'reopen: loop {
|
'reopen: loop {
|
||||||
@@ -78,42 +67,33 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
|||||||
let mut chunk = String::new();
|
let mut chunk = String::new();
|
||||||
match reader.read_line(&mut chunk).await {
|
match reader.read_line(&mut chunk).await {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
// EOF: decide whether the file was truncated/recreated or just hasn't grown yet.
|
if let Ok(meta) = tokio::fs::metadata(&path).await {
|
||||||
match tokio::fs::metadata(&path).await {
|
|
||||||
Ok(meta) => {
|
|
||||||
let new_id = file_id(&meta);
|
let new_id = file_id(&meta);
|
||||||
if new_id != id || meta.len() < offset {
|
if new_id != id || meta.len() < offset {
|
||||||
continue 'reopen;
|
continue 'reopen;
|
||||||
}
|
}
|
||||||
id = new_id;
|
id = new_id;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
|
||||||
// Path gone (mid-recreation); wait for it to reappear and reopen fresh.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(POLL_INTERVAL).await;
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
}
|
}
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
offset += n as u64;
|
offset += n as u64;
|
||||||
pending.push_str(&chunk);
|
pending.push_str(&chunk);
|
||||||
if pending.ends_with('\n') {
|
if pending.ends_with('\n') {
|
||||||
let text = pending
|
let text = pending.trim_end_matches(['\n', '\r']).to_string();
|
||||||
.trim_end_matches(['\n', '\r'])
|
|
||||||
.to_string();
|
|
||||||
pending.clear();
|
pending.clear();
|
||||||
|
|
||||||
let line = IngestedLine {
|
let line = IngestedLine {
|
||||||
sequence,
|
sequence,
|
||||||
timestamp: Timestamp::now(),
|
timestamp: Timestamp::now(),
|
||||||
text,
|
text: text.into(),
|
||||||
};
|
};
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
|
|
||||||
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
|
if engine.tx.send(EngineMessage::Ingest(line)).await.is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// else: partial line at EOF (write in progress) — keep buffering.
|
|
||||||
}
|
}
|
||||||
Err(_) => continue 'reopen,
|
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
|
async fn fetch_metrics_body(client: &reqwest::Client, url: &str) -> Result<String, String> {
|
||||||
/// sample straight into the engine. Errors (unreachable endpoint, non-2xx response) are only logged —
|
let response = client
|
||||||
/// polling keeps retrying on the next tick rather than exiting.
|
.get(url)
|
||||||
pub async fn run_metrics_poll(engine: mpsc::Sender<EngineMessage>, url: String) {
|
.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();
|
let client = reqwest::Client::new();
|
||||||
loop {
|
loop {
|
||||||
match client.get(&url).send().await {
|
match fetch_metrics_body(&client, &url).await {
|
||||||
Ok(response) => match response.error_for_status() {
|
|
||||||
Ok(response) => match response.text().await {
|
|
||||||
Ok(body) => {
|
Ok(body) => {
|
||||||
let now = Timestamp::now();
|
let now = Timestamp::now();
|
||||||
for sample in parse_openmetrics(&body, now) {
|
for sample in parse_openmetrics(&body, now) {
|
||||||
if engine.send(EngineMessage::IngestMetricSample(sample)).await.is_err() {
|
if engine
|
||||||
|
.tx
|
||||||
|
.send(EngineMessage::IngestMetricSample(sample))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => eprintln!("warning: failed to read metrics response from {url}: {err}"),
|
Err(warning) => eprintln!("{warning}"),
|
||||||
},
|
|
||||||
Err(err) => eprintln!("warning: metrics endpoint {url} returned an error: {err}"),
|
|
||||||
},
|
|
||||||
Err(err) => eprintln!("warning: failed to poll metrics endpoint {url}: {err}"),
|
|
||||||
}
|
}
|
||||||
tokio::time::sleep(METRICS_POLL_INTERVAL).await;
|
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> {
|
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> {
|
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('{') {
|
let (metric_name, labels) = match name_and_labels.find('{') {
|
||||||
Some(open) => {
|
Some(open) => {
|
||||||
let close = name_and_labels.rfind('}')?;
|
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()),
|
None => (name_and_labels.to_string(), BTreeMap::new()),
|
||||||
};
|
};
|
||||||
@@ -182,15 +174,23 @@ fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
|
|||||||
return None;
|
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> {
|
fn parse_openmetrics_labels(raw: &str) -> BTreeMap<String, String> {
|
||||||
raw.split(',')
|
raw.split(',')
|
||||||
.filter_map(|pair| pair.split_once('='))
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,10 +212,8 @@ mod openmetrics_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_labels_and_explicit_timestamp() {
|
fn parses_labels_and_explicit_timestamp() {
|
||||||
let now = Timestamp::from_millis(1000);
|
let now = Timestamp::from_millis(1000);
|
||||||
let samples = parse_openmetrics(
|
let samples =
|
||||||
r#"http_requests_total{method="GET",path="/a"} 7 5000"#,
|
parse_openmetrics(r#"http_requests_total{method="GET",path="/a"} 7 5000"#, now);
|
||||||
now,
|
|
||||||
);
|
|
||||||
assert_eq!(samples.len(), 1);
|
assert_eq!(samples.len(), 1);
|
||||||
let sample = &samples[0];
|
let sample = &samples[0];
|
||||||
assert_eq!(sample.metric_name, "http_requests_total");
|
assert_eq!(sample.metric_name, "http_requests_total");
|
||||||
@@ -238,7 +236,9 @@ mod openmetrics_tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::engine::test_handle;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
async fn recv_text(rx: &mut mpsc::Receiver<EngineMessage>) -> String {
|
async fn recv_text(rx: &mut mpsc::Receiver<EngineMessage>) -> String {
|
||||||
@@ -247,7 +247,7 @@ mod tests {
|
|||||||
.expect("timed out waiting for ingested line")
|
.expect("timed out waiting for ingested line")
|
||||||
.expect("engine channel closed")
|
.expect("engine channel closed")
|
||||||
{
|
{
|
||||||
EngineMessage::Ingest(line) => line.text,
|
EngineMessage::Ingest(line) => line.text.to_string(),
|
||||||
_ => panic!("expected Ingest"),
|
_ => panic!("expected Ingest"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,12 +258,15 @@ mod tests {
|
|||||||
let path = dir.path().join("app.log");
|
let path = dir.path().join("app.log");
|
||||||
std::fs::write(&path, "first\n").unwrap();
|
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()));
|
tokio::spawn(run_file(tx, path.clone()));
|
||||||
|
|
||||||
assert_eq!(recv_text(&mut rx).await, "first");
|
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();
|
writeln!(f, "second").unwrap();
|
||||||
assert_eq!(recv_text(&mut rx).await, "second");
|
assert_eq!(recv_text(&mut rx).await, "second");
|
||||||
}
|
}
|
||||||
@@ -274,7 +277,7 @@ mod tests {
|
|||||||
let path = dir.path().join("app.log");
|
let path = dir.path().join("app.log");
|
||||||
std::fs::write(&path, "before-restart\n").unwrap();
|
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()));
|
tokio::spawn(run_file(tx, path.clone()));
|
||||||
|
|
||||||
assert_eq!(recv_text(&mut rx).await, "before-restart");
|
assert_eq!(recv_text(&mut rx).await, "before-restart");
|
||||||
@@ -291,7 +294,7 @@ mod tests {
|
|||||||
let path = dir.path().join("app.log");
|
let path = dir.path().join("app.log");
|
||||||
std::fs::write(&path, "long line before truncation\n").unwrap();
|
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()));
|
tokio::spawn(run_file(tx, path.clone()));
|
||||||
|
|
||||||
assert_eq!(recv_text(&mut rx).await, "long line before truncation");
|
assert_eq!(recv_text(&mut rx).await, "long line before truncation");
|
||||||
@@ -312,7 +315,7 @@ mod tests {
|
|||||||
let path = dir.path().join("app.log");
|
let path = dir.path().join("app.log");
|
||||||
std::fs::write(&path, "one\n").unwrap();
|
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()));
|
tokio::spawn(run_file(tx, path.clone()));
|
||||||
|
|
||||||
let first = match tokio::time::timeout(Duration::from_secs(2), rx.recv())
|
let first = match tokio::time::timeout(Duration::from_secs(2), rx.recv())
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 serde::Serialize;
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// A simple, self-describing value used by metrics/labels without pulling in JSON.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub enum MetricPrimitive {
|
pub enum MetricPrimitive {
|
||||||
String(String),
|
String(String),
|
||||||
Number(Number),
|
Number(Number),
|
||||||
Timestamp(Timestamp),
|
Timestamp(Timestamp),
|
||||||
/// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form.
|
|
||||||
Duration(i64),
|
Duration(i64),
|
||||||
/// A `Boolean`-kind param's value: whether `message_match_regex` matched the line. See `MetricPrimitiveKind::Boolean`.
|
|
||||||
Boolean(bool),
|
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 {
|
impl fmt::Display for MetricPrimitive {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
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)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
pub enum Number {
|
pub enum Number {
|
||||||
@@ -74,7 +69,6 @@ pub enum Number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub(crate) fn as_f64(&self) -> f64 {
|
||||||
match self {
|
match self {
|
||||||
Number::Int(v) => *v as f64,
|
Number::Int(v) => *v as f64,
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AggOp {
|
pub enum AggOp {
|
||||||
Sum,
|
Sum,
|
||||||
Avg,
|
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,
|
Count,
|
||||||
Rate,
|
Rate,
|
||||||
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
|
|
||||||
Histogram,
|
Histogram,
|
||||||
/// The most-recent counter value per label combination.
|
|
||||||
Gauge,
|
Gauge,
|
||||||
|
Quantile,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -30,31 +22,30 @@ pub struct PromqlQuery {
|
|||||||
pub agg: Option<AggOp>,
|
pub agg: Option<AggOp>,
|
||||||
pub metric_name: String,
|
pub metric_name: String,
|
||||||
pub matchers: Vec<LabelMatcher>,
|
pub matchers: Vec<LabelMatcher>,
|
||||||
/// Duration range selector, e.g. `[5m]`; only meaningful for `rate()`.
|
|
||||||
pub range: Option<Duration>,
|
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>,
|
pub bucket_width: Option<f64>,
|
||||||
/// Set when `agg` is `Sum`/`Avg` and it wraps an inner `rate(metric[range])` rather than a bare vector
|
/// The `p` in `histogram_quantile(p, metric[range])`, e.g. `0.95` for p95. Only set when
|
||||||
/// selector, e.g. `sum(rate(requests[5m]))`. `range` above carries the inner `rate()`'s duration in that case.
|
/// `agg` is `Some(AggOp::Quantile)`.
|
||||||
|
pub quantile: Option<f64>,
|
||||||
pub inner_rate: bool,
|
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>,
|
pub by_labels: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a `[...]` range selector turned out to hold: a duration, or a bare width.
|
|
||||||
enum RangeSelector {
|
enum RangeSelector {
|
||||||
Duration(Duration),
|
Duration(Duration),
|
||||||
BareWidth(f64),
|
BareWidth(f64),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a PromQL query string, returning a human-readable error message on malformed input.
|
impl PromqlQuery {
|
||||||
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
|
pub fn parse(input: &str) -> Result<PromqlQuery, String> {
|
||||||
let mut parser = Parser::new(input);
|
let mut parser = Parser::new(input);
|
||||||
let query = parser.parse_query()?;
|
let query = parser.parse_query()?;
|
||||||
parser.skip_whitespace();
|
parser.skip_whitespace();
|
||||||
if !parser.is_at_end() {
|
if !parser.is_at_end() {
|
||||||
return Err(format!("unexpected trailing input: '{}'", parser.remaining()));
|
return Err(format!(
|
||||||
|
"unexpected trailing input: '{}'",
|
||||||
|
parser.remaining()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if query.agg == Some(AggOp::Rate) && query.range.is_none() {
|
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());
|
return Err("rate() requires a range selector, e.g. rate(my_metric[5m])".to_string());
|
||||||
@@ -64,8 +55,16 @@ pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
|
|||||||
"histogram() requires a bucket width, e.g. histogram(my_metric[10])".to_string(),
|
"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)
|
Ok(query)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Parser<'a> {
|
struct Parser<'a> {
|
||||||
input: &'a str,
|
input: &'a str,
|
||||||
@@ -128,7 +127,9 @@ impl<'a> Parser<'a> {
|
|||||||
fn parse_quoted_string(&mut self) -> Result<String, String> {
|
fn parse_quoted_string(&mut self) -> Result<String, String> {
|
||||||
self.expect_char('"')?;
|
self.expect_char('"')?;
|
||||||
let rest = self.remaining();
|
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];
|
let value = &rest[..end];
|
||||||
if value.contains('\\') {
|
if value.contains('\\') {
|
||||||
return Err("escape sequences in label values are not supported".to_string());
|
return Err("escape sequences in label values are not supported".to_string());
|
||||||
@@ -177,7 +178,6 @@ impl<'a> Parser<'a> {
|
|||||||
Ok(matchers)
|
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> {
|
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
if self.peek() != Some('[') {
|
if self.peek() != Some('[') {
|
||||||
@@ -201,7 +201,9 @@ impl<'a> Parser<'a> {
|
|||||||
return Ok(Some(RangeSelector::BareWidth(digits as f64)));
|
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 {
|
let seconds_per_unit: i64 = match unit {
|
||||||
's' => 1,
|
's' => 1,
|
||||||
'm' => 60,
|
'm' => 60,
|
||||||
@@ -234,12 +236,42 @@ impl<'a> Parser<'a> {
|
|||||||
matchers,
|
matchers,
|
||||||
range,
|
range,
|
||||||
bucket_width,
|
bucket_width,
|
||||||
|
quantile: None,
|
||||||
inner_rate: false,
|
inner_rate: false,
|
||||||
by_labels: Vec::new(),
|
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> {
|
fn parse_by_clause(&mut self) -> Result<Vec<String>, String> {
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
let checkpoint = self.pos;
|
let checkpoint = self.pos;
|
||||||
@@ -286,18 +318,33 @@ impl<'a> Parser<'a> {
|
|||||||
"rate" => Some(AggOp::Rate),
|
"rate" => Some(AggOp::Rate),
|
||||||
"histogram" => Some(AggOp::Histogram),
|
"histogram" => Some(AggOp::Histogram),
|
||||||
"gauge" => Some(AggOp::Gauge),
|
"gauge" => Some(AggOp::Gauge),
|
||||||
|
"histogram_quantile" => Some(AggOp::Quantile),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
// `sum by (label) (metric)`: a `by (...)` clause may also precede the aggregation's
|
let prefix_by_labels = if agg.is_some() {
|
||||||
// parenthesized argument, not just follow it.
|
self.parse_by_clause()?
|
||||||
let prefix_by_labels = if agg.is_some() { self.parse_by_clause()? } else { Vec::new() };
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
if agg.is_some() && self.peek() == Some('(') {
|
if agg.is_some() && self.peek() == Some('(') {
|
||||||
self.expect_char('(')?;
|
self.expect_char('(')?;
|
||||||
self.skip_whitespace();
|
self.skip_whitespace();
|
||||||
// `sum()`/`avg()` may wrap `rate(metric[range])` directly instead of a bare vector selector.
|
let mut inner = if agg == Some(AggOp::Quantile) {
|
||||||
let mut inner = if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
|
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 inner_checkpoint = self.pos;
|
||||||
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
|
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
|
||||||
if took_inner_rate {
|
if took_inner_rate {
|
||||||
@@ -334,11 +381,13 @@ impl<'a> Parser<'a> {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
inner.by_labels =
|
inner.by_labels = if prefix_by_labels.is_empty() {
|
||||||
if prefix_by_labels.is_empty() { trailing_by_labels } else { prefix_by_labels };
|
trailing_by_labels
|
||||||
|
} else {
|
||||||
|
prefix_by_labels
|
||||||
|
};
|
||||||
Ok(inner)
|
Ok(inner)
|
||||||
} else {
|
} else {
|
||||||
// Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
|
|
||||||
self.pos = checkpoint;
|
self.pos = checkpoint;
|
||||||
self.parse_vector_selector()
|
self.parse_vector_selector()
|
||||||
}
|
}
|
||||||
@@ -351,7 +400,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_bare_metric_name() {
|
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.agg, None);
|
||||||
assert_eq!(query.metric_name, "requests");
|
assert_eq!(query.metric_name, "requests");
|
||||||
assert!(query.matchers.is_empty());
|
assert!(query.matchers.is_empty());
|
||||||
@@ -360,17 +409,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_metric_with_single_label_matcher() {
|
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.metric_name, "requests");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
query.matchers,
|
query.matchers,
|
||||||
vec![LabelMatcher { name: "path".to_string(), value: "/a".to_string() }]
|
vec![LabelMatcher {
|
||||||
|
name: "path".to_string(),
|
||||||
|
value: "/a".to_string()
|
||||||
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_metric_with_multiple_label_matchers() {
|
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.len(), 2);
|
||||||
assert_eq!(query.matchers[0].name, "path");
|
assert_eq!(query.matchers[0].name, "path");
|
||||||
assert_eq!(query.matchers[1].name, "method");
|
assert_eq!(query.matchers[1].name, "method");
|
||||||
@@ -378,68 +430,67 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_range_selector() {
|
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)));
|
assert_eq!(query.range, Some(Duration::from_secs(300)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_label_matchers_and_range_together() {
|
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.matchers.len(), 1);
|
||||||
assert_eq!(query.range, Some(Duration::from_secs(3600)));
|
assert_eq!(query.range, Some(Duration::from_secs(3600)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_sum_aggregation() {
|
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.agg, Some(AggOp::Sum));
|
||||||
assert_eq!(query.metric_name, "requests");
|
assert_eq!(query.metric_name, "requests");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_avg_aggregation() {
|
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));
|
assert_eq!(query.agg, Some(AggOp::Avg));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_rate_with_range() {
|
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.agg, Some(AggOp::Rate));
|
||||||
assert_eq!(query.range, Some(Duration::from_secs(300)));
|
assert_eq!(query.range, Some(Duration::from_secs(300)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_rate_without_range() {
|
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"));
|
assert!(err.contains("range selector"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_empty_input() {
|
fn rejects_empty_input() {
|
||||||
assert!(parse_query("").is_err());
|
assert!(PromqlQuery::parse("").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unclosed_brace() {
|
fn rejects_unclosed_brace() {
|
||||||
assert!(parse_query(r#"requests{path="/a""#).is_err());
|
assert!(PromqlQuery::parse(r#"requests{path="/a""#).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unclosed_bracket() {
|
fn rejects_unclosed_bracket() {
|
||||||
assert!(parse_query("requests[5m").is_err());
|
assert!(PromqlQuery::parse("requests[5m").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_aggregation() {
|
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 = PromqlQuery::parse("median(requests)").unwrap_err();
|
||||||
let err = parse_query("median(requests)").unwrap_err();
|
|
||||||
assert!(err.contains("unexpected trailing input"));
|
assert!(err.contains("unexpected trailing input"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_count_aggregation() {
|
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.agg, Some(AggOp::Count));
|
||||||
assert_eq!(query.metric_name, "requests");
|
assert_eq!(query.metric_name, "requests");
|
||||||
assert!(!query.inner_rate);
|
assert!(!query.inner_rate);
|
||||||
@@ -447,37 +498,37 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_count_with_by_clause() {
|
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.agg, Some(AggOp::Count));
|
||||||
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_regex_label_matcher() {
|
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"));
|
assert!(err.contains("regex label matchers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_missing_metric_name() {
|
fn rejects_missing_metric_name() {
|
||||||
assert!(parse_query(r#"{path="/a"}"#).is_err());
|
assert!(PromqlQuery::parse(r#"{path="/a"}"#).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_duration_unit() {
|
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"));
|
assert!(err.contains("duration unit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_trailing_garbage() {
|
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"));
|
assert!(err.contains("unexpected trailing input"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_histogram_with_bare_width() {
|
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.agg, Some(AggOp::Histogram));
|
||||||
assert_eq!(query.metric_name, "latency_ms");
|
assert_eq!(query.metric_name, "latency_ms");
|
||||||
assert_eq!(query.bucket_width, Some(10.0));
|
assert_eq!(query.bucket_width, Some(10.0));
|
||||||
@@ -486,27 +537,33 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_histogram_without_width() {
|
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"));
|
assert!(err.contains("bucket width"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_gauge_with_no_range_selector() {
|
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.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]
|
#[test]
|
||||||
fn rate_range_selector_is_still_a_duration_not_a_bare_width() {
|
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.range, Some(Duration::from_secs(300)));
|
||||||
assert_eq!(query.bucket_width, None);
|
assert_eq!(query.bucket_width, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_sum_of_rate() {
|
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_eq!(query.agg, Some(AggOp::Sum));
|
||||||
assert!(query.inner_rate);
|
assert!(query.inner_rate);
|
||||||
assert_eq!(query.metric_name, "requests");
|
assert_eq!(query.metric_name, "requests");
|
||||||
@@ -516,21 +573,27 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_avg_of_rate_with_label_matchers() {
|
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_eq!(query.agg, Some(AggOp::Avg));
|
||||||
assert!(query.inner_rate);
|
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]
|
#[test]
|
||||||
fn rejects_sum_of_rate_without_range() {
|
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"));
|
assert!(err.contains("range selector"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_sum_with_by_clause() {
|
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_eq!(query.agg, Some(AggOp::Sum));
|
||||||
assert!(!query.inner_rate);
|
assert!(!query.inner_rate);
|
||||||
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||||
@@ -538,44 +601,96 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_sum_of_rate_with_by_clause_multiple_labels() {
|
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!(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]
|
#[test]
|
||||||
fn sum_without_by_clause_has_empty_by_labels() {
|
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());
|
assert!(query.by_labels.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_empty_by_clause() {
|
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"));
|
assert!(err.contains("at least one label"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unterminated_by_clause() {
|
fn rejects_unterminated_by_clause() {
|
||||||
assert!(parse_query("sum(requests) by (path").is_err());
|
assert!(PromqlQuery::parse("sum(requests) by (path").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_sum_with_prefix_by_clause() {
|
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.agg, Some(AggOp::Sum));
|
||||||
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_sum_with_prefix_by_clause_multiple_labels() {
|
fn parses_sum_with_prefix_by_clause_multiple_labels() {
|
||||||
let query = parse_query("sum by (path, method) (requests)").unwrap();
|
let query = PromqlQuery::parse("sum by (path, method) (requests)").unwrap();
|
||||||
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]
|
#[test]
|
||||||
fn rejects_by_clause_both_before_and_after_aggregation() {
|
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"));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,15 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||||
pub struct Timestamp(i64);
|
pub struct Timestamp(i64);
|
||||||
|
|
||||||
impl Timestamp {
|
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 {
|
pub fn now() -> Self {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -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)+ }
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"time":"2026-09-06T10:35:34.499921+01:00","level":"WARN","msg":"eventC","labelC":1}
|
||||||
|
{"time":"2026-09-06T10:35:35.000837+01:00","level":"ERROR","msg":"eventB","labelC":2,"labelA":3}
|
||||||
|
{"time":"2026-09-06T10:35:36.504236+01:00","level":"INFO","msg":"eventB","labelB":"three"}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
Timestamp Thread Type Activity PID TTL
|
||||||
|
2026-09-06 01:48:54.465144+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:54.466387+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.196298]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:54.466396+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.196300]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:54.466397+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.196305]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:54.466397+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.196308]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:54.466386+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.496702+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:54.500366+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) [com.apple.Multitouch:Plugin] [TP] Dispatching digitizer event with 3 children, _eventMask=0x0 _childEventMask=0x2 Cancel=0 Touching=1 inRange=1
|
||||||
|
2026-09-06 01:48:54.532524+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.554814+0100 0x144b Default 0x0 606 7 WindowServer: (SkyLight) [com.apple.SkyLight:KeyboardEvent] kCGSEventKeyUp was part of a sequence with destination <keyboardFocus; pid: 1343; token: viewbridge-key-window>
|
||||||
|
2026-09-06 01:48:54.555315+0100 0x2e72 Default 0x0 1343 0 kitty: (TextInputUIMacHelper) [com.apple.TextInputUI:CursorUI] -[TUINSCursorUIController scheduleUpdateCursorLocation]
|
||||||
|
2026-09-06 01:48:54.562153+0100 0x3d1 Default 0x0 0 0 kernel: (AppleAVD) AppleAVD: INFO: logPerfAndUserClientStats(): core 0, Vmin/nom/max: 99% 0% 0%, clientCount 1 1 1 1 1
|
||||||
|
2026-09-06 01:48:54.564722+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.598759+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.632768+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.666589+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.671971+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] Driver Event: <airport[642]> _bsd_80211_event_callback: APPLE80211_M_WEIGHT_AVG_LQM_UPDATE/145 (en0)
|
||||||
|
2026-09-06 01:48:54.680948+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:54.700119+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.732678+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.766150+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.801202+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.807884+0100 0x4e1fd2 Activity 0x9cc91e 975 0 CoreServicesUIAgent: Handle XPC Request
|
||||||
|
2026-09-06 01:48:54.807881+0100 0x4e214c Activity 0x9cc91d 975 0 CoreServicesUIAgent: Handle XPC Request
|
||||||
|
2026-09-06 01:48:54.807903+0100 0x4e23c3 Activity 0x9cc91f 975 0 CoreServicesUIAgent: Handle XPC Request
|
||||||
|
2026-09-06 01:48:54.808141+0100 0x4e23c4 Error 0x9cc91e 975 2 CoreServicesUIAgent: [com.apple.launchservices:uiagent] CSUIGetDisplayName, failing with -10810 for path '<private>
|
||||||
|
2026-09-06 01:48:54.808194+0100 0x4e23c4 Error 0x9cc91f 975 2 CoreServicesUIAgent: [com.apple.launchservices:uiagent] CSUIGetDisplayName, failing with -10810 for path '<private>
|
||||||
|
2026-09-06 01:48:54.808238+0100 0x4e23c4 Error 0x9cc91d 975 2 CoreServicesUIAgent: [com.apple.launchservices:uiagent] CSUIGetDisplayName, failing with -10810 for path '<private>
|
||||||
|
2026-09-06 01:48:54.823648+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255552387 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:54.823667+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:54.832643+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.845410+0100 0x4dff68 Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.LE.Scan] Matched UUID 0xFCB2 for device "A1161273-82A9-54D0-9283-720AC7DD464C"
|
||||||
|
2026-09-06 01:48:54.845529+0100 0x4dff68 Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.LE.Scan] Matched UUID 0xFCB2 for device "A1161273-82A9-54D0-9283-720AC7DD464C"
|
||||||
|
2026-09-06 01:48:54.845773+0100 0x4df240 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"onAvengerAdvertisementDetected: got avenger advertisement", "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:54.845815+0100 0x4defc1 Default 0x0 572 0 locationd: (SPFinder) [com.apple.icloud.SPFinder:advertisementCache] saveBeaconPayload: 1
|
||||||
|
2026-09-06 01:48:54.845818+0100 0x4df240 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"convertToSPAdvertisement", "address":<private>, "data":<private>, "date":<private>, "rssi":<private>, "status":<private>, "reserved":<private>, "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:54.845837+0100 0x4defc1 Activity 0x9d0e2a 572 0 locationd: (SPFinder) SPAdvertisementCache saveBeaconPayloads:completion:
|
||||||
|
2026-09-06 01:48:54.845847+0100 0x4df240 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"onAvengerAdvertisementDetected: advertisement is near-owner from other people and do not process it", "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:54.846284+0100 0x4dfe20 Activity 0x9d0dbf 746 0 searchpartyd: (FindMyBase) SaveObservedAdvertisment
|
||||||
|
2026-09-06 01:48:54.846660+0100 0x4dfe20 Default 0x9d0dbf 746 0 searchpartyd: (FindMyBase) [com.apple.icloud.searchpartyd:observationStoring] TIME: ObservationStoring.Reconcile: <private>
|
||||||
|
2026-09-06 01:48:54.865403+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.889179+0100 0x4de289 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:54.899419+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.932587+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.940343+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:54.965716+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:54.969316+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:54.999946+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.002756+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10141] : Active Sockets false ValidSvc 4 NumAirplay 0 AFHandlePending 0 TimeOfLastAFHandleRequest 141710 ms
|
||||||
|
2026-09-06 01:48:55.002786+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10239]: BonJourTrig 1 ValidSvc 4 RTApp 0 TSReq 0 HasActAirDrop 0 SocketsActive 0
|
||||||
|
2026-09-06 01:48:55.002799+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setScheduleState[11041]: reason:UserTriggered sc:Infra Priority and force:NO, AWDL-restore:No
|
||||||
|
2026-09-06 01:48:55.002824+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: AWDLStateDump: AWDL up time: 15732 Secs, 82:45:A3:D5:31:34 peers:0, cached:0(0), rtg(0), <SDB: :(00:00:00:00:00:00) 44, infraRT:Yes, country:GB>
|
||||||
|
2026-09-06 01:48:55.002862+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: AWDLStateDump: Services[Valid 4 Airplay 0 WiFid 0 AirDrop 0 nonAirplay:4 rdlink:0 clink:0]
|
||||||
|
2026-09-06 01:48:55.002872+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: AWDLStateDump: AirPlay:0 AirPlayToHT:0 AirPlayOverInfra:0 SideCar:0 SideCarSender:0 SideCarPeer:0x0 TimeSync:0 HighThroughput:0
|
||||||
|
2026-09-06 01:48:55.002892+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: AWDLStateDump: AWDL enabled with no data connection from 15732 seconds
|
||||||
|
2026-09-06 01:48:55.002901+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: AWDLStateDump: PSFDwell Support/Active: 1/0 {0ms 0ms}, miconfigured:0 sessin count:7, MIoNDwell:35, dwelEvent:0
|
||||||
|
2026-09-06 01:48:55.002922+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: AWDL ON:[infra(44) 90%] [sdb:0] (6/44/44) [44 0 44 0 0 0 0 0 6 0 44 0 0 0 0 0] Infra Priority
|
||||||
|
2026-09-06 01:48:55.002931+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.stats.awdl0: -------------------------------------------------------------------------------------------------------------
|
||||||
|
2026-09-06 01:48:55.002941+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.stats.awdl0: AFSPerSlot : [0 0 0 0] [0 0 0 0] [0 0 0 0] [0 0 0 0]
|
||||||
|
2026-09-06 01:48:55.002945+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.stats.awdl0: AFsPerSignal (batchSize -1) : 0 <= 1, 0 <= 10, 0 <= 30, 0 <= 50, 0 <= 100, 0 > 100
|
||||||
|
2026-09-06 01:48:55.002949+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.stats.awdl0: TimePerSignal : 0 < 250uSec, 0 250-500uSec, 0 500-1000uSec, 0 1000-2000uSec, 0 > 2000uSec
|
||||||
|
2026-09-06 01:48:55.002951+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.stats.awdl0: TimePerAF : 0 < 100uSec, 0 100-250uSec, 0 250-500uSec, 0 500-1000uSec, 0 > 1000uSec
|
||||||
|
2026-09-06 01:48:55.002954+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.stats.awdl0: -------------------------------------------------------------------------------------------------------------
|
||||||
|
2026-09-06 01:48:55.002965+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.wifi.awdl0: LQM-WiFi:AWDL State #16 Infra Priority(29) DutyCycle 10 StateDuration 45lu.791s[255506.775 - 0.000] StateComplete 0
|
||||||
|
2026-09-06 01:48:55.002969+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.wifi.awdl0: LQM-WiFi:AWDL Active Time 500ms(9%) 5.002s[255547.563s - 255552.566s]
|
||||||
|
2026-09-06 01:48:55.003060+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) IO80211PeerMonitor::checkForDPS() Enter
|
||||||
|
2026-09-06 01:48:55.003076+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: MonitorTimer en0/5000: packet counts: I239 Q0 O295
|
||||||
|
2026-09-06 01:48:55.003089+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: postMessageOnInfraInterface:3463 posting msgId:169 on skywalk infraInterface
|
||||||
|
2026-09-06 01:48:55.003106+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) LQM-WiFi: BE : Count: 292 avgLatencyMs:3 maxLatencyMs:7
|
||||||
|
2026-09-06 01:48:55.003109+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) LQM-WiFi: BK : Count: 2 avgLatencyMs:1 maxLatencyMs:1
|
||||||
|
2026-09-06 01:48:55.003110+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) LQM-WiFi: VI : Count: 0 avgLatencyMs:0 maxLatencyMs:0
|
||||||
|
2026-09-06 01:48:55.003112+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) LQM-WiFi: VO : Count: 1 avgLatencyMs:1 maxLatencyMs:1
|
||||||
|
2026-09-06 01:48:55.020289+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:55.032976+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.056189+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:55.066877+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.099602+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.134018+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.165500+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.914235]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.165510+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.914238]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:55.165510+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.914246]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.165500+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.200023+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:55.200400+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.925162]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.200409+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.925165]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:55.200409+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.925172]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.200409+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.934699]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.200410+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.934702]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:55.200410+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.934708]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:55.200410+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.934712]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.200411+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.934718]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:55.200415+0100 0x4a3b07 Default 0x0 0 0 kernel: [745895.934722]: arm_cpu_init(): cpu 15 online
|
||||||
|
2026-09-06 01:48:55.200398+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.232960+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.236607+0100 0x4e2059 Default 0x0 12260 0 firefox: (Network) [com.apple.network:path] nw_path_libinfo_path_check [C2C8616C-6980-4FDC-9314-BD5FEF18542E Hostname#d4e6e062:0 tcp, legacy-socket, attribution: developer]
|
||||||
|
libinfo check path: <private>
|
||||||
|
2026-09-06 01:48:55.236975+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820] DNSServiceQueryRecord START -- qname: <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>, qtype: A, flags: 0x1D000, interface index: 0, client pid: 12260 (firefox), name hash: 70429ff7
|
||||||
|
2026-09-06 01:48:55.237323+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820->Q2661] Question assigned DNS service 84
|
||||||
|
2026-09-06 01:48:55.237423+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114821] DNSServiceQueryRecord START -- qname: <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>, qtype: AAAA, flags: 0x1D000, interface index: 0, client pid: 12260 (firefox), name hash: 70429ff7
|
||||||
|
2026-09-06 01:48:55.237588+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114821->Q58675] Question assigned DNS service 84
|
||||||
|
2026-09-06 01:48:55.237601+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [Q58675] DetermineUnicastQuerySuppression: Query suppressed for <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='> AAAA (AAAA records are unusable)
|
||||||
|
2026-09-06 01:48:55.237640+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [Q58675] GenerateNegativeResponse: Generating negative response for question
|
||||||
|
2026-09-06 01:48:55.237651+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114821->Q58675] DNSServiceQueryRecord result -- event: add, expired: no, ifindex: 0, name hash: 70429ff7, type: AAAA, rdata: <none>
|
||||||
|
2026-09-06 01:48:55.237903+0100 0x4e172c Default 0x0 639 0 mDNSResponder: [com.apple.mdns:resolver] [Q2661] Sent 55-byte query #1 to <IPv4:BBEFvEFo> over UDP via en0/14 -- id: 0x089F (2207), flags: 0x0100 (Q/Query, RD, NoError), counts: 1/0/0/0, BBgyliWZ IN A?
|
||||||
|
2026-09-06 01:48:55.254247+0100 0x4e172c Default 0x0 639 0 mDNSResponder: [com.apple.mdns:resolver] [Q2661] Received acceptable 155-byte response from <IPv4:BBEFvEFo> over UDP via en0/14 -- id: 0x089F (2207), flags: 0x8180 (R/Query, RD, RA, NoError), counts: 1/5/0/0, BBgyliWZ IN A?, 45 IN CNAME BBRnzZbz, 34 IN A BBXtrByy, 34 IN A BBEFKvtH, 34 IN A BBOpbcRV, 34 IN A BBcTGmrX
|
||||||
|
2026-09-06 01:48:55.254376+0100 0x4e2058 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [Q2661] Handling concluded querier: BBgyliWZ A IN
|
||||||
|
2026-09-06 01:48:55.254443+0100 0x4e2058 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820->Q2661] DNSServiceQueryRecord result -- event: add, expired: no, ifindex: 0, name hash: 70429ff7, type: CNAME, rdata: <mask.hash: 'cDKUsCTEZJB+B/gEdA8N4g=='>
|
||||||
|
2026-09-06 01:48:55.254463+0100 0x4e2058 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [Q2661] AnswerQuestionByFollowingCNAME: following CNAME referral -- CNAME: <mask.hash: 'hCeMQekHxjAP1gJ3ZlTg5A=='>, referral count: 0
|
||||||
|
2026-09-06 01:48:55.254711+0100 0x4e2058 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820->Q8111] Question assigned DNS service 84
|
||||||
|
2026-09-06 01:48:55.254778+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820->Q8111] DNSServiceQueryRecord result -- event: add, expired: no, ifindex: 0, name hash: 4d30d13e, type: A, rdata: <mask.hash: '6b9WEaPm7Gqw4Flcl9+ddQ=='>
|
||||||
|
2026-09-06 01:48:55.254807+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820->Q8111] DNSServiceQueryRecord result -- event: add, expired: no, ifindex: 0, name hash: 4d30d13e, type: A, rdata: <mask.hash: 'TfCnnBV+Z8bdyTgzgKVY6A=='>
|
||||||
|
2026-09-06 01:48:55.254820+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820->Q8111] DNSServiceQueryRecord result -- event: add, expired: no, ifindex: 0, name hash: 4d30d13e, type: A, rdata: <mask.hash: 'I/iOn17GO6bh2gByjdYhqw=='>
|
||||||
|
2026-09-06 01:48:55.254831+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820->Q8111] DNSServiceQueryRecord result -- event: add, expired: no, ifindex: 0, name hash: 4d30d13e, type: A, rdata: <mask.hash: 'J24bRFaq4i4juI0qgCq6/g=='>
|
||||||
|
2026-09-06 01:48:55.254894+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114820] DNSServiceQueryRecord STOP -- name hash: 4d30d13e, duration: 1s
|
||||||
|
2026-09-06 01:48:55.254919+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114821] DNSServiceQueryRecord STOP -- name hash: 70429ff7, duration: 1s
|
||||||
|
2026-09-06 01:48:55.255418+0100 0x11696 Default 0x0 0 0 kernel: [745895.993200]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:55.255424+0100 0x11696 Default 0x0 0 0 kernel: [745895.993205]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:55.255425+0100 0x11696 Default 0x0 0 0 kernel: [745895.993207]: arm_cpu_init(): cpu 15 online
|
||||||
|
2026-09-06 01:48:55.255417+0100 0x11696 Default 0x0 0 0 kernel: SK[6]: flow_entry_alloc fe "0 proc kernel_task(0)firefox nx_port 1 flow_uuid 74BDAEDA-44EF-49A6-9EF0-A23219CFCCE0 flags 0x14120<CONNECTED,QOS_MARKING,EXT_PORT,EXT_FLOWID> ipver=4,src=<IPv4-redacted>.53525,dst=<IPv4-redacted>.443,proto=0x06 mask=0x0000003f,hash=0x7baac1cc tp_proto=0x06"
|
||||||
|
2026-09-06 01:48:55.255454+0100 0x11696 Default 0x0 0 0 kernel: tcp connect outgoing: [<IPv4-redacted>:53525<-><IPv4-redacted>:443] interface: en0 (skipped: 593)
|
||||||
|
so_gencnt: 3510228 t_state: SYN_SENT process: firefox:12260 SYN in/out: 0/1 bytes in/out: 0/0 pkts in/out: 0/0 rtt: 0.0 ms rttvar: 250.0 ms base_rtt: 5 ms error: 0 so_error: 0 svc/tc: 0 flow: 0x96139917
|
||||||
|
2026-09-06 01:48:55.261279+0100 0x1c98 Default 0x0 0 0 kernel: tcp connected: [<IPv4-redacted>:53525<-><IPv4-redacted>:443] interface: en0 (skipped: 593)
|
||||||
|
so_gencnt: 3510228 t_state: ESTABLISHED process: firefox:12260 SYN in/out: 1/1 bytes in/out: 0/0 pkts in/out: 0/0 rtt: 0.0 ms rttvar: 250.0 ms base_rtt: 5 ms error: 0 so_error: 0 svc/tc: 0 flow: 0x96139917
|
||||||
|
2026-09-06 01:48:55.267182+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.300375+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.043247]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.300382+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.043252]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:55.300383+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.043256]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.300383+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.043260]: arm_cpu_init(): cpu 15 online
|
||||||
|
2026-09-06 01:48:55.300384+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.053765]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300385+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.053794]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300386+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.053827]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300386+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.053860]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300388+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.053892]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300388+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.053923]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300389+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.053958]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300395+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.053990]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300396+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.054022]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:55.300365+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.311658+0100 0x4e2059 Default 0x0 12260 0 firefox: (Network) [com.apple.network:path] nw_path_libinfo_path_check [B2491361-91B6-46C7-842E-F521AA802C01 Hostname#12130b20:0 tcp, legacy-socket, attribution: developer]
|
||||||
|
libinfo check path: <private>
|
||||||
|
2026-09-06 01:48:55.311755+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114822] DNSServiceQueryRecord START -- qname: <mask.hash: 'jTFU35oMx3mxTHXGo40ISg=='>, qtype: A, flags: 0x1D000, interface index: 0, client pid: 12260 (firefox), name hash: cc4a6c20
|
||||||
|
2026-09-06 01:48:55.312122+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114822->Q38154] Question assigned DNS service 84
|
||||||
|
2026-09-06 01:48:55.312209+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114823] DNSServiceQueryRecord START -- qname: <mask.hash: 'jTFU35oMx3mxTHXGo40ISg=='>, qtype: AAAA, flags: 0x1D000, interface index: 0, client pid: 12260 (firefox), name hash: cc4a6c20
|
||||||
|
2026-09-06 01:48:55.312387+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114823->Q15486] Question assigned DNS service 84
|
||||||
|
2026-09-06 01:48:55.312402+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [Q15486] DetermineUnicastQuerySuppression: Query suppressed for <mask.hash: 'jTFU35oMx3mxTHXGo40ISg=='> AAAA (AAAA records are unusable)
|
||||||
|
2026-09-06 01:48:55.312448+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [Q15486] GenerateNegativeResponse: Generating negative response for question
|
||||||
|
2026-09-06 01:48:55.312465+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114823->Q15486] DNSServiceQueryRecord result -- event: add, expired: no, ifindex: 0, name hash: cc4a6c20, type: AAAA, rdata: <none>
|
||||||
|
2026-09-06 01:48:55.312719+0100 0x4e2058 Default 0x0 639 0 mDNSResponder: [com.apple.mdns:resolver] [Q38154] Sent 53-byte query #1 to <IPv4:BBEFvEFo> over UDP via en0/14 -- id: 0xCBF5 (52213), flags: 0x0100 (Q/Query, RD, NoError), counts: 1/0/0/0, BBmCsKYR IN A?
|
||||||
|
2026-09-06 01:48:55.329218+0100 0x4e2058 Default 0x0 639 0 mDNSResponder: [com.apple.mdns:resolver] [Q38154] Received acceptable 69-byte response from <IPv4:BBEFvEFo> over UDP via en0/14 -- id: 0xCBF5 (52213), flags: 0x8180 (R/Query, RD, RA, NoError), counts: 1/1/0/0, BBmCsKYR IN A?, 2008 IN A BBYEzGsX
|
||||||
|
2026-09-06 01:48:55.329361+0100 0x4e172c Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [Q38154] Handling concluded querier: BBmCsKYR A IN
|
||||||
|
2026-09-06 01:48:55.329425+0100 0x4e172c Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114822->Q38154] DNSServiceQueryRecord result -- event: add, expired: no, ifindex: 0, name hash: cc4a6c20, type: A, rdata: <mask.hash: 'JGEmga7PvGVKnDgblBSHtg=='>
|
||||||
|
2026-09-06 01:48:55.329646+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114822] DNSServiceQueryRecord STOP -- name hash: cc4a6c20, duration: 0s
|
||||||
|
2026-09-06 01:48:55.329671+0100 0x1639 Default 0x0 639 0 mDNSResponder: [com.apple.mDNSResponder:Default] [R114823] DNSServiceQueryRecord STOP -- name hash: cc4a6c20, duration: 0s
|
||||||
|
2026-09-06 01:48:55.329920+0100 0x11696 Default 0x0 0 0 kernel: [745896.057369]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.329927+0100 0x11696 Default 0x0 0 0 kernel: [745896.057375]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:55.329927+0100 0x11696 Default 0x0 0 0 kernel: [745896.065099]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.329927+0100 0x11696 Default 0x0 0 0 kernel: [745896.065103]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.329927+0100 0x11696 Default 0x0 0 0 kernel: [745896.065106]: arm_cpu_init(): cpu 15 online
|
||||||
|
2026-09-06 01:48:55.329928+0100 0x11696 Default 0x0 0 0 kernel: [745896.072609]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.329927+0100 0x11696 Default 0x0 0 0 kernel: [745896.072615]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.329920+0100 0x11696 Default 0x0 0 0 kernel: SK[7]: flow_entry_alloc fe "0 proc kernel_task(0)firefox nx_port 1 flow_uuid 543F6B4B-FE19-4D0E-B3DD-2A550A742A3D flags 0x14120<CONNECTED,QOS_MARKING,EXT_PORT,EXT_FLOWID> ipver=4,src=<IPv4-redacted>.53540,dst=<IPv4-redacted>.443,proto=0x06 mask=0x0000003f,hash=0x7c762c2b tp_proto=0x06"
|
||||||
|
2026-09-06 01:48:55.329956+0100 0x11696 Default 0x0 0 0 kernel: tcp connect outgoing: [<IPv4-redacted>:53540<-><IPv4-redacted>:443] interface: en0 (skipped: 593)
|
||||||
|
so_gencnt: 3510252 t_state: SYN_SENT process: firefox:12260 SYN in/out: 0/1 bytes in/out: 0/0 pkts in/out: 0/0 rtt: 0.0 ms rttvar: 250.0 ms base_rtt: 0 ms error: 0 so_error: 0 svc/tc: 0 flow: 0x90dee0b7
|
||||||
|
2026-09-06 01:48:55.333766+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.336428+0100 0x1c98 Default 0x0 0 0 kernel: tcp connected: [<IPv4-redacted>:53540<-><IPv4-redacted>:443] interface: en0 (skipped: 593)
|
||||||
|
so_gencnt: 3510252 t_state: ESTABLISHED process: firefox:12260 SYN in/out: 1/1 bytes in/out: 0/0 pkts in/out: 0/0 rtt: 0.0 ms rttvar: 250.0 ms base_rtt: 0 ms error: 0 so_error: 0 svc/tc: 0 flow: 0x90dee0b7
|
||||||
|
2026-09-06 01:48:55.348649+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1 option:32 threshold:50 noRegistrations:1 cachedPeerCount:0 fastDiscoveryInactive:1 fastDiscoveryOnSince:255552912
|
||||||
|
2026-09-06 01:48:55.348674+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: currentInfraTrafficType:9277 checking if realtime upgrade required with inputPackets:62 outputPackets:53 packetThreshold:50
|
||||||
|
2026-09-06 01:48:55.348689+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isSteeringRequired 0 Attempt Count 0
|
||||||
|
2026-09-06 01:48:55.348696+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isRestartSteeringRequired is false
|
||||||
|
2026-09-06 01:48:55.348709+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255552912 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:55.348719+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:55.365920+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.381034+0100 0x4de1c1 Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.MacCoex] Posting Bluetooth Status Notification <private>
|
||||||
|
2026-09-06 01:48:55.399752+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.130195]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:55.399761+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.130199]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.399761+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.130202]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:55.399766+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.141340]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:55.399766+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.141344]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.399766+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.141347]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:55.399750+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.407537+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:55.415091+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:55.433664+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.466363+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.190261]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.466373+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.190271]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.466361+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.499477+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.533288+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.544460+0100 0x4de289 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:55.556747+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:55.566993+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.600082+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.632344+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.666250+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.679793+0100 0x1f39 Default 0x0 1018 3 sharingd: (CoreUtils) [com.apple.sharing:SFBLEScanner] [NearbyInfo] Clearing duplicate cache
|
||||||
|
2026-09-06 01:48:55.679857+0100 0x1f39 Default 0x0 1018 0 sharingd: (WirelessProximity) [com.apple.bluetooth:WirelessProximity] Nearby clear duplicate filter cache type 1
|
||||||
|
2026-09-06 01:48:55.679911+0100 0x1f39 Default 0x0 1018 3 sharingd: (CoreUtils) [com.apple.sharing:SFBLEScanner] [NearbyAction] Clearing duplicate cache
|
||||||
|
2026-09-06 01:48:55.679932+0100 0x1f39 Default 0x0 1018 0 sharingd: (WirelessProximity) [com.apple.bluetooth:WirelessProximity] Nearby clear duplicate filter cache type 0
|
||||||
|
2026-09-06 01:48:55.680056+0100 0x1f39 Default 0x0 1018 3 sharingd: (CoreUtils) [com.apple.sharing:SDNearbyAgentCore] Identification removed stale 27b8d190-4d1a-f393-5364-ee9e347ff0ff
|
||||||
|
2026-09-06 01:48:55.680234+0100 0x4de1c1 Default 0x0 589 0 bluetoothd: (WPDaemon) [com.apple.bluetooth:WirelessProximity] clear dup filter cache for process sharingd (1018) with scan request of type 16, blob: {length = 0, bytes = 0x}, mask {length = 0, bytes = 0x}, active: 0, duplicates: 0, screen on: No Scan, screen off: No Scan, locked: 1, rssi: -90, peers: (
|
||||||
|
) nearby scan mode: 10, advbuf: 0, priority critical: 0, range: 0, usecases:
|
||||||
|
2026-09-06 01:48:55.680281+0100 0x4de1c1 Default 0x0 589 0 bluetoothd: (WPDaemon) [com.apple.bluetooth:WirelessProximity] About to clearDuplicateFilterCache for type: 16
|
||||||
|
2026-09-06 01:48:55.680354+0100 0x4dbefc Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.XPC] Received XPC message "CBMsgIdClearDuplicateFilterCache" from session "com.apple.bluetoothd-central-589-1"
|
||||||
|
2026-09-06 01:48:55.680398+0100 0x4de1c1 Default 0x0 589 0 bluetoothd: (WPDaemon) [com.apple.bluetooth:WirelessProximity] clear dup filter cache for process sharingd (1018) with scan request of type 15, blob: {length = 0, bytes = 0x}, mask {length = 0, bytes = 0x}, active: 0, duplicates: 0, screen on: No Scan, screen off: No Scan, locked: 1, rssi: -90, peers: (
|
||||||
|
) nearby scan mode: 10, advbuf: 0, priority critical: 0, range: 0, usecases:
|
||||||
|
2026-09-06 01:48:55.680422+0100 0x4de1c1 Default 0x0 589 0 bluetoothd: (WPDaemon) [com.apple.bluetooth:WirelessProximity] About to clearDuplicateFilterCache for type: 15
|
||||||
|
2026-09-06 01:48:55.684985+0100 0x4dbefc Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.App] clearDuplicateFilterCache for session "com.apple.bluetoothd" type 16
|
||||||
|
2026-09-06 01:48:55.685072+0100 0x4dbefc Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.XPC] Received XPC message "CBMsgIdClearDuplicateFilterCache" from session "com.apple.bluetoothd-central-589-1"
|
||||||
|
2026-09-06 01:48:55.688823+0100 0x4dbefc Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.App] clearDuplicateFilterCache for session "com.apple.bluetoothd" type 15
|
||||||
|
2026-09-06 01:48:55.699243+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.719580+0100 0x4e1c4e Default 0x0 1376 0 WallpaperAerialsExtension: (MediaToolbox) [com.apple.coremedia:] <<<< FigVideoQueue >>>> FigVideoQueueGMStats: (0x86f1eb400) 0 frames enqueued in the last 6 seconds; 0 were suspended. sbuf queue contains 0 frames (0.000 sec), max PTS: nan; LWM: 1.000 HWM: 2.000. Timebase time 238.895, rate 0.00. (pid 1376)
|
||||||
|
2026-09-06 01:48:55.728451+0100 0x4e1c4e Default 0x0 1376 0 WallpaperAerialsExtension: (MediaToolbox) [com.apple.coremedia:] <<<< FigVideoQueue >>>> FigVideoQueueGMStats: (0x86f1eb800) 0 frames enqueued in the last 6 seconds; 0 were suspended. sbuf queue contains 0 frames (0.000 sec), max PTS: nan; LWM: 1.000 HWM: 2.000. Timebase time 238.895, rate 0.00. (pid 1376)
|
||||||
|
2026-09-06 01:48:55.728480+0100 0x4e1c50 Default 0x0 1376 0 WallpaperAerialsExtension: (MediaToolbox) [com.apple.coremedia:] <<<< FigVideoQueue >>>> FigVideoQueueGMStats: (0x86f1ea800) 0 frames enqueued in the last 6 seconds; 0 were suspended. sbuf queue contains 0 frames (0.000 sec), max PTS: nan; LWM: 1.000 HWM: 2.000. Timebase time 238.895, rate 0.00. (pid 1376)
|
||||||
|
2026-09-06 01:48:55.728505+0100 0x4e23d2 Default 0x0 1376 0 WallpaperAerialsExtension: (MediaToolbox) [com.apple.coremedia:] <<<< FigVideoQueue >>>> FigVideoQueueGMStats: (0x86f1ebc00) 0 frames enqueued in the last 6 seconds; 0 were suspended. sbuf queue contains 0 frames (0.000 sec), max PTS: nan; LWM: 1.000 HWM: 2.000. Timebase time 238.895, rate 0.00. (pid 1376)
|
||||||
|
2026-09-06 01:48:55.731950+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.736436+0100 0x4defc2 Default 0x0 572 0 locationd: (SPFinder) [com.apple.icloud.SPFinder:advertisementCache] saveBeaconPayload: 1
|
||||||
|
2026-09-06 01:48:55.736480+0100 0x4df240 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"onAvengerAdvertisementDetected: got avenger advertisement", "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:55.736490+0100 0x4defc2 Activity 0x9d0e2b 572 0 locationd: (SPFinder) SPAdvertisementCache saveBeaconPayloads:completion:
|
||||||
|
2026-09-06 01:48:55.736553+0100 0x4df240 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"convertToSPAdvertisement", "address":<private>, "data":<private>, "date":<private>, "rssi":<private>, "status":<private>, "reserved":<private>, "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:55.736607+0100 0x4df240 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"onAvengerAdvertisementDetected: advertisement is near-owner from other people and do not process it", "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:55.736837+0100 0x4dfe20 Activity 0x9d0ff0 746 0 searchpartyd: (FindMyBase) SaveObservedAdvertisment
|
||||||
|
2026-09-06 01:48:55.737231+0100 0x4dfe20 Default 0x9d0ff0 746 0 searchpartyd: (FindMyBase) [com.apple.icloud.searchpartyd:observationStoring] TIME: ObservationStoring.Reconcile: <private>
|
||||||
|
2026-09-06 01:48:55.765790+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.799736+0100 0x4e0051 Activity 0x9d0fd0 553 0 mds: (CoreDuetContext) CoreDuet: ClientContext objectForContextualKeyPath:
|
||||||
|
2026-09-06 01:48:55.801580+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.824926+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:55.830347+0100 0x4e1ff6 Error 0x0 0 0 kernel: (Sandbox) Sandbox: spotlightknowledged(19183) deny(1) mach-lookup com.apple.diagnosticd
|
||||||
|
2026-09-06 01:48:55.831847+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.841825+0100 0x4db1a8 Default 0x0 1157 0 mediaanalysisd: Running task MADServiceTextProcessingTask (57542) [QoS: 21]
|
||||||
|
2026-09-06 01:48:55.841897+0100 0x4db1a8 Default 0x0 1157 0 mediaanalysisd: Watchdog already enabled
|
||||||
|
2026-09-06 01:48:55.841929+0100 0x4db1a8 Default 0x0 1157 5 mediaanalysisd: [com.apple.mediaanalysis:persistent] [Scheduler] Running <private> (57542) [QoS: 21 Cost: 0.100]; remaining resource: 0.90
|
||||||
|
2026-09-06 01:48:55.841976+0100 0x4db1a8 Default 0x0 1157 5 mediaanalysisd: [com.apple.mediaanalysis:persistent] [QueuingScheduler] Scheduled foreground task MADServiceTextProcessingTask (MADRequestID 377, VCPMADTaskID 57542) for client <private>
|
||||||
|
2026-09-06 01:48:55.842274+0100 0x4d2d86 Default 0x0 1157 0 mediaanalysisd: (CoreSceneUnderstanding) Setting new context length 512
|
||||||
|
2026-09-06 01:48:55.860531+0100 0x4d2d86 Default 0x0 1157 0 mediaanalysisd: (MIL) MMapped <private> to base address 100d80000 with size 2008
|
||||||
|
2026-09-06 01:48:55.866558+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.872246+0100 0x4de289 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255553435 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:55.872263+0100 0x4de289 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:55.875121+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:55.881387+0100 0x4d2d86 Default 0x0 1157 0 mediaanalysisd: (MIL) MMapped <private> to base address 10c840000 with size 677474
|
||||||
|
2026-09-06 01:48:55.899719+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.934529+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.673815]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.934541+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.673820]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:55.934542+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.673825]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.934527+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.937693+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:55.966206+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:55.966800+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:55.992284+0100 0x35de04 Default 0x0 0 0 kernel: [745896.723885]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:55.992293+0100 0x35de04 Default 0x0 0 0 kernel: [745896.723889]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.992294+0100 0x35de04 Default 0x0 0 0 kernel: [745896.723892]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:55.992294+0100 0x35de04 Default 0x0 0 0 kernel: [745896.732976]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:55.992294+0100 0x35de04 Default 0x0 0 0 kernel: [745896.732979]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:55.992294+0100 0x35de04 Default 0x0 0 0 kernel: [745896.732987]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:55.992282+0100 0x35de04 Default 0x0 0 0 kernel: process_is_plugin_host: running binary "sh" in keys-off mode due to identity: com.apple.sh
|
||||||
|
2026-09-06 01:48:55.993293+0100 0x4e23d7 Default 0x0 0 0 kernel: sh[40634] triggered unnest of range 0x1ee000000->0x1f2000000 of DYLD shared region in VM map 0xe6c827f1353fb483. While not abnormal for debuggers, this increases system memory footprint almost permanently (until the shared region is re-slid).
|
||||||
|
2026-09-06 01:48:55.994465+0100 0x4e23d7 Default 0x0 0 0 kernel: process_is_plugin_host: running binary "bash" in keys-off mode due to identity: com.apple.bash
|
||||||
|
2026-09-06 01:48:55.995238+0100 0x4e23d8 Default 0x0 0 0 kernel: bash[40634] triggered unnest of range 0x1ee000000->0x1f2000000 of DYLD shared region in VM map 0x797c4c4fe647b2b9. While not abnormal for debuggers, this increases system memory footprint almost permanently (until the shared region is re-slid).
|
||||||
|
2026-09-06 01:48:56.000882+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10141] : Active Sockets false ValidSvc 4 NumAirplay 0 AFHandlePending 0 TimeOfLastAFHandleRequest 142708 ms
|
||||||
|
2026-09-06 01:48:56.000916+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10239]: BonJourTrig 1 ValidSvc 4 RTApp 0 TSReq 0 HasActAirDrop 0 SocketsActive 0
|
||||||
|
2026-09-06 01:48:56.000928+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setScheduleState[11041]: reason:UserTriggered sc:Infra Priority and force:NO, AWDL-restore:No
|
||||||
|
2026-09-06 01:48:56.001188+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.001370+0100 0x4e23d9 Default 0x0 0 0 kernel: python3.13[40634] triggered unnest of range 0x1ee000000->0x1f2000000 of DYLD shared region in VM map 0x38801cc981e88d6f. While not abnormal for debuggers, this increases system memory footprint almost permanently (until the shared region is re-slid).
|
||||||
|
2026-09-06 01:48:56.032459+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.772342]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:56.032468+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.772347]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:56.032468+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.772353]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:56.032469+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.772356]: arm_cpu_init(): cpu 15 online
|
||||||
|
2026-09-06 01:48:56.032458+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.049546+0100 0x35de04 Default 0x0 0 0 kernel: process_is_plugin_host: running binary "zsh" in keys-off mode due to identity: com.apple.zsh
|
||||||
|
2026-09-06 01:48:56.050416+0100 0x4e23da Default 0x0 0 0 kernel: zsh[40635] triggered unnest of range 0x1ee000000->0x1f2000000 of DYLD shared region in VM map 0xe6c827f1353fb483. While not abnormal for debuggers, this increases system memory footprint almost permanently (until the shared region is re-slid).
|
||||||
|
2026-09-06 01:48:56.052423+0100 0x4e23da Activity 0x9d1000 40635 0 zsh: (libsystem_info.dylib) Retrieve User by ID
|
||||||
|
2026-09-06 01:48:56.056832+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:56.066425+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.806256]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:56.066433+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.806262]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:56.066423+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.068814+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:56.070421+0100 0x4e23de Default 0x0 0 0 kernel: 2.1.261[40637] triggered unnest of range 0x1ee000000->0x1f2000000 of DYLD shared region in VM map 0xddbc6b7757294f33. While not abnormal for debuggers, this increases system memory footprint almost permanently (until the shared region is re-slid).
|
||||||
|
2026-09-06 01:48:56.100262+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.832882]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:56.100272+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.832886]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:56.100273+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.832889]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:56.100273+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.843348]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:56.100274+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.843354]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:56.100261+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.133625+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.166361+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.200737+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.233953+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.961670]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:56.233962+0100 0x4a3b07 Default 0x0 0 0 kernel: [745896.961676]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:56.233952+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.247221+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:56.267171+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.300298+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.047876]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300305+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.047905]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300307+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.047936]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300307+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.047980]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300309+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048016]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300314+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048045]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300315+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048094]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300317+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048123]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300317+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048155]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300318+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048200]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300319+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048233]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300320+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048263]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:56.300320+0100 0x4a3b07 Default 0x0 0 0 kernel: [745897.048311]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/ad
|
||||||
|
2026-09-06 01:48:56.300287+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.333609+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.366240+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.396619+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1 option:32 threshold:50 noRegistrations:1 cachedPeerCount:0 fastDiscoveryInactive:1 fastDiscoveryOnSince:255553960
|
||||||
|
2026-09-06 01:48:56.396649+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: currentInfraTrafficType:9277 checking if realtime upgrade required with inputPackets:106 outputPackets:155 packetThreshold:50
|
||||||
|
2026-09-06 01:48:56.396662+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isSteeringRequired 0 Attempt Count 0
|
||||||
|
2026-09-06 01:48:56.396671+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isRestartSteeringRequired is false
|
||||||
|
2026-09-06 01:48:56.396691+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255553960 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:56.396701+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:56.400278+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.408316+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:56.432865+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.462152+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:56.466080+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.499132+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.506657+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:56.533255+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.556058+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:56.565352+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.591174+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) allConsoleUserIDs <private>
|
||||||
|
2026-09-06 01:48:56.591400+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] <private> creating an xpc connection {syncXPC: YES}
|
||||||
|
2026-09-06 01:48:56.591427+0100 0x4e226a Default 0x0 1165 0 parsecd: (libxpc.dylib) [com.apple.xpc:connection] [0xba30a1b80] activating connection: mach=true listener=false peer=false name=com.apple.apsd
|
||||||
|
2026-09-06 01:48:56.591483+0100 0x4e1fdb Error 0x0 0 0 kernel: (Sandbox) 2 duplicate reports for Sandbox: spotlightknowledged(19183) deny(1) mach-lookup com.apple.diagnosticd
|
||||||
|
2026-09-06 01:48:56.591494+0100 0x4e1fdb Error 0x0 0 0 kernel: (Sandbox) Sandbox: parsecd(1165) deny(1) mach-lookup com.apple.apsd
|
||||||
|
2026-09-06 01:48:56.591677+0100 0x4e226a Default 0x0 1165 0 parsecd: (libxpc.dylib) [com.apple.xpc:connection] [0xba30a1b80] failed to do a bootstrap look-up: xpc_error=[159: Unknown error: 159]
|
||||||
|
2026-09-06 01:48:56.591714+0100 0x4e226a Default 0x0 1165 0 parsecd: (libxpc.dylib) [com.apple.xpc:connection] [0xba30a1b80] invalidated after a failed init
|
||||||
|
2026-09-06 01:48:56.591861+0100 0x4e1e08 Error 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:xpc] Connection Invalid for service com.apple.apsd
|
||||||
|
2026-09-06 01:48:56.591878+0100 0x4e1e08 Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] invalidationHandler called for <private>
|
||||||
|
2026-09-06 01:48:56.591962+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] <private> Bad response from apsd: <private>
|
||||||
|
2026-09-06 01:48:56.592001+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] Daemon connection did not succeed. Is apsd running?
|
||||||
|
2026-09-06 01:48:56.592065+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] <private>: Will attempt reconnect in 300.000000 seconds
|
||||||
|
2026-09-06 01:48:56.592102+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] <private> disconnected from apsd...
|
||||||
|
2026-09-06 01:48:56.592111+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] <private> failing 0 pending outgoing messages.
|
||||||
|
2026-09-06 01:48:56.592118+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] <private> calling connectIfNecessary
|
||||||
|
2026-09-06 01:48:56.592205+0100 0x4e226a Default 0x0 1165 0 parsecd: (ApplePushService) [com.apple.apsd:connection] <private>: Reconnect scheduled, skipping immediate connection to com.apple.apsd
|
||||||
|
2026-09-06 01:48:56.593107+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:56.599921+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.632882+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.656803+0100 0x4d6c29 Default 0x0 643 0 symptomsd: (SymptomEvaluator) [com.apple.symptomsd:bbh] TCP metrics iteration:270 since 30.00 secs: allflows=44/C=0/R=0/W=0/flows=3/unacked=0/rxbytes=14768/txbytes=4622/rxooo=0/rxdup=0/retx=0
|
||||||
|
2026-09-06 01:48:56.656853+0100 0x4d6c29 Default 0x0 643 0 symptomsd: (SymptomEvaluator) [com.apple.symptomsd:bbh] TCP progress metrics score: 0, problem ratio: 0.00 (baseline: 0.00)
|
||||||
|
2026-09-06 01:48:56.667344+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.699360+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.732739+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.766028+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.766434+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:56.800349+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.832644+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.866678+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.897602+0100 0x4dfc8c Activity 0x9d0fd1 553 0 mds: (CoreDuetContext) CoreDuet: ClientContext objectForContextualKeyPath:
|
||||||
|
2026-09-06 01:48:56.897908+0100 0x4dfc8c Activity 0x9d0fd2 553 0 mds: (CoreDuetContext) CoreDuet: ClientContext objectForContextualKeyPath:
|
||||||
|
2026-09-06 01:48:56.898157+0100 0x4dfc8c Activity 0x9d0fd3 553 0 mds: (CoreDuetContext) CoreDuet: ClientContext objectForContextualKeyPath:
|
||||||
|
2026-09-06 01:48:56.898614+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.920820+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255554484 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:56.920848+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:56.923048+0100 0x4d2d86 Default 0x0 1157 5 mediaanalysisd: [com.apple.mediaanalysis:persistent] [Scheduler] Completed <private> (57542) [QoS: 21]
|
||||||
|
2026-09-06 01:48:56.923306+0100 0x4e1ff6 Error 0x0 0 0 kernel: (Sandbox) Sandbox: spotlightknowledged(19183) deny(1) mach-lookup com.apple.diagnosticd
|
||||||
|
2026-09-06 01:48:56.927493+0100 0x1f39 Default 0x0 1018 3 sharingd: (CoreUtils) [com.apple.sharing:SFBLEScanner] [Prox] Clearing duplicate cache
|
||||||
|
2026-09-06 01:48:56.927537+0100 0x1f39 Default 0x0 1018 0 sharingd: (WirelessProximity) [com.apple.bluetooth:WirelessProximity] ProximityPairing clear duplicate cache
|
||||||
|
2026-09-06 01:48:56.927786+0100 0x4dff68 Default 0x0 589 0 bluetoothd: (WPDaemon) [com.apple.bluetooth:WirelessProximity] clear dup filter cache for process sharingd (1018) with scan request of type 7, blob: {length = 0, bytes = 0x}, mask {length = 0, bytes = 0x}, active: 0, duplicates: 0, screen on: No Scan, screen off: No Scan, locked: 1, rssi: -90, peers: (
|
||||||
|
) nearby scan mode: 10, advbuf: 0, priority critical: 0, range: 0, usecases:
|
||||||
|
2026-09-06 01:48:56.927825+0100 0x4dff68 Default 0x0 589 0 bluetoothd: (WPDaemon) [com.apple.bluetooth:WirelessProximity] About to clearDuplicateFilterCache for type: 7
|
||||||
|
2026-09-06 01:48:56.927883+0100 0x4de1c1 Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.XPC] Received XPC message "CBMsgIdClearDuplicateFilterCache" from session "com.apple.bluetoothd-central-589-1"
|
||||||
|
2026-09-06 01:48:56.930165+0100 0x4de1c1 Default 0x0 589 0 bluetoothd: [com.apple.bluetooth:Server.App] clearDuplicateFilterCache for session "com.apple.bluetoothd" type 7
|
||||||
|
2026-09-06 01:48:56.933261+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.966964+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:56.986301+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:57.000034+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.001909+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10141] : Active Sockets false ValidSvc 4 NumAirplay 0 AFHandlePending 0 TimeOfLastAFHandleRequest 143709 ms
|
||||||
|
2026-09-06 01:48:57.001941+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10239]: BonJourTrig 1 ValidSvc 4 RTApp 0 TSReq 0 HasActAirDrop 0 SocketsActive 0
|
||||||
|
2026-09-06 01:48:57.001953+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setScheduleState[11041]: reason:UserTriggered sc:Infra Priority and force:NO, AWDL-restore:No
|
||||||
|
2026-09-06 01:48:57.015059+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:57.026106+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:57.033034+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.056352+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:57.066726+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.099720+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.118079+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:57.134134+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.166618+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.196253+0100 0x4df240 Default 0x0 572 0 locationd: (SPFinder) [com.apple.icloud.SPFinder:advertisementCache] saveBeaconPayload: 1
|
||||||
|
2026-09-06 01:48:57.196249+0100 0x4defc1 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"onAvengerAdvertisementDetected: got avenger advertisement", "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:57.196286+0100 0x4df240 Activity 0x9d0e2c 572 0 locationd: (SPFinder) SPAdvertisementCache saveBeaconPayloads:completion:
|
||||||
|
2026-09-06 01:48:57.196317+0100 0x4defc1 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"convertToSPAdvertisement", "address":<private>, "data":<private>, "date":<private>, "rssi":<private>, "status":<private>, "reserved":<private>, "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:57.196359+0100 0x4defc1 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"onAvengerAdvertisementDetected: advertisement is near-owner from other people and do not process it", "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:57.196618+0100 0x4dfe20 Activity 0x9d0ff1 746 0 searchpartyd: (FindMyBase) SaveObservedAdvertisment
|
||||||
|
2026-09-06 01:48:57.197020+0100 0x4e069c Default 0x9d0ff1 746 0 searchpartyd: (FindMyBase) [com.apple.icloud.searchpartyd:observationStoring] TIME: ObservationStoring.Reconcile: <private>
|
||||||
|
2026-09-06 01:48:57.201156+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.231679+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.266391+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.285883+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:57.300070+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.047797]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300086+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.047821]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300087+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.047850]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300088+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.047904]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300089+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.047934]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300090+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.047960]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300091+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.048015]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300091+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.048040]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300093+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.048071]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300093+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.048118]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300095+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.048150]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300096+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.048184]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:57.300097+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.048237]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/ad
|
||||||
|
2026-09-06 01:48:57.300059+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.333480+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.366348+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.400255+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.408573+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:57.412060+0100 0x4e2016 Activity 0x9d0fd4 553 0 mds: (CoreDuetContext) CoreDuet: ClientContext objectForContextualKeyPath:
|
||||||
|
2026-09-06 01:48:57.433241+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.436836+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745898.192389 wlan0.E[819960] [wcl] checkTrafficCounters@2686:realTime before<0> -> after<0>, realTimeNumPackets =<2> inactive before<0> -> after<0>, infraNumPackets =<752> statsIntervalMultiplier=<5>
|
||||||
|
2026-09-06 01:48:57.436954+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745898.192514 wlan0.E[819961] [wcl] computeAdvisoryScores@666:LQM-WiFi: WiFi Latency: TxLat-hist [3(100 %) / 0(0 %) / 0(0 %) / 0(0 %)] RxLat-hist [0(0 %) / 0(0 %) / 0(0 %) / 0(0 %)]
|
||||||
|
2026-09-06 01:48:57.437120+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16356
|
||||||
|
2026-09-06 01:48:57.437176+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16356
|
||||||
|
2026-09-06 01:48:57.437219+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16360
|
||||||
|
2026-09-06 01:48:57.437246+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16364
|
||||||
|
2026-09-06 01:48:57.437279+0100 0x4e2150 Default 0x0 929 0 wifip2pd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16356
|
||||||
|
2026-09-06 01:48:57.437351+0100 0x4e2150 Default 0x0 929 0 wifip2pd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16360
|
||||||
|
2026-09-06 01:48:57.437383+0100 0x4e2150 Default 0x0 929 0 wifip2pd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16364
|
||||||
|
2026-09-06 01:48:57.437270+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16368
|
||||||
|
2026-09-06 01:48:57.437406+0100 0x4e2150 Default 0x0 929 0 wifip2pd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16368
|
||||||
|
2026-09-06 01:48:57.437428+0100 0x4e2150 Default 0x0 929 0 wifip2pd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16372
|
||||||
|
2026-09-06 01:48:57.437450+0100 0x4e2150 Default 0x0 929 0 wifip2pd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16376
|
||||||
|
2026-09-06 01:48:57.437472+0100 0x4e2150 Default 0x0 929 0 wifip2pd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16380
|
||||||
|
2026-09-06 01:48:57.438233+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16372
|
||||||
|
2026-09-06 01:48:57.438397+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16376
|
||||||
|
2026-09-06 01:48:57.438965+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16380
|
||||||
|
2026-09-06 01:48:57.439214+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16360
|
||||||
|
2026-09-06 01:48:57.439350+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16364
|
||||||
|
2026-09-06 01:48:57.439506+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16368
|
||||||
|
2026-09-06 01:48:57.439540+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16372
|
||||||
|
2026-09-06 01:48:57.439564+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16376
|
||||||
|
2026-09-06 01:48:57.439587+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) Apple80211EventMonitoringHelper:5877 Skipping Padding Read offset is 16380
|
||||||
|
2026-09-06 01:48:57.439664+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] Driver Event: <airport[642]> _bsd_80211_event_callback: APPLE80211_M_REALTIME_SESSION_NOTIFICATION/156 (en0)
|
||||||
|
2026-09-06 01:48:57.439751+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] Info: <airport[642]> 11ax ASR session: new-state = 0x0
|
||||||
|
2026-09-06 01:48:57.439802+0100 0x4db330 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] Info: <airport[642]> airportdProcessRealtimeSessionNotificationEvent channel score: chq = 3, tx-lat = 5, rx-lat = 3, tx-loss = 5, rx-loss = 5, p95-lat = 3, RT = 0x0, link-reco = 0x0
|
||||||
|
2026-09-06 01:48:57.442603+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745898.198158 wlan0.A[819962] [dk] updateLinkQualityMetrics@750:For current session updated fRxDurMulticast 5892240, fRxDurBroadcast 1909060
|
||||||
|
2026-09-06 01:48:57.445101+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1 option:32 threshold:50 noRegistrations:1 cachedPeerCount:0 fastDiscoveryInactive:1 fastDiscoveryOnSince:255555008
|
||||||
|
2026-09-06 01:48:57.445119+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: currentInfraTrafficType:9277 checking if realtime upgrade required with inputPackets:34 outputPackets:35 packetThreshold:50
|
||||||
|
2026-09-06 01:48:57.445143+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isSteeringRequired 0 Attempt Count 0
|
||||||
|
2026-09-06 01:48:57.445152+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isRestartSteeringRequired is false
|
||||||
|
2026-09-06 01:48:57.445166+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255555008 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:57.445176+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:57.449237+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: IO80211PeerManager::reportDataPathEvents APPLE80211_M_RSSI_CHANGED RSSI -68 noise -94 snr 27. Core0Rssi:-66 Core1Rssi:-71
|
||||||
|
2026-09-06 01:48:57.449468+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] Driver Event: <airport[642]> _bsd_80211_event_callback: APPLE80211_M_RSSI_CHANGED/39 (en0)
|
||||||
|
2026-09-06 01:48:57.449638+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] Info: <airport[642]> Per-MLO link stats
|
||||||
|
2026-09-06 01:48:57.449750+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) airportdProcessTrafficEngineeringEvents: <en0> eventType[39] @[745898.205068], took 0.000171
|
||||||
|
2026-09-06 01:48:57.449808+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] <airport[642]> airportdProcessTrafficEngineeringEvents: <en0> eventType[39] @[745898.205068], took 0.000171
|
||||||
|
2026-09-06 01:48:57.450027+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM: rssi=-68dBm per_ant_rssi=(-66dBm, -71dBm) noise=-94dBm snr=27 cca=6.0% ccaSelfTotal=1 ccaOtherTotal=1 interferenceTotal=4 txRate=324.3Mbps txFrames=293 txFail=0 txRetrans=48 txFallbackRate=108.1Mbps rxRate=648.5Mbps rxFrames=322 rxRetryFrames=3 rxToss=0 beaconRecv=49 beaconSched=49 txFwFrames=0 txFwFail=0 txFwRetrans=0 network = <redacted> bssid = <redacted> channel = 44 BW = 80 band = 2
|
||||||
|
2026-09-06 01:48:57.450274+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: WeightAvgLQM rssi=-69 snr=27 txRate=301662 rxRate=576470
|
||||||
|
2026-09-06 01:48:57.450293+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: (HighBand) rxCrsGlitch=179 rxBphyCrsGlitch=0 rxStart=2993 rxBadPLCP=18 rxBphyBadPLCP=0 rxBadFCS=6 rxFifo0Ovfl=0 rxFifo1Ovfl=0 rx_nobuf=0 rxAnyErr=1643 rxResponseTimeout=16 rxNoDelim=91 rxFrmTooLong=0 rxFrmTooShort=0
|
||||||
|
2026-09-06 01:48:57.450303+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: (HighBand) txRTSFrm=169 txRTSFail=0 rxCTSUcast=168 rxRTSUcast=201 txCTSFrm=201 txAMPDU=170 rxBACK=155 txPhyError=0 txAllFrm=763 txMPDU=0 txUcast=170 rxACKUcast=0 OfdmDesense=0 dB
|
||||||
|
2026-09-06 01:48:57.450311+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: (HighBand) rxBeaconMbss=49 rxBeaconObss=0 rxDataUcastMbss=189 rxMgmtUcastMbss=0 rxCNTRLUcast=549 txACKFrm=0 txBACK=184 ctxFifoFull=0 ctxFifo2Full=0 rxDataMcast=4 rxMgmtMcast=49 rxDrop20s=210
|
||||||
|
2026-09-06 01:48:57.450316+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: RX AMPDU (HighBand) rxAmpdu=205 txBACK(Ucode)=184 rxMpduInAmpdu=214 rxholes=0 rxdup=0 rxstuck=0 rxoow=0 rxoos=1 rxaddbareq=0 txaddbaresp=0 rxbar=0 txdelba=0 rxdelba=0 rxQueued=1 rxRetryNoBA=0
|
||||||
|
2026-09-06 01:48:57.450322+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: WME RX MPDUs (rxPER 0 %) in tids 0:292, 1:0, 2:0, 3:0, 4:0, 5:0, 6:0, 7:0
|
||||||
|
2026-09-06 01:48:57.450328+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: Stats12(5G): bt5g_status=0x3 bt5g_defer_cnt=0 bt5g_no_defer_cnt=0 bt5g_defer_max_switch_dur=0 bt5g_no_defer_max_switch_dur=0 bt5g_switch_succ_cnt=0 bt5g_switch_fail_cnt=0 bt5g_switch_reason_bm=0
|
||||||
|
2026-09-06 01:48:57.450335+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: Stats12(2G): btDur=10ms(0%) status=0x10d reqTypeMap=0x500000 req=23 gnt=14 abort=2 rx1ovfl=0 latency=0 pmAttempt=0 sucPM=0 sucCTS2S=0 wlanTXPreempt=19 wlanRXPreempt=0 apTXafterPM=0 gntAudio=0 denyAudio=0 gntA2DP=0 denyA2DP=0
|
||||||
|
2026-09-06 01:48:57.450342+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: (HighBand) rxToss=0 rxLastTossRsn=39 rxNoFrag=0 rxNoCmplId=0 rxNoHaddr=0 rxMulti=2 rxUndec=0
|
||||||
|
2026-09-06 01:48:57.450354+0100 0x4db330 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] BEGIN REQ [GET INTF NAME] (pid=643 proc=symptomsd bundleID=com.apple.symptomsd codesignID=com.apple.symptomsd service=com.apple.private.corewifi-xpc qos=17 intf=(null) uuid=62638 info=(null))
|
||||||
|
2026-09-06 01:48:57.450365+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: Stats12(2G): CrtPriCnt=69 PriCnt=88 gntSniff=0 denySniff=0 antGrant0~10ms=157 antGrant10~30ms=0 antGrant30~60ms=0 antGrant>60ms=0 APLeakiness=0 rr_cnt=0 rr_succ_cnt=0
|
||||||
|
2026-09-06 01:48:57.450398+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: Stats12(2G): btDurAir=97ms(1%) desenseMode=0 wlrssi=0 btrssi=0 profile2gActive=0 profile5gActive=6 inactiveDur=4485 btPmAttemptNoackCnt=0 status2=0x0 [5000ms]
|
||||||
|
2026-09-06 01:48:57.450406+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: 15.4 Coex Stats V3: Mode=0 Request=0 Grant=0 Duration=0us AckedPM=0 TXedCTS2A=0 GrantDelay=0 CritPhyCal=0 CritRateRecovery=0 CritBcnLoss=0 CritHpp=0 CritBT=0 CritAwdlNan=0 CritMaxDur=1050 CirtAggregate=0 CritBitMapMax=2
|
||||||
|
2026-09-06 01:48:57.450429+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: 15.4 Coex Stats V3: 154_tx_req_cnt=0 154_rx_req_cnt=0 154_tx_deny_cnt=0 154_rx_deny_cnt=0 [5000ms]
|
||||||
|
2026-09-06 01:48:57.450417+0100 0x4db330 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] [62638] Incoming QoS is less than 'default', promoting to 'default'
|
||||||
|
2026-09-06 01:48:57.450437+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: TX(00:00:00:00:00:00) AC<SU MS NB NRS NA CM EX TF FFP MRET FLE> BE<292 0 0 0 0 0 0 0 0 0 0> (5002ms)
|
||||||
|
2026-09-06 01:48:57.450474+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: TX(00:00:00:00:00:00) AC<SU MS NB NRS NA CM EX TF FFP MRET FLE> BK<2 0 0 0 0 0 0 0 0 0 0> (5002ms)
|
||||||
|
2026-09-06 01:48:57.450483+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: TX(00:00:00:00:00:00) AC<SU MS NB NRS NA CM EX TF FFP MRET FLE> VI<0 0 0 0 0 0 0 0 0 0 0> (5002ms)
|
||||||
|
2026-09-06 01:48:57.450504+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: TX(00:00:00:00:00:00) AC<SU MS NB NRS NA CM EX TF FFP MRET FLE> VO<1 0 0 0 0 0 0 0 0 0 0> (5002ms)
|
||||||
|
2026-09-06 01:48:57.450596+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] LQM-WiFi: L3 Control VO TX(00:00:00:00:00:00) Success=1 NoACK=0 Expired=0 OtherErr=0
|
||||||
|
2026-09-06 01:48:57.450869+0100 0x4db330 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] END REQ [GET INTF NAME] took 0.000471584s (pid=643 proc=symptomsd bundleID=com.apple.symptomsd codesignID=com.apple.symptomsd service=com.apple.private.corewifi-xpc qos=21 intf=(null) uuid=62638 err=0 reply=<redacted>
|
||||||
|
2026-09-06 01:48:57.450959+0100 0x4df3b0 Default 0x0 643 0 symptomsd: (SymptomEvaluator) [com.apple.symptomsd:rnf] L2 Metrics on en0: rssi: -68 [-1,-1] -> -68, snr: 27 (cca [wake/total] self/other/intf): [1,1]/[0,1]/[4,4]/6 (txFrames/txReTx/txFail): 293/48/0 -> (was/is) 0/0, txRate: 324.0, rxRate: 648.0, rssiEMA: -67.6, snrEMA: 27.0, txRateEMA: 312.7, rxRateEMA: 534.6, isPrimary: 1, radio: 802.11ax
|
||||||
|
2026-09-06 01:48:57.451145+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] BEGIN REQ [GET BSSID] (pid=643 proc=symptomsd bundleID=com.apple.symptomsd codesignID=com.apple.symptomsd service=com.apple.private.corewifi-xpc qos=17 intf=(null) uuid=67C47 info=(null))
|
||||||
|
2026-09-06 01:48:57.451176+0100 0x4dfc53 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] [67C47] Incoming QoS is less than 'default', promoting to 'default'
|
||||||
|
2026-09-06 01:48:57.451603+0100 0x4dc356 Default 0x0 642 0 airportd: (CoreWiFi) [com.apple.WiFiManager:] [corewifi] END REQ [GET BSSID] took 0.000425417s (pid=643 proc=symptomsd bundleID=com.apple.symptomsd codesignID=com.apple.symptomsd service=com.apple.private.corewifi-xpc qos=21 intf=(null) uuid=67C47 err=0 reply=<redacted>
|
||||||
|
2026-09-06 01:48:57.467726+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.499311+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.510770+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setSyncFramePeriod:25958 +++ Configuring the sync params(period, length) {16 16}, extlen = 16
|
||||||
|
2026-09-06 01:48:57.510803+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setSyncFramePeriod::Setting PSF period to 110
|
||||||
|
2026-09-06 01:48:57.510824+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: programChipWithNewMaster: Programming new Mst: 82:45:A3:D5:31:34 at 255555.074ms HC 0 met 510 my met 510
|
||||||
|
2026-09-06 01:48:57.510847+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setSyncFramePeriod:25958 +++ Configuring the sync params(period, length) {16 16}, extlen = 16
|
||||||
|
2026-09-06 01:48:57.510863+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setSyncFramePeriod::Setting PSF period to 110
|
||||||
|
2026-09-06 01:48:57.510873+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: runElection: ElectionParamsChanged - Prog (F:0, 0) New P:82:45:A3:D5:31:34 R:82:45:A3:D5:31:34 [RLFC:49492 HC:0 RMet:510 SLFC:49492 SMet:510]
|
||||||
|
2026-09-06 01:48:57.510891+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:57.533659+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.558289+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:57.565927+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.586473+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:57.599662+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.633376+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.641692+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:57.665974+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.699771+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.733070+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.766563+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.800210+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.832349+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.561636]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:57.832356+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.561642]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:57.832347+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.854407+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:57.866599+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.899028+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.932110+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.966597+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:57.969365+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255555533 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:57.969385+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:58.000845+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10141] : Active Sockets false ValidSvc 4 NumAirplay 0 AFHandlePending 0 TimeOfLastAFHandleRequest 144708 ms
|
||||||
|
2026-09-06 01:48:58.000878+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10239]: BonJourTrig 1 ValidSvc 4 RTApp 0 TSReq 0 HasActAirDrop 0 SocketsActive 0
|
||||||
|
2026-09-06 01:48:58.000891+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setScheduleState[11041]: reason:UserTriggered sc:Infra Priority and force:NO, AWDL-restore:No
|
||||||
|
2026-09-06 01:48:58.001361+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.033116+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.034822+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:58.056677+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:58.066963+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.099429+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.132720+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.133661+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:58.139542+0100 0x4e2016 Activity 0x9d0fd5 553 0 mds: (CoreDuetContext) CoreDuet: ClientContext objectForContextualKeyPath:
|
||||||
|
2026-09-06 01:48:58.166016+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:58.166457+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.200626+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.233134+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.970309]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:58.233143+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.970312]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:58.233143+0100 0x4a3b07 Default 0x0 0 0 kernel: [745898.970319]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:58.233133+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.266457+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.299153+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.047915]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299159+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.047958]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299161+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.047998]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299168+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048043]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299168+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048079]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299169+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048108]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299170+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048147]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299171+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048187]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299171+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048211]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299173+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048265]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299173+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048290]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299174+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048321]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:58.299175+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.048363]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/ad
|
||||||
|
2026-09-06 01:48:58.299142+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.333347+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.366017+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.400317+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.409677+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:58.432904+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.466670+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.494467+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1 option:32 threshold:50 noRegistrations:1 cachedPeerCount:0 fastDiscoveryInactive:1 fastDiscoveryOnSince:255556058
|
||||||
|
2026-09-06 01:48:58.494496+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: currentInfraTrafficType:9277 checking if realtime upgrade required with inputPackets:33 outputPackets:32 packetThreshold:50
|
||||||
|
2026-09-06 01:48:58.494510+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isSteeringRequired 0 Attempt Count 0
|
||||||
|
2026-09-06 01:48:58.494517+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isRestartSteeringRequired is false
|
||||||
|
2026-09-06 01:48:58.494532+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255556058 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:58.494541+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:58.498467+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.533601+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.558028+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:58.561375+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:58.566902+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.599663+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.633427+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.665831+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.683749+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:58.690410+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:58.699997+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.732558+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.765115+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.799353+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.832325+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.865970+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.898628+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.933533+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.966260+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:58.989972+0100 0x4defc1 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"onAvengerAdvertisementDetected: got avenger advertisement", "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:58.989993+0100 0x4defc2 Default 0x0 572 0 locationd: (SPFinder) [com.apple.icloud.SPFinder:advertisementCache] saveBeaconPayload: 1
|
||||||
|
2026-09-06 01:48:58.990030+0100 0x4defc2 Activity 0x9d0e2d 572 0 locationd: (SPFinder) SPAdvertisementCache saveBeaconPayloads:completion:
|
||||||
|
2026-09-06 01:48:58.990047+0100 0x4defc1 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"convertToSPAdvertisement", "address":<private>, "data":<private>, "date":<private>, "rssi":<private>, "status":<private>, "reserved":<private>, "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:58.990092+0100 0x4defc1 Default 0x0 572 0 locationd: [com.apple.locationd.Core:Harvester] {"msg":"onAvengerAdvertisementDetected: advertisement is near-owner from other people and do not process it", "subHarvester":"Avenger"}
|
||||||
|
2026-09-06 01:48:58.990439+0100 0x4e069c Activity 0x9d0ff2 746 0 searchpartyd: (FindMyBase) SaveObservedAdvertisment
|
||||||
|
2026-09-06 01:48:58.990963+0100 0x4e069c Default 0x9d0ff2 746 0 searchpartyd: (FindMyBase) [com.apple.icloud.searchpartyd:observationStoring] TIME: ObservationStoring.Reconcile: <private>
|
||||||
|
2026-09-06 01:48:59.000898+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.002606+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10141] : Active Sockets false ValidSvc 4 NumAirplay 0 AFHandlePending 0 TimeOfLastAFHandleRequest 145710 ms
|
||||||
|
2026-09-06 01:48:59.002639+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLState[10239]: BonJourTrig 1 ValidSvc 4 RTApp 0 TSReq 0 HasActAirDrop 0 SocketsActive 0
|
||||||
|
2026-09-06 01:48:59.002652+0100 0x4df69c Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: setScheduleState[11041]: reason:UserTriggered sc:Infra Priority and force:NO, AWDL-restore:No
|
||||||
|
2026-09-06 01:48:59.017950+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255556581 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:59.017974+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:59.031972+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.056221+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:59.066719+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.081521+0100 0x4e20a1 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:59.081758+0100 0x4dfe54 Default 0x0 538 8 powerd: [com.apple.powerd:BDC] Battery percentage last 80 now 79
|
||||||
|
2026-09-06 01:48:59.082100+0100 0x144b Default 0x0 606 0 WindowServer: (BackBoardHIDEventFoundation) [com.apple.BackBoard:EventDelivery] destinations for Keyboard event: (<keyboardFocus; kitty.1343; token: viewbridge-key-window>)
|
||||||
|
2026-09-06 01:48:59.082142+0100 0x144b Default 0x0 606 7 WindowServer: (SkyLight) [com.apple.SkyLight:KeyboardEvent] delivery manager destinations for kCGSEventFlagsChanged event: <keyboardFocus; pid: 1343; token: viewbridge-key-window>
|
||||||
|
2026-09-06 01:48:59.082554+0100 0x2e72 Default 0x0 1343 0 kitty: (TextInputUIMacHelper) [com.apple.TextInputUI:CursorUI] -[TUINSCursorUIController scheduleUpdateCursorLocation]
|
||||||
|
2026-09-06 01:48:59.083180+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:59.083489+0100 0x4e002b Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:59.087177+0100 0x4dfe54 Default 0x0 538 8 powerd: [com.apple.powerd:BDC] Sending data to powerlog for group BDC_SBC
|
||||||
|
2026-09-06 01:48:59.099446+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.110515+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) IO80211ControllerMonitor::setAMPDUstat unhandled kAMPDUStat_ type 14
|
||||||
|
2026-09-06 01:48:59.110533+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) IO80211ControllerMonitor::setAMPDUstat unhandled kAMPDUStat_ type 13
|
||||||
|
2026-09-06 01:48:59.110544+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) IO80211ControllerMonitor::setAMPDUstat unhandled kAMPDUStat_ type 18
|
||||||
|
2026-09-06 01:48:59.115191+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745899.870747 wlan0.A[819963] [dk] parsePHYEcounterV5@31099: Version = 5, ChanSpec=0xe22a, noise_req=13095, noise_crsbit=8021, cal_counter=4181, rxchain=15, chiptemp=37, femtemp=-50, btcx_mode=0, ltecx_mode=0, weakest_rssi=-74, chan_switc
|
||||||
|
2026-09-06 01:48:59.115222+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745899.870782 wlan0.A[819964] [dk] parsePHYEcounterV5@31104: gbd_ofdm_desense=0, gbd_bphy_desense=0, featureflag=96, crsmin_pwr_apply_cnt=33882, preempt_status2=64278, ed_crs_status=0, measurehold=0, ocl_disable_reqs=0, gci_rst_tx= 15463,
|
||||||
|
2026-09-06 01:48:59.115234+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745899.870797 wlan0.A[819965] [dk] parsePHYEcounterV5@31109: asym_intf_avg_noise[0/1]= [-98, -98], asym_intf_latest_noise[0/1]=[-98, -98], pwridx_init[0/1]=[35, 37], baseindxval[0/1]=[36, 37]
|
||||||
|
2026-09-06 01:48:59.115247+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745899.870809 wlan0.A[819966] [dk] parsePHYEcounterV5@31115: estpwradj[0/1]=[60, 60], ed_threshold[0/1]=[-74, -75], crsmin_pwr_idx[0/1]=[7, 7], crsmin_pwr[0/1]=[-95, -94], noise_level_inst[0/1]=[-96, -95]
|
||||||
|
2026-09-06 01:48:59.120723+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745899.876283 wlan0.A[819967] [dk] updateChanSeqCommonStats@3612:P2P chan 44: dur 744287836, entry 18673, crsglitch 9822232, start 132520737, badplcp 763283, badfcs 3170566, f0ov 420, f1ov 577, anyerr 30581679, resptout 1168070, frmtooln 0
|
||||||
|
2026-09-06 01:48:59.120738+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745899.876300 wlan0.A[819968] [dk] updateChanSeqCommonStats@3612:P2P chan 6: dur 15735521, entry 1, crsglitch 696648, start 188156, badplcp 6484, badfcs 33786, f0ov 0, f1ov 0, anyerr 23863, resptout 0, frmtooln 0, frmtoosh 7630
|
||||||
|
2026-09-06 01:48:59.120744+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745899.876308 wlan0.A[819969] NAV stats: ch 44 (135039794 689529 490534 167738 748 86) ch 6 (132084 1835 1281 106 1750 5)
|
||||||
|
2026-09-06 01:48:59.120783+0100 0x1bf1 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) 745899.876346 wlan0.A[819970] [dk] printDeltaSliceUcodeRxErrs@1925:(HighBand) rxNotPrimary20s=1245 ucodeLateDrop=0 rxInvalidUcodeLateWrongMACPHY=0 rxLate30us=0 rxLegacyFrmInvalid=0 txSifsErr=0 ooseqMacSusp=0
|
||||||
|
2026-09-06 01:48:59.132741+0100 0x1551 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] Driver Event: <airport[642]> _bsd_80211_event_callback: APPLE80211_M_BT_COEX_MODE_STATS_EVENT/190 (en0)
|
||||||
|
2026-09-06 01:48:59.133026+0100 0x4dc356 Default 0x0 642 0 airportd: (IO80211) [com.apple.WiFiManager:] Info: <airport[642]> handle airportdProcessBtCoexStatsEvent
|
||||||
|
2026-09-06 01:48:59.133418+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.165682+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.170964+0100 0x35de04 Default 0x0 0 0 kernel: process_is_plugin_host: running binary "sh" in keys-off mode due to identity: com.apple.sh
|
||||||
|
2026-09-06 01:48:59.172051+0100 0x4e2411 Default 0x0 0 0 kernel: sh[40639] triggered unnest of range 0x1ee000000->0x1f2000000 of DYLD shared region in VM map 0xe6c827f1353fb483. While not abnormal for debuggers, this increases system memory footprint almost permanently (until the shared region is re-slid).
|
||||||
|
2026-09-06 01:48:59.173175+0100 0x4e2411 Default 0x0 0 0 kernel: [745899.928319]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:59.173180+0100 0x4e2411 Default 0x0 0 0 kernel: [745899.928329]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:59.173174+0100 0x4e2411 Default 0x0 0 0 kernel: process_is_plugin_host: running binary "bash" in keys-off mode due to identity: com.apple.bash
|
||||||
|
2026-09-06 01:48:59.174017+0100 0x4e2412 Default 0x0 0 0 kernel: bash[40639] triggered unnest of range 0x1ee000000->0x1f2000000 of DYLD shared region in VM map 0xb2a61f073e8c5e91. While not abnormal for debuggers, this increases system memory footprint almost permanently (until the shared region is re-slid).
|
||||||
|
2026-09-06 01:48:59.200132+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.949345]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:59.200143+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.949348]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:59.200144+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.949354]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:59.200145+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.949357]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:59.200145+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.949360]: arm_cpu_init(): cpu 15 online
|
||||||
|
2026-09-06 01:48:59.200126+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.214584+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.en0: set Datapath Open needsTrigger 0
|
||||||
|
2026-09-06 01:48:59.235340+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.984752]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:59.235348+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.984754]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:59.235349+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.984760]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:59.235350+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.984763]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:59.235339+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.267868+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.997722]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:59.267880+0100 0x4a3b07 Default 0x0 0 0 kernel: [745899.997727]: arm_cpu_init(): cpu 15 online
|
||||||
|
2026-09-06 01:48:59.267881+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.010921]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:59.267882+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.010924]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:59.267883+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.010929]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:59.267892+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.020472]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:59.267892+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.020475]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:59.267892+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.020480]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:59.267894+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.020483]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:59.267894+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.020486]: arm_cpu_init(): cpu 15 online
|
||||||
|
2026-09-06 01:48:59.267864+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.300117+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.030709]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:59.300125+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.030712]: arm_cpu_init(): cpu 11 online
|
||||||
|
2026-09-06 01:48:59.300125+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.030718]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:59.300125+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.030721]: arm_cpu_init(): cpu 14 online
|
||||||
|
2026-09-06 01:48:59.300126+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046426]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300128+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046472]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300129+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046504]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300129+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046540]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300130+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046572]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300131+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046618]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300133+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046638]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300134+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046668]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300135+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046700]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300141+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046749]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300142+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046778]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300143+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.046809]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrdincr)/0x0 vc/sc/tz/crh/tid:0x2/0x1/0x0/0x0/0x2f size:0x3 piopkt:0x0 user:0x4 userext:0x0 hasdata/has2data:0x0/0x0 dstchildid/dstparentid:0x3f/0x2 srcchildid/srcparentid:0x8ea/0x1d addrl/addrh:0x0/0x0 timestamp:0x0 errorvec:0x10
|
||||||
|
2026-09-06 01:48:59.300143+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.047460]: Ignored NI0 NI0 niGeneral 2 NO_ACCESS error (0x00000010) cmd/st:0x14(ncrd
|
||||||
|
2026-09-06 01:48:59.300105+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.333193+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.366943+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.098299]: arm_cpu_init(): cpu 10 online
|
||||||
|
2026-09-06 01:48:59.366951+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.098302]: arm_cpu_init(): cpu 12 online
|
||||||
|
2026-09-06 01:48:59.366952+0100 0x4a3b07 Default 0x0 0 0 kernel: [745900.098309]: arm_cpu_init(): cpu 13 online
|
||||||
|
2026-09-06 01:48:59.366941+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.400043+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.409533+0100 0x2e72 Default 0x0 1343 7 kitty: (SkyLight) [com.apple.SkyLight:default] SLSGetDisplaysWithOpenGLDisplayMask, 3614: Display 0x00000001: Mode 54, (0 x 0) [1728 x 1117], Depth 32 BPP, active
|
||||||
|
2026-09-06 01:48:59.432119+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.467080+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.499502+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.533324+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.543029+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1 option:32 threshold:50 noRegistrations:1 cachedPeerCount:0 fastDiscoveryInactive:1 fastDiscoveryOnSince:255557106
|
||||||
|
2026-09-06 01:48:59.543059+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: currentInfraTrafficType:9277 checking if realtime upgrade required with inputPackets:99 outputPackets:144 packetThreshold:50
|
||||||
|
2026-09-06 01:48:59.543072+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isSteeringRequired 0 Attempt Count 0
|
||||||
|
2026-09-06 01:48:59.543090+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: peerChannelSteer: PEER_CHANNEL_STEER isRestartSteeringRequired is false
|
||||||
|
2026-09-06 01:48:59.543104+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9668] : In AW: Tx:0 Rx:0 Prev Tx:0 Rx:0 time since 255557106 indatamode 1 infraPriority 1 cpc 0
|
||||||
|
2026-09-06 01:48:59.543150+0100 0x4de131 Default 0x0 0 0 kernel: (com.apple.DriverKit-AppleBCMWLAN.dext) wlan0:com.apple.p2p.awdl0: monitorAWDLDataMode[9673] : Ignore changing the state as infraPriority 1 P2P-CPC 0
|
||||||
|
2026-09-06 01:48:59.565970+0100 0x4a3b07 Error 0x0 0 0 kernel: (IOSurface) SID: 0x0 task: <decode: mismatch for [%p] got [SCALAR private sz:0]> buffer: 0x0 buffer->fClientTask = 0x0 not found
|
||||||
|
2026-09-06 01:48:59.577680+0100 0x4dfe54 Default 0x0 538 8 powerd: [com.apple.powerd:displayState] DesktopMode check on Battery 0
|
||||||
|
2026-09-06 01:48:59.578339+0100 0x144b Default 0x0 606 0 WindowServer: (BackBoardHIDEventFoundation) [com.apple.BackBoard:EventDelivery] destinations for Keyboard event: (<keyboardFocus; kitty.1343; token: viewbridge-key-window>)
|
||||||
|
2026-09-06 01:48:59.578385+0100 0x144b Default 0x0 606 7 WindowServer: (SkyLight) [com.apple.SkyLight:KeyboardEvent] delivery manager destinations for kCGSEventKeyDown event: <keyboardFocus; pid: 1343; token: viewbridge-key-window>
|
||||||
|
2026-09-06 01:48:59.579704+0100 0x2e72 Default 0x0 1343 0 kitty: (SidecarCore) [com.apple.sidecar:core] updateDevices: deviceGeneration=0 (prev=0) displayGeneration=0 (prev=0)
|
||||||
|
2026-09-06 01:48:59.579746+0100 0x2e72 Default 0x0 1343 0 kitty: (SidecarCore) [com.apple.sidecar:core] updateDevices(connectedDevices): returning 0 device(s) to caller: (
|
||||||
|
)
|
||||||
|
2026-09-06 01:48:59.579759+0100 0x2e72 Default 0x0 1343 0 kitty: (SidecarCore) [com.apple.sidecar:core] updateDevices: deviceGeneration=0 (prev=0) displayGeneration=0 (prev=0)
|
||||||
|
2026-09-06 01:48:59.579773+0100 0x2e72 Default 0x0 1343 0 kitty: (SidecarCore) [com.apple.sidecar:core] updateDevices(devices): returning 0 device(s) to caller: (
|
||||||
|
)
|
||||||
|
2026-09-06 01:48:59.580218+0100 0x2e72 Default 0x0 1343 0 kitty: (SidecarCore) [com.apple.sidecar:core] updateDevices: deviceGeneration=0 (prev=0) displayGeneration=0 (prev=0)
|
||||||
|
2026-09-06 01:48:59.580238+0100 0x2e72 Default 0x0 1343 0 kitty: (SidecarCore) [com.apple.sidecar:core] updateDevices(connectedDevices): returning 0 device(s) to caller: (
|
||||||
|
)
|
||||||
|
2026-09-06 01:48:59.580248+0100 0x2e72 Default 0x0 1343 0 kitty: (SidecarCore) [com.apple.sidecar:core] updateDevices: deviceGeneration=0 (prev=0) displayGeneration=0 (prev=0)
|
||||||
|
2026-09-06 01:48:59.580273+0100 0x2e72 Default 0x0 1343 0 kitty: (SidecarCore) [com.apple.sidecar:core] updateDevices(devices): returning 0 device(s) to caller: (
|
||||||
|
)
|
||||||
|
2026-09-06 01:48:59.580663+0100 0x2e72 Default 0x0 1343 0 kitty: (TextInputUIMacHelper) [com.apple.TextInputUI:CursorUI] -[TUINSCursorUIController scheduleUpdateCursorLocation]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
2026-09-06 10:35:55,217 INFO delete-py-log event=eventC labelC=1
|
||||||
|
2026-09-06 10:35:55,627 INFO delete-py-log event=eventB labelB=one
|
||||||
|
2026-09-06 10:35:56,442 INFO delete-py-log event=eventB labelA=1
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
-149
@@ -1,149 +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, take_while1};
|
|
||||||
use nom::character::complete::{char, none_of, one_of};
|
|
||||||
use nom::sequence::delimited;
|
|
||||||
use nom::IResult;
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)?;
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The value of the `ottyl_fmt` reserved param (`crate::engine::compile_filter`/`eval_alert_expr`):
|
|
||||||
/// which format a line was recognized as when parsed. JSON is checked first 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
|
|
||||||
/// neither is `"none"`.
|
|
||||||
pub fn detect_format(text: &str) -> &'static str {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-151
@@ -1,157 +1,11 @@
|
|||||||
mod alerts;
|
|
||||||
mod api;
|
mod api;
|
||||||
mod data_value;
|
mod cli;
|
||||||
mod dto;
|
mod config;
|
||||||
|
mod consts;
|
||||||
mod engine;
|
mod engine;
|
||||||
mod filter_datetime;
|
pub(crate) mod flavour;
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let args = Args::parse();
|
cli::run().await;
|
||||||
|
|
||||||
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"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-143
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-645
@@ -1,645 +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,
|
|
||||||
#[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 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# 050 — Bound logfmt-overfit param cardinality; derive params from `log stream`-style structured headers
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
A real-world `log stream` (macOS unified log) dump (`huig_dump.txt`, ~700
|
|
||||||
lines) exposes two related problems in the auto-param-derivation path
|
|
||||||
(`auto_declare_params` in `src/engine.rs`, backed by `extract_pairs` in
|
|
||||||
`src/logfmt.rs`, run on *every* ingested line at `src/engine.rs:155`):
|
|
||||||
|
|
||||||
1. **`=`-overfitting produces runaway, low-value param cardinality.**
|
|
||||||
`extract_pairs` scans anywhere in the line for `key=value`/`key="value"`
|
|
||||||
and treats every match as a declarable param. On the sample dump this
|
|
||||||
discovers **220 distinct auto-declared param names** from under 700
|
|
||||||
lines — one-off WiFi/radio stat dumps alone contribute lines with
|
|
||||||
20-27 `key=value` pairs each (e.g. `txRTSFrm=169 txRTSFail=0
|
|
||||||
rxCTSUcast=168 ...`), most of which are internal counters that will
|
|
||||||
never recur meaningfully. Some matches are outright false positives:
|
|
||||||
base64-encoded blobs ending in padding (e.g.
|
|
||||||
`rKTEtG7JmnThhoKPbZiVrA=='>,`) get parsed as a `key=value` pair because
|
|
||||||
`=` precedes non-whitespace. Since `auto_declare_params` "never
|
|
||||||
overwrites an existing declaration" and there is no cap or pruning
|
|
||||||
anywhere in the ingest path, every distinct key seen ever becomes a
|
|
||||||
permanent declaration — this is almost certainly the source of the
|
|
||||||
reported ingest lag on this input (per-line nom scanning cost is
|
|
||||||
presumably fine; the unbounded, ever-growing declaration/index set is
|
|
||||||
the more likely culprit and should be measured either way) and it
|
|
||||||
clutters the params UI with noise no user asked for.
|
|
||||||
|
|
||||||
2. **The input's real structure is being ignored.** `log stream` output is
|
|
||||||
not logfmt at all — it's a fixed-column header (`Timestamp`, `Thread`,
|
|
||||||
`Type`, `Activity`, `PID`, `TTL`) followed by a free-text
|
|
||||||
`process: message` tail (see header row and body of `huig_dump.txt`).
|
|
||||||
`Type` (`Default`/`Error`/`Activity`), the process name (`kernel`,
|
|
||||||
`airportd`, `bluetoothd`, `WindowServer`, ...), and `PID` are
|
|
||||||
low-cardinality, genuinely useful params that could be derived
|
|
||||||
directly and cheaply from column position — with no `=`-scanning, no
|
|
||||||
ambiguity, and no risk of false positives — instead of (or in addition
|
|
||||||
to) whatever generic `key=value` pairs happen to appear later in the
|
|
||||||
line.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add a bound/safeguard on `auto_declare_params` so a single burst of
|
|
||||||
unusual lines can't permanently inflate the declared-param set:
|
|
||||||
options include (pick and justify one, or combine) a cap on the total
|
|
||||||
number of auto-derived declarations, a per-line cap on how many
|
|
||||||
`key=value` pairs from one line are eligible for auto-declaration, and/or
|
|
||||||
a heuristic to reject candidate pairs that look like base64 fragments
|
|
||||||
(trailing `=`/`==` with a base64-alphabet value and no realistic bareword
|
|
||||||
shape) in `src/logfmt.rs`'s `extract_pairs`/`pair`.
|
|
||||||
- Add an optional structured-header extractor for `log stream`-shaped
|
|
||||||
input: detect the fixed-column header/row shape (timestamp, thread id,
|
|
||||||
log type, activity id, pid, ttl, `process: message`) and surface
|
|
||||||
`Type`/process-name/`PID` as derivable params, analogous to how
|
|
||||||
`ottyl_fmt` (ticket 044) records which format a line matched. Decide
|
|
||||||
whether this is a new `ottyl_fmt` variant (e.g. `"log_stream"`) or a
|
|
||||||
separate always-attempted extraction that runs alongside
|
|
||||||
logfmt/JSON detection, and record the decision in the PR.
|
|
||||||
- Use `huig_dump.txt` (already in the repo root) as the seed fixture for
|
|
||||||
tests — copy the relevant lines into a test fixture under wherever
|
|
||||||
existing ingest fixtures live, rather than reading the file from the
|
|
||||||
repo root at test time.
|
|
||||||
- Re-measure ingest throughput on this fixture before/after to confirm
|
|
||||||
the lag is actually addressed, and note the before/after numbers in the
|
|
||||||
PR description.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- Ingesting `huig_dump.txt`'s lines no longer produces hundreds of
|
|
||||||
permanent auto-declared params from one-off stat-dump lines; the bound
|
|
||||||
chosen is verified by a test asserting the declared-param count stays
|
|
||||||
under a stated threshold after ingesting the fixture.
|
|
||||||
- Base64-padding false positives (e.g. the `==`-terminated blobs in the
|
|
||||||
fixture) are not auto-declared as params.
|
|
||||||
- `Type`, process name, and `PID` are available as derivable/queryable
|
|
||||||
params for `log stream`-shaped lines, verified against the fixture.
|
|
||||||
- Existing logfmt/JSON auto-declaration behavior for non-`log
|
|
||||||
stream`-shaped lines (existing tests in `src/logfmt.rs`/`src/engine.rs`)
|
|
||||||
is unchanged.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
044 (reserved param naming / `ottyl_fmt` — this ticket likely extends the
|
|
||||||
same format-detection mechanism).
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 051 — Allow deleting a declared metric
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
There is currently no way to delete a metric once declared. `src/api.rs`
|
|
||||||
only routes `GET`/`POST /api/metrics` and `GET`/`PUT /api/metrics/{name}`
|
|
||||||
(`src/api.rs:132-135`) — there is no `DELETE`. The engine already has the
|
|
||||||
machinery for it internally: `EngineMessage::RemoveMetricDeclaration`
|
|
||||||
(`src/engine.rs:103`) removes a metric's declaration from the index, but
|
|
||||||
it is only ever sent as part of `delete_profile`'s cleanup
|
|
||||||
(`src/api.rs:689`) when an entire profile is deleted, not as a
|
|
||||||
user-facing action on a single metric. Persistence has the same gap:
|
|
||||||
`Settings`/profile storage (`src/profile.rs`, post ticket 049 now inline
|
|
||||||
in `settings.json`) has `record_metric_declaration`/
|
|
||||||
`record_metric_declaration_update` but no corresponding removal method, so
|
|
||||||
even if a metric were removed from the live engine index there'd be
|
|
||||||
nothing to stop it reappearing on next load. The webapp UI
|
|
||||||
(`webapp/src/components/Metrics.tsx`) has no delete affordance either —
|
|
||||||
compare to param declarations, which have the same underlying gap (no
|
|
||||||
`RemoveParamDeclaration`-triggering UI or persisted-removal path either),
|
|
||||||
though this ticket is scoped to metrics only.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add `DELETE /api/metrics/{name}` in `src/api.rs`, sending
|
|
||||||
`EngineMessage::RemoveMetricDeclaration` to the engine (same message
|
|
||||||
the profile-delete cleanup path already uses) and returning 404 if the
|
|
||||||
metric doesn't exist.
|
|
||||||
- Add a persisted-removal method (e.g. `Settings::remove_metric_declaration`
|
|
||||||
or equivalent, alongside `record_metric_declaration`/
|
|
||||||
`record_metric_declaration_update` in `src/profile.rs`/`src/settings.rs`)
|
|
||||||
so a deleted metric doesn't reappear when declarations replay from
|
|
||||||
`settings.json` on the next restart, and call it from the new route the
|
|
||||||
same way `persist_metric_declaration` is called from `declare_metric`.
|
|
||||||
- Add a delete affordance in `webapp/src/components/Metrics.tsx` (icon
|
|
||||||
button or similar, following whatever confirm-before-destructive-action
|
|
||||||
pattern already exists elsewhere in the settings/profile UI — check
|
|
||||||
`NavMenu.tsx`'s profile deletion for the existing convention) that calls
|
|
||||||
the new endpoint and removes the metric from the list on success.
|
|
||||||
- Decide and document what happens to bucket-value history already
|
|
||||||
recorded under the deleted metric's name (e.g. `MetricPrimitive` samples
|
|
||||||
indexed by metric name in the engine) — either leave it orphaned
|
|
||||||
(queryable by name won't work without a declaration, but doesn't need
|
|
||||||
active cleanup) or purge it; state the choice in the PR.
|
|
||||||
- Decide and document what happens to dashboard panels or alert rules
|
|
||||||
whose PromQL query text/filter references the deleted metric by name —
|
|
||||||
at minimum they should fail gracefully (a clear "unknown metric" error
|
|
||||||
rather than a panic or silent empty result) rather than needing to be
|
|
||||||
actively kept in sync; a full dependent-reference check/warning before
|
|
||||||
delete is out of scope unless trivial.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- A metric can be deleted via the API and the UI; it disappears from
|
|
||||||
`GET /api/metrics` immediately and does not reappear after a restart.
|
|
||||||
- Deleting an unknown metric name returns a clear 404, not a 500 or panic.
|
|
||||||
- A dashboard panel or alert rule referencing the deleted metric by name
|
|
||||||
fails with a readable error rather than crashing the engine or API,
|
|
||||||
verified with a seeded fixture.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
None (touches the same engine/persistence seam as ticket 049, but that
|
|
||||||
ticket is already merged).
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# 052 — Allow clearing all params, then re-extract them from already-ingested logs
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
There is no bulk "clear params" action. The engine already supports
|
|
||||||
removing a single param declaration internally —
|
|
||||||
`EngineMessage::RemoveParamDeclaration { name, respond_to }`
|
|
||||||
(`src/engine.rs:85-92`), handled at `src/engine.rs:190-197` by removing
|
|
||||||
the `IndexKey::ParamDeclaration` entry — but it is only ever invoked from
|
|
||||||
`reload_engine_declarations` (`src/api.rs:664-682`), a profile-switch
|
|
||||||
cleanup path that filters out auto-derived params (comment at
|
|
||||||
`src/api.rs:665-668`) and removes the rest one at a time. There is no
|
|
||||||
route exposing this to the user, no persistence-side removal method
|
|
||||||
(`src/profile.rs` has `record_param_declaration` at `profile.rs:606` but
|
|
||||||
nothing to un-record it), and no webapp affordance
|
|
||||||
(`webapp/src/lib/api.ts:283-287` only exposes `getParams`/`declareParam`).
|
|
||||||
|
|
||||||
The harder half of this request — "logs get re-iterated to extract
|
|
||||||
params from them" — is not just a rewording of `auto_declare_params`.
|
|
||||||
That function runs once, at ingest time, against each incoming line
|
|
||||||
(`src/engine.rs:154`, `auto_declare_params` defined at
|
|
||||||
`src/engine.rs:307-325`, backed by `extract_pairs` in `src/logfmt.rs`) and
|
|
||||||
is never replayed. Re-extraction after a clear means running that same
|
|
||||||
logic again, after the fact, against lines already sitting in the index.
|
|
||||||
This is structurally possible: raw ingested text is retained as
|
|
||||||
`IndexKey::IngestedLog { sequence, timestamp } -> DataValue::LogLiteral`
|
|
||||||
in the same `BTreeMap` (`src/engine.rs:148-152`), occupying the index
|
|
||||||
range between `IndexKey::Start` and `IndexKey::LogParamSplit`
|
|
||||||
(iterated at `src/engine.rs:1166`, `1181`) — but only for lines that
|
|
||||||
haven't already been evicted by `max_logs`/`evict_excess_logs`
|
|
||||||
(`src/engine.rs:151-156`). Lines evicted before a clear+re-extract cannot
|
|
||||||
regain their params; this bound should be surfaced to the user, not
|
|
||||||
silently ignored.
|
|
||||||
|
|
||||||
Note `main.rs:75-96`'s `profile::load_into_engine` replay path is not
|
|
||||||
directly reusable here — it replays *declarations* into a fresh engine
|
|
||||||
before ingest starts, whereas this feature must re-run *extraction*
|
|
||||||
against logs the engine already holds.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add a bulk "clear params" `EngineMessage` (or reuse
|
|
||||||
`RemoveParamDeclaration` in a loop, mirroring
|
|
||||||
`reload_engine_declarations`'s existing pattern at
|
|
||||||
`src/api.rs:664-682`) that removes all declared params — deciding
|
|
||||||
whether auto-derived and manually-declared params are both cleared or
|
|
||||||
only one category, and stating that choice in the PR (the existing
|
|
||||||
`reload_engine_declarations` precedent excludes auto-derived ones for a
|
|
||||||
reason worth re-reading before copying it here).
|
|
||||||
- Add a persisted-removal counterpart in `src/profile.rs`/`src/settings.rs`
|
|
||||||
so cleared params don't reappear from `settings.json` on next load.
|
|
||||||
- After clearing, re-run `auto_declare_params` against every retained
|
|
||||||
`IndexKey::IngestedLog` entry still in the index (oldest to newest, to
|
|
||||||
preserve declaration-order semantics), producing a fresh param set from
|
|
||||||
currently-available log text.
|
|
||||||
- Expose this as a single user-facing action (`DELETE /api/params` or
|
|
||||||
similar in `src/api.rs`, plus a button in whatever webapp component
|
|
||||||
hosts the params list) — not a per-param loop the user has to drive
|
|
||||||
from the UI.
|
|
||||||
- Surface to the user (via response body or UI copy) that re-extraction
|
|
||||||
only covers logs still retained in memory, not lines already evicted by
|
|
||||||
`max_logs`.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- Triggering "clear params" removes all param declarations from
|
|
||||||
`GET /api/params` and they do not reappear after a restart.
|
|
||||||
- Immediately after clearing, params re-appear derived from the log text
|
|
||||||
still held in the engine's index, without requiring new lines to be
|
|
||||||
ingested, verified with a seeded fixture where params are cleared and
|
|
||||||
re-extracted from a fixed set of already-ingested lines.
|
|
||||||
- If some lines have been evicted before the clear, the re-extracted
|
|
||||||
param set reflects only the retained subset, and this is not reported
|
|
||||||
as an error.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
None (touches the same `RemoveParamDeclaration`/`reload_engine_declarations`
|
|
||||||
seam used by profile switching).
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# 053 — Move the log sort selector from Log Preview to the Timeline
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The "Sort by" control lives in the wrong component. It's currently
|
|
||||||
rendered inside `LogPreview`'s own section header
|
|
||||||
(`webapp/src/components/LogPreview.tsx:327-336`) as a `SortPicker`
|
|
||||||
(`LogPreview.tsx:68-107`, a `<select>` plus an "Fall back to ottyl_seq"
|
|
||||||
checkbox), with its `LogSortSettings` type and
|
|
||||||
`loadLogSortSettings`/`saveLogSortSettings` persistence helpers
|
|
||||||
(`LogPreview.tsx:37-66`, localStorage key `ottyl.logPreview.sort`) also
|
|
||||||
defined there. `LogTimeline.tsx` has no sort-related prop at all
|
|
||||||
(`LogTimeline.tsx:44-56`) — its header only has time-range preset buttons
|
|
||||||
and a "Clear selection" link (`LogTimeline.tsx:118-149`).
|
|
||||||
|
|
||||||
The actual sort state is owned one level up, duplicated identically in
|
|
||||||
`App.tsx`'s two top-level render modes: `FullApp`
|
|
||||||
(`App.tsx:389-394`) and `StandaloneLog` (`App.tsx:263-268`), both via
|
|
||||||
`useState<LogSortSettings>(loadLogSortSettings)` plus a
|
|
||||||
`handleSortSettingsChange` callback. Both currently pass
|
|
||||||
`sortSettings`/`onSortSettingsChange` only to `LogPreview`
|
|
||||||
(`App.tsx:361`, `App.tsx:527`), never to `LogTimeline`
|
|
||||||
(`App.tsx:237-243`, `354-360`/`520-526`). The setting already drives
|
|
||||||
`fetchLogs` (`App.tsx:319-326`, `464-471`, dependency at `App.tsx:347`,
|
|
||||||
`494`), which forwards `sortBy`/`sortMissingFallback` to
|
|
||||||
`GET /api/logs` via `fetchLogs` in `webapp/src/lib/api.ts:242-262`. The
|
|
||||||
backend already fully supports this — `src/dto.rs:86-90`'s
|
|
||||||
`sort_by`/`sort_missing_fallback` fields, validated and applied in
|
|
||||||
`src/engine.rs:1223-1281` (400 if `sort_by` isn't a declared param,
|
|
||||||
`engine.rs:1227`; missing-value fallback behavior at `engine.rs:1260-1281`).
|
|
||||||
The timeline query/DTO backing `fetchTimeline`
|
|
||||||
(`webapp/src/lib/api.ts:264-274`) has no `sort_by` field and doesn't need
|
|
||||||
one — this is a frontend-only relocation of where the control lives, not
|
|
||||||
a backend change.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Move `SortPicker` (and ideally the `LogSortSettings` type plus
|
|
||||||
`loadLogSortSettings`/`saveLogSortSettings`) out of `LogPreview.tsx`
|
|
||||||
into `LogTimeline.tsx`, rendering it in the Timeline's header
|
|
||||||
(`LogTimeline.tsx:118-149`) alongside the existing range controls.
|
|
||||||
- Add `sortSettings`/`onSortSettingsChange` props to `LogTimeline`.
|
|
||||||
- Update both `App.tsx` render sites (`FullApp` around line 237,
|
|
||||||
`StandaloneLog` around line 354/520) to pass `sortSettings`/
|
|
||||||
`onSortSettingsChange` to `LogTimeline` instead of `LogPreview`.
|
|
||||||
- `LogPreview` keeps rendering the `logs` prop as already sorted by the
|
|
||||||
parent's `fetchLogs` call — remove its own `SortPicker` usage and the
|
|
||||||
now-unused sort-control markup from its header, but keep consuming
|
|
||||||
`sortSettings` if it still needs read access to it (e.g. for a
|
|
||||||
"sorted by X" label), or drop that too if not needed.
|
|
||||||
- No backend changes — `sort_by`/`sort_missing_fallback` on
|
|
||||||
`GET /api/logs` are unaffected.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- The "Sort by" selector and "fall back to ottyl_seq" checkbox appear in
|
|
||||||
the Timeline component's header, not in Log Preview's.
|
|
||||||
- Changing sort order from the Timeline's control changes the order of
|
|
||||||
entries shown in Log Preview (same behavior as today, just relocated).
|
|
||||||
- The chosen sort setting still persists across reloads (localStorage key
|
|
||||||
can be renamed if it moves file, but must not silently reset to
|
|
||||||
default on the first load after this change — migrate or fall back
|
|
||||||
gracefully).
|
|
||||||
- `make build` (webapp `tsc -b && vite build`) passes with no leftover
|
|
||||||
unused imports/types in `LogPreview.tsx`.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
None.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 054 — `log stream` output isn't recognized as a known format (folds into ticket 050)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The report was "`log stream |& ottyl` should capture the format
|
|
||||||
properly... tab separated format is being ignored, add it as a known
|
|
||||||
type." Investigating `huig_dump.txt` (a real `log stream` capture already
|
|
||||||
in the repo root) shows the premise needs correcting: the file is **not**
|
|
||||||
tab-separated. `grep -P "\t"` finds a literal tab character on only 3 of
|
|
||||||
697 lines, and none of those are the header or a representative data row
|
|
||||||
— they're incidental (two `libinfo check path:` continuation lines, one
|
|
||||||
WiFi stat line with an embedded tab mid-message). `od -c` on the header
|
|
||||||
and data rows confirms the real structure is **space-padded fixed-width
|
|
||||||
columns** (`Timestamp`, `Thread`, `Type`, `Activity`, `PID`, `TTL`,
|
|
||||||
followed by a free-text `process: message` tail) — visually aligned
|
|
||||||
columns that read as tab-separated but aren't.
|
|
||||||
|
|
||||||
There is no TSV handling anywhere in the codebase today: `src/logfmt.rs`,
|
|
||||||
`src/engine.rs`, and `src/ingest.rs` contain zero references to tabs or
|
|
||||||
TSV. Format detection is entirely `detect_format` (`src/logfmt.rs:70-78`):
|
|
||||||
try JSON, else fall back to `extract_pairs` (`logfmt.rs:45-59`) for
|
|
||||||
logfmt, else `"none"` — no third branch exists, and `ottyl_fmt`
|
|
||||||
(`src/engine.rs:1005`, filter compilation at `engine.rs:775`, `824`) only
|
|
||||||
ever reports one of those three values.
|
|
||||||
|
|
||||||
This is a duplicate of, not distinct from, ticket 050
|
|
||||||
(`tickets/050-structured-header-extraction-and-bounded-logfmt-overfit.md`),
|
|
||||||
which already identifies the exact same root cause — `log stream`
|
|
||||||
output "is not logfmt at all — it's a fixed-column header... followed by
|
|
||||||
a free-text `process: message` tail" — and already scopes a structured-
|
|
||||||
header extractor plus the question of whether it becomes a new
|
|
||||||
`ottyl_fmt` variant (e.g. `"log_stream"`). The reporter most likely
|
|
||||||
visually misread the aligned whitespace columns as tab-separated.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Do not implement a separate "TSV `ottyl_fmt` type" — there is no
|
|
||||||
evidence of genuinely tab-delimited `log stream` output in this
|
|
||||||
codebase's fixtures, and building detection for a format that doesn't
|
|
||||||
occur would be speculative.
|
|
||||||
- Close/merge this wishlist item into ticket 050's existing scope: its
|
|
||||||
structured-header extractor for `log stream`-shaped input already
|
|
||||||
covers "capturing the format properly" for this input shape.
|
|
||||||
- If the user can supply a genuinely tab-delimited sample (e.g. from a
|
|
||||||
different `log stream --style` flag, or a shell pipeline that
|
|
||||||
reformats the output with real tabs), re-open this as a distinct
|
|
||||||
ticket scoped to that concrete input — file:line references above
|
|
||||||
should make it easy to confirm one way or the other before implementing
|
|
||||||
anything.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- No code changes result directly from this ticket; instead, ticket 050
|
|
||||||
is updated (or its implementation, when picked up, is confirmed) to
|
|
||||||
cover recognizing `log stream`'s fixed-column shape as a known format.
|
|
||||||
- If a true TSV sample is later provided, a new ticket is filed against
|
|
||||||
it specifically, rather than reopening this one with an unconfirmed
|
|
||||||
premise.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
050 (this ticket's concern is already inside 050's scope; treat as
|
|
||||||
duplicate/fold-in rather than independent work).
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 055 — Make `ottyl_*` reserved params usable in dashboard (PromQL) queries, not just filters
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Three reserved params exist, all hardcoded in the filter compiler:
|
|
||||||
`ottyl_seq` (ingest sequence number), `ottyl_ts` (ingest timestamp,
|
|
||||||
supports relative/absolute values), and `ottyl_fmt` (auto-detected
|
|
||||||
`json`/`logfmt`/`none`) — defined at `src/engine.rs:773-775, 795, 806, 824`,
|
|
||||||
grammar comments at `src/filter_parser.pest:7-9`, docs at
|
|
||||||
`webapp/src/components/Docs.tsx:105-106, 375` ("reserved metric names
|
|
||||||
present on every log line, so they're always filterable without
|
|
||||||
declaring anything"). Alert conditions restrict to the same three plus
|
|
||||||
`regex()`/`json[field]` (`src/alerts.rs:3, 199-225`).
|
|
||||||
|
|
||||||
These already work end-to-end for the filter/query bar syntax used by
|
|
||||||
`GET /api/logs`, `GET /api/timeline`, and alert conditions — that part of
|
|
||||||
"accessible parameters for queries" is true today. What's missing is
|
|
||||||
dashboards: PromQL-style panel queries (`src/promql.rs`,
|
|
||||||
`evaluate_promql` in `src/engine.rs:1562-1601`) have no special case for
|
|
||||||
`ottyl_*` anywhere. A PromQL query resolves `metric_name` via
|
|
||||||
`bucket_values(index, &parsed.metric_name)`
|
|
||||||
(`src/engine.rs`, called from `evaluate_promql`), which only returns data
|
|
||||||
for names backed by a `DataValue::MetricDeclaration` and bucket entries
|
|
||||||
created through `POST /api/metrics`. `ottyl_*` values are computed
|
|
||||||
line-locally at filter-compile time and never written into declared-
|
|
||||||
metric buckets, so `sum(ottyl_seq)` or similar in a dashboard panel would
|
|
||||||
silently return an empty series rather than real data.
|
|
||||||
|
|
||||||
Separately, `ottyl_*` never appear in `GET /api/params`
|
|
||||||
(`src/api.rs:281-287` → `EngineQuery::ParamDeclarations` →
|
|
||||||
`param_declarations_range`, `src/engine.rs:1340-1355`), since that only
|
|
||||||
lists explicit `ParamDeclaration` index entries and `ottyl_*` never
|
|
||||||
create one. `FilterBar.tsx` (`webapp/src/components/FilterBar.tsx:36-41`)
|
|
||||||
has no autocomplete/suggestion list at all today (plain text input), so
|
|
||||||
there's nothing currently excluding them from UI suggestions either —
|
|
||||||
but if param suggestions are ever added, this gap should be kept in mind.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add a special case in `evaluate_promql`/`src/promql.rs` (analogous to
|
|
||||||
the filter compiler's handling at `src/engine.rs:773-775`) that
|
|
||||||
synthesizes a virtual bucket series for `ottyl_seq`/`ottyl_ts`/
|
|
||||||
`ottyl_fmt` metric names from the ingested-log index, instead of
|
|
||||||
requiring a `MetricDeclaration`. Decide and document in the PR how
|
|
||||||
`ottyl_fmt`'s categorical string values map onto a numeric time
|
|
||||||
series (e.g. count-by-value, or reject it from PromQL numeric
|
|
||||||
aggregation with a clear error, since `ottyl_seq`/`ottyl_ts` are
|
|
||||||
naturally numeric but `ottyl_fmt` is not).
|
|
||||||
- Decide whether `ottyl_*` should appear in `GET /api/params` (or a
|
|
||||||
parallel listing) so dashboard panel builders in the webapp can
|
|
||||||
discover them without prior knowledge of the reserved names; if yes,
|
|
||||||
extend `param_declarations_range` or add a small hardcoded merge at the
|
|
||||||
API layer rather than forcing them into the index as real
|
|
||||||
`ParamDeclaration` entries (they aren't user-declared and shouldn't be
|
|
||||||
deletable via ticket 051's/052's params-removal paths).
|
|
||||||
- Update `README.md`/`Docs.tsx` to state whether `ottyl_*` is usable in
|
|
||||||
dashboard queries, once implemented (or explicitly document it as
|
|
||||||
filter-only, if the numeric/categorical mismatch above makes broad
|
|
||||||
PromQL support impractical for `ottyl_fmt`).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- A dashboard panel using a PromQL query over `ottyl_seq` or `ottyl_ts`
|
|
||||||
(e.g. `count(ottyl_seq)` or a rate over `ottyl_ts`) returns real data
|
|
||||||
from ingested logs, verified with a seeded fixture.
|
|
||||||
- The behavior for `ottyl_fmt` in a PromQL query is either implemented
|
|
||||||
(with a stated value-to-series mapping) or explicitly rejected with a
|
|
||||||
clear, documented error — not a silent empty result.
|
|
||||||
- `GET /api/params` behavior for `ottyl_*` is decided and documented;
|
|
||||||
if included, they're clearly marked as reserved/non-deletable rather
|
|
||||||
than indistinguishable from user-declared params.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
None (independent of the params-removal tickets, but note the
|
|
||||||
non-deletable requirement above if `ottyl_*` end up listed alongside
|
|
||||||
regular params).
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 056 — Add `Light *` variants for the remaining colour schemes
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Ticket 048 already added one light-surfaced variant, `light_green`
|
|
||||||
(`webapp/src/lib/themes.ts:231-254`, commit `5371d3e`: "Adds a new
|
|
||||||
'Light Green' scheme (light-surfaced, same green/magenta hue family as
|
|
||||||
'Green') selectable alongside the existing built-ins"). The wishlist
|
|
||||||
comment that prompted 048 — "we need a `Light Green` alongside `Green`"
|
|
||||||
— is now clarified as applying to *every* colour scheme, not just green:
|
|
||||||
the current built-in set is `dark`, `light`, `red`, `green`, `blue`,
|
|
||||||
`yellow`, `pink`, `orange`, `light_green`
|
|
||||||
(`webapp/src/lib/themes.ts:38-255`), so `red`, `blue`, `yellow`, `pink`,
|
|
||||||
and `orange` still have no light-surfaced counterpart.
|
|
||||||
|
|
||||||
Adding a scheme is not fully data-driven from `themes.ts` alone — six
|
|
||||||
files must be kept in sync per scheme, per the no-build-step-links-them
|
|
||||||
comment at `webapp/src/index.css:1-7`:
|
|
||||||
|
|
||||||
1. `webapp/src/index.css:264-300` — the `html.light_green` CSS block is
|
|
||||||
the reference recipe: same hue + same accent hues as the dark
|
|
||||||
counterpart, but with `light`'s inverted (high-lightness)
|
|
||||||
surface/border/text scale, and `light`'s hue-neutral danger colors
|
|
||||||
(comment at `index.css:264-268` documents this explicitly).
|
|
||||||
2. `webapp/src/lib/themes.ts:231-254` — the matching `BUILTIN_THEMES`
|
|
||||||
entry (used for CSS export/download and validation).
|
|
||||||
3. `webapp/src/lib/api.ts:83-93` — the `ColorScheme` TS union type.
|
|
||||||
4. `src/settings.rs:30-42` — the matching Rust `ColorScheme` enum variant,
|
|
||||||
with `#[serde(rename = "light_<name>")]`.
|
|
||||||
5. `webapp/src/lib/useSettingsPanel.ts:24-34` — `THEME_CLASSES`, used to
|
|
||||||
apply/strip the `html.*` class.
|
|
||||||
6. `webapp/src/components/NavMenu.tsx:20-31` — `COLOR_SCHEMES`, which
|
|
||||||
drives the picker UI; `schemeLabel()` (`NavMenu.tsx:34-35`) already
|
|
||||||
auto-titles `light_red` → "Light Red" by replacing underscores, so no
|
|
||||||
extra label wiring is needed once a name is added to this array.
|
|
||||||
|
|
||||||
`PanelChart.tsx`'s multi-accent `color-mix()` series-color logic (touched
|
|
||||||
by ticket 048) is generic per-theme rather than per-scheme-name, so it
|
|
||||||
likely needs no changes — worth a quick check while implementing, not
|
|
||||||
expected to require edits.
|
|
||||||
|
|
||||||
Tickets 047/048's original files were deleted on implementation (git
|
|
||||||
history only shows their removal commits, `5cb2dfd`/`7c331e7`; bodies
|
|
||||||
aren't recoverable), but commit `5371d3e`'s message and the resulting
|
|
||||||
`light_green` block are sufficient as the template.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add `light_red`, `light_blue`, `light_yellow`, `light_pink`, and
|
|
||||||
`light_orange` following the exact `light_green` recipe: same base
|
|
||||||
hue and accent-hue choices as the corresponding dark-surfaced scheme,
|
|
||||||
`light`'s inverted surface/border/text lightness scale, `light`'s
|
|
||||||
neutral danger colors.
|
|
||||||
- Update all six files listed above for each new scheme:
|
|
||||||
`webapp/src/index.css`, `webapp/src/lib/themes.ts`,
|
|
||||||
`webapp/src/lib/api.ts`, `src/settings.rs`,
|
|
||||||
`webapp/src/lib/useSettingsPanel.ts`, `webapp/src/components/NavMenu.tsx`.
|
|
||||||
- Verify `schemeLabel()` produces correct titles ("Light Red", "Light
|
|
||||||
Blue", etc.) without additional label-mapping code.
|
|
||||||
- Spot-check `PanelChart.tsx`'s series-color logic against one new light
|
|
||||||
scheme to confirm it needs no changes (or fix it if it does).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- All five new schemes are selectable in the settings picker
|
|
||||||
(`NavMenu.tsx`), persist correctly, and are downloadable as CSS via
|
|
||||||
the existing "download built-in theme" path.
|
|
||||||
- Each new scheme's CSS block round-trips through `Settings`
|
|
||||||
serialization/deserialization (`src/settings.rs`) without errors,
|
|
||||||
verified with a test analogous to any existing `ColorScheme`
|
|
||||||
serialization test.
|
|
||||||
- `make build` and `make lint` pass with the new schemes added.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
048 (implemented; this ticket extends its established pattern to the
|
|
||||||
remaining base hues).
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>webapp</title>
|
<title>ottyl</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 669 B |
+95
-106
@@ -3,13 +3,13 @@ import { NavMenu } from './components/NavMenu'
|
|||||||
import { FilterBar } from './components/FilterBar'
|
import { FilterBar } from './components/FilterBar'
|
||||||
import {
|
import {
|
||||||
LogTimeline,
|
LogTimeline,
|
||||||
TIME_RANGE_PRESETS,
|
|
||||||
computeBucketMs,
|
computeBucketMs,
|
||||||
computePresetRange,
|
loadLogSortSettings,
|
||||||
type TimeRange,
|
saveLogSortSettings,
|
||||||
type TimeRangePresetId,
|
type LogSortSettings,
|
||||||
} from './components/LogTimeline'
|
} from './components/LogTimeline'
|
||||||
import { LogPreview, loadLogSortSettings, saveLogSortSettings, type LogSortSettings } from './components/LogPreview'
|
import { TIME_RANGE_PRESETS, type TimeRange, type TimeRangePresetId } from './lib/timeRange'
|
||||||
|
import { LogPreview } from './components/LogPreview'
|
||||||
import { Params } from './components/Params'
|
import { Params } from './components/Params'
|
||||||
import { Metrics } from './components/Metrics'
|
import { Metrics } from './components/Metrics'
|
||||||
import { Dashboards } from './components/Dashboards'
|
import { Dashboards } from './components/Dashboards'
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type TimelineBucket,
|
type TimelineBucket,
|
||||||
} from './lib/api'
|
} from './lib/api'
|
||||||
import { DocsNavProvider } from './lib/docsNav'
|
import { DocsNavProvider } from './lib/docsNav'
|
||||||
|
import { useRangeSelection } from './lib/useRangeSelection'
|
||||||
import { useSettingsPanel } from './lib/useSettingsPanel'
|
import { useSettingsPanel } from './lib/useSettingsPanel'
|
||||||
import { POLL_MS } from './lib/polling'
|
import { POLL_MS } from './lib/polling'
|
||||||
|
|
||||||
@@ -66,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
|
/// 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
|
/// 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.
|
/// the final size can be persisted once, not on every pixel of movement.
|
||||||
@@ -169,47 +187,18 @@ function StandaloneRanged({
|
|||||||
initialFixedRange: TimeRange | null
|
initialFixedRange: TimeRange | null
|
||||||
}) {
|
}) {
|
||||||
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
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.
|
// 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>(
|
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
|
||||||
initialPresetId ?? (initialFixedRange ? null : 'all'),
|
preset: initialPresetId ?? (initialFixedRange ? null : 'all'),
|
||||||
)
|
range: initialFixedRange,
|
||||||
|
})
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
async function poll() {
|
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.
|
// 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 =
|
const effectiveRange = resolveEffectiveRange()
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const nextBuckets = await fetchTimeline({
|
const nextBuckets = await fetchTimeline({
|
||||||
startMs: effectiveRange?.startMs,
|
startMs: effectiveRange?.startMs,
|
||||||
@@ -251,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.
|
/// 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() {
|
function StandaloneLog() {
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||||
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
||||||
const [range, setRange] = useState<TimeRange | null>(null)
|
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
|
||||||
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
preset: 'all',
|
||||||
|
range: null,
|
||||||
|
})
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [filterError, setFilterError] = useState<string | null>(null)
|
const [filterError, setFilterError] = useState<string | null>(null)
|
||||||
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
||||||
|
const [sortIndexBuilding, setSortIndexBuilding] = useState(false)
|
||||||
|
|
||||||
function handleSortSettingsChange(next: LogSortSettings) {
|
function handleSortSettingsChange(next: LogSortSettings) {
|
||||||
setSortSettings(next)
|
setSortSettings(next)
|
||||||
saveLogSortSettings(next)
|
saveLogSortSettings(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangeRef = useRef(range)
|
|
||||||
useEffect(() => {
|
|
||||||
rangeRef.current = range
|
|
||||||
}, [range])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchParamDeclarations()
|
fetchParamDeclarations()
|
||||||
.then(setParams)
|
.then(setParams)
|
||||||
@@ -280,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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
const effectiveRange =
|
const effectiveRange = resolveEffectiveRange()
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const [nextBuckets, nextLogs] = await Promise.all([
|
const [nextBuckets, nextLogs, nextParams] = await Promise.all([
|
||||||
fetchTimeline({
|
fetchTimeline({
|
||||||
startMs: effectiveRange?.startMs,
|
startMs: effectiveRange?.startMs,
|
||||||
endMs: effectiveRange?.endMs,
|
endMs: effectiveRange?.endMs,
|
||||||
@@ -316,6 +303,9 @@ function StandaloneLog() {
|
|||||||
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
||||||
filter: filter || undefined,
|
filter: filter || undefined,
|
||||||
}),
|
}),
|
||||||
|
trackSlowSortedFetch(
|
||||||
|
sortSettings.sortBy,
|
||||||
|
setSortIndexBuilding,
|
||||||
fetchLogs({
|
fetchLogs({
|
||||||
startMs: effectiveRange?.startMs,
|
startMs: effectiveRange?.startMs,
|
||||||
endMs: effectiveRange?.endMs,
|
endMs: effectiveRange?.endMs,
|
||||||
@@ -324,10 +314,14 @@ function StandaloneLog() {
|
|||||||
sortBy: sortSettings.sortBy ?? undefined,
|
sortBy: sortSettings.sortBy ?? undefined,
|
||||||
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : 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) {
|
if (!cancelled) {
|
||||||
setBuckets(nextBuckets)
|
setBuckets(nextBuckets)
|
||||||
setLogs(nextLogs)
|
setLogs(nextLogs)
|
||||||
|
updateParamsIfChanged(setParams, nextParams)
|
||||||
setFilterError(null)
|
setFilterError(null)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -349,7 +343,12 @@ function StandaloneLog() {
|
|||||||
return (
|
return (
|
||||||
<StandalonePage>
|
<StandalonePage>
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<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">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<LogTimeline
|
<LogTimeline
|
||||||
buckets={buckets}
|
buckets={buckets}
|
||||||
@@ -357,8 +356,12 @@ function StandaloneLog() {
|
|||||||
onSelectRange={handleManualRangeSelect}
|
onSelectRange={handleManualRangeSelect}
|
||||||
activePreset={activePreset}
|
activePreset={activePreset}
|
||||||
onSelectPreset={selectPreset}
|
onSelectPreset={selectPreset}
|
||||||
|
paramNames={params.map((p) => p.name)}
|
||||||
|
sortSettings={sortSettings}
|
||||||
|
onSortSettingsChange={handleSortSettingsChange}
|
||||||
|
sortIndexBuilding={sortIndexBuilding}
|
||||||
/>
|
/>
|
||||||
<LogPreview logs={logs} params={params} sortSettings={sortSettings} onSortSettingsChange={handleSortSettingsChange} />
|
<LogPreview logs={logs} params={params} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</StandalonePage>
|
</StandalonePage>
|
||||||
@@ -380,13 +383,16 @@ function FullApp() {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||||
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
||||||
const [range, setRange] = useState<TimeRange | null>(null)
|
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
|
||||||
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
preset: 'all',
|
||||||
|
range: null,
|
||||||
|
})
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [filterError, setFilterError] = useState<string | null>(null)
|
const [filterError, setFilterError] = useState<string | null>(null)
|
||||||
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
||||||
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
|
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
|
||||||
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
||||||
|
const [sortIndexBuilding, setSortIndexBuilding] = useState(false)
|
||||||
|
|
||||||
function handleSortSettingsChange(next: LogSortSettings) {
|
function handleSortSettingsChange(next: LogSortSettings) {
|
||||||
setSortSettings(next)
|
setSortSettings(next)
|
||||||
@@ -404,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() {
|
async function loadParams() {
|
||||||
try {
|
try {
|
||||||
setParams(await fetchParamDeclarations())
|
setParams(await fetchParamDeclarations())
|
||||||
@@ -422,37 +422,14 @@ function FullApp() {
|
|||||||
loadParams()
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time.
|
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time.
|
||||||
const effectiveRange =
|
const effectiveRange = resolveEffectiveRange()
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const [nextStats, nextBuckets, nextLogs] = await Promise.all([
|
const [nextStats, nextBuckets, nextLogs, nextParams] = await Promise.all([
|
||||||
fetchStats(),
|
fetchStats(),
|
||||||
fetchTimeline({
|
fetchTimeline({
|
||||||
startMs: effectiveRange?.startMs,
|
startMs: effectiveRange?.startMs,
|
||||||
@@ -461,6 +438,9 @@ function FullApp() {
|
|||||||
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
||||||
filter: filter || undefined,
|
filter: filter || undefined,
|
||||||
}),
|
}),
|
||||||
|
trackSlowSortedFetch(
|
||||||
|
sortSettings.sortBy,
|
||||||
|
setSortIndexBuilding,
|
||||||
fetchLogs({
|
fetchLogs({
|
||||||
startMs: effectiveRange?.startMs,
|
startMs: effectiveRange?.startMs,
|
||||||
endMs: effectiveRange?.endMs,
|
endMs: effectiveRange?.endMs,
|
||||||
@@ -469,11 +449,15 @@ function FullApp() {
|
|||||||
sortBy: sortSettings.sortBy ?? undefined,
|
sortBy: sortSettings.sortBy ?? undefined,
|
||||||
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : 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) {
|
if (!cancelled) {
|
||||||
setStats(nextStats)
|
setStats(nextStats)
|
||||||
setBuckets(nextBuckets)
|
setBuckets(nextBuckets)
|
||||||
setLogs(nextLogs)
|
setLogs(nextLogs)
|
||||||
|
updateParamsIfChanged(setParams, nextParams)
|
||||||
setFilterError(null)
|
setFilterError(null)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -502,6 +486,7 @@ function FullApp() {
|
|||||||
value={filter}
|
value={filter}
|
||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
error={filterError}
|
error={filterError}
|
||||||
|
activeSummary={describeActiveState(activePreset, sortSettings)}
|
||||||
collapseToggle={
|
collapseToggle={
|
||||||
<>
|
<>
|
||||||
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
||||||
@@ -523,8 +508,12 @@ function FullApp() {
|
|||||||
onSelectRange={handleManualRangeSelect}
|
onSelectRange={handleManualRangeSelect}
|
||||||
activePreset={activePreset}
|
activePreset={activePreset}
|
||||||
onSelectPreset={selectPreset}
|
onSelectPreset={selectPreset}
|
||||||
|
paramNames={params.map((p) => p.name)}
|
||||||
|
sortSettings={sortSettings}
|
||||||
|
onSortSettingsChange={handleSortSettingsChange}
|
||||||
|
sortIndexBuilding={sortIndexBuilding}
|
||||||
/>
|
/>
|
||||||
<LogPreview logs={logs} params={params} sortSettings={sortSettings} onSortSettingsChange={handleSortSettingsChange} />
|
<LogPreview logs={logs} params={params} />
|
||||||
</div>
|
</div>
|
||||||
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
|
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
|
||||||
<Params params={params} onDeclared={loadParams} />
|
<Params params={params} onDeclared={loadParams} />
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #fff;
|
||||||
|
stroke-width: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke: #000;
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 8px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect class="cls-1" x="10" y="10" width="80" height="80" rx="18.79" ry="18.79"/>
|
||||||
|
<polyline class="cls-2" points="25,33 42,50 25,67"/>
|
||||||
|
<line class="cls-2" x1="46" y1="66" x2="75" y2="37"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 669 B |
@@ -9,13 +9,7 @@ import {
|
|||||||
type AlertRule,
|
type AlertRule,
|
||||||
type CompareOp,
|
type CompareOp,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
import {
|
import { FormFooter, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
|
||||||
CollapseToggle,
|
|
||||||
handleSectionHeaderKeyDown,
|
|
||||||
sectionHeaderInteractiveClassName,
|
|
||||||
sectionHeaderLeftClassName,
|
|
||||||
useSectionCollapsed,
|
|
||||||
} from './CollapsibleSection'
|
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||||
import { queryInputClassName } from './FilterBar'
|
import { queryInputClassName } from './FilterBar'
|
||||||
@@ -238,22 +232,7 @@ export function Alerts() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border text-sm">
|
<div className="border-b border-border text-sm">
|
||||||
<div
|
<SectionHeader title="Alerts" helpSectionId="alerts" collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
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>
|
|
||||||
<div hidden={collapsed}>
|
<div hidden={collapsed}>
|
||||||
<p className="px-3 pt-3 text-xs text-text-faint">
|
<p className="px-3 pt-3 text-xs text-text-faint">
|
||||||
Every rule delivers to the ntfy sink configured in Settings.
|
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>}
|
{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">
|
<FormFooter
|
||||||
<button
|
editing={editingId !== null}
|
||||||
type="submit"
|
submitting={submitting}
|
||||||
disabled={submitting}
|
idleLabel="Create rule"
|
||||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
editingLabel="Save rule"
|
||||||
>
|
onCancel={startCreate}
|
||||||
{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>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
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
|
/// 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.
|
/// 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.
|
/// 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'
|
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
|
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
|
||||||
import { PanelChart } from './PanelChart'
|
import { PanelChart } from './PanelChart'
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -15,15 +15,7 @@ import {
|
|||||||
type PromqlSeries,
|
type PromqlSeries,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
import { POLL_MS } from '../lib/polling'
|
import { POLL_MS } from '../lib/polling'
|
||||||
import {
|
import { PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
|
||||||
CollapseToggle,
|
|
||||||
handleSectionHeaderKeyDown,
|
|
||||||
PopOutButton,
|
|
||||||
sectionHeaderInteractiveClassName,
|
|
||||||
sectionHeaderLeftClassName,
|
|
||||||
useSectionCollapsed,
|
|
||||||
} from './CollapsibleSection'
|
|
||||||
import { HelpIcon } from './HelpIcon'
|
|
||||||
|
|
||||||
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
||||||
|
|
||||||
@@ -96,7 +88,7 @@ function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null
|
|||||||
if (loading && series.length === 0) {
|
if (loading && series.length === 0) {
|
||||||
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">Loading…</div>
|
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">Loading…</div>
|
||||||
}
|
}
|
||||||
return <PanelChart series={series} chartType={panel.chart_type} />
|
return <PanelChart series={series} chartType={panel.chart_type} range={range} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function AutosaveStatus({ status, error }: { status: SaveStatus; error: string | null }) {
|
function AutosaveStatus({ status, error }: { status: SaveStatus; error: string | null }) {
|
||||||
@@ -153,6 +145,9 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
|||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
|
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
|
||||||
const lastSavedRef = useRef<string | null>(null)
|
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')
|
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
|
||||||
|
|
||||||
async function loadDashboards(profileName: string, selectAfterLoad?: string) {
|
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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const found = dashboards.find((d) => d.id === selectedId) ?? null
|
const found = dashboards.find((d) => d.id === selectedId) ?? null
|
||||||
setDraft(found)
|
setDraft(found)
|
||||||
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
|
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.
|
// 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(() => {
|
useEffect(() => {
|
||||||
@@ -198,9 +203,14 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
|||||||
setSaveStatus('saving')
|
setSaveStatus('saving')
|
||||||
try {
|
try {
|
||||||
const saved = await saveDashboard(activeProfile, dashboardId, dashboardSaveShape(draft))
|
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)))
|
setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
|
||||||
|
// 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)
|
setDraft(saved)
|
||||||
|
}
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -264,23 +274,13 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
|||||||
if (!activeProfile) {
|
if (!activeProfile) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border text-sm">
|
<div className="border-b border-border text-sm">
|
||||||
<div
|
<SectionHeader
|
||||||
className={sectionHeaderInteractiveClassName}
|
title="Dashboards"
|
||||||
role="button"
|
helpSectionId="dashboards"
|
||||||
tabIndex={0}
|
collapsed={collapsed}
|
||||||
aria-expanded={!collapsed}
|
onToggle={() => setCollapsed(!collapsed)}
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
popOut={<PopOutButton section="dashboards" range={range} activePreset={activePreset} />}
|
||||||
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>
|
|
||||||
<div className="p-3 text-xs text-text-faint">
|
<div className="p-3 text-xs text-text-faint">
|
||||||
Activate a profile (see the settings menu) to create and save dashboards.
|
Activate a profile (see the settings menu) to create and save dashboards.
|
||||||
</div>
|
</div>
|
||||||
@@ -290,23 +290,13 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border text-sm">
|
<div className="border-b border-border text-sm">
|
||||||
<div
|
<SectionHeader
|
||||||
className={sectionHeaderInteractiveClassName}
|
title="Dashboards"
|
||||||
role="button"
|
helpSectionId="dashboards"
|
||||||
tabIndex={0}
|
collapsed={collapsed}
|
||||||
aria-expanded={!collapsed}
|
onToggle={() => setCollapsed(!collapsed)}
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
popOut={<PopOutButton section="dashboards" range={range} activePreset={activePreset} />}
|
||||||
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>
|
|
||||||
{error && !collapsed && (
|
{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>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+141
-27
@@ -78,36 +78,62 @@ const SECTIONS: DocSection[] = [
|
|||||||
id: 'filtering',
|
id: 'filtering',
|
||||||
title: 'Filtering',
|
title: 'Filtering',
|
||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
<p>
|
<p>
|
||||||
The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
|
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>
|
||||||
|
|
||||||
|
<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>
|
</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>
|
<li>
|
||||||
<Code>{'regex("error")'}</Code> — lines containing "error".
|
<Code>{'ottyl_seq<5'}</Code> — ingest sequence number under 5.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5.
|
<Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes (see Date/time formats below).
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
|
<Code>{'ottyl_fmt==json'}</Code> — the line's auto-detected text format:{' '}
|
||||||
<Code>duration</Code> param over 100.
|
<Code>log_stream</Code> (macOS <Code>log stream</Code> output — its columns are also auto-declared as
|
||||||
</li>
|
their own named params, e.g. <Code>PID</Code>/<Code>Thread</Code>, from that line's own header), <Code>json</Code>,{' '}
|
||||||
<li>
|
<Code>logfmt</Code>, or <Code>none</Code>.
|
||||||
<Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Code>{'ottyl_fmt==json'}</Code> — lines auto-detected as JSON.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
</div>
|
||||||
<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
|
<div>
|
||||||
auto-detected format of the line's text — <Code>json</Code>, <Code>logfmt</Code>, or <Code>none</Code>.
|
|
||||||
</p>
|
|
||||||
<p className="font-semibold text-text">Date/time formats</p>
|
<p className="font-semibold text-text">Date/time formats</p>
|
||||||
<p>Timestamp comparisons accept three forms:</p>
|
<p>Timestamp comparisons (e.g. against <Code>ottyl_ts</Code>) accept three forms:</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>
|
<li>
|
||||||
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
|
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
|
||||||
@@ -119,9 +145,22 @@ const SECTIONS: DocSection[] = [
|
|||||||
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
|
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
</div>
|
||||||
The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.
|
|
||||||
</p>
|
<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>
|
<p className="font-semibold text-text">Worked examples</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>
|
<li>
|
||||||
@@ -132,16 +171,74 @@ const SECTIONS: DocSection[] = [
|
|||||||
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
|
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>
|
<Code>duration</Code> param declared: <Code>{'requests[duration]>200'}</Code>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
|
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto-formats',
|
||||||
|
title: 'Auto-detected formats',
|
||||||
|
body: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>
|
||||||
|
Every ingested line is classified into one of four formats, exposed as the reserved{' '}
|
||||||
|
<Code>ottyl_fmt</Code> param (see <em>Filtering</em> above). The format also decides what — if anything —
|
||||||
|
gets auto-declared as a param without you doing anything:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">
|
||||||
|
<Code>log_stream</Code>
|
||||||
|
</span>{' '}
|
||||||
|
— macOS <Code>{'log stream'}</Code> output's fixed-column shape (timestamp, thread, type, activity id,
|
||||||
|
pid, ttl, then <Code>process: message</Code>). The first such line seen auto-declares{' '}
|
||||||
|
<Code>log_type</Code>, <Code>process</Code>, and <Code>pid</Code> as params, parsed straight out of those
|
||||||
|
columns.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">
|
||||||
|
<Code>json</Code>
|
||||||
|
</span>{' '}
|
||||||
|
— the line parses as a JSON object (a bare array or scalar doesn't count). Every leaf scalar field
|
||||||
|
(string/number/bool) auto-declares a param named after its dot-separated path, extracted by JSON field
|
||||||
|
path rather than regex — a nested field like <Code>{'{"request":{"path":"/x"}}'}</Code> becomes a param
|
||||||
|
named <Code>request.path</Code>. Nested objects are walked into automatically; arrays and <Code>null</Code>{' '}
|
||||||
|
fields are skipped since there's no single scalar value to declare a param against.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">
|
||||||
|
<Code>logfmt</Code>
|
||||||
|
</span>{' '}
|
||||||
|
— the line contains at least one <Code>key=value</Code>/<Code>{'key="value"'}</Code> pair anywhere in its
|
||||||
|
text (it doesn't need to be logfmt end-to-end — a free-text prefix or suffix around the pairs is fine).
|
||||||
|
Every distinct key auto-declares a param of the same name, type-inferred as <Code>number</Code> if its
|
||||||
|
first-seen value parses as one, else <Code>string</Code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">
|
||||||
|
<Code>none</Code>
|
||||||
|
</span>{' '}
|
||||||
|
— none of the above; plain prose with no structure ottyl can key off of. Nothing is auto-declared, though
|
||||||
|
the line is still fully searchable via <Code>regex(...)</Code> in the filter bar.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Auto-declared params are flagged <Code>auto_derived</Code> in <Code>GET /api/params</Code> and show up as
|
||||||
|
log-table columns immediately, same as a manually declared param — but they can't be edited or removed
|
||||||
|
individually, and they're never persisted to a profile (so they're rediscovered fresh each run rather than
|
||||||
|
traveling with a profile like manual declarations do). A manual param declaration for a name always takes
|
||||||
|
precedence and is never overwritten by auto-detection. To keep one unusual line from flooding the params
|
||||||
|
list, auto-detection is capped at 64 auto-derived params total and 8 new ones per line — once either cap is
|
||||||
|
hit, further would-be auto-declarations from new keys/fields are simply skipped.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -284,6 +381,15 @@ const SECTIONS: DocSection[] = [
|
|||||||
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
|
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
|
||||||
latest reading.
|
latest reading.
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
<p className="font-semibold text-text">Worked example</p>
|
<p className="font-semibold text-text">Worked example</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -335,6 +441,14 @@ const SECTIONS: DocSection[] = [
|
|||||||
Dashboards are saved per profile, so they travel with it when you switch or copy it — an active profile is
|
Dashboards are saved per profile, so they travel with it when you switch or copy it — an active profile is
|
||||||
required to create or save one; pick or create one from the settings menu first.
|
required to create or save one; pick or create one from the settings menu first.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
The reserved <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> names (see <em>Filtering</em> above) work in panel
|
||||||
|
queries the same as any declared metric — e.g. <Code>count(ottyl_seq)</Code> for log volume, or{' '}
|
||||||
|
<Code>rate(ottyl_ts[1m])</Code> for ingest rate — since every log line carries them without a metric
|
||||||
|
declaration. <Code>ottyl_fmt</Code> is categorical (<Code>json</Code>/<Code>logfmt</Code>/<Code>none</Code>
|
||||||
|
), not a number, so it's rejected in a panel query with an explicit error rather than silently charting
|
||||||
|
nothing — filter on it in the filter bar instead.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||||
|
|
||||||
@@ -13,14 +13,22 @@ export function FilterBar({
|
|||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
collapseToggle,
|
collapseToggle,
|
||||||
|
activeSummary,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
error?: string | null
|
error?: string | null
|
||||||
collapseToggle?: ReactNode
|
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)
|
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 (
|
return (
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-1 border-b border-border px-3 py-2"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<HelpIcon sectionId="filtering" label="Help: Filtering" />
|
<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" />
|
<RegexHelpIcon label="Help: regex(...) syntax" />
|
||||||
|
<span className="text-xs text-text-faint" title="Cheat sheet for regex(...) clauses specifically">
|
||||||
|
regex(...)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
{collapseToggle}
|
{collapseToggle}
|
||||||
<input
|
<input
|
||||||
className={queryInputClassName}
|
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}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -47,6 +62,7 @@ export function FilterBar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-danger-text">{error}</p>}
|
{error && <p className="text-xs text-danger-text">{error}</p>}
|
||||||
|
{!error && !draft && activeSummary && <p className="text-xs text-text-subtle">Active: {activeSummary}</p>}
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
|
import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
|
||||||
|
import { useClickOutside } from '../lib/useClickOutside'
|
||||||
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
|
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
|
||||||
@@ -25,87 +26,6 @@ function saveVisibleParamColumns(columns: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ticket 045: ordering key + missing-param handling for the log preview / timeline. Scoped like
|
|
||||||
// `VISIBLE_PARAM_COLUMNS_STORAGE_KEY` and `App.tsx`'s `LOG_VIEWER_HEIGHT_STORAGE_KEY` above — one
|
|
||||||
// shared "log viewer" concept, so a single localStorage key covers both the inline viewer
|
|
||||||
// (`FullApp`) and its popout (`StandaloneLog`) rather than each keeping its own copy, which would
|
|
||||||
// surprise a user switching between the two. There's no dashboard/panel-scoped analog for this
|
|
||||||
// setting to divide by (unlike e.g. per-dashboard panels), so a single global key matches how the
|
|
||||||
// rest of the log viewer's own settings are scoped.
|
|
||||||
const LOG_SORT_STORAGE_KEY = 'ottyl.logPreview.sort'
|
|
||||||
|
|
||||||
export interface LogSortSettings {
|
|
||||||
/** An already-declared param name to order log lines by, or `null` for the default (`ottyl_ts`-tracking) order. */
|
|
||||||
sortBy: string | null
|
|
||||||
/** Only meaningful when `sortBy` is set. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them from view. */
|
|
||||||
sortMissingFallback: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_LOG_SORT_SETTINGS: LogSortSettings = { sortBy: null, sortMissingFallback: true }
|
|
||||||
|
|
||||||
export function loadLogSortSettings(): LogSortSettings {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LOG_SORT_STORAGE_KEY)
|
|
||||||
if (!raw) return DEFAULT_LOG_SORT_SETTINGS
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (!parsed || typeof parsed !== 'object') return DEFAULT_LOG_SORT_SETTINGS
|
|
||||||
const sortBy = typeof parsed.sortBy === 'string' ? parsed.sortBy : null
|
|
||||||
const sortMissingFallback = typeof parsed.sortMissingFallback === 'boolean' ? parsed.sortMissingFallback : true
|
|
||||||
return { sortBy, sortMissingFallback }
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_LOG_SORT_SETTINGS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveLogSortSettings(settings: LogSortSettings) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(LOG_SORT_STORAGE_KEY, JSON.stringify(settings))
|
|
||||||
} catch {
|
|
||||||
// best-effort — private browsing or full storage just means the selection won't persist across reloads
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortPicker({
|
|
||||||
paramNames,
|
|
||||||
settings,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
paramNames: string[]
|
|
||||||
settings: LogSortSettings
|
|
||||||
onChange: (next: LogSortSettings) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-text-secondary">
|
|
||||||
<label className="flex items-center gap-1">
|
|
||||||
Sort by
|
|
||||||
<select
|
|
||||||
value={settings.sortBy ?? ''}
|
|
||||||
onChange={(e) => onChange({ ...settings, sortBy: e.target.value || null })}
|
|
||||||
className="rounded border border-border-soft bg-surface-0 px-1 py-0.5 text-text"
|
|
||||||
>
|
|
||||||
<option value="">ottyl_ts (default)</option>
|
|
||||||
{paramNames.map((name) => (
|
|
||||||
<option key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{settings.sortBy && (
|
|
||||||
<label className="flex cursor-pointer items-center gap-1" title="How lines missing the sort param are handled">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.sortMissingFallback}
|
|
||||||
onChange={(e) => onChange({ ...settings, sortMissingFallback: e.target.checked })}
|
|
||||||
className="accent-text-muted"
|
|
||||||
/>
|
|
||||||
Fall back to ottyl_seq
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColumnPicker({
|
function ColumnPicker({
|
||||||
paramNames,
|
paramNames,
|
||||||
visible,
|
visible,
|
||||||
@@ -118,14 +38,7 @@ function ColumnPicker({
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, () => setOpen(false), open)
|
||||||
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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
@@ -200,7 +113,7 @@ function JsonNode({
|
|||||||
entries: ReadonlyArray<readonly [string, unknown]>
|
entries: ReadonlyArray<readonly [string, unknown]>
|
||||||
bracket: [string, string]
|
bracket: [string, string]
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -293,14 +206,10 @@ function useScrollAnchor(containerRef: React.RefObject<HTMLDivElement | null>, d
|
|||||||
export function LogPreview({
|
export function LogPreview({
|
||||||
logs,
|
logs,
|
||||||
params,
|
params,
|
||||||
sortSettings,
|
|
||||||
onSortSettingsChange,
|
|
||||||
}: {
|
}: {
|
||||||
logs: LogEntry[]
|
logs: LogEntry[]
|
||||||
|
/** Ticket 045/053: already sorted by the caller's `fetchLogs` call per its own sort setting (owned and rendered up in `LogTimeline`'s header now) — this component just renders whatever order it's given. */
|
||||||
params: ParamDeclaration[]
|
params: ParamDeclaration[]
|
||||||
/** Ticket 045: current ordering choice, owned by the caller (it also feeds `fetchLogs`) rather than internally like `visibleParamColumns` — see `App.tsx`'s poll loop. */
|
|
||||||
sortSettings: LogSortSettings
|
|
||||||
onSortSettingsChange: (next: LogSortSettings) => void
|
|
||||||
}) {
|
}) {
|
||||||
const paramNames = params.map((d) => d.name)
|
const paramNames = params.map((d) => d.name)
|
||||||
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
|
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
|
||||||
@@ -330,7 +239,6 @@ export function LogPreview({
|
|||||||
<HelpIcon sectionId="log-preview" label="Help: Log preview" />
|
<HelpIcon sectionId="log-preview" label="Help: Log preview" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SortPicker paramNames={paramNames} settings={sortSettings} onChange={onSortSettingsChange} />
|
|
||||||
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,9 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import type { TimelineBucket } from '../lib/api'
|
import type { TimelineBucket } from '../lib/api'
|
||||||
|
import { TIME_RANGE_PRESETS, type TimeRange, type TimeRangePresetId } from '../lib/timeRange'
|
||||||
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
|
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
|
||||||
import { HelpIcon } from './HelpIcon'
|
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.
|
/// 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
|
const TIMELINE_TARGET_BUCKETS = 60
|
||||||
|
|
||||||
@@ -41,18 +16,120 @@ function formatTimestamp(ms: number): string {
|
|||||||
return new Date(ms).toLocaleTimeString()
|
return new Date(ms).toLocaleTimeString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket 053: moved here from LogPreview.tsx, along with the `SortPicker` control below — the
|
||||||
|
// setting now renders in the Timeline's header, but it still drives `fetchLogs` up in App.tsx the
|
||||||
|
// same way it always did. Renamed from the old `ottyl.logPreview.sort` key since the control no
|
||||||
|
// longer lives in "log preview"; `loadLogSortSettings` still falls back to the old key so an
|
||||||
|
// existing choice survives this move instead of silently resetting on first load.
|
||||||
|
const LOG_SORT_STORAGE_KEY = 'ottyl.logTimeline.sort'
|
||||||
|
const LEGACY_LOG_SORT_STORAGE_KEY = 'ottyl.logPreview.sort'
|
||||||
|
|
||||||
|
export interface LogSortSettings {
|
||||||
|
/** An already-declared param name to order log lines by, or `null` for the default (`ottyl_ts`-tracking) order. */
|
||||||
|
sortBy: string | null
|
||||||
|
/** Only meaningful when `sortBy` is set. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them from view. */
|
||||||
|
sortMissingFallback: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOG_SORT_SETTINGS: LogSortSettings = { sortBy: null, sortMissingFallback: true }
|
||||||
|
|
||||||
|
function parseLogSortSettings(raw: string | null): LogSortSettings | null {
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null
|
||||||
|
const sortBy = typeof parsed.sortBy === 'string' ? parsed.sortBy : null
|
||||||
|
const sortMissingFallback = typeof parsed.sortMissingFallback === 'boolean' ? parsed.sortMissingFallback : true
|
||||||
|
return { sortBy, sortMissingFallback }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLogSortSettings(): LogSortSettings {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
parseLogSortSettings(localStorage.getItem(LOG_SORT_STORAGE_KEY)) ??
|
||||||
|
parseLogSortSettings(localStorage.getItem(LEGACY_LOG_SORT_STORAGE_KEY)) ??
|
||||||
|
DEFAULT_LOG_SORT_SETTINGS
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LOG_SORT_SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLogSortSettings(settings: LogSortSettings) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOG_SORT_STORAGE_KEY, JSON.stringify(settings))
|
||||||
|
} catch {
|
||||||
|
// best-effort — private browsing or full storage just means the selection won't persist across reloads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortPicker({
|
||||||
|
paramNames,
|
||||||
|
settings,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
paramNames: string[]
|
||||||
|
settings: LogSortSettings
|
||||||
|
onChange: (next: LogSortSettings) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-secondary">
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
Sort by
|
||||||
|
<select
|
||||||
|
value={settings.sortBy ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...settings, sortBy: e.target.value || null })}
|
||||||
|
className="rounded border border-border-soft bg-surface-0 px-1 py-0.5 text-text"
|
||||||
|
>
|
||||||
|
<option value="">ottyl_ts (default)</option>
|
||||||
|
{paramNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{settings.sortBy && (
|
||||||
|
<label className="flex cursor-pointer items-center gap-1" title="How lines missing the sort param are handled">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.sortMissingFallback}
|
||||||
|
onChange={(e) => onChange({ ...settings, sortMissingFallback: e.target.checked })}
|
||||||
|
className="accent-text-muted"
|
||||||
|
/>
|
||||||
|
Fall back to ottyl_seq
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function LogTimeline({
|
export function LogTimeline({
|
||||||
buckets,
|
buckets,
|
||||||
range,
|
range,
|
||||||
onSelectRange,
|
onSelectRange,
|
||||||
activePreset,
|
activePreset,
|
||||||
onSelectPreset,
|
onSelectPreset,
|
||||||
|
paramNames,
|
||||||
|
sortSettings,
|
||||||
|
onSortSettingsChange,
|
||||||
|
sortIndexBuilding,
|
||||||
}: {
|
}: {
|
||||||
buckets: TimelineBucket[]
|
buckets: TimelineBucket[]
|
||||||
range: TimeRange | null
|
range: TimeRange | null
|
||||||
onSelectRange: (range: TimeRange | null) => void
|
onSelectRange: (range: TimeRange | null) => void
|
||||||
activePreset: TimeRangePresetId | null
|
activePreset: TimeRangePresetId | null
|
||||||
onSelectPreset: (id: TimeRangePresetId) => void
|
onSelectPreset: (id: TimeRangePresetId) => void
|
||||||
|
/** Ticket 045/053: current ordering choice, owned by the caller (it also feeds `fetchLogs`). Omitted by callers with no log list to sort (e.g. the standalone metrics/dashboards popout). */
|
||||||
|
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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [dragStartX, setDragStartX] = useState<number | null>(null)
|
const [dragStartX, setDragStartX] = useState<number | null>(null)
|
||||||
@@ -122,7 +199,16 @@ export function LogTimeline({
|
|||||||
Timeline
|
Timeline
|
||||||
<HelpIcon sectionId="timeline" label="Help: Timeline" />
|
<HelpIcon sectionId="timeline" label="Help: Timeline" />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-text-subtle">
|
<div className="flex items-center gap-3 text-xs text-text-subtle">
|
||||||
|
{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">
|
<div className="flex items-center gap-1">
|
||||||
{TIME_RANGE_PRESETS.map((preset) => (
|
{TIME_RANGE_PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
@@ -147,6 +233,7 @@ export function LogTimeline({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
|
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
declareMetric,
|
declareMetric,
|
||||||
|
deleteMetric,
|
||||||
fetchMetricBucketValues,
|
fetchMetricBucketValues,
|
||||||
fetchMetricDeclarations,
|
fetchMetricDeclarations,
|
||||||
fetchParamDeclarations,
|
fetchParamDeclarations,
|
||||||
@@ -10,17 +11,10 @@ import {
|
|||||||
updateMetric,
|
updateMetric,
|
||||||
type BucketValue,
|
type BucketValue,
|
||||||
type MetricDeclaration,
|
type MetricDeclaration,
|
||||||
|
type MetricSemantics,
|
||||||
type ParamDeclaration,
|
type ParamDeclaration,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
import {
|
import { FormFooter, PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
|
||||||
CollapseToggle,
|
|
||||||
handleSectionHeaderKeyDown,
|
|
||||||
PopOutButton,
|
|
||||||
sectionHeaderInteractiveClassName,
|
|
||||||
sectionHeaderLeftClassName,
|
|
||||||
useSectionCollapsed,
|
|
||||||
} from './CollapsibleSection'
|
|
||||||
import { HelpIcon } from './HelpIcon'
|
|
||||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||||
|
|
||||||
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
|
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
|
||||||
@@ -89,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.
|
// (ticket 037) rather than re-entering a regex/capture-group/kind per param.
|
||||||
const [labelParams, setLabelParams] = useState<string[]>([])
|
const [labelParams, setLabelParams] = useState<string[]>([])
|
||||||
const [valueParam, setValueParam] = useState<string>('')
|
const [valueParam, setValueParam] = useState<string>('')
|
||||||
|
const [semantics, setSemantics] = useState<MetricSemantics>('gauge')
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
|
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
|
||||||
@@ -148,6 +143,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
setRegex('')
|
setRegex('')
|
||||||
setLabelParams([])
|
setLabelParams([])
|
||||||
setValueParam('')
|
setValueParam('')
|
||||||
|
setSemantics('gauge')
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +154,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
setRegex(metric.message_match_regex)
|
setRegex(metric.message_match_regex)
|
||||||
setLabelParams(metric.label_params)
|
setLabelParams(metric.label_params)
|
||||||
setValueParam(metric.value_param ?? '')
|
setValueParam(metric.value_param ?? '')
|
||||||
|
setSemantics(metric.semantics ?? 'gauge')
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +163,19 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
editMetric(metric)
|
editMetric(metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No confirm-before-delete dialog — matches the rest of this settings UI (e.g. CSS entry / profile
|
||||||
|
// deletion in NavMenu.tsx), which also deletes directly on click rather than prompting first.
|
||||||
|
async function handleDelete(metricName: string) {
|
||||||
|
try {
|
||||||
|
await deleteMetric(metricName)
|
||||||
|
if (selected === metricName) setSelected(null)
|
||||||
|
if (editingName === metricName) resetForm()
|
||||||
|
await loadMetrics()
|
||||||
|
} catch {
|
||||||
|
// best-effort; the declared-metrics list simply keeps showing it if the delete failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
@@ -176,6 +186,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
message_match_regex: regex,
|
message_match_regex: regex,
|
||||||
label_params: labelParams,
|
label_params: labelParams,
|
||||||
value_param: valueParam || null,
|
value_param: valueParam || null,
|
||||||
|
semantics,
|
||||||
}
|
}
|
||||||
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
|
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
|
||||||
resetForm()
|
resetForm()
|
||||||
@@ -193,23 +204,13 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border text-sm">
|
<div className="border-b border-border text-sm">
|
||||||
<div
|
<SectionHeader
|
||||||
className={sectionHeaderInteractiveClassName}
|
title="Metrics"
|
||||||
role="button"
|
helpSectionId="metrics"
|
||||||
tabIndex={0}
|
collapsed={collapsed}
|
||||||
aria-expanded={!collapsed}
|
onToggle={() => setCollapsed(!collapsed)}
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
popOut={<PopOutButton section="metrics" range={range} activePreset={activePreset} />}
|
||||||
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>
|
|
||||||
<div className="flex gap-4 p-3" hidden={collapsed}>
|
<div className="flex gap-4 p-3" hidden={collapsed}>
|
||||||
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -280,31 +281,37 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 && (
|
{editingName && (
|
||||||
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
||||||
Editing "{editingName}"
|
Editing "{editingName}"
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
||||||
<div className="flex items-center gap-2">
|
<FormFooter
|
||||||
<button
|
editing={editingName !== null}
|
||||||
type="submit"
|
submitting={submitting}
|
||||||
disabled={submitting}
|
idleLabel="Declare metric"
|
||||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
editingLabel="Save changes"
|
||||||
>
|
submittingLabel="Declaring…"
|
||||||
{submitting ? (editingName ? 'Saving…' : 'Declaring…') : editingName ? 'Save changes' : 'Declare metric'}
|
editingSubmittingLabel="Saving…"
|
||||||
</button>
|
onCancel={resetForm}
|
||||||
{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>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -312,18 +319,27 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
<div className="mb-3 flex flex-wrap gap-1">
|
<div className="mb-3 flex flex-wrap gap-1">
|
||||||
{metrics.length === 0 && <span className="text-xs text-text-faint">No metrics declared yet.</span>}
|
{metrics.length === 0 && <span className="text-xs text-text-faint">No metrics declared yet.</span>}
|
||||||
{metrics.map((m) => (
|
{metrics.map((m) => (
|
||||||
<button
|
<div
|
||||||
key={m.name}
|
key={m.name}
|
||||||
onClick={() => selectMetric(m)}
|
className={`flex items-center gap-0.5 rounded border pl-2 pr-1 py-1 text-xs ${
|
||||||
className={`rounded border px-2 py-1 text-xs ${
|
|
||||||
selected === m.name
|
selected === m.name
|
||||||
? 'border-accent bg-accent/10 text-accent-text'
|
? 'border-accent bg-accent/10 text-accent-text'
|
||||||
: 'border-border-soft text-text-secondary hover:bg-surface-2'
|
: 'border-border-soft text-text-secondary hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
title={m.message_match_regex}
|
|
||||||
>
|
>
|
||||||
|
<button type="button" onClick={() => selectMetric(m)} title={m.message_match_regex}>
|
||||||
{m.name} ({m.label_params.join(', ') || 'no params'})
|
{m.name} ({m.label_params.join(', ') || 'no params'})
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(m.name)}
|
||||||
|
className="rounded px-1 text-text-faint hover:bg-danger-bg/50 hover:text-danger-text"
|
||||||
|
aria-label={`Delete metric ${m.name}`}
|
||||||
|
title={`Delete metric "${m.name}"`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -350,6 +366,10 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
<span className="text-text-subtle">Value param: </span>
|
<span className="text-text-subtle">Value param: </span>
|
||||||
<span className="font-mono">{selectedMetric.value_param ?? <span className="text-text-faint">none</span>}</span>
|
<span className="font-mono">{selectedMetric.value_param ?? <span className="text-text-faint">none</span>}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-text-subtle">Semantics: </span>
|
||||||
|
<span className="font-mono">{selectedMetric.semantics ?? 'gauge'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Stats } from '../lib/api'
|
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 { useSettingsPanel } from '../lib/useSettingsPanel'
|
||||||
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
||||||
import { DocsPanel } from './Docs'
|
import { DocsPanel } from './Docs'
|
||||||
import { HelpIcon } from './HelpIcon'
|
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
|
type PanelName = 'settings' | 'docs' | null
|
||||||
|
|
||||||
@@ -17,22 +17,19 @@ const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
|
|||||||
/** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */
|
/** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */
|
||||||
const DEFAULT_PROFILE_NAME = 'Default'
|
const DEFAULT_PROFILE_NAME = 'Default'
|
||||||
|
|
||||||
const COLOR_SCHEMES: ColorScheme[] = [
|
/** Display label for a theme name: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
|
||||||
'system',
|
function themeLabel(theme: string): string {
|
||||||
'light',
|
return theme.replace(/_/g, ' ')
|
||||||
'dark',
|
}
|
||||||
'red',
|
|
||||||
'green',
|
|
||||||
'blue',
|
|
||||||
'yellow',
|
|
||||||
'pink',
|
|
||||||
'orange',
|
|
||||||
'light_green',
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Display label for a `ColorScheme`: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
|
/** 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 schemeLabel(scheme: ColorScheme): string {
|
function formatIngestLag(ms: number): string {
|
||||||
return scheme.replace(/_/g, ' ')
|
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 }) {
|
export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||||
@@ -62,7 +59,10 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
|
|||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3">
|
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 text-lg font-semibold text-text">ottyl</div>
|
<div className="mb-4 flex items-center gap-2 text-lg font-semibold text-text">
|
||||||
|
<img src={ottylIcon} alt="" className="h-5 w-5" />
|
||||||
|
ottyl
|
||||||
|
</div>
|
||||||
<PresetsDropdown panel={panel} />
|
<PresetsDropdown panel={panel} />
|
||||||
<nav className="mt-2 flex flex-col gap-1 text-sm text-text-muted">
|
<nav className="mt-2 flex flex-col gap-1 text-sm text-text-muted">
|
||||||
<button
|
<button
|
||||||
@@ -93,6 +93,9 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
|
|||||||
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
|
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
|
||||||
</div>
|
</div>
|
||||||
<div>{stats.approx_bytes_total.toLocaleString()} bytes</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>
|
<div>connecting…</div>
|
||||||
@@ -191,10 +194,6 @@ function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel>
|
|||||||
|
|
||||||
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
|
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
|
||||||
const [maxLogsInput, setMaxLogsInput] = useState('')
|
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 [ntfyTopicInput, setNtfyTopicInput] = useState('')
|
||||||
const [ntfyServerInput, setNtfyServerInput] = useState('')
|
const [ntfyServerInput, setNtfyServerInput] = useState('')
|
||||||
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
|
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
|
||||||
@@ -250,198 +249,43 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
panel.setMaxLogs(Math.floor(parsed))
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<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">
|
<span className="text-[11px] text-text-faint">
|
||||||
one list of interchangeable styles — a built-in palette or your own imported/linked stylesheet. Any of
|
each theme is a complete stylesheet (colours, spacing, fonts, sizes, ...); only one is active at a time.
|
||||||
them can be a simple colour swap or a full layout redesign; only one is active at a time.
|
To add your own, drop a `.css` file in `themes/` and rebuild.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{COLOR_SCHEMES.map((scheme) => (
|
|
||||||
<div key={scheme} className="flex items-center">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => panel.setColorScheme(scheme)}
|
onClick={() => panel.setTheme('system')}
|
||||||
className={`rounded-l border px-2 py-1 text-xs capitalize ${
|
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||||
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme
|
panel.settings?.theme === 'system'
|
||||||
? 'border-border-strong bg-surface-2 text-text'
|
? 'border-border-strong bg-surface-2 text-text'
|
||||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{schemeLabel(scheme)}
|
System
|
||||||
</button>
|
</button>
|
||||||
{scheme !== 'system' && (
|
{panel.themes.map((theme) => (
|
||||||
<button
|
<button
|
||||||
|
key={theme}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadBuiltinTheme(scheme)}
|
onClick={() => panel.setTheme(theme)}
|
||||||
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"
|
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||||
aria-label={`Download ${schemeLabel(scheme)} theme`}
|
panel.settings?.theme === theme
|
||||||
title="Download as .css"
|
? 'border-border-strong bg-surface-2 text-text'
|
||||||
>
|
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</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}
|
{themeLabel(theme)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { ChartType, PromqlSeries } from '../lib/api'
|
import type { ChartType, PromqlSeries } from '../lib/api'
|
||||||
|
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`.
|
/// 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 {
|
export function seriesLabel(labels: Record<string, string>): string {
|
||||||
@@ -91,19 +92,36 @@ function sumValue(series: PromqlSeries): number {
|
|||||||
return series.points.reduce((total, [, value]) => total + value, 0)
|
return series.points.reduce((total, [, value]) => total + value, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelChart({ series, chartType }: { series: PromqlSeries[]; chartType: ChartType }) {
|
export function PanelChart({
|
||||||
|
series,
|
||||||
|
chartType,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
series: PromqlSeries[]
|
||||||
|
chartType: ChartType
|
||||||
|
range?: TimeRange | null
|
||||||
|
}) {
|
||||||
if (series.length === 0) {
|
if (series.length === 0) {
|
||||||
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No data in range.</div>
|
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No data in range.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'line') {
|
if (chartType === 'line') {
|
||||||
const { rows, keys } = toLineData(series)
|
const { rows, keys } = toLineData(series)
|
||||||
|
// Ticket 057: without an explicit numeric domain, recharts auto-scales the x-axis to the
|
||||||
|
// min/max timestamp actually present in `rows` — so a metric whose occurrences only cover a
|
||||||
|
// sliver of the queried range (a burst followed by silence) silently renders as if that
|
||||||
|
// sliver *were* the full range, with no visual sign the axis got narrowed. Pinning the domain
|
||||||
|
// to the panel's actual queried range keeps the axis honest; `type="number"` is required for
|
||||||
|
// a `domain` tuple to be interpreted as a value range rather than a category filter.
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<LineChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
<LineChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp_ms"
|
dataKey="timestamp_ms"
|
||||||
|
type="number"
|
||||||
|
domain={range ? [range.startMs, range.endMs] : ['dataMin', 'dataMax']}
|
||||||
|
allowDataOverflow
|
||||||
tickFormatter={formatTimestamp}
|
tickFormatter={formatTimestamp}
|
||||||
stroke="var(--c-text-subtle)"
|
stroke="var(--c-text-subtle)"
|
||||||
tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }}
|
tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }}
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { ApiError, declareParam, type ParamDeclaration } from '../lib/api'
|
import { ApiError, clearParams, declareParam, type ParamDeclaration } from '../lib/api'
|
||||||
import {
|
import { FormFooter, PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
|
||||||
CollapseToggle,
|
|
||||||
handleSectionHeaderKeyDown,
|
|
||||||
PopOutButton,
|
|
||||||
sectionHeaderInteractiveClassName,
|
|
||||||
sectionHeaderLeftClassName,
|
|
||||||
useSectionCollapsed,
|
|
||||||
} from './CollapsibleSection'
|
|
||||||
import { HelpIcon } from './HelpIcon'
|
|
||||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||||
|
|
||||||
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
||||||
@@ -19,6 +11,16 @@ const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
|||||||
// sharing the same label set (ticket 038).
|
// sharing the same label set (ticket 038).
|
||||||
type ExtractionSource = 'regex' | 'json_field' | 'elapsed_since_last'
|
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
|
/// 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
|
/// 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.
|
/// unlocks the "JSON field" extraction option.
|
||||||
@@ -48,6 +50,15 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
// in place by name, so changing it here would silently create a second param and orphan the
|
// in place by name, so changing it here would silently create a second param and orphan the
|
||||||
// original (and anything referencing it by name) rather than renaming it.
|
// original (and anything referencing it by name) rather than renaming it.
|
||||||
const [editingName, setEditingName] = useState<string | null>(null)
|
const [editingName, setEditingName] = useState<string | null>(null)
|
||||||
|
const [clearing, setClearing] = useState(false)
|
||||||
|
const [clearResult, setClearResult] = useState<string | null>(null)
|
||||||
|
const [clearError, setClearError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// `reserved` params (`ottyl_seq`/`ottyl_ts`/`ottyl_fmt`) are synthesized server-side purely for
|
||||||
|
// discoverability (see `GET /api/params`) — they aren't real declarations, so they're kept out of
|
||||||
|
// the editable/clickable list and shown separately, non-interactively.
|
||||||
|
const declaredParams = useMemo(() => params.filter((p) => !p.reserved), [params])
|
||||||
|
const reservedParams = useMemo(() => params.filter((p) => p.reserved), [params])
|
||||||
|
|
||||||
const isBoolean = kind === 'boolean'
|
const isBoolean = kind === 'boolean'
|
||||||
const isElapsedSinceLast = source === 'elapsed_since_last'
|
const isElapsedSinceLast = source === 'elapsed_since_last'
|
||||||
@@ -117,25 +128,42 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clears every declared param (manual and auto-derived) in one action, then immediately
|
||||||
|
// re-derives whatever the engine can still find in retained log text (ticket 052) — a single
|
||||||
|
// confirmed click rather than removing params one at a time. `logs_considered` in the response
|
||||||
|
// is surfaced in the status line so it's clear the re-extraction only covers logs the engine
|
||||||
|
// still holds, not ones already evicted by a max-logs cap.
|
||||||
|
async function handleClearAll() {
|
||||||
|
if (params.length === 0) return
|
||||||
|
if (!window.confirm(`Clear all ${params.length} declared param(s)? Params will immediately be re-derived from log lines still held in memory; lines already evicted by a log cap can't regain their params.`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setClearing(true)
|
||||||
|
setClearError(null)
|
||||||
|
setClearResult(null)
|
||||||
|
try {
|
||||||
|
const result = await clearParams()
|
||||||
|
setClearResult(
|
||||||
|
`Cleared ${result.removed}, re-derived ${result.reextracted} from ${result.logs_considered} retained log line(s).`,
|
||||||
|
)
|
||||||
|
resetForm()
|
||||||
|
onDeclared()
|
||||||
|
} catch (err) {
|
||||||
|
setClearError(err instanceof ApiError ? err.message : 'Failed to clear params')
|
||||||
|
} finally {
|
||||||
|
setClearing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border text-sm">
|
<div className="border-b border-border text-sm">
|
||||||
<div
|
<SectionHeader
|
||||||
className={sectionHeaderInteractiveClassName}
|
title="Params"
|
||||||
role="button"
|
helpSectionId="parameters"
|
||||||
tabIndex={0}
|
collapsed={collapsed}
|
||||||
aria-expanded={!collapsed}
|
onToggle={() => setCollapsed(!collapsed)}
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
popOut={<PopOutButton section="params" />}
|
||||||
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>
|
|
||||||
<div className="flex gap-4 p-3" hidden={collapsed}>
|
<div className="flex gap-4 p-3" hidden={collapsed}>
|
||||||
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -240,32 +268,37 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
||||||
<div className="flex items-center gap-2">
|
<FormFooter
|
||||||
<button
|
editing={editingName !== null}
|
||||||
type="submit"
|
submitting={submitting}
|
||||||
disabled={submitting}
|
idleLabel="Declare param"
|
||||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
editingLabel="Save changes"
|
||||||
>
|
submittingLabel="Declaring…"
|
||||||
{submitting ? (editingName ? 'Saving…' : 'Declaring…') : editingName ? 'Save changes' : 'Declare param'}
|
editingSubmittingLabel="Saving…"
|
||||||
</button>
|
onCancel={resetForm}
|
||||||
{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>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mb-1 text-xs text-text-subtle">Declared params</div>
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-text-subtle">Declared params</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={clearing || params.length === 0}
|
||||||
|
className="rounded border border-border-soft px-2 py-0.5 text-xs text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
title="Clear every declared param, then re-derive params from log lines still held in memory"
|
||||||
|
>
|
||||||
|
{clearing ? 'Clearing…' : 'Clear all'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{clearResult && <div className="mb-1 text-xs text-text-subtle">{clearResult}</div>}
|
||||||
|
{clearError && <div className="mb-1 text-xs text-danger-text">{clearError}</div>}
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{params.length === 0 && <span className="text-xs text-text-faint">No params declared yet.</span>}
|
{declaredParams.length === 0 && (
|
||||||
{params.map((p) => (
|
<span className="text-xs text-text-faint">No params declared yet.</span>
|
||||||
|
)}
|
||||||
|
{declaredParams.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.name}
|
key={p.name}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -278,15 +311,29 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
title={p.elapsed_since_last ? undefined : p.message_match_regex}
|
title={p.elapsed_since_last ? undefined : p.message_match_regex}
|
||||||
>
|
>
|
||||||
{p.name} ({p.kind}
|
{p.name} ({p.kind}
|
||||||
{p.elapsed_since_last
|
{paramBadgeSuffix(p)}
|
||||||
? ', elapsed since previous'
|
|
||||||
: p.kind === 'boolean'
|
|
||||||
? ''
|
|
||||||
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
|
|
||||||
)
|
)
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{reservedParams.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-1 mt-2 text-xs text-text-subtle">
|
||||||
|
Reserved (built in — present on every line, not editable)
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{reservedParams.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.name}
|
||||||
|
className="rounded border border-dashed border-border-soft px-2 py-1 text-xs text-text-faint"
|
||||||
|
title={`${p.name} (${p.kind}, reserved)`}
|
||||||
|
>
|
||||||
|
{p.name} ({p.kind})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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 open = pinned || hovering
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useClickOutside(ref, () => setPinned(false), pinned)
|
||||||
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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
+9
-251
@@ -12,36 +12,16 @@
|
|||||||
* danger banners, accent highlights) is a plain CSS custom property here,
|
* danger banners, accent highlights) is a plain CSS custom property here,
|
||||||
* bridged into real Tailwind utilities (bg-surface-0, text-text-muted,
|
* bridged into real Tailwind utilities (bg-surface-0, text-text-muted,
|
||||||
* border-accent, ...) via `@theme inline`. Components use those utilities
|
* border-accent, ...) via `@theme inline`. Components use those utilities
|
||||||
* instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...),
|
* 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.
|
|
||||||
*
|
*
|
||||||
* Derivation: `dark` uses one true black-based neutral (S=0) scale, `light`
|
* The actual theme files live in `themes/` at the repo root (embedded into
|
||||||
* is its exact lightness mirror — these two stay grey throughout. (There
|
* the binary at build time — see `THEMES` in `src/api/mod.rs`), one CSS file
|
||||||
* used to be a separate `grey` theme sharing `dark`'s neutral scale with
|
* per theme, each free to redeclare `--c-*` under `:root` and/or add any
|
||||||
* only its accent trio differing; it was a near-duplicate of `dark` and was
|
* other rules (spacing, fonts, layout, ...) it wants. The `:root` block below
|
||||||
* removed in favour of a single `dark` theme — ticket 004. Settings saved
|
* is only the pre-theme-load fallback (identical to `themes/dark.css`) so
|
||||||
* with `color_scheme: "grey"` still load fine: the backend aliases it to
|
* there's no flash of unstyled content before the selected theme's
|
||||||
* `dark`, see `ColorScheme` in `src/settings.rs`.)
|
* stylesheet is injected — see `useSettingsPanel.ts`. To add a new theme,
|
||||||
* Each named theme (red/green/blue/yellow/pink/orange) instead tints its
|
* drop another file in `themes/` and rebuild; no code changes needed.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
@@ -74,228 +54,6 @@
|
|||||||
--c-accent-3: #4ade80;
|
--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%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-surface-0: var(--c-surface-0);
|
--color-surface-0: var(--c-surface-0);
|
||||||
--color-surface-1: var(--c-surface-1);
|
--color-surface-1: var(--c-surface-1);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user