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