/* Cordon Cloud dashboard client. * * Polls /v1/metrics and /v1/events every 2 s, diffs against the last * snapshot, prepends new rows with an enter animation, and renders the * sparkline. Vanilla JS — no framework — to keep the dashboard fast * and self-contained. */ (() => { "use strict"; const $ = (id) => document.getElementById(id); const REFRESH_MS = 2000; const SEVERITY_COLOR = { critical: "text-red-400 border-red-500/30 bg-red-500/10", dangerous: "text-orange-300 border-orange-500/30 bg-orange-500/10", high: "text-yellow-300 border-yellow-500/30 bg-yellow-500/10", medium: "text-sky-300 border-sky-500/30 bg-sky-500/10", low: "text-neutral-300 border-white/10 bg-white/5", }; // The access token is read once from the URL the operator opened // the dashboard with (``/?t=``) and forwarded on every API // call. We never persist it anywhere — that way revoking access is // just "rotate the env var on the Space"; no client-side state to // chase. const ACCESS_TOKEN = new URLSearchParams(location.search).get("t") || ""; const state = { project: "demo", window: 86400, decision: "", seenIds: new Set(), lastTopId: 0, polling: true, }; /** Build ``/v1/`` with the dashboard access token appended. */ function apiUrl(path, params = {}) { const u = new URL(path, location.origin); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null && v !== "") { u.searchParams.set(k, v); } } if (ACCESS_TOKEN) u.searchParams.set("t", ACCESS_TOKEN); return u.pathname + u.search; } // ─── Filters ────────────────────────────────────────────────── for (const btn of document.querySelectorAll(".flt")) { btn.classList.add("border", "border-white/10", "text-neutral-300", "hover:text-white", "hover:border-white/30", "transition-colors"); btn.addEventListener("click", () => { for (const b of document.querySelectorAll(".flt")) { b.classList.remove("active", "bg-white", "text-black", "border-white"); b.classList.add("text-neutral-300", "border-white/10"); } btn.classList.add("active", "bg-white", "text-black", "border-white"); btn.classList.remove("text-neutral-300", "border-white/10"); state.decision = btn.dataset.value || ""; state.seenIds = new Set(); // re-render from scratch $("events-body").innerHTML = ""; poll(); }); } // initial styling for "all" document.querySelector('.flt[data-value=""]').classList.add( "bg-white", "text-black", "border-white", ); $("window-select").addEventListener("change", (e) => { state.window = Number(e.target.value); poll(); }); // ─── Endpoint snippet auto-detection ────────────────────────── // Show the same origin the dashboard is being served from, so users // who self-host see the right URL in the snippet. $("endpoint-snippet").textContent = `"${location.origin}"`; // ─── Polling ────────────────────────────────────────────────── async function poll() { if (!state.polling) return; try { const [metricsRes, eventsRes] = await Promise.all([ fetch(apiUrl("/v1/metrics", { project: state.project, window_s: state.window, })), fetch(apiUrl("/v1/events", { project: state.project, limit: 100, decision: state.decision || undefined, })), ]); if (metricsRes.status === 401 || eventsRes.status === 401) { $("live-status").textContent = "access denied"; state.polling = false; // The page itself will already have shown the access-required // gate from the server if the user landed here without a token, // but this guards against tokens being rotated mid-session. return; } const metrics = await metricsRes.json(); const events = await eventsRes.json(); renderMetrics(metrics); renderEvents(events.events || []); renderSparkline(metrics.sparkline || []); $("live-status").textContent = `live · ${new Date().toLocaleTimeString()}`; } catch (err) { $("live-status").textContent = "offline (retrying)"; } } function renderMetrics(m) { $("m-total").textContent = m.n_total.toLocaleString(); $("m-total-sub").textContent = `${humanWindow(m.window_s)} · ${m.n_allowed} allowed`; const rate = (m.block_rate * 100).toFixed(1); const el = $("m-block"); el.textContent = `${rate}%`; el.className = "mt-2 mono text-3xl " + (m.block_rate >= 0.3 ? "text-red-400" : m.block_rate >= 0.1 ? "text-orange-300" : "text-white"); $("m-block-sub").textContent = `${m.n_blocked} blocked · ${m.n_flagged} flagged`; $("m-score").textContent = (m.mean_score ?? 0).toFixed(3); const top = (m.top_probes || [])[0]; if (top) { $("m-top-probe").textContent = top.probe; $("m-top-probe-sub").textContent = `${top.count} hits`; } else { $("m-top-probe").textContent = "—"; $("m-top-probe-sub").textContent = "no probes fired"; } } function renderEvents(events) { const tbody = $("events-body"); const empty = $("events-empty"); if (!events.length) { tbody.innerHTML = ""; empty.classList.remove("hidden"); return; } empty.classList.add("hidden"); // Build full HTML for known events, prepend rows we haven't seen. // The most recent events come first from /v1/events (ts DESC). const newRows = []; for (const ev of events) { if (state.seenIds.has(ev.id)) continue; state.seenIds.add(ev.id); newRows.push(rowHTML(ev, /* fresh = */ state.lastTopId > 0)); } if (newRows.length) { // Prepend new rows in time order (oldest first → newest at top). tbody.insertAdjacentHTML("afterbegin", newRows.join("")); // Trim to 100 rows so the DOM stays light. while (tbody.children.length > 100) { tbody.removeChild(tbody.lastChild); } // Wire row click handlers for the freshly-inserted rows. for (const row of tbody.querySelectorAll("tr[data-event-id]:not([data-bound])")) { row.setAttribute("data-bound", "1"); row.addEventListener("click", () => openDetail(Number(row.dataset.eventId))); } } state.lastTopId = events[0].id; } function rowHTML(ev, fresh) { const sev = (ev.top_severity || "low").toLowerCase(); const sevCls = SEVERITY_COLOR[sev] || SEVERITY_COLOR.low; const decisionCls = ev.decision === "block" ? "text-red-400" : ev.decision === "flag" ? "text-yellow-300" : "text-green-400"; const cmd = ev.command_preview || (ev.kind === "write_file" ? "(file diff)" : ""); const evidence = ev.top_evidence || cmd || "—"; const score = ev.suspicion_score != null ? ev.suspicion_score.toFixed(3) : "—"; return ` ${relTime(ev.ts)} ${escapeHTML(ev.kind || "—")} ${escapeHTML(ev.decision)} ${ev.top_probe ? `${escapeHTML(ev.top_probe)}` : ``} ${escapeHTML(evidence)} ${score} `; } // ─── Sparkline ──────────────────────────────────────────────── function renderSparkline(buckets) { // buckets[0] = most recent; we want to draw left → right oldest → newest. const data = buckets.slice().reverse(); const W = 600, H = 80, pad = 4; const n = data.length; if (!n) return; const max = Math.max(1, ...data.map(b => b.n || 0)); const xs = i => pad + (i * (W - 2 * pad)) / Math.max(1, n - 1); const ys = v => H - pad - (v / max) * (H - 2 * pad); const linePts = data.map((b, i) => `${xs(i).toFixed(1)},${ys(b.n || 0).toFixed(1)}`).join(" "); const blockedPts = data.map((b, i) => `${xs(i).toFixed(1)},${ys(b.n_blocked || 0).toFixed(1)}` ).join(" "); const areaPts = `${pad},${H - pad} ${linePts} ${(W - pad).toFixed(1)},${H - pad}`; $("sparkline").innerHTML = ` `; } // ─── Detail drawer ──────────────────────────────────────────── async function openDetail(eventId) { const drawer = $("detail-drawer"); const backdrop = $("detail-backdrop"); const body = $("detail-body"); body.innerHTML = `
loading…
`; drawer.classList.remove("hidden"); backdrop.classList.remove("hidden"); try { const r = await fetch(apiUrl(`/v1/events/${eventId}`, { project: state.project, })); if (!r.ok) throw new Error(`HTTP ${r.status}`); const ev = await r.json(); body.innerHTML = detailHTML(ev); } catch (err) { body.innerHTML = `
${escapeHTML(err.message)}
`; } } function detailHTML(ev) { const probes = (ev.raw && ev.raw.probes_triggered) || []; return `
action
${escapeHTML(ev.action_id || "—")}
${escapeHTML(ev.kind || "—")} · ${new Date(ev.ts * 1000).toISOString()}
decision
${escapeHTML(ev.decision)} score ${ev.suspicion_score != null ? ev.suspicion_score.toFixed(3) : "—"}
${ev.command_preview ? `
command preview
${escapeHTML(ev.command_preview)}
` : ""}
probes triggered
${probes.length ? `
${probes.map(p => `
${escapeHTML(p.probe)} ${escapeHTML(p.severity || "—")} conf ${(p.confidence ?? 0).toFixed(3)}
${escapeHTML(p.evidence || "—")}
`).join("")}
` : `
none
`}
raw payload
${escapeHTML(JSON.stringify(ev.raw, null, 2))}
`; } $("detail-close").addEventListener("click", closeDetail); $("detail-backdrop").addEventListener("click", closeDetail); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); }); function closeDetail() { $("detail-drawer").classList.add("hidden"); $("detail-backdrop").classList.add("hidden"); } // ─── Utilities ──────────────────────────────────────────────── function escapeHTML(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function relTime(ts) { const d = Date.now() / 1000 - ts; if (d < 60) return `${Math.max(0, Math.floor(d))}s ago`; if (d < 3600) return `${Math.floor(d / 60)}m ago`; if (d < 86400) return `${Math.floor(d / 3600)}h ago`; return `${Math.floor(d / 86400)}d ago`; } function humanWindow(s) { if (s >= 86400) return `last ${Math.round(s / 86400)} d`; if (s >= 3600) return `last ${Math.round(s / 3600)} h`; if (s >= 60) return `last ${Math.round(s / 60)} min`; return `last ${Math.round(s)} s`; } // ─── Kick off ───────────────────────────────────────────────── poll(); setInterval(poll, REFRESH_MS); })();