detectkit 0.48.0__tar.gz → 0.49.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {detectkit-0.48.0 → detectkit-0.49.0}/MANIFEST.in +1 -0
- {detectkit-0.48.0/detectkit.egg-info → detectkit-0.49.0}/PKG-INFO +1 -1
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/__init__.py +1 -1
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/cli.md +39 -0
- detectkit-0.49.0/detectkit/cli/commands/ui.py +97 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/main.py +54 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_base.py +11 -2
- detectkit-0.49.0/detectkit/reporting/assets/report.js +80 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/reporting/builder.py +63 -28
- detectkit-0.49.0/detectkit/ui/__init__.py +25 -0
- detectkit-0.49.0/detectkit/ui/assets/ui.js +243 -0
- detectkit-0.49.0/detectkit/ui/html.py +84 -0
- detectkit-0.49.0/detectkit/ui/jobs.py +334 -0
- detectkit-0.49.0/detectkit/ui/overview.py +475 -0
- detectkit-0.49.0/detectkit/ui/server.py +614 -0
- {detectkit-0.48.0 → detectkit-0.49.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit.egg-info/SOURCES.txt +7 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/pyproject.toml +2 -0
- detectkit-0.48.0/detectkit/reporting/assets/report.js +0 -80
- {detectkit-0.48.0 → detectkit-0.49.0}/LICENSE +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/README.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/runner.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/osi.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/tune.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/core/models.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/semantic/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/semantic/errors.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/semantic/exporter.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/semantic/importer.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/semantic/osi_model.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/semantic/query_gen.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/tuning/assets/tune.js +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/tuning/payload.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/tuning/server.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/requirements.txt +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/setup.cfg +0 -0
- {detectkit-0.48.0 → detectkit-0.49.0}/setup.py +0 -0
|
@@ -5,6 +5,7 @@ recursive-include detectkit *.py
|
|
|
5
5
|
recursive-include detectkit/cli/assets *.md
|
|
6
6
|
recursive-include detectkit/reporting/assets *.js
|
|
7
7
|
recursive-include detectkit/tuning/assets *.js
|
|
8
|
+
recursive-include detectkit/ui/assets *.js
|
|
8
9
|
recursive-exclude tests *
|
|
9
10
|
recursive-exclude * __pycache__
|
|
10
11
|
recursive-exclude * *.pyc
|
|
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
|
|
|
4
4
|
A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.49.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -40,6 +40,7 @@ version — **read the relevant one on demand** instead of guessing:
|
|
|
40
40
|
| Alert rules (quorum/direction/consecutive), cooldown, recovery, templates | `.claude/rules/detectkit/alerting.md` |
|
|
41
41
|
| Auto-tuning a detector (`dtk autotune`), labels file, the `autotune:` block, `_dtk_autotune_runs` | `.claude/rules/detectkit/autotune.md` |
|
|
42
42
|
| Manually/interactively tuning a detector in the browser and writing it back in place (`dtk tune`) | `.claude/rules/detectkit/cli.md` |
|
|
43
|
+
| A project-wide live overview + driving the pipeline from the browser (`dtk ui`) | `.claude/rules/detectkit/cli.md` |
|
|
43
44
|
| OSI (Open Semantic Interchange) interop: import an OSI metric into a native metric / export back (`dtk osi`) | `.claude/rules/detectkit/cli.md` |
|
|
44
45
|
|
|
45
46
|
### Skills
|
|
@@ -12,6 +12,7 @@ Run all commands from a project directory (the one containing
|
|
|
12
12
|
| `dtk run --select <sel>` | Run the load → detect → alert pipeline |
|
|
13
13
|
| `dtk autotune --select <sel>` | Auto-configure a metric's detector (see `autotune.md`) |
|
|
14
14
|
| `dtk tune --select <sel>` | Interactively tune a detector on real data, write it back in place |
|
|
15
|
+
| `dtk ui` | Project-wide live overview + browser pipeline control panel |
|
|
15
16
|
| `dtk test-alert <metric>` | Send a mock alert to the metric's channels |
|
|
16
17
|
| `dtk unlock --select <sel>` | Clear a stuck pipeline lock |
|
|
17
18
|
| `dtk clean --select <sel>` | Prune internal data that no longer matches the config |
|
|
@@ -171,6 +172,44 @@ re-run `dtk run` afterward to recompute detections under the new config.
|
|
|
171
172
|
**Save incidents** downloads the labels file); `--from` / `--to` bound the window;
|
|
172
173
|
`--no-open` prints the URL without opening a browser.
|
|
173
174
|
|
|
175
|
+
## `dtk ui`
|
|
176
|
+
|
|
177
|
+
A **project-wide** localhost cockpit over every selected metric's alerting
|
|
178
|
+
behavior — the live counterpart to a one-off `--report` file. Opens an
|
|
179
|
+
**overview** table (grouped by `metrics/` subfolder, filterable by tag) with,
|
|
180
|
+
per metric: alerts in the selected window + per-day rate + last alert,
|
|
181
|
+
no-data events, anomaly rate, data freshness, and a sparkline — all
|
|
182
|
+
**replayed** from stored detections through the same seam `dtk run --report`
|
|
183
|
+
uses, so the counts match what the pipeline would actually have alerted.
|
|
184
|
+
When `incidents/<metric>/` labels exist (from `dtk tune`'s Label/Review +
|
|
185
|
+
Save incidents), the row also shows recall / false-alert rate / reviewed,
|
|
186
|
+
matched the same way the `dtk tune` cockpit's metrics bar does; without
|
|
187
|
+
labels the row still shows frequency stats to eyeball.
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
dtk ui [-s/--select "*"] [--window 24h|7d|30d|90d|all] [--profile NAME] [--no-open]
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `--select` — same selector syntax as `dtk run`; scopes which metrics the
|
|
194
|
+
overview and pipeline panel cover (default `*`, everything).
|
|
195
|
+
- `--window` — the preset selected when the page opens (default `30d`);
|
|
196
|
+
switch freely in the browser afterward.
|
|
197
|
+
- `--profile` — forwarded to the DB connection **and** to every subprocess
|
|
198
|
+
the pipeline panel spawns.
|
|
199
|
+
- `--no-open` — print the URL instead of opening a browser tab.
|
|
200
|
+
|
|
201
|
+
Clicking a metric's **Open** shows the existing self-contained HTML report
|
|
202
|
+
(the same one `--report` writes) in an overlay. A **pipeline panel** drives
|
|
203
|
+
the real CLI as subprocesses — `dtk run` (select/steps/from/to/force/
|
|
204
|
+
full-refresh), `dtk autotune`, `dtk unlock` — streaming their terminal
|
|
205
|
+
output live; **only one of these three runs at a time** (they'd contend for
|
|
206
|
+
the same pipeline lock anyway). **Tune** launches `dtk tune --select
|
|
207
|
+
<metric>` for a metric and opens its cockpit in a new tab — unlike
|
|
208
|
+
run/autotune/unlock, **multiple tune jobs run concurrently**, since each is
|
|
209
|
+
its own isolated, lock-free session. The `dtk ui` server itself takes **no
|
|
210
|
+
pipeline lock** and never mutates anything — every spawned command behaves
|
|
211
|
+
exactly as if typed into a terminal.
|
|
212
|
+
|
|
174
213
|
## `dtk test-alert <metric>`
|
|
175
214
|
|
|
176
215
|
Sends a mock alert (fake value/CI/severity) through the metric's configured
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""``dtk ui`` — the project-level monitoring cockpit.
|
|
2
|
+
|
|
3
|
+
Opens a localhost web UI over the already-persisted ``_dtk_*`` tables: an
|
|
4
|
+
overview of every selected metric's alerting behavior, a per-metric detail
|
|
5
|
+
view (the existing HTML report in an iframe), and a pipeline control panel
|
|
6
|
+
that drives the real ``dtk run`` / ``dtk autotune`` / ``dtk unlock`` / ``dtk
|
|
7
|
+
tune`` commands as subprocesses. Like ``dtk tune`` it takes no pipeline lock —
|
|
8
|
+
it only reads the internal tables and spawns other ``dtk`` invocations, each
|
|
9
|
+
of which takes its own lock exactly as if run from the terminal.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from detectkit.cli.commands.run import find_project_root, select_metrics
|
|
17
|
+
from detectkit.config.profile import ProfilesConfig
|
|
18
|
+
from detectkit.config.project_config import ProjectConfig
|
|
19
|
+
from detectkit.database.internal_tables import InternalTablesManager
|
|
20
|
+
from detectkit.ui.overview import ALL_WINDOW_PRESETS
|
|
21
|
+
from detectkit.ui.server import serve_ui
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_ui(
|
|
25
|
+
*,
|
|
26
|
+
select: str = "*",
|
|
27
|
+
profile: str | None = None,
|
|
28
|
+
window: str = "30d",
|
|
29
|
+
no_open: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Bootstrap the project and serve the cockpit until Ctrl-C."""
|
|
32
|
+
if window not in ALL_WINDOW_PRESETS:
|
|
33
|
+
allowed = ", ".join(sorted(ALL_WINDOW_PRESETS))
|
|
34
|
+
click.echo(
|
|
35
|
+
click.style(
|
|
36
|
+
f"Error: invalid --window '{window}'. Choose one of: {allowed}.",
|
|
37
|
+
fg="red",
|
|
38
|
+
bold=True,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
|
|
43
|
+
project_root = find_project_root()
|
|
44
|
+
if not project_root:
|
|
45
|
+
click.echo(click.style("Error: Not in a detectkit project directory!", fg="red", bold=True))
|
|
46
|
+
click.echo("Run 'dtk init <project_name>' to create a new project.")
|
|
47
|
+
raise SystemExit(1)
|
|
48
|
+
|
|
49
|
+
click.echo(f"Project root: {project_root}")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
project_config = ProjectConfig.from_yaml_file(project_root / "detectkit_project.yml")
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
click.echo(click.style(f"Error loading detectkit_project.yml: {exc}", fg="red", bold=True))
|
|
55
|
+
raise SystemExit(1) from exc
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
metrics = select_metrics(select, project_root)
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
click.echo(click.style(f"Error: {exc}", fg="red", bold=True))
|
|
61
|
+
raise SystemExit(1) from exc
|
|
62
|
+
if not metrics:
|
|
63
|
+
click.echo(click.style(f"No metrics found matching selector: {select}", fg="yellow"))
|
|
64
|
+
raise SystemExit(1)
|
|
65
|
+
metrics = sorted(metrics, key=lambda item: str(item[0]))
|
|
66
|
+
|
|
67
|
+
profiles_path = project_root / "profiles.yml"
|
|
68
|
+
if not profiles_path.exists():
|
|
69
|
+
click.echo(click.style("Error: profiles.yml not found!", fg="red", bold=True))
|
|
70
|
+
click.echo(f"Expected at: {profiles_path}")
|
|
71
|
+
raise SystemExit(1)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
profiles_config = ProfilesConfig.from_yaml(profiles_path)
|
|
75
|
+
db_manager = profiles_config.create_manager(profile)
|
|
76
|
+
internal_manager = InternalTablesManager(db_manager)
|
|
77
|
+
internal_manager.ensure_tables()
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
click.echo(click.style(f"Error connecting to the database: {exc}", fg="red", bold=True))
|
|
80
|
+
raise SystemExit(1) from exc
|
|
81
|
+
|
|
82
|
+
click.echo(click.style(f"detectkit UI: {project_config.name}", fg="cyan", bold=True))
|
|
83
|
+
click.echo(f" {len(metrics)} metric(s) covered by --select {select} (window: {window})")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
serve_ui(
|
|
87
|
+
project_config=project_config,
|
|
88
|
+
project_root=project_root,
|
|
89
|
+
metrics=metrics,
|
|
90
|
+
internal_manager=internal_manager,
|
|
91
|
+
initial_window=window,
|
|
92
|
+
profile=profile,
|
|
93
|
+
echo=click.echo,
|
|
94
|
+
open_browser=not no_open,
|
|
95
|
+
)
|
|
96
|
+
except KeyboardInterrupt:
|
|
97
|
+
pass
|
|
@@ -307,6 +307,60 @@ def autotune(
|
|
|
307
307
|
)
|
|
308
308
|
|
|
309
309
|
|
|
310
|
+
@cli.command()
|
|
311
|
+
@click.option(
|
|
312
|
+
"--select",
|
|
313
|
+
"-s",
|
|
314
|
+
default="*",
|
|
315
|
+
show_default=True,
|
|
316
|
+
help="Selector for the metrics the UI covers (metric name, path, or tag)",
|
|
317
|
+
)
|
|
318
|
+
@click.option(
|
|
319
|
+
"--profile",
|
|
320
|
+
help="Profile to use (default: from project config)",
|
|
321
|
+
)
|
|
322
|
+
@click.option(
|
|
323
|
+
"--window",
|
|
324
|
+
default="30d",
|
|
325
|
+
show_default=True,
|
|
326
|
+
help="Initial window preset: 24h, 7d, 30d, 90d, or all",
|
|
327
|
+
)
|
|
328
|
+
@click.option(
|
|
329
|
+
"--no-open",
|
|
330
|
+
is_flag=True,
|
|
331
|
+
help="Don't auto-open the browser (just print the local URL)",
|
|
332
|
+
)
|
|
333
|
+
def ui(select: str, profile: str, window: str, no_open: bool) -> None:
|
|
334
|
+
"""
|
|
335
|
+
Open the project-level monitoring cockpit.
|
|
336
|
+
|
|
337
|
+
A localhost web UI over the already-persisted _dtk_* tables: an overview
|
|
338
|
+
of every selected metric's alerting behavior (how often alerts fire, per
|
|
339
|
+
metric / per tag / per metrics/ folder), a per-metric detail view (the
|
|
340
|
+
existing HTML report in an iframe), and a pipeline control panel that runs
|
|
341
|
+
`dtk run` / `dtk autotune` / `dtk unlock` / `dtk tune` as subprocesses —
|
|
342
|
+
exactly as if typed at a terminal. Alert counts are replayed from stored
|
|
343
|
+
detections, so the numbers match what the pipeline would actually have
|
|
344
|
+
alerted.
|
|
345
|
+
|
|
346
|
+
Takes no pipeline lock itself; spawned commands take their own lock.
|
|
347
|
+
Selector semantics match `dtk run`.
|
|
348
|
+
|
|
349
|
+
Examples:
|
|
350
|
+
# Cover every metric, starting on the last 30 days
|
|
351
|
+
dtk ui
|
|
352
|
+
|
|
353
|
+
# Only metrics tagged critical, starting on the last 7 days
|
|
354
|
+
dtk ui --select "tag:critical" --window 7d
|
|
355
|
+
|
|
356
|
+
# Don't auto-open the browser
|
|
357
|
+
dtk ui --no-open
|
|
358
|
+
"""
|
|
359
|
+
from detectkit.cli.commands.ui import run_ui
|
|
360
|
+
|
|
361
|
+
run_ui(select=select, profile=profile, window=window, no_open=no_open)
|
|
362
|
+
|
|
363
|
+
|
|
310
364
|
@cli.command()
|
|
311
365
|
@click.option(
|
|
312
366
|
"--select",
|
|
@@ -9,7 +9,7 @@ look for those helpers.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from datetime import datetime
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
13
|
|
|
14
14
|
from detectkit.database.manager import BaseDatabaseManager
|
|
15
15
|
|
|
@@ -24,12 +24,19 @@ class _InternalTablesBase:
|
|
|
24
24
|
|
|
25
25
|
@staticmethod
|
|
26
26
|
def _normalize_max_timestamp(value: datetime | None) -> datetime | None:
|
|
27
|
-
"""Treat the Unix epoch sentinel as a missing value.
|
|
27
|
+
"""Treat the Unix epoch sentinel as a missing value; return naive UTC.
|
|
28
28
|
|
|
29
29
|
ClickHouse's ``max(timestamp)`` over an empty selection returns
|
|
30
30
|
``1970-01-01 00:00:00`` instead of NULL. Without normalisation,
|
|
31
31
|
idempotency checks would think we already processed everything up
|
|
32
32
|
to 1970 and refuse to do work.
|
|
33
|
+
|
|
34
|
+
The driver may also hand back an *aware* datetime (clickhouse-driver
|
|
35
|
+
attaches the server column's timezone), while everything in-memory in
|
|
36
|
+
detectkit is naive UTC — mixing the two raises ``TypeError`` on the
|
|
37
|
+
first comparison/subtraction. Cursor reads are normalized to naive
|
|
38
|
+
UTC here, at the single seam every reader shares, instead of at each
|
|
39
|
+
call site.
|
|
33
40
|
"""
|
|
34
41
|
if value is None:
|
|
35
42
|
return None
|
|
@@ -38,4 +45,6 @@ class _InternalTablesBase:
|
|
|
38
45
|
epoch = epoch.replace(tzinfo=value.tzinfo)
|
|
39
46
|
if value == epoch:
|
|
40
47
|
return None
|
|
48
|
+
if value.tzinfo is not None:
|
|
49
|
+
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
|
41
50
|
return value
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";(()=>{var ye={"--term-bg":"#211e1a","--clay":"#d15b36","--st-anomaly":"#d63232","--st-recovery":"#36a64f","--st-nodata":"#f0ad4e","--st-error":"#5a7a8c","--faint":"#9a9384","--muted":"#6e675b","--border":"#332f29","--term-border":"#332f29"};function E(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()||ye[e]||"#888"}function we(e){let t=e.replace("#","").trim();t.length===3&&(t=t[0]+t[0]+t[1]+t[1]+t[2]+t[2]);let n=parseInt(t,16);return t.length!==6||Number.isNaN(n)?[209,91,54]:[n>>16&255,n>>8&255,n&255]}function D(e,t){let[n,i,s]=we(e);return`rgba(${n},${i},${s},${t})`}function ee(e){let t=Math.max(1,window.devicePixelRatio||1),n=e.clientWidth||e.offsetWidth||0,i=e.clientHeight||e.offsetHeight||0;return e.width=Math.round(n*t),e.height=Math.round(i*t),t}function te(e,t,n,i){let s=()=>e.width-(t.l+t.r)*i,v=()=>e.height-(t.t+t.b)*i,r=()=>n.tmax-n.tmin||1,a=()=>n.vmax-n.vmin||1;return{px:u=>t.l*i+(u-n.tmin)/r()*s(),py:u=>e.height-t.b*i-(u-n.vmin)/a()*v(),tAt:u=>n.tmin+(u-t.l*i)/(s()||1)*r(),vAt:u=>n.vmin+(e.height-t.b*i-u)/(v()||1)*a(),plotW:s,plotH:v,tspan:r}}var F=Number.isFinite;function ne(e,t,n,i,s,v,r,a,b,f,h,d){let u=t.length,A=Math.max(1,Math.round(r)),S=s-i||1,z=0;for(let g=0;g<u;g++){let m=n[g];!F(m)||t[g]<i||t[g]>s||z++}if(e.strokeStyle=f,e.lineWidth=h*d,e.lineJoin="round",e.beginPath(),z<=A){let g=!1;for(let m=0;m<u;m++){let p=n[m],$=t[m];if(!F(p)||$<i||$>s){g=!1;continue}let C=a($),P=b(p);g?e.lineTo(C,P):(e.moveTo(C,P),g=!0)}}else{let g=new Array(A).fill(null),m=new Array(A).fill(null);for(let $=0;$<u;$++){let C=n[$],P=t[$];if(!F(C)||P<i||P>s)continue;let T=Math.floor((P-i)/S*(A-1));T=T<0?0:T>A-1?A-1:T,(g[T]===null||C<g[T])&&(g[T]=C),(m[T]===null||C>m[T])&&(m[T]=C)}let p=!1;for(let $=0;$<A;$++){if(m[$]===null){p=!1;continue}let C=v+$,P=b(m[$]),T=b(g[$]);p?e.lineTo(C,P):(e.moveTo(C,P),p=!0),e.lineTo(C,T)}}e.stroke()}function re(e){let t=[],n=-1;for(let i=0;i<e.length;i++){let s=e[i];s.lo!==null&&s.hi!==null&&F(s.lo)&&F(s.hi)?n===-1&&(n=i):n!==-1&&(t.push([n,i-1]),n=-1)}return n!==-1&&t.push([n,e.length-1]),t}function oe(e,t,n,i,s,v,r,a,b){e.fillStyle=D(v,r);for(let[f,h]of n){e.beginPath(),e.moveTo(i(t[f].t),s(t[f].hi));for(let d=f+1;d<=h;d++)e.lineTo(i(t[d].t),s(t[d].hi));for(let d=h;d>=f;d--)e.lineTo(i(t[d].t),s(t[d].lo));e.closePath(),e.fill()}e.strokeStyle=D(v,a),e.lineWidth=1*b;for(let[f,h]of n)for(let d of["hi","lo"]){e.beginPath();for(let u=f;u<=h;u++){let A=i(t[u].t),S=s(t[u][d]);u===f?e.moveTo(A,S):e.lineTo(A,S)}e.stroke()}}function ie(e,t,n,i,s,v,r,a){for(let b=0;b<t.length;b++){let f=t[b];if(!F(f.v)||f.t<n||f.t>i)continue;let h=s(f.t),d=v(f.v);e.fillStyle=D(r,.18),e.beginPath(),e.arc(h,d,6*a,0,Math.PI*2),e.fill(),e.fillStyle=r,e.beginPath(),e.arc(h,d,3*a,0,Math.PI*2),e.fill()}}function se(e,t,n,i,s,v,r,a,b,f,h){e.font=`${11*h}px ui-monospace, 'JetBrains Mono', monospace`,e.textBaseline="middle";for(let u=0;u<=4;u++){let A=i.vmin+(i.vmax-i.vmin)*u/4,S=v(A);e.strokeStyle=D(b,.1),e.lineWidth=1*h,e.beginPath(),e.moveTo(n.l*h,S),e.lineTo(t.width-n.r*h,S),e.stroke(),e.fillStyle=f,e.textAlign="right",e.fillText(W(A),(n.l-8)*h,S)}e.textBaseline="top";let d=a-r||1;for(let u=0;u<=5;u++){let A=r+d*u/5,S=s(A);e.fillStyle=f,e.textAlign=u===0?"left":u===5?"right":"center",e.fillText($e(A,d),S,(t.height-n.b+7)*h)}}function W(e){let t=Math.abs(e);return t>=1e3?e.toFixed(0):t>=10?e.toFixed(1):t>=1?e.toFixed(2):e.toFixed(3)}function $e(e,t){let n=new Date(e).toISOString();return t<2*864e5?n.slice(5,16).replace("T"," "):n.slice(5,10)}function X(e){return new Date(e).toISOString().slice(0,19).replace("T"," ")}function ae(e){let t=Math.round(e/6e4);if(t<60)return t+"m";let n=Math.floor(t/60),i=t%60;if(n<24)return n+"h"+(i?" "+i+"m":"");let s=Math.floor(n/24),v=n%24;return s+"d"+(v?" "+v+"h":"")}function le(e,t,n){return{left:t.l*n,top:t.t*n,right:e.width-t.r*n,bottom:e.height-t.b*n}}function ce(e,t,n,i,s,v,r){let a=le(t,n,i),b=Math.max(a.left,Math.min(s(v),a.right));b<=a.left+.5||(e.save(),e.fillStyle="rgba(17,15,13,0.42)",e.fillRect(a.left,a.top,b-a.left,a.bottom-a.top),e.strokeStyle=D(E("--faint"),.7),e.lineWidth=1*i,e.setLineDash([4*i,4*i]),e.beginPath(),e.moveTo(b,a.top),e.lineTo(b,a.bottom),e.stroke(),e.setLineDash([]),e.fillStyle=D(E("--faint"),.95),e.font=`${10*i}px ui-monospace, monospace`,e.textAlign="left",e.textBaseline="top",e.fillText(r,b+6*i,a.top+5*i),e.restore())}function de(e,t,n,i,s,v,r){let a=le(t,n,i),b=5*i;e.save();for(let f=0;f<v.length;f++){let h=v[f],d=s(h.t);if(d<a.left-1||d>a.right+1)continue;let u=r(h.kind);e.strokeStyle=D(u,.45),e.lineWidth=1*i,e.beginPath(),e.moveTo(d,a.top),e.lineTo(d,a.bottom),e.stroke(),e.fillStyle=u,e.beginPath(),e.moveTo(d-b,a.top),e.lineTo(d+b,a.top),e.lineTo(d,a.top+b*1.4),e.closePath(),e.fill()}e.restore()}var L={l:56,r:16,t:14,b:28},Me=300*1e3,c="dtk-report";function G(e){return e==="recovery"?E("--st-recovery"):e==="no_data"?E("--st-nodata"):E("--st-anomaly")}function Ae(e){return e==="recovery"?"recovery":e==="no_data"?"no-data":"anomaly"}var M=e=>String(e).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),K=(e,t,n)=>Math.max(t,Math.min(n,e)),ue=["--clay","--st-error","--st-recovery","--st-nodata"];function Te(e,t){Re(),t.classList.add(c),t.innerHTML="";let n=document.createElement("div");n.className="dtk-report-root",t.appendChild(n);let i=new Map;for(let m of e.points)m.v!==null&&i.set(m.t,m.v);let s=e.detectors.map((m,p)=>{let $=m.effective_start,C=m.points.map(T=>$!==null&&T.t<$?{t:T.t,lo:null,hi:null}:{t:T.t,lo:T.lo,hi:T.hi}),P=[];for(let T of m.points)if(T.a===1&&($===null||T.t>=$)){let I=i.get(T.t);I!==void 0&&P.push({t:T.t,v:I})}return{det:m,band:C,anomalies:P,effectiveStart:$,color:E(ue[p%ue.length]),shown:p===0}});s.length===1&&(s[0].shown=!0),n.appendChild(Se(e));let v=null;s.length>1&&(v=Ce(s,()=>d.repaint()),n.appendChild(v));let r=document.createElement("div");r.className="dtk-bar",n.appendChild(r);let a=document.createElement("div");a.className="dtk-presets",r.appendChild(a);let b=document.createElement("div");b.className="dtk-readout",b.textContent="hover the chart for a point readout",r.appendChild(b);let f=document.createElement("div");f.className="dtk-chart";let h=document.createElement("canvas");f.appendChild(h),n.appendChild(f);let d=Pe(h,e,s,i,m=>{b.innerHTML=m}),u=[{label:"24h",ms:24*3600*1e3},{label:"7d",ms:7*86400*1e3},{label:"30d",ms:30*86400*1e3},{label:"All",ms:null}];for(let m of u){let p=document.createElement("button");p.className="dtk-preset",p.textContent=m.label,p.onclick=()=>{m.ms===null?d.resetView():d.setView(e.period.end-m.ms,e.period.end),Le(a,p)},a.appendChild(p)}let A=document.createElement("label");A.className="dtk-zero",A.title="Draw a horizontal line at y = 0 and scale the chart to include zero.";let S=document.createElement("input");S.type="checkbox",S.onchange=()=>d.setZeroLine(S.checked),A.appendChild(S),A.appendChild(document.createTextNode(" y = 0")),a.appendChild(A),n.appendChild(Ee(e,m=>d.focusAlert(m))),d.resize();let z=0,g=()=>{z||(z=requestAnimationFrame(()=>{z=0,d.resize()}))};window.addEventListener("resize",g)}function Se(e){let t=document.createElement("div");t.className="dtk-header";let n=e.interval_seconds/60,i=e.interval_seconds>=86400?e.interval_seconds/86400+"d":e.interval_seconds>=3600?e.interval_seconds/3600+"h":n>=1?n+"min":e.interval_seconds+"s",s=e.project?`${M(e.project)} \xB7 ${M(e.metric)}`:M(e.metric),v=e.summary;return t.innerHTML=`<div class="dtk-h-top"><h1 class="dtk-title">${s}</h1><div class="dtk-meta">${M(X(e.period.start))} \u2013 ${M(X(e.period.end))} \xB7 interval ${M(i)}${e.generated_at?` \xB7 generated ${M(e.generated_at)}`:""}</div></div>`+(e.description?`<p class="dtk-desc">${M(e.description)}</p>`:"")+'<div class="dtk-chips">'+Y("anomalies",v.anomalies,"--st-anomaly")+Y("alerts",v.alerts,"--clay")+Y("recoveries",v.recoveries,"--st-recovery")+Y("no-data",v.no_data,"--st-nodata")+"</div>",t}function Y(e,t,n){let i=E(n);return`<span class="dtk-chip"><span class="dtk-dot" style="background:${M(i)}"></span><span class="dtk-chip-n">${t}</span><span class="dtk-chip-l">${M(e)}</span></span>`}function Ce(e,t){let n=document.createElement("div");return n.className="dtk-legend",e.forEach(i=>{let s=document.createElement("button");s.className="dtk-legend-item"+(i.shown?"":" off"),s.innerHTML=`<span class="dtk-swatch" style="background:${M(i.color)}"></span><span class="dtk-legend-name">${M(i.det.name)}</span><span class="dtk-legend-id">${M(i.det.id.slice(0,8))}</span><span class="dtk-legend-n">${i.det.anomaly_count}</span>`,s.onclick=()=>{i.shown=!i.shown,s.classList.toggle("off",!i.shown),t()},n.appendChild(s)}),n}function Ee(e,t){let n=document.createElement("div");n.className="dtk-alerts";let i=document.createElement("div");if(i.className="dtk-alerts-head",i.textContent=`Alerts (${e.alerts.length})`,n.appendChild(i),e.alerts.length===0){let r=document.createElement("div");return r.className="dtk-alerts-empty",r.textContent="No alerts fired in this period.",n.appendChild(r),n}let s=document.createElement("div");s.className="dtk-alerts-list";let v=[...e.alerts].sort((r,a)=>a.t-r.t);for(let r of v){let a=document.createElement("button");a.className="dtk-alert-row";let b=G(r.kind),f=r.direction!=="none"?` \xB7 ${M(r.direction)}`:"",h=r.severity>0?` \xB7 sev ${r.severity.toFixed(2)}`:"",d=r.value!==null?` \xB7 value ${W(r.value)}`:"",u=r.onset!==null&&r.kind!=="no_data"?` \xB7 ${ae(Math.max(0,r.t-r.onset))} (${r.consecutive} pts)`:"";a.innerHTML=`<span class="dtk-alert-time">${M(X(r.t))}</span><span class="dtk-badge" style="background:${M(D(b,.18))};color:${M(b)};border-color:${M(D(b,.5))}">${M(Ae(r.kind))}</span><span class="dtk-alert-body"><span class="dtk-alert-rule">${M(r.rule)}</span><span class="dtk-alert-sub">${M(r.detector)}${f}${h}${d}${M(u)}</span></span>`,a.onclick=()=>t(r),s.appendChild(a)}return n.appendChild(s),n}function Le(e,t){e.querySelectorAll(".dtk-preset").forEach(n=>n.classList.remove("active")),t.classList.add("active")}function Pe(e,t,n,i,s){let v=e.getContext("2d");if(!v)throw new Error("report: 2D context unavailable");let r=v,a=t.points.map(o=>o.t),b=t.points.map(o=>o.v===null?NaN:o.v),f=t.period.start,h=t.period.end,d=h-f||1,u=Math.min(Me,d),A=0,S=1,z=!1;C();let g=f,m=h,p=1,$=null;function C(){let o=1/0,l=-1/0;for(let y of b)Number.isFinite(y)&&(y<o&&(o=y),y>l&&(l=y));for(let y of n)for(let k of y.band)k.lo!==null&&Number.isFinite(k.lo)&&k.lo<o&&(o=k.lo),k.hi!==null&&Number.isFinite(k.hi)&&k.hi>l&&(l=k.hi);(!Number.isFinite(o)||!Number.isFinite(l))&&(o=0,l=1),z&&(o>0&&(o=0),l<0&&(l=0)),l<=o&&(l=o+1);let x=(l-o)*.06;A=o-x,S=l+x}function P(o){z!==o&&(z=o,C(),V())}function T(){for(let o of n)if(o.shown&&o.effectiveStart!==null)return o.effectiveStart;for(let o of n)if(o.effectiveStart!==null)return o.effectiveStart;return null}function I(){return{tmin:g,tmax:m,vmin:A,vmax:S}}function fe(){return te(e,L,I(),p)}function O(o,l){let x=l-o;if(x<u){let y=(o+l)/2;o=y-u/2,l=y+u/2,x=u}x>=d&&(o=f,l=h),o<f&&(l+=f-o,o=f),l>h&&(o-=l-h,l=h),g=K(o,f,h),m=K(l,f,h),V()}function Z(){g=f,m=h,V()}function pe(o){let l=o.onset!==null?Math.min(o.onset,o.t):o.t,x=o.t,k=Math.max(x-l,u)*1.5+u;O(l-k,x+k)}let j=0;function q(){j===0&&(j=requestAnimationFrame(V))}function V(){if(j=0,e.width===0||e.height===0)return;let o=fe(),l=E("--faint"),x=E("--muted"),y=E("--clay");if(r.fillStyle=E("--term-bg"),r.fillRect(0,0,e.width,e.height),a.length===0)return;if(se(r,e,L,I(),o.px,o.py,g,m,l,x,p),z&&A<=0&&S>=0){let w=o.py(0);r.strokeStyle=D(x,.6),r.lineWidth=1.25*p,r.beginPath(),r.moveTo(L.l*p,w),r.lineTo(e.width-L.r*p,w),r.stroke(),r.fillStyle=x,r.textAlign="right",r.textBaseline="middle",r.fillText("0",(L.l-8)*p,w)}r.save(),r.beginPath(),r.rect(L.l*p,L.t*p,o.plotW(),o.plotH()),r.clip();let k=L.t*p,N=o.plotH();for(let w of t.alerts){if(w.onset===null||w.kind==="no_data")continue;let B=Math.min(w.onset,w.t),U=Math.max(w.onset,w.t);if(U<g||B>m)continue;let ke=G(w.kind),Q=o.px(B),ge=o.px(U);r.fillStyle=D(ke,.08),r.fillRect(Q,k,Math.max(ge-Q,1*p),N)}for(let w of n){if(!w.shown)continue;let B=re(w.band);oe(r,w.band,B,o.px,o.py,w.color,.13,.4,p)}ne(r,a,b,g,m,L.l*p,o.plotW(),o.px,o.py,y,1.5,p);for(let w of n)w.shown&&ie(r,w.anomalies,g,m,o.px,o.py,E("--st-anomaly"),p);let R=T();R!==null&&R>g&&ce(r,e,L,p,o.px,R,"detection at full power \u2192");let H=t.alerts.map(w=>({t:w.t,kind:w.kind}));de(r,e,L,p,o.px,H,w=>G(w)),$!==null&&he(o,k,N,l),r.restore()}function be(o){let l=a;if(l.length===0)return-1;let x=0,y=l.length-1;for(;x<y;){let k=x+y>>1;l[k]<o?x=k+1:y=k}return x>0&&o-l[x-1]<l[x]-o&&(x-=1),x}function he(o,l,x,y){let k=be($);if(k<0)return;let N=a[k];if(N<g||N>m){s("hover the chart for a point readout");return}let R=o.px(N);r.strokeStyle=D(y,.45),r.lineWidth=1*p,r.setLineDash([2*p,2*p]),r.beginPath(),r.moveTo(R,l),r.lineTo(R,l+x),r.stroke(),r.setLineDash([]);let H=b[k];if(Number.isFinite(H)){let w=o.py(H);r.fillStyle=E("--term-bg"),r.beginPath(),r.arc(R,w,4*p,0,Math.PI*2),r.fill(),r.strokeStyle=E("--clay"),r.lineWidth=2*p,r.beginPath(),r.arc(R,w,4*p,0,Math.PI*2),r.stroke()}ve(k)}function ve(o){let l=a[o],x=b[o],y=`<span class="dtk-ro-t">${M(X(l))}</span>`;y+=`<span class="dtk-ro-v">value ${Number.isFinite(x)?W(x):"\u2014"}</span>`;for(let k of n){if(!k.shown)continue;let N=k.band[o],R=k.det.points[o];if(N&&N.lo!==null&&N.hi!==null){let H=R&&R.a===1,w=H&&R.sev!==null?` sev ${R.sev.toFixed(2)}`:"",B=H?`<span class="dtk-ro-anom" style="color:${M(E("--st-anomaly"))}">anomaly${M(w)}</span>`:'<span class="dtk-ro-ok">ok</span>';y+=`<span class="dtk-ro-det"><span class="dtk-swatch" style="background:${M(k.color)}"></span>${M(k.det.name)}: [${W(N.lo)}, ${W(N.hi)}] ${B}</span>`}}s(y)}function J(o){let l=e.getBoundingClientRect(),x=(o-l.left-L.l)/(l.width-(L.l+L.r)||1);return g+K(x,0,1)*(m-g)}e.addEventListener("wheel",o=>{o.preventDefault();let l=J(o.clientX),x=m-g,y=K(x*Math.pow(1.0015,o.deltaY),u,d),k=(l-g)/(x||1);O(l-k*y,l-k*y+y)},{passive:!1});let _=null;e.addEventListener("mousedown",o=>{_={x:o.clientX,vMin:g,vMax:m},e.style.cursor="grabbing"}),window.addEventListener("mousemove",o=>{if(!_)return;let l=e.getBoundingClientRect(),x=(_.vMax-_.vMin)/(l.width-(L.l+L.r)||1),y=(o.clientX-_.x)*x;O(_.vMin-y,_.vMax-y)}),window.addEventListener("mouseup",()=>{_&&(_=null,e.style.cursor="crosshair")}),e.addEventListener("mousemove",o=>{_||($=J(o.clientX),q())}),e.addEventListener("mouseleave",()=>{$!==null&&($=null,s("hover the chart for a point readout"),q())}),e.addEventListener("dblclick",()=>Z()),e.style.cursor="crosshair";function xe(){p=ee(e),V()}return{repaint:()=>q(),resize:xe,setView:O,resetView:Z,focusAlert:pe,setZeroLine:P}}var me=!1;function Re(){if(me)return;me=!0;let e=`
|
|
2
|
+
.${c}{--term-bg:#211e1a;--term-border:#332f29;--term-text:#c9c2b4;
|
|
3
|
+
--clay:#d15b36;--clay-700:#b4471f;--ink:#1b1916;--paper:#f5f1e8;--surface:#fbf9f3;
|
|
4
|
+
--border:#e6e0d4;--muted:#6e675b;--faint:#9a9384;
|
|
5
|
+
--anom:#d63232;--rec:#36a64f;--nod:#f0ad4e;
|
|
6
|
+
--sans:'Schibsted Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
|
|
7
|
+
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
8
|
+
font-family:var(--sans);color:var(--ink);}
|
|
9
|
+
.${c} *{box-sizing:border-box;}
|
|
10
|
+
.${c} .dtk-report-root{max-width:1100px;margin:0 auto;padding:20px 18px 40px;}
|
|
11
|
+
/* --- header row ----------------------------------------------------------- */
|
|
12
|
+
.${c} .dtk-header{margin-bottom:16px;padding-left:12px;
|
|
13
|
+
border-left:3px solid var(--clay);}
|
|
14
|
+
.${c} .dtk-h-top{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 14px;}
|
|
15
|
+
.${c} .dtk-title{font-size:21px;font-weight:700;margin:0;color:var(--ink);
|
|
16
|
+
font-family:var(--sans);letter-spacing:-0.01em;}
|
|
17
|
+
.${c} .dtk-meta{font-size:12px;color:var(--muted);font-family:var(--mono);}
|
|
18
|
+
.${c} .dtk-desc{margin:8px 0 0;font-size:13px;color:var(--muted);max-width:760px;
|
|
19
|
+
line-height:1.5;}
|
|
20
|
+
/* --- summary chips (surface cards) ---------------------------------------- */
|
|
21
|
+
.${c} .dtk-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;}
|
|
22
|
+
.${c} .dtk-chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;
|
|
23
|
+
background:var(--surface);border:1px solid var(--border);border-radius:10px;font-size:12px;}
|
|
24
|
+
.${c} .dtk-dot{width:8px;height:8px;border-radius:50%;display:inline-block;}
|
|
25
|
+
.${c} .dtk-chip-n{font-weight:700;font-family:var(--mono);color:var(--ink);}
|
|
26
|
+
.${c} .dtk-chip-l{color:var(--faint);font-family:var(--mono);font-size:11px;
|
|
27
|
+
text-transform:uppercase;letter-spacing:0.05em;}
|
|
28
|
+
/* --- detector legend ------------------------------------------------------ */
|
|
29
|
+
.${c} .dtk-legend{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px;}
|
|
30
|
+
.${c} .dtk-legend-item{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;
|
|
31
|
+
background:var(--surface);border:1px solid var(--border);border-radius:8px;cursor:pointer;
|
|
32
|
+
color:var(--ink);font-size:12px;font-family:var(--sans);transition:border-color .12s ease;}
|
|
33
|
+
.${c} .dtk-legend-item:hover{border-color:var(--clay);}
|
|
34
|
+
.${c} .dtk-legend-item.off{opacity:0.45;}
|
|
35
|
+
.${c} .dtk-legend-id{color:var(--faint);font-family:var(--mono);font-size:11px;}
|
|
36
|
+
.${c} .dtk-legend-n{color:var(--anom);font-weight:700;font-family:var(--mono);}
|
|
37
|
+
.${c} .dtk-swatch{width:10px;height:10px;border-radius:2px;display:inline-block;}
|
|
38
|
+
/* --- toolbar (presets + readout) ------------------------------------------ */
|
|
39
|
+
.${c} .dtk-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;
|
|
40
|
+
gap:8px;margin-bottom:8px;}
|
|
41
|
+
.${c} .dtk-presets{display:flex;gap:5px;}
|
|
42
|
+
.${c} .dtk-preset{padding:5px 13px;background:var(--surface);
|
|
43
|
+
border:1px solid var(--border);border-radius:8px;color:var(--muted);cursor:pointer;
|
|
44
|
+
font-size:12px;font-family:var(--sans);transition:border-color .12s ease,color .12s ease;}
|
|
45
|
+
.${c} .dtk-preset:hover{border-color:var(--clay);color:var(--ink);}
|
|
46
|
+
.${c} .dtk-preset.active{background:var(--clay);color:#fff;border-color:var(--clay);}
|
|
47
|
+
.${c} .dtk-zero{display:inline-flex;align-items:center;gap:5px;margin-left:6px;
|
|
48
|
+
font-size:12px;color:var(--muted);font-family:var(--sans);cursor:pointer;user-select:none;}
|
|
49
|
+
.${c} .dtk-zero input{accent-color:var(--clay);cursor:pointer;}
|
|
50
|
+
.${c} .dtk-readout{font-size:11px;color:var(--muted);
|
|
51
|
+
font-family:var(--mono);display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;}
|
|
52
|
+
.${c} .dtk-readout .dtk-swatch{margin-right:4px;}
|
|
53
|
+
.${c} .dtk-ro-t{font-weight:700;color:var(--ink);}
|
|
54
|
+
/* --- chart panel (dark terminal surface) ---------------------------------- */
|
|
55
|
+
.${c} .dtk-chart{position:relative;width:100%;height:360px;background:var(--term-bg);
|
|
56
|
+
border:1px solid var(--term-border);border-radius:12px;overflow:hidden;
|
|
57
|
+
box-shadow:0 24px 60px -30px rgba(27,25,22,.45);}
|
|
58
|
+
.${c} .dtk-chart canvas{width:100%;height:100%;display:block;}
|
|
59
|
+
/* --- alerts list (surface cards) ------------------------------------------ */
|
|
60
|
+
.${c} .dtk-alerts{margin-top:18px;}
|
|
61
|
+
.${c} .dtk-alerts-head{font-size:12px;font-weight:600;color:var(--faint);
|
|
62
|
+
margin-bottom:9px;font-family:var(--mono);text-transform:uppercase;letter-spacing:0.06em;}
|
|
63
|
+
.${c} .dtk-alerts-empty{font-size:13px;color:var(--muted);padding:8px 0;}
|
|
64
|
+
.${c} .dtk-alerts-list{display:flex;flex-direction:column;gap:5px;
|
|
65
|
+
max-height:340px;overflow:auto;}
|
|
66
|
+
.${c} .dtk-alert-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;
|
|
67
|
+
padding:8px 11px;background:var(--surface);border:1px solid var(--border);
|
|
68
|
+
border-radius:8px;cursor:pointer;color:var(--ink);font-family:var(--sans);
|
|
69
|
+
transition:border-color .12s ease,box-shadow .12s ease;}
|
|
70
|
+
.${c} .dtk-alert-row:hover{border-color:var(--clay);
|
|
71
|
+
box-shadow:0 4px 14px -8px rgba(27,25,22,.35);}
|
|
72
|
+
.${c} .dtk-alert-time{font-size:11px;color:var(--muted);
|
|
73
|
+
font-family:var(--mono);white-space:nowrap;min-width:142px;}
|
|
74
|
+
.${c} .dtk-badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:11px;
|
|
75
|
+
font-weight:700;border:1px solid;text-transform:uppercase;letter-spacing:0.03em;white-space:nowrap;}
|
|
76
|
+
.${c} .dtk-alert-body{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
|
77
|
+
.${c} .dtk-alert-rule{font-size:12px;color:var(--ink);
|
|
78
|
+
font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
79
|
+
.${c} .dtk-alert-sub{font-size:11px;color:var(--muted);}
|
|
80
|
+
`,t=document.createElement("style");t.setAttribute("data-dtk-report",""),t.textContent=e,document.head.appendChild(t)}window.__DTK_REPORT__={render:Te};})();
|
|
@@ -16,7 +16,7 @@ from typing import Any
|
|
|
16
16
|
|
|
17
17
|
import numpy as np
|
|
18
18
|
|
|
19
|
-
from detectkit.alerting.orchestrator import AlertOrchestrator
|
|
19
|
+
from detectkit.alerting.orchestrator import AlertOrchestrator, ReplayedEvent
|
|
20
20
|
from detectkit.alerting.orchestrator._types import (
|
|
21
21
|
AlertConditions,
|
|
22
22
|
DetectionRecord,
|
|
@@ -176,7 +176,7 @@ def resolve_window(
|
|
|
176
176
|
return start, end
|
|
177
177
|
|
|
178
178
|
|
|
179
|
-
def
|
|
179
|
+
def record_from_row(row: dict) -> DetectionRecord:
|
|
180
180
|
"""Build a :class:`DetectionRecord` from a flat ``load_detections`` row.
|
|
181
181
|
|
|
182
182
|
Mirrors ``hydrate_detection_records`` (direction/severity derived from
|
|
@@ -204,6 +204,61 @@ def _record_from_row(row: dict) -> DetectionRecord:
|
|
|
204
204
|
)
|
|
205
205
|
|
|
206
206
|
|
|
207
|
+
def replay_alert_events(
|
|
208
|
+
metric_config: MetricConfig,
|
|
209
|
+
internal_unused: InternalTablesManager | None,
|
|
210
|
+
records: list[DetectionRecord],
|
|
211
|
+
value_at: dict[np.datetime64, float | None],
|
|
212
|
+
start: datetime,
|
|
213
|
+
end: datetime,
|
|
214
|
+
project_name: str | None,
|
|
215
|
+
) -> list[tuple[str, ReplayedEvent]]:
|
|
216
|
+
"""Replay every *enabled* alerting config over ``[start, end]``.
|
|
217
|
+
|
|
218
|
+
The per-alert-config replay loop of :func:`build_report_payload`, extracted
|
|
219
|
+
so ``detectkit/ui/overview.py`` can reuse the exact same code path — the
|
|
220
|
+
alert numbers on both the report and the overview cockpit come from an
|
|
221
|
+
identical replay, not two implementations that could drift.
|
|
222
|
+
|
|
223
|
+
``internal_unused`` is threaded into :class:`AlertOrchestrator` for
|
|
224
|
+
constructor parity with the live/dispatch path, but the pure ``replay()``
|
|
225
|
+
method never reads it (no DB, no wall-clock — everything comes from
|
|
226
|
+
``records`` / ``value_at``).
|
|
227
|
+
|
|
228
|
+
Returns ``(config_id, event)`` pairs in the order the configs were
|
|
229
|
+
evaluated (each config's events are chronological; pairs across configs
|
|
230
|
+
are NOT globally sorted — sort on the caller's chosen key, e.g. timestamp).
|
|
231
|
+
"""
|
|
232
|
+
name = metric_config.name
|
|
233
|
+
interval = metric_config.get_interval()
|
|
234
|
+
out: list[tuple[str, ReplayedEvent]] = []
|
|
235
|
+
active_configs = [c for c in (metric_config.alerting or []) if c.enabled and c.channels]
|
|
236
|
+
for cfg in active_configs:
|
|
237
|
+
config_id = make_alert_config_id(cfg)
|
|
238
|
+
orchestrator = AlertOrchestrator(
|
|
239
|
+
metric_name=name,
|
|
240
|
+
interval=interval,
|
|
241
|
+
alert_config_id=config_id,
|
|
242
|
+
conditions=AlertConditions(
|
|
243
|
+
min_detectors=cfg.min_detectors,
|
|
244
|
+
direction=cfg.direction,
|
|
245
|
+
consecutive_anomalies=cfg.consecutive_anomalies,
|
|
246
|
+
),
|
|
247
|
+
timezone_display=cfg.timezone,
|
|
248
|
+
internal=internal_unused,
|
|
249
|
+
alert_config=cfg,
|
|
250
|
+
description=metric_config.description,
|
|
251
|
+
mentions=cfg.mentions,
|
|
252
|
+
dashboard_url=cfg.dashboard_url,
|
|
253
|
+
links=cfg.links,
|
|
254
|
+
project_name=project_name,
|
|
255
|
+
help_url=None,
|
|
256
|
+
)
|
|
257
|
+
for event in orchestrator.replay(records, value_at, start, end):
|
|
258
|
+
out.append((config_id, event))
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
207
262
|
def _event_to_payload(event: Any, config_id: str) -> dict:
|
|
208
263
|
"""Project a ``ReplayedEvent`` into the payload alert shape."""
|
|
209
264
|
ad = event.alert_data
|
|
@@ -342,33 +397,13 @@ def build_report_payload(
|
|
|
342
397
|
slot["effective_start"] = grid_ms[warm] if 0 < warm < n_grid else None
|
|
343
398
|
|
|
344
399
|
# ---- alerts: replay every active alert config over the period -------------
|
|
345
|
-
records = [
|
|
346
|
-
alerts: list[dict] = [
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
orchestrator = AlertOrchestrator(
|
|
351
|
-
metric_name=name,
|
|
352
|
-
interval=interval,
|
|
353
|
-
alert_config_id=config_id,
|
|
354
|
-
conditions=AlertConditions(
|
|
355
|
-
min_detectors=cfg.min_detectors,
|
|
356
|
-
direction=cfg.direction,
|
|
357
|
-
consecutive_anomalies=cfg.consecutive_anomalies,
|
|
358
|
-
),
|
|
359
|
-
timezone_display=cfg.timezone,
|
|
360
|
-
internal=internal,
|
|
361
|
-
alert_config=cfg,
|
|
362
|
-
description=metric_config.description,
|
|
363
|
-
mentions=cfg.mentions,
|
|
364
|
-
dashboard_url=cfg.dashboard_url,
|
|
365
|
-
links=cfg.links,
|
|
366
|
-
project_name=project_name,
|
|
367
|
-
help_url=None,
|
|
400
|
+
records = [record_from_row(r) for r in det_rows]
|
|
401
|
+
alerts: list[dict] = [
|
|
402
|
+
_event_to_payload(event, config_id)
|
|
403
|
+
for config_id, event in replay_alert_events(
|
|
404
|
+
metric_config, internal, records, value_at, start, end, project_name
|
|
368
405
|
)
|
|
369
|
-
|
|
370
|
-
alerts.append(_event_to_payload(event, config_id))
|
|
371
|
-
|
|
406
|
+
]
|
|
372
407
|
alerts.sort(key=lambda a: a["t"])
|
|
373
408
|
summary = {
|
|
374
409
|
"anomalies": len(anomalous_timestamps),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Project-level monitoring cockpit for detectkit (``dtk ui``).
|
|
2
|
+
|
|
3
|
+
A localhost web UI over the already-persisted ``_dtk_*`` tables: an overview of
|
|
4
|
+
every selected metric's alerting behavior (how often alerts fire, per metric /
|
|
5
|
+
per tag / per ``metrics/`` folder), a per-metric detail view (the existing HTML
|
|
6
|
+
report in an iframe), and a pipeline control panel that drives the existing CLI
|
|
7
|
+
commands (``dtk run`` / ``dtk autotune`` / ``dtk unlock`` as subprocesses,
|
|
8
|
+
``dtk tune`` launched per metric).
|
|
9
|
+
|
|
10
|
+
``dtk ui`` is a *superstructure over existing dtk commands*: the server itself
|
|
11
|
+
never runs the pipeline in-process and takes no pipeline lock — spawned ``dtk
|
|
12
|
+
run`` subprocesses take their own lock exactly as if run from the terminal.
|
|
13
|
+
Alert counts are replayed from stored detections via the pure
|
|
14
|
+
``AlertOrchestrator.replay`` (the same seam ``dtk run --report`` uses), so the
|
|
15
|
+
numbers match what the pipeline would actually have alerted.
|
|
16
|
+
|
|
17
|
+
- ``overview.py`` — ``build_overview_payload``: per-metric frequency/quality
|
|
18
|
+
stats, pure given an ``InternalTablesManager``.
|
|
19
|
+
- ``jobs.py`` — ``JobManager``: subprocess registry + output pumping for the
|
|
20
|
+
pipeline control panel.
|
|
21
|
+
- ``server.py`` — ``build_ui_server`` / ``serve_ui``: routes, token auth, the
|
|
22
|
+
single ``db_lock`` serializing DB-touching requests.
|
|
23
|
+
- ``html.py`` — ``render_ui_html``: inlines the committed ``assets/ui.js``
|
|
24
|
+
renderer bundle + the boot payload into one self-contained page.
|
|
25
|
+
"""
|