detectkit 0.47.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.
Files changed (155) hide show
  1. {detectkit-0.47.0 → detectkit-0.49.0}/MANIFEST.in +1 -0
  2. {detectkit-0.47.0/detectkit.egg-info → detectkit-0.49.0}/PKG-INFO +1 -1
  3. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/__init__.py +1 -1
  4. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -0
  5. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/cli.md +52 -3
  6. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +11 -3
  7. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/run.py +48 -47
  8. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/tune.py +6 -1
  9. detectkit-0.49.0/detectkit/cli/commands/ui.py +97 -0
  10. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/main.py +54 -0
  11. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/config/validator.py +40 -4
  12. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_base.py +11 -2
  13. detectkit-0.49.0/detectkit/reporting/assets/report.js +80 -0
  14. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/reporting/builder.py +63 -28
  15. detectkit-0.49.0/detectkit/tuning/assets/tune.js +187 -0
  16. detectkit-0.49.0/detectkit/tuning/config_writer.py +253 -0
  17. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/tuning/payload.py +81 -7
  18. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/tuning/server.py +47 -5
  19. detectkit-0.49.0/detectkit/ui/__init__.py +25 -0
  20. detectkit-0.49.0/detectkit/ui/assets/ui.js +243 -0
  21. detectkit-0.49.0/detectkit/ui/html.py +84 -0
  22. detectkit-0.49.0/detectkit/ui/jobs.py +334 -0
  23. detectkit-0.49.0/detectkit/ui/overview.py +475 -0
  24. detectkit-0.49.0/detectkit/ui/server.py +614 -0
  25. {detectkit-0.47.0 → detectkit-0.49.0/detectkit.egg-info}/PKG-INFO +1 -1
  26. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit.egg-info/SOURCES.txt +7 -0
  27. {detectkit-0.47.0 → detectkit-0.49.0}/pyproject.toml +2 -0
  28. detectkit-0.47.0/detectkit/reporting/assets/report.js +0 -80
  29. detectkit-0.47.0/detectkit/tuning/assets/tune.js +0 -187
  30. detectkit-0.47.0/detectkit/tuning/config_writer.py +0 -138
  31. {detectkit-0.47.0 → detectkit-0.49.0}/LICENSE +0 -0
  32. {detectkit-0.47.0 → detectkit-0.49.0}/README.md +0 -0
  33. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/__init__.py +0 -0
  34. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/__init__.py +0 -0
  35. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/base.py +0 -0
  36. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/branding.py +0 -0
  37. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/email.py +0 -0
  38. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/factory.py +0 -0
  39. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/mattermost.py +0 -0
  40. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/slack.py +0 -0
  41. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/telegram.py +0 -0
  42. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/channels/webhook.py +0 -0
  43. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  44. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  45. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  46. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  47. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  48. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  49. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  50. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  51. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  52. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/__init__.py +0 -0
  53. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/_base.py +0 -0
  54. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/_types.py +0 -0
  55. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/autotuner.py +0 -0
  56. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/config_emitter.py +0 -0
  57. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/crossval.py +0 -0
  58. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/detector_select.py +0 -0
  59. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/distribution.py +0 -0
  60. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/grid_search.py +0 -0
  61. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/labels.py +0 -0
  62. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/result.py +0 -0
  63. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/runner.py +0 -0
  64. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/scoring.py +0 -0
  65. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/seasonality_search.py +0 -0
  66. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/settings.py +0 -0
  67. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/autotune/window_select.py +0 -0
  68. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/__init__.py +0 -0
  69. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/_output.py +0 -0
  70. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  71. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  72. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  73. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  74. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  75. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  76. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  77. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  78. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  79. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  80. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/__init__.py +0 -0
  81. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/autotune.py +0 -0
  82. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/clean.py +0 -0
  83. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/init.py +0 -0
  84. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/init_claude.py +0 -0
  85. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/osi.py +0 -0
  86. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/test_alert.py +0 -0
  87. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/cli/commands/unlock.py +0 -0
  88. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/config/__init__.py +0 -0
  89. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/config/metric_config.py +0 -0
  90. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/config/profile.py +0 -0
  91. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/config/project_config.py +0 -0
  92. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/core/__init__.py +0 -0
  93. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/core/interval.py +0 -0
  94. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/core/models.py +0 -0
  95. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/__init__.py +0 -0
  96. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/_sql_manager.py +0 -0
  97. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/clickhouse_manager.py +0 -0
  98. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/__init__.py +0 -0
  99. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  100. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  101. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  102. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_detections.py +0 -0
  103. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  104. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  105. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_schema.py +0 -0
  106. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  107. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/internal_tables/manager.py +0 -0
  108. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/manager.py +0 -0
  109. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/mysql_manager.py +0 -0
  110. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/postgres_manager.py +0 -0
  111. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/database/tables.py +0 -0
  112. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/__init__.py +0 -0
  113. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/base.py +0 -0
  114. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/factory.py +0 -0
  115. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/seasonality.py +0 -0
  116. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/statistical/__init__.py +0 -0
  117. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  118. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/statistical/iqr.py +0 -0
  119. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/statistical/mad.py +0 -0
  120. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  121. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/detectors/statistical/zscore.py +0 -0
  122. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/loaders/__init__.py +0 -0
  123. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/loaders/metric_loader.py +0 -0
  124. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/loaders/query_template.py +0 -0
  125. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/__init__.py +0 -0
  126. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/error_dispatch.py +0 -0
  127. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  128. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  129. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  130. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  131. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  132. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  133. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  134. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/reporting/__init__.py +0 -0
  135. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/reporting/html_report.py +0 -0
  136. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/semantic/__init__.py +0 -0
  137. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/semantic/errors.py +0 -0
  138. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/semantic/exporter.py +0 -0
  139. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/semantic/importer.py +0 -0
  140. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/semantic/osi_model.py +0 -0
  141. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/semantic/query_gen.py +0 -0
  142. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/tuning/__init__.py +0 -0
  143. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/tuning/html.py +0 -0
  144. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/utils/__init__.py +0 -0
  145. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/utils/datetime_utils.py +0 -0
  146. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/utils/env_interpolation.py +0 -0
  147. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/utils/json_utils.py +0 -0
  148. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit/utils/stats.py +0 -0
  149. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit.egg-info/dependency_links.txt +0 -0
  150. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit.egg-info/entry_points.txt +0 -0
  151. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit.egg-info/requires.txt +0 -0
  152. {detectkit-0.47.0 → detectkit-0.49.0}/detectkit.egg-info/top_level.txt +0 -0
  153. {detectkit-0.47.0 → detectkit-0.49.0}/requirements.txt +0 -0
  154. {detectkit-0.47.0 → detectkit-0.49.0}/setup.cfg +0 -0
  155. {detectkit-0.47.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.47.0
3
+ Version: 0.49.0
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.47.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 |
@@ -97,7 +98,14 @@ anomalies**) and the **y = 0** toggle — stay visible in every mode. The chart
97
98
  **zoomable** (scroll/drag + navigator strip) with a **"Points shown"** trim slider.
98
99
  Clicking **Apply** writes the chosen
99
100
  config back into the metric YAML **in place** (autotune, by contrast, writes a new
100
- `__tuned_<id>.yml` and never edits the original). Reads the metric's loaded
101
+ `__tuned_<id>.yml` and never edits the original). Apply **merges** it rewrites
102
+ only the detector(s) you tuned and keeps every **other** detector unchanged, so a
103
+ `manual_bounds` floor alongside a `mad` detector (and a `min_detectors: 2` alert)
104
+ survives a retune. If a metric configures **more than one detector**, a **Tuning
105
+ detector** picker in the Tune rail lets you choose which one to tune (the cockpit
106
+ shows one band at a time); switching re-seeds every knob from that detector, and
107
+ non-tunable detectors (`prophet`/`timesfm`) plus the ones you didn't touch are
108
+ listed as "preserved on Apply". Reads the metric's loaded
101
109
  datapoints (run `dtk run --steps load` first if empty); the selector must resolve
102
110
  to a single metric.
103
111
 
@@ -155,12 +163,53 @@ relative to zero.
155
163
 
156
164
  Safe write-back: the config is validated before anything is written, the previous
157
165
  YAML is archived under `metrics/.history/<metric>/`, and only then is the metric
158
- overwritten. Takes **no pipeline lock** (it only edits a config file); re-run
159
- `dtk run` afterward to recompute detections under the new config.
166
+ overwritten (the merge keeps your other detectors verbatim; the re-emitted header
167
+ names what was updated vs preserved). The `.history/` archive is **not** discovered
168
+ as a live metric, so a tuned metric never collides with its own snapshots as a
169
+ "duplicate metric name". Takes **no pipeline lock** (it only edits a config file);
170
+ re-run `dtk run` afterward to recompute detections under the new config.
160
171
  `--no-serve` writes a static read-only preview HTML instead (no write-back —
161
172
  **Save incidents** downloads the labels file); `--from` / `--to` bound the window;
162
173
  `--no-open` prints the URL without opening a browser.
163
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
+
164
213
  ## `dtk test-alert <metric>`
165
214
 
166
215
  Sends a mock alert (fake value/CI/severity) through the metric's configured
@@ -146,9 +146,17 @@ false-alert rate exceeds it (tuning-only; labeling stays optional).
146
146
  When the user is happy, they click **Apply to metric**. detectkit then, in order:
147
147
  **validates** the config (a bad/untunable config is rejected and **nothing is
148
148
  written**), **archives** the current YAML verbatim under
149
- `metrics/.history/<metric>/`, and **re-emits** the metric in place with the tuned
150
- detector (and updates the first alerting block's `consecutive_anomalies` if it has
151
- one). Applying ends the session; saving incidents does not.
149
+ `metrics/.history/<metric>/`, and **re-emits** the metric in place, **merging** the
150
+ tuned detector(s) back in (and updating the first alerting block's
151
+ `consecutive_anomalies` if it has one). Applying ends the session; saving incidents
152
+ does not. **Merge, not replace:** only the detector(s) the user tuned are
153
+ rewritten; every other detector is kept **verbatim** — so a metric with a
154
+ `manual_bounds` floor alongside a `mad` detector (and a `min_detectors: 2` alert)
155
+ keeps both. When a metric has more than one detector, the Tune rail shows a
156
+ **Tuning detector** picker to choose which one to tune. If the user asks to tune a
157
+ metric that has several detectors, reassure them the others are preserved. (The
158
+ `.history/` archive is not discovered as a live metric, so it never causes a
159
+ "duplicate metric name" error.)
152
160
 
153
161
  ## Step 5 — Recompute under the new config
154
162
 
@@ -12,7 +12,11 @@ import click
12
12
  from detectkit.config.metric_config import MetricConfig
13
13
  from detectkit.config.profile import ProfilesConfig
14
14
  from detectkit.config.project_config import ProjectConfig
15
- from detectkit.config.validator import validate_metric_uniqueness
15
+ from detectkit.config.validator import (
16
+ discover_metric_files,
17
+ is_discoverable_metric_file,
18
+ validate_metric_uniqueness,
19
+ )
16
20
  from detectkit.database.internal_tables import InternalTablesManager
17
21
  from detectkit.orchestration.error_dispatch import dispatch_project_error_alert
18
22
  from detectkit.orchestration.task_manager import PipelineStep, TaskManager
@@ -433,17 +437,16 @@ def select_metrics(selector: str, project_root: Path) -> list[tuple[Path, Metric
433
437
  if selector == "*":
434
438
  # "all metrics" — search recursively so nested metrics are included
435
439
  # (mirrors validate_project_metrics); a plain glob of "metrics/*"
436
- # would only see the top level.
437
- metric_paths = [p for sub in ("**/*.yml", "**/*.yaml") for p in metrics_dir.glob(sub)]
440
+ # would only see the top level. Excludes the metrics/.history/ archive.
441
+ metric_paths = discover_metric_files(metrics_dir)
438
442
  else:
439
443
  pattern = selector if selector.startswith("metrics/") else f"metrics/{selector}"
440
444
  # Keep only metric files: a bare glob also matches the `.gitkeep`
441
- # stub created by `dtk init`, any other non-YAML files, and
442
- # directories all of which would crash the YAML parser.
445
+ # stub created by `dtk init`, any other non-YAML files, directories, and
446
+ # the metrics/.history/ archive dtk tune writes (whose snapshots keep the
447
+ # original name:) — all of which would crash the parser or collide on name.
443
448
  metric_paths = [
444
- p
445
- for p in project_root.glob(pattern)
446
- if p.is_file() and p.suffix in (".yml", ".yaml")
449
+ p for p in project_root.glob(pattern) if is_discoverable_metric_file(p, metrics_dir)
447
450
  ]
448
451
  # Metric name selector
449
452
  else:
@@ -485,26 +488,25 @@ def find_metrics_by_tag(metrics_dir: Path, tag: str) -> list[Path]:
485
488
 
486
489
  matching_metrics = []
487
490
 
488
- # Search both .yml and .yaml extensions (consistent with find_metric_by_name)
489
- for pattern in ["**/*.yml", "**/*.yaml"]:
490
- for metric_file in metrics_dir.glob(pattern):
491
- try:
492
- with open(metric_file) as f:
493
- config = yaml.safe_load(f)
494
-
495
- if config and "tags" in config:
496
- if tag in config["tags"]:
497
- matching_metrics.append(metric_file)
498
- except Exception as e:
499
- # Warn about unparseable files but continue searching
500
- click.echo(
501
- click.style(
502
- f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
503
- fg="yellow",
504
- ),
505
- err=True,
506
- )
507
- continue
491
+ # Live metric files only (excludes the metrics/.history/ archive).
492
+ for metric_file in discover_metric_files(metrics_dir):
493
+ try:
494
+ with open(metric_file) as f:
495
+ config = yaml.safe_load(f)
496
+
497
+ if config and "tags" in config:
498
+ if tag in config["tags"]:
499
+ matching_metrics.append(metric_file)
500
+ except Exception as e:
501
+ # Warn about unparseable files but continue searching
502
+ click.echo(
503
+ click.style(
504
+ f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
505
+ fg="yellow",
506
+ ),
507
+ err=True,
508
+ )
509
+ continue
508
510
 
509
511
  return matching_metrics
510
512
 
@@ -522,25 +524,24 @@ def find_metric_by_name(metrics_dir: Path, name: str) -> Path | None:
522
524
  """
523
525
  import yaml
524
526
 
525
- # Search both .yml and .yaml extensions
526
- for pattern in ["**/*.yml", "**/*.yaml"]:
527
- for metric_file in metrics_dir.glob(pattern):
528
- try:
529
- with open(metric_file) as f:
530
- config = yaml.safe_load(f)
531
-
532
- if config and config.get("name") == name:
533
- return metric_file
534
- except Exception as e:
535
- # Warn about unparseable files but continue searching
536
- click.echo(
537
- click.style(
538
- f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
539
- fg="yellow",
540
- ),
541
- err=True,
542
- )
543
- continue
527
+ # Live metric files only (excludes the metrics/.history/ archive).
528
+ for metric_file in discover_metric_files(metrics_dir):
529
+ try:
530
+ with open(metric_file) as f:
531
+ config = yaml.safe_load(f)
532
+
533
+ if config and config.get("name") == name:
534
+ return metric_file
535
+ except Exception as e:
536
+ # Warn about unparseable files but continue searching
537
+ click.echo(
538
+ click.style(
539
+ f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
540
+ fg="yellow",
541
+ ),
542
+ err=True,
543
+ )
544
+ continue
544
545
 
545
546
  return None
546
547
 
@@ -153,6 +153,11 @@ def run_tune(
153
153
  return False
154
154
 
155
155
  click.echo(f" Archived previous config: {applied.archived.relative_to(project_root)}")
156
- echo_done(f"{name}: applied tuned detector → {applied.saved.relative_to(project_root)}")
156
+ updated = ", ".join(applied.updated) or "detector"
157
+ echo_done(
158
+ f"{name}: applied tuned detector(s) [{updated}] → {applied.saved.relative_to(project_root)}"
159
+ )
160
+ if applied.preserved:
161
+ click.echo(f" Preserved the metric's other detector(s): {', '.join(applied.preserved)}")
157
162
  click.echo(f" Re-run `dtk run --select {name}` to recompute detections under the new config.")
158
163
  return True
@@ -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",
@@ -10,6 +10,44 @@ from pathlib import Path
10
10
  from detectkit.config.metric_config import MetricConfig
11
11
 
12
12
 
13
+ def is_discoverable_metric_file(path: Path, metrics_dir: Path) -> bool:
14
+ """True for a *live* metric YAML — excludes the ``metrics/.history/`` archive.
15
+
16
+ ``dtk tune`` archives the previous metric config under
17
+ ``metrics/.history/<metric>/`` before writing the tuned version in place. Those
18
+ snapshots keep the original ``name:``, so discovering them as live metrics would
19
+ (a) flag every tuned metric as a duplicate-name conflict and (b) run stale
20
+ configs. Python's ``pathlib`` glob traverses hidden directories (unlike shell
21
+ globbing), so they must be filtered out explicitly. Any hidden path component
22
+ (a leading dot) *under* ``metrics/`` is skipped — this covers ``.history`` and
23
+ any editor/VCS scratch dir a user drops there, while a project rooted under a
24
+ dot-directory (checked only below ``metrics/``) is unaffected.
25
+ """
26
+ if not path.is_file() or path.suffix not in (".yml", ".yaml"):
27
+ return False
28
+ try:
29
+ parts = path.relative_to(metrics_dir).parts
30
+ except ValueError:
31
+ parts = path.parts
32
+ return not any(part.startswith(".") for part in parts)
33
+
34
+
35
+ def discover_metric_files(metrics_dir: Path) -> list[Path]:
36
+ """All live metric YAMLs under ``metrics/`` (recursive), excluding the ``.history`` archive.
37
+
38
+ The single discovery seam shared by project validation and CLI metric selection,
39
+ so the ``.history`` exclusion can't drift between them. Returns a sorted list for
40
+ deterministic ordering.
41
+ """
42
+ files = [
43
+ p
44
+ for pattern in ("**/*.yml", "**/*.yaml")
45
+ for p in metrics_dir.glob(pattern)
46
+ if is_discoverable_metric_file(p, metrics_dir)
47
+ ]
48
+ return sorted(set(files))
49
+
50
+
13
51
  def validate_metric_uniqueness(metric_paths: list[Path]) -> list[tuple[Path, MetricConfig]]:
14
52
  """
15
53
  Load all metrics and validate that metric names are unique.
@@ -106,10 +144,8 @@ def validate_project_metrics(project_root: Path) -> list[tuple[Path, MetricConfi
106
144
  f" your_metric.yml\n"
107
145
  )
108
146
 
109
- # Find all metric files recursively
110
- metric_paths = []
111
- for pattern in ["**/*.yml", "**/*.yaml"]:
112
- metric_paths.extend(metrics_dir.glob(pattern))
147
+ # Find all metric files recursively (excluding the metrics/.history/ archive).
148
+ metric_paths = discover_metric_files(metrics_dir)
113
149
 
114
150
  if not metric_paths:
115
151
  raise ValueError(
@@ -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