Files
aoc/backend/frontend/index.html
2025-11-28 21:43:44 +01:00

294 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AOC Events</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div class="page">
<header class="hero">
<div>
<p class="eyebrow">Admin Operations Center</p>
<h1>Directory Audit Explorer</h1>
<p class="lede">Filter Microsoft Entra audit events by user, app, time, action, and action type.</p>
</div>
<div class="cta">
<button id="fetchBtn" aria-label="Fetch latest audit logs">Fetch new</button>
<button id="refreshBtn" aria-label="Refresh events">Refresh</button>
</div>
</header>
<section class="panel">
<form id="filters" class="filters">
<label>
User (name/UPN)
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" />
<datalist id="actorOptions"></datalist>
</label>
<label>
App / Service
<input name="service" type="text" placeholder="DirectoryManagement" list="serviceOptions" />
<datalist id="serviceOptions"></datalist>
</label>
<label>
Search (raw/full-text)
<input name="search" type="text" placeholder="Any text to search in raw/summary" />
</label>
<label>
Action (display name)
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" />
<datalist id="operationOptions"></datalist>
</label>
<label>
Action type (result)
<input name="result" type="text" placeholder="success / failure" list="resultOptions" />
<datalist id="resultOptions"></datalist>
</label>
<label>
From
<input name="start" type="datetime-local" />
</label>
<label>
To
<input name="end" type="datetime-local" />
</label>
<label>
Limit
<input name="limit" type="number" min="1" max="500" value="100" />
</label>
<div class="actions">
<button type="submit">Apply filters</button>
<button type="button" id="clearBtn" class="ghost">Clear</button>
</div>
</form>
</section>
<section class="panel">
<div class="panel-header">
<h2>Events</h2>
<span id="count"></span>
</div>
<div id="status" class="status" aria-live="polite"></div>
<div id="events" class="events"></div>
<div id="pagination" class="pagination"></div>
</section>
</div>
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal__content">
<div class="modal__header">
<h3 id="modalTitle">Raw Event</h3>
<button type="button" id="closeModal" class="ghost">Close</button>
</div>
<pre id="modalBody"></pre>
</div>
</div>
<script type="module">
const form = document.getElementById('filters');
const eventsContainer = document.getElementById('events');
const status = document.getElementById('status');
const count = document.getElementById('count');
const refreshBtn = document.getElementById('refreshBtn');
const fetchBtn = document.getElementById('fetchBtn');
const clearBtn = document.getElementById('clearBtn');
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modalBody');
const closeModal = document.getElementById('closeModal');
let currentEvents = [];
let currentPage = 1;
let totalItems = 0;
let pageSize = 50;
const lists = {
actor: document.getElementById('actorOptions'),
service: document.getElementById('serviceOptions'),
operation: document.getElementById('operationOptions'),
result: document.getElementById('resultOptions'),
};
const toIso = (value) => {
if (!value) return '';
const date = new Date(value);
return isNaN(date.getTime()) ? '' : date.toISOString();
};
async function loadEvents() {
const params = new URLSearchParams();
const data = new FormData(form);
['actor', 'service', 'operation', 'result', 'search'].forEach((key) => {
const val = data.get(key)?.trim();
if (val) params.append(key, val);
});
const startIso = toIso(data.get('start'));
const endIso = toIso(data.get('end'));
if (startIso) params.append('start', startIso);
if (endIso) params.append('end', endIso);
const limit = data.get('limit');
if (limit) {
pageSize = Number(limit);
params.append('page_size', limit);
} else {
params.append('page_size', pageSize);
}
params.append('page', currentPage);
status.textContent = 'Loading events…';
eventsContainer.innerHTML = '';
count.textContent = '';
try {
const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json' } });
if (!res.ok) {
const msg = await res.text();
throw new Error(`Request failed: ${res.status} ${msg}`);
}
const body = await res.json();
const events = body.items || [];
totalItems = body.total || events.length;
pageSize = body.page_size || pageSize;
currentPage = body.page || currentPage;
currentEvents = events;
renderEvents(events);
renderPagination();
status.textContent = events.length ? '' : 'No events found for these filters.';
} catch (err) {
status.textContent = err.message || 'Failed to load events.';
}
}
async function fetchLogs() {
status.textContent = 'Fetching latest audit logs…';
try {
const res = await fetch('/api/fetch-audit-logs');
if (!res.ok) {
const msg = await res.text();
throw new Error(`Fetch failed: ${res.status} ${msg}`);
}
const body = await res.json();
const errs = Array.isArray(body.errors) && body.errors.length ? `Warnings: ${body.errors.join(' | ')}` : '';
status.textContent = `Fetched and stored ${body.stored_events || 0} events.${errs ? ' ' + errs : ''} Refreshing list…`;
await loadEvents();
} catch (err) {
status.textContent = err.message || 'Failed to fetch audit logs.';
}
}
async function loadFilterOptions() {
try {
const res = await fetch('/api/filter-options');
if (!res.ok) return;
const opts = await res.json();
const setOptions = (el, values) => {
if (!el) return;
el.innerHTML = (values || []).slice(0, 200).map((v) => `<option value="${String(v)}"></option>`).join('');
};
setOptions(lists.actor, opts.actors);
setOptions(lists.service, opts.services);
setOptions(lists.operation, opts.operations);
setOptions(lists.result, opts.results);
} catch {
/* ignore */
}
}
function renderEvents(events) {
count.textContent = totalItems ? `${totalItems} event${totalItems === 1 ? '' : 's'}` : '';
eventsContainer.innerHTML = events
.map((e, idx) => {
const actor =
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';
const owners = Array.isArray(e.actor_owner_names) && e.actor_owner_names.length
? `Owners: ${e.actor_owner_names.slice(0, 3).join(', ')}`
: '';
const time = e.timestamp ? new Date(e.timestamp).toLocaleString() : '—';
const service = e.service || '—';
const operation = e.operation || '—';
const result = e.result || '—';
const category = e.display_category || service;
const targets = Array.isArray(e.target_displays) && e.target_displays.length
? e.target_displays.join(', ')
: (Array.isArray(e.targets) && e.targets.length
? (e.targets[0].displayName || e.targets[0].id || '—')
: '—');
const summary = e.display_summary || '';
const actorLabel = e.display_actor_label || 'User';
const actorValue = e.display_actor_value || actor;
return `
<article class="event">
<div class="event__meta">
<span class="pill">${category}</span>
<span class="pill ${result.toLowerCase() === 'success' ? 'pill--ok' : 'pill--warn'}">${result}</span>
</div>
<h3>${operation}</h3>
${summary ? `<p class="event__detail"><strong>Summary:</strong> ${summary}</p>` : ''}
<p class="event__detail"><strong>${actorLabel}:</strong> ${actorValue}</p>
${owners ? `<p class="event__detail"><strong>App owners:</strong> ${owners}</p>` : ''}
<p class="event__detail"><strong>Target:</strong> ${targets}</p>
<p class="event__detail"><strong>When:</strong> ${time}</p>
<p class="event__actions"><button class="ghost view-raw" data-idx="${idx}" type="button">View raw event</button></p>
</article>
`;
})
.join('');
eventsContainer.querySelectorAll('.view-raw').forEach((btn) => {
btn.addEventListener('click', () => {
const idx = Number(btn.dataset.idx);
const event = currentEvents[idx];
const raw = event?.raw || event;
modalBody.textContent = JSON.stringify(raw, null, 2);
modal.classList.remove('hidden');
});
});
}
function renderPagination() {
const pagination = document.getElementById('pagination');
if (!pagination) return;
const totalPages = Math.max(1, Math.ceil((totalItems || 0) / (pageSize || 1)));
pagination.innerHTML = `
<button type="button" id="prevPage" ${currentPage <= 1 ? 'disabled' : ''}>Prev</button>
<span>Page ${currentPage} / ${totalPages}</span>
<button type="button" id="nextPage" ${currentPage >= totalPages ? 'disabled' : ''}>Next</button>
`;
const prev = document.getElementById('prevPage');
const next = document.getElementById('nextPage');
if (prev) prev.addEventListener('click', () => { if (currentPage > 1) { currentPage -= 1; loadEvents(); } });
if (next) next.addEventListener('click', () => { if (currentPage < totalPages) { currentPage += 1; loadEvents(); } });
}
closeModal.addEventListener('click', () => modal.classList.add('hidden'));
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.classList.add('hidden');
});
form.addEventListener('submit', (e) => {
e.preventDefault();
currentPage = 1;
loadEvents();
});
fetchBtn.addEventListener('click', () => fetchLogs());
refreshBtn.addEventListener('click', () => loadEvents());
clearBtn.addEventListener('click', () => {
form.reset();
currentPage = 1;
loadEvents();
});
loadFilterOptions();
loadEvents();
</script>
</body>
</html>