Files
AI_engine/dashboard/static/command_center.html

849 lines
37 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LogiFlow — Command Center</title>
<style>
/* ================= tokens ================= */
:root {
color-scheme: dark;
--page: #060b12;
--card-hi: rgba(18, 30, 40, 0.92);
--card-lo: rgba(9, 15, 22, 0.92);
--node-fill: #0a141c;
--ink: #f2f7f7;
--ink-2: #a9bcc0;
--muted: #5f757c;
--hairline: rgba(127, 216, 216, 0.10);
--hairline-2: rgba(255, 255, 255, 0.06);
--accent: #2dd4bf;
--accent-hi: #9be8e0;
--accent-dim: rgba(45, 212, 191, 0.14);
--accent-border: rgba(45, 212, 191, 0.28);
--good: #0ca30c;
--warn: #fab219;
--serious: #ec835a;
--crit: #d03b3b;
--crit-dim: rgba(208, 59, 59, 0.14);
--crit-border: rgba(208, 59, 59, 0.32);
--sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: ui-monospace, Consolas, "Cascadia Mono", monospace;
--r: 20px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: var(--page);
color: var(--ink);
font-family: var(--sans);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
body::before {
content: "";
position: fixed; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 45% 40% at 78% -5%, rgba(45,212,191,0.10), transparent 65%),
radial-gradient(ellipse 50% 45% at 12% 110%, rgba(57,135,229,0.08), transparent 65%),
radial-gradient(ellipse 35% 30% at 45% 55%, rgba(45,212,191,0.04), transparent 70%);
}
.app { display: grid; grid-template-columns: 232px 1fr; height: 100vh; }
/* ================= sidebar ================= */
aside {
display: flex; flex-direction: column;
padding: 22px 16px;
border-right: 1px solid var(--hairline-2);
background: linear-gradient(180deg, rgba(10,17,24,0.6), rgba(6,11,18,0.2));
}
.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 26px; }
.brand .mark {
width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
background: linear-gradient(135deg, var(--accent), #3987e5);
display: flex; align-items: center; justify-content: center;
font-size: 16px;
box-shadow: 0 4px 18px rgba(45, 212, 191, 0.35);
}
.brand h1 { font-size: 15px; font-weight: 700; letter-spacing: 0.01em; }
.brand p { font-size: 9.5px; letter-spacing: 0.22em; color: var(--muted); margin-top: 1px; }
nav { display: flex; flex-direction: column; gap: 4px; }
.navlabel { font-size: 9.5px; letter-spacing: 0.2em; color: var(--muted); padding: 8px 12px 6px; text-transform: uppercase; }
nav button {
display: flex; align-items: center; gap: 10px;
background: none; border: none; cursor: pointer;
color: var(--ink-2); font-family: var(--sans); font-size: 13px; font-weight: 500;
padding: 9px 12px; border-radius: 12px; text-align: left;
transition: background 0.15s, color 0.15s;
}
nav button:hover { background: rgba(255,255,255,0.04); color: var(--ink); }
nav button.active { background: var(--accent-dim); color: var(--accent-hi); }
nav button .ndot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.55; }
nav button .badge {
margin-left: auto; font-size: 10.5px; font-family: var(--mono);
color: var(--muted); background: rgba(255,255,255,0.05);
padding: 2px 8px; border-radius: 99px;
}
nav button.active .badge { color: var(--accent-hi); background: rgba(45,212,191,0.15); }
.side-bottom { margin-top: auto; display: flex; flex-direction: column; gap: 12px; padding: 0 4px; }
.buspill {
display: flex; align-items: center; gap: 9px;
background: linear-gradient(180deg, var(--card-hi), var(--card-lo));
border: 1px solid var(--hairline);
border-radius: 14px; padding: 11px 14px;
font-size: 12px; font-weight: 600; color: var(--ink-2);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
.dot.on { background: var(--good); box-shadow: 0 0 10px rgba(12,163,12,0.8); animation: breathe 2.4s ease-in-out infinite; }
.dot.off { background: var(--crit); box-shadow: 0 0 10px rgba(208,59,59,0.8); }
@keyframes breathe { 50% { opacity: 0.4; } }
.clockbox { padding: 2px 10px; }
#clock { font-family: var(--mono); font-size: 17px; color: var(--ink); letter-spacing: 0.06em; }
#date { font-size: 10px; color: var(--muted); letter-spacing: 0.16em; margin-top: 2px; }
/* ================= main ================= */
main {
display: grid;
grid-template-rows: 64px 104px 1fr 148px;
grid-template-columns: 1fr 388px;
gap: 14px;
padding: 18px 20px 20px;
min-height: 0; min-width: 0;
}
.top { grid-column: 1 / 3; display: flex; align-items: center; gap: 16px; padding: 0 2px; }
.top h2 { font-size: 22px; font-weight: 650; letter-spacing: -0.01em; }
.top .sub { font-size: 12px; color: var(--muted); margin-top: 3px; }
.livepill {
margin-left: auto;
display: flex; align-items: center; gap: 8px;
font-size: 12px; font-weight: 600; color: var(--ink-2);
background: rgba(255,255,255,0.04);
border: 1px solid var(--hairline-2);
padding: 7px 14px; border-radius: 99px;
}
/* ---- stat cards ---- */
.stats { grid-column: 1 / 3; display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; }
.card {
background: linear-gradient(180deg, var(--card-hi), var(--card-lo));
border: 1px solid var(--hairline);
border-radius: var(--r);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.stat { padding: 15px 18px 13px; display: flex; flex-direction: column; min-width: 0; }
.stat .k { font-size: 9.5px; letter-spacing: 0.18em; color: var(--muted); text-transform: uppercase; font-weight: 600; }
.stat .v { font-size: 28px; font-weight: 650; letter-spacing: -0.01em; margin-top: 3px; line-height: 1.1; }
.stat .v small { font-size: 13px; color: var(--muted); font-weight: 500; }
.stat .s { font-size: 11px; color: var(--muted); margin-top: 2px; }
.stat .s.up { color: var(--good); }
.stat.alert .v { color: var(--crit); }
.bar { height: 4px; border-radius: 99px; background: rgba(255,255,255,0.07); margin-top: 8px; overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-hi)); width: 0%; transition: width 0.4s; }
/* ---- stage ---- */
.stage { grid-column: 1; grid-row: 3; position: relative; overflow: hidden; min-height: 0; }
.stage svg { width: 100%; height: 100%; display: block; }
.cardhead {
position: absolute; top: 0; left: 0; right: 0; z-index: 2;
display: flex; align-items: center; gap: 10px;
padding: 15px 18px 10px;
pointer-events: none;
}
.cardhead h3 { font-size: 13px; font-weight: 650; }
.cardhead .hint { font-size: 11px; color: var(--muted); }
.offline-veil {
position: absolute; inset: 0; z-index: 5;
display: none; align-items: center; justify-content: center; flex-direction: column; gap: 10px;
background: rgba(6, 11, 18, 0.78); backdrop-filter: blur(4px);
border-radius: var(--r);
}
.offline-veil.show { display: flex; }
.offline-veil b { color: var(--crit); letter-spacing: 0.22em; font-size: 14px; }
.offline-veil span { color: var(--ink-2); font-size: 12px; }
/* ---- feed ---- */
.feed { grid-column: 2; grid-row: 3 / 5; display: flex; flex-direction: column; min-height: 0; }
.feed .fhead {
display: flex; align-items: center; gap: 10px;
padding: 15px 18px 11px;
border-bottom: 1px solid var(--hairline-2);
}
.feed .fhead h3 { font-size: 13px; font-weight: 650; }
.feed .fhead .count {
margin-left: auto; font-size: 10.5px; font-family: var(--mono);
color: var(--muted); background: rgba(255,255,255,0.05);
padding: 3px 9px; border-radius: 99px;
}
#feedlist { flex: 1; overflow-y: auto; padding: 8px 10px 12px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.12) transparent; }
.row {
display: flex; align-items: center; gap: 10px;
padding: 9px 10px; margin: 3px 0;
border-radius: 13px;
animation: slidein 0.25s ease;
transition: background 0.15s;
}
.row:hover { background: rgba(255,255,255,0.035); }
@keyframes slidein { from { opacity: 0; transform: translateY(-5px); } }
.row .cd { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.row .body { flex: 1; min-width: 0; }
.row .l1 { font-size: 12px; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .l1 .dir { color: var(--muted); font-weight: 400; }
.row .l2 { font-size: 10.5px; color: var(--muted); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.row .right { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; flex-shrink: 0; }
.pill {
font-size: 9.5px; font-weight: 700; letter-spacing: 0.06em;
padding: 3px 9px; border-radius: 99px;
background: var(--accent-dim); color: var(--accent-hi);
border: 1px solid var(--accent-border);
white-space: nowrap;
}
.pill.warn { background: rgba(250,178,25,0.12); color: #fdd06a; border-color: rgba(250,178,25,0.3); }
.pill.crit { background: var(--crit-dim); color: #ef9a9a; border-color: var(--crit-border); }
.row .t { font-size: 9.5px; color: var(--muted); font-family: var(--mono); }
.fempty { color: var(--muted); font-size: 12px; padding: 18px 16px; line-height: 1.6; }
/* ---- throughput ---- */
.spark { grid-column: 1; grid-row: 4; position: relative; padding: 14px 18px 10px; display: flex; flex-direction: column; min-height: 0; }
.spark .k { font-size: 9.5px; letter-spacing: 0.18em; color: var(--muted); text-transform: uppercase; font-weight: 600; }
.spark canvas { flex: 1; width: 100%; min-height: 0; margin-top: 6px; }
#tip {
position: absolute; display: none; z-index: 10;
background: var(--node-fill); border: 1px solid var(--hairline); border-radius: 9px;
padding: 5px 10px; font-family: var(--mono); font-size: 11px; color: var(--ink-2);
pointer-events: none; white-space: nowrap;
box-shadow: 0 8px 20px rgba(0,0,0,0.4);
}
/* ================= constellation svg ================= */
.link { stroke: rgba(127, 216, 216, 0.10); stroke-width: 1; }
.node { cursor: pointer; }
.node:hover .node-ring { stroke-width: 2.5; }
.node-ring { fill: var(--node-fill); stroke-width: 1.5; transition: stroke-width 0.15s; }
.node-glyph { font-size: 16px; text-anchor: middle; }
.node-label { fill: var(--ink); font-size: 10.5px; font-weight: 650; text-anchor: middle; letter-spacing: 0.04em; }
.node-status { font-size: 8.5px; text-anchor: middle; letter-spacing: 0.14em; font-weight: 700; }
.node-task { fill: var(--accent); font-size: 8.5px; text-anchor: middle; font-family: var(--mono); }
.node-count { fill: var(--ink-2); font-size: 9.5px; text-anchor: middle; font-family: var(--mono); }
.orbit { fill: none; stroke: rgba(127, 216, 216, 0.09); stroke-dasharray: 2 7; }
.jring { fill: none; stroke: var(--accent); stroke-opacity: 0.45; stroke-dasharray: 40 18 8 18; animation: spin 24s linear infinite; }
.jring2 { fill: none; stroke: var(--accent); stroke-opacity: 0.2; stroke-dasharray: 6 10; animation: spin 40s linear infinite reverse; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes nodepulse { 50% { stroke-opacity: 0.35; } }
.working .node-ring { animation: nodepulse 1s ease-in-out infinite; }
.flashring { fill: none; pointer-events: none; }
/* ================= dossier drawer ================= */
.drawer {
position: absolute; top: 0; right: 0; bottom: 0; width: 336px; z-index: 8;
background: rgba(7, 13, 20, 0.96);
border-left: 1px solid var(--hairline);
border-radius: 0 var(--r) var(--r) 0;
transform: translateX(102%);
transition: transform 0.22s ease;
display: flex; flex-direction: column;
backdrop-filter: blur(6px);
}
.drawer.open { transform: translateX(0); }
.drawer .dhead { display: flex; gap: 12px; align-items: center; padding: 18px 18px 13px; border-bottom: 1px solid var(--hairline-2); }
.drawer .dhead .g {
font-size: 22px; width: 46px; height: 46px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--accent-dim); border: 1px solid var(--accent-border); border-radius: 14px;
}
.drawer .dhead h3 { font-size: 15px; letter-spacing: 0.04em; font-weight: 650; }
.drawer .dhead p { font-size: 11px; color: var(--muted); margin-top: 2px; line-height: 1.45; }
.drawer .close {
position: absolute; top: 12px; right: 12px;
background: rgba(255,255,255,0.05); border: none; color: var(--muted);
width: 26px; height: 26px; border-radius: 8px;
font-size: 13px; cursor: pointer;
}
.drawer .close:hover { color: var(--ink); background: rgba(255,255,255,0.1); }
.dstatus { display: flex; align-items: center; gap: 9px; padding: 12px 18px; font-size: 11.5px; letter-spacing: 0.16em; font-weight: 700; }
.dgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; padding: 2px 16px 14px; border-bottom: 1px solid var(--hairline-2); }
.dcell { background: rgba(255,255,255,0.03); border: 1px solid var(--hairline-2); border-radius: 13px; padding: 9px 12px; }
.dcell .k { font-size: 8.5px; letter-spacing: 0.16em; color: var(--muted); text-transform: uppercase; font-weight: 600; }
.dcell .v { font-size: 17px; font-weight: 650; margin-top: 2px; }
.dcell .v small { font-size: 11px; color: var(--muted); font-weight: 400; }
.drawer h4 { font-size: 9.5px; letter-spacing: 0.2em; color: var(--muted); text-transform: uppercase; padding: 13px 18px 5px; }
#dlog { flex: 1; overflow-y: auto; padding: 0 10px 12px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.12) transparent; }
#dlog .row { padding: 7px 8px; margin: 2px 0; }
#dlog .empty { color: var(--muted); font-size: 11.5px; padding: 12px 10px; line-height: 1.6; }
</style>
</head>
<body>
<div class="app">
<aside>
<div class="brand">
<div class="mark">🧠</div>
<div><h1>LogiFlow</h1><p>COMMAND CENTER</p></div>
</div>
<nav id="nav">
<div class="navlabel">Monitor</div>
<button data-f="all" class="active"><span class="ndot"></span>Overview<span class="badge" id="n-all">0</span></button>
<button data-f="task"><span class="ndot"></span>Agent tasks<span class="badge" id="n-task">0</span></button>
<button data-f="bus"><span class="ndot"></span>Bus traffic<span class="badge" id="n-bus">0</span></button>
<button data-f="exc"><span class="ndot"></span>Exceptions<span class="badge" id="n-exc">0</span></button>
</nav>
<div class="side-bottom">
<div class="buspill"><span id="busdot" class="dot"></span><span id="bustext">Connecting…</span></div>
<div class="clockbox"><div id="clock">--:--:--</div><div id="date"></div></div>
</div>
</aside>
<main>
<div class="top">
<div>
<h2>Agent operations</h2>
<div class="sub">LogiFlow workspace · autonomous logistics grid</div>
</div>
<div class="livepill"><span id="livedot" class="dot"></span><span id="livetext">Live</span></div>
</div>
<div class="stats">
<div class="card stat">
<div class="k">Agents online</div>
<div class="v" id="t-agents">0<small> / 8</small></div>
<div class="bar"><i id="agentbar"></i></div>
</div>
<div class="card stat">
<div class="k">Messages / min</div>
<div class="v" id="t-rate">0</div>
<div class="s up" id="t-total">0 total on bus</div>
</div>
<div class="card stat">
<div class="k">Tasks completed</div>
<div class="v" id="t-done">0</div>
<div class="s" id="t-avg">across all agents</div>
</div>
<div class="card stat" id="tile-failed">
<div class="k">Tasks failed</div>
<div class="v" id="t-failed">0</div>
<div class="s">requires attention</div>
</div>
<div class="card stat" id="tile-exc">
<div class="k">Exceptions</div>
<div class="v" id="t-exc">0</div>
<div class="s">detected on bus</div>
</div>
</div>
<div class="card stage">
<div class="cardhead"><h3>Agent constellation</h3><span class="hint">· click an agent for its dossier</span></div>
<svg id="cons" viewBox="0 0 1000 720" preserveAspectRatio="xMidYMid meet"></svg>
<div class="offline-veil" id="veil">
<b>⚠ BUS LINK OFFLINE</b>
<span>No NATS connection — waiting to re-establish uplink…</span>
</div>
<div class="drawer" id="drawer">
<button class="close" id="dclose">✕</button>
<div class="dhead">
<div class="g" id="dglyph">🤖</div>
<div><h3 id="dname">AGENT</h3><p id="ddesc"></p></div>
</div>
<div class="dstatus"><span class="dot" id="ddot"></span><span id="dstate">OFFLINE</span>
<span style="margin-left:auto;font-weight:400;letter-spacing:0;color:var(--muted);font-size:10.5px" id="dseen"></span></div>
<div class="dgrid">
<div class="dcell"><div class="k">Tasks done</div><div class="v" id="d-done">0</div></div>
<div class="dcell"><div class="k">Tasks failed</div><div class="v" id="d-failed">0</div></div>
<div class="dcell"><div class="k">Msgs out / in</div><div class="v" id="d-msgs">0 <small>/ 0</small></div></div>
<div class="dcell"><div class="k">Avg task time</div><div class="v" id="d-avg">—</div></div>
</div>
<div style="padding:9px 16px 0"><div class="dcell"><div class="k">Current task</div>
<div class="v" id="d-task" style="font-size:11.5px;font-family:var(--mono)">—</div></div></div>
<h4>Recent activity — this agent</h4>
<div id="dlog"></div>
</div>
</div>
<div class="card feed">
<div class="fhead"><h3>Live activity</h3><span class="count" id="fcount">0 events</span></div>
<div id="feedlist"><div class="fempty" id="fempty">Waiting for agent activity…<br>Tasks, bus messages and exceptions appear here in real time.</div></div>
</div>
<div class="card spark">
<div class="k">Bus throughput · messages per 2s · last 3 min</div>
<canvas id="sparkc"></canvas>
<div id="tip"></div>
</div>
</main>
</div>
<script>
"use strict";
/* ------------------------------------------------ state ---- */
const KNOWN = ["ORDER_AGENT","DISPATCH_AGENT","FLEET_AGENT","HUB_AGENT",
"CUSTOMER_AGENT","EXCEPTION_AGENT","ROUTE_OPTIMIZER"];
const agents = {};
const nodePos = {};
let totals = { messages: 0, tasks_completed: 0, tasks_failed: 0, exceptions: 0 };
let ratePerMin = 0;
const counts = { all: 0, task: 0, bus: 0, exc: 0 };
const buckets = new Array(90).fill(0);
let bucketHead = Date.now();
const GLYPH = {
JARVIS: "🧠", ORDER_AGENT: "📦", DISPATCH_AGENT: "🚚", FLEET_AGENT: "🚛",
HUB_AGENT: "🏭", CUSTOMER_AGENT: "💬", EXCEPTION_AGENT: "⚠️", ROUTE_OPTIMIZER: "🗺️",
};
const STATUS = {
idle: { hex: "#0ca30c", label: "IDLE" },
working: { hex: "#fab219", label: "WORKING" },
error: { hex: "#d03b3b", label: "ERROR" },
stopped: { hex: "#5f757c", label: "STOPPED" },
offline: { hex: "#5f757c", label: "OFFLINE" },
};
const ACCENT = "#2dd4bf", CRIT = "#d03b3b";
/* ------------------------------------------ constellation ---- */
const svg = document.getElementById("cons");
const NS = "http://www.w3.org/2000/svg";
const CX = 500, CY = 368, R = 252;
function el(tag, attrs, parent) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
(parent || svg).appendChild(e);
return e;
}
function buildConstellation() {
svg.innerHTML = "";
const ids = Object.keys(agents).filter(a => a !== "JARVIS");
const ring = ids.length ? ids : KNOWN;
el("circle", { cx: CX, cy: CY, r: R, class: "orbit" });
el("circle", { cx: CX, cy: CY, r: R + 32, class: "orbit", "stroke-opacity": 0.5 });
ring.forEach((id, i) => {
const ang = (i / ring.length) * 2 * Math.PI - Math.PI / 2;
nodePos[id] = { x: CX + R * Math.cos(ang), y: CY + R * Math.sin(ang) };
});
nodePos["JARVIS"] = { x: CX, y: CY };
ring.forEach(id => {
const p = nodePos[id];
el("line", { x1: CX, y1: CY, x2: p.x, y2: p.y, class: "link" });
});
const jg = el("g", { id: "node-JARVIS", class: "node" });
el("circle", { cx: CX, cy: CY, r: 62, class: "jring", style: `transform-origin:${CX}px ${CY}px` }, jg);
el("circle", { cx: CX, cy: CY, r: 74, class: "jring2", style: `transform-origin:${CX}px ${CY}px` }, jg);
el("circle", { cx: CX, cy: CY, r: 48, class: "node-ring", id: "ring-JARVIS", stroke: ACCENT }, jg);
el("text", { x: CX, y: CY - 16, class: "node-glyph", style: "font-size:20px" }, jg).textContent = GLYPH.JARVIS;
el("text", { x: CX, y: CY + 6, class: "node-label", style: "font-size:14px" }, jg).textContent = "JARVIS";
el("text", { x: CX, y: CY + 22, class: "node-status", id: "st-JARVIS", fill: "#5f757c" }, jg).textContent = "OFFLINE";
el("text", { x: CX, y: CY + 100, class: "node-count", id: "ct-JARVIS" }).textContent = "";
el("text", { x: CX, y: CY + 113, class: "node-task", id: "tk-JARVIS" }).textContent = "";
jg.addEventListener("click", () => openDossier("JARVIS"));
ring.forEach(id => {
const p = nodePos[id];
const g = el("g", { id: "node-" + id, class: "node" });
el("circle", { cx: p.x, cy: p.y, r: 38, class: "node-ring", id: "ring-" + id, stroke: "#5f757c" }, g);
el("text", { x: p.x, y: p.y - 12, class: "node-glyph" }, g).textContent = GLYPH[id] || "🤖";
el("text", { x: p.x, y: p.y + 6, class: "node-label" }, g).textContent = id.replace("_AGENT", "");
el("text", { x: p.x, y: p.y + 19, class: "node-status", id: "st-" + id, fill: "#5f757c" }, g).textContent = "OFFLINE";
el("text", { x: p.x, y: p.y + 54, class: "node-count", id: "ct-" + id }).textContent = "✓ 0 · ⇄ 0";
el("text", { x: p.x, y: p.y + 67, class: "node-task", id: "tk-" + id }).textContent = "";
g.addEventListener("click", () => openDossier(id));
});
for (const id of Object.keys(agents)) renderAgent(id);
}
function renderAgent(id) {
const a = agents[id];
if (!a) return;
const st = STATUS[a.status] || STATUS.offline;
const ring = document.getElementById("ring-" + id);
const stEl = document.getElementById("st-" + id);
const node = document.getElementById("node-" + id);
if (!ring) { buildConstellation(); return; }
ring.setAttribute("stroke", st.hex);
ring.style.filter = a.status === "working" || a.status === "idle"
? `drop-shadow(0 0 7px ${st.hex})` : "none";
stEl.textContent = st.label;
stEl.setAttribute("fill", st.hex);
if (node) node.classList.toggle("working", a.status === "working");
const ct = document.getElementById("ct-" + id);
if (ct) ct.textContent =
`✓ ${a.tasks_completed || 0}${a.tasks_failed ? " · ✗ " + a.tasks_failed : ""} · ⇄ ${(a.msgs_sent || 0) + (a.msgs_received || 0)}`;
const tk = document.getElementById("tk-" + id);
if (tk) tk.textContent = a.current_task ? "▸ " + String(a.current_task).slice(0, 30) : "";
updateAgentTile();
}
function updateAgentTile() {
const ids = Object.keys(agents);
const total = Math.max(ids.length, 8);
const online = ids.filter(i => agents[i].status !== "offline" && agents[i].status !== "stopped").length;
document.getElementById("t-agents").innerHTML = `${online}<small> / ${total}</small>`;
document.getElementById("agentbar").style.width = (online / total * 100) + "%";
}
function flash(id, color) {
const p = nodePos[id];
if (!p) return;
const r0 = id === "JARVIS" ? 48 : 38;
const c = el("circle", { cx: p.x, cy: p.y, r: r0, class: "flashring", stroke: color, "stroke-width": 2 });
const t0 = performance.now();
(function step(t) {
const k = Math.min((t - t0) / 500, 1);
c.setAttribute("r", r0 + 16 * k);
c.setAttribute("stroke-opacity", 0.8 * (1 - k));
if (k < 1) requestAnimationFrame(step); else c.remove();
})(t0);
}
function pulse(from, to, color) {
const a = nodePos[from], b = nodePos[to];
if (!a || !b) return;
const c = el("circle", { r: 4.5, fill: color, cx: a.x, cy: a.y,
style: `filter: drop-shadow(0 0 6px ${color})` });
const t0 = performance.now(), dur = 650;
(function step(t) {
const k = Math.min((t - t0) / dur, 1);
const e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
c.setAttribute("cx", a.x + (b.x - a.x) * e);
c.setAttribute("cy", a.y + (b.y - a.y) * e);
c.setAttribute("opacity", 1 - 0.3 * e);
if (k < 1) requestAnimationFrame(step); else c.remove();
})(t0);
}
function pulseMessage(sender, recipient, color) {
const s = nodePos[sender] ? sender : "JARVIS";
if (recipient === "ALL") {
Object.keys(nodePos).filter(id => id !== s)
.forEach((id, i) => setTimeout(() => pulse(s, id, color), i * 40));
} else {
pulse(s, nodePos[recipient] ? recipient : "JARVIS", color);
}
}
/* ------------------------------------------------- feed ---- */
const feed = document.getElementById("feedlist");
let filter = "all";
document.getElementById("nav").addEventListener("click", e => {
const btn = e.target.closest("button");
if (!btn) return;
filter = btn.dataset.f;
document.querySelectorAll("#nav button").forEach(b => b.classList.toggle("active", b === btn));
applyFilter();
});
function rowMatches(row) {
if (filter === "all") return true;
if (filter === "exc") return row.dataset.exc === "1";
return row.dataset.kind === filter;
}
function applyFilter() {
for (const row of feed.querySelectorAll(".row"))
row.style.display = rowMatches(row) ? "" : "none";
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g,
c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
function addFeedRow(ev, animate = true) {
const empty = document.getElementById("fempty");
if (empty) empty.remove();
const row = document.createElement("div");
row.className = "row";
if (!animate) row.style.animation = "none";
const time = (ev.ts || "").slice(11, 19) || "--:--:--";
let l1, l2, pill, pillClass = "", dotColor = ACCENT, isExc = false;
if (ev.type === "task") {
row.dataset.kind = "task";
l1 = `${esc(ev.agent_id)} <span class="dir">ran</span> ${esc(ev.task_type)}`;
l2 = ev.error ? esc(ev.error) : (ev.duration_ms != null ? "completed in " + ev.duration_ms + "ms" : "");
if (ev.status === "failed") { pill = "FAILED"; pillClass = "crit"; isExc = true; dotColor = CRIT; }
else { pill = "DONE"; }
if (!ev.status || ev.status === "completed") dotColor = "#9085e9";
} else {
row.dataset.kind = "bus";
const mt = ev.msg_type || "?";
l1 = `${esc(ev.sender)} <span class="dir">→</span> ${esc(ev.recipient)}`;
l2 = esc(ev.summary || "");
pill = esc(mt.replace(/_/g, " "));
if (/EXCEPTION/.test(mt)) { pillClass = "crit"; isExc = true; dotColor = CRIT; }
else if (/DELAY|CANCEL/.test(mt)) { pillClass = "warn"; dotColor = "#fab219"; }
}
if (isExc) row.dataset.exc = "1";
row.innerHTML =
`<span class="cd" style="background:${dotColor}"></span>` +
`<div class="body"><div class="l1">${l1}</div><div class="l2">${l2}</div></div>` +
`<div class="right"><span class="pill ${pillClass}">${pill}</span><span class="t">${time}</span></div>`;
feed.prepend(row);
if (!rowMatches(row)) row.style.display = "none";
while (feed.childElementCount > 250) feed.lastElementChild.remove();
counts.all++;
counts[row.dataset.kind]++;
if (isExc) counts.exc++;
renderCounts();
}
function renderCounts() {
for (const k of ["all", "task", "bus", "exc"])
document.getElementById("n-" + k).textContent = counts[k];
document.getElementById("fcount").textContent = counts.all + " events";
}
/* ------------------------------------------------ tiles ---- */
function renderTotals() {
document.getElementById("t-rate").textContent = ratePerMin;
document.getElementById("t-total").textContent = totals.messages.toLocaleString() + " total on bus";
document.getElementById("t-done").textContent = totals.tasks_completed.toLocaleString();
document.getElementById("t-failed").textContent = totals.tasks_failed.toLocaleString();
document.getElementById("t-exc").textContent = totals.exceptions.toLocaleString();
document.getElementById("tile-failed").classList.toggle("alert", totals.tasks_failed > 0);
document.getElementById("tile-exc").classList.toggle("alert", totals.exceptions > 0);
}
/* --------------------------------------------- bar chart ---- */
const canvas = document.getElementById("sparkc");
const ctx = canvas.getContext("2d");
const tip = document.getElementById("tip");
let hoverIdx = -1;
function rotateBuckets() {
const now = Date.now();
while (now - bucketHead >= 2000) {
buckets.push(0); buckets.shift(); bucketHead += 2000;
}
}
function bump() { rotateBuckets(); buckets[buckets.length - 1]++; }
function roundTopRect(x, y, w, h, r) {
r = Math.min(r, w / 2, Math.abs(h));
ctx.beginPath();
ctx.moveTo(x, y + h);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h);
ctx.closePath();
}
function drawSpark() {
rotateBuckets();
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
if (canvas.width !== w * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const max = Math.max(4, ...buckets);
const n = buckets.length;
const slot = w / n, bw = Math.max(2, slot - 2.5);
ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, h - 2.5); ctx.lineTo(w, h - 2.5); ctx.stroke();
buckets.forEach((v, i) => {
const bh = v === 0 ? 2 : (v / max) * (h - 14) + 2;
const x = i * slot + (slot - bw) / 2;
const y = h - 3 - bh;
const grad = ctx.createLinearGradient(0, y, 0, h - 3);
if (i === hoverIdx) { grad.addColorStop(0, "#9be8e0"); grad.addColorStop(1, "rgba(155,232,224,0.35)"); }
else if (v === 0) { grad.addColorStop(0, "rgba(45,212,191,0.12)"); grad.addColorStop(1, "rgba(45,212,191,0.06)"); }
else { grad.addColorStop(0, "#2dd4bf"); grad.addColorStop(1, "rgba(45,212,191,0.22)"); }
ctx.fillStyle = grad;
roundTopRect(x, y, bw, bh, 3);
ctx.fill();
});
}
canvas.addEventListener("mousemove", e => {
const r = canvas.getBoundingClientRect();
hoverIdx = Math.max(0, Math.min(buckets.length - 1,
Math.floor((e.clientX - r.left) / r.width * buckets.length)));
const secsAgo = (buckets.length - 1 - hoverIdx) * 2;
tip.style.display = "block";
tip.style.left = Math.min(e.clientX - r.left + 26, r.width - 110) + "px";
tip.style.top = "10px";
tip.textContent = `${buckets[hoverIdx]} msgs · ${secsAgo ? secsAgo + "s ago" : "now"}`;
});
canvas.addEventListener("mouseleave", () => { hoverIdx = -1; tip.style.display = "none"; });
setInterval(drawSpark, 500);
/* ----------------------------------------------- dossier ---- */
const drawer = document.getElementById("drawer");
let dossierId = null, dossierTimer = null;
function openDossier(id) {
dossierId = id;
drawer.classList.add("open");
refreshDossier();
clearInterval(dossierTimer);
dossierTimer = setInterval(refreshDossier, 2000);
}
document.getElementById("dclose").addEventListener("click", () => {
drawer.classList.remove("open");
dossierId = null;
clearInterval(dossierTimer);
});
async function refreshDossier() {
if (!dossierId) return;
let d;
try { d = await (await fetch("/api/agent/" + dossierId)).json(); }
catch { return; }
if (d.error) return;
const st = STATUS[d.status] || STATUS.offline;
document.getElementById("dglyph").textContent = GLYPH[d.agent_id] || "🤖";
document.getElementById("dname").textContent = d.agent_id;
document.getElementById("ddesc").textContent = d.description || "";
const ddot = document.getElementById("ddot");
ddot.className = "dot";
ddot.style.background = st.hex;
ddot.style.boxShadow = `0 0 8px ${st.hex}`;
const ds = document.getElementById("dstate");
ds.textContent = st.label;
ds.style.color = st.hex;
document.getElementById("dseen").textContent =
d.last_seen ? "last seen " + d.last_seen.slice(11, 19) : "never seen";
document.getElementById("d-done").textContent = (d.tasks_completed || 0).toLocaleString();
document.getElementById("d-failed").textContent = (d.tasks_failed || 0).toLocaleString();
document.getElementById("d-failed").style.color = d.tasks_failed ? "var(--crit)" : "";
document.getElementById("d-msgs").innerHTML =
`${(d.msgs_sent || 0).toLocaleString()} <small>/ ${(d.msgs_received || 0).toLocaleString()}</small>`;
document.getElementById("d-avg").textContent =
d.avg_task_ms != null ? (d.avg_task_ms >= 1000 ? (d.avg_task_ms / 1000).toFixed(1) + "s" : d.avg_task_ms + "ms") : "—";
document.getElementById("d-task").textContent = d.current_task || "—";
const dlog = document.getElementById("dlog");
dlog.innerHTML = "";
const recent = (d.recent || []).slice().reverse();
if (!recent.length) {
dlog.innerHTML = '<div class="empty">No activity yet — waiting for tasks or bus traffic…</div>';
return;
}
for (const ev of recent) {
const row = document.createElement("div");
row.className = "row";
row.style.animation = "none";
const time = (ev.ts || "").slice(11, 19);
let l1, l2, pill, pillClass = "", dotColor = ACCENT;
if (ev.type === "task") {
l1 = esc(ev.task_type);
l2 = ev.error ? esc(ev.error) : (ev.duration_ms != null ? ev.duration_ms + "ms" : "");
pill = ev.status === "failed" ? "FAILED" : "DONE";
if (ev.status === "failed") { pillClass = "crit"; dotColor = CRIT; } else dotColor = "#9085e9";
} else {
const out = ev.sender === d.agent_id;
l1 = `<span class="dir">${out ? "→" : "←"}</span> ${esc(out ? ev.recipient : ev.sender)}`;
l2 = esc(ev.summary || "");
pill = esc((ev.msg_type || "?").replace(/_/g, " "));
if (/EXCEPTION/.test(ev.msg_type || "")) { pillClass = "crit"; dotColor = CRIT; }
}
row.innerHTML =
`<span class="cd" style="background:${dotColor}"></span>` +
`<div class="body"><div class="l1">${l1}</div><div class="l2">${l2}</div></div>` +
`<div class="right"><span class="pill ${pillClass}">${pill}</span><span class="t">${time}</span></div>`;
dlog.appendChild(row);
}
}
/* ------------------------------------------------ socket ---- */
const busdot = document.getElementById("busdot");
const bustext = document.getElementById("bustext");
const livedot = document.getElementById("livedot");
const livetext = document.getElementById("livetext");
const veil = document.getElementById("veil");
function setBus(connected, wsAlive = true) {
const on = connected && wsAlive;
busdot.className = "dot " + (on ? "on" : "off");
livedot.className = "dot " + (on ? "on" : "off");
bustext.textContent = !wsAlive ? "Uplink lost…" : connected ? "NATS bus online" : "NATS bus offline";
livetext.textContent = on ? "Live" : "Offline";
veil.classList.toggle("show", !on);
}
function handle(ev) {
switch (ev.type) {
case "snapshot":
Object.assign(totals, ev.totals);
ratePerMin = ev.rate_per_min;
for (const id in ev.agents) agents[id] = ev.agents[id];
buildConstellation();
feed.querySelectorAll(".row").forEach(r => r.remove());
counts.all = counts.task = counts.bus = counts.exc = 0;
(ev.events || []).slice(-120).forEach(e => addFeedRow(e, false));
setBus(ev.bus_connected);
renderTotals();
renderCounts();
break;
case "agent":
agents[ev.agent_id] = ev;
if (!nodePos[ev.agent_id]) buildConstellation(); else renderAgent(ev.agent_id);
break;
case "bus": {
totals.messages++;
bump();
addFeedRow(ev);
const pc = /EXCEPTION/.test(ev.msg_type) ? CRIT : ACCENT;
pulseMessage(ev.sender, ev.recipient, pc);
flash(ev.sender, pc);
if (ev.recipient !== "ALL") flash(ev.recipient, pc);
if (agents[ev.sender]) { agents[ev.sender].msgs_sent = (agents[ev.sender].msgs_sent || 0) + 1; renderAgent(ev.sender); }
if (agents[ev.recipient]) { agents[ev.recipient].msgs_received = (agents[ev.recipient].msgs_received || 0) + 1; renderAgent(ev.recipient); }
renderTotals();
break;
}
case "task":
if (ev.status === "completed") totals.tasks_completed++;
if (ev.status === "failed") totals.tasks_failed++;
addFeedRow(ev);
renderTotals();
break;
case "rate":
ratePerMin = ev.rate_per_min;
Object.assign(totals, ev.totals);
renderTotals();
break;
case "sys":
setBus(ev.bus_connected);
break;
}
}
let ws, pingTimer;
function connect() {
ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
ws.onopen = () => { pingTimer = setInterval(() => ws.readyState === 1 && ws.send("ping"), 20000); };
ws.onmessage = e => handle(JSON.parse(e.data));
ws.onclose = () => {
clearInterval(pingTimer);
setBus(false, false);
setTimeout(connect, 2000);
};
}
buildConstellation();
connect();
/* ------------------------------------------------- clock ---- */
setInterval(() => {
const d = new Date();
document.getElementById("clock").textContent = d.toLocaleTimeString("en-GB");
document.getElementById("date").textContent = d.toLocaleDateString("en-GB",
{ weekday: "short", day: "2-digit", month: "short", year: "numeric" }).toUpperCase();
}, 500);
</script>
</body>
</html>