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.
Files changed (59) hide show
  1. {detectkit-0.2.2/detectkit.egg-info → detectkit-0.2.4}/PKG-INFO +1 -1
  2. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/commands/run.py +50 -14
  3. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/clickhouse_manager.py +11 -3
  4. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/internal_tables.py +2 -2
  5. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/orchestration/task_manager.py +62 -2
  6. {detectkit-0.2.2 → detectkit-0.2.4/detectkit.egg-info}/PKG-INFO +1 -1
  7. {detectkit-0.2.2 → detectkit-0.2.4}/pyproject.toml +1 -1
  8. {detectkit-0.2.2 → detectkit-0.2.4}/LICENSE +0 -0
  9. {detectkit-0.2.2 → detectkit-0.2.4}/MANIFEST.in +0 -0
  10. {detectkit-0.2.2 → detectkit-0.2.4}/README.md +0 -0
  11. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/__init__.py +0 -0
  12. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/__init__.py +0 -0
  13. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/__init__.py +0 -0
  14. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/base.py +0 -0
  15. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/email.py +0 -0
  16. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/factory.py +0 -0
  17. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/mattermost.py +0 -0
  18. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/slack.py +0 -0
  19. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/telegram.py +0 -0
  20. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/channels/webhook.py +0 -0
  21. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/alerting/orchestrator.py +0 -0
  22. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/__init__.py +0 -0
  23. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/commands/__init__.py +0 -0
  24. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/commands/init.py +0 -0
  25. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/commands/test_alert.py +0 -0
  26. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/cli/main.py +0 -0
  27. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/__init__.py +0 -0
  28. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/metric_config.py +0 -0
  29. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/profile.py +0 -0
  30. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/project_config.py +0 -0
  31. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/config/validator.py +0 -0
  32. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/core/__init__.py +0 -0
  33. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/core/interval.py +0 -0
  34. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/core/models.py +0 -0
  35. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/__init__.py +0 -0
  36. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/manager.py +0 -0
  37. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/database/tables.py +0 -0
  38. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/__init__.py +0 -0
  39. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/base.py +0 -0
  40. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/factory.py +0 -0
  41. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/__init__.py +0 -0
  42. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/iqr.py +0 -0
  43. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/mad.py +0 -0
  44. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  45. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/detectors/statistical/zscore.py +0 -0
  46. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/loaders/__init__.py +0 -0
  47. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/loaders/metric_loader.py +0 -0
  48. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/loaders/query_template.py +0 -0
  49. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/orchestration/__init__.py +0 -0
  50. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/utils/__init__.py +0 -0
  51. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit/utils/stats.py +0 -0
  52. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/SOURCES.txt +0 -0
  53. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/dependency_links.txt +0 -0
  54. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/entry_points.txt +0 -0
  55. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/requires.txt +0 -0
  56. {detectkit-0.2.2 → detectkit-0.2.4}/detectkit.egg-info/top_level.txt +0 -0
  57. {detectkit-0.2.2 → detectkit-0.2.4}/requirements.txt +0 -0
  58. {detectkit-0.2.2 → detectkit-0.2.4}/setup.cfg +0 -0
  59. {detectkit-0.2.2 → detectkit-0.2.4}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -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 (only searches root metrics/ directory)
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(" Success!", fg="green", bold=True))
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([last_processed_timestamp], dtype="datetime64[ms]") if last_processed_timestamp else np.array([None]),
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
- anomalies_count += sum(1 for r in batch_results if r.is_anomaly)
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "detectkit"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Metric monitoring with automatic anomaly detection"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes