detectkit 0.33.0__tar.gz → 0.35.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.33.0/detectkit.egg-info → detectkit-0.35.0}/PKG-INFO +1 -1
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/__init__.py +1 -1
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/base.py +39 -8
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/email.py +6 -4
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/telegram.py +10 -5
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/webhook.py +8 -6
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/label_server.py +6 -21
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/labels.py +41 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/alerting.md +15 -10
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/cli.md +14 -3
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/tune.py +25 -0
- detectkit-0.35.0/detectkit/reporting/assets/report.js +80 -0
- detectkit-0.35.0/detectkit/tuning/assets/tune.js +117 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/tuning/payload.py +11 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/tuning/server.py +68 -14
- {detectkit-0.33.0 → detectkit-0.35.0/detectkit.egg-info}/PKG-INFO +1 -1
- detectkit-0.33.0/detectkit/reporting/assets/report.js +0 -77
- detectkit-0.33.0/detectkit/tuning/assets/tune.js +0 -82
- {detectkit-0.33.0 → detectkit-0.35.0}/LICENSE +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/MANIFEST.in +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/README.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/html_labeler.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/core/models.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/pyproject.toml +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/requirements.txt +0 -0
- {detectkit-0.33.0 → detectkit-0.35.0}/setup.cfg +0 -0
- {detectkit-0.33.0 → detectkit-0.35.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.35.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -183,12 +183,16 @@ class BaseAlertChannel(ABC):
|
|
|
183
183
|
- {interval_display} — metric interval as a string (e.g. "10min")
|
|
184
184
|
- {duration_display} — how long the streak/incident lasted
|
|
185
185
|
(e.g. "2h 30m"; "over …" when it predates the lookback window)
|
|
186
|
-
- {onset_display} / {started_display} — first timestamp of
|
|
187
|
-
({started_display} adds "or earlier" when the run is capped)
|
|
186
|
+
- {onset_display} / {started_display} — first anomalous timestamp of
|
|
187
|
+
the run ({started_display} adds "or earlier" when the run is capped)
|
|
188
|
+
- {fired_display} — on-grid moment the alert first fired
|
|
189
|
+
(onset + (consecutive_required − 1) × interval); empty when the run
|
|
190
|
+
is capped or timing isn't wired in
|
|
188
191
|
- {anomaly_lead} / {recovery_lead} — the ready-made plain-language
|
|
189
192
|
lead sentence ("Anomalous for …" / "… Incident lasted …")
|
|
190
|
-
- {window_line} — "
|
|
191
|
-
"
|
|
193
|
+
- {window_line} — "Anomaly began: … | Latest reading: …" (anomaly) /
|
|
194
|
+
"Anomaly began: … | Alert fired: … | Recovered: …" (recovery), or a
|
|
195
|
+
single "Detected at: …" line when the onset is unknown
|
|
192
196
|
- {severity}
|
|
193
197
|
- {status}
|
|
194
198
|
|
|
@@ -249,7 +253,7 @@ class BaseAlertChannel(ABC):
|
|
|
249
253
|
templates use, plus a few extras (``dashboard_url``, ``dashboard_line``).
|
|
250
254
|
"""
|
|
251
255
|
import math
|
|
252
|
-
from datetime import datetime
|
|
256
|
+
from datetime import datetime, timedelta
|
|
253
257
|
from zoneinfo import ZoneInfo
|
|
254
258
|
|
|
255
259
|
import numpy as np
|
|
@@ -269,6 +273,14 @@ class BaseAlertChannel(ABC):
|
|
|
269
273
|
return f"{t.strftime('%Y-%m-%d %H:%M:%S')} ({alert_data.timezone})"
|
|
270
274
|
return t.strftime("%Y-%m-%d %H:%M:%S")
|
|
271
275
|
|
|
276
|
+
def _shift_ts(value: Any, seconds: int) -> Any:
|
|
277
|
+
"""Shift a timestamp forward by *seconds*, preserving its type."""
|
|
278
|
+
if isinstance(value, np.datetime64):
|
|
279
|
+
return value + np.timedelta64(int(seconds), "s")
|
|
280
|
+
if isinstance(value, datetime):
|
|
281
|
+
return value + timedelta(seconds=int(seconds))
|
|
282
|
+
return value
|
|
283
|
+
|
|
272
284
|
ts_str = _fmt_ts(alert_data.timestamp)
|
|
273
285
|
onset_str = _fmt_ts(alert_data.onset_timestamp)
|
|
274
286
|
|
|
@@ -359,13 +371,31 @@ class BaseAlertChannel(ABC):
|
|
|
359
371
|
"expected bounds."
|
|
360
372
|
)
|
|
361
373
|
|
|
374
|
+
# "Alert fired" — the on-grid moment the rule's consecutive threshold was
|
|
375
|
+
# first met: onset + (consecutive_required - 1) * interval. Recovery
|
|
376
|
+
# messages render it as the middle of the "anomaly began → alert fired →
|
|
377
|
+
# recovered" timeline so a stakeholder can tell the onset apart from when
|
|
378
|
+
# detectkit actually notified. Skipped when the run is capped (onset is
|
|
379
|
+
# only a lower bound, so the fire time is unknown) or timing isn't wired
|
|
380
|
+
# in (direct-API callers). The firing message doesn't show it: there it
|
|
381
|
+
# coincides with the latest point the alert is firing on.
|
|
382
|
+
fired_display = ""
|
|
383
|
+
if alert_data.onset_timestamp is not None and interval_seconds and not capped:
|
|
384
|
+
req = (
|
|
385
|
+
consecutive_required if (consecutive_required and consecutive_required >= 1) else 1
|
|
386
|
+
)
|
|
387
|
+
fired_ts = _shift_ts(alert_data.onset_timestamp, (req - 1) * interval_seconds)
|
|
388
|
+
fired_display = _fmt_ts(fired_ts)
|
|
389
|
+
|
|
362
390
|
# Kind-aware "window" line for the plain-text templates: the anomalous
|
|
363
|
-
# span (
|
|
391
|
+
# span (began → latest, or began → fired → recovered) when known, else
|
|
392
|
+
# the single point.
|
|
364
393
|
kind = self.status_kind(alert_data)
|
|
365
394
|
if started_display and kind == "anomaly":
|
|
366
|
-
window_line = f"
|
|
395
|
+
window_line = f"Anomaly began: {started_display} | Latest reading: {ts_str}\n"
|
|
367
396
|
elif started_display and kind == "recovery":
|
|
368
|
-
|
|
397
|
+
fired_part = f"Alert fired: {fired_display} | " if fired_display else ""
|
|
398
|
+
window_line = f"Anomaly began: {started_display} | {fired_part}Recovered: {ts_str}\n"
|
|
369
399
|
else:
|
|
370
400
|
window_label = {
|
|
371
401
|
"recovery": "Cleared at",
|
|
@@ -443,6 +473,7 @@ class BaseAlertChannel(ABC):
|
|
|
443
473
|
"streak_capped": capped,
|
|
444
474
|
"onset_display": onset_str,
|
|
445
475
|
"started_display": started_display,
|
|
476
|
+
"fired_display": fired_display,
|
|
446
477
|
"anomaly_lead": anomaly_lead,
|
|
447
478
|
"recovery_lead": recovery_lead,
|
|
448
479
|
"window_line": window_line,
|
|
@@ -326,8 +326,8 @@ class EmailChannel(BaseAlertChannel):
|
|
|
326
326
|
("Quorum", f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}"),
|
|
327
327
|
]
|
|
328
328
|
if ctx["started_display"]:
|
|
329
|
-
stats.append(("
|
|
330
|
-
stats.append(("Latest", ctx["timestamp"]))
|
|
329
|
+
stats.append(("Anomaly began", ctx["started_display"]))
|
|
330
|
+
stats.append(("Latest reading", ctx["timestamp"]))
|
|
331
331
|
else:
|
|
332
332
|
stats.append(("Detected at", ctx["timestamp"]))
|
|
333
333
|
parts.append(self._stat_grid(stats))
|
|
@@ -341,8 +341,10 @@ class EmailChannel(BaseAlertChannel):
|
|
|
341
341
|
("Expected", ctx["expected_range"]),
|
|
342
342
|
]
|
|
343
343
|
if ctx["started_display"]:
|
|
344
|
-
stats.append(("
|
|
345
|
-
|
|
344
|
+
stats.append(("Anomaly began", ctx["started_display"]))
|
|
345
|
+
if ctx["fired_display"]:
|
|
346
|
+
stats.append(("Alert fired", ctx["fired_display"]))
|
|
347
|
+
stats.append(("Recovered", ctx["timestamp"]))
|
|
346
348
|
else:
|
|
347
349
|
stats.append(("Cleared at", ctx["timestamp"]))
|
|
348
350
|
stats.append(("Detector", ctx["detector_name"]))
|
|
@@ -182,8 +182,8 @@ class TelegramChannel(BaseAlertChannel):
|
|
|
182
182
|
lines.append(f"• Severity: <code>{alert_data.severity:.2f}</code>")
|
|
183
183
|
if ctx["started_display"]:
|
|
184
184
|
lines.append(
|
|
185
|
-
f"•
|
|
186
|
-
f"Latest: <code>{esc(ctx['timestamp'])}</code>"
|
|
185
|
+
f"• Anomaly began: <code>{esc(ctx['started_display'])}</code> · "
|
|
186
|
+
f"Latest reading: <code>{esc(ctx['timestamp'])}</code>"
|
|
187
187
|
)
|
|
188
188
|
else:
|
|
189
189
|
lines.append(f"• Time: <code>{esc(ctx['timestamp'])}</code>")
|
|
@@ -204,12 +204,17 @@ class TelegramChannel(BaseAlertChannel):
|
|
|
204
204
|
f"Expected: <code>{esc(ctx['expected_range'])}</code>"
|
|
205
205
|
)
|
|
206
206
|
if ctx["started_display"]:
|
|
207
|
+
fired = (
|
|
208
|
+
f"Alert fired: <code>{esc(ctx['fired_display'])}</code> · "
|
|
209
|
+
if ctx["fired_display"]
|
|
210
|
+
else ""
|
|
211
|
+
)
|
|
207
212
|
lines.append(
|
|
208
|
-
f"•
|
|
209
|
-
f"
|
|
213
|
+
f"• Anomaly began: <code>{esc(ctx['started_display'])}</code> · "
|
|
214
|
+
f"{fired}Recovered: <code>{esc(ctx['timestamp'])}</code>"
|
|
210
215
|
)
|
|
211
216
|
else:
|
|
212
|
-
lines.append(f"•
|
|
217
|
+
lines.append(f"• Recovered: <code>{esc(ctx['timestamp'])}</code>")
|
|
213
218
|
lines.append(f"• Detector: <code>{esc(ctx['detector_name'])}</code>")
|
|
214
219
|
elif kind == "no_data":
|
|
215
220
|
lines.append("Query returned no datapoint for the latest expected interval.")
|
|
@@ -276,10 +276,10 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
276
276
|
short("Expected", code(ctx["expected_range"]))
|
|
277
277
|
short("Quorum", f"{ctx['detector_count']}/{ctx['min_detectors']} · {ctx['direction']}")
|
|
278
278
|
short("Severity", f"{alert_data.severity:.2f}")
|
|
279
|
-
# The problematic span: when
|
|
279
|
+
# The problematic span: when the anomaly began and its latest point.
|
|
280
280
|
if ctx["started_display"]:
|
|
281
|
-
short("
|
|
282
|
-
short("Latest", ctx["timestamp"])
|
|
281
|
+
short("Anomaly began", ctx["started_display"])
|
|
282
|
+
short("Latest reading", ctx["timestamp"])
|
|
283
283
|
else:
|
|
284
284
|
full("Detected at", ctx["timestamp"])
|
|
285
285
|
full("Detectors", code(ctx["detector_name"]))
|
|
@@ -289,10 +289,12 @@ class WebhookChannel(BaseAlertChannel):
|
|
|
289
289
|
lead = f"{ctx['recovery_lead']}\n{rule_chip}"
|
|
290
290
|
short("Value", code(ctx["value_display"]))
|
|
291
291
|
short("Expected", code(ctx["expected_range"]))
|
|
292
|
-
# The incident
|
|
292
|
+
# The incident timeline: anomaly onset → when the alert fired →
|
|
293
|
+
# when it recovered. ``short`` skips the fired field when unknown.
|
|
293
294
|
if ctx["started_display"]:
|
|
294
|
-
short("
|
|
295
|
-
short("
|
|
295
|
+
short("Anomaly began", ctx["started_display"])
|
|
296
|
+
short("Alert fired", ctx["fired_display"])
|
|
297
|
+
short("Recovered", ctx["timestamp"])
|
|
296
298
|
else:
|
|
297
299
|
full("Cleared at", ctx["timestamp"])
|
|
298
300
|
full("Detectors", code(ctx["detector_name"]))
|
|
@@ -10,12 +10,10 @@ off the machine, and nothing is written until the user explicitly saves.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
-
import re
|
|
14
13
|
import secrets
|
|
15
14
|
import threading
|
|
16
15
|
import webbrowser
|
|
17
16
|
from collections.abc import Callable
|
|
18
|
-
from datetime import datetime, timezone
|
|
19
17
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
18
|
from pathlib import Path
|
|
21
19
|
from typing import Any, cast
|
|
@@ -24,23 +22,12 @@ from urllib.parse import parse_qs, urlparse
|
|
|
24
22
|
import numpy as np
|
|
25
23
|
|
|
26
24
|
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
|
|
25
|
+
from detectkit.autotune.labels import parse_incident_labels, versioned_labels_path
|
|
31
26
|
|
|
27
|
+
# Re-exported under the historical name for the labeler-server tests.
|
|
28
|
+
from detectkit.autotune.labels import sanitize_label_set_name as _sanitize # noqa: F401
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
"""Filesystem-safe slug for a label-set name; ``""`` when blank.
|
|
35
|
-
|
|
36
|
-
Used as an optional *suffix* appended to the metric-named filename, so a blank
|
|
37
|
-
name simply yields no suffix (the file is named after the metric alone).
|
|
38
|
-
"""
|
|
39
|
-
return _NAME_RE.sub("-", name.strip().lower()).strip("-")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _stamp() -> str:
|
|
43
|
-
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
30
|
+
_MAX_BODY = 5_000_000 # generous cap on the posted labels payload
|
|
44
31
|
|
|
45
32
|
|
|
46
33
|
class _LabelServer(ThreadingHTTPServer):
|
|
@@ -90,16 +77,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
90
77
|
|
|
91
78
|
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
92
79
|
yaml_text = str(payload.get("yaml", ""))
|
|
93
|
-
suffix = _sanitize(str(payload.get("name", "")))
|
|
94
80
|
raw = _yaml.safe_load(yaml_text)
|
|
95
81
|
# validate against the canonical schema before writing anything
|
|
96
82
|
parse_incident_labels(
|
|
97
83
|
raw, interval_seconds=srv.interval_seconds, metric_name=srv.metric
|
|
98
84
|
)
|
|
99
85
|
srv.incidents_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
out = srv.incidents_dir / f"{stem}-{_stamp()}.yml"
|
|
86
|
+
# Versioned, metric-named file (the optional set name is a suffix).
|
|
87
|
+
out = versioned_labels_path(srv.incidents_dir, srv.metric, str(payload.get("name", "")))
|
|
103
88
|
out.write_text(yaml_text, encoding="utf-8")
|
|
104
89
|
srv.saved_path = out
|
|
105
90
|
except Exception as exc:
|
|
@@ -14,6 +14,7 @@ When no labels are supplied the tuner falls back to unsupervised mode.
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import re
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from datetime import datetime, timezone
|
|
19
20
|
from pathlib import Path
|
|
@@ -279,3 +280,43 @@ def load_capture_windows(
|
|
|
279
280
|
"""Load a labels file and render its capture windows as labeler display dicts."""
|
|
280
281
|
labels = parse_labels_file(path, interval_seconds=interval_seconds, metric_name=metric_name)
|
|
281
282
|
return capture_windows_to_display(labels)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Versioned labels store helpers (shared by the autotune labeler server and the
|
|
286
|
+
# `dtk tune` labeler so both name + discover files the same way).
|
|
287
|
+
_LABELS_GLOBS = ("*.yml", "*.yaml", "*.json")
|
|
288
|
+
_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def newest_labels_file(directory: str | Path) -> Path | None:
|
|
292
|
+
"""Newest versioned labels file in *directory* (``None`` if empty/absent).
|
|
293
|
+
|
|
294
|
+
Versioned names (``<metric>[-<set>]-<UTCstamp>.yml``) sort chronologically by
|
|
295
|
+
name; mtime tie-breaks.
|
|
296
|
+
"""
|
|
297
|
+
d = Path(directory)
|
|
298
|
+
if not d.is_dir():
|
|
299
|
+
return None
|
|
300
|
+
files: list[Path] = []
|
|
301
|
+
for pattern in _LABELS_GLOBS:
|
|
302
|
+
files.extend(d.glob(pattern))
|
|
303
|
+
if not files:
|
|
304
|
+
return None
|
|
305
|
+
return sorted(files, key=lambda p: (p.name, p.stat().st_mtime))[-1]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def sanitize_label_set_name(name: str) -> str:
|
|
309
|
+
"""Filesystem-safe slug for an optional label-set name (``""`` when blank)."""
|
|
310
|
+
return _NAME_RE.sub("-", name.strip().lower()).strip("-")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def versioned_labels_path(incidents_dir: str | Path, metric: str, name: str = "") -> Path:
|
|
314
|
+
"""Versioned labels path ``<metric>[-<slug>]-<UTCstamp>.yml`` in *incidents_dir*.
|
|
315
|
+
|
|
316
|
+
The single source of the labels filename convention: the optional set *name*
|
|
317
|
+
is sanitized to a suffix, blank → just the metric name.
|
|
318
|
+
"""
|
|
319
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
320
|
+
slug = sanitize_label_set_name(name)
|
|
321
|
+
stem = f"{metric}-{slug}" if slug else metric
|
|
322
|
+
return Path(incidents_dir) / f"{stem}-{stamp}.yml"
|
|
@@ -196,15 +196,15 @@ leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-
|
|
|
196
196
|
status-colored accent bar, a clickable title (the metric; links to
|
|
197
197
|
`dashboard_url` when set), a short markdown lead (the duration sentence — see
|
|
198
198
|
"Incident timing" below) with the **Rule** chip beneath it, and a compact
|
|
199
|
-
fields grid: short fields Value / Expected / Quorum / Severity /
|
|
200
|
-
Latest (
|
|
199
|
+
fields grid: short fields Value / Expected / Quorum / Severity / Anomaly began /
|
|
200
|
+
Latest reading (Anomaly began / Alert fired / Recovered on recovery), then full-width Detectors / Parameters,
|
|
201
201
|
plus a branded footer + footer icon. @mentions ride in the **top-level**
|
|
202
202
|
message text so they notify. A custom `template` instead renders as a plain
|
|
203
203
|
text-only attachment (color/title/ branding kept, no fields grid).
|
|
204
204
|
- **Telegram** — default `parse_mode` is now **HTML**. The default message is
|
|
205
205
|
structured and HTML-escaped: a colored status dot (red anomaly / green
|
|
206
206
|
recovery / yellow no-data / blue error), a bold headline, the lead + rule, then
|
|
207
|
-
evidence in `<code>` (value/expected/quorum/severity/
|
|
207
|
+
evidence in `<code>` (value/expected/quorum/severity/began → latest/detector/
|
|
208
208
|
params), an inline "Open dashboard" link, then mentions. This fixes the old
|
|
209
209
|
Markdown mode raising "can't parse entities" on params JSON containing
|
|
210
210
|
underscores (e.g. `window_size`). Custom templates are sent verbatim under the
|
|
@@ -212,7 +212,7 @@ leads with a colored **status circle** — 🔴 anomaly, 🟢 recovery, 🟡 no-
|
|
|
212
212
|
old behavior.
|
|
213
213
|
- **Email** — a branded HTML card (inline-CSS, table-based, Outlook-safe):
|
|
214
214
|
colored accent + status pill, the metric, the lead + Rule chip, a 2-col stat
|
|
215
|
-
grid (value/expected/severity/quorum/
|
|
215
|
+
grid (value/expected/severity/quorum/anomaly began/latest reading; began/alert fired/recovered on recovery), a monospace params box,
|
|
216
216
|
an optional "Open dashboard" button, and a footer. The plain-text body remains
|
|
217
217
|
the multipart fallback.
|
|
218
218
|
|
|
@@ -226,15 +226,20 @@ identical everywhere.
|
|
|
226
226
|
|
|
227
227
|
**Incident timing — "how long has this been going on".** Each default anomaly
|
|
228
228
|
leads with `Anomalous for 2h 30m — 15 consecutive 10min intervals.` (metric
|
|
229
|
-
interval + true streak length + wall-clock duration);
|
|
230
|
-
|
|
229
|
+
interval + true streak length + wall-clock duration); the **Anomaly began /
|
|
230
|
+
Latest reading** fields bound the span. Labels are self-describing so the onset
|
|
231
|
+
isn't misread as the alert-fire moment: **Anomaly began** is the resolved onset,
|
|
232
|
+
**not** when the alert fired. Recovery shows the fuller **began → fired →
|
|
233
|
+
recovered** timeline (`Incident lasted …`), where **Alert fired** =
|
|
234
|
+
`onset + (consecutive_required − 1) × interval` (computed in `build_context`,
|
|
235
|
+
exposed as `{fired_display}`, omitted when the run is capped). The true
|
|
231
236
|
streak/onset is resolved only when an alert fires/clears (a bounded lookback over
|
|
232
237
|
the detection history; a run older than the window shows `over …`), so the hot
|
|
233
238
|
no-alert path stays cheap. Exposed to templates as `{anomaly_lead}` /
|
|
234
239
|
`{recovery_lead}` / `{duration_display}` / `{interval_display}` /
|
|
235
|
-
`{started_display}` / `{
|
|
236
|
-
*true* streak length. Custom templates and
|
|
237
|
-
same order.
|
|
240
|
+
`{started_display}` / `{fired_display}` / `{window_line}` — and
|
|
241
|
+
`{consecutive_count}` now carries the *true* streak length. Custom templates and
|
|
242
|
+
the plain-text fallbacks follow the same order.
|
|
238
243
|
|
|
239
244
|
## Project label (multi-project channels)
|
|
240
245
|
|
|
@@ -294,7 +299,7 @@ referenced by path). Key variables:
|
|
|
294
299
|
| `{direction}`, `{severity}` | observed values |
|
|
295
300
|
| `{consecutive_count}` | **true** streak length (resolved at fire time, not capped at the rule) |
|
|
296
301
|
| `{anomaly_lead}` / `{recovery_lead}` | ready-made "how long" lead sentence |
|
|
297
|
-
| `{interval_display}` / `{duration_display}` / `{started_display}` / `{window_line}` | incident-timing bits (interval, duration, onset, `
|
|
302
|
+
| `{interval_display}` / `{duration_display}` / `{started_display}` / `{fired_display}` / `{window_line}` | incident-timing bits (interval, duration, onset, alert-fire moment, `Anomaly began… \| Latest reading…` line) |
|
|
298
303
|
| `{status}` | `ANOMALY` / `RECOVERED` / `NO_DATA` / `ERROR` |
|
|
299
304
|
| `{mentions}` / `{mentions_line}` | formatted mentions |
|
|
300
305
|
| `{dashboard_url}` | raw `dashboard_url` (empty string when unset) |
|
|
@@ -88,13 +88,24 @@ config back into the metric YAML **in place** (autotune, by contrast, writes a n
|
|
|
88
88
|
datapoints (run `dtk run --steps load` first if empty); the selector must resolve
|
|
89
89
|
to a single metric.
|
|
90
90
|
|
|
91
|
+
**Mark incidents + see alert quality live.** A second, **synced** chart beneath
|
|
92
|
+
the detector view lets you **mark the real incidents** (drag to create a span,
|
|
93
|
+
drag its edges/middle to adjust, ✕ or Delete to remove). As you tune, a metrics
|
|
94
|
+
bar shows two operator numbers: **incident catch rate (recall)** — how many marked
|
|
95
|
+
incidents your config catches — and **false-alert rate** — what share of alerts
|
|
96
|
+
fall outside any real incident ("≈1 in N false"). **Save incidents** writes a
|
|
97
|
+
versioned `incidents/<metric>/*.yml` — the same store `dtk autotune` reads, so the
|
|
98
|
+
same labels feed the next supervised tune (it seeds from the newest file on open).
|
|
99
|
+
Saving incidents does not end the session; only **Apply** does. A **y = 0 line**
|
|
100
|
+
toggle (shared with `dtk run --report`) shows the metric relative to zero.
|
|
101
|
+
|
|
91
102
|
Safe write-back: the config is validated before anything is written, the previous
|
|
92
103
|
YAML is archived under `metrics/.history/<metric>/`, and only then is the metric
|
|
93
104
|
overwritten. Takes **no pipeline lock** (it only edits a config file); re-run
|
|
94
105
|
`dtk run` afterward to recompute detections under the new config.
|
|
95
|
-
`--no-serve` writes a static read-only preview HTML instead (no write-back
|
|
96
|
-
`--from` / `--to` bound the window;
|
|
97
|
-
browser.
|
|
106
|
+
`--no-serve` writes a static read-only preview HTML instead (no write-back —
|
|
107
|
+
**Save incidents** downloads the labels file); `--from` / `--to` bound the window;
|
|
108
|
+
`--no-open` prints the URL without opening a browser.
|
|
98
109
|
|
|
99
110
|
## `dtk test-alert <metric>`
|
|
100
111
|
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import click
|
|
17
17
|
|
|
18
|
+
from detectkit.autotune.labels import load_incidents_for_display, newest_labels_file
|
|
18
19
|
from detectkit.cli._output import echo_done, echo_error, echo_noop
|
|
19
20
|
from detectkit.cli.commands.autotune import _load_project
|
|
20
21
|
from detectkit.cli.commands.run import parse_date, select_metrics
|
|
@@ -56,6 +57,26 @@ def run_tune(
|
|
|
56
57
|
|
|
57
58
|
metric_path, config = metrics[0]
|
|
58
59
|
name = config.name
|
|
60
|
+
interval_seconds = config.get_interval().seconds
|
|
61
|
+
|
|
62
|
+
# Seed the synced labeler with the newest already-marked incidents from the
|
|
63
|
+
# shared store incidents/<metric>/ (the same files dtk autotune reads), so
|
|
64
|
+
# labeling round-trips across both tools. Best-effort — a missing/bad file
|
|
65
|
+
# just yields no seed.
|
|
66
|
+
incidents_dir = project_root / "incidents" / name
|
|
67
|
+
preload_incidents: list[dict[str, str]] = []
|
|
68
|
+
newest = newest_labels_file(incidents_dir)
|
|
69
|
+
if newest is not None:
|
|
70
|
+
try:
|
|
71
|
+
preload_incidents = load_incidents_for_display(
|
|
72
|
+
newest, interval_seconds=interval_seconds, metric_name=name
|
|
73
|
+
)
|
|
74
|
+
click.echo(
|
|
75
|
+
f" Seeded {len(preload_incidents)} incident(s) from "
|
|
76
|
+
f"{newest.relative_to(project_root)}"
|
|
77
|
+
)
|
|
78
|
+
except Exception as exc: # noqa: BLE001 — preload is a best-effort convenience
|
|
79
|
+
click.echo(click.style(f" Could not preload {newest}: {exc}", fg="yellow"))
|
|
59
80
|
|
|
60
81
|
from_dt = parse_date(from_date) if from_date else None
|
|
61
82
|
to_dt = parse_date(to_date) if to_date else None
|
|
@@ -68,6 +89,7 @@ def run_tune(
|
|
|
68
89
|
start=from_dt,
|
|
69
90
|
end=to_dt,
|
|
70
91
|
project_name=project_name,
|
|
92
|
+
incidents=preload_incidents,
|
|
71
93
|
)
|
|
72
94
|
n_points = len(payload["points"])
|
|
73
95
|
if n_points == 0:
|
|
@@ -97,6 +119,9 @@ def run_tune(
|
|
|
97
119
|
project_root=project_root,
|
|
98
120
|
open_browser=not no_open,
|
|
99
121
|
echo=click.echo,
|
|
122
|
+
metric_name=name,
|
|
123
|
+
incidents_dir=incidents_dir,
|
|
124
|
+
interval_seconds=interval_seconds,
|
|
100
125
|
)
|
|
101
126
|
if applied is None:
|
|
102
127
|
echo_noop(name, "tuning cancelled — metric unchanged")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";(()=>{var ye={"--term-bg":"#211e1a","--clay":"#d15b36","--st-anomaly":"#d63232","--st-recovery":"#36a64f","--st-nodata":"#f0ad4e","--st-error":"#5a7a8c","--faint":"#9a9384","--muted":"#6e675b","--border":"#332f29","--term-border":"#332f29"};function L(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()||ye[e]||"#888"}function we(e){let t=e.replace("#","").trim();t.length===3&&(t=t[0]+t[0]+t[1]+t[1]+t[2]+t[2]);let n=parseInt(t,16);return t.length!==6||Number.isNaN(n)?[209,91,54]:[n>>16&255,n>>8&255,n&255]}function N(e,t){let[n,i,a]=we(e);return`rgba(${n},${i},${a},${t})`}function ee(e){let t=Math.max(1,window.devicePixelRatio||1),n=e.clientWidth||e.offsetWidth||0,i=e.clientHeight||e.offsetHeight||0;return e.width=Math.round(n*t),e.height=Math.round(i*t),t}function te(e,t,n,i){let a=()=>e.width-(t.l+t.r)*i,h=()=>e.height-(t.t+t.b)*i,r=()=>n.tmax-n.tmin||1,l=()=>n.vmax-n.vmin||1;return{px:m=>t.l*i+(m-n.tmin)/r()*a(),py:m=>e.height-t.b*i-(m-n.vmin)/l()*h(),tAt:m=>n.tmin+(m-t.l*i)/(a()||1)*r(),vAt:m=>n.vmin+(e.height-t.b*i-m)/(h()||1)*l(),plotW:a,plotH:h,tspan:r}}var F=Number.isFinite;function ne(e,t,n,i,a,h,r,l,p,f,b,u){let m=t.length,$=Math.max(1,Math.round(r)),C=a-i||1,T=0;for(let y=0;y<m;y++){let s=n[y];!F(s)||t[y]<i||t[y]>a||T++}if(e.strokeStyle=f,e.lineWidth=b*u,e.lineJoin="round",e.beginPath(),T<=$){let y=!1;for(let s=0;s<m;s++){let S=n[s],M=t[s];if(!F(S)||M<i||M>a){y=!1;continue}let E=l(M),R=p(S);y?e.lineTo(E,R):(e.moveTo(E,R),y=!0)}}else{let y=new Array($).fill(null),s=new Array($).fill(null);for(let M=0;M<m;M++){let E=n[M],R=t[M];if(!F(E)||R<i||R>a)continue;let A=Math.floor((R-i)/C*($-1));A=A<0?0:A>$-1?$-1:A,(y[A]===null||E<y[A])&&(y[A]=E),(s[A]===null||E>s[A])&&(s[A]=E)}let S=!1;for(let M=0;M<$;M++){if(s[M]===null){S=!1;continue}let E=h+M,R=p(s[M]),A=p(y[M]);S?e.lineTo(E,R):(e.moveTo(E,R),S=!0),e.lineTo(E,A)}}e.stroke()}function re(e){let t=[],n=-1;for(let i=0;i<e.length;i++){let a=e[i];a.lo!==null&&a.hi!==null&&F(a.lo)&&F(a.hi)?n===-1&&(n=i):n!==-1&&(t.push([n,i-1]),n=-1)}return n!==-1&&t.push([n,e.length-1]),t}function oe(e,t,n,i,a,h,r,l,p){e.fillStyle=N(h,r);for(let[f,b]of n){e.beginPath(),e.moveTo(i(t[f].t),a(t[f].hi));for(let u=f+1;u<=b;u++)e.lineTo(i(t[u].t),a(t[u].hi));for(let u=b;u>=f;u--)e.lineTo(i(t[u].t),a(t[u].lo));e.closePath(),e.fill()}e.strokeStyle=N(h,l),e.lineWidth=1*p;for(let[f,b]of n)for(let u of["hi","lo"]){e.beginPath();for(let m=f;m<=b;m++){let $=i(t[m].t),C=a(t[m][u]);m===f?e.moveTo($,C):e.lineTo($,C)}e.stroke()}}function ie(e,t,n,i,a,h,r,l){for(let p=0;p<t.length;p++){let f=t[p];if(!F(f.v)||f.t<n||f.t>i)continue;let b=a(f.t),u=h(f.v);e.fillStyle=N(r,.18),e.beginPath(),e.arc(b,u,6*l,0,Math.PI*2),e.fill(),e.fillStyle=r,e.beginPath(),e.arc(b,u,3*l,0,Math.PI*2),e.fill()}}function se(e,t,n,i,a,h,r,l,p,f,b){e.font=`${11*b}px ui-monospace, 'JetBrains Mono', monospace`,e.textBaseline="middle";for(let m=0;m<=4;m++){let $=i.vmin+(i.vmax-i.vmin)*m/4,C=h($);e.strokeStyle=N(p,.1),e.lineWidth=1*b,e.beginPath(),e.moveTo(n.l*b,C),e.lineTo(t.width-n.r*b,C),e.stroke(),e.fillStyle=f,e.textAlign="right",e.fillText(W($),(n.l-8)*b,C)}e.textBaseline="top";let u=l-r||1;for(let m=0;m<=5;m++){let $=r+u*m/5,C=a($);e.fillStyle=f,e.textAlign=m===0?"left":m===5?"right":"center",e.fillText($e($,u),C,(t.height-n.b+7)*b)}}function W(e){let t=Math.abs(e);return t>=1e3?e.toFixed(0):t>=10?e.toFixed(1):t>=1?e.toFixed(2):e.toFixed(3)}function $e(e,t){let n=new Date(e).toISOString();return t<2*864e5?n.slice(5,16).replace("T"," "):n.slice(5,10)}function X(e){return new Date(e).toISOString().slice(0,19).replace("T"," ")}function ae(e){let t=Math.round(e/6e4);if(t<60)return t+"m";let n=Math.floor(t/60),i=t%60;if(n<24)return n+"h"+(i?" "+i+"m":"");let a=Math.floor(n/24),h=n%24;return a+"d"+(h?" "+h+"h":"")}function le(e,t,n){return{left:t.l*n,top:t.t*n,right:e.width-t.r*n,bottom:e.height-t.b*n}}function ce(e,t,n,i,a,h,r){let l=le(t,n,i),p=Math.max(l.left,Math.min(a(h),l.right));p<=l.left+.5||(e.save(),e.fillStyle="rgba(17,15,13,0.42)",e.fillRect(l.left,l.top,p-l.left,l.bottom-l.top),e.strokeStyle=N(L("--faint"),.7),e.lineWidth=1*i,e.setLineDash([4*i,4*i]),e.beginPath(),e.moveTo(p,l.top),e.lineTo(p,l.bottom),e.stroke(),e.setLineDash([]),e.fillStyle=N(L("--faint"),.95),e.font=`${10*i}px ui-monospace, monospace`,e.textAlign="left",e.textBaseline="top",e.fillText(r,p+6*i,l.top+5*i),e.restore())}function de(e,t,n,i,a,h,r){let l=le(t,n,i),p=5*i;e.save();for(let f=0;f<h.length;f++){let b=h[f],u=a(b.t);if(u<l.left-1||u>l.right+1)continue;let m=r(b.kind);e.strokeStyle=N(m,.45),e.lineWidth=1*i,e.beginPath(),e.moveTo(u,l.top),e.lineTo(u,l.bottom),e.stroke(),e.fillStyle=m,e.beginPath(),e.moveTo(u-p,l.top),e.lineTo(u+p,l.top),e.lineTo(u,l.top+p*1.4),e.closePath(),e.fill()}e.restore()}var P={l:56,r:16,t:14,b:28},Me=300*1e3,d="dtk-report";function G(e){return e==="recovery"?L("--st-recovery"):e==="no_data"?L("--st-nodata"):L("--st-anomaly")}function Ae(e){return e==="recovery"?"recovery":e==="no_data"?"no-data":"anomaly"}var w=e=>String(e).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),K=(e,t,n)=>Math.max(t,Math.min(n,e)),ue=["--clay","--st-error","--st-recovery","--st-nodata"];function Te(e,t){Re(),t.classList.add(d),t.innerHTML="";let n=document.createElement("div");n.className="dtk-report-root",t.appendChild(n);let i=new Map;for(let s of e.points)s.v!==null&&i.set(s.t,s.v);let a=e.detectors.map((s,S)=>{let M=s.effective_start,E=s.points.map(A=>M!==null&&A.t<M?{t:A.t,lo:null,hi:null}:{t:A.t,lo:A.lo,hi:A.hi}),R=[];for(let A of s.points)if(A.a===1&&(M===null||A.t>=M)){let I=i.get(A.t);I!==void 0&&R.push({t:A.t,v:I})}return{det:s,band:E,anomalies:R,effectiveStart:M,color:L(ue[S%ue.length]),shown:S===0}});a.length===1&&(a[0].shown=!0),n.appendChild(Se(e));let h=null;a.length>1&&(h=Ce(a,()=>u.repaint()),n.appendChild(h));let r=document.createElement("div");r.className="dtk-bar",n.appendChild(r);let l=document.createElement("div");l.className="dtk-presets",r.appendChild(l);let p=document.createElement("div");p.className="dtk-readout",p.textContent="hover the chart for a point readout",r.appendChild(p);let f=document.createElement("div");f.className="dtk-chart";let b=document.createElement("canvas");f.appendChild(b),n.appendChild(f);let u=Pe(b,e,a,i,s=>{p.innerHTML=s}),m=[{label:"24h",ms:24*3600*1e3},{label:"7d",ms:7*86400*1e3},{label:"30d",ms:30*86400*1e3},{label:"All",ms:null}];for(let s of m){let S=document.createElement("button");S.className="dtk-preset",S.textContent=s.label,S.onclick=()=>{s.ms===null?u.resetView():u.setView(e.period.end-s.ms,e.period.end),Le(l,S)},l.appendChild(S)}let $=document.createElement("label");$.className="dtk-zero",$.title="Draw a horizontal line at y = 0 and scale the chart to include zero.";let C=document.createElement("input");C.type="checkbox",C.onchange=()=>u.setZeroLine(C.checked),$.appendChild(C),$.appendChild(document.createTextNode(" y = 0")),l.appendChild($),n.appendChild(Ee(e,s=>u.focusAlert(s))),u.resize();let T=0,y=()=>{T||(T=requestAnimationFrame(()=>{T=0,u.resize()}))};window.addEventListener("resize",y)}function Se(e){let t=document.createElement("div");t.className="dtk-header";let n=e.interval_seconds/60,i=e.interval_seconds>=86400?e.interval_seconds/86400+"d":e.interval_seconds>=3600?e.interval_seconds/3600+"h":n>=1?n+"min":e.interval_seconds+"s",a=e.project?`${w(e.project)} \xB7 ${w(e.metric)}`:w(e.metric),h=e.summary;return t.innerHTML=`<div class="dtk-h-top"><h1 class="dtk-title">${a}</h1><div class="dtk-meta">${w(X(e.period.start))} \u2013 ${w(X(e.period.end))} \xB7 interval ${w(i)}${e.generated_at?` \xB7 generated ${w(e.generated_at)}`:""}</div></div>`+(e.description?`<p class="dtk-desc">${w(e.description)}</p>`:"")+'<div class="dtk-chips">'+Y("anomalies",h.anomalies,"--st-anomaly")+Y("alerts",h.alerts,"--clay")+Y("recoveries",h.recoveries,"--st-recovery")+Y("no-data",h.no_data,"--st-nodata")+"</div>",t}function Y(e,t,n){let i=L(n);return`<span class="dtk-chip"><span class="dtk-dot" style="background:${w(i)}"></span><span class="dtk-chip-n">${t}</span><span class="dtk-chip-l">${w(e)}</span></span>`}function Ce(e,t){let n=document.createElement("div");return n.className="dtk-legend",e.forEach(i=>{let a=document.createElement("button");a.className="dtk-legend-item"+(i.shown?"":" off"),a.innerHTML=`<span class="dtk-swatch" style="background:${w(i.color)}"></span><span class="dtk-legend-name">${w(i.det.name)}</span><span class="dtk-legend-id">${w(i.det.id.slice(0,8))}</span><span class="dtk-legend-n">${i.det.anomaly_count}</span>`,a.onclick=()=>{i.shown=!i.shown,a.classList.toggle("off",!i.shown),t()},n.appendChild(a)}),n}function Ee(e,t){let n=document.createElement("div");n.className="dtk-alerts";let i=document.createElement("div");if(i.className="dtk-alerts-head",i.textContent=`Alerts (${e.alerts.length})`,n.appendChild(i),e.alerts.length===0){let r=document.createElement("div");return r.className="dtk-alerts-empty",r.textContent="No alerts fired in this period.",n.appendChild(r),n}let a=document.createElement("div");a.className="dtk-alerts-list";let h=[...e.alerts].sort((r,l)=>l.t-r.t);for(let r of h){let l=document.createElement("button");l.className="dtk-alert-row";let p=G(r.kind),f=r.direction!=="none"?` \xB7 ${w(r.direction)}`:"",b=r.severity>0?` \xB7 sev ${r.severity.toFixed(2)}`:"",u=r.value!==null?` \xB7 value ${W(r.value)}`:"",m=r.onset!==null&&r.kind!=="no_data"?` \xB7 ${ae(Math.max(0,r.t-r.onset))} (${r.consecutive} pts)`:"";l.innerHTML=`<span class="dtk-alert-time">${w(X(r.t))}</span><span class="dtk-badge" style="background:${w(N(p,.18))};color:${w(p)};border-color:${w(N(p,.5))}">${w(Ae(r.kind))}</span><span class="dtk-alert-body"><span class="dtk-alert-rule">${w(r.rule)}</span><span class="dtk-alert-sub">${w(r.detector)}${f}${b}${u}${w(m)}</span></span>`,l.onclick=()=>t(r),a.appendChild(l)}return n.appendChild(a),n}function Le(e,t){e.querySelectorAll(".dtk-preset").forEach(n=>n.classList.remove("active")),t.classList.add("active")}function Pe(e,t,n,i,a){let h=e.getContext("2d");if(!h)throw new Error("report: 2D context unavailable");let r=h,l=t.points.map(o=>o.t),p=t.points.map(o=>o.v===null?NaN:o.v),f=t.period.start,b=t.period.end,u=b-f||1,m=Math.min(Me,u),$=0,C=1;E();let T=f,y=b,s=1,S=null,M=!1;function E(){let o=1/0,c=-1/0;for(let k of p)Number.isFinite(k)&&(k<o&&(o=k),k>c&&(c=k));for(let k of n)for(let x of k.band)x.lo!==null&&Number.isFinite(x.lo)&&x.lo<o&&(o=x.lo),x.hi!==null&&Number.isFinite(x.hi)&&x.hi>c&&(c=x.hi);(!Number.isFinite(o)||!Number.isFinite(c))&&(o=0,c=1),M&&(o>0&&(o=0),c<0&&(c=0)),c<=o&&(c=o+1);let v=(c-o)*.06;$=o-v,C=c+v}function R(o){M!==o&&(M=o,E(),V())}function A(){for(let o of n)if(o.shown&&o.effectiveStart!==null)return o.effectiveStart;for(let o of n)if(o.effectiveStart!==null)return o.effectiveStart;return null}function I(){return{tmin:T,tmax:y,vmin:$,vmax:C}}function fe(){return te(e,P,I(),s)}function O(o,c){let v=c-o;if(v<m){let k=(o+c)/2;o=k-m/2,c=k+m/2,v=m}v>=u&&(o=f,c=b),o<f&&(c+=f-o,o=f),c>b&&(o-=c-b,c=b),T=K(o,f,b),y=K(c,f,b),V()}function Z(){T=f,y=b,V()}function pe(o){let c=o.onset!==null?Math.min(o.onset,o.t):o.t,v=o.t,x=Math.max(v-c,m)*1.5+m;O(c-x,v+x)}let j=0;function q(){j===0&&(j=requestAnimationFrame(V))}function V(){if(j=0,e.width===0||e.height===0)return;let o=fe(),c=L("--faint"),v=L("--muted"),k=L("--clay");if(r.fillStyle=L("--term-bg"),r.fillRect(0,0,e.width,e.height),l.length===0)return;if(se(r,e,P,I(),o.px,o.py,T,y,c,v,s),M&&$<=0&&C>=0){let g=o.py(0);r.strokeStyle=N(v,.6),r.lineWidth=1.25*s,r.beginPath(),r.moveTo(P.l*s,g),r.lineTo(e.width-P.r*s,g),r.stroke(),r.fillStyle=v,r.textAlign="right",r.textBaseline="middle",r.fillText("0",(P.l-8)*s,g)}r.save(),r.beginPath(),r.rect(P.l*s,P.t*s,o.plotW(),o.plotH()),r.clip();let x=P.t*s,z=o.plotH();for(let g of t.alerts){if(g.onset===null||g.kind==="no_data")continue;let B=Math.min(g.onset,g.t),U=Math.max(g.onset,g.t);if(U<T||B>y)continue;let ke=G(g.kind),Q=o.px(B),ge=o.px(U);r.fillStyle=N(ke,.08),r.fillRect(Q,x,Math.max(ge-Q,1*s),z)}for(let g of n){if(!g.shown)continue;let B=re(g.band);oe(r,g.band,B,o.px,o.py,g.color,.13,.4,s)}ne(r,l,p,T,y,P.l*s,o.plotW(),o.px,o.py,k,1.5,s);for(let g of n)g.shown&&ie(r,g.anomalies,T,y,o.px,o.py,L("--st-anomaly"),s);let D=A();D!==null&&D>T&&ce(r,e,P,s,o.px,D,"detection at full power \u2192");let H=t.alerts.map(g=>({t:g.t,kind:g.kind}));de(r,e,P,s,o.px,H,g=>G(g)),S!==null&&he(o,x,z,c),r.restore()}function be(o){let c=l;if(c.length===0)return-1;let v=0,k=c.length-1;for(;v<k;){let x=v+k>>1;c[x]<o?v=x+1:k=x}return v>0&&o-c[v-1]<c[v]-o&&(v-=1),v}function he(o,c,v,k){let x=be(S);if(x<0)return;let z=l[x];if(z<T||z>y){a("hover the chart for a point readout");return}let D=o.px(z);r.strokeStyle=N(k,.45),r.lineWidth=1*s,r.setLineDash([2*s,2*s]),r.beginPath(),r.moveTo(D,c),r.lineTo(D,c+v),r.stroke(),r.setLineDash([]);let H=p[x];if(Number.isFinite(H)){let g=o.py(H);r.fillStyle=L("--term-bg"),r.beginPath(),r.arc(D,g,4*s,0,Math.PI*2),r.fill(),r.strokeStyle=L("--clay"),r.lineWidth=2*s,r.beginPath(),r.arc(D,g,4*s,0,Math.PI*2),r.stroke()}ve(x)}function ve(o){let c=l[o],v=p[o],k=`<span class="dtk-ro-t">${w(X(c))}</span>`;k+=`<span class="dtk-ro-v">value ${Number.isFinite(v)?W(v):"\u2014"}</span>`;for(let x of n){if(!x.shown)continue;let z=x.band[o],D=x.det.points[o];if(z&&z.lo!==null&&z.hi!==null){let H=D&&D.a===1,g=H&&D.sev!==null?` sev ${D.sev.toFixed(2)}`:"",B=H?`<span class="dtk-ro-anom" style="color:${w(L("--st-anomaly"))}">anomaly${w(g)}</span>`:'<span class="dtk-ro-ok">ok</span>';k+=`<span class="dtk-ro-det"><span class="dtk-swatch" style="background:${w(x.color)}"></span>${w(x.det.name)}: [${W(z.lo)}, ${W(z.hi)}] ${B}</span>`}}a(k)}function J(o){let c=e.getBoundingClientRect(),v=(o-c.left-P.l)/(c.width-(P.l+P.r)||1);return T+K(v,0,1)*(y-T)}e.addEventListener("wheel",o=>{o.preventDefault();let c=J(o.clientX),v=y-T,k=K(v*Math.pow(1.0015,o.deltaY),m,u),x=(c-T)/(v||1);O(c-x*k,c-x*k+k)},{passive:!1});let _=null;e.addEventListener("mousedown",o=>{_={x:o.clientX,vMin:T,vMax:y},e.style.cursor="grabbing"}),window.addEventListener("mousemove",o=>{if(!_)return;let c=e.getBoundingClientRect(),v=(_.vMax-_.vMin)/(c.width-(P.l+P.r)||1),k=(o.clientX-_.x)*v;O(_.vMin-k,_.vMax-k)}),window.addEventListener("mouseup",()=>{_&&(_=null,e.style.cursor="crosshair")}),e.addEventListener("mousemove",o=>{_||(S=J(o.clientX),q())}),e.addEventListener("mouseleave",()=>{S!==null&&(S=null,a("hover the chart for a point readout"),q())}),e.addEventListener("dblclick",()=>Z()),e.style.cursor="crosshair";function xe(){s=ee(e),V()}return{repaint:()=>q(),resize:xe,setView:O,resetView:Z,focusAlert:pe,setZeroLine:R}}var me=!1;function Re(){if(me)return;me=!0;let e=`
|
|
2
|
+
.${d}{--term-bg:#211e1a;--term-border:#332f29;--term-text:#c9c2b4;
|
|
3
|
+
--clay:#d15b36;--clay-700:#b4471f;--ink:#1b1916;--paper:#f5f1e8;--surface:#fbf9f3;
|
|
4
|
+
--border:#e6e0d4;--muted:#6e675b;--faint:#9a9384;
|
|
5
|
+
--anom:#d63232;--rec:#36a64f;--nod:#f0ad4e;
|
|
6
|
+
--sans:'Schibsted Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
|
|
7
|
+
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
8
|
+
font-family:var(--sans);color:var(--ink);}
|
|
9
|
+
.${d} *{box-sizing:border-box;}
|
|
10
|
+
.${d} .dtk-report-root{max-width:1100px;margin:0 auto;padding:20px 18px 40px;}
|
|
11
|
+
/* --- header row ----------------------------------------------------------- */
|
|
12
|
+
.${d} .dtk-header{margin-bottom:16px;padding-left:12px;
|
|
13
|
+
border-left:3px solid var(--clay);}
|
|
14
|
+
.${d} .dtk-h-top{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 14px;}
|
|
15
|
+
.${d} .dtk-title{font-size:21px;font-weight:700;margin:0;color:var(--ink);
|
|
16
|
+
font-family:var(--sans);letter-spacing:-0.01em;}
|
|
17
|
+
.${d} .dtk-meta{font-size:12px;color:var(--muted);font-family:var(--mono);}
|
|
18
|
+
.${d} .dtk-desc{margin:8px 0 0;font-size:13px;color:var(--muted);max-width:760px;
|
|
19
|
+
line-height:1.5;}
|
|
20
|
+
/* --- summary chips (surface cards) ---------------------------------------- */
|
|
21
|
+
.${d} .dtk-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;}
|
|
22
|
+
.${d} .dtk-chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;
|
|
23
|
+
background:var(--surface);border:1px solid var(--border);border-radius:10px;font-size:12px;}
|
|
24
|
+
.${d} .dtk-dot{width:8px;height:8px;border-radius:50%;display:inline-block;}
|
|
25
|
+
.${d} .dtk-chip-n{font-weight:700;font-family:var(--mono);color:var(--ink);}
|
|
26
|
+
.${d} .dtk-chip-l{color:var(--faint);font-family:var(--mono);font-size:11px;
|
|
27
|
+
text-transform:uppercase;letter-spacing:0.05em;}
|
|
28
|
+
/* --- detector legend ------------------------------------------------------ */
|
|
29
|
+
.${d} .dtk-legend{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px;}
|
|
30
|
+
.${d} .dtk-legend-item{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;
|
|
31
|
+
background:var(--surface);border:1px solid var(--border);border-radius:8px;cursor:pointer;
|
|
32
|
+
color:var(--ink);font-size:12px;font-family:var(--sans);transition:border-color .12s ease;}
|
|
33
|
+
.${d} .dtk-legend-item:hover{border-color:var(--clay);}
|
|
34
|
+
.${d} .dtk-legend-item.off{opacity:0.45;}
|
|
35
|
+
.${d} .dtk-legend-id{color:var(--faint);font-family:var(--mono);font-size:11px;}
|
|
36
|
+
.${d} .dtk-legend-n{color:var(--anom);font-weight:700;font-family:var(--mono);}
|
|
37
|
+
.${d} .dtk-swatch{width:10px;height:10px;border-radius:2px;display:inline-block;}
|
|
38
|
+
/* --- toolbar (presets + readout) ------------------------------------------ */
|
|
39
|
+
.${d} .dtk-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;
|
|
40
|
+
gap:8px;margin-bottom:8px;}
|
|
41
|
+
.${d} .dtk-presets{display:flex;gap:5px;}
|
|
42
|
+
.${d} .dtk-preset{padding:5px 13px;background:var(--surface);
|
|
43
|
+
border:1px solid var(--border);border-radius:8px;color:var(--muted);cursor:pointer;
|
|
44
|
+
font-size:12px;font-family:var(--sans);transition:border-color .12s ease,color .12s ease;}
|
|
45
|
+
.${d} .dtk-preset:hover{border-color:var(--clay);color:var(--ink);}
|
|
46
|
+
.${d} .dtk-preset.active{background:var(--clay);color:#fff;border-color:var(--clay);}
|
|
47
|
+
.${d} .dtk-zero{display:inline-flex;align-items:center;gap:5px;margin-left:6px;
|
|
48
|
+
font-size:12px;color:var(--muted);font-family:var(--sans);cursor:pointer;user-select:none;}
|
|
49
|
+
.${d} .dtk-zero input{accent-color:var(--clay);cursor:pointer;}
|
|
50
|
+
.${d} .dtk-readout{font-size:11px;color:var(--muted);
|
|
51
|
+
font-family:var(--mono);display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;}
|
|
52
|
+
.${d} .dtk-readout .dtk-swatch{margin-right:4px;}
|
|
53
|
+
.${d} .dtk-ro-t{font-weight:700;color:var(--ink);}
|
|
54
|
+
/* --- chart panel (dark terminal surface) ---------------------------------- */
|
|
55
|
+
.${d} .dtk-chart{position:relative;width:100%;height:360px;background:var(--term-bg);
|
|
56
|
+
border:1px solid var(--term-border);border-radius:12px;overflow:hidden;
|
|
57
|
+
box-shadow:0 24px 60px -30px rgba(27,25,22,.45);}
|
|
58
|
+
.${d} .dtk-chart canvas{width:100%;height:100%;display:block;}
|
|
59
|
+
/* --- alerts list (surface cards) ------------------------------------------ */
|
|
60
|
+
.${d} .dtk-alerts{margin-top:18px;}
|
|
61
|
+
.${d} .dtk-alerts-head{font-size:12px;font-weight:600;color:var(--faint);
|
|
62
|
+
margin-bottom:9px;font-family:var(--mono);text-transform:uppercase;letter-spacing:0.06em;}
|
|
63
|
+
.${d} .dtk-alerts-empty{font-size:13px;color:var(--muted);padding:8px 0;}
|
|
64
|
+
.${d} .dtk-alerts-list{display:flex;flex-direction:column;gap:5px;
|
|
65
|
+
max-height:340px;overflow:auto;}
|
|
66
|
+
.${d} .dtk-alert-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;
|
|
67
|
+
padding:8px 11px;background:var(--surface);border:1px solid var(--border);
|
|
68
|
+
border-radius:8px;cursor:pointer;color:var(--ink);font-family:var(--sans);
|
|
69
|
+
transition:border-color .12s ease,box-shadow .12s ease;}
|
|
70
|
+
.${d} .dtk-alert-row:hover{border-color:var(--clay);
|
|
71
|
+
box-shadow:0 4px 14px -8px rgba(27,25,22,.35);}
|
|
72
|
+
.${d} .dtk-alert-time{font-size:11px;color:var(--muted);
|
|
73
|
+
font-family:var(--mono);white-space:nowrap;min-width:142px;}
|
|
74
|
+
.${d} .dtk-badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:11px;
|
|
75
|
+
font-weight:700;border:1px solid;text-transform:uppercase;letter-spacing:0.03em;white-space:nowrap;}
|
|
76
|
+
.${d} .dtk-alert-body{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
|
77
|
+
.${d} .dtk-alert-rule{font-size:12px;color:var(--ink);
|
|
78
|
+
font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
79
|
+
.${d} .dtk-alert-sub{font-size:11px;color:var(--muted);}
|
|
80
|
+
`,t=document.createElement("style");t.setAttribute("data-dtk-report",""),t.textContent=e,document.head.appendChild(t)}window.__DTK_REPORT__={render:Te};})();
|