detectkit 0.2.2__tar.gz → 0.2.4__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.2.2/detectkit.egg-info → detectkit-0.2.4}/PKG-INFO +1 -1
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/commands/run.py +50 -14
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/clickhouse_manager.py +11 -3
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/internal_tables.py +2 -2
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/orchestration/task_manager.py +62 -2
- {detectkit-0.2.2 → detectkit-0.2.4/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.2.2 → detectkit-0.2.4}/pyproject.toml +1 -1
- {detectkit-0.2.2 → detectkit-0.2.4}/LICENSE +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/MANIFEST.in +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/README.md +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/orchestrator.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/main.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/profile.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/project_config.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/validator.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/core/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/core/interval.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/core/models.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/manager.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/tables.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/base.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/utils/stats.py +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/requirements.txt +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/setup.cfg +0 -0
- {detectkit-0.2.2 → detectkit-0.2.4}/setup.py +0 -0
|
@@ -282,10 +282,14 @@ def select_metrics(selector: str, project_root: Path) -> List[tuple[Path, Metric
|
|
|
282
282
|
Select metrics based on selector and validate uniqueness.
|
|
283
283
|
|
|
284
284
|
Selector types:
|
|
285
|
-
- Metric name: "cpu_usage"
|
|
286
|
-
- Path pattern: "metrics/critical/*.yml"
|
|
285
|
+
- Metric name: "cpu_usage" (searches by 'name' field recursively in subdirectories)
|
|
286
|
+
- Path pattern: "metrics/critical/*.yml" or "league/cpu_usage"
|
|
287
287
|
- Tag: "tag:critical"
|
|
288
288
|
|
|
289
|
+
For name selector:
|
|
290
|
+
1. First tries filename-based search in root metrics/ directory
|
|
291
|
+
2. If not found, searches recursively by 'name' field in all subdirectories
|
|
292
|
+
|
|
289
293
|
Args:
|
|
290
294
|
selector: Selector string
|
|
291
295
|
project_root: Project root path
|
|
@@ -312,8 +316,9 @@ def select_metrics(selector: str, project_root: Path) -> List[tuple[Path, Metric
|
|
|
312
316
|
elif "*" in selector or "/" in selector:
|
|
313
317
|
pattern = selector if selector.startswith("metrics/") else f"metrics/{selector}"
|
|
314
318
|
metric_paths = list(project_root.glob(pattern))
|
|
315
|
-
# Metric name selector
|
|
319
|
+
# Metric name selector
|
|
316
320
|
else:
|
|
321
|
+
# First try filename-based search in root (backward compatibility)
|
|
317
322
|
metric_file = metrics_dir / f"{selector}.yml"
|
|
318
323
|
if metric_file.exists():
|
|
319
324
|
metric_paths = [metric_file]
|
|
@@ -322,6 +327,11 @@ def select_metrics(selector: str, project_root: Path) -> List[tuple[Path, Metric
|
|
|
322
327
|
metric_file = metrics_dir / f"{selector}.yaml"
|
|
323
328
|
if metric_file.exists():
|
|
324
329
|
metric_paths = [metric_file]
|
|
330
|
+
else:
|
|
331
|
+
# Fall back to recursive search by 'name' field
|
|
332
|
+
found_metric = find_metric_by_name(metrics_dir, selector)
|
|
333
|
+
if found_metric:
|
|
334
|
+
metric_paths = [found_metric]
|
|
325
335
|
|
|
326
336
|
if not metric_paths:
|
|
327
337
|
return []
|
|
@@ -361,6 +371,35 @@ def find_metrics_by_tag(metrics_dir: Path, tag: str) -> List[Path]:
|
|
|
361
371
|
return matching_metrics
|
|
362
372
|
|
|
363
373
|
|
|
374
|
+
def find_metric_by_name(metrics_dir: Path, name: str) -> Optional[Path]:
|
|
375
|
+
"""
|
|
376
|
+
Find metric by name field (searches recursively in subdirectories).
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
metrics_dir: Metrics directory path
|
|
380
|
+
name: Metric name to search for (from 'name' field in YAML)
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Path to metric file if found, None otherwise
|
|
384
|
+
"""
|
|
385
|
+
import yaml
|
|
386
|
+
|
|
387
|
+
# Search both .yml and .yaml extensions
|
|
388
|
+
for pattern in ["**/*.yml", "**/*.yaml"]:
|
|
389
|
+
for metric_file in metrics_dir.glob(pattern):
|
|
390
|
+
try:
|
|
391
|
+
with open(metric_file) as f:
|
|
392
|
+
config = yaml.safe_load(f)
|
|
393
|
+
|
|
394
|
+
if config and config.get("name") == name:
|
|
395
|
+
return metric_file
|
|
396
|
+
except Exception:
|
|
397
|
+
# Skip files that can't be parsed
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
364
403
|
def process_metric(
|
|
365
404
|
metric_path: Path,
|
|
366
405
|
config: MetricConfig,
|
|
@@ -406,6 +445,11 @@ def process_metric(
|
|
|
406
445
|
|
|
407
446
|
# Run pipeline
|
|
408
447
|
try:
|
|
448
|
+
# Log step headers
|
|
449
|
+
if PipelineStep.LOAD in steps:
|
|
450
|
+
click.echo()
|
|
451
|
+
click.echo(click.style(" ┌─ LOAD", fg="cyan", bold=True))
|
|
452
|
+
|
|
409
453
|
result = task_manager.run_metric(
|
|
410
454
|
config=config,
|
|
411
455
|
steps=steps,
|
|
@@ -415,18 +459,10 @@ def process_metric(
|
|
|
415
459
|
force=force,
|
|
416
460
|
)
|
|
417
461
|
|
|
418
|
-
# Display results
|
|
462
|
+
# Display results - task_manager already printed details
|
|
463
|
+
click.echo()
|
|
419
464
|
if result["status"] == "success":
|
|
420
|
-
click.echo(click.style("
|
|
421
|
-
|
|
422
|
-
if PipelineStep.LOAD in steps:
|
|
423
|
-
click.echo(f" Loaded: {result['datapoints_loaded']} datapoints")
|
|
424
|
-
|
|
425
|
-
if PipelineStep.DETECT in steps:
|
|
426
|
-
click.echo(f" Detected: {result['anomalies_detected']} anomalies")
|
|
427
|
-
|
|
428
|
-
if PipelineStep.ALERT in steps:
|
|
429
|
-
click.echo(f" Sent: {result['alerts_sent']} alerts")
|
|
465
|
+
click.echo(click.style("✓ Pipeline completed successfully", fg="green", bold=True))
|
|
430
466
|
else:
|
|
431
467
|
click.echo(
|
|
432
468
|
click.style(
|
|
@@ -330,8 +330,8 @@ class ClickHouseDatabaseManager(BaseDatabaseManager):
|
|
|
330
330
|
"""
|
|
331
331
|
from detectkit.database.tables import TABLE_TASKS
|
|
332
332
|
|
|
333
|
-
# Get current UTC time
|
|
334
|
-
now = datetime.now(timezone.utc)
|
|
333
|
+
# Get current UTC time (convert to naive UTC for numpy compatibility)
|
|
334
|
+
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
335
335
|
|
|
336
336
|
# First, delete existing record (if any)
|
|
337
337
|
delete_query = f"""
|
|
@@ -350,6 +350,14 @@ class ClickHouseDatabaseManager(BaseDatabaseManager):
|
|
|
350
350
|
}
|
|
351
351
|
)
|
|
352
352
|
|
|
353
|
+
# Convert last_processed_timestamp to naive UTC if needed
|
|
354
|
+
last_ts_naive = None
|
|
355
|
+
if last_processed_timestamp:
|
|
356
|
+
if last_processed_timestamp.tzinfo is not None:
|
|
357
|
+
last_ts_naive = last_processed_timestamp.replace(tzinfo=None)
|
|
358
|
+
else:
|
|
359
|
+
last_ts_naive = last_processed_timestamp
|
|
360
|
+
|
|
353
361
|
# Then insert new record
|
|
354
362
|
insert_data = {
|
|
355
363
|
"metric_name": np.array([metric_name]),
|
|
@@ -358,7 +366,7 @@ class ClickHouseDatabaseManager(BaseDatabaseManager):
|
|
|
358
366
|
"status": np.array([status]),
|
|
359
367
|
"started_at": np.array([now], dtype="datetime64[ms]"),
|
|
360
368
|
"updated_at": np.array([now], dtype="datetime64[ms]"),
|
|
361
|
-
"last_processed_timestamp": np.array([
|
|
369
|
+
"last_processed_timestamp": np.array([last_ts_naive], dtype="datetime64[ms]") if last_ts_naive else np.array([None]),
|
|
362
370
|
"error_message": np.array([error_message]),
|
|
363
371
|
"timeout_seconds": np.array([timeout_seconds], dtype=np.int32),
|
|
364
372
|
}
|
|
@@ -125,7 +125,7 @@ class InternalTablesManager:
|
|
|
125
125
|
num_rows, ",".join(seasonality_columns), dtype=object
|
|
126
126
|
),
|
|
127
127
|
"created_at": np.full(
|
|
128
|
-
num_rows, datetime.now(timezone.utc), dtype="datetime64[ms]"
|
|
128
|
+
num_rows, datetime.now(timezone.utc).replace(tzinfo=None), dtype="datetime64[ms]"
|
|
129
129
|
),
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -196,7 +196,7 @@ class InternalTablesManager:
|
|
|
196
196
|
"detector_params": np.full(num_rows, detector_params, dtype=object),
|
|
197
197
|
"detection_metadata": data["detection_metadata"],
|
|
198
198
|
"created_at": np.full(
|
|
199
|
-
num_rows, datetime.now(timezone.utc), dtype="datetime64[ms]"
|
|
199
|
+
num_rows, datetime.now(timezone.utc).replace(tzinfo=None), dtype="datetime64[ms]"
|
|
200
200
|
),
|
|
201
201
|
}
|
|
202
202
|
|
|
@@ -12,6 +12,7 @@ from enum import Enum
|
|
|
12
12
|
from typing import Dict, List, Optional
|
|
13
13
|
import json
|
|
14
14
|
|
|
15
|
+
import click
|
|
15
16
|
import numpy as np
|
|
16
17
|
|
|
17
18
|
from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
|
|
@@ -165,12 +166,16 @@ class TaskManager:
|
|
|
165
166
|
|
|
166
167
|
# Step 2: Detect anomalies
|
|
167
168
|
if PipelineStep.DETECT in steps:
|
|
169
|
+
click.echo()
|
|
170
|
+
click.echo(click.style(" ┌─ DETECT", fg="cyan", bold=True))
|
|
168
171
|
detect_result = self._run_detect_step(config, from_date, to_date, full_refresh)
|
|
169
172
|
result["anomalies_detected"] = detect_result["anomalies_count"]
|
|
170
173
|
result["steps_completed"].append(PipelineStep.DETECT)
|
|
171
174
|
|
|
172
175
|
# Step 3: Send alerts
|
|
173
176
|
if PipelineStep.ALERT in steps:
|
|
177
|
+
click.echo()
|
|
178
|
+
click.echo(click.style(" ┌─ ALERT", fg="cyan", bold=True))
|
|
174
179
|
alert_result = self._run_alert_step(config)
|
|
175
180
|
result["alerts_sent"] = alert_result["alerts_sent"]
|
|
176
181
|
result["steps_completed"].append(PipelineStep.ALERT)
|
|
@@ -221,6 +226,7 @@ class TaskManager:
|
|
|
221
226
|
|
|
222
227
|
# Determine date range
|
|
223
228
|
if full_refresh:
|
|
229
|
+
click.echo(" │ Deleting existing datapoints...")
|
|
224
230
|
# Delete existing data for this metric
|
|
225
231
|
self.internal.delete_datapoints(
|
|
226
232
|
metric_name=config.name,
|
|
@@ -239,12 +245,14 @@ class TaskManager:
|
|
|
239
245
|
# Start from next interval after last timestamp
|
|
240
246
|
interval = config.get_interval()
|
|
241
247
|
actual_from = last_ts + timedelta(seconds=interval.seconds)
|
|
248
|
+
click.echo(f" │ Resuming from last saved: {last_ts.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
242
249
|
else:
|
|
243
250
|
# No data yet - use loading_start_time from config
|
|
244
251
|
if config.loading_start_time:
|
|
245
252
|
actual_from = datetime.strptime(
|
|
246
253
|
config.loading_start_time, "%Y-%m-%d %H:%M:%S"
|
|
247
254
|
).replace(tzinfo=timezone.utc)
|
|
255
|
+
click.echo(f" │ Starting fresh from: {config.loading_start_time}")
|
|
248
256
|
else:
|
|
249
257
|
raise ValueError(
|
|
250
258
|
"No existing data and no loading_start_time configured. "
|
|
@@ -260,16 +268,26 @@ class TaskManager:
|
|
|
260
268
|
total_points = int(total_seconds / interval.seconds)
|
|
261
269
|
batch_size = config.loading_batch_size
|
|
262
270
|
|
|
271
|
+
click.echo(f" │ Loading from {actual_from.strftime('%Y-%m-%d %H:%M:%S')} to {actual_to.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
272
|
+
click.echo(f" │ Total points: ~{total_points:,} | Batch size: {batch_size:,}")
|
|
273
|
+
|
|
263
274
|
# If total points <= batch_size, load in one go
|
|
264
275
|
if total_points <= batch_size:
|
|
276
|
+
click.echo(" │ Loading in single batch...")
|
|
265
277
|
rows_inserted = loader.load_and_save(from_date=actual_from, to_date=actual_to)
|
|
278
|
+
click.echo(click.style(f" └─ Loaded {rows_inserted:,} datapoints", fg="green"))
|
|
266
279
|
return {"points_loaded": rows_inserted}
|
|
267
280
|
|
|
268
281
|
# Load in batches
|
|
269
282
|
total_loaded = 0
|
|
270
283
|
current_from = actual_from
|
|
284
|
+
num_batches = int(total_points / batch_size) + 1
|
|
285
|
+
batch_num = 0
|
|
286
|
+
|
|
287
|
+
click.echo(f" │ Loading in {num_batches} batches...")
|
|
271
288
|
|
|
272
289
|
while current_from < actual_to:
|
|
290
|
+
batch_num += 1
|
|
273
291
|
# Calculate batch end time
|
|
274
292
|
batch_seconds = batch_size * interval.seconds
|
|
275
293
|
batch_to = current_from + timedelta(seconds=batch_seconds)
|
|
@@ -280,9 +298,12 @@ class TaskManager:
|
|
|
280
298
|
rows = loader.load_and_save(from_date=current_from, to_date=batch_to)
|
|
281
299
|
total_loaded += rows
|
|
282
300
|
|
|
301
|
+
click.echo(f" │ Batch {batch_num}/{num_batches}: +{rows:,} points (total: {total_loaded:,})")
|
|
302
|
+
|
|
283
303
|
# Move to next batch
|
|
284
304
|
current_from = batch_to
|
|
285
305
|
|
|
306
|
+
click.echo(click.style(f" └─ Loaded {total_loaded:,} datapoints", fg="green"))
|
|
286
307
|
return {"points_loaded": total_loaded}
|
|
287
308
|
|
|
288
309
|
def _run_detect_step(
|
|
@@ -308,10 +329,12 @@ class TaskManager:
|
|
|
308
329
|
|
|
309
330
|
# Skip if no detectors configured
|
|
310
331
|
if not config.detectors:
|
|
332
|
+
click.echo(" │ No detectors configured, skipping detection")
|
|
311
333
|
return {"anomalies_count": 0}
|
|
312
334
|
|
|
313
335
|
# Get interval
|
|
314
336
|
interval = config.get_interval()
|
|
337
|
+
click.echo(f" │ Running {len(config.detectors)} detector(s)...")
|
|
315
338
|
|
|
316
339
|
# Determine to_date if not specified
|
|
317
340
|
actual_to = to_date or datetime.now(timezone.utc)
|
|
@@ -325,7 +348,10 @@ class TaskManager:
|
|
|
325
348
|
normalized_from_date = normalized_from_date.replace(tzinfo=None)
|
|
326
349
|
|
|
327
350
|
# Run each detector
|
|
328
|
-
for detector_config in config.detectors:
|
|
351
|
+
for idx, detector_config in enumerate(config.detectors, 1):
|
|
352
|
+
click.echo(f" │")
|
|
353
|
+
click.echo(f" │ [{idx}/{len(config.detectors)}] Detector: {detector_config.type}")
|
|
354
|
+
|
|
329
355
|
# Create detector to get detector_id
|
|
330
356
|
# Combine algorithm params with execution params (seasonality_components)
|
|
331
357
|
detector_params = detector_config.get_algorithm_params()
|
|
@@ -344,6 +370,7 @@ class TaskManager:
|
|
|
344
370
|
|
|
345
371
|
# Delete existing detections if full_refresh
|
|
346
372
|
if full_refresh:
|
|
373
|
+
click.echo(" │ Deleting existing detections...")
|
|
347
374
|
self.internal.delete_detections(
|
|
348
375
|
metric_name=config.name,
|
|
349
376
|
detector_id=detector_id,
|
|
@@ -387,6 +414,7 @@ class TaskManager:
|
|
|
387
414
|
|
|
388
415
|
# Skip if nothing to detect
|
|
389
416
|
if not actual_from or actual_from >= actual_to:
|
|
417
|
+
click.echo(" │ Nothing to detect (already up to date)")
|
|
390
418
|
continue
|
|
391
419
|
|
|
392
420
|
# Get batch_size and context_size
|
|
@@ -397,10 +425,17 @@ class TaskManager:
|
|
|
397
425
|
total_seconds = (actual_to - actual_from).total_seconds()
|
|
398
426
|
total_points = int(total_seconds / interval.seconds)
|
|
399
427
|
|
|
428
|
+
click.echo(f" │ Detecting from {actual_from.strftime('%Y-%m-%d %H:%M:%S')} to {actual_to.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
429
|
+
click.echo(f" │ Total points: ~{total_points:,} | Batch size: {batch_size:,}")
|
|
430
|
+
|
|
400
431
|
# BATCHING: Process in batches
|
|
401
432
|
current_from = actual_from
|
|
433
|
+
detector_anomalies = 0
|
|
434
|
+
num_batches = int(total_points / batch_size) + 1 if total_points > batch_size else 1
|
|
435
|
+
batch_num = 0
|
|
402
436
|
|
|
403
437
|
while current_from < actual_to:
|
|
438
|
+
batch_num += 1
|
|
404
439
|
# Calculate batch end
|
|
405
440
|
batch_seconds = batch_size * interval.seconds
|
|
406
441
|
batch_to = current_from + timedelta(seconds=batch_seconds)
|
|
@@ -461,11 +496,19 @@ class TaskManager:
|
|
|
461
496
|
)
|
|
462
497
|
|
|
463
498
|
# Count anomalies
|
|
464
|
-
|
|
499
|
+
batch_anomalies = sum(1 for r in batch_results if r.is_anomaly)
|
|
500
|
+
detector_anomalies += batch_anomalies
|
|
501
|
+
anomalies_count += batch_anomalies
|
|
502
|
+
|
|
503
|
+
if num_batches > 1:
|
|
504
|
+
click.echo(f" │ Batch {batch_num}/{num_batches}: {len(batch_results):,} points, {batch_anomalies} anomalies")
|
|
465
505
|
|
|
466
506
|
# Move to next batch
|
|
467
507
|
current_from = batch_to
|
|
468
508
|
|
|
509
|
+
click.echo(click.style(f" │ └─ Detected {detector_anomalies:,} anomalies", fg="yellow" if detector_anomalies > 0 else "green"))
|
|
510
|
+
|
|
511
|
+
click.echo(click.style(f" └─ Total anomalies: {anomalies_count:,}", fg="yellow" if anomalies_count > 0 else "green"))
|
|
469
512
|
return {"anomalies_count": anomalies_count}
|
|
470
513
|
|
|
471
514
|
def _run_alert_step(self, config: MetricConfig) -> Dict[str, int]:
|
|
@@ -482,11 +525,15 @@ class TaskManager:
|
|
|
482
525
|
|
|
483
526
|
# Check if alerting is configured
|
|
484
527
|
if not config.alerting or not config.alerting.enabled:
|
|
528
|
+
click.echo(" │ Alerting not enabled")
|
|
485
529
|
return {"alerts_sent": 0}
|
|
486
530
|
|
|
487
531
|
if not config.alerting.channels:
|
|
532
|
+
click.echo(" │ No alert channels configured")
|
|
488
533
|
return {"alerts_sent": 0}
|
|
489
534
|
|
|
535
|
+
click.echo(f" │ Checking alert conditions...")
|
|
536
|
+
|
|
490
537
|
# Get alerting config
|
|
491
538
|
alerting_config = config.alerting
|
|
492
539
|
|
|
@@ -515,12 +562,15 @@ class TaskManager:
|
|
|
515
562
|
)
|
|
516
563
|
|
|
517
564
|
if not recent_detections:
|
|
565
|
+
click.echo(" │ No recent detections found")
|
|
518
566
|
return {"alerts_sent": 0}
|
|
519
567
|
|
|
520
568
|
# Check if alert should be sent
|
|
521
569
|
should_alert, alert_data = orchestrator.should_alert(recent_detections)
|
|
522
570
|
|
|
523
571
|
if should_alert:
|
|
572
|
+
click.echo(click.style(f" │ ⚠ Alert triggered! Sending to {len(alerting_config.channels)} channel(s)...", fg="yellow", bold=True))
|
|
573
|
+
|
|
524
574
|
# Create alert channels from config
|
|
525
575
|
channels = self._create_alert_channels(alerting_config.channels)
|
|
526
576
|
|
|
@@ -529,6 +579,16 @@ class TaskManager:
|
|
|
529
579
|
results = orchestrator.send_alerts(alert_data, channels)
|
|
530
580
|
alerts_sent = sum(1 for success in results.values() if success)
|
|
531
581
|
|
|
582
|
+
for channel_name, success in results.items():
|
|
583
|
+
status = click.style("✓", fg="green") if success else click.style("✗", fg="red")
|
|
584
|
+
click.echo(f" │ {status} {channel_name}")
|
|
585
|
+
|
|
586
|
+
click.echo(click.style(f" └─ Sent {alerts_sent}/{len(channels)} alerts", fg="green" if alerts_sent > 0 else "yellow"))
|
|
587
|
+
else:
|
|
588
|
+
click.echo(click.style(" └─ No valid alert channels available", fg="yellow"))
|
|
589
|
+
else:
|
|
590
|
+
click.echo(" └─ No alert needed (conditions not met)")
|
|
591
|
+
|
|
532
592
|
return {"alerts_sent": alerts_sent}
|
|
533
593
|
|
|
534
594
|
def _load_recent_detections(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|