diff --git a/.gitignore b/.gitignore
index 21b4c57..d400280 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ venv/
coverage.xml
.vscode/
.idea/
+memory/
diff --git a/VERSION b/VERSION
index 68ced4b..25eebeb 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.7.14
+1.7.15
diff --git a/backend/auth.py b/backend/auth.py
index 541c82e..303a6ce 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -1,4 +1,6 @@
+import asyncio
import contextvars
+import threading
import time
import requests
@@ -20,23 +22,37 @@ from jwt.algorithms import RSAAlgorithm
_auth_context: contextvars.ContextVar[dict | None] = contextvars.ContextVar("auth_context", default=None)
JWKS_CACHE = {"exp": 0, "keys": []}
+_jwks_lock = threading.Lock()
logger = structlog.get_logger("aoc.auth")
-def _get_jwks():
- now = time.time()
- if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
- return JWKS_CACHE["keys"]
-
+def _fetch_jwks_blocking() -> list:
+ """Fetch JWKS from Microsoft — runs in a thread, never in the event loop."""
oidc = requests.get(
f"https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration",
timeout=10,
).json()
jwks_uri = oidc["jwks_uri"]
- keys = requests.get(jwks_uri, timeout=10).json()["keys"]
- JWKS_CACHE["keys"] = keys
- JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h
- return keys
+ return requests.get(jwks_uri, timeout=10).json()["keys"]
+
+
+def _get_jwks():
+ now = time.time()
+ with _jwks_lock:
+ if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
+ return JWKS_CACHE["keys"]
+ keys = _fetch_jwks_blocking()
+ JWKS_CACHE["keys"] = keys
+ JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h
+ return keys
+
+
+async def _get_jwks_async() -> list:
+ """Non-blocking JWKS fetch: return from cache or refresh in a thread pool."""
+ now = time.time()
+ if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now:
+ return JWKS_CACHE["keys"]
+ return await asyncio.to_thread(_get_jwks)
def _allowed(claims: dict, allowed_roles: set[str], allowed_groups: set[str]) -> bool:
@@ -96,7 +112,7 @@ def user_can_access_privacy_services(claims: dict) -> bool:
return bool(user_roles.intersection(PRIVACY_SERVICE_ROLES))
-def require_auth(authorization: str | None = Header(None)):
+async def require_auth(authorization: str | None = Header(None)):
if not AUTH_ENABLED:
user = {"sub": "anonymous"}
_auth_context.set(user)
@@ -106,7 +122,7 @@ def require_auth(authorization: str | None = Header(None)):
raise HTTPException(status_code=401, detail="Missing bearer token")
token = authorization.split(" ", 1)[1]
- jwks = _get_jwks()
+ jwks = await _get_jwks_async()
claims = _decode_token(token, jwks)
if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS):
diff --git a/backend/frontend/app.js b/backend/frontend/app.js
new file mode 100644
index 0000000..0a4fd79
--- /dev/null
+++ b/backend/frontend/app.js
@@ -0,0 +1,820 @@
+function aocApp() {
+ return {
+ events: [],
+ sourceHealth: [],
+ statusText: '',
+ countText: '',
+ cursorStack: [],
+ nextCursor: null,
+ currentCursor: null,
+ modalOpen: false,
+ modalBody: '',
+ modalEventId: '',
+ modalExplanation: '',
+ modalExplainLoading: false,
+ modalExplainError: '',
+ authBtnText: 'Login',
+ authConfig: null,
+ msalInstance: null,
+ account: null,
+ accessToken: null,
+ authScopes: [],
+ filters: {
+ 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: '',
+ askAnswerHtml: '',
+ askEvents: [],
+ askLlmUsed: false,
+ askLlmError: '',
+
+ async initApp() {
+ 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();
+ }
+ },
+
+ loadSavedFilters() {
+ try {
+ const saved = localStorage.getItem('aoc_filters');
+ if (!saved) return;
+ const parsed = JSON.parse(saved);
+ const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
+ fields.forEach((f) => {
+ if (parsed[f] !== undefined) this.filters[f] = parsed[f];
+ });
+ } catch {}
+ },
+
+ saveFilters() {
+ try {
+ localStorage.setItem('aoc_filters', JSON.stringify(this.filters));
+ } 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 || '').replace(/^v/, '');
+ }
+ } catch {}
+ },
+
+ authHeader() {
+ return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
+ },
+
+ pickToken(res) {
+ if (!res) return null;
+ const clientId = this.authConfig?.client_id;
+ // If accessToken is present and its audience matches our API, use it.
+ if (res.accessToken && clientId) {
+ try {
+ const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
+ const payload = JSON.parse(atob(padded));
+ if (payload.aud === clientId) {
+ return res.accessToken;
+ }
+ } catch {}
+ }
+ // Fall back to idToken (always aud=clientId) or accessToken
+ return res.idToken || res.accessToken || null;
+ },
+
+ async initAuth() {
+ try {
+ const res = await fetch('/api/config/auth');
+ if (!res.ok) {
+ console.error('Auth config fetch failed:', res.status, res.statusText);
+ this.authConfig = { auth_enabled: false, _error: res.status };
+ } else {
+ this.authConfig = await res.json();
+ }
+ } catch (err) {
+ console.error('Auth config fetch error:', err);
+ this.authConfig = { auth_enabled: false, _error: 'network' };
+ }
+
+ try {
+ const featRes = await fetch('/api/config/features');
+ 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;
+ }
+ } catch {
+ this.aiFeaturesEnabled = true;
+ }
+
+ if (!this.authConfig?.auth_enabled) {
+ this.authBtnText = 'Auth: OFF';
+ console.warn('AOC auth is disabled. Set AUTH_ENABLED=true in .env to enable login.');
+ return;
+ }
+
+ const tenantId = this.authConfig.tenant_id;
+ const clientId = this.authConfig.client_id;
+ if (!clientId || !tenantId) {
+ this.authBtnText = 'Auth: misconfigured';
+ this.statusText = 'Auth is enabled but client_id or tenant_id is missing. Check .env configuration.';
+ console.error('AOC auth misconfigured: missing client_id or tenant_id in /api/config/auth');
+ return;
+ }
+
+ if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
+ this.statusText = 'Login library failed to load. Please check network or CDN.';
+ return;
+ }
+
+ const baseScope = this.authConfig.scope || "";
+ this.authScopes = Array.from(new Set(['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)]));
+ const authority = `https://login.microsoftonline.com/${tenantId}`;
+ const redirectUri = window.location.origin;
+
+ this.msalInstance = new msal.PublicClientApplication({
+ auth: { clientId, authority, redirectUri },
+ cache: { cacheLocation: 'sessionStorage' },
+ });
+
+ const redirectResult = await this.msalInstance.handleRedirectPromise().catch(() => null);
+ if (redirectResult) {
+ this.account = redirectResult.account;
+ this.msalInstance.setActiveAccount(this.account);
+ this.accessToken = this.pickToken(redirectResult);
+ } else {
+ const accounts = this.msalInstance.getAllAccounts();
+ if (accounts.length) {
+ this.account = accounts[0];
+ this.msalInstance.setActiveAccount(this.account);
+ this.accessToken = await this.acquireToken(this.authScopes);
+ }
+ }
+
+ this.updateAuthButtons();
+ },
+
+ async acquireToken(scopes) {
+ if (!this.msalInstance || !this.account) return null;
+ const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account: this.account };
+ try {
+ const res = await this.msalInstance.acquireTokenSilent(request);
+ return this.pickToken(res);
+ } catch {
+ const res = await this.msalInstance.acquireTokenPopup(request);
+ return this.pickToken(res);
+ }
+ },
+
+ updateAuthButtons() {
+ const loggedIn = !!this.account;
+ if (this.authConfig?.auth_enabled) {
+ this.authBtnText = loggedIn ? 'Logout' : 'Login';
+ }
+ if (loggedIn) {
+ this.acquireToken(this.authScopes).then((t) => { if (t) this.accessToken = t; }).catch(() => {});
+ this.statusText = '';
+ } else if (this.authConfig?.auth_enabled) {
+ this.statusText = 'Please log in to view events.';
+ }
+ },
+
+ async toggleAuth() {
+ if (!this.authConfig?.auth_enabled || !this.msalInstance) return;
+ if (this.account) {
+ const acc = this.msalInstance.getActiveAccount();
+ this.accessToken = null;
+ this.account = null;
+ this.updateAuthButtons();
+ if (acc) await this.msalInstance.logoutPopup({ account: acc });
+ return;
+ }
+ const scopes = this.authScopes && this.authScopes.length ? this.authScopes : ['openid', 'profile', 'email'];
+ this.statusText = 'Redirecting to sign in...';
+ this.msalInstance.loginRedirect({ scopes });
+ },
+
+ async loadEvents(cursor) {
+ this.currentCursor = cursor || null;
+ const params = new URLSearchParams();
+ ['actor', 'operation', 'result', 'search'].forEach((key) => {
+ const val = this.filters[key];
+ if (val) params.append(key, val);
+ });
+ if (this.filters.selectedServices && this.filters.selectedServices.length) {
+ this.filters.selectedServices.forEach((s) => params.append('services', s));
+ }
+ if (this.filters.includeTags) {
+ this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
+ }
+ if (this.filters.excludeTags) {
+ this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
+ }
+ if (this.filters.start) {
+ const d = new Date(this.filters.start);
+ if (!isNaN(d.getTime())) params.append('start', d.toISOString());
+ }
+ if (this.filters.end) {
+ const d = new Date(this.filters.end);
+ if (!isNaN(d.getTime())) params.append('end', d.toISOString());
+ }
+ params.append('page_size', String(this.filters.limit || 50));
+ if (cursor) params.append('cursor', cursor);
+
+ this.statusText = 'Loading events…';
+ this.countText = '';
+
+ if (this.authConfig?.auth_enabled && !this.accessToken) {
+ this.statusText = 'Please sign in to load events.';
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...this.authHeader() } });
+ if (!res.ok) throw new Error(`Request failed: ${res.status} ${await res.text()}`);
+ const body = await res.json();
+ this.events = body.items || [];
+ this.nextCursor = body.next_cursor || null;
+ this.countText = body.total >= 0 ? `${body.total} event${body.total === 1 ? '' : 's'}` : '';
+ this.statusText = this.events.length ? '' : 'No events found for these filters.';
+ this.saveFilters();
+ } catch (err) {
+ this.statusText = err.message || 'Failed to load events.';
+ }
+ },
+
+ async fetchLogs() {
+ this.statusText = 'Fetching latest audit logs…';
+ if (this.authConfig?.auth_enabled && !this.accessToken) {
+ this.statusText = 'Please sign in first.';
+ return;
+ }
+ try {
+ const res = await fetch('/api/fetch-audit-logs', { headers: this.authHeader() });
+ if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${await res.text()}`);
+ const body = await res.json();
+ const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : '';
+ this.statusText = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
+ this.resetPagination();
+ await this.loadEvents();
+ await this.loadSourceHealth();
+ } catch (err) {
+ this.statusText = err.message || 'Failed to fetch audit logs.';
+ }
+ },
+
+ async loadFilterOptions() {
+ if (this.authConfig?.auth_enabled && !this.accessToken) return;
+ try {
+ const res = await fetch('/api/filter-options', { headers: this.authHeader() });
+ if (!res.ok) return;
+ const opts = await res.json();
+ this.options.actors = (opts.actors || []).slice(0, 200);
+ this.options.services = (opts.services || []).slice(0, 200);
+ this.options.operations = (opts.operations || []).slice(0, 200);
+ this.options.results = (opts.results || []).slice(0, 200);
+
+ const saved = localStorage.getItem('aoc_filters');
+ if (!saved && this.options.services.length) {
+ // 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);
+ if (parsed.selectedServices) {
+ this.filters.selectedServices = parsed.selectedServices.filter((s) => this.options.services.includes(s));
+ }
+ } catch {}
+ }
+ } catch {}
+ },
+
+ async loadSourceHealth() {
+ try {
+ const res = await fetch('/api/source-health', { headers: this.authHeader() });
+ if (!res.ok) return;
+ this.sourceHealth = await res.json();
+ } catch {}
+ },
+
+ async loadSavedSearches() {
+ try {
+ const res = await fetch('/api/saved-searches', { headers: this.authHeader() });
+ if (!res.ok) return;
+ this.savedSearches = await res.json();
+ } catch {}
+ },
+
+ async saveCurrentFilters() {
+ const name = prompt('Name this saved filter:');
+ if (!name || !name.trim()) return;
+ try {
+ const res = await fetch('/api/saved-searches', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...this.authHeader() },
+ body: JSON.stringify({ name: name.trim(), filters: { ...this.filters } }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ const created = await res.json();
+ this.savedSearches.unshift(created);
+ this.statusText = 'Filters saved.';
+ setTimeout(() => { if (this.statusText === 'Filters saved.') this.statusText = ''; }, 2000);
+ } catch (err) {
+ this.statusText = err.message || 'Failed to save filters.';
+ }
+ },
+
+ applySavedSearch(ss) {
+ if (!ss || !ss.filters) return;
+ const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
+ fields.forEach((f) => {
+ if (ss.filters[f] !== undefined) this.filters[f] = ss.filters[f];
+ });
+ // Validate selectedServices against current options
+ this.filters.selectedServices = this.filters.selectedServices.filter((s) => this.options.services.includes(s));
+ this.resetPagination();
+ this.loadEvents();
+ },
+
+ async deleteSavedSearch(id) {
+ if (!confirm('Delete this saved search?')) return;
+ try {
+ const res = await fetch(`/api/saved-searches/${id}`, {
+ method: 'DELETE',
+ headers: this.authHeader(),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ this.savedSearches = this.savedSearches.filter((s) => s.id !== id);
+ } catch (err) {
+ this.statusText = err.message || 'Failed to delete saved search.';
+ }
+ },
+
+ resetPagination() {
+ this.cursorStack = [];
+ this.nextCursor = null;
+ this.currentCursor = null;
+ },
+
+ goPrev() {
+ if (this.cursorStack.length) {
+ const prevCursor = this.cursorStack.pop();
+ this.loadEvents(prevCursor);
+ }
+ },
+
+ goNext() {
+ if (this.nextCursor) {
+ this.cursorStack.push(this.currentCursor);
+ this.loadEvents(this.nextCursor);
+ }
+ },
+
+ clearFilters() {
+ 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;
+ this.askLoading = true;
+ this.askAnswer = '';
+ this.askAnswerHtml = '';
+ this.askEvents = [];
+ this.askLlmError = '';
+
+ const payload = { question: q };
+ if (this.filters.selectedServices && this.filters.selectedServices.length) {
+ payload.services = this.filters.selectedServices;
+ }
+ if (this.filters.actor) payload.actor = this.filters.actor;
+ if (this.filters.operation) payload.operation = this.filters.operation;
+ if (this.filters.result) payload.result = this.filters.result;
+ if (this.filters.start) payload.start = new Date(this.filters.start).toISOString();
+ if (this.filters.end) payload.end = new Date(this.filters.end).toISOString();
+ if (this.filters.includeTags) {
+ payload.include_tags = this.filters.includeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
+ }
+ if (this.filters.excludeTags) {
+ payload.exclude_tags = this.filters.excludeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
+ }
+
+ try {
+ const res = await fetch('/api/ask', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...this.authHeader() },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ const body = await res.json();
+ this.askAnswer = body.answer;
+ this.askAnswerHtml = this._mdToHtml(body.answer);
+ this.askEvents = body.events || [];
+ this.askLlmUsed = body.llm_used;
+ this.askLlmError = body.llm_error || '';
+ } catch (err) {
+ this.askAnswer = 'Sorry, something went wrong: ' + (err.message || 'Unknown error');
+ this.askAnswerHtml = this.askAnswer;
+ } finally {
+ this.askLoading = false;
+ }
+ },
+
+ clearAsk() {
+ this.askQuestionText = '';
+ this.askAnswer = '';
+ this.askAnswerHtml = '';
+ this.askEvents = [];
+ this.askLlmUsed = false;
+ this.askLlmError = '';
+ },
+
+ _mdToHtml(text) {
+ // Very lightweight markdown-to-HTML for LLM answers
+ return text
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/Event #(\d+)/g, 'Event #$1')
+ .replace(/\n/g, '
');
+ },
+
+ hasActiveFilters() {
+ return this.filters.actor || this.filters.operation || this.filters.result ||
+ this.filters.start || this.filters.end || this.filters.includeTags ||
+ this.filters.excludeTags ||
+ (this.filters.selectedServices && this.filters.selectedServices.length &&
+ this.filters.selectedServices.length < this.options.services.length);
+ },
+
+ activeFilterSummary() {
+ const parts = [];
+ if (this.filters.actor) parts.push('actor');
+ if (this.filters.operation) parts.push('action');
+ if (this.filters.result) parts.push('result');
+ if (this.filters.start || this.filters.end) parts.push('time');
+ if (this.filters.includeTags || this.filters.excludeTags) parts.push('tags');
+ const svcCount = this.filters.selectedServices?.length || 0;
+ const allCount = this.options.services?.length || 0;
+ if (svcCount && svcCount < allCount) parts.push(`${svcCount} service${svcCount === 1 ? '' : 's'}`);
+ return parts.join(', ') || 'none';
+ },
+
+ async bulkTagMatching() {
+ const tag = prompt('Enter tag to apply to all matching events:');
+ if (!tag || !tag.trim()) return;
+ const mode = confirm('Click OK to REPLACE existing tags.\nClick Cancel to APPEND the new tag.') ? 'replace' : 'append';
+ const params = new URLSearchParams();
+ ['actor', 'operation', 'result', 'search'].forEach((key) => {
+ const val = this.filters[key];
+ if (val) params.append(key, val);
+ });
+ if (this.filters.selectedServices && this.filters.selectedServices.length) {
+ this.filters.selectedServices.forEach((s) => params.append('services', s));
+ }
+ if (this.filters.includeTags) {
+ this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t));
+ }
+ if (this.filters.excludeTags) {
+ this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t));
+ }
+ if (this.filters.start) {
+ const d = new Date(this.filters.start);
+ if (!isNaN(d.getTime())) params.append('start', d.toISOString());
+ }
+ if (this.filters.end) {
+ const d = new Date(this.filters.end);
+ if (!isNaN(d.getTime())) params.append('end', d.toISOString());
+ }
+ this.statusText = 'Applying bulk tag…';
+ try {
+ const res = await fetch(`/api/events/bulk-tags?${params.toString()}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...this.authHeader() },
+ body: JSON.stringify({ tags: [tag.trim()], mode }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ const body = await res.json();
+ this.statusText = `Tagged ${body.matched} events (${body.modified} modified).`;
+ await this.loadEvents();
+ } catch (err) {
+ this.statusText = err.message || 'Failed to apply bulk tag.';
+ }
+ },
+
+ displayActor(e) {
+ const app = e.actor?.application || e.actor?.app;
+ if (app?.displayName) return app.displayName;
+ return e.actor_display ||
+ (e.actor_resolved?.name) ||
+ (e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
+ ? `${e.actor.user.displayName} (${e.actor.user.userPrincipalName})`
+ : (e.actor?.user?.displayName || e.actor?.user?.userPrincipalName)) ||
+ e.actor?.servicePrincipal?.displayName ||
+ 'Unknown actor';
+ },
+
+ displayTargets(e) {
+ if (Array.isArray(e.target_displays) && e.target_displays.length) return e.target_displays.join(', ');
+ if (Array.isArray(e.targets) && e.targets.length) return e.targets[0].displayName || e.targets[0].id || '—';
+ return '—';
+ },
+
+ openModal(e) {
+ const seen = new WeakSet();
+ try {
+ this.modalBody = JSON.stringify(e.raw || e, (key, value) => {
+ if (typeof value === 'object' && value !== null) {
+ if (seen.has(value)) return '[Circular]';
+ seen.add(value);
+ }
+ return value;
+ }, 2);
+ } catch (err) {
+ this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
+ }
+ this.modalEventId = e.id || '';
+ this.modalExplanation = '';
+ this.modalExplainError = '';
+ this.modalOpen = true;
+ },
+
+ async copyRawEvent() {
+ if (!this.modalBody) return;
+ try {
+ await navigator.clipboard.writeText(this.modalBody);
+ this.statusText = 'Raw event copied to clipboard.';
+ setTimeout(() => { if (this.statusText === 'Raw event copied to clipboard.') this.statusText = ''; }, 2000);
+ } catch (err) {
+ this.statusText = 'Failed to copy to clipboard.';
+ }
+ },
+
+ async explainEvent() {
+ if (!this.modalEventId) return;
+ this.modalExplainLoading = true;
+ this.modalExplanation = '';
+ this.modalExplainError = '';
+ try {
+ const res = await fetch(`/api/events/${this.modalEventId}/explain`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...this.authHeader() },
+ });
+ if (!res.ok) throw new Error(await res.text());
+ const body = await res.json();
+ this.modalExplanation = body.explanation;
+ this.modalExplainError = body.llm_error || '';
+ } catch (err) {
+ this.modalExplainError = err.message || 'Failed to explain event.';
+ } finally {
+ this.modalExplainLoading = false;
+ }
+ },
+
+ async addTag(e, tag) {
+ if (!tag.trim()) return;
+ const tags = [...(e.tags || []), tag.trim()];
+ try {
+ const res = await fetch(`/api/events/${e.id}/tags`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', ...this.authHeader() },
+ body: JSON.stringify({ tags }),
+ });
+ if (res.ok) e.tags = tags;
+ } catch {}
+ },
+
+ async addComment(e, text) {
+ if (!text.trim()) return;
+ try {
+ const res = await fetch(`/api/events/${e.id}/comments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...this.authHeader() },
+ body: JSON.stringify({ text: text.trim() }),
+ });
+ if (res.ok) {
+ const c = await res.json();
+ e.comments = [...(e.comments || []), c];
+ }
+ } catch {}
+ },
+
+ exportJSON() {
+ const blob = new Blob([JSON.stringify(this.events, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ },
+
+ exportCSV() {
+ if (!this.events.length) return;
+ const headers = ['timestamp', 'service', 'operation', 'result', 'actor_display', 'target_displays', 'display_summary'];
+ const rows = this.events.map((e) => [
+ e.timestamp || '',
+ e.service || '',
+ e.operation || '',
+ e.result || '',
+ (e.actor_display || '').replace(/"/g, '""'),
+ (Array.isArray(e.target_displays) ? e.target_displays.join('; ') : '').replace(/"/g, '""'),
+ (e.display_summary || '').replace(/"/g, '""'),
+ ]);
+ const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${c}"`).join(','))].join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `aoc-events-${new Date().toISOString().slice(0,10)}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ },
+ };
+}
diff --git a/backend/frontend/index.html b/backend/frontend/index.html
index 8b95074..d14f787 100644
--- a/backend/frontend/index.html
+++ b/backend/frontend/index.html
@@ -5,6 +5,7 @@