detectkit 0.19.0__tar.gz → 0.21.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 (129) hide show
  1. {detectkit-0.19.0/detectkit.egg-info → detectkit-0.21.0}/PKG-INFO +1 -1
  2. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/autotuner.py +6 -1
  4. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/config_emitter.py +8 -3
  5. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/detector_select.py +16 -10
  6. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/grid_search.py +19 -4
  7. detectkit-0.21.0/detectkit/autotune/html_labeler.py +219 -0
  8. detectkit-0.21.0/detectkit/autotune/scoring.py +330 -0
  9. detectkit-0.21.0/detectkit/autotune/seasonality_search.py +216 -0
  10. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/settings.py +12 -5
  11. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/window_select.py +14 -6
  12. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/CLAUDE.section.md +11 -0
  13. detectkit-0.21.0/detectkit/cli/assets/claude/rules/autotune.md +181 -0
  14. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +91 -19
  15. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +9 -0
  16. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/autotune.py +15 -1
  17. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/init.py +64 -2
  18. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/metric_config.py +70 -1
  19. {detectkit-0.19.0 → detectkit-0.21.0/detectkit.egg-info}/PKG-INFO +1 -1
  20. detectkit-0.19.0/detectkit/autotune/html_labeler.py +0 -103
  21. detectkit-0.19.0/detectkit/autotune/scoring.py +0 -177
  22. detectkit-0.19.0/detectkit/autotune/seasonality_search.py +0 -135
  23. detectkit-0.19.0/detectkit/cli/assets/claude/rules/autotune.md +0 -128
  24. {detectkit-0.19.0 → detectkit-0.21.0}/LICENSE +0 -0
  25. {detectkit-0.19.0 → detectkit-0.21.0}/MANIFEST.in +0 -0
  26. {detectkit-0.19.0 → detectkit-0.21.0}/README.md +0 -0
  27. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/__init__.py +0 -0
  28. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/__init__.py +0 -0
  29. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/base.py +0 -0
  30. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/branding.py +0 -0
  31. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/email.py +0 -0
  32. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/factory.py +0 -0
  33. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/mattermost.py +0 -0
  34. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/slack.py +0 -0
  35. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/telegram.py +0 -0
  36. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/webhook.py +0 -0
  37. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  38. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  39. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  40. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  41. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  42. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  43. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  44. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  45. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/__init__.py +0 -0
  46. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/_base.py +0 -0
  47. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/_types.py +0 -0
  48. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/crossval.py +0 -0
  49. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/distribution.py +0 -0
  50. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/labels.py +0 -0
  51. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/result.py +0 -0
  52. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/__init__.py +0 -0
  53. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/_output.py +0 -0
  54. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  55. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  56. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  57. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  58. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  59. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  60. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  61. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  62. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/__init__.py +0 -0
  63. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/clean.py +0 -0
  64. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/init_claude.py +0 -0
  65. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/run.py +0 -0
  66. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/test_alert.py +0 -0
  67. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/unlock.py +0 -0
  68. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/main.py +0 -0
  69. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/__init__.py +0 -0
  70. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/profile.py +0 -0
  71. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/project_config.py +0 -0
  72. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/validator.py +0 -0
  73. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/core/__init__.py +0 -0
  74. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/core/interval.py +0 -0
  75. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/core/models.py +0 -0
  76. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/__init__.py +0 -0
  77. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/_sql_manager.py +0 -0
  78. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/clickhouse_manager.py +0 -0
  79. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/__init__.py +0 -0
  80. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  81. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  82. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_base.py +0 -0
  83. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  84. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_detections.py +0 -0
  85. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  86. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  87. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_schema.py +0 -0
  88. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  89. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/manager.py +0 -0
  90. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/manager.py +0 -0
  91. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/mysql_manager.py +0 -0
  92. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/postgres_manager.py +0 -0
  93. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/tables.py +0 -0
  94. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/__init__.py +0 -0
  95. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/base.py +0 -0
  96. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/factory.py +0 -0
  97. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/seasonality.py +0 -0
  98. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/__init__.py +0 -0
  99. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  100. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/iqr.py +0 -0
  101. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/mad.py +0 -0
  102. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  103. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/zscore.py +0 -0
  104. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/loaders/__init__.py +0 -0
  105. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/loaders/metric_loader.py +0 -0
  106. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/loaders/query_template.py +0 -0
  107. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/__init__.py +0 -0
  108. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/error_dispatch.py +0 -0
  109. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  110. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  111. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  112. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  113. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  114. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  115. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  116. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/__init__.py +0 -0
  117. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/datetime_utils.py +0 -0
  118. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/env_interpolation.py +0 -0
  119. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/json_utils.py +0 -0
  120. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/stats.py +0 -0
  121. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/SOURCES.txt +0 -0
  122. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/dependency_links.txt +0 -0
  123. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/entry_points.txt +0 -0
  124. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/requires.txt +0 -0
  125. {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/top_level.txt +0 -0
  126. {detectkit-0.19.0 → detectkit-0.21.0}/pyproject.toml +0 -0
  127. {detectkit-0.19.0 → detectkit-0.21.0}/requirements.txt +0 -0
  128. {detectkit-0.19.0 → detectkit-0.21.0}/setup.cfg +0 -0
  129. {detectkit-0.19.0 → detectkit-0.21.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.19.0
3
+ Version: 0.21.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.19.0"
7
+ __version__ = "0.21.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,219 @@
1
+ """Generate a self-contained HTML labeler for a metric series.
2
+
3
+ Emits a single HTML file (inline CSS/JS, no CDN) with the series embedded as a
4
+ JSON literal: a canvas line chart where the user click-drags over incident
5
+ spans and exports a labels file in the canonical schema, which is then fed back
6
+ via ``dtk autotune --select <metric> --incidents <file>``.
7
+
8
+ Docs sync: the autotune reference page embeds a *live* copy of this output
9
+ (``docs/examples/autotune-labeler.html``) so the site always shows the real UI.
10
+ After changing the template below, regenerate that example so the docs don't
11
+ drift: ``python website/scripts/gen-labeler-example.py`` (also in the release
12
+ checklist).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime, timedelta
18
+
19
+ import numpy as np
20
+
21
+ from detectkit.utils.json_utils import json_dumps_sorted
22
+
23
+
24
+ def _ts_to_str(ts64: np.datetime64) -> str:
25
+ ms = int(ts64.astype("datetime64[ms]").astype(np.int64))
26
+ return (datetime(1970, 1, 1) + timedelta(milliseconds=ms)).strftime("%Y-%m-%d %H:%M:%S")
27
+
28
+
29
+ # Built with .replace() (not .format()), so braces are literal — keep them single.
30
+ # Self-contained: inline brand styling/logo/JS, no network. Palette + fonts mirror
31
+ # website/src/styles/brand.css (.claude/rules/design.md); incident bands use the
32
+ # anomaly status color, the drag preview the no-data color.
33
+ _TEMPLATE = """<!doctype html>
34
+ <meta charset="utf-8">
35
+ <meta name="viewport" content="width=device-width, initial-scale=1">
36
+ <title>detectkit · label incidents · __METRIC__</title>
37
+ <style>
38
+ :root {
39
+ --clay:#d15b36; --clay-700:#b4471f; --paper:#f5f1e8; --muted:#6e675b; --faint:#9a9384;
40
+ --term-bg:#211e1a; --term-surface:#1b1916; --term-border:#332f29; --term-text:#c9c2b4;
41
+ --anomaly:#d63232; --nodata:#f0ad4e;
42
+ --ui:'Schibsted Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
43
+ --mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
44
+ }
45
+ * { box-sizing: border-box; }
46
+ body { font-family: var(--ui); margin: 0; background: var(--term-bg); color: var(--term-text);
47
+ -webkit-font-smoothing: antialiased; }
48
+ .shell { max-width: 1080px; margin: 0 auto; padding: 22px 22px 40px; }
49
+ .brand { display:flex; align-items:center; gap:9px; margin-bottom: 14px; }
50
+ .brand svg { width: 26px; height: 26px; border-radius: 7px; display:block; }
51
+ .brand b { color: var(--paper); font-weight: 600; font-size: 15px; letter-spacing: .2px; }
52
+ .brand span { color: var(--faint); font-size: 12px; }
53
+ h1 { font-size: 18px; line-height: 1.3; margin: 0 0 6px; color: var(--paper); font-weight: 600; }
54
+ h1 code { color: var(--clay); font-family: var(--mono); font-size: .82em; }
55
+ .hint { color: var(--faint); font-size: 13px; margin: 0 0 18px; line-height: 1.5; }
56
+ .hint code { color: var(--term-text); font-family: var(--mono); font-size: 12px;
57
+ background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 5px; padding: 1px 6px; }
58
+ .toolbar { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom: 12px; }
59
+ button { font-family: var(--ui); font-size: 13px; font-weight: 500; border: 0; border-radius: 7px;
60
+ padding: 9px 15px; cursor: pointer; transition: background .12s ease, border-color .12s ease; }
61
+ button.primary { background: var(--clay); color: #fff; }
62
+ button.primary:hover { background: var(--clay-700); }
63
+ button.ghost { background: transparent; color: var(--term-text); border: 1px solid var(--term-border); }
64
+ button.ghost:hover { border-color: var(--faint); color: var(--paper); }
65
+ .summary { margin-left: auto; color: var(--faint); font-size: 12.5px; font-family: var(--mono); }
66
+ .summary b { color: var(--clay); font-weight: 600; }
67
+ canvas { width: 100%; height: clamp(320px, 46vh, 520px); display:block;
68
+ background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
69
+ .empty { color: var(--faint); font-size: 13px; margin: 16px 2px; font-style: italic; }
70
+ ul { list-style: none; margin: 14px 0 0; padding: 0; }
71
+ li { display:flex; align-items:center; gap:12px; padding: 9px 12px; font-size: 13px;
72
+ border: 1px solid var(--term-border); border-radius: 8px; margin-bottom: 7px; background: var(--term-surface); }
73
+ li .dot { width:9px; height:9px; border-radius:50%; background: var(--anomaly); flex: 0 0 auto; }
74
+ li .span { font-family: var(--mono); color: var(--term-text); }
75
+ li .dur { color: var(--faint); font-size: 12px; }
76
+ li button { margin-left: auto; padding: 5px 11px; font-size: 12px; }
77
+ footer { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--term-border);
78
+ color: var(--faint); font-size: 12px; }
79
+ footer code { font-family: var(--mono); color: var(--term-text); }
80
+ </style>
81
+ <div class="shell">
82
+ <div class="brand">
83
+ <svg viewBox="0 0 100 100" aria-hidden="true"><rect x="3" y="3" width="94" height="94" rx="26" fill="#D15B36"/><polyline points="14,62 36,62 50,22 64,62 86,62" fill="none" stroke="#FBF9F3" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="50" cy="22" r="6.5" fill="#FBF9F3"/></svg>
84
+ <b>detectkit</b><span>· incident labeler</span>
85
+ </div>
86
+ <h1>Label incidents — <code>__METRIC__</code></h1>
87
+ <p class="hint">Click-drag across the chart to mark each real incident, then <b>Export</b> and run
88
+ <code>dtk autotune --select __METRIC__ --incidents incidents-__METRIC__.yml</code></p>
89
+ <div class="toolbar">
90
+ <button id="export" class="primary">Export incidents-__METRIC__.yml</button>
91
+ <button id="clear" class="ghost">Clear all</button>
92
+ <span id="summary" class="summary"></span>
93
+ </div>
94
+ <canvas id="c" aria-label="metric series chart — drag horizontally to mark an incident span"></canvas>
95
+ <div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
96
+ <ul id="list"></ul>
97
+ <footer>All times UTC · self-contained, no data leaves your browser · generated by
98
+ <code>dtk autotune --label</code></footer>
99
+ </div>
100
+ <script>
101
+ const DATA = __PAYLOAD__;
102
+ const pts = DATA.points.map(p => ({t: p.t, ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
103
+ const incidents = [];
104
+ const c = document.getElementById('c');
105
+ const ctx = c.getContext('2d');
106
+ const vals = pts.map(p => p.v).filter(v => v !== null);
107
+ const vmin = vals.length ? Math.min.apply(null, vals) : 0;
108
+ const vmax = vals.length ? Math.max.apply(null, vals) : 1;
109
+ const tmin = pts.length ? pts[0].ts : 0;
110
+ const tmax = pts.length ? pts[pts.length-1].ts : 1;
111
+ const M = {l: 56, r: 16, t: 14, b: 30}; // css-px plot margins
112
+ let dpr = 1, hover = null, dragging = null;
113
+ function plotW() { return c.width - (M.l + M.r) * dpr; }
114
+ function plotH() { return c.height - (M.t + M.b) * dpr; }
115
+ function px(ts) { return M.l*dpr + (ts - tmin) / ((tmax - tmin) || 1) * plotW(); }
116
+ function py(v) { return (c.height - M.b*dpr) - (v - vmin) / ((vmax - vmin) || 1) * plotH(); }
117
+ function tsAt(clientX) { const r=c.getBoundingClientRect();
118
+ const fr=((clientX-r.left)-M.l)/((r.width-(M.l+M.r))||1); return tmin + Math.max(0,Math.min(1,fr))*(tmax-tmin); }
119
+ function fmtTs(ts) { return new Date(ts).toISOString().slice(0,19).replace('T',' '); }
120
+ function fmtAxis(ts) { return new Date(ts).toISOString().slice(5,16).replace('T',' '); }
121
+ function fmtVal(v) { const a=Math.abs(v); return a>=1000 ? v.toFixed(0) : a>=10 ? v.toFixed(1) : v.toFixed(2); }
122
+ function fmtDur(ms) { const m=Math.round(ms/60000); if(m<60) return m+'m';
123
+ const h=Math.floor(m/60), mm=m%60; if(h<24) return h+'h'+(mm?(' '+mm+'m'):'');
124
+ const d=Math.floor(h/24), hh=h%24; return d+'d'+(hh?(' '+hh+'h'):''); }
125
+ function fit() { dpr = window.devicePixelRatio || 1;
126
+ c.width = c.clientWidth * dpr; c.height = c.clientHeight * dpr; draw(); }
127
+ function draw() {
128
+ ctx.clearRect(0,0,c.width,c.height);
129
+ ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
130
+ // y gridlines + value ticks
131
+ ctx.textBaseline = 'middle';
132
+ for (let i=0;i<=4;i++) { const v=vmin+(vmax-vmin)*i/4, yy=py(v);
133
+ ctx.strokeStyle='rgba(255,255,255,0.05)'; ctx.lineWidth=1*dpr; ctx.beginPath();
134
+ ctx.moveTo(M.l*dpr, yy); ctx.lineTo(c.width-M.r*dpr, yy); ctx.stroke();
135
+ ctx.fillStyle='#6e675b'; ctx.textAlign='right'; ctx.fillText(fmtVal(v), (M.l-8)*dpr, yy); }
136
+ // x ticks
137
+ ctx.textBaseline='top';
138
+ for (let i=0;i<=5;i++) { const ts=tmin+(tmax-tmin)*i/5, xx=px(ts);
139
+ ctx.fillStyle='#6e675b'; ctx.textAlign = i===0?'left':i===5?'right':'center';
140
+ ctx.fillText(fmtAxis(ts), xx, (c.height-M.b+8)*dpr); }
141
+ // committed incident bands
142
+ incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
143
+ ctx.fillStyle='rgba(214,50,50,0.20)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH());
144
+ ctx.strokeStyle='rgba(214,50,50,0.55)'; ctx.lineWidth=1*dpr;
145
+ ctx.strokeRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); });
146
+ // drag preview
147
+ if (dragging) { const x0=px(dragging.a), x1=px(dragging.b);
148
+ ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
149
+ // series line
150
+ ctx.strokeStyle='#d15b36'; ctx.lineWidth=1.5*dpr; ctx.beginPath();
151
+ let started=false;
152
+ pts.forEach(p => { if (p.v===null) { started=false; return; } const X=px(p.ts), Y=py(p.v);
153
+ if (!started) { ctx.moveTo(X,Y); started=true; } else ctx.lineTo(X,Y); });
154
+ ctx.stroke();
155
+ // hover crosshair + tooltip
156
+ if (hover && !dragging) drawHover();
157
+ }
158
+ function drawHover() {
159
+ let best=null, bd=Infinity;
160
+ for (const p of pts) { if (p.v===null) continue; const d=Math.abs(p.ts-hover.ts);
161
+ if (d<bd) { bd=d; best=p; } }
162
+ if (!best) return;
163
+ const X=px(best.ts), Y=py(best.v);
164
+ ctx.strokeStyle='rgba(201,194,180,0.25)'; ctx.lineWidth=1*dpr; ctx.beginPath();
165
+ ctx.moveTo(X, M.t*dpr); ctx.lineTo(X, c.height-M.b*dpr); ctx.stroke();
166
+ ctx.fillStyle='#f5f1e8'; ctx.beginPath(); ctx.arc(X,Y,3*dpr,0,7); ctx.fill();
167
+ const label=fmtAxis(best.ts)+' · '+fmtVal(best.v);
168
+ ctx.font=(11*dpr)+'px ui-monospace, monospace'; const tw=ctx.measureText(label).width;
169
+ let bx=X+8*dpr; if (bx+tw+12*dpr > c.width) bx=X-tw-20*dpr;
170
+ ctx.fillStyle='rgba(27,25,22,0.92)'; ctx.strokeStyle='#332f29';
171
+ ctx.fillRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr); ctx.strokeRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr);
172
+ ctx.fillStyle='#c9c2b4'; ctx.textAlign='left'; ctx.textBaseline='middle';
173
+ ctx.fillText(label, bx+6*dpr, M.t*dpr+12);
174
+ }
175
+ c.addEventListener('mousedown', e => { dragging={a:tsAt(e.clientX), b:tsAt(e.clientX)}; });
176
+ c.addEventListener('mousemove', e => { if (dragging) { dragging.b=tsAt(e.clientX); }
177
+ else { hover={ts:tsAt(e.clientX)}; } draw(); });
178
+ c.addEventListener('mouseleave', () => { hover=null; draw(); });
179
+ window.addEventListener('mouseup', () => { if(!dragging) return;
180
+ if (Math.abs(dragging.b-dragging.a) > 1000) incidents.push({a:Math.min(dragging.a,dragging.b), b:Math.max(dragging.a,dragging.b)});
181
+ dragging=null; render(); });
182
+ function render() {
183
+ const list=document.getElementById('list');
184
+ incidents.sort((p,q)=>p.a-q.a);
185
+ list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
186
+ +'<span class="span">'+fmtTs(iv.a)+' &rarr; '+fmtTs(iv.b)+'</span>'
187
+ +'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
188
+ +'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
189
+ document.getElementById('empty').style.display = incidents.length ? 'none' : '';
190
+ const total = incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
191
+ document.getElementById('summary').innerHTML = incidents.length
192
+ ? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total'
193
+ : '';
194
+ draw();
195
+ }
196
+ function rm(i) { incidents.splice(i,1); render(); }
197
+ document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
198
+ document.getElementById('export').onclick = () => {
199
+ let y = 'metric: __METRIC__\\ntimezone: UTC\\nincidents:\\n';
200
+ if (!incidents.length) y += ' []\\n';
201
+ incidents.forEach(iv => { y += ' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"}\\n'; });
202
+ const blob = new Blob([y], {type:'text/yaml'}); const a=document.createElement('a');
203
+ a.href = URL.createObjectURL(blob); a.download = 'incidents-__METRIC__.yml'; a.click();
204
+ };
205
+ window.addEventListener('resize', fit); fit(); render();
206
+ </script>
207
+ """
208
+
209
+
210
+ def render_labeler_html(metric_name: str, data: dict[str, np.ndarray]) -> str:
211
+ """Return a self-contained HTML labeler page for *metric_name*'s series."""
212
+ timestamps = data["timestamp"]
213
+ values = data["value"]
214
+ points = []
215
+ for i in range(len(timestamps)):
216
+ v = values[i]
217
+ points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
218
+ payload = json_dumps_sorted({"metric": metric_name, "points": points})
219
+ return _TEMPLATE.replace("__PAYLOAD__", payload).replace("__METRIC__", metric_name)