detectkit 0.21.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 (127) hide show
  1. {detectkit-0.21.0/detectkit.egg-info → detectkit-0.23.0}/PKG-INFO +1 -1
  2. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/__init__.py +1 -1
  3. detectkit-0.23.0/detectkit/autotune/html_labeler.py +456 -0
  4. detectkit-0.23.0/detectkit/autotune/label_server.py +166 -0
  5. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/autotune.md +24 -13
  6. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +19 -10
  7. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/autotune.py +75 -8
  8. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/main.py +16 -2
  9. {detectkit-0.21.0 → detectkit-0.23.0/detectkit.egg-info}/PKG-INFO +1 -1
  10. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/SOURCES.txt +1 -0
  11. detectkit-0.21.0/detectkit/autotune/html_labeler.py +0 -219
  12. {detectkit-0.21.0 → detectkit-0.23.0}/LICENSE +0 -0
  13. {detectkit-0.21.0 → detectkit-0.23.0}/MANIFEST.in +0 -0
  14. {detectkit-0.21.0 → detectkit-0.23.0}/README.md +0 -0
  15. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/__init__.py +0 -0
  16. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/__init__.py +0 -0
  17. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/base.py +0 -0
  18. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/branding.py +0 -0
  19. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/email.py +0 -0
  20. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/factory.py +0 -0
  21. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/mattermost.py +0 -0
  22. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/slack.py +0 -0
  23. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/telegram.py +0 -0
  24. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/webhook.py +0 -0
  25. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  26. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  27. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  28. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  29. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  30. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  31. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  32. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  33. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/__init__.py +0 -0
  34. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/_base.py +0 -0
  35. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/_types.py +0 -0
  36. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/autotuner.py +0 -0
  37. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/config_emitter.py +0 -0
  38. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/crossval.py +0 -0
  39. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/detector_select.py +0 -0
  40. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/distribution.py +0 -0
  41. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/grid_search.py +0 -0
  42. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/labels.py +0 -0
  43. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/result.py +0 -0
  44. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/scoring.py +0 -0
  45. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/seasonality_search.py +0 -0
  46. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/settings.py +0 -0
  47. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/window_select.py +0 -0
  48. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/__init__.py +0 -0
  49. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/_output.py +0 -0
  50. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  51. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  52. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  53. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  54. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  55. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  56. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  57. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  58. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  59. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  60. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/__init__.py +0 -0
  61. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/clean.py +0 -0
  62. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/init.py +0 -0
  63. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/init_claude.py +0 -0
  64. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/run.py +0 -0
  65. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/test_alert.py +0 -0
  66. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/unlock.py +0 -0
  67. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/__init__.py +0 -0
  68. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/metric_config.py +0 -0
  69. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/profile.py +0 -0
  70. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/project_config.py +0 -0
  71. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/validator.py +0 -0
  72. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/core/__init__.py +0 -0
  73. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/core/interval.py +0 -0
  74. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/core/models.py +0 -0
  75. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/__init__.py +0 -0
  76. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/_sql_manager.py +0 -0
  77. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/clickhouse_manager.py +0 -0
  78. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/__init__.py +0 -0
  79. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  80. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  81. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_base.py +0 -0
  82. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  83. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_detections.py +0 -0
  84. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  85. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  86. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_schema.py +0 -0
  87. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  88. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/manager.py +0 -0
  89. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/manager.py +0 -0
  90. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/mysql_manager.py +0 -0
  91. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/postgres_manager.py +0 -0
  92. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/tables.py +0 -0
  93. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/__init__.py +0 -0
  94. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/base.py +0 -0
  95. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/factory.py +0 -0
  96. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/seasonality.py +0 -0
  97. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/__init__.py +0 -0
  98. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  99. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/iqr.py +0 -0
  100. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/mad.py +0 -0
  101. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  102. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/zscore.py +0 -0
  103. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/loaders/__init__.py +0 -0
  104. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/loaders/metric_loader.py +0 -0
  105. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/loaders/query_template.py +0 -0
  106. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/__init__.py +0 -0
  107. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/error_dispatch.py +0 -0
  108. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  109. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  110. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  111. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  112. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  113. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  114. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  115. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/__init__.py +0 -0
  116. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/datetime_utils.py +0 -0
  117. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/env_interpolation.py +0 -0
  118. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/json_utils.py +0 -0
  119. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/stats.py +0 -0
  120. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/dependency_links.txt +0 -0
  121. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/entry_points.txt +0 -0
  122. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/requires.txt +0 -0
  123. {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/top_level.txt +0 -0
  124. {detectkit-0.21.0 → detectkit-0.23.0}/pyproject.toml +0 -0
  125. {detectkit-0.21.0 → detectkit-0.23.0}/requirements.txt +0 -0
  126. {detectkit-0.21.0 → detectkit-0.23.0}/setup.cfg +0 -0
  127. {detectkit-0.21.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.21.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.21.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
@@ -0,0 +1,456 @@
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
+ 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); }
75
+ .summary { margin-left: auto; color: var(--faint); font-size: 12.5px; font-family: var(--mono); }
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); }
81
+ canvas#c { width: 100%; height: clamp(300px, 44vh, 500px); display:block; touch-action: none;
82
+ background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
83
+ .zoombar { display:flex; align-items:center; gap:8px; margin: 10px 0 6px; }
84
+ .rangelbl { margin-left: auto; color: var(--faint); font-size: 12px; font-family: var(--mono); }
85
+ canvas#ov { width: 100%; height: 66px; display:block; touch-action: none;
86
+ background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: grab; }
87
+ .navhint { color: var(--faint); font-size: 12px; margin: 7px 2px 0; }
88
+ .empty { color: var(--faint); font-size: 13px; margin: 18px 2px; font-style: italic; }
89
+ ul { list-style: none; margin: 16px 0 0; padding: 0; }
90
+ li { display:flex; align-items:center; gap:11px; padding: 9px 12px; font-size: 13px; flex-wrap: wrap;
91
+ border: 1px solid var(--term-border); border-radius: 8px; margin-bottom: 7px; background: var(--term-surface); }
92
+ li .dot { width:9px; height:9px; border-radius:50%; background: var(--anomaly); flex: 0 0 auto; }
93
+ li .span { font-family: var(--mono); color: var(--term-text); }
94
+ li .dur { color: var(--faint); font-size: 12px; }
95
+ li input.desc { flex: 1 1 220px; min-width: 160px; background: var(--term-bg); color: var(--paper);
96
+ border: 1px solid var(--term-border); border-radius: 6px; padding: 6px 9px; font-family: var(--ui); font-size: 12.5px; }
97
+ li input.desc::placeholder { color: var(--muted); }
98
+ li input.desc:focus { outline: none; border-color: var(--clay); }
99
+ li button { margin-left: auto; padding: 5px 11px; font-size: 12px; }
100
+ footer { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--term-border);
101
+ color: var(--faint); font-size: 12px; line-height: 1.6; }
102
+ footer code { font-family: var(--mono); color: var(--term-text); }
103
+ </style>
104
+ <div class="shell">
105
+ <div class="brand">
106
+ <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>
107
+ <b>detectkit</b><span>· incident labeler</span>
108
+ </div>
109
+ <h1>Label incidents — <code>__METRIC__</code></h1>
110
+ <p class="hint">Click-drag across the chart to mark each real incident, add a short description, then
111
+ <b>Export</b>. Save the file into <code class="k">incidents/__METRIC__/</code> and run
112
+ <code class="k">dtk autotune --select __METRIC__ --incidents incidents/__METRIC__/</code></p>
113
+ <div class="toolbar">
114
+ <input id="setname" class="setname" type="text" placeholder="name this set (optional)" />
115
+ <button id="export" class="primary">Export labels</button>
116
+ <button id="clear" class="ghost">Clear all</button>
117
+ <span id="summary" class="summary"></span>
118
+ </div>
119
+ <div id="savemsg" class="savemsg"></div>
120
+ <canvas id="c" aria-label="metric series — drag to mark an incident, scroll to zoom"></canvas>
121
+ <div class="zoombar">
122
+ <button id="zreset" class="ghost">Reset zoom</button>
123
+ <span id="range" class="rangelbl"></span>
124
+ </div>
125
+ <canvas id="ov" aria-label="navigator — drag the window to pan, its edges to stretch the view"></canvas>
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>
129
+ <div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
130
+ <ul id="list"></ul>
131
+ <footer>All times UTC · self-contained, nothing leaves your browser · re-label any time —
132
+ exports are versioned (<code>__METRIC__-&lt;timestamp&gt;.yml</code>), so keep every round in
133
+ <code>incidents/__METRIC__/</code>. Generated by <code>dtk autotune --label</code>.</footer>
134
+ </div>
135
+ <script>
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__;
141
+ const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
142
+ const N = pts.length;
143
+ const vraw = pts.filter(p => p.v !== null).map(p => p.v);
144
+ const vmin0 = vraw.length ? Math.min.apply(null, vraw) : 0;
145
+ const vmax0 = vraw.length ? Math.max.apply(null, vraw) : 1;
146
+ const vpad = (vmax0 - vmin0) * 0.06 || 1;
147
+ const vmin = vmin0 - vpad, vmax = vmax0 + vpad;
148
+ const tmin = N ? pts[0].ts : 0, tmax = N ? pts[N-1].ts : 1, fullSpan = (tmax - tmin) || 1;
149
+ const step = fullSpan / Math.max(1, N - 1);
150
+ const minSpan = Math.max(step * 8, 1000);
151
+ let viewMin = tmin, viewMax = tmax;
152
+ const incidents = [];
153
+ const c = document.getElementById('c'), ov = document.getElementById('ov');
154
+ const ctx = c.getContext('2d'), octx = ov.getContext('2d');
155
+ const M = {l:56, r:16, t:14, b:30}, OM = {l:56, r:16, t:8, b:8};
156
+ let dpr = 1, hover = null, dragging = null, ovAct = null;
157
+
158
+ const clamp = (x,a,b) => Math.max(a, Math.min(b, x));
159
+ const vspan = () => viewMax - viewMin;
160
+ const plotW = () => c.width - (M.l+M.r)*dpr;
161
+ const plotH = () => c.height - (M.t+M.b)*dpr;
162
+ const px = ts => M.l*dpr + (ts-viewMin)/(vspan()||1)*plotW();
163
+ const py = v => (c.height - M.b*dpr) - (v-vmin)/((vmax-vmin)||1)*plotH();
164
+ const ovWd = () => ov.width - (OM.l+OM.r)*dpr;
165
+ const ovHt = () => ov.height - (OM.t+OM.b)*dpr;
166
+ const ovpx = ts => OM.l*dpr + (ts-tmin)/fullSpan*ovWd();
167
+ const ovpy = v => (ov.height - OM.b*dpr) - (v-vmin)/((vmax-vmin)||1)*ovHt();
168
+ const pad2 = n => String(n).padStart(2,'0');
169
+ const fmtTs = ts => new Date(ts).toISOString().slice(0,19).replace('T',' ');
170
+ const fmtTick = (ts, sp) => { const s = new Date(ts).toISOString();
171
+ return sp < 2*86400000 ? s.slice(5,16).replace('T',' ') : s.slice(5,10); };
172
+ const fmtVal = v => { const a = Math.abs(v); return a>=1000 ? v.toFixed(0) : a>=10 ? v.toFixed(1) : v.toFixed(2); };
173
+ function fmtDur(ms) { const m = Math.round(ms/60000); if (m<60) return m+'m';
174
+ const h = Math.floor(m/60), mm = m%60; if (h<24) return h+'h'+(mm?(' '+mm+'m'):'');
175
+ const d = Math.floor(h/24), hh = h%24; return d+'d'+(hh?(' '+hh+'h'):''); }
176
+ const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
177
+ const yamlStr = s => '"' + String(s).replace(/\\\\/g,'\\\\\\\\').replace(/"/g,'\\\\"') + '"';
178
+
179
+ function setView(a, b) {
180
+ let s = b - a;
181
+ if (s < minSpan) { const m=(a+b)/2; a=m-minSpan/2; b=m+minSpan/2; s=minSpan; }
182
+ if (s >= fullSpan) { a=tmin; b=tmax; }
183
+ if (a < tmin) { b += tmin-a; a=tmin; }
184
+ if (b > tmax) { a -= b-tmax; b=tmax; }
185
+ viewMin = clamp(a, tmin, tmax); viewMax = clamp(b, tmin, tmax);
186
+ drawAll();
187
+ }
188
+
189
+ // Series drawing with min/max decimation (one envelope segment per pixel column)
190
+ // so a 100k-point series stays fast and spikes stay visible; direct polyline when
191
+ // few points are visible (zoomed in).
192
+ function drawSeries(ctx2, xfn, yfn, lo, hi, leftDev, widthDev, color, lw) {
193
+ const cols = Math.max(1, Math.round(widthDev)), sp = (hi-lo)||1;
194
+ let vis = 0;
195
+ for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<lo||p.ts>hi) continue; vis++; }
196
+ ctx2.strokeStyle = color; ctx2.lineWidth = lw*dpr; ctx2.beginPath();
197
+ if (vis <= cols) {
198
+ let pen = false;
199
+ for (let i=0;i<N;i++) { const p=pts[i];
200
+ if (p.v===null || p.ts<lo || p.ts>hi) { pen=false; continue; }
201
+ const X=xfn(p.ts), Y=yfn(p.v);
202
+ if (!pen) { ctx2.moveTo(X,Y); pen=true; } else ctx2.lineTo(X,Y);
203
+ }
204
+ } else {
205
+ const cmin = new Array(cols).fill(null), cmax = new Array(cols).fill(null);
206
+ for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<lo||p.ts>hi) continue;
207
+ let col = Math.floor((p.ts-lo)/sp*(cols-1)); col = col<0?0:col>cols-1?cols-1:col;
208
+ if (cmin[col]===null||p.v<cmin[col]) cmin[col]=p.v;
209
+ if (cmax[col]===null||p.v>cmax[col]) cmax[col]=p.v;
210
+ }
211
+ let pen = false;
212
+ for (let col=0;col<cols;col++) { if (cmax[col]===null) { pen=false; continue; }
213
+ const X=leftDev+col, yh=yfn(cmax[col]), yl=yfn(cmin[col]);
214
+ if (!pen) { ctx2.moveTo(X,yh); pen=true; } else ctx2.lineTo(X,yh);
215
+ ctx2.lineTo(X,yl);
216
+ }
217
+ }
218
+ ctx2.stroke();
219
+ }
220
+
221
+ function draw() {
222
+ ctx.clearRect(0,0,c.width,c.height);
223
+ ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
224
+ ctx.textBaseline = 'middle';
225
+ for (let i=0;i<=4;i++) { const v=vmin+(vmax-vmin)*i/4, yy=py(v);
226
+ ctx.strokeStyle='rgba(255,255,255,0.05)'; ctx.lineWidth=1*dpr;
227
+ ctx.beginPath(); ctx.moveTo(M.l*dpr,yy); ctx.lineTo(c.width-M.r*dpr,yy); ctx.stroke();
228
+ ctx.fillStyle='#6e675b'; ctx.textAlign='right'; ctx.fillText(fmtVal(v),(M.l-8)*dpr,yy); }
229
+ ctx.textBaseline = 'top';
230
+ for (let i=0;i<=5;i++) { const ts=viewMin+vspan()*i/5, xx=px(ts);
231
+ ctx.fillStyle='#6e675b'; ctx.textAlign=i===0?'left':i===5?'right':'center';
232
+ ctx.fillText(fmtTick(ts,vspan()), xx, (c.height-M.b+8)*dpr); }
233
+ ctx.save(); ctx.beginPath(); ctx.rect(M.l*dpr, M.t*dpr, plotW(), plotH()); ctx.clip();
234
+ incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
235
+ ctx.fillStyle='rgba(214,50,50,0.20)'; ctx.fillRect(x0, M.t*dpr, x1-x0, plotH());
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);
242
+ ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
243
+ drawSeries(ctx, px, py, viewMin, viewMax, M.l*dpr, plotW(), '#d15b36', 1.5);
244
+ ctx.restore();
245
+ if (hover && !dragging && !ovAct) drawHover();
246
+ }
247
+
248
+ function drawHover() {
249
+ let best=null, bd=Infinity;
250
+ for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<viewMin||p.ts>viewMax) continue;
251
+ const d=Math.abs(p.ts-hover.ts); if (d<bd) { bd=d; best=p; } }
252
+ if (!best) return;
253
+ const X=px(best.ts), Y=py(best.v);
254
+ ctx.strokeStyle='rgba(201,194,180,0.25)'; ctx.lineWidth=1*dpr;
255
+ ctx.beginPath(); ctx.moveTo(X, M.t*dpr); ctx.lineTo(X, c.height-M.b*dpr); ctx.stroke();
256
+ ctx.fillStyle='#f5f1e8'; ctx.beginPath(); ctx.arc(X,Y,3*dpr,0,7); ctx.fill();
257
+ const label=fmtTick(best.ts, 0)+' · '+fmtVal(best.v);
258
+ ctx.font=(11*dpr)+'px ui-monospace, monospace'; const tw=ctx.measureText(label).width;
259
+ let bx=X+8*dpr; if (bx+tw+12*dpr > c.width) bx=X-tw-20*dpr;
260
+ ctx.fillStyle='rgba(27,25,22,0.92)'; ctx.strokeStyle='#332f29';
261
+ ctx.fillRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr); ctx.strokeRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr);
262
+ ctx.fillStyle='#c9c2b4'; ctx.textAlign='left'; ctx.textBaseline='middle';
263
+ ctx.fillText(label, bx+6*dpr, M.t*dpr+12);
264
+ }
265
+
266
+ function drawOverview() {
267
+ octx.clearRect(0,0,ov.width,ov.height);
268
+ octx.save(); octx.beginPath(); octx.rect(OM.l*dpr, OM.t*dpr, ovWd(), ovHt()); octx.clip();
269
+ incidents.forEach(iv => { const x0=ovpx(iv.a), x1=ovpx(iv.b);
270
+ octx.fillStyle='rgba(214,50,50,0.30)'; octx.fillRect(x0, OM.t*dpr, x1-x0, ovHt()); });
271
+ drawSeries(octx, ovpx, ovpy, tmin, tmax, OM.l*dpr, ovWd(), 'rgba(209,91,54,0.7)', 1.1);
272
+ octx.restore();
273
+ const vx0=ovpx(viewMin), vx1=ovpx(viewMax);
274
+ octx.fillStyle='rgba(27,25,22,0.55)';
275
+ octx.fillRect(OM.l*dpr, OM.t*dpr, vx0-OM.l*dpr, ovHt());
276
+ octx.fillRect(vx1, OM.t*dpr, (ov.width-OM.r*dpr)-vx1, ovHt());
277
+ octx.fillStyle='rgba(245,241,232,0.06)'; octx.fillRect(vx0, OM.t*dpr, vx1-vx0, ovHt());
278
+ octx.strokeStyle='#d15b36'; octx.lineWidth=1.5*dpr;
279
+ octx.strokeRect(vx0, OM.t*dpr+1, vx1-vx0, ovHt()-2);
280
+ octx.fillStyle='#d15b36'; const hy=OM.t*dpr+ovHt()/2-9*dpr;
281
+ octx.fillRect(vx0-2*dpr, hy, 4*dpr, 18*dpr); octx.fillRect(vx1-2*dpr, hy, 4*dpr, 18*dpr);
282
+ }
283
+
284
+ const tsAt = clientX => { const r=c.getBoundingClientRect();
285
+ const fr=((clientX-r.left)-M.l)/((r.width-(M.l+M.r))||1); return viewMin + clamp(fr,0,1)*vspan(); };
286
+ const ovTsAtCss = clientX => { const r=ov.getBoundingClientRect();
287
+ const fr=((clientX-r.left)-OM.l)/((r.width-(OM.l+OM.r))||1); return tmin + clamp(fr,0,1)*fullSpan; };
288
+ const ovEdgeCss = ts => { const r=ov.getBoundingClientRect();
289
+ return r.left + OM.l + (ts-tmin)/fullSpan*(r.width-(OM.l+OM.r)); };
290
+
291
+ c.addEventListener('wheel', e => { e.preventDefault(); const t=tsAt(e.clientX);
292
+ let s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan);
293
+ const f=(t-viewMin)/(vspan()||1); setView(t-f*s, t-f*s+s); }, {passive:false});
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
+ });
315
+ c.addEventListener('mousemove', e => { if (ovAct) return;
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
+ });
333
+ c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; draw(); } });
334
+
335
+ ov.addEventListener('mousedown', e => { e.preventDefault(); ov.style.cursor='grabbing';
336
+ const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax), x=e.clientX, H=8;
337
+ if (Math.abs(x-xl)<=H) ovAct={type:'l'};
338
+ else if (Math.abs(x-xr)<=H) ovAct={type:'r'};
339
+ else if (x>xl && x<xr) ovAct={type:'pan', grab:ovTsAtCss(x), vMin:viewMin, vMax:viewMax};
340
+ else { const t=ovTsAtCss(x), s=vspan(); setView(t-s/2, t+s/2); ovAct={type:'pan', grab:t, vMin:viewMin, vMax:viewMax}; }
341
+ });
342
+ ov.addEventListener('mousemove', e => { if (ovAct) return; const x=e.clientX, H=8;
343
+ const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax);
344
+ ov.style.cursor = (Math.abs(x-xl)<=H || Math.abs(x-xr)<=H) ? 'ew-resize' : (x>xl && x<xr) ? 'grab' : 'pointer'; });
345
+ ov.addEventListener('wheel', e => { e.preventDefault(); const t=ovTsAtCss(e.clientX);
346
+ const s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan); setView(t-s/2, t+s/2); }, {passive:false});
347
+
348
+ window.addEventListener('mousemove', e => { if (!ovAct) return; const t=ovTsAtCss(e.clientX);
349
+ if (ovAct.type==='l') setView(Math.min(t, viewMax-minSpan), viewMax);
350
+ else if (ovAct.type==='r') setView(viewMin, Math.max(t, viewMin+minSpan));
351
+ else { const d=t-ovAct.grab; setView(ovAct.vMin+d, ovAct.vMax+d); } });
352
+ window.addEventListener('mouseup', () => {
353
+ if (ovAct) { ovAct=null; return; }
354
+ if (!dragging) return;
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; } }
362
+ dragging=null; render();
363
+ });
364
+
365
+ document.getElementById('zreset').onclick = () => setView(tmin, tmax);
366
+ c.addEventListener('dblclick', () => setView(tmin, tmax));
367
+ document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
368
+ window.setLabel = (i, val) => { if (incidents[i]) incidents[i].label = val; };
369
+ window.rm = i => { incidents.splice(i,1); render(); };
370
+
371
+ function render() {
372
+ incidents.sort((p,q)=>p.a-q.a);
373
+ const list=document.getElementById('list');
374
+ list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
375
+ +'<span class="span">'+fmtTs(iv.a)+' &rarr; '+fmtTs(iv.b)+'</span>'
376
+ +'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
377
+ +'<input class="desc" type="text" placeholder="describe this incident (optional)" '
378
+ +'value="'+esc(iv.label||'')+'" oninput="setLabel('+i+', this.value)">'
379
+ +'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
380
+ document.getElementById('empty').style.display = incidents.length ? 'none' : '';
381
+ const total=incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
382
+ document.getElementById('summary').innerHTML = incidents.length
383
+ ? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total' : '';
384
+ drawAll();
385
+ }
386
+
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 = () => {
391
+ let y='metric: __METRIC__\\ntimezone: UTC\\nincidents:\\n';
392
+ const sorted=incidents.slice().sort((p,q)=>p.a-q.a);
393
+ if (!sorted.length) y+=' []\\n';
394
+ sorted.forEach(iv => { y+=' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"'
395
+ + (iv.label && iv.label.trim() ? ', label: '+yamlStr(iv.label.trim()) : '') + '}\\n'; });
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
+ }
421
+ };
422
+
423
+ function drawAll() { draw(); drawOverview();
424
+ document.getElementById('range').textContent =
425
+ 'viewing ' + fmtTs(viewMin) + ' → ' + fmtTs(viewMax) + ' · ' + fmtDur(vspan()) + ' of ' + fmtDur(fullSpan); }
426
+ function fit() { dpr = window.devicePixelRatio || 1;
427
+ c.width=c.clientWidth*dpr; c.height=c.clientHeight*dpr;
428
+ ov.width=ov.clientWidth*dpr; ov.height=ov.clientHeight*dpr; drawAll(); }
429
+ window.addEventListener('resize', fit); fit(); render();
430
+ </script>
431
+ """
432
+
433
+
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
+
445
+ timestamps = data["timestamp"]
446
+ values = data["value"]
447
+ points = []
448
+ for i in range(len(timestamps)):
449
+ v = values[i]
450
+ points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
451
+ payload = json_dumps_sorted({"metric": metric_name, "points": points})
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