detectkit 0.20.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 (128) hide show
  1. {detectkit-0.20.0/detectkit.egg-info → detectkit-0.22.0}/PKG-INFO +1 -1
  2. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/autotuner.py +6 -1
  4. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/config_emitter.py +8 -3
  5. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/detector_select.py +16 -10
  6. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/grid_search.py +19 -4
  7. detectkit-0.22.0/detectkit/autotune/html_labeler.py +362 -0
  8. detectkit-0.22.0/detectkit/autotune/scoring.py +330 -0
  9. detectkit-0.22.0/detectkit/autotune/seasonality_search.py +216 -0
  10. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/settings.py +12 -5
  11. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/window_select.py +14 -6
  12. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/autotune.md +69 -19
  13. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +88 -29
  14. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/autotune.py +32 -1
  15. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/metric_config.py +27 -0
  16. {detectkit-0.20.0 → detectkit-0.22.0/detectkit.egg-info}/PKG-INFO +1 -1
  17. detectkit-0.20.0/detectkit/autotune/html_labeler.py +0 -103
  18. detectkit-0.20.0/detectkit/autotune/scoring.py +0 -177
  19. detectkit-0.20.0/detectkit/autotune/seasonality_search.py +0 -135
  20. {detectkit-0.20.0 → detectkit-0.22.0}/LICENSE +0 -0
  21. {detectkit-0.20.0 → detectkit-0.22.0}/MANIFEST.in +0 -0
  22. {detectkit-0.20.0 → detectkit-0.22.0}/README.md +0 -0
  23. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/__init__.py +0 -0
  24. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/__init__.py +0 -0
  25. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/base.py +0 -0
  26. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/branding.py +0 -0
  27. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/email.py +0 -0
  28. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/factory.py +0 -0
  29. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/mattermost.py +0 -0
  30. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/slack.py +0 -0
  31. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/telegram.py +0 -0
  32. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/webhook.py +0 -0
  33. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  34. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  35. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  36. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  37. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  38. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  39. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  40. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  41. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/__init__.py +0 -0
  42. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/_base.py +0 -0
  43. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/_types.py +0 -0
  44. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/crossval.py +0 -0
  45. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/distribution.py +0 -0
  46. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/labels.py +0 -0
  47. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/result.py +0 -0
  48. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/__init__.py +0 -0
  49. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/_output.py +0 -0
  50. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  51. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  52. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  53. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  54. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  55. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  56. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  57. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  58. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  59. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  60. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/__init__.py +0 -0
  61. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/clean.py +0 -0
  62. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/init.py +0 -0
  63. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/init_claude.py +0 -0
  64. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/run.py +0 -0
  65. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/test_alert.py +0 -0
  66. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/unlock.py +0 -0
  67. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/main.py +0 -0
  68. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/__init__.py +0 -0
  69. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/profile.py +0 -0
  70. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/project_config.py +0 -0
  71. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/validator.py +0 -0
  72. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/core/__init__.py +0 -0
  73. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/core/interval.py +0 -0
  74. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/core/models.py +0 -0
  75. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/__init__.py +0 -0
  76. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/_sql_manager.py +0 -0
  77. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/clickhouse_manager.py +0 -0
  78. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/__init__.py +0 -0
  79. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  80. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  81. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_base.py +0 -0
  82. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  83. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_detections.py +0 -0
  84. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  85. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  86. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_schema.py +0 -0
  87. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  88. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/manager.py +0 -0
  89. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/manager.py +0 -0
  90. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/mysql_manager.py +0 -0
  91. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/postgres_manager.py +0 -0
  92. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/tables.py +0 -0
  93. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/__init__.py +0 -0
  94. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/base.py +0 -0
  95. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/factory.py +0 -0
  96. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/seasonality.py +0 -0
  97. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/__init__.py +0 -0
  98. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  99. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/iqr.py +0 -0
  100. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/mad.py +0 -0
  101. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  102. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/zscore.py +0 -0
  103. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/loaders/__init__.py +0 -0
  104. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/loaders/metric_loader.py +0 -0
  105. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/loaders/query_template.py +0 -0
  106. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/__init__.py +0 -0
  107. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/error_dispatch.py +0 -0
  108. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  109. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  110. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  111. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  112. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  113. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  114. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  115. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/__init__.py +0 -0
  116. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/datetime_utils.py +0 -0
  117. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/env_interpolation.py +0 -0
  118. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/json_utils.py +0 -0
  119. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/stats.py +0 -0
  120. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/SOURCES.txt +0 -0
  121. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/dependency_links.txt +0 -0
  122. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/entry_points.txt +0 -0
  123. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/requires.txt +0 -0
  124. {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/top_level.txt +0 -0
  125. {detectkit-0.20.0 → detectkit-0.22.0}/pyproject.toml +0 -0
  126. {detectkit-0.20.0 → detectkit-0.22.0}/requirements.txt +0 -0
  127. {detectkit-0.20.0 → detectkit-0.22.0}/setup.cfg +0 -0
  128. {detectkit-0.20.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.20.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.20.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
@@ -65,10 +65,15 @@ class AutoTuner(_AutoTuneBase):
65
65
  )
66
66
 
67
67
  gt = self.ground_truth
68
+ objective = (
69
+ f"scoring={self.settings.metric.value}"
70
+ if gt.mode == TuneMode.SUPERVISED
71
+ else "objective=unsupervised (band-fit + flag-budget)"
72
+ )
68
73
  self.log(
69
74
  "labels",
70
75
  f"{gt.n_intervals} interval(s) + {gt.n_points} point(s) → {gt.mode.value} mode "
71
- f"({gt.n_positive} labeled grid point(s)); scoring={self.settings.metric.value}",
76
+ f"({gt.n_positive} labeled grid point(s)); {objective}",
72
77
  mode=gt.mode.value,
73
78
  n_positive=gt.n_positive,
74
79
  )
@@ -120,9 +120,14 @@ def _build_comments(result: AutoTuneResult, source_label: str, run_id: str) -> s
120
120
  f"{summary.get('positive_grid_points', 0)} labeled grid point(s)"
121
121
  )
122
122
  folds = " ".join(f"{f:.2f}" for f in result.cv_per_fold) or "—"
123
- lines.append(
124
- f"# Scoring metric : {result.scoring_metric} = {result.score:.3f} (CV folds: {folds})"
125
- )
123
+ if result.mode == "supervised":
124
+ obj_label, objective = "Scoring metric", result.scoring_metric
125
+ else:
126
+ # Unsupervised runs never optimize the labelled metric (MCC etc.); they use
127
+ # the no-label objective. Label it honestly so the header doesn't claim an
128
+ # "mcc =" score it never computed (and contradict the Labels line).
129
+ obj_label, objective = "Objective", "unsupervised (band-fit + flag-budget)"
130
+ lines.append(f"# {obj_label:<16}: {objective} = {result.score:.3f} (CV folds: {folds})")
126
131
  lines.append("#")
127
132
  for entry in result.decision_log:
128
133
  label = _STAGE_LABELS.get(entry.get("stage", ""))
@@ -1,11 +1,13 @@
1
- """Stage 2: pick candidate detector type(s) from the data distribution.
1
+ """Stage 2: order the candidate detector types by distribution suitability.
2
2
 
3
3
  The "decision tree" is a small suitability spec keyed by detector *type name*
4
4
  (kept here, not on the detector classes, so existing detectors stay untouched
5
5
  and the whole feature is easy to remove). Each seasonality group votes for the
6
- type that best fits its distribution; types meeting the vote quorum (plus the
7
- global-group winner) become the candidates the grid search then sweeps
8
- because different seasonal groups may favor different algorithms.
6
+ type that best fits its distribution. The vote is **advisory only** it ranks
7
+ the types (most promising first) for the grid search, which then evaluates *all*
8
+ of them and lets the cross-validated objective pick the winner. A hand-tuned
9
+ heuristic therefore never excludes a type; it only decides who is tried first
10
+ (and is recorded in the decision log for transparency).
9
11
  """
10
12
 
11
13
  from __future__ import annotations
@@ -140,16 +142,20 @@ def select_detector_types(
140
142
  if label == "global":
141
143
  global_winner = ranked[0][0]
142
144
 
143
- quorum = tuner.settings.candidate_quorum
144
- chosen = {t for t, v in votes.items() if v >= quorum}
145
- chosen.add(global_winner)
146
- ranked_chosen = sorted(chosen, key=lambda t: total_suit[t], reverse=True)
147
- final = ranked_chosen[: tuner.settings.max_candidate_types]
145
+ # Evaluate ALL candidate types in the grid search and let the cross-validated
146
+ # objective pick the winner the suitability vote only ORDERS them (most
147
+ # promising first) and is logged for transparency; it never excludes a type,
148
+ # so a hand-tuned heuristic can't drop the detector that would have scored
149
+ # best. The cap is a pure cost backstop (default ≥ the number of detectors).
150
+ final = sorted(candidate_types, key=lambda t: total_suit[t], reverse=True)
151
+ final = final[: tuner.settings.max_candidate_types]
152
+ if global_winner not in final:
153
+ final.append(global_winner)
148
154
 
149
155
  tally = ", ".join(f"{t}:{votes[t]:.1f}" for t in candidate_types)
150
156
  tuner.log(
151
157
  "detector_select",
152
- f"votes — {tally}; shortlist: {', '.join(final)}",
158
+ f"suitability votes — {tally}; evaluating (best-first): {', '.join(final)}",
153
159
  votes=votes,
154
160
  shortlist=final,
155
161
  n_groups=len(groups),
@@ -2,9 +2,11 @@
2
2
 
3
3
  Not a Cartesian product — a greedy coordinate sweep per candidate type:
4
4
  threshold → recency weighting → detrend (gated by a trend test) → window size
5
- (the final axis, with the large-window tie-bias from window_select). The
6
- objective is the cross-validated score (settings.metric). Total evaluations
7
- stay in the low tens per type and are capped by settings.max_candidates.
5
+ (with the trend-gated window tie-bias from window_select) → a final threshold
6
+ re-sweep at the chosen window (the threshold↔window coupling fix). The objective
7
+ is the cross-validated score (settings.metric, or the unsupervised objective when
8
+ there are no labels). Total evaluations stay in the low tens per type and are
9
+ capped by settings.max_candidates.
8
10
  """
9
11
 
10
12
  from __future__ import annotations
@@ -87,11 +89,24 @@ def grid_search(
87
89
  if ev is not None and ev.score > best.score + eps:
88
90
  best, accepted["detrend"] = ev, detrend
89
91
 
90
- # Axis 4: window size (final; large-window tie-bias).
92
+ # Axis 4: window size (large-window tie-bias, trend-gated in select_window).
91
93
  window_best = select_window(tuner, detector_type, accepted, best, grid)
92
94
  if window_best.score >= best.score - tuner.settings.window_tie_margin:
93
95
  best = window_best
94
96
 
97
+ # Axis 5: re-sweep threshold at the now-fixed window. The optimal threshold
98
+ # depends on window size (a longer window gives a steadier spread estimate),
99
+ # but threshold was chosen first against the seed window — without this pass
100
+ # a large window swing can leave it stranded. Strict improvement only.
101
+ accepted = dict(best.params)
102
+ for threshold in tuner.settings.threshold_grid(detector_type):
103
+ if threshold == accepted.get("threshold"):
104
+ continue
105
+ ev = tuner.safe_evaluate(detector_type, {**accepted, "threshold": threshold})
106
+ if ev is not None and ev.score > best.score:
107
+ best = ev
108
+ accepted["threshold"] = threshold
109
+
95
110
  tuner.log(
96
111
  "grid_search",
97
112
  f"{detector_type}: best score {best.score:.3f} "
@@ -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)