route optimizer agent with dailygrubs ai assign
This commit is contained in:
300
dashboard/command_center.py
Normal file
300
dashboard/command_center.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
LogiFlow Command Center — real-time JARVIS-style mission control.
|
||||
|
||||
A read-only tap on the live infrastructure:
|
||||
- subscribes to `logistics.>` (every real agent-to-agent message on the bus)
|
||||
- subscribes to `telemetry.>` (agent state + task lifecycle events)
|
||||
and streams everything to the browser over WebSocket.
|
||||
|
||||
Runs as a separate process — it never interferes with the agents:
|
||||
plain (core) NATS subscriptions only, no durable consumers, no JetStream acks.
|
||||
|
||||
Run:
|
||||
python main.py --command-center # http://localhost:8600
|
||||
python dashboard/command_center.py # same, standalone
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
# Allow running as `python dashboard/command_center.py`
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import nats
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from config.system_config import NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD, AGENT_CONFIG
|
||||
from core.logger import logger
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
PORT = 8600
|
||||
|
||||
KNOWN_AGENTS = [
|
||||
"JARVIS", "ORDER_AGENT", "DISPATCH_AGENT", "FLEET_AGENT",
|
||||
"HUB_AGENT", "CUSTOMER_AGENT", "EXCEPTION_AGENT", "ROUTE_OPTIMIZER",
|
||||
]
|
||||
|
||||
AGENT_DESCRIPTIONS = {cfg["name"]: cfg["description"] for cfg in AGENT_CONFIG.values()}
|
||||
|
||||
|
||||
class CommandCenterState:
|
||||
"""Aggregated live view of the agent system, fed by NATS events."""
|
||||
|
||||
def __init__(self):
|
||||
self.bus_connected = False
|
||||
self.agents: Dict[str, Dict[str, Any]] = {
|
||||
a: {"agent_id": a, "status": "offline", "current_task": None,
|
||||
"tasks_completed": 0, "tasks_failed": 0, "last_seen": None,
|
||||
"msgs_sent": 0, "msgs_received": 0,
|
||||
"description": AGENT_DESCRIPTIONS.get(a, "")}
|
||||
for a in KNOWN_AGENTS
|
||||
}
|
||||
self.agent_recent: Dict[str, deque] = {a: deque(maxlen=40) for a in KNOWN_AGENTS}
|
||||
self.agent_durations: Dict[str, deque] = {a: deque(maxlen=50) for a in KNOWN_AGENTS}
|
||||
self.events: deque = deque(maxlen=300)
|
||||
self.totals = {"messages": 0, "tasks_completed": 0, "tasks_failed": 0, "exceptions": 0}
|
||||
self.msg_times: deque = deque(maxlen=5000) # unix ts of bus messages, for rate
|
||||
|
||||
def rate_per_min(self) -> int:
|
||||
cutoff = time.time() - 60
|
||||
return sum(1 for t in self.msg_times if t >= cutoff)
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "snapshot",
|
||||
"bus_connected": self.bus_connected,
|
||||
"agents": self.agents,
|
||||
"events": list(self.events),
|
||||
"totals": self.totals,
|
||||
"rate_per_min": self.rate_per_min(),
|
||||
}
|
||||
|
||||
|
||||
state = CommandCenterState()
|
||||
websockets: Set[WebSocket] = set()
|
||||
app = FastAPI(title="LogiFlow Command Center")
|
||||
|
||||
|
||||
async def broadcast(event: Dict[str, Any]):
|
||||
"""Push one event to every connected browser."""
|
||||
dead = []
|
||||
for ws in websockets:
|
||||
try:
|
||||
await ws.send_text(json.dumps(event))
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
websockets.discard(ws)
|
||||
|
||||
|
||||
def _compact(payload: Any, limit: int = 240) -> str:
|
||||
try:
|
||||
s = json.dumps(payload, default=str)
|
||||
except Exception:
|
||||
s = str(payload)
|
||||
return s[:limit] + ("…" if len(s) > limit else "")
|
||||
|
||||
|
||||
async def _on_bus_message(msg):
|
||||
"""Any real agent message on `logistics.>` (direct or broadcast)."""
|
||||
now = time.time()
|
||||
state.totals["messages"] += 1
|
||||
state.msg_times.append(now)
|
||||
try:
|
||||
data = json.loads(msg.data.decode())
|
||||
except Exception:
|
||||
data = {"raw": msg.data.decode(errors="replace")}
|
||||
|
||||
msg_type = data.get("message_type", msg.subject.split(".")[-1])
|
||||
if msg_type == "EXCEPTION_DETECTED":
|
||||
state.totals["exceptions"] += 1
|
||||
|
||||
event = {
|
||||
"type": "bus",
|
||||
"ts": data.get("timestamp", datetime.now().isoformat()),
|
||||
"sender": data.get("sender", "?"),
|
||||
"recipient": data.get("recipient", msg.subject.split(".")[-1] if ".direct." in msg.subject else "ALL"),
|
||||
"msg_type": msg_type,
|
||||
"summary": _compact(data.get("payload", data)),
|
||||
"correlation_id": data.get("correlation_id"),
|
||||
}
|
||||
for role, key in (("sender", "msgs_sent"), ("recipient", "msgs_received")):
|
||||
aid = event[role]
|
||||
if aid in state.agents:
|
||||
state.agents[aid][key] = state.agents[aid].get(key, 0) + 1
|
||||
state.agent_recent.setdefault(aid, deque(maxlen=40)).append(event)
|
||||
state.events.append(event)
|
||||
await broadcast(event)
|
||||
|
||||
|
||||
async def _on_telemetry(msg):
|
||||
"""Agent state / task lifecycle events on `telemetry.>`."""
|
||||
try:
|
||||
data = json.loads(msg.data.decode())
|
||||
except Exception:
|
||||
return
|
||||
|
||||
kind = data.get("kind")
|
||||
if kind == "agent":
|
||||
agent_id = data.get("agent_id")
|
||||
if not agent_id:
|
||||
return
|
||||
entry = state.agents.setdefault(agent_id, {
|
||||
"msgs_sent": 0, "msgs_received": 0,
|
||||
"description": AGENT_DESCRIPTIONS.get(agent_id, ""),
|
||||
})
|
||||
entry.update({
|
||||
"agent_id": agent_id,
|
||||
"status": data.get("status", "idle"),
|
||||
"current_task": data.get("current_task"),
|
||||
"tasks_completed": data.get("tasks_completed", 0),
|
||||
"tasks_failed": data.get("tasks_failed", 0),
|
||||
"last_seen": data.get("ts"),
|
||||
})
|
||||
await broadcast({"type": "agent", **entry})
|
||||
|
||||
elif kind == "task":
|
||||
if data.get("status") == "completed":
|
||||
state.totals["tasks_completed"] += 1
|
||||
elif data.get("status") == "failed":
|
||||
state.totals["tasks_failed"] += 1
|
||||
event = {
|
||||
"type": "task",
|
||||
"ts": data.get("ts", datetime.now().isoformat()),
|
||||
"agent_id": data.get("agent_id"),
|
||||
"task_id": data.get("task_id"),
|
||||
"task_type": data.get("task_type"),
|
||||
"status": data.get("status"),
|
||||
"error": data.get("error"),
|
||||
"duration_ms": data.get("duration_ms"),
|
||||
}
|
||||
aid = event["agent_id"]
|
||||
if aid:
|
||||
state.agent_recent.setdefault(aid, deque(maxlen=40)).append(event)
|
||||
if event["duration_ms"] is not None:
|
||||
state.agent_durations.setdefault(aid, deque(maxlen=50)).append(event["duration_ms"])
|
||||
state.events.append(event)
|
||||
await broadcast(event)
|
||||
|
||||
|
||||
async def nats_tap():
|
||||
"""Connect to NATS (with retry forever) and hold plain subscriptions."""
|
||||
while True:
|
||||
try:
|
||||
nc = await nats.connect(
|
||||
servers=[f"nats://{NATS_HOST}:{NATS_PORT}"],
|
||||
user=NATS_USER,
|
||||
password=NATS_PASSWORD,
|
||||
max_reconnect_attempts=-1,
|
||||
disconnected_cb=_bus_down,
|
||||
reconnected_cb=_bus_up,
|
||||
)
|
||||
await nc.subscribe("logistics.>", cb=_on_bus_message)
|
||||
await nc.subscribe("telemetry.>", cb=_on_telemetry)
|
||||
await _bus_up()
|
||||
logger.info(f"Command Center tapped into NATS at {NATS_HOST}:{NATS_PORT}")
|
||||
while nc.is_connected or nc.is_reconnecting:
|
||||
await asyncio.sleep(2)
|
||||
await _bus_down()
|
||||
except Exception as e:
|
||||
logger.warning(f"Command Center NATS connect failed: {e} — retrying in 5s")
|
||||
await _bus_down()
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def _bus_up():
|
||||
state.bus_connected = True
|
||||
await broadcast({"type": "sys", "bus_connected": True})
|
||||
|
||||
|
||||
async def _bus_down():
|
||||
if state.bus_connected:
|
||||
logger.warning("Command Center lost NATS connection")
|
||||
state.bus_connected = False
|
||||
await broadcast({"type": "sys", "bus_connected": False})
|
||||
|
||||
|
||||
async def rate_ticker():
|
||||
"""Push the message rate + mark stale agents offline every 2s."""
|
||||
while True:
|
||||
await asyncio.sleep(2)
|
||||
now = datetime.now()
|
||||
for entry in state.agents.values():
|
||||
last = entry.get("last_seen")
|
||||
if last and entry.get("status") != "offline":
|
||||
try:
|
||||
age = (now - datetime.fromisoformat(last)).total_seconds()
|
||||
if age > 15:
|
||||
entry["status"] = "offline"
|
||||
await broadcast({"type": "agent", **entry})
|
||||
except ValueError:
|
||||
pass
|
||||
if websockets:
|
||||
await broadcast({
|
||||
"type": "rate",
|
||||
"rate_per_min": state.rate_per_min(),
|
||||
"totals": state.totals,
|
||||
})
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
asyncio.create_task(nats_tap())
|
||||
asyncio.create_task(rate_ticker())
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse(STATIC_DIR / "command_center.html")
|
||||
|
||||
|
||||
@app.get("/api/state")
|
||||
async def api_state():
|
||||
return state.snapshot()
|
||||
|
||||
|
||||
@app.get("/api/agent/{agent_id}")
|
||||
async def api_agent(agent_id: str):
|
||||
"""Full dossier for one agent — used by the UI detail panel."""
|
||||
entry = state.agents.get(agent_id)
|
||||
if not entry:
|
||||
return {"error": "unknown agent"}
|
||||
durations = list(state.agent_durations.get(agent_id, []))
|
||||
return {
|
||||
**entry,
|
||||
"avg_task_ms": round(sum(durations) / len(durations)) if durations else None,
|
||||
"recent": list(state.agent_recent.get(agent_id, []))[-30:],
|
||||
}
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def ws_endpoint(ws: WebSocket):
|
||||
await ws.accept()
|
||||
websockets.add(ws)
|
||||
await ws.send_text(json.dumps(state.snapshot()))
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text() # keepalive pings from the browser
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
websockets.discard(ws)
|
||||
|
||||
|
||||
def run(port: int = PORT):
|
||||
logger.info(f"LogiFlow Command Center → http://localhost:{port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
848
dashboard/static/command_center.html
Normal file
848
dashboard/static/command_center.html
Normal file
@@ -0,0 +1,848 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user