detectkit 0.24.2__tar.gz → 0.26.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.2/detectkit.egg-info → detectkit-0.26.0}/PKG-INFO +1 -1
  2. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/html_labeler.py +302 -32
  4. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/label_server.py +13 -2
  5. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/labels.py +53 -4
  6. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/autotune.md +19 -6
  7. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +19 -8
  8. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/autotune.py +85 -2
  9. {detectkit-0.24.2 → detectkit-0.26.0/detectkit.egg-info}/PKG-INFO +1 -1
  10. {detectkit-0.24.2 → detectkit-0.26.0}/LICENSE +0 -0
  11. {detectkit-0.24.2 → detectkit-0.26.0}/MANIFEST.in +0 -0
  12. {detectkit-0.24.2 → detectkit-0.26.0}/README.md +0 -0
  13. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/__init__.py +0 -0
  14. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/__init__.py +0 -0
  15. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/base.py +0 -0
  16. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/branding.py +0 -0
  17. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/email.py +0 -0
  18. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/factory.py +0 -0
  19. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/mattermost.py +0 -0
  20. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/slack.py +0 -0
  21. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/telegram.py +0 -0
  22. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/webhook.py +0 -0
  23. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  24. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  25. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  26. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  27. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  28. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  29. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  30. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  31. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/__init__.py +0 -0
  32. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/_base.py +0 -0
  33. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/_types.py +0 -0
  34. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/autotuner.py +0 -0
  35. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/config_emitter.py +0 -0
  36. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/crossval.py +0 -0
  37. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/detector_select.py +0 -0
  38. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/distribution.py +0 -0
  39. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/grid_search.py +0 -0
  40. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/result.py +0 -0
  41. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/scoring.py +0 -0
  42. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/seasonality_search.py +0 -0
  43. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/settings.py +0 -0
  44. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/window_select.py +0 -0
  45. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/__init__.py +0 -0
  46. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/_output.py +0 -0
  47. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  48. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  49. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  50. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  51. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  52. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  53. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  54. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  55. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  56. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  57. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/__init__.py +0 -0
  58. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/clean.py +0 -0
  59. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/init.py +0 -0
  60. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/init_claude.py +0 -0
  61. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/run.py +0 -0
  62. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/test_alert.py +0 -0
  63. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/unlock.py +0 -0
  64. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/main.py +0 -0
  65. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/__init__.py +0 -0
  66. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/metric_config.py +0 -0
  67. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/profile.py +0 -0
  68. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/project_config.py +0 -0
  69. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/validator.py +0 -0
  70. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/core/__init__.py +0 -0
  71. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/core/interval.py +0 -0
  72. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/core/models.py +0 -0
  73. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/__init__.py +0 -0
  74. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/_sql_manager.py +0 -0
  75. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/clickhouse_manager.py +0 -0
  76. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/__init__.py +0 -0
  77. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  78. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  79. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_base.py +0 -0
  80. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  81. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_detections.py +0 -0
  82. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  83. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  84. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_schema.py +0 -0
  85. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  86. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/manager.py +0 -0
  87. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/manager.py +0 -0
  88. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/mysql_manager.py +0 -0
  89. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/postgres_manager.py +0 -0
  90. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/tables.py +0 -0
  91. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/__init__.py +0 -0
  92. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/base.py +0 -0
  93. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/factory.py +0 -0
  94. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/seasonality.py +0 -0
  95. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/__init__.py +0 -0
  96. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  97. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/iqr.py +0 -0
  98. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/mad.py +0 -0
  99. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  100. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/zscore.py +0 -0
  101. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/loaders/__init__.py +0 -0
  102. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/loaders/metric_loader.py +0 -0
  103. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/loaders/query_template.py +0 -0
  104. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/__init__.py +0 -0
  105. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/error_dispatch.py +0 -0
  106. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  107. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  108. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  109. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  110. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  111. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  112. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  113. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/__init__.py +0 -0
  114. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/datetime_utils.py +0 -0
  115. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/env_interpolation.py +0 -0
  116. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/json_utils.py +0 -0
  117. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/stats.py +0 -0
  118. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/SOURCES.txt +0 -0
  119. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/dependency_links.txt +0 -0
  120. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/entry_points.txt +0 -0
  121. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/requires.txt +0 -0
  122. {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/top_level.txt +0 -0
  123. {detectkit-0.24.2 → detectkit-0.26.0}/pyproject.toml +0 -0
  124. {detectkit-0.24.2 → detectkit-0.26.0}/requirements.txt +0 -0
  125. {detectkit-0.24.2 → detectkit-0.26.0}/setup.cfg +0 -0
  126. {detectkit-0.24.2 → detectkit-0.26.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.24.2
3
+ Version: 0.26.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.2"
7
+ __version__ = "0.26.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -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,29 @@ _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="thwin" class="ghost" style="display:none" title="capture across the whole current view again">↺ whole view</button>
181
+ <button id="thadd" class="primary" disabled>Add 0 spans</button>
182
+ <button id="thdone" class="ghost">Done</button>
183
+ </div>
126
184
  <div id="savemsg" class="savemsg"></div>
127
185
  <canvas id="c" aria-label="metric series — drag to mark an incident, scroll to zoom"></canvas>
128
186
  <div class="zoombar">
@@ -131,8 +189,11 @@ _TEMPLATE = """<!doctype html>
131
189
  </div>
132
190
  <canvas id="ov" aria-label="navigator — drag the window to pan, its edges to stretch the view"></canvas>
133
191
  <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>
192
+ adjust it, or its middle to move it · click its <b>✕</b> (or select it and press Delete) to remove it ·
193
+ use <b>Threshold capture</b> to grab every span past a horizontal line at once (click sets the line; drag
194
+ across the chart to limit it to a time window — handy when the metric behaves differently across periods) ·
195
+ <b>focus</b> a row to jump the chart to it · scroll to zoom, double-click to reset · drag the navigator
196
+ window below to pan.</div>
136
197
  <div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
137
198
  <ul id="list"></ul>
138
199
  <footer>All times UTC · self-contained, nothing leaves your browser · re-label any time —
@@ -148,6 +209,9 @@ const SAVE_URL = __SAVE_URL__;
148
209
  // The metric's sampling interval (seconds). Passed straight from the metric when
149
210
  // known; otherwise inferred from the median spacing of points.
150
211
  const INTERVAL_S = __INTERVAL__;
212
+ // Incidents to seed the editor with (editing an existing labels file). Each is
213
+ // {start, end, label} in "YYYY-MM-DD HH:MM:SS" UTC; a point is start === end.
214
+ const PRELOAD = __INCIDENTS__;
151
215
  const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
152
216
  const N = pts.length;
153
217
  const vraw = pts.filter(p => p.v !== null).map(p => p.v);
@@ -160,10 +224,20 @@ const step = fullSpan / Math.max(1, N - 1);
160
224
  const minSpan = Math.max(step * 8, 1000);
161
225
  let viewMin = tmin, viewMax = tmax;
162
226
  const incidents = [];
227
+ // Seed from PRELOAD so re-opening a saved labels file continues where it left off.
228
+ PRELOAD.forEach(p => { const a = Date.parse(String(p.start).replace(' ','T')+'Z'),
229
+ b = Date.parse(String(p.end).replace(' ','T')+'Z');
230
+ if (!isNaN(a) && !isNaN(b)) incidents.push({a: Math.min(a,b), b: Math.max(a,b), label: p.label || ''}); });
163
231
  const c = document.getElementById('c'), ov = document.getElementById('ov');
164
232
  const ctx = c.getContext('2d'), octx = ov.getContext('2d');
165
233
  const M = {l:56, r:16, t:14, b:30}, OM = {l:56, r:16, t:8, b:8};
166
234
  let dpr = 1, hover = null, dragging = null, ovAct = null;
235
+ // selObj: the currently selected incident (object ref, survives re-sorting);
236
+ // hoverRow/hoverDel: list-row / ✕-handle hover targets; threshold-capture state.
237
+ let selObj = null, hoverRow = -1, hoverDel = -1, thMode = false, thHover = null;
238
+ // Threshold-capture window: thDown tracks a press, thDragWin a live drag, capWin
239
+ // the committed custom window (null → capture within the current view).
240
+ let thDown = null, thDragWin = null, capWin = null;
167
241
 
168
242
  const clamp = (x,a,b) => Math.max(a, Math.min(b, x));
169
243
  const vspan = () => viewMax - viewMin;
@@ -228,6 +302,63 @@ function drawSeries(ctx2, xfn, yfn, lo, hi, leftDev, widthDev, color, lw) {
228
302
  ctx2.stroke();
229
303
  }
230
304
 
305
+ // Value at a CSS-pixel Y on the chart (inverse of py) — used to read the
306
+ // threshold line off the cursor.
307
+ const vAt = clientY => { const r=c.getBoundingClientRect();
308
+ const fr=((clientY-r.top)-M.t)/((r.height-(M.t+M.b))||1);
309
+ return vmax - clamp(fr,0,1)*(vmax-vmin); };
310
+ // The active threshold value: the locked input wins, else the live cursor value.
311
+ function thEff() { const s=thvalEl.value.trim();
312
+ return (s!=='' && !isNaN(Number(s))) ? Number(s) : thHover; }
313
+ // The active capture window: a live/committed custom window, else the current
314
+ // view — so the threshold only grabs the period you're looking at (or painted),
315
+ // not the whole series. Returns [lo, hi] in ms.
316
+ function capRange() {
317
+ const w = thDragWin || capWin;
318
+ if (w) return [Math.min(w.a,w.b), Math.max(w.a,w.b)];
319
+ return [viewMin, viewMax]; }
320
+ // Contiguous runs of points on the chosen side of the line within the capture
321
+ // window, bridging short gaps.
322
+ function thRuns() { const val=thEff(); if (val===null) return [];
323
+ const dir=thdirEl.value, gapMax=Math.max(0, parseInt(thgapEl.value)||0);
324
+ const win=capRange(), lo=win[0], hi=win[1];
325
+ const runs=[]; let s=null, e=null, gap=0;
326
+ for (let i=0;i<N;i++) { const p=pts[i]; if (p.ts<lo || p.ts>hi) continue;
327
+ const q = p.v!==null && (dir==='above' ? p.v>val : p.v<val);
328
+ if (q) { if (s===null) s=p.ts; e=p.ts; gap=0; }
329
+ else if (s!==null) { gap++; if (gap>gapMax) { runs.push([s,e]); s=null; gap=0; } } }
330
+ if (s!==null) runs.push([s,e]);
331
+ return runs; }
332
+ function thCount() { const n=thRuns().length;
333
+ thaddEl.textContent = 'Add '+n+' span'+(n===1?'':'s'); thaddEl.disabled = n===0; }
334
+ // Add a captured span, merging it into any overlapping incidents (a single span
335
+ // can bridge several) into one band that keeps the first one's label.
336
+ function addCaptured(a,b) {
337
+ let host=null;
338
+ for (let i=incidents.length-1;i>=0;i--) { const iv=incidents[i];
339
+ if (a<=iv.b && b>=iv.a) {
340
+ if (host===null) { iv.a=Math.min(iv.a,a); iv.b=Math.max(iv.b,b); host=iv; }
341
+ else { host.a=Math.min(host.a,iv.a); host.b=Math.max(host.b,iv.b);
342
+ if (selObj===iv) selObj=host; incidents.splice(i,1); } } }
343
+ if (host===null) incidents.push({a, b, label:''}); }
344
+ function toggleTh(on) { thMode = (on===undefined) ? !thMode : !!on;
345
+ thbtnEl.classList.toggle('active', thMode); thbarEl.style.display = thMode ? 'flex' : 'none';
346
+ if (thMode) { hover=null; } else { thHover=null; thDown=null; thDragWin=null; capWin=null; updateThWin(); }
347
+ c.style.cursor = thMode ? 'crosshair' : 'crosshair'; if (thMode) { updateThWin(); thCount(); } draw(); }
348
+
349
+ function rr(g,x,y,w,h,r) { g.beginPath();
350
+ 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);
351
+ g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
352
+ // The ✕ delete handle at a band's top-right (device px); `hot` brightens it.
353
+ function drawDelHandle(x1, hot) { const s=14*dpr, m=3*dpr, bx=x1-s-m, by=M.t*dpr+m;
354
+ ctx.fillStyle = hot ? 'rgba(214,50,50,0.95)' : 'rgba(27,25,22,0.82)';
355
+ ctx.strokeStyle = 'rgba(214,50,50,0.9)'; ctx.lineWidth = 1*dpr;
356
+ rr(ctx, bx, by, s, s, 3*dpr); ctx.fill(); ctx.stroke();
357
+ ctx.strokeStyle = hot ? '#fff' : '#d63232'; ctx.lineWidth = 1.5*dpr;
358
+ const p=4*dpr; ctx.beginPath();
359
+ ctx.moveTo(bx+p, by+p); ctx.lineTo(bx+s-p, by+s-p);
360
+ ctx.moveTo(bx+s-p, by+p); ctx.lineTo(bx+p, by+s-p); ctx.stroke(); }
361
+
231
362
  function draw() {
232
363
  ctx.clearRect(0,0,c.width,c.height);
233
364
  ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
@@ -241,21 +372,60 @@ function draw() {
241
372
  ctx.fillStyle='#6e675b'; ctx.textAlign=i===0?'left':i===5?'right':'center';
242
373
  ctx.fillText(fmtTick(ts,vspan()), xx, (c.height-M.b+8)*dpr); }
243
374
  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());
375
+ // Threshold-capture preview bands (amber), under the committed incident bands.
376
+ if (thMode) { const val=thEff(); if (val!==null) thRuns().forEach(r => {
377
+ const x0=px(r[0]), w=Math.max(px(r[1])-x0, 2*dpr);
378
+ ctx.fillStyle='rgba(240,173,78,0.22)'; ctx.fillRect(x0, M.t*dpr, w, plotH());
379
+ ctx.strokeStyle='rgba(240,173,78,0.6)'; ctx.lineWidth=1*dpr; ctx.strokeRect(x0, M.t*dpr, w, plotH()); }); }
380
+ incidents.forEach((iv,idx) => { const x0=px(iv.a), x1=px(iv.b), w=Math.max(x1-x0, 2*dpr);
381
+ const isSel=(iv===selObj), isHov=(idx===hoverRow);
382
+ ctx.fillStyle = (isSel||isHov) ? 'rgba(214,50,50,0.32)' : 'rgba(214,50,50,0.20)';
383
+ ctx.fillRect(x0, M.t*dpr, w, plotH());
384
+ ctx.strokeStyle = isSel ? 'rgba(214,50,50,0.95)' : isHov ? 'rgba(214,50,50,0.78)' : 'rgba(214,50,50,0.55)';
385
+ ctx.lineWidth=(isSel?2:1)*dpr; ctx.strokeRect(x0, M.t*dpr, w, plotH());
247
386
  // draggable edge handles
248
387
  ctx.fillStyle='rgba(214,50,50,0.95)';
249
388
  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()); });
389
+ ctx.fillRect(x1-1.5*dpr, M.t*dpr, 3*dpr, plotH());
390
+ if ((x1-x0) >= 22*dpr || isSel) drawDelHandle(x1, isSel || idx===hoverDel); });
251
391
  if (dragging && dragging.mode==='new') { const x0=px(dragging.a), x1=px(dragging.b);
252
392
  ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
253
393
  drawSeries(ctx, px, py, viewMin, viewMax, M.l*dpr, plotW(), '#d15b36', 1.5);
394
+ if (thMode) {
395
+ const win=capRange(), narrow = !!(thDragWin || capWin);
396
+ const xlo=clamp(px(win[0]), M.l*dpr, c.width-M.r*dpr), xhi=clamp(px(win[1]), M.l*dpr, c.width-M.r*dpr);
397
+ if (narrow) { // dim everything outside the capture window
398
+ ctx.fillStyle='rgba(27,25,22,0.5)';
399
+ ctx.fillRect(M.l*dpr, M.t*dpr, xlo-M.l*dpr, plotH());
400
+ ctx.fillRect(xhi, M.t*dpr, (c.width-M.r*dpr)-xhi, plotH());
401
+ ctx.strokeStyle='rgba(240,173,78,0.7)'; ctx.lineWidth=1*dpr; ctx.setLineDash([3*dpr,3*dpr]);
402
+ ctx.beginPath(); ctx.moveTo(xlo,M.t*dpr); ctx.lineTo(xlo,c.height-M.b*dpr);
403
+ ctx.moveTo(xhi,M.t*dpr); ctx.lineTo(xhi,c.height-M.b*dpr); ctx.stroke(); ctx.setLineDash([]); }
404
+ const val=thEff();
405
+ if (val!==null && val>=vmin && val<=vmax) { const yy=py(val);
406
+ ctx.strokeStyle='#f0ad4e'; ctx.lineWidth=1.5*dpr; ctx.setLineDash([6*dpr,4*dpr]);
407
+ ctx.beginPath(); ctx.moveTo(xlo, yy); ctx.lineTo(xhi, yy); ctx.stroke(); ctx.setLineDash([]); } }
254
408
  ctx.restore();
255
- if (dragging && !ovAct) drawDragLabel();
409
+ if (thMode) drawThLabel();
410
+ else if (dragging && !ovAct) drawDragLabel();
256
411
  else if (hover && !ovAct) drawHover();
257
412
  }
258
413
 
414
+ // Readout while picking a threshold: the line value, side, and how many spans
415
+ // would be captured.
416
+ function drawThLabel() {
417
+ const val=thEff(); if (val===null) return;
418
+ const n=thRuns().length, win=capRange(), narrow = !!(thDragWin || capWin);
419
+ const text='line '+fmtVal(val)+' · '+thdirEl.value+' · '+n+' span'+(n===1?'':'s')
420
+ + (narrow ? (' · '+fmtDur(win[1]-win[0])+' window') : '');
421
+ ctx.font=(11*dpr)+'px ui-monospace, monospace';
422
+ const tw=ctx.measureText(text).width, bw=tw+14*dpr, bh=22*dpr, bx=M.l*dpr+6*dpr, by=M.t*dpr+2;
423
+ ctx.fillStyle='rgba(27,25,22,0.96)'; ctx.strokeStyle='#f0ad4e'; ctx.lineWidth=1*dpr;
424
+ ctx.fillRect(bx, by, bw, bh); ctx.strokeRect(bx, by, bw, bh);
425
+ ctx.fillStyle='#f0ad4e'; ctx.textAlign='left'; ctx.textBaseline='middle';
426
+ ctx.fillText(text, bx+7*dpr, by+bh/2);
427
+ }
428
+
259
429
  // Live time readout while marking/resizing/moving an incident, so you can place
260
430
  // an edge precisely (an edge shows old → new; move/new show the resulting span).
261
431
  function drawDragLabel() {
@@ -263,7 +433,7 @@ function drawDragLabel() {
263
433
  if (dragging.mode==='new') {
264
434
  const a=Math.min(dragging.a,dragging.b), b=Math.max(dragging.a,dragging.b);
265
435
  text = fmtTs(a)+' → '+fmtTs(b); atTs = dragging.b;
266
- } else { const iv=incidents[dragging.i]; if (!iv) return;
436
+ } else { const iv=dragging.iv; if (!iv) return;
267
437
  if (dragging.mode==='edge') {
268
438
  const old = dragging.edge==='a' ? dragging.a0 : dragging.b0;
269
439
  const cur = dragging.edge==='a' ? iv.a : iv.b;
@@ -329,13 +499,17 @@ const ovEdgeCss = ts => { const r=ov.getBoundingClientRect();
329
499
  c.addEventListener('wheel', e => { e.preventDefault(); const t=tsAt(e.clientX);
330
500
  let s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan);
331
501
  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).
502
+ // Hit-test an existing incident's ✕ handle / edge / body in CSS px.
333
503
  const EDGE_PX = 6;
334
504
  const minStep = () => Math.max(step, 1);
335
505
  const pxCss = ts => { const r=c.getBoundingClientRect();
336
506
  return M.l + (ts-viewMin)/(vspan()||1)*(r.width-(M.l+M.r)); };
337
- function hitIncident(clientX) {
338
- const x = clientX - c.getBoundingClientRect().left;
507
+ function hitIncident(clientX, clientY) {
508
+ const r=c.getBoundingClientRect(), x=clientX-r.left, y=clientY-r.top;
509
+ for (let i=0;i<incidents.length;i++) { const xa=pxCss(incidents[i].a), xb=pxCss(incidents[i].b);
510
+ if ((xb-xa)>=22 || incidents[i]===selObj) {
511
+ const s=14, m=3, hx0=xb-s-m, hy0=M.t+m;
512
+ if (x>=hx0 && x<=hx0+s && y>=hy0 && y<=hy0+s) return {i, edge:'del'}; } }
339
513
  for (let i=0;i<incidents.length;i++) { const xa=pxCss(incidents[i].a), xb=pxCss(incidents[i].b);
340
514
  if (Math.abs(x-xa)<=EDGE_PX) return {i, edge:'a'};
341
515
  if (Math.abs(x-xb)<=EDGE_PX) return {i, edge:'b'}; }
@@ -344,32 +518,41 @@ function hitIncident(clientX) {
344
518
  return null;
345
519
  }
346
520
  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};
521
+ // In threshold mode a press either sets the line (a click) or paints a capture
522
+ // window (a horizontal drag) resolved on mouseup by how far it moved.
523
+ if (thMode) { thDown = {x:e.clientX, ts:tsAt(e.clientX)}; thHover = vAt(e.clientY); thCount(); draw(); return; }
524
+ const hit = hitIncident(e.clientX, e.clientY), t = tsAt(e.clientX);
525
+ if (hit && hit.edge==='del') { removeIncident(incidents[hit.i]); return; }
526
+ if (hit && hit.edge==='move') { const iv=incidents[hit.i]; selObj=iv;
527
+ dragging={mode:'move', iv, grab:t, a0:iv.a, b0:iv.b, sx:e.clientX, cx:e.clientX}; render(); }
528
+ else if (hit) { const iv=incidents[hit.i]; selObj=iv;
529
+ dragging={mode:'edge', iv, edge:hit.edge, a0:iv.a, b0:iv.b, sx:e.clientX, cx:e.clientX}; draw(); }
530
+ else { selObj=null; dragging={mode:'new', a:t, b:t, sx:e.clientX, cx:e.clientX}; draw(); }
353
531
  });
354
532
  c.addEventListener('mousemove', e => { if (ovAct) return;
533
+ if (thMode && !dragging) {
534
+ if (thDown && Math.abs(e.clientX - thDown.x) > 6) { thDragWin = {a: thDown.ts, b: tsAt(e.clientX)}; }
535
+ else { if (thDown) thDragWin = null; thHover = vAt(e.clientY); }
536
+ thCount(); draw(); return; }
355
537
  if (dragging) {
356
538
  dragging.cx=e.clientX; const t=tsAt(e.clientX);
357
539
  if (dragging.mode==='new') { dragging.b=t; }
358
- else if (dragging.mode==='edge') { const iv=incidents[dragging.i]; if (!iv) return;
540
+ else if (dragging.mode==='edge') { const iv=dragging.iv; if (!iv) return;
359
541
  if (dragging.edge==='a') iv.a=clamp(Math.min(t, iv.b-minStep()), tmin, tmax);
360
542
  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;
543
+ else if (dragging.mode==='move') { const iv=dragging.iv; if (!iv) return;
362
544
  let na=dragging.a0+(t-dragging.grab), nb=dragging.b0+(t-dragging.grab);
363
545
  if (na<tmin) { nb+=tmin-na; na=tmin; } if (nb>tmax) { na-=nb-tmax; nb=tmax; }
364
546
  iv.a=clamp(na,tmin,tmax); iv.b=clamp(nb,tmin,tmax); }
365
547
  draw();
366
548
  } else {
367
- const hit=hitIncident(e.clientX);
368
- c.style.cursor = hit ? (hit.edge==='move' ? 'grab' : 'ew-resize') : 'crosshair';
549
+ const hit=hitIncident(e.clientX, e.clientY);
550
+ hoverDel = (hit && hit.edge==='del') ? hit.i : -1;
551
+ c.style.cursor = hit ? (hit.edge==='del' ? 'pointer' : hit.edge==='move' ? 'grab' : 'ew-resize') : 'crosshair';
369
552
  hover={ts:tsAt(e.clientX)}; draw();
370
553
  }
371
554
  });
372
- c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; draw(); } });
555
+ c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; hoverDel=-1; draw(); } });
373
556
 
374
557
  ov.addEventListener('mousedown', e => { e.preventDefault(); ov.style.cursor='grabbing';
375
558
  const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax), x=e.clientX, H=8;
@@ -388,38 +571,118 @@ window.addEventListener('mousemove', e => { if (!ovAct) return; const t=ovTsAtCs
388
571
  if (ovAct.type==='l') setView(Math.min(t, viewMax-minSpan), viewMax);
389
572
  else if (ovAct.type==='r') setView(viewMin, Math.max(t, viewMin+minSpan));
390
573
  else { const d=t-ovAct.grab; setView(ovAct.vMin+d, ovAct.vMax+d); } });
391
- window.addEventListener('mouseup', () => {
574
+ window.addEventListener('mouseup', e => {
392
575
  if (ovAct) { ovAct=null; return; }
576
+ if (thMode && thDown) {
577
+ if (Math.abs(e.clientX - thDown.x) > 6) { // a drag → set the capture window
578
+ const a=thDown.ts, b=tsAt(e.clientX); capWin = {a:Math.min(a,b), b:Math.max(a,b)};
579
+ } else { // a click → set the threshold line value
580
+ thvalEl.value = String(Math.round(vAt(e.clientY)*1000)/1000);
581
+ }
582
+ thDown=null; thDragWin=null; updateThWin(); thCount(); draw(); return;
583
+ }
393
584
  if (!dragging) return;
394
585
  if (dragging.mode==='new') {
395
586
  if (Math.abs(dragging.cx-dragging.sx) > 4) {
396
587
  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
588
+ const iv={a, b, label:''}; incidents.push(iv); selObj=iv;
589
+ } else { selObj=null; } // a plain click on empty space clears the selection
590
+ } else { const iv=dragging.iv; // edge/move: keep start <= end
400
591
  if (iv && iv.a>iv.b) { const t=iv.a; iv.a=iv.b; iv.b=t; } }
401
592
  dragging=null; render();
402
593
  });
403
594
 
595
+ // Remove an incident by object reference (survives list re-sorting).
596
+ function removeIncident(iv) { const k=incidents.indexOf(iv); if (k<0) return;
597
+ incidents.splice(k,1); if (selObj===iv) selObj=null; hoverDel=-1; render(); }
598
+ window.addEventListener('keydown', e => {
599
+ const t=e.target, typing = t && (t.tagName==='INPUT' || t.tagName==='TEXTAREA' || t.isContentEditable);
600
+ if (e.key==='Escape') { if (thMode) toggleTh(false); else if (selObj) { selObj=null; draw(); } return; }
601
+ if ((e.key==='Delete' || e.key==='Backspace') && selObj && !typing) { e.preventDefault(); removeIncident(selObj); }
602
+ });
603
+
404
604
  document.getElementById('zreset').onclick = () => setView(tmin, tmax);
405
- c.addEventListener('dblclick', () => setView(tmin, tmax));
406
- document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
605
+ c.addEventListener('dblclick', () => { if (!thMode) setView(tmin, tmax); });
606
+ document.getElementById('clear').onclick = () => { incidents.length=0; selObj=null; render(); };
407
607
  window.setLabel = (i, val) => { if (incidents[i]) incidents[i].label = val; };
408
- window.rm = i => { incidents.splice(i,1); render(); };
608
+ window.rm = i => { const iv=incidents[i]; if (iv && selObj===iv) selObj=null; incidents.splice(i,1); render(); };
609
+ window.hl = i => { hoverRow=i; draw(); };
610
+ window.focusInc = i => { const iv=incidents[i]; if (!iv) return; selObj=iv;
611
+ const pad=Math.max((iv.b-iv.a)*1.5, step*10, minSpan*0.5); setView(iv.a-pad, iv.b+pad); render(); };
612
+
613
+ // Threshold-capture controls.
614
+ const thbtnEl=document.getElementById('thbtn'), thbarEl=document.getElementById('thbar');
615
+ const thvalEl=document.getElementById('thval'), thdirEl=document.getElementById('thdir');
616
+ const thgapEl=document.getElementById('thgap'), thaddEl=document.getElementById('thadd');
617
+ const thdoneEl=document.getElementById('thdone'), thwinEl=document.getElementById('thwin');
618
+ function updateThWin() { thwinEl.style.display = capWin ? '' : 'none'; }
619
+ thwinEl.onclick = () => { capWin=null; updateThWin(); thCount(); draw(); };
620
+ thbtnEl.onclick = () => toggleTh();
621
+ thdoneEl.onclick = () => toggleTh(false);
622
+ thdirEl.onchange = () => { thCount(); draw(); };
623
+ thgapEl.oninput = () => { thCount(); draw(); };
624
+ thvalEl.oninput = () => { thCount(); draw(); };
625
+ thaddEl.onclick = () => { const runs=thRuns(); if (!runs.length) return;
626
+ runs.forEach(r => addCaptured(r[0], r[1]));
627
+ setMsg('Added '+runs.length+' incident'+(runs.length===1?'':'s')+' from the threshold — review and tidy below.', 'ok');
628
+ render(); thCount(); };
629
+
630
+ // Import an existing labels file (YAML/JSON) the user picks, merging it in.
631
+ const fileEl=document.getElementById('file');
632
+ document.getElementById('importbtn').onclick = () => fileEl.click();
633
+ function normEntry(e) { const lab = e.label!=null ? String(e.label) : '';
634
+ const toMs = s => Date.parse(String(s).trim().replace(' ','T')+'Z');
635
+ if (e.at!=null) { const t=toMs(e.at); return isNaN(t) ? null : {a:t, b:t, label:lab}; }
636
+ if (e.start!=null && e.end!=null) { const a=toMs(e.start), b=toMs(e.end);
637
+ return (isNaN(a)||isNaN(b)) ? null : {a:Math.min(a,b), b:Math.max(a,b), label:lab}; }
638
+ return null; }
639
+ function parseLabelsText(txt) {
640
+ txt = txt.trim();
641
+ if (txt[0]==='[' || txt[0]==='{') { try { const j=JSON.parse(txt);
642
+ const arr = Array.isArray(j) ? j : (j.incidents || []);
643
+ return arr.map(normEntry).filter(Boolean); } catch (e) { /* fall through to YAML */ } }
644
+ const out=[], lines=txt.split(/\\r?\\n/); let cur=null;
645
+ const flush = () => { if (cur) { const e=normEntry(cur); if (e) out.push(e); cur=null; } };
646
+ for (let ln of lines) {
647
+ if (/^\\s*-/.test(ln)) { flush(); cur={}; ln=ln.replace(/^\\s*-\\s*/, ''); }
648
+ if (cur===null) continue;
649
+ // strip only the wrapping braces of a flow map, so braces inside a quoted
650
+ // label survive (a global brace strip would mangle them).
651
+ ln = ln.trim();
652
+ if (ln[0]==='{') ln=ln.slice(1);
653
+ if (ln[ln.length-1]==='}') ln=ln.slice(0,-1);
654
+ const re=/(start|end|at|label)\\s*:\\s*("([^"]*)"|'([^']*)'|[^,]+)/g; let m;
655
+ while ((m=re.exec(ln))) { const k=m[1];
656
+ const v = m[3]!==undefined ? m[3] : (m[4]!==undefined ? m[4] : m[2]); cur[k]=String(v).trim(); } }
657
+ flush();
658
+ return out; }
659
+ fileEl.onchange = ev => { const f=ev.target.files && ev.target.files[0]; if (!f) return;
660
+ const rd=new FileReader();
661
+ rd.onload = () => { try { const got=parseLabelsText(String(rd.result));
662
+ if (!got.length) { setMsg('No incidents found in '+f.name+'.', 'err'); }
663
+ else { got.forEach(g => incidents.push(g)); render();
664
+ setMsg('Imported '+got.length+' incident'+(got.length===1?'':'s')+' from '+f.name+'.', 'ok'); }
665
+ } catch (err) { setMsg('Could not read '+f.name+': '+err.message, 'err'); }
666
+ fileEl.value=''; };
667
+ rd.readAsText(f); };
409
668
 
410
669
  function render() {
411
670
  incidents.sort((p,q)=>p.a-q.a);
412
671
  const list=document.getElementById('list');
413
- list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
672
+ list.innerHTML = incidents.map((iv,i)=>'<li data-k="'+i+'" class="'+(iv===selObj?'sel':'')+'"'
673
+ +' onmouseenter="hl('+i+')" onmouseleave="hl(-1)"><span class="dot"></span>'
414
674
  +'<span class="span">'+fmtTs(iv.a)+' &rarr; '+fmtTs(iv.b)+'</span>'
415
675
  +'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
416
676
  +'<input class="desc" type="text" placeholder="describe this incident (optional)" '
417
677
  +'value="'+esc(iv.label||'')+'" oninput="setLabel('+i+', this.value)">'
678
+ +'<button class="ghost focus" title="zoom the chart to this incident" onclick="focusInc('+i+')">focus</button>'
418
679
  +'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
419
680
  document.getElementById('empty').style.display = incidents.length ? 'none' : '';
420
681
  const total=incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
421
682
  document.getElementById('summary').innerHTML = incidents.length
422
683
  ? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total' : '';
684
+ if (selObj) { const k=incidents.indexOf(selObj);
685
+ if (k>=0 && list.children[k]) list.children[k].scrollIntoView({block:'nearest'}); }
423
686
  drawAll();
424
687
  }
425
688
 
@@ -488,6 +751,7 @@ def render_labeler_html(
488
751
  *,
489
752
  save_url: str | None = None,
490
753
  interval_seconds: int | None = None,
754
+ incidents: list[dict[str, str]] | None = None,
491
755
  ) -> str:
492
756
  """Return a self-contained HTML labeler page for *metric_name*'s series.
493
757
 
@@ -495,6 +759,9 @@ def render_labeler_html(
495
759
  Export button POSTs the labels straight to that endpoint; without it (a static
496
760
  file) Export falls back to a browser download. ``interval_seconds`` is the
497
761
  metric's sampling interval shown as a chip (inferred from the data if omitted).
762
+ ``incidents`` seeds the editor with already-labeled spans (each a
763
+ ``{"start", "end", "label"}`` dict in naive-UTC ``"YYYY-MM-DD HH:MM:SS"``; a
764
+ point is ``start == end``) so an existing labels file can be opened and edited.
498
765
  """
499
766
  import json
500
767
 
@@ -505,8 +772,11 @@ def render_labeler_html(
505
772
  v = values[i]
506
773
  points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
507
774
  payload = json_dumps_sorted({"metric": metric_name, "points": points})
775
+ preload = json_dumps_sorted(incidents or [])
508
776
  return (
509
777
  _TEMPLATE.replace("__PAYLOAD__", payload)
778
+ .replace("__INCIDENTS__", preload)
779
+ .replace("__FAVICON__", _favicon_data_uri())
510
780
  .replace("__SAVE_URL__", json.dumps(save_url))
511
781
  .replace("__INTERVAL__", json.dumps(interval_seconds))
512
782
  .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)