849 lines
37 KiB
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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>
|