detectkit 0.24.1__tar.gz → 0.25.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. {detectkit-0.24.1/detectkit.egg-info → detectkit-0.25.0}/PKG-INFO +1 -1
  2. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/config_emitter.py +11 -3
  4. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/html_labeler.py +259 -31
  5. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/label_server.py +13 -2
  6. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/labels.py +53 -4
  7. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/rules/autotune.md +19 -7
  8. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/rules/detectors.md +1 -1
  9. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +16 -8
  10. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/commands/autotune.py +85 -2
  11. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_datapoints.py +15 -0
  12. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/task_manager/_detect_step.py +24 -0
  13. {detectkit-0.24.1 → detectkit-0.25.0/detectkit.egg-info}/PKG-INFO +1 -1
  14. {detectkit-0.24.1 → detectkit-0.25.0}/LICENSE +0 -0
  15. {detectkit-0.24.1 → detectkit-0.25.0}/MANIFEST.in +0 -0
  16. {detectkit-0.24.1 → detectkit-0.25.0}/README.md +0 -0
  17. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/__init__.py +0 -0
  18. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/__init__.py +0 -0
  19. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/base.py +0 -0
  20. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/branding.py +0 -0
  21. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/email.py +0 -0
  22. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/factory.py +0 -0
  23. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/mattermost.py +0 -0
  24. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/slack.py +0 -0
  25. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/telegram.py +0 -0
  26. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/channels/webhook.py +0 -0
  27. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  28. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  29. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  30. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  31. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  32. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  33. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  34. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  35. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/__init__.py +0 -0
  36. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/_base.py +0 -0
  37. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/_types.py +0 -0
  38. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/autotuner.py +0 -0
  39. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/crossval.py +0 -0
  40. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/detector_select.py +0 -0
  41. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/distribution.py +0 -0
  42. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/grid_search.py +0 -0
  43. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/result.py +0 -0
  44. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/scoring.py +0 -0
  45. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/seasonality_search.py +0 -0
  46. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/settings.py +0 -0
  47. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/autotune/window_select.py +0 -0
  48. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/__init__.py +0 -0
  49. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/_output.py +0 -0
  50. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  51. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  52. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  53. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  54. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  55. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  56. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  57. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  58. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  59. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/commands/__init__.py +0 -0
  60. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/commands/clean.py +0 -0
  61. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/commands/init.py +0 -0
  62. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/commands/init_claude.py +0 -0
  63. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/commands/run.py +0 -0
  64. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/commands/test_alert.py +0 -0
  65. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/commands/unlock.py +0 -0
  66. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/cli/main.py +0 -0
  67. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/config/__init__.py +0 -0
  68. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/config/metric_config.py +0 -0
  69. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/config/profile.py +0 -0
  70. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/config/project_config.py +0 -0
  71. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/config/validator.py +0 -0
  72. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/core/__init__.py +0 -0
  73. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/core/interval.py +0 -0
  74. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/core/models.py +0 -0
  75. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/__init__.py +0 -0
  76. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/_sql_manager.py +0 -0
  77. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/clickhouse_manager.py +0 -0
  78. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/__init__.py +0 -0
  79. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  80. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  81. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_base.py +0 -0
  82. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_detections.py +0 -0
  83. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  84. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  85. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_schema.py +0 -0
  86. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  87. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/internal_tables/manager.py +0 -0
  88. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/manager.py +0 -0
  89. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/mysql_manager.py +0 -0
  90. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/postgres_manager.py +0 -0
  91. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/database/tables.py +0 -0
  92. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/__init__.py +0 -0
  93. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/base.py +0 -0
  94. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/factory.py +0 -0
  95. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/seasonality.py +0 -0
  96. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/statistical/__init__.py +0 -0
  97. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  98. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/statistical/iqr.py +0 -0
  99. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/statistical/mad.py +0 -0
  100. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  101. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/detectors/statistical/zscore.py +0 -0
  102. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/loaders/__init__.py +0 -0
  103. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/loaders/metric_loader.py +0 -0
  104. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/loaders/query_template.py +0 -0
  105. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/__init__.py +0 -0
  106. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/error_dispatch.py +0 -0
  107. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  108. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  109. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  110. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  111. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  112. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  113. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/utils/__init__.py +0 -0
  114. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/utils/datetime_utils.py +0 -0
  115. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/utils/env_interpolation.py +0 -0
  116. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/utils/json_utils.py +0 -0
  117. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit/utils/stats.py +0 -0
  118. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit.egg-info/SOURCES.txt +0 -0
  119. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit.egg-info/dependency_links.txt +0 -0
  120. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit.egg-info/entry_points.txt +0 -0
  121. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit.egg-info/requires.txt +0 -0
  122. {detectkit-0.24.1 → detectkit-0.25.0}/detectkit.egg-info/top_level.txt +0 -0
  123. {detectkit-0.24.1 → detectkit-0.25.0}/pyproject.toml +0 -0
  124. {detectkit-0.24.1 → detectkit-0.25.0}/requirements.txt +0 -0
  125. {detectkit-0.24.1 → detectkit-0.25.0}/setup.cfg +0 -0
  126. {detectkit-0.24.1 → detectkit-0.25.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.24.1
3
+ Version: 0.25.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.24.1"
7
+ __version__ = "0.25.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -103,9 +103,17 @@ def _build_body(original: MetricConfig, result: AutoTuneResult, new_name: str) -
103
103
  elif original.seasonality_columns:
104
104
  body["seasonality_columns"] = original.seasonality_columns
105
105
 
106
- body["detectors"] = [
107
- {"type": result.chosen_detector_type, "params": result.chosen_detector_params}
108
- ]
106
+ # Pin the detector's detection start to the load start so the first
107
+ # `dtk run` on the generated config detects across all loaded history. A
108
+ # fresh detector that omits `start_time` has no lower bound (no prior
109
+ # detections, no --from); DETECT falls back to loading_start_time, but
110
+ # emitting it keeps the generated config explicit and self-sufficient on
111
+ # older detectkit. `start_time` is execution-level and excluded from the
112
+ # detector_id hash, so it never changes detector identity.
113
+ detector_params = dict(result.chosen_detector_params)
114
+ if "start_time" not in detector_params and body.get("loading_start_time"):
115
+ detector_params["start_time"] = body["loading_start_time"]
116
+ body["detectors"] = [{"type": result.chosen_detector_type, "params": detector_params}]
109
117
  alerting = _build_alerting(original, result)
110
118
  if alerting is not None:
111
119
  body["alerting"] = alerting
@@ -6,6 +6,14 @@ incident spans (with an optional per-incident description) and exports a labels
6
6
  file in the canonical schema, fed back via
7
7
  ``dtk autotune --select <metric> --incidents <file-or-dir>``.
8
8
 
9
+ It can be **seeded with existing incidents** (the ``incidents=`` argument) so a
10
+ labels file can be opened and edited in place — the round-trip flow of filling
11
+ incidents in over time. Beyond click-drag marking it supports **threshold
12
+ capture** (grab every contiguous span above/below a horizontal line in one
13
+ gesture), **on-chart deletion** (each band carries an ✕ handle; the selected
14
+ band also responds to the Delete key), and an in-browser **Import** button that
15
+ loads a labels file you pick (YAML/JSON).
16
+
9
17
  The page is offline-only — a browser cannot write to the project, so Export
10
18
  downloads a **versioned** file named after the metric, with the optional set name
11
19
  folded in as a suffix (``<metric>[-<name>]-<UTC-stamp>.yml``); drop it into
@@ -21,6 +29,7 @@ release checklist).
21
29
 
22
30
  from __future__ import annotations
23
31
 
32
+ import base64
24
33
  from datetime import datetime, timedelta
25
34
 
26
35
  import numpy as np
@@ -33,13 +42,30 @@ def _ts_to_str(ts64: np.datetime64) -> str:
33
42
  return (datetime(1970, 1, 1) + timedelta(milliseconds=ms)).strftime("%Y-%m-%d %H:%M:%S")
34
43
 
35
44
 
45
+ # The brand mark, served as the page favicon (data URI, no network). Mirrors the
46
+ # inline header logo + website/public/favicon.svg (.claude/rules/design.md).
47
+ _FAVICON_SVG = (
48
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
49
+ '<rect x="3" y="3" width="94" height="94" rx="26" fill="#D15B36"/>'
50
+ '<polyline points="14,62 36,62 50,22 64,62 86,62" fill="none" stroke="#FBF9F3" '
51
+ 'stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>'
52
+ '<circle cx="50" cy="22" r="6.5" fill="#FBF9F3"/></svg>'
53
+ )
54
+
55
+
56
+ def _favicon_data_uri() -> str:
57
+ b64 = base64.b64encode(_FAVICON_SVG.encode("utf-8")).decode("ascii")
58
+ return "data:image/svg+xml;base64," + b64
59
+
60
+
36
61
  # Built with .replace() (not .format()), so braces are literal — keep them single.
37
62
  # Self-contained: inline brand styling/logo/JS, no network. Palette + fonts mirror
38
63
  # website/src/styles/brand.css (.claude/rules/design.md); incident bands use the
39
- # anomaly status color, the drag preview the no-data color.
64
+ # anomaly status color, the drag preview / threshold guide the no-data color.
40
65
  _TEMPLATE = """<!doctype html>
41
66
  <meta charset="utf-8">
42
67
  <meta name="viewport" content="width=device-width, initial-scale=1">
68
+ <link rel="icon" type="image/svg+xml" href="__FAVICON__">
43
69
  <title>detectkit · label incidents · __METRIC__</title>
44
70
  <style>
45
71
  :root {
@@ -72,8 +98,10 @@ _TEMPLATE = """<!doctype html>
72
98
  padding: 9px 15px; cursor: pointer; transition: background .12s ease, border-color .12s ease, color .12s ease; }
73
99
  button.primary { background: var(--clay); color: #fff; }
74
100
  button.primary:hover { background: var(--clay-700); }
101
+ button.primary:disabled { background: var(--term-border); color: var(--faint); cursor: default; }
75
102
  button.ghost { background: transparent; color: var(--term-text); border: 1px solid var(--term-border); }
76
103
  button.ghost:hover { border-color: var(--faint); color: var(--paper); }
104
+ button.ghost.active { border-color: var(--nodata); color: var(--paper); background: rgba(240,173,78,0.16); }
77
105
  input.setname { background: var(--term-surface); color: var(--paper); border: 1px solid var(--term-border);
78
106
  border-radius: 7px; padding: 9px 11px; font-family: var(--ui); font-size: 13px; min-width: 200px; }
79
107
  input.setname::placeholder { color: var(--muted); }
@@ -84,17 +112,27 @@ _TEMPLATE = """<!doctype html>
84
112
  .savemsg.ok { display: block; color: var(--accent-green, #2e9e73); }
85
113
  .savemsg.err { display: block; color: var(--anomaly); }
86
114
  .savemsg.info { display: block; color: var(--faint); }
115
+ .thbar { display:none; flex-wrap:wrap; gap:12px; align-items:center; margin: 0 0 12px;
116
+ padding: 11px 13px; border: 1px solid var(--nodata); border-radius: 9px; background: var(--term-surface); }
117
+ .thbar .thlabel { color: var(--nodata); font-size: 12.5px; font-weight: 600; }
118
+ .thbar label { color: var(--faint); font-size: 12.5px; display:inline-flex; align-items:center; gap:6px; }
119
+ .thbar select, .thbar input { background: var(--term-bg); color: var(--paper); border: 1px solid var(--term-border);
120
+ border-radius: 6px; padding: 6px 8px; font-family: var(--ui); font-size: 12.5px; }
121
+ .thbar input.num { width: 84px; font-family: var(--mono); }
122
+ .thbar input:focus, .thbar select:focus { outline: none; border-color: var(--nodata); }
123
+ .thbar button { padding: 7px 13px; }
87
124
  canvas#c { width: 100%; height: clamp(300px, 44vh, 500px); display:block; touch-action: none;
88
125
  background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
89
126
  .zoombar { display:flex; align-items:center; gap:8px; margin: 10px 0 6px; }
90
127
  .rangelbl { margin-left: auto; color: var(--faint); font-size: 12px; font-family: var(--mono); }
91
128
  canvas#ov { width: 100%; height: 66px; display:block; touch-action: none;
92
129
  background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: grab; }
93
- .navhint { color: var(--faint); font-size: 12px; margin: 7px 2px 0; }
130
+ .navhint { color: var(--faint); font-size: 12px; margin: 7px 2px 0; line-height: 1.55; }
94
131
  .empty { color: var(--faint); font-size: 13px; margin: 18px 2px; font-style: italic; }
95
132
  ul { list-style: none; margin: 16px 0 0; padding: 0; }
96
133
  li { display:flex; align-items:center; gap:11px; padding: 9px 12px; font-size: 13px; flex-wrap: wrap;
97
134
  border: 1px solid var(--term-border); border-radius: 8px; margin-bottom: 7px; background: var(--term-surface); }
135
+ li.sel { border-color: var(--clay); background: rgba(209,91,54,0.10); }
98
136
  li .dot { width:9px; height:9px; border-radius:50%; background: var(--anomaly); flex: 0 0 auto; }
99
137
  li .span { font-family: var(--mono); color: var(--term-text); }
100
138
  li .dur { color: var(--faint); font-size: 12px; }
@@ -103,6 +141,8 @@ _TEMPLATE = """<!doctype html>
103
141
  li input.desc::placeholder { color: var(--muted); }
104
142
  li input.desc:focus { outline: none; border-color: var(--clay); }
105
143
  li button { margin-left: auto; padding: 5px 11px; font-size: 12px; }
144
+ li button.focus { margin-left: auto; }
145
+ li button.focus + button { margin-left: 0; }
106
146
  footer { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--term-border);
107
147
  color: var(--faint); font-size: 12px; line-height: 1.6; }
108
148
  footer code { font-family: var(--mono); color: var(--term-text); }
@@ -118,11 +158,28 @@ _TEMPLATE = """<!doctype html>
118
158
  <b>Export</b>. Save the file into <code class="k">incidents/__METRIC__/</code> and run
119
159
  <code class="k">dtk autotune --select __METRIC__ --incidents incidents/__METRIC__/</code></p>
120
160
  <div class="toolbar">
161
+ <button id="importbtn" class="ghost" title="open an existing labels file to keep editing">Import file…</button>
162
+ <input id="file" type="file" accept=".yml,.yaml,.json,.txt" style="display:none">
121
163
  <input id="setname" class="setname" type="text" placeholder="name this set (optional)" />
122
164
  <button id="export" class="primary">Export labels</button>
165
+ <button id="thbtn" class="ghost" title="capture every span above or below a horizontal line">Threshold capture</button>
123
166
  <button id="clear" class="ghost">Clear all</button>
124
167
  <span id="summary" class="summary"></span>
125
168
  </div>
169
+ <div id="thbar" class="thbar">
170
+ <span class="thlabel">Threshold capture</span>
171
+ <label>grab points
172
+ <select id="thdir"><option value="above">above the line</option><option value="below">below the line</option></select>
173
+ </label>
174
+ <label>line value
175
+ <input id="thval" class="num" type="number" step="any" placeholder="hover chart" />
176
+ </label>
177
+ <label>bridge gaps ≤
178
+ <input id="thgap" class="num" type="number" min="0" step="1" value="0" /> intervals
179
+ </label>
180
+ <button id="thadd" class="primary" disabled>Add 0 spans</button>
181
+ <button id="thdone" class="ghost">Done</button>
182
+ </div>
126
183
  <div id="savemsg" class="savemsg"></div>
127
184
  <canvas id="c" aria-label="metric series — drag to mark an incident, scroll to zoom"></canvas>
128
185
  <div class="zoombar">
@@ -131,8 +188,9 @@ _TEMPLATE = """<!doctype html>
131
188
  </div>
132
189
  <canvas id="ov" aria-label="navigator — drag the window to pan, its edges to stretch the view"></canvas>
133
190
  <div class="navhint">Drag on an empty area to mark an incident · drag an existing incident's edges to
134
- adjust it, or its middle to move it · scroll to zoom, double-click to reset · drag the navigator
135
- window below to pan, its edges to stretch / squeeze the view.</div>
191
+ adjust it, or its middle to move it · click its <b>✕</b> (or select it and press Delete) to remove it ·
192
+ use <b>Threshold capture</b> to grab every span past a horizontal line at once · <b>focus</b> a row to jump
193
+ the chart to it · scroll to zoom, double-click to reset · drag the navigator window below to pan.</div>
136
194
  <div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
137
195
  <ul id="list"></ul>
138
196
  <footer>All times UTC · self-contained, nothing leaves your browser · re-label any time —
@@ -148,6 +206,9 @@ const SAVE_URL = __SAVE_URL__;
148
206
  // The metric's sampling interval (seconds). Passed straight from the metric when
149
207
  // known; otherwise inferred from the median spacing of points.
150
208
  const INTERVAL_S = __INTERVAL__;
209
+ // Incidents to seed the editor with (editing an existing labels file). Each is
210
+ // {start, end, label} in "YYYY-MM-DD HH:MM:SS" UTC; a point is start === end.
211
+ const PRELOAD = __INCIDENTS__;
151
212
  const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
152
213
  const N = pts.length;
153
214
  const vraw = pts.filter(p => p.v !== null).map(p => p.v);
@@ -160,10 +221,17 @@ const step = fullSpan / Math.max(1, N - 1);
160
221
  const minSpan = Math.max(step * 8, 1000);
161
222
  let viewMin = tmin, viewMax = tmax;
162
223
  const incidents = [];
224
+ // Seed from PRELOAD so re-opening a saved labels file continues where it left off.
225
+ PRELOAD.forEach(p => { const a = Date.parse(String(p.start).replace(' ','T')+'Z'),
226
+ b = Date.parse(String(p.end).replace(' ','T')+'Z');
227
+ if (!isNaN(a) && !isNaN(b)) incidents.push({a: Math.min(a,b), b: Math.max(a,b), label: p.label || ''}); });
163
228
  const c = document.getElementById('c'), ov = document.getElementById('ov');
164
229
  const ctx = c.getContext('2d'), octx = ov.getContext('2d');
165
230
  const M = {l:56, r:16, t:14, b:30}, OM = {l:56, r:16, t:8, b:8};
166
231
  let dpr = 1, hover = null, dragging = null, ovAct = null;
232
+ // selObj: the currently selected incident (object ref, survives re-sorting);
233
+ // hoverRow/hoverDel: list-row / ✕-handle hover targets; threshold-capture state.
234
+ let selObj = null, hoverRow = -1, hoverDel = -1, thMode = false, thHover = null;
167
235
 
168
236
  const clamp = (x,a,b) => Math.max(a, Math.min(b, x));
169
237
  const vspan = () => viewMax - viewMin;
@@ -228,6 +296,54 @@ function drawSeries(ctx2, xfn, yfn, lo, hi, leftDev, widthDev, color, lw) {
228
296
  ctx2.stroke();
229
297
  }
230
298
 
299
+ // Value at a CSS-pixel Y on the chart (inverse of py) — used to read the
300
+ // threshold line off the cursor.
301
+ const vAt = clientY => { const r=c.getBoundingClientRect();
302
+ const fr=((clientY-r.top)-M.t)/((r.height-(M.t+M.b))||1);
303
+ return vmax - clamp(fr,0,1)*(vmax-vmin); };
304
+ // The active threshold value: the locked input wins, else the live cursor value.
305
+ function thEff() { const s=thvalEl.value.trim();
306
+ return (s!=='' && !isNaN(Number(s))) ? Number(s) : thHover; }
307
+ // Contiguous runs of points on the chosen side of the line, bridging short gaps.
308
+ function thRuns() { const val=thEff(); if (val===null) return [];
309
+ const dir=thdirEl.value, gapMax=Math.max(0, parseInt(thgapEl.value)||0);
310
+ const runs=[]; let s=null, e=null, gap=0;
311
+ for (let i=0;i<N;i++) { const p=pts[i];
312
+ const q = p.v!==null && (dir==='above' ? p.v>val : p.v<val);
313
+ if (q) { if (s===null) s=p.ts; e=p.ts; gap=0; }
314
+ else if (s!==null) { gap++; if (gap>gapMax) { runs.push([s,e]); s=null; gap=0; } } }
315
+ if (s!==null) runs.push([s,e]);
316
+ return runs; }
317
+ function thCount() { const n=thRuns().length;
318
+ thaddEl.textContent = 'Add '+n+' span'+(n===1?'':'s'); thaddEl.disabled = n===0; }
319
+ // Add a captured span, merging it into any overlapping incidents (a single span
320
+ // can bridge several) into one band that keeps the first one's label.
321
+ function addCaptured(a,b) {
322
+ let host=null;
323
+ for (let i=incidents.length-1;i>=0;i--) { const iv=incidents[i];
324
+ if (a<=iv.b && b>=iv.a) {
325
+ if (host===null) { iv.a=Math.min(iv.a,a); iv.b=Math.max(iv.b,b); host=iv; }
326
+ else { host.a=Math.min(host.a,iv.a); host.b=Math.max(host.b,iv.b);
327
+ if (selObj===iv) selObj=host; incidents.splice(i,1); } } }
328
+ if (host===null) incidents.push({a, b, label:''}); }
329
+ function toggleTh(on) { thMode = (on===undefined) ? !thMode : !!on;
330
+ thbtnEl.classList.toggle('active', thMode); thbarEl.style.display = thMode ? 'flex' : 'none';
331
+ if (thMode) { hover=null; } else { thHover=null; }
332
+ c.style.cursor = thMode ? 'row-resize' : 'crosshair'; if (thMode) thCount(); draw(); }
333
+
334
+ function rr(g,x,y,w,h,r) { g.beginPath();
335
+ g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r);
336
+ g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
337
+ // The ✕ delete handle at a band's top-right (device px); `hot` brightens it.
338
+ function drawDelHandle(x1, hot) { const s=14*dpr, m=3*dpr, bx=x1-s-m, by=M.t*dpr+m;
339
+ ctx.fillStyle = hot ? 'rgba(214,50,50,0.95)' : 'rgba(27,25,22,0.82)';
340
+ ctx.strokeStyle = 'rgba(214,50,50,0.9)'; ctx.lineWidth = 1*dpr;
341
+ rr(ctx, bx, by, s, s, 3*dpr); ctx.fill(); ctx.stroke();
342
+ ctx.strokeStyle = hot ? '#fff' : '#d63232'; ctx.lineWidth = 1.5*dpr;
343
+ const p=4*dpr; ctx.beginPath();
344
+ ctx.moveTo(bx+p, by+p); ctx.lineTo(bx+s-p, by+s-p);
345
+ ctx.moveTo(bx+s-p, by+p); ctx.lineTo(bx+p, by+s-p); ctx.stroke(); }
346
+
231
347
  function draw() {
232
348
  ctx.clearRect(0,0,c.width,c.height);
233
349
  ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
@@ -241,21 +357,48 @@ function draw() {
241
357
  ctx.fillStyle='#6e675b'; ctx.textAlign=i===0?'left':i===5?'right':'center';
242
358
  ctx.fillText(fmtTick(ts,vspan()), xx, (c.height-M.b+8)*dpr); }
243
359
  ctx.save(); ctx.beginPath(); ctx.rect(M.l*dpr, M.t*dpr, plotW(), plotH()); ctx.clip();
244
- incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
245
- ctx.fillStyle='rgba(214,50,50,0.20)'; ctx.fillRect(x0, M.t*dpr, x1-x0, plotH());
246
- ctx.strokeStyle='rgba(214,50,50,0.55)'; ctx.lineWidth=1*dpr; ctx.strokeRect(x0, M.t*dpr, x1-x0, plotH());
360
+ // Threshold-capture preview bands (amber), under the committed incident bands.
361
+ if (thMode) { const val=thEff(); if (val!==null) thRuns().forEach(r => {
362
+ const x0=px(r[0]), w=Math.max(px(r[1])-x0, 2*dpr);
363
+ ctx.fillStyle='rgba(240,173,78,0.22)'; ctx.fillRect(x0, M.t*dpr, w, plotH());
364
+ ctx.strokeStyle='rgba(240,173,78,0.6)'; ctx.lineWidth=1*dpr; ctx.strokeRect(x0, M.t*dpr, w, plotH()); }); }
365
+ incidents.forEach((iv,idx) => { const x0=px(iv.a), x1=px(iv.b), w=Math.max(x1-x0, 2*dpr);
366
+ const isSel=(iv===selObj), isHov=(idx===hoverRow);
367
+ ctx.fillStyle = (isSel||isHov) ? 'rgba(214,50,50,0.32)' : 'rgba(214,50,50,0.20)';
368
+ ctx.fillRect(x0, M.t*dpr, w, plotH());
369
+ ctx.strokeStyle = isSel ? 'rgba(214,50,50,0.95)' : isHov ? 'rgba(214,50,50,0.78)' : 'rgba(214,50,50,0.55)';
370
+ ctx.lineWidth=(isSel?2:1)*dpr; ctx.strokeRect(x0, M.t*dpr, w, plotH());
247
371
  // draggable edge handles
248
372
  ctx.fillStyle='rgba(214,50,50,0.95)';
249
373
  ctx.fillRect(x0-1.5*dpr, M.t*dpr, 3*dpr, plotH());
250
- ctx.fillRect(x1-1.5*dpr, M.t*dpr, 3*dpr, plotH()); });
374
+ ctx.fillRect(x1-1.5*dpr, M.t*dpr, 3*dpr, plotH());
375
+ if ((x1-x0) >= 22*dpr || isSel) drawDelHandle(x1, isSel || idx===hoverDel); });
251
376
  if (dragging && dragging.mode==='new') { const x0=px(dragging.a), x1=px(dragging.b);
252
377
  ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
253
378
  drawSeries(ctx, px, py, viewMin, viewMax, M.l*dpr, plotW(), '#d15b36', 1.5);
379
+ if (thMode) { const val=thEff(); if (val!==null && val>=vmin && val<=vmax) { const yy=py(val);
380
+ ctx.strokeStyle='#f0ad4e'; ctx.lineWidth=1.5*dpr; ctx.setLineDash([6*dpr,4*dpr]);
381
+ ctx.beginPath(); ctx.moveTo(M.l*dpr, yy); ctx.lineTo(c.width-M.r*dpr, yy); ctx.stroke(); ctx.setLineDash([]); } }
254
382
  ctx.restore();
255
- if (dragging && !ovAct) drawDragLabel();
383
+ if (thMode) drawThLabel();
384
+ else if (dragging && !ovAct) drawDragLabel();
256
385
  else if (hover && !ovAct) drawHover();
257
386
  }
258
387
 
388
+ // Readout while picking a threshold: the line value, side, and how many spans
389
+ // would be captured.
390
+ function drawThLabel() {
391
+ const val=thEff(); if (val===null) return;
392
+ const n=thRuns().length;
393
+ const text='line '+fmtVal(val)+' · '+thdirEl.value+' · '+n+' span'+(n===1?'':'s');
394
+ ctx.font=(11*dpr)+'px ui-monospace, monospace';
395
+ const tw=ctx.measureText(text).width, bw=tw+14*dpr, bh=22*dpr, bx=M.l*dpr+6*dpr, by=M.t*dpr+2;
396
+ ctx.fillStyle='rgba(27,25,22,0.96)'; ctx.strokeStyle='#f0ad4e'; ctx.lineWidth=1*dpr;
397
+ ctx.fillRect(bx, by, bw, bh); ctx.strokeRect(bx, by, bw, bh);
398
+ ctx.fillStyle='#f0ad4e'; ctx.textAlign='left'; ctx.textBaseline='middle';
399
+ ctx.fillText(text, bx+7*dpr, by+bh/2);
400
+ }
401
+
259
402
  // Live time readout while marking/resizing/moving an incident, so you can place
260
403
  // an edge precisely (an edge shows old → new; move/new show the resulting span).
261
404
  function drawDragLabel() {
@@ -263,7 +406,7 @@ function drawDragLabel() {
263
406
  if (dragging.mode==='new') {
264
407
  const a=Math.min(dragging.a,dragging.b), b=Math.max(dragging.a,dragging.b);
265
408
  text = fmtTs(a)+' → '+fmtTs(b); atTs = dragging.b;
266
- } else { const iv=incidents[dragging.i]; if (!iv) return;
409
+ } else { const iv=dragging.iv; if (!iv) return;
267
410
  if (dragging.mode==='edge') {
268
411
  const old = dragging.edge==='a' ? dragging.a0 : dragging.b0;
269
412
  const cur = dragging.edge==='a' ? iv.a : iv.b;
@@ -329,13 +472,17 @@ const ovEdgeCss = ts => { const r=ov.getBoundingClientRect();
329
472
  c.addEventListener('wheel', e => { e.preventDefault(); const t=tsAt(e.clientX);
330
473
  let s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan);
331
474
  const f=(t-viewMin)/(vspan()||1); setView(t-f*s, t-f*s+s); }, {passive:false});
332
- // Hit-test an existing incident edge / body in CSS px (for editing vs creating).
475
+ // Hit-test an existing incident's ✕ handle / edge / body in CSS px.
333
476
  const EDGE_PX = 6;
334
477
  const minStep = () => Math.max(step, 1);
335
478
  const pxCss = ts => { const r=c.getBoundingClientRect();
336
479
  return M.l + (ts-viewMin)/(vspan()||1)*(r.width-(M.l+M.r)); };
337
- function hitIncident(clientX) {
338
- const x = clientX - c.getBoundingClientRect().left;
480
+ function hitIncident(clientX, clientY) {
481
+ const r=c.getBoundingClientRect(), x=clientX-r.left, y=clientY-r.top;
482
+ for (let i=0;i<incidents.length;i++) { const xa=pxCss(incidents[i].a), xb=pxCss(incidents[i].b);
483
+ if ((xb-xa)>=22 || incidents[i]===selObj) {
484
+ const s=14, m=3, hx0=xb-s-m, hy0=M.t+m;
485
+ if (x>=hx0 && x<=hx0+s && y>=hy0 && y<=hy0+s) return {i, edge:'del'}; } }
339
486
  for (let i=0;i<incidents.length;i++) { const xa=pxCss(incidents[i].a), xb=pxCss(incidents[i].b);
340
487
  if (Math.abs(x-xa)<=EDGE_PX) return {i, edge:'a'};
341
488
  if (Math.abs(x-xb)<=EDGE_PX) return {i, edge:'b'}; }
@@ -344,32 +491,36 @@ function hitIncident(clientX) {
344
491
  return null;
345
492
  }
346
493
  c.addEventListener('mousedown', e => {
347
- const hit = hitIncident(e.clientX), t = tsAt(e.clientX);
348
- if (hit && hit.edge==='move') { const iv=incidents[hit.i];
349
- dragging={mode:'move', i:hit.i, grab:t, a0:iv.a, b0:iv.b, sx:e.clientX, cx:e.clientX}; }
350
- else if (hit) { const iv=incidents[hit.i];
351
- dragging={mode:'edge', i:hit.i, edge:hit.edge, a0:iv.a, b0:iv.b, sx:e.clientX, cx:e.clientX}; }
352
- else dragging={mode:'new', a:t, b:t, sx:e.clientX, cx:e.clientX};
494
+ if (thMode) { thvalEl.value = String(Math.round(vAt(e.clientY)*1000)/1000); thCount(); draw(); return; }
495
+ const hit = hitIncident(e.clientX, e.clientY), t = tsAt(e.clientX);
496
+ if (hit && hit.edge==='del') { removeIncident(incidents[hit.i]); return; }
497
+ if (hit && hit.edge==='move') { const iv=incidents[hit.i]; selObj=iv;
498
+ dragging={mode:'move', iv, grab:t, a0:iv.a, b0:iv.b, sx:e.clientX, cx:e.clientX}; render(); }
499
+ else if (hit) { const iv=incidents[hit.i]; selObj=iv;
500
+ dragging={mode:'edge', iv, edge:hit.edge, a0:iv.a, b0:iv.b, sx:e.clientX, cx:e.clientX}; draw(); }
501
+ else { selObj=null; dragging={mode:'new', a:t, b:t, sx:e.clientX, cx:e.clientX}; draw(); }
353
502
  });
354
503
  c.addEventListener('mousemove', e => { if (ovAct) return;
504
+ if (thMode && !dragging) { thHover=vAt(e.clientY); thCount(); draw(); return; }
355
505
  if (dragging) {
356
506
  dragging.cx=e.clientX; const t=tsAt(e.clientX);
357
507
  if (dragging.mode==='new') { dragging.b=t; }
358
- else if (dragging.mode==='edge') { const iv=incidents[dragging.i]; if (!iv) return;
508
+ else if (dragging.mode==='edge') { const iv=dragging.iv; if (!iv) return;
359
509
  if (dragging.edge==='a') iv.a=clamp(Math.min(t, iv.b-minStep()), tmin, tmax);
360
510
  else iv.b=clamp(Math.max(t, iv.a+minStep()), tmin, tmax); }
361
- else if (dragging.mode==='move') { const iv=incidents[dragging.i]; if (!iv) return;
511
+ else if (dragging.mode==='move') { const iv=dragging.iv; if (!iv) return;
362
512
  let na=dragging.a0+(t-dragging.grab), nb=dragging.b0+(t-dragging.grab);
363
513
  if (na<tmin) { nb+=tmin-na; na=tmin; } if (nb>tmax) { na-=nb-tmax; nb=tmax; }
364
514
  iv.a=clamp(na,tmin,tmax); iv.b=clamp(nb,tmin,tmax); }
365
515
  draw();
366
516
  } else {
367
- const hit=hitIncident(e.clientX);
368
- c.style.cursor = hit ? (hit.edge==='move' ? 'grab' : 'ew-resize') : 'crosshair';
517
+ const hit=hitIncident(e.clientX, e.clientY);
518
+ hoverDel = (hit && hit.edge==='del') ? hit.i : -1;
519
+ c.style.cursor = hit ? (hit.edge==='del' ? 'pointer' : hit.edge==='move' ? 'grab' : 'ew-resize') : 'crosshair';
369
520
  hover={ts:tsAt(e.clientX)}; draw();
370
521
  }
371
522
  });
372
- c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; draw(); } });
523
+ c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; hoverDel=-1; draw(); } });
373
524
 
374
525
  ov.addEventListener('mousedown', e => { e.preventDefault(); ov.style.cursor='grabbing';
375
526
  const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax), x=e.clientX, H=8;
@@ -394,32 +545,102 @@ window.addEventListener('mouseup', () => {
394
545
  if (dragging.mode==='new') {
395
546
  if (Math.abs(dragging.cx-dragging.sx) > 4) {
396
547
  const a=clamp(Math.min(dragging.a,dragging.b),tmin,tmax), b=clamp(Math.max(dragging.a,dragging.b),tmin,tmax);
397
- incidents.push({a, b, label:''});
398
- }
399
- } else { const iv=incidents[dragging.i]; // edge/move: keep start <= end
548
+ const iv={a, b, label:''}; incidents.push(iv); selObj=iv;
549
+ } else { selObj=null; } // a plain click on empty space clears the selection
550
+ } else { const iv=dragging.iv; // edge/move: keep start <= end
400
551
  if (iv && iv.a>iv.b) { const t=iv.a; iv.a=iv.b; iv.b=t; } }
401
552
  dragging=null; render();
402
553
  });
403
554
 
555
+ // Remove an incident by object reference (survives list re-sorting).
556
+ function removeIncident(iv) { const k=incidents.indexOf(iv); if (k<0) return;
557
+ incidents.splice(k,1); if (selObj===iv) selObj=null; hoverDel=-1; render(); }
558
+ window.addEventListener('keydown', e => {
559
+ const t=e.target, typing = t && (t.tagName==='INPUT' || t.tagName==='TEXTAREA' || t.isContentEditable);
560
+ if (e.key==='Escape') { if (thMode) toggleTh(false); else if (selObj) { selObj=null; draw(); } return; }
561
+ if ((e.key==='Delete' || e.key==='Backspace') && selObj && !typing) { e.preventDefault(); removeIncident(selObj); }
562
+ });
563
+
404
564
  document.getElementById('zreset').onclick = () => setView(tmin, tmax);
405
- c.addEventListener('dblclick', () => setView(tmin, tmax));
406
- document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
565
+ c.addEventListener('dblclick', () => { if (!thMode) setView(tmin, tmax); });
566
+ document.getElementById('clear').onclick = () => { incidents.length=0; selObj=null; render(); };
407
567
  window.setLabel = (i, val) => { if (incidents[i]) incidents[i].label = val; };
408
- window.rm = i => { incidents.splice(i,1); render(); };
568
+ window.rm = i => { const iv=incidents[i]; if (iv && selObj===iv) selObj=null; incidents.splice(i,1); render(); };
569
+ window.hl = i => { hoverRow=i; draw(); };
570
+ window.focusInc = i => { const iv=incidents[i]; if (!iv) return; selObj=iv;
571
+ const pad=Math.max((iv.b-iv.a)*1.5, step*10, minSpan*0.5); setView(iv.a-pad, iv.b+pad); render(); };
572
+
573
+ // Threshold-capture controls.
574
+ const thbtnEl=document.getElementById('thbtn'), thbarEl=document.getElementById('thbar');
575
+ const thvalEl=document.getElementById('thval'), thdirEl=document.getElementById('thdir');
576
+ const thgapEl=document.getElementById('thgap'), thaddEl=document.getElementById('thadd');
577
+ const thdoneEl=document.getElementById('thdone');
578
+ thbtnEl.onclick = () => toggleTh();
579
+ thdoneEl.onclick = () => toggleTh(false);
580
+ thdirEl.onchange = () => { thCount(); draw(); };
581
+ thgapEl.oninput = () => { thCount(); draw(); };
582
+ thvalEl.oninput = () => { thCount(); draw(); };
583
+ thaddEl.onclick = () => { const runs=thRuns(); if (!runs.length) return;
584
+ runs.forEach(r => addCaptured(r[0], r[1]));
585
+ setMsg('Added '+runs.length+' incident'+(runs.length===1?'':'s')+' from the threshold — review and tidy below.', 'ok');
586
+ render(); thCount(); };
587
+
588
+ // Import an existing labels file (YAML/JSON) the user picks, merging it in.
589
+ const fileEl=document.getElementById('file');
590
+ document.getElementById('importbtn').onclick = () => fileEl.click();
591
+ function normEntry(e) { const lab = e.label!=null ? String(e.label) : '';
592
+ const toMs = s => Date.parse(String(s).trim().replace(' ','T')+'Z');
593
+ if (e.at!=null) { const t=toMs(e.at); return isNaN(t) ? null : {a:t, b:t, label:lab}; }
594
+ if (e.start!=null && e.end!=null) { const a=toMs(e.start), b=toMs(e.end);
595
+ return (isNaN(a)||isNaN(b)) ? null : {a:Math.min(a,b), b:Math.max(a,b), label:lab}; }
596
+ return null; }
597
+ function parseLabelsText(txt) {
598
+ txt = txt.trim();
599
+ if (txt[0]==='[' || txt[0]==='{') { try { const j=JSON.parse(txt);
600
+ const arr = Array.isArray(j) ? j : (j.incidents || []);
601
+ return arr.map(normEntry).filter(Boolean); } catch (e) { /* fall through to YAML */ } }
602
+ const out=[], lines=txt.split(/\\r?\\n/); let cur=null;
603
+ const flush = () => { if (cur) { const e=normEntry(cur); if (e) out.push(e); cur=null; } };
604
+ for (let ln of lines) {
605
+ if (/^\\s*-/.test(ln)) { flush(); cur={}; ln=ln.replace(/^\\s*-\\s*/, ''); }
606
+ if (cur===null) continue;
607
+ // strip only the wrapping braces of a flow map, so braces inside a quoted
608
+ // label survive (a global brace strip would mangle them).
609
+ ln = ln.trim();
610
+ if (ln[0]==='{') ln=ln.slice(1);
611
+ if (ln[ln.length-1]==='}') ln=ln.slice(0,-1);
612
+ const re=/(start|end|at|label)\\s*:\\s*("([^"]*)"|'([^']*)'|[^,]+)/g; let m;
613
+ while ((m=re.exec(ln))) { const k=m[1];
614
+ const v = m[3]!==undefined ? m[3] : (m[4]!==undefined ? m[4] : m[2]); cur[k]=String(v).trim(); } }
615
+ flush();
616
+ return out; }
617
+ fileEl.onchange = ev => { const f=ev.target.files && ev.target.files[0]; if (!f) return;
618
+ const rd=new FileReader();
619
+ rd.onload = () => { try { const got=parseLabelsText(String(rd.result));
620
+ if (!got.length) { setMsg('No incidents found in '+f.name+'.', 'err'); }
621
+ else { got.forEach(g => incidents.push(g)); render();
622
+ setMsg('Imported '+got.length+' incident'+(got.length===1?'':'s')+' from '+f.name+'.', 'ok'); }
623
+ } catch (err) { setMsg('Could not read '+f.name+': '+err.message, 'err'); }
624
+ fileEl.value=''; };
625
+ rd.readAsText(f); };
409
626
 
410
627
  function render() {
411
628
  incidents.sort((p,q)=>p.a-q.a);
412
629
  const list=document.getElementById('list');
413
- list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
630
+ list.innerHTML = incidents.map((iv,i)=>'<li data-k="'+i+'" class="'+(iv===selObj?'sel':'')+'"'
631
+ +' onmouseenter="hl('+i+')" onmouseleave="hl(-1)"><span class="dot"></span>'
414
632
  +'<span class="span">'+fmtTs(iv.a)+' &rarr; '+fmtTs(iv.b)+'</span>'
415
633
  +'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
416
634
  +'<input class="desc" type="text" placeholder="describe this incident (optional)" '
417
635
  +'value="'+esc(iv.label||'')+'" oninput="setLabel('+i+', this.value)">'
636
+ +'<button class="ghost focus" title="zoom the chart to this incident" onclick="focusInc('+i+')">focus</button>'
418
637
  +'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
419
638
  document.getElementById('empty').style.display = incidents.length ? 'none' : '';
420
639
  const total=incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
421
640
  document.getElementById('summary').innerHTML = incidents.length
422
641
  ? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total' : '';
642
+ if (selObj) { const k=incidents.indexOf(selObj);
643
+ if (k>=0 && list.children[k]) list.children[k].scrollIntoView({block:'nearest'}); }
423
644
  drawAll();
424
645
  }
425
646
 
@@ -488,6 +709,7 @@ def render_labeler_html(
488
709
  *,
489
710
  save_url: str | None = None,
490
711
  interval_seconds: int | None = None,
712
+ incidents: list[dict[str, str]] | None = None,
491
713
  ) -> str:
492
714
  """Return a self-contained HTML labeler page for *metric_name*'s series.
493
715
 
@@ -495,6 +717,9 @@ def render_labeler_html(
495
717
  Export button POSTs the labels straight to that endpoint; without it (a static
496
718
  file) Export falls back to a browser download. ``interval_seconds`` is the
497
719
  metric's sampling interval shown as a chip (inferred from the data if omitted).
720
+ ``incidents`` seeds the editor with already-labeled spans (each a
721
+ ``{"start", "end", "label"}`` dict in naive-UTC ``"YYYY-MM-DD HH:MM:SS"``; a
722
+ point is ``start == end``) so an existing labels file can be opened and edited.
498
723
  """
499
724
  import json
500
725
 
@@ -505,8 +730,11 @@ def render_labeler_html(
505
730
  v = values[i]
506
731
  points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
507
732
  payload = json_dumps_sorted({"metric": metric_name, "points": points})
733
+ preload = json_dumps_sorted(incidents or [])
508
734
  return (
509
735
  _TEMPLATE.replace("__PAYLOAD__", payload)
736
+ .replace("__INCIDENTS__", preload)
737
+ .replace("__FAVICON__", _favicon_data_uri())
510
738
  .replace("__SAVE_URL__", json.dumps(save_url))
511
739
  .replace("__INTERVAL__", json.dumps(interval_seconds))
512
740
  .replace("__METRIC__", metric_name)
@@ -121,8 +121,13 @@ def build_label_server(
121
121
  data: dict[str, np.ndarray],
122
122
  incidents_dir: Path,
123
123
  interval_seconds: int,
124
+ preload: list[dict[str, str]] | None = None,
124
125
  ) -> tuple[_LabelServer, str]:
125
- """Construct (without running) the labeler server; return ``(server, page_url)``."""
126
+ """Construct (without running) the labeler server; return ``(server, page_url)``.
127
+
128
+ ``preload`` seeds the labeler with already-marked incidents (editing an
129
+ existing labels file); the caller resolves which file to load.
130
+ """
126
131
  server = _LabelServer(("127.0.0.1", 0), _Handler)
127
132
  token = secrets.token_urlsafe(16)
128
133
  port = int(server.server_address[1])
@@ -135,6 +140,7 @@ def build_label_server(
135
140
  data,
136
141
  save_url=f"http://127.0.0.1:{port}/save?token={token}",
137
142
  interval_seconds=interval_seconds,
143
+ incidents=preload,
138
144
  )
139
145
  return server, f"http://127.0.0.1:{port}/?token={token}"
140
146
 
@@ -148,13 +154,18 @@ def serve_labeler(
148
154
  open_browser: bool = True,
149
155
  echo: Callable[[str], None] = print,
150
156
  on_ready: Callable[[str], None] | None = None,
157
+ preload: list[dict[str, str]] | None = None,
151
158
  ) -> Path | None:
152
- """Serve the labeler until the user saves (returns the file) or cancels (None)."""
159
+ """Serve the labeler until the user saves (returns the file) or cancels (None).
160
+
161
+ ``preload`` seeds the page with existing incidents to edit in place.
162
+ """
153
163
  server, url = build_label_server(
154
164
  metric_name=metric_name,
155
165
  data=data,
156
166
  incidents_dir=incidents_dir,
157
167
  interval_seconds=interval_seconds,
168
+ preload=preload,
158
169
  )
159
170
  if on_ready is not None:
160
171
  on_ready(url)