GitHub Actions
ci: deploy clean snapshot to HF Space
3a6e663
Raw
History Blame Contribute Delete
69 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>OpenEnv Β· Cluster SRE Command Center</title>
<meta name="description" content="OpenEnv Distributed Cluster Triage β€” Interactive RL environment where an AI agent acts as an SRE triaging infrastructure failures.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
<style>
/* ── DESIGN TOKENS ─────────────────────────────────────── */
:root {
--bg: #09090b;
--surface: #131316;
--elevated: #1c1c1f;
--border: #232328;
--border2: #3f3f46;
--accent: #8b5cf6;
--accent-h: #7c3aed;
--accent-glow: rgba(139,92,246,0.15);
--green: #10b981;
--red: #ef4444;
--yellow: #eab308;
--blue: #3b82f6;
--cyan: #22d3ee;
--text: #e4e4e7;
--muted: #71717a;
--subtle: #a1a1aa;
--mono: 'IBM Plex Mono', 'Courier New', monospace;
--sans: 'Inter', system-ui, sans-serif;
--r: 8px;
--r-lg: 12px;
}
/* ── RESET ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed; inset: 0;
pointer-events: none; z-index: 0;
background-image:
linear-gradient(rgba(139,92,246,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(139,92,246,0.02) 1px, transparent 1px);
background-size: 48px 48px;
}
/* ── LAYOUT ─────────────────────────────────────────────── */
.app {
position: relative; z-index: 1;
max-width: 1480px; margin: 0 auto;
padding: 12px; display: flex;
flex-direction: column; gap: 10px;
}
/* ── CARDS ──────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
}
.card-header {
display: flex; align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
gap: 8px; min-height: 34px;
}
.card-title {
font-family: var(--mono);
font-size: 9px; font-weight: 700;
color: var(--muted);
letter-spacing: 2px; text-transform: uppercase;
}
.card-body { padding: 10px; }
/* ── HEADER ─────────────────────────────────────────────── */
.header {
display: flex; align-items: center;
justify-content: space-between;
padding: 9px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
gap: 12px; flex-wrap: wrap;
}
.logo { display: flex; align-items: baseline; gap: 8px; }
.logo-name {
font-family: var(--mono); font-size: 16px; font-weight: 700;
background: linear-gradient(135deg, var(--accent), #a78bfa);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.logo-sub { font-family: var(--mono); font-size: 9px; color: var(--muted); letter-spacing: 1px; }
.header-right { display: flex; align-items: center; gap: 8px; }
.status-badge {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px;
background: #0a0a0d;
border: 1px solid var(--border);
border-radius: 100px;
font-family: var(--mono); font-size: 9px; color: var(--muted);
}
.status-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--muted); flex-shrink: 0;
}
.status-dot.live { background: var(--green); animation: pulse-dot 2s infinite; }
.status-dot.err { background: var(--red); }
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0.4); }
50% { box-shadow: 0 0 0 4px rgba(16,185,129,0); }
}
.version-tag {
font-family: var(--mono); font-size: 9px; color: var(--accent);
background: rgba(139,92,246,0.1);
border: 1px solid rgba(139,92,246,0.25);
padding: 2px 8px; border-radius: 100px;
}
/* ══════════════════════════════════════════════════════════
MAIN 3-COL GRID
═══════════════════════════════════════════════════════════ */
.main-grid {
display: grid;
grid-template-columns: 220px 1fr 210px;
gap: 10px; align-items: start;
}
/* ══════════════════════════════════════════════════════════
LEFT SIDEBAR: AGENTS β†’ TASKS
──────────────────────────────────────────────────────────
Auto-populated from GET /agents.
Each agent is collapsible; clicking expands its task list.
EXTENSIBILITY: add agents to agents/__init__.py β†’ auto-appears.
═══════════════════════════════════════════════════════════ */
.sidebar-inner { display: flex; flex-direction: column; gap: 8px; }
/* Agent group = agent header + collapsible task list */
.agent-group { margin-bottom: 2px; }
.agent-header {
display: flex; align-items: center; gap: 8px;
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: var(--r);
cursor: pointer; transition: all 0.15s;
background: #0e0e11;
user-select: none;
}
.agent-header:hover { border-color: var(--border2); background: #141417; }
.agent-header.active {
border-color: var(--accent);
background: rgba(139,92,246,0.06);
box-shadow: 0 0 8px var(--accent-glow);
}
.agent-icon { font-size: 16px; flex-shrink: 0; }
.agent-header-text { flex: 1; min-width: 0; }
.agent-name { font-size: 12px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.agent-desc-mini { font-family: var(--mono); font-size: 8px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.agent-chevron {
font-family: var(--mono); font-size: 10px; color: var(--muted);
transition: transform 0.2s;
}
.agent-header.active .agent-chevron { transform: rotate(90deg); color: var(--accent); }
/* Task list inside agent β€” hidden until agent is expanded */
.task-list {
max-height: 0; overflow: hidden;
transition: max-height 0.3s ease;
padding-left: 12px;
}
.task-list.open { max-height: 500px; }
.task-list-inner { padding: 4px 0 2px; display: flex; flex-direction: column; gap: 3px; }
.task-item {
display: flex; align-items: center; gap: 6px;
padding: 6px 9px;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer; transition: all 0.12s;
background: transparent;
}
.task-item:hover { background: #141417; border-color: var(--border); }
.task-item.selected {
background: rgba(139,92,246,0.08);
border-color: rgba(139,92,246,0.3);
}
.task-dot {
width: 6px; height: 6px;
border-radius: 50%; flex-shrink: 0;
}
.task-item-name { font-size: 11px; color: var(--text); flex: 1; }
.task-item-diff {
font-family: var(--mono); font-size: 8px; font-weight: 700;
padding: 1px 5px; border-radius: 3px;
letter-spacing: 0.5px;
}
/* "Add Agent" placeholder β€” bottom of sidebar */
.add-agent-slot {
display: flex; align-items: center; gap: 8px;
padding: 9px 10px;
border: 1px dashed var(--border2);
border-radius: var(--r);
opacity: 0.4; cursor: default;
margin-top: 2px;
}
.add-agent-icon { font-size: 14px; color: var(--muted); }
.add-agent-text { font-family: var(--mono); font-size: 8px; color: var(--muted); letter-spacing: 1px; line-height: 1.5; }
/* ── CONTROL BUTTONS ────────────────────────────────────── */
.ctrl-btns { display: flex; flex-direction: column; gap: 5px; margin-top: 8px; }
.btn {
display: flex; align-items: center; justify-content: center; gap: 5px;
padding: 8px 12px; border-radius: var(--r); border: none;
font-family: var(--mono); font-size: 9px;
font-weight: 700; letter-spacing: 1px;
cursor: pointer; transition: all 0.15s;
text-transform: uppercase; width: 100%;
}
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover:not(:disabled) {
background: var(--accent-h); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(139,92,246,0.4);
}
.btn-ghost { background: #0e0e11; color: var(--subtle); border: 1px solid var(--border); }
.btn-ghost:hover:not(:disabled) { border-color: var(--border2); color: var(--text); }
.spinner {
width: 11px; height: 11px;
border: 2px solid rgba(255,255,255,0.2); border-top-color: #fff;
border-radius: 50%; animation: spin 0.7s linear infinite; display: none;
}
.spinner.on { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── CENTER PANEL ────────────────────────────────────────── */
.center-panel { display: flex; flex-direction: column; gap: 10px; }
.briefing-text {
font-family: var(--mono); font-size: 11px;
color: var(--subtle); line-height: 1.8;
white-space: pre-wrap; padding: 10px;
background: #0a0a0d; border-radius: var(--r);
min-height: 80px;
}
/* ── NODE GRID ───────────────────────────────────────────── */
.node-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.node-card {
background: #0a0a0d;
border: 1px solid var(--border);
border-radius: var(--r);
padding: 8px 10px;
border-left-width: 3px;
transition: border-color 0.4s;
}
.node-card.healthy { border-left-color: var(--green); }
.node-card.offline { border-left-color: var(--red); }
.node-card.high_memory { border-left-color: var(--yellow); }
.node-card.disk_full { border-left-color: var(--yellow); }
.node-card.unknown { border-left-color: var(--border2);}
.node-id { font-family: var(--mono); font-size: 11px; font-weight: 700; }
.node-status {
font-family: var(--mono); font-size: 8px; font-weight: 700;
text-transform: uppercase; letter-spacing: 1px; margin: 2px 0 5px;
}
.node-status.healthy { color: var(--green); }
.node-status.offline { color: var(--red); }
.node-status.high_memory { color: var(--yellow); }
.node-status.disk_full { color: var(--yellow); }
.node-status.unknown { color: var(--muted); }
.node-metrics {
display: flex; justify-content: space-between;
font-family: var(--mono); font-size: 9px; color: var(--muted);
}
.metric-bar { height: 2px; background: var(--border); border-radius: 2px; margin-top: 4px; overflow: hidden; }
.metric-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s ease; }
/* ── SCORE PANEL (right col) ─────────────────────────────── */
.score-number {
font-family: var(--mono); font-size: 44px;
font-weight: 700; line-height: 1;
text-align: center; transition: color 0.3s;
}
.score-label {
font-family: var(--mono); font-size: 8px;
color: var(--muted); letter-spacing: 2px;
text-align: center; text-transform: uppercase; margin-top: 4px;
}
.score-divider { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
.score-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; }
.score-row-lbl { font-family: var(--mono); font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; }
.score-row-val { font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--green); }
.reward-bars {
display: flex; align-items: flex-end; height: 45px; gap: 3px;
background: rgba(0,0,0,0.3); border-radius: 5px 5px 0 0; padding: 6px 6px 0;
}
.rbar { flex: 1; border-radius: 2px 2px 0 0; opacity: 0.85; min-height: 3px; transition: height 0.3s; }
.rbar.pos { background: var(--green); } .rbar.neg { background: var(--red); }
.rbar-axis { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 8px; color: var(--muted); margin-top: 3px; }
/* ── TERMINAL ────────────────────────────────────────────── */
.terminal {
background: #050507; border-radius: var(--r);
padding: 10px 12px; height: 220px;
overflow-y: auto;
font-family: var(--mono); font-size: 11px; line-height: 1.8;
border: 1px solid #1a1a1f;
}
.terminal::-webkit-scrollbar { width: 3px; }
.terminal::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.t-sys { color: var(--muted); }
.t-reset { color: #a78bfa; font-weight: 500; }
.t-step { color: var(--text); }
.t-rpos { color: var(--green); font-weight: 700; }
.t-rneg { color: var(--red); font-weight: 700; }
.t-ok { color: var(--green); }
.t-warn { color: var(--yellow); }
.t-err { color: var(--red); }
.t-info { color: var(--blue); }
/* ── BOTTOM GRID ──────────────────────────────────────────── */
.bottom-grid { display: grid; grid-template-columns: 1fr 310px; gap: 10px; }
.api-pills { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
.api-pill {
display: flex; align-items: center; gap: 3px;
padding: 3px 8px; background: #0a0a0d;
border: 1px solid var(--border); border-radius: 100px;
font-family: var(--mono); font-size: 8px; color: var(--muted);
cursor: pointer; transition: all 0.15s;
}
.api-pill:hover { border-color: var(--accent); color: var(--accent); }
.m-get { color: var(--green); font-weight: 700; }
.m-post { color: var(--cyan); font-weight: 700; }
.api-output {
background: #050507; border: 1px solid var(--border); border-radius: var(--r);
padding: 8px 10px; font-family: var(--mono); font-size: 9px;
color: #88aacc; line-height: 1.7; white-space: pre-wrap; max-height: 100px; overflow-y: auto;
}
/* ── UTILITY ──────────────────────────────────────────────── */
.tag {
font-family: var(--mono); font-size: 8px; color: var(--muted);
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
padding: 2px 6px; border-radius: 4px;
}
.flex-row { display: flex; align-items: center; gap: 6px; }
.footer {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 4px; font-family: var(--mono); font-size: 8px;
color: var(--muted); flex-wrap: wrap; gap: 4px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(3px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeIn 0.2s ease forwards; }
/* ══════════════════════════════════════════════════════════
SPLIT-BRAIN WAR ROOM STYLES
═══════════════════════════════════════════════════════════ */
.warroom { display: none; }
.warroom.active { display: grid; grid-template-columns: 280px 1fr 240px; gap: 10px; align-items: start; }
/* War Room Chat Log */
.wr-chat { height: 420px; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 6px; }
.wr-chat::-webkit-scrollbar { width: 3px; }
.wr-chat::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.wr-msg { display: flex; gap: 8px; padding: 8px 10px; border-radius: var(--r); background: #0a0a0d; border: 1px solid var(--border); animation: fadeIn 0.3s ease; }
.wr-msg-avatar { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
.wr-msg-body { flex: 1; min-width: 0; }
.wr-msg-name { font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 2px; }
.wr-msg-text { font-family: var(--mono); font-size: 10px; color: var(--subtle); line-height: 1.6; word-break: break-word; }
.wr-orch .wr-msg-avatar { background: rgba(139,92,246,0.2); }
.wr-orch .wr-msg-name { color: #a78bfa; }
.wr-netops .wr-msg-avatar { background: rgba(59,130,246,0.2); }
.wr-netops .wr-msg-name { color: #60a5fa; }
.wr-dataops .wr-msg-avatar { background: rgba(16,185,129,0.2); }
.wr-dataops .wr-msg-name { color: #34d399; }
.wr-system .wr-msg-avatar { background: rgba(113,113,122,0.2); }
.wr-system .wr-msg-name { color: var(--muted); }
/* Topology Graph */
#cyGraph { width: 100%; height: 420px; background: #050507; border-radius: var(--r); border: 1px solid var(--border); }
/* Telemetry Gauges */
.telem-gauge { margin-bottom: 12px; }
.telem-label { font-family: var(--mono); font-size: 8px; color: var(--muted); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 4px; }
.telem-val { font-family: var(--mono); font-size: 22px; font-weight: 700; line-height: 1.2; }
.telem-bar { height: 6px; background: var(--border); border-radius: 3px; margin-top: 4px; overflow: hidden; }
.telem-bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease, background 0.5s ease; }
.telem-sub { font-family: var(--mono); font-size: 8px; color: var(--muted); margin-top: 3px; }
/* Actor Badge */
.actor-badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 100px; font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; }
.actor-badge.orchestrator { background: rgba(139,92,246,0.15); color: #a78bfa; border: 1px solid rgba(139,92,246,0.3); }
.actor-badge.netops { background: rgba(59,130,246,0.15); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }
.actor-badge.dataops { background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3); }
/* ── RESPONSIVE ──────────────────────────────────────────── */
@media (max-width: 1100px) { .main-grid { grid-template-columns: 200px 1fr 195px; } .warroom.active { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } .bottom-grid { grid-template-columns: 1fr; } .warroom.active { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="app">
<!-- ── HEADER ── -->
<header class="header" id="appHeader">
<div class="logo">
<span class="logo-name">OpenEnv</span>
<span class="logo-sub">Cluster SRE Β· Distributed Triage</span>
</div>
<div class="header-right">
<div class="status-badge">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Connecting...</span>
</div>
<span class="version-tag" id="versionTag">v1.0.0</span>
</div>
</header>
<!-- ═══════════════════════════════════════════════════════
MAIN 3-COLUMN GRID
════════════════════════════════════════════════════════ -->
<div class="main-grid">
<!-- ── COL 1: AGENTS SIDEBAR ──────────────────────────── -->
<div class="card" id="sidebarCard">
<div class="card-header">
<span class="card-title">πŸ€– Agents</span>
<span class="tag" id="agentCountTag">Loading</span>
</div>
<div class="card-body">
<div class="sidebar-inner" id="sidebarAgents">
<div style="font-family:var(--mono);font-size:10px;color:var(--muted);padding:8px;">Discovering agents...</div>
</div>
<!-- Controls -->
<div class="ctrl-btns">
<button class="btn btn-ghost" id="btnState" onclick="doState()">πŸ“Š State</button>
<button class="btn btn-ghost" id="btnReset" onclick="doReset()">πŸ”„ Reset</button>
<button class="btn btn-primary" id="btnStep" onclick="doAgentStep()">
<div class="spinner" id="stepSpinner"></div>
<span id="stepBtnTxt">β–Ά Agent Step</span>
</button>
</div>
</div>
</div>
<!-- ── COL 2: BRIEFING + NODES ────────────────────────── -->
<div class="center-panel" id="centerPanel">
<div class="card">
<div class="card-header">
<span class="card-title">πŸ“‹ Active Incident Briefing</span>
<div class="flex-row">
<span class="tag" id="stepCounter">Step 0</span>
<span class="tag" id="healthTag">Health: β€”</span>
</div>
</div>
<div class="card-body" style="padding:0;">
<div class="briefing-text" id="briefingText">Select an agent and task from the sidebar, then click πŸ”„ Reset...</div>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">πŸ–₯️ Live Node Telemetry</span>
<span id="nodeSub" style="font-family:var(--mono);font-size:8px;color:var(--muted);">4 nodes Β· awaiting reset</span>
</div>
<div class="card-body">
<div class="node-grid" id="nodeGrid">
<div class="node-card unknown"><div class="node-id">worker_01</div><div class="node-status unknown">standby</div><div class="node-metrics"><span>Disk β€”</span><span>RAM β€”</span></div></div>
<div class="node-card unknown"><div class="node-id">worker_02</div><div class="node-status unknown">standby</div><div class="node-metrics"><span>Disk β€”</span><span>RAM β€”</span></div></div>
<div class="node-card unknown"><div class="node-id">worker_03</div><div class="node-status unknown">standby</div><div class="node-metrics"><span>Disk β€”</span><span>RAM β€”</span></div></div>
<div class="node-card unknown"><div class="node-id">worker_04</div><div class="node-status unknown">standby</div><div class="node-metrics"><span>Disk β€”</span><span>RAM β€”</span></div></div>
</div>
</div>
</div>
</div>
<!-- ── COL 3: EPISODE SCORE ───────────────────────────── -->
<div class="card" id="scoreCard">
<div class="card-header">
<span class="card-title">πŸ“ˆ Score</span>
<span id="doneTag" style="display:none;font-family:var(--mono);font-size:8px;color:var(--green);font-weight:700;letter-spacing:1px;">βœ“ DONE</span>
</div>
<div class="card-body">
<div class="score-number" id="scoreReward" style="color:var(--muted);">+0.000</div>
<div class="score-label">Step Reward</div>
<hr class="score-divider">
<div class="score-row">
<span class="score-row-lbl">Health</span>
<span class="score-row-val" id="scoreHealth">β€”</span>
</div>
<div class="score-row">
<span class="score-row-lbl">Total</span>
<span style="font-family:var(--mono);font-size:14px;font-weight:700;color:var(--subtle);" id="scoreTotal">0.000</span>
</div>
<hr class="score-divider">
<div style="font-family:var(--mono);font-size:8px;color:var(--muted);letter-spacing:2px;text-transform:uppercase;margin-bottom:6px;">Reward History</div>
<div class="reward-bars" id="rewardBars">
<div style="font-family:var(--mono);font-size:8px;color:var(--muted);margin:auto;">No steps yet</div>
</div>
<div class="rbar-axis"><span>+1.0</span><span id="avgRwd">avg β€”</span><span>-1.0</span></div>
<div style="font-family:var(--mono);font-size:9px;color:var(--muted);text-align:center;margin-top:6px;" id="stepCounterScore">0 / β€” steps</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
SPLIT-BRAIN WAR ROOM (hidden until split_brain agent selected)
════════════════════════════════════════════════════════ -->
<div class="warroom" id="warroom">
<!-- LEFT: Agent Chat Log -->
<div class="card">
<div class="card-header">
<span class="card-title">πŸ’¬ War Room</span>
<span class="actor-badge orchestrator" id="actorBadge">🎯 Orchestrator</span>
</div>
<div class="card-body" style="padding:0;">
<div class="wr-chat" id="wrChat">
<div class="wr-msg wr-system"><div class="wr-msg-avatar">⚑</div><div class="wr-msg-body"><div class="wr-msg-name">System</div><div class="wr-msg-text">War room initialized. Waiting for incident...</div></div></div>
</div>
</div>
</div>
<!-- CENTER: Topology Graph -->
<div class="card">
<div class="card-header">
<span class="card-title">🌐 Network Topology</span>
<span class="tag" id="topoStatus">Awaiting reset</span>
</div>
<div class="card-body" style="padding:6px;">
<div id="cyGraph"></div>
</div>
</div>
<!-- RIGHT: Telemetry -->
<div class="card">
<div class="card-header">
<span class="card-title">πŸ“Š Telemetry</span>
</div>
<div class="card-body">
<div class="telem-gauge">
<div class="telem-label">Global Health</div>
<div class="telem-val" id="tmHealth" style="color:var(--red);">0%</div>
<div class="telem-bar"><div class="telem-bar-fill" id="tmHealthBar" style="width:0%;background:var(--red);"></div></div>
</div>
<div class="telem-gauge">
<div class="telem-label">HDFS I/O Bandwidth</div>
<div class="telem-val" id="tmIO" style="color:var(--red);">100%</div>
<div class="telem-bar"><div class="telem-bar-fill" id="tmIOBar" style="width:100%;background:var(--red);"></div></div>
<div class="telem-sub" id="tmStorm">⚠ REPLICATION STORM</div>
</div>
<div class="telem-gauge">
<div class="telem-label">NewSQL Ledger Drift</div>
<div class="telem-val" id="tmDrift" style="color:var(--red);">15</div>
<div class="telem-bar"><div class="telem-bar-fill" id="tmDriftBar" style="width:100%;background:var(--red);"></div></div>
<div class="telem-sub" id="tmBrain">⚠ SPLIT-BRAIN ACTIVE</div>
</div>
<div class="telem-gauge">
<div class="telem-label">Network Status</div>
<div class="telem-val" id="tmNet" style="color:var(--red);">PARTITIONED</div>
<div class="telem-sub" id="tmRouting">Routing: unverified</div>
</div>
<div class="telem-gauge">
<div class="telem-label">Redis Cache Usage</div>
<div class="telem-val" id="tmRedis" style="color:var(--green);">0%</div>
<div class="telem-bar"><div class="telem-bar-fill" id="tmRedisBar" style="width:0%;background:var(--green);"></div></div>
<div class="telem-sub" id="tmAuth">Auth: online</div>
</div>
<hr class="score-divider">
<div class="telem-gauge">
<div class="telem-label">Reward This Step</div>
<div class="telem-val" id="tmReward" style="color:var(--muted);">+0.00</div>
</div>
<div class="telem-gauge">
<div class="telem-label">Total Reward</div>
<div class="telem-val" id="tmTotal" style="color:var(--subtle);">0.00</div>
</div>
</div>
</div>
</div><!-- /.warroom -->
</div><!-- /.main-grid -->
<!-- ═══════════════════════════════════════════════════════
BOTTOM GRID: TERMINAL + SIDE PANELS
════════════════════════════════════════════════════════ -->
<div class="bottom-grid">
<div class="card">
<div class="card-header">
<span class="card-title">πŸ’» Agent Terminal</span>
<div class="flex-row">
<div class="spinner" id="termSpinner"></div>
<span id="llmTag" style="font-family:var(--mono);font-size:8px;color:var(--accent);display:none;letter-spacing:1px;">LLM THINKING...</span>
<button class="btn btn-ghost" style="padding:3px 8px;font-size:8px;width:auto;" onclick="clearTerminal()">CLEAR</button>
</div>
</div>
<div class="card-body" style="padding:0 10px 10px;">
<div class="terminal" id="terminal">
<div class="t-sys">// OpenEnv Cluster SRE Command Center</div>
<div class="t-sys">// Select an agent β†’ pick a task β†’ click Reset β†’ Agent Step</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<!-- API EXPLORER -->
<div class="card">
<div class="card-header">
<span class="card-title">πŸ”Œ API Explorer</span>
<span style="font-family:var(--mono);font-size:8px;color:var(--muted);" id="apiBaseUrl"></span>
</div>
<div class="card-body">
<div class="api-pills">
<div class="api-pill" onclick="apiTest('/health','GET')"><span class="m-get">GET</span>/health</div>
<div class="api-pill" onclick="apiTestTasks()"><span class="m-get">GET</span>/tasks</div>
<div class="api-pill" onclick="apiTest('/agents','GET')"><span class="m-get">GET</span>/agents</div>
<div class="api-pill" onclick="apiTest('/state','GET')"><span class="m-get">GET</span>/state</div>
<div class="api-pill" onclick="apiTestReset()"><span class="m-post">POST</span>/reset</div>
<div class="api-pill" onclick="apiTestAgentStep()"><span class="m-post">POST</span>/agent/step</div>
</div>
<div class="api-output" id="apiOutput">// Click any endpoint to test</div>
</div>
</div>
<!-- JOBS / ALERTS -->
<div class="card">
<div class="card-header"><span class="card-title">βš™οΈ Active Jobs</span></div>
<div class="card-body"><div id="jobsList" style="font-family:var(--mono);font-size:9px;color:var(--muted);">No active jobs</div></div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">🚨 Alerts</span></div>
<div class="card-body"><div id="alertsList" style="font-family:var(--mono);font-size:8px;color:var(--muted);line-height:1.7;">β€”</div></div>
</div>
</div>
</div>
<footer class="footer">
<span>OpenEnv Β· Cluster SRE Β· Distributed Triage</span>
<span id="footerInfo" style="color:var(--accent);"></span>
<span>FastAPI + HTML/CSS/JS Β· Port 7860</span>
</footer>
</div>
<script>
'use strict';
/* ════════════════════════════════════════════════════
STATE
════════════════════════════════════════════════════ */
var BASE = window.location.origin;
var selectedAgentId = null;
var selectedTaskId = null;
var selectedTaskMeta = null; /* {id, name, max_steps, ...} */
var agentsData = []; /* from GET /agents */
var rewardHistory = [];
var totalReward = 0;
var stepCount = 0;
var maxSteps = 25;
var episodeDone = false;
var isRunning = false;
/* Task briefings β€” kept client-side for instant display */
var TASK_DESCS = {
easy:
'🟒 EASY β€” The Stuck Job\n\n' +
'A single rogue MapReduce job is stuck in an infinite loop,\nconsuming all RAM on worker_01.\n\n' +
'Goal: Kill the rogue job (job_rogue_99).\nExpected Steps: 1',
medium:
'🟑 MEDIUM β€” The Full Disk\n\n' +
'Worker node worker_03 has hit 99.9% disk capacity.\nAll writes are failing.\n\n' +
'Goal: Clear the storage, then restart the node safely.\nExpected Steps: 2',
hard:
'🟠 HARD β€” The Cascade Failure\n\n' +
'job_rogue_99 caused a HDFS replication storm,\ncrashing Nodes 1 & 2. Failover traffic is\noverloading Nodes 3 & 4.\n\n' +
'Goal: Kill root job β†’ clear worker_01 & worker_02 β†’ restart both.\nExpected Steps: 5',
very_hard:
'πŸ”΄ VERY HARD β€” Multi-Vector Attack\n\n' +
'Two simultaneous malware jobs:\n β€’ disk-filling log spammer (worker_01/02)\n β€’ RAM-hogging crypto miner (worker_03/04)\n\n' +
'Goal: Kill BOTH jobs β†’ clear dead nodes β†’ reboot them.\nExpected Steps: 6',
nightmare:
'☠️ NIGHTMARE β€” The Hydra Protocol\n\n' +
'Total cluster collapse. All 4 nodes offline.\nTHREE self-replicating malware jobs are writing\ngarbage data to every disk simultaneously.\n\n' +
'⚠️ If ANY Hydra survives, it will instantly refill cleared storage!\n\n' +
'Goal: Kill ALL 3 Hydras β†’ clear all 4 nodes β†’ restart all 4.\nExpected Steps: 11',
partition_basic:
'🟒 BASIC β€” The Partition\n\n' +
'DC1↔DC2 primary link severed. Backup subnets congested.\n' +
'3 agents: Orchestrator β†’ NetOps β†’ Orchestrator\n\n' +
'Goal: Establish bypass routing and verify connectivity.\nExpected Steps: ~5',
replication_storm:
'🟑 STORM β€” Replication Storm\n\n' +
'Network partition + HDFS replication storm consuming 100% I/O.\n' +
'3 agents must coordinate: fix network FIRST, then kill storm.\n\n' +
'Goal: Restore network β†’ halt replication storm.\nExpected Steps: ~10',
split_brain:
'πŸ”΄ SPLIT β€” The Split-Brain Collapse\n\n' +
'CRITICAL MULTI-SYSTEM FAILURE:\n β€’ Network partition (primary link severed)\n β€’ HDFS replication storm (100% I/O)\n β€’ NewSQL split-brain (dual leaders, 15 conflicts)\n\n' +
'⚠️ Reconciling before routing verification causes\nDELAYED CORRUPTION 10 steps later!\n\n' +
'Goal: Fix network β†’ kill storm β†’ stepdown β†’ reconcile.\nExpected Steps: ~12',
};
/* ════════════════════════════════════════════════════
API FETCHERL
════════════════════════════════════════════════════ */
function apiFetch(path, method, body) {
var opts = { method: method, headers: { 'Content-Type': 'application/json' }, cache: 'no-store' };
if (body !== null && body !== undefined) opts.body = JSON.stringify(body);
return fetch(BASE + path, opts)
.then(function (r) {
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('json') < 0) return r.text().then(function (t) { return { ok: false, data: null, err: 'HTTP ' + r.status }; });
return r.json().then(function (d) {
if (!r.ok) return { ok: false, data: null, err: (d && d.detail) ? String(d.detail) : 'HTTP ' + r.status };
return { ok: true, data: d, err: null };
});
})
.catch(function (e) { return { ok: false, data: null, err: e.message }; });
}
/* ════════════════════════════════════════════════════
TERMINAL
════════════════════════════════════════════════════ */
function tlog(cls, txt) {
var el = document.getElementById('terminal');
var d = document.createElement('div');
d.className = cls;
d.textContent = txt;
el.appendChild(d);
el.scrollTop = el.scrollHeight;
}
function clearTerminal() {
document.getElementById('terminal').innerHTML = '<div class="t-sys">// Terminal cleared</div>';
}
/* ════════════════════════════════════════════════════
SIDEBAR β€” AGENT β†’ TASK HIERARCHY
──────────────────────────────────────────────────
Fetches GET /agents and builds the collapsible
sidebar. Each agent expands to show its tasks.
EXTENSIBILITY: new agents in the registry
auto-appear here with zero HTML changes.
════════════════════════════════════════════════════ */
function loadAgents() {
apiFetch('/agents', 'GET', null).then(function (res) {
var container = document.getElementById('sidebarAgents');
if (!res.ok) {
container.innerHTML = '<div style="font-family:var(--mono);font-size:10px;color:var(--red);">Failed: ' + res.err + '</div>';
return;
}
agentsData = res.data;
container.innerHTML = '';
agentsData.forEach(function (agent, idx) {
var group = document.createElement('div');
group.className = 'agent-group';
group.id = 'ag_' + agent.id;
/* Agent header (clickable, expands task list) */
var header = document.createElement('div');
header.className = 'agent-header';
header.id = 'ah_' + agent.id;
header.innerHTML =
'<span class="agent-icon">' + agent.icon + '</span>' +
'<div class="agent-header-text">' +
'<div class="agent-name">' + agent.name + '</div>' +
'<div class="agent-desc-mini">' + agent.description.slice(0, 50) + '</div>' +
'</div>' +
'<span class="agent-chevron">β–Έ</span>';
header.onclick = function () { toggleAgent(agent.id); };
/* Task list (hidden by default) */
var taskList = document.createElement('div');
taskList.className = 'task-list';
taskList.id = 'tl_' + agent.id;
var inner = document.createElement('div');
inner.className = 'task-list-inner';
agent.tasks.forEach(function (task) {
var item = document.createElement('div');
item.className = 'task-item';
item.id = 'ti_' + agent.id + '_' + task.id;
item.innerHTML =
'<span class="task-dot" style="background:' + task.color + ';"></span>' +
'<span class="task-item-name">' + task.name + '</span>' +
'<span class="task-item-diff" style="color:' + (task.label_color || task.color) + ';background:' + (task.label_color || task.color) + '18;">' + task.label + '</span>';
item.onclick = function (e) {
e.stopPropagation();
selectTask(agent.id, task);
};
inner.appendChild(item);
});
taskList.appendChild(inner);
group.appendChild(header);
group.appendChild(taskList);
container.appendChild(group);
});
/* Auto-expand the first agent after all agents are added to the DOM */
if (agentsData.length > 0) {
toggleAgent(agentsData[0].id);
}
/* "Add Agent" placeholder */
var addSlot = document.createElement('div');
addSlot.className = 'add-agent-slot';
addSlot.innerHTML = '<span class="add-agent-icon">οΌ‹</span><div class="add-agent-text">Add Agent<br>agents/__init__.py</div>';
container.appendChild(addSlot);
document.getElementById('agentCountTag').textContent = agentsData.length + ' agent' + (agentsData.length !== 1 ? 's' : '');
});
}
function toggleAgent(agentId) {
/* Collapse all other agents, expand this one */
agentsData.forEach(function (a) {
var h = document.getElementById('ah_' + a.id);
var tl = document.getElementById('tl_' + a.id);
if (a.id === agentId) {
var isOpen = tl.classList.contains('open');
if (!isOpen) {
h.classList.add('active');
tl.classList.add('open');
selectedAgentId = agentId;
} else {
h.classList.remove('active');
tl.classList.remove('open');
}
} else {
h.classList.remove('active');
tl.classList.remove('open');
}
});
}
function selectTask(agentId, task) {
/* Make sure this agent is expanded */
selectedAgentId = agentId;
selectedTaskId = task.id;
selectedTaskMeta = task;
maxSteps = task.max_steps;
/* Highlight in sidebar */
document.querySelectorAll('.task-item').forEach(function (ti) { ti.classList.remove('selected'); });
var el = document.getElementById('ti_' + agentId + '_' + task.id);
if (el) el.classList.add('selected');
/* Expand parent agent header */
var h = document.getElementById('ah_' + agentId);
var tl = document.getElementById('tl_' + agentId);
if (h) h.classList.add('active');
if (tl) tl.classList.add('open');
/* Update briefing */
document.getElementById('briefingText').textContent = TASK_DESCS[task.id] || task.name;
document.getElementById('stepCounterScore').textContent = '0 / ' + task.max_steps + ' steps';
/* Find agent name for footer */
var agent = agentsData.find(function (a) { return a.id === agentId; });
document.getElementById('footerInfo').textContent = (agent ? agent.name : agentId) + ' Β· ' + task.name;
tlog('t-sys', '// Selected: ' + (agent ? agent.name : agentId) + ' β†’ ' + task.name + ' [' + task.label + ']');
}
/* ════════════════════════════════════════════════════
NODE GRID
════════════════════════════════════════════════════ */
function renderNodes(nodes) {
var grid = document.getElementById('nodeGrid');
grid.innerHTML = '';
var healthy = 0;
nodes.forEach(function (n) {
if (n.status === 'healthy') healthy++;
var dc = n.disk_usage > 85 ? '#ef4444' : n.disk_usage > 60 ? '#eab308' : '#10b981';
var rc = n.ram_usage > 85 ? '#ef4444' : n.ram_usage > 60 ? '#eab308' : '#10b981';
var c = document.createElement('div');
c.className = 'node-card ' + n.status + ' fade-in';
c.innerHTML =
'<div class="node-id">' + n.node_id + '</div>' +
'<div class="node-status ' + n.status + '">' + n.status.replace('_', ' ') + '</div>' +
'<div class="node-metrics">' +
'<span>Disk <b style="color:' + dc + '">' + n.disk_usage.toFixed(1) + '%</b></span>' +
'<span>RAM <b style="color:' + rc + '">' + n.ram_usage.toFixed(1) + '%</b></span>' +
'</div>' +
'<div class="metric-bar"><div class="metric-bar-fill" style="width:' + Math.min(n.disk_usage, 100) + '%;background:' + dc + '"></div></div>' +
'<div class="metric-bar" style="margin-top:2px"><div class="metric-bar-fill" style="width:' + Math.min(n.ram_usage, 100) + '%;background:' + rc + '"></div></div>';
grid.appendChild(c);
});
document.getElementById('nodeSub').textContent = nodes.length + ' nodes Β· ' + healthy + ' healthy Β· ' + (nodes.length - healthy) + ' degraded';
}
/* ════════════════════════════════════════════════════
SCORE
════════════════════════════════════════════════════ */
function updateScore(reward, health) {
totalReward += reward;
var el = document.getElementById('scoreReward');
el.textContent = (reward >= 0 ? '+' : '') + reward.toFixed(3);
el.style.color = reward > 0 ? '#10b981' : reward < 0 ? '#ef4444' : '#71717a';
document.getElementById('scoreHealth').textContent = health.toFixed(2);
document.getElementById('healthTag').textContent = 'Health: ' + (health * 100).toFixed(0) + '%';
document.getElementById('scoreTotal').textContent = totalReward.toFixed(3);
document.getElementById('stepCounter').textContent = 'Step ' + stepCount;
document.getElementById('stepCounterScore').textContent = stepCount + ' / ' + maxSteps + ' steps';
rewardHistory.push(reward);
renderRewardBars();
}
function renderRewardBars() {
var c = document.getElementById('rewardBars');
var d = rewardHistory.slice(-12);
if (!d.length) return;
c.innerHTML = '';
var s = 0;
d.forEach(function (r) {
var b = document.createElement('div');
b.className = 'rbar ' + (r >= 0 ? 'pos' : 'neg');
b.style.height = Math.max(4, Math.round(Math.abs(r) * 100)) + '%';
c.appendChild(b); s += r;
});
document.getElementById('avgRwd').textContent = 'avg ' + (s / d.length).toFixed(3);
}
/* ════════════════════════════════════════════════════
JOBS + ALERTS
════════════════════════════════════════════════════ */
function renderObs(obs) {
var j = document.getElementById('jobsList');
if (!obs.active_jobs || !obs.active_jobs.length) {
j.innerHTML = '<span style="color:var(--green);">No active jobs βœ“</span>';
} else {
j.innerHTML = obs.active_jobs.map(function (x) {
var cl = x.status === 'hanging' ? 'var(--red)' : 'var(--yellow)';
return '<div style="color:' + cl + ';margin-bottom:2px;">⚠ ' + x.job_id + ' <span style="color:var(--muted);">[' + x.status + ' · ' + x.ram_allocated + 'GB]</span></div>';
}).join('');
}
var a = document.getElementById('alertsList');
if (!obs.recent_alerts || !obs.recent_alerts.length) {
a.textContent = 'None';
} else {
a.innerHTML = obs.recent_alerts.map(function (x) {
var cl = /^(FATAL|CRITICAL)/.test(x) ? 'var(--red)' : /^(WARN)/.test(x) ? 'var(--yellow)' : /^(GOOD|SUCCESS)/.test(x) ? 'var(--green)' : 'var(--subtle)';
return '<div style="color:' + cl + ';margin-bottom:1px;">' + x + '</div>';
}).join('');
}
}
/* ════════════════════════════════════════════════════
RESET
════════════════════════════════════════════════════ */
function doReset() {
if (!selectedAgentId || !selectedTaskId) {
tlog('t-warn', '[WARN] Select an agent and a task first.');
return;
}
rewardHistory = []; totalReward = 0; stepCount = 0; episodeDone = false;
document.getElementById('scoreReward').textContent = '+0.000';
document.getElementById('scoreReward').style.color = 'var(--muted)';
document.getElementById('scoreHealth').textContent = 'β€”';
document.getElementById('scoreTotal').textContent = '0.000';
document.getElementById('stepCounter').textContent = 'Step 0';
document.getElementById('stepCounterScore').textContent = '0 / ' + maxSteps + ' steps';
document.getElementById('avgRwd').textContent = 'avg β€”';
document.getElementById('rewardBars').innerHTML = '<div style="font-family:var(--mono);font-size:8px;color:var(--muted);margin:auto;">No steps yet</div>';
document.getElementById('doneTag').style.display = 'none';
document.getElementById('healthTag').textContent = 'Health: β€”';
document.getElementById('btnStep').disabled = false;
tlog('t-reset', '═'.repeat(50));
tlog('t-reset', ' πŸ”„ RESET β€” ' + selectedTaskId.toUpperCase().replace('_', ' '));
tlog('t-reset', '═'.repeat(50));
apiFetch('/reset', 'POST', { agent_id: selectedAgentId, task: selectedTaskId }).then(function (res) {
if (!res.ok) { tlog('t-err', '[ERROR] ' + res.err); return; }
var obs = res.data;
renderNodes(obs.nodes || []);
renderObs(obs);
document.getElementById('scoreHealth').textContent = obs.health_score.toFixed(2);
document.getElementById('healthTag').textContent = 'Health: ' + (obs.health_score * 100).toFixed(0) + '%';
tlog('t-ok', '[READY] Health: ' + obs.health_score.toFixed(2) + ' | Jobs: ' +
(obs.active_jobs.map(function (j) { return j.job_id; }).join(', ') || 'none'));
tlog('t-sys', '// Click β–Ά Agent Step to run the LLM.');
});
}
/* ════════════════════════════════════════════════════
STATE
════════════════════════════════════════════════════ */
function doState() {
apiFetch('/state', 'GET', null).then(function (res) {
if (!res.ok) { tlog('t-err', '[ERROR] ' + res.err); return; }
var obs = res.data;
tlog('t-info', '--- πŸ“Š STATE (step ' + stepCount + ') ---');
tlog('t-info', 'Health: ' + obs.health_score.toFixed(2) + ' | ' +
obs.nodes.map(function (n) { return n.node_id + '(' + n.status + ')'; }).join(', '));
renderNodes(obs.nodes);
renderObs(obs);
});
}
/* ════════════════════════════════════════════════════
AGENT STEP
════════════════════════════════════════════════════ */
function doAgentStep() {
if (isRunning || episodeDone) return;
if (!selectedAgentId || !selectedTaskId) {
tlog('t-warn', '[WARN] Select agent + task and Reset first.');
return;
}
isRunning = true;
setStepUI(true);
apiFetch('/agent/step', 'POST', { agent_id: selectedAgentId, task: selectedTaskId }).then(function (res) {
isRunning = false;
setStepUI(false);
if (!res.ok) { tlog('t-err', '[API ERROR] ' + res.err); return; }
var d = res.data;
stepCount = d.step;
var reward = d.reward;
var done = d.done;
var msg = d.message || '';
var action = d.action || {};
var obs = d.observation;
var actStr = (action.action_type || '?') + ' β†’ ' + (action.target_id || '?');
var rCls = reward > 0 ? 't-rpos' : reward < 0 ? 't-rneg' : 't-sys';
tlog('t-step', 'Step ' + String(stepCount).padStart(2, ' ') + ' | ' + actStr);
tlog(rCls, ' | reward=' + (reward >= 0 ? '+' : '') + reward.toFixed(3) +
' | health=' + obs.health_score.toFixed(2) + ' | ' + msg);
updateScore(reward, obs.health_score);
renderNodes(obs.nodes);
renderObs(obs);
if (done) {
episodeDone = true;
var win = obs.health_score >= 1.0;
tlog('t-reset', '═'.repeat(50));
tlog(win ? 't-ok' : 't-err',
' ' + (win ? 'βœ… SYSTEM NOMINAL β€” INCIDENT RESOLVED' : '❌ EPISODE ENDED β€” CLUSTER FAILED'));
tlog('t-reset', '═'.repeat(50));
document.getElementById('doneTag').style.display = 'inline';
document.getElementById('btnStep').disabled = true;
}
});
}
function setStepUI(on) {
document.getElementById('stepSpinner').className = 'spinner' + (on ? ' on' : '');
document.getElementById('termSpinner').className = 'spinner' + (on ? ' on' : '');
document.getElementById('llmTag').style.display = on ? 'inline' : 'none';
document.getElementById('stepBtnTxt').textContent = on ? 'Thinking...' : 'β–Ά Agent Step';
document.getElementById('btnStep').disabled = on;
}
/* ════════════════════════════════════════════════════
API EXPLORER
════════════════════════════════════════════════════ */
function apiTest(path, method, body) {
var out = document.getElementById('apiOutput');
out.textContent = '// ' + method + ' ' + path + ' ...';
apiFetch(path, method, body || null).then(function (res) {
out.textContent = res.ok ? JSON.stringify(res.data, null, 2).slice(0, 800) : '// ERROR: ' + res.err;
});
}
function apiTestTasks() {
apiTest('/tasks?agent_id=' + (selectedAgentId || 'split_brain'), 'GET');
}
function apiTestReset() {
apiTest('/reset', 'POST', { agent_id: selectedAgentId || 'split_brain', task: selectedTaskId || 'partition_basic' });
}
function apiTestAgentStep() {
apiTest('/agent/step', 'POST', { agent_id: selectedAgentId || 'split_brain', task: selectedTaskId || 'partition_basic' });
}
/* ════════════════════════════════════════════════════
INIT
════════════════════════════════════════════════════ */
function init() {
document.getElementById('apiBaseUrl').textContent = BASE;
apiFetch('/health', 'GET', null).then(function (res) {
var dot = document.getElementById('statusDot');
var txt = document.getElementById('statusText');
if (res.ok) {
dot.className = 'status-dot live';
txt.textContent = 'Online Β· ' + (res.data.env || 'cluster-triage');
document.getElementById('versionTag').textContent = 'v' + (res.data.version || '1.0.0');
} else {
dot.className = 'status-dot err';
txt.textContent = 'Offline';
}
});
loadAgents();
}
init();
/* ════════════════════════════════════════════════════
SPLIT-BRAIN WAR ROOM
════════════════════════════════════════════════════ */
var cyInstance = null;
var sbTotalReward = 0;
var ACTOR_INFO = {
orchestrator: { icon: '🎯', name: 'Orchestrator', cls: 'wr-orch' },
netops: { icon: '🌐', name: 'NetOps', cls: 'wr-netops' },
dataops: { icon: 'πŸ’Ύ', name: 'DataOps', cls: 'wr-dataops' },
system: { icon: '⚑', name: 'System', cls: 'wr-system' },
};
function isSplitBrain() { return selectedAgentId === 'split_brain'; }
function toggleWarRoom(show) {
var wr = document.getElementById('warroom');
var mg = document.querySelector('.main-grid');
var center = document.getElementById('centerPanel');
var score = document.getElementById('scoreCard');
if (show) {
wr.classList.add('active');
mg.style.gridTemplateColumns = '220px 1fr';
if (center) center.style.display = 'none';
if (score) score.style.display = 'none';
} else {
wr.classList.remove('active');
mg.style.gridTemplateColumns = '';
if (center) center.style.display = '';
if (score) score.style.display = '';
}
}
function wrAddMsg(actor, text) {
var info = ACTOR_INFO[actor] || ACTOR_INFO.system;
var chat = document.getElementById('wrChat');
var div = document.createElement('div');
div.className = 'wr-msg ' + info.cls;
div.innerHTML = '<div class="wr-msg-avatar">' + info.icon + '</div>' +
'<div class="wr-msg-body"><div class="wr-msg-name">' + info.name + '</div>' +
'<div class="wr-msg-text">' + text.replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</div></div>';
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
}
function updateActorBadge(actor) {
var badge = document.getElementById('actorBadge');
var info = ACTOR_INFO[actor] || ACTOR_INFO.orchestrator;
badge.className = 'actor-badge ' + actor;
badge.textContent = info.icon + ' ' + info.name;
}
/* ── Cytoscape.js Topology ──────────────────────── */
function initCytoscape(nodes, edges) {
var elements = [];
nodes.forEach(function(n) {
var x = 150; // default for dc1
if (n.datacenter === 'dc2') x = 450;
if (n.datacenter === 'dc3') x = 750;
var yMap = { router: 50, switch: 180, server_a: 310, server_b: 310 };
var subtype = n.node_id.replace(n.datacenter + '_', '');
var y = yMap[subtype] || 200;
if (subtype === 'server_a') x -= 60;
if (subtype === 'server_b') x += 60;
elements.push({ data: { id: n.node_id, label: n.node_id.replace('_', '\n'), status: n.status, dc: n.datacenter }, position: { x: x, y: y } });
});
edges.forEach(function(e) {
elements.push({ data: { id: e.edge_id, source: e.source, target: e.target, status: e.status } });
});
if (cyInstance) cyInstance.destroy();
cyInstance = cytoscape({
container: document.getElementById('cyGraph'),
elements: elements,
style: [
{ selector: 'node', style: { 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'font-size': '8px', 'font-family': 'IBM Plex Mono', 'color': '#e4e4e7', 'text-wrap': 'wrap', 'width': 50, 'height': 50, 'border-width': 2, 'background-color': '#131316', 'border-color': '#10b981' } },
{ selector: 'node[status="offline"]', style: { 'border-color': '#ef4444', 'background-color': '#1a0505' } },
{ selector: 'node[status="degraded"]', style: { 'border-color': '#eab308' } },
{ selector: 'node[status="partitioned"]', style: { 'border-color': '#ef4444', 'border-style': 'dashed' } },
{ selector: 'edge', style: { 'width': 3, 'line-color': '#10b981', 'curve-style': 'bezier', 'opacity': 0.8 } },
{ selector: 'edge[status="severed"]', style: { 'line-color': '#ef4444', 'line-style': 'dashed', 'opacity': 0.4, 'width': 2 } },
{ selector: 'edge[status="congested"]', style: { 'line-color': '#eab308', 'width': 4, 'opacity': 0.7 } },
{ selector: 'edge[status="bypass"]', style: { 'line-color': '#22d3ee', 'width': 3, 'line-style': 'dotted' } },
{ selector: 'edge[status="offline"]', style: { 'line-color': '#3f3f46', 'opacity': 0.3 } },
],
layout: { name: 'preset' },
userZoomingEnabled: false, userPanningEnabled: false, boxSelectionEnabled: false,
});
}
function updateCytoscape(nodes, edges) {
if (!cyInstance) return;
nodes.forEach(function(n) {
var el = cyInstance.getElementById(n.node_id);
if (el.length) el.data('status', n.status);
});
edges.forEach(function(e) {
var el = cyInstance.getElementById(e.edge_id);
if (el.length) el.data('status', e.status);
});
}
/* ── Telemetry Update ────────────────────────────── */
function gColor(v, inv) { var p = inv ? (100-v) : v; return p > 70 ? '#10b981' : p > 40 ? '#eab308' : '#ef4444'; }
function updateTelemetry(obs, reward) {
var h = (obs.global_health * 100);
document.getElementById('tmHealth').textContent = h.toFixed(0) + '%';
document.getElementById('tmHealth').style.color = gColor(h, false);
document.getElementById('tmHealthBar').style.width = h + '%';
document.getElementById('tmHealthBar').style.background = gColor(h, false);
var io = obs.hdfs.io_bandwidth_used_pct;
document.getElementById('tmIO').textContent = io.toFixed(0) + '%';
document.getElementById('tmIO').style.color = gColor(io, true);
document.getElementById('tmIOBar').style.width = io + '%';
document.getElementById('tmIOBar').style.background = gColor(io, true);
document.getElementById('tmStorm').textContent = obs.hdfs.replication_storm_active ? '⚠ REPLICATION STORM' : 'βœ“ Stable';
document.getElementById('tmStorm').style.color = obs.hdfs.replication_storm_active ? '#ef4444' : '#10b981';
var drift = obs.newsql.conflicting_entries;
var dPct = Math.min(100, drift * 5);
document.getElementById('tmDrift').textContent = drift;
document.getElementById('tmDrift').style.color = drift > 0 ? '#ef4444' : '#10b981';
document.getElementById('tmDriftBar').style.width = dPct + '%';
document.getElementById('tmDriftBar').style.background = drift > 0 ? '#ef4444' : '#10b981';
document.getElementById('tmBrain').textContent = obs.newsql.split_brain_active ? '⚠ SPLIT-BRAIN ACTIVE' : 'βœ“ Single leader';
document.getElementById('tmBrain').style.color = obs.newsql.split_brain_active ? '#ef4444' : '#10b981';
var ns = obs.network_status;
var nsCol = ns === 'healthy' ? '#10b981' : ns === 'degraded' ? '#eab308' : '#ef4444';
document.getElementById('tmNet').textContent = ns.toUpperCase();
document.getElementById('tmNet').style.color = nsCol;
document.getElementById('tmRouting').textContent = 'Routing: ' + (obs.routing_verified ? 'verified βœ“' : 'unverified');
document.getElementById('tmRouting').style.color = obs.routing_verified ? '#10b981' : '#ef4444';
var redis = obs.redis_cache_usage || 0;
var rColor = redis >= 100 ? '#ef4444' : redis > 50 ? '#eab308' : '#10b981';
document.getElementById('tmRedis').textContent = redis.toFixed(0) + '%';
document.getElementById('tmRedis').style.color = rColor;
document.getElementById('tmRedisBar').style.width = redis + '%';
document.getElementById('tmRedisBar').style.background = rColor;
var authStr = obs.auth_status || 'online';
document.getElementById('tmAuth').textContent = 'Auth: ' + authStr;
document.getElementById('tmAuth').style.color = authStr === 'offline' ? '#ef4444' : '#10b981';
sbTotalReward += reward;
document.getElementById('tmReward').textContent = (reward >= 0 ? '+' : '') + reward.toFixed(2);
document.getElementById('tmReward').style.color = reward > 0 ? '#10b981' : reward < 0 ? '#ef4444' : 'var(--muted)';
document.getElementById('tmTotal').textContent = sbTotalReward.toFixed(2);
document.getElementById('tmTotal').style.color = sbTotalReward >= 0 ? '#10b981' : '#ef4444';
document.getElementById('topoStatus').textContent = ns + ' | step ' + obs.step;
}
/* ── Override doReset for split-brain ──────────── */
var _origReset = doReset;
doReset = function() {
if (!isSplitBrain()) { toggleWarRoom(false); _origReset(); return; }
if (!selectedTaskId) { tlog('t-warn','[WARN] Select a task first.'); return; }
toggleWarRoom(true);
rewardHistory = []; totalReward = 0; stepCount = 0; episodeDone = false; sbTotalReward = 0;
document.getElementById('wrChat').innerHTML = '';
document.getElementById('btnStep').disabled = false;
document.getElementById('doneTag').style.display = 'none';
document.getElementById('scoreReward').textContent = '+0.000';
document.getElementById('scoreReward').style.color = 'var(--muted)';
document.getElementById('scoreHealth').textContent = 'β€”';
document.getElementById('scoreTotal').textContent = '0.000';
document.getElementById('stepCounter').textContent = 'Step 0';
document.getElementById('stepCounterScore').textContent = '0 / ' + maxSteps + ' steps';
document.getElementById('rewardBars').innerHTML = '<div style="font-family:var(--mono);font-size:8px;color:var(--muted);margin:auto;">No steps yet</div>';
wrAddMsg('system', 'πŸ”„ INCIDENT RESET β€” ' + selectedTaskId.toUpperCase().replace('_',' '));
tlog('t-reset', '═'.repeat(50));
tlog('t-reset', ' 🧠 SPLIT-BRAIN β€” ' + selectedTaskId.toUpperCase().replace('_',' '));
tlog('t-reset', '═'.repeat(50));
apiFetch('/reset','POST',{agent_id:'split_brain',task:selectedTaskId}).then(function(res){
if (!res.ok) { tlog('t-err','[ERROR] ' + res.err); return; }
var obs = res.data;
initCytoscape(obs.network_nodes, obs.network_edges);
updateTelemetry(obs, 0);
updateActorBadge(obs.current_actor);
document.getElementById('briefingText').textContent = TASK_DESCS[selectedTaskId] || selectedTaskId;
wrAddMsg('system', 'Health: ' + (obs.global_health*100).toFixed(0) + '% | Actor: ' + obs.current_actor);
obs.recent_events.forEach(function(e) { wrAddMsg('system', e); });
tlog('t-ok','[READY] Health: ' + obs.global_health.toFixed(2) + ' | Actor: ' + obs.current_actor);
});
};
/* ── Override doAgentStep for split-brain ──────── */
var _origStep = doAgentStep;
doAgentStep = function() {
if (!isSplitBrain()) { _origStep(); return; }
if (isRunning || episodeDone) return;
if (!selectedTaskId) { tlog('t-warn','[WARN] Select task + Reset first.'); return; }
isRunning = true; setStepUI(true);
apiFetch('/agent/step','POST',{agent_id:'split_brain',task:selectedTaskId}).then(function(res){
isRunning = false; setStepUI(false);
if (!res.ok) { tlog('t-err','[API ERROR] ' + res.err); return; }
var d = res.data;
stepCount = d.step;
var obs = d.observation;
var action = d.action || {};
var actStr = action.action_type + (action.target_id ? ' β†’ ' + action.target_id : '') + (action.target_agent ? ' β†’ ' + action.target_agent : '');
var actor = d.current_actor || obs.current_actor || 'orchestrator';
var prevActor = action.action_type === 'delegate' ? (action.target_agent === actor ? (action.action_type) : actor) : actor;
wrAddMsg(prevActor === actor ? actor : 'system', 'Step ' + d.step + ': ' + actStr);
wrAddMsg(prevActor === actor ? actor : 'system', d.message);
if (action.action_type === 'delegate' && action.instruction_payload) {
wrAddMsg(actor, action.instruction_payload);
}
updateActorBadge(actor);
updateCytoscape(obs.network_nodes, obs.network_edges);
updateTelemetry(obs, d.reward);
updateScore(d.reward, obs.global_health);
var rCls = d.reward > 0 ? 't-rpos' : d.reward < 0 ? 't-rneg' : 't-sys';
tlog('t-step','Step ' + d.step + ' [' + actor + '] ' + actStr);
tlog(rCls, ' reward=' + (d.reward>=0?'+':'') + d.reward.toFixed(3) + ' | ' + d.message);
if (d.done) {
episodeDone = true;
var win = obs.global_health >= 1.0;
wrAddMsg('system', win ? 'βœ… INCIDENT RESOLVED β€” ALL SYSTEMS NOMINAL' : '❌ EPISODE ENDED β€” INCIDENT UNRESOLVED');
tlog('t-reset','═'.repeat(50));
tlog(win?'t-ok':'t-err', ' ' + (win ? 'βœ… INCIDENT RESOLVED' : '❌ EPISODE FAILED'));
tlog('t-reset','═'.repeat(50));
document.getElementById('doneTag').style.display = 'inline';
document.getElementById('btnStep').disabled = true;
}
});
};
/* ── Override selectTask to toggle war room ──── */
var _origSelectTask = selectTask;
selectTask = function(agentId, task) {
_origSelectTask(agentId, task);
if (agentId === 'split_brain') { toggleWarRoom(true); }
else { toggleWarRoom(false); }
};
</script>
</body>
</html>