|
| 1 | +"""Self-contained control-plane web UI served at ``/``. |
| 2 | +
|
| 3 | +A single static page (no build step, no dependencies) that talks to the REST API |
| 4 | +in this same process. Read-only views of agents, sessions, and goals, plus the |
| 5 | +pending-approval queue with approve/deny actions -- the browser-based control plane. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +from fastapi import APIRouter |
| 11 | +from fastapi.responses import HTMLResponse |
| 12 | + |
| 13 | +router = APIRouter(tags=["ui"]) |
| 14 | + |
| 15 | + |
| 16 | +_HTML = """<!DOCTYPE html> |
| 17 | +<html lang="en"> |
| 18 | +<head> |
| 19 | +<meta charset="utf-8"> |
| 20 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 21 | +<title>Hive AgentOS</title> |
| 22 | +<style> |
| 23 | + :root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#e6edf3; --dim:#8b949e; |
| 24 | + --accent:#58a6ff; --green:#3fb950; --yellow:#d29922; --magenta:#bc8cff; --red:#f85149; } |
| 25 | + * { box-sizing: border-box; } |
| 26 | + body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 -apple-system, |
| 27 | + BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; } |
| 28 | + header { display:flex; align-items:center; gap:16px; padding:14px 20px; |
| 29 | + border-bottom:1px solid var(--border); background:var(--panel); position:sticky; top:0; } |
| 30 | + header h1 { font-size:16px; margin:0; font-weight:600; } |
| 31 | + header h1 span { color:var(--accent); } |
| 32 | + header .meta { margin-left:auto; color:var(--dim); font-size:12px; display:flex; gap:12px; align-items:center; } |
| 33 | + input { background:var(--bg); border:1px solid var(--border); color:var(--fg); |
| 34 | + border-radius:6px; padding:4px 8px; font:inherit; } |
| 35 | + main { padding:20px; max-width:1100px; margin:0 auto; } |
| 36 | + section { background:var(--panel); border:1px solid var(--border); border-radius:8px; |
| 37 | + margin-bottom:20px; overflow:hidden; } |
| 38 | + h2 { font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:var(--dim); |
| 39 | + margin:0; padding:12px 16px; border-bottom:1px solid var(--border); } |
| 40 | + table { width:100%; border-collapse:collapse; } |
| 41 | + td, th { text-align:left; padding:9px 16px; border-bottom:1px solid var(--border); font-size:13px; } |
| 42 | + th { color:var(--dim); font-weight:500; } |
| 43 | + tr:last-child td { border-bottom:none; } |
| 44 | + .empty { padding:16px; color:var(--dim); font-style:italic; } |
| 45 | + .badge { padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; } |
| 46 | + .idle { color:var(--dim); } .working { color:var(--yellow); } |
| 47 | + .waiting_approval { color:var(--magenta); } .error,.dead { color:var(--red); } |
| 48 | + code { background:var(--bg); padding:1px 5px; border-radius:4px; color:var(--dim); font-size:12px; } |
| 49 | + button { background:var(--accent); color:#fff; border:none; border-radius:6px; padding:4px 12px; |
| 50 | + font:inherit; cursor:pointer; } |
| 51 | + button.deny { background:var(--red); } |
| 52 | + button:hover { opacity:.9; } |
| 53 | +</style> |
| 54 | +</head> |
| 55 | +<body> |
| 56 | +<header> |
| 57 | + <h1>Hive <span>AgentOS</span></h1> |
| 58 | + <div class="meta"> |
| 59 | + <label>user <input id="user" value="default" size="8"></label> |
| 60 | + <span id="clock">--</span> |
| 61 | + </div> |
| 62 | +</header> |
| 63 | +<main> |
| 64 | + <section><h2>Pending Approvals</h2><div id="approvals"></div></section> |
| 65 | + <section><h2>Agents</h2><div id="agents"></div></section> |
| 66 | + <section><h2>Sessions</h2><div id="sessions"></div></section> |
| 67 | +</main> |
| 68 | +<script> |
| 69 | +const $ = id => document.getElementById(id); |
| 70 | +const userHdr = () => ({ "X-Hive-User": $("user").value || "default" }); |
| 71 | +// Escape for HTML text AND attribute contexts (includes quotes), so untrusted |
| 72 | +// fields (ids, status, args) can't break out of an attribute or inject markup. |
| 73 | +const ESC = {"&":"&","<":"<",">":">",'"':""","'":"'"}; |
| 74 | +const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ESC[c]); |
| 75 | +
|
| 76 | +async function api(path, opts = {}) { |
| 77 | + // Merge headers so a caller-supplied `headers` can't drop X-Hive-User. |
| 78 | + const { headers, ...rest } = opts; |
| 79 | + const r = await fetch(path, { headers: { ...userHdr(), ...headers }, ...rest }); |
| 80 | + if (!r.ok) throw new Error(r.status + " " + path); |
| 81 | + return r.status === 204 ? null : r.json(); |
| 82 | +} |
| 83 | +
|
| 84 | +function table(rows, cols) { |
| 85 | + if (!rows.length) return '<div class="empty">none</div>'; |
| 86 | + const head = "<tr>" + cols.map(c => "<th>" + c.h + "</th>").join("") + "</tr>"; |
| 87 | + const body = rows.map(r => "<tr>" + cols.map(c => "<td>" + c.f(r) + "</td>").join("") + "</tr>").join(""); |
| 88 | + return "<table>" + head + body + "</table>"; |
| 89 | +} |
| 90 | +
|
| 91 | +async function decide(agentId, approvalId, decision) { |
| 92 | + try { |
| 93 | + await api(`/agents/${encodeURIComponent(agentId)}/approvals/${encodeURIComponent(approvalId)}`, |
| 94 | + { method: "POST", headers: { "Content-Type": "application/json" }, |
| 95 | + body: JSON.stringify({ decision }) }); |
| 96 | + refresh(); |
| 97 | + } catch (e) { alert("Failed: " + e.message); } |
| 98 | +} |
| 99 | +
|
| 100 | +// Delegated click handler: ids come from dataset (never from interpolated markup). |
| 101 | +$("approvals").addEventListener("click", e => { |
| 102 | + const btn = e.target.closest("button.act"); |
| 103 | + if (btn) decide(btn.dataset.agent, btn.dataset.id, btn.dataset.decision); |
| 104 | +}); |
| 105 | +
|
| 106 | +async function refresh() { |
| 107 | + try { |
| 108 | + const [agents, approvals, sessions] = await Promise.all([ |
| 109 | + api("/agents"), api("/approvals"), api("/sessions"), |
| 110 | + ]); |
| 111 | + $("agents").innerHTML = table(agents, [ |
| 112 | + { h: "Name", f: a => esc(a.name) }, |
| 113 | + { h: "Role", f: a => esc(a.role) }, |
| 114 | + { h: "Model", f: a => "<code>" + esc(a.model) + "</code>" }, |
| 115 | + { h: "Status", f: a => `<span class="badge ${esc(a.status)}">${esc(a.status)}</span>` }, |
| 116 | + { h: "Goal", f: a => esc(a.goal) || "<span class='empty'>-</span>" }, |
| 117 | + ]); |
| 118 | + // Buttons carry ids in escaped data-* attributes; a single delegated listener |
| 119 | + // (below) handles clicks, so no untrusted value is ever interpolated into JS. |
| 120 | + $("approvals").innerHTML = table(approvals, [ |
| 121 | + { h: "Tool", f: a => "<code>" + esc(a.tool_name) + "</code>" }, |
| 122 | + { h: "Agent", f: a => esc(a.agent_id) }, |
| 123 | + { h: "Arguments", f: a => "<code>" + esc((a.arguments || "").slice(0, 80)) + "</code>" }, |
| 124 | + { h: "", f: a => { |
| 125 | + const attrs = `data-agent="${esc(a.agent_id)}" data-id="${esc(a.approval_id)}"`; |
| 126 | + return `<button class="act" data-decision="approve" ${attrs}>Approve</button> |
| 127 | + <button class="act deny" data-decision="deny" ${attrs}>Deny</button>`; |
| 128 | + } }, |
| 129 | + ]); |
| 130 | + $("sessions").innerHTML = table(sessions, [ |
| 131 | + { h: "Session", f: s => "<code>" + esc(s.session_id) + "</code>" }, |
| 132 | + { h: "Agent", f: s => esc(s.agent_id) }, |
| 133 | + { h: "Status", f: s => esc(s.status) }, |
| 134 | + { h: "Task", f: s => esc((s.task || "").slice(0, 60)) }, |
| 135 | + ]); |
| 136 | + $("clock").textContent = new Date().toLocaleTimeString(); |
| 137 | + } catch (e) { $("clock").textContent = "error: " + e.message; } |
| 138 | +} |
| 139 | +
|
| 140 | +$("user").addEventListener("change", refresh); |
| 141 | +refresh(); |
| 142 | +setInterval(refresh, 3000); |
| 143 | +</script> |
| 144 | +</body> |
| 145 | +</html>""" |
| 146 | + |
| 147 | + |
| 148 | +@router.get("/", response_class=HTMLResponse, include_in_schema=False) |
| 149 | +async def control_plane() -> str: |
| 150 | + """Serve the browser control plane.""" |
| 151 | + return _HTML |
0 commit comments