sentinel / server /app.py
HARSH SHUKLA
Polish Space dashboard UI
adaa97b
Raw
History Blame Contribute Delete
26.6 kB
"""FastAPI entrypoint for the OpenEnv validator and Hugging Face Space runtime."""
from __future__ import annotations
from fastapi.responses import HTMLResponse
from sentinel.api.server import app
SPACE_URL = "https://harry1911-sentinel.hf.space"
SPACE_REPO_URL = "https://huggingface.co/spaces/harry1911/sentinel"
GITHUB_URL = "https://github.com/sayantikalaskar/sentinel"
@app.get("/", include_in_schema=False)
async def root() -> HTMLResponse:
"""Render the public Space landing and live evaluation dashboard."""
return HTMLResponse(_dashboard_html())
@app.get("/dashboard", include_in_schema=False)
async def dashboard() -> HTMLResponse:
"""Render the live evaluation dashboard at the advertised URL."""
return HTMLResponse(_dashboard_html())
def _dashboard_html() -> str:
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SENTINEL - Live Incident Response Environment</title>
<style>
:root {{
color-scheme: dark;
--bg: #07111f;
--panel: #101b2d;
--panel-2: #14243a;
--panel-3: #0b1627;
--line: rgba(174, 190, 214, 0.22);
--text: #eef4ff;
--muted: #a9b8ce;
--cyan: #22d3ee;
--green: #34d399;
--amber: #fbbf24;
--rose: #fb7185;
--blue: #60a5fa;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
linear-gradient(rgba(96, 165, 250, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.05) 1px, transparent 1px),
radial-gradient(circle at 18% 0%, rgba(34, 211, 238, 0.18), transparent 31rem),
radial-gradient(circle at 86% 8%, rgba(52, 211, 153, 0.13), transparent 30rem),
radial-gradient(circle at 55% 100%, rgba(251, 113, 133, 0.08), transparent 34rem),
linear-gradient(145deg, #07111f 0%, #0f172a 50%, #111827 100%);
background-size: 42px 42px, 42px 42px, auto, auto, auto, auto;
color: var(--text);
overflow-x: hidden;
}}
body::before {{
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(105deg, transparent 0%, rgba(34, 211, 238, 0.09) 48%, transparent 56%),
repeating-linear-gradient(180deg, rgba(255,255,255,0.028) 0 1px, transparent 1px 6px);
mix-blend-mode: screen;
opacity: .48;
animation: sweep 11s linear infinite;
}}
@keyframes sweep {{
0% {{ transform: translateX(-35%); }}
100% {{ transform: translateX(35%); }}
}}
a {{ color: inherit; text-decoration: none; }}
button, input, select {{ font: inherit; }}
.wrap {{ max-width: 1240px; margin: 0 auto; padding: 24px 18px 42px; }}
.nav {{
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding: 10px;
border: 1px solid rgba(174, 190, 214, 0.16);
border-radius: 8px;
background: rgba(7, 17, 31, 0.72);
backdrop-filter: blur(18px);
}}
.brand {{ display: flex; align-items: center; gap: 12px; font-weight: 900; }}
.mark {{ width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; background: linear-gradient(135deg, var(--cyan), var(--green)); color: #04111f; font-weight: 900; }}
.links {{ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }}
.pill, .btn {{
border: 1px solid var(--line);
background: rgba(15, 23, 42, 0.78);
color: var(--muted);
padding: 9px 12px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: transform .16s ease, border-color .16s ease, color .16s ease, background .16s ease, box-shadow .16s ease;
}}
.pill:hover, .btn:hover {{
transform: translateY(-1px);
color: var(--text);
border-color: rgba(34, 211, 238, 0.55);
box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.13), 0 14px 38px rgba(0, 0, 0, 0.24);
}}
.pill.primary, .btn.primary {{ background: var(--green); color: #05111d; border-color: transparent; font-weight: 850; }}
.btn.cyan {{ background: var(--cyan); color: #05111d; border-color: transparent; font-weight: 850; }}
.btn.rose {{ background: rgba(251, 113, 133, 0.16); color: #fecdd3; border-color: rgba(251, 113, 133, 0.36); }}
.hero {{ display: grid; grid-template-columns: minmax(0, 1fr) minmax(380px, 0.92fr); gap: 18px; align-items: stretch; }}
.hero-copy, .panel {{
position: relative;
overflow: hidden;
border: 1px solid var(--line);
background:
linear-gradient(145deg, rgba(16, 27, 45, 0.9), rgba(9, 18, 32, 0.78)),
linear-gradient(90deg, rgba(34, 211, 238, 0.08), rgba(52, 211, 153, 0.04));
border-radius: 8px;
padding: 22px;
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.045);
}}
.hero-copy::before, .panel::before {{
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(34, 211, 238, .68), rgba(52, 211, 153, .16), rgba(251, 191, 36, .2), rgba(251, 113, 133, .32));
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
}}
.hero-copy > *, .panel > * {{ position: relative; z-index: 1; }}
.hero-copy::after {{
content: "LIVE INCIDENT RESPONSE ENVIRONMENT";
position: absolute;
right: -34px;
bottom: 14px;
color: rgba(174, 190, 214, 0.055);
font-size: 44px;
font-weight: 950;
letter-spacing: .08em;
white-space: nowrap;
}}
.hero-copy {{ background: rgba(11, 22, 39, 0.72); }}
.eyebrow {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }}
.tag {{ border: 1px solid var(--line); color: var(--muted); background: rgba(20, 36, 58, 0.82); border-radius: 8px; padding: 7px 9px; font-size: 12px; }}
h1 {{
font-size: clamp(42px, 6.4vw, 88px);
line-height: 0.92;
margin: 0 0 18px;
letter-spacing: 0;
max-width: 920px;
background: linear-gradient(95deg, #f8fbff 0%, #c7f9ff 42%, #b7f7d5 72%, #ffe5a3 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 46px rgba(34, 211, 238, 0.12);
}}
.lead {{ font-size: 18px; line-height: 1.55; color: var(--muted); max-width: 780px; margin: 0 0 20px; }}
.cta {{ display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }}
.stats {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 18px; }}
.stat {{ border-left: 3px solid var(--green); background: rgba(20, 36, 58, 0.7); border-radius: 8px; padding: 12px; box-shadow: inset 0 0 32px rgba(34, 211, 238, 0.035); }}
.stat:nth-child(2) {{ border-left-color: var(--cyan); }}
.stat:nth-child(3) {{ border-left-color: var(--amber); }}
.stat:nth-child(4) {{ border-left-color: var(--rose); }}
.stat strong {{ display: block; font-size: 24px; margin-bottom: 2px; }}
.stat span {{ color: var(--muted); font-size: 12px; line-height: 1.35; }}
.panel h2, .section h2 {{ margin: 0 0 14px; font-size: 21px; }}
.controls {{ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }}
label {{ color: var(--muted); font-size: 12px; display: grid; gap: 6px; }}
input, select, textarea {{
width: 100%;
color: var(--text);
background: rgba(7, 17, 31, 0.92);
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 11px;
outline: none;
}}
textarea {{ min-height: 136px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; line-height: 1.45; }}
.button-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }}
.button-grid .btn {{ min-height: 46px; text-align: left; }}
.section {{ margin-top: 18px; }}
.kpis {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }}
.kpi {{
background: linear-gradient(145deg, rgba(16, 27, 45, 0.86), rgba(8, 17, 31, 0.72));
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
box-shadow: inset 0 0 38px rgba(96, 165, 250, 0.035);
}}
.kpi span {{ display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }}
.kpi strong {{ display: block; margin-top: 5px; font-size: 21px; overflow-wrap: anywhere; }}
.workspace {{ display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(340px, 0.9fr); gap: 18px; margin-top: 18px; }}
.tabs {{ display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }}
.tab.active {{ color: #05111d; background: var(--cyan); border-color: transparent; font-weight: 850; }}
pre {{
margin: 0;
min-height: 360px;
max-height: 560px;
overflow: auto;
background: rgba(5, 10, 20, 0.86);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
color: #d7e3f4;
font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}}
.log {{ min-height: 196px; max-height: 230px; }}
.cards {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }}
.card {{
background: linear-gradient(145deg, rgba(16, 27, 45, 0.86), rgba(7, 17, 31, 0.74));
border: 1px solid var(--line);
border-radius: 8px;
padding: 15px;
min-height: 132px;
transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
}}
.card:hover {{ transform: translateY(-2px); border-color: rgba(52, 211, 153, 0.48); box-shadow: 0 18px 48px rgba(0,0,0,.24); }}
.card h3 {{ margin: 0 0 8px; font-size: 15px; }}
.card p {{ margin: 0 0 12px; color: var(--muted); font-size: 13px; line-height: 1.45; }}
.endpoint {{ color: var(--cyan); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }}
.toast {{ margin-top: 12px; min-height: 22px; color: var(--muted); font-size: 13px; }}
.topology {{
margin-top: 18px;
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 7px;
max-width: 760px;
}}
.node {{
height: 30px;
border-radius: 8px;
border: 1px solid rgba(174, 190, 214, 0.17);
background: linear-gradient(145deg, rgba(34, 211, 238, 0.19), rgba(52, 211, 153, 0.08));
box-shadow: 0 0 22px rgba(34, 211, 238, 0.08), inset 0 0 18px rgba(255,255,255,.025);
}}
.node.hot {{ background: linear-gradient(145deg, rgba(251, 113, 133, 0.42), rgba(251, 191, 36, 0.13)); border-color: rgba(251, 113, 133, 0.45); }}
.node.warn {{ background: linear-gradient(145deg, rgba(251, 191, 36, 0.35), rgba(34, 211, 238, 0.08)); border-color: rgba(251, 191, 36, 0.4); }}
.panel-title {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}}
.panel-title h2 {{ margin: 0; }}
.live-chip {{
display: inline-flex;
align-items: center;
gap: 7px;
color: #b7f7d5;
border: 1px solid rgba(52, 211, 153, .38);
background: rgba(52, 211, 153, .1);
padding: 6px 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 800;
}}
.live-chip::before {{
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 14px var(--green);
}}
@media (max-width: 980px) {{
.hero, .workspace, .cards, .kpis, .stats {{ grid-template-columns: 1fr; }}
.nav {{ align-items: flex-start; flex-direction: column; }}
.links {{ justify-content: flex-start; }}
}}
</style>
</head>
<body>
<main class="wrap">
<nav class="nav">
<a class="brand" href="/"><span class="mark">S</span><span>SENTINEL</span></a>
<div class="links">
<a class="pill primary" href="/docs" target="_blank" rel="noopener noreferrer">API Docs</a>
<a class="pill" href="/health" target="_blank" rel="noopener noreferrer">Health</a>
<a class="pill" href="{SPACE_REPO_URL}" target="_blank" rel="noopener noreferrer">HF Space</a>
<a class="pill" href="{GITHUB_URL}" target="_blank" rel="noopener noreferrer">GitHub</a>
<a class="pill" href="{GITHUB_URL}/blob/main/README.md" target="_blank" rel="noopener noreferrer">README</a>
<a class="pill" href="{GITHUB_URL}/blob/main/Blog.MD" target="_blank" rel="noopener noreferrer">Blog</a>
</div>
</nav>
<section class="hero">
<div class="hero-copy">
<div class="eyebrow">
<span class="tag">Live OpenEnv API</span>
<span class="tag">FastAPI Space runtime</span>
<span class="tag">Judge-ready endpoint surface</span>
</div>
<h1>Multi-agent incident response, live in this Space.</h1>
<p class="lead">Reset a 30-service cloud outage, run investigation and remediation actions, inspect observations, and verify the same HTTP endpoints judges will call from the public Space URL.</p>
<div class="cta">
<button class="btn cyan" onclick="resetEpisode()">Reset Episode</button>
<button class="btn primary" onclick="runSmokeTest()">Run Smoke Test</button>
<a class="btn" href="/docs" target="_blank" rel="noopener noreferrer">Open API Schema</a>
<a class="btn" href="{GITHUB_URL}/tree/main/results" target="_blank" rel="noopener noreferrer">Results</a>
</div>
<div class="stats">
<div class="stat"><strong>30</strong><span>services in NexaStack</span></div>
<div class="stat"><strong>7</strong><span>observation channels</span></div>
<div class="stat"><strong>5</strong><span>agent roles</span></div>
<div class="stat"><strong>4</strong><span>reward axes</span></div>
</div>
<div class="topology" aria-label="NexaStack service topology preview">
<span class="node"></span><span class="node"></span><span class="node warn"></span><span class="node"></span><span class="node"></span>
<span class="node hot"></span><span class="node warn"></span><span class="node"></span><span class="node"></span><span class="node"></span>
<span class="node"></span><span class="node warn"></span><span class="node hot"></span><span class="node warn"></span><span class="node"></span>
<span class="node"></span><span class="node"></span><span class="node"></span><span class="node warn"></span><span class="node"></span>
</div>
</div>
<aside class="panel">
<div class="panel-title"><h2>Live Controls</h2><span class="live-chip">RUNNING</span></div>
<div class="controls">
<label>Seed <input id="seed" type="number" value="42"></label>
<label>Incident
<select id="incident">
<option>E1</option><option>E2</option><option>E3</option>
<option>M1</option><option>M2</option><option>M3</option><option>M4</option>
<option>H1</option><option>H2</option><option>H3</option>
</select>
</label>
</div>
<div class="button-grid">
<button class="btn cyan" onclick="resetEpisode()">Reset</button>
<button class="btn primary" onclick="runSmokeTest()">Smoke Test</button>
<button class="btn" onclick="callPreset('queryLogs')">Holmes Logs</button>
<button class="btn" onclick="callPreset('queryMetrics')">Argus Metrics</button>
<button class="btn" onclick="callPreset('restart')">Forge Restart</button>
<button class="btn" onclick="callPreset('close')">Hermes Close</button>
<button class="btn" onclick="loadRender()">Render</button>
<button class="btn rose" onclick="closeEpisode()">Close API</button>
</div>
<div id="toast" class="toast">Ready. Click Reset Episode to initialize the environment.</div>
</aside>
</section>
<section class="section kpis">
<div class="kpi"><span>Status</span><strong id="kpi-status">Idle</strong></div>
<div class="kpi"><span>Episode</span><strong id="kpi-episode">-</strong></div>
<div class="kpi"><span>Incident</span><strong id="kpi-incident">-</strong></div>
<div class="kpi"><span>Last Reward</span><strong id="kpi-reward">-</strong></div>
</section>
<section class="workspace">
<div class="panel">
<div class="tabs">
<button class="pill tab active" onclick="showTab('observation', this)">Observation</button>
<button class="pill tab" onclick="showTab('step', this)">Step Result</button>
<button class="pill tab" onclick="showTab('render', this)">Render</button>
<button class="pill tab" onclick="showTab('custom', this)">Custom Action</button>
</div>
<pre id="output">{{"message":"No episode yet. Press Reset Episode."}}</pre>
<div id="custom-box" style="display:none;margin-top:10px;">
<textarea id="custom-action">{{
"agent": "holmes",
"category": "investigative",
"name": "FormHypothesis",
"params": {{
"service": "cart-service",
"failure_type": "memory_leak",
"confidence": 0.7
}}
}}</textarea>
<div class="cta"><button class="btn primary" onclick="runCustomAction()">Run Custom Action</button></div>
</div>
</div>
<div class="panel">
<h2>Action Log</h2>
<pre id="log" class="log">No actions yet.</pre>
<div class="section">
<h2>Judge Quickstart</h2>
<pre class="log">POST {SPACE_URL}/reset
{{"seed": 42}}
POST {SPACE_URL}/step
{{"action": {{"agent": "holmes", "category": "investigative", "name": "QueryLogs", "params": {{"service": "cart-service", "time_range": [0, 60]}}}}}}</pre>
</div>
</div>
</section>
<section class="section">
<h2>Submission Links</h2>
<div class="cards">
<a class="card" href="{GITHUB_URL}" target="_blank" rel="noopener noreferrer"><h3>Source Code</h3><p>Environment, agents, server, tests, and training pipeline.</p><span class="endpoint">GitHub repository</span></a>
<a class="card" href="{GITHUB_URL}/blob/main/openenv.yaml" target="_blank" rel="noopener noreferrer"><h3>OpenEnv Manifest</h3><p>Runtime declaration used by validators and deployment.</p><span class="endpoint">openenv.yaml</span></a>
<a class="card" href="{GITHUB_URL}/blob/main/sentinel_colab_training.ipynb" target="_blank" rel="noopener noreferrer"><h3>Training Notebook</h3><p>Colab workflow for reproducing training and plots.</p><span class="endpoint">sentinel_colab_training.ipynb</span></a>
<a class="card" href="{GITHUB_URL}/tree/main/results" target="_blank" rel="noopener noreferrer"><h3>Results</h3><p>Evaluation logs, TensorBoard events, and training curves.</p><span class="endpoint">results/</span></a>
</div>
</section>
</main>
<script>
const presets = {{
queryLogs: {{agent:"holmes", category:"investigative", name:"QueryLogs", params:{{service:"cart-service", time_range:[0,60]}}}},
queryMetrics: {{agent:"argus", category:"investigative", name:"QueryMetrics", params:{{service:"order-service", metric_name:"cpu", time_range:[0,60]}}}},
restart: {{agent:"forge", category:"remediation", name:"RestartService", params:{{service:"cart-service"}}}},
close: {{agent:"hermes", category:"meta", name:"CloseIncident", params:{{resolution_summary:"Closed from live Space dashboard"}}}}
}};
let lastObservation = null;
let lastStep = null;
let currentTab = "observation";
const logs = [];
function $(id) {{ return document.getElementById(id); }}
function pretty(value) {{ return JSON.stringify(value, null, 2); }}
function toast(message) {{ $("toast").textContent = message; }}
function appendLog(message) {{
logs.unshift(new Date().toLocaleTimeString() + " " + message);
$("log").textContent = logs.slice(0, 24).join("\\n");
}}
function setOutput(value) {{ $("output").textContent = typeof value === "string" ? value : pretty(value); }}
function updateKpis(extra={{}}) {{
$("kpi-status").textContent = extra.status || (lastStep?.terminated ? "Done" : lastObservation ? "Active" : "Idle");
$("kpi-episode").textContent = extra.episode || lastObservation?.info?.episode_id || "-";
$("kpi-incident").textContent = extra.incident || lastObservation?.info?.incident_id || "-";
$("kpi-reward").textContent = typeof lastStep?.reward === "number" ? lastStep.reward.toFixed(3) : "-";
}}
async function api(path, options={{}}) {{
const response = await fetch(path, {{
method: options.method || "GET",
headers: {{"Content-Type": "application/json"}},
body: options.body ? JSON.stringify(options.body) : undefined
}});
const text = await response.text();
let data;
try {{ data = text ? JSON.parse(text) : {{}}; }} catch {{ data = text; }}
if (!response.ok) throw new Error(typeof data === "string" ? data : pretty(data));
return data;
}}
async function resetEpisode() {{
try {{
toast("Resetting episode...");
const seed = Number($("seed").value || 42);
const incident = $("incident").value;
const data = await api("/reset", {{method:"POST", body:{{seed, options:{{incident_id: incident}}}}}});
lastObservation = data;
lastStep = null;
showData("observation", data);
updateKpis({{status:"Active", incident:data.info?.incident_id || incident}});
appendLog("reset(seed=" + seed + ", incident=" + incident + ") -> ok");
toast("Episode initialized.");
}} catch (err) {{ toast("Reset failed: " + err.message); appendLog("reset -> ERROR"); setOutput(err.message); }}
}}
async function callPreset(name) {{
try {{
if (!lastObservation) await resetEpisode();
const action = presets[name];
toast("Running " + action.agent + "::" + action.name + "...");
const data = await api("/step", {{method:"POST", body:{{action}}}});
lastStep = data;
lastObservation = {{observation:data.observation, info:data.info}};
showData("step", data);
updateKpis();
appendLog(action.agent + "::" + action.name + " -> reward=" + Number(data.reward).toFixed(3) + ", done=" + data.terminated);
toast("Action completed.");
}} catch (err) {{ toast("Action failed: " + err.message); appendLog(name + " -> ERROR"); setOutput(err.message); }}
}}
async function runSmokeTest() {{
await resetEpisode();
await callPreset("queryLogs");
await callPreset("queryMetrics");
await loadRender();
toast("Smoke test passed: reset, two steps, and render completed.");
appendLog("smoke_test -> passed");
}}
async function runCustomAction() {{
try {{
if (!lastObservation) await resetEpisode();
const action = JSON.parse($("custom-action").value);
const data = await api("/step", {{method:"POST", body:{{action}}}});
lastStep = data;
lastObservation = {{observation:data.observation, info:data.info}};
showData("step", data);
updateKpis();
appendLog(action.agent + "::" + action.name + " -> reward=" + Number(data.reward).toFixed(3));
toast("Custom action completed.");
}} catch (err) {{ toast("Custom action failed: " + err.message); setOutput(err.message); appendLog("custom_action -> ERROR"); }}
}}
async function loadRender() {{
try {{
const data = await api("/render");
showData("render", data.render || data);
appendLog("render() -> ok");
}} catch (err) {{ toast("Render requires reset first."); setOutput(err.message); }}
}}
async function closeEpisode() {{
try {{
const data = await api("/close", {{method:"POST"}});
lastObservation = null; lastStep = null;
updateKpis({{status:"Closed", episode:"-", incident:"-"}});
showData("step", data);
appendLog("close() -> ok");
toast("Environment closed.");
}} catch (err) {{ toast("Close failed: " + err.message); setOutput(err.message); }}
}}
function showData(tab, data) {{
currentTab = tab;
$("custom-box").style.display = tab === "custom" ? "block" : "none";
if (tab !== "custom") setOutput(data);
document.querySelectorAll(".tab").forEach(btn => btn.classList.remove("active"));
const labels = {{observation:0, step:1, render:2, custom:3}};
document.querySelectorAll(".tab")[labels[tab]]?.classList.add("active");
}}
function showTab(tab, button) {{
document.querySelectorAll(".tab").forEach(btn => btn.classList.remove("active"));
button.classList.add("active");
$("custom-box").style.display = tab === "custom" ? "block" : "none";
if (tab === "observation") setOutput(lastObservation || {{"message":"No observation yet. Press Reset Episode."}});
if (tab === "step") setOutput(lastStep || {{"message":"No step result yet."}});
if (tab === "render") loadRender();
if (tab === "custom") setOutput({{"message":"Edit the JSON below and run a custom action."}});
}}
api("/health").then(data => {{
updateKpis({{status:data.initialized ? "Active" : "Idle", episode:data.episode_id || "-"}});
appendLog("health() -> " + data.status);
}}).catch(() => appendLog("health() -> unavailable"));
</script>
</body>
</html>"""