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
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Admin Operations Center</title>
|
<title>Admin Operations Center</title>
|
||||||
<link rel="stylesheet" href="/style.css?v=13" />
|
<link rel="stylesheet" href="/style.css?v=14" />
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -119,6 +119,96 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>Alert Rules</h3>
|
||||||
|
<button type="button" class="btn--compact" @click="openRuleEditor()">+ Add rule</button>
|
||||||
|
</div>
|
||||||
|
<div class="rules-list">
|
||||||
|
<template x-for="rule in rules" :key="rule.id">
|
||||||
|
<div class="rule-card" :class="rule.enabled ? '' : 'rule-card--disabled'">
|
||||||
|
<div class="rule-card__meta">
|
||||||
|
<span class="pill" :class="rule.severity === 'high' ? 'pill--err' : (rule.severity === 'medium' ? 'pill--warn' : '')" x-text="rule.severity"></span>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" :checked="rule.enabled" @change="toggleRule(rule.id, $event.target.checked)">
|
||||||
|
<span x-text="rule.enabled ? 'On' : 'Off'"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<strong x-text="rule.name"></strong>
|
||||||
|
<p x-text="rule.message"></p>
|
||||||
|
<div class="rule-card__conditions">
|
||||||
|
<template x-for="(cond, idx) in rule.conditions" :key="idx">
|
||||||
|
<span class="pill pill--tag" x-text="`${cond.field} ${cond.op} ${cond.value}`"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="rule-card__actions">
|
||||||
|
<button type="button" class="ghost btn--compact" @click="openRuleEditor(rule)">Edit</button>
|
||||||
|
<button type="button" class="ghost btn--compact" @click="deleteRule(rule.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="rules-empty" x-show="rules.length === 0">
|
||||||
|
<p>No custom rules yet. Pre-built admin-ops rules are active by default. Add your own rules to detect specific patterns.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ruleModal" class="modal hidden" role="dialog" aria-modal="true" :class="{ 'hidden': !ruleModalOpen }">
|
||||||
|
<div class="modal__content" style="max-width: 600px;">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h3 x-text="ruleEditId ? 'Edit Rule' : 'New Rule'"></h3>
|
||||||
|
<button type="button" class="ghost" @click="ruleModalOpen = false">Close</button>
|
||||||
|
</div>
|
||||||
|
<form class="rule-form" @submit.prevent="saveRule()">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" x-model="ruleEdit.name" placeholder="e.g. Failed CA Policy" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Severity
|
||||||
|
<select x-model="ruleEdit.severity">
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Message
|
||||||
|
<textarea x-model="ruleEdit.message" placeholder="What should the alert say?" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="rule-conditions">
|
||||||
|
<span>Conditions (all must match)</span>
|
||||||
|
<template x-for="(cond, idx) in ruleEdit.conditions" :key="idx">
|
||||||
|
<div class="condition-row">
|
||||||
|
<input type="text" x-model="cond.field" placeholder="field" list="ruleFieldOptions" required />
|
||||||
|
<select x-model="cond.op">
|
||||||
|
<option value="eq">equals</option>
|
||||||
|
<option value="neq">not equals</option>
|
||||||
|
<option value="contains">contains</option>
|
||||||
|
<option value="in">in list</option>
|
||||||
|
<option value="after_hours">after hours</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" x-model="cond.value" placeholder="value" :required="cond.op !== 'after_hours'" />
|
||||||
|
<button type="button" class="ghost btn--compact" @click="ruleEdit.conditions.splice(idx, 1)">−</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button type="button" class="ghost btn--compact" @click="ruleEdit.conditions.push({field:'', op:'eq', value:''})">+ Add condition</button>
|
||||||
|
</div>
|
||||||
|
<datalist id="ruleFieldOptions">
|
||||||
|
<option value="service"></option>
|
||||||
|
<option value="operation"></option>
|
||||||
|
<option value="result"></option>
|
||||||
|
<option value="actor_display"></option>
|
||||||
|
<option value="timestamp"></option>
|
||||||
|
</datalist>
|
||||||
|
<div class="rule-form__actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" class="ghost" @click="ruleModalOpen = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
@@ -373,6 +463,10 @@
|
|||||||
alertsTotal: 0,
|
alertsTotal: 0,
|
||||||
alertsPage: 1,
|
alertsPage: 1,
|
||||||
alertsFilter: { status: 'open', severity: '' },
|
alertsFilter: { status: 'open', severity: '' },
|
||||||
|
rules: [],
|
||||||
|
ruleModalOpen: false,
|
||||||
|
ruleEditId: null,
|
||||||
|
ruleEdit: { name: '', enabled: true, severity: 'medium', message: '', conditions: [] },
|
||||||
askQuestionText: '',
|
askQuestionText: '',
|
||||||
askLoading: false,
|
askLoading: false,
|
||||||
askAnswer: '',
|
askAnswer: '',
|
||||||
@@ -391,6 +485,7 @@
|
|||||||
await this.loadSourceHealth();
|
await this.loadSourceHealth();
|
||||||
await this.loadAlertSummary();
|
await this.loadAlertSummary();
|
||||||
await this.loadAlerts();
|
await this.loadAlerts();
|
||||||
|
await this.loadRules();
|
||||||
await this.loadEvents();
|
await this.loadEvents();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -790,6 +885,73 @@
|
|||||||
} catch {}
|
} 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() {
|
async askQuestion() {
|
||||||
const q = this.askQuestionText.trim();
|
const q = this.askQuestionText.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|||||||
@@ -818,6 +818,130 @@ input {
|
|||||||
border-radius: 10px;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user