use super::*;
pub(in crate::commands::ops) fn build_ops_result(
snapshot: &StatusSnapshot,
alerts: &[OpsAlert],
overall_health: &str,
) -> serde_json::Value {
let loaded_plugins = snapshot
.plugins
.iter()
.filter(|plugin| plugin.loaded)
.count();
let pending_replay_sessions = snapshot
.missions
.iter()
.filter(|mission| mission.state.eq_ignore_ascii_case("recovering"))
.count();
let local_reliable_subscribers = snapshot
.topics
.iter()
.map(|topic| topic.reliable_local_ack.subscribers)
.sum::<usize>();
let local_reliable_acked_subscribers = snapshot
.topics
.iter()
.map(|topic| topic.reliable_local_ack.acked_subscribers)
.sum::<usize>();
let remote_reliable_subscribers = snapshot
.topics
.iter()
.map(|topic| topic.reliable_remote_ack.subscribers)
.sum::<usize>();
let remote_reliable_acked_subscribers = snapshot
.topics
.iter()
.map(|topic| topic.reliable_remote_ack.acked_subscribers)
.sum::<usize>();
serde_json::json!({
"summary": {
"overall_health": overall_health,
"alerts": alerts.len(),
"loaded_plugins": loaded_plugins,
"pending_replay_sessions": pending_replay_sessions,
"topic_reliable_ack": {
"local": {
"subscribers": local_reliable_subscribers,
"acked_subscribers": local_reliable_acked_subscribers,
},
"remote": {
"subscribers": remote_reliable_subscribers,
"acked_subscribers": remote_reliable_acked_subscribers,
},
},
},
"runtime": {
"nodes": snapshot.nodes.len(),
"topics": snapshot.topics.len(),
"services": snapshot.services.len(),
"actions": snapshot.actions.len(),
"missions": snapshot.missions.len(),
"plugins_total": snapshot.plugins.len(),
"plugins_loaded": loaded_plugins,
},
"topology": {
"nodes": snapshot.nodes,
"edges": snapshot.edges,
},
"missions": snapshot.missions,
"plugins": snapshot.plugins,
"alerts": alerts
.iter()
.map(|alert| {
serde_json::json!({
"severity": alert.severity,
"component": alert.component,
"message": alert.message,
})
})
.collect::<Vec<_>>(),
})
}
pub(in crate::commands::ops) fn render_console_html(
snapshot: &StatusSnapshot,
source_label: &str,
alerts: &[OpsAlert],
overall_health: &str,
) -> String {
let loaded_plugins = snapshot
.plugins
.iter()
.filter(|plugin| plugin.loaded)
.count();
let mission_rows = snapshot
.missions
.iter()
.map(|mission| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
escape_html(&mission.name),
escape_html(&mission.state),
escape_html(mission.last_checkpoint.as_deref().unwrap_or("-"))
)
})
.collect::<Vec<_>>()
.join("\n");
let plugin_rows = snapshot
.plugins
.iter()
.map(|plugin| {
let badge_class = if plugin.loaded {
"badge-on"
} else {
"badge-off"
};
format!(
"<tr><td>{}</td><td>{}</td><td><span class=\"{}\">{}</span></td></tr>",
escape_html(&plugin.name),
escape_html(&plugin.kind),
badge_class,
if plugin.loaded { "loaded" } else { "unloaded" }
)
})
.collect::<Vec<_>>()
.join("\n");
let graph_rows = snapshot
.edges
.iter()
.map(|edge| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
escape_html(&edge.from),
escape_html(&edge.relation),
escape_html(&edge.to)
)
})
.collect::<Vec<_>>()
.join("\n");
let alert_rows = if alerts.is_empty() {
"<li class=\"alert-row info\">no active alerts</li>".to_string()
} else {
alerts
.iter()
.map(|alert| {
format!(
"<li class=\"alert-row {}\"><strong>{}</strong> [{}] {}</li>",
escape_html(&alert.severity),
escape_html(&alert.severity),
escape_html(&alert.component),
escape_html(&alert.message)
)
})
.collect::<Vec<_>>()
.join("\n")
};
format!(
"<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>RobotRT Ops Console</title>
<style>
:root {{
--bg-1: #0e1726;
--bg-2: #1b2a41;
--card: rgba(255, 255, 255, 0.92);
--ink: #12263a;
--muted: #5c6f82;
--ok: #1f9d55;
--warn: #cc7a00;
--critical: #b42318;
--border: rgba(18, 38, 58, 0.14);
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: \"Space Grotesk\", \"Noto Sans\", sans-serif;
color: var(--ink);
background:
radial-gradient(1200px 500px at -5% -10%, #4ba3c7 0%, transparent 60%),
radial-gradient(900px 500px at 105% -10%, #ffcf7f 0%, transparent 55%),
linear-gradient(135deg, var(--bg-1), var(--bg-2));
min-height: 100vh;
padding: 28px;
}}
.shell {{
max-width: 1180px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}}
.hero {{
grid-column: 1 / -1;
background: var(--card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 18px 20px;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.16);
}}
.hero h1 {{ margin: 0 0 8px; font-size: 28px; letter-spacing: 0.02em; }}
.meta {{ color: var(--muted); font-size: 13px; }}
.chips {{ margin-top: 14px; display: flex; flex-wrap: wrap; gap: 8px; }}
.chip {{
border-radius: 999px;
padding: 6px 11px;
border: 1px solid var(--border);
font-size: 12px;
background: #ffffff;
}}
.panel {{
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 14px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
}}
h2 {{ margin: 0 0 10px; font-size: 16px; }}
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
th, td {{ text-align: left; padding: 6px 4px; border-bottom: 1px solid rgba(18, 38, 58, 0.08); }}
th {{ color: var(--muted); font-weight: 600; }}
ul {{ margin: 0; padding-left: 18px; }}
.alert-row {{ margin-bottom: 7px; }}
.alert-row.warning {{ color: var(--warn); }}
.alert-row.critical {{ color: var(--critical); }}
.alert-row.info {{ color: var(--muted); }}
.badge-on {{ color: var(--ok); font-weight: 700; }}
.badge-off {{ color: var(--muted); }}
@media (max-width: 700px) {{ body {{ padding: 14px; }} .hero h1 {{ font-size: 22px; }} }}
</style>
</head>
<body>
<section class=\"shell\">
<article class=\"hero\">
<h1>RobotRT Ops Console</h1>
<div class=\"meta\">source: {} | captured_at_unix_nanos: {}</div>
<div class=\"chips\">
<span class=\"chip\">overall: {}</span>
<span class=\"chip\">nodes: {}</span>
<span class=\"chip\">topics: {}</span>
<span class=\"chip\">missions: {}</span>
<span class=\"chip\">plugins loaded: {}/{}</span>
<span class=\"chip\">alerts: {}</span>
</div>
</article>
<article class=\"panel\">
<h2>Active Alerts</h2>
<ul>{}</ul>
</article>
<article class=\"panel\">
<h2>Mission States</h2>
<table>
<thead><tr><th>mission</th><th>state</th><th>checkpoint</th></tr></thead>
<tbody>{}</tbody>
</table>
</article>
<article class=\"panel\">
<h2>Plugin Lifecycle</h2>
<table>
<thead><tr><th>plugin</th><th>kind</th><th>state</th></tr></thead>
<tbody>{}</tbody>
</table>
</article>
<article class=\"panel\" style=\"grid-column: 1 / -1;\">
<h2>Topology Graph</h2>
<table>
<thead><tr><th>from</th><th>relation</th><th>to</th></tr></thead>
<tbody>{}</tbody>
</table>
</article>
</section>
</body>
</html>",
escape_html(source_label),
snapshot.captured_at_unix_nanos,
escape_html(overall_health),
snapshot.nodes.len(),
snapshot.topics.len(),
snapshot.missions.len(),
loaded_plugins,
snapshot.plugins.len(),
alerts.len(),
alert_rows,
mission_rows,
plugin_rows,
graph_rows,
)
}
pub(in crate::commands::ops) fn escape_html(raw: &str) -> String {
raw.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}