Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f7a98f21c | |||
| 19ed231a31 | |||
| f812fda150 | |||
| a194c78c59 | |||
| e984899d4c | |||
| b618cb29ea |
@@ -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=15" />
|
||||||
<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>
|
||||||
@@ -56,8 +56,11 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h3>Source Health</h3>
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('sourceHealth')">
|
||||||
<div class="source-health">
|
<h3>Source Health</h3>
|
||||||
|
<span class="panel-toggle" :class="panelState.sourceHealth ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
|
<div x-show="panelState.sourceHealth">
|
||||||
<template x-for="src in sourceHealth" :key="src.source">
|
<template x-for="src in sourceHealth" :key="src.source">
|
||||||
<div class="health-card">
|
<div class="health-card">
|
||||||
<strong x-text="src.source"></strong>
|
<strong x-text="src.source"></strong>
|
||||||
@@ -71,11 +74,15 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('alerts')">
|
||||||
<h3>Alerts</h3>
|
<h3>Alerts</h3>
|
||||||
<span x-text="`${alertSummary.total_open} open`" class="alert-open-count"></span>
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<span x-text="`${alertSummary.total_open} open`" class="alert-open-count"></span>
|
||||||
|
<span class="panel-toggle" :class="panelState.alerts ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert-filters">
|
<div x-show="panelState.alerts">
|
||||||
|
<div class="alert-filters">
|
||||||
<select x-model="alertsFilter.status" @change="alertsPage = 1; loadAlerts()">
|
<select x-model="alertsFilter.status" @change="alertsPage = 1; loadAlerts()">
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
<option value="open">Open</option>
|
<option value="open">Open</option>
|
||||||
@@ -117,10 +124,110 @@
|
|||||||
<span x-text="`Page ${alertsPage}`"></span>
|
<span x-text="`Page ${alertsPage}`"></span>
|
||||||
<button type="button" :disabled="alertsPage * 20 >= alertsTotal" @click="alertsPage++; loadAlerts()">Next</button>
|
<button type="button" :disabled="alertsPage * 20 >= alertsTotal" @click="alertsPage++; loadAlerts()">Next</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('rules')">
|
||||||
|
<h3>Alert Rules</h3>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<button type="button" class="btn--compact" @click.stop="openRuleEditor()">+ Add rule</button>
|
||||||
|
<span class="panel-toggle" :class="panelState.rules ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="panelState.rules">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('filters')">
|
||||||
|
<h3>Filters</h3>
|
||||||
|
<span class="panel-toggle" :class="panelState.filters ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
|
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()" x-show="panelState.filters">
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<label>
|
<label>
|
||||||
User (name/UPN)
|
User (name/UPN)
|
||||||
@@ -214,8 +321,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" x-show="aiFeaturesEnabled">
|
<section class="panel" x-show="aiFeaturesEnabled">
|
||||||
<h3>Ask a question</h3>
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('ask')">
|
||||||
<form class="ask-form" @submit.prevent="askQuestion()">
|
<h3>Ask a question</h3>
|
||||||
|
<span class="panel-toggle" :class="panelState.ask ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
|
<form class="ask-form" @submit.prevent="askQuestion()" x-show="panelState.ask">
|
||||||
<div class="ask-row">
|
<div class="ask-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -257,11 +367,15 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('events')">
|
||||||
<h2>Events</h2>
|
<h2>Events</h2>
|
||||||
<span id="count" x-text="countText"></span>
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
|
<span id="count" x-text="countText"></span>
|
||||||
|
<span class="panel-toggle" :class="panelState.events ? 'panel-toggle--open' : ''">▸</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status" aria-live="polite" x-text="statusText"></div>
|
<div x-show="panelState.events">
|
||||||
|
<div id="status" class="status" aria-live="polite" x-text="statusText"></div>
|
||||||
<div id="events" class="events">
|
<div id="events" class="events">
|
||||||
<template x-for="(evt, idx) in events" :key="evt._id || evt.id || idx">
|
<template x-for="(evt, idx) in events" :key="evt._id || evt.id || idx">
|
||||||
<article class="event">
|
<article class="event">
|
||||||
@@ -301,6 +415,7 @@
|
|||||||
<span x-text="`Page ${cursorStack.length + 1}`"></span>
|
<span x-text="`Page ${cursorStack.length + 1}`"></span>
|
||||||
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
|
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
||||||
@@ -362,6 +477,7 @@
|
|||||||
filters: {
|
filters: {
|
||||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '',
|
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '',
|
||||||
},
|
},
|
||||||
|
panelState: { sourceHealth: true, alerts: true, rules: true, filters: true, ask: true, events: true },
|
||||||
options: { actors: [], services: [], operations: [], results: [] },
|
options: { actors: [], services: [], operations: [], results: [] },
|
||||||
savedSearches: [],
|
savedSearches: [],
|
||||||
appVersion: '',
|
appVersion: '',
|
||||||
@@ -373,6 +489,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: '',
|
||||||
@@ -385,12 +505,14 @@
|
|||||||
await this.loadVersion();
|
await this.loadVersion();
|
||||||
await this.initAuth();
|
await this.initAuth();
|
||||||
this.loadSavedFilters();
|
this.loadSavedFilters();
|
||||||
|
this.loadPanelState();
|
||||||
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
||||||
await this.loadFilterOptions();
|
await this.loadFilterOptions();
|
||||||
await this.loadSavedSearches();
|
await this.loadSavedSearches();
|
||||||
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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -413,6 +535,27 @@
|
|||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadPanelState() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('aoc_panels');
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
Object.keys(parsed).forEach((k) => { if (this.panelState[k] !== undefined) this.panelState[k] = parsed[k]; });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
savePanelState() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('aoc_panels', JSON.stringify(this.panelState));
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePanel(key) {
|
||||||
|
this.panelState[key] = !this.panelState[key];
|
||||||
|
this.savePanelState();
|
||||||
|
},
|
||||||
|
|
||||||
async loadVersion() {
|
async loadVersion() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/version');
|
const res = await fetch('/api/version');
|
||||||
@@ -790,6 +933,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;
|
||||||
|
|||||||
@@ -274,6 +274,31 @@ input {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header--collapsible {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header--collapsible:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-toggle--open {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
#count {
|
#count {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -818,6 +843,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;
|
||||||
|
|||||||
@@ -136,10 +136,7 @@ def _create_alert(rule: dict, event: dict):
|
|||||||
|
|
||||||
|
|
||||||
def seed_default_rules():
|
def seed_default_rules():
|
||||||
"""Insert pre-built admin-ops rule templates if the collection is empty."""
|
"""Upsert pre-built admin-ops rule templates. Safe for concurrent startup."""
|
||||||
if rules_collection.count_documents({}) > 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
defaults = [
|
defaults = [
|
||||||
{
|
{
|
||||||
"name": "Failed Conditional Access",
|
"name": "Failed Conditional Access",
|
||||||
@@ -261,8 +258,17 @@ def seed_default_rules():
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
inserted = 0
|
||||||
rules_collection.insert_many(defaults)
|
for rule in defaults:
|
||||||
logger.info("Default admin-ops rules seeded", count=len(defaults))
|
try:
|
||||||
except Exception as exc:
|
result = rules_collection.replace_one(
|
||||||
logger.warning("Failed to seed default rules", error=str(exc))
|
{"name": rule["name"]},
|
||||||
|
rule,
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
if result.upserted_id:
|
||||||
|
inserted += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to seed rule", rule=rule["name"], error=str(exc))
|
||||||
|
if inserted:
|
||||||
|
logger.info("Default admin-ops rules seeded", inserted=inserted, total=len(defaults))
|
||||||
|
|||||||
Reference in New Issue
Block a user