Compare commits
@@ -1,3 +1,6 @@
|
||||
/target
|
||||
target/
|
||||
.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 -->
|
||||
@@ -0,0 +1,47 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
ottyl turns a piped command's stdout (or a tailed file) into a local observability dashboard: searchable log timeline, ad-hoc metrics extracted from log lines via regex, and Grafana-style dashboards — all served from a single self-contained binary for the lifetime of that process. It can also poll a Prometheus/OpenMetrics endpoint alongside log ingestion. See `README.md` for the user-facing feature list and CLI usage.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make build # builds webapp (npm install + vite build) then the release Rust binary; webapp/dist is embedded into the binary
|
||||
make dev # frontend dev server with hot reload, proxying API calls to a separately running `cargo run` backend (see webapp/vite.config.ts)
|
||||
make test # cargo test + frontend build (frontend has no test suite, just `tsc -b && vite build`)
|
||||
make lint # cargo clippy --all-targets + oxlint (webapp)
|
||||
make run # build then run ./target/release/ottyl
|
||||
```
|
||||
|
||||
- Single backend test: `cargo test <test_name>`
|
||||
- Backend tests live inline in each `src/*.rs` file (`#[cfg(test)] mod tests`), not in a separate `tests/` dir.
|
||||
- The webapp has no unit tests — `npm run build` (tsc + vite) is the only frontend check besides lint.
|
||||
- `webapp/dist` must exist before `cargo build` will produce a working binary (it's embedded via `fs_embed!`), so always build the frontend first — `make build` handles ordering; don't run `cargo build` alone after frontend changes without rebuilding `webapp/dist`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Backend: Rust (axum + tokio), single binary, in `src/`. Frontend: React 19 + TypeScript + Tailwind + Recharts, in `webapp/`, built with Vite and embedded into the binary at build time (`src/api.rs`'s `WEBAPP_DIST` via `fs_embed!("webapp/dist")`).
|
||||
|
||||
**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/`** — owns all live state, split across two actor tasks under `engine/actor/`: `actor/primary.rs` (ingested log lines, param declarations) and `actor/profile.rs` (metric declarations, computed metric buckets, alert evaluation — moved off the primary per `ideas/0001-actor-split.md`). Both are plain `tokio::spawn`ed loops; `actor/mod.rs` holds the `left_right`-based `IndexReader`/`IndexReadHandle` read plumbing they share. All access is through `EngineHandle` (defined in `actor/primary.rs`, re-exported from `engine/mod.rs`) sending `EngineMessage` over an mpsc channel and awaiting a response via oneshot — there is no shared mutable engine state accessed directly from request handlers. `engine/snapshot/` is the state (`EngineSnapshot`) both actors mutate; this is the place to start reading to understand how declarations, filtering, and bucketing actually work.
|
||||
- **`api.rs`** — axum routes and HTTP-facing `ApiState` (holds `EngineHandle` + `SettingsHandle`). Translates HTTP requests into `EngineMessage`s and DTOs back into JSON. Also serves the embedded webapp.
|
||||
- **`settings.rs` / `profile.rs`** — persisted configuration. A **profile** is per-app: parsing rules (param/metric declarations) and dashboards, saved so they're there again next time that app runs through ottyl (see README's "Profiles"). Declarations are persisted here so the engine can replay them on restart; `api.rs` handlers that mutate engine state (e.g. `declare_metric`) call a corresponding persist method here so the two stay in sync — this dual-write is a recurring pattern (see `ApiState::persist_param_declaration` / `persist_metric_declaration` in `api.rs`), and adding a new declaration-mutating endpoint means updating both sides plus the load-time replay path.
|
||||
- **`filter_parser.rs`** (grammar in `filter_parser.pest`, via `pest`) + **`filter_datetime.rs`** — parse the search/filter bar syntax (regex matches, metric comparisons like `latency_ms[value]<200`, relative/absolute datetimes).
|
||||
- **`promql.rs`** — parses and evaluates the PromQL-style queries backing dashboard panels.
|
||||
- **`logfmt.rs` / `ingest.rs`** — line ingestion: tailing stdin/a file, structured logfmt-style extraction, and polling a Prometheus/OpenMetrics `--metrics-url`.
|
||||
- **`alerts.rs`** — alert rule evaluation and ntfy delivery.
|
||||
- **`dto.rs`** — shared request/response types between `api.rs` and the frontend's `webapp/src/lib/api.ts`; **`data_value.rs`** / **`metric_primitive.rs`** — the value/type model for params and metrics; **`index_key.rs`** / **`rate.rs`** — bucket indexing and rate computation helpers.
|
||||
|
||||
Frontend structure: `webapp/src/App.tsx` is the shell; `webapp/src/components/` has one component per feature area (`FilterBar`, `LogTimeline`, `Metrics`, `Params`, `Dashboards`, `Alerts`, `Docs`, `NavMenu`, ...) that map closely to the backend concepts above. `webapp/src/lib/api.ts` is the typed client for the Rust API; `webapp/src/lib/polling.ts` handles refresh polling; `webapp/src/lib/themes.ts` covers the style-preset/CSS system.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **`tickets/`** holds scoped, written-up work items (problem statement, pointers to exact call sites with `file.rs:line` references, and an explicit scope section) — check here for planned/in-flight work before starting something that might overlap. Tickets are removed from this directory once implemented (see recent commit history — e.g. "Remove ticket 047 (implemented: ...)").
|
||||
- Declaration-related features (params, metrics) consistently need three coordinated changes: an `EngineMessage` variant + handler in `engine.rs`, a persistence method in `settings.rs`/`profile.rs`, and an `api.rs` route wiring them together (plus a webapp UI affordance). Grep for an existing similar declaration (e.g. how metric declaration is wired) as the template before adding a new one.
|
||||
- Doc comments in this codebase tend to explain *why* a non-obvious choice was made (e.g. ordering constraints, a workaround, a past ticket's rationale) rather than restating what the code does — match that style rather than adding descriptive comments.
|
||||
- All markdown files (`*.md`) in this repo should follow one-sentence-per-line: each sentence starts on its own line, even within the same paragraph.
|
||||
|
||||
Generated
+438
@@ -70,6 +70,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -200,6 +206,7 @@ dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -265,6 +272,46 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "6.0.0"
|
||||
@@ -297,6 +344,18 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -355,6 +414,21 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -362,6 +436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -370,6 +445,40 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
@@ -382,12 +491,32 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -415,6 +544,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -639,6 +774,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -679,6 +820,18 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.1"
|
||||
@@ -708,6 +861,23 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "left-right"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"loom",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -750,12 +920,34 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "loom"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"generator",
|
||||
"scoped-tls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
@@ -774,6 +966,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
@@ -785,6 +983,25 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -816,6 +1033,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
name = "ottyl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -824,10 +1042,14 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"humantime",
|
||||
"include_dir",
|
||||
"left-right",
|
||||
"nom",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"petname",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -858,6 +1080,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -906,6 +1134,27 @@ dependencies = [
|
||||
"pest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "petname"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfb4653a82ec48e6d324c788597aadf46449bfc7b8e76d2a03b513a944325c88"
|
||||
dependencies = [
|
||||
"petname-macros",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "petname-macros"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c406fb544296f598ccf69b82bcf1b12440c8f23ebf75946105f86304f16eb2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
@@ -1065,6 +1314,26 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
@@ -1146,6 +1415,41 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"indexmap",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -1221,6 +1525,38 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -1257,6 +1593,17 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
@@ -1293,6 +1640,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -1430,6 +1786,15 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -1493,6 +1858,20 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -1547,9 +1926,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -1557,6 +1948,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1607,6 +2028,23 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
+6
-1
@@ -10,6 +10,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
regex = "1"
|
||||
nom = "7"
|
||||
pest = "2"
|
||||
pest_derive = "2"
|
||||
chrono = "0.4"
|
||||
@@ -17,7 +18,11 @@ humantime = "2"
|
||||
directories = "6.0.0"
|
||||
fs-embed = "0.2.4"
|
||||
include_dir = "0.7.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
petname = { version = "3.2.0", default-features = false, features = ["default-rng", "default-words"] }
|
||||
rmcp = { version = "3.2.0", default-features = false, features = ["server", "macros", "transport-io"] }
|
||||
anyhow = "1"
|
||||
left-right = "0.11.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
|
||||
@@ -23,7 +23,8 @@ test-fe:
|
||||
lint: lint-be lint-fe
|
||||
|
||||
lint-be:
|
||||
cargo clippy --all-targets
|
||||
cargo fmt -- --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
lint-fe:
|
||||
cd webapp && npm run lint
|
||||
@@ -40,3 +41,5 @@ dev:
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf webapp/dist webapp/node_modules
|
||||
rm profile.json.gz
|
||||
|
||||
|
||||
@@ -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
+947
-316
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,106 +0,0 @@
|
||||
use crate::metric_primitive::MetricPrimitive;
|
||||
use crate::timestamp::Timestamp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegexToMetricPrimitiveMatcher {
|
||||
pub param_name: 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 matchers.
|
||||
#[serde(default)]
|
||||
pub json_field_path: Option<String>,
|
||||
pub kind: MetricPrimitiveKind,
|
||||
/// Whether this param is a bucket label or its captured value (at most one Value-role param per metric); `#[serde(default)]` so profile files saved before this field existed still load as `Label`.
|
||||
#[serde(default)]
|
||||
pub role: ParamRole,
|
||||
}
|
||||
|
||||
/// Reads a value out of a JSON object by dot-separated field path (e.g. `"foo.bar"`), for
|
||||
/// [`RegexToMetricPrimitiveMatcher::json_field_path`] and the sibling `json_field_path` on a
|
||||
/// standalone param declaration. 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()?;
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`RegexToMetricPrimitiveMatcher::role`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ParamRole {
|
||||
#[default]
|
||||
Label,
|
||||
Value,
|
||||
}
|
||||
|
||||
/// `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,
|
||||
/// On a Value-role param this is derived as elapsed time since the previous event (not parsed from text); otherwise it's a literal i64 millisecond count.
|
||||
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 {
|
||||
message_match_regex: String,
|
||||
capture_group: String,
|
||||
/// See `RegexToMetricPrimitiveMatcher::json_field_path`.
|
||||
json_field_path: Option<String>,
|
||||
kind: MetricPrimitiveKind,
|
||||
},
|
||||
/// Paired with `IndexKey::MetricDeclaration`.
|
||||
MetricDeclaration {
|
||||
message_match_regex: String,
|
||||
params: Vec<RegexToMetricPrimitiveMatcher>,
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
-340
@@ -1,340 +0,0 @@
|
||||
use crate::alerts::AlertCondition;
|
||||
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
||||
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::RegexToMetricPrimitiveMatcher::json_field_path`.
|
||||
pub json_field_path: Option<String>,
|
||||
pub kind: MetricPrimitiveKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MetricDeclarationDto {
|
||||
pub name: String,
|
||||
pub message_match_regex: String,
|
||||
pub params: Vec<RegexToMetricPrimitiveMatcher>,
|
||||
/// 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 sequence<5`.
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
#[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.profiles.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>,
|
||||
}
|
||||
-2621
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,9 +1,5 @@
|
||||
//! Alert rules: definitions, trigger evaluation, and ntfy delivery.
|
||||
//!
|
||||
//! Alert queries are restricted to regex/sequence/timestamp/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/sequence/timestamp, it only inspects the line's own text/fields, no index lookup.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io;
|
||||
@@ -13,11 +9,12 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::{apply_compare_op, eval_json_comparison};
|
||||
use crate::filter_datetime;
|
||||
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
|
||||
use crate::settings::Settings;
|
||||
use crate::timestamp::Timestamp;
|
||||
use crate::config::Settings;
|
||||
use crate::engine::filter_datetime;
|
||||
use crate::engine::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
|
||||
use crate::engine::rate::RateWindow;
|
||||
use crate::engine::timestamp::Timestamp;
|
||||
use crate::engine::{self, eval_json_comparison};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AlertRule {
|
||||
@@ -30,20 +27,19 @@ pub struct AlertRule {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum AlertCondition {
|
||||
Immediate { query: String },
|
||||
/// Fires when `query`'s match count within a rolling `window_ms` crosses `threshold` (via `op`).
|
||||
Immediate {
|
||||
query: String,
|
||||
},
|
||||
Threshold {
|
||||
query: String,
|
||||
op: CompareOp,
|
||||
threshold: f64,
|
||||
window_ms: i64,
|
||||
},
|
||||
/// Seen values live only in memory — a restart forgets them, so previously-seen values can fire again; a deliberate simplicity tradeoff, not an oversight.
|
||||
NewValue {
|
||||
field_regex: String,
|
||||
capture_group: String,
|
||||
},
|
||||
/// A zero `count_b` skips evaluation for that line rather than treating the ratio as infinite.
|
||||
Ratio {
|
||||
query_a: String,
|
||||
query_b: String,
|
||||
@@ -76,7 +72,6 @@ impl From<io::Error> for AlertError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hashes the name and current time rather than pulling in a `uuid` dependency.
|
||||
fn generate_alert_rule_id(name: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
name.hash(&mut hasher);
|
||||
@@ -96,7 +91,12 @@ impl Settings {
|
||||
enabled: bool,
|
||||
condition: AlertCondition,
|
||||
) -> Result<AlertRule, AlertError> {
|
||||
let rule = AlertRule { id: generate_alert_rule_id(&name), name, enabled, condition };
|
||||
let rule = AlertRule {
|
||||
id: generate_alert_rule_id(&name),
|
||||
name,
|
||||
enabled,
|
||||
condition,
|
||||
};
|
||||
self.alert_rules.push(rule.clone());
|
||||
self.save(settings_path)?;
|
||||
Ok(rule)
|
||||
@@ -135,76 +135,113 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-rule in-memory evaluation state; lives only for the process's lifetime — not persisted, not backfilled against already-ingested logs.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AlertRuntimeState {
|
||||
threshold_matches: VecDeque<i64>,
|
||||
ratio_a_matches: VecDeque<i64>,
|
||||
ratio_b_matches: VecDeque<i64>,
|
||||
threshold_window: RateWindow,
|
||||
threshold_backfilled: bool,
|
||||
ratio_a_window: RateWindow,
|
||||
ratio_a_backfilled: bool,
|
||||
ratio_b_window: RateWindow,
|
||||
ratio_b_backfilled: bool,
|
||||
seen_values: HashSet<String>,
|
||||
}
|
||||
|
||||
/// `timestamps` is push_back-ordered, so once the front entry is within the window, every entry behind it is too.
|
||||
fn prune_window(timestamps: &mut VecDeque<i64>, now_ms: i64, window_ms: i64) {
|
||||
while let Some(&front) = timestamps.front() {
|
||||
if now_ms - front > window_ms {
|
||||
timestamps.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
fn backfill_window(
|
||||
snapshot: &engine::EngineSnapshot,
|
||||
query: &str,
|
||||
before_sequence: u64,
|
||||
now: Timestamp,
|
||||
window_ms: i64,
|
||||
) -> RateWindow {
|
||||
let since_ms = now.as_millis() - window_ms;
|
||||
let matching_timestamps = snapshot
|
||||
.logs_since(before_sequence, since_ms, now.as_millis())
|
||||
.filter_map(|(sequence, timestamp, text)| {
|
||||
(eval_alert_query(query, sequence, timestamp, text) == Ok(true)).then_some(timestamp)
|
||||
});
|
||||
RateWindow::backfill(matching_timestamps, now, window_ms)
|
||||
}
|
||||
|
||||
/// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc; json[field] is supported since it's also line-local).
|
||||
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
|
||||
fn eval_alert_query(
|
||||
query: &str,
|
||||
sequence: u64,
|
||||
timestamp: Timestamp,
|
||||
text: &str,
|
||||
) -> Result<bool, String> {
|
||||
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
|
||||
eval_alert_expr(&expr, sequence, timestamp, text)
|
||||
}
|
||||
|
||||
fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
|
||||
fn eval_alert_expr(
|
||||
expr: &FilterExpr,
|
||||
sequence: u64,
|
||||
timestamp: Timestamp,
|
||||
text: &str,
|
||||
) -> Result<bool, String> {
|
||||
match expr {
|
||||
FilterExpr::Regex(pattern) => {
|
||||
let re = Regex::new(pattern).map_err(|err| format!("invalid regex in alert query: {err}"))?;
|
||||
let re = Regex::new(pattern)
|
||||
.map_err(|err| format!("invalid regex in alert query: {err}"))?;
|
||||
Ok(re.is_match(text))
|
||||
}
|
||||
FilterExpr::Comparison { metric, param: None, op, value } if metric == "sequence" => {
|
||||
FilterExpr::Comparison {
|
||||
metric,
|
||||
param: None,
|
||||
op,
|
||||
value,
|
||||
} if metric == "ottyl_seq" => {
|
||||
let target = match value {
|
||||
FilterValue::Number(n) => *n,
|
||||
FilterValue::Raw(raw) => raw
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("'sequence' comparisons require a numeric value, got {raw:?}"))?,
|
||||
FilterValue::Raw(raw) => raw.parse::<f64>().map_err(|_| {
|
||||
format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}")
|
||||
})?,
|
||||
};
|
||||
Ok(apply_compare_op((sequence as f64).total_cmp(&target), *op))
|
||||
Ok(op.matches((sequence as f64).total_cmp(&target)))
|
||||
}
|
||||
FilterExpr::Comparison { metric, param: None, op, value } if metric == "timestamp" => {
|
||||
FilterExpr::Comparison {
|
||||
metric,
|
||||
param: None,
|
||||
op,
|
||||
value,
|
||||
} if metric == "ottyl_ts" => {
|
||||
let target_ms = match value {
|
||||
FilterValue::Number(n) => *n as i64,
|
||||
FilterValue::Raw(raw) => filter_datetime::parse_datetime(raw, Timestamp::now())
|
||||
.map_err(|err| err.to_string())?
|
||||
.as_millis(),
|
||||
};
|
||||
Ok(apply_compare_op(timestamp.as_millis().cmp(&target_ms), *op))
|
||||
Ok(op.matches(timestamp.as_millis().cmp(&target_ms)))
|
||||
}
|
||||
FilterExpr::Comparison {
|
||||
metric,
|
||||
param: None,
|
||||
op,
|
||||
value,
|
||||
} if metric == "ottyl_fmt" => {
|
||||
let target = match value {
|
||||
FilterValue::Number(n) => n.to_string(),
|
||||
FilterValue::Raw(raw) => raw.clone(),
|
||||
};
|
||||
Ok(op.matches(crate::engine::logfmt::detect_format(text).cmp(target.as_str())))
|
||||
}
|
||||
FilterExpr::Comparison { metric, .. } => Err(format!(
|
||||
"alert queries only support regex()/sequence/timestamp (not metric comparisons like \
|
||||
"alert queries only support regex()/ottyl_seq/ottyl_ts/ottyl_fmt (not metric comparisons like \
|
||||
'{metric}[param]'): metric-based alert conditions aren't supported yet"
|
||||
)),
|
||||
FilterExpr::JsonComparison { field, op, value } => {
|
||||
Ok(eval_json_comparison(text, field, *op, value))
|
||||
}
|
||||
FilterExpr::And(lhs, rhs) => {
|
||||
Ok(eval_alert_expr(lhs, sequence, timestamp, text)? && eval_alert_expr(rhs, sequence, timestamp, text)?)
|
||||
}
|
||||
FilterExpr::Or(lhs, rhs) => {
|
||||
Ok(eval_alert_expr(lhs, sequence, timestamp, text)? || eval_alert_expr(rhs, sequence, timestamp, text)?)
|
||||
}
|
||||
FilterExpr::And(lhs, rhs) => Ok(eval_alert_expr(lhs, sequence, timestamp, text)?
|
||||
&& eval_alert_expr(rhs, sequence, timestamp, text)?),
|
||||
FilterExpr::Or(lhs, rhs) => Ok(eval_alert_expr(lhs, sequence, timestamp, text)?
|
||||
|| eval_alert_expr(rhs, sequence, timestamp, text)?),
|
||||
}
|
||||
}
|
||||
|
||||
/// A malformed query (bad regex, unsupported metric comparison) is logged and simply never matches, rather than panicking.
|
||||
pub fn evaluate_alerts(
|
||||
rules: &[AlertRule],
|
||||
states: &mut HashMap<String, AlertRuntimeState>,
|
||||
snapshot: &engine::EngineSnapshot,
|
||||
sequence: u64,
|
||||
timestamp: Timestamp,
|
||||
text: &str,
|
||||
@@ -215,7 +252,15 @@ pub fn evaluate_alerts(
|
||||
continue;
|
||||
}
|
||||
let state = states.entry(rule.id.clone()).or_default();
|
||||
if let Some(message) = evaluate_condition(&rule.condition, state, sequence, timestamp, text, &rule.name) {
|
||||
if let Some(message) = evaluate_condition(
|
||||
&rule.condition,
|
||||
state,
|
||||
snapshot,
|
||||
sequence,
|
||||
timestamp,
|
||||
text,
|
||||
&rule.name,
|
||||
) {
|
||||
fired.push((rule.clone(), message));
|
||||
}
|
||||
}
|
||||
@@ -225,28 +270,39 @@ pub fn evaluate_alerts(
|
||||
fn evaluate_condition(
|
||||
condition: &AlertCondition,
|
||||
state: &mut AlertRuntimeState,
|
||||
snapshot: &engine::EngineSnapshot,
|
||||
sequence: u64,
|
||||
timestamp: Timestamp,
|
||||
text: &str,
|
||||
rule_name: &str,
|
||||
) -> Option<String> {
|
||||
match condition {
|
||||
AlertCondition::Immediate { query } => match eval_alert_query(query, sequence, timestamp, text) {
|
||||
AlertCondition::Immediate { query } => {
|
||||
match eval_alert_query(query, sequence, timestamp, text) {
|
||||
Ok(true) => Some(format!("{rule_name}: matched line: {text}")),
|
||||
Ok(false) => None,
|
||||
Err(err) => {
|
||||
eprintln!("warning: alert '{rule_name}' query error: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
AlertCondition::Threshold { query, op, threshold, window_ms } => {
|
||||
}
|
||||
}
|
||||
AlertCondition::Threshold {
|
||||
query,
|
||||
op,
|
||||
threshold,
|
||||
window_ms,
|
||||
} => {
|
||||
if !state.threshold_backfilled {
|
||||
state.threshold_window =
|
||||
backfill_window(snapshot, query, sequence, timestamp, *window_ms);
|
||||
state.threshold_backfilled = true;
|
||||
}
|
||||
match eval_alert_query(query, sequence, timestamp, text) {
|
||||
Ok(true) => {
|
||||
let now_ms = timestamp.as_millis();
|
||||
state.threshold_matches.push_back(now_ms);
|
||||
prune_window(&mut state.threshold_matches, now_ms, *window_ms);
|
||||
let count = state.threshold_matches.len() as f64;
|
||||
apply_compare_op(count.total_cmp(threshold), *op).then(|| {
|
||||
state.threshold_window.push(timestamp, *window_ms);
|
||||
let count = state.threshold_window.count() as f64;
|
||||
op.matches(count.total_cmp(threshold)).then(|| {
|
||||
format!("{rule_name}: match count {count} {op:?} threshold {threshold} (window {window_ms}ms)")
|
||||
})
|
||||
}
|
||||
@@ -257,7 +313,10 @@ fn evaluate_condition(
|
||||
}
|
||||
}
|
||||
}
|
||||
AlertCondition::NewValue { field_regex, capture_group } => {
|
||||
AlertCondition::NewValue {
|
||||
field_regex,
|
||||
capture_group,
|
||||
} => {
|
||||
let re = match Regex::new(field_regex) {
|
||||
Ok(re) => re,
|
||||
Err(err) => {
|
||||
@@ -272,7 +331,13 @@ fn evaluate_condition(
|
||||
.insert(value.clone())
|
||||
.then(|| format!("{rule_name}: new value seen: {value} (line: {text})"))
|
||||
}
|
||||
AlertCondition::Ratio { query_a, query_b, op, ratio, window_ms } => {
|
||||
AlertCondition::Ratio {
|
||||
query_a,
|
||||
query_b,
|
||||
op,
|
||||
ratio,
|
||||
window_ms,
|
||||
} => {
|
||||
let matched_a = eval_alert_query(query_a, sequence, timestamp, text);
|
||||
let matched_b = eval_alert_query(query_b, sequence, timestamp, text);
|
||||
let (matched_a, matched_b) = match (matched_a, matched_b) {
|
||||
@@ -285,23 +350,29 @@ fn evaluate_condition(
|
||||
if !matched_a && !matched_b {
|
||||
return None;
|
||||
}
|
||||
let now_ms = timestamp.as_millis();
|
||||
if !state.ratio_a_backfilled {
|
||||
state.ratio_a_window =
|
||||
backfill_window(snapshot, query_a, sequence, timestamp, *window_ms);
|
||||
state.ratio_a_backfilled = true;
|
||||
}
|
||||
if !state.ratio_b_backfilled {
|
||||
state.ratio_b_window =
|
||||
backfill_window(snapshot, query_b, sequence, timestamp, *window_ms);
|
||||
state.ratio_b_backfilled = true;
|
||||
}
|
||||
if matched_a {
|
||||
state.ratio_a_matches.push_back(now_ms);
|
||||
prune_window(&mut state.ratio_a_matches, now_ms, *window_ms);
|
||||
state.ratio_a_window.push(timestamp, *window_ms);
|
||||
}
|
||||
if matched_b {
|
||||
state.ratio_b_matches.push_back(now_ms);
|
||||
prune_window(&mut state.ratio_b_matches, now_ms, *window_ms);
|
||||
state.ratio_b_window.push(timestamp, *window_ms);
|
||||
}
|
||||
let count_a = state.ratio_a_matches.len() as f64;
|
||||
let count_b = state.ratio_b_matches.len() as f64;
|
||||
let count_a = state.ratio_a_window.count() as f64;
|
||||
let count_b = state.ratio_b_window.count() as f64;
|
||||
if count_b == 0.0 {
|
||||
// Zero denominator: skip rather than treat the ratio as infinite.
|
||||
return None;
|
||||
}
|
||||
let observed = count_a / count_b;
|
||||
apply_compare_op(observed.total_cmp(ratio), *op).then(|| {
|
||||
op.matches(observed.total_cmp(ratio)).then(|| {
|
||||
format!(
|
||||
"{rule_name}: ratio {observed:.3} ({count_a}/{count_b}) {op:?} {ratio} (window {window_ms}ms)"
|
||||
)
|
||||
@@ -310,10 +381,12 @@ fn evaluate_condition(
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivers to the single, process-wide ntfy sink configured in Settings. Errors are only logged
|
||||
/// (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
|
||||
/// A silently no-op'd delivery (no topic configured) is expected — a rule can be defined before the sink is.
|
||||
pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<String>, server: Option<String>) {
|
||||
pub async fn deliver_for_rule(
|
||||
rule_name: String,
|
||||
message: String,
|
||||
topic: Option<String>,
|
||||
server: Option<String>,
|
||||
) {
|
||||
let Some(topic) = topic else {
|
||||
return;
|
||||
};
|
||||
@@ -323,7 +396,6 @@ pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<
|
||||
}
|
||||
}
|
||||
|
||||
/// Standalone and `AlertRule`-free so a "send test notification" endpoint can call it directly.
|
||||
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
|
||||
let url = format!("{}/{}", server.trim_end_matches('/'), topic);
|
||||
let response = reqwest::Client::new()
|
||||
@@ -352,7 +424,9 @@ mod tests {
|
||||
id: "r1".to_string(),
|
||||
name: "test rule".to_string(),
|
||||
enabled: true,
|
||||
condition: AlertCondition::Immediate { query: query.to_string() },
|
||||
condition: AlertCondition::Immediate {
|
||||
query: query.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +441,9 @@ mod tests {
|
||||
&settings_path,
|
||||
"spike".to_string(),
|
||||
true,
|
||||
AlertCondition::Immediate { query: r#"regex("error")"#.to_string() },
|
||||
AlertCondition::Immediate {
|
||||
query: r#"regex("error")"#.to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings.alert_rules.len(), 1);
|
||||
@@ -381,14 +457,18 @@ mod tests {
|
||||
&created.id,
|
||||
"renamed".to_string(),
|
||||
false,
|
||||
AlertCondition::Immediate { query: r#"regex("warn")"#.to_string() },
|
||||
AlertCondition::Immediate {
|
||||
query: r#"regex("warn")"#.to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.id, created.id);
|
||||
assert_eq!(updated.name, "renamed");
|
||||
assert!(!updated.enabled);
|
||||
|
||||
settings.remove_alert_rule(&settings_path, &created.id).unwrap();
|
||||
settings
|
||||
.remove_alert_rule(&settings_path, &created.id)
|
||||
.unwrap();
|
||||
assert!(settings.alert_rules.is_empty());
|
||||
let reloaded = Settings::load(&settings_path);
|
||||
assert!(reloaded.alert_rules.is_empty());
|
||||
@@ -405,7 +485,9 @@ mod tests {
|
||||
"does-not-exist",
|
||||
"x".to_string(),
|
||||
true,
|
||||
AlertCondition::Immediate { query: r#"regex("x")"#.to_string() },
|
||||
AlertCondition::Immediate {
|
||||
query: r#"regex("x")"#.to_string(),
|
||||
},
|
||||
);
|
||||
assert!(matches!(result, Err(AlertError::NotFound)));
|
||||
|
||||
@@ -417,12 +499,13 @@ mod tests {
|
||||
fn immediate_fires_on_matching_line_only() {
|
||||
let rules = vec![immediate_rule(r#"regex("error")"#)];
|
||||
let mut states = HashMap::new();
|
||||
let index = engine::EngineSnapshot::default();
|
||||
|
||||
let (seq, ts, text) = line("all good", 0, 0);
|
||||
assert!(evaluate_alerts(&rules, &mut states, seq, ts, text).is_empty());
|
||||
assert!(evaluate_alerts(&rules, &mut states, &index, seq, ts, text).is_empty());
|
||||
|
||||
let (seq, ts, text) = line("an error happened", 1, 1);
|
||||
let fired = evaluate_alerts(&rules, &mut states, seq, ts, text);
|
||||
let fired = evaluate_alerts(&rules, &mut states, &index, seq, ts, text);
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert!(fired[0].1.contains("an error happened"));
|
||||
}
|
||||
@@ -432,8 +515,9 @@ mod tests {
|
||||
let mut rule = immediate_rule(r#"regex("error")"#);
|
||||
rule.enabled = false;
|
||||
let mut states = HashMap::new();
|
||||
let index = engine::EngineSnapshot::default();
|
||||
let (seq, ts, text) = line("error!", 0, 0);
|
||||
assert!(evaluate_alerts(&[rule], &mut states, seq, ts, text).is_empty());
|
||||
assert!(evaluate_alerts(&[rule], &mut states, &index, seq, ts, text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -450,14 +534,38 @@ mod tests {
|
||||
},
|
||||
};
|
||||
let mut states = HashMap::new();
|
||||
let index = engine::EngineSnapshot::default();
|
||||
for (seq, ms) in [(0u64, 0i64), (1, 1000)] {
|
||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, seq, Timestamp::from_millis(ms), "error");
|
||||
assert!(fired.is_empty(), "should not fire before threshold is crossed");
|
||||
let fired = evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
seq,
|
||||
Timestamp::from_millis(ms),
|
||||
"error",
|
||||
);
|
||||
assert!(
|
||||
fired.is_empty(),
|
||||
"should not fire before threshold is crossed"
|
||||
);
|
||||
}
|
||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 2, Timestamp::from_millis(2000), "error");
|
||||
let fired = evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
2,
|
||||
Timestamp::from_millis(2000),
|
||||
"error",
|
||||
);
|
||||
assert_eq!(fired.len(), 1);
|
||||
// Stays quiet for a non-matching line even though the window is full.
|
||||
let fired = evaluate_alerts(&[rule], &mut states, 3, Timestamp::from_millis(2100), "fine");
|
||||
let fired = evaluate_alerts(
|
||||
&[rule],
|
||||
&mut states,
|
||||
&index,
|
||||
3,
|
||||
Timestamp::from_millis(2100),
|
||||
"fine",
|
||||
);
|
||||
assert!(fired.is_empty());
|
||||
}
|
||||
|
||||
@@ -475,9 +583,29 @@ mod tests {
|
||||
},
|
||||
};
|
||||
let mut states = HashMap::new();
|
||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "error").is_empty());
|
||||
// Second match arrives well after the first has aged out of the window.
|
||||
assert!(evaluate_alerts(&[rule], &mut states, 1, Timestamp::from_millis(5_000), "error").is_empty());
|
||||
let index = engine::EngineSnapshot::default();
|
||||
assert!(
|
||||
evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
0,
|
||||
Timestamp::from_millis(0),
|
||||
"error"
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
evaluate_alerts(
|
||||
&[rule],
|
||||
&mut states,
|
||||
&index,
|
||||
1,
|
||||
Timestamp::from_millis(5_000),
|
||||
"error"
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -492,11 +620,33 @@ mod tests {
|
||||
},
|
||||
};
|
||||
let mut states = HashMap::new();
|
||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "agent=curl");
|
||||
let index = engine::EngineSnapshot::default();
|
||||
let fired = evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
0,
|
||||
Timestamp::from_millis(0),
|
||||
"agent=curl",
|
||||
);
|
||||
assert_eq!(fired.len(), 1);
|
||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 1, Timestamp::from_millis(1), "agent=curl");
|
||||
let fired = evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
1,
|
||||
Timestamp::from_millis(1),
|
||||
"agent=curl",
|
||||
);
|
||||
assert!(fired.is_empty());
|
||||
let fired = evaluate_alerts(&[rule], &mut states, 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);
|
||||
}
|
||||
|
||||
@@ -515,16 +665,71 @@ mod tests {
|
||||
},
|
||||
};
|
||||
let mut states = HashMap::new();
|
||||
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
|
||||
assert!(evaluate_alerts(&[rule.clone()], &mut states, 0, Timestamp::from_millis(0), "error").is_empty());
|
||||
// One request: ratio 1/1 = 1.0 >= 0.5 -> fires.
|
||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 1, Timestamp::from_millis(1), "request");
|
||||
let index = engine::EngineSnapshot::default();
|
||||
assert!(
|
||||
evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
0,
|
||||
Timestamp::from_millis(0),
|
||||
"error"
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
let fired = evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
1,
|
||||
Timestamp::from_millis(1),
|
||||
"request",
|
||||
);
|
||||
assert_eq!(fired.len(), 1);
|
||||
// A second request: ratio 1/2 = 0.5 still >= 0.5 -> fires again.
|
||||
let fired = evaluate_alerts(&[rule.clone()], &mut states, 2, Timestamp::from_millis(2), "request");
|
||||
let fired = evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
2,
|
||||
Timestamp::from_millis(2),
|
||||
"request",
|
||||
);
|
||||
assert_eq!(fired.len(), 1);
|
||||
// A third request with no more errors: ratio drops to 1/3 -> no longer fires.
|
||||
let fired = evaluate_alerts(&[rule], &mut states, 3, Timestamp::from_millis(3), "request");
|
||||
let fired = evaluate_alerts(
|
||||
&[rule],
|
||||
&mut states,
|
||||
&index,
|
||||
3,
|
||||
Timestamp::from_millis(3),
|
||||
"request",
|
||||
);
|
||||
assert!(fired.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ottyl_fmt_alert_query_matches_detected_format() {
|
||||
let rule = immediate_rule("ottyl_fmt==json");
|
||||
let mut states = HashMap::new();
|
||||
let index = engine::EngineSnapshot::default();
|
||||
|
||||
let fired = evaluate_alerts(
|
||||
std::slice::from_ref(&rule),
|
||||
&mut states,
|
||||
&index,
|
||||
0,
|
||||
Timestamp::from_millis(0),
|
||||
r#"{"level":"error"}"#,
|
||||
);
|
||||
assert_eq!(fired.len(), 1);
|
||||
|
||||
let fired = evaluate_alerts(
|
||||
&[rule],
|
||||
&mut states,
|
||||
&index,
|
||||
1,
|
||||
Timestamp::from_millis(1),
|
||||
"plain text",
|
||||
);
|
||||
assert!(fired.is_empty());
|
||||
}
|
||||
|
||||
@@ -532,16 +737,23 @@ mod tests {
|
||||
fn metric_comparison_in_alert_query_is_rejected_not_panicking() {
|
||||
let rule = immediate_rule("some_metric[param]==5");
|
||||
let mut states = HashMap::new();
|
||||
// Should log a warning and simply not fire, never panic.
|
||||
let fired = evaluate_alerts(&[rule], &mut states, 0, Timestamp::from_millis(0), "anything");
|
||||
let index = engine::EngineSnapshot::default();
|
||||
let fired = evaluate_alerts(
|
||||
&[rule],
|
||||
&mut states,
|
||||
&index,
|
||||
0,
|
||||
Timestamp::from_millis(0),
|
||||
"anything",
|
||||
);
|
||||
assert!(fired.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deliver_ntfy_posts_message_to_server_topic_path() {
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
@@ -558,7 +770,9 @@ mod tests {
|
||||
}
|
||||
|
||||
let captured = Captured::default();
|
||||
let app = Router::new().route("/{topic}", post(capture)).with_state(captured.clone());
|
||||
let app = Router::new()
|
||||
.route("/{topic}", post(capture))
|
||||
.with_state(captured.clone());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
@@ -566,7 +780,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let server = format!("http://{addr}");
|
||||
deliver_ntfy(&server, "alerts", "hello world").await.unwrap();
|
||||
deliver_ntfy(&server, "alerts", "hello world")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (topic, body) = captured.0.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(topic, "alerts");
|
||||
@@ -0,0 +1,119 @@
|
||||
use crate::engine::metric_primitive::MetricPrimitive;
|
||||
use crate::engine::timestamp::Timestamp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ParamDeclaration {
|
||||
pub name: String,
|
||||
pub message_match_regex: String,
|
||||
pub capture_group: String,
|
||||
#[serde(default)]
|
||||
pub json_field_path: Option<String>,
|
||||
pub kind: MetricPrimitiveKind,
|
||||
#[serde(default)]
|
||||
pub elapsed_since_last: bool,
|
||||
#[serde(default)]
|
||||
pub auto_derived: bool,
|
||||
#[serde(default)]
|
||||
pub log_stream_gated: bool,
|
||||
}
|
||||
|
||||
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
|
||||
let root: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let mut current = root.as_object()?;
|
||||
let mut segments = field_path.split('.').peekable();
|
||||
loop {
|
||||
let segment = segments.next()?;
|
||||
let next_value = current.get(segment)?;
|
||||
if segments.peek().is_none() {
|
||||
return match next_value {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
current = next_value.as_object()?;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flatten_json_object(text: &str) -> Vec<(String, String)> {
|
||||
let Ok(serde_json::Value::Object(root)) = serde_json::from_str::<serde_json::Value>(text)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
flatten_json_object_into(&root, "", &mut results);
|
||||
results
|
||||
}
|
||||
|
||||
fn flatten_json_object_into(
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
prefix: &str,
|
||||
results: &mut Vec<(String, String)>,
|
||||
) {
|
||||
for (key, value) in object {
|
||||
let path = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{prefix}.{key}")
|
||||
};
|
||||
match value {
|
||||
serde_json::Value::String(s) => results.push((path, s.clone())),
|
||||
serde_json::Value::Number(n) => results.push((path, n.to_string())),
|
||||
serde_json::Value::Bool(b) => results.push((path, b.to_string())),
|
||||
serde_json::Value::Object(nested) => flatten_json_object_into(nested, &path, results),
|
||||
serde_json::Value::Null | serde_json::Value::Array(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MetricPrimitiveKind {
|
||||
String,
|
||||
Number,
|
||||
Timestamp,
|
||||
Duration,
|
||||
Boolean,
|
||||
}
|
||||
|
||||
/// Whether a declared metric's value is a point-in-time reading (`Gauge`, e.g. queue depth) or a
|
||||
/// monotonically-increasing total (`Counter`, e.g. requests served) — orthogonal to
|
||||
/// `MetricPrimitiveKind`, which describes the value's data type rather than how it accumulates.
|
||||
/// `Gauge` is the default so existing declarations (persisted before this field existed) keep
|
||||
/// behaving the way bare selectors and the `gauge()` PromQL wrapper already did.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MetricSemantics {
|
||||
#[default]
|
||||
Gauge,
|
||||
Counter,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum DataValue {
|
||||
LogLiteral {
|
||||
text: Arc<str>,
|
||||
},
|
||||
ParamDeclaration(ParamDeclaration),
|
||||
MetricDeclaration {
|
||||
message_match_regex: String,
|
||||
label_params: Vec<String>,
|
||||
value_param: Option<String>,
|
||||
matched_labels: Option<BTreeMap<String, String>>,
|
||||
unmatched_labels: Option<BTreeMap<String, String>>,
|
||||
semantics: MetricSemantics,
|
||||
},
|
||||
BucketValue {
|
||||
values: Vec<(u64, Timestamp, MetricPrimitive)>,
|
||||
},
|
||||
LastEventTimestamp {
|
||||
timestamp: Timestamp,
|
||||
},
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
//! Datetime parsing for the filter query language's timestamp comparisons: relative
|
||||
//! humantime, time-of-day (resolved against the UTC calendar day of `now`), or a
|
||||
//! full date/datetime literal.
|
||||
|
||||
// `#![allow(dead_code)]` stays since most of this module's surface is still only
|
||||
// exercised by its own unit tests below.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||
|
||||
use crate::timestamp::Timestamp;
|
||||
use crate::engine::timestamp::Timestamp;
|
||||
|
||||
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DateTimeParseError {
|
||||
/// The input matched a supported format's shape but its value was invalid, e.g. `25:99`.
|
||||
Invalid { input: String, reason: String },
|
||||
/// The input didn't match any supported format at all.
|
||||
Unparseable(String),
|
||||
}
|
||||
|
||||
@@ -37,8 +28,6 @@ impl std::fmt::Display for DateTimeParseError {
|
||||
|
||||
impl std::error::Error for DateTimeParseError {}
|
||||
|
||||
/// Parses a filter datetime literal into an absolute [`Timestamp`]; `now` is a parameter
|
||||
/// (rather than calling `Timestamp::now()` internally) so callers can pass a fixed value in tests.
|
||||
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -60,8 +49,6 @@ pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTime
|
||||
Err(DateTimeParseError::Unparseable(input.to_string()))
|
||||
}
|
||||
|
||||
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`);
|
||||
/// returns `None` if it doesn't look like one at all, so the caller can try other formats.
|
||||
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||
let (sign, rest) = match input.strip_prefix('-') {
|
||||
Some(rest) => (-1i128, rest),
|
||||
@@ -71,11 +58,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
|
||||
},
|
||||
};
|
||||
|
||||
// Require a digit followed by an alphabetic unit so date-like inputs (e.g. "2024-01-15") fall through to the date parser instead of erroring here as an invalid duration.
|
||||
let looks_like_duration = rest
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_digit())
|
||||
let looks_like_duration = rest.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||
&& rest.chars().any(|c| c.is_ascii_alphabetic())
|
||||
&& !rest.contains('-')
|
||||
&& !rest.contains(':');
|
||||
@@ -97,9 +80,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
|
||||
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||
// Only attempt this path for inputs shaped like a date, e.g. "2024-01-15".
|
||||
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
|
||||
if !looks_like_date {
|
||||
return None;
|
||||
@@ -123,11 +104,7 @@ fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to interpret `input` as a bare time-of-day, resolved against the UTC calendar day of `now`.
|
||||
fn try_time_of_day(
|
||||
input: &str,
|
||||
now: Timestamp,
|
||||
) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||
fn try_time_of_day(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||
if !input.contains(':') {
|
||||
return None;
|
||||
}
|
||||
@@ -146,9 +123,13 @@ fn try_time_of_day(
|
||||
};
|
||||
|
||||
let now_millis = now.as_millis();
|
||||
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
|
||||
// Fall back to the epoch date; shouldn't happen since `Timestamp` is always a valid instant.
|
||||
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
|
||||
let now_dt = Utc
|
||||
.timestamp_millis_opt(now_millis)
|
||||
.single()
|
||||
.unwrap_or_else(|| {
|
||||
Utc.timestamp_millis_opt(0)
|
||||
.single()
|
||||
.expect("epoch is valid")
|
||||
});
|
||||
let today = now_dt.date_naive();
|
||||
let dt = Utc.from_utc_datetime(&today.and_time(time));
|
||||
@@ -0,0 +1,30 @@
|
||||
WHITESPACE = _{ " " | "\t" | "\n" | "\r" }
|
||||
|
||||
file = { SOI ~ or_expr ~ EOI }
|
||||
|
||||
or_expr = { and_expr ~ (or_kw ~ and_expr)* }
|
||||
and_expr = { term ~ (and_kw ~ term)* }
|
||||
|
||||
term = _{ regex_expr | comparison | "(" ~ or_expr ~ ")" }
|
||||
|
||||
and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") }
|
||||
or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") }
|
||||
|
||||
regex_expr = { "regex" ~ "(" ~ string_literal ~ ")" }
|
||||
|
||||
string_literal = ${ "\"" ~ inner_str ~ "\"" }
|
||||
inner_str = @{ (escaped_char | !("\"" | "\\") ~ ANY)* }
|
||||
escaped_char = @{ "\\" ~ ANY }
|
||||
|
||||
comparison = _{ json_comparison | metric_comparison }
|
||||
|
||||
json_comparison = { "json" ~ "[" ~ param ~ "]" ~ op ~ value }
|
||||
metric_comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value }
|
||||
|
||||
metric = @{ ident }
|
||||
param = @{ ident }
|
||||
ident = @{ (ASCII_ALPHANUMERIC | "_")+ }
|
||||
|
||||
op = { "<=" | ">=" | "==" | "!=" | "<" | ">" }
|
||||
|
||||
value = @{ (!(WHITESPACE | ")") ~ ANY)+ }
|
||||
@@ -1,5 +1,3 @@
|
||||
//! FilterBar query language: grammar (see `src/filter_parser.pest`), AST and parser.
|
||||
//! Evaluating the AST against real log data is left to `engine.rs`.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use pest::Parser;
|
||||
@@ -8,23 +6,18 @@ use pest_derive::Parser as PestParser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(PestParser)]
|
||||
#[grammar = "filter_parser.pest"]
|
||||
#[grammar = "engine/filter_parser.pest"]
|
||||
struct FilterGrammar;
|
||||
|
||||
/// The parsed AST for a FilterBar query.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FilterExpr {
|
||||
/// `regex("...")` — raw-text regex search over log lines.
|
||||
Regex(String),
|
||||
/// `metric[param] <op> value` — a single metric comparison.
|
||||
Comparison {
|
||||
metric: String,
|
||||
param: Option<String>,
|
||||
op: CompareOp,
|
||||
value: FilterValue,
|
||||
},
|
||||
/// `json[field] <op> value` — a comparison against a field of the line's parsed JSON.
|
||||
/// Lines that aren't valid JSON (or lack the field) simply don't match.
|
||||
JsonComparison {
|
||||
field: String,
|
||||
op: CompareOp,
|
||||
@@ -34,8 +27,6 @@ pub enum FilterExpr {
|
||||
Or(Box<FilterExpr>, Box<FilterExpr>),
|
||||
}
|
||||
|
||||
/// Comparison operators for metric comparisons; also reused by `crate::alerts::AlertCondition`
|
||||
/// for threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CompareOp {
|
||||
@@ -47,15 +38,12 @@ pub enum CompareOp {
|
||||
Ne,
|
||||
}
|
||||
|
||||
/// The right-hand side of a comparison: values that parse as a number become
|
||||
/// [`FilterValue::Number`], everything else is kept as an opaque [`FilterValue::Raw`] string.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FilterValue {
|
||||
Number(f64),
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
/// A located, human-readable parse error.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FilterParseError {
|
||||
message: String,
|
||||
@@ -79,12 +67,10 @@ impl std::error::Error for FilterParseError {}
|
||||
|
||||
impl From<pest::error::Error<Rule>> for FilterParseError {
|
||||
fn from(err: pest::error::Error<Rule>) -> Self {
|
||||
// pest's Display already renders a located message, so use it and add a short prefix.
|
||||
FilterParseError::new(format!("failed to parse filter expression: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a FilterBar query string into a [`FilterExpr`] AST.
|
||||
pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
|
||||
let mut pairs = FilterGrammar::parse(Rule::file, input)?;
|
||||
|
||||
@@ -102,10 +88,7 @@ pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
|
||||
|
||||
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
|
||||
// `or_kw` is atomic so it's not silent, showing up as a sibling pair between `and_expr` operands — skip it rather than assuming a fixed alternating structure.
|
||||
let mut and_exprs = pair
|
||||
.into_inner()
|
||||
.filter(|p| p.as_rule() != Rule::or_kw);
|
||||
let mut and_exprs = pair.into_inner().filter(|p| p.as_rule() != Rule::or_kw);
|
||||
let first = and_exprs
|
||||
.next()
|
||||
.ok_or_else(|| FilterParseError::new("malformed 'or' expression"))?;
|
||||
@@ -119,10 +102,7 @@ fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||
|
||||
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||
debug_assert_eq!(pair.as_rule(), Rule::and_expr);
|
||||
// Same reasoning as `build_or_expr` for `and_kw`.
|
||||
let mut terms = pair
|
||||
.into_inner()
|
||||
.filter(|p| p.as_rule() != Rule::and_kw);
|
||||
let mut terms = pair.into_inner().filter(|p| p.as_rule() != Rule::and_kw);
|
||||
let first = terms
|
||||
.next()
|
||||
.ok_or_else(|| FilterParseError::new("malformed 'and' expression"))?;
|
||||
@@ -198,9 +178,9 @@ fn build_comparison(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||
fn build_json_comparison(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
|
||||
let mut inner = pair.into_inner();
|
||||
|
||||
let field_pair = inner
|
||||
.next()
|
||||
.ok_or_else(|| FilterParseError::new("malformed json[...] comparison: missing field name"))?;
|
||||
let field_pair = inner.next().ok_or_else(|| {
|
||||
FilterParseError::new("malformed json[...] comparison: missing field name")
|
||||
})?;
|
||||
let field = field_pair.as_str().to_string();
|
||||
|
||||
let op_pair = inner
|
||||
@@ -237,7 +217,6 @@ fn parse_value(s: &str) -> FilterValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, `\x` -> `x`).
|
||||
fn unescape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
@@ -284,12 +263,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_reserved_metric_sequence() {
|
||||
let expr = parse("sequence<5").expect("should parse");
|
||||
fn parses_reserved_metric_ottyl_seq() {
|
||||
let expr = parse("ottyl_seq<5").expect("should parse");
|
||||
assert_eq!(
|
||||
expr,
|
||||
FilterExpr::Comparison {
|
||||
metric: "sequence".to_string(),
|
||||
metric: "ottyl_seq".to_string(),
|
||||
param: None,
|
||||
op: CompareOp::Lt,
|
||||
value: FilterValue::Number(5.0),
|
||||
@@ -298,12 +277,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_reserved_metric_timestamp_as_raw_value() {
|
||||
let expr = parse("timestamp>12:00").expect("should parse");
|
||||
fn parses_reserved_metric_ottyl_ts_as_raw_value() {
|
||||
let expr = parse("ottyl_ts>12:00").expect("should parse");
|
||||
assert_eq!(
|
||||
expr,
|
||||
FilterExpr::Comparison {
|
||||
metric: "timestamp".to_string(),
|
||||
metric: "ottyl_ts".to_string(),
|
||||
param: None,
|
||||
op: CompareOp::Gt,
|
||||
value: FilterValue::Raw("12:00".to_string()),
|
||||
@@ -311,6 +290,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_reserved_metric_ottyl_fmt() {
|
||||
let expr = parse("ottyl_fmt==json").expect("should parse");
|
||||
assert_eq!(
|
||||
expr,
|
||||
FilterExpr::Comparison {
|
||||
metric: "ottyl_fmt".to_string(),
|
||||
param: None,
|
||||
op: CompareOp::Eq,
|
||||
value: FilterValue::Raw("json".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_json_field_comparison() {
|
||||
let expr = parse("json[level]==error").expect("should parse");
|
||||
@@ -339,7 +332,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn json_field_composes_with_and_or() {
|
||||
let expr = parse(r#"json[level]==error and sequence<5"#).expect("should parse");
|
||||
let expr = parse(r#"json[level]==error and ottyl_seq<5"#).expect("should parse");
|
||||
let expected = FilterExpr::And(
|
||||
Box::new(FilterExpr::JsonComparison {
|
||||
field: "level".to_string(),
|
||||
@@ -347,7 +340,7 @@ mod tests {
|
||||
value: FilterValue::Raw("error".to_string()),
|
||||
}),
|
||||
Box::new(FilterExpr::Comparison {
|
||||
metric: "sequence".to_string(),
|
||||
metric: "ottyl_seq".to_string(),
|
||||
param: None,
|
||||
op: CompareOp::Lt,
|
||||
value: FilterValue::Number(5.0),
|
||||
@@ -358,21 +351,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_and_or_with_and_binding_tighter() {
|
||||
let expr = parse(r#"regex("error") and sequence<5 or timestamp>12:00"#)
|
||||
.expect("should parse");
|
||||
let expr =
|
||||
parse(r#"regex("error") and ottyl_seq<5 or ottyl_ts>12:00"#).expect("should parse");
|
||||
|
||||
let expected = FilterExpr::Or(
|
||||
Box::new(FilterExpr::And(
|
||||
Box::new(FilterExpr::Regex("error".to_string())),
|
||||
Box::new(FilterExpr::Comparison {
|
||||
metric: "sequence".to_string(),
|
||||
metric: "ottyl_seq".to_string(),
|
||||
param: None,
|
||||
op: CompareOp::Lt,
|
||||
value: FilterValue::Number(5.0),
|
||||
}),
|
||||
)),
|
||||
Box::new(FilterExpr::Comparison {
|
||||
metric: "timestamp".to_string(),
|
||||
metric: "ottyl_ts".to_string(),
|
||||
param: None,
|
||||
op: CompareOp::Gt,
|
||||
value: FilterValue::Raw("12:00".to_string()),
|
||||
@@ -384,20 +377,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parenthesized_expression_overrides_precedence() {
|
||||
let expr = parse(r#"regex("error") and (sequence<5 or timestamp>12:00)"#)
|
||||
.expect("should parse");
|
||||
let expr =
|
||||
parse(r#"regex("error") and (ottyl_seq<5 or ottyl_ts>12:00)"#).expect("should parse");
|
||||
|
||||
let expected = FilterExpr::And(
|
||||
Box::new(FilterExpr::Regex("error".to_string())),
|
||||
Box::new(FilterExpr::Or(
|
||||
Box::new(FilterExpr::Comparison {
|
||||
metric: "sequence".to_string(),
|
||||
metric: "ottyl_seq".to_string(),
|
||||
param: None,
|
||||
op: CompareOp::Lt,
|
||||
value: FilterValue::Number(5.0),
|
||||
}),
|
||||
Box::new(FilterExpr::Comparison {
|
||||
metric: "timestamp".to_string(),
|
||||
metric: "ottyl_ts".to_string(),
|
||||
param: None,
|
||||
op: CompareOp::Gt,
|
||||
value: FilterValue::Raw("12:00".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::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::engine::{EngineMessage, IngestedLine, MetricSample};
|
||||
use crate::timestamp::Timestamp;
|
||||
use crate::consts::{METRICS_POLL_INTERVAL, STDIN_POLL_INTERVAL as POLL_INTERVAL};
|
||||
use crate::engine::timestamp::Timestamp;
|
||||
use crate::engine::{EngineHandle, EngineMessage, IngestedLine, MetricSample};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||
|
||||
const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Reads stdin line by line, sequences and timestamps each line, and forwards it
|
||||
/// to the engine task.
|
||||
pub async fn run(engine: mpsc::Sender<EngineMessage>) {
|
||||
pub async fn run(engine: EngineHandle) {
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut lines = BufReader::new(stdin).lines();
|
||||
let mut sequence: u64 = 0;
|
||||
@@ -23,17 +16,16 @@ pub async fn run(engine: mpsc::Sender<EngineMessage>) {
|
||||
let line = IngestedLine {
|
||||
sequence,
|
||||
timestamp: Timestamp::now(),
|
||||
text,
|
||||
text: text.into(),
|
||||
};
|
||||
sequence += 1;
|
||||
|
||||
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
|
||||
if engine.tx.send(EngineMessage::Ingest(line)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies a specific file instance on disk, so a recreated path can be told apart from the same file simply growing.
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
|
||||
|
||||
@@ -56,10 +48,7 @@ async fn open(path: &Path) -> Option<(File, FileId)> {
|
||||
Some((file, file_id(&meta)))
|
||||
}
|
||||
|
||||
/// Tails `path` like `tail -f`, resuming from the start if the file is deleted/recreated or
|
||||
/// truncated in place rather than exiting or getting stuck; `sequence` keeps incrementing
|
||||
/// across a recreation since it's ottyl's own line counter, not tied to the source file.
|
||||
pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
||||
pub async fn run_file(engine: EngineHandle, path: PathBuf) {
|
||||
let mut sequence: u64 = 0;
|
||||
|
||||
'reopen: loop {
|
||||
@@ -78,42 +67,33 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
||||
let mut chunk = String::new();
|
||||
match reader.read_line(&mut chunk).await {
|
||||
Ok(0) => {
|
||||
// EOF: decide whether the file was truncated/recreated or just hasn't grown yet.
|
||||
match tokio::fs::metadata(&path).await {
|
||||
Ok(meta) => {
|
||||
if let Ok(meta) = tokio::fs::metadata(&path).await {
|
||||
let new_id = file_id(&meta);
|
||||
if new_id != id || meta.len() < offset {
|
||||
continue 'reopen;
|
||||
}
|
||||
id = new_id;
|
||||
}
|
||||
Err(_) => {
|
||||
// Path gone (mid-recreation); wait for it to reappear and reopen fresh.
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
Ok(n) => {
|
||||
offset += n as u64;
|
||||
pending.push_str(&chunk);
|
||||
if pending.ends_with('\n') {
|
||||
let text = pending
|
||||
.trim_end_matches(['\n', '\r'])
|
||||
.to_string();
|
||||
let text = pending.trim_end_matches(['\n', '\r']).to_string();
|
||||
pending.clear();
|
||||
|
||||
let line = IngestedLine {
|
||||
sequence,
|
||||
timestamp: Timestamp::now(),
|
||||
text,
|
||||
text: text.into(),
|
||||
};
|
||||
sequence += 1;
|
||||
|
||||
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
|
||||
if engine.tx.send(EngineMessage::Ingest(line)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// else: partial line at EOF (write in progress) — keep buffering.
|
||||
}
|
||||
Err(_) => continue 'reopen,
|
||||
}
|
||||
@@ -121,39 +101,48 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls `url` on a fixed interval for a Prometheus/OpenMetrics-format response, pushing every parsed
|
||||
/// sample straight into the engine. Errors (unreachable endpoint, non-2xx response) are only logged —
|
||||
/// polling keeps retrying on the next tick rather than exiting.
|
||||
pub async fn run_metrics_poll(engine: mpsc::Sender<EngineMessage>, url: String) {
|
||||
async fn fetch_metrics_body(client: &reqwest::Client, url: &str) -> Result<String, String> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("warning: failed to poll metrics endpoint {url}: {err}"))?;
|
||||
let response = response
|
||||
.error_for_status()
|
||||
.map_err(|err| format!("warning: metrics endpoint {url} returned an error: {err}"))?;
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("warning: failed to read metrics response from {url}: {err}"))
|
||||
}
|
||||
|
||||
pub async fn run_metrics_poll(engine: EngineHandle, url: String) {
|
||||
let client = reqwest::Client::new();
|
||||
loop {
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) => match response.error_for_status() {
|
||||
Ok(response) => match response.text().await {
|
||||
match fetch_metrics_body(&client, &url).await {
|
||||
Ok(body) => {
|
||||
let now = Timestamp::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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!("warning: failed to read metrics response from {url}: {err}"),
|
||||
},
|
||||
Err(err) => eprintln!("warning: metrics endpoint {url} returned an error: {err}"),
|
||||
},
|
||||
Err(err) => eprintln!("warning: failed to poll metrics endpoint {url}: {err}"),
|
||||
Err(warning) => eprintln!("{warning}"),
|
||||
}
|
||||
tokio::time::sleep(METRICS_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the Prometheus text exposition / OpenMetrics format: one sample per non-comment,
|
||||
/// non-empty line, as `metric_name{label="value",...} value [timestamp_ms]` (labels and the
|
||||
/// timestamp are both optional). A line that doesn't parse is skipped rather than aborting the
|
||||
/// whole scrape.
|
||||
fn parse_openmetrics(body: &str, now: Timestamp) -> Vec<MetricSample> {
|
||||
body.lines().filter_map(|line| parse_openmetrics_line(line, now)).collect()
|
||||
body.lines()
|
||||
.filter_map(|line| parse_openmetrics_line(line, now))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
|
||||
@@ -174,7 +163,10 @@ fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
|
||||
let (metric_name, labels) = match name_and_labels.find('{') {
|
||||
Some(open) => {
|
||||
let close = name_and_labels.rfind('}')?;
|
||||
(name_and_labels[..open].to_string(), parse_openmetrics_labels(&name_and_labels[open + 1..close]))
|
||||
(
|
||||
name_and_labels[..open].to_string(),
|
||||
parse_openmetrics_labels(&name_and_labels[open + 1..close]),
|
||||
)
|
||||
}
|
||||
None => (name_and_labels.to_string(), BTreeMap::new()),
|
||||
};
|
||||
@@ -182,15 +174,23 @@ fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(MetricSample { metric_name, labels, value, timestamp })
|
||||
Some(MetricSample {
|
||||
metric_name,
|
||||
labels,
|
||||
value,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Splits on commas without handling an escaped comma inside a quoted label value — fine for the
|
||||
/// common case this targets, not a full OpenMetrics-grammar parser.
|
||||
fn parse_openmetrics_labels(raw: &str) -> BTreeMap<String, String> {
|
||||
raw.split(',')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.map(|(name, value)| (name.trim().to_string(), value.trim().trim_matches('"').to_string()))
|
||||
.map(|(name, value)| {
|
||||
(
|
||||
name.trim().to_string(),
|
||||
value.trim().trim_matches('"').to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -212,10 +212,8 @@ mod openmetrics_tests {
|
||||
#[test]
|
||||
fn parses_labels_and_explicit_timestamp() {
|
||||
let now = Timestamp::from_millis(1000);
|
||||
let samples = parse_openmetrics(
|
||||
r#"http_requests_total{method="GET",path="/a"} 7 5000"#,
|
||||
now,
|
||||
);
|
||||
let samples =
|
||||
parse_openmetrics(r#"http_requests_total{method="GET",path="/a"} 7 5000"#, now);
|
||||
assert_eq!(samples.len(), 1);
|
||||
let sample = &samples[0];
|
||||
assert_eq!(sample.metric_name, "http_requests_total");
|
||||
@@ -238,7 +236,9 @@ mod openmetrics_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engine::test_handle;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
async fn recv_text(rx: &mut mpsc::Receiver<EngineMessage>) -> String {
|
||||
@@ -247,7 +247,7 @@ mod tests {
|
||||
.expect("timed out waiting for ingested line")
|
||||
.expect("engine channel closed")
|
||||
{
|
||||
EngineMessage::Ingest(line) => line.text,
|
||||
EngineMessage::Ingest(line) => line.text.to_string(),
|
||||
_ => panic!("expected Ingest"),
|
||||
}
|
||||
}
|
||||
@@ -258,12 +258,15 @@ mod tests {
|
||||
let path = dir.path().join("app.log");
|
||||
std::fs::write(&path, "first\n").unwrap();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let (tx, mut rx) = test_handle(16);
|
||||
tokio::spawn(run_file(tx, path.clone()));
|
||||
|
||||
assert_eq!(recv_text(&mut rx).await, "first");
|
||||
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
writeln!(f, "second").unwrap();
|
||||
assert_eq!(recv_text(&mut rx).await, "second");
|
||||
}
|
||||
@@ -274,7 +277,7 @@ mod tests {
|
||||
let path = dir.path().join("app.log");
|
||||
std::fs::write(&path, "before-restart\n").unwrap();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let (tx, mut rx) = test_handle(16);
|
||||
tokio::spawn(run_file(tx, path.clone()));
|
||||
|
||||
assert_eq!(recv_text(&mut rx).await, "before-restart");
|
||||
@@ -291,7 +294,7 @@ mod tests {
|
||||
let path = dir.path().join("app.log");
|
||||
std::fs::write(&path, "long line before truncation\n").unwrap();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let (tx, mut rx) = test_handle(16);
|
||||
tokio::spawn(run_file(tx, path.clone()));
|
||||
|
||||
assert_eq!(recv_text(&mut rx).await, "long line before truncation");
|
||||
@@ -312,7 +315,7 @@ mod tests {
|
||||
let path = dir.path().join("app.log");
|
||||
std::fs::write(&path, "one\n").unwrap();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let (tx, mut rx) = test_handle(16);
|
||||
tokio::spawn(run_file(tx, path.clone()));
|
||||
|
||||
let first = match tokio::time::timeout(Duration::from_secs(2), rx.recv())
|
||||
@@ -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 std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
|
||||
/// A simple, self-describing value used by metrics/labels without pulling in JSON.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum MetricPrimitive {
|
||||
String(String),
|
||||
Number(Number),
|
||||
Timestamp(Timestamp),
|
||||
/// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form.
|
||||
Duration(i64),
|
||||
/// A `Boolean`-kind param's value: whether `message_match_regex` matched the line. See `MetricPrimitiveKind::Boolean`.
|
||||
Boolean(bool),
|
||||
}
|
||||
|
||||
@@ -51,7 +48,6 @@ impl Ord for MetricPrimitive {
|
||||
}
|
||||
}
|
||||
|
||||
/// Textual form used to compare label filters (query-string values) against a formatted `MetricPrimitive` rather than parsing them into one.
|
||||
impl fmt::Display for MetricPrimitive {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -65,7 +61,6 @@ impl fmt::Display for MetricPrimitive {
|
||||
}
|
||||
}
|
||||
|
||||
/// Numbers are kept as a tagged int/float pair so exact integers stay exact.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub enum Number {
|
||||
@@ -74,7 +69,6 @@ pub enum Number {
|
||||
}
|
||||
|
||||
impl Number {
|
||||
/// Widens to `f64` for cross-kind numeric comparisons; `pub(crate)` since the filter evaluator in `engine.rs` needs it.
|
||||
pub(crate) fn as_f64(&self) -> f64 {
|
||||
match self {
|
||||
Number::Int(v) => *v as f64,
|
||||
@@ -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,18 +1,14 @@
|
||||
//! Hand-rolled parser for a small PromQL subset: metric name + label matchers, an optional `[5m]` range, and a top-level `sum()`/`avg()`/`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.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AggOp {
|
||||
Sum,
|
||||
Avg,
|
||||
Count,
|
||||
Rate,
|
||||
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
|
||||
Histogram,
|
||||
/// The most-recent counter value per label combination.
|
||||
Gauge,
|
||||
Quantile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -26,31 +22,30 @@ pub struct PromqlQuery {
|
||||
pub agg: Option<AggOp>,
|
||||
pub metric_name: String,
|
||||
pub matchers: Vec<LabelMatcher>,
|
||||
/// Duration range selector, e.g. `[5m]`; only meaningful for `rate()`.
|
||||
pub range: Option<Duration>,
|
||||
/// Bare-number range selector, e.g. `[10]`; only meaningful for `histogram()`, where it's the bucket width.
|
||||
pub bucket_width: Option<f64>,
|
||||
/// Set when `agg` is `Sum`/`Avg` and it wraps an inner `rate(metric[range])` rather than a bare vector
|
||||
/// selector, e.g. `sum(rate(requests[5m]))`. `range` above carries the inner `rate()`'s duration in that case.
|
||||
/// The `p` in `histogram_quantile(p, metric[range])`, e.g. `0.95` for p95. Only set when
|
||||
/// `agg` is `Some(AggOp::Quantile)`.
|
||||
pub quantile: Option<f64>,
|
||||
pub inner_rate: bool,
|
||||
/// Labels named in a trailing `by (...)` clause; empty means "collapse to a single series" (the historical
|
||||
/// behavior of `sum()`/`avg()`). When non-empty, one series is kept per distinct combination of these labels.
|
||||
pub by_labels: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a `[...]` range selector turned out to hold: a duration, or a bare width.
|
||||
enum RangeSelector {
|
||||
Duration(Duration),
|
||||
BareWidth(f64),
|
||||
}
|
||||
|
||||
/// Parses a PromQL query string, returning a human-readable error message on malformed input.
|
||||
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
|
||||
impl PromqlQuery {
|
||||
pub fn parse(input: &str) -> Result<PromqlQuery, String> {
|
||||
let mut parser = Parser::new(input);
|
||||
let query = parser.parse_query()?;
|
||||
parser.skip_whitespace();
|
||||
if !parser.is_at_end() {
|
||||
return Err(format!("unexpected trailing input: '{}'", parser.remaining()));
|
||||
return Err(format!(
|
||||
"unexpected trailing input: '{}'",
|
||||
parser.remaining()
|
||||
));
|
||||
}
|
||||
if query.agg == Some(AggOp::Rate) && query.range.is_none() {
|
||||
return Err("rate() requires a range selector, e.g. rate(my_metric[5m])".to_string());
|
||||
@@ -60,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(),
|
||||
);
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
input: &'a str,
|
||||
@@ -124,7 +127,9 @@ impl<'a> Parser<'a> {
|
||||
fn parse_quoted_string(&mut self) -> Result<String, String> {
|
||||
self.expect_char('"')?;
|
||||
let rest = self.remaining();
|
||||
let end = rest.find('"').ok_or("unterminated string literal (missing closing '\"')")?;
|
||||
let end = rest
|
||||
.find('"')
|
||||
.ok_or("unterminated string literal (missing closing '\"')")?;
|
||||
let value = &rest[..end];
|
||||
if value.contains('\\') {
|
||||
return Err("escape sequences in label values are not supported".to_string());
|
||||
@@ -173,7 +178,6 @@ impl<'a> Parser<'a> {
|
||||
Ok(matchers)
|
||||
}
|
||||
|
||||
/// Parses `[<digits><unit>]` (a duration, e.g. `[5m]`) or a bare `[<digits>]` (a bucket width).
|
||||
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
|
||||
self.skip_whitespace();
|
||||
if self.peek() != Some('[') {
|
||||
@@ -197,7 +201,9 @@ impl<'a> Parser<'a> {
|
||||
return Ok(Some(RangeSelector::BareWidth(digits as f64)));
|
||||
}
|
||||
|
||||
let unit = self.peek().ok_or("range selector is missing a duration unit or ']'")?;
|
||||
let unit = self
|
||||
.peek()
|
||||
.ok_or("range selector is missing a duration unit or ']'")?;
|
||||
let seconds_per_unit: i64 = match unit {
|
||||
's' => 1,
|
||||
'm' => 60,
|
||||
@@ -230,12 +236,42 @@ impl<'a> Parser<'a> {
|
||||
matchers,
|
||||
range,
|
||||
bucket_width,
|
||||
quantile: None,
|
||||
inner_rate: false,
|
||||
by_labels: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a trailing `by (label, ...)` clause, if present; returns an empty vec otherwise.
|
||||
/// Parses a decimal number (e.g. `0`, `1`, `0.95`) — used for `histogram_quantile(p, ...)`'s
|
||||
/// `p` argument, which isn't an integer like the range-selector widths elsewhere in this grammar.
|
||||
fn parse_float(&mut self) -> Result<f64, String> {
|
||||
self.skip_whitespace();
|
||||
let rest = self.remaining();
|
||||
let mut end = 0;
|
||||
let mut seen_digit = false;
|
||||
let mut seen_dot = false;
|
||||
for (idx, c) in rest.char_indices() {
|
||||
if c.is_ascii_digit() {
|
||||
seen_digit = true;
|
||||
end = idx + c.len_utf8();
|
||||
} else if c == '.' && !seen_dot {
|
||||
seen_dot = true;
|
||||
end = idx + c.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !seen_digit {
|
||||
return Err("expected a number".to_string());
|
||||
}
|
||||
let text = &rest[..end];
|
||||
let value: f64 = text
|
||||
.parse()
|
||||
.map_err(|_| format!("'{text}' is not a valid number"))?;
|
||||
self.pos += end;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_by_clause(&mut self) -> Result<Vec<String>, String> {
|
||||
self.skip_whitespace();
|
||||
let checkpoint = self.pos;
|
||||
@@ -278,17 +314,37 @@ impl<'a> Parser<'a> {
|
||||
let agg = match ident.as_str() {
|
||||
"sum" => Some(AggOp::Sum),
|
||||
"avg" => Some(AggOp::Avg),
|
||||
"count" => Some(AggOp::Count),
|
||||
"rate" => Some(AggOp::Rate),
|
||||
"histogram" => Some(AggOp::Histogram),
|
||||
"gauge" => Some(AggOp::Gauge),
|
||||
"histogram_quantile" => Some(AggOp::Quantile),
|
||||
_ => None,
|
||||
};
|
||||
self.skip_whitespace();
|
||||
let prefix_by_labels = if agg.is_some() {
|
||||
self.parse_by_clause()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.skip_whitespace();
|
||||
if agg.is_some() && self.peek() == Some('(') {
|
||||
self.expect_char('(')?;
|
||||
self.skip_whitespace();
|
||||
// `sum()`/`avg()` may wrap `rate(metric[range])` directly instead of a bare vector selector.
|
||||
let mut inner = if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
|
||||
let mut inner = if agg == Some(AggOp::Quantile) {
|
||||
let quantile = self.parse_float()?;
|
||||
if !(0.0..=1.0).contains(&quantile) {
|
||||
return Err(format!(
|
||||
"histogram_quantile() quantile must be between 0 and 1, got {quantile}"
|
||||
));
|
||||
}
|
||||
self.skip_whitespace();
|
||||
self.expect_char(',')?;
|
||||
self.skip_whitespace();
|
||||
let mut vector = self.parse_vector_selector()?;
|
||||
vector.quantile = Some(quantile);
|
||||
vector
|
||||
} else if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
|
||||
let inner_checkpoint = self.pos;
|
||||
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
|
||||
if took_inner_rate {
|
||||
@@ -318,10 +374,20 @@ impl<'a> Parser<'a> {
|
||||
};
|
||||
self.expect_char(')')?;
|
||||
inner.agg = agg;
|
||||
inner.by_labels = self.parse_by_clause()?;
|
||||
let trailing_by_labels = self.parse_by_clause()?;
|
||||
if !prefix_by_labels.is_empty() && !trailing_by_labels.is_empty() {
|
||||
return Err(
|
||||
"by (...) clause may only appear once, either before or after the aggregation's argument"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
inner.by_labels = if prefix_by_labels.is_empty() {
|
||||
trailing_by_labels
|
||||
} else {
|
||||
prefix_by_labels
|
||||
};
|
||||
Ok(inner)
|
||||
} else {
|
||||
// Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
|
||||
self.pos = checkpoint;
|
||||
self.parse_vector_selector()
|
||||
}
|
||||
@@ -334,7 +400,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_bare_metric_name() {
|
||||
let query = parse_query("requests").unwrap();
|
||||
let query = PromqlQuery::parse("requests").unwrap();
|
||||
assert_eq!(query.agg, None);
|
||||
assert_eq!(query.metric_name, "requests");
|
||||
assert!(query.matchers.is_empty());
|
||||
@@ -343,17 +409,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_metric_with_single_label_matcher() {
|
||||
let query = parse_query(r#"requests{path="/a"}"#).unwrap();
|
||||
let query = PromqlQuery::parse(r#"requests{path="/a"}"#).unwrap();
|
||||
assert_eq!(query.metric_name, "requests");
|
||||
assert_eq!(
|
||||
query.matchers,
|
||||
vec![LabelMatcher { name: "path".to_string(), value: "/a".to_string() }]
|
||||
vec![LabelMatcher {
|
||||
name: "path".to_string(),
|
||||
value: "/a".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_metric_with_multiple_label_matchers() {
|
||||
let query = parse_query(r#"requests{path="/a", method="GET"}"#).unwrap();
|
||||
let query = PromqlQuery::parse(r#"requests{path="/a", method="GET"}"#).unwrap();
|
||||
assert_eq!(query.matchers.len(), 2);
|
||||
assert_eq!(query.matchers[0].name, "path");
|
||||
assert_eq!(query.matchers[1].name, "method");
|
||||
@@ -361,91 +430,105 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_range_selector() {
|
||||
let query = parse_query("requests[5m]").unwrap();
|
||||
let query = PromqlQuery::parse("requests[5m]").unwrap();
|
||||
assert_eq!(query.range, Some(Duration::from_secs(300)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_label_matchers_and_range_together() {
|
||||
let query = parse_query(r#"requests{path="/a"}[1h]"#).unwrap();
|
||||
let query = PromqlQuery::parse(r#"requests{path="/a"}[1h]"#).unwrap();
|
||||
assert_eq!(query.matchers.len(), 1);
|
||||
assert_eq!(query.range, Some(Duration::from_secs(3600)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sum_aggregation() {
|
||||
let query = parse_query(r#"sum(requests{path="/a"})"#).unwrap();
|
||||
let query = PromqlQuery::parse(r#"sum(requests{path="/a"})"#).unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Sum));
|
||||
assert_eq!(query.metric_name, "requests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_avg_aggregation() {
|
||||
let query = parse_query("avg(requests)").unwrap();
|
||||
let query = PromqlQuery::parse("avg(requests)").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Avg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rate_with_range() {
|
||||
let query = parse_query("rate(requests[5m])").unwrap();
|
||||
let query = PromqlQuery::parse("rate(requests[5m])").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Rate));
|
||||
assert_eq!(query.range, Some(Duration::from_secs(300)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_rate_without_range() {
|
||||
let err = parse_query("rate(requests)").unwrap_err();
|
||||
let err = PromqlQuery::parse("rate(requests)").unwrap_err();
|
||||
assert!(err.contains("range selector"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_input() {
|
||||
assert!(parse_query("").is_err());
|
||||
assert!(PromqlQuery::parse("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unclosed_brace() {
|
||||
assert!(parse_query(r#"requests{path="/a""#).is_err());
|
||||
assert!(PromqlQuery::parse(r#"requests{path="/a""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unclosed_bracket() {
|
||||
assert!(parse_query("requests[5m").is_err());
|
||||
assert!(PromqlQuery::parse("requests[5m").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_aggregation() {
|
||||
// `count` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`.
|
||||
let err = parse_query("count(requests)").unwrap_err();
|
||||
let err = PromqlQuery::parse("median(requests)").unwrap_err();
|
||||
assert!(err.contains("unexpected trailing input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_count_aggregation() {
|
||||
let query = PromqlQuery::parse("count(requests)").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Count));
|
||||
assert_eq!(query.metric_name, "requests");
|
||||
assert!(!query.inner_rate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_count_with_by_clause() {
|
||||
let query = PromqlQuery::parse("count(requests) by (path)").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Count));
|
||||
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_regex_label_matcher() {
|
||||
let err = parse_query(r#"requests{path=~"/a"}"#).unwrap_err();
|
||||
let err = PromqlQuery::parse(r#"requests{path=~"/a"}"#).unwrap_err();
|
||||
assert!(err.contains("regex label matchers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_metric_name() {
|
||||
assert!(parse_query(r#"{path="/a"}"#).is_err());
|
||||
assert!(PromqlQuery::parse(r#"{path="/a"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_duration_unit() {
|
||||
let err = parse_query("requests[5x]").unwrap_err();
|
||||
let err = PromqlQuery::parse("requests[5x]").unwrap_err();
|
||||
assert!(err.contains("duration unit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_trailing_garbage() {
|
||||
let err = parse_query("requests extra").unwrap_err();
|
||||
let err = PromqlQuery::parse("requests extra").unwrap_err();
|
||||
assert!(err.contains("unexpected trailing input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_histogram_with_bare_width() {
|
||||
let query = parse_query("histogram(latency_ms[10])").unwrap();
|
||||
let query = PromqlQuery::parse("histogram(latency_ms[10])").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Histogram));
|
||||
assert_eq!(query.metric_name, "latency_ms");
|
||||
assert_eq!(query.bucket_width, Some(10.0));
|
||||
@@ -454,27 +537,33 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_histogram_without_width() {
|
||||
let err = parse_query("histogram(latency_ms)").unwrap_err();
|
||||
let err = PromqlQuery::parse("histogram(latency_ms)").unwrap_err();
|
||||
assert!(err.contains("bucket width"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gauge_with_no_range_selector() {
|
||||
let query = parse_query(r#"gauge(connections{host="a"})"#).unwrap();
|
||||
let query = PromqlQuery::parse(r#"gauge(connections{host="a"})"#).unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Gauge));
|
||||
assert_eq!(query.matchers, vec![LabelMatcher { name: "host".to_string(), value: "a".to_string() }]);
|
||||
assert_eq!(
|
||||
query.matchers,
|
||||
vec![LabelMatcher {
|
||||
name: "host".to_string(),
|
||||
value: "a".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_range_selector_is_still_a_duration_not_a_bare_width() {
|
||||
let query = parse_query("rate(requests[5m])").unwrap();
|
||||
let query = PromqlQuery::parse("rate(requests[5m])").unwrap();
|
||||
assert_eq!(query.range, Some(Duration::from_secs(300)));
|
||||
assert_eq!(query.bucket_width, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sum_of_rate() {
|
||||
let query = parse_query("sum(rate(requests[1m]))").unwrap();
|
||||
let query = PromqlQuery::parse("sum(rate(requests[1m]))").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Sum));
|
||||
assert!(query.inner_rate);
|
||||
assert_eq!(query.metric_name, "requests");
|
||||
@@ -484,21 +573,27 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_avg_of_rate_with_label_matchers() {
|
||||
let query = parse_query(r#"avg(rate(requests{path="/a"}[5m]))"#).unwrap();
|
||||
let query = PromqlQuery::parse(r#"avg(rate(requests{path="/a"}[5m]))"#).unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Avg));
|
||||
assert!(query.inner_rate);
|
||||
assert_eq!(query.matchers, vec![LabelMatcher { name: "path".to_string(), value: "/a".to_string() }]);
|
||||
assert_eq!(
|
||||
query.matchers,
|
||||
vec![LabelMatcher {
|
||||
name: "path".to_string(),
|
||||
value: "/a".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_sum_of_rate_without_range() {
|
||||
let err = parse_query("sum(rate(requests))").unwrap_err();
|
||||
let err = PromqlQuery::parse("sum(rate(requests))").unwrap_err();
|
||||
assert!(err.contains("range selector"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sum_with_by_clause() {
|
||||
let query = parse_query("sum(requests) by (path)").unwrap();
|
||||
let query = PromqlQuery::parse("sum(requests) by (path)").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Sum));
|
||||
assert!(!query.inner_rate);
|
||||
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||
@@ -506,25 +601,96 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_sum_of_rate_with_by_clause_multiple_labels() {
|
||||
let query = parse_query("sum(rate(requests[1m])) by (path, method)").unwrap();
|
||||
let query = PromqlQuery::parse("sum(rate(requests[1m])) by (path, method)").unwrap();
|
||||
assert!(query.inner_rate);
|
||||
assert_eq!(query.by_labels, vec!["path".to_string(), "method".to_string()]);
|
||||
assert_eq!(
|
||||
query.by_labels,
|
||||
vec!["path".to_string(), "method".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_without_by_clause_has_empty_by_labels() {
|
||||
let query = parse_query("sum(requests)").unwrap();
|
||||
let query = PromqlQuery::parse("sum(requests)").unwrap();
|
||||
assert!(query.by_labels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_by_clause() {
|
||||
let err = parse_query("sum(requests) by ()").unwrap_err();
|
||||
let err = PromqlQuery::parse("sum(requests) by ()").unwrap_err();
|
||||
assert!(err.contains("at least one label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unterminated_by_clause() {
|
||||
assert!(parse_query("sum(requests) by (path").is_err());
|
||||
assert!(PromqlQuery::parse("sum(requests) by (path").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sum_with_prefix_by_clause() {
|
||||
let query = PromqlQuery::parse("sum by (path) (requests)").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Sum));
|
||||
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sum_with_prefix_by_clause_multiple_labels() {
|
||||
let query = PromqlQuery::parse("sum by (path, method) (requests)").unwrap();
|
||||
assert_eq!(
|
||||
query.by_labels,
|
||||
vec!["path".to_string(), "method".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_by_clause_both_before_and_after_aggregation() {
|
||||
let err = PromqlQuery::parse("sum by (path) (requests) by (method)").unwrap_err();
|
||||
assert!(err.contains("only appear once"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_histogram_quantile() {
|
||||
let query = PromqlQuery::parse("histogram_quantile(0.95, latency_ms)").unwrap();
|
||||
assert_eq!(query.agg, Some(AggOp::Quantile));
|
||||
assert_eq!(query.metric_name, "latency_ms");
|
||||
assert_eq!(query.quantile, Some(0.95));
|
||||
assert_eq!(query.range, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_histogram_quantile_with_range_and_matchers() {
|
||||
let query =
|
||||
PromqlQuery::parse(r#"histogram_quantile(0.5, latency_ms{path="/a"}[5m])"#).unwrap();
|
||||
assert_eq!(query.quantile, Some(0.5));
|
||||
assert_eq!(query.range, Some(Duration::from_secs(300)));
|
||||
assert_eq!(
|
||||
query.matchers,
|
||||
vec![LabelMatcher {
|
||||
name: "path".to_string(),
|
||||
value: "/a".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_histogram_quantile_with_by_clause() {
|
||||
let query = PromqlQuery::parse("histogram_quantile(0.99, latency_ms) by (path)").unwrap();
|
||||
assert_eq!(query.by_labels, vec!["path".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_histogram_quantile_missing_comma() {
|
||||
assert!(PromqlQuery::parse("histogram_quantile(0.95 latency_ms)").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_histogram_quantile_out_of_range() {
|
||||
let err = PromqlQuery::parse("histogram_quantile(1.5, latency_ms)").unwrap_err();
|
||||
assert!(err.contains("between 0 and 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_histogram_quantile_without_number() {
|
||||
assert!(PromqlQuery::parse("histogram_quantile(latency_ms)").is_err());
|
||||
}
|
||||
}
|
||||
@@ -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 std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Wall-clock instant stored as nanoseconds since the Unix epoch (not `std::time::Instant`, which has no epoch and can't be serialized).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct Timestamp(i64);
|
||||
|
||||
impl Timestamp {
|
||||
/// The smallest representable timestamp — used as a synthetic sub-key when seeking
|
||||
/// `IndexKey::IngestedLog { sequence, timestamp }` by `sequence` alone, since it sorts
|
||||
/// below any real timestamp recorded for that sequence.
|
||||
pub const MIN: Timestamp = Timestamp(i64::MIN);
|
||||
|
||||
pub fn now() -> Self {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -1,48 +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)
|
||||
// - sequence <op> value reserved metric name (no [param])
|
||||
// - timestamp <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,
|
||||
}
|
||||
+5
-150
@@ -1,156 +1,11 @@
|
||||
mod alerts;
|
||||
mod api;
|
||||
mod data_value;
|
||||
mod dto;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod consts;
|
||||
mod engine;
|
||||
mod filter_datetime;
|
||||
mod filter_parser;
|
||||
mod index_key;
|
||||
mod ingest;
|
||||
mod metric_primitive;
|
||||
mod profile;
|
||||
mod promql;
|
||||
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 profile::ProfileStore;
|
||||
use settings::Settings;
|
||||
|
||||
/// The resolved default settings path, rendered into `--help` so `--settings-path`'s help text
|
||||
/// names a real, concrete location instead of only describing where to look for one.
|
||||
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
|
||||
format!(
|
||||
"Path to the settings file. Defaults to {}.",
|
||||
settings::default_settings_path()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "a per-OS config location".to_string())
|
||||
)
|
||||
});
|
||||
|
||||
/// Observability dashboard from terminal output.
|
||||
#[derive(Parser, Debug)]
|
||||
struct Args {
|
||||
/// Port to serve the API and webapp on. Defaults to 0, letting the OS assign
|
||||
/// an ephemeral free port.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
port: u16,
|
||||
|
||||
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
|
||||
settings_path: Option<PathBuf>,
|
||||
|
||||
/// File to tail for log lines, instead of reading from stdin. Behaves like `tail -f`.
|
||||
#[arg(long)]
|
||||
file: Option<PathBuf>,
|
||||
|
||||
/// URL of a Prometheus/OpenMetrics-compatible endpoint to poll for metrics, in addition to log ingestion.
|
||||
#[arg(long)]
|
||||
metrics_url: Option<String>,
|
||||
}
|
||||
pub(crate) mod flavour;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
|
||||
settings::default_settings_path()
|
||||
.expect("could not determine a default settings path; pass --settings-path")
|
||||
});
|
||||
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
|
||||
|
||||
let (engine_tx, engine_rx) = mpsc::channel(1024);
|
||||
tokio::spawn(engine::run(engine_rx, settings.clone()));
|
||||
{
|
||||
let max_logs = settings.lock().unwrap().max_logs;
|
||||
let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
|
||||
}
|
||||
|
||||
// Reload the active profile's declarations before ingest starts: there's no way to
|
||||
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
|
||||
{
|
||||
let (active_profile_name, profiles_dir) = {
|
||||
let mut settings_guard = settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings_guard, &settings_path);
|
||||
let mut store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
store
|
||||
.ensure_default(&profile::profiles_store_path(&profiles_dir), &mut settings_guard, &settings_path)
|
||||
.expect("failed to ensure the Default profile exists");
|
||||
(settings_guard.profiles.active_profile.clone(), profiles_dir)
|
||||
};
|
||||
if let Some(profile_name) = active_profile_name {
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
match store.get(&profile_name) {
|
||||
Some(active_profile) => {
|
||||
profile::load_into_engine(&engine_tx, active_profile).await;
|
||||
}
|
||||
None => {
|
||||
eprintln!(
|
||||
"warning: active profile '{profile_name}' not found under {}; skipping",
|
||||
profiles_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match args.file.clone() {
|
||||
Some(path) => {
|
||||
tokio::spawn(ingest::run_file(engine_tx.clone(), path));
|
||||
}
|
||||
None => {
|
||||
tokio::spawn(ingest::run(engine_tx.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(metrics_url) = args.metrics_url.clone() {
|
||||
tokio::spawn(ingest::run_metrics_poll(engine_tx.clone(), metrics_url));
|
||||
}
|
||||
|
||||
let app = api::router(engine_tx, settings, settings_path);
|
||||
let listener = TcpListener::bind(("0.0.0.0", args.port))
|
||||
.await
|
||||
.expect("failed to bind listener");
|
||||
let bound_port = listener
|
||||
.local_addr()
|
||||
.expect("failed to read back bound address")
|
||||
.port();
|
||||
|
||||
let source = match &args.file {
|
||||
Some(path) => format!("tailing {}", path.display()),
|
||||
None => "reading logs from stdin".to_string(),
|
||||
};
|
||||
eprintln!(
|
||||
"ottyl serving on http://localhost:{} ({})",
|
||||
bound_port, source
|
||||
);
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_path_defaults_to_none() {
|
||||
let args = Args::parse_from(["ottyl"]);
|
||||
assert_eq!(args.settings_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_path_flag_overrides_default() {
|
||||
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
|
||||
assert_eq!(
|
||||
args.settings_path,
|
||||
Some(PathBuf::from("/tmp/custom-settings.json"))
|
||||
);
|
||||
}
|
||||
cli::run().await;
|
||||
}
|
||||
|
||||
-1114
File diff suppressed because it is too large
Load Diff
-461
@@ -1,461 +0,0 @@
|
||||
//! Process-wide settings model: colour scheme, CSS entries, and profile pointers, 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;
|
||||
|
||||
/// 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,
|
||||
#[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 },
|
||||
}
|
||||
|
||||
/// Tracks known profile names and which one is active; the profile files themselves live elsewhere.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct Profiles {
|
||||
/// Names of known profiles.
|
||||
pub names: Vec<String>,
|
||||
/// Name of the currently active profile, if any.
|
||||
pub active_profile: Option<String>,
|
||||
/// Directory (relative to the settings file's directory, or absolute)
|
||||
/// where individual profile files are expected to live.
|
||||
pub profiles_dir: String,
|
||||
}
|
||||
|
||||
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
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>,
|
||||
pub profiles: Profiles,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
color_scheme: ColorScheme::default(),
|
||||
css_entries: Vec::new(),
|
||||
custom_theme_id: None,
|
||||
profiles: Profiles {
|
||||
names: Vec::new(),
|
||||
active_profile: None,
|
||||
profiles_dir: "profiles".to_string(),
|
||||
},
|
||||
max_logs: None,
|
||||
alert_rules: Vec::new(),
|
||||
ntfy_topic: None,
|
||||
ntfy_server: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn load(path: &Path) -> Settings {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(settings) => 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 settings = Settings::default();
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
#[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.names.push("default".to_string());
|
||||
settings.profiles.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)));
|
||||
}
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webapp</title>
|
||||
<title>ottyl</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 669 B |
+121
-106
@@ -3,12 +3,12 @@ import { NavMenu } from './components/NavMenu'
|
||||
import { FilterBar } from './components/FilterBar'
|
||||
import {
|
||||
LogTimeline,
|
||||
TIME_RANGE_PRESETS,
|
||||
computeBucketMs,
|
||||
computePresetRange,
|
||||
type TimeRange,
|
||||
type TimeRangePresetId,
|
||||
loadLogSortSettings,
|
||||
saveLogSortSettings,
|
||||
type LogSortSettings,
|
||||
} from './components/LogTimeline'
|
||||
import { TIME_RANGE_PRESETS, type TimeRange, type TimeRangePresetId } from './lib/timeRange'
|
||||
import { LogPreview } from './components/LogPreview'
|
||||
import { Params } from './components/Params'
|
||||
import { Metrics } from './components/Metrics'
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type TimelineBucket,
|
||||
} from './lib/api'
|
||||
import { DocsNavProvider } from './lib/docsNav'
|
||||
import { useRangeSelection } from './lib/useRangeSelection'
|
||||
import { useSettingsPanel } from './lib/useSettingsPanel'
|
||||
import { POLL_MS } from './lib/polling'
|
||||
|
||||
@@ -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
|
||||
/// 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.
|
||||
@@ -169,47 +187,18 @@ function StandaloneRanged({
|
||||
initialFixedRange: TimeRange | null
|
||||
}) {
|
||||
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
||||
const [range, setRange] = useState<TimeRange | null>(
|
||||
initialPresetId ? computePresetRange(initialPresetId) : initialFixedRange,
|
||||
)
|
||||
// No preset and no fixed range means the popout was opened with nothing carried over (e.g. a bare URL) — default to 'all', matching FullApp's own default.
|
||||
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>(
|
||||
initialPresetId ?? (initialFixedRange ? null : 'all'),
|
||||
)
|
||||
|
||||
const rangeRef = useRef(range)
|
||||
useEffect(() => {
|
||||
rangeRef.current = range
|
||||
}, [range])
|
||||
|
||||
function selectPreset(id: TimeRangePresetId) {
|
||||
setActivePreset(id)
|
||||
setRange(computePresetRange(id))
|
||||
}
|
||||
|
||||
function handleManualRangeSelect(next: TimeRange | null) {
|
||||
if (next === null) {
|
||||
selectPreset('all')
|
||||
} else {
|
||||
setActivePreset(null)
|
||||
setRange(next)
|
||||
}
|
||||
}
|
||||
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
|
||||
preset: initialPresetId ?? (initialFixedRange ? null : 'all'),
|
||||
range: initialFixedRange,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function poll() {
|
||||
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time — this is what makes a carried-over preset independently slide rather than track the source tab.
|
||||
const effectiveRange =
|
||||
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
|
||||
if (activePreset && activePreset !== 'all') {
|
||||
setRange((current) =>
|
||||
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
|
||||
? current
|
||||
: effectiveRange,
|
||||
)
|
||||
}
|
||||
const effectiveRange = resolveEffectiveRange()
|
||||
try {
|
||||
const nextBuckets = await fetchTimeline({
|
||||
startMs: effectiveRange?.startMs,
|
||||
@@ -251,20 +240,46 @@ function StandaloneRanged({
|
||||
)
|
||||
}
|
||||
|
||||
/// Ticket 058: only replaces `params` state when the declaration list actually changed, so a param
|
||||
/// list refetched on every poll tick doesn't force `Params.tsx` to re-render when nothing changed.
|
||||
function updateParamsIfChanged(
|
||||
setParams: (updater: (current: ParamDeclaration[]) => ParamDeclaration[]) => void,
|
||||
next: ParamDeclaration[],
|
||||
) {
|
||||
setParams((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next))
|
||||
}
|
||||
|
||||
/// Ticket 066: text shown under the filter bar when the user hasn't typed a manual filter, so an active preset/sort order is still visible somewhere near the filter input instead of leaving it looking empty/stale.
|
||||
function describeActiveState(activePreset: TimeRangePresetId | null, sortSettings: LogSortSettings): string | null {
|
||||
const parts: string[] = []
|
||||
if (activePreset && activePreset !== 'all') {
|
||||
const preset = TIME_RANGE_PRESETS.find((p) => p.id === activePreset)
|
||||
parts.push(`last ${preset?.label ?? activePreset}`)
|
||||
}
|
||||
if (sortSettings.sortBy) {
|
||||
parts.push(`sorted by ${sortSettings.sortBy}`)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(', ') : null
|
||||
}
|
||||
|
||||
/// The log viewer has its own `LogTimeline`, so it needs its own copy of App's log/timeline polling state instead of a carried-over range.
|
||||
function StandaloneLog() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
||||
const [range, setRange] = useState<TimeRange | null>(null)
|
||||
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
||||
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
|
||||
preset: 'all',
|
||||
range: null,
|
||||
})
|
||||
const [filter, setFilter] = useState('')
|
||||
const [filterError, setFilterError] = useState<string | null>(null)
|
||||
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
||||
const [sortIndexBuilding, setSortIndexBuilding] = useState(false)
|
||||
|
||||
const rangeRef = useRef(range)
|
||||
useEffect(() => {
|
||||
rangeRef.current = range
|
||||
}, [range])
|
||||
function handleSortSettingsChange(next: LogSortSettings) {
|
||||
setSortSettings(next)
|
||||
saveLogSortSettings(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchParamDeclarations()
|
||||
@@ -274,35 +289,13 @@ function StandaloneLog() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
function selectPreset(id: TimeRangePresetId) {
|
||||
setActivePreset(id)
|
||||
setRange(computePresetRange(id))
|
||||
}
|
||||
|
||||
function handleManualRangeSelect(next: TimeRange | null) {
|
||||
if (next === null) {
|
||||
selectPreset('all')
|
||||
} else {
|
||||
setActivePreset(null)
|
||||
setRange(next)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function poll() {
|
||||
const effectiveRange =
|
||||
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
|
||||
if (activePreset && activePreset !== 'all') {
|
||||
setRange((current) =>
|
||||
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
|
||||
? current
|
||||
: effectiveRange,
|
||||
)
|
||||
}
|
||||
const effectiveRange = resolveEffectiveRange()
|
||||
try {
|
||||
const [nextBuckets, nextLogs] = await Promise.all([
|
||||
const [nextBuckets, nextLogs, nextParams] = await Promise.all([
|
||||
fetchTimeline({
|
||||
startMs: effectiveRange?.startMs,
|
||||
endMs: effectiveRange?.endMs,
|
||||
@@ -310,11 +303,25 @@ function StandaloneLog() {
|
||||
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
||||
filter: filter || undefined,
|
||||
}),
|
||||
fetchLogs({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, limit: 1000, filter: filter || undefined }),
|
||||
trackSlowSortedFetch(
|
||||
sortSettings.sortBy,
|
||||
setSortIndexBuilding,
|
||||
fetchLogs({
|
||||
startMs: effectiveRange?.startMs,
|
||||
endMs: effectiveRange?.endMs,
|
||||
limit: 1000,
|
||||
filter: filter || undefined,
|
||||
sortBy: sortSettings.sortBy ?? undefined,
|
||||
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
|
||||
}),
|
||||
),
|
||||
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
|
||||
fetchParamDeclarations(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setBuckets(nextBuckets)
|
||||
setLogs(nextLogs)
|
||||
updateParamsIfChanged(setParams, nextParams)
|
||||
setFilterError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -331,12 +338,17 @@ function StandaloneLog() {
|
||||
clearInterval(id)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter, activePreset])
|
||||
}, [filter, activePreset, sortSettings])
|
||||
|
||||
return (
|
||||
<StandalonePage>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<FilterBar value={filter} onChange={setFilter} error={filterError} />
|
||||
<FilterBar
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
error={filterError}
|
||||
activeSummary={describeActiveState(activePreset, sortSettings)}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<LogTimeline
|
||||
buckets={buckets}
|
||||
@@ -344,6 +356,10 @@ function StandaloneLog() {
|
||||
onSelectRange={handleManualRangeSelect}
|
||||
activePreset={activePreset}
|
||||
onSelectPreset={selectPreset}
|
||||
paramNames={params.map((p) => p.name)}
|
||||
sortSettings={sortSettings}
|
||||
onSortSettingsChange={handleSortSettingsChange}
|
||||
sortIndexBuilding={sortIndexBuilding}
|
||||
/>
|
||||
<LogPreview logs={logs} params={params} />
|
||||
</div>
|
||||
@@ -367,12 +383,21 @@ function FullApp() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
||||
const [range, setRange] = useState<TimeRange | null>(null)
|
||||
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
|
||||
const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({
|
||||
preset: 'all',
|
||||
range: null,
|
||||
})
|
||||
const [filter, setFilter] = useState('')
|
||||
const [filterError, setFilterError] = useState<string | null>(null)
|
||||
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
||||
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
|
||||
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
|
||||
const [sortIndexBuilding, setSortIndexBuilding] = useState(false)
|
||||
|
||||
function handleSortSettingsChange(next: LogSortSettings) {
|
||||
setSortSettings(next)
|
||||
saveLogSortSettings(next)
|
||||
}
|
||||
|
||||
function handleLogViewerResize(deltaY: number) {
|
||||
setLogViewerHeight((current) => clampLogViewerHeight(current + deltaY))
|
||||
@@ -385,12 +410,6 @@ function FullApp() {
|
||||
})
|
||||
}
|
||||
|
||||
// Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick.
|
||||
const rangeRef = useRef(range)
|
||||
useEffect(() => {
|
||||
rangeRef.current = range
|
||||
}, [range])
|
||||
|
||||
async function loadParams() {
|
||||
try {
|
||||
setParams(await fetchParamDeclarations())
|
||||
@@ -403,37 +422,14 @@ function FullApp() {
|
||||
loadParams()
|
||||
}, [])
|
||||
|
||||
function selectPreset(id: TimeRangePresetId) {
|
||||
setActivePreset(id)
|
||||
setRange(computePresetRange(id))
|
||||
}
|
||||
|
||||
// A drag always yields a fixed (non-rolling) range, overriding any active preset.
|
||||
function handleManualRangeSelect(next: TimeRange | null) {
|
||||
if (next === null) {
|
||||
selectPreset('all')
|
||||
} else {
|
||||
setActivePreset(null)
|
||||
setRange(next)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function poll() {
|
||||
// Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time.
|
||||
const effectiveRange =
|
||||
activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current
|
||||
if (activePreset && activePreset !== 'all') {
|
||||
setRange((current) =>
|
||||
current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs
|
||||
? current
|
||||
: effectiveRange,
|
||||
)
|
||||
}
|
||||
const effectiveRange = resolveEffectiveRange()
|
||||
try {
|
||||
const [nextStats, nextBuckets, nextLogs] = await Promise.all([
|
||||
const [nextStats, nextBuckets, nextLogs, nextParams] = await Promise.all([
|
||||
fetchStats(),
|
||||
fetchTimeline({
|
||||
startMs: effectiveRange?.startMs,
|
||||
@@ -442,12 +438,26 @@ function FullApp() {
|
||||
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
|
||||
filter: filter || undefined,
|
||||
}),
|
||||
fetchLogs({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, limit: 1000, filter: filter || undefined }),
|
||||
trackSlowSortedFetch(
|
||||
sortSettings.sortBy,
|
||||
setSortIndexBuilding,
|
||||
fetchLogs({
|
||||
startMs: effectiveRange?.startMs,
|
||||
endMs: effectiveRange?.endMs,
|
||||
limit: 1000,
|
||||
filter: filter || undefined,
|
||||
sortBy: sortSettings.sortBy ?? undefined,
|
||||
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
|
||||
}),
|
||||
),
|
||||
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
|
||||
fetchParamDeclarations(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setStats(nextStats)
|
||||
setBuckets(nextBuckets)
|
||||
setLogs(nextLogs)
|
||||
updateParamsIfChanged(setParams, nextParams)
|
||||
setFilterError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -465,7 +475,7 @@ function FullApp() {
|
||||
}
|
||||
// Intentionally excludes `range` to avoid restarting this interval on every rolling-preset tick.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter, activePreset])
|
||||
}, [filter, activePreset, sortSettings])
|
||||
|
||||
return (
|
||||
<DocsNavProvider>
|
||||
@@ -476,6 +486,7 @@ function FullApp() {
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
error={filterError}
|
||||
activeSummary={describeActiveState(activePreset, sortSettings)}
|
||||
collapseToggle={
|
||||
<>
|
||||
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
||||
@@ -497,6 +508,10 @@ function FullApp() {
|
||||
onSelectRange={handleManualRangeSelect}
|
||||
activePreset={activePreset}
|
||||
onSelectPreset={selectPreset}
|
||||
paramNames={params.map((p) => p.name)}
|
||||
sortSettings={sortSettings}
|
||||
onSortSettingsChange={handleSortSettingsChange}
|
||||
sortIndexBuilding={sortIndexBuilding}
|
||||
/>
|
||||
<LogPreview logs={logs} params={params} />
|
||||
</div>
|
||||
|
||||
@@ -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 CompareOp,
|
||||
} from '../lib/api'
|
||||
import {
|
||||
CollapseToggle,
|
||||
handleSectionHeaderKeyDown,
|
||||
sectionHeaderInteractiveClassName,
|
||||
sectionHeaderLeftClassName,
|
||||
useSectionCollapsed,
|
||||
} from './CollapsibleSection'
|
||||
import { FormFooter, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||
import { queryInputClassName } from './FilterBar'
|
||||
@@ -238,22 +232,7 @@ export function Alerts() {
|
||||
|
||||
return (
|
||||
<div className="border-b border-border text-sm">
|
||||
<div
|
||||
className={sectionHeaderInteractiveClassName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
|
||||
>
|
||||
<div className={sectionHeaderLeftClassName}>
|
||||
Alerts
|
||||
<HelpIcon sectionId="alerts" label="Help: Alerts" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CollapseToggle collapsed={collapsed} />
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeader title="Alerts" helpSectionId="alerts" collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||
<div hidden={collapsed}>
|
||||
<p className="px-3 pt-3 text-xs text-text-faint">
|
||||
Every rule delivers to the ntfy sink configured in Settings.
|
||||
@@ -420,20 +399,13 @@ export function Alerts() {
|
||||
|
||||
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Saving…' : editingId ? 'Save rule' : 'Create rule'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" onClick={startCreate} className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FormFooter
|
||||
editing={editingId !== null}
|
||||
submitting={submitting}
|
||||
idleLabel="Create rule"
|
||||
editingLabel="Save rule"
|
||||
onCancel={startCreate}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
|
||||
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
/// Shared look for a primary section's title bar: shaded so it reads as a
|
||||
/// distinct band rather than blending into the section body below it.
|
||||
@@ -95,6 +96,100 @@ export function handleSectionHeaderKeyDown(onToggle: () => void) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The header row every settings panel (Params/Metrics/Alerts/Dashboards) opens with: a click/keyboard-toggleable
|
||||
/// title bar pairing a title + `HelpIcon` on the left with `CollapseToggle` (and, for panels with a standalone
|
||||
/// popped-out view, a `PopOutButton`) on the right. Assembles `sectionHeaderInteractiveClassName` /
|
||||
/// `handleSectionHeaderKeyDown` / `sectionHeaderLeftClassName` so callers don't each re-wire the same
|
||||
/// `role="button"`/`tabIndex`/`aria-expanded` boilerplate by hand.
|
||||
export function SectionHeader({
|
||||
title,
|
||||
helpSectionId,
|
||||
helpLabel,
|
||||
collapsed,
|
||||
onToggle,
|
||||
popOut,
|
||||
}: {
|
||||
title: string
|
||||
helpSectionId: string
|
||||
helpLabel?: string
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
popOut?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={sectionHeaderInteractiveClassName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={onToggle}
|
||||
onKeyDown={handleSectionHeaderKeyDown(onToggle)}
|
||||
>
|
||||
<div className={sectionHeaderLeftClassName}>
|
||||
{title}
|
||||
<HelpIcon sectionId={helpSectionId} label={helpLabel ?? `Help: ${title}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CollapseToggle collapsed={collapsed} />
|
||||
{popOut}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/// The declare/edit form footer shared by `Params.tsx`/`Metrics.tsx`/`Alerts.tsx`: a submit button whose label
|
||||
/// swaps between create/edit and idle/in-flight variants, plus a Cancel button that only appears while editing.
|
||||
/// Doesn't render the "Editing X" banner some callers show above this — that banner's placement relative to
|
||||
/// `submitError` differs per caller, so it stays theirs to render; this covers only the part that's identical
|
||||
/// everywhere. Label text stays a per-caller prop (e.g. "Declare param" vs "Declare metric") rather than
|
||||
/// hardcoded here, since that's the one part of this footer each panel's copy actually differs on.
|
||||
export function FormFooter({
|
||||
editing,
|
||||
submitting,
|
||||
idleLabel,
|
||||
editingLabel,
|
||||
submittingLabel = 'Saving…',
|
||||
editingSubmittingLabel,
|
||||
onCancel,
|
||||
}: {
|
||||
editing: boolean
|
||||
submitting: boolean
|
||||
idleLabel: string
|
||||
editingLabel: string
|
||||
submittingLabel?: string
|
||||
editingSubmittingLabel?: string
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const label = submitting
|
||||
? editing
|
||||
? (editingSubmittingLabel ?? submittingLabel)
|
||||
: submittingLabel
|
||||
: editing
|
||||
? editingLabel
|
||||
: idleLabel
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads.
|
||||
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
|
||||
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
|
||||
import { PanelChart } from './PanelChart'
|
||||
import {
|
||||
ApiError,
|
||||
@@ -15,15 +15,7 @@ import {
|
||||
type PromqlSeries,
|
||||
} from '../lib/api'
|
||||
import { POLL_MS } from '../lib/polling'
|
||||
import {
|
||||
CollapseToggle,
|
||||
handleSectionHeaderKeyDown,
|
||||
PopOutButton,
|
||||
sectionHeaderInteractiveClassName,
|
||||
sectionHeaderLeftClassName,
|
||||
useSectionCollapsed,
|
||||
} from './CollapsibleSection'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
import { PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
|
||||
|
||||
const CHART_TYPES: ChartType[] = ['line', 'bar', 'pie']
|
||||
|
||||
@@ -96,7 +88,7 @@ function PanelPreview({ panel, range }: { panel: Panel; range: TimeRange | null
|
||||
if (loading && series.length === 0) {
|
||||
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 }) {
|
||||
@@ -153,6 +145,9 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
|
||||
const lastSavedRef = useRef<string | null>(null)
|
||||
// Ticket 064: latest `draft`, read inside the autosave timeout's async callback so a save response can tell
|
||||
// whether the user kept typing after the request went out, instead of unconditionally overwriting `draft`.
|
||||
const draftRef = useRef<Dashboard | null>(draft)
|
||||
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
|
||||
|
||||
async function loadDashboards(profileName: string, selectAfterLoad?: string) {
|
||||
@@ -181,11 +176,21 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
}, [draft])
|
||||
|
||||
// Deliberately keyed only on `selectedId`, not `dashboards` — this effect's job is to load a *newly selected*
|
||||
// dashboard's content into `draft`. Keying it on `dashboards` too meant every autosave's own `setDashboards`
|
||||
// call (below) re-ran this effect and clobbered whatever the user had typed since the save was kicked off
|
||||
// (ticket 064). Reading `dashboards` from the closure here still sees the just-loaded array, since
|
||||
// `setSelectedId`/`setDashboards` from `loadDashboards` land in the same render.
|
||||
useEffect(() => {
|
||||
const found = dashboards.find((d) => d.id === selectedId) ?? null
|
||||
setDraft(found)
|
||||
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
|
||||
}, [selectedId, dashboards])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId])
|
||||
|
||||
// Skips entirely once the draft matches what's already persisted — covers both re-selecting an unchanged dashboard and this effect's own post-save re-render.
|
||||
useEffect(() => {
|
||||
@@ -198,9 +203,14 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
const saved = await saveDashboard(activeProfile, dashboardId, dashboardSaveShape(draft))
|
||||
lastSavedRef.current = JSON.stringify(dashboardSaveShape(saved))
|
||||
lastSavedRef.current = snapshot
|
||||
setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
|
||||
// Only reconcile `saved` into `draft` if nothing newer was typed while the request was in flight —
|
||||
// otherwise this stale response would overwrite those in-progress keystrokes (ticket 064).
|
||||
const current = draftRef.current
|
||||
if (current && current.id === dashboardId && JSON.stringify(dashboardSaveShape(current)) === snapshot) {
|
||||
setDraft(saved)
|
||||
}
|
||||
setSaveStatus('saved')
|
||||
setSaveError(null)
|
||||
} catch (err) {
|
||||
@@ -264,23 +274,13 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
||||
if (!activeProfile) {
|
||||
return (
|
||||
<div className="border-b border-border text-sm">
|
||||
<div
|
||||
className={sectionHeaderInteractiveClassName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
|
||||
>
|
||||
<div className={sectionHeaderLeftClassName}>
|
||||
Dashboards
|
||||
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CollapseToggle collapsed={collapsed} />
|
||||
<PopOutButton section="dashboards" range={range} activePreset={activePreset} />
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="Dashboards"
|
||||
helpSectionId="dashboards"
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
popOut={<PopOutButton section="dashboards" range={range} activePreset={activePreset} />}
|
||||
/>
|
||||
<div className="p-3 text-xs text-text-faint">
|
||||
Activate a profile (see the settings menu) to create and save dashboards.
|
||||
</div>
|
||||
@@ -290,23 +290,13 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
||||
|
||||
return (
|
||||
<div className="border-b border-border text-sm">
|
||||
<div
|
||||
className={sectionHeaderInteractiveClassName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
|
||||
>
|
||||
<div className={sectionHeaderLeftClassName}>
|
||||
Dashboards
|
||||
<HelpIcon sectionId="dashboards" label="Help: Dashboards" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CollapseToggle collapsed={collapsed} />
|
||||
<PopOutButton section="dashboards" range={range} activePreset={activePreset} />
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="Dashboards"
|
||||
helpSectionId="dashboards"
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
popOut={<PopOutButton section="dashboards" range={range} activePreset={activePreset} />}
|
||||
/>
|
||||
{error && !collapsed && (
|
||||
<div className="mx-3 mt-3 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{error}</div>
|
||||
)}
|
||||
|
||||
+154
-33
@@ -50,9 +50,10 @@ const SECTIONS: DocSection[] = [
|
||||
</ul>
|
||||
<p className="font-semibold text-text">Settings</p>
|
||||
<p>
|
||||
One <em>settings</em> file per machine holds the colour scheme, imported/linked CSS, the ntfy sink, and
|
||||
which profile is active. Settings only tracks profile pointers — the profiles themselves (params, metrics,
|
||||
dashboards) are stored separately, next to the settings file by default.
|
||||
One <em>settings</em> file per machine holds the active style (a built-in colour scheme or an
|
||||
imported/linked stylesheet — see below), the ntfy sink, and which profile is active. Settings only tracks
|
||||
profile pointers — the profiles themselves (params, metrics, dashboards) are stored separately, next to the
|
||||
settings file by default.
|
||||
</p>
|
||||
<p className="font-semibold text-text">Profiles</p>
|
||||
<p>
|
||||
@@ -77,32 +78,62 @@ const SECTIONS: DocSection[] = [
|
||||
id: 'filtering',
|
||||
title: 'Filtering',
|
||||
body: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
|
||||
by <Code>and</Code>/<Code>or</Code>. Examples:
|
||||
by <Code>and</Code>/<Code>or</Code>. The server evaluates the filter string; a parse error shows as inline
|
||||
text under the filter bar.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-text">Regex match</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>
|
||||
<Code>{'regex("error")'}</Code> — lines containing "error". See the <Code>@</Code> icon next to the
|
||||
filter bar for the exact regex syntax supported (Rust's <Code>regex</Code> crate — no lookaround or
|
||||
backreferences).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-text">Metric/param comparisons</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>
|
||||
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
|
||||
<Code>duration</Code> param over 100. <Code>[param]</Code> comparisons only work against a{' '}
|
||||
<em>declared metric's</em> labels, not a bare param declared on its own, and values are unquoted (no
|
||||
surrounding <Code>"..."</Code>).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-text">Reserved names (always filterable)</p>
|
||||
<p>
|
||||
<Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, and <Code>ottyl_fmt</Code> are present on every log line
|
||||
without declaring anything, and also appear (flagged <Code>reserved</Code>) in{' '}
|
||||
<Code>GET /api/params</Code> for discoverability, though there's no UI to edit or remove them.
|
||||
</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>
|
||||
<Code>{'regex("error")'}</Code> — lines containing "error".
|
||||
<Code>{'ottyl_seq<5'}</Code> — ingest sequence number under 5.
|
||||
</li>
|
||||
<li>
|
||||
<Code>{'regex("error") and sequence<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>
|
||||
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
|
||||
<Code>duration</Code> param over 100.
|
||||
</li>
|
||||
<li>
|
||||
<Code>{'timestamp>-5m'}</Code> — lines from the last 5 minutes.
|
||||
<Code>{'ottyl_fmt==json'}</Code> — the line's auto-detected text format:{' '}
|
||||
<Code>log_stream</Code> (macOS <Code>log stream</Code> output — its columns are also auto-declared as
|
||||
their own named params, e.g. <Code>PID</Code>/<Code>Thread</Code>, from that line's own header), <Code>json</Code>,{' '}
|
||||
<Code>logfmt</Code>, or <Code>none</Code>.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<Code>sequence</Code> and <Code>timestamp</Code> are reserved metric names present on every log line, so
|
||||
they're always filterable without declaring anything.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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">
|
||||
<li>
|
||||
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
|
||||
@@ -114,29 +145,100 @@ const SECTIONS: DocSection[] = [
|
||||
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-text">Combining clauses</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>
|
||||
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5.
|
||||
</li>
|
||||
<li>
|
||||
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code> — 5xx responses on one path, given a{' '}
|
||||
<Code>requests</Code> metric with a <Code>path</Code> label param.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-text">Worked examples</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>
|
||||
Only lines mentioning "timeout" in the last 15 minutes:{' '}
|
||||
<Code>{'regex("timeout") and timestamp>-15m'}</Code>
|
||||
<Code>{'regex("timeout") and ottyl_ts>-15m'}</Code>
|
||||
</li>
|
||||
<li>
|
||||
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
|
||||
<Code>duration</Code> param declared: <Code>{'requests[duration]>200'}</Code>
|
||||
</li>
|
||||
<li>
|
||||
5xx responses on one path, given a <Code>requests</Code> metric with a <Code>path</Code> label param —
|
||||
note that <Code>[param]</Code> comparisons only work against a <em>declared metric's</em> labels, not a
|
||||
bare param declared on its own, and values are unquoted (no surrounding <Code>"..."</Code>):{' '}
|
||||
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code>
|
||||
</li>
|
||||
<li>
|
||||
Everything before noon on a specific date: <Code>{'timestamp<2024-06-15T12:00:00Z'}</Code>
|
||||
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-1 text-text-subtle">
|
||||
See also <em>Dashboards</em> below for using <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> in a panel
|
||||
query.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
@@ -279,6 +381,15 @@ const SECTIONS: DocSection[] = [
|
||||
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
|
||||
latest reading.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold text-text">
|
||||
<Code>histogram_quantile(p, metric_name)</Code>
|
||||
</span>{' '}
|
||||
— the <Code>p</Code>th quantile (<Code>0</Code>–<Code>1</Code>, e.g. <Code>0.95</Code> for p95) of each
|
||||
label combination's retained values, interpolated over the raw samples rather than pre-aggregated
|
||||
buckets. Add a bracketed range to narrow to a trailing window instead of the whole active range, e.g.{' '}
|
||||
<Code>histogram_quantile(0.95, durations[5m])</Code>.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="font-semibold text-text">Worked example</p>
|
||||
<p>
|
||||
@@ -330,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
|
||||
required to create or save one; pick or create one from the settings menu first.
|
||||
</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>
|
||||
),
|
||||
},
|
||||
@@ -367,8 +486,9 @@ const SECTIONS: DocSection[] = [
|
||||
</ul>
|
||||
<p>
|
||||
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, restricted to{' '}
|
||||
<Code>regex(...)</Code>, <Code>sequence</Code>, <Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>
|
||||
. A <Code>metric_name[param]</Code> comparison isn't supported in an alert query.
|
||||
<Code>regex(...)</Code>, <Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, <Code>ottyl_fmt</Code>, and{' '}
|
||||
<Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison isn't supported in an alert
|
||||
query.
|
||||
</p>
|
||||
<p>
|
||||
A window accepts a plain number of milliseconds or a humantime-style duration: <Code>30s</Code>,{' '}
|
||||
@@ -419,9 +539,10 @@ const SECTIONS: DocSection[] = [
|
||||
body: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>
|
||||
The Settings panel controls process-wide state: colour scheme, the ntfy sink alert rules deliver to, the
|
||||
max number of logs kept in memory (blank means unlimited), and imported/linked CSS. Changes apply
|
||||
immediately and persist to the settings file on disk. There is no save button.
|
||||
The Settings panel controls process-wide state: the active style — one unified list of built-in colour
|
||||
schemes and any imported/linked stylesheets, any of which can be a simple palette or a full layout
|
||||
redesign — the ntfy sink alert rules deliver to, and the max number of logs kept in memory (blank means
|
||||
unlimited). Changes apply immediately and persist to the settings file on disk. There is no save button.
|
||||
</p>
|
||||
<p className="font-semibold text-text">Worked example</p>
|
||||
<p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||
|
||||
@@ -13,14 +13,22 @@ export function FilterBar({
|
||||
onChange,
|
||||
error,
|
||||
collapseToggle,
|
||||
activeSummary,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
error?: string | null
|
||||
collapseToggle?: ReactNode
|
||||
/** Ticket 066: a read-only textual summary of the active time-range preset / sort order, shown below the input when the user hasn't typed a manual filter — the filter text itself stays the single source of truth for what's actually sent to the server. */
|
||||
activeSummary?: string | null
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value)
|
||||
|
||||
// Resync when `value` changes externally (e.g. a programmatic clear) rather than only reading it once on mount.
|
||||
useEffect(() => {
|
||||
setDraft(value)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-1 border-b border-border px-3 py-2"
|
||||
@@ -30,12 +38,19 @@ export function FilterBar({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpIcon sectionId="filtering" label="Help: Filtering" />
|
||||
<span className="flex items-center gap-1">
|
||||
<HelpIcon sectionId="filtering" label="Help: filter syntax" />
|
||||
<span className="text-xs text-text-subtle">Filter syntax</span>
|
||||
<span className="mx-0.5 h-4 w-px bg-border-soft" aria-hidden="true" />
|
||||
<RegexHelpIcon label="Help: regex(...) syntax" />
|
||||
<span className="text-xs text-text-faint" title="Cheat sheet for regex(...) clauses specifically">
|
||||
regex(...)
|
||||
</span>
|
||||
</span>
|
||||
{collapseToggle}
|
||||
<input
|
||||
className={queryInputClassName}
|
||||
placeholder='regex("error") and sequence<5 or timestamp>12:00'
|
||||
placeholder='ottyl_ts>-15m and requests[duration]>200 or regex("error")'
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
@@ -47,6 +62,7 @@ export function FilterBar({
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-danger-text">{error}</p>}
|
||||
{!error && !draft && activeSummary && <p className="text-xs text-text-subtle">Active: {activeSummary}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
|
||||
import { useClickOutside } from '../lib/useClickOutside'
|
||||
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
@@ -37,14 +38,7 @@ function ColumnPicker({
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
useClickOutside(ref, () => setOpen(false), open)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
@@ -119,7 +113,7 @@ function JsonNode({
|
||||
entries: ReadonlyArray<readonly [string, unknown]>
|
||||
bracket: [string, string]
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
@@ -209,7 +203,14 @@ function useScrollAnchor(containerRef: React.RefObject<HTMLDivElement | null>, d
|
||||
}, deps)
|
||||
}
|
||||
|
||||
export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDeclaration[] }) {
|
||||
export function LogPreview({
|
||||
logs,
|
||||
params,
|
||||
}: {
|
||||
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[]
|
||||
}) {
|
||||
const paramNames = params.map((d) => d.name)
|
||||
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -237,8 +238,10 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
|
||||
Log preview
|
||||
<HelpIcon sectionId="log-preview" label="Help: Log preview" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
||||
</div>
|
||||
</div>
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead className="sticky top-0 bg-surface-0 text-text-subtle">
|
||||
|
||||
@@ -1,34 +1,9 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { TimelineBucket } from '../lib/api'
|
||||
import { TIME_RANGE_PRESETS, type TimeRange, type TimeRangePresetId } from '../lib/timeRange'
|
||||
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
|
||||
export interface TimeRange {
|
||||
startMs: number
|
||||
endMs: number
|
||||
}
|
||||
|
||||
// `'all'` clears the range (the implicit default); the rest resolve to a window ending "now" at the moment they're (re-)computed — see `computePresetRange`.
|
||||
export type TimeRangePresetId = 'all' | '1m' | '2m' | '5m' | '15m' | '1h' | '24h'
|
||||
|
||||
export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [
|
||||
{ id: 'all', label: 'All', ms: null },
|
||||
{ id: '1m', label: '1m', ms: 60 * 1000 },
|
||||
{ id: '2m', label: '2m', ms: 2 * 60 * 1000 },
|
||||
{ id: '5m', label: '5m', ms: 5 * 60 * 1000 },
|
||||
{ id: '15m', label: '15m', ms: 15 * 60 * 1000 },
|
||||
{ id: '1h', label: '1h', ms: 60 * 60 * 1000 },
|
||||
{ id: '24h', label: '24h', ms: 24 * 60 * 60 * 1000 },
|
||||
]
|
||||
|
||||
// Callers re-invoke this on their existing poll cadence (see `App.tsx`) to keep a rolling preset's window moving with time, rather than freezing it at selection time.
|
||||
export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
|
||||
const preset = TIME_RANGE_PRESETS.find((p) => p.id === id)
|
||||
if (!preset || preset.ms == null) return null
|
||||
const now = Date.now()
|
||||
return { startMs: now - preset.ms, endMs: now }
|
||||
}
|
||||
|
||||
/// Buckets a fixed range into this many slots, so e.g. a rolling `5m` preset always requests a resolution fine enough to visibly slide instead of a handful of coarse, jumpy bars.
|
||||
const TIMELINE_TARGET_BUCKETS = 60
|
||||
|
||||
@@ -41,18 +16,120 @@ function formatTimestamp(ms: number): string {
|
||||
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({
|
||||
buckets,
|
||||
range,
|
||||
onSelectRange,
|
||||
activePreset,
|
||||
onSelectPreset,
|
||||
paramNames,
|
||||
sortSettings,
|
||||
onSortSettingsChange,
|
||||
sortIndexBuilding,
|
||||
}: {
|
||||
buckets: TimelineBucket[]
|
||||
range: TimeRange | null
|
||||
onSelectRange: (range: TimeRange | null) => void
|
||||
activePreset: TimeRangePresetId | null
|
||||
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 [dragStartX, setDragStartX] = useState<number | null>(null)
|
||||
@@ -122,7 +199,16 @@ export function LogTimeline({
|
||||
Timeline
|
||||
<HelpIcon sectionId="timeline" label="Help: Timeline" />
|
||||
</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">
|
||||
{TIME_RANGE_PRESETS.map((preset) => (
|
||||
<button
|
||||
@@ -147,6 +233,7 @@ export function LogTimeline({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
+142
-125
@@ -1,29 +1,22 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
|
||||
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
|
||||
import {
|
||||
ApiError,
|
||||
declareMetric,
|
||||
deleteMetric,
|
||||
fetchMetricBucketValues,
|
||||
fetchMetricDeclarations,
|
||||
fetchParamDeclarations,
|
||||
metricPrimitiveToDisplayString,
|
||||
updateMetric,
|
||||
type BucketValue,
|
||||
type MetricDeclaration,
|
||||
type MetricParamMatcher,
|
||||
type MetricSemantics,
|
||||
type ParamDeclaration,
|
||||
} from '../lib/api'
|
||||
import {
|
||||
CollapseToggle,
|
||||
handleSectionHeaderKeyDown,
|
||||
PopOutButton,
|
||||
sectionHeaderInteractiveClassName,
|
||||
sectionHeaderLeftClassName,
|
||||
useSectionCollapsed,
|
||||
} from './CollapsibleSection'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
import { FormFooter, PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
|
||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||
|
||||
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
|
||||
|
||||
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
|
||||
interface LabelGroup {
|
||||
key: string
|
||||
@@ -80,12 +73,17 @@ function sparkline(values: BucketValue[], range: TimeRange | null): string {
|
||||
|
||||
export function Metrics({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
|
||||
const [metrics, setMetrics] = useState<MetricDeclaration[]>([])
|
||||
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [regex, setRegex] = useState('')
|
||||
const [matchers, setMatchers] = useState<MetricParamMatcher[]>([{ ...EMPTY_MATCHER }])
|
||||
// A metric picks its label/value params from the params already declared in the Params panel
|
||||
// (ticket 037) rather than re-entering a regex/capture-group/kind per param.
|
||||
const [labelParams, setLabelParams] = useState<string[]>([])
|
||||
const [valueParam, setValueParam] = useState<string>('')
|
||||
const [semantics, setSemantics] = useState<MetricSemantics>('gauge')
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
|
||||
@@ -101,8 +99,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParams() {
|
||||
try {
|
||||
setParams(await fetchParamDeclarations())
|
||||
} catch {
|
||||
// engine not reachable yet; leave the previous list in place
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadMetrics()
|
||||
loadParams()
|
||||
}, [])
|
||||
|
||||
// Refetch-on-change rather than continuous polling: bucket values only move when the selected metric or range changes, so no interval is needed here like `App.tsx`'s log/timeline polling.
|
||||
@@ -124,23 +131,19 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
}
|
||||
}, [selected, range])
|
||||
|
||||
function updateMatcher(index: number, patch: Partial<MetricParamMatcher>) {
|
||||
setMatchers((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m)))
|
||||
}
|
||||
|
||||
function addMatcher() {
|
||||
setMatchers((prev) => [...prev, { ...EMPTY_MATCHER }])
|
||||
}
|
||||
|
||||
function removeMatcher(index: number) {
|
||||
setMatchers((prev) => prev.filter((_, i) => i !== index))
|
||||
function toggleLabelParam(paramName: string) {
|
||||
setLabelParams((prev) =>
|
||||
prev.includes(paramName) ? prev.filter((name) => name !== paramName) : [...prev, paramName],
|
||||
)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingName(null)
|
||||
setName('')
|
||||
setRegex('')
|
||||
setMatchers([{ ...EMPTY_MATCHER }])
|
||||
setLabelParams([])
|
||||
setValueParam('')
|
||||
setSemantics('gauge')
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -149,7 +152,9 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
setEditingName(metric.name)
|
||||
setName(metric.name)
|
||||
setRegex(metric.message_match_regex)
|
||||
setMatchers(metric.params.length > 0 ? metric.params.map((p) => ({ ...p })) : [{ ...EMPTY_MATCHER }])
|
||||
setLabelParams(metric.label_params)
|
||||
setValueParam(metric.value_param ?? '')
|
||||
setSemantics(metric.semantics ?? 'gauge')
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -158,6 +163,19 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
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) {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
@@ -166,7 +184,9 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
const request: MetricDeclaration = {
|
||||
name,
|
||||
message_match_regex: regex,
|
||||
params: matchers.filter((m) => m.param_name && m.capture_group),
|
||||
label_params: labelParams,
|
||||
value_param: valueParam || null,
|
||||
semantics,
|
||||
}
|
||||
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
|
||||
resetForm()
|
||||
@@ -184,23 +204,13 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
|
||||
return (
|
||||
<div className="border-b border-border text-sm">
|
||||
<div
|
||||
className={sectionHeaderInteractiveClassName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
|
||||
>
|
||||
<div className={sectionHeaderLeftClassName}>
|
||||
Metrics
|
||||
<HelpIcon sectionId="metrics" label="Help: Metrics" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CollapseToggle collapsed={collapsed} />
|
||||
<PopOutButton section="metrics" range={range} activePreset={activePreset} />
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="Metrics"
|
||||
helpSectionId="metrics"
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
popOut={<PopOutButton section="metrics" range={range} activePreset={activePreset} />}
|
||||
/>
|
||||
<div className="flex gap-4 p-3" hidden={collapsed}>
|
||||
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
||||
<div>
|
||||
@@ -227,61 +237,65 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-text-subtle">
|
||||
<span>Params</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMatcher}
|
||||
className="rounded border border-border-soft px-2 py-0.5 text-text-secondary hover:bg-surface-2"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{matchers.map((matcher, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<label className="mb-1 block text-xs text-text-subtle">
|
||||
Label params
|
||||
{params.length === 0 && (
|
||||
<span className="ml-1 text-text-faint">(declare a param in the Params panel below first)</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="max-h-32 space-y-0.5 overflow-auto rounded border border-border-soft bg-surface-1 p-1.5">
|
||||
{params.length === 0 ? (
|
||||
<span className="text-xs text-text-faint">No params declared yet.</span>
|
||||
) : (
|
||||
params.map((p) => (
|
||||
<label key={p.name} className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<input
|
||||
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
value={matcher.param_name}
|
||||
onChange={(e) => updateMatcher(i, { param_name: e.target.value })}
|
||||
placeholder="param_name"
|
||||
type="checkbox"
|
||||
checked={labelParams.includes(p.name)}
|
||||
onChange={() => toggleLabelParam(p.name)}
|
||||
/>
|
||||
<input
|
||||
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
value={matcher.capture_group}
|
||||
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
|
||||
placeholder="capture_group"
|
||||
/>
|
||||
<select
|
||||
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
||||
value={matcher.kind}
|
||||
onChange={(e) => updateMatcher(i, { kind: e.target.value as MetricParamMatcher['kind'] })}
|
||||
>
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="timestamp">timestamp</option>
|
||||
<option value="duration">duration</option>
|
||||
</select>
|
||||
<select
|
||||
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
||||
value={matcher.role ?? 'label'}
|
||||
onChange={(e) => updateMatcher(i, { role: e.target.value as MetricParamMatcher['role'] })}
|
||||
title="Label params identify a bucket; at most one Value param becomes the counter's per-event captured value (used by histogram()/gauge()). A Value param of kind 'duration' is derived automatically — elapsed time since the previous match with the same labels — rather than parsed from the capture group."
|
||||
>
|
||||
<option value="label">label</option>
|
||||
<option value="value">value</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMatcher(i)}
|
||||
disabled={matchers.length === 1}
|
||||
className="rounded border border-border-soft px-1.5 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-30"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span className="font-mono">{p.name}</span>
|
||||
<span className="text-text-faint">({p.elapsed_since_last ? 'elapsed since previous' : p.kind})</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="mb-1 block text-xs text-text-subtle"
|
||||
title="At most one param becomes the counter's per-event captured value (used by histogram()/gauge()) instead of a label. An 'elapsed since previous event' param (declared in the Params panel) is computed automatically — elapsed time since the previous match with the same labels — rather than parsed from its capture group."
|
||||
>
|
||||
Value param
|
||||
</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={valueParam}
|
||||
onChange={(e) => setValueParam(e.target.value)}
|
||||
>
|
||||
<option value="">(none — occurrence count)</option>
|
||||
{params.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name} ({p.elapsed_since_last ? 'elapsed since previous' : p.kind})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="mb-1 block text-xs text-text-subtle"
|
||||
title="Gauge: a point-in-time reading (e.g. queue depth) — rate() over it is rejected. Counter: a monotonically-increasing total (e.g. requests served) — sum()/avg() directly on it is rejected; wrap it in rate() first."
|
||||
>
|
||||
Semantics
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
||||
value={semantics}
|
||||
onChange={(e) => setSemantics(e.target.value as MetricSemantics)}
|
||||
>
|
||||
<option value="gauge">Gauge (point-in-time reading)</option>
|
||||
<option value="counter">Counter (monotonically increasing)</option>
|
||||
</select>
|
||||
</div>
|
||||
{editingName && (
|
||||
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
||||
@@ -289,25 +303,15 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
</div>
|
||||
)}
|
||||
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (editingName ? 'Saving…' : 'Declaring…') : editingName ? 'Save changes' : 'Declare metric'}
|
||||
</button>
|
||||
{editingName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
disabled={submitting}
|
||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FormFooter
|
||||
editing={editingName !== null}
|
||||
submitting={submitting}
|
||||
idleLabel="Declare metric"
|
||||
editingLabel="Save changes"
|
||||
submittingLabel="Declaring…"
|
||||
editingSubmittingLabel="Saving…"
|
||||
onCancel={resetForm}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -315,18 +319,27 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
<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.map((m) => (
|
||||
<button
|
||||
<div
|
||||
key={m.name}
|
||||
onClick={() => selectMetric(m)}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
className={`flex items-center gap-0.5 rounded border pl-2 pr-1 py-1 text-xs ${
|
||||
selected === m.name
|
||||
? 'border-accent bg-accent/10 text-accent-text'
|
||||
: 'border-border-soft text-text-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
title={m.message_match_regex}
|
||||
>
|
||||
{m.name} ({m.params.map((p) => p.param_name).join(', ') || 'no params'})
|
||||
<button type="button" onClick={() => selectMetric(m)} title={m.message_match_regex}>
|
||||
{m.name} ({m.label_params.join(', ') || 'no params'})
|
||||
</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>
|
||||
|
||||
@@ -342,17 +355,21 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
||||
<span className="font-mono">{selectedMetric.message_match_regex}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-subtle">Params: </span>
|
||||
{selectedMetric.params.length === 0 ? (
|
||||
<span className="text-text-subtle">Label params: </span>
|
||||
{selectedMetric.label_params.length === 0 ? (
|
||||
<span className="text-text-faint">none</span>
|
||||
) : (
|
||||
<span className="font-mono">
|
||||
{selectedMetric.params
|
||||
.map((p) => `${p.param_name}←${p.capture_group} (${p.kind}, ${p.role ?? 'label'})`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
<span className="font-mono">{selectedMetric.label_params.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-subtle">Value param: </span>
|
||||
<span className="font-mono">{selectedMetric.value_param ?? <span className="text-text-faint">none</span>}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-subtle">Semantics: </span>
|
||||
<span className="font-mono">{selectedMetric.semantics ?? 'gauge'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{groups.length === 0 ? (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Stats } from '../lib/api'
|
||||
import { cssContentUrl, sendTestAlertNotification, type ColorScheme, type CssEntry } from '../lib/api'
|
||||
import { sendTestAlertNotification } from '../lib/api'
|
||||
import { useSettingsPanel } from '../lib/useSettingsPanel'
|
||||
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
||||
import { DocsPanel } from './Docs'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes'
|
||||
import ottylIcon from '../assets/ottyl-icon.svg'
|
||||
|
||||
type PanelName = 'settings' | 'docs' | null
|
||||
|
||||
@@ -17,17 +17,20 @@ const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
|
||||
/** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */
|
||||
const DEFAULT_PROFILE_NAME = 'Default'
|
||||
|
||||
const COLOR_SCHEMES: ColorScheme[] = [
|
||||
'system',
|
||||
'light',
|
||||
'dark',
|
||||
'red',
|
||||
'green',
|
||||
'blue',
|
||||
'yellow',
|
||||
'pink',
|
||||
'orange',
|
||||
]
|
||||
/** Display label for a theme name: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
|
||||
function themeLabel(theme: string): string {
|
||||
return theme.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** Ticket 073: plain `Xms` under a second (this stat is normally near-zero, so that's the common case), a humantime-style `Xs`/`Xm` above it — mirrors the backend's own humantime-duration inputs (see `Docs.tsx`'s window-parsing note) without pulling in a full duration-formatting library for one stat. */
|
||||
function formatIngestLag(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const totalSeconds = ms / 1000
|
||||
if (totalSeconds < 60) return `${totalSeconds % 1 === 0 ? totalSeconds : totalSeconds.toFixed(1)}s`
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = Math.round(totalSeconds % 60)
|
||||
return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`
|
||||
}
|
||||
|
||||
export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||
const [openPanel, setOpenPanel] = useState<PanelName>(null)
|
||||
@@ -56,7 +59,10 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||
<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>
|
||||
<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} />
|
||||
<nav className="mt-2 flex flex-col gap-1 text-sm text-text-muted">
|
||||
<button
|
||||
@@ -87,6 +93,9 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
|
||||
</div>
|
||||
<div>{stats.approx_bytes_total.toLocaleString()} bytes</div>
|
||||
<div title="Highest ingestion-to-engine-processing lag seen in the last 5s">
|
||||
lag {formatIngestLag(stats.ingest_lag_high_watermark_ms)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>connecting…</div>
|
||||
@@ -185,10 +194,6 @@ function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel>
|
||||
|
||||
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
|
||||
const [maxLogsInput, setMaxLogsInput] = useState('')
|
||||
const [cssFilename, setCssFilename] = useState('')
|
||||
const [cssContent, setCssContent] = useState('')
|
||||
const [cssUploadError, setCssUploadError] = useState<string | null>(null)
|
||||
const [cssLinkUrl, setCssLinkUrl] = useState('')
|
||||
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
|
||||
const [ntfyServerInput, setNtfyServerInput] = useState('')
|
||||
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
|
||||
@@ -244,83 +249,41 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
panel.setMaxLogs(Math.floor(parsed))
|
||||
}
|
||||
|
||||
async function handleCssFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setCssFilename(file.name)
|
||||
setCssContent(await file.text())
|
||||
setCssUploadError(null)
|
||||
}
|
||||
|
||||
// Every imported/linked stylesheet is always a selectable theme (ticket 020), so it's always
|
||||
// validated against the canonical `--c-*` variable set before it's sent.
|
||||
async function handleCssImport(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setCssUploadError(null)
|
||||
if (!cssFilename.trim() || !cssContent.trim()) return
|
||||
const { missing } = parseThemeCss(cssContent)
|
||||
if (missing.length > 0) {
|
||||
setCssUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
|
||||
return
|
||||
}
|
||||
await panel.importCssEntry(cssFilename.trim(), cssContent)
|
||||
setCssFilename('')
|
||||
setCssContent('')
|
||||
}
|
||||
|
||||
async function handleCssLink(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!cssLinkUrl.trim()) return
|
||||
await panel.linkCssEntry(cssLinkUrl.trim())
|
||||
setCssLinkUrl('')
|
||||
}
|
||||
|
||||
function downloadBuiltinTheme(name: string) {
|
||||
const vars = BUILTIN_THEMES[name]
|
||||
if (!vars) return
|
||||
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
|
||||
}
|
||||
|
||||
async function downloadCssEntry(entry: CssEntry) {
|
||||
if (entry.kind === 'imported') {
|
||||
const res = await fetch(cssContentUrl(entry.id))
|
||||
const text = await res.text()
|
||||
downloadThemeFile(entry.filename ?? `${entry.id}.css`, text)
|
||||
} else if (entry.path_or_url) {
|
||||
window.open(entry.path_or_url, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Theme</h3>
|
||||
<span className="text-[11px] text-text-faint">
|
||||
each theme is a complete stylesheet (colours, spacing, fonts, sizes, ...); only one is active at a time.
|
||||
To add your own, drop a `.css` file in `themes/` and rebuild.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COLOR_SCHEMES.map((scheme) => (
|
||||
<div key={scheme} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => panel.setColorScheme(scheme)}
|
||||
className={`rounded-l border px-2 py-1 text-xs capitalize ${
|
||||
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme
|
||||
onClick={() => panel.setTheme('system')}
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
panel.settings?.theme === 'system'
|
||||
? 'border-border-strong bg-surface-2 text-text'
|
||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{scheme}
|
||||
System
|
||||
</button>
|
||||
{scheme !== 'system' && (
|
||||
{panel.themes.map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
onClick={() => downloadBuiltinTheme(scheme)}
|
||||
className="rounded-r border border-l-0 border-border-soft px-1.5 py-1 text-xs text-text-faint hover:bg-surface-2 hover:text-text"
|
||||
aria-label={`Download ${scheme} theme`}
|
||||
title="Download as .css"
|
||||
onClick={() => panel.setTheme(theme)}
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
panel.settings?.theme === theme
|
||||
? 'border-border-strong bg-surface-2 text-text'
|
||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
↓
|
||||
{themeLabel(theme)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -378,114 +341,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">CSS</h3>
|
||||
<span className="text-[11px] text-text-faint">
|
||||
imported/linked stylesheets — each one becomes a selectable alternative to the built-in colour schemes
|
||||
above
|
||||
</span>
|
||||
</div>
|
||||
{panel.cssEntries.length === 0 ? (
|
||||
<div className="mb-2 text-xs text-text-faint">No CSS configured.</div>
|
||||
) : (
|
||||
<ul className="mb-2 flex flex-col gap-1">
|
||||
{panel.cssEntries.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => panel.setCustomTheme(entry.id)}
|
||||
className={`block w-full truncate text-left capitalize ${
|
||||
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
|
||||
}`}
|
||||
>
|
||||
{entry.filename ?? entry.path_or_url}
|
||||
</button>
|
||||
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
|
||||
</div>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadCssEntry(entry)}
|
||||
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
|
||||
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
|
||||
title="Download as .css"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
{entry.kind === 'imported' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => panel.removeCssEntry(entry.id)}
|
||||
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
||||
aria-label={`Delete ${entry.filename}`}
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCssImport} className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".css,text/css"
|
||||
onChange={handleCssFileChange}
|
||||
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="enter name"
|
||||
value={cssFilename}
|
||||
onChange={(e) => setCssFilename(e.target.value)}
|
||||
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Paste CSS content here"
|
||||
value={cssContent}
|
||||
onChange={(e) => setCssContent(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
/>
|
||||
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://example.com/style.css"
|
||||
value={cssLinkUrl}
|
||||
onChange={(e) => setCssLinkUrl(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
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`.
|
||||
export function seriesLabel(labels: Record<string, string>): string {
|
||||
@@ -22,8 +23,38 @@ export function seriesLabel(labels: Record<string, string>): string {
|
||||
return entries.map(([k, v]) => `${k}=${v}`).join(', ')
|
||||
}
|
||||
|
||||
// Fixed, small categorical palette to avoid a colour-scale dependency; cycles if there are more series than colours.
|
||||
const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316']
|
||||
// Categorical palette drawn from the active theme's accent set (ticket 048: every built-in theme
|
||||
// exposes --c-accent/--c-accent-2/--c-accent-3, not just one accent hue) instead of a hardcoded,
|
||||
// theme-independent hex list. `color-mix` tints/shades those three base accents into a fuller
|
||||
// 8-colour cycle (matching the previous fixed palette's size, so MAX_KEPT_SERIES/squashing below is
|
||||
// unaffected) without any JS colour math or a getComputedStyle read — the browser resolves the CSS
|
||||
// variables per-theme at paint time, same as `stroke="var(--c-border)"` elsewhere in this file.
|
||||
const COLORS = [
|
||||
'var(--c-accent)',
|
||||
'var(--c-accent-2)',
|
||||
'var(--c-accent-3)',
|
||||
'color-mix(in srgb, var(--c-accent) 60%, white)',
|
||||
'color-mix(in srgb, var(--c-accent-2) 60%, white)',
|
||||
'color-mix(in srgb, var(--c-accent-3) 60%, white)',
|
||||
'color-mix(in srgb, var(--c-accent) 55%, black)',
|
||||
'color-mix(in srgb, var(--c-accent-2) 55%, black)',
|
||||
]
|
||||
|
||||
// One less than the palette size: above this many series, keep the top MAX_KEPT_SERIES (by value) and squash the
|
||||
// rest into a single "Other" bucket, so the squashed range never exceeds COLORS.length and never repeats a colour.
|
||||
const MAX_KEPT_SERIES = COLORS.length - 1
|
||||
|
||||
// Sorts rows by value descending and, if there are more than MAX_KEPT_SERIES, keeps the top MAX_KEPT_SERIES and
|
||||
// collapses the remainder into a single "Other" row summing their values. Below the threshold, rows are returned
|
||||
// unchanged so panels with few series keep their existing bar/slice order and colour assignment.
|
||||
function squashRows(rows: { name: string; value: number }[]): { name: string; value: number }[] {
|
||||
if (rows.length <= MAX_KEPT_SERIES) return rows
|
||||
const sorted = [...rows].sort((a, b) => b.value - a.value)
|
||||
const kept = sorted.slice(0, MAX_KEPT_SERIES)
|
||||
const rest = sorted.slice(MAX_KEPT_SERIES)
|
||||
const otherValue = rest.reduce((sum, r) => sum + r.value, 0)
|
||||
return [...kept, { name: `Other (${rest.length})`, value: otherValue }]
|
||||
}
|
||||
|
||||
// Merges series into one row-per-timestamp dataset (recharts wants a single array keyed by series name); a timestamp missing from a series is left undefined there, which recharts skips when drawing that line.
|
||||
function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } {
|
||||
@@ -61,19 +92,36 @@ function sumValue(series: PromqlSeries): number {
|
||||
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) {
|
||||
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No data in range.</div>
|
||||
}
|
||||
|
||||
if (chartType === 'line') {
|
||||
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 (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border)" />
|
||||
<XAxis
|
||||
dataKey="timestamp_ms"
|
||||
type="number"
|
||||
domain={range ? [range.startMs, range.endMs] : ['dataMin', 'dataMax']}
|
||||
allowDataOverflow
|
||||
tickFormatter={formatTimestamp}
|
||||
stroke="var(--c-text-subtle)"
|
||||
tick={{ fontSize: 11, fill: 'var(--c-text-muted)' }}
|
||||
@@ -101,7 +149,7 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
|
||||
}
|
||||
|
||||
if (chartType === 'bar') {
|
||||
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) }))
|
||||
const rows = squashRows(series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) })))
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||
@@ -119,7 +167,7 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
|
||||
)
|
||||
}
|
||||
|
||||
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
|
||||
const rows = squashRows(series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) })))
|
||||
const total = rows.reduce((sum, r) => sum + r.value, 0)
|
||||
if (total === 0) {
|
||||
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No non-zero values in range.</div>
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ApiError, declareParam, type ParamDeclaration } from '../lib/api'
|
||||
import {
|
||||
CollapseToggle,
|
||||
handleSectionHeaderKeyDown,
|
||||
PopOutButton,
|
||||
sectionHeaderInteractiveClassName,
|
||||
sectionHeaderLeftClassName,
|
||||
useSectionCollapsed,
|
||||
} from './CollapsibleSection'
|
||||
import { HelpIcon } from './HelpIcon'
|
||||
import { ApiError, clearParams, declareParam, type ParamDeclaration } from '../lib/api'
|
||||
import { FormFooter, PopOutButton, SectionHeader, useSectionCollapsed } from './CollapsibleSection'
|
||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||
|
||||
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
||||
|
||||
type ExtractionSource = 'regex' | 'json_field'
|
||||
// 'elapsed_since_last' is always offered, regardless of what the message match regex looks like —
|
||||
// unlike 'json_field' (which only makes sense once the sample line looks like a JSON object), this
|
||||
// param's value is never extracted from text at all: it's elapsed time since the previous event
|
||||
// sharing the same label set (ticket 038).
|
||||
type ExtractionSource = 'regex' | 'json_field' | 'elapsed_since_last'
|
||||
|
||||
// The parenthesized suffix shown after a declared-param badge's kind, e.g. "(string, group path)" —
|
||||
// one of five possible forms depending on how the param's value is produced.
|
||||
function paramBadgeSuffix(p: ParamDeclaration): string {
|
||||
if (p.elapsed_since_last) return ', elapsed since previous'
|
||||
if (p.kind === 'boolean') return ''
|
||||
if (p.json_field_path) return `, json field ${p.json_field_path}`
|
||||
if (p.capture_group !== p.name) return `, group ${p.capture_group}`
|
||||
return ''
|
||||
}
|
||||
|
||||
/// A JSON *object* (not an array/scalar) parses cleanly and pretty-prints with braces — the same
|
||||
/// shape a JSON field path can be read from, so this is the "line looks like JSON" gate that
|
||||
@@ -39,71 +45,141 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [collapsed, setCollapsed] = useSectionCollapsed('params')
|
||||
// Name of the declaration currently loaded into the form for editing, or `null` when the form is
|
||||
// declaring a brand-new param. The name field is locked while editing — `declareParam` overwrites
|
||||
// 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.
|
||||
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 isElapsedSinceLast = source === 'elapsed_since_last'
|
||||
// The message match regex doubles as a sample line here so a pasted JSON log line unlocks the
|
||||
// "JSON field" extraction option — regex-based params are unaffected either way.
|
||||
// "JSON field" extraction option — regex-based params are unaffected either way. Not consulted at
|
||||
// all for "elapsed since previous event", which is always offered.
|
||||
const detectedJson = useMemo(() => looksLikeJsonObject(regex), [regex])
|
||||
const effectiveSource: ExtractionSource = detectedJson && !isBoolean ? source : 'regex'
|
||||
const effectiveSource: ExtractionSource = isElapsedSinceLast
|
||||
? 'elapsed_since_last'
|
||||
: detectedJson && !isBoolean
|
||||
? source
|
||||
: 'regex'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// A boolean-kind param never uses a capture group — its value is just whether the regex matched.
|
||||
await declareParam({
|
||||
name,
|
||||
message_match_regex: regex,
|
||||
capture_group: isBoolean || effectiveSource === 'json_field' ? '' : captureGroup,
|
||||
json_field_path: effectiveSource === 'json_field' ? jsonFieldPath : null,
|
||||
kind,
|
||||
})
|
||||
function handleSourceChange(next: ExtractionSource) {
|
||||
setSource(next)
|
||||
// "Elapsed since previous event" always produces a Duration value — never text-extracted — so
|
||||
// there's no separate kind to pick; force it rather than leaving a stale/mismatched kind selected.
|
||||
if (next === 'elapsed_since_last') setKind('duration')
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingName(null)
|
||||
setName('')
|
||||
setRegex('')
|
||||
setCaptureGroup('')
|
||||
setJsonFieldPath('')
|
||||
setSource('regex')
|
||||
setKind(EMPTY_KIND)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
// Loads `param`'s current values into the declaration form for editing — a click handler for a
|
||||
// declared-param badge.
|
||||
function editParam(param: ParamDeclaration) {
|
||||
setEditingName(param.name)
|
||||
setName(param.name)
|
||||
setRegex(param.message_match_regex)
|
||||
setCaptureGroup(param.capture_group)
|
||||
setJsonFieldPath(param.json_field_path ?? '')
|
||||
setSource(param.elapsed_since_last ? 'elapsed_since_last' : param.json_field_path ? 'json_field' : 'regex')
|
||||
setKind(param.kind)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// A boolean-kind param never uses a capture group — its value is just whether the regex
|
||||
// matched. An elapsed-since-last param never uses a capture group, JSON field path, or even
|
||||
// its own message match regex — its value is computed, not extracted from text at all.
|
||||
await declareParam({
|
||||
name,
|
||||
message_match_regex: effectiveSource === 'elapsed_since_last' ? '' : regex,
|
||||
capture_group: isBoolean || effectiveSource !== 'regex' ? '' : captureGroup,
|
||||
json_field_path: effectiveSource === 'json_field' ? jsonFieldPath : null,
|
||||
kind: effectiveSource === 'elapsed_since_last' ? 'duration' : kind,
|
||||
elapsed_since_last: effectiveSource === 'elapsed_since_last',
|
||||
})
|
||||
resetForm()
|
||||
onDeclared()
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof ApiError ? err.message : 'Failed to declare param')
|
||||
setSubmitError(err instanceof ApiError ? err.message : `Failed to ${editingName ? 'update' : 'declare'} param`)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="border-b border-border text-sm">
|
||||
<div
|
||||
className={sectionHeaderInteractiveClassName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
|
||||
>
|
||||
<div className={sectionHeaderLeftClassName}>
|
||||
Params
|
||||
<HelpIcon sectionId="parameters" label="Help: Params" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CollapseToggle collapsed={collapsed} />
|
||||
<PopOutButton section="params" />
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="Params"
|
||||
helpSectionId="parameters"
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
popOut={<PopOutButton section="params" />}
|
||||
/>
|
||||
<div className="flex gap-4 p-3" hidden={collapsed}>
|
||||
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-subtle">Name</label>
|
||||
<label className="mb-1 block text-xs text-text-subtle">
|
||||
Name{editingName && ' (locked while editing)'}
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none disabled:opacity-50"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="path"
|
||||
required
|
||||
disabled={editingName !== null}
|
||||
/>
|
||||
</div>
|
||||
{!isElapsedSinceLast && (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs text-text-subtle">
|
||||
Message match regex
|
||||
@@ -117,20 +193,25 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{detectedJson && !isBoolean && (
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-subtle">Extraction</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={source}
|
||||
onChange={(e) => setSource(e.target.value as ExtractionSource)}
|
||||
onChange={(e) => handleSourceChange(e.target.value as ExtractionSource)}
|
||||
>
|
||||
<option value="regex">Regex capture group</option>
|
||||
<option value="json_field">JSON field</option>
|
||||
{detectedJson && !isBoolean && <option value="json_field">JSON field</option>}
|
||||
<option value="elapsed_since_last">Elapsed since previous event</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{effectiveSource === 'json_field' ? (
|
||||
{isElapsedSinceLast ? (
|
||||
<p className="text-xs text-text-faint">
|
||||
Computed automatically — elapsed time since the previous event that shares the same
|
||||
labels wherever this param is used, not parsed from the log line.
|
||||
</p>
|
||||
) : effectiveSource === 'json_field' ? (
|
||||
<div className="flex items-end gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-1 block text-xs text-text-subtle">JSON field path</label>
|
||||
@@ -181,34 +262,78 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{editingName && (
|
||||
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
||||
Editing "{editingName}"
|
||||
</div>
|
||||
)}
|
||||
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Declaring…' : 'Declare param'}
|
||||
</button>
|
||||
<FormFooter
|
||||
editing={editingName !== null}
|
||||
submitting={submitting}
|
||||
idleLabel="Declare param"
|
||||
editingLabel="Save changes"
|
||||
submittingLabel="Declaring…"
|
||||
editingSubmittingLabel="Saving…"
|
||||
onCancel={resetForm}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<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">
|
||||
{params.length === 0 && <span className="text-xs text-text-faint">No params declared yet.</span>}
|
||||
{params.map((p) => (
|
||||
<span
|
||||
{declaredParams.length === 0 && (
|
||||
<span className="text-xs text-text-faint">No params declared yet.</span>
|
||||
)}
|
||||
{declaredParams.map((p) => (
|
||||
<button
|
||||
key={p.name}
|
||||
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary"
|
||||
title={p.message_match_regex}
|
||||
type="button"
|
||||
onClick={() => editParam(p)}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
editingName === p.name
|
||||
? 'border-accent bg-accent/10 text-accent-text'
|
||||
: 'border-border-soft text-text-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
title={p.elapsed_since_last ? undefined : p.message_match_regex}
|
||||
>
|
||||
{p.name} ({p.kind}
|
||||
{p.kind === 'boolean'
|
||||
? ''
|
||||
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
|
||||
{paramBadgeSuffix(p)}
|
||||
)
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useClickOutside } from '../lib/useClickOutside'
|
||||
|
||||
// Regex syntax cheat sheet, scoped to what the Rust `regex` crate actually supports (used server-side
|
||||
// for message-match/field-match regexes and the filter bar's `regex(...)`) — no PCRE-only lookaround
|
||||
@@ -30,14 +31,7 @@ export function RegexHelpIcon({ label }: { label?: string }) {
|
||||
const open = pinned || hovering
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pinned) return
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setPinned(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [pinned])
|
||||
useClickOutside(ref, () => setPinned(false), pinned)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
+13
-194
@@ -12,28 +12,16 @@
|
||||
* danger banners, accent highlights) is a plain CSS custom property here,
|
||||
* bridged into real Tailwind utilities (bg-surface-0, text-text-muted,
|
||||
* border-accent, ...) via `@theme inline`. Components use those utilities
|
||||
* instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...),
|
||||
* so re-declaring the `--c-*` variables under a `html.<theme>` selector
|
||||
* re-skins the whole app with no per-component changes.
|
||||
* instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...).
|
||||
*
|
||||
* Derivation: `dark` uses one true black-based neutral (S=0) scale, `light`
|
||||
* is its exact lightness mirror — these two stay grey throughout. (There
|
||||
* used to be a separate `grey` theme sharing `dark`'s neutral scale with
|
||||
* only its accent trio differing; it was a near-duplicate of `dark` and was
|
||||
* removed in favour of a single `dark` theme — ticket 004. Settings saved
|
||||
* with `color_scheme: "grey"` still load fine: the backend aliases it to
|
||||
* `dark`, see `ColorScheme` in `src/settings.rs`.)
|
||||
* Each named theme (red/green/blue/yellow/pink/orange) instead tints its
|
||||
* whole surface/border/text scale in its own hue at low saturation (so the
|
||||
* chrome itself reads as "a shade of red", not grey-with-a-red-light), and
|
||||
* pulls its accent trio from two *other* hues on the wheel: --c-accent and
|
||||
* --c-accent-text share the base hue's complement (base+180°), while
|
||||
* --c-accent-soft sits at a split-complement (~base+150°/-150°) — a
|
||||
* "composite" accent distinct from the straight complement. To add a new
|
||||
* themed variant: pick a base hue H, a complement C = H+180, and a
|
||||
* composite K = H+150 (mod 360), then fill --c-surface-, --c-border-,
|
||||
* --c-text- as hsl(H, ...) at the saturation/lightness steps below, accent
|
||||
* + accent-text as hsl(C, ...), and accent-soft as hsl(K, ...).
|
||||
* The actual theme files live in `themes/` at the repo root (embedded into
|
||||
* the binary at build time — see `THEMES` in `src/api/mod.rs`), one CSS file
|
||||
* per theme, each free to redeclare `--c-*` under `:root` and/or add any
|
||||
* other rules (spacing, fonts, layout, ...) it wants. The `:root` block below
|
||||
* is only the pre-theme-load fallback (identical to `themes/dark.css`) so
|
||||
* there's no flash of unstyled content before the selected theme's
|
||||
* stylesheet is injected — see `useSettingsPanel.ts`. To add a new theme,
|
||||
* drop another file in `themes/` and rebuild; no code changes needed.
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
@@ -62,179 +50,8 @@
|
||||
--c-accent: #0ea5e9;
|
||||
--c-accent-soft: #0369a1;
|
||||
--c-accent-text: #7dd3fc;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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%);
|
||||
}
|
||||
|
||||
/* 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%);
|
||||
}
|
||||
|
||||
/* 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%);
|
||||
}
|
||||
|
||||
/* 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%);
|
||||
}
|
||||
|
||||
/* 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%);
|
||||
}
|
||||
|
||||
/* 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: #a78bfa;
|
||||
--c-accent-3: #4ade80;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -262,6 +79,8 @@ html.orange {
|
||||
--color-accent: var(--c-accent);
|
||||
--color-accent-soft: var(--c-accent-soft);
|
||||
--color-accent-text: var(--c-accent-text);
|
||||
--color-accent-2: var(--c-accent-2);
|
||||
--color-accent-3: var(--c-accent-3);
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
// Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds).
|
||||
export type MetricPrimitive =
|
||||
| { String: string }
|
||||
| { Number: { Int: number } | { Float: number } }
|
||||
| { Timestamp: number }
|
||||
| { Duration: number }
|
||||
| { Boolean: boolean }
|
||||
|
||||
export function metricPrimitiveToDisplayString(value: MetricPrimitive): string {
|
||||
if ('String' in value) return value.String
|
||||
if ('Timestamp' in value) return String(value.Timestamp)
|
||||
if ('Duration' in value) return `${value.Duration}ms`
|
||||
if ('Boolean' in value) return String(value.Boolean)
|
||||
if ('Int' in value.Number) return String(value.Number.Int)
|
||||
return String(value.Number.Float)
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
sequence: number
|
||||
timestamp_ms: number
|
||||
text: string
|
||||
// A param with no match on this line is simply absent from the map.
|
||||
params: Record<string, MetricPrimitive>
|
||||
}
|
||||
|
||||
export interface ParamDeclaration {
|
||||
name: string
|
||||
message_match_regex: string
|
||||
capture_group: string
|
||||
/** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */
|
||||
json_field_path?: string | null
|
||||
kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean'
|
||||
}
|
||||
|
||||
export interface TimelineBucket {
|
||||
start_ms: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
log_count: number
|
||||
first_sequence: number | null
|
||||
last_sequence: number | null
|
||||
first_timestamp_ms: number | null
|
||||
last_timestamp_ms: number | null
|
||||
/** Ticket 011: running approximate byte size of all currently-ingested logs. */
|
||||
approx_bytes_total: number
|
||||
}
|
||||
|
||||
export type ParamRole = 'label' | 'value'
|
||||
|
||||
export interface MetricParamMatcher {
|
||||
param_name: string
|
||||
capture_group: string
|
||||
/** See `ParamDeclaration.json_field_path`. */
|
||||
json_field_path?: string | null
|
||||
kind: 'string' | 'number' | 'timestamp' | 'duration'
|
||||
/** Defaults to 'label' server-side when omitted. */
|
||||
role?: ParamRole
|
||||
}
|
||||
|
||||
export interface MetricDeclaration {
|
||||
name: string
|
||||
message_match_regex: string
|
||||
params: MetricParamMatcher[]
|
||||
/** Fixed labels applied on a match/non-match, on top of any captured params. */
|
||||
matched_labels?: Record<string, string> | null
|
||||
unmatched_labels?: Record<string, string> | null
|
||||
}
|
||||
|
||||
export interface BucketValue {
|
||||
labels: Record<string, MetricPrimitive>
|
||||
sequence: number
|
||||
timestamp_ms: number
|
||||
value: MetricPrimitive
|
||||
}
|
||||
|
||||
// Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend).
|
||||
export interface ApiErrorBody {
|
||||
error: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export type ColorScheme =
|
||||
| 'light'
|
||||
| 'dark'
|
||||
| 'system'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'yellow'
|
||||
| 'pink'
|
||||
| 'orange'
|
||||
|
||||
export interface Settings {
|
||||
color_scheme: ColorScheme
|
||||
active_profile: string | null
|
||||
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
||||
max_logs: number | null
|
||||
/** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */
|
||||
custom_theme_id: string | null
|
||||
/** Ntfy topic every alert rule delivers to; `null` until configured. */
|
||||
ntfy_topic: string | null
|
||||
/** Ntfy server override; `null` means `https://ntfy.sh`. */
|
||||
ntfy_server: string | null
|
||||
}
|
||||
|
||||
export type CssEntryKind = 'imported' | 'linked'
|
||||
|
||||
export interface CssEntry {
|
||||
id: string
|
||||
kind: CssEntryKind
|
||||
filename: string | null
|
||||
path_or_url: string | null
|
||||
}
|
||||
|
||||
export interface ProfileSummary {
|
||||
name: string
|
||||
param_count: number
|
||||
metric_count: number
|
||||
dashboard_count: number
|
||||
}
|
||||
|
||||
// One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp.
|
||||
export interface PromqlSeries {
|
||||
labels: Record<string, string>
|
||||
points: [number, number][]
|
||||
}
|
||||
|
||||
export type ChartType = 'bar' | 'line' | 'pie'
|
||||
|
||||
export interface Panel {
|
||||
id: string
|
||||
title: string
|
||||
promql: string
|
||||
chart_type: ChartType
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
id: string
|
||||
name: string
|
||||
panels: Panel[]
|
||||
}
|
||||
|
||||
export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
|
||||
|
||||
// Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend.
|
||||
export type AlertCondition =
|
||||
| { kind: 'immediate'; query: string }
|
||||
| { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number }
|
||||
| { kind: 'newvalue'; field_regex: string; capture_group: string }
|
||||
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
|
||||
|
||||
export interface AlertRule {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
condition: AlertCondition
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path)
|
||||
if (!res.ok) {
|
||||
let message = `${path} failed: ${res.status}`
|
||||
try {
|
||||
const body: unknown = await res.json()
|
||||
if (body && typeof body === 'object' && 'error' in body && typeof (body as { error: unknown }).error === 'string') {
|
||||
message = (body as { error: string }).error
|
||||
}
|
||||
} catch {
|
||||
// body wasn't JSON (or had no `error` field) — fall back to the generic message
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = `${path} failed: ${res.status}`
|
||||
try {
|
||||
const errorBody = (await res.json()) as Partial<ApiErrorBody>
|
||||
if (errorBody && typeof errorBody.error === 'string') {
|
||||
message = errorBody.error
|
||||
}
|
||||
} catch {
|
||||
// response body wasn't JSON; fall back to the generic message
|
||||
}
|
||||
throw new ApiError(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// Same error handling as `postJson` (throws `ApiError` with the parsed message) but for a PUT — used where updating in place, rather than creating, is the operation.
|
||||
async function putJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = `${path} failed: ${res.status}`
|
||||
try {
|
||||
const errorBody = (await res.json()) as Partial<ApiErrorBody>
|
||||
if (errorBody && typeof errorBody.error === 'string') {
|
||||
message = errorBody.error
|
||||
}
|
||||
} catch {
|
||||
// response body wasn't JSON; fall back to the generic message
|
||||
}
|
||||
throw new ApiError(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = `${path} failed: ${res.status}`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.error) message = data.error
|
||||
} catch {
|
||||
// response body wasn't JSON (or was empty) — fall back to the generic message
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function fetchLogs(params: {
|
||||
sinceSequence?: number
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
limit?: number
|
||||
filter?: string
|
||||
}): Promise<LogEntry[]> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
|
||||
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
if (params.limit !== undefined) search.set('limit', String(params.limit))
|
||||
if (params.filter) search.set('filter', params.filter)
|
||||
return getJson(`/api/logs?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchTimeline(params: {
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
bucketMs?: number
|
||||
filter?: string
|
||||
}): Promise<TimelineBucket[]> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
if (params.bucketMs !== undefined) search.set('bucket_ms', String(params.bucketMs))
|
||||
if (params.filter) search.set('filter', params.filter)
|
||||
return getJson(`/api/timeline?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchStats(): Promise<Stats> {
|
||||
return getJson('/api/stats')
|
||||
}
|
||||
|
||||
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
|
||||
return getJson('/api/params')
|
||||
}
|
||||
|
||||
export function declareParam(request: ParamDeclaration): Promise<ParamDeclaration> {
|
||||
return postJson('/api/params', request)
|
||||
}
|
||||
|
||||
export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
|
||||
return getJson('/api/metrics')
|
||||
}
|
||||
|
||||
export function declareMetric(request: MetricDeclaration): Promise<MetricDeclaration> {
|
||||
return postJson('/api/metrics', request)
|
||||
}
|
||||
|
||||
// Updates the metric declaration named `existingName` in place (`existingName` may differ from `request.name` to rename it), rather than declaring a second metric or failing on a duplicate-name conflict.
|
||||
export function updateMetric(existingName: string, request: MetricDeclaration): Promise<MetricDeclaration> {
|
||||
return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request)
|
||||
}
|
||||
|
||||
export function fetchMetricBucketValues(
|
||||
name: string,
|
||||
params: {
|
||||
labels?: string
|
||||
sinceSequence?: number
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
limit?: number
|
||||
} = {},
|
||||
): Promise<BucketValue[]> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.labels !== undefined) search.set('labels', params.labels)
|
||||
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
|
||||
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
if (params.limit !== undefined) search.set('limit', String(params.limit))
|
||||
return getJson(`/api/metrics/${encodeURIComponent(name)}?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchSettings(): Promise<Settings> {
|
||||
return getJson('/api/settings')
|
||||
}
|
||||
|
||||
export function updateSettings(update: {
|
||||
colorScheme?: ColorScheme
|
||||
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
|
||||
maxLogs?: number | null
|
||||
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
|
||||
customThemeId?: string | null
|
||||
/** Omit to leave unchanged; `null` clears the configured topic. */
|
||||
ntfyTopic?: string | null
|
||||
/** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */
|
||||
ntfyServer?: string | null
|
||||
}): Promise<Settings> {
|
||||
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
|
||||
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
||||
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
|
||||
if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic
|
||||
if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer
|
||||
return sendJson('/api/settings', 'PATCH', body)
|
||||
}
|
||||
|
||||
export function fetchCssEntries(): Promise<CssEntry[]> {
|
||||
return getJson('/api/settings/css')
|
||||
}
|
||||
|
||||
export function importCss(filename: string, content: string): Promise<CssEntry> {
|
||||
return sendJson('/api/settings/css', 'POST', { filename, content })
|
||||
}
|
||||
|
||||
export function linkCss(pathOrUrl: string): Promise<CssEntry> {
|
||||
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl })
|
||||
}
|
||||
|
||||
export function deleteCssEntry(id: string): Promise<void> {
|
||||
return sendJson(`/api/settings/css/${encodeURIComponent(id)}`, 'DELETE')
|
||||
}
|
||||
|
||||
export function cssContentUrl(id: string): string {
|
||||
return `/api/settings/css/${encodeURIComponent(id)}/content`
|
||||
}
|
||||
|
||||
export function fetchProfiles(): Promise<ProfileSummary[]> {
|
||||
return getJson('/api/settings/profiles')
|
||||
}
|
||||
|
||||
export function createProfile(name: string): Promise<ProfileSummary> {
|
||||
return sendJson('/api/settings/profiles', 'POST', { name })
|
||||
}
|
||||
|
||||
export function deleteProfile(name: string): Promise<void> {
|
||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
|
||||
}
|
||||
|
||||
export function activateProfile(name: string): Promise<Settings> {
|
||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
|
||||
}
|
||||
|
||||
// Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page.
|
||||
export function fetchPromqlSeries(params: {
|
||||
promql: string
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
}): Promise<PromqlSeries[]> {
|
||||
const search = new URLSearchParams()
|
||||
search.set('promql', params.promql)
|
||||
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
return getJson(`/api/query?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchDashboards(profileName: string): Promise<Dashboard[]> {
|
||||
return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`)
|
||||
}
|
||||
|
||||
export function createDashboard(profileName: string, name: string): Promise<Dashboard> {
|
||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name })
|
||||
}
|
||||
|
||||
export function saveDashboard(
|
||||
profileName: string,
|
||||
dashboardId: string,
|
||||
update: { name: string; panels: Panel[] },
|
||||
): Promise<Dashboard> {
|
||||
return sendJson(
|
||||
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
|
||||
'PUT',
|
||||
update,
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteDashboard(profileName: string, dashboardId: string): Promise<void> {
|
||||
return sendJson(
|
||||
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
|
||||
'DELETE',
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAlertRules(): Promise<AlertRule[]> {
|
||||
return getJson('/api/settings/alerts')
|
||||
}
|
||||
|
||||
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise<AlertRule> {
|
||||
return postJson('/api/settings/alerts', rule)
|
||||
}
|
||||
|
||||
export function updateAlertRule(
|
||||
id: string,
|
||||
rule: { name: string; enabled: boolean; condition: AlertCondition },
|
||||
): Promise<AlertRule> {
|
||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
|
||||
}
|
||||
|
||||
export function deleteAlertRule(id: string): Promise<void> {
|
||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE')
|
||||
}
|
||||
|
||||
// Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline.
|
||||
export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> {
|
||||
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getJson, postJson, sendJson } from './shared'
|
||||
|
||||
export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
|
||||
|
||||
// Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend.
|
||||
export type AlertCondition =
|
||||
| { kind: 'immediate'; query: string }
|
||||
| { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number }
|
||||
| { kind: 'newvalue'; field_regex: string; capture_group: string }
|
||||
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
|
||||
|
||||
export interface AlertRule {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
condition: AlertCondition
|
||||
}
|
||||
|
||||
export function fetchAlertRules(): Promise<AlertRule[]> {
|
||||
return getJson('/api/settings/alerts')
|
||||
}
|
||||
|
||||
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise<AlertRule> {
|
||||
return postJson('/api/settings/alerts', rule)
|
||||
}
|
||||
|
||||
export function updateAlertRule(
|
||||
id: string,
|
||||
rule: { name: string; enabled: boolean; condition: AlertCondition },
|
||||
): Promise<AlertRule> {
|
||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
|
||||
}
|
||||
|
||||
export function deleteAlertRule(id: string): Promise<void> {
|
||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE')
|
||||
}
|
||||
|
||||
// Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline.
|
||||
export function sendTestAlertNotification(topic: string, server: string | null): Promise<void> {
|
||||
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getJson, sendJson } from './shared'
|
||||
|
||||
// One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp.
|
||||
export interface PromqlSeries {
|
||||
labels: Record<string, string>
|
||||
points: [number, number][]
|
||||
}
|
||||
|
||||
export type ChartType = 'bar' | 'line' | 'pie'
|
||||
|
||||
export interface Panel {
|
||||
id: string
|
||||
title: string
|
||||
promql: string
|
||||
chart_type: ChartType
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
id: string
|
||||
name: string
|
||||
panels: Panel[]
|
||||
}
|
||||
|
||||
// Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page.
|
||||
export function fetchPromqlSeries(params: {
|
||||
promql: string
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
}): Promise<PromqlSeries[]> {
|
||||
const search = new URLSearchParams()
|
||||
search.set('promql', params.promql)
|
||||
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
return getJson(`/api/query?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchDashboards(profileName: string): Promise<Dashboard[]> {
|
||||
return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`)
|
||||
}
|
||||
|
||||
export function createDashboard(profileName: string, name: string): Promise<Dashboard> {
|
||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name })
|
||||
}
|
||||
|
||||
export function saveDashboard(
|
||||
profileName: string,
|
||||
dashboardId: string,
|
||||
update: { name: string; panels: Panel[] },
|
||||
): Promise<Dashboard> {
|
||||
return sendJson(
|
||||
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
|
||||
'PUT',
|
||||
update,
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteDashboard(profileName: string, dashboardId: string): Promise<void> {
|
||||
return sendJson(
|
||||
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
|
||||
'DELETE',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './shared'
|
||||
export * from './logs'
|
||||
export * from './params'
|
||||
export * from './metrics'
|
||||
export * from './dashboards'
|
||||
export * from './alerts'
|
||||
export * from './settings'
|
||||
@@ -0,0 +1,85 @@
|
||||
import { buildSearchParams, getJson } from './shared'
|
||||
|
||||
// Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds).
|
||||
export type MetricPrimitive =
|
||||
| { String: string }
|
||||
| { Number: { Int: number } | { Float: number } }
|
||||
| { Timestamp: number }
|
||||
| { Duration: number }
|
||||
| { Boolean: boolean }
|
||||
|
||||
export function metricPrimitiveToDisplayString(value: MetricPrimitive): string {
|
||||
if ('String' in value) return value.String
|
||||
if ('Timestamp' in value) return String(value.Timestamp)
|
||||
if ('Duration' in value) return `${value.Duration}ms`
|
||||
if ('Boolean' in value) return String(value.Boolean)
|
||||
if ('Int' in value.Number) return String(value.Number.Int)
|
||||
return String(value.Number.Float)
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
sequence: number
|
||||
timestamp_ms: number
|
||||
text: string
|
||||
// A param with no match on this line is simply absent from the map.
|
||||
params: Record<string, MetricPrimitive>
|
||||
}
|
||||
|
||||
export interface TimelineBucket {
|
||||
start_ms: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
log_count: number
|
||||
first_sequence: number | null
|
||||
last_sequence: number | null
|
||||
first_timestamp_ms: number | null
|
||||
last_timestamp_ms: number | null
|
||||
/** Ticket 011: running approximate byte size of all currently-ingested logs. */
|
||||
approx_bytes_total: number
|
||||
/** Ticket 073: highest ingestion-to-engine-processing lag (ms) seen within the trailing 5s. */
|
||||
ingest_lag_high_watermark_ms: number
|
||||
}
|
||||
|
||||
export function fetchLogs(params: {
|
||||
sinceSequence?: number
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
limit?: number
|
||||
filter?: string
|
||||
/** Ticket 045: an already-declared param name to order entries by, instead of the default (`ottyl_ts`-tracking) order. */
|
||||
sortBy?: string
|
||||
/** Ticket 045: only meaningful alongside `sortBy`. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them. */
|
||||
sortMissingFallback?: boolean
|
||||
}): Promise<LogEntry[]> {
|
||||
const search = buildSearchParams({
|
||||
since_sequence: params.sinceSequence,
|
||||
start_ms: params.startMs,
|
||||
end_ms: params.endMs,
|
||||
limit: params.limit,
|
||||
filter: params.filter,
|
||||
sort_by: params.sortBy,
|
||||
sort_missing_fallback: params.sortMissingFallback,
|
||||
})
|
||||
return getJson(`/api/logs?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchTimeline(params: {
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
bucketMs?: number
|
||||
filter?: string
|
||||
}): Promise<TimelineBucket[]> {
|
||||
const search = buildSearchParams({
|
||||
start_ms: params.startMs,
|
||||
end_ms: params.endMs,
|
||||
bucket_ms: params.bucketMs,
|
||||
filter: params.filter,
|
||||
})
|
||||
return getJson(`/api/timeline?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchStats(): Promise<Stats> {
|
||||
return getJson('/api/stats')
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getJson, postJson, putJson, sendJson } from './shared'
|
||||
import type { MetricPrimitive } from './logs'
|
||||
|
||||
export type MetricSemantics = 'gauge' | 'counter'
|
||||
|
||||
export interface MetricDeclaration {
|
||||
name: string
|
||||
message_match_regex: string
|
||||
/** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */
|
||||
label_params: string[]
|
||||
/** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */
|
||||
value_param?: string | null
|
||||
/** Fixed labels applied on a match/non-match, on top of any captured params. */
|
||||
matched_labels?: Record<string, string> | null
|
||||
unmatched_labels?: Record<string, string> | null
|
||||
/** Point-in-time reading ('gauge', e.g. queue depth) vs. monotonically-increasing total ('counter', e.g. requests served); guards which PromQL aggregations are valid. Defaults to 'gauge'. */
|
||||
semantics?: MetricSemantics
|
||||
}
|
||||
|
||||
export interface BucketValue {
|
||||
labels: Record<string, MetricPrimitive>
|
||||
sequence: number
|
||||
timestamp_ms: number
|
||||
value: MetricPrimitive
|
||||
}
|
||||
|
||||
export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
|
||||
return getJson('/api/metrics')
|
||||
}
|
||||
|
||||
export function declareMetric(request: MetricDeclaration): Promise<MetricDeclaration> {
|
||||
return postJson('/api/metrics', request)
|
||||
}
|
||||
|
||||
// Updates the metric declaration named `existingName` in place (`existingName` may differ from `request.name` to rename it), rather than declaring a second metric or failing on a duplicate-name conflict.
|
||||
export function updateMetric(existingName: string, request: MetricDeclaration): Promise<MetricDeclaration> {
|
||||
return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request)
|
||||
}
|
||||
|
||||
export function deleteMetric(name: string): Promise<void> {
|
||||
return sendJson(`/api/metrics/${encodeURIComponent(name)}`, 'DELETE')
|
||||
}
|
||||
|
||||
export function fetchMetricBucketValues(
|
||||
name: string,
|
||||
params: {
|
||||
labels?: string
|
||||
sinceSequence?: number
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
limit?: number
|
||||
} = {},
|
||||
): Promise<BucketValue[]> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.labels !== undefined) search.set('labels', params.labels)
|
||||
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
|
||||
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
if (params.limit !== undefined) search.set('limit', String(params.limit))
|
||||
return getJson(`/api/metrics/${encodeURIComponent(name)}?${search.toString()}`)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getJson, postJson, sendJson } from './shared'
|
||||
|
||||
export interface ParamDeclaration {
|
||||
name: string
|
||||
message_match_regex: string
|
||||
capture_group: string
|
||||
/** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */
|
||||
json_field_path?: string | null
|
||||
kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean'
|
||||
/** A third extraction source, distinct from `capture_group`/`json_field_path`: when true, this param's value is never parsed from text — it's computed as elapsed time since the previous event sharing the same label set. Only meaningful paired with `kind: 'duration'`. */
|
||||
elapsed_since_last?: boolean
|
||||
/** Set only for the built-in `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` names, synthesized into `GET /api/params` for discoverability — they're usable in the filter bar (and `ottyl_seq`/`ottyl_ts` in dashboard PromQL queries) without ever being declared, and can't be edited or removed. */
|
||||
reserved?: boolean
|
||||
}
|
||||
|
||||
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
|
||||
return getJson('/api/params')
|
||||
}
|
||||
|
||||
export function declareParam(request: ParamDeclaration): Promise<ParamDeclaration> {
|
||||
return postJson('/api/params', request)
|
||||
}
|
||||
|
||||
export interface ClearParamsResult {
|
||||
/** How many param declarations (manual + auto-derived) were removed. */
|
||||
removed: number
|
||||
/** How many params were immediately re-derived from log text still retained by the engine. */
|
||||
reextracted: number
|
||||
/** How many retained log lines were re-scanned — lines already evicted by `max_logs` before the clear can't regain their params, so this can be smaller than the total lines ever ingested. */
|
||||
logs_considered: number
|
||||
}
|
||||
|
||||
// Clears every declared param (ticket 052) and re-derives whatever the engine's still-retained log
|
||||
// text supports — see `ClearParamsResult`'s fields for what came back.
|
||||
export function clearParams(): Promise<ClearParamsResult> {
|
||||
return sendJson('/api/params', 'DELETE')
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getJson, sendJson } from './shared'
|
||||
|
||||
/** Name of a theme embedded in this build (see `GET /api/themes`), or `"system"` to follow the OS light/dark preference. */
|
||||
export type Theme = string
|
||||
|
||||
export interface Settings {
|
||||
theme: Theme
|
||||
active_profile: string | null
|
||||
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
||||
max_logs: number | null
|
||||
/** Ntfy topic every alert rule delivers to; `null` until configured. */
|
||||
ntfy_topic: string | null
|
||||
/** Ntfy server override; `null` means `https://ntfy.sh`. */
|
||||
ntfy_server: string | null
|
||||
}
|
||||
|
||||
export interface ProfileSummary {
|
||||
name: string
|
||||
param_count: number
|
||||
metric_count: number
|
||||
dashboard_count: number
|
||||
}
|
||||
|
||||
export function fetchSettings(): Promise<Settings> {
|
||||
return getJson('/api/settings')
|
||||
}
|
||||
|
||||
export function updateSettings(update: {
|
||||
theme?: Theme
|
||||
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
|
||||
maxLogs?: number | null
|
||||
/** Omit to leave unchanged; `null` clears the configured topic. */
|
||||
ntfyTopic?: string | null
|
||||
/** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */
|
||||
ntfyServer?: string | null
|
||||
}): Promise<Settings> {
|
||||
const body: Record<string, unknown> = { theme: update.theme }
|
||||
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
||||
if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic
|
||||
if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer
|
||||
return sendJson('/api/settings', 'PATCH', body)
|
||||
}
|
||||
|
||||
/** Names of the themes embedded in this build (see `themes/` at the repo root). */
|
||||
export function fetchThemes(): Promise<string[]> {
|
||||
return getJson('/api/themes')
|
||||
}
|
||||
|
||||
export function themeCssUrl(name: string): string {
|
||||
return `/api/themes/${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
export function fetchProfiles(): Promise<ProfileSummary[]> {
|
||||
return getJson('/api/settings/profiles')
|
||||
}
|
||||
|
||||
export function createProfile(name: string): Promise<ProfileSummary> {
|
||||
return sendJson('/api/settings/profiles', 'POST', { name })
|
||||
}
|
||||
|
||||
export function deleteProfile(name: string): Promise<void> {
|
||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
|
||||
}
|
||||
|
||||
export function activateProfile(name: string): Promise<Settings> {
|
||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend).
|
||||
export interface ApiErrorBody {
|
||||
error: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a `URLSearchParams` from an object of optional query fields, skipping any that are
|
||||
// `undefined` (falsy strings like `''` are also skipped, matching the hand-written `if` checks
|
||||
// this replaces).
|
||||
export function buildSearchParams(fields: Record<string, string | number | boolean | undefined>): URLSearchParams {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value !== undefined && value !== '') search.set(key, String(value))
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
export async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path)
|
||||
if (!res.ok) {
|
||||
let message = `${path} failed: ${res.status}`
|
||||
try {
|
||||
const body: unknown = await res.json()
|
||||
if (body && typeof body === 'object' && 'error' in body && typeof (body as { error: unknown }).error === 'string') {
|
||||
message = (body as { error: string }).error
|
||||
}
|
||||
} catch {
|
||||
// body wasn't JSON (or had no `error` field) — fall back to the generic message
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
|
||||
export async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = `${path} failed: ${res.status}`
|
||||
try {
|
||||
const errorBody = (await res.json()) as Partial<ApiErrorBody>
|
||||
if (errorBody && typeof errorBody.error === 'string') {
|
||||
message = errorBody.error
|
||||
}
|
||||
} catch {
|
||||
// response body wasn't JSON; fall back to the generic message
|
||||
}
|
||||
throw new ApiError(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// Same error handling as `postJson` (throws `ApiError` with the parsed message) but for a PUT — used where updating in place, rather than creating, is the operation.
|
||||
export async function putJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = `${path} failed: ${res.status}`
|
||||
try {
|
||||
const errorBody = (await res.json()) as Partial<ApiErrorBody>
|
||||
if (errorBody && typeof errorBody.error === 'string') {
|
||||
message = errorBody.error
|
||||
}
|
||||
} catch {
|
||||
// response body wasn't JSON; fall back to the generic message
|
||||
}
|
||||
throw new ApiError(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = `${path} failed: ${res.status}`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.error) message = data.error
|
||||
} catch {
|
||||
// response body wasn't JSON (or was empty) — fall back to the generic message
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user