detectkit 0.20.0__tar.gz → 0.22.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.20.0/detectkit.egg-info → detectkit-0.22.0}/PKG-INFO +1 -1
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/__init__.py +1 -1
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/autotuner.py +6 -1
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/config_emitter.py +8 -3
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/detector_select.py +16 -10
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/grid_search.py +19 -4
- detectkit-0.22.0/detectkit/autotune/html_labeler.py +362 -0
- detectkit-0.22.0/detectkit/autotune/scoring.py +330 -0
- detectkit-0.22.0/detectkit/autotune/seasonality_search.py +216 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/settings.py +12 -5
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/window_select.py +14 -6
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/autotune.md +69 -19
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +88 -29
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/autotune.py +32 -1
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/metric_config.py +27 -0
- {detectkit-0.20.0 → detectkit-0.22.0/detectkit.egg-info}/PKG-INFO +1 -1
- detectkit-0.20.0/detectkit/autotune/html_labeler.py +0 -103
- detectkit-0.20.0/detectkit/autotune/scoring.py +0 -177
- detectkit-0.20.0/detectkit/autotune/seasonality_search.py +0 -135
- {detectkit-0.20.0 → detectkit-0.22.0}/LICENSE +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/MANIFEST.in +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/README.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/core/models.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/pyproject.toml +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/requirements.txt +0 -0
- {detectkit-0.20.0 → detectkit-0.22.0}/setup.cfg +0 -0
- {detectkit-0.20.0 → detectkit-0.22.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.22.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -65,10 +65,15 @@ class AutoTuner(_AutoTuneBase):
|
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
gt = self.ground_truth
|
|
68
|
+
objective = (
|
|
69
|
+
f"scoring={self.settings.metric.value}"
|
|
70
|
+
if gt.mode == TuneMode.SUPERVISED
|
|
71
|
+
else "objective=unsupervised (band-fit + flag-budget)"
|
|
72
|
+
)
|
|
68
73
|
self.log(
|
|
69
74
|
"labels",
|
|
70
75
|
f"{gt.n_intervals} interval(s) + {gt.n_points} point(s) → {gt.mode.value} mode "
|
|
71
|
-
f"({gt.n_positive} labeled grid point(s));
|
|
76
|
+
f"({gt.n_positive} labeled grid point(s)); {objective}",
|
|
72
77
|
mode=gt.mode.value,
|
|
73
78
|
n_positive=gt.n_positive,
|
|
74
79
|
)
|
|
@@ -120,9 +120,14 @@ def _build_comments(result: AutoTuneResult, source_label: str, run_id: str) -> s
|
|
|
120
120
|
f"{summary.get('positive_grid_points', 0)} labeled grid point(s)"
|
|
121
121
|
)
|
|
122
122
|
folds = " ".join(f"{f:.2f}" for f in result.cv_per_fold) or "—"
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
if result.mode == "supervised":
|
|
124
|
+
obj_label, objective = "Scoring metric", result.scoring_metric
|
|
125
|
+
else:
|
|
126
|
+
# Unsupervised runs never optimize the labelled metric (MCC etc.); they use
|
|
127
|
+
# the no-label objective. Label it honestly so the header doesn't claim an
|
|
128
|
+
# "mcc =" score it never computed (and contradict the Labels line).
|
|
129
|
+
obj_label, objective = "Objective", "unsupervised (band-fit + flag-budget)"
|
|
130
|
+
lines.append(f"# {obj_label:<16}: {objective} = {result.score:.3f} (CV folds: {folds})")
|
|
126
131
|
lines.append("#")
|
|
127
132
|
for entry in result.decision_log:
|
|
128
133
|
label = _STAGE_LABELS.get(entry.get("stage", ""))
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
"""Stage 2:
|
|
1
|
+
"""Stage 2: order the candidate detector types by distribution suitability.
|
|
2
2
|
|
|
3
3
|
The "decision tree" is a small suitability spec keyed by detector *type name*
|
|
4
4
|
(kept here, not on the detector classes, so existing detectors stay untouched
|
|
5
5
|
and the whole feature is easy to remove). Each seasonality group votes for the
|
|
6
|
-
type that best fits its distribution
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
type that best fits its distribution. The vote is **advisory only** — it ranks
|
|
7
|
+
the types (most promising first) for the grid search, which then evaluates *all*
|
|
8
|
+
of them and lets the cross-validated objective pick the winner. A hand-tuned
|
|
9
|
+
heuristic therefore never excludes a type; it only decides who is tried first
|
|
10
|
+
(and is recorded in the decision log for transparency).
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
13
|
from __future__ import annotations
|
|
@@ -140,16 +142,20 @@ def select_detector_types(
|
|
|
140
142
|
if label == "global":
|
|
141
143
|
global_winner = ranked[0][0]
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
# Evaluate ALL candidate types in the grid search and let the cross-validated
|
|
146
|
+
# objective pick the winner — the suitability vote only ORDERS them (most
|
|
147
|
+
# promising first) and is logged for transparency; it never excludes a type,
|
|
148
|
+
# so a hand-tuned heuristic can't drop the detector that would have scored
|
|
149
|
+
# best. The cap is a pure cost backstop (default ≥ the number of detectors).
|
|
150
|
+
final = sorted(candidate_types, key=lambda t: total_suit[t], reverse=True)
|
|
151
|
+
final = final[: tuner.settings.max_candidate_types]
|
|
152
|
+
if global_winner not in final:
|
|
153
|
+
final.append(global_winner)
|
|
148
154
|
|
|
149
155
|
tally = ", ".join(f"{t}:{votes[t]:.1f}" for t in candidate_types)
|
|
150
156
|
tuner.log(
|
|
151
157
|
"detector_select",
|
|
152
|
-
f"votes — {tally};
|
|
158
|
+
f"suitability votes — {tally}; evaluating (best-first): {', '.join(final)}",
|
|
153
159
|
votes=votes,
|
|
154
160
|
shortlist=final,
|
|
155
161
|
n_groups=len(groups),
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Not a Cartesian product — a greedy coordinate sweep per candidate type:
|
|
4
4
|
threshold → recency weighting → detrend (gated by a trend test) → window size
|
|
5
|
-
(
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
(with the trend-gated window tie-bias from window_select) → a final threshold
|
|
6
|
+
re-sweep at the chosen window (the threshold↔window coupling fix). The objective
|
|
7
|
+
is the cross-validated score (settings.metric, or the unsupervised objective when
|
|
8
|
+
there are no labels). Total evaluations stay in the low tens per type and are
|
|
9
|
+
capped by settings.max_candidates.
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
from __future__ import annotations
|
|
@@ -87,11 +89,24 @@ def grid_search(
|
|
|
87
89
|
if ev is not None and ev.score > best.score + eps:
|
|
88
90
|
best, accepted["detrend"] = ev, detrend
|
|
89
91
|
|
|
90
|
-
# Axis 4: window size (
|
|
92
|
+
# Axis 4: window size (large-window tie-bias, trend-gated in select_window).
|
|
91
93
|
window_best = select_window(tuner, detector_type, accepted, best, grid)
|
|
92
94
|
if window_best.score >= best.score - tuner.settings.window_tie_margin:
|
|
93
95
|
best = window_best
|
|
94
96
|
|
|
97
|
+
# Axis 5: re-sweep threshold at the now-fixed window. The optimal threshold
|
|
98
|
+
# depends on window size (a longer window gives a steadier spread estimate),
|
|
99
|
+
# but threshold was chosen first against the seed window — without this pass
|
|
100
|
+
# a large window swing can leave it stranded. Strict improvement only.
|
|
101
|
+
accepted = dict(best.params)
|
|
102
|
+
for threshold in tuner.settings.threshold_grid(detector_type):
|
|
103
|
+
if threshold == accepted.get("threshold"):
|
|
104
|
+
continue
|
|
105
|
+
ev = tuner.safe_evaluate(detector_type, {**accepted, "threshold": threshold})
|
|
106
|
+
if ev is not None and ev.score > best.score:
|
|
107
|
+
best = ev
|
|
108
|
+
accepted["threshold"] = threshold
|
|
109
|
+
|
|
95
110
|
tuner.log(
|
|
96
111
|
"grid_search",
|
|
97
112
|
f"{detector_type}: best score {best.score:.3f} "
|
|
@@ -0,0 +1,362 @@
|
|
|
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
|
+
.summary { margin-left: auto; color: var(--faint); font-size: 12.5px; font-family: var(--mono); }
|
|
72
|
+
.summary b { color: var(--clay); font-weight: 600; }
|
|
73
|
+
canvas#c { width: 100%; height: clamp(300px, 44vh, 500px); display:block; touch-action: none;
|
|
74
|
+
background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
|
|
75
|
+
.zoombar { display:flex; align-items:center; gap:8px; margin: 10px 0 6px; }
|
|
76
|
+
.rangelbl { margin-left: auto; color: var(--faint); font-size: 12px; font-family: var(--mono); }
|
|
77
|
+
canvas#ov { width: 100%; height: 66px; display:block; touch-action: none;
|
|
78
|
+
background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: grab; }
|
|
79
|
+
.navhint { color: var(--faint); font-size: 12px; margin: 7px 2px 0; }
|
|
80
|
+
.empty { color: var(--faint); font-size: 13px; margin: 18px 2px; font-style: italic; }
|
|
81
|
+
ul { list-style: none; margin: 16px 0 0; padding: 0; }
|
|
82
|
+
li { display:flex; align-items:center; gap:11px; padding: 9px 12px; font-size: 13px; flex-wrap: wrap;
|
|
83
|
+
border: 1px solid var(--term-border); border-radius: 8px; margin-bottom: 7px; background: var(--term-surface); }
|
|
84
|
+
li .dot { width:9px; height:9px; border-radius:50%; background: var(--anomaly); flex: 0 0 auto; }
|
|
85
|
+
li .span { font-family: var(--mono); color: var(--term-text); }
|
|
86
|
+
li .dur { color: var(--faint); font-size: 12px; }
|
|
87
|
+
li input.desc { flex: 1 1 220px; min-width: 160px; background: var(--term-bg); color: var(--paper);
|
|
88
|
+
border: 1px solid var(--term-border); border-radius: 6px; padding: 6px 9px; font-family: var(--ui); font-size: 12.5px; }
|
|
89
|
+
li input.desc::placeholder { color: var(--muted); }
|
|
90
|
+
li input.desc:focus { outline: none; border-color: var(--clay); }
|
|
91
|
+
li button { margin-left: auto; padding: 5px 11px; font-size: 12px; }
|
|
92
|
+
footer { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--term-border);
|
|
93
|
+
color: var(--faint); font-size: 12px; line-height: 1.6; }
|
|
94
|
+
footer code { font-family: var(--mono); color: var(--term-text); }
|
|
95
|
+
</style>
|
|
96
|
+
<div class="shell">
|
|
97
|
+
<div class="brand">
|
|
98
|
+
<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>
|
|
99
|
+
<b>detectkit</b><span>· incident labeler</span>
|
|
100
|
+
</div>
|
|
101
|
+
<h1>Label incidents — <code>__METRIC__</code></h1>
|
|
102
|
+
<p class="hint">Click-drag across the chart to mark each real incident, add a short description, then
|
|
103
|
+
<b>Export</b>. Save the file into <code class="k">incidents/__METRIC__/</code> and run
|
|
104
|
+
<code class="k">dtk autotune --select __METRIC__ --incidents incidents/__METRIC__/</code></p>
|
|
105
|
+
<div class="toolbar">
|
|
106
|
+
<button id="export" class="primary">Export labels</button>
|
|
107
|
+
<button id="clear" class="ghost">Clear all</button>
|
|
108
|
+
<span id="summary" class="summary"></span>
|
|
109
|
+
</div>
|
|
110
|
+
<canvas id="c" aria-label="metric series — drag to mark an incident, scroll to zoom"></canvas>
|
|
111
|
+
<div class="zoombar">
|
|
112
|
+
<button id="zreset" class="ghost">Reset zoom</button>
|
|
113
|
+
<span id="range" class="rangelbl"></span>
|
|
114
|
+
</div>
|
|
115
|
+
<canvas id="ov" aria-label="navigator — drag the window to pan, its edges to stretch the view"></canvas>
|
|
116
|
+
<div class="navhint">Scroll to zoom where you point · double-click to reset · drag the navigator window
|
|
117
|
+
below to move, or drag its edges to stretch / squeeze the view.</div>
|
|
118
|
+
<div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
|
|
119
|
+
<ul id="list"></ul>
|
|
120
|
+
<footer>All times UTC · self-contained, nothing leaves your browser · re-label any time —
|
|
121
|
+
exports are versioned (<code>__METRIC__-<timestamp>.yml</code>), so keep every round in
|
|
122
|
+
<code>incidents/__METRIC__/</code>. Generated by <code>dtk autotune --label</code>.</footer>
|
|
123
|
+
</div>
|
|
124
|
+
<script>
|
|
125
|
+
const DATA = __PAYLOAD__;
|
|
126
|
+
const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
|
|
127
|
+
const N = pts.length;
|
|
128
|
+
const vraw = pts.filter(p => p.v !== null).map(p => p.v);
|
|
129
|
+
const vmin0 = vraw.length ? Math.min.apply(null, vraw) : 0;
|
|
130
|
+
const vmax0 = vraw.length ? Math.max.apply(null, vraw) : 1;
|
|
131
|
+
const vpad = (vmax0 - vmin0) * 0.06 || 1;
|
|
132
|
+
const vmin = vmin0 - vpad, vmax = vmax0 + vpad;
|
|
133
|
+
const tmin = N ? pts[0].ts : 0, tmax = N ? pts[N-1].ts : 1, fullSpan = (tmax - tmin) || 1;
|
|
134
|
+
const step = fullSpan / Math.max(1, N - 1);
|
|
135
|
+
const minSpan = Math.max(step * 8, 1000);
|
|
136
|
+
let viewMin = tmin, viewMax = tmax;
|
|
137
|
+
const incidents = [];
|
|
138
|
+
const c = document.getElementById('c'), ov = document.getElementById('ov');
|
|
139
|
+
const ctx = c.getContext('2d'), octx = ov.getContext('2d');
|
|
140
|
+
const M = {l:56, r:16, t:14, b:30}, OM = {l:56, r:16, t:8, b:8};
|
|
141
|
+
let dpr = 1, hover = null, dragging = null, ovAct = null;
|
|
142
|
+
|
|
143
|
+
const clamp = (x,a,b) => Math.max(a, Math.min(b, x));
|
|
144
|
+
const vspan = () => viewMax - viewMin;
|
|
145
|
+
const plotW = () => c.width - (M.l+M.r)*dpr;
|
|
146
|
+
const plotH = () => c.height - (M.t+M.b)*dpr;
|
|
147
|
+
const px = ts => M.l*dpr + (ts-viewMin)/(vspan()||1)*plotW();
|
|
148
|
+
const py = v => (c.height - M.b*dpr) - (v-vmin)/((vmax-vmin)||1)*plotH();
|
|
149
|
+
const ovWd = () => ov.width - (OM.l+OM.r)*dpr;
|
|
150
|
+
const ovHt = () => ov.height - (OM.t+OM.b)*dpr;
|
|
151
|
+
const ovpx = ts => OM.l*dpr + (ts-tmin)/fullSpan*ovWd();
|
|
152
|
+
const ovpy = v => (ov.height - OM.b*dpr) - (v-vmin)/((vmax-vmin)||1)*ovHt();
|
|
153
|
+
const pad2 = n => String(n).padStart(2,'0');
|
|
154
|
+
const fmtTs = ts => new Date(ts).toISOString().slice(0,19).replace('T',' ');
|
|
155
|
+
const fmtTick = (ts, sp) => { const s = new Date(ts).toISOString();
|
|
156
|
+
return sp < 2*86400000 ? s.slice(5,16).replace('T',' ') : s.slice(5,10); };
|
|
157
|
+
const fmtVal = v => { const a = Math.abs(v); return a>=1000 ? v.toFixed(0) : a>=10 ? v.toFixed(1) : v.toFixed(2); };
|
|
158
|
+
function fmtDur(ms) { const m = Math.round(ms/60000); if (m<60) return m+'m';
|
|
159
|
+
const h = Math.floor(m/60), mm = m%60; if (h<24) return h+'h'+(mm?(' '+mm+'m'):'');
|
|
160
|
+
const d = Math.floor(h/24), hh = h%24; return d+'d'+(hh?(' '+hh+'h'):''); }
|
|
161
|
+
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
162
|
+
const yamlStr = s => '"' + String(s).replace(/\\\\/g,'\\\\\\\\').replace(/"/g,'\\\\"') + '"';
|
|
163
|
+
|
|
164
|
+
function setView(a, b) {
|
|
165
|
+
let s = b - a;
|
|
166
|
+
if (s < minSpan) { const m=(a+b)/2; a=m-minSpan/2; b=m+minSpan/2; s=minSpan; }
|
|
167
|
+
if (s >= fullSpan) { a=tmin; b=tmax; }
|
|
168
|
+
if (a < tmin) { b += tmin-a; a=tmin; }
|
|
169
|
+
if (b > tmax) { a -= b-tmax; b=tmax; }
|
|
170
|
+
viewMin = clamp(a, tmin, tmax); viewMax = clamp(b, tmin, tmax);
|
|
171
|
+
drawAll();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Series drawing with min/max decimation (one envelope segment per pixel column)
|
|
175
|
+
// so a 100k-point series stays fast and spikes stay visible; direct polyline when
|
|
176
|
+
// few points are visible (zoomed in).
|
|
177
|
+
function drawSeries(ctx2, xfn, yfn, lo, hi, leftDev, widthDev, color, lw) {
|
|
178
|
+
const cols = Math.max(1, Math.round(widthDev)), sp = (hi-lo)||1;
|
|
179
|
+
let vis = 0;
|
|
180
|
+
for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<lo||p.ts>hi) continue; vis++; }
|
|
181
|
+
ctx2.strokeStyle = color; ctx2.lineWidth = lw*dpr; ctx2.beginPath();
|
|
182
|
+
if (vis <= cols) {
|
|
183
|
+
let pen = false;
|
|
184
|
+
for (let i=0;i<N;i++) { const p=pts[i];
|
|
185
|
+
if (p.v===null || p.ts<lo || p.ts>hi) { pen=false; continue; }
|
|
186
|
+
const X=xfn(p.ts), Y=yfn(p.v);
|
|
187
|
+
if (!pen) { ctx2.moveTo(X,Y); pen=true; } else ctx2.lineTo(X,Y);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
const cmin = new Array(cols).fill(null), cmax = new Array(cols).fill(null);
|
|
191
|
+
for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<lo||p.ts>hi) continue;
|
|
192
|
+
let col = Math.floor((p.ts-lo)/sp*(cols-1)); col = col<0?0:col>cols-1?cols-1:col;
|
|
193
|
+
if (cmin[col]===null||p.v<cmin[col]) cmin[col]=p.v;
|
|
194
|
+
if (cmax[col]===null||p.v>cmax[col]) cmax[col]=p.v;
|
|
195
|
+
}
|
|
196
|
+
let pen = false;
|
|
197
|
+
for (let col=0;col<cols;col++) { if (cmax[col]===null) { pen=false; continue; }
|
|
198
|
+
const X=leftDev+col, yh=yfn(cmax[col]), yl=yfn(cmin[col]);
|
|
199
|
+
if (!pen) { ctx2.moveTo(X,yh); pen=true; } else ctx2.lineTo(X,yh);
|
|
200
|
+
ctx2.lineTo(X,yl);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
ctx2.stroke();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function draw() {
|
|
207
|
+
ctx.clearRect(0,0,c.width,c.height);
|
|
208
|
+
ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
|
|
209
|
+
ctx.textBaseline = 'middle';
|
|
210
|
+
for (let i=0;i<=4;i++) { const v=vmin+(vmax-vmin)*i/4, yy=py(v);
|
|
211
|
+
ctx.strokeStyle='rgba(255,255,255,0.05)'; ctx.lineWidth=1*dpr;
|
|
212
|
+
ctx.beginPath(); ctx.moveTo(M.l*dpr,yy); ctx.lineTo(c.width-M.r*dpr,yy); ctx.stroke();
|
|
213
|
+
ctx.fillStyle='#6e675b'; ctx.textAlign='right'; ctx.fillText(fmtVal(v),(M.l-8)*dpr,yy); }
|
|
214
|
+
ctx.textBaseline = 'top';
|
|
215
|
+
for (let i=0;i<=5;i++) { const ts=viewMin+vspan()*i/5, xx=px(ts);
|
|
216
|
+
ctx.fillStyle='#6e675b'; ctx.textAlign=i===0?'left':i===5?'right':'center';
|
|
217
|
+
ctx.fillText(fmtTick(ts,vspan()), xx, (c.height-M.b+8)*dpr); }
|
|
218
|
+
ctx.save(); ctx.beginPath(); ctx.rect(M.l*dpr, M.t*dpr, plotW(), plotH()); ctx.clip();
|
|
219
|
+
incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
|
|
220
|
+
ctx.fillStyle='rgba(214,50,50,0.20)'; ctx.fillRect(x0, M.t*dpr, x1-x0, plotH());
|
|
221
|
+
ctx.strokeStyle='rgba(214,50,50,0.55)'; ctx.lineWidth=1*dpr; ctx.strokeRect(x0, M.t*dpr, x1-x0, plotH()); });
|
|
222
|
+
if (dragging) { const x0=px(dragging.a), x1=px(dragging.b);
|
|
223
|
+
ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
|
|
224
|
+
drawSeries(ctx, px, py, viewMin, viewMax, M.l*dpr, plotW(), '#d15b36', 1.5);
|
|
225
|
+
ctx.restore();
|
|
226
|
+
if (hover && !dragging && !ovAct) drawHover();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function drawHover() {
|
|
230
|
+
let best=null, bd=Infinity;
|
|
231
|
+
for (let i=0;i<N;i++) { const p=pts[i]; if (p.v===null||p.ts<viewMin||p.ts>viewMax) continue;
|
|
232
|
+
const d=Math.abs(p.ts-hover.ts); if (d<bd) { bd=d; best=p; } }
|
|
233
|
+
if (!best) return;
|
|
234
|
+
const X=px(best.ts), Y=py(best.v);
|
|
235
|
+
ctx.strokeStyle='rgba(201,194,180,0.25)'; ctx.lineWidth=1*dpr;
|
|
236
|
+
ctx.beginPath(); ctx.moveTo(X, M.t*dpr); ctx.lineTo(X, c.height-M.b*dpr); ctx.stroke();
|
|
237
|
+
ctx.fillStyle='#f5f1e8'; ctx.beginPath(); ctx.arc(X,Y,3*dpr,0,7); ctx.fill();
|
|
238
|
+
const label=fmtTick(best.ts, 0)+' · '+fmtVal(best.v);
|
|
239
|
+
ctx.font=(11*dpr)+'px ui-monospace, monospace'; const tw=ctx.measureText(label).width;
|
|
240
|
+
let bx=X+8*dpr; if (bx+tw+12*dpr > c.width) bx=X-tw-20*dpr;
|
|
241
|
+
ctx.fillStyle='rgba(27,25,22,0.92)'; ctx.strokeStyle='#332f29';
|
|
242
|
+
ctx.fillRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr); ctx.strokeRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr);
|
|
243
|
+
ctx.fillStyle='#c9c2b4'; ctx.textAlign='left'; ctx.textBaseline='middle';
|
|
244
|
+
ctx.fillText(label, bx+6*dpr, M.t*dpr+12);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function drawOverview() {
|
|
248
|
+
octx.clearRect(0,0,ov.width,ov.height);
|
|
249
|
+
octx.save(); octx.beginPath(); octx.rect(OM.l*dpr, OM.t*dpr, ovWd(), ovHt()); octx.clip();
|
|
250
|
+
incidents.forEach(iv => { const x0=ovpx(iv.a), x1=ovpx(iv.b);
|
|
251
|
+
octx.fillStyle='rgba(214,50,50,0.30)'; octx.fillRect(x0, OM.t*dpr, x1-x0, ovHt()); });
|
|
252
|
+
drawSeries(octx, ovpx, ovpy, tmin, tmax, OM.l*dpr, ovWd(), 'rgba(209,91,54,0.7)', 1.1);
|
|
253
|
+
octx.restore();
|
|
254
|
+
const vx0=ovpx(viewMin), vx1=ovpx(viewMax);
|
|
255
|
+
octx.fillStyle='rgba(27,25,22,0.55)';
|
|
256
|
+
octx.fillRect(OM.l*dpr, OM.t*dpr, vx0-OM.l*dpr, ovHt());
|
|
257
|
+
octx.fillRect(vx1, OM.t*dpr, (ov.width-OM.r*dpr)-vx1, ovHt());
|
|
258
|
+
octx.fillStyle='rgba(245,241,232,0.06)'; octx.fillRect(vx0, OM.t*dpr, vx1-vx0, ovHt());
|
|
259
|
+
octx.strokeStyle='#d15b36'; octx.lineWidth=1.5*dpr;
|
|
260
|
+
octx.strokeRect(vx0, OM.t*dpr+1, vx1-vx0, ovHt()-2);
|
|
261
|
+
octx.fillStyle='#d15b36'; const hy=OM.t*dpr+ovHt()/2-9*dpr;
|
|
262
|
+
octx.fillRect(vx0-2*dpr, hy, 4*dpr, 18*dpr); octx.fillRect(vx1-2*dpr, hy, 4*dpr, 18*dpr);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const tsAt = clientX => { const r=c.getBoundingClientRect();
|
|
266
|
+
const fr=((clientX-r.left)-M.l)/((r.width-(M.l+M.r))||1); return viewMin + clamp(fr,0,1)*vspan(); };
|
|
267
|
+
const ovTsAtCss = clientX => { const r=ov.getBoundingClientRect();
|
|
268
|
+
const fr=((clientX-r.left)-OM.l)/((r.width-(OM.l+OM.r))||1); return tmin + clamp(fr,0,1)*fullSpan; };
|
|
269
|
+
const ovEdgeCss = ts => { const r=ov.getBoundingClientRect();
|
|
270
|
+
return r.left + OM.l + (ts-tmin)/fullSpan*(r.width-(OM.l+OM.r)); };
|
|
271
|
+
|
|
272
|
+
c.addEventListener('wheel', e => { e.preventDefault(); const t=tsAt(e.clientX);
|
|
273
|
+
let s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan);
|
|
274
|
+
const f=(t-viewMin)/(vspan()||1); setView(t-f*s, t-f*s+s); }, {passive:false});
|
|
275
|
+
c.addEventListener('mousedown', e => { dragging={a:tsAt(e.clientX), b:tsAt(e.clientX), sx:e.clientX, cx:e.clientX}; });
|
|
276
|
+
c.addEventListener('mousemove', e => { if (ovAct) return;
|
|
277
|
+
if (dragging) { dragging.b=tsAt(e.clientX); dragging.cx=e.clientX; } else { hover={ts:tsAt(e.clientX)}; } draw(); });
|
|
278
|
+
c.addEventListener('mouseleave', () => { if (!dragging) { hover=null; draw(); } });
|
|
279
|
+
|
|
280
|
+
ov.addEventListener('mousedown', e => { e.preventDefault(); ov.style.cursor='grabbing';
|
|
281
|
+
const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax), x=e.clientX, H=8;
|
|
282
|
+
if (Math.abs(x-xl)<=H) ovAct={type:'l'};
|
|
283
|
+
else if (Math.abs(x-xr)<=H) ovAct={type:'r'};
|
|
284
|
+
else if (x>xl && x<xr) ovAct={type:'pan', grab:ovTsAtCss(x), vMin:viewMin, vMax:viewMax};
|
|
285
|
+
else { const t=ovTsAtCss(x), s=vspan(); setView(t-s/2, t+s/2); ovAct={type:'pan', grab:t, vMin:viewMin, vMax:viewMax}; }
|
|
286
|
+
});
|
|
287
|
+
ov.addEventListener('mousemove', e => { if (ovAct) return; const x=e.clientX, H=8;
|
|
288
|
+
const xl=ovEdgeCss(viewMin), xr=ovEdgeCss(viewMax);
|
|
289
|
+
ov.style.cursor = (Math.abs(x-xl)<=H || Math.abs(x-xr)<=H) ? 'ew-resize' : (x>xl && x<xr) ? 'grab' : 'pointer'; });
|
|
290
|
+
ov.addEventListener('wheel', e => { e.preventDefault(); const t=ovTsAtCss(e.clientX);
|
|
291
|
+
const s=clamp(vspan()*Math.pow(1.0015, e.deltaY), minSpan, fullSpan); setView(t-s/2, t+s/2); }, {passive:false});
|
|
292
|
+
|
|
293
|
+
window.addEventListener('mousemove', e => { if (!ovAct) return; const t=ovTsAtCss(e.clientX);
|
|
294
|
+
if (ovAct.type==='l') setView(Math.min(t, viewMax-minSpan), viewMax);
|
|
295
|
+
else if (ovAct.type==='r') setView(viewMin, Math.max(t, viewMin+minSpan));
|
|
296
|
+
else { const d=t-ovAct.grab; setView(ovAct.vMin+d, ovAct.vMax+d); } });
|
|
297
|
+
window.addEventListener('mouseup', () => {
|
|
298
|
+
if (ovAct) { ovAct=null; return; }
|
|
299
|
+
if (!dragging) return;
|
|
300
|
+
if (Math.abs(dragging.cx-dragging.sx) > 4) {
|
|
301
|
+
const a=clamp(Math.min(dragging.a,dragging.b),tmin,tmax), b=clamp(Math.max(dragging.a,dragging.b),tmin,tmax);
|
|
302
|
+
incidents.push({a, b, label:''});
|
|
303
|
+
}
|
|
304
|
+
dragging=null; render();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
document.getElementById('zreset').onclick = () => setView(tmin, tmax);
|
|
308
|
+
c.addEventListener('dblclick', () => setView(tmin, tmax));
|
|
309
|
+
document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
|
|
310
|
+
window.setLabel = (i, val) => { if (incidents[i]) incidents[i].label = val; };
|
|
311
|
+
window.rm = i => { incidents.splice(i,1); render(); };
|
|
312
|
+
|
|
313
|
+
function render() {
|
|
314
|
+
incidents.sort((p,q)=>p.a-q.a);
|
|
315
|
+
const list=document.getElementById('list');
|
|
316
|
+
list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
|
|
317
|
+
+'<span class="span">'+fmtTs(iv.a)+' → '+fmtTs(iv.b)+'</span>'
|
|
318
|
+
+'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
|
|
319
|
+
+'<input class="desc" type="text" placeholder="describe this incident (optional)" '
|
|
320
|
+
+'value="'+esc(iv.label||'')+'" oninput="setLabel('+i+', this.value)">'
|
|
321
|
+
+'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
|
|
322
|
+
document.getElementById('empty').style.display = incidents.length ? 'none' : '';
|
|
323
|
+
const total=incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
|
|
324
|
+
document.getElementById('summary').innerHTML = incidents.length
|
|
325
|
+
? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total' : '';
|
|
326
|
+
drawAll();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
document.getElementById('export').onclick = () => {
|
|
330
|
+
const d=new Date();
|
|
331
|
+
const stamp=d.getUTCFullYear()+pad2(d.getUTCMonth()+1)+pad2(d.getUTCDate())+'T'
|
|
332
|
+
+pad2(d.getUTCHours())+pad2(d.getUTCMinutes())+pad2(d.getUTCSeconds())+'Z';
|
|
333
|
+
let y='metric: __METRIC__\\ntimezone: UTC\\nincidents:\\n';
|
|
334
|
+
const sorted=incidents.slice().sort((p,q)=>p.a-q.a);
|
|
335
|
+
if (!sorted.length) y+=' []\\n';
|
|
336
|
+
sorted.forEach(iv => { y+=' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"'
|
|
337
|
+
+ (iv.label && iv.label.trim() ? ', label: '+yamlStr(iv.label.trim()) : '') + '}\\n'; });
|
|
338
|
+
const blob=new Blob([y], {type:'text/yaml'}); const a=document.createElement('a');
|
|
339
|
+
a.href=URL.createObjectURL(blob); a.download='__METRIC__-'+stamp+'.yml'; a.click();
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
function drawAll() { draw(); drawOverview();
|
|
343
|
+
document.getElementById('range').textContent =
|
|
344
|
+
'viewing ' + fmtTs(viewMin) + ' → ' + fmtTs(viewMax) + ' · ' + fmtDur(vspan()) + ' of ' + fmtDur(fullSpan); }
|
|
345
|
+
function fit() { dpr = window.devicePixelRatio || 1;
|
|
346
|
+
c.width=c.clientWidth*dpr; c.height=c.clientHeight*dpr;
|
|
347
|
+
ov.width=ov.clientWidth*dpr; ov.height=ov.clientHeight*dpr; drawAll(); }
|
|
348
|
+
window.addEventListener('resize', fit); fit(); render();
|
|
349
|
+
</script>
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def render_labeler_html(metric_name: str, data: dict[str, np.ndarray]) -> str:
|
|
354
|
+
"""Return a self-contained HTML labeler page for *metric_name*'s series."""
|
|
355
|
+
timestamps = data["timestamp"]
|
|
356
|
+
values = data["value"]
|
|
357
|
+
points = []
|
|
358
|
+
for i in range(len(timestamps)):
|
|
359
|
+
v = values[i]
|
|
360
|
+
points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
|
|
361
|
+
payload = json_dumps_sorted({"metric": metric_name, "points": points})
|
|
362
|
+
return _TEMPLATE.replace("__PAYLOAD__", payload).replace("__METRIC__", metric_name)
|