detectkit 0.48.0__tar.gz → 0.49.1__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.
Files changed (153) hide show
  1. {detectkit-0.48.0 → detectkit-0.49.1}/MANIFEST.in +1 -0
  2. {detectkit-0.48.0/detectkit.egg-info → detectkit-0.49.1}/PKG-INFO +1 -1
  3. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/__init__.py +1 -1
  4. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -0
  5. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/rules/cli.md +39 -0
  6. detectkit-0.49.1/detectkit/cli/commands/ui.py +97 -0
  7. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/main.py +54 -0
  8. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_base.py +11 -2
  9. detectkit-0.49.1/detectkit/reporting/assets/report.js +80 -0
  10. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/reporting/builder.py +63 -28
  11. detectkit-0.49.1/detectkit/ui/__init__.py +25 -0
  12. detectkit-0.49.1/detectkit/ui/assets/ui.js +249 -0
  13. detectkit-0.49.1/detectkit/ui/html.py +84 -0
  14. detectkit-0.49.1/detectkit/ui/jobs.py +334 -0
  15. detectkit-0.49.1/detectkit/ui/overview.py +570 -0
  16. detectkit-0.49.1/detectkit/ui/server.py +667 -0
  17. {detectkit-0.48.0 → detectkit-0.49.1/detectkit.egg-info}/PKG-INFO +1 -1
  18. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit.egg-info/SOURCES.txt +7 -0
  19. {detectkit-0.48.0 → detectkit-0.49.1}/pyproject.toml +2 -0
  20. detectkit-0.48.0/detectkit/reporting/assets/report.js +0 -80
  21. {detectkit-0.48.0 → detectkit-0.49.1}/LICENSE +0 -0
  22. {detectkit-0.48.0 → detectkit-0.49.1}/README.md +0 -0
  23. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/__init__.py +0 -0
  24. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/__init__.py +0 -0
  25. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/base.py +0 -0
  26. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/branding.py +0 -0
  27. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/email.py +0 -0
  28. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/factory.py +0 -0
  29. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/mattermost.py +0 -0
  30. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/slack.py +0 -0
  31. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/telegram.py +0 -0
  32. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/channels/webhook.py +0 -0
  33. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/__init__.py +0 -0
  34. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/_base.py +0 -0
  35. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  36. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/_decision.py +0 -0
  37. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  38. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  39. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/_replay.py +0 -0
  40. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/_types.py +0 -0
  41. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  42. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/__init__.py +0 -0
  43. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/_base.py +0 -0
  44. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/_types.py +0 -0
  45. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/autotuner.py +0 -0
  46. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/config_emitter.py +0 -0
  47. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/crossval.py +0 -0
  48. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/detector_select.py +0 -0
  49. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/distribution.py +0 -0
  50. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/grid_search.py +0 -0
  51. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/labels.py +0 -0
  52. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/result.py +0 -0
  53. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/runner.py +0 -0
  54. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/scoring.py +0 -0
  55. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/seasonality_search.py +0 -0
  56. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/settings.py +0 -0
  57. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/autotune/window_select.py +0 -0
  58. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/__init__.py +0 -0
  59. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/_output.py +0 -0
  60. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  61. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  62. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  63. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  64. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  65. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/rules/project.md +0 -0
  66. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  67. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  68. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  69. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  70. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  71. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/__init__.py +0 -0
  72. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/autotune.py +0 -0
  73. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/clean.py +0 -0
  74. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/init.py +0 -0
  75. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/init_claude.py +0 -0
  76. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/osi.py +0 -0
  77. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/run.py +0 -0
  78. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/test_alert.py +0 -0
  79. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/tune.py +0 -0
  80. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/cli/commands/unlock.py +0 -0
  81. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/config/__init__.py +0 -0
  82. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/config/metric_config.py +0 -0
  83. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/config/profile.py +0 -0
  84. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/config/project_config.py +0 -0
  85. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/config/validator.py +0 -0
  86. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/core/__init__.py +0 -0
  87. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/core/interval.py +0 -0
  88. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/core/models.py +0 -0
  89. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/__init__.py +0 -0
  90. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/_sql_manager.py +0 -0
  91. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/clickhouse_manager.py +0 -0
  92. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/__init__.py +0 -0
  93. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_alert_states.py +0 -0
  94. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  95. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_datapoints.py +0 -0
  96. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_detections.py +0 -0
  97. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_maintenance.py +0 -0
  98. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_metrics.py +0 -0
  99. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_schema.py +0 -0
  100. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/_tasks.py +0 -0
  101. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/internal_tables/manager.py +0 -0
  102. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/manager.py +0 -0
  103. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/mysql_manager.py +0 -0
  104. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/postgres_manager.py +0 -0
  105. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/database/tables.py +0 -0
  106. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/__init__.py +0 -0
  107. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/base.py +0 -0
  108. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/factory.py +0 -0
  109. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/seasonality.py +0 -0
  110. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/statistical/__init__.py +0 -0
  111. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/statistical/_windowed.py +0 -0
  112. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/statistical/iqr.py +0 -0
  113. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/statistical/mad.py +0 -0
  114. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  115. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/detectors/statistical/zscore.py +0 -0
  116. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/loaders/__init__.py +0 -0
  117. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/loaders/metric_loader.py +0 -0
  118. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/loaders/query_template.py +0 -0
  119. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/__init__.py +0 -0
  120. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/error_dispatch.py +0 -0
  121. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/task_manager/__init__.py +0 -0
  122. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  123. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/task_manager/_base.py +0 -0
  124. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  125. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  126. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/task_manager/_types.py +0 -0
  127. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/orchestration/task_manager/manager.py +0 -0
  128. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/reporting/__init__.py +0 -0
  129. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/reporting/html_report.py +0 -0
  130. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/semantic/__init__.py +0 -0
  131. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/semantic/errors.py +0 -0
  132. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/semantic/exporter.py +0 -0
  133. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/semantic/importer.py +0 -0
  134. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/semantic/osi_model.py +0 -0
  135. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/semantic/query_gen.py +0 -0
  136. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/tuning/__init__.py +0 -0
  137. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/tuning/assets/tune.js +0 -0
  138. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/tuning/config_writer.py +0 -0
  139. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/tuning/html.py +0 -0
  140. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/tuning/payload.py +0 -0
  141. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/tuning/server.py +0 -0
  142. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/utils/__init__.py +0 -0
  143. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/utils/datetime_utils.py +0 -0
  144. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/utils/env_interpolation.py +0 -0
  145. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/utils/json_utils.py +0 -0
  146. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit/utils/stats.py +0 -0
  147. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit.egg-info/dependency_links.txt +0 -0
  148. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit.egg-info/entry_points.txt +0 -0
  149. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit.egg-info/requires.txt +0 -0
  150. {detectkit-0.48.0 → detectkit-0.49.1}/detectkit.egg-info/top_level.txt +0 -0
  151. {detectkit-0.48.0 → detectkit-0.49.1}/requirements.txt +0 -0
  152. {detectkit-0.48.0 → detectkit-0.49.1}/setup.cfg +0 -0
  153. {detectkit-0.48.0 → detectkit-0.49.1}/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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.48.0
3
+ Version: 0.49.1
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -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.48.0"
7
+ __version__ = "0.49.1"
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),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 _record_from_row(row: dict) -> DetectionRecord:
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 = [_record_from_row(r) for r in det_rows]
346
- alerts: list[dict] = []
347
- active_configs = [c for c in (metric_config.alerting or []) if c.enabled and c.channels]
348
- for cfg in active_configs:
349
- config_id = make_alert_config_id(cfg)
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
- for event in orchestrator.replay(records, value_at, start, end):
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
+ """