detectkit 0.21.0__tar.gz → 0.23.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.21.0/detectkit.egg-info → detectkit-0.23.0}/PKG-INFO +1 -1
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/__init__.py +1 -1
- detectkit-0.23.0/detectkit/autotune/html_labeler.py +456 -0
- detectkit-0.23.0/detectkit/autotune/label_server.py +166 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/autotune.md +24 -13
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +19 -10
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/autotune.py +75 -8
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/main.py +16 -2
- {detectkit-0.21.0 → detectkit-0.23.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/SOURCES.txt +1 -0
- detectkit-0.21.0/detectkit/autotune/html_labeler.py +0 -219
- {detectkit-0.21.0 → detectkit-0.23.0}/LICENSE +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/MANIFEST.in +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/README.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/core/models.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/pyproject.toml +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/requirements.txt +0 -0
- {detectkit-0.21.0 → detectkit-0.23.0}/setup.cfg +0 -0
- {detectkit-0.21.0 → detectkit-0.23.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.23.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Generate a self-contained HTML labeler for a metric series.
|
|
2
|
+
|
|
3
|
+
Emits a single HTML file (inline CSS/JS, no CDN) with the series embedded as a
|
|
4
|
+
JSON literal: a zoomable/pannable canvas chart where the user click-drags over
|
|
5
|
+
incident spans (with an optional per-incident description) and exports a labels
|
|
6
|
+
file in the canonical schema, fed back via
|
|
7
|
+
``dtk autotune --select <metric> --incidents <file-or-dir>``.
|
|
8
|
+
|
|
9
|
+
The page is offline-only — a browser cannot write to the project, so Export
|
|
10
|
+
downloads a **versioned** file (``<metric>-<UTC-stamp>.yml``); drop it into
|
|
11
|
+
``incidents/<metric>/`` to keep every labeling round (``--incidents`` accepts
|
|
12
|
+
that directory and uses the newest version).
|
|
13
|
+
|
|
14
|
+
Docs sync: the autotune reference page + landing embed a *live* copy of this
|
|
15
|
+
output (``docs/examples/autotune-labeler.html``) so the site always shows the
|
|
16
|
+
real UI. After changing the template below, regenerate that example so the docs
|
|
17
|
+
don't drift: ``python website/scripts/gen-labeler-example.py`` (also in the
|
|
18
|
+
release checklist).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from datetime import datetime, timedelta
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from detectkit.utils.json_utils import json_dumps_sorted
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ts_to_str(ts64: np.datetime64) -> str:
|
|
31
|
+
ms = int(ts64.astype("datetime64[ms]").astype(np.int64))
|
|
32
|
+
return (datetime(1970, 1, 1) + timedelta(milliseconds=ms)).strftime("%Y-%m-%d %H:%M:%S")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Built with .replace() (not .format()), so braces are literal — keep them single.
|
|
36
|
+
# Self-contained: inline brand styling/logo/JS, no network. Palette + fonts mirror
|
|
37
|
+
# website/src/styles/brand.css (.claude/rules/design.md); incident bands use the
|
|
38
|
+
# anomaly status color, the drag preview the no-data color.
|
|
39
|
+
_TEMPLATE = """<!doctype html>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
42
|
+
<title>detectkit · label incidents · __METRIC__</title>
|
|
43
|
+
<style>
|
|
44
|
+
:root {
|
|
45
|
+
--clay:#d15b36; --clay-700:#b4471f; --paper:#f5f1e8; --muted:#6e675b; --faint:#9a9384;
|
|
46
|
+
--term-bg:#211e1a; --term-surface:#1b1916; --term-border:#332f29; --term-text:#c9c2b4;
|
|
47
|
+
--anomaly:#d63232; --nodata:#f0ad4e;
|
|
48
|
+
--ui:'Schibsted Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
|
|
49
|
+
--mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
|
|
50
|
+
}
|
|
51
|
+
* { box-sizing: border-box; }
|
|
52
|
+
body { font-family: var(--ui); margin: 0; background: var(--term-bg); color: var(--term-text);
|
|
53
|
+
-webkit-font-smoothing: antialiased; }
|
|
54
|
+
.shell { max-width: 1080px; margin: 0 auto; padding: 22px 22px 44px; }
|
|
55
|
+
.brand { display:flex; align-items:center; gap:9px; margin-bottom: 14px; }
|
|
56
|
+
.brand svg { width: 26px; height: 26px; border-radius: 7px; display:block; }
|
|
57
|
+
.brand b { color: var(--paper); font-weight: 600; font-size: 15px; letter-spacing: .2px; }
|
|
58
|
+
.brand span { color: var(--faint); font-size: 12px; }
|
|
59
|
+
h1 { font-size: 18px; line-height: 1.3; margin: 0 0 6px; color: var(--paper); font-weight: 600; }
|
|
60
|
+
h1 code { color: var(--clay); font-family: var(--mono); font-size: .82em; }
|
|
61
|
+
.hint { color: var(--faint); font-size: 13px; margin: 0 0 18px; line-height: 1.55; }
|
|
62
|
+
.hint code, code.k { color: var(--term-text); font-family: var(--mono); font-size: 12px;
|
|
63
|
+
background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 5px; padding: 1px 6px; }
|
|
64
|
+
.toolbar { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom: 12px; }
|
|
65
|
+
button { font-family: var(--ui); font-size: 13px; font-weight: 500; border: 0; border-radius: 7px;
|
|
66
|
+
padding: 9px 15px; cursor: pointer; transition: background .12s ease, border-color .12s ease, color .12s ease; }
|
|
67
|
+
button.primary { background: var(--clay); color: #fff; }
|
|
68
|
+
button.primary:hover { background: var(--clay-700); }
|
|
69
|
+
button.ghost { background: transparent; color: var(--term-text); border: 1px solid var(--term-border); }
|
|
70
|
+
button.ghost:hover { border-color: var(--faint); color: var(--paper); }
|
|
71
|
+
input.setname { background: var(--term-surface); color: var(--paper); border: 1px solid var(--term-border);
|
|
72
|
+
border-radius: 7px; padding: 9px 11px; font-family: var(--ui); font-size: 13px; min-width: 200px; }
|
|
73
|
+
input.setname::placeholder { color: var(--muted); }
|
|
74
|
+
input.setname:focus { outline: none; border-color: var(--clay); }
|
|
75
|
+
.summary { margin-left: auto; color: var(--faint); font-size: 12.5px; font-family: var(--mono); }
|
|
76
|
+
.summary b { color: var(--clay); font-weight: 600; }
|
|
77
|
+
.savemsg { margin: 4px 2px 0; font-size: 13px; display: none; }
|
|
78
|
+
.savemsg.ok { display: block; color: var(--accent-green, #2e9e73); }
|
|
79
|
+
.savemsg.err { display: block; color: var(--anomaly); }
|
|
80
|
+
.savemsg.info { display: block; color: var(--faint); }
|
|
81
|
+
canvas#c { width: 100%; height: clamp(300px, 44vh, 500px); display:block; touch-action: none;
|
|
82
|
+
background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
|
|
83
|
+
.zoombar { display:flex; align-items:center; gap:8px; margin: 10px 0 6px; }
|
|
84
|
+
.rangelbl { margin-left: auto; color: var(--faint); font-size: 12px; font-family: var(--mono); }
|
|
85
|
+
canvas#ov { width: 100%; height: 66px; display:block; touch-action: none;
|
|
86
|
+
background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: grab; }
|
|
87
|
+
.navhint { color: var(--faint); font-size: 12px; margin: 7px 2px 0; }
|
|
88
|
+
.empty { color: var(--faint); font-size: 13px; margin: 18px 2px; font-style: italic; }
|
|
89
|
+
ul { list-style: none; margin: 16px 0 0; padding: 0; }
|
|
90
|
+
li { display:flex; align-items:center; gap:11px; padding: 9px 12px; font-size: 13px; flex-wrap: wrap;
|
|
91
|
+
border: 1px solid var(--term-border); border-radius: 8px; margin-bottom: 7px; background: var(--term-surface); }
|
|
92
|
+
li .dot { width:9px; height:9px; border-radius:50%; background: var(--anomaly); flex: 0 0 auto; }
|
|
93
|
+
li .span { font-family: var(--mono); color: var(--term-text); }
|
|
94
|
+
li .dur { color: var(--faint); font-size: 12px; }
|
|
95
|
+
li input.desc { flex: 1 1 220px; min-width: 160px; background: var(--term-bg); color: var(--paper);
|
|
96
|
+
border: 1px solid var(--term-border); border-radius: 6px; padding: 6px 9px; font-family: var(--ui); font-size: 12.5px; }
|
|
97
|
+
li input.desc::placeholder { color: var(--muted); }
|
|
98
|
+
li input.desc:focus { outline: none; border-color: var(--clay); }
|
|
99
|
+
li button { margin-left: auto; padding: 5px 11px; font-size: 12px; }
|
|
100
|
+
footer { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--term-border);
|
|
101
|
+
color: var(--faint); font-size: 12px; line-height: 1.6; }
|
|
102
|
+
footer code { font-family: var(--mono); color: var(--term-text); }
|
|
103
|
+
</style>
|
|
104
|
+
<div class="shell">
|
|
105
|
+
<div class="brand">
|
|
106
|
+
<svg viewBox="0 0 100 100" aria-hidden="true"><rect x="3" y="3" width="94" height="94" rx="26" fill="#D15B36"/><polyline points="14,62 36,62 50,22 64,62 86,62" fill="none" stroke="#FBF9F3" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="50" cy="22" r="6.5" fill="#FBF9F3"/></svg>
|
|
107
|
+
<b>detectkit</b><span>· incident labeler</span>
|
|
108
|
+
</div>
|
|
109
|
+
<h1>Label incidents — <code>__METRIC__</code></h1>
|
|
110
|
+
<p class="hint">Click-drag across the chart to mark each real incident, add a short description, then
|
|
111
|
+
<b>Export</b>. Save the file into <code class="k">incidents/__METRIC__/</code> and run
|
|
112
|
+
<code class="k">dtk autotune --select __METRIC__ --incidents incidents/__METRIC__/</code></p>
|
|
113
|
+
<div class="toolbar">
|
|
114
|
+
<input id="setname" class="setname" type="text" placeholder="name this set (optional)" />
|
|
115
|
+
<button id="export" class="primary">Export labels</button>
|
|
116
|
+
<button id="clear" class="ghost">Clear all</button>
|
|
117
|
+
<span id="summary" class="summary"></span>
|
|
118
|
+
</div>
|
|
119
|
+
<div id="savemsg" class="savemsg"></div>
|
|
120
|
+
<canvas id="c" aria-label="metric series — drag to mark an incident, scroll to zoom"></canvas>
|
|
121
|
+
<div class="zoombar">
|
|
122
|
+
<button id="zreset" class="ghost">Reset zoom</button>
|
|
123
|
+
<span id="range" class="rangelbl"></span>
|
|
124
|
+
</div>
|
|
125
|
+
<canvas id="ov" aria-label="navigator — drag the window to pan, its edges to stretch the view"></canvas>
|
|
126
|
+
<div class="navhint">Drag on an empty area to mark an incident · drag an existing incident's edges to
|
|
127
|
+
adjust it, or its middle to move it · scroll to zoom, double-click to reset · drag the navigator
|
|
128
|
+
window below to pan, its edges to stretch / squeeze the view.</div>
|
|
129
|
+
<div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
|
|
130
|
+
<ul id="list"></ul>
|
|
131
|
+
<footer>All times UTC · self-contained, nothing leaves your browser · re-label any time —
|
|
132
|
+
exports are versioned (<code>__METRIC__-<timestamp>.yml</code>), so keep every round in
|
|
133
|
+
<code>incidents/__METRIC__/</code>. Generated by <code>dtk autotune --label</code>.</footer>
|
|
134
|
+
</div>
|
|
135
|
+
<script>
|
|
136
|
+
const DATA = __PAYLOAD__;
|
|
137
|
+
// When served by `dtk autotune --label` (local server), this is the save endpoint
|
|
138
|
+
// and Export POSTs straight into incidents/<metric>/. As a static file it is null,
|
|
139
|
+
// and Export falls back to a browser download.
|
|
140
|
+
const SAVE_URL = __SAVE_URL__;
|
|
141
|
+
const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
|
|
142
|
+
const N = pts.length;
|
|
143
|
+
const vraw = pts.filter(p => p.v !== null).map(p => p.v);
|
|
144
|
+
const vmin0 = vraw.length ? Math.min.apply(null, vraw) : 0;
|
|
145
|
+
const vmax0 = vraw.length ? Math.max.apply(null, vraw) : 1;
|
|
146
|
+
const vpad = (vmax0 - vmin0) * 0.06 || 1;
|
|
147
|
+
const vmin = vmin0 - vpad, vmax = vmax0 + vpad;
|
|
148
|
+
const tmin = N ? pts[0].ts : 0, tmax = N ? pts[N-1].ts : 1, fullSpan = (tmax - tmin) || 1;
|
|
149
|
+
const step = fullSpan / Math.max(1, N - 1);
|
|
150
|
+
const minSpan = Math.max(step * 8, 1000);
|
|
151
|
+
let viewMin = tmin, viewMax = tmax;
|
|
152
|
+
const incidents = [];
|
|
153
|
+
const c = document.getElementById('c'), ov = document.getElementById('ov');
|
|
154
|
+
const ctx = c.getContext('2d'), octx = ov.getContext('2d');
|
|
155
|
+
const M = {l:56, r:16, t:14, b:30}, OM = {l:56, r:16, t:8, b:8};
|
|
156
|
+
let dpr = 1, hover = null, dragging = null, ovAct = null;
|
|
157
|
+
|
|
158
|
+
const clamp = (x,a,b) => Math.max(a, Math.min(b, x));
|
|
159
|
+
const vspan = () => viewMax - viewMin;
|
|
160
|
+
const plotW = () => c.width - (M.l+M.r)*dpr;
|
|
161
|
+
const plotH = () => c.height - (M.t+M.b)*dpr;
|
|
162
|
+
const px = ts => M.l*dpr + (ts-viewMin)/(vspan()||1)*plotW();
|
|
163
|
+
const py = v => (c.height - M.b*dpr) - (v-vmin)/((vmax-vmin)||1)*plotH();
|
|
164
|
+
const ovWd = () => ov.width - (OM.l+OM.r)*dpr;
|
|
165
|
+
const ovHt = () => ov.height - (OM.t+OM.b)*dpr;
|
|
166
|
+
const ovpx = ts => OM.l*dpr + (ts-tmin)/fullSpan*ovWd();
|
|
167
|
+
const ovpy = v => (ov.height - OM.b*dpr) - (v-vmin)/((vmax-vmin)||1)*ovHt();
|
|
168
|
+
const pad2 = n => String(n).padStart(2,'0');
|
|
169
|
+
const fmtTs = ts => new Date(ts).toISOString().slice(0,19).replace('T',' ');
|
|
170
|
+
const fmtTick = (ts, sp) => { const s = new Date(ts).toISOString();
|
|
171
|
+
return sp < 2*86400000 ? s.slice(5,16).replace('T',' ') : s.slice(5,10); };
|
|
172
|
+
const fmtVal = v => { const a = Math.abs(v); return a>=1000 ? v.toFixed(0) : a>=10 ? v.toFixed(1) : v.toFixed(2); };
|
|
173
|
+
function fmtDur(ms) { const m = Math.round(ms/60000); if (m<60) return m+'m';
|
|
174
|
+
const h = Math.floor(m/60), mm = m%60; if (h<24) return h+'h'+(mm?(' '+mm+'m'):'');
|
|
175
|
+
const d = Math.floor(h/24), hh = h%24; return d+'d'+(hh?(' '+hh+'h'):''); }
|
|
176
|
+
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
177
|
+
const yamlStr = s => '"' + String(s).replace(/\\\\/g,'\\\\\\\\').replace(/"/g,'\\\\"') + '"';
|
|
178
|
+
|
|
179
|
+
function setView(a, b) {
|
|
180
|
+
let s = b - a;
|
|
181
|
+
if (s < minSpan) { const m=(a+b)/2; a=m-minSpan/2; b=m+minSpan/2; s=minSpan; }
|
|
182
|
+
if (s >= fullSpan) { a=tmin; b=tmax; }
|
|
183
|
+
if (a < tmin) { b += tmin-a; a=tmin; }
|
|
184
|
+
if (b > tmax) { a -= b-tmax; b=tmax; }
|
|
185
|
+
viewMin = clamp(a, tmin, tmax); viewMax = clamp(b, tmin, tmax);
|
|
186
|
+
drawAll();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Series drawing with min/max decimation (one envelope segment per pixel column)
|
|
190
|
+
// so a 100k-point series stays fast and spikes stay visible; direct polyline when
|
|
191
|
+
// few points are visible (zoomed in).
|
|
192
|
+
function drawSeries(ctx2, xfn, yfn, lo, hi, leftDev, widthDev, color, lw) {
|
|
193
|
+
const cols = Math.max(1, Math.round(widthDev)), sp = (hi-lo)||1;
|
|
194
|
+
let vis = 0;
|
|
195
|
+
for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<lo||p.ts>hi) continue; vis++; }
|
|
196
|
+
ctx2.strokeStyle = color; ctx2.lineWidth = lw*dpr; ctx2.beginPath();
|
|
197
|
+
if (vis <= cols) {
|
|
198
|
+
let pen = false;
|
|
199
|
+
for (let i=0;i<N;i++) { const p=pts[i];
|
|
200
|
+
if (p.v===null || p.ts<lo || p.ts>hi) { pen=false; continue; }
|
|
201
|
+
const X=xfn(p.ts), Y=yfn(p.v);
|
|
202
|
+
if (!pen) { ctx2.moveTo(X,Y); pen=true; } else ctx2.lineTo(X,Y);
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const cmin = new Array(cols).fill(null), cmax = new Array(cols).fill(null);
|
|
206
|
+
for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<lo||p.ts>hi) continue;
|
|
207
|
+
let col = Math.floor((p.ts-lo)/sp*(cols-1)); col = col<0?0:col>cols-1?cols-1:col;
|
|
208
|
+
if (cmin[col]===null||p.v<cmin[col]) cmin[col]=p.v;
|
|
209
|
+
if (cmax[col]===null||p.v>cmax[col]) cmax[col]=p.v;
|
|
210
|
+
}
|
|
211
|
+
let pen = false;
|
|
212
|
+
for (let col=0;col<cols;col++) { if (cmax[col]===null) { pen=false; continue; }
|
|
213
|
+
const X=leftDev+col, yh=yfn(cmax[col]), yl=yfn(cmin[col]);
|
|
214
|
+
if (!pen) { ctx2.moveTo(X,yh); pen=true; } else ctx2.lineTo(X,yh);
|
|
215
|
+
ctx2.lineTo(X,yl);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
ctx2.stroke();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function draw() {
|
|
222
|
+
ctx.clearRect(0,0,c.width,c.height);
|
|
223
|
+
ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
|
|
224
|
+
ctx.textBaseline = 'middle';
|
|
225
|
+
for (let i=0;i<=4;i++) { const v=vmin+(vmax-vmin)*i/4, yy=py(v);
|
|
226
|
+
ctx.strokeStyle='rgba(255,255,255,0.05)'; ctx.lineWidth=1*dpr;
|
|
227
|
+
ctx.beginPath(); ctx.moveTo(M.l*dpr,yy); ctx.lineTo(c.width-M.r*dpr,yy); ctx.stroke();
|
|
228
|
+
ctx.fillStyle='#6e675b'; ctx.textAlign='right'; ctx.fillText(fmtVal(v),(M.l-8)*dpr,yy); }
|
|
229
|
+
ctx.textBaseline = 'top';
|
|
230
|
+
for (let i=0;i<=5;i++) { const ts=viewMin+vspan()*i/5, xx=px(ts);
|
|
231
|
+
ctx.fillStyle='#6e675b'; ctx.textAlign=i===0?'left':i===5?'right':'center';
|
|
232
|
+
ctx.fillText(fmtTick(ts,vspan()), xx, (c.height-M.b+8)*dpr); }
|
|
233
|
+
ctx.save(); ctx.beginPath(); ctx.rect(M.l*dpr, M.t*dpr, plotW(), plotH()); ctx.clip();
|
|
234
|
+
incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
|
|
235
|
+
ctx.fillStyle='rgba(214,50,50,0.20)'; ctx.fillRect(x0, M.t*dpr, x1-x0, plotH());
|
|
236
|
+
ctx.strokeStyle='rgba(214,50,50,0.55)'; ctx.lineWidth=1*dpr; ctx.strokeRect(x0, M.t*dpr, x1-x0, plotH());
|
|
237
|
+
// draggable edge handles
|
|
238
|
+
ctx.fillStyle='rgba(214,50,50,0.95)';
|
|
239
|
+
ctx.fillRect(x0-1.5*dpr, M.t*dpr, 3*dpr, plotH());
|
|
240
|
+
ctx.fillRect(x1-1.5*dpr, M.t*dpr, 3*dpr, plotH()); });
|
|
241
|
+
if (dragging && dragging.mode==='new') { const x0=px(dragging.a), x1=px(dragging.b);
|
|
242
|
+
ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
|
|
243
|
+
drawSeries(ctx, px, py, viewMin, viewMax, M.l*dpr, plotW(), '#d15b36', 1.5);
|
|
244
|
+
ctx.restore();
|
|
245
|
+
if (hover && !dragging && !ovAct) drawHover();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function drawHover() {
|
|
249
|
+
let best=null, bd=Infinity;
|
|
250
|
+
for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<viewMin||p.ts>viewMax) continue;
|
|
251
|
+
const d=Math.abs(p.ts-hover.ts); if (d<bd) { bd=d; best=p; } }
|
|
252
|
+
if (!best) return;
|
|
253
|
+
const X=px(best.ts), Y=py(best.v);
|
|
254
|
+
ctx.strokeStyle='rgba(201,194,180,0.25)'; ctx.lineWidth=1*dpr;
|
|
255
|
+
ctx.beginPath(); ctx.moveTo(X, M.t*dpr); ctx.lineTo(X, c.height-M.b*dpr); ctx.stroke();
|
|
256
|
+
ctx.fillStyle='#f5f1e8'; ctx.beginPath(); ctx.arc(X,Y,3*dpr,0,7); ctx.fill();
|
|
257
|
+
const label=fmtTick(best.ts, 0)+' · '+fmtVal(best.v);
|
|
258
|
+
ctx.font=(11*dpr)+'px ui-monospace, monospace'; const tw=ctx.measureText(label).width;
|
|
259
|
+
let bx=X+8*dpr; if (bx+tw+12*dpr > c.width) bx=X-tw-20*dpr;
|
|
260
|
+
ctx.fillStyle='rgba(27,25,22,0.92)'; ctx.strokeStyle='#332f29';
|
|
261
|
+
ctx.fillRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr); ctx.strokeRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr);
|
|
262
|
+
ctx.fillStyle='#c9c2b4'; ctx.textAlign='left'; ctx.textBaseline='middle';
|
|
263
|
+
ctx.fillText(label, bx+6*dpr, M.t*dpr+12);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function drawOverview() {
|
|
267
|
+
octx.clearRect(0,0,ov.width,ov.height);
|
|
268
|
+
octx.save(); octx.beginPath(); octx.rect(OM.l*dpr, OM.t*dpr, ovWd(), ovHt()); octx.clip();
|
|
269
|
+
incidents.forEach(iv => { const x0=ovpx(iv.a), x1=ovpx(iv.b);
|
|
270
|
+
octx.fillStyle='rgba(214,50,50,0.30)'; octx.fillRect(x0, OM.t*dpr, x1-x0, ovHt()); });
|
|
271
|
+
drawSeries(octx, ovpx, ovpy, tmin, tmax, OM.l*dpr, ovWd(), 'rgba(209,91,54,0.7)', 1.1);
|
|
272
|
+
octx.restore();
|
|
273
|
+
const vx0=ovpx(viewMin), vx1=ovpx(viewMax);
|
|
274
|
+
octx.fillStyle='rgba(27,25,22,0.55)';
|
|
275
|
+
octx.fillRect(OM.l*dpr, OM.t*dpr, vx0-OM.l*dpr, ovHt());
|
|
276
|
+
octx.fillRect(vx1, OM.t*dpr, (ov.width-OM.r*dpr)-vx1, ovHt());
|
|
277
|
+
octx.fillStyle='rgba(245,241,232,0.06)'; octx.fillRect(vx0, OM.t*dpr, vx1-vx0, ovHt());
|
|
278
|
+
octx.strokeStyle='#d15b36'; octx.lineWidth=1.5*dpr;
|
|
279
|
+
octx.strokeRect(vx0, OM.t*dpr+1, vx1-vx0, ovHt()-2);
|
|
280
|
+
octx.fillStyle='#d15b36'; const hy=OM.t*dpr+ovHt()/2-9*dpr;
|
|
281
|
+
octx.fillRect(vx0-2*dpr, hy, 4*dpr, 18*dpr); octx.fillRect(vx1-2*dpr, hy, 4*dpr, 18*dpr);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const tsAt = clientX => { const r=c.getBoundingClientRect();
|
|
285
|
+
const fr=((clientX-r.left)-M.l)/((r.width-(M.l+M.r))||1); return viewMin + clamp(fr,0,1)*vspan(); };
|
|
286
|
+
const ovTsAtCss = clientX => { const r=ov.getBoundingClientRect();
|
|
287
|
+
const fr=((clientX-r.left)-OM.l)/((r.width-(OM.l+OM.r))||1); return tmin + clamp(fr,0,1)*fullSpan; };
|
|
288
|
+
const ovEdgeCss = ts => { const r=ov.getBoundingClientRect();
|
|
289
|
+
return r.left + OM.l + (ts-tmin)/fullSpan*(r.width-(OM.l+OM.r)); };
|
|
290
|
+
|
|
291
|
+
c.addEventListener('wheel', e => { e.preventDefault(); const t=tsAt(e.clientX);
|
|
292
|
+
let s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan);
|
|
293
|
+
const f=(t-viewMin)/(vspan()||1); setView(t-f*s, t-f*s+s); }, {passive:false});
|
|
294
|
+
// Hit-test an existing incident edge / body in CSS px (for editing vs creating).
|
|
295
|
+
const EDGE_PX = 6;
|
|
296
|
+
const minStep = () => Math.max(step, 1);
|
|
297
|
+
const pxCss = ts => { const r=c.getBoundingClientRect();
|
|
298
|
+
return M.l + (ts-viewMin)/(vspan()||1)*(r.width-(M.l+M.r)); };
|
|
299
|
+
function hitIncident(clientX) {
|
|
300
|
+
const x = clientX - c.getBoundingClientRect().left;
|
|
301
|
+
for (let i=0;i<incidents.length;i++) { const xa=pxCss(incidents[i].a), xb=pxCss(incidents[i].b);
|
|
302
|
+
if (Math.abs(x-xa)<=EDGE_PX) return {i, edge:'a'};
|
|
303
|
+
if (Math.abs(x-xb)<=EDGE_PX) return {i, edge:'b'}; }
|
|
304
|
+
for (let i=0;i<incidents.length;i++) { const xa=pxCss(incidents[i].a), xb=pxCss(incidents[i].b);
|
|
305
|
+
if (x>xa+EDGE_PX && x<xb-EDGE_PX) return {i, edge:'move'}; }
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
c.addEventListener('mousedown', e => {
|
|
309
|
+
const hit = hitIncident(e.clientX), t = tsAt(e.clientX);
|
|
310
|
+
if (hit && hit.edge==='move') { const iv=incidents[hit.i];
|
|
311
|
+
dragging={mode:'move', i:hit.i, grab:t, a0:iv.a, b0:iv.b, sx:e.clientX, cx:e.clientX}; }
|
|
312
|
+
else if (hit) dragging={mode:'edge', i:hit.i, edge:hit.edge, sx:e.clientX, cx:e.clientX};
|
|
313
|
+
else dragging={mode:'new', a:t, b:t, sx:e.clientX, cx:e.clientX};
|
|
314
|
+
});
|
|
315
|
+
c.addEventListener('mousemove', e => { if (ovAct) return;
|
|
316
|
+
if (dragging) {
|
|
317
|
+
dragging.cx=e.clientX; const t=tsAt(e.clientX);
|
|
318
|
+
if (dragging.mode==='new') { dragging.b=t; }
|
|
319
|
+
else if (dragging.mode==='edge') { const iv=incidents[dragging.i]; if (!iv) return;
|
|
320
|
+
if (dragging.edge==='a') iv.a=clamp(Math.min(t, iv.b-minStep()), tmin, tmax);
|
|
321
|
+
else iv.b=clamp(Math.max(t, iv.a+minStep()), tmin, tmax); }
|
|
322
|
+
else if (dragging.mode==='move') { const iv=incidents[dragging.i]; if (!iv) return;
|
|
323
|
+
let na=dragging.a0+(t-dragging.grab), nb=dragging.b0+(t-dragging.grab);
|
|
324
|
+
if (na<tmin) { nb+=tmin-na; na=tmin; } if (nb>tmax) { na-=nb-tmax; nb=tmax; }
|
|
325
|
+
iv.a=clamp(na,tmin,tmax); iv.b=clamp(nb,tmin,tmax); }
|
|
326
|
+
draw();
|
|
327
|
+
} else {
|
|
328
|
+
const hit=hitIncident(e.clientX);
|
|
329
|
+
c.style.cursor = hit ? (hit.edge==='move' ? 'grab' : 'ew-resize') : 'crosshair';
|
|
330
|
+
hover={ts:tsAt(e.clientX)}; draw();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; draw(); } });
|
|
334
|
+
|
|
335
|
+
ov.addEventListener('mousedown', e => { e.preventDefault(); ov.style.cursor='grabbing';
|
|
336
|
+
const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax), x=e.clientX, H=8;
|
|
337
|
+
if (Math.abs(x-xl)<=H) ovAct={type:'l'};
|
|
338
|
+
else if (Math.abs(x-xr)<=H) ovAct={type:'r'};
|
|
339
|
+
else if (x>xl && x<xr) ovAct={type:'pan', grab:ovTsAtCss(x), vMin:viewMin, vMax:viewMax};
|
|
340
|
+
else { const t=ovTsAtCss(x), s=vspan(); setView(t-s/2, t+s/2); ovAct={type:'pan', grab:t, vMin:viewMin, vMax:viewMax}; }
|
|
341
|
+
});
|
|
342
|
+
ov.addEventListener('mousemove', e => { if (ovAct) return; const x=e.clientX, H=8;
|
|
343
|
+
const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax);
|
|
344
|
+
ov.style.cursor = (Math.abs(x-xl)<=H || Math.abs(x-xr)<=H) ? 'ew-resize' : (x>xl && x<xr) ? 'grab' : 'pointer'; });
|
|
345
|
+
ov.addEventListener('wheel', e => { e.preventDefault(); const t=ovTsAtCss(e.clientX);
|
|
346
|
+
const s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan); setView(t-s/2, t+s/2); }, {passive:false});
|
|
347
|
+
|
|
348
|
+
window.addEventListener('mousemove', e => { if (!ovAct) return; const t=ovTsAtCss(e.clientX);
|
|
349
|
+
if (ovAct.type==='l') setView(Math.min(t, viewMax-minSpan), viewMax);
|
|
350
|
+
else if (ovAct.type==='r') setView(viewMin, Math.max(t, viewMin+minSpan));
|
|
351
|
+
else { const d=t-ovAct.grab; setView(ovAct.vMin+d, ovAct.vMax+d); } });
|
|
352
|
+
window.addEventListener('mouseup', () => {
|
|
353
|
+
if (ovAct) { ovAct=null; return; }
|
|
354
|
+
if (!dragging) return;
|
|
355
|
+
if (dragging.mode==='new') {
|
|
356
|
+
if (Math.abs(dragging.cx-dragging.sx) > 4) {
|
|
357
|
+
const a=clamp(Math.min(dragging.a,dragging.b),tmin,tmax), b=clamp(Math.max(dragging.a,dragging.b),tmin,tmax);
|
|
358
|
+
incidents.push({a, b, label:''});
|
|
359
|
+
}
|
|
360
|
+
} else { const iv=incidents[dragging.i]; // edge/move: keep start <= end
|
|
361
|
+
if (iv && iv.a>iv.b) { const t=iv.a; iv.a=iv.b; iv.b=t; } }
|
|
362
|
+
dragging=null; render();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
document.getElementById('zreset').onclick = () => setView(tmin, tmax);
|
|
366
|
+
c.addEventListener('dblclick', () => setView(tmin, tmax));
|
|
367
|
+
document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
|
|
368
|
+
window.setLabel = (i, val) => { if (incidents[i]) incidents[i].label = val; };
|
|
369
|
+
window.rm = i => { incidents.splice(i,1); render(); };
|
|
370
|
+
|
|
371
|
+
function render() {
|
|
372
|
+
incidents.sort((p,q)=>p.a-q.a);
|
|
373
|
+
const list=document.getElementById('list');
|
|
374
|
+
list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
|
|
375
|
+
+'<span class="span">'+fmtTs(iv.a)+' → '+fmtTs(iv.b)+'</span>'
|
|
376
|
+
+'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
|
|
377
|
+
+'<input class="desc" type="text" placeholder="describe this incident (optional)" '
|
|
378
|
+
+'value="'+esc(iv.label||'')+'" oninput="setLabel('+i+', this.value)">'
|
|
379
|
+
+'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
|
|
380
|
+
document.getElementById('empty').style.display = incidents.length ? 'none' : '';
|
|
381
|
+
const total=incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
|
|
382
|
+
document.getElementById('summary').innerHTML = incidents.length
|
|
383
|
+
? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total' : '';
|
|
384
|
+
drawAll();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const slug = s => (String(s).toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'') || '__METRIC__');
|
|
388
|
+
const setMsg = (text, cls) => { const el=document.getElementById('savemsg');
|
|
389
|
+
el.textContent=text; el.className='savemsg '+cls; };
|
|
390
|
+
const buildYaml = () => {
|
|
391
|
+
let y='metric: __METRIC__\\ntimezone: UTC\\nincidents:\\n';
|
|
392
|
+
const sorted=incidents.slice().sort((p,q)=>p.a-q.a);
|
|
393
|
+
if (!sorted.length) y+=' []\\n';
|
|
394
|
+
sorted.forEach(iv => { y+=' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"'
|
|
395
|
+
+ (iv.label && iv.label.trim() ? ', label: '+yamlStr(iv.label.trim()) : '') + '}\\n'; });
|
|
396
|
+
return y;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const exportBtn = document.getElementById('export');
|
|
400
|
+
if (SAVE_URL) exportBtn.textContent = 'Save & tune';
|
|
401
|
+
exportBtn.onclick = () => {
|
|
402
|
+
const y = buildYaml();
|
|
403
|
+
const name = document.getElementById('setname').value;
|
|
404
|
+
if (SAVE_URL) {
|
|
405
|
+
setMsg('Saving…', 'info'); exportBtn.disabled = true;
|
|
406
|
+
fetch(SAVE_URL, {method:'POST', headers:{'Content-Type':'application/json'},
|
|
407
|
+
body: JSON.stringify({name: name, yaml: y})})
|
|
408
|
+
.then(r => r.ok ? r.json() : r.text().then(t => { throw new Error(t || ('HTTP '+r.status)); }))
|
|
409
|
+
.then(res => setMsg('Saved to ' + res.saved + ' — autotune is now running in your terminal. '
|
|
410
|
+
+ 'You can close this tab.', 'ok'))
|
|
411
|
+
.catch(e => { exportBtn.disabled = false; setMsg('Save failed: ' + e.message, 'err'); });
|
|
412
|
+
} else {
|
|
413
|
+
const d=new Date();
|
|
414
|
+
const stamp=d.getUTCFullYear()+pad2(d.getUTCMonth()+1)+pad2(d.getUTCDate())+'T'
|
|
415
|
+
+pad2(d.getUTCHours())+pad2(d.getUTCMinutes())+pad2(d.getUTCSeconds())+'Z';
|
|
416
|
+
const base = name.trim() ? slug(name) : '__METRIC__';
|
|
417
|
+
const blob=new Blob([y], {type:'text/yaml'}); const a=document.createElement('a');
|
|
418
|
+
a.href=URL.createObjectURL(blob); a.download=base+'-'+stamp+'.yml'; a.click();
|
|
419
|
+
setMsg('Downloaded ' + base + '-' + stamp + '.yml — move it into incidents/__METRIC__/ and re-run.', 'info');
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
function drawAll() { draw(); drawOverview();
|
|
424
|
+
document.getElementById('range').textContent =
|
|
425
|
+
'viewing ' + fmtTs(viewMin) + ' → ' + fmtTs(viewMax) + ' · ' + fmtDur(vspan()) + ' of ' + fmtDur(fullSpan); }
|
|
426
|
+
function fit() { dpr = window.devicePixelRatio || 1;
|
|
427
|
+
c.width=c.clientWidth*dpr; c.height=c.clientHeight*dpr;
|
|
428
|
+
ov.width=ov.clientWidth*dpr; ov.height=ov.clientHeight*dpr; drawAll(); }
|
|
429
|
+
window.addEventListener('resize', fit); fit(); render();
|
|
430
|
+
</script>
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def render_labeler_html(
|
|
435
|
+
metric_name: str, data: dict[str, np.ndarray], *, save_url: str | None = None
|
|
436
|
+
) -> str:
|
|
437
|
+
"""Return a self-contained HTML labeler page for *metric_name*'s series.
|
|
438
|
+
|
|
439
|
+
With ``save_url`` (set by ``dtk autotune --label``'s local server) the page's
|
|
440
|
+
Export button POSTs the labels straight to that endpoint; without it (a static
|
|
441
|
+
file) Export falls back to a browser download.
|
|
442
|
+
"""
|
|
443
|
+
import json
|
|
444
|
+
|
|
445
|
+
timestamps = data["timestamp"]
|
|
446
|
+
values = data["value"]
|
|
447
|
+
points = []
|
|
448
|
+
for i in range(len(timestamps)):
|
|
449
|
+
v = values[i]
|
|
450
|
+
points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
|
|
451
|
+
payload = json_dumps_sorted({"metric": metric_name, "points": points})
|
|
452
|
+
return (
|
|
453
|
+
_TEMPLATE.replace("__PAYLOAD__", payload)
|
|
454
|
+
.replace("__SAVE_URL__", json.dumps(save_url))
|
|
455
|
+
.replace("__METRIC__", metric_name)
|
|
456
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Local browser labeler server for ``dtk autotune --label``.
|
|
2
|
+
|
|
3
|
+
A pure-stdlib localhost server: it serves the interactive labeler page and, when
|
|
4
|
+
the user clicks **Save & tune**, validates the labels, writes a versioned file
|
|
5
|
+
into ``incidents/<metric>/`` and stops — so the command can continue straight
|
|
6
|
+
into the tuning run. Bound to 127.0.0.1 with a one-shot token; nothing is exposed
|
|
7
|
+
off the machine, and nothing is written until the user explicitly saves.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
import threading
|
|
16
|
+
import webbrowser
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, cast
|
|
22
|
+
from urllib.parse import parse_qs, urlparse
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from detectkit.autotune.html_labeler import render_labeler_html
|
|
27
|
+
from detectkit.autotune.labels import parse_incident_labels
|
|
28
|
+
|
|
29
|
+
_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
|
30
|
+
_MAX_BODY = 5_000_000 # generous cap on the posted labels payload
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sanitize(name: str) -> str:
|
|
34
|
+
"""Filesystem-safe slug for a label-set name; falls back to ``incidents``."""
|
|
35
|
+
slug = _NAME_RE.sub("-", name.strip().lower()).strip("-")
|
|
36
|
+
return slug or "incidents"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _stamp() -> str:
|
|
40
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _LabelServer(ThreadingHTTPServer):
|
|
44
|
+
"""Localhost server holding the per-run state the handler reads/writes."""
|
|
45
|
+
|
|
46
|
+
# Don't block process/interpreter exit on in-flight request threads (we stop
|
|
47
|
+
# after a single save anyway); also avoids coverage's thread-tracing hanging
|
|
48
|
+
# at exit on lingering handler threads.
|
|
49
|
+
daemon_threads = True
|
|
50
|
+
|
|
51
|
+
def __init__(self, address: tuple[str, int], handler: type[BaseHTTPRequestHandler]) -> None:
|
|
52
|
+
super().__init__(address, handler)
|
|
53
|
+
self.token: str = ""
|
|
54
|
+
self.html: str = ""
|
|
55
|
+
self.metric: str = ""
|
|
56
|
+
self.incidents_dir: Path = Path(".")
|
|
57
|
+
self.interval_seconds: int = 1
|
|
58
|
+
self.saved_path: Path | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
62
|
+
def log_message(self, *args: Any) -> None: # silence default stderr logging
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
def _srv(self) -> _LabelServer:
|
|
66
|
+
return cast(_LabelServer, self.server)
|
|
67
|
+
|
|
68
|
+
def do_GET(self) -> None:
|
|
69
|
+
body = self._srv().html.encode("utf-8")
|
|
70
|
+
self.send_response(200)
|
|
71
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
72
|
+
self.send_header("Content-Length", str(len(body)))
|
|
73
|
+
self.end_headers()
|
|
74
|
+
self.wfile.write(body)
|
|
75
|
+
|
|
76
|
+
def do_POST(self) -> None:
|
|
77
|
+
srv = self._srv()
|
|
78
|
+
if parse_qs(urlparse(self.path).query).get("token", [""])[0] != srv.token:
|
|
79
|
+
self.send_error(403, "bad token")
|
|
80
|
+
return
|
|
81
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
82
|
+
if length <= 0 or length > _MAX_BODY:
|
|
83
|
+
self.send_error(413, "empty or too large")
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
import yaml as _yaml
|
|
87
|
+
|
|
88
|
+
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
89
|
+
yaml_text = str(payload.get("yaml", ""))
|
|
90
|
+
set_name = _sanitize(str(payload.get("name", "")))
|
|
91
|
+
raw = _yaml.safe_load(yaml_text)
|
|
92
|
+
# validate against the canonical schema before writing anything
|
|
93
|
+
parse_incident_labels(
|
|
94
|
+
raw, interval_seconds=srv.interval_seconds, metric_name=srv.metric
|
|
95
|
+
)
|
|
96
|
+
srv.incidents_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
out = srv.incidents_dir / f"{set_name}-{_stamp()}.yml"
|
|
98
|
+
out.write_text(yaml_text, encoding="utf-8")
|
|
99
|
+
srv.saved_path = out
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
self.send_error(400, f"invalid labels: {exc}")
|
|
102
|
+
return
|
|
103
|
+
resp = json.dumps({"saved": str(out)}).encode("utf-8")
|
|
104
|
+
self.send_response(200)
|
|
105
|
+
self.send_header("Content-Type", "application/json")
|
|
106
|
+
self.send_header("Content-Length", str(len(resp)))
|
|
107
|
+
self.end_headers()
|
|
108
|
+
self.wfile.write(resp)
|
|
109
|
+
# stop serving (from this worker thread) so the command can continue
|
|
110
|
+
threading.Thread(target=srv.shutdown, daemon=True).start()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_label_server(
|
|
114
|
+
*,
|
|
115
|
+
metric_name: str,
|
|
116
|
+
data: dict[str, np.ndarray],
|
|
117
|
+
incidents_dir: Path,
|
|
118
|
+
interval_seconds: int,
|
|
119
|
+
) -> tuple[_LabelServer, str]:
|
|
120
|
+
"""Construct (without running) the labeler server; return ``(server, page_url)``."""
|
|
121
|
+
server = _LabelServer(("127.0.0.1", 0), _Handler)
|
|
122
|
+
token = secrets.token_urlsafe(16)
|
|
123
|
+
port = int(server.server_address[1])
|
|
124
|
+
server.token = token
|
|
125
|
+
server.metric = metric_name
|
|
126
|
+
server.incidents_dir = incidents_dir
|
|
127
|
+
server.interval_seconds = interval_seconds
|
|
128
|
+
server.html = render_labeler_html(
|
|
129
|
+
metric_name, data, save_url=f"http://127.0.0.1:{port}/save?token={token}"
|
|
130
|
+
)
|
|
131
|
+
return server, f"http://127.0.0.1:{port}/?token={token}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def serve_labeler(
|
|
135
|
+
*,
|
|
136
|
+
metric_name: str,
|
|
137
|
+
data: dict[str, np.ndarray],
|
|
138
|
+
incidents_dir: Path,
|
|
139
|
+
interval_seconds: int,
|
|
140
|
+
open_browser: bool = True,
|
|
141
|
+
echo: Callable[[str], None] = print,
|
|
142
|
+
on_ready: Callable[[str], None] | None = None,
|
|
143
|
+
) -> Path | None:
|
|
144
|
+
"""Serve the labeler until the user saves (returns the file) or cancels (None)."""
|
|
145
|
+
server, url = build_label_server(
|
|
146
|
+
metric_name=metric_name,
|
|
147
|
+
data=data,
|
|
148
|
+
incidents_dir=incidents_dir,
|
|
149
|
+
interval_seconds=interval_seconds,
|
|
150
|
+
)
|
|
151
|
+
if on_ready is not None:
|
|
152
|
+
on_ready(url)
|
|
153
|
+
echo(f" Labeler: {url}")
|
|
154
|
+
echo(" Mark incidents in the browser, then click Save & tune (Ctrl-C to cancel).")
|
|
155
|
+
if open_browser:
|
|
156
|
+
try:
|
|
157
|
+
webbrowser.open(url)
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
try:
|
|
161
|
+
server.serve_forever(poll_interval=0.3)
|
|
162
|
+
except KeyboardInterrupt:
|
|
163
|
+
return None
|
|
164
|
+
finally:
|
|
165
|
+
server.server_close()
|
|
166
|
+
return server.saved_path
|