Files
aoc/backend/frontend/index.html
Tomas Kracmar de7df3f390
All checks were successful
Release / build-and-push (push) Successful in 41s
CI / lint-and-test (push) Successful in 25s
v1.7.13: switch Alpine.js to CSP build, remove unsafe-eval from CSP
2026-04-27 16:04:45 +02:00

1287 lines
57 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<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=15" />
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@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</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="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">
<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>
<span class="pill"
:class="src.status === 'healthy' ? 'pill--ok' : (src.status === 'error' ? 'pill--err' : 'pill--warn')"
x-text="src.status"></span>
<small x-text="src.last_fetch_time ? new Date(src.last_fetch_time).toLocaleString() : (src.last_attempt_time ? new Date(src.last_attempt_time).toLocaleString() : 'Never')"></small>
</div>
</template>
</div>
</section>
<section class="panel">
<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)
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" x-model="filters.actor" />
<datalist id="actorOptions">
<template x-for="opt in options.actors" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
Action (display name)
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" x-model="filters.operation" />
<datalist id="operationOptions">
<template x-for="opt in options.operations" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
Action type (result)
<input name="result" type="text" placeholder="success / failure" list="resultOptions" x-model="filters.result" />
<datalist id="resultOptions">
<template x-for="opt in options.results" :key="opt"><option :value="opt"></option></template>
</datalist>
</label>
<label>
Limit
<input name="limit" type="number" min="1" max="500" value="100" x-model.number="filters.limit" />
</label>
</div>
<div class="filter-row">
<label>
From
<input name="start" type="datetime-local" x-model="filters.start" />
</label>
<label>
To
<input name="end" type="datetime-local" x-model="filters.end" />
</label>
<label>
Include tags
<input name="includeTags" type="text" placeholder="backup, critical" x-model="filters.includeTags" />
</label>
<label>
Exclude tags
<input name="excludeTags" type="text" placeholder="noise, auto" x-model="filters.excludeTags" />
</label>
</div>
<div class="filter-row">
<label class="span-2">
Search (raw/full-text)
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
</label>
<div class="filter-group span-2">
<span>App / Service</span>
<div class="multi-select">
<div class="multi-select__actions">
<button type="button" class="link" @click="filters.selectedServices = [...options.services]">All</button>
<button type="button" class="link" @click="filters.selectedServices = []">None</button>
</div>
<div class="multi-select__options">
<template x-for="opt in options.services" :key="opt">
<label class="checkbox-label">
<input type="checkbox" :value="opt" x-model="filters.selectedServices" />
<span x-text="opt"></span>
</label>
</template>
</div>
</div>
</div>
</div>
<div class="filter-row actions-row">
<div class="actions">
<button type="submit">Apply filters</button>
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
<button type="button" class="ghost" @click="saveCurrentFilters()">Save filters</button>
<button type="button" class="ghost" @click="bulkTagMatching()">Bulk tag matching</button>
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
</div>
</div>
<div class="filter-row" x-show="savedSearches.length">
<div class="saved-searches">
<span>Saved:</span>
<template x-for="ss in savedSearches" :key="ss.id">
<span class="pill pill--tag" style="cursor:pointer;" @click="applySavedSearch(ss)">
<span x-text="ss.name"></span>
<button type="button" class="link" style="margin-left:4px;" @click.stop="deleteSavedSearch(ss.id)">×</button>
</span>
</template>
</div>
</div>
</form>
</section>
<section class="panel" x-show="aiFeaturesEnabled">
<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"
placeholder="What happened to device ABC123 in the last 3 days?"
x-model="askQuestionText"
class="ask-input"
/>
<button type="submit" :disabled="askLoading" x-text="askLoading ? 'Thinking…' : 'Ask'">Ask</button>
</div>
<div x-show="hasActiveFilters()" class="ask-filter-hint">
<small>Respecting active filters: <span x-text="activeFilterSummary()"></span></small>
</div>
</form>
<template x-if="askAnswer">
<div class="ask-result">
<div x-show="askLlmError" class="ask-error" x-text="askLlmError"></div>
<div class="ask-answer" x-html="askAnswerHtml"></div>
<template x-if="askEvents.length">
<div class="ask-events">
<h4>Referenced events</h4>
<template x-for="(evt, idx) in askEvents" :key="evt.id || idx">
<article class="event event--compact">
<div class="event__meta">
<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>
<p class="event__detail"><strong>Actor:</strong> <span x-text="evt.actor_display || '—'"></span></p>
<p class="event__detail"><strong>Target:</strong> <span x-text="Array.isArray(evt.target_displays) ? evt.target_displays.join(', ') : '—'"></span></p>
<p class="event__detail"><strong>When:</strong> <span x-text="evt.timestamp ? new Date(evt.timestamp).toLocaleString() : '—'"></span></p>
</article>
</template>
</div>
</template>
<button type="button" class="ghost" @click="clearAsk()">Clear</button>
</div>
</template>
</section>
<section class="panel">
<div class="panel-header panel-header--collapsible" @click="togglePanel('events')">
<h2>Events</h2>
<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 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 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>
<p class="event__detail"><strong x-text="evt.display_actor_label || 'User'"></strong>: <span x-text="displayActor(evt)"></span></p>
<p class="event__detail" x-show="evt.actor_owner_names && evt.actor_owner_names.length"><strong>App owners:</strong> <span x-text="(evt.actor_owner_names || []).slice(0,3).join(', ')"></span></p>
<p class="event__detail"><strong>Target:</strong> <span x-text="displayTargets(evt)"></span></p>
<p class="event__detail"><strong>When:</strong> <span x-text="evt.timestamp ? new Date(evt.timestamp).toLocaleString() : '—'"></span></p>
<div class="event__tags" x-show="evt.tags && evt.tags.length">
<template x-for="tag in (evt.tags || [])" :key="tag">
<span class="pill pill--tag" x-text="tag"></span>
</template>
</div>
<div class="event__comments" x-show="evt.comments && evt.comments.length">
<template x-for="c in (evt.comments || [])" :key="c.timestamp + c.text">
<p class="comment"><strong x-text="c.author"></strong>: <span x-text="c.text"></span> <small x-text="new Date(c.timestamp).toLocaleString()"></small></p>
</template>
</div>
<div class="event__actions">
<button type="button" class="ghost" @click="openModal(evt)">View raw event</button>
<input type="text" placeholder="Add tag" @keydown.enter="addTag(evt, $event.target.value); $event.target.value=''" />
<input type="text" placeholder="Add comment" @keydown.enter="addComment(evt, $event.target.value); $event.target.value=''" />
</div>
</article>
</template>
</div>
<div id="pagination" class="pagination">
<button type="button" id="prevPage" :disabled="cursorStack.length === 0" @click="goPrev()">Prev</button>
<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 }">
<div class="modal__content">
<div class="modal__header">
<h3 id="modalTitle">Raw Event</h3>
<div class="modal__actions">
<button type="button" class="ghost" @click="copyRawEvent()">Copy</button>
<button type="button" class="ghost" x-show="aiFeaturesEnabled" :disabled="modalExplainLoading" @click="explainEvent()" x-text="modalExplainLoading ? 'Explaining…' : 'Explain'">Explain</button>
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
</div>
</div>
<div x-show="modalExplanation || modalExplainError" class="modal__explanation">
<div x-show="modalExplainError" class="ask-error" x-text="modalExplainError"></div>
<div x-show="modalExplanation" class="ask-answer" x-html="_mdToHtml(modalExplanation)"></div>
</div>
<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>
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/Event #(\d+)/g, '<strong>Event #$1</strong>')
.replace(/\n/g, '<br>');
},
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);
},
};
}
</script>
<script>
// Alpine CSP build requires explicit start
document.addEventListener('DOMContentLoaded', function () {
if (window.Alpine && typeof window.Alpine.start === 'function') {
window.Alpine.start();
}
});
</script>
</body>
</html>