286 lines
14 KiB
HTML
286 lines
14 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(4,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)}
|
|
.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="events">Events</button>
|
|
</div>
|
|
<section id="incidentTab"></section>
|
|
<section id="alertsTab" 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:[], 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, events] = await Promise.all([
|
|
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/events")
|
|
]);
|
|
state.incidents = incidents; state.alerts = alerts; 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",""]].forEach(([k,c])=>{
|
|
const m=el("div","metric "+c); m.append(el("b",null,String(k==="TOTAL" ? st.total_alerts||0 : counts[k]||0)));
|
|
m.append(el("span",null,k==="TOTAL" ? "alerts" : k)); 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("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("eventsTab").hidden = state.tab !== "events";
|
|
if(state.tab === "incident") renderIncident();
|
|
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 showError(msg){
|
|
document.getElementById("incidentTab").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>
|