detectkit 0.22.0__tar.gz → 0.23.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.22.0/detectkit.egg-info → detectkit-0.23.0}/PKG-INFO +1 -1
  2. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/html_labeler.py +113 -19
  4. detectkit-0.23.0/detectkit/autotune/label_server.py +166 -0
  5. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/autotune.md +23 -18
  6. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +17 -13
  7. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/commands/autotune.py +56 -19
  8. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/main.py +16 -2
  9. {detectkit-0.22.0 → detectkit-0.23.0/detectkit.egg-info}/PKG-INFO +1 -1
  10. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit.egg-info/SOURCES.txt +1 -0
  11. {detectkit-0.22.0 → detectkit-0.23.0}/LICENSE +0 -0
  12. {detectkit-0.22.0 → detectkit-0.23.0}/MANIFEST.in +0 -0
  13. {detectkit-0.22.0 → detectkit-0.23.0}/README.md +0 -0
  14. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/__init__.py +0 -0
  15. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/__init__.py +0 -0
  16. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/base.py +0 -0
  17. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/branding.py +0 -0
  18. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/email.py +0 -0
  19. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/factory.py +0 -0
  20. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/mattermost.py +0 -0
  21. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/slack.py +0 -0
  22. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/telegram.py +0 -0
  23. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/channels/webhook.py +0 -0
  24. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  25. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  26. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  27. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  28. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  29. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  30. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  31. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  32. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/__init__.py +0 -0
  33. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/_base.py +0 -0
  34. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/_types.py +0 -0
  35. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/autotuner.py +0 -0
  36. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/config_emitter.py +0 -0
  37. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/crossval.py +0 -0
  38. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/detector_select.py +0 -0
  39. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/distribution.py +0 -0
  40. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/grid_search.py +0 -0
  41. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/labels.py +0 -0
  42. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/result.py +0 -0
  43. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/scoring.py +0 -0
  44. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/seasonality_search.py +0 -0
  45. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/settings.py +0 -0
  46. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/autotune/window_select.py +0 -0
  47. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/__init__.py +0 -0
  48. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/_output.py +0 -0
  49. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  50. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  51. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  52. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  53. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  54. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  55. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  56. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  57. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  58. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  59. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/commands/__init__.py +0 -0
  60. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/commands/clean.py +0 -0
  61. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/commands/init.py +0 -0
  62. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/commands/init_claude.py +0 -0
  63. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/commands/run.py +0 -0
  64. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/commands/test_alert.py +0 -0
  65. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/cli/commands/unlock.py +0 -0
  66. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/config/__init__.py +0 -0
  67. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/config/metric_config.py +0 -0
  68. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/config/profile.py +0 -0
  69. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/config/project_config.py +0 -0
  70. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/config/validator.py +0 -0
  71. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/core/__init__.py +0 -0
  72. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/core/interval.py +0 -0
  73. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/core/models.py +0 -0
  74. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/__init__.py +0 -0
  75. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/_sql_manager.py +0 -0
  76. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/clickhouse_manager.py +0 -0
  77. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/__init__.py +0 -0
  78. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  79. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  80. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_base.py +0 -0
  81. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  82. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_detections.py +0 -0
  83. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  84. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  85. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_schema.py +0 -0
  86. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  87. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/internal_tables/manager.py +0 -0
  88. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/manager.py +0 -0
  89. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/mysql_manager.py +0 -0
  90. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/postgres_manager.py +0 -0
  91. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/database/tables.py +0 -0
  92. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/__init__.py +0 -0
  93. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/base.py +0 -0
  94. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/factory.py +0 -0
  95. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/seasonality.py +0 -0
  96. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/statistical/__init__.py +0 -0
  97. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  98. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/statistical/iqr.py +0 -0
  99. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/statistical/mad.py +0 -0
  100. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  101. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/detectors/statistical/zscore.py +0 -0
  102. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/loaders/__init__.py +0 -0
  103. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/loaders/metric_loader.py +0 -0
  104. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/loaders/query_template.py +0 -0
  105. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/__init__.py +0 -0
  106. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/error_dispatch.py +0 -0
  107. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  108. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  109. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  110. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  111. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  112. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  113. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  114. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/utils/__init__.py +0 -0
  115. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/utils/datetime_utils.py +0 -0
  116. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/utils/env_interpolation.py +0 -0
  117. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/utils/json_utils.py +0 -0
  118. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit/utils/stats.py +0 -0
  119. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit.egg-info/dependency_links.txt +0 -0
  120. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit.egg-info/entry_points.txt +0 -0
  121. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit.egg-info/requires.txt +0 -0
  122. {detectkit-0.22.0 → detectkit-0.23.0}/detectkit.egg-info/top_level.txt +0 -0
  123. {detectkit-0.22.0 → detectkit-0.23.0}/pyproject.toml +0 -0
  124. {detectkit-0.22.0 → detectkit-0.23.0}/requirements.txt +0 -0
  125. {detectkit-0.22.0 → detectkit-0.23.0}/setup.cfg +0 -0
  126. {detectkit-0.22.0 → detectkit-0.23.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.22.0
3
+ Version: 0.23.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.22.0"
7
+ __version__ = "0.23.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -68,8 +68,16 @@ _TEMPLATE = """<!doctype html>
68
68
  button.primary:hover { background: var(--clay-700); }
69
69
  button.ghost { background: transparent; color: var(--term-text); border: 1px solid var(--term-border); }
70
70
  button.ghost:hover { border-color: var(--faint); color: var(--paper); }
71
+ input.setname { background: var(--term-surface); color: var(--paper); border: 1px solid var(--term-border);
72
+ border-radius: 7px; padding: 9px 11px; font-family: var(--ui); font-size: 13px; min-width: 200px; }
73
+ input.setname::placeholder { color: var(--muted); }
74
+ input.setname:focus { outline: none; border-color: var(--clay); }
71
75
  .summary { margin-left: auto; color: var(--faint); font-size: 12.5px; font-family: var(--mono); }
72
76
  .summary b { color: var(--clay); font-weight: 600; }
77
+ .savemsg { margin: 4px 2px 0; font-size: 13px; display: none; }
78
+ .savemsg.ok { display: block; color: var(--accent-green, #2e9e73); }
79
+ .savemsg.err { display: block; color: var(--anomaly); }
80
+ .savemsg.info { display: block; color: var(--faint); }
73
81
  canvas#c { width: 100%; height: clamp(300px, 44vh, 500px); display:block; touch-action: none;
74
82
  background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
75
83
  .zoombar { display:flex; align-items:center; gap:8px; margin: 10px 0 6px; }
@@ -103,18 +111,21 @@ _TEMPLATE = """<!doctype html>
103
111
  <b>Export</b>. Save the file into <code class="k">incidents/__METRIC__/</code> and run
104
112
  <code class="k">dtk autotune --select __METRIC__ --incidents incidents/__METRIC__/</code></p>
105
113
  <div class="toolbar">
114
+ <input id="setname" class="setname" type="text" placeholder="name this set (optional)" />
106
115
  <button id="export" class="primary">Export labels</button>
107
116
  <button id="clear" class="ghost">Clear all</button>
108
117
  <span id="summary" class="summary"></span>
109
118
  </div>
119
+ <div id="savemsg" class="savemsg"></div>
110
120
  <canvas id="c" aria-label="metric series — drag to mark an incident, scroll to zoom"></canvas>
111
121
  <div class="zoombar">
112
122
  <button id="zreset" class="ghost">Reset zoom</button>
113
123
  <span id="range" class="rangelbl"></span>
114
124
  </div>
115
125
  <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>
126
+ <div class="navhint">Drag on an empty area to mark an incident · drag an existing incident's edges to
127
+ adjust it, or its middle to move it · scroll to zoom, double-click to reset · drag the navigator
128
+ window below to pan, its edges to stretch / squeeze the view.</div>
118
129
  <div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
119
130
  <ul id="list"></ul>
120
131
  <footer>All times UTC · self-contained, nothing leaves your browser · re-label any time —
@@ -123,6 +134,10 @@ _TEMPLATE = """<!doctype html>
123
134
  </div>
124
135
  <script>
125
136
  const DATA = __PAYLOAD__;
137
+ // When served by `dtk autotune --label` (local server), this is the save endpoint
138
+ // and Export POSTs straight into incidents/<metric>/. As a static file it is null,
139
+ // and Export falls back to a browser download.
140
+ const SAVE_URL = __SAVE_URL__;
126
141
  const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
127
142
  const N = pts.length;
128
143
  const vraw = pts.filter(p => p.v !== null).map(p => p.v);
@@ -218,8 +233,12 @@ function draw() {
218
233
  ctx.save(); ctx.beginPath(); ctx.rect(M.l*dpr, M.t*dpr, plotW(), plotH()); ctx.clip();
219
234
  incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
220
235
  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);
236
+ ctx.strokeStyle='rgba(214,50,50,0.55)'; ctx.lineWidth=1*dpr; ctx.strokeRect(x0, M.t*dpr, x1-x0, plotH());
237
+ // draggable edge handles
238
+ ctx.fillStyle='rgba(214,50,50,0.95)';
239
+ ctx.fillRect(x0-1.5*dpr, M.t*dpr, 3*dpr, plotH());
240
+ ctx.fillRect(x1-1.5*dpr, M.t*dpr, 3*dpr, plotH()); });
241
+ if (dragging && dragging.mode==='new') { const x0=px(dragging.a), x1=px(dragging.b);
223
242
  ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
224
243
  drawSeries(ctx, px, py, viewMin, viewMax, M.l*dpr, plotW(), '#d15b36', 1.5);
225
244
  ctx.restore();
@@ -272,9 +291,45 @@ const ovEdgeCss = ts => { const r=ov.getBoundingClientRect();
272
291
  c.addEventListener('wheel', e => { e.preventDefault(); const t=tsAt(e.clientX);
273
292
  let s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan);
274
293
  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}; });
294
+ // Hit-test an existing incident edge / body in CSS px (for editing vs creating).
295
+ const EDGE_PX = 6;
296
+ const minStep = () => Math.max(step, 1);
297
+ const pxCss = ts => { const r=c.getBoundingClientRect();
298
+ return M.l + (ts-viewMin)/(vspan()||1)*(r.width-(M.l+M.r)); };
299
+ function hitIncident(clientX) {
300
+ const x = clientX - c.getBoundingClientRect().left;
301
+ for (let i=0;i<incidents.length;i++) { const xa=pxCss(incidents[i].a), xb=pxCss(incidents[i].b);
302
+ if (Math.abs(x-xa)<=EDGE_PX) return {i, edge:'a'};
303
+ if (Math.abs(x-xb)<=EDGE_PX) return {i, edge:'b'}; }
304
+ for (let i=0;i<incidents.length;i++) { const xa=pxCss(incidents[i].a), xb=pxCss(incidents[i].b);
305
+ if (x>xa+EDGE_PX && x<xb-EDGE_PX) return {i, edge:'move'}; }
306
+ return null;
307
+ }
308
+ c.addEventListener('mousedown', e => {
309
+ const hit = hitIncident(e.clientX), t = tsAt(e.clientX);
310
+ if (hit && hit.edge==='move') { const iv=incidents[hit.i];
311
+ dragging={mode:'move', i:hit.i, grab:t, a0:iv.a, b0:iv.b, sx:e.clientX, cx:e.clientX}; }
312
+ else if (hit) dragging={mode:'edge', i:hit.i, edge:hit.edge, sx:e.clientX, cx:e.clientX};
313
+ else dragging={mode:'new', a:t, b:t, sx:e.clientX, cx:e.clientX};
314
+ });
276
315
  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(); });
316
+ if (dragging) {
317
+ dragging.cx=e.clientX; const t=tsAt(e.clientX);
318
+ if (dragging.mode==='new') { dragging.b=t; }
319
+ else if (dragging.mode==='edge') { const iv=incidents[dragging.i]; if (!iv) return;
320
+ if (dragging.edge==='a') iv.a=clamp(Math.min(t, iv.b-minStep()), tmin, tmax);
321
+ else iv.b=clamp(Math.max(t, iv.a+minStep()), tmin, tmax); }
322
+ else if (dragging.mode==='move') { const iv=incidents[dragging.i]; if (!iv) return;
323
+ let na=dragging.a0+(t-dragging.grab), nb=dragging.b0+(t-dragging.grab);
324
+ if (na<tmin) { nb+=tmin-na; na=tmin; } if (nb>tmax) { na-=nb-tmax; nb=tmax; }
325
+ iv.a=clamp(na,tmin,tmax); iv.b=clamp(nb,tmin,tmax); }
326
+ draw();
327
+ } else {
328
+ const hit=hitIncident(e.clientX);
329
+ c.style.cursor = hit ? (hit.edge==='move' ? 'grab' : 'ew-resize') : 'crosshair';
330
+ hover={ts:tsAt(e.clientX)}; draw();
331
+ }
332
+ });
278
333
  c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; draw(); } });
279
334
 
280
335
  ov.addEventListener('mousedown', e => { e.preventDefault(); ov.style.cursor='grabbing';
@@ -297,10 +352,13 @@ window.addEventListener('mousemove', e => { if (!ovAct) return; const t=ovTsAtCs
297
352
  window.addEventListener('mouseup', () => {
298
353
  if (ovAct) { ovAct=null; return; }
299
354
  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
- }
355
+ if (dragging.mode==='new') {
356
+ if (Math.abs(dragging.cx-dragging.sx) > 4) {
357
+ const a=clamp(Math.min(dragging.a,dragging.b),tmin,tmax), b=clamp(Math.max(dragging.a,dragging.b),tmin,tmax);
358
+ incidents.push({a, b, label:''});
359
+ }
360
+ } else { const iv=incidents[dragging.i]; // edge/move: keep start <= end
361
+ if (iv && iv.a>iv.b) { const t=iv.a; iv.a=iv.b; iv.b=t; } }
304
362
  dragging=null; render();
305
363
  });
306
364
 
@@ -326,17 +384,40 @@ function render() {
326
384
  drawAll();
327
385
  }
328
386
 
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';
387
+ const slug = s => (String(s).toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'') || '__METRIC__');
388
+ const setMsg = (text, cls) => { const el=document.getElementById('savemsg');
389
+ el.textContent=text; el.className='savemsg '+cls; };
390
+ const buildYaml = () => {
333
391
  let y='metric: __METRIC__\\ntimezone: UTC\\nincidents:\\n';
334
392
  const sorted=incidents.slice().sort((p,q)=>p.a-q.a);
335
393
  if (!sorted.length) y+=' []\\n';
336
394
  sorted.forEach(iv => { y+=' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"'
337
395
  + (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();
396
+ return y;
397
+ };
398
+
399
+ const exportBtn = document.getElementById('export');
400
+ if (SAVE_URL) exportBtn.textContent = 'Save & tune';
401
+ exportBtn.onclick = () => {
402
+ const y = buildYaml();
403
+ const name = document.getElementById('setname').value;
404
+ if (SAVE_URL) {
405
+ setMsg('Saving…', 'info'); exportBtn.disabled = true;
406
+ fetch(SAVE_URL, {method:'POST', headers:{'Content-Type':'application/json'},
407
+ body: JSON.stringify({name: name, yaml: y})})
408
+ .then(r => r.ok ? r.json() : r.text().then(t => { throw new Error(t || ('HTTP '+r.status)); }))
409
+ .then(res => setMsg('Saved to ' + res.saved + ' — autotune is now running in your terminal. '
410
+ + 'You can close this tab.', 'ok'))
411
+ .catch(e => { exportBtn.disabled = false; setMsg('Save failed: ' + e.message, 'err'); });
412
+ } else {
413
+ const d=new Date();
414
+ const stamp=d.getUTCFullYear()+pad2(d.getUTCMonth()+1)+pad2(d.getUTCDate())+'T'
415
+ +pad2(d.getUTCHours())+pad2(d.getUTCMinutes())+pad2(d.getUTCSeconds())+'Z';
416
+ const base = name.trim() ? slug(name) : '__METRIC__';
417
+ const blob=new Blob([y], {type:'text/yaml'}); const a=document.createElement('a');
418
+ a.href=URL.createObjectURL(blob); a.download=base+'-'+stamp+'.yml'; a.click();
419
+ setMsg('Downloaded ' + base + '-' + stamp + '.yml — move it into incidents/__METRIC__/ and re-run.', 'info');
420
+ }
340
421
  };
341
422
 
342
423
  function drawAll() { draw(); drawOverview();
@@ -350,8 +431,17 @@ window.addEventListener('resize', fit); fit(); render();
350
431
  """
351
432
 
352
433
 
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."""
434
+ def render_labeler_html(
435
+ metric_name: str, data: dict[str, np.ndarray], *, save_url: str | None = None
436
+ ) -> str:
437
+ """Return a self-contained HTML labeler page for *metric_name*'s series.
438
+
439
+ With ``save_url`` (set by ``dtk autotune --label``'s local server) the page's
440
+ Export button POSTs the labels straight to that endpoint; without it (a static
441
+ file) Export falls back to a browser download.
442
+ """
443
+ import json
444
+
355
445
  timestamps = data["timestamp"]
356
446
  values = data["value"]
357
447
  points = []
@@ -359,4 +449,8 @@ def render_labeler_html(metric_name: str, data: dict[str, np.ndarray]) -> str:
359
449
  v = values[i]
360
450
  points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
361
451
  payload = json_dumps_sorted({"metric": metric_name, "points": points})
362
- return _TEMPLATE.replace("__PAYLOAD__", payload).replace("__METRIC__", metric_name)
452
+ return (
453
+ _TEMPLATE.replace("__PAYLOAD__", payload)
454
+ .replace("__SAVE_URL__", json.dumps(save_url))
455
+ .replace("__METRIC__", metric_name)
456
+ )
@@ -0,0 +1,166 @@
1
+ """Local browser labeler server for ``dtk autotune --label``.
2
+
3
+ A pure-stdlib localhost server: it serves the interactive labeler page and, when
4
+ the user clicks **Save & tune**, validates the labels, writes a versioned file
5
+ into ``incidents/<metric>/`` and stops — so the command can continue straight
6
+ into the tuning run. Bound to 127.0.0.1 with a one-shot token; nothing is exposed
7
+ off the machine, and nothing is written until the user explicitly saves.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import secrets
15
+ import threading
16
+ import webbrowser
17
+ from collections.abc import Callable
18
+ from datetime import datetime, timezone
19
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
+ from pathlib import Path
21
+ from typing import Any, cast
22
+ from urllib.parse import parse_qs, urlparse
23
+
24
+ import numpy as np
25
+
26
+ from detectkit.autotune.html_labeler import render_labeler_html
27
+ from detectkit.autotune.labels import parse_incident_labels
28
+
29
+ _NAME_RE = re.compile(r"[^a-z0-9_-]+")
30
+ _MAX_BODY = 5_000_000 # generous cap on the posted labels payload
31
+
32
+
33
+ def _sanitize(name: str) -> str:
34
+ """Filesystem-safe slug for a label-set name; falls back to ``incidents``."""
35
+ slug = _NAME_RE.sub("-", name.strip().lower()).strip("-")
36
+ return slug or "incidents"
37
+
38
+
39
+ def _stamp() -> str:
40
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
41
+
42
+
43
+ class _LabelServer(ThreadingHTTPServer):
44
+ """Localhost server holding the per-run state the handler reads/writes."""
45
+
46
+ # Don't block process/interpreter exit on in-flight request threads (we stop
47
+ # after a single save anyway); also avoids coverage's thread-tracing hanging
48
+ # at exit on lingering handler threads.
49
+ daemon_threads = True
50
+
51
+ def __init__(self, address: tuple[str, int], handler: type[BaseHTTPRequestHandler]) -> None:
52
+ super().__init__(address, handler)
53
+ self.token: str = ""
54
+ self.html: str = ""
55
+ self.metric: str = ""
56
+ self.incidents_dir: Path = Path(".")
57
+ self.interval_seconds: int = 1
58
+ self.saved_path: Path | None = None
59
+
60
+
61
+ class _Handler(BaseHTTPRequestHandler):
62
+ def log_message(self, *args: Any) -> None: # silence default stderr logging
63
+ return
64
+
65
+ def _srv(self) -> _LabelServer:
66
+ return cast(_LabelServer, self.server)
67
+
68
+ def do_GET(self) -> None:
69
+ body = self._srv().html.encode("utf-8")
70
+ self.send_response(200)
71
+ self.send_header("Content-Type", "text/html; charset=utf-8")
72
+ self.send_header("Content-Length", str(len(body)))
73
+ self.end_headers()
74
+ self.wfile.write(body)
75
+
76
+ def do_POST(self) -> None:
77
+ srv = self._srv()
78
+ if parse_qs(urlparse(self.path).query).get("token", [""])[0] != srv.token:
79
+ self.send_error(403, "bad token")
80
+ return
81
+ length = int(self.headers.get("Content-Length", "0") or "0")
82
+ if length <= 0 or length > _MAX_BODY:
83
+ self.send_error(413, "empty or too large")
84
+ return
85
+ try:
86
+ import yaml as _yaml
87
+
88
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
89
+ yaml_text = str(payload.get("yaml", ""))
90
+ set_name = _sanitize(str(payload.get("name", "")))
91
+ raw = _yaml.safe_load(yaml_text)
92
+ # validate against the canonical schema before writing anything
93
+ parse_incident_labels(
94
+ raw, interval_seconds=srv.interval_seconds, metric_name=srv.metric
95
+ )
96
+ srv.incidents_dir.mkdir(parents=True, exist_ok=True)
97
+ out = srv.incidents_dir / f"{set_name}-{_stamp()}.yml"
98
+ out.write_text(yaml_text, encoding="utf-8")
99
+ srv.saved_path = out
100
+ except Exception as exc:
101
+ self.send_error(400, f"invalid labels: {exc}")
102
+ return
103
+ resp = json.dumps({"saved": str(out)}).encode("utf-8")
104
+ self.send_response(200)
105
+ self.send_header("Content-Type", "application/json")
106
+ self.send_header("Content-Length", str(len(resp)))
107
+ self.end_headers()
108
+ self.wfile.write(resp)
109
+ # stop serving (from this worker thread) so the command can continue
110
+ threading.Thread(target=srv.shutdown, daemon=True).start()
111
+
112
+
113
+ def build_label_server(
114
+ *,
115
+ metric_name: str,
116
+ data: dict[str, np.ndarray],
117
+ incidents_dir: Path,
118
+ interval_seconds: int,
119
+ ) -> tuple[_LabelServer, str]:
120
+ """Construct (without running) the labeler server; return ``(server, page_url)``."""
121
+ server = _LabelServer(("127.0.0.1", 0), _Handler)
122
+ token = secrets.token_urlsafe(16)
123
+ port = int(server.server_address[1])
124
+ server.token = token
125
+ server.metric = metric_name
126
+ server.incidents_dir = incidents_dir
127
+ server.interval_seconds = interval_seconds
128
+ server.html = render_labeler_html(
129
+ metric_name, data, save_url=f"http://127.0.0.1:{port}/save?token={token}"
130
+ )
131
+ return server, f"http://127.0.0.1:{port}/?token={token}"
132
+
133
+
134
+ def serve_labeler(
135
+ *,
136
+ metric_name: str,
137
+ data: dict[str, np.ndarray],
138
+ incidents_dir: Path,
139
+ interval_seconds: int,
140
+ open_browser: bool = True,
141
+ echo: Callable[[str], None] = print,
142
+ on_ready: Callable[[str], None] | None = None,
143
+ ) -> Path | None:
144
+ """Serve the labeler until the user saves (returns the file) or cancels (None)."""
145
+ server, url = build_label_server(
146
+ metric_name=metric_name,
147
+ data=data,
148
+ incidents_dir=incidents_dir,
149
+ interval_seconds=interval_seconds,
150
+ )
151
+ if on_ready is not None:
152
+ on_ready(url)
153
+ echo(f" Labeler: {url}")
154
+ echo(" Mark incidents in the browser, then click Save & tune (Ctrl-C to cancel).")
155
+ if open_browser:
156
+ try:
157
+ webbrowser.open(url)
158
+ except Exception:
159
+ pass
160
+ try:
161
+ server.serve_forever(poll_interval=0.3)
162
+ except KeyboardInterrupt:
163
+ return None
164
+ finally:
165
+ server.server_close()
166
+ return server.saved_path
@@ -47,13 +47,16 @@ dtk autotune --select <sel> [--incidents FILE] [--label] [--scoring METRIC] \
47
47
  ```
48
48
 
49
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).
50
+ **directory** (e.g. `incidents/<name>/`): interactive runs prompt to pick a
51
+ version (default newest), non-interactive use the newest. With nothing given, an
52
+ interactive terminal prompts to enter incidents inline; declining (or running
53
+ non-interactively) tunes **unsupervised**.
54
+ - `--label` — open the interactive labeler (zoom/pan, edit incident edges,
55
+ per-incident descriptions, named sets). **Default:** a local 127.0.0.1 server +
56
+ browser; **Save & tune** writes `incidents/<name>/<set>-<UTC>.yml` and the run
57
+ **continues into tuning on it**. `--no-serve` writes a static
58
+ `metrics/<name>__labeler.html` (Export downloads the file) and exits; `--no-open`
59
+ prints the URL instead of launching a browser.
57
60
  - `--scoring` — `mcc` (default), `f1`, `f_beta`, `balanced_accuracy`, `roc_auc`,
58
61
  `pr_auc`. MCC uses the whole confusion matrix and suits rare anomalies.
59
62
  - `--dry-run` — run the search but persist nothing and write no config.
@@ -82,17 +85,19 @@ incidents:
82
85
  When labels would help, **offer the interactive HTML labeler before asking the
83
86
  user to recall timestamps** — it is the easiest, most reliable path:
84
87
 
85
- 1. Run `dtk autotune --select <name> --label` (offline; writes no DB rows). It
86
- renders the series to a self-contained `metrics/<name>__labeler.html`.
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>/`.
88
+ 1. Run `dtk autotune --select <name> --label`. It starts a local labeler server
89
+ and opens the browser (use `--no-open` on a remote box and share the printed
90
+ 127.0.0.1 URL; the user port-forwards or runs it locally).
91
+ 2. The user marks incidents on the chart (scroll to zoom, drag the navigator to
92
+ move, click-drag to mark, drag an incident's edges to adjust, add a
93
+ description, optionally name the set), then clicks **Save & tune**.
94
+ 3. That writes `incidents/<name>/<set>-<UTC>.yml` automatically (versioned
95
+ re-labeling never overwrites) and the **same command continues into the tuning
96
+ run** on it. No manual file moving.
97
+ 4. To re-tune later on saved sets, point `--incidents` at the folder
98
+ (`incidents/<name>/`)interactive runs let the user pick a version. The
99
+ static `--no-serve` path still exists (Export downloads a file you move into
100
+ `incidents/<name>/`).
96
101
 
97
102
  Prefer this whenever the user can *recognise* incidents on a chart but doesn't
98
103
  have exact times. If they already know the times (or you found them via a DB
@@ -86,21 +86,25 @@ recall timestamps:
86
86
  dtk autotune --select <name> --label
87
87
  ```
88
88
 
89
- This is offline (no DB writes): it renders the metric's series into one
90
- self-contained file `metrics/<name>__labeler.html`. Walk the user through it:
89
+ By default this starts a **local labeler server** (127.0.0.1) and opens the
90
+ browser; on a remote machine add `--no-open` and share the printed URL. Walk the
91
+ user through it:
91
92
 
92
- 1. Open `metrics/<name>__labeler.html` in any browser just double-click it,
93
- it's self-contained (inline chart + data, no server, no internet).
94
- 2. Navigate a long/dense series: **scroll to zoom**, double-click to reset, drag
93
+ 1. Navigate a long/dense series: **scroll to zoom**, double-click to reset, drag
95
94
  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>/`.
95
+ 2. **Click-drag across the chart** to mark each incident (red band + a row below
96
+ with an optional **description**). Adjust one by dragging its **edges**, or its
97
+ **middle** to move it; optionally **name the set**; *remove* / *Clear all* fix
98
+ mistakes.
99
+ 3. Click **Save & tune**. The server writes `incidents/<name>/<set>-<UTC>.yml`
100
+ automatically (versioned re-labeling never overwrites) and the **same command
101
+ continues into the tuning run on it**. No manual file moving.
102
+
103
+ (`--no-serve` is the offline fallback: it writes a static
104
+ `metrics/<name>__labeler.html` whose Export downloads a versioned file you then
105
+ move into `incidents/<name>/` and pass via `--incidents`.) To re-tune later on a
106
+ saved set, point `--incidents` at `incidents/<name>/` (interactive runs let the
107
+ user pick a version).
104
108
 
105
109
  Prefer this whenever the user can *recognise* incidents on a chart but doesn't
106
110
  have exact timestamps — it is far easier than dictating times, and they label
@@ -27,6 +27,7 @@ from detectkit.autotune import (
27
27
  render_labeler_html,
28
28
  run_autotune_engine,
29
29
  )
30
+ from detectkit.autotune.label_server import serve_labeler
30
31
  from detectkit.autotune.labels import GroundTruth, IncidentLabels, parse_incident_labels
31
32
  from detectkit.cli._output import echo_done, echo_error, echo_noop
32
33
  from detectkit.cli.commands.run import find_project_root, parse_date, select_metrics
@@ -96,20 +97,29 @@ def _resolve_scoring(scoring_override: str | None, autotune_cfg: AutoTuneConfig)
96
97
  _LABELS_GLOBS = ("*.yml", "*.yaml", "*.json")
97
98
 
98
99
 
99
- def _newest_labels_file(directory: Path) -> Path | None:
100
- """Newest labels file in *directory*.
100
+ def _labels_files(directory: Path) -> list[Path]:
101
+ """All labels files in *directory*, oldest→newest.
101
102
 
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.
103
+ The labeler saves versioned, ISO-stamped names (``<set>-<UTC>.yml``) which sort
104
+ chronologically, so name order is chronological (tie-broken by mtime). This
105
+ lets ``incidents/<metric>/`` keep every labeling round on disk.
106
106
  """
107
107
  files: list[Path] = []
108
108
  for pattern in _LABELS_GLOBS:
109
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))
110
+ return sorted(files, key=lambda p: (p.name, p.stat().st_mtime))
111
+
112
+
113
+ def _pick_labels_file(files: list[Path]) -> tuple[Path, str]:
114
+ """Choose one labels file from a versioned set; prompt when interactive."""
115
+ newest = files[-1]
116
+ if len(files) == 1 or not sys.stdin.isatty():
117
+ return newest, "newest"
118
+ click.echo(" Saved label sets:")
119
+ for i, f in enumerate(files, 1):
120
+ click.echo(f" {i}) {f.name}" + (" (newest)" if f is newest else ""))
121
+ idx = click.prompt(f" Choose a set [1-{len(files)}]", default=len(files), type=int)
122
+ return files[min(max(idx, 1), len(files)) - 1], "chosen"
113
123
 
114
124
 
115
125
  def _resolve_labels(
@@ -132,15 +142,16 @@ def _resolve_labels(
132
142
  if not file_path.is_absolute():
133
143
  file_path = project_root / file_path
134
144
  if file_path.is_dir():
135
- chosen = _newest_labels_file(file_path)
136
- if chosen is None:
145
+ files = _labels_files(file_path)
146
+ if not files:
137
147
  raise FileNotFoundError(
138
148
  f"No labels files (*.yml / *.yaml / *.json) found in {file_path}"
139
149
  )
150
+ chosen, how = _pick_labels_file(files)
140
151
  labels = parse_labels_file(
141
152
  chosen, interval_seconds=interval_seconds, metric_name=metric_name
142
153
  )
143
- return labels, f"file {chosen} (newest in {file_path}/)"
154
+ return labels, f"file {chosen} ({how} in {file_path}/)"
144
155
  labels = parse_labels_file(
145
156
  file_path, interval_seconds=interval_seconds, metric_name=metric_name
146
157
  )
@@ -248,6 +259,8 @@ def run_autotune(
248
259
  select: str,
249
260
  incidents_path: str | None,
250
261
  label: bool,
262
+ no_serve: bool = False,
263
+ no_open: bool = False,
251
264
  scoring_override: str | None,
252
265
  from_date: str | None,
253
266
  to_date: str | None,
@@ -283,6 +296,8 @@ def run_autotune(
283
296
  internal_manager=internal_manager,
284
297
  incidents_path=incidents_path,
285
298
  label=label,
299
+ no_serve=no_serve,
300
+ no_open=no_open,
286
301
  scoring_override=scoring_override,
287
302
  from_dt=from_dt,
288
303
  to_dt=to_dt,
@@ -303,6 +318,8 @@ def _tune_one(
303
318
  internal_manager: InternalTablesManager,
304
319
  incidents_path: str | None,
305
320
  label: bool,
321
+ no_serve: bool = False,
322
+ no_open: bool = False,
306
323
  scoring_override: str | None,
307
324
  from_dt: datetime | None,
308
325
  to_dt: datetime | None,
@@ -324,15 +341,35 @@ def _tune_one(
324
341
  echo_noop(name, "no datapoints — run `dtk run --select " + name + " --steps load` first")
325
342
  return False
326
343
 
327
- # --label: emit the HTML labeler and stop (offline; no DB writes).
344
+ # --label: open the interactive labeler. Default a local server that saves the
345
+ # marked incidents straight into incidents/<name>/ and then falls through to
346
+ # tuning on them. --no-serve instead writes a static HTML file and exits (you
347
+ # move the downloaded export in yourself); --no-open skips launching a browser.
328
348
  if label:
329
- html = render_labeler_html(name, data)
330
- out = project_root / "metrics" / f"{metric_path.stem}__labeler.html"
331
- out.write_text(html, encoding="utf-8")
332
349
  click.echo(click.style(f"Processing metric: {name}", fg="cyan", bold=True))
333
- click.echo(f" Wrote labeler: {out.relative_to(project_root)}")
334
- click.echo(" Open it, mark incidents, export, then re-run with --incidents")
335
- return True
350
+ if no_serve:
351
+ html = render_labeler_html(name, data)
352
+ out = project_root / "metrics" / f"{metric_path.stem}__labeler.html"
353
+ out.write_text(html, encoding="utf-8")
354
+ click.echo(f" Wrote labeler: {out.relative_to(project_root)}")
355
+ click.echo(
356
+ f" Open it, mark incidents, Export, save into incidents/{name}/, "
357
+ f"then re-run with --incidents incidents/{name}/"
358
+ )
359
+ return True
360
+ saved = serve_labeler(
361
+ metric_name=name,
362
+ data=data,
363
+ incidents_dir=project_root / "incidents" / name,
364
+ interval_seconds=interval_seconds,
365
+ open_browser=not no_open,
366
+ echo=click.echo,
367
+ )
368
+ if saved is None:
369
+ echo_noop(name, "labeling cancelled — no labels saved")
370
+ return False
371
+ click.echo(f" Saved labels: {saved.relative_to(project_root)}")
372
+ incidents_path = str(saved) # continue into supervised tuning on this set
336
373
 
337
374
  click.echo(click.style(f"Tuning metric: {name}", fg="cyan", bold=True))
338
375
  click.echo(f" Config file: {metric_path.relative_to(project_root)}")
@@ -205,7 +205,17 @@ def run(
205
205
  @click.option(
206
206
  "--label",
207
207
  is_flag=True,
208
- help="Emit a self-contained HTML labeler for the series and exit",
208
+ help="Open the interactive labeler (local server) to mark incidents, then tune on them",
209
+ )
210
+ @click.option(
211
+ "--no-serve",
212
+ is_flag=True,
213
+ help="With --label, write a static HTML labeler file and exit instead of serving",
214
+ )
215
+ @click.option(
216
+ "--no-open",
217
+ is_flag=True,
218
+ help="With --label, don't auto-open the browser (just print the local URL)",
209
219
  )
210
220
  @click.option(
211
221
  "--scoring",
@@ -240,6 +250,8 @@ def autotune(
240
250
  select: str,
241
251
  incidents_path: str,
242
252
  label: bool,
253
+ no_serve: bool,
254
+ no_open: bool,
243
255
  scoring_override: str,
244
256
  from_date: str,
245
257
  to_date: str,
@@ -266,7 +278,7 @@ def autotune(
266
278
  # Unsupervised (no labels)
267
279
  dtk autotune --select checkout_errors
268
280
 
269
- # Emit an HTML labeler to mark incidents visually
281
+ # Mark incidents in a browser, save into incidents/<m>/, and tune on them
270
282
  dtk autotune --select checkout_errors --label
271
283
 
272
284
  # Search only, change nothing
@@ -278,6 +290,8 @@ def autotune(
278
290
  select=select,
279
291
  incidents_path=incidents_path,
280
292
  label=label,
293
+ no_serve=no_serve,
294
+ no_open=no_open,
281
295
  scoring_override=scoring_override,
282
296
  from_date=from_date,
283
297
  to_date=to_date,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -39,6 +39,7 @@ detectkit/autotune/detector_select.py
39
39
  detectkit/autotune/distribution.py
40
40
  detectkit/autotune/grid_search.py
41
41
  detectkit/autotune/html_labeler.py
42
+ detectkit/autotune/label_server.py
42
43
  detectkit/autotune/labels.py
43
44
  detectkit/autotune/result.py
44
45
  detectkit/autotune/scoring.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes