enodia-sentinal/enodia_sentinel/static/dashboard.html

323 lines
16 KiB
HTML

<!DOCTYPE html>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Enodia Sentinel</title>
<style>
:root{
--bg:#0a0c0f; --ink:#d8dee7; --muted:#8590a3; --soft:#aeb7c7;
--panel:#11151b; --panel2:#151b23; --rail:#0d1117; --line:#26313f;
--accent:#22c7a9; --amber:#f2b84b; --red:#f06476; --blue:#61a8ff;
--ok:#56d364; --shadow:0 18px 48px rgba(0,0,0,.28);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.45 "Aptos Mono","IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace}
body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.18;
background-image:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px);
background-size:28px 28px}
button{font:inherit;color:inherit;background:var(--panel2);border:1px solid var(--line);
border-radius:6px;padding:8px 10px;cursor:pointer}
button:hover{border-color:var(--accent);color:#fff}
header{height:58px;display:flex;align-items:center;gap:16px;padding:0 18px;
border-bottom:1px solid var(--line);background:rgba(13,17,23,.96);position:relative;z-index:2}
h1{font-size:15px;margin:0;color:#fff;letter-spacing:.16em;text-transform:uppercase}
.pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);
border-radius:999px;padding:4px 9px;color:var(--soft);font-size:12px}
.dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 12px var(--red)}
.dot.up{background:var(--ok);box-shadow:0 0 12px var(--ok)}
.spacer{flex:1}
.tls{color:var(--accent)}
.layout{display:grid;grid-template-columns:330px minmax(420px,1fr) 390px;
height:calc(100vh - 58px);position:relative;z-index:1}
aside,.work,.plan{min-height:0;overflow:auto}
aside{background:rgba(13,17,23,.9);border-right:1px solid var(--line)}
.work{background:linear-gradient(180deg,#0f1319,#0b0d11);padding:18px}
.plan{background:rgba(17,21,27,.92);border-left:1px solid var(--line);padding:16px}
.section-title{display:flex;align-items:center;justify-content:space-between;
padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em;
text-transform:uppercase}
.metrics{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:16px}
.metric{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}
.metric b{display:block;font-size:24px;line-height:1;color:#fff}
.metric span{display:block;color:var(--muted);font-size:11px;margin-top:8px;text-transform:uppercase}
.metric.crit b{color:var(--red)} .metric.high b{color:var(--amber)} .metric.med b{color:var(--blue)}
.metric.posture b{color:var(--accent)}
.list{padding:0 8px 14px}
.item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px;
cursor:pointer;border-radius:7px;margin-bottom:4px}
.item:hover{background:var(--panel)}
.item.sel{background:var(--panel2);border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
.item .top{display:flex;align-items:center;gap:8px;justify-content:space-between}
.id{font-size:12px;color:#fff;word-break:break-word}
.time,.fine{color:var(--muted);font-size:11px}
.tags{margin-top:7px;display:flex;gap:6px;flex-wrap:wrap}
.tag{font-size:11px;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--soft)}
.sev{font-weight:700;font-size:11px;border-radius:4px;padding:2px 7px;border:1px solid var(--line)}
.sev.CRITICAL{color:var(--red);border-color:var(--red)}
.sev.HIGH{color:var(--amber);border-color:var(--amber)}
.sev.MEDIUM{color:var(--blue);border-color:var(--blue)}
.tabs{display:flex;gap:6px;margin-bottom:14px}
.tabs button{padding:7px 11px}
.tabs button.active{background:var(--accent);border-color:var(--accent);color:#00110e}
.card{background:rgba(17,21,27,.9);border:1px solid var(--line);border-radius:8px;
padding:14px;margin-bottom:14px;box-shadow:var(--shadow)}
h2{font-size:18px;margin:0 0 8px;color:#fff;letter-spacing:0}
h3{font-size:12px;margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}
.kv{display:grid;grid-template-columns:120px 1fr;gap:8px;margin-top:10px}
.kv div:nth-child(odd){color:var(--muted)}
.timeline{display:grid;gap:8px}
.event{border-left:2px solid var(--accent);padding:8px 10px;background:rgba(255,255,255,.03)}
.event b{display:block;color:#fff}
.event code,.cmd{display:block;margin-top:6px;padding:8px;background:#080a0d;
border:1px solid var(--line);border-radius:6px;color:#cbd5e1;white-space:pre-wrap;word-break:break-word}
.action{border:1px solid var(--line);border-radius:8px;background:#0d1117;padding:11px;margin-bottom:10px}
.action .head{display:flex;gap:8px;align-items:center;justify-content:space-between}
.risk{font-size:10px;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--muted)}
.risk.high{color:var(--red);border-color:var(--red)}
.risk.medium{color:var(--amber);border-color:var(--amber)}
.risk.low{color:var(--accent);border-color:var(--accent)}
pre{margin:0;white-space:pre-wrap;word-break:break-word;color:#c8d1df;font-size:12px}
.empty,.err{padding:30px;color:var(--muted);text-align:center}
.err{color:var(--red)}
@media (max-width:1100px){
.layout{grid-template-columns:300px 1fr}
.plan{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line);height:44vh}
}
@media (max-width:760px){
header{height:auto;min-height:58px;flex-wrap:wrap;padding:12px}
.layout{display:block;height:auto}
aside,.work,.plan{height:auto;max-height:none}
.metrics{grid-template-columns:repeat(2,1fr)}
}
</style>
</head>
<body>
<header>
<h1>Enodia Sentinel</h1>
<span class="pill"><span class="dot" id="dot"></span><span id="daemon">connecting</span></span>
<span class="pill tls">HTTPS self-signed</span>
<span class="pill" id="host">host unknown</span>
<span class="spacer"></span>
<span class="pill" id="ebpf">eBPF unknown</span>
<button id="refreshBtn" title="Refresh console">Refresh</button>
</header>
<div class="layout">
<aside>
<div class="section-title"><span>Incidents</span><span id="incidentCount">0</span></div>
<div class="list" id="incidents"><div class="empty">loading incidents</div></div>
<div class="section-title"><span>Recent Alerts</span><span id="alertCount">0</span></div>
<div class="list" id="alerts"></div>
</aside>
<main class="work">
<div class="metrics" id="metrics"></div>
<div class="tabs">
<button class="active" data-tab="incident">Incident</button>
<button data-tab="alerts">Alerts</button>
<button data-tab="posture">Posture</button>
<button data-tab="events">Events</button>
</div>
<section id="incidentTab"></section>
<section id="alertsTab" hidden></section>
<section id="postureTab" hidden></section>
<section id="eventsTab" hidden></section>
</main>
<section class="plan">
<div class="section-title"><span>Dry-run Response Plan</span><span id="planMode">read-only</span></div>
<div id="plan"><div class="empty">Select an incident to generate a plan.</div></div>
</section>
</div>
<script>
const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
const state = {incidents:[], alerts:[], posture:null, selected:null, incident:null, plan:null, tab:"incident"};
async function api(path){
const r = await fetch(path,{headers:HDR});
if(r.status===401) throw new Error("unauthorized - open with ?token=YOUR_TOKEN");
if(!r.ok) throw new Error("HTTP "+r.status);
return r.json();
}
function el(tag, cls, txt){const e=document.createElement(tag); if(cls)e.className=cls;
if(txt!==undefined)e.textContent=txt; return e}
function sev(s){return el("span","sev "+(s||""),s||"?")}
function tags(xs){const box=el("div","tags"); (xs||[]).forEach(x=>box.append(el("span","tag",x))); return box}
function fmt(t){return (t||"?").replace("T"," ").slice(0,19)}
async function refresh(){
try{
const [status, incidents, alerts, posture, events] = await Promise.all([
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/events")
]);
state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.events = events.events || [];
renderStatus(status); renderLists();
if(!state.selected && incidents.length) await openIncident(incidents[0].id);
else if(state.selected) await openIncident(state.selected, false);
renderTab(); renderEvents();
}catch(e){ showError(e.message); }
}
function renderStatus(st){
document.getElementById("dot").className = "dot "+(st.running && !st.heartbeat_stale ? "up" : "");
let hb = st.heartbeat_age == null ? "no heartbeat" : Math.round(st.heartbeat_age)+"s heartbeat";
document.getElementById("daemon").textContent = (st.running ? "running" : "not running")+" / "+hb;
document.getElementById("host").textContent = st.host+" / v"+st.version;
document.getElementById("ebpf").textContent = "eBPF "+(st.ebpf || "unknown");
const counts = st.counts || {};
const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""],["POSTURE","posture"]].forEach(([k,c])=>{
const value = k==="TOTAL" ? st.total_alerts||0 : k==="POSTURE" ? state.posture?.count||0 : counts[k]||0;
const label = k==="TOTAL" ? "alerts" : k==="POSTURE" ? "findings" : k;
const m=el("div","metric "+c); m.append(el("b",null,String(value)));
m.append(el("span",null,label)); metrics.append(m);
});
}
function renderLists(){
document.getElementById("incidentCount").textContent = state.incidents.length;
document.getElementById("alertCount").textContent = state.alerts.length;
const incBox = document.getElementById("incidents"); incBox.innerHTML = "";
if(!state.incidents.length) incBox.append(el("div","empty","No incidents recorded."));
state.incidents.forEach(i=>{
const row=el("div","item"); row.classList.toggle("sel", i.id===state.selected);
row.onclick=()=>openIncident(i.id);
const top=el("div","top"); top.append(el("span","id",i.id)); top.append(sev(i.severity));
row.append(top); row.append(el("div","time",fmt(i.last_seen)));
row.append(tags(i.signatures)); incBox.append(row);
});
const alertBox = document.getElementById("alerts"); alertBox.innerHTML = "";
state.alerts.slice(0,12).forEach(a=>{
const row=el("div","item"); row.onclick=()=>openAlert(a.name);
const top=el("div","top"); top.append(el("span","id",a.name)); top.append(sev(a.severity));
row.append(top); row.append(el("div","time",fmt(a.time))); row.append(tags(a.signatures));
alertBox.append(row);
});
}
async function openIncident(id, mark=true){
state.selected = id;
if(mark) renderLists();
try{
const [inc, plan] = await Promise.all([
api("/api/incidents/"+encodeURIComponent(id)),
api("/api/respond/plan/"+encodeURIComponent(id))
]);
state.incident = inc; state.plan = plan;
renderIncident(); renderPlan();
}catch(e){ document.getElementById("incidentTab").innerHTML='<div class="err">'+e.message+'</div>'; }
}
async function openAlert(name){
state.tab = "alerts"; setTabButtons();
const box = document.getElementById("alertsTab");
box.hidden = false; document.getElementById("incidentTab").hidden = true;
document.getElementById("postureTab").hidden = true; document.getElementById("eventsTab").hidden = true;
box.innerHTML='<div class="empty">loading snapshot</div>';
try{
const a = await api("/api/alerts/"+encodeURIComponent(name));
box.innerHTML = '<div class="card"><h3>'+name+'</h3><pre></pre></div>';
box.querySelector("pre").textContent = a.text || JSON.stringify(a.json,null,2);
}catch(e){ box.innerHTML='<div class="err">'+e.message+'</div>'; }
}
function renderIncident(){
const box = document.getElementById("incidentTab"); const data = state.incident;
if(!data){ box.innerHTML='<div class="empty">No incident selected.</div>'; return; }
const i = data.incident;
box.innerHTML = "";
const card = el("div","card");
card.append(el("h2",null,i.id)); card.append(tags(i.signatures));
const kv = el("div","kv");
[["Severity",i.severity],["First seen",fmt(i.first_seen)],["Last seen",fmt(i.last_seen)],
["Alert count",String(i.alert_count||0)],["PIDs",(i.pids||[]).join(", ")||"-"],["SIDs",(i.sids||[]).join(", ")||"-"]]
.forEach(([k,v])=>{kv.append(el("div",null,k)); kv.append(el("div",null,v));});
card.append(kv); box.append(card);
const tl = el("div","card"); tl.append(el("h3",null,"Timeline"));
const list = el("div","timeline");
(data.timeline||[]).forEach(r=>{
const ev = el("div","event"); ev.append(el("b",null,fmt(r.time)+" ["+(r.severity||"?")+"]"));
ev.append(el("div","fine",(r.signatures||[]).join(", ") || r.snapshot));
ev.append(el("code",null,r.snapshot)); list.append(ev);
});
tl.append(list); box.append(tl);
}
function renderPlan(){
const box = document.getElementById("plan"); const plan = state.plan;
if(!plan){ box.innerHTML='<div class="empty">No response plan available.</div>'; return; }
document.getElementById("planMode").textContent = plan.mode + " / no apply";
box.innerHTML = "";
(plan.actions||[]).forEach(a=>{
const row = el("div","action");
const head = el("div","head"); head.append(el("b",null,a.id+" "+a.title));
head.append(el("span","risk "+a.risk,a.risk)); row.append(head);
row.append(el("code","cmd",(a.command||[]).join(" ")));
row.append(el("div","fine",a.reason)); box.append(row);
});
}
function renderTab(){
setTabButtons();
document.getElementById("incidentTab").hidden = state.tab !== "incident";
document.getElementById("alertsTab").hidden = state.tab !== "alerts";
document.getElementById("postureTab").hidden = state.tab !== "posture";
document.getElementById("eventsTab").hidden = state.tab !== "events";
if(state.tab === "incident") renderIncident();
if(state.tab === "posture") renderPosture();
if(state.tab === "events") renderEvents();
}
function setTabButtons(){
document.querySelectorAll(".tabs button").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab));
}
function renderEvents(){
const box = document.getElementById("eventsTab");
box.innerHTML = '<div class="card"><h3>Event tail</h3><pre></pre></div>';
box.querySelector("pre").textContent = (state.events||[]).join("\n") || "No events.";
}
function renderPosture(){
const box = document.getElementById("postureTab");
const report = state.posture || {findings:[], counts:{}};
box.innerHTML = "";
const summary = el("div","card");
summary.append(el("h2",null,"Host posture"));
const counts = report.counts || {};
summary.append(tags([
"critical "+(counts.CRITICAL||0),
"high "+(counts.HIGH||0),
"medium "+(counts.MEDIUM||0)
]));
if(!report.findings || !report.findings.length){
summary.append(el("div","fine","No SSH, sudo, PATH, sensitive-file, or package-signature posture findings."));
box.append(summary);
return;
}
summary.append(el("div","fine",String(report.count)+" advisory finding(s). These are read-only hygiene checks, not live containment actions."));
box.append(summary);
report.findings.forEach(f=>{
const row = el("div","card");
const top = el("div","top"); top.append(sev(f.severity)); top.append(el("span","id",f.signature));
row.append(top);
row.append(tags(["sid "+(f.sid||0), f.classtype||"host-posture"]));
row.append(el("code","cmd",f.detail || ""));
box.append(row);
});
}
function showError(msg){
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("postureTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
}
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; renderTab();});
document.getElementById("refreshBtn").onclick=refresh;
refresh();
setInterval(refresh, 10000);
</script>
</body>
</html>