add ui
This commit is contained in:
154
src/fgai/dashboard.py
Normal file
154
src/fgai/dashboard.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>fgAI Monitor</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font-family: Arial, sans-serif; background: #f5f7f9; color: #16202a; }
|
||||
body { margin: 0; }
|
||||
header { background: #102032; color: white; padding: 18px 24px; }
|
||||
h1 { margin: 0; font-size: 22px; }
|
||||
main { padding: 18px; max-width: 1320px; margin: 0 auto; }
|
||||
.hero { display: grid; grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.1fr); gap: 14px; align-items: stretch; }
|
||||
.hero img { width: 100%; height: 100%; max-height: 360px; object-fit: cover; border-radius: 6px; border: 1px solid #1f3b57; background: #061322; }
|
||||
.hero .panel { margin-bottom: 0; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||
.panel { background: white; border: 1px solid #d9e0e7; border-radius: 6px; padding: 14px; margin-bottom: 14px; }
|
||||
.metric { font-size: 28px; font-weight: 700; }
|
||||
.label { color: #536170; font-size: 13px; margin-top: 4px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { border-bottom: 1px solid #e4e9ee; padding: 8px; text-align: left; vertical-align: top; }
|
||||
th { color: #536170; font-weight: 600; }
|
||||
.sev-critical { color: #b00020; font-weight: 700; }
|
||||
.sev-high { color: #b54708; font-weight: 700; }
|
||||
.sev-medium { color: #8a6d00; font-weight: 700; }
|
||||
.sev-low { color: #345995; font-weight: 700; }
|
||||
.muted { color: #697789; }
|
||||
code { background: #eef2f6; padding: 2px 4px; border-radius: 4px; }
|
||||
@media (max-width: 860px) { .hero { grid-template-columns: 1fr; } .hero img { max-height: 240px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>fgAI Monitor</h1><div id="stamp" class="muted"></div></header>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<img src="/images/FGinspectionagent.png" alt="FortiGate AI/ML Analyzer">
|
||||
<div>
|
||||
<section class="grid" id="metrics"></section>
|
||||
<section class="panel"><h2>Live Status</h2><div id="liveStatus" class="muted">Waiting for monitor data.</div></section>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel"><h2>Anomalies</h2><div id="anomalies"></div></section>
|
||||
<section class="panel"><h2>Block Candidates</h2><div id="blocks"></div></section>
|
||||
<section class="panel"><h2>Policy Findings</h2><div id="policies"></div></section>
|
||||
<section class="panel"><h2>Diagnostics</h2><div id="diagnostics"></div></section>
|
||||
</main>
|
||||
<script>
|
||||
function esc(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function metric(label, value) {
|
||||
return `<div class="panel"><div class="metric">${esc(value)}</div><div class="label">${esc(label)}</div></div>`;
|
||||
}
|
||||
function table(rows, columns) {
|
||||
if (!rows || rows.length === 0) return '<p class="muted">No data.</p>';
|
||||
const head = columns.map(c => `<th>${esc(c.label)}</th>`).join('');
|
||||
const body = rows.map(row => `<tr>${columns.map(c => `<td>${c.render ? c.render(row) : esc(row[c.key])}</td>`).join('')}</tr>`).join('');
|
||||
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
||||
}
|
||||
async function refresh() {
|
||||
const res = await fetch('/api/status', {cache: 'no-store'});
|
||||
const data = await res.json();
|
||||
const s = data.summary || {};
|
||||
const a = data.anomaly_summary || {};
|
||||
document.getElementById('stamp').textContent = data.generated_at ? `Updated ${new Date(data.generated_at * 1000).toLocaleString()}` : 'Waiting for monitor data';
|
||||
document.getElementById('metrics').innerHTML = [
|
||||
metric('Total events', s.total || 0),
|
||||
metric('UTM events', s.utm || 0),
|
||||
metric('Threat actions', s.threat_actions || 0),
|
||||
metric('Anomalies high+', (a.high || 0) + (a.critical || 0))
|
||||
].join('');
|
||||
document.getElementById('liveStatus').innerHTML = [
|
||||
`Log file: <code>${esc(data.log_path || '')}</code>`,
|
||||
`Policy file: <code>${esc(data.policy_path || 'none')}</code>`,
|
||||
`Critical anomalies: ${esc((a.critical || 0))}`,
|
||||
`High anomalies: ${esc((a.high || 0))}`
|
||||
].join('<br>');
|
||||
document.getElementById('anomalies').innerHTML = table(data.anomalies || [], [
|
||||
{label:'Source', key:'subject'},
|
||||
{label:'Score', key:'score'},
|
||||
{label:'Severity', render:r => `<span class="sev-${esc(r.severity)}">${esc(r.severity)}</span>`},
|
||||
{label:'Confidence', key:'confidence'},
|
||||
{label:'Reasons', render:r => esc((r.reasons || []).join('; '))}
|
||||
]);
|
||||
document.getElementById('blocks').innerHTML = table(data.block_candidates || [], [
|
||||
{label:'Source', key:'src_ip'},
|
||||
{label:'Score', key:'score'},
|
||||
{label:'Reasons', render:r => esc((r.reasons || []).join('; '))}
|
||||
]);
|
||||
document.getElementById('policies').innerHTML = table(data.policy_findings || [], [
|
||||
{label:'Severity', render:r => `<span class="sev-${esc(r.severity)}">${esc(r.severity)}</span>`},
|
||||
{label:'Reference', key:'reference'},
|
||||
{label:'Title', key:'title'},
|
||||
{label:'Detail', key:'detail'}
|
||||
]);
|
||||
const d = data.diagnostics || {};
|
||||
document.getElementById('diagnostics').innerHTML =
|
||||
'<h3>Top Sources</h3>' + table(d.top_source_ips || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
|
||||
'<h3>Top Services</h3>' + table(d.top_services || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
|
||||
'<h3>Local-in Failures</h3>' + table(d.local_in_failures || [], [{label:'Source', key:'src_ip'}, {label:'Service', key:'service'}, {label:'Policy', key:'policy'}, {label:'Count', key:'count'}]);
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | None = None) -> None:
|
||||
status_path = Path(status_file)
|
||||
image_root = Path(image_dir) if image_dir else None
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/":
|
||||
self._send(200, "text/html; charset=utf-8", HTML.encode("utf-8"))
|
||||
return
|
||||
if self.path == "/api/status":
|
||||
if status_path.exists():
|
||||
body = status_path.read_bytes()
|
||||
else:
|
||||
body = json.dumps({"summary": {}, "anomalies": [], "block_candidates": []}).encode("utf-8")
|
||||
self._send(200, "application/json", body)
|
||||
return
|
||||
if self.path.startswith("/images/") and image_root:
|
||||
image_path = image_root / Path(self.path).name
|
||||
if image_path.exists() and image_path.is_file():
|
||||
content_type = "image/png" if image_path.suffix.lower() == ".png" else "application/octet-stream"
|
||||
self._send(200, content_type, image_path.read_bytes())
|
||||
return
|
||||
self._send(404, "text/plain; charset=utf-8", b"not found")
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def _send(self, status: int, content_type: str, body: bytes) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
server = ThreadingHTTPServer((host, port), Handler)
|
||||
print(f"Dashboard listening on http://{host}:{port}")
|
||||
print(f"Reading status from {status_path}")
|
||||
server.serve_forever()
|
||||
Reference in New Issue
Block a user