detectkit 0.21.0__tar.gz → 0.22.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 (126) hide show
  1. {detectkit-0.21.0/detectkit.egg-info → detectkit-0.22.0}/PKG-INFO +1 -1
  2. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/__init__.py +1 -1
  3. detectkit-0.22.0/detectkit/autotune/html_labeler.py +362 -0
  4. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/autotune.md +17 -11
  5. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +10 -5
  6. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/commands/autotune.py +31 -1
  7. {detectkit-0.21.0 → detectkit-0.22.0/detectkit.egg-info}/PKG-INFO +1 -1
  8. detectkit-0.21.0/detectkit/autotune/html_labeler.py +0 -219
  9. {detectkit-0.21.0 → detectkit-0.22.0}/LICENSE +0 -0
  10. {detectkit-0.21.0 → detectkit-0.22.0}/MANIFEST.in +0 -0
  11. {detectkit-0.21.0 → detectkit-0.22.0}/README.md +0 -0
  12. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/__init__.py +0 -0
  13. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/__init__.py +0 -0
  14. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/base.py +0 -0
  15. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/branding.py +0 -0
  16. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/email.py +0 -0
  17. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/factory.py +0 -0
  18. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/mattermost.py +0 -0
  19. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/slack.py +0 -0
  20. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/telegram.py +0 -0
  21. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/channels/webhook.py +0 -0
  22. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  23. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  24. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  25. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  26. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  27. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  28. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  29. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  30. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/__init__.py +0 -0
  31. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/_base.py +0 -0
  32. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/_types.py +0 -0
  33. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/autotuner.py +0 -0
  34. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/config_emitter.py +0 -0
  35. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/crossval.py +0 -0
  36. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/detector_select.py +0 -0
  37. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/distribution.py +0 -0
  38. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/grid_search.py +0 -0
  39. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/labels.py +0 -0
  40. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/result.py +0 -0
  41. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/scoring.py +0 -0
  42. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/seasonality_search.py +0 -0
  43. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/settings.py +0 -0
  44. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/autotune/window_select.py +0 -0
  45. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/__init__.py +0 -0
  46. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/_output.py +0 -0
  47. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  48. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  49. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  50. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  51. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  52. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  53. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  54. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  55. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  56. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  57. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/commands/__init__.py +0 -0
  58. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/commands/clean.py +0 -0
  59. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/commands/init.py +0 -0
  60. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/commands/init_claude.py +0 -0
  61. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/commands/run.py +0 -0
  62. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/commands/test_alert.py +0 -0
  63. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/commands/unlock.py +0 -0
  64. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/cli/main.py +0 -0
  65. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/config/__init__.py +0 -0
  66. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/config/metric_config.py +0 -0
  67. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/config/profile.py +0 -0
  68. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/config/project_config.py +0 -0
  69. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/config/validator.py +0 -0
  70. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/core/__init__.py +0 -0
  71. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/core/interval.py +0 -0
  72. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/core/models.py +0 -0
  73. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/__init__.py +0 -0
  74. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/_sql_manager.py +0 -0
  75. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/clickhouse_manager.py +0 -0
  76. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/__init__.py +0 -0
  77. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  78. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  79. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_base.py +0 -0
  80. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  81. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_detections.py +0 -0
  82. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  83. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  84. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_schema.py +0 -0
  85. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  86. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/internal_tables/manager.py +0 -0
  87. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/manager.py +0 -0
  88. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/mysql_manager.py +0 -0
  89. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/postgres_manager.py +0 -0
  90. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/database/tables.py +0 -0
  91. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/__init__.py +0 -0
  92. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/base.py +0 -0
  93. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/factory.py +0 -0
  94. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/seasonality.py +0 -0
  95. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/statistical/__init__.py +0 -0
  96. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  97. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/statistical/iqr.py +0 -0
  98. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/statistical/mad.py +0 -0
  99. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  100. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/detectors/statistical/zscore.py +0 -0
  101. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/loaders/__init__.py +0 -0
  102. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/loaders/metric_loader.py +0 -0
  103. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/loaders/query_template.py +0 -0
  104. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/__init__.py +0 -0
  105. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/error_dispatch.py +0 -0
  106. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  107. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  108. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  109. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  110. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  111. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  112. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  113. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/utils/__init__.py +0 -0
  114. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/utils/datetime_utils.py +0 -0
  115. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/utils/env_interpolation.py +0 -0
  116. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/utils/json_utils.py +0 -0
  117. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit/utils/stats.py +0 -0
  118. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit.egg-info/SOURCES.txt +0 -0
  119. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit.egg-info/dependency_links.txt +0 -0
  120. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit.egg-info/entry_points.txt +0 -0
  121. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit.egg-info/requires.txt +0 -0
  122. {detectkit-0.21.0 → detectkit-0.22.0}/detectkit.egg-info/top_level.txt +0 -0
  123. {detectkit-0.21.0 → detectkit-0.22.0}/pyproject.toml +0 -0
  124. {detectkit-0.21.0 → detectkit-0.22.0}/requirements.txt +0 -0
  125. {detectkit-0.21.0 → detectkit-0.22.0}/setup.cfg +0 -0
  126. {detectkit-0.21.0 → detectkit-0.22.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.21.0
3
+ Version: 0.22.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.21.0"
7
+ __version__ = "0.22.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -0,0 +1,362 @@
1
+ """Generate a self-contained HTML labeler for a metric series.
2
+
3
+ Emits a single HTML file (inline CSS/JS, no CDN) with the series embedded as a
4
+ JSON literal: a zoomable/pannable canvas chart where the user click-drags over
5
+ incident spans (with an optional per-incident description) and exports a labels
6
+ file in the canonical schema, fed back via
7
+ ``dtk autotune --select <metric> --incidents <file-or-dir>``.
8
+
9
+ The page is offline-only — a browser cannot write to the project, so Export
10
+ downloads a **versioned** file (``<metric>-<UTC-stamp>.yml``); drop it into
11
+ ``incidents/<metric>/`` to keep every labeling round (``--incidents`` accepts
12
+ that directory and uses the newest version).
13
+
14
+ Docs sync: the autotune reference page + landing embed a *live* copy of this
15
+ output (``docs/examples/autotune-labeler.html``) so the site always shows the
16
+ real UI. After changing the template below, regenerate that example so the docs
17
+ don't drift: ``python website/scripts/gen-labeler-example.py`` (also in the
18
+ release checklist).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from datetime import datetime, timedelta
24
+
25
+ import numpy as np
26
+
27
+ from detectkit.utils.json_utils import json_dumps_sorted
28
+
29
+
30
+ def _ts_to_str(ts64: np.datetime64) -> str:
31
+ ms = int(ts64.astype("datetime64[ms]").astype(np.int64))
32
+ return (datetime(1970, 1, 1) + timedelta(milliseconds=ms)).strftime("%Y-%m-%d %H:%M:%S")
33
+
34
+
35
+ # Built with .replace() (not .format()), so braces are literal — keep them single.
36
+ # Self-contained: inline brand styling/logo/JS, no network. Palette + fonts mirror
37
+ # website/src/styles/brand.css (.claude/rules/design.md); incident bands use the
38
+ # anomaly status color, the drag preview the no-data color.
39
+ _TEMPLATE = """<!doctype html>
40
+ <meta charset="utf-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1">
42
+ <title>detectkit · label incidents · __METRIC__</title>
43
+ <style>
44
+ :root {
45
+ --clay:#d15b36; --clay-700:#b4471f; --paper:#f5f1e8; --muted:#6e675b; --faint:#9a9384;
46
+ --term-bg:#211e1a; --term-surface:#1b1916; --term-border:#332f29; --term-text:#c9c2b4;
47
+ --anomaly:#d63232; --nodata:#f0ad4e;
48
+ --ui:'Schibsted Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
49
+ --mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
50
+ }
51
+ * { box-sizing: border-box; }
52
+ body { font-family: var(--ui); margin: 0; background: var(--term-bg); color: var(--term-text);
53
+ -webkit-font-smoothing: antialiased; }
54
+ .shell { max-width: 1080px; margin: 0 auto; padding: 22px 22px 44px; }
55
+ .brand { display:flex; align-items:center; gap:9px; margin-bottom: 14px; }
56
+ .brand svg { width: 26px; height: 26px; border-radius: 7px; display:block; }
57
+ .brand b { color: var(--paper); font-weight: 600; font-size: 15px; letter-spacing: .2px; }
58
+ .brand span { color: var(--faint); font-size: 12px; }
59
+ h1 { font-size: 18px; line-height: 1.3; margin: 0 0 6px; color: var(--paper); font-weight: 600; }
60
+ h1 code { color: var(--clay); font-family: var(--mono); font-size: .82em; }
61
+ .hint { color: var(--faint); font-size: 13px; margin: 0 0 18px; line-height: 1.55; }
62
+ .hint code, code.k { color: var(--term-text); font-family: var(--mono); font-size: 12px;
63
+ background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 5px; padding: 1px 6px; }
64
+ .toolbar { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom: 12px; }
65
+ button { font-family: var(--ui); font-size: 13px; font-weight: 500; border: 0; border-radius: 7px;
66
+ padding: 9px 15px; cursor: pointer; transition: background .12s ease, border-color .12s ease, color .12s ease; }
67
+ button.primary { background: var(--clay); color: #fff; }
68
+ button.primary:hover { background: var(--clay-700); }
69
+ button.ghost { background: transparent; color: var(--term-text); border: 1px solid var(--term-border); }
70
+ button.ghost:hover { border-color: var(--faint); color: var(--paper); }
71
+ .summary { margin-left: auto; color: var(--faint); font-size: 12.5px; font-family: var(--mono); }
72
+ .summary b { color: var(--clay); font-weight: 600; }
73
+ canvas#c { width: 100%; height: clamp(300px, 44vh, 500px); display:block; touch-action: none;
74
+ background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
75
+ .zoombar { display:flex; align-items:center; gap:8px; margin: 10px 0 6px; }
76
+ .rangelbl { margin-left: auto; color: var(--faint); font-size: 12px; font-family: var(--mono); }
77
+ canvas#ov { width: 100%; height: 66px; display:block; touch-action: none;
78
+ background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: grab; }
79
+ .navhint { color: var(--faint); font-size: 12px; margin: 7px 2px 0; }
80
+ .empty { color: var(--faint); font-size: 13px; margin: 18px 2px; font-style: italic; }
81
+ ul { list-style: none; margin: 16px 0 0; padding: 0; }
82
+ li { display:flex; align-items:center; gap:11px; padding: 9px 12px; font-size: 13px; flex-wrap: wrap;
83
+ border: 1px solid var(--term-border); border-radius: 8px; margin-bottom: 7px; background: var(--term-surface); }
84
+ li .dot { width:9px; height:9px; border-radius:50%; background: var(--anomaly); flex: 0 0 auto; }
85
+ li .span { font-family: var(--mono); color: var(--term-text); }
86
+ li .dur { color: var(--faint); font-size: 12px; }
87
+ li input.desc { flex: 1 1 220px; min-width: 160px; background: var(--term-bg); color: var(--paper);
88
+ border: 1px solid var(--term-border); border-radius: 6px; padding: 6px 9px; font-family: var(--ui); font-size: 12.5px; }
89
+ li input.desc::placeholder { color: var(--muted); }
90
+ li input.desc:focus { outline: none; border-color: var(--clay); }
91
+ li button { margin-left: auto; padding: 5px 11px; font-size: 12px; }
92
+ footer { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--term-border);
93
+ color: var(--faint); font-size: 12px; line-height: 1.6; }
94
+ footer code { font-family: var(--mono); color: var(--term-text); }
95
+ </style>
96
+ <div class="shell">
97
+ <div class="brand">
98
+ <svg viewBox="0 0 100 100" aria-hidden="true"><rect x="3" y="3" width="94" height="94" rx="26" fill="#D15B36"/><polyline points="14,62 36,62 50,22 64,62 86,62" fill="none" stroke="#FBF9F3" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="50" cy="22" r="6.5" fill="#FBF9F3"/></svg>
99
+ <b>detectkit</b><span>· incident labeler</span>
100
+ </div>
101
+ <h1>Label incidents — <code>__METRIC__</code></h1>
102
+ <p class="hint">Click-drag across the chart to mark each real incident, add a short description, then
103
+ <b>Export</b>. Save the file into <code class="k">incidents/__METRIC__/</code> and run
104
+ <code class="k">dtk autotune --select __METRIC__ --incidents incidents/__METRIC__/</code></p>
105
+ <div class="toolbar">
106
+ <button id="export" class="primary">Export labels</button>
107
+ <button id="clear" class="ghost">Clear all</button>
108
+ <span id="summary" class="summary"></span>
109
+ </div>
110
+ <canvas id="c" aria-label="metric series — drag to mark an incident, scroll to zoom"></canvas>
111
+ <div class="zoombar">
112
+ <button id="zreset" class="ghost">Reset zoom</button>
113
+ <span id="range" class="rangelbl"></span>
114
+ </div>
115
+ <canvas id="ov" aria-label="navigator — drag the window to pan, its edges to stretch the view"></canvas>
116
+ <div class="navhint">Scroll to zoom where you point · double-click to reset · drag the navigator window
117
+ below to move, or drag its edges to stretch / squeeze the view.</div>
118
+ <div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
119
+ <ul id="list"></ul>
120
+ <footer>All times UTC · self-contained, nothing leaves your browser · re-label any time —
121
+ exports are versioned (<code>__METRIC__-&lt;timestamp&gt;.yml</code>), so keep every round in
122
+ <code>incidents/__METRIC__/</code>. Generated by <code>dtk autotune --label</code>.</footer>
123
+ </div>
124
+ <script>
125
+ const DATA = __PAYLOAD__;
126
+ const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
127
+ const N = pts.length;
128
+ const vraw = pts.filter(p => p.v !== null).map(p => p.v);
129
+ const vmin0 = vraw.length ? Math.min.apply(null, vraw) : 0;
130
+ const vmax0 = vraw.length ? Math.max.apply(null, vraw) : 1;
131
+ const vpad = (vmax0 - vmin0) * 0.06 || 1;
132
+ const vmin = vmin0 - vpad, vmax = vmax0 + vpad;
133
+ const tmin = N ? pts[0].ts : 0, tmax = N ? pts[N-1].ts : 1, fullSpan = (tmax - tmin) || 1;
134
+ const step = fullSpan / Math.max(1, N - 1);
135
+ const minSpan = Math.max(step * 8, 1000);
136
+ let viewMin = tmin, viewMax = tmax;
137
+ const incidents = [];
138
+ const c = document.getElementById('c'), ov = document.getElementById('ov');
139
+ const ctx = c.getContext('2d'), octx = ov.getContext('2d');
140
+ const M = {l:56, r:16, t:14, b:30}, OM = {l:56, r:16, t:8, b:8};
141
+ let dpr = 1, hover = null, dragging = null, ovAct = null;
142
+
143
+ const clamp = (x,a,b) => Math.max(a, Math.min(b, x));
144
+ const vspan = () => viewMax - viewMin;
145
+ const plotW = () => c.width - (M.l+M.r)*dpr;
146
+ const plotH = () => c.height - (M.t+M.b)*dpr;
147
+ const px = ts => M.l*dpr + (ts-viewMin)/(vspan()||1)*plotW();
148
+ const py = v => (c.height - M.b*dpr) - (v-vmin)/((vmax-vmin)||1)*plotH();
149
+ const ovWd = () => ov.width - (OM.l+OM.r)*dpr;
150
+ const ovHt = () => ov.height - (OM.t+OM.b)*dpr;
151
+ const ovpx = ts => OM.l*dpr + (ts-tmin)/fullSpan*ovWd();
152
+ const ovpy = v => (ov.height - OM.b*dpr) - (v-vmin)/((vmax-vmin)||1)*ovHt();
153
+ const pad2 = n => String(n).padStart(2,'0');
154
+ const fmtTs = ts => new Date(ts).toISOString().slice(0,19).replace('T',' ');
155
+ const fmtTick = (ts, sp) => { const s = new Date(ts).toISOString();
156
+ return sp < 2*86400000 ? s.slice(5,16).replace('T',' ') : s.slice(5,10); };
157
+ const fmtVal = v => { const a = Math.abs(v); return a>=1000 ? v.toFixed(0) : a>=10 ? v.toFixed(1) : v.toFixed(2); };
158
+ function fmtDur(ms) { const m = Math.round(ms/60000); if (m<60) return m+'m';
159
+ const h = Math.floor(m/60), mm = m%60; if (h<24) return h+'h'+(mm?(' '+mm+'m'):'');
160
+ const d = Math.floor(h/24), hh = h%24; return d+'d'+(hh?(' '+hh+'h'):''); }
161
+ const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
162
+ const yamlStr = s => '"' + String(s).replace(/\\\\/g,'\\\\\\\\').replace(/"/g,'\\\\"') + '"';
163
+
164
+ function setView(a, b) {
165
+ let s = b - a;
166
+ if (s < minSpan) { const m=(a+b)/2; a=m-minSpan/2; b=m+minSpan/2; s=minSpan; }
167
+ if (s >= fullSpan) { a=tmin; b=tmax; }
168
+ if (a < tmin) { b += tmin-a; a=tmin; }
169
+ if (b > tmax) { a -= b-tmax; b=tmax; }
170
+ viewMin = clamp(a, tmin, tmax); viewMax = clamp(b, tmin, tmax);
171
+ drawAll();
172
+ }
173
+
174
+ // Series drawing with min/max decimation (one envelope segment per pixel column)
175
+ // so a 100k-point series stays fast and spikes stay visible; direct polyline when
176
+ // few points are visible (zoomed in).
177
+ function drawSeries(ctx2, xfn, yfn, lo, hi, leftDev, widthDev, color, lw) {
178
+ const cols = Math.max(1, Math.round(widthDev)), sp = (hi-lo)||1;
179
+ let vis = 0;
180
+ for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<lo||p.ts>hi) continue; vis++; }
181
+ ctx2.strokeStyle = color; ctx2.lineWidth = lw*dpr; ctx2.beginPath();
182
+ if (vis <= cols) {
183
+ let pen = false;
184
+ for (let i=0;i<N;i++) { const p=pts[i];
185
+ if (p.v===null || p.ts<lo || p.ts>hi) { pen=false; continue; }
186
+ const X=xfn(p.ts), Y=yfn(p.v);
187
+ if (!pen) { ctx2.moveTo(X,Y); pen=true; } else ctx2.lineTo(X,Y);
188
+ }
189
+ } else {
190
+ const cmin = new Array(cols).fill(null), cmax = new Array(cols).fill(null);
191
+ for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<lo||p.ts>hi) continue;
192
+ let col = Math.floor((p.ts-lo)/sp*(cols-1)); col = col<0?0:col>cols-1?cols-1:col;
193
+ if (cmin[col]===null||p.v<cmin[col]) cmin[col]=p.v;
194
+ if (cmax[col]===null||p.v>cmax[col]) cmax[col]=p.v;
195
+ }
196
+ let pen = false;
197
+ for (let col=0;col<cols;col++) { if (cmax[col]===null) { pen=false; continue; }
198
+ const X=leftDev+col, yh=yfn(cmax[col]), yl=yfn(cmin[col]);
199
+ if (!pen) { ctx2.moveTo(X,yh); pen=true; } else ctx2.lineTo(X,yh);
200
+ ctx2.lineTo(X,yl);
201
+ }
202
+ }
203
+ ctx2.stroke();
204
+ }
205
+
206
+ function draw() {
207
+ ctx.clearRect(0,0,c.width,c.height);
208
+ ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
209
+ ctx.textBaseline = 'middle';
210
+ for (let i=0;i<=4;i++) { const v=vmin+(vmax-vmin)*i/4, yy=py(v);
211
+ ctx.strokeStyle='rgba(255,255,255,0.05)'; ctx.lineWidth=1*dpr;
212
+ ctx.beginPath(); ctx.moveTo(M.l*dpr,yy); ctx.lineTo(c.width-M.r*dpr,yy); ctx.stroke();
213
+ ctx.fillStyle='#6e675b'; ctx.textAlign='right'; ctx.fillText(fmtVal(v),(M.l-8)*dpr,yy); }
214
+ ctx.textBaseline = 'top';
215
+ for (let i=0;i<=5;i++) { const ts=viewMin+vspan()*i/5, xx=px(ts);
216
+ ctx.fillStyle='#6e675b'; ctx.textAlign=i===0?'left':i===5?'right':'center';
217
+ ctx.fillText(fmtTick(ts,vspan()), xx, (c.height-M.b+8)*dpr); }
218
+ ctx.save(); ctx.beginPath(); ctx.rect(M.l*dpr, M.t*dpr, plotW(), plotH()); ctx.clip();
219
+ incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
220
+ ctx.fillStyle='rgba(214,50,50,0.20)'; ctx.fillRect(x0, M.t*dpr, x1-x0, plotH());
221
+ ctx.strokeStyle='rgba(214,50,50,0.55)'; ctx.lineWidth=1*dpr; ctx.strokeRect(x0, M.t*dpr, x1-x0, plotH()); });
222
+ if (dragging) { const x0=px(dragging.a), x1=px(dragging.b);
223
+ ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
224
+ drawSeries(ctx, px, py, viewMin, viewMax, M.l*dpr, plotW(), '#d15b36', 1.5);
225
+ ctx.restore();
226
+ if (hover && !dragging && !ovAct) drawHover();
227
+ }
228
+
229
+ function drawHover() {
230
+ let best=null, bd=Infinity;
231
+ for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<viewMin||p.ts>viewMax) continue;
232
+ const d=Math.abs(p.ts-hover.ts); if (d<bd) { bd=d; best=p; } }
233
+ if (!best) return;
234
+ const X=px(best.ts), Y=py(best.v);
235
+ ctx.strokeStyle='rgba(201,194,180,0.25)'; ctx.lineWidth=1*dpr;
236
+ ctx.beginPath(); ctx.moveTo(X, M.t*dpr); ctx.lineTo(X, c.height-M.b*dpr); ctx.stroke();
237
+ ctx.fillStyle='#f5f1e8'; ctx.beginPath(); ctx.arc(X,Y,3*dpr,0,7); ctx.fill();
238
+ const label=fmtTick(best.ts, 0)+' · '+fmtVal(best.v);
239
+ ctx.font=(11*dpr)+'px ui-monospace, monospace'; const tw=ctx.measureText(label).width;
240
+ let bx=X+8*dpr; if (bx+tw+12*dpr > c.width) bx=X-tw-20*dpr;
241
+ ctx.fillStyle='rgba(27,25,22,0.92)'; ctx.strokeStyle='#332f29';
242
+ ctx.fillRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr); ctx.strokeRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr);
243
+ ctx.fillStyle='#c9c2b4'; ctx.textAlign='left'; ctx.textBaseline='middle';
244
+ ctx.fillText(label, bx+6*dpr, M.t*dpr+12);
245
+ }
246
+
247
+ function drawOverview() {
248
+ octx.clearRect(0,0,ov.width,ov.height);
249
+ octx.save(); octx.beginPath(); octx.rect(OM.l*dpr, OM.t*dpr, ovWd(), ovHt()); octx.clip();
250
+ incidents.forEach(iv => { const x0=ovpx(iv.a), x1=ovpx(iv.b);
251
+ octx.fillStyle='rgba(214,50,50,0.30)'; octx.fillRect(x0, OM.t*dpr, x1-x0, ovHt()); });
252
+ drawSeries(octx, ovpx, ovpy, tmin, tmax, OM.l*dpr, ovWd(), 'rgba(209,91,54,0.7)', 1.1);
253
+ octx.restore();
254
+ const vx0=ovpx(viewMin), vx1=ovpx(viewMax);
255
+ octx.fillStyle='rgba(27,25,22,0.55)';
256
+ octx.fillRect(OM.l*dpr, OM.t*dpr, vx0-OM.l*dpr, ovHt());
257
+ octx.fillRect(vx1, OM.t*dpr, (ov.width-OM.r*dpr)-vx1, ovHt());
258
+ octx.fillStyle='rgba(245,241,232,0.06)'; octx.fillRect(vx0, OM.t*dpr, vx1-vx0, ovHt());
259
+ octx.strokeStyle='#d15b36'; octx.lineWidth=1.5*dpr;
260
+ octx.strokeRect(vx0, OM.t*dpr+1, vx1-vx0, ovHt()-2);
261
+ octx.fillStyle='#d15b36'; const hy=OM.t*dpr+ovHt()/2-9*dpr;
262
+ octx.fillRect(vx0-2*dpr, hy, 4*dpr, 18*dpr); octx.fillRect(vx1-2*dpr, hy, 4*dpr, 18*dpr);
263
+ }
264
+
265
+ const tsAt = clientX => { const r=c.getBoundingClientRect();
266
+ const fr=((clientX-r.left)-M.l)/((r.width-(M.l+M.r))||1); return viewMin + clamp(fr,0,1)*vspan(); };
267
+ const ovTsAtCss = clientX => { const r=ov.getBoundingClientRect();
268
+ const fr=((clientX-r.left)-OM.l)/((r.width-(OM.l+OM.r))||1); return tmin + clamp(fr,0,1)*fullSpan; };
269
+ const ovEdgeCss = ts => { const r=ov.getBoundingClientRect();
270
+ return r.left + OM.l + (ts-tmin)/fullSpan*(r.width-(OM.l+OM.r)); };
271
+
272
+ c.addEventListener('wheel', e => { e.preventDefault(); const t=tsAt(e.clientX);
273
+ let s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan);
274
+ const f=(t-viewMin)/(vspan()||1); setView(t-f*s, t-f*s+s); }, {passive:false});
275
+ c.addEventListener('mousedown', e => { dragging={a:tsAt(e.clientX), b:tsAt(e.clientX), sx:e.clientX, cx:e.clientX}; });
276
+ c.addEventListener('mousemove', e => { if (ovAct) return;
277
+ if (dragging) { dragging.b=tsAt(e.clientX); dragging.cx=e.clientX; } else { hover={ts:tsAt(e.clientX)}; } draw(); });
278
+ c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; draw(); } });
279
+
280
+ ov.addEventListener('mousedown', e => { e.preventDefault(); ov.style.cursor='grabbing';
281
+ const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax), x=e.clientX, H=8;
282
+ if (Math.abs(x-xl)<=H) ovAct={type:'l'};
283
+ else if (Math.abs(x-xr)<=H) ovAct={type:'r'};
284
+ else if (x>xl && x<xr) ovAct={type:'pan', grab:ovTsAtCss(x), vMin:viewMin, vMax:viewMax};
285
+ else { const t=ovTsAtCss(x), s=vspan(); setView(t-s/2, t+s/2); ovAct={type:'pan', grab:t, vMin:viewMin, vMax:viewMax}; }
286
+ });
287
+ ov.addEventListener('mousemove', e => { if (ovAct) return; const x=e.clientX, H=8;
288
+ const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax);
289
+ ov.style.cursor = (Math.abs(x-xl)<=H || Math.abs(x-xr)<=H) ? 'ew-resize' : (x>xl && x<xr) ? 'grab' : 'pointer'; });
290
+ ov.addEventListener('wheel', e => { e.preventDefault(); const t=ovTsAtCss(e.clientX);
291
+ const s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan); setView(t-s/2, t+s/2); }, {passive:false});
292
+
293
+ window.addEventListener('mousemove', e => { if (!ovAct) return; const t=ovTsAtCss(e.clientX);
294
+ if (ovAct.type==='l') setView(Math.min(t, viewMax-minSpan), viewMax);
295
+ else if (ovAct.type==='r') setView(viewMin, Math.max(t, viewMin+minSpan));
296
+ else { const d=t-ovAct.grab; setView(ovAct.vMin+d, ovAct.vMax+d); } });
297
+ window.addEventListener('mouseup', () => {
298
+ if (ovAct) { ovAct=null; return; }
299
+ if (!dragging) return;
300
+ if (Math.abs(dragging.cx-dragging.sx) > 4) {
301
+ const a=clamp(Math.min(dragging.a,dragging.b),tmin,tmax), b=clamp(Math.max(dragging.a,dragging.b),tmin,tmax);
302
+ incidents.push({a, b, label:''});
303
+ }
304
+ dragging=null; render();
305
+ });
306
+
307
+ document.getElementById('zreset').onclick = () => setView(tmin, tmax);
308
+ c.addEventListener('dblclick', () => setView(tmin, tmax));
309
+ document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
310
+ window.setLabel = (i, val) => { if (incidents[i]) incidents[i].label = val; };
311
+ window.rm = i => { incidents.splice(i,1); render(); };
312
+
313
+ function render() {
314
+ incidents.sort((p,q)=>p.a-q.a);
315
+ const list=document.getElementById('list');
316
+ list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
317
+ +'<span class="span">'+fmtTs(iv.a)+' &rarr; '+fmtTs(iv.b)+'</span>'
318
+ +'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
319
+ +'<input class="desc" type="text" placeholder="describe this incident (optional)" '
320
+ +'value="'+esc(iv.label||'')+'" oninput="setLabel('+i+', this.value)">'
321
+ +'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
322
+ document.getElementById('empty').style.display = incidents.length ? 'none' : '';
323
+ const total=incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
324
+ document.getElementById('summary').innerHTML = incidents.length
325
+ ? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total' : '';
326
+ drawAll();
327
+ }
328
+
329
+ document.getElementById('export').onclick = () => {
330
+ const d=new Date();
331
+ const stamp=d.getUTCFullYear()+pad2(d.getUTCMonth()+1)+pad2(d.getUTCDate())+'T'
332
+ +pad2(d.getUTCHours())+pad2(d.getUTCMinutes())+pad2(d.getUTCSeconds())+'Z';
333
+ let y='metric: __METRIC__\\ntimezone: UTC\\nincidents:\\n';
334
+ const sorted=incidents.slice().sort((p,q)=>p.a-q.a);
335
+ if (!sorted.length) y+=' []\\n';
336
+ sorted.forEach(iv => { y+=' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"'
337
+ + (iv.label && iv.label.trim() ? ', label: '+yamlStr(iv.label.trim()) : '') + '}\\n'; });
338
+ const blob=new Blob([y], {type:'text/yaml'}); const a=document.createElement('a');
339
+ a.href=URL.createObjectURL(blob); a.download='__METRIC__-'+stamp+'.yml'; a.click();
340
+ };
341
+
342
+ function drawAll() { draw(); drawOverview();
343
+ document.getElementById('range').textContent =
344
+ 'viewing ' + fmtTs(viewMin) + ' → ' + fmtTs(viewMax) + ' · ' + fmtDur(vspan()) + ' of ' + fmtDur(fullSpan); }
345
+ function fit() { dpr = window.devicePixelRatio || 1;
346
+ c.width=c.clientWidth*dpr; c.height=c.clientHeight*dpr;
347
+ ov.width=ov.clientWidth*dpr; ov.height=ov.clientHeight*dpr; drawAll(); }
348
+ window.addEventListener('resize', fit); fit(); render();
349
+ </script>
350
+ """
351
+
352
+
353
+ def render_labeler_html(metric_name: str, data: dict[str, np.ndarray]) -> str:
354
+ """Return a self-contained HTML labeler page for *metric_name*'s series."""
355
+ timestamps = data["timestamp"]
356
+ values = data["value"]
357
+ points = []
358
+ for i in range(len(timestamps)):
359
+ v = values[i]
360
+ points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
361
+ payload = json_dumps_sorted({"metric": metric_name, "points": points})
362
+ return _TEMPLATE.replace("__PAYLOAD__", payload).replace("__METRIC__", metric_name)
@@ -46,12 +46,14 @@ dtk autotune --select <sel> [--incidents FILE] [--label] [--scoring METRIC] \
46
46
  [--from DATE] [--to DATE] [--profile NAME] [--force] [--dry-run]
47
47
  ```
48
48
 
49
- - `--incidents FILE` — a labels file (below) → **supervised** tuning. With no
50
- labels file, an interactive terminal prompts to enter incidents inline;
51
- declining (or running non-interactively) tunes **unsupervised**.
52
- - `--label` write a self-contained HTML chart of the series to
53
- `metrics/<name>__labeler.html`; the user marks incidents in a browser and its
54
- **Export** button downloads a labels file. Generate-and-exit (no DB writes).
49
+ - `--incidents FILE|DIR` — a labels file (below) → **supervised** tuning. May be a
50
+ **directory** (e.g. `incidents/<name>/`) the newest versioned file in it is
51
+ used. With nothing given, an interactive terminal prompts to enter incidents
52
+ inline; declining (or running non-interactively) tunes **unsupervised**.
53
+ - `--label` write a self-contained, zoomable/pannable HTML chart of the series
54
+ to `metrics/<name>__labeler.html`; the user marks incidents in a browser (with
55
+ optional per-incident descriptions) and **Export** downloads a *versioned*
56
+ labels file `<name>-<UTC>.yml`. Generate-and-exit (no DB writes).
55
57
  - `--scoring` — `mcc` (default), `f1`, `f_beta`, `balanced_accuracy`, `roc_auc`,
56
58
  `pr_auc`. MCC uses the whole confusion matrix and suits rare anomalies.
57
59
  - `--dry-run` — run the search but persist nothing and write no config.
@@ -82,11 +84,15 @@ user to recall timestamps** — it is the easiest, most reliable path:
82
84
 
83
85
  1. Run `dtk autotune --select <name> --label` (offline; writes no DB rows). It
84
86
  renders the series to a self-contained `metrics/<name>__labeler.html`.
85
- 2. Tell the user to open that file in a browser and **click-drag across the chart**
86
- to mark each real incident (red bands; *remove* / *Clear all* to fix), then
87
- click **Export incidents-<name>.yml** to download a labels file in the format
88
- above.
89
- 3. Feed it back: `dtk autotune --select <name> --incidents incidents-<name>.yml`.
87
+ 2. Tell the user to open it in a browser and mark incidents on the chart: scroll
88
+ to zoom, drag the navigator strip to move, **click-drag** to mark each span
89
+ (add an optional description), then **Export**.
90
+ 3. Persist with versioning: a browser can't write into the project, so Export
91
+ downloads a versioned `<name>-<UTC>.yml`. **Move it into `incidents/<name>/`**
92
+ — re-labeling adds a new file, never overwrites, so every round is kept. You
93
+ (the assistant, with filesystem access) do this move.
94
+ 4. Tune on the latest: point `--incidents` at the folder so the newest version is
95
+ used — `dtk autotune --select <name> --incidents incidents/<name>/`.
90
96
 
91
97
  Prefer this whenever the user can *recognise* incidents on a chart but doesn't
92
98
  have exact times. If they already know the times (or you found them via a DB
@@ -91,11 +91,16 @@ self-contained file `metrics/<name>__labeler.html`. Walk the user through it:
91
91
 
92
92
  1. Open `metrics/<name>__labeler.html` in any browser — just double-click it,
93
93
  it's self-contained (inline chart + data, no server, no internet).
94
- 2. **Click-drag across the chart** over each real incident: it appears as a red
95
- band and a row in the list below (use *remove* / *Clear all* to fix mistakes).
96
- 3. Click **Export incidents-<name>.yml** the browser downloads a labels file in
97
- the canonical schema.
98
- 4. Feed it back: `dtk autotune --select <name> --incidents incidents-<name>.yml`.
94
+ 2. Navigate a long/dense series: **scroll to zoom**, double-click to reset, drag
95
+ the **navigator strip** to move the view (window = pan, edges = stretch).
96
+ 3. **Click-drag across the chart** over each real incident (red band + a row
97
+ below with an optional **description**); *remove* / *Clear all* fix mistakes.
98
+ 4. Click **Export** the browser downloads a *versioned* `<name>-<UTC>.yml`. A
99
+ browser can't write into the project, so **move it into `incidents/<name>/`**.
100
+ Re-labeling later adds another versioned file — nothing is overwritten, so the
101
+ whole history is kept. Do this move for the user (you have filesystem access).
102
+ 5. Tune on the latest version by pointing at the folder:
103
+ `dtk autotune --select <name> --incidents incidents/<name>/`.
99
104
 
100
105
  Prefer this whenever the user can *recognise* incidents on a chart but doesn't
101
106
  have exact timestamps — it is far easier than dictating times, and they label
@@ -93,6 +93,25 @@ def _resolve_scoring(scoring_override: str | None, autotune_cfg: AutoTuneConfig)
93
93
  raise click.BadParameter(f"Invalid scoring metric '{value}'. Allowed: {allowed}") from exc
94
94
 
95
95
 
96
+ _LABELS_GLOBS = ("*.yml", "*.yaml", "*.json")
97
+
98
+
99
+ def _newest_labels_file(directory: Path) -> Path | None:
100
+ """Newest labels file in *directory*.
101
+
102
+ The labeler exports versioned, ISO-stamped names (``<metric>-<UTC>.yml``)
103
+ which sort chronologically, so we pick the lexicographically-greatest name
104
+ (tie-broken by mtime). This lets ``--incidents incidents/<metric>/`` keep
105
+ every labeling round on disk while always tuning on the latest one.
106
+ """
107
+ files: list[Path] = []
108
+ for pattern in _LABELS_GLOBS:
109
+ files.extend(directory.glob(pattern))
110
+ if not files:
111
+ return None
112
+ return max(files, key=lambda p: (p.name, p.stat().st_mtime))
113
+
114
+
96
115
  def _resolve_labels(
97
116
  *,
98
117
  metric_name: str,
@@ -104,13 +123,24 @@ def _resolve_labels(
104
123
  """Resolve labels by precedence.
105
124
 
106
125
  ``--incidents`` flag > config ``labels_file`` > config inline ``incidents`` >
107
- interactive prompt > none (unsupervised).
126
+ interactive prompt > none (unsupervised). A directory may be given for the
127
+ flag or ``labels_file``; its newest versioned file is used.
108
128
  """
109
129
  path = incidents_path or autotune_cfg.labels_file
110
130
  if path:
111
131
  file_path = Path(path)
112
132
  if not file_path.is_absolute():
113
133
  file_path = project_root / file_path
134
+ if file_path.is_dir():
135
+ chosen = _newest_labels_file(file_path)
136
+ if chosen is None:
137
+ raise FileNotFoundError(
138
+ f"No labels files (*.yml / *.yaml / *.json) found in {file_path}"
139
+ )
140
+ labels = parse_labels_file(
141
+ chosen, interval_seconds=interval_seconds, metric_name=metric_name
142
+ )
143
+ return labels, f"file {chosen} (newest in {file_path}/)"
114
144
  labels = parse_labels_file(
115
145
  file_path, interval_seconds=interval_seconds, metric_name=metric_name
116
146
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.21.0
3
+ Version: 0.22.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -1,219 +0,0 @@
1
- """Generate a self-contained HTML labeler for a metric series.
2
-
3
- Emits a single HTML file (inline CSS/JS, no CDN) with the series embedded as a
4
- JSON literal: a canvas line chart where the user click-drags over incident
5
- spans and exports a labels file in the canonical schema, which is then fed back
6
- via ``dtk autotune --select <metric> --incidents <file>``.
7
-
8
- Docs sync: the autotune reference page embeds a *live* copy of this output
9
- (``docs/examples/autotune-labeler.html``) so the site always shows the real UI.
10
- After changing the template below, regenerate that example so the docs don't
11
- drift: ``python website/scripts/gen-labeler-example.py`` (also in the release
12
- checklist).
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- from datetime import datetime, timedelta
18
-
19
- import numpy as np
20
-
21
- from detectkit.utils.json_utils import json_dumps_sorted
22
-
23
-
24
- def _ts_to_str(ts64: np.datetime64) -> str:
25
- ms = int(ts64.astype("datetime64[ms]").astype(np.int64))
26
- return (datetime(1970, 1, 1) + timedelta(milliseconds=ms)).strftime("%Y-%m-%d %H:%M:%S")
27
-
28
-
29
- # Built with .replace() (not .format()), so braces are literal — keep them single.
30
- # Self-contained: inline brand styling/logo/JS, no network. Palette + fonts mirror
31
- # website/src/styles/brand.css (.claude/rules/design.md); incident bands use the
32
- # anomaly status color, the drag preview the no-data color.
33
- _TEMPLATE = """<!doctype html>
34
- <meta charset="utf-8">
35
- <meta name="viewport" content="width=device-width, initial-scale=1">
36
- <title>detectkit · label incidents · __METRIC__</title>
37
- <style>
38
- :root {
39
- --clay:#d15b36; --clay-700:#b4471f; --paper:#f5f1e8; --muted:#6e675b; --faint:#9a9384;
40
- --term-bg:#211e1a; --term-surface:#1b1916; --term-border:#332f29; --term-text:#c9c2b4;
41
- --anomaly:#d63232; --nodata:#f0ad4e;
42
- --ui:'Schibsted Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
43
- --mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
44
- }
45
- * { box-sizing: border-box; }
46
- body { font-family: var(--ui); margin: 0; background: var(--term-bg); color: var(--term-text);
47
- -webkit-font-smoothing: antialiased; }
48
- .shell { max-width: 1080px; margin: 0 auto; padding: 22px 22px 40px; }
49
- .brand { display:flex; align-items:center; gap:9px; margin-bottom: 14px; }
50
- .brand svg { width: 26px; height: 26px; border-radius: 7px; display:block; }
51
- .brand b { color: var(--paper); font-weight: 600; font-size: 15px; letter-spacing: .2px; }
52
- .brand span { color: var(--faint); font-size: 12px; }
53
- h1 { font-size: 18px; line-height: 1.3; margin: 0 0 6px; color: var(--paper); font-weight: 600; }
54
- h1 code { color: var(--clay); font-family: var(--mono); font-size: .82em; }
55
- .hint { color: var(--faint); font-size: 13px; margin: 0 0 18px; line-height: 1.5; }
56
- .hint code { color: var(--term-text); font-family: var(--mono); font-size: 12px;
57
- background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 5px; padding: 1px 6px; }
58
- .toolbar { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom: 12px; }
59
- button { font-family: var(--ui); font-size: 13px; font-weight: 500; border: 0; border-radius: 7px;
60
- padding: 9px 15px; cursor: pointer; transition: background .12s ease, border-color .12s ease; }
61
- button.primary { background: var(--clay); color: #fff; }
62
- button.primary:hover { background: var(--clay-700); }
63
- button.ghost { background: transparent; color: var(--term-text); border: 1px solid var(--term-border); }
64
- button.ghost:hover { border-color: var(--faint); color: var(--paper); }
65
- .summary { margin-left: auto; color: var(--faint); font-size: 12.5px; font-family: var(--mono); }
66
- .summary b { color: var(--clay); font-weight: 600; }
67
- canvas { width: 100%; height: clamp(320px, 46vh, 520px); display:block;
68
- background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
69
- .empty { color: var(--faint); font-size: 13px; margin: 16px 2px; font-style: italic; }
70
- ul { list-style: none; margin: 14px 0 0; padding: 0; }
71
- li { display:flex; align-items:center; gap:12px; padding: 9px 12px; font-size: 13px;
72
- border: 1px solid var(--term-border); border-radius: 8px; margin-bottom: 7px; background: var(--term-surface); }
73
- li .dot { width:9px; height:9px; border-radius:50%; background: var(--anomaly); flex: 0 0 auto; }
74
- li .span { font-family: var(--mono); color: var(--term-text); }
75
- li .dur { color: var(--faint); font-size: 12px; }
76
- li button { margin-left: auto; padding: 5px 11px; font-size: 12px; }
77
- footer { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--term-border);
78
- color: var(--faint); font-size: 12px; }
79
- footer code { font-family: var(--mono); color: var(--term-text); }
80
- </style>
81
- <div class="shell">
82
- <div class="brand">
83
- <svg viewBox="0 0 100 100" aria-hidden="true"><rect x="3" y="3" width="94" height="94" rx="26" fill="#D15B36"/><polyline points="14,62 36,62 50,22 64,62 86,62" fill="none" stroke="#FBF9F3" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="50" cy="22" r="6.5" fill="#FBF9F3"/></svg>
84
- <b>detectkit</b><span>· incident labeler</span>
85
- </div>
86
- <h1>Label incidents — <code>__METRIC__</code></h1>
87
- <p class="hint">Click-drag across the chart to mark each real incident, then <b>Export</b> and run
88
- <code>dtk autotune --select __METRIC__ --incidents incidents-__METRIC__.yml</code></p>
89
- <div class="toolbar">
90
- <button id="export" class="primary">Export incidents-__METRIC__.yml</button>
91
- <button id="clear" class="ghost">Clear all</button>
92
- <span id="summary" class="summary"></span>
93
- </div>
94
- <canvas id="c" aria-label="metric series chart — drag horizontally to mark an incident span"></canvas>
95
- <div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
96
- <ul id="list"></ul>
97
- <footer>All times UTC · self-contained, no data leaves your browser · generated by
98
- <code>dtk autotune --label</code></footer>
99
- </div>
100
- <script>
101
- const DATA = __PAYLOAD__;
102
- const pts = DATA.points.map(p => ({t: p.t, ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
103
- const incidents = [];
104
- const c = document.getElementById('c');
105
- const ctx = c.getContext('2d');
106
- const vals = pts.map(p => p.v).filter(v => v !== null);
107
- const vmin = vals.length ? Math.min.apply(null, vals) : 0;
108
- const vmax = vals.length ? Math.max.apply(null, vals) : 1;
109
- const tmin = pts.length ? pts[0].ts : 0;
110
- const tmax = pts.length ? pts[pts.length-1].ts : 1;
111
- const M = {l: 56, r: 16, t: 14, b: 30}; // css-px plot margins
112
- let dpr = 1, hover = null, dragging = null;
113
- function plotW() { return c.width - (M.l + M.r) * dpr; }
114
- function plotH() { return c.height - (M.t + M.b) * dpr; }
115
- function px(ts) { return M.l*dpr + (ts - tmin) / ((tmax - tmin) || 1) * plotW(); }
116
- function py(v) { return (c.height - M.b*dpr) - (v - vmin) / ((vmax - vmin) || 1) * plotH(); }
117
- function tsAt(clientX) { const r=c.getBoundingClientRect();
118
- const fr=((clientX-r.left)-M.l)/((r.width-(M.l+M.r))||1); return tmin + Math.max(0,Math.min(1,fr))*(tmax-tmin); }
119
- function fmtTs(ts) { return new Date(ts).toISOString().slice(0,19).replace('T',' '); }
120
- function fmtAxis(ts) { return new Date(ts).toISOString().slice(5,16).replace('T',' '); }
121
- function fmtVal(v) { const a=Math.abs(v); return a>=1000 ? v.toFixed(0) : a>=10 ? v.toFixed(1) : v.toFixed(2); }
122
- function fmtDur(ms) { const m=Math.round(ms/60000); if(m<60) return m+'m';
123
- const h=Math.floor(m/60), mm=m%60; if(h<24) return h+'h'+(mm?(' '+mm+'m'):'');
124
- const d=Math.floor(h/24), hh=h%24; return d+'d'+(hh?(' '+hh+'h'):''); }
125
- function fit() { dpr = window.devicePixelRatio || 1;
126
- c.width = c.clientWidth * dpr; c.height = c.clientHeight * dpr; draw(); }
127
- function draw() {
128
- ctx.clearRect(0,0,c.width,c.height);
129
- ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
130
- // y gridlines + value ticks
131
- ctx.textBaseline = 'middle';
132
- for (let i=0;i<=4;i++) { const v=vmin+(vmax-vmin)*i/4, yy=py(v);
133
- ctx.strokeStyle='rgba(255,255,255,0.05)'; ctx.lineWidth=1*dpr; ctx.beginPath();
134
- ctx.moveTo(M.l*dpr, yy); ctx.lineTo(c.width-M.r*dpr, yy); ctx.stroke();
135
- ctx.fillStyle='#6e675b'; ctx.textAlign='right'; ctx.fillText(fmtVal(v), (M.l-8)*dpr, yy); }
136
- // x ticks
137
- ctx.textBaseline='top';
138
- for (let i=0;i<=5;i++) { const ts=tmin+(tmax-tmin)*i/5, xx=px(ts);
139
- ctx.fillStyle='#6e675b'; ctx.textAlign = i===0?'left':i===5?'right':'center';
140
- ctx.fillText(fmtAxis(ts), xx, (c.height-M.b+8)*dpr); }
141
- // committed incident bands
142
- incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
143
- ctx.fillStyle='rgba(214,50,50,0.20)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH());
144
- ctx.strokeStyle='rgba(214,50,50,0.55)'; ctx.lineWidth=1*dpr;
145
- ctx.strokeRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); });
146
- // drag preview
147
- if (dragging) { const x0=px(dragging.a), x1=px(dragging.b);
148
- ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
149
- // series line
150
- ctx.strokeStyle='#d15b36'; ctx.lineWidth=1.5*dpr; ctx.beginPath();
151
- let started=false;
152
- pts.forEach(p => { if (p.v===null) { started=false; return; } const X=px(p.ts), Y=py(p.v);
153
- if (!started) { ctx.moveTo(X,Y); started=true; } else ctx.lineTo(X,Y); });
154
- ctx.stroke();
155
- // hover crosshair + tooltip
156
- if (hover && !dragging) drawHover();
157
- }
158
- function drawHover() {
159
- let best=null, bd=Infinity;
160
- for (const p of pts) { if (p.v===null) continue; const d=Math.abs(p.ts-hover.ts);
161
- if (d<bd) { bd=d; best=p; } }
162
- if (!best) return;
163
- const X=px(best.ts), Y=py(best.v);
164
- ctx.strokeStyle='rgba(201,194,180,0.25)'; ctx.lineWidth=1*dpr; ctx.beginPath();
165
- ctx.moveTo(X, M.t*dpr); ctx.lineTo(X, c.height-M.b*dpr); ctx.stroke();
166
- ctx.fillStyle='#f5f1e8'; ctx.beginPath(); ctx.arc(X,Y,3*dpr,0,7); ctx.fill();
167
- const label=fmtAxis(best.ts)+' · '+fmtVal(best.v);
168
- ctx.font=(11*dpr)+'px ui-monospace, monospace'; const tw=ctx.measureText(label).width;
169
- let bx=X+8*dpr; if (bx+tw+12*dpr > c.width) bx=X-tw-20*dpr;
170
- ctx.fillStyle='rgba(27,25,22,0.92)'; ctx.strokeStyle='#332f29';
171
- ctx.fillRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr); ctx.strokeRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr);
172
- ctx.fillStyle='#c9c2b4'; ctx.textAlign='left'; ctx.textBaseline='middle';
173
- ctx.fillText(label, bx+6*dpr, M.t*dpr+12);
174
- }
175
- c.addEventListener('mousedown', e => { dragging={a:tsAt(e.clientX), b:tsAt(e.clientX)}; });
176
- c.addEventListener('mousemove', e => { if (dragging) { dragging.b=tsAt(e.clientX); }
177
- else { hover={ts:tsAt(e.clientX)}; } draw(); });
178
- c.addEventListener('mouseleave', () => { hover=null; draw(); });
179
- window.addEventListener('mouseup', () => { if(!dragging) return;
180
- if (Math.abs(dragging.b-dragging.a) > 1000) incidents.push({a:Math.min(dragging.a,dragging.b), b:Math.max(dragging.a,dragging.b)});
181
- dragging=null; render(); });
182
- function render() {
183
- const list=document.getElementById('list');
184
- incidents.sort((p,q)=>p.a-q.a);
185
- list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
186
- +'<span class="span">'+fmtTs(iv.a)+' &rarr; '+fmtTs(iv.b)+'</span>'
187
- +'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
188
- +'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
189
- document.getElementById('empty').style.display = incidents.length ? 'none' : '';
190
- const total = incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
191
- document.getElementById('summary').innerHTML = incidents.length
192
- ? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total'
193
- : '';
194
- draw();
195
- }
196
- function rm(i) { incidents.splice(i,1); render(); }
197
- document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
198
- document.getElementById('export').onclick = () => {
199
- let y = 'metric: __METRIC__\\ntimezone: UTC\\nincidents:\\n';
200
- if (!incidents.length) y += ' []\\n';
201
- incidents.forEach(iv => { y += ' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"}\\n'; });
202
- const blob = new Blob([y], {type:'text/yaml'}); const a=document.createElement('a');
203
- a.href = URL.createObjectURL(blob); a.download = 'incidents-__METRIC__.yml'; a.click();
204
- };
205
- window.addEventListener('resize', fit); fit(); render();
206
- </script>
207
- """
208
-
209
-
210
- def render_labeler_html(metric_name: str, data: dict[str, np.ndarray]) -> str:
211
- """Return a self-contained HTML labeler page for *metric_name*'s series."""
212
- timestamps = data["timestamp"]
213
- values = data["value"]
214
- points = []
215
- for i in range(len(timestamps)):
216
- v = values[i]
217
- points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
218
- payload = json_dumps_sorted({"metric": metric_name, "points": points})
219
- return _TEMPLATE.replace("__PAYLOAD__", payload).replace("__METRIC__", metric_name)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes