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.
- {detectkit-0.24.2/detectkit.egg-info → detectkit-0.26.0}/PKG-INFO +1 -1
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/__init__.py +1 -1
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/html_labeler.py +302 -32
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/label_server.py +13 -2
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/labels.py +53 -4
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/autotune.md +19 -6
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +19 -8
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/autotune.py +85 -2
- {detectkit-0.24.2 → detectkit-0.26.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.24.2 → detectkit-0.26.0}/LICENSE +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/MANIFEST.in +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/README.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/core/models.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/pyproject.toml +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/requirements.txt +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/setup.cfg +0 -0
- {detectkit-0.24.2 → detectkit-0.26.0}/setup.py +0 -0
|
@@ -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.
|
|
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 ·
|
|
135
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 (
|
|
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=
|
|
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
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
}
|
|
399
|
-
} else { const iv=
|
|
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
|
|
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)+' → '+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)
|