First version
This commit is contained in:
293
backend/frontend/index.html
Normal file
293
backend/frontend/index.html
Normal file
@@ -0,0 +1,293 @@
|
||||
<!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>
|
||||
271
backend/frontend/style.css
Normal file
271
backend/frontend/style.css
Normal file
@@ -0,0 +1,271 @@
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: rgba(255, 255, 255, 0.04);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: #e6edf3;
|
||||
--muted: #94a3b8;
|
||||
--accent: #7dd3fc;
|
||||
--accent-strong: #38bdf8;
|
||||
--warn: #f97316;
|
||||
--ok: #22c55e;
|
||||
--shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
|
||||
font-family: "SF Pro Display", "Helvetica Neue", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(56, 189, 248, 0.08), transparent 30%),
|
||||
radial-gradient(circle at 80% 0%, rgba(125, 211, 252, 0.08), transparent 25%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 60px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-strong);
|
||||
font-size: 12px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.cta button,
|
||||
button,
|
||||
input[type="submit"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
margin-bottom: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#count {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 22px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.events {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.event__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(125, 211, 252, 0.12);
|
||||
border: 1px solid rgba(125, 211, 252, 0.4);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pill--ok {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.pill--warn {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
border-color: rgba(249, 115, 22, 0.5);
|
||||
}
|
||||
|
||||
.event h3 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.event__detail {
|
||||
margin: 4px 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.event__actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal__content {
|
||||
width: min(900px, 95vw);
|
||||
max-height: 85vh;
|
||||
background: #0b0f19;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.modal pre {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user