Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cd7709b4a | |||
| 9cd50d1257 | |||
| 646d61f72e | |||
| 5f7a98f21c | |||
| 19ed231a31 | |||
| f812fda150 | |||
| a194c78c59 |
@@ -12,6 +12,20 @@ alerts_collection = db["alerts"]
|
|||||||
logger = structlog.get_logger("aoc.database")
|
logger = structlog.get_logger("aoc.database")
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_alert_rules():
|
||||||
|
"""Remove duplicate alert_rules by name, keeping the oldest document."""
|
||||||
|
try:
|
||||||
|
pipeline = [
|
||||||
|
{"$sort": {"_id": ASCENDING}},
|
||||||
|
{"$group": {"_id": "$name", "first_id": {"$first": "$_id"}}},
|
||||||
|
]
|
||||||
|
seen = {doc["_id"]: doc["first_id"] for doc in db["alert_rules"].aggregate(pipeline)}
|
||||||
|
for name, keep_id in seen.items():
|
||||||
|
db["alert_rules"].delete_many({"name": name, "_id": {"$ne": keep_id}})
|
||||||
|
except Exception:
|
||||||
|
pass # Collection may not exist yet
|
||||||
|
|
||||||
|
|
||||||
def setup_indexes(max_retries: int = 5, delay: float = 2.0):
|
def setup_indexes(max_retries: int = 5, delay: float = 2.0):
|
||||||
"""Ensure MongoDB indexes exist. Retries on connection errors."""
|
"""Ensure MongoDB indexes exist. Retries on connection errors."""
|
||||||
from time import sleep
|
from time import sleep
|
||||||
@@ -23,6 +37,8 @@ def setup_indexes(max_retries: int = 5, delay: float = 2.0):
|
|||||||
events_collection.create_index([("service", ASCENDING), ("timestamp", DESCENDING)])
|
events_collection.create_index([("service", ASCENDING), ("timestamp", DESCENDING)])
|
||||||
events_collection.create_index("id")
|
events_collection.create_index("id")
|
||||||
saved_searches_collection.create_index([("created_by", ASCENDING), ("created_at", DESCENDING)])
|
saved_searches_collection.create_index([("created_by", ASCENDING), ("created_at", DESCENDING)])
|
||||||
|
_dedupe_alert_rules()
|
||||||
|
db["alert_rules"].create_index("name", unique=True)
|
||||||
events_collection.create_index(
|
events_collection.create_index(
|
||||||
[("actor_display", TEXT), ("raw_text", TEXT), ("operation", TEXT)],
|
[("actor_display", TEXT), ("raw_text", TEXT), ("operation", TEXT)],
|
||||||
name="text_search_index",
|
name="text_search_index",
|
||||||
|
|||||||
@@ -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=14" />
|
<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,14 +124,19 @@
|
|||||||
<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">
|
||||||
<div class="panel-header">
|
<div class="panel-header panel-header--collapsible" @click="togglePanel('rules')">
|
||||||
<h3>Alert Rules</h3>
|
<h3>Alert Rules</h3>
|
||||||
<button type="button" class="btn--compact" @click="openRuleEditor()">+ Add rule</button>
|
<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>
|
||||||
<div class="rules-list">
|
<div x-show="panelState.rules">
|
||||||
|
<div class="rules-list">
|
||||||
<template x-for="rule in rules" :key="rule.id">
|
<template x-for="rule in rules" :key="rule.id">
|
||||||
<div class="rule-card" :class="rule.enabled ? '' : 'rule-card--disabled'">
|
<div class="rule-card" :class="rule.enabled ? '' : 'rule-card--disabled'">
|
||||||
<div class="rule-card__meta">
|
<div class="rule-card__meta">
|
||||||
@@ -151,6 +163,7 @@
|
|||||||
<div class="rules-empty" x-show="rules.length === 0">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div id="ruleModal" class="modal hidden" role="dialog" aria-modal="true" :class="{ 'hidden': !ruleModalOpen }">
|
<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__content" style="max-width: 600px;">
|
||||||
@@ -210,7 +223,11 @@
|
|||||||
</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('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)
|
||||||
@@ -304,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"
|
||||||
@@ -347,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">
|
||||||
@@ -391,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 }">
|
||||||
@@ -452,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: '',
|
||||||
@@ -479,6 +505,7 @@
|
|||||||
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();
|
||||||
@@ -508,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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
import structlog
|
import structlog
|
||||||
from config import ALERT_DEDUPE_MINUTES, ALERT_WEBHOOK_FORMAT, ALERT_WEBHOOK_URL
|
from config import ALERT_DEDUPE_MINUTES, ALERT_WEBHOOK_FORMAT, ALERT_WEBHOOK_URL
|
||||||
from database import db
|
from database import db
|
||||||
|
from pymongo import ASCENDING
|
||||||
|
|
||||||
logger = structlog.get_logger("aoc.rules")
|
logger = structlog.get_logger("aoc.rules")
|
||||||
rules_collection = db["alert_rules"]
|
rules_collection = db["alert_rules"]
|
||||||
@@ -136,9 +137,15 @@ 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:
|
# One-time cleanup: remove duplicates by name, keep the oldest (_id ascending)
|
||||||
return
|
pipeline = [
|
||||||
|
{"$sort": {"_id": ASCENDING}},
|
||||||
|
{"$group": {"_id": "$name", "first_id": {"$first": "$_id"}}},
|
||||||
|
]
|
||||||
|
seen = {doc["_id"]: doc["first_id"] for doc in rules_collection.aggregate(pipeline)}
|
||||||
|
for name, keep_id in seen.items():
|
||||||
|
rules_collection.delete_many({"name": name, "_id": {"$ne": keep_id}})
|
||||||
|
|
||||||
defaults = [
|
defaults = [
|
||||||
{
|
{
|
||||||
@@ -261,8 +268,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