diff --git a/dashboard.py b/dashboard.py index 3bf081e..cd3bb6d 100755 --- a/dashboard.py +++ b/dashboard.py @@ -424,7 +424,35 @@ def _budget_init_db(): ON alert_history(fired_at DESC); CREATE INDEX IF NOT EXISTS idx_alert_history_rule ON alert_history(rule_id, fired_at DESC); + CREATE TABLE IF NOT EXISTS alert_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + webhook_url TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); """) + # Migration: ensure alert_channels uses v2 schema with webhook_url (not config) + try: + cols = [r[1] for r in db.execute("PRAGMA table_info(alert_channels)").fetchall()] + needs_recreate = cols and ('config' in cols or 'webhook_url' not in cols) + if needs_recreate: + # v1 schema detected — drop and recreate with v2 schema + db.execute("DROP TABLE IF EXISTS alert_channels") + db.execute("""CREATE TABLE alert_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + webhook_url TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + )""") + db.commit() + except Exception as e: + print(f"Warning: alert_channels migration failed: {e}") db.close() @@ -650,6 +678,8 @@ def _fire_alert(rule_id, alert_type, message, channels=None): _send_telegram_alert(message) elif ch == 'webhook': pass # webhook sending handled by custom alert rules + # Also dispatch to configured Slack/Discord/webhook channels + _dispatch_alert_to_channels(message) def _send_telegram_alert(message): @@ -696,6 +726,84 @@ def _send_webhook_alert(url, alert_data): pass +def _send_slack_alert(webhook_url, message, alert_type='alert'): + """Send alert to a Slack incoming webhook URL.""" + if not webhook_url: + return + try: + import urllib.request as _ur + colour = {'alert': '#E01E5A', 'warning': '#ECB22E', 'info': '#2EB67D'}.get(alert_type, '#E01E5A') + payload = json.dumps({ + 'attachments': [{ + 'color': colour, + 'fallback': f'[ClawMetry] {message}', + 'text': f'*ClawMetry Alert*\n{message}', + 'footer': 'ClawMetry', + 'ts': int(time.time()), + }] + }).encode() + req = _ur.Request(webhook_url, data=payload, headers={'Content-Type': 'application/json'}, method='POST') + _ur.urlopen(req, timeout=10) + except Exception as e: + print(f"Warning: Slack alert failed: {e}") + + +def _send_discord_alert(webhook_url, message, alert_type='alert'): + """Send alert to a Discord webhook URL.""" + if not webhook_url: + return + try: + import urllib.request as _ur + colour = {'alert': 0xE01E5A, 'warning': 0xECB22E, 'info': 0x2EB67D}.get(alert_type, 0xE01E5A) + payload = json.dumps({ + 'embeds': [{ + 'title': 'ClawMetry Alert', + 'description': message, + 'color': colour, + 'timestamp': datetime.utcnow().isoformat(), + 'footer': {'text': 'ClawMetry'}, + }] + }).encode() + req = _ur.Request(webhook_url, data=payload, headers={'Content-Type': 'application/json'}, method='POST') + _ur.urlopen(req, timeout=10) + except Exception as e: + print(f"Warning: Discord alert failed: {e}") + + +def _get_alert_channels(): + """Get all configured alert channel destinations.""" + try: + with _fleet_db_lock: + db = _fleet_db() + rows = db.execute("SELECT * FROM alert_channels ORDER BY created_at DESC").fetchall() + db.close() + return [dict(r) for r in rows] + except Exception: + return [] + + +def _dispatch_alert_to_channels(message, alert_type='alert'): + """Dispatch an alert message to all enabled alert channels (Slack, Discord, webhook).""" + channels = _get_alert_channels() + for ch in channels: + if not ch.get('enabled'): + continue + ch_type = ch.get('type', '') + url = ch.get('webhook_url', '') + if not url: + continue + try: + if ch_type == 'slack': + _send_slack_alert(url, message, alert_type) + elif ch_type == 'discord': + _send_discord_alert(url, message, alert_type) + elif ch_type == 'webhook': + _send_webhook_alert(url, {'type': alert_type, 'message': message, 'timestamp': time.time()}) + except Exception as e: + print(f"Warning: Alert dispatch to {ch_type} failed: {e}") + + + def _get_alert_rules(): """Get all alert rules.""" try: @@ -2601,6 +2709,7 @@ def get_local_ip(): + @@ -2679,6 +2788,24 @@ def get_local_ip():
+ + @@ -7307,6 +7676,24 @@ def get_local_ip():
+ +