detectkit 0.49.2__tar.gz → 0.50.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.49.2/detectkit.egg-info → detectkit-0.50.0}/PKG-INFO +1 -1
  2. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -1
  4. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/rules/cli.md +27 -1
  5. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/rules/overview.md +6 -0
  6. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +8 -0
  7. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/config/metric_config.py +4 -3
  8. detectkit-0.50.0/detectkit/config/metric_io.py +90 -0
  9. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/tuning/config_writer.py +9 -15
  10. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/ui/__init__.py +6 -2
  11. detectkit-0.50.0/detectkit/ui/assets/ui.js +323 -0
  12. detectkit-0.50.0/detectkit/ui/metric_files.py +256 -0
  13. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/ui/server.py +214 -8
  14. {detectkit-0.49.2 → detectkit-0.50.0/detectkit.egg-info}/PKG-INFO +1 -1
  15. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit.egg-info/SOURCES.txt +2 -0
  16. detectkit-0.49.2/detectkit/ui/assets/ui.js +0 -255
  17. {detectkit-0.49.2 → detectkit-0.50.0}/LICENSE +0 -0
  18. {detectkit-0.49.2 → detectkit-0.50.0}/MANIFEST.in +0 -0
  19. {detectkit-0.49.2 → detectkit-0.50.0}/README.md +0 -0
  20. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/__init__.py +0 -0
  21. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/__init__.py +0 -0
  22. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/base.py +0 -0
  23. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/branding.py +0 -0
  24. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/email.py +0 -0
  25. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/factory.py +0 -0
  26. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/mattermost.py +0 -0
  27. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/slack.py +0 -0
  28. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/telegram.py +0 -0
  29. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/channels/webhook.py +0 -0
  30. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  31. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  32. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  33. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  34. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  35. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  36. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  37. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  38. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  39. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/__init__.py +0 -0
  40. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/_base.py +0 -0
  41. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/_types.py +0 -0
  42. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/autotuner.py +0 -0
  43. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/config_emitter.py +0 -0
  44. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/crossval.py +0 -0
  45. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/detector_select.py +0 -0
  46. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/distribution.py +0 -0
  47. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/grid_search.py +0 -0
  48. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/labels.py +0 -0
  49. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/result.py +0 -0
  50. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/runner.py +0 -0
  51. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/scoring.py +0 -0
  52. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/seasonality_search.py +0 -0
  53. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/settings.py +0 -0
  54. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/autotune/window_select.py +0 -0
  55. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/__init__.py +0 -0
  56. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/_output.py +0 -0
  57. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  58. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  59. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  60. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  61. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  62. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  63. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  64. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  65. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  66. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/__init__.py +0 -0
  67. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/autotune.py +0 -0
  68. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/clean.py +0 -0
  69. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/init.py +0 -0
  70. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/init_claude.py +0 -0
  71. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/osi.py +0 -0
  72. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/run.py +0 -0
  73. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/test_alert.py +0 -0
  74. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/tune.py +0 -0
  75. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/ui.py +0 -0
  76. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/commands/unlock.py +0 -0
  77. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/cli/main.py +0 -0
  78. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/config/__init__.py +0 -0
  79. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/config/profile.py +0 -0
  80. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/config/project_config.py +0 -0
  81. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/config/validator.py +0 -0
  82. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/core/__init__.py +0 -0
  83. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/core/interval.py +0 -0
  84. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/core/models.py +0 -0
  85. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/__init__.py +0 -0
  86. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/_sql_manager.py +0 -0
  87. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/clickhouse_manager.py +0 -0
  88. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/__init__.py +0 -0
  89. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  90. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  91. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_base.py +0 -0
  92. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  93. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_detections.py +0 -0
  94. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  95. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  96. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_schema.py +0 -0
  97. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  98. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/internal_tables/manager.py +0 -0
  99. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/manager.py +0 -0
  100. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/mysql_manager.py +0 -0
  101. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/postgres_manager.py +0 -0
  102. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/database/tables.py +0 -0
  103. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/__init__.py +0 -0
  104. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/base.py +0 -0
  105. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/factory.py +0 -0
  106. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/seasonality.py +0 -0
  107. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/statistical/__init__.py +0 -0
  108. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  109. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/statistical/iqr.py +0 -0
  110. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/statistical/mad.py +0 -0
  111. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  112. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/detectors/statistical/zscore.py +0 -0
  113. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/loaders/__init__.py +0 -0
  114. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/loaders/metric_loader.py +0 -0
  115. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/loaders/query_template.py +0 -0
  116. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/__init__.py +0 -0
  117. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/error_dispatch.py +0 -0
  118. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  119. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  120. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  121. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  122. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  123. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  124. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  125. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/reporting/__init__.py +0 -0
  126. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/reporting/assets/report.js +0 -0
  127. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/reporting/builder.py +0 -0
  128. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/reporting/html_report.py +0 -0
  129. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/semantic/__init__.py +0 -0
  130. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/semantic/errors.py +0 -0
  131. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/semantic/exporter.py +0 -0
  132. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/semantic/importer.py +0 -0
  133. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/semantic/osi_model.py +0 -0
  134. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/semantic/query_gen.py +0 -0
  135. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/tuning/__init__.py +0 -0
  136. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/tuning/assets/tune.js +0 -0
  137. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/tuning/html.py +0 -0
  138. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/tuning/payload.py +0 -0
  139. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/tuning/server.py +0 -0
  140. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/ui/html.py +0 -0
  141. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/ui/jobs.py +0 -0
  142. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/ui/overview.py +0 -0
  143. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/utils/__init__.py +0 -0
  144. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/utils/datetime_utils.py +0 -0
  145. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/utils/env_interpolation.py +0 -0
  146. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/utils/json_utils.py +0 -0
  147. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit/utils/stats.py +0 -0
  148. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit.egg-info/dependency_links.txt +0 -0
  149. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit.egg-info/entry_points.txt +0 -0
  150. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit.egg-info/requires.txt +0 -0
  151. {detectkit-0.49.2 → detectkit-0.50.0}/detectkit.egg-info/top_level.txt +0 -0
  152. {detectkit-0.49.2 → detectkit-0.50.0}/pyproject.toml +0 -0
  153. {detectkit-0.49.2 → detectkit-0.50.0}/requirements.txt +0 -0
  154. {detectkit-0.49.2 → detectkit-0.50.0}/setup.cfg +0 -0
  155. {detectkit-0.49.2 → detectkit-0.50.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.49.2
3
+ Version: 0.50.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.49.2"
7
+ __version__ = "0.50.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -40,7 +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
+ | A project-wide live overview, driving the pipeline, and creating/editing/deleting metric YAMLs from the browser (`dtk ui`) | `.claude/rules/detectkit/cli.md` |
44
44
  | OSI (Open Semantic Interchange) interop: import an OSI metric into a native metric / export back (`dtk osi`) | `.claude/rules/detectkit/cli.md` |
45
45
 
46
46
  ### Skills
@@ -12,7 +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
+ | `dtk ui` | Project-wide live overview + browser pipeline control panel + create/edit/delete metric YAMLs |
16
16
  | `dtk test-alert <metric>` | Send a mock alert to the metric's channels |
17
17
  | `dtk unlock --select <sel>` | Clear a stuck pipeline lock |
18
18
  | `dtk clean --select <sel>` | Prune internal data that no longer matches the config |
@@ -210,6 +210,32 @@ its own isolated, lock-free session. The `dtk ui` server itself takes **no
210
210
  pipeline lock** and never mutates anything — every spawned command behaves
211
211
  exactly as if typed into a terminal.
212
212
 
213
+ ### Managing metrics from the UI
214
+
215
+ The cockpit header has a **New metric** button — an editor overlay seeded
216
+ with a starter YAML template, with an optional subfolder under `metrics/`;
217
+ saving writes `metrics/[<folder>/]<name>.yml`. Every metric row also has an
218
+ **Edit** action that opens its raw YAML in the same editor. **Save** validates
219
+ server-side **before any write**: YAML syntax → full `MetricConfig` → a deep
220
+ detector-params check (each factory-known detector is actually constructed) —
221
+ an invalid config lands in the editor's error pane with nothing written. A
222
+ successful save **archives the previous file verbatim** to
223
+ `metrics/.history/<metric>/` (the same archive `dtk tune`'s Apply writes,
224
+ excluded from metric discovery) and only then overwrites in place, so the
225
+ text typed is what lands on disk, comments intact (normalized only to end
226
+ with a newline). A save is refused when the file changed on disk after the
227
+ editor was opened (a `dtk tune` Apply or another session saved first) —
228
+ reopen the metric rather than overwrite it. Renaming via `name:`
229
+ is allowed — uniqueness is enforced project-wide — and rows under the old name
230
+ stay in the `_dtk_*` tables until `dtk clean`. **Delete** lives in the edit
231
+ overlay behind an explicit confirmation step, and the server additionally
232
+ requires the request to echo the metric name back; it archives the file
233
+ (`…-deleted.yml`) before removing it, so a delete is reversible by hand, and
234
+ its `_dtk_*` rows likewise remain until `dtk clean`. While a `dtk tune` session
235
+ for that metric is running, Save and Delete are refused (its Apply would race
236
+ the edit). Like the rest of `dtk ui`, metric management takes no pipeline lock
237
+ and never touches the database — it only manages metric YAML files.
238
+
213
239
  ## `dtk test-alert <metric>`
214
240
 
215
241
  Sends a mock alert (fake value/CI/severity) through the metric's configured
@@ -97,6 +97,12 @@ series, watch the band recompute live, then write the config back into the metri
97
97
  — use `dtk tune --select <m>`, the interactive sibling of `dtk autotune`
98
98
  (`cli.md`).
99
99
 
100
+ For a **live**, project-wide view instead of a one-off file, run `dtk ui`: the
101
+ same reports in a browser overlay plus an overview table (alert frequency,
102
+ freshness, quality when incidents are labeled), a panel that drives
103
+ `dtk run` / `dtk autotune` / `dtk unlock`, and an in-browser editor to create,
104
+ edit and delete metric YAML files. See `cli.md`.
105
+
100
106
  ## Glossary
101
107
 
102
108
  - **metric** — a named time series (SQL + interval) you monitor.
@@ -15,6 +15,14 @@ need detail on any field, read the matching file under
15
15
  `.claude/rules/detectkit/` (`metrics.md`, `detectors.md`, `alerting.md`,
16
16
  `project.md`); this skill is the procedure, those are the reference.
17
17
 
18
+ `dtk ui` also has a browser editor for metric YAMLs (a **New metric** button
19
+ and, per row, an **Edit** action), validated the same way before it writes.
20
+ Prefer this skill when scaffolding from scratch — it gathers the query,
21
+ detector and alerting choices and explains each one in context; prefer the UI
22
+ for a quick manual edit while you're already watching the cockpit (nudging a
23
+ threshold, adding a channel) — it's a raw-YAML editor with no procedure behind
24
+ it.
25
+
18
26
  ## Step 0 — Confirm you're in a detectkit project
19
27
 
20
28
  A project root contains `detectkit_project.yml`. Verify it exists in the target
@@ -890,8 +890,9 @@ class MetricConfig(BaseModel):
890
890
  if not data:
891
891
  raise ValueError(f"Empty metric config file: {path}")
892
892
 
893
- # Support nested structure: metric: { ... }
894
- if "metric" in data and isinstance(data["metric"], dict):
895
- data = data["metric"]
893
+ # Support nested structure: metric: { ... } (shared unwrap seam)
894
+ from detectkit.config.metric_io import unwrap_metric_mapping
895
+
896
+ data = unwrap_metric_mapping(data)
896
897
 
897
898
  return cls.model_validate(data)
@@ -0,0 +1,90 @@
1
+ """Shared low-level seams for metric YAML files.
2
+
3
+ Three tiny helpers that were previously re-implemented per caller and had
4
+ already started to drift (``tuning/config_writer.py``'s archive write had no
5
+ same-second collision handling; the nested-form unwrap lived in four copies):
6
+
7
+ - :func:`unwrap_metric_mapping` — the nested ``metric: {...}`` form accepted
8
+ everywhere a metric YAML is read (``MetricConfig.from_yaml_file``, the tune
9
+ write-back, the ``dtk ui`` editor).
10
+ - :func:`safe_metric_stem` — a metric name reduced to one safe path component
11
+ (filenames, ``.history`` archive keys).
12
+ - :func:`archive_metric_text` — the ``metrics/.history/<metric>/`` verbatim
13
+ archive convention shared by ``dtk tune``'s Apply and ``dtk ui``'s
14
+ update/delete, collision-safe within one UTC second.
15
+
16
+ No imports from the rest of detectkit — safe to use from any layer.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ _SAFE_CHAR_RE = re.compile(r"[A-Za-z0-9_.\-]")
27
+
28
+
29
+ def unwrap_metric_mapping(data: dict[str, Any]) -> dict[str, Any]:
30
+ """The metric body of a parsed YAML mapping — unwraps the nested ``metric: {...}`` form.
31
+
32
+ Returns the **same object reference** for the flat form and the nested
33
+ body for the wrapped form, so in-place mutations (the tune write-back
34
+ edits the body inside the original document) behave identically.
35
+ """
36
+ nested = data.get("metric")
37
+ return nested if isinstance(nested, dict) else data
38
+
39
+
40
+ def safe_metric_stem(name: str) -> str:
41
+ """A single safe path component for a metric name — sanitized, never refused.
42
+
43
+ ``MetricConfig`` accepts names (unicode letters, a leading ``-``) that make
44
+ unsafe or awkward filenames; rather than rejecting a valid metric, every
45
+ character outside the safe set is replaced with ``_`` and leading dots or
46
+ dashes are stripped. Path separators can never survive, so the result is
47
+ always one component; a same-stem collision surfaces as "file already
48
+ exists". Used for created files' stems and ``.history`` archive keys.
49
+ """
50
+ stem = "".join(c if _SAFE_CHAR_RE.fullmatch(c) else "_" for c in name)
51
+ stem = stem.lstrip(".-")
52
+ return stem or "metric"
53
+
54
+
55
+ def metric_stamp(now: datetime | None = None) -> str:
56
+ """UTC filesystem-safe timestamp (``20260709T101530Z``) used in archive filenames."""
57
+ now = now or datetime.now(timezone.utc)
58
+ return now.strftime("%Y%m%dT%H%M%SZ")
59
+
60
+
61
+ def archive_metric_text(
62
+ project_root: Path,
63
+ metric_name: str,
64
+ text: str,
65
+ *,
66
+ suffix: str = "",
67
+ stamp: str | None = None,
68
+ now: datetime | None = None,
69
+ ) -> Path:
70
+ """Write *text* verbatim into ``metrics/.history/<key>/`` and return the path.
71
+
72
+ The directory key is the **sanitized** metric name (:func:`safe_metric_stem`)
73
+ — at archive time the name can be attacker/editor-influenced free text
74
+ (an on-disk ``name:`` changed after boot), and joining it raw into a path
75
+ would let ``../`` or an absolute component escape ``metrics/.history/``.
76
+ Two archives within the same UTC second get ``-1``, ``-2``, … suffixes so
77
+ a tune Apply and a UI save landing together never overwrite each other's
78
+ snapshot.
79
+ """
80
+ key = safe_metric_stem(metric_name)
81
+ archive_dir = project_root / "metrics" / ".history" / key
82
+ archive_dir.mkdir(parents=True, exist_ok=True)
83
+ base = f"{key}-{stamp or metric_stamp(now)}{suffix}"
84
+ archive_path = archive_dir / f"{base}.yml"
85
+ counter = 1
86
+ while archive_path.exists():
87
+ archive_path = archive_dir / f"{base}-{counter}.yml"
88
+ counter += 1
89
+ archive_path.write_text(text, encoding="utf-8")
90
+ return archive_path
@@ -18,13 +18,14 @@ others and, when an alert used ``min_detectors >= 2``, permanently killing it.
18
18
  from __future__ import annotations
19
19
 
20
20
  from dataclasses import dataclass
21
- from datetime import datetime, timezone
21
+ from datetime import datetime
22
22
  from pathlib import Path
23
23
  from typing import Any
24
24
 
25
25
  import yaml
26
26
 
27
27
  from detectkit.config.metric_config import MetricConfig
28
+ from detectkit.config.metric_io import archive_metric_text, metric_stamp, unwrap_metric_mapping
28
29
  from detectkit.detectors.factory import DetectorFactory
29
30
 
30
31
  # The detector types the interactive tuner can emit: the windowed statistical
@@ -68,12 +69,6 @@ class AppliedConfig:
68
69
  preserved: tuple[str, ...] = ()
69
70
 
70
71
 
71
- def _stamp(now: datetime | None = None) -> str:
72
- """UTC filesystem-safe timestamp (``20260624T101530Z``)."""
73
- now = now or datetime.now(timezone.utc)
74
- return now.strftime("%Y%m%dT%H%M%SZ")
75
-
76
-
77
72
  def _apply_consecutive(body: dict[str, Any], consecutive: int) -> None:
78
73
  """Set ``consecutive_anomalies`` on the metric's first alerting config.
79
74
 
@@ -192,9 +187,9 @@ def apply_tuned_config(
192
187
  if not isinstance(raw, dict):
193
188
  raise ValueError(f"metric config is empty or malformed: {original_path}")
194
189
 
195
- # Support the nested `metric: { ... }` form (see MetricConfig.from_yaml_file).
196
- nested = isinstance(raw.get("metric"), dict)
197
- body: dict[str, Any] = raw["metric"] if nested else raw
190
+ # Support the nested `metric: { ... }` form (shared unwrap; the body is a
191
+ # reference into `raw`, so edits below land inside the original document).
192
+ body: dict[str, Any] = unwrap_metric_mapping(raw)
198
193
 
199
194
  existing = body.get("detectors")
200
195
  merged: list[Any] = list(existing) if isinstance(existing, list) else []
@@ -227,11 +222,10 @@ def apply_tuned_config(
227
222
  validated = MetricConfig.model_validate(body)
228
223
  metric_name = validated.name
229
224
 
230
- stamp = _stamp(now)
231
- archive_dir = project_root / "metrics" / ".history" / metric_name
232
- archive_dir.mkdir(parents=True, exist_ok=True)
233
- archive_path = archive_dir / f"{metric_name}-{stamp}.yml"
234
- archive_path.write_text(original_text, encoding="utf-8")
225
+ stamp = metric_stamp(now)
226
+ # Shared, collision-safe archive seam (config/metric_io.py) a UI save and
227
+ # this Apply landing in the same UTC second keep both snapshots.
228
+ archive_path = archive_metric_text(project_root, metric_name, original_text, stamp=stamp)
235
229
 
236
230
  try:
237
231
  archive_rel = str(archive_path.relative_to(project_root))
@@ -3,9 +3,10 @@
3
3
  A localhost web UI over the already-persisted ``_dtk_*`` tables: an overview of
4
4
  every selected metric's alerting behavior (how often alerts fire, per metric /
5
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
6
+ report in an iframe), a pipeline control panel that drives the existing CLI
7
7
  commands (``dtk run`` / ``dtk autotune`` / ``dtk unlock`` as subprocesses,
8
- ``dtk tune`` launched per metric).
8
+ ``dtk tune`` launched per metric), and metric management — creating, editing
9
+ and deleting metric YAML files from a browser editor.
9
10
 
10
11
  ``dtk ui`` is a *superstructure over existing dtk commands*: the server itself
11
12
  never runs the pipeline in-process and takes no pipeline lock — spawned ``dtk
@@ -20,6 +21,9 @@ numbers match what the pipeline would actually have alerted.
20
21
  pipeline control panel.
21
22
  - ``server.py`` — ``build_ui_server`` / ``serve_ui``: routes, token auth, the
22
23
  single ``db_lock`` serializing DB-touching requests.
24
+ - ``metric_files.py`` — the metric-YAML create/update/delete seam: validate
25
+ before write, archive to ``metrics/.history/<metric>/`` before every
26
+ overwrite or delete.
23
27
  - ``html.py`` — ``render_ui_html``: inlines the committed ``assets/ui.js``
24
28
  renderer bundle + the boot payload into one self-contained page.
25
29
  """