robotrt-cli 0.1.0-beta.1

RobotRT modular robotics runtime and middleware components.
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();

    serde_json::json!({
        "summary": {
            "overall_health": overall_health,
            "alerts": alerts.len(),
            "loaded_plugins": loaded_plugins,
            "pending_replay_sessions": pending_replay_sessions,
        },
        "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('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}