From b618cb29ea7660e6130930d3a8c8c3203ab273bf Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Wed, 22 Apr 2026 14:42:58 +0200 Subject: [PATCH] feat: alert rules management UI - Add Alert Rules panel between Alerts and Filters sections - List all rules with severity badge, on/off toggle, conditions preview - Add Rule button opens modal with form for name, severity, message, conditions - Edit existing rules inline - Delete rules with confirmation - Condition builder supports eq, neq, contains, in, after_hours operators --- backend/frontend/index.html | 164 +++++++++++++++++++++++++++++++++++- backend/frontend/style.css | 124 +++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 1 deletion(-) diff --git a/backend/frontend/index.html b/backend/frontend/index.html index 5369818..bda1650 100644 --- a/backend/frontend/index.html +++ b/backend/frontend/index.html @@ -4,7 +4,7 @@ Admin Operations Center - + @@ -119,6 +119,96 @@ +
+
+

Alert Rules

+ +
+
+ +
+
+

No custom rules yet. Pre-built admin-ops rules are active by default. Add your own rules to detect specific patterns.

+
+ + +
+
@@ -373,6 +463,10 @@ alertsTotal: 0, alertsPage: 1, alertsFilter: { status: 'open', severity: '' }, + rules: [], + ruleModalOpen: false, + ruleEditId: null, + ruleEdit: { name: '', enabled: true, severity: 'medium', message: '', conditions: [] }, askQuestionText: '', askLoading: false, askAnswer: '', @@ -391,6 +485,7 @@ await this.loadSourceHealth(); await this.loadAlertSummary(); await this.loadAlerts(); + await this.loadRules(); await this.loadEvents(); } }, @@ -790,6 +885,73 @@ } catch {} }, + async loadRules() { + try { + const res = await fetch('/api/rules', { headers: this.authHeader() }); + if (!res.ok) return; + this.rules = await res.json(); + } catch {} + }, + + openRuleEditor(rule) { + if (rule) { + this.ruleEditId = rule.id; + this.ruleEdit = { + name: rule.name, + enabled: rule.enabled, + severity: rule.severity, + message: rule.message, + conditions: JSON.parse(JSON.stringify(rule.conditions)), + }; + } else { + this.ruleEditId = null; + this.ruleEdit = { name: '', enabled: true, severity: 'medium', message: '', conditions: [] }; + } + this.ruleModalOpen = true; + }, + + async saveRule() { + const payload = { ...this.ruleEdit }; + try { + const url = this.ruleEditId ? `/api/rules/${this.ruleEditId}` : '/api/rules'; + const method = this.ruleEditId ? 'PUT' : 'POST'; + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(await res.text()); + this.ruleModalOpen = false; + await this.loadRules(); + } catch (err) { + alert('Failed to save rule: ' + err.message); + } + }, + + async toggleRule(ruleId, enabled) { + try { + const rule = this.rules.find((r) => r.id === ruleId); + if (!rule) return; + const res = await fetch(`/api/rules/${ruleId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify({ ...rule, enabled }), + }); + if (res.ok) await this.loadRules(); + } catch {} + }, + + async deleteRule(ruleId) { + if (!confirm('Delete this rule?')) return; + try { + const res = await fetch(`/api/rules/${ruleId}`, { + method: 'DELETE', + headers: this.authHeader(), + }); + if (res.ok) await this.loadRules(); + } catch {} + }, + async askQuestion() { const q = this.askQuestionText.trim(); if (!q) return; diff --git a/backend/frontend/style.css b/backend/frontend/style.css index 77e4b20..9b5c7c0 100644 --- a/backend/frontend/style.css +++ b/backend/frontend/style.css @@ -818,6 +818,130 @@ input { border-radius: 10px; } +/* Rules management */ +.rules-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.rule-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.02); +} + +.rule-card--disabled { + opacity: 0.6; +} + +.rule-card__meta { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.toggle-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + cursor: pointer; +} + +.toggle-label input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent-strong); +} + +.rule-card strong { + font-size: 14px; + display: block; + margin-bottom: 4px; +} + +.rule-card p { + margin: 0 0 8px; + font-size: 13px; + color: var(--muted); + line-height: 1.4; +} + +.rule-card__conditions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.rule-card__actions { + display: flex; + gap: 8px; +} + +.rules-empty { + padding: 20px; + text-align: center; + color: var(--muted); + font-size: 14px; + border: 1px dashed var(--border); + border-radius: 10px; +} + +.rule-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.rule-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 14px; + color: var(--muted); +} + +.rule-form input, +.rule-form select, +.rule-form textarea { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + color: var(--text); + font-size: 14px; +} + +.rule-conditions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.condition-row { + display: flex; + gap: 8px; + align-items: center; +} + +.condition-row input, +.condition-row select { + flex: 1; + min-width: 0; +} + +.rule-form__actions { + display: flex; + gap: 10px; + margin-top: 8px; +} + @media (max-width: 640px) { .topbar { flex-direction: column;