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():
Budget Limits
Alert Rules
Telegram
+ 🔗 Integrations
History
@@ -2679,6 +2788,24 @@ def get_local_ip():
+
+
+
Configure Slack, Discord, or custom webhook destinations for all ClawMetry alerts.
+
+
+
Loading...
@@ -3483,12 +3610,13 @@ def get_local_ip():
function switchBudgetTab(tab, el) {
document.querySelectorAll('#budget-modal-tabs .modal-tab').forEach(function(t){t.classList.remove('active');});
if(el) el.classList.add('active');
- ['limits','alerts','telegram','history'].forEach(function(t){
+ ['limits','alerts','telegram','integrations','history'].forEach(function(t){
var d = document.getElementById('budget-tab-'+t);
if(d) d.style.display = t===tab ? 'block' : 'none';
});
if(tab==='alerts') loadAlertRules();
if(tab==='telegram') loadTelegramConfig();
+ if(tab==='integrations') loadAlertChannels();
if(tab==='history') loadAlertHistory();
}
@@ -3596,6 +3724,138 @@ def get_local_ip():
loadAlertRules();
}
+
+async function loadAlertChannels() {
+ try {
+ var data = await fetch('/api/alerts/channels').then(function(r){return r.json();});
+ var channels = data.channels || [];
+ var el = document.getElementById('alert-channels-list');
+ if(!el) return;
+ if(channels.length === 0) {
+ el.innerHTML = '
No integrations configured yet
';
+ return;
+ }
+ var typeIcon = {'slack':'
■ Slack','discord':'
◆ Discord','webhook':'
🔗 Webhook'};
+ var html = '';
+ channels.forEach(function(ch) {
+ var icon = typeIcon[ch.type] || ch.type;
+ var enabled = ch.enabled ? '● Enabled' : '○ Disabled';
+ html += '
';
+ html += '' + icon + ' — ' + escHtml(ch.name) + '';
+ html += '' + enabled + '';
+ html += '';
+ html += '';
+ html += '
';
+ });
+ el.innerHTML = html;
+ } catch(e) {
+ var el = document.getElementById('alert-channels-list');
+ if(el) el.textContent = 'Failed to load integrations';
+ }
+}
+
+async function addAlertChannel() {
+ var type = document.getElementById('new-channel-type').value;
+ var name = document.getElementById('new-channel-name').value.trim();
+ var url = document.getElementById('new-channel-url').value.trim();
+ if(!url) { alert('Webhook URL is required'); return; }
+ try {
+ var r = await fetch('/api/alerts/channels', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({type: type, name: name || type, webhook_url: url, enabled: true})
+ });
+ var d = await r.json();
+ if(d.ok) {
+ document.getElementById('new-channel-name').value = '';
+ document.getElementById('new-channel-url').value = '';
+ loadAlertChannels();
+ } else {
+ alert(d.error || 'Failed to add channel');
+ }
+ } catch(e) { alert('Error adding channel'); }
+}
+
+async function deleteAlertChannel(id) {
+ if(!confirm('Remove this integration?')) return;
+ await fetch('/api/alerts/channels/' + id, {method: 'DELETE'});
+ loadAlertChannels();
+}
+
+async function testAlertChannel(id) {
+ try {
+ var r = await fetch('/api/alerts/channels/' + id + '/test', {method: 'POST'});
+ var d = await r.json();
+ if(d.ok) { alert('Test alert sent successfully!'); }
+ else { alert('Test failed: ' + (d.error || 'Unknown error')); }
+ } catch(e) { alert('Error sending test'); }
+}
+
+async function loadAlertChannels() {
+ try {
+ var data = await fetch('/api/alerts/channels').then(function(r){return r.json();});
+ var channels = data.channels || [];
+ var el = document.getElementById('alert-channels-list');
+ if(!el) return;
+ if(channels.length === 0) {
+ el.innerHTML = '
No integrations configured yet
';
+ return;
+ }
+ var typeIcon = {'slack':'
■ Slack','discord':'
◆ Discord','webhook':'
🔗 Webhook'};
+ var html = '';
+ channels.forEach(function(ch) {
+ var icon = typeIcon[ch.type] || ch.type;
+ var enabled = ch.enabled ? '● Enabled' : '○ Disabled';
+ html += '
';
+ html += '' + icon + ' — ' + escHtml(ch.name) + '';
+ html += '' + enabled + '';
+ html += '';
+ html += '';
+ html += '
';
+ });
+ el.innerHTML = html;
+ } catch(e) {
+ var el = document.getElementById('alert-channels-list');
+ if(el) el.textContent = 'Failed to load integrations';
+ }
+}
+
+async function addAlertChannel() {
+ var type = document.getElementById('new-channel-type').value;
+ var name = document.getElementById('new-channel-name').value.trim();
+ var url = document.getElementById('new-channel-url').value.trim();
+ if(!url) { alert('Webhook URL is required'); return; }
+ try {
+ var r = await fetch('/api/alerts/channels', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({type: type, name: name || type, webhook_url: url, enabled: true})
+ });
+ var d = await r.json();
+ if(d.ok) {
+ document.getElementById('new-channel-name').value = '';
+ document.getElementById('new-channel-url').value = '';
+ loadAlertChannels();
+ } else {
+ alert(d.error || 'Failed to add channel');
+ }
+ } catch(e) { alert('Error adding channel'); }
+}
+
+async function deleteAlertChannel(id) {
+ if(!confirm('Remove this integration?')) return;
+ await fetch('/api/alerts/channels/' + id, {method: 'DELETE'});
+ loadAlertChannels();
+}
+
+async function testAlertChannel(id) {
+ try {
+ var r = await fetch('/api/alerts/channels/' + id + '/test', {method: 'POST'});
+ var d = await r.json();
+ if(d.ok) { alert('Test alert sent successfully!'); }
+ else { alert('Test failed: ' + (d.error || 'Unknown error')); }
+ } catch(e) { alert('Error sending test'); }
+}
async function loadAlertHistory() {
try {
var data = await fetch('/api/alerts/history?limit=50').then(function(r){return r.json();});
@@ -4972,7 +5232,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()
@@ -5198,6 +5486,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):
@@ -5244,6 +5534,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:
@@ -7229,6 +7597,7 @@ def get_local_ip():
Budget Limits
Alert Rules
Telegram
+
🔗 Integrations
History
@@ -7307,6 +7676,24 @@ def get_local_ip():
+
+
+
Configure Slack, Discord, or custom webhook destinations for all ClawMetry alerts.
+
+
+
Loading...
@@ -8111,12 +8498,13 @@ def get_local_ip():
function switchBudgetTab(tab, el) {
document.querySelectorAll('#budget-modal-tabs .modal-tab').forEach(function(t){t.classList.remove('active');});
if(el) el.classList.add('active');
- ['limits','alerts','telegram','history'].forEach(function(t){
+ ['limits','alerts','telegram','integrations','history'].forEach(function(t){
var d = document.getElementById('budget-tab-'+t);
if(d) d.style.display = t===tab ? 'block' : 'none';
});
if(tab==='alerts') loadAlertRules();
if(tab==='telegram') loadTelegramConfig();
+ if(tab==='integrations') loadAlertChannels();
if(tab==='history') loadAlertHistory();
}
@@ -17177,6 +17565,92 @@ def api_alerts_active():
return jsonify({'alerts': _get_active_alerts()})
+
+@bp_alerts.route('/api/alerts/channels', methods=['GET', 'POST'])
+def api_alert_channels():
+ """List or create alert channel integrations (Slack, Discord, webhook)."""
+ if request.method == 'POST':
+ data = request.get_json(silent=True) or {}
+ ch_type = data.get('type', '').lower()
+ name = data.get('name', '').strip()
+ webhook_url = data.get('webhook_url', '').strip()
+ enabled = data.get('enabled', True)
+ if ch_type not in ('slack', 'discord', 'webhook'):
+ return jsonify({'error': 'type must be slack, discord, or webhook'}), 400
+ if not webhook_url:
+ return jsonify({'error': 'webhook_url is required'}), 400
+ if not name:
+ name = ch_type.capitalize()
+ import uuid
+ ch_id = str(uuid.uuid4())[:8]
+ now = time.time()
+ with _fleet_db_lock:
+ db = _fleet_db()
+ db.execute(
+ "INSERT INTO alert_channels (id, name, type, webhook_url, enabled, created_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (ch_id, name, ch_type, webhook_url, 1 if enabled else 0, now, now)
+ )
+ db.commit()
+ db.close()
+ return jsonify({'ok': True, 'id': ch_id})
+ return jsonify({'channels': _get_alert_channels()})
+
+
+@bp_alerts.route('/api/alerts/channels/
', methods=['PUT', 'DELETE'])
+def api_alert_channel(ch_id):
+ """Update or delete an alert channel integration."""
+ if request.method == 'DELETE':
+ with _fleet_db_lock:
+ db = _fleet_db()
+ db.execute("DELETE FROM alert_channels WHERE id = ?", (ch_id,))
+ db.commit()
+ db.close()
+ return jsonify({'ok': True})
+ data = request.get_json(silent=True) or {}
+ sets, vals = [], []
+ for field in ['name', 'webhook_url']:
+ if field in data:
+ sets.append(f"{field} = ?")
+ vals.append(data[field])
+ if 'enabled' in data:
+ sets.append("enabled = ?")
+ vals.append(1 if data['enabled'] else 0)
+ if not sets:
+ return jsonify({'error': 'No fields to update'}), 400
+ sets.append("updated_at = ?")
+ vals.append(time.time())
+ vals.append(ch_id)
+ with _fleet_db_lock:
+ db = _fleet_db()
+ db.execute(f"UPDATE alert_channels SET {', '.join(sets)} WHERE id = ?", vals)
+ db.commit()
+ db.close()
+ return jsonify({'ok': True})
+
+
+@bp_alerts.route('/api/alerts/channels//test', methods=['POST'])
+def api_alert_channel_test(ch_id):
+ """Send a test alert to a specific channel."""
+ channels = _get_alert_channels()
+ ch = next((c for c in channels if c.get('id') == ch_id), None)
+ if not ch:
+ return jsonify({'error': 'Channel not found'}), 404
+ msg = 'This is a test alert from ClawMetry. If you see this, your integration is working!'
+ ch_type = ch.get('type', '')
+ url = ch.get('webhook_url', '')
+ try:
+ if ch_type == 'slack':
+ _send_slack_alert(url, msg, 'info')
+ elif ch_type == 'discord':
+ _send_discord_alert(url, msg, 'info')
+ elif ch_type == 'webhook':
+ _send_webhook_alert(url, {'type': 'test', 'message': msg, 'timestamp': time.time()})
+ return jsonify({'ok': True})
+ except Exception as e:
+ return jsonify({'ok': False, 'error': str(e)}), 500
+
+
# ── History / Time-Series API ────────────────────────────────────────────
@bp_history.route('/api/history/metrics')
diff --git a/tests/test_api.py b/tests/test_api.py
index 0bc61e0..1f02e5b 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -471,3 +471,118 @@ def test_memory_analytics_files_have_status(self, api, base_url):
for f in d["files"]:
assert_keys(f, "path", "sizeBytes", "sizeKB", "estTokens", "status")
assert f["status"] in ("ok", "warning", "critical")
+
+
+class TestAlertChannels:
+ """Tests for Webhook & Slack/Discord alerting integrations (GH #204)."""
+
+ def test_list_channels_empty(self, api, base_url):
+ """Alert channels list returns an empty list initially."""
+ d = assert_ok(get(api, base_url, "/api/alerts/channels"))
+ assert "channels" in d, "Response must have 'channels' key"
+ assert isinstance(d["channels"], list)
+
+ def test_create_slack_channel(self, api, base_url):
+ """Can create a Slack webhook integration."""
+ payload = {
+ "type": "slack",
+ "name": "test-slack",
+ "webhook_url": "https://hooks.slack.com/services/test/test/test",
+ "enabled": True,
+ }
+ r = api.post(f"{base_url}/api/alerts/channels", json=payload)
+ assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}"
+ d = r.json()
+ assert d.get("ok"), f"Expected ok=True, got {d}"
+ assert "id" in d, "Response must include channel id"
+
+ def test_create_discord_channel(self, api, base_url):
+ """Can create a Discord webhook integration."""
+ payload = {
+ "type": "discord",
+ "name": "test-discord",
+ "webhook_url": "https://discord.com/api/webhooks/test/test",
+ "enabled": True,
+ }
+ r = api.post(f"{base_url}/api/alerts/channels", json=payload)
+ assert r.status_code == 200
+ d = r.json()
+ assert d.get("ok")
+
+ def test_create_generic_webhook(self, api, base_url):
+ """Can create a generic webhook integration."""
+ payload = {
+ "type": "webhook",
+ "name": "test-webhook",
+ "webhook_url": "https://example.com/hook",
+ }
+ r = api.post(f"{base_url}/api/alerts/channels", json=payload)
+ assert r.status_code == 200
+ d = r.json()
+ assert d.get("ok")
+
+ def test_invalid_channel_type_rejected(self, api, base_url):
+ """Invalid channel type returns 400."""
+ r = api.post(f"{base_url}/api/alerts/channels", json={
+ "type": "pagerduty",
+ "webhook_url": "https://example.com",
+ })
+ assert r.status_code == 400
+
+ def test_missing_webhook_url_rejected(self, api, base_url):
+ """Missing webhook_url returns 400."""
+ r = api.post(f"{base_url}/api/alerts/channels", json={"type": "slack"})
+ assert r.status_code == 400
+
+ def test_created_channels_appear_in_list(self, api, base_url):
+ """Channels created via POST appear in GET list."""
+ api.post(f"{base_url}/api/alerts/channels", json={
+ "type": "webhook",
+ "name": "list-test",
+ "webhook_url": "https://example.com/list-test",
+ })
+ d = assert_ok(get(api, base_url, "/api/alerts/channels"))
+ names = [c.get("name") for c in d["channels"]]
+ assert "list-test" in names
+
+ def test_channel_entry_schema(self, api, base_url):
+ """Each channel entry has required fields."""
+ api.post(f"{base_url}/api/alerts/channels", json={
+ "type": "discord",
+ "name": "schema-test",
+ "webhook_url": "https://discord.com/api/webhooks/schema/test",
+ })
+ d = assert_ok(get(api, base_url, "/api/alerts/channels"))
+ for ch in d["channels"]:
+ assert_keys(ch, "id", "name", "type", "webhook_url", "enabled")
+
+ def test_delete_channel(self, api, base_url):
+ """Can delete a channel integration."""
+ r = api.post(f"{base_url}/api/alerts/channels", json={
+ "type": "webhook",
+ "name": "delete-test",
+ "webhook_url": "https://example.com/delete-test",
+ })
+ ch_id = r.json().get("id")
+ assert ch_id
+ dr = api.delete(f"{base_url}/api/alerts/channels/{ch_id}")
+ assert dr.status_code == 200
+ d = assert_ok(get(api, base_url, "/api/alerts/channels"))
+ ids = [c.get("id") for c in d["channels"]]
+ assert ch_id not in ids
+
+ def test_update_channel_enabled_state(self, api, base_url):
+ """Can enable/disable a channel via PUT."""
+ r = api.post(f"{base_url}/api/alerts/channels", json={
+ "type": "slack",
+ "name": "update-test",
+ "webhook_url": "https://hooks.slack.com/test/update",
+ })
+ ch_id = r.json().get("id")
+ assert ch_id
+ ur = api.put(f"{base_url}/api/alerts/channels/{ch_id}", json={"enabled": False})
+ assert ur.status_code == 200
+ d = assert_ok(get(api, base_url, "/api/alerts/channels"))
+ ch = next((c for c in d["channels"] if c.get("id") == ch_id), None)
+ assert ch is not None
+ assert ch.get("enabled") in (0, False)