detectkit 0.19.0__tar.gz → 0.21.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.19.0/detectkit.egg-info → detectkit-0.21.0}/PKG-INFO +1 -1
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/__init__.py +1 -1
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/autotuner.py +6 -1
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/config_emitter.py +8 -3
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/detector_select.py +16 -10
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/grid_search.py +19 -4
- detectkit-0.21.0/detectkit/autotune/html_labeler.py +219 -0
- detectkit-0.21.0/detectkit/autotune/scoring.py +330 -0
- detectkit-0.21.0/detectkit/autotune/seasonality_search.py +216 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/settings.py +12 -5
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/window_select.py +14 -6
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/CLAUDE.section.md +11 -0
- detectkit-0.21.0/detectkit/cli/assets/claude/rules/autotune.md +181 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +91 -19
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +9 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/autotune.py +15 -1
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/init.py +64 -2
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/metric_config.py +70 -1
- {detectkit-0.19.0 → detectkit-0.21.0/detectkit.egg-info}/PKG-INFO +1 -1
- detectkit-0.19.0/detectkit/autotune/html_labeler.py +0 -103
- detectkit-0.19.0/detectkit/autotune/scoring.py +0 -177
- detectkit-0.19.0/detectkit/autotune/seasonality_search.py +0 -135
- detectkit-0.19.0/detectkit/cli/assets/claude/rules/autotune.md +0 -128
- {detectkit-0.19.0 → detectkit-0.21.0}/LICENSE +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/MANIFEST.in +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/README.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/core/models.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/pyproject.toml +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/requirements.txt +0 -0
- {detectkit-0.19.0 → detectkit-0.21.0}/setup.cfg +0 -0
- {detectkit-0.19.0 → detectkit-0.21.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.21.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,219 @@
|
|
|
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 canvas line chart where the user click-drags over incident
|
|
5
|
+
spans and exports a labels file in the canonical schema, which is then fed back
|
|
6
|
+
via ``dtk autotune --select <metric> --incidents <file>``.
|
|
7
|
+
|
|
8
|
+
Docs sync: the autotune reference page embeds a *live* copy of this output
|
|
9
|
+
(``docs/examples/autotune-labeler.html``) so the site always shows the real UI.
|
|
10
|
+
After changing the template below, regenerate that example so the docs don't
|
|
11
|
+
drift: ``python website/scripts/gen-labeler-example.py`` (also in the release
|
|
12
|
+
checklist).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from detectkit.utils.json_utils import json_dumps_sorted
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ts_to_str(ts64: np.datetime64) -> str:
|
|
25
|
+
ms = int(ts64.astype("datetime64[ms]").astype(np.int64))
|
|
26
|
+
return (datetime(1970, 1, 1) + timedelta(milliseconds=ms)).strftime("%Y-%m-%d %H:%M:%S")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Built with .replace() (not .format()), so braces are literal — keep them single.
|
|
30
|
+
# Self-contained: inline brand styling/logo/JS, no network. Palette + fonts mirror
|
|
31
|
+
# website/src/styles/brand.css (.claude/rules/design.md); incident bands use the
|
|
32
|
+
# anomaly status color, the drag preview the no-data color.
|
|
33
|
+
_TEMPLATE = """<!doctype html>
|
|
34
|
+
<meta charset="utf-8">
|
|
35
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
36
|
+
<title>detectkit · label incidents · __METRIC__</title>
|
|
37
|
+
<style>
|
|
38
|
+
:root {
|
|
39
|
+
--clay:#d15b36; --clay-700:#b4471f; --paper:#f5f1e8; --muted:#6e675b; --faint:#9a9384;
|
|
40
|
+
--term-bg:#211e1a; --term-surface:#1b1916; --term-border:#332f29; --term-text:#c9c2b4;
|
|
41
|
+
--anomaly:#d63232; --nodata:#f0ad4e;
|
|
42
|
+
--ui:'Schibsted Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
|
|
43
|
+
--mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
|
|
44
|
+
}
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
body { font-family: var(--ui); margin: 0; background: var(--term-bg); color: var(--term-text);
|
|
47
|
+
-webkit-font-smoothing: antialiased; }
|
|
48
|
+
.shell { max-width: 1080px; margin: 0 auto; padding: 22px 22px 40px; }
|
|
49
|
+
.brand { display:flex; align-items:center; gap:9px; margin-bottom: 14px; }
|
|
50
|
+
.brand svg { width: 26px; height: 26px; border-radius: 7px; display:block; }
|
|
51
|
+
.brand b { color: var(--paper); font-weight: 600; font-size: 15px; letter-spacing: .2px; }
|
|
52
|
+
.brand span { color: var(--faint); font-size: 12px; }
|
|
53
|
+
h1 { font-size: 18px; line-height: 1.3; margin: 0 0 6px; color: var(--paper); font-weight: 600; }
|
|
54
|
+
h1 code { color: var(--clay); font-family: var(--mono); font-size: .82em; }
|
|
55
|
+
.hint { color: var(--faint); font-size: 13px; margin: 0 0 18px; line-height: 1.5; }
|
|
56
|
+
.hint code { color: var(--term-text); font-family: var(--mono); font-size: 12px;
|
|
57
|
+
background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 5px; padding: 1px 6px; }
|
|
58
|
+
.toolbar { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom: 12px; }
|
|
59
|
+
button { font-family: var(--ui); font-size: 13px; font-weight: 500; border: 0; border-radius: 7px;
|
|
60
|
+
padding: 9px 15px; cursor: pointer; transition: background .12s ease, border-color .12s ease; }
|
|
61
|
+
button.primary { background: var(--clay); color: #fff; }
|
|
62
|
+
button.primary:hover { background: var(--clay-700); }
|
|
63
|
+
button.ghost { background: transparent; color: var(--term-text); border: 1px solid var(--term-border); }
|
|
64
|
+
button.ghost:hover { border-color: var(--faint); color: var(--paper); }
|
|
65
|
+
.summary { margin-left: auto; color: var(--faint); font-size: 12.5px; font-family: var(--mono); }
|
|
66
|
+
.summary b { color: var(--clay); font-weight: 600; }
|
|
67
|
+
canvas { width: 100%; height: clamp(320px, 46vh, 520px); display:block;
|
|
68
|
+
background: var(--term-surface); border: 1px solid var(--term-border); border-radius: 10px; cursor: crosshair; }
|
|
69
|
+
.empty { color: var(--faint); font-size: 13px; margin: 16px 2px; font-style: italic; }
|
|
70
|
+
ul { list-style: none; margin: 14px 0 0; padding: 0; }
|
|
71
|
+
li { display:flex; align-items:center; gap:12px; padding: 9px 12px; font-size: 13px;
|
|
72
|
+
border: 1px solid var(--term-border); border-radius: 8px; margin-bottom: 7px; background: var(--term-surface); }
|
|
73
|
+
li .dot { width:9px; height:9px; border-radius:50%; background: var(--anomaly); flex: 0 0 auto; }
|
|
74
|
+
li .span { font-family: var(--mono); color: var(--term-text); }
|
|
75
|
+
li .dur { color: var(--faint); font-size: 12px; }
|
|
76
|
+
li button { margin-left: auto; padding: 5px 11px; font-size: 12px; }
|
|
77
|
+
footer { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--term-border);
|
|
78
|
+
color: var(--faint); font-size: 12px; }
|
|
79
|
+
footer code { font-family: var(--mono); color: var(--term-text); }
|
|
80
|
+
</style>
|
|
81
|
+
<div class="shell">
|
|
82
|
+
<div class="brand">
|
|
83
|
+
<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>
|
|
84
|
+
<b>detectkit</b><span>· incident labeler</span>
|
|
85
|
+
</div>
|
|
86
|
+
<h1>Label incidents — <code>__METRIC__</code></h1>
|
|
87
|
+
<p class="hint">Click-drag across the chart to mark each real incident, then <b>Export</b> and run
|
|
88
|
+
<code>dtk autotune --select __METRIC__ --incidents incidents-__METRIC__.yml</code></p>
|
|
89
|
+
<div class="toolbar">
|
|
90
|
+
<button id="export" class="primary">Export incidents-__METRIC__.yml</button>
|
|
91
|
+
<button id="clear" class="ghost">Clear all</button>
|
|
92
|
+
<span id="summary" class="summary"></span>
|
|
93
|
+
</div>
|
|
94
|
+
<canvas id="c" aria-label="metric series chart — drag horizontally to mark an incident span"></canvas>
|
|
95
|
+
<div id="empty" class="empty">No incidents marked yet — drag across a span on the chart above.</div>
|
|
96
|
+
<ul id="list"></ul>
|
|
97
|
+
<footer>All times UTC · self-contained, no data leaves your browser · generated by
|
|
98
|
+
<code>dtk autotune --label</code></footer>
|
|
99
|
+
</div>
|
|
100
|
+
<script>
|
|
101
|
+
const DATA = __PAYLOAD__;
|
|
102
|
+
const pts = DATA.points.map(p => ({t: p.t, ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
|
|
103
|
+
const incidents = [];
|
|
104
|
+
const c = document.getElementById('c');
|
|
105
|
+
const ctx = c.getContext('2d');
|
|
106
|
+
const vals = pts.map(p => p.v).filter(v => v !== null);
|
|
107
|
+
const vmin = vals.length ? Math.min.apply(null, vals) : 0;
|
|
108
|
+
const vmax = vals.length ? Math.max.apply(null, vals) : 1;
|
|
109
|
+
const tmin = pts.length ? pts[0].ts : 0;
|
|
110
|
+
const tmax = pts.length ? pts[pts.length-1].ts : 1;
|
|
111
|
+
const M = {l: 56, r: 16, t: 14, b: 30}; // css-px plot margins
|
|
112
|
+
let dpr = 1, hover = null, dragging = null;
|
|
113
|
+
function plotW() { return c.width - (M.l + M.r) * dpr; }
|
|
114
|
+
function plotH() { return c.height - (M.t + M.b) * dpr; }
|
|
115
|
+
function px(ts) { return M.l*dpr + (ts - tmin) / ((tmax - tmin) || 1) * plotW(); }
|
|
116
|
+
function py(v) { return (c.height - M.b*dpr) - (v - vmin) / ((vmax - vmin) || 1) * plotH(); }
|
|
117
|
+
function tsAt(clientX) { const r=c.getBoundingClientRect();
|
|
118
|
+
const fr=((clientX-r.left)-M.l)/((r.width-(M.l+M.r))||1); return tmin + Math.max(0,Math.min(1,fr))*(tmax-tmin); }
|
|
119
|
+
function fmtTs(ts) { return new Date(ts).toISOString().slice(0,19).replace('T',' '); }
|
|
120
|
+
function fmtAxis(ts) { return new Date(ts).toISOString().slice(5,16).replace('T',' '); }
|
|
121
|
+
function fmtVal(v) { const a=Math.abs(v); return a>=1000 ? v.toFixed(0) : a>=10 ? v.toFixed(1) : v.toFixed(2); }
|
|
122
|
+
function fmtDur(ms) { const m=Math.round(ms/60000); if(m<60) return m+'m';
|
|
123
|
+
const h=Math.floor(m/60), mm=m%60; if(h<24) return h+'h'+(mm?(' '+mm+'m'):'');
|
|
124
|
+
const d=Math.floor(h/24), hh=h%24; return d+'d'+(hh?(' '+hh+'h'):''); }
|
|
125
|
+
function fit() { dpr = window.devicePixelRatio || 1;
|
|
126
|
+
c.width = c.clientWidth * dpr; c.height = c.clientHeight * dpr; draw(); }
|
|
127
|
+
function draw() {
|
|
128
|
+
ctx.clearRect(0,0,c.width,c.height);
|
|
129
|
+
ctx.font = (11*dpr)+'px ui-sans-serif, system-ui, sans-serif';
|
|
130
|
+
// y gridlines + value ticks
|
|
131
|
+
ctx.textBaseline = 'middle';
|
|
132
|
+
for (let i=0;i<=4;i++) { const v=vmin+(vmax-vmin)*i/4, yy=py(v);
|
|
133
|
+
ctx.strokeStyle='rgba(255,255,255,0.05)'; ctx.lineWidth=1*dpr; ctx.beginPath();
|
|
134
|
+
ctx.moveTo(M.l*dpr, yy); ctx.lineTo(c.width-M.r*dpr, yy); ctx.stroke();
|
|
135
|
+
ctx.fillStyle='#6e675b'; ctx.textAlign='right'; ctx.fillText(fmtVal(v), (M.l-8)*dpr, yy); }
|
|
136
|
+
// x ticks
|
|
137
|
+
ctx.textBaseline='top';
|
|
138
|
+
for (let i=0;i<=5;i++) { const ts=tmin+(tmax-tmin)*i/5, xx=px(ts);
|
|
139
|
+
ctx.fillStyle='#6e675b'; ctx.textAlign = i===0?'left':i===5?'right':'center';
|
|
140
|
+
ctx.fillText(fmtAxis(ts), xx, (c.height-M.b+8)*dpr); }
|
|
141
|
+
// committed incident bands
|
|
142
|
+
incidents.forEach(iv => { const x0=px(iv.a), x1=px(iv.b);
|
|
143
|
+
ctx.fillStyle='rgba(214,50,50,0.20)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH());
|
|
144
|
+
ctx.strokeStyle='rgba(214,50,50,0.55)'; ctx.lineWidth=1*dpr;
|
|
145
|
+
ctx.strokeRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); });
|
|
146
|
+
// drag preview
|
|
147
|
+
if (dragging) { const x0=px(dragging.a), x1=px(dragging.b);
|
|
148
|
+
ctx.fillStyle='rgba(240,173,78,0.28)'; ctx.fillRect(Math.min(x0,x1), M.t*dpr, Math.abs(x1-x0), plotH()); }
|
|
149
|
+
// series line
|
|
150
|
+
ctx.strokeStyle='#d15b36'; ctx.lineWidth=1.5*dpr; ctx.beginPath();
|
|
151
|
+
let started=false;
|
|
152
|
+
pts.forEach(p => { if (p.v===null) { started=false; return; } const X=px(p.ts), Y=py(p.v);
|
|
153
|
+
if (!started) { ctx.moveTo(X,Y); started=true; } else ctx.lineTo(X,Y); });
|
|
154
|
+
ctx.stroke();
|
|
155
|
+
// hover crosshair + tooltip
|
|
156
|
+
if (hover && !dragging) drawHover();
|
|
157
|
+
}
|
|
158
|
+
function drawHover() {
|
|
159
|
+
let best=null, bd=Infinity;
|
|
160
|
+
for (const p of pts) { if (p.v===null) continue; const d=Math.abs(p.ts-hover.ts);
|
|
161
|
+
if (d<bd) { bd=d; best=p; } }
|
|
162
|
+
if (!best) return;
|
|
163
|
+
const X=px(best.ts), Y=py(best.v);
|
|
164
|
+
ctx.strokeStyle='rgba(201,194,180,0.25)'; ctx.lineWidth=1*dpr; ctx.beginPath();
|
|
165
|
+
ctx.moveTo(X, M.t*dpr); ctx.lineTo(X, c.height-M.b*dpr); ctx.stroke();
|
|
166
|
+
ctx.fillStyle='#f5f1e8'; ctx.beginPath(); ctx.arc(X,Y,3*dpr,0,7); ctx.fill();
|
|
167
|
+
const label=fmtAxis(best.ts)+' · '+fmtVal(best.v);
|
|
168
|
+
ctx.font=(11*dpr)+'px ui-monospace, monospace'; const tw=ctx.measureText(label).width;
|
|
169
|
+
let bx=X+8*dpr; if (bx+tw+12*dpr > c.width) bx=X-tw-20*dpr;
|
|
170
|
+
ctx.fillStyle='rgba(27,25,22,0.92)'; ctx.strokeStyle='#332f29';
|
|
171
|
+
ctx.fillRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr); ctx.strokeRect(bx, M.t*dpr+2, tw+12*dpr, 20*dpr);
|
|
172
|
+
ctx.fillStyle='#c9c2b4'; ctx.textAlign='left'; ctx.textBaseline='middle';
|
|
173
|
+
ctx.fillText(label, bx+6*dpr, M.t*dpr+12);
|
|
174
|
+
}
|
|
175
|
+
c.addEventListener('mousedown', e => { dragging={a:tsAt(e.clientX), b:tsAt(e.clientX)}; });
|
|
176
|
+
c.addEventListener('mousemove', e => { if (dragging) { dragging.b=tsAt(e.clientX); }
|
|
177
|
+
else { hover={ts:tsAt(e.clientX)}; } draw(); });
|
|
178
|
+
c.addEventListener('mouseleave', () => { hover=null; draw(); });
|
|
179
|
+
window.addEventListener('mouseup', () => { if(!dragging) return;
|
|
180
|
+
if (Math.abs(dragging.b-dragging.a) > 1000) incidents.push({a:Math.min(dragging.a,dragging.b), b:Math.max(dragging.a,dragging.b)});
|
|
181
|
+
dragging=null; render(); });
|
|
182
|
+
function render() {
|
|
183
|
+
const list=document.getElementById('list');
|
|
184
|
+
incidents.sort((p,q)=>p.a-q.a);
|
|
185
|
+
list.innerHTML = incidents.map((iv,i)=>'<li><span class="dot"></span>'
|
|
186
|
+
+'<span class="span">'+fmtTs(iv.a)+' → '+fmtTs(iv.b)+'</span>'
|
|
187
|
+
+'<span class="dur">'+fmtDur(iv.b-iv.a)+'</span>'
|
|
188
|
+
+'<button class="ghost" onclick="rm('+i+')">remove</button></li>').join('');
|
|
189
|
+
document.getElementById('empty').style.display = incidents.length ? 'none' : '';
|
|
190
|
+
const total = incidents.reduce((s,iv)=>s+(iv.b-iv.a),0);
|
|
191
|
+
document.getElementById('summary').innerHTML = incidents.length
|
|
192
|
+
? '<b>'+incidents.length+'</b> incident'+(incidents.length>1?'s':'')+' · '+fmtDur(total)+' total'
|
|
193
|
+
: '';
|
|
194
|
+
draw();
|
|
195
|
+
}
|
|
196
|
+
function rm(i) { incidents.splice(i,1); render(); }
|
|
197
|
+
document.getElementById('clear').onclick = () => { incidents.length=0; render(); };
|
|
198
|
+
document.getElementById('export').onclick = () => {
|
|
199
|
+
let y = 'metric: __METRIC__\\ntimezone: UTC\\nincidents:\\n';
|
|
200
|
+
if (!incidents.length) y += ' []\\n';
|
|
201
|
+
incidents.forEach(iv => { y += ' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"}\\n'; });
|
|
202
|
+
const blob = new Blob([y], {type:'text/yaml'}); const a=document.createElement('a');
|
|
203
|
+
a.href = URL.createObjectURL(blob); a.download = 'incidents-__METRIC__.yml'; a.click();
|
|
204
|
+
};
|
|
205
|
+
window.addEventListener('resize', fit); fit(); render();
|
|
206
|
+
</script>
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def render_labeler_html(metric_name: str, data: dict[str, np.ndarray]) -> str:
|
|
211
|
+
"""Return a self-contained HTML labeler page for *metric_name*'s series."""
|
|
212
|
+
timestamps = data["timestamp"]
|
|
213
|
+
values = data["value"]
|
|
214
|
+
points = []
|
|
215
|
+
for i in range(len(timestamps)):
|
|
216
|
+
v = values[i]
|
|
217
|
+
points.append({"t": _ts_to_str(timestamps[i]), "v": None if np.isnan(v) else float(v)})
|
|
218
|
+
payload = json_dumps_sorted({"metric": metric_name, "points": points})
|
|
219
|
+
return _TEMPLATE.replace("__PAYLOAD__", payload).replace("__METRIC__", metric_name)
|