Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67f3c28e82 | |||
| 04c41ee740 | |||
| cbd46adaa6 | |||
| e4bafbc4b0 |
@@ -4,23 +4,49 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Admin Operations Center</title>
|
<title>Admin Operations Center</title>
|
||||||
<link rel="stylesheet" href="/style.css?v=8" />
|
<link rel="stylesheet" href="/style.css?v=9" />
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<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>
|
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page" x-data="aocApp()" x-init="initApp()">
|
<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">
|
<header class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Admin Operations Center <span class="version-badge" x-text="appVersion"></span></p>
|
<p class="eyebrow">Admin Operations Center</p>
|
||||||
<h1>Audit Log Explorer</h1>
|
<h1>Audit Log Explorer</h1>
|
||||||
<p class="lede">Search and review Microsoft audit events from Entra, Intune, Exchange, SharePoint, and Teams.</p>
|
<p class="lede">Search and review Microsoft audit events from Entra, Intune, Exchange, SharePoint, and Teams.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cta">
|
|
||||||
<button id="authBtn" class="ghost" aria-label="Login" x-text="authBtnText" @click="toggleAuth()"></button>
|
|
||||||
<button id="fetchBtn" aria-label="Fetch latest audit logs" @click="fetchLogs()">Fetch new</button>
|
|
||||||
<button id="refreshBtn" aria-label="Refresh events" @click="loadEvents(currentCursor)">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -239,6 +265,21 @@
|
|||||||
<pre id="modalBody" x-text="modalBody"></pre>
|
<pre id="modalBody" x-text="modalBody"></pre>
|
||||||
</div>
|
</div>
|
||||||
</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</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -269,6 +310,8 @@
|
|||||||
options: { actors: [], services: [], operations: [], results: [] },
|
options: { actors: [], services: [], operations: [], results: [] },
|
||||||
savedSearches: [],
|
savedSearches: [],
|
||||||
appVersion: '',
|
appVersion: '',
|
||||||
|
repoUrl: 'https://git.cqre.net/cqrenet/aoc',
|
||||||
|
docsUrl: 'https://git.cqre.net/cqrenet/aoc/src/branch/main/README.md',
|
||||||
aiFeaturesEnabled: true,
|
aiFeaturesEnabled: true,
|
||||||
askQuestionText: '',
|
askQuestionText: '',
|
||||||
askLoading: false,
|
askLoading: false,
|
||||||
|
|||||||
@@ -28,7 +28,115 @@ body {
|
|||||||
.page {
|
.page {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 20px 60px;
|
padding: 0 20px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__logo {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__links a {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__links a:hover {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
color: #0b1220;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--compact {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
@@ -37,6 +145,7 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
@@ -508,7 +617,70 @@ input {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__brand {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__version {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(125, 211, 252, 0.1);
|
||||||
|
border: 1px solid rgba(125, 211, 252, 0.2);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__center {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__center a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__center a:hover {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer__right {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__links {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -522,4 +694,10 @@ input {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ async def set_cached_explain(redis, event_id: str, result: dict):
|
|||||||
# arq job functions
|
# arq job functions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def process_ask_question(ctx, question: str, filters: dict, events: list, total: int, excluded_services: list | None):
|
|
||||||
|
async def process_ask_question(
|
||||||
|
ctx, question: str, filters: dict, events: list, total: int, excluded_services: list | None
|
||||||
|
):
|
||||||
"""Background job: call LLM for /api/ask and cache result."""
|
"""Background job: call LLM for /api/ask and cache result."""
|
||||||
from routes.ask import _call_llm
|
from routes.ask import _call_llm
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ async def process_explain_event(ctx, event_id: str, event: dict, related: list):
|
|||||||
# arq worker configuration
|
# arq worker configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def startup(ctx):
|
async def startup(ctx):
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
|||||||
@@ -813,9 +813,17 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
|
|||||||
try:
|
try:
|
||||||
answer = await _call_llm(question, events, total=total, excluded_services=excluded_services)
|
answer = await _call_llm(question, events, total=total, excluded_services=excluded_services)
|
||||||
llm_used = True
|
llm_used = True
|
||||||
await set_cached_ask(redis, question, filters_snapshot, events, {
|
await set_cached_ask(
|
||||||
"answer": answer, "llm_used": True, "llm_error": None,
|
redis,
|
||||||
})
|
question,
|
||||||
|
filters_snapshot,
|
||||||
|
events,
|
||||||
|
{
|
||||||
|
"answer": answer,
|
||||||
|
"llm_used": True,
|
||||||
|
"llm_error": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
llm_error = f"LLM call failed: {exc}"
|
llm_error = f"LLM call failed: {exc}"
|
||||||
logger.warning("LLM call failed, falling back to structured summary", error=str(exc))
|
logger.warning("LLM call failed, falling back to structured summary", error=str(exc))
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
|||||||
class FakeRedis:
|
class FakeRedis:
|
||||||
async def get(self, key):
|
async def get(self, key):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def setex(self, key, ttl, value):
|
async def setex(self, key, ttl, value):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ def test_explain_event_with_llm_mock(client, mock_events_collection, monkeypatch
|
|||||||
class FakeRedis:
|
class FakeRedis:
|
||||||
async def get(self, key):
|
async def get(self, key):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def setex(self, key, ttl, value):
|
async def setex(self, key, ttl, value):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import asyncio
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from jobs import set_cached_ask
|
||||||
from routes.ask import _build_event_query, _extract_entity, _extract_time_range
|
from routes.ask import _build_event_query, _extract_entity, _extract_time_range
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -393,8 +395,6 @@ class TestAskCaching:
|
|||||||
|
|
||||||
redis = CachingFakeRedis()
|
redis = CachingFakeRedis()
|
||||||
# Seed cache with the exact filters the endpoint will generate
|
# Seed cache with the exact filters the endpoint will generate
|
||||||
import asyncio
|
|
||||||
from jobs import set_cached_ask
|
|
||||||
filters_snapshot = {
|
filters_snapshot = {
|
||||||
"services": None,
|
"services": None,
|
||||||
"actor": None,
|
"actor": None,
|
||||||
@@ -405,7 +405,15 @@ class TestAskCaching:
|
|||||||
"include_tags": None,
|
"include_tags": None,
|
||||||
"exclude_tags": None,
|
"exclude_tags": None,
|
||||||
}
|
}
|
||||||
asyncio.run(set_cached_ask(redis, "What happened to USER-001?", filters_snapshot, [{"id": "evt-cache"}], {"answer": "Cached answer!", "llm_used": True, "llm_error": None}))
|
asyncio.run(
|
||||||
|
set_cached_ask(
|
||||||
|
redis,
|
||||||
|
"What happened to USER-001?",
|
||||||
|
filters_snapshot,
|
||||||
|
[{"id": "evt-cache"}],
|
||||||
|
{"answer": "Cached answer!", "llm_used": True, "llm_error": None},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def fake_get_arq_pool():
|
async def fake_get_arq_pool():
|
||||||
return redis
|
return redis
|
||||||
@@ -451,6 +459,7 @@ class TestAskCaching:
|
|||||||
|
|
||||||
async def enqueue_job(self, func, *args, **kwargs):
|
async def enqueue_job(self, func, *args, **kwargs):
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
job = MagicMock()
|
job = MagicMock()
|
||||||
job.job_id = "job-12345"
|
job.job_id = "job-12345"
|
||||||
self.enqueued.append((func, args, kwargs))
|
self.enqueued.append((func, args, kwargs))
|
||||||
|
|||||||
Reference in New Issue
Block a user