- Replace single-click service pill filter with explicit +/− buttons - '+' adds the service to the current filter (keeps other selections) - '−' removes the service from the current filter - Result pills keep toggle click behavior - Add .pill__action styles for small inline buttons
965 lines
42 KiB
HTML
965 lines
42 KiB
HTML
<!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=11" />
|
||
<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</p>
|
||
<h1>Audit Log Explorer</h1>
|
||
<p class="lede">Search and review Microsoft audit events from Entra, Intune, Exchange, SharePoint, and Teams.</p>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="panel">
|
||
<h3>Source Health</h3>
|
||
<div class="source-health">
|
||
<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">
|
||
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
||
<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">
|
||
<h3>Ask a question</h3>
|
||
<form class="ask-form" @submit.prevent="askQuestion()">
|
||
<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">
|
||
<span x-text="evt.display_category || evt.service || '—'"></span>
|
||
<span class="pill__action" @click.stop="addServiceFilter(evt.service || evt.display_category)" title="Include this service">+</span>
|
||
<span class="pill__action" @click.stop="removeServiceFilter(evt.service || evt.display_category)" title="Exclude this service">−</span>
|
||
</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">
|
||
<h2>Events</h2>
|
||
<span id="count" x-text="countText"></span>
|
||
</div>
|
||
<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>
|
||
</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: 25, includeTags: '', excludeTags: '',
|
||
},
|
||
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,
|
||
askQuestionText: '',
|
||
askLoading: false,
|
||
askAnswer: '',
|
||
askAnswerHtml: '',
|
||
askEvents: [],
|
||
askLlmUsed: false,
|
||
askLlmError: '',
|
||
|
||
async initApp() {
|
||
await this.loadVersion();
|
||
await this.initAuth();
|
||
this.loadSavedFilters();
|
||
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
||
await this.loadFilterOptions();
|
||
await this.loadSavedSearches();
|
||
await this.loadSourceHealth();
|
||
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 {}
|
||
},
|
||
|
||
async loadVersion() {
|
||
try {
|
||
const res = await fetch('/api/version');
|
||
if (res.ok) {
|
||
const body = await res.json();
|
||
this.appVersion = body.version || '';
|
||
}
|
||
} 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');
|
||
this.authConfig = await res.json();
|
||
} catch {
|
||
this.authConfig = { auth_enabled: false };
|
||
}
|
||
|
||
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.aiFeaturesEnabled = true;
|
||
}
|
||
} catch {
|
||
this.aiFeaturesEnabled = true;
|
||
}
|
||
|
||
if (!this.authConfig?.auth_enabled) {
|
||
this.authBtnText = '';
|
||
return;
|
||
}
|
||
|
||
if (typeof msal === 'undefined' || !msal.PublicClientApplication) {
|
||
this.statusText = 'Login library failed to load. Please check network or CDN.';
|
||
return;
|
||
}
|
||
|
||
const tenantId = this.authConfig.tenant_id;
|
||
const clientId = this.authConfig.client_id;
|
||
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: exclude noisy high-volume services
|
||
const noisy = ['Exchange', 'SharePoint', 'Teams'];
|
||
this.filters.selectedServices = this.options.services.filter((s) => !noisy.includes(s));
|
||
} 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() {
|
||
const noisy = ['Exchange', 'SharePoint', 'Teams'];
|
||
this.filters = { actor: '', selectedServices: this.options.services.filter((s) => !noisy.includes(s)), search: '', operation: '', result: '', start: '', end: '', limit: 25, includeTags: '', excludeTags: '' };
|
||
this.saveFilters();
|
||
this.resetPagination();
|
||
this.loadEvents();
|
||
},
|
||
|
||
addServiceFilter(service) {
|
||
if (!service) return;
|
||
if (!this.filters.selectedServices.includes(service)) {
|
||
this.filters.selectedServices.push(service);
|
||
this.saveFilters();
|
||
this.resetPagination();
|
||
this.loadEvents();
|
||
}
|
||
},
|
||
|
||
removeServiceFilter(service) {
|
||
if (!service) return;
|
||
this.filters.selectedServices = this.filters.selectedServices.filter((s) => s !== 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 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, '>')
|
||
.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>
|
||
</body>
|
||
</html>
|