Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f7a98f21c | |||
| 19ed231a31 | |||
| f812fda150 | |||
| a194c78c59 | |||
| e984899d4c | |||
| b618cb29ea | |||
| 3e1416cd52 | |||
| 94983c43e9 | |||
| 0a16cf6870 | |||
| e348881083 | |||
| a220494bcf | |||
| 5bda1dd616 | |||
| 3e333291c6 | |||
| aa62528862 | |||
| ac155d8843 | |||
| ed7465f5cd | |||
| 0eebcd0765 | |||
| 67f3c28e82 | |||
| 04c41ee740 | |||
| cbd46adaa6 | |||
| e4bafbc4b0 | |||
| f75f165911 |
14
.env.example
14
.env.example
@@ -50,6 +50,20 @@ LLM_MAX_EVENTS=200
|
||||
LLM_TIMEOUT_SECONDS=30
|
||||
LLM_API_VERSION=
|
||||
|
||||
# Valkey (caching + async job queue for LLM calls)
|
||||
# In Docker Compose, this is set automatically to redis://redis:6379/0
|
||||
# For local dev, start Valkey with: docker run -d -p 6379:6379 valkey/valkey:8-alpine
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# UI default page size (number of events shown per page)
|
||||
DEFAULT_PAGE_SIZE=24
|
||||
|
||||
# Alert notifications (optional)
|
||||
# Send triggered admin-ops alerts to a webhook (Slack, Teams, or generic)
|
||||
ALERT_WEBHOOK_URL=
|
||||
ALERT_WEBHOOK_FORMAT=generic # generic | slack | teams
|
||||
ALERT_DEDUPE_MINUTES=15
|
||||
|
||||
# Optional: privacy / access control
|
||||
# Hide entire services from users without PRIVACY_SERVICE_ROLES
|
||||
# PRIVACY_SERVICES=Exchange,Teams
|
||||
|
||||
36
ROADMAP.md
36
ROADMAP.md
@@ -65,9 +65,39 @@ Goal: add AI-powered analysis and external tool integration.
|
||||
- [x] AI feature flag (`AI_FEATURES_ENABLED`) to gate LLM-dependent features
|
||||
- [x] Natural language query endpoint (`/api/ask`) with intent extraction and smart sampling
|
||||
- [x] MCP (Model Context Protocol) server for Claude Desktop / Cursor integration
|
||||
- [x] Valkey caching for LLM responses and frequent queries
|
||||
- [x] Async queue (arq) for LLM requests to prevent timeout/cost explosions at scale
|
||||
- [ ] Advanced analytics dashboard (trending operations, anomaly detection)
|
||||
- [ ] Redis caching for LLM responses and frequent queries
|
||||
- [ ] Async queue for LLM requests to prevent timeout/cost explosions at scale
|
||||
|
||||
## Completed in this PR
|
||||
All Phase 5 items marked done were implemented in v1.3.0.
|
||||
All Phase 5 items marked done were implemented in v1.3.0–v1.5.0.
|
||||
Redis caching + async queue implemented in v1.6.0, switched to Valkey.
|
||||
UI polish (topbar, footer, clickable pills) in v1.6.1–v1.6.4.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Multi-Tenancy (Premium) ⏸️
|
||||
Goal: allow MSPs to manage multiple client tenants from a single deployment.
|
||||
|
||||
Status: **Planned — not started**. Architecture designed, pending validation of core features (SIEM export, alerting) in production first.
|
||||
|
||||
### Architecture
|
||||
- Row-level isolation: `tenant_id` field on every MongoDB document
|
||||
- Each tenant has their own Microsoft Entra tenant + app registration credentials
|
||||
- Auth: user's JWT `tid` claim maps to tenant config automatically
|
||||
- Super-admin role for MSP staff to access all tenants
|
||||
|
||||
### Implementation phases
|
||||
- **Phase 6.1** (2–3 days): Tenant model & registry, tenant-aware data layer, per-tenant Graph API auth
|
||||
- **Phase 6.2** (1 day): Tenant-scoped API routes, tenant-specific config endpoints
|
||||
- **Phase 6.3** (2 days): Frontend tenant switcher, tenant name display, admin page
|
||||
- **Phase 6.4** (1 day): License gating — signed JWT `LICENSE_KEY` gates multi-tenant mode
|
||||
|
||||
### Licensing model
|
||||
- Single-tenant: remains MIT/free
|
||||
- Multi-tenant: premium feature requiring a signed license key
|
||||
- License key is a JWT with claims: `plan`, `max_tenants`, `exp`, `features`
|
||||
- Offline license generation tool included
|
||||
|
||||
### Effort estimate
|
||||
~7–9 days total. Deferred until SIEM export and alerting are battle-tested.
|
||||
|
||||
@@ -57,6 +57,17 @@ class Settings(BaseSettings):
|
||||
PRIVACY_SENSITIVE_OPERATIONS: str = "" # comma-separated, e.g. "MailItemsAccessed,Search-Mailbox,Send"
|
||||
PRIVACY_SERVICE_ROLES: str = "" # comma-separated, e.g. "SecurityAdministrator,ComplianceAdministrator"
|
||||
|
||||
# Redis (caching + async job queue)
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# UI defaults
|
||||
DEFAULT_PAGE_SIZE: int = 24
|
||||
|
||||
# Alert notifications
|
||||
ALERT_WEBHOOK_URL: str = ""
|
||||
ALERT_WEBHOOK_FORMAT: str = "generic" # generic | slack | teams
|
||||
ALERT_DEDUPE_MINUTES: int = 15
|
||||
|
||||
|
||||
_settings = Settings()
|
||||
|
||||
@@ -95,3 +106,10 @@ LLM_API_VERSION = _settings.LLM_API_VERSION
|
||||
PRIVACY_SERVICES = {s.strip() for s in _settings.PRIVACY_SERVICES.split(",") if s.strip()}
|
||||
PRIVACY_SENSITIVE_OPERATIONS = {o.strip() for o in _settings.PRIVACY_SENSITIVE_OPERATIONS.split(",") if o.strip()}
|
||||
PRIVACY_SERVICE_ROLES = {r.strip() for r in _settings.PRIVACY_SERVICE_ROLES.split(",") if r.strip()}
|
||||
|
||||
REDIS_URL = _settings.REDIS_URL
|
||||
DEFAULT_PAGE_SIZE = _settings.DEFAULT_PAGE_SIZE
|
||||
|
||||
ALERT_WEBHOOK_URL = _settings.ALERT_WEBHOOK_URL
|
||||
ALERT_WEBHOOK_FORMAT = _settings.ALERT_WEBHOOK_FORMAT
|
||||
ALERT_DEDUPE_MINUTES = _settings.ALERT_DEDUPE_MINUTES
|
||||
|
||||
@@ -8,6 +8,7 @@ client = MongoClient(MONGO_URI or "mongodb://localhost:27017")
|
||||
db = client[DB_NAME]
|
||||
events_collection = db["events"]
|
||||
saved_searches_collection = db["saved_searches"]
|
||||
alerts_collection = db["alerts"]
|
||||
logger = structlog.get_logger("aoc.database")
|
||||
|
||||
|
||||
|
||||
@@ -4,28 +4,63 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Operations Center</title>
|
||||
<link rel="stylesheet" href="/style.css?v=8" />
|
||||
<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 src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page" x-data="aocApp()" x-init="initApp()">
|
||||
<nav class="topbar">
|
||||
<div class="topbar__brand">
|
||||
<span class="topbar__logo">🔍</span>
|
||||
<span class="topbar__name">AOC</span>
|
||||
<span class="version-badge" x-text="appVersion"></span>
|
||||
</div>
|
||||
<div class="topbar__links">
|
||||
<a :href="repoUrl" target="_blank" rel="noopener">Repository</a>
|
||||
<a :href="docsUrl" target="_blank" rel="noopener">Docs</a>
|
||||
</div>
|
||||
<div class="topbar__meta">
|
||||
<template x-if="account">
|
||||
<div class="user-chip">
|
||||
<div class="user-avatar" x-text="(account.name || account.username || '?').charAt(0).toUpperCase()"></div>
|
||||
<div class="user-details">
|
||||
<span class="user-name" x-text="account.name || account.username || ''"></span>
|
||||
<span class="user-email" x-text="account.username || ''"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!account && authConfig?.auth_enabled">
|
||||
<span class="login-hint">Not signed in</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="topbar__actions">
|
||||
<button id="fetchBtn" class="ghost btn--compact" aria-label="Fetch latest audit logs" @click="fetchLogs()">Fetch</button>
|
||||
<button id="refreshBtn" class="ghost btn--compact" aria-label="Refresh events" @click="loadEvents(currentCursor)">Refresh</button>
|
||||
<button id="authBtn" class="ghost btn--compact" aria-label="Login" x-text="authBtnText" @click="toggleAuth()"></button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Admin Operations Center <span class="version-badge" x-text="appVersion"></span></p>
|
||||
<p class="eyebrow">Admin Operations Center</p>
|
||||
<h1>Audit Log Explorer</h1>
|
||||
<p class="lede">Search and review Microsoft audit events from Entra, Intune, Exchange, SharePoint, and Teams.</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<button id="authBtn" class="ghost" aria-label="Login" x-text="authBtnText" @click="toggleAuth()"></button>
|
||||
<button id="fetchBtn" aria-label="Fetch latest audit logs" @click="fetchLogs()">Fetch new</button>
|
||||
<button id="refreshBtn" aria-label="Refresh events" @click="loadEvents(currentCursor)">Refresh</button>
|
||||
<div class="alert-summary" x-show="alertSummary.total_open > 0">
|
||||
<div class="alert-badge alert-badge--high" x-show="alertSummary.high > 0" x-text="alertSummary.high"></div>
|
||||
<div class="alert-badge alert-badge--medium" x-show="alertSummary.medium > 0" x-text="alertSummary.medium"></div>
|
||||
<div class="alert-badge alert-badge--low" x-show="alertSummary.low > 0" x-text="alertSummary.low"></div>
|
||||
<span class="alert-label">open alerts</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h3>Source Health</h3>
|
||||
<div class="source-health">
|
||||
<div class="panel-header panel-header--collapsible" @click="togglePanel('sourceHealth')">
|
||||
<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">
|
||||
<div class="health-card">
|
||||
<strong x-text="src.source"></strong>
|
||||
@@ -39,7 +74,160 @@
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
||||
<div class="panel-header panel-header--collapsible" @click="togglePanel('alerts')">
|
||||
<h3>Alerts</h3>
|
||||
<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 x-show="panelState.alerts">
|
||||
<div class="alert-filters">
|
||||
<select x-model="alertsFilter.status" @change="alertsPage = 1; loadAlerts()">
|
||||
<option value="">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="acknowledged">Acknowledged</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="false_positive">False Positive</option>
|
||||
</select>
|
||||
<select x-model="alertsFilter.severity" @change="alertsPage = 1; loadAlerts()">
|
||||
<option value="">All severities</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="alerts-list" x-show="alerts.length > 0">
|
||||
<template x-for="alert in alerts" :key="alert._id || alert.event_id">
|
||||
<div class="alert-card" :class="'alert-card--' + alert.severity">
|
||||
<div class="alert-card__meta">
|
||||
<span class="pill" :class="alert.severity === 'high' ? 'pill--err' : (alert.severity === 'medium' ? 'pill--warn' : '')" x-text="alert.severity"></span>
|
||||
<span class="pill" x-text="alert.status"></span>
|
||||
<small x-text="new Date(alert.timestamp).toLocaleString()"></small>
|
||||
</div>
|
||||
<strong x-text="alert.rule_name"></strong>
|
||||
<p x-text="alert.message"></p>
|
||||
<div class="alert-card__actions">
|
||||
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'acknowledged')" x-show="alert.status === 'open'">Acknowledge</button>
|
||||
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'resolved')" x-show="alert.status !== 'resolved' && alert.status !== 'false_positive'">Resolve</button>
|
||||
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'false_positive')" x-show="alert.status !== 'false_positive'">False Positive</button>
|
||||
<button type="button" class="ghost btn--compact" @click="updateAlertStatus(alert._id, 'open')" x-show="alert.status !== 'open'">Reopen</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="alerts-empty" x-show="alerts.length === 0">
|
||||
<p>No alerts match the current filters. Alerts appear here when rules trigger during event ingestion.</p>
|
||||
</div>
|
||||
<div class="pagination" x-show="alertsTotal > 20">
|
||||
<button type="button" :disabled="alertsPage === 1" @click="alertsPage--; loadAlerts()">Prev</button>
|
||||
<span x-text="`Page ${alertsPage}`"></span>
|
||||
<button type="button" :disabled="alertsPage * 20 >= alertsTotal" @click="alertsPage++; loadAlerts()">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<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">
|
||||
<label>
|
||||
User (name/UPN)
|
||||
@@ -133,8 +321,11 @@
|
||||
</section>
|
||||
|
||||
<section class="panel" x-show="aiFeaturesEnabled">
|
||||
<h3>Ask a question</h3>
|
||||
<form class="ask-form" @submit.prevent="askQuestion()">
|
||||
<div class="panel-header panel-header--collapsible" @click="togglePanel('ask')">
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
@@ -158,8 +349,8 @@
|
||||
<template x-for="(evt, idx) in askEvents" :key="evt.id || idx">
|
||||
<article class="event event--compact">
|
||||
<div class="event__meta">
|
||||
<span class="pill" x-text="evt.display_category || evt.service || '—'"></span>
|
||||
<span class="pill" :class="['success','succeeded','ok','passed','true'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'"></span>
|
||||
<span class="pill pill--clickable" x-text="evt.display_category || evt.service || '—'" @click="filterByService(evt.service || evt.display_category)" title="Filter by this service"></span>
|
||||
<span class="pill pill--clickable" :class="['success','succeeded','ok','passed','true'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'" @click="filterByResult(evt.result)" title="Filter by this result"></span>
|
||||
</div>
|
||||
<h3 x-text="evt.operation || '—'"></h3>
|
||||
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
|
||||
@@ -176,17 +367,21 @@
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-header panel-header--collapsible" @click="togglePanel('events')">
|
||||
<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 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">
|
||||
<template x-for="(evt, idx) in events" :key="evt._id || evt.id || idx">
|
||||
<article class="event">
|
||||
<div class="event__meta">
|
||||
<span class="pill" x-text="evt.display_category || evt.service || '—'"></span>
|
||||
<span class="pill" :class="['success','succeeded','ok','passed','true'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'"></span>
|
||||
<span class="pill pill--clickable" x-text="evt.display_category || evt.service || '—'" @click="filterByService(evt.service || evt.display_category)" title="Filter by this service"></span>
|
||||
<span class="pill pill--clickable" :class="['success','succeeded','ok','passed','true'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'" @click="filterByResult(evt.result)" title="Filter by this result"></span>
|
||||
</div>
|
||||
<h3 x-text="evt.operation || '—'"></h3>
|
||||
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
|
||||
@@ -220,6 +415,7 @@
|
||||
<span x-text="`Page ${cursorStack.length + 1}`"></span>
|
||||
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
||||
@@ -239,6 +435,21 @@
|
||||
<pre id="modalBody" x-text="modalBody"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer__left">
|
||||
<span class="footer__brand">Admin Operations Center</span>
|
||||
<span class="footer__version" x-text="'v' + appVersion"></span>
|
||||
</div>
|
||||
<div class="footer__center">
|
||||
<a :href="repoUrl + '/issues/new'" target="_blank" rel="noopener">🐛 Report an issue</a>
|
||||
<a :href="repoUrl" target="_blank" rel="noopener">💻 Source code</a>
|
||||
<a :href="docsUrl" target="_blank" rel="noopener">📖 Documentation</a>
|
||||
</div>
|
||||
<div class="footer__right">
|
||||
<span>Built with ❤️ by CQRE.NET</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -264,12 +475,24 @@
|
||||
accessToken: null,
|
||||
authScopes: [],
|
||||
filters: {
|
||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, 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: [] },
|
||||
savedSearches: [],
|
||||
appVersion: '',
|
||||
repoUrl: 'https://git.cqre.net/cqrenet/aoc',
|
||||
docsUrl: 'https://git.cqre.net/cqrenet/aoc/src/branch/main/README.md',
|
||||
aiFeaturesEnabled: true,
|
||||
alertSummary: { total_open: 0, high: 0, medium: 0, low: 0 },
|
||||
alerts: [],
|
||||
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: '',
|
||||
@@ -282,10 +505,14 @@
|
||||
await this.loadVersion();
|
||||
await this.initAuth();
|
||||
this.loadSavedFilters();
|
||||
this.loadPanelState();
|
||||
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
||||
await this.loadFilterOptions();
|
||||
await this.loadSavedSearches();
|
||||
await this.loadSourceHealth();
|
||||
await this.loadAlertSummary();
|
||||
await this.loadAlerts();
|
||||
await this.loadRules();
|
||||
await this.loadEvents();
|
||||
}
|
||||
},
|
||||
@@ -308,12 +535,33 @@
|
||||
} 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() {
|
||||
try {
|
||||
const res = await fetch('/api/version');
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
this.appVersion = body.version || '';
|
||||
this.appVersion = (body.version || '').replace(/^v/, '');
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
@@ -353,6 +601,11 @@
|
||||
if (featRes.ok) {
|
||||
const featBody = await featRes.json();
|
||||
this.aiFeaturesEnabled = featBody.ai_features_enabled !== false;
|
||||
if (featBody.default_page_size) {
|
||||
this.filters.limit = featBody.default_page_size;
|
||||
} else {
|
||||
this.filters.limit = 24;
|
||||
}
|
||||
} else {
|
||||
this.aiFeaturesEnabled = true;
|
||||
}
|
||||
@@ -521,9 +774,8 @@
|
||||
|
||||
const saved = localStorage.getItem('aoc_filters');
|
||||
if (!saved && this.options.services.length) {
|
||||
// Default: exclude noisy high-volume services
|
||||
const noisy = ['Exchange', 'SharePoint', 'Teams'];
|
||||
this.filters.selectedServices = this.options.services.filter((s) => !noisy.includes(s));
|
||||
// Default: show all services (privacy controls handle exclusions server-side)
|
||||
this.filters.selectedServices = [...this.options.services];
|
||||
} else if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
@@ -617,13 +869,137 @@
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
const noisy = ['Exchange', 'SharePoint', 'Teams'];
|
||||
this.filters = { actor: '', selectedServices: this.options.services.filter((s) => !noisy.includes(s)), search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '' };
|
||||
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 24, includeTags: '', excludeTags: '' };
|
||||
this.saveFilters();
|
||||
this.resetPagination();
|
||||
this.loadEvents();
|
||||
},
|
||||
|
||||
filterByService(service) {
|
||||
if (!service) return;
|
||||
this.filters.selectedServices = [service];
|
||||
this.saveFilters();
|
||||
this.resetPagination();
|
||||
this.loadEvents();
|
||||
},
|
||||
|
||||
filterByResult(result) {
|
||||
if (!result) return;
|
||||
this.filters.result = this.filters.result === result ? '' : result;
|
||||
this.saveFilters();
|
||||
this.resetPagination();
|
||||
this.loadEvents();
|
||||
},
|
||||
|
||||
async loadAlertSummary() {
|
||||
try {
|
||||
const res = await fetch('/api/alerts/summary', { headers: this.authHeader() });
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
this.alertSummary.total_open = body.total_open || 0;
|
||||
const sev = body.by_status_severity || [];
|
||||
this.alertSummary.high = sev.filter((s) => s._id.severity === 'high' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||
this.alertSummary.medium = sev.filter((s) => s._id.severity === 'medium' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||
this.alertSummary.low = sev.filter((s) => s._id.severity === 'low' && s._id.status === 'open').reduce((a, b) => a + b.count, 0);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async loadAlerts() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page_size', '20');
|
||||
params.append('page', String(this.alertsPage));
|
||||
if (this.alertsFilter.status) params.append('status', this.alertsFilter.status);
|
||||
if (this.alertsFilter.severity) params.append('severity', this.alertsFilter.severity);
|
||||
const res = await fetch(`/api/alerts?${params.toString()}`, { headers: this.authHeader() });
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
this.alerts = body.items || [];
|
||||
this.alertsTotal = body.total || 0;
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async updateAlertStatus(alertId, status) {
|
||||
try {
|
||||
const res = await fetch(`/api/alerts/${alertId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.loadAlerts();
|
||||
await this.loadAlertSummary();
|
||||
}
|
||||
} 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;
|
||||
|
||||
@@ -28,7 +28,115 @@ body {
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 60px;
|
||||
padding: 0 20px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.topbar__logo {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.topbar__links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.topbar__links a {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.topbar__links a:hover {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.topbar__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px 4px 4px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #0b1220;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.topbar__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn--compact {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -37,6 +145,7 @@ body {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@@ -165,6 +274,31 @@ input {
|
||||
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 {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
@@ -246,6 +380,27 @@ input {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.pill--clickable {
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.pill--clickable:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(125, 211, 252, 0.2);
|
||||
background: rgba(125, 211, 252, 0.2);
|
||||
}
|
||||
|
||||
.pill--clickable.pill--ok:hover {
|
||||
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.2);
|
||||
background: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.pill--clickable.pill--warn:hover {
|
||||
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.2);
|
||||
background: rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
.event h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 17px;
|
||||
@@ -508,7 +663,321 @@ input {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footer__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer__brand {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.footer__version {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(125, 211, 252, 0.1);
|
||||
border: 1px solid rgba(125, 211, 252, 0.2);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.footer__center {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer__center a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.footer__center a:hover {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.footer__right {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Alert summary in hero */
|
||||
.alert-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.alert-badge {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #0b1220;
|
||||
}
|
||||
|
||||
.alert-badge--high {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.alert-badge--medium {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.alert-badge--low {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.alert-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.alert-open-count {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.alert-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.alert-filters select {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.alert-card--high {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.alert-card--medium {
|
||||
border-left-color: #f97316;
|
||||
}
|
||||
|
||||
.alert-card--low {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.alert-card__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.alert-card__meta small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert-card strong {
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.alert-card p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.alert-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.alerts-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
border: 1px dashed var(--border);
|
||||
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;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar__links {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -522,4 +991,10 @@ input {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
117
backend/jobs.py
Normal file
117
backend/jobs.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""arq job functions for async LLM processing."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import structlog
|
||||
from arq.connections import RedisSettings
|
||||
from config import REDIS_URL
|
||||
|
||||
logger = structlog.get_logger("aoc.jobs")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CACHE_TTL_ASK = 3600 # 1 hour
|
||||
CACHE_TTL_EXPLAIN = 86400 # 24 hours
|
||||
|
||||
|
||||
def _ask_cache_key(question: str, filters: dict, events: list) -> str:
|
||||
payload = json.dumps({"q": question, "f": filters, "e": [e.get("id") for e in events]}, sort_keys=True)
|
||||
return f"aoc:cache:ask:{hashlib.md5(payload.encode()).hexdigest()}"
|
||||
|
||||
|
||||
def _explain_cache_key(event_id: str) -> str:
|
||||
return f"aoc:cache:explain:{event_id}"
|
||||
|
||||
|
||||
async def get_cached_ask(redis, question: str, filters: dict, events: list) -> dict | None:
|
||||
key = _ask_cache_key(question, filters, events)
|
||||
raw = await redis.get(key)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
return None
|
||||
|
||||
|
||||
async def set_cached_ask(redis, question: str, filters: dict, events: list, result: dict):
|
||||
key = _ask_cache_key(question, filters, events)
|
||||
await redis.setex(key, CACHE_TTL_ASK, json.dumps(result, default=str))
|
||||
|
||||
|
||||
async def get_cached_explain(redis, event_id: str) -> dict | None:
|
||||
key = _explain_cache_key(event_id)
|
||||
raw = await redis.get(key)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
return None
|
||||
|
||||
|
||||
async def set_cached_explain(redis, event_id: str, result: dict):
|
||||
key = _explain_cache_key(event_id)
|
||||
await redis.setex(key, CACHE_TTL_EXPLAIN, json.dumps(result, default=str))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# arq job functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def process_ask_question(
|
||||
ctx, question: str, filters: dict, events: list, total: int, excluded_services: list | None
|
||||
):
|
||||
"""Background job: call LLM for /api/ask and cache result."""
|
||||
from routes.ask import _call_llm
|
||||
|
||||
redis = ctx["redis"]
|
||||
try:
|
||||
answer = await _call_llm(question, events, total=total, excluded_services=excluded_services)
|
||||
result = {"status": "completed", "answer": answer, "llm_used": True, "llm_error": None}
|
||||
except Exception as exc:
|
||||
logger.warning("Async ask LLM failed", error=str(exc))
|
||||
result = {"status": "failed", "answer": "", "llm_used": False, "llm_error": str(exc)}
|
||||
|
||||
await set_cached_ask(redis, question, filters, events, result)
|
||||
return result
|
||||
|
||||
|
||||
async def process_explain_event(ctx, event_id: str, event: dict, related: list):
|
||||
"""Background job: call LLM for /api/events/{id}/explain and cache result."""
|
||||
from routes.ask import _explain_event
|
||||
|
||||
redis = ctx["redis"]
|
||||
try:
|
||||
explanation = await _explain_event(event, related)
|
||||
result = {"status": "completed", "explanation": explanation, "llm_used": True, "llm_error": None}
|
||||
except Exception as exc:
|
||||
logger.warning("Async explain LLM failed", error=str(exc))
|
||||
result = {"status": "failed", "explanation": "", "llm_used": False, "llm_error": str(exc)}
|
||||
|
||||
await set_cached_explain(redis, event_id, result)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# arq worker configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def startup(ctx):
|
||||
from redis.asyncio import Redis
|
||||
|
||||
ctx["redis"] = Redis.from_url(REDIS_URL, decode_responses=True)
|
||||
|
||||
|
||||
async def shutdown(ctx):
|
||||
await ctx["redis"].close()
|
||||
|
||||
|
||||
class WorkerSettings:
|
||||
functions = [process_ask_question, process_explain_event]
|
||||
redis_settings = RedisSettings.from_dsn(REDIS_URL)
|
||||
on_startup = startup
|
||||
on_shutdown = shutdown
|
||||
max_jobs = 10
|
||||
job_timeout = 120
|
||||
keep_result = 3600
|
||||
keep_result_forever = False
|
||||
@@ -14,11 +14,13 @@ from fastapi.responses import Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from metrics import observe_request, prometheus_metrics
|
||||
from middleware import CorrelationIdMiddleware
|
||||
from routes.alerts import router as alerts_router
|
||||
from routes.config import router as config_router
|
||||
from routes.events import router as events_router
|
||||
from routes.fetch import router as fetch_router
|
||||
from routes.fetch import run_fetch
|
||||
from routes.health import router as health_router
|
||||
from routes.jobs import router as jobs_router
|
||||
from routes.rules import router as rules_router
|
||||
from routes.saved_searches import router as saved_searches_router
|
||||
from routes.webhooks import router as webhooks_router
|
||||
@@ -122,6 +124,8 @@ if AI_FEATURES_ENABLED:
|
||||
app.mount("/mcp", mcp_asgi)
|
||||
app.include_router(saved_searches_router, prefix="/api")
|
||||
app.include_router(rules_router, prefix="/api")
|
||||
app.include_router(alerts_router, prefix="/api")
|
||||
app.include_router(jobs_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -165,6 +169,9 @@ async def _periodic_fetch():
|
||||
@app.on_event("startup")
|
||||
async def start_periodic_fetch():
|
||||
setup_indexes()
|
||||
from rules import seed_default_rules
|
||||
|
||||
seed_default_rules()
|
||||
if ENABLE_PERIODIC_FETCH:
|
||||
app.state.fetch_task = asyncio.create_task(_periodic_fetch())
|
||||
|
||||
@@ -176,3 +183,6 @@ async def stop_periodic_fetch():
|
||||
task.cancel()
|
||||
with suppress(Exception):
|
||||
await task
|
||||
from redis_client import close_redis_connections
|
||||
|
||||
await close_redis_connections()
|
||||
|
||||
@@ -82,6 +82,7 @@ class AskRequest(BaseModel):
|
||||
end: str | None = None
|
||||
include_tags: list[str] | None = None
|
||||
exclude_tags: list[str] | None = None
|
||||
async_mode: bool = False # enqueue async job instead of waiting
|
||||
|
||||
|
||||
class AskEventRef(BaseModel):
|
||||
@@ -101,3 +102,4 @@ class AskResponse(BaseModel):
|
||||
query_info: dict
|
||||
llm_used: bool
|
||||
llm_error: str | None = None
|
||||
job_id: str | None = None
|
||||
|
||||
172
backend/notifications.py
Normal file
172
backend/notifications.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Pluggable notification channels for admin-ops alerts.
|
||||
|
||||
Supported channels:
|
||||
- webhook: POST JSON to any URL (Slack, Teams, generic)
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import requests
|
||||
import structlog
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
|
||||
logger = structlog.get_logger("aoc.notifications")
|
||||
|
||||
WEBHOOK_TIMEOUT = 15
|
||||
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)),
|
||||
reraise=True,
|
||||
)
|
||||
def _post_webhook(url: str, payload: dict) -> requests.Response:
|
||||
"""POST to webhook with retry on connection/timeout errors."""
|
||||
return requests.post(url, json=payload, timeout=WEBHOOK_TIMEOUT, headers={"Content-Type": "application/json"})
|
||||
|
||||
|
||||
def _build_slack_payload(rule_name: str, severity: str, message: str, event: dict) -> dict:
|
||||
"""Build a Slack-compatible block payload."""
|
||||
color = {"high": "#ef4444", "medium": "#f97316", "low": "#3b82f6"}.get(severity, "#94a3b8")
|
||||
ts = event.get("timestamp", "?")
|
||||
op = event.get("operation", "unknown")
|
||||
actor = event.get("actor_display", "unknown")
|
||||
targets = ", ".join(event.get("target_displays", [])) or "—"
|
||||
svc = event.get("service", "unknown")
|
||||
return {
|
||||
"text": f"[{severity.upper()}] {rule_name}: {message}",
|
||||
"attachments": [
|
||||
{
|
||||
"color": color,
|
||||
"fields": [
|
||||
{"title": "Rule", "value": rule_name, "short": True},
|
||||
{"title": "Severity", "value": severity.upper(), "short": True},
|
||||
{"title": "Service", "value": svc, "short": True},
|
||||
{"title": "Action", "value": op, "short": True},
|
||||
{"title": "Actor", "value": actor, "short": True},
|
||||
{"title": "Target", "value": targets, "short": True},
|
||||
{"title": "Time", "value": ts, "short": False},
|
||||
],
|
||||
"footer": "AOC Admin Operations Center",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_teams_payload(rule_name: str, severity: str, message: str, event: dict) -> dict:
|
||||
"""Build a Microsoft Teams adaptive card payload."""
|
||||
color = {"high": "Attention", "medium": "Warning", "low": "Good"}.get(severity, "Default")
|
||||
ts = event.get("timestamp", "?")
|
||||
op = event.get("operation", "unknown")
|
||||
actor = event.get("actor_display", "unknown")
|
||||
targets = ", ".join(event.get("target_displays", [])) or "—"
|
||||
svc = event.get("service", "unknown")
|
||||
return {
|
||||
"type": "message",
|
||||
"attachments": [
|
||||
{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
"content": {
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.4",
|
||||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"🚨 {severity.upper()}: {rule_name}",
|
||||
"weight": "Bolder",
|
||||
"size": "Medium",
|
||||
"color": color,
|
||||
},
|
||||
{"type": "TextBlock", "text": message, "wrap": True},
|
||||
{
|
||||
"type": "FactSet",
|
||||
"facts": [
|
||||
{"title": "Service:", "value": svc},
|
||||
{"title": "Action:", "value": op},
|
||||
{"title": "Actor:", "value": actor},
|
||||
{"title": "Target:", "value": targets},
|
||||
{"title": "Time:", "value": ts},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_generic_payload(rule_name: str, severity: str, message: str, event: dict) -> dict:
|
||||
"""Build a generic JSON payload."""
|
||||
return {
|
||||
"alert": {
|
||||
"rule_name": rule_name,
|
||||
"severity": severity,
|
||||
"message": message,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"event": {
|
||||
"id": event.get("id"),
|
||||
"timestamp": event.get("timestamp"),
|
||||
"service": event.get("service"),
|
||||
"operation": event.get("operation"),
|
||||
"actor_display": event.get("actor_display"),
|
||||
"target_displays": event.get("target_displays"),
|
||||
"result": event.get("result"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def send_notification(
|
||||
webhook_url: str,
|
||||
format_type: str,
|
||||
rule_name: str,
|
||||
severity: str,
|
||||
message: str,
|
||||
event: dict,
|
||||
) -> bool:
|
||||
"""Send an alert notification to the configured channel.
|
||||
|
||||
Args:
|
||||
webhook_url: URL to POST to.
|
||||
format_type: "slack", "teams", or "generic".
|
||||
rule_name: Name of the triggered rule.
|
||||
severity: high, medium, or low.
|
||||
message: Human-readable alert message.
|
||||
event: The normalized event that triggered the alert.
|
||||
|
||||
Returns:
|
||||
True if delivery succeeded, False otherwise.
|
||||
"""
|
||||
if not webhook_url:
|
||||
return False
|
||||
|
||||
builders = {
|
||||
"slack": _build_slack_payload,
|
||||
"teams": _build_teams_payload,
|
||||
"generic": _build_generic_payload,
|
||||
}
|
||||
builder = builders.get(format_type, _build_generic_payload)
|
||||
payload = builder(rule_name, severity, message, event)
|
||||
|
||||
try:
|
||||
res = _post_webhook(webhook_url, payload)
|
||||
res.raise_for_status()
|
||||
logger.info(
|
||||
"Notification sent",
|
||||
rule=rule_name,
|
||||
severity=severity,
|
||||
format=format_type,
|
||||
status_code=res.status_code,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Notification failed after retries",
|
||||
rule=rule_name,
|
||||
severity=severity,
|
||||
format=format_type,
|
||||
error=str(exc),
|
||||
)
|
||||
return False
|
||||
36
backend/redis_client.py
Normal file
36
backend/redis_client.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Async Redis client singleton for caching and job queue."""
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from arq import create_pool
|
||||
from arq.connections import ArqRedis, RedisSettings
|
||||
from config import REDIS_URL
|
||||
|
||||
_arq_pool: ArqRedis | None = None
|
||||
_plain_redis: aioredis.Redis | None = None
|
||||
|
||||
|
||||
async def get_arq_pool() -> ArqRedis:
|
||||
"""Return a shared arq pool (ArqRedis extends redis.asyncio.Redis)."""
|
||||
global _arq_pool
|
||||
if _arq_pool is None:
|
||||
_arq_pool = await create_pool(RedisSettings.from_dsn(REDIS_URL))
|
||||
return _arq_pool
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
"""Return a shared plain async Redis client."""
|
||||
global _plain_redis
|
||||
if _plain_redis is None:
|
||||
_plain_redis = aioredis.from_url(REDIS_URL, decode_responses=True)
|
||||
return _plain_redis
|
||||
|
||||
|
||||
async def close_redis_connections():
|
||||
"""Close all Redis connections (call on shutdown)."""
|
||||
global _arq_pool, _plain_redis
|
||||
if _arq_pool:
|
||||
await _arq_pool.close()
|
||||
_arq_pool = None
|
||||
if _plain_redis:
|
||||
await _plain_redis.close()
|
||||
_plain_redis = None
|
||||
@@ -14,3 +14,5 @@ prometheus-client
|
||||
httpx
|
||||
gunicorn
|
||||
mcp
|
||||
redis
|
||||
arq
|
||||
|
||||
78
backend/routes/alerts.py
Normal file
78
backend/routes/alerts.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Alert management endpoints."""
|
||||
|
||||
from auth import require_auth
|
||||
from bson import ObjectId
|
||||
from database import alerts_collection
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class AlertStatusUpdate(BaseModel):
|
||||
status: str # open | acknowledged | resolved | false_positive
|
||||
|
||||
|
||||
class AlertListResponse(BaseModel):
|
||||
items: list[dict]
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("/alerts", response_model=AlertListResponse)
|
||||
def list_alerts(
|
||||
status: str = Query(default="", description="Filter by status"),
|
||||
severity: str = Query(default="", description="Filter by severity"),
|
||||
rule_name: str = Query(default="", description="Filter by rule name"),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
page: int = Query(default=1, ge=1),
|
||||
):
|
||||
query = {}
|
||||
if status:
|
||||
query["status"] = status
|
||||
if severity:
|
||||
query["severity"] = severity
|
||||
if rule_name:
|
||||
query["rule_name"] = {"$regex": rule_name, "$options": "i"}
|
||||
|
||||
total = alerts_collection.count_documents(query)
|
||||
skip = (page - 1) * page_size
|
||||
cursor = alerts_collection.find(query, {"_id": 0}).sort("timestamp", -1).skip(skip).limit(page_size)
|
||||
return {"items": list(cursor), "total": total}
|
||||
|
||||
|
||||
@router.patch("/alerts/{alert_id}/status")
|
||||
def update_alert_status(alert_id: str, body: AlertStatusUpdate):
|
||||
result = alerts_collection.update_one(
|
||||
{"_id": ObjectId(alert_id)},
|
||||
{"$set": {"status": body.status}},
|
||||
)
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
return {"updated": True, "status": body.status}
|
||||
|
||||
|
||||
@router.get("/alerts/summary")
|
||||
def alert_summary():
|
||||
"""Return counts by status and severity for the dashboard."""
|
||||
pipeline = [
|
||||
{
|
||||
"$group": {
|
||||
"_id": {"status": "$status", "severity": "$severity"},
|
||||
"count": {"$sum": 1},
|
||||
}
|
||||
}
|
||||
]
|
||||
by_status_severity = list(alerts_collection.aggregate(pipeline))
|
||||
|
||||
total_open = alerts_collection.count_documents({"status": "open"})
|
||||
total_acknowledged = alerts_collection.count_documents({"status": "acknowledged"})
|
||||
total_resolved = alerts_collection.count_documents({"status": "resolved"})
|
||||
total_false_positive = alerts_collection.count_documents({"status": "false_positive"})
|
||||
|
||||
return {
|
||||
"total_open": total_open,
|
||||
"total_acknowledged": total_acknowledged,
|
||||
"total_resolved": total_resolved,
|
||||
"total_false_positive": total_false_positive,
|
||||
"by_status_severity": by_status_severity,
|
||||
}
|
||||
@@ -18,7 +18,9 @@ from config import (
|
||||
)
|
||||
from database import events_collection
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from jobs import get_cached_ask, get_cached_explain, set_cached_ask, set_cached_explain
|
||||
from models.api import AskRequest, AskResponse
|
||||
from redis_client import get_arq_pool
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
logger = structlog.get_logger("aoc.ask")
|
||||
@@ -640,14 +642,23 @@ async def explain_event(event_id: str, user: dict = Depends(require_auth)):
|
||||
"llm_error": "LLM_API_KEY not configured",
|
||||
}
|
||||
|
||||
# Check cache first
|
||||
redis = await get_arq_pool()
|
||||
cached = await get_cached_explain(redis, event_id)
|
||||
if cached:
|
||||
cached["related_count"] = len(related)
|
||||
return cached
|
||||
|
||||
try:
|
||||
explanation = await _explain_event(event, related)
|
||||
return {
|
||||
result = {
|
||||
"explanation": explanation,
|
||||
"llm_used": True,
|
||||
"llm_error": None,
|
||||
"related_count": len(related),
|
||||
}
|
||||
await set_cached_explain(redis, event_id, result)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning("Event explanation failed", error=str(exc))
|
||||
return {
|
||||
@@ -746,19 +757,78 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
||||
llm_error="LLM not used — no events found." if not LLM_API_KEY else None,
|
||||
)
|
||||
|
||||
# Try LLM summarisation
|
||||
# Try LLM summarisation (with caching + optional async)
|
||||
answer = ""
|
||||
llm_used = False
|
||||
llm_error = None
|
||||
if not LLM_API_KEY:
|
||||
llm_error = "LLM_API_KEY is not configured. Set it in your .env to enable AI narrative summarisation."
|
||||
job_id = None
|
||||
|
||||
filters_snapshot = {
|
||||
"services": body.services,
|
||||
"actor": body.actor,
|
||||
"operation": body.operation,
|
||||
"result": body.result,
|
||||
"start": body.start,
|
||||
"end": body.end,
|
||||
"include_tags": body.include_tags,
|
||||
"exclude_tags": body.exclude_tags,
|
||||
}
|
||||
|
||||
if LLM_API_KEY:
|
||||
redis = await get_arq_pool()
|
||||
cached = await get_cached_ask(redis, question, filters_snapshot, events)
|
||||
if cached:
|
||||
answer = cached.get("answer", "")
|
||||
llm_used = cached.get("llm_used", False)
|
||||
llm_error = cached.get("llm_error")
|
||||
elif body.async_mode:
|
||||
pool = await get_arq_pool()
|
||||
job = await pool.enqueue_job(
|
||||
"process_ask_question",
|
||||
question,
|
||||
filters_snapshot,
|
||||
events,
|
||||
total,
|
||||
excluded_services,
|
||||
)
|
||||
job_id = job.job_id if job else None
|
||||
return AskResponse(
|
||||
answer="Your question is being processed. Poll /api/jobs/{job_id} for the result.",
|
||||
events=[_to_event_ref(e) for e in events],
|
||||
query_info={
|
||||
"entity": entity,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"event_count": len(events),
|
||||
"total_matched": total,
|
||||
"services_queried": query_services,
|
||||
"excluded_services": excluded_services,
|
||||
"mongo_query": json.dumps(query, default=str),
|
||||
},
|
||||
llm_used=False,
|
||||
llm_error=None,
|
||||
job_id=job_id,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
answer = await _call_llm(question, events, total=total, excluded_services=excluded_services)
|
||||
llm_used = True
|
||||
await set_cached_ask(
|
||||
redis,
|
||||
question,
|
||||
filters_snapshot,
|
||||
events,
|
||||
{
|
||||
"answer": answer,
|
||||
"llm_used": True,
|
||||
"llm_error": None,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
llm_error = f"LLM call failed: {exc}"
|
||||
logger.warning("LLM call failed, falling back to structured summary", error=str(exc))
|
||||
else:
|
||||
try:
|
||||
answer = await _call_llm(question, events, total=total, excluded_services=excluded_services)
|
||||
llm_used = True
|
||||
except Exception as exc:
|
||||
llm_error = f"LLM call failed: {exc}"
|
||||
logger.warning("LLM call failed, falling back to structured summary", error=str(exc))
|
||||
llm_error = "LLM_API_KEY is not configured. Set it in your .env to enable AI narrative summarisation."
|
||||
|
||||
# Fallback: structured summary if LLM unavailable or failed
|
||||
if not answer:
|
||||
@@ -797,4 +867,5 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
||||
},
|
||||
llm_used=llm_used,
|
||||
llm_error=llm_error,
|
||||
job_id=job_id,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from config import (
|
||||
AUTH_ENABLED,
|
||||
AUTH_SCOPE,
|
||||
AUTH_TENANT_ID,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
)
|
||||
from fastapi import APIRouter
|
||||
|
||||
@@ -25,4 +26,5 @@ def auth_config():
|
||||
def features_config():
|
||||
return {
|
||||
"ai_features_enabled": AI_FEATURES_ENABLED,
|
||||
"default_page_size": DEFAULT_PAGE_SIZE,
|
||||
}
|
||||
|
||||
43
backend/routes/jobs.py
Normal file
43
backend/routes/jobs.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Job status endpoints for async LLM operations."""
|
||||
|
||||
from arq.jobs import Job, JobStatus
|
||||
from auth import require_auth
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from redis_client import get_redis
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class JobStatusResponse(BaseModel):
|
||||
job_id: str
|
||||
status: str # queued, in_progress, complete, not_found, deferred
|
||||
result: dict | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=JobStatusResponse)
|
||||
async def get_job_status(job_id: str, user: dict = Depends(require_auth)):
|
||||
"""Poll for the result of an async LLM job."""
|
||||
redis = await get_redis()
|
||||
job = Job(job_id, redis)
|
||||
status = await job.status()
|
||||
|
||||
if status == JobStatus.not_found:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
result = None
|
||||
error = None
|
||||
if status == JobStatus.complete:
|
||||
try:
|
||||
result_data = await job.result(timeout=0)
|
||||
result = result_data if isinstance(result_data, dict) else {"data": str(result_data)}
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
|
||||
return JobStatusResponse(
|
||||
job_id=job_id,
|
||||
status=status.value,
|
||||
result=result,
|
||||
error=error,
|
||||
)
|
||||
195
backend/rules.py
195
backend/rules.py
@@ -1,6 +1,16 @@
|
||||
from datetime import UTC, datetime
|
||||
"""Rule-based alerting for admin operations.
|
||||
|
||||
Rules are evaluated during event ingestion. Triggered alerts are stored in MongoDB
|
||||
and optionally forwarded to a notification channel (webhook, Slack, Teams).
|
||||
|
||||
Deduplication: the same rule firing for the same actor within ALERT_DEDUPE_MINUTES
|
||||
produces only one alert.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from config import ALERT_DEDUPE_MINUTES, ALERT_WEBHOOK_FORMAT, ALERT_WEBHOOK_URL
|
||||
from database import db
|
||||
|
||||
logger = structlog.get_logger("aoc.rules")
|
||||
@@ -18,6 +28,13 @@ def evaluate_event(event: dict) -> list[dict]:
|
||||
rules = load_rules()
|
||||
for rule in rules:
|
||||
if _matches(rule, event):
|
||||
if _is_duplicate(rule, event):
|
||||
logger.debug(
|
||||
"Alert deduplicated",
|
||||
rule=rule.get("name"),
|
||||
event_id=event.get("id"),
|
||||
)
|
||||
continue
|
||||
triggered.append(rule)
|
||||
_create_alert(rule, event)
|
||||
return triggered
|
||||
@@ -50,6 +67,9 @@ def _matches(rule: dict, event: dict) -> bool:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
if op == "threshold_count":
|
||||
# Threshold rules are evaluated at query time, not per-event
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -64,7 +84,22 @@ def _get_nested(obj: dict, path: str):
|
||||
return val
|
||||
|
||||
|
||||
def _is_duplicate(rule: dict, event: dict) -> bool:
|
||||
"""Check if an alert for this rule + actor was recently created."""
|
||||
if ALERT_DEDUPE_MINUTES <= 0:
|
||||
return False
|
||||
cutoff = (datetime.now(UTC) - timedelta(minutes=ALERT_DEDUPE_MINUTES)).isoformat()
|
||||
actor = event.get("actor_display") or event.get("actor_upn") or "unknown"
|
||||
query = {
|
||||
"rule_id": str(rule.get("_id")),
|
||||
"actor": actor,
|
||||
"timestamp": {"$gte": cutoff},
|
||||
}
|
||||
return alerts_collection.count_documents(query, limit=1) > 0
|
||||
|
||||
|
||||
def _create_alert(rule: dict, event: dict):
|
||||
actor = event.get("actor_display") or event.get("actor_upn") or "unknown"
|
||||
alert = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"rule_id": str(rule.get("_id")),
|
||||
@@ -72,10 +107,168 @@ def _create_alert(rule: dict, event: dict):
|
||||
"severity": rule.get("severity", "medium"),
|
||||
"event_id": event.get("id"),
|
||||
"event_dedupe_key": event.get("dedupe_key"),
|
||||
"actor": actor,
|
||||
"message": rule.get("message", f"Rule '{rule.get('name')}' triggered"),
|
||||
"status": "open", # open | acknowledged | resolved | false_positive
|
||||
}
|
||||
try:
|
||||
alerts_collection.insert_one(alert)
|
||||
logger.info("Alert created", rule=rule.get("name"), event_id=event.get("id"))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to create alert", error=str(exc))
|
||||
return
|
||||
|
||||
# Send notification
|
||||
if ALERT_WEBHOOK_URL:
|
||||
try:
|
||||
from notifications import send_notification
|
||||
|
||||
send_notification(
|
||||
webhook_url=ALERT_WEBHOOK_URL,
|
||||
format_type=ALERT_WEBHOOK_FORMAT,
|
||||
rule_name=rule.get("name", "Unnamed rule"),
|
||||
severity=rule.get("severity", "medium"),
|
||||
message=rule.get("message", ""),
|
||||
event=event,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to send notification", error=str(exc))
|
||||
|
||||
|
||||
def seed_default_rules():
|
||||
"""Upsert pre-built admin-ops rule templates. Safe for concurrent startup."""
|
||||
defaults = [
|
||||
{
|
||||
"name": "Failed Conditional Access",
|
||||
"enabled": True,
|
||||
"severity": "high",
|
||||
"message": (
|
||||
"A Conditional Access policy evaluation failed. "
|
||||
"This may indicate a sign-in risk or policy misconfiguration."
|
||||
),
|
||||
"conditions": [
|
||||
{"field": "service", "op": "eq", "value": "Directory"},
|
||||
{"field": "operation", "op": "contains", "value": "ConditionalAccess"},
|
||||
{"field": "result", "op": "neq", "value": "success"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "After-Hours Admin Activity",
|
||||
"enabled": True,
|
||||
"severity": "medium",
|
||||
"message": "A privileged operation was performed outside business hours (9 AM – 5 PM).",
|
||||
"conditions": [
|
||||
{
|
||||
"field": "service",
|
||||
"op": "in",
|
||||
"value": ["Directory", "UserManagement", "GroupManagement", "RoleManagement"],
|
||||
},
|
||||
{"field": "timestamp", "op": "after_hours"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "New Application Registration",
|
||||
"enabled": True,
|
||||
"severity": "medium",
|
||||
"message": (
|
||||
"A new application was registered in Entra ID. Review for shadow IT or unauthorized integrations."
|
||||
),
|
||||
"conditions": [
|
||||
{"field": "service", "op": "eq", "value": "ApplicationManagement"},
|
||||
{"field": "operation", "op": "contains", "value": "Add application"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Admin Role Assignment",
|
||||
"enabled": True,
|
||||
"severity": "high",
|
||||
"message": "A user was assigned an administrative role. Verify this was expected and authorized.",
|
||||
"conditions": [
|
||||
{"field": "service", "op": "eq", "value": "RoleManagement"},
|
||||
{"field": "operation", "op": "contains", "value": "Add member to role"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "License Change",
|
||||
"enabled": True,
|
||||
"severity": "low",
|
||||
"message": "A license was assigned or removed from a user. Monitor for unexpected cost changes.",
|
||||
"conditions": [
|
||||
{"field": "service", "op": "eq", "value": "License"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Bulk User Deletion",
|
||||
"enabled": True,
|
||||
"severity": "high",
|
||||
"message": (
|
||||
"Multiple users were deleted in a short window. "
|
||||
"This may indicate a compromised admin account or cleanup activity."
|
||||
),
|
||||
"conditions": [
|
||||
{"field": "service", "op": "in", "value": ["Directory", "UserManagement"]},
|
||||
{"field": "operation", "op": "contains", "value": "Delete user"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Device Compliance Failure",
|
||||
"enabled": True,
|
||||
"severity": "medium",
|
||||
"message": (
|
||||
"A device failed compliance evaluation. "
|
||||
"It may no longer meet your organization's security requirements."
|
||||
),
|
||||
"conditions": [
|
||||
{"field": "service", "op": "eq", "value": "Intune"},
|
||||
{"field": "operation", "op": "contains", "value": "compliance"},
|
||||
{"field": "result", "op": "neq", "value": "success"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Exchange Transport Rule Change",
|
||||
"enabled": True,
|
||||
"severity": "high",
|
||||
"message": "An Exchange transport rule was modified. This could affect mail flow or security filtering.",
|
||||
"conditions": [
|
||||
{"field": "service", "op": "eq", "value": "Exchange"},
|
||||
{"field": "operation", "op": "contains", "value": "Transport rule"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Service Principal Credential Added",
|
||||
"enabled": True,
|
||||
"severity": "high",
|
||||
"message": "A new secret or certificate was added to a service principal. Verify this was expected.",
|
||||
"conditions": [
|
||||
{"field": "service", "op": "eq", "value": "ApplicationManagement"},
|
||||
{"field": "operation", "op": "contains", "value": "Add service principal credentials"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "External Sharing Enabled",
|
||||
"enabled": True,
|
||||
"severity": "medium",
|
||||
"message": (
|
||||
"External sharing settings were modified on a SharePoint site or team. Review for data exposure risk."
|
||||
),
|
||||
"conditions": [
|
||||
{"field": "service", "op": "in", "value": ["SharePoint", "Teams"]},
|
||||
{"field": "operation", "op": "contains", "value": "Sharing"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
inserted = 0
|
||||
for rule in defaults:
|
||||
try:
|
||||
result = rules_collection.replace_one(
|
||||
{"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))
|
||||
|
||||
@@ -49,6 +49,21 @@ def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
||||
monkeypatch.setattr("rules.rules_collection", audit_db["alert_rules"])
|
||||
monkeypatch.setattr("routes.rules.rules_collection", audit_db["alert_rules"])
|
||||
|
||||
# Mock Redis so tests don't require a running Redis server
|
||||
class FakeRedis:
|
||||
async def get(self, key):
|
||||
return None
|
||||
|
||||
async def setex(self, key, ttl, value):
|
||||
pass
|
||||
|
||||
async def fake_get_arq_pool():
|
||||
return FakeRedis()
|
||||
|
||||
monkeypatch.setattr("redis_client.get_arq_pool", fake_get_arq_pool)
|
||||
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||
monkeypatch.setattr("routes.jobs.get_redis", fake_get_arq_pool)
|
||||
|
||||
from main import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
@@ -89,6 +89,18 @@ def test_explain_event_with_llm_mock(client, mock_events_collection, monkeypatch
|
||||
|
||||
monkeypatch.setattr("routes.ask._explain_event", fake_explain)
|
||||
|
||||
class FakeRedis:
|
||||
async def get(self, key):
|
||||
return None
|
||||
|
||||
async def setex(self, key, ttl, value):
|
||||
pass
|
||||
|
||||
async def fake_get_arq_pool():
|
||||
return FakeRedis()
|
||||
|
||||
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||
|
||||
mock_events_collection.insert_one(
|
||||
{
|
||||
"id": "evt-explain2",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from jobs import set_cached_ask
|
||||
from routes.ask import _build_event_query, _extract_entity, _extract_time_range
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -350,3 +352,131 @@ class TestAskEndpoint:
|
||||
data = response.json()
|
||||
assert data["query_info"]["event_count"] == 1
|
||||
assert data["events"][0]["id"] == "evt-bob"
|
||||
|
||||
|
||||
class TestAskCaching:
|
||||
def test_ask_cache_hit_returns_cached_answer(self, client, mock_events_collection, monkeypatch):
|
||||
"""If the answer is cached, the LLM should not be called."""
|
||||
now = datetime.now(UTC)
|
||||
mock_events_collection.insert_one(
|
||||
{
|
||||
"id": "evt-cache",
|
||||
"timestamp": now.isoformat(),
|
||||
"service": "Directory",
|
||||
"operation": "Add user",
|
||||
"result": "success",
|
||||
"actor_display": "Alice",
|
||||
"target_displays": ["USER-001"],
|
||||
"display_summary": "summary",
|
||||
"raw_text": "raw",
|
||||
}
|
||||
)
|
||||
|
||||
llm_called = False
|
||||
|
||||
async def fake_llm(question, events, total=None, excluded_services=None):
|
||||
nonlocal llm_called
|
||||
llm_called = True
|
||||
return "This should NOT appear."
|
||||
|
||||
monkeypatch.setattr("routes.ask.LLM_API_KEY", "fake-key")
|
||||
monkeypatch.setattr("routes.ask._call_llm", fake_llm)
|
||||
|
||||
# Pre-populate cache with a specific answer
|
||||
class CachingFakeRedis:
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
|
||||
async def get(self, key):
|
||||
return self.store.get(key)
|
||||
|
||||
async def setex(self, key, ttl, value):
|
||||
self.store[key] = value
|
||||
|
||||
redis = CachingFakeRedis()
|
||||
# Seed cache with the exact filters the endpoint will generate
|
||||
filters_snapshot = {
|
||||
"services": None,
|
||||
"actor": None,
|
||||
"operation": None,
|
||||
"result": None,
|
||||
"start": None,
|
||||
"end": None,
|
||||
"include_tags": None,
|
||||
"exclude_tags": None,
|
||||
}
|
||||
asyncio.run(
|
||||
set_cached_ask(
|
||||
redis,
|
||||
"What happened to USER-001?",
|
||||
filters_snapshot,
|
||||
[{"id": "evt-cache"}],
|
||||
{"answer": "Cached answer!", "llm_used": True, "llm_error": None},
|
||||
)
|
||||
)
|
||||
|
||||
async def fake_get_arq_pool():
|
||||
return redis
|
||||
|
||||
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||
|
||||
response = client.post("/api/ask", json={"question": "What happened to USER-001?"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["answer"] == "Cached answer!"
|
||||
assert data["llm_used"] is True
|
||||
assert llm_called is False
|
||||
|
||||
def test_ask_async_mode_returns_job_id(self, client, mock_events_collection, monkeypatch):
|
||||
"""Async mode should return immediately with a job_id."""
|
||||
now = datetime.now(UTC)
|
||||
mock_events_collection.insert_one(
|
||||
{
|
||||
"id": "evt-async",
|
||||
"timestamp": now.isoformat(),
|
||||
"service": "Directory",
|
||||
"operation": "Add user",
|
||||
"result": "success",
|
||||
"actor_display": "Alice",
|
||||
"target_displays": ["USER-001"],
|
||||
"display_summary": "summary",
|
||||
"raw_text": "raw",
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("routes.ask.LLM_API_KEY", "fake-key")
|
||||
|
||||
# Mock arq pool to capture enqueue_job call
|
||||
class FakeArqPool:
|
||||
def __init__(self):
|
||||
self.enqueued = []
|
||||
|
||||
async def get(self, key):
|
||||
return None
|
||||
|
||||
async def setex(self, key, ttl, value):
|
||||
pass
|
||||
|
||||
async def enqueue_job(self, func, *args, **kwargs):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
job = MagicMock()
|
||||
job.job_id = "job-12345"
|
||||
self.enqueued.append((func, args, kwargs))
|
||||
return job
|
||||
|
||||
pool = FakeArqPool()
|
||||
|
||||
async def fake_get_arq_pool():
|
||||
return pool
|
||||
|
||||
monkeypatch.setattr("routes.ask.get_arq_pool", fake_get_arq_pool)
|
||||
|
||||
response = client.post("/api/ask", json={"question": "What happened to USER-001?", "async_mode": True})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["job_id"] == "job-12345"
|
||||
assert data["llm_used"] is False
|
||||
assert "being processed" in data["answer"]
|
||||
assert len(pool.enqueued) == 1
|
||||
assert pool.enqueued[0][0] == "process_ask_question"
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_evaluate_event_creates_alert(monkeypatch):
|
||||
inserted["doc"] = doc
|
||||
|
||||
monkeypatch.setattr(alerts_collection, "insert_one", mock_insert)
|
||||
monkeypatch.setattr(alerts_collection, "count_documents", lambda *args, **kwargs: 0)
|
||||
|
||||
event = {"id": "e1", "operation": "Add user", "timestamp": datetime.now(UTC).isoformat(), "dedupe_key": "dk1"}
|
||||
triggered = evaluate_event(event)
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
services:
|
||||
redis:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: aoc-redis
|
||||
restart: always
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- aoc-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
mongo:
|
||||
image: mongo:7
|
||||
container_name: aoc-mongo
|
||||
@@ -27,9 +42,12 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:27017/
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- aoc-internal
|
||||
healthcheck:
|
||||
@@ -39,6 +57,24 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
worker:
|
||||
image: git.cqre.net/cqrenet/aoc-backend:${AOC_VERSION:-latest}
|
||||
container_name: aoc-worker
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:27017/
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
command: ["arq", "jobs.WorkerSettings"]
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- aoc-internal
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: aoc-nginx
|
||||
@@ -58,6 +94,7 @@ services:
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
aoc-internal:
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
services:
|
||||
redis:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: aoc-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
mongo:
|
||||
image: mongo:7
|
||||
container_name: aoc-mongo
|
||||
@@ -21,10 +30,27 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:${MONGO_PORT}/
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
worker:
|
||||
build: ./backend
|
||||
container_name: aoc-worker
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongo:${MONGO_PORT}/
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
command: ["arq", "jobs.WorkerSettings"]
|
||||
depends_on:
|
||||
- redis
|
||||
- mongo
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
redis_data:
|
||||
|
||||
Reference in New Issue
Block a user