Răsfoiți Sursa

task: Build shared reusable localized renderer module with plain/md/html renderers over a pure-data report model — all three renderers resolve fluent messages via t! with any new keys added to both locales/en and locales/ru; scope wiring to health now, do not retrofit doctor/tmux status | decisions (explicit user choices — these OVERRIDE the spec doc wherever they conflict; follow them exactly): all three renderers (plain/md/html) resolve fluent messages via t! from a pure-data report model, with any new message keys added to both locales/en and locales/ru; health only — build the md/html renderer as a shared reusable module and wire --format into sm121 health now, do not retrofit doctor/tmux status in this series (TASK_0002)

Alexander Korolev 1 lună în urmă
părinte
comite
fbb37658dd

+ 18 - 0
locales/en/main.ftl

@@ -186,3 +186,21 @@ composed-dir-built = Built composed model dir '{ $host }' on '{ $node }' ({ $sou
         [one] file
        *[other] files
     })
+
+# health render module (plain/md/html) — new keys; es/it/fr/de fall back to en
+health-nodes-count = { $nodes } { $nodes ->
+        [one] node
+       *[other] nodes
+    }
+health-col-node = Node
+health-col-address = Address
+health-col-status = Status
+health-col-containers = Containers
+health-col-container = Container
+health-col-image = Image
+health-col-recipe = Recipe
+health-col-cluster = Cluster
+health-col-nodes = Nodes
+health-status-online = ● online
+health-status-offline = ✗ offline
+health-container-line =     { $name }  { $status }  { $image }

+ 19 - 0
locales/ru/main.ftl

@@ -196,3 +196,22 @@ venv-creating = venv не найден на { $name } — создаю...
 venv-created = ✓ venv успешно создан на { $name }
 venv-prepare-failed = подготовка venv: узел { $name } завершился с ошибкой: { $error }
 venv-not-created = venv не был создан успешно на { $name } — команды настройки выполнились, но скрипт activate отсутствует
+
+# health render module (plain/md/html) — new keys; es/it/fr/de fall back to en
+health-nodes-count = { $nodes } { $nodes ->
+        [one] узел
+        [few] узла
+       *[many] узлов
+    }
+health-col-node = Нода
+health-col-address = Адрес
+health-col-status = Статус
+health-col-containers = Контейнеры
+health-col-container = Контейнер
+health-col-image = Образ
+health-col-recipe = Рецепт
+health-col-cluster = Кластер
+health-col-nodes = Ноды
+health-status-online = ● онлайн
+health-status-offline = ✗ офлайн
+health-container-line =     { $name }  { $status }  { $image }

+ 72 - 1
src/cli.rs

@@ -1,4 +1,15 @@
-use clap::{Args, Parser, Subcommand};
+use clap::{Args, Parser, Subcommand, ValueEnum};
+
+/// Output format for human-readable reports (`--format`).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
+pub enum OutputFormat {
+    /// Human-readable text (default).
+    Plain,
+    /// Markdown (headings + tables).
+    Md,
+    /// HTML (escaped remote data).
+    Html,
+}
 
 #[derive(Parser)]
 #[command(author, version, about = "DGX Spark Launcher for vLLM and SGLang")]
@@ -139,6 +150,9 @@ pub enum Commands {
         cluster: Option<String>,
         #[arg(long, help = "Show node host keys (for adding to config.toml)")]
         fingerprint: bool,
+        /// Output format (cluster-health mode only; ignored with --fingerprint).
+        #[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
+        format: OutputFormat,
     },
 
     /// Check and repair a recipe (doctor mode)
@@ -373,3 +387,60 @@ pub enum TmuxCommands {
         cluster: String,
     },
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Parse `sm121 health <args>` and return the parsed `--format`.
+    fn health_format(args: &[&str]) -> Result<(OutputFormat, bool), clap::Error> {
+        let cli = Cli::try_parse_from(
+            std::iter::once("sm121")
+                .chain(std::iter::once("health"))
+                .chain(args.iter().copied()),
+        )?;
+        let Commands::Health {
+            fingerprint,
+            format,
+            ..
+        } = cli.command
+        else {
+            panic!("expected Health")
+        };
+        Ok((format, fingerprint))
+    }
+
+    #[test]
+    fn health_format_defaults_to_plain() {
+        assert_eq!(health_format(&[]).unwrap(), (OutputFormat::Plain, false));
+    }
+
+    #[test]
+    fn health_format_parses_each_variant() {
+        assert_eq!(
+            health_format(&["--format", "plain"]).unwrap().0,
+            OutputFormat::Plain
+        );
+        assert_eq!(
+            health_format(&["--format", "md"]).unwrap().0,
+            OutputFormat::Md
+        );
+        assert_eq!(
+            health_format(&["--format", "html"]).unwrap().0,
+            OutputFormat::Html
+        );
+    }
+
+    #[test]
+    fn health_format_combines_with_cluster_and_fingerprint() {
+        let (fmt, fp) = health_format(&["--cluster", "c1", "--format", "md"]).unwrap();
+        assert_eq!((fmt, fp), (OutputFormat::Md, false));
+        let (fmt, fp) = health_format(&["--fingerprint", "--format", "html"]).unwrap();
+        assert_eq!((fmt, fp), (OutputFormat::Html, true));
+    }
+
+    #[test]
+    fn health_format_rejects_invalid_value() {
+        assert!(health_format(&["--format", "bogus"]).is_err());
+    }
+}

+ 6 - 6
src/docker/health/mod.rs

@@ -1,20 +1,20 @@
-//! Cluster health — pure-data collection + printing consumers.
+//! Cluster health — pure-data collection + report rendering.
 //!
 //! [`collect`] is the printing-free collection pass: one call per
 //! `health` invocation gathers a [`HealthReport`] per requested cluster
 //! (verdict via the shared [`crate::cluster_state::verdict`], per-node
 //! docker rows, tmux entries) plus — when no cluster filter is given —
-//! the fleet-level [`RecipesSummary`]. The printing functions
-//! ([`print_health`], [`health_summary`]) render that model; nothing
-//! here touches the web dashboard's probe.
+//! the fleet-level [`RecipesSummary`]. The [`render`] module turns that
+//! model into plain/markdown/HTML output; nothing here touches the web
+//! dashboard's probe.
 
 mod collect;
-mod print;
+mod render;
 #[cfg(test)]
 mod tests;
 
 pub use collect::collect;
-pub use print::{health_check, health_summary, print_health};
+pub use render::{health_check, print_fleet};
 
 use crate::cluster_state::ClusterVerdict;
 use crate::tmux::discover::TmuxHealthEntry;

+ 0 - 157
src/docker/health/print.rs

@@ -1,157 +0,0 @@
-//! Printing consumers for the collected [`HealthReport`] model.
-//!
-//! All output vocabulary lives here (and in
-//! [`crate::tmux::discover::print_tmux_health`] for the tmux section);
-//! the data comes from [`super::collect`] untouched.
-
-use super::{FleetHealth, HealthReport};
-use crate::config::{Cluster, Config};
-use anyhow::Result;
-
-/// `health` command, single cluster: collect that cluster and print it
-/// (docker rows + tmux section).
-///
-/// Cluster-level semantics (shared with the web dashboard via
-/// [`crate::cluster_state::verdict`]): the cluster is UP only when every
-/// node is reachable and the HA switch entities (if configured) are on.
-/// A down cluster prints no containers, even on nodes that are alive.
-pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) -> Result<()> {
-    let fleet = super::collect(config, Some(&cluster.name), dry_run).await?;
-    let Some(report) = fleet.clusters.first() else {
-        return Ok(());
-    };
-    print_health(report);
-    crate::tmux::discover::print_tmux_health(&report.tmux);
-    Ok(())
-}
-
-/// Print one cluster's report: header/verdict line + per-node rows.
-/// Container rows are reported only when the cluster is up.
-pub fn print_health(report: &HealthReport) {
-    let nodes = &report.nodes;
-    let v = &report.verdict;
-    let total = nodes.len();
-    let online = nodes.iter().filter(|n| n.online).count();
-    let offline = total - online;
-    let net_ok = nodes.iter().all(|n| !n.online || n.net_up);
-    let container_count: usize = if v.up {
-        nodes.iter().map(|n| n.containers.len()).sum()
-    } else {
-        0
-    };
-
-    print!(
-        "{}",
-        fluent_i18n::t!("health-cluster-header", { "name" => &report.cluster, "nodes" => total })
-    );
-    if !v.up {
-        // Cluster is down as a whole — say why, nothing else to report.
-        if v.switch_off {
-            print!(" {}", fluent_i18n::t!("health-switch-off"));
-        } else {
-            print!(
-                "{}",
-                fluent_i18n::t!("health-partial-online", { "online" => online, "offline" => offline })
-            );
-        }
-        if !report.soft_off.is_empty() {
-            print!(
-                " {}",
-                fluent_i18n::t!("health-soft-off", { "list" => &report.soft_off.join(", ") })
-            );
-        }
-        println!(" {}", fluent_i18n::t!("health-cluster-down"));
-    } else {
-        // `v.up` ⇒ every node is reachable.
-        print!(" {}", fluent_i18n::t!("health-all-online"));
-        if !net_ok {
-            print!(" {}", fluent_i18n::t!("health-interface-down"));
-        }
-        print!(
-            " {}",
-            fluent_i18n::t!("health-free-count", { "free" => v.free })
-        );
-        if !report.soft_off.is_empty() {
-            print!(
-                " {}",
-                fluent_i18n::t!("health-soft-off", { "list" => &report.soft_off.join(", ") })
-            );
-        }
-    }
-    // Container count: only for up clusters — the down path already
-    // printed its header line (with newline), and an offline cluster
-    // reports no containers by definition.
-    if v.up {
-        if container_count > 0 {
-            println!(
-                "{}",
-                fluent_i18n::t!("health-container-count", { "count" => container_count })
-            );
-        } else {
-            print!("{}", fluent_i18n::t!("health-no-containers-inline"));
-        }
-    }
-
-    for nh in nodes {
-        if !nh.online {
-            println!(
-                "{}",
-                fluent_i18n::t!("health-node-offline", { "name" => &nh.name, "ip" => &nh.ib_ip })
-            );
-            continue;
-        }
-        let net_status = if !nh.net_up {
-            fluent_i18n::t!("health-net-status", { "details" => nh.net_details.join(", ") })
-        } else {
-            String::new()
-        };
-        if !v.up {
-            // Cluster is down as a whole: the node's containers are not
-            // ours to report — print the node as alive-but-ignored.
-            println!(
-                "{}",
-                fluent_i18n::t!("health-node-online-cluster-down", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
-            );
-            continue;
-        }
-        if nh.containers.is_empty() {
-            println!(
-                "{}",
-                fluent_i18n::t!("health-node-idle", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
-            );
-        } else {
-            println!(
-                "{}",
-                fluent_i18n::t!("health-node-online", { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net_status })
-            );
-            for line in &nh.containers {
-                println!("    {line}");
-            }
-        }
-    }
-}
-
-/// Fleet running-recipes summary — prints only when a summary was
-/// collected (no `--cluster` filter). Down clusters were already
-/// excluded during collection.
-pub fn health_summary(fleet: &FleetHealth) {
-    let Some(summary) = &fleet.recipes else {
-        return;
-    };
-    let mut lines: Vec<String> = Vec::new();
-    for (recipe, by_cluster) in &summary.by_recipe {
-        for nodes in by_cluster.values() {
-            lines.push(fluent_i18n::t!("health-recipe-line", { "recipe" => recipe, "nodes" => nodes.len(), "list" => &nodes.join(", ") }));
-        }
-    }
-
-    if lines.is_empty() {
-        println!("\n{}", fluent_i18n::t!("health-no-recipes"));
-        return;
-    }
-
-    println!("\n{}", fluent_i18n::t!("health-running-recipes"));
-    for line in &lines {
-        println!("{line}");
-    }
-}

+ 121 - 0
src/docker/health/render/html.rs

@@ -0,0 +1,121 @@
+//! HTML renderer + generic HTML machinery (`esc_html`, `html_table`).
+//! Interpolated data originates from remote SSH output, so every value
+//! is escaped here, at the point of interpolation — the same escape
+//! set (`& < > " '`) as the web dashboard's `esc()`.
+
+use super::{FleetHealth, HealthReport};
+use super::{
+    container_headers, container_rows, node_headers, node_rows, tmux_active, tmux_entry_line,
+    tmux_stopped_count, verdict_line,
+};
+use crate::tmux::discover::TmuxHealthEntry;
+
+/// Escape a value interpolated into HTML markup: `& < > " '`.
+pub fn esc_html(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            '\'' => out.push_str("&#39;"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
+/// Render an HTML table; every header and cell is escaped here, at the
+/// point of interpolation.
+pub fn html_table(headers: &[String], rows: &[Vec<String>]) -> String {
+    let cells = |values: &[String], tag: &str| {
+        values
+            .iter()
+            .map(|v| format!("<{tag}>{}</{tag}>", esc_html(v)))
+            .collect::<String>()
+    };
+    let head = headers.to_vec();
+    let body: String = rows
+        .iter()
+        .map(|r| format!("<tr>{}</tr>\n", cells(r, "td")))
+        .collect();
+    format!(
+        "<table>\n<tr>{}</tr>\n<tbody>\n{}</tbody></table>\n",
+        cells(&head, "th"),
+        body
+    )
+}
+
+pub(super) fn fleet(fleet: &FleetHealth) -> String {
+    let mut out = String::new();
+    for r in &fleet.clusters {
+        out.push_str(&cluster(r));
+        out.push_str(&tmux(&r.tmux));
+    }
+    out.push_str(&recipes(fleet));
+    out
+}
+
+fn cluster(r: &HealthReport) -> String {
+    let mut out = format!(
+        "<h2>{}</h2>\n<p>{}</p>\n",
+        esc_html(&r.cluster),
+        esc_html(&verdict_line(r))
+    );
+    out.push_str(&html_table(&node_headers(), &node_rows(r)));
+    if r.verdict.up {
+        let rows = container_rows(r);
+        if !rows.is_empty() {
+            out.push_str(&html_table(&container_headers(), &rows));
+        }
+    }
+    out
+}
+
+fn tmux(entries: &[TmuxHealthEntry]) -> String {
+    if entries.is_empty() {
+        return String::new();
+    }
+    let active = tmux_active(entries);
+    let mut out = String::from("\n");
+    if active.is_empty() {
+        out.push_str(&format!(
+            "<p>{}</p>\n",
+            esc_html(&fluent_i18n::t!(
+                "tmux-health-all-stopped",
+                { "count" => tmux_stopped_count(entries) }
+            ))
+        ));
+    } else {
+        out.push_str(&format!(
+            "<h3>{}</h3>\n<ul>\n",
+            esc_html(&fluent_i18n::t!("tmux-health-section"))
+        ));
+        for e in active {
+            out.push_str(&format!("<li>{}</li>\n", esc_html(&tmux_entry_line(e))));
+        }
+        out.push_str("</ul>\n");
+    }
+    out
+}
+
+fn recipes(fleet: &FleetHealth) -> String {
+    let Some(summary) = &fleet.recipes else {
+        return String::new();
+    };
+    let mut out = format!(
+        "<h2>{}</h2>\n",
+        esc_html(&fluent_i18n::t!("health-running-recipes"))
+    );
+    let rows = super::markdown::recipe_rows(summary);
+    if rows.is_empty() {
+        out.push_str(&format!(
+            "<p>{}</p>\n",
+            esc_html(&fluent_i18n::t!("health-no-recipes"))
+        ));
+    } else {
+        out.push_str(&html_table(&super::markdown::recipe_headers(), &rows));
+    }
+    out
+}

+ 120 - 0
src/docker/health/render/markdown.rs

@@ -0,0 +1,120 @@
+//! Markdown renderer + generic markdown table machinery (`md_table`,
+//! `md_cell`) — plain strings in, markup out, no health-specific
+//! types, so other commands can reuse the table emission.
+
+use super::{FleetHealth, HealthReport};
+use super::{
+    container_headers, container_rows, node_headers, node_rows, tmux_active, tmux_entry_line,
+    tmux_stopped_count, verdict_line,
+};
+use crate::tmux::discover::TmuxHealthEntry;
+
+/// Neutralize markdown table structure inside a cell (`|` → `\|`).
+pub fn md_cell(s: &str) -> String {
+    s.replace('|', "\\|")
+}
+
+/// Render a markdown table; cells are pipe-neutralized.
+pub fn md_table(headers: &[String], rows: &[Vec<String>]) -> String {
+    let mut out = format!("| {} |\n", headers.join(" | "));
+    out.push('|');
+    out.push_str(&" --- |".repeat(headers.len()));
+    out.push('\n');
+    for row in rows {
+        out.push_str("| ");
+        out.push_str(
+            &row.iter()
+                .map(|c| md_cell(c))
+                .collect::<Vec<_>>()
+                .join(" | "),
+        );
+        out.push_str(" |\n");
+    }
+    out
+}
+
+pub(super) fn fleet(fleet: &FleetHealth) -> String {
+    let mut out = String::new();
+    for r in &fleet.clusters {
+        out.push_str(&cluster(r));
+        out.push_str(&tmux(&r.tmux));
+    }
+    out.push_str(&recipes(fleet));
+    out
+}
+
+fn cluster(r: &HealthReport) -> String {
+    let mut out = format!("## {}\n\n{}\n\n", r.cluster, verdict_line(r));
+    out.push_str(&md_table(&node_headers(), &node_rows(r)));
+    out.push('\n');
+    if r.verdict.up {
+        let rows = container_rows(r);
+        if !rows.is_empty() {
+            out.push_str(&md_table(&container_headers(), &rows));
+            out.push('\n');
+        }
+    }
+    out
+}
+
+fn tmux(entries: &[TmuxHealthEntry]) -> String {
+    if entries.is_empty() {
+        return String::new();
+    }
+    let active = tmux_active(entries);
+    let mut out = String::from("\n");
+    if active.is_empty() {
+        out.push_str(&fluent_i18n::t!(
+            "tmux-health-all-stopped",
+            { "count" => tmux_stopped_count(entries) }
+        ));
+        out.push('\n');
+    } else {
+        out.push_str("### ");
+        out.push_str(&fluent_i18n::t!("tmux-health-section"));
+        out.push_str("\n\n");
+        for e in active {
+            out.push_str("- ");
+            out.push_str(&tmux_entry_line(e));
+            out.push('\n');
+        }
+    }
+    out
+}
+
+fn recipes(fleet: &FleetHealth) -> String {
+    let Some(summary) = &fleet.recipes else {
+        return String::new();
+    };
+    let mut out = String::from("\n## ");
+    out.push_str(&fluent_i18n::t!("health-running-recipes"));
+    out.push_str("\n\n");
+    let rows = recipe_rows(summary);
+    if rows.is_empty() {
+        out.push_str(&fluent_i18n::t!("health-no-recipes"));
+        out.push('\n');
+    } else {
+        out.push_str(&md_table(&recipe_headers(), &rows));
+    }
+    out
+}
+
+/// Recipe/cluster/nodes rows shared by the md and html renderers.
+pub(super) fn recipe_rows(summary: &super::RecipesSummary) -> Vec<Vec<String>> {
+    super::sorted_summary(summary)
+        .into_iter()
+        .flat_map(|(recipe, clusters)| {
+            clusters
+                .into_iter()
+                .map(move |(cluster, nodes)| vec![recipe.clone(), cluster, nodes.join(", ")])
+        })
+        .collect()
+}
+
+pub(super) fn recipe_headers() -> Vec<String> {
+    vec![
+        fluent_i18n::t!("health-col-recipe"),
+        fluent_i18n::t!("health-col-cluster"),
+        fluent_i18n::t!("health-col-nodes"),
+    ]
+}

+ 230 - 0
src/docker/health/render/mod.rs

@@ -0,0 +1,230 @@
+//! Report renderers for the collected [`super::FleetHealth`] model —
+//! plain, markdown, and HTML — behind one data-in / `String`-out entry
+//! point ([`render_fleet`], plus a thin print-to-stdout wrapper). The
+//! renderers consume the pure data model read-only: no `Config`, SSH,
+//! or I/O here.
+//!
+//! The md/html machinery ([`markdown::md_table`], [`html::html_table`],
+//! [`html::esc_html`], [`markdown::md_cell`]) is generic — plain
+//! strings in, markup out, no health-specific types — so other
+//! commands (doctor, tmux status) can adopt it without rework.
+
+mod html;
+mod markdown;
+mod plain;
+
+use super::{FleetHealth, HealthReport, NodeHealth, RecipesSummary};
+use crate::cli::OutputFormat;
+use crate::config::{Cluster, Config};
+use crate::tmux::discover::TmuxHealthEntry;
+use anyhow::Result;
+
+// ----- entry points -----
+
+/// `health` command, single cluster: collect that cluster and render
+/// it. A cluster filter means the fleet recipes summary is `None`, so
+/// no recipes section is rendered — that is the normal shape here.
+pub async fn health_check(
+    config: &Config,
+    cluster: &Cluster,
+    dry_run: bool,
+    fmt: OutputFormat,
+) -> Result<()> {
+    let fleet = super::collect(config, Some(&cluster.name), dry_run).await?;
+    print_fleet(&fleet, fmt);
+    Ok(())
+}
+
+/// Render the fleet in `fmt` and print it to stdout.
+pub fn print_fleet(fleet: &FleetHealth, fmt: OutputFormat) {
+    print!("{}", render_fleet(fleet, fmt));
+}
+
+/// Data-in / String-out: render the fleet model (per-cluster sections
+/// with tmux sections, plus the optional recipes summary).
+pub fn render_fleet(fleet: &FleetHealth, fmt: OutputFormat) -> String {
+    match fmt {
+        OutputFormat::Plain => plain::fleet(fleet),
+        OutputFormat::Md => markdown::fleet(fleet),
+        OutputFormat::Html => html::fleet(fleet),
+    }
+}
+
+// ----- shared pieces (visible to the per-format child modules) -----
+
+/// Free-node count plus the container-count tail (`— N free, M
+/// containers` / `, no containers`). The container keys continue the
+/// line with a leading comma.
+fn free_and_containers(r: &HealthReport) -> String {
+    let mut s = fluent_i18n::t!("health-free-count", { "free" => r.verdict.free });
+    let containers: u32 = r.nodes.iter().map(|n| n.containers.len() as u32).sum();
+    if containers > 0 {
+        s.push_str(&fluent_i18n::t!("health-container-count", { "count" => containers }));
+    } else {
+        s.push_str(&fluent_i18n::t!("health-no-containers-inline"));
+    }
+    s
+}
+
+/// Verdict/status line for md + html (plain uses the decorated
+/// `health-cluster-header` key instead — frozen, includes the name).
+fn verdict_line(r: &HealthReport) -> String {
+    let v = &r.verdict;
+    let total = r.nodes.len() as u32;
+    let mut parts = vec![fluent_i18n::t!("health-nodes-count", { "nodes" => total })];
+    if v.up {
+        parts.push(fluent_i18n::t!("health-all-online"));
+        let net_ok = r.nodes.iter().all(|n| !n.online || n.net_up);
+        if !net_ok {
+            parts.push(fluent_i18n::t!("health-interface-down"));
+        }
+        parts.push(free_and_containers(r));
+    } else if v.switch_off {
+        parts.push(fluent_i18n::t!("health-switch-off"));
+    } else {
+        let online = r.nodes.iter().filter(|n| n.online).count() as u32;
+        parts.push(fluent_i18n::t!(
+            "health-partial-online",
+            { "online" => online, "offline" => total - online }
+        ));
+    }
+    if !r.soft_off.is_empty() {
+        parts.push(fluent_i18n::t!("health-soft-off", { "list" => &r.soft_off.join(", ") }));
+    }
+    if !v.up {
+        parts.push(fluent_i18n::t!("health-cluster-down"));
+    }
+    parts.join(" ")
+}
+
+fn node_headers() -> Vec<String> {
+    vec![
+        fluent_i18n::t!("health-col-node"),
+        fluent_i18n::t!("health-col-address"),
+        fluent_i18n::t!("health-col-status"),
+        fluent_i18n::t!("health-col-containers"),
+    ]
+}
+
+/// Node table rows. Container counts are suppressed entirely for down
+/// clusters (the data exists but must not be rendered).
+fn node_rows(r: &HealthReport) -> Vec<Vec<String>> {
+    r.nodes
+        .iter()
+        .map(|n| {
+            vec![
+                n.name.clone(),
+                n.ib_ip.clone(),
+                status_cell(n, r.verdict.up),
+                containers_cell(n, r.verdict.up),
+            ]
+        })
+        .collect()
+}
+
+fn status_cell(n: &NodeHealth, up: bool) -> String {
+    if !n.online {
+        return fluent_i18n::t!("health-status-offline");
+    }
+    let mut s = fluent_i18n::t!("health-status-online");
+    if !up {
+        s.push(' ');
+        s.push_str(&fluent_i18n::t!("health-cluster-down"));
+    } else if !n.net_up {
+        s.push_str(&fluent_i18n::t!(
+            "health-net-status",
+            { "details" => n.net_details.join(", ") }
+        ));
+    }
+    s
+}
+
+fn containers_cell(n: &NodeHealth, up: bool) -> String {
+    if !up {
+        return String::new();
+    }
+    if n.containers.is_empty() {
+        return fluent_i18n::t!("health-no-containers");
+    }
+    n.containers.len().to_string()
+}
+
+fn container_headers() -> Vec<String> {
+    vec![
+        fluent_i18n::t!("health-col-container"),
+        fluent_i18n::t!("health-col-node"),
+        fluent_i18n::t!("health-col-status"),
+        fluent_i18n::t!("health-col-image"),
+    ]
+}
+
+/// Container rows across all nodes, columns split out of the raw
+/// `name\tstatus\timage` lines. Only for up clusters.
+fn container_rows(r: &HealthReport) -> Vec<Vec<String>> {
+    r.nodes
+        .iter()
+        .flat_map(|n| {
+            n.containers.iter().map(move |c| {
+                let parts: Vec<&str> = c.split('\t').collect();
+                let cell = |i: usize| parts.get(i).copied().unwrap_or("").to_string();
+                vec![cell(0), n.name.clone(), cell(1), cell(2)]
+            })
+        })
+        .collect()
+}
+
+/// Recipes summary rows: `(recipe, [(cluster, nodes)])`, both sorted.
+type RecipeRows = Vec<(String, Vec<(String, Vec<String>)>)>;
+
+/// Recipes summary with deterministic ordering (recipe, then cluster).
+fn sorted_summary(summary: &RecipesSummary) -> RecipeRows {
+    let mut recipes: Vec<_> = summary.by_recipe.iter().collect();
+    recipes.sort_by(|a, b| a.0.cmp(b.0));
+    recipes
+        .into_iter()
+        .map(|(recipe, by_cluster)| {
+            let mut clusters: Vec<_> = by_cluster.iter().collect();
+            clusters.sort_by(|a, b| a.0.cmp(b.0));
+            (
+                (*recipe).clone(),
+                clusters
+                    .into_iter()
+                    .map(|(c, nodes)| ((*c).clone(), nodes.clone()))
+                    .collect(),
+            )
+        })
+        .collect()
+}
+
+/// Active (session_exists) tmux entries — the only ones rendered.
+fn tmux_active(entries: &[TmuxHealthEntry]) -> Vec<&TmuxHealthEntry> {
+    entries.iter().filter(|e| e.session_exists).collect()
+}
+
+/// Distinct tmux recipes behind all-stopped entries.
+fn tmux_stopped_count(entries: &[TmuxHealthEntry]) -> u32 {
+    entries
+        .iter()
+        .map(|e| e.recipe_display.as_str())
+        .collect::<std::collections::HashSet<_>>()
+        .len() as u32
+}
+
+fn tmux_entry_line(e: &TmuxHealthEntry) -> String {
+    if e.process_running {
+        let api = if e.api_ok {
+            fluent_i18n::t!("tmux-api-ok")
+        } else {
+            fluent_i18n::t!("tmux-api-down")
+        };
+        fluent_i18n::t!(
+            "tmux-health-entry-running",
+            { "recipe" => &e.recipe_display, "node" => &e.node_name, "api" => &api }
+        )
+    } else {
+        fluent_i18n::t!(
+            "tmux-health-entry-degraded",
+            { "recipe" => &e.recipe_display, "node" => &e.node_name }
+        )
+    }
+}

+ 176 - 0
src/docker/health/render/plain.rs

@@ -0,0 +1,176 @@
+//! Plain-text renderer — closest to the historical ad-hoc output, with
+//! container columns split out of the raw tab-separated lines.
+
+use super::{
+    FleetHealth, HealthReport, free_and_containers, sorted_summary, tmux_active, tmux_entry_line,
+    tmux_stopped_count,
+};
+use crate::tmux::discover::TmuxHealthEntry;
+
+pub(super) fn fleet(fleet: &FleetHealth) -> String {
+    let mut out = String::new();
+    for r in &fleet.clusters {
+        out.push_str(&cluster(r));
+        out.push_str(&tmux(&r.tmux));
+    }
+    out.push_str(&recipes(fleet));
+    out
+}
+
+/// One cluster: header/verdict line + per-node rows with container
+/// columns padded for alignment (no raw tab blobs).
+fn cluster(r: &HealthReport) -> String {
+    let v = &r.verdict;
+    let mut lines = vec![header(r)];
+    for nh in &r.nodes {
+        if !nh.online {
+            lines.push(fluent_i18n::t!(
+                "health-node-offline",
+                { "name" => &nh.name, "ip" => &nh.ib_ip }
+            ));
+            continue;
+        }
+        let net = if v.up && !nh.net_up {
+            fluent_i18n::t!("health-net-status", { "details" => nh.net_details.join(", ") })
+        } else {
+            String::new()
+        };
+        if !v.up {
+            // Cluster down as a whole: the node is alive but its
+            // containers are not ours to report.
+            lines.push(fluent_i18n::t!(
+                "health-node-online-cluster-down",
+                { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net }
+            ));
+        } else if nh.containers.is_empty() {
+            lines.push(fluent_i18n::t!(
+                "health-node-idle",
+                { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net }
+            ));
+        } else {
+            lines.push(fluent_i18n::t!(
+                "health-node-online",
+                { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net }
+            ));
+            lines.extend(container_lines(&nh.containers));
+        }
+    }
+    let mut out = lines.join("\n");
+    out.push('\n');
+    out
+}
+
+fn header(r: &HealthReport) -> String {
+    let v = &r.verdict;
+    let total = r.nodes.len();
+    let online = r.nodes.iter().filter(|n| n.online).count();
+    let net_ok = r.nodes.iter().all(|n| !n.online || n.net_up);
+    let mut out =
+        fluent_i18n::t!("health-cluster-header", { "name" => &r.cluster, "nodes" => total });
+    if v.up {
+        out.push_str(&fluent_i18n::t!("health-all-online"));
+        if !net_ok {
+            out.push(' ');
+            out.push_str(&fluent_i18n::t!("health-interface-down"));
+        }
+        out.push_str(&free_and_containers(r));
+    } else if v.switch_off {
+        out.push(' ');
+        out.push_str(&fluent_i18n::t!("health-switch-off"));
+    } else {
+        out.push(' ');
+        out.push_str(&fluent_i18n::t!(
+            "health-partial-online",
+            { "online" => online, "offline" => total - online }
+        ));
+    }
+    if !r.soft_off.is_empty() {
+        out.push(' ');
+        out.push_str(&fluent_i18n::t!("health-soft-off", { "list" => &r.soft_off.join(", ") }));
+    }
+    if !v.up {
+        out.push(' ');
+        out.push_str(&fluent_i18n::t!("health-cluster-down"));
+    }
+    out
+}
+
+/// Container rows for one node, columns padded for alignment.
+fn container_lines<'a>(containers: &'a [String]) -> Vec<String> {
+    let rows: Vec<Vec<&'a str>> = containers
+        .iter()
+        .map(|c| c.split('\t').collect::<Vec<_>>())
+        .collect();
+    let width = |col: usize| {
+        rows.iter()
+            .filter_map(|r| r.get(col))
+            .map(|s| s.chars().count())
+            .max()
+            .unwrap_or(0)
+    };
+    let (wn, ws) = (width(0), width(1));
+    rows.iter()
+        .map(|r| {
+            let name = r.first().copied().unwrap_or("");
+            let status = r.get(1).copied().unwrap_or("");
+            let image = r.get(2).copied().unwrap_or("");
+            fluent_i18n::t!(
+                "health-container-line",
+                {
+                    "name" => format!("{:<wn$}", name),
+                    "status" => format!("{:<ws$}", status),
+                    "image" => image.to_string(),
+                }
+            )
+        })
+        .collect()
+}
+
+fn tmux(entries: &[TmuxHealthEntry]) -> String {
+    if entries.is_empty() {
+        return String::new();
+    }
+    let active = tmux_active(entries);
+    let mut out = String::from("\n");
+    if active.is_empty() {
+        out.push_str(&format!(
+            "  {}\n",
+            fluent_i18n::t!("tmux-health-all-stopped", { "count" => tmux_stopped_count(entries) })
+        ));
+    } else {
+        out.push_str(&fluent_i18n::t!("tmux-health-section"));
+        out.push('\n');
+        for e in active {
+            out.push_str(&format!("  {}\n", tmux_entry_line(e)));
+        }
+    }
+    out
+}
+
+fn recipes(fleet: &FleetHealth) -> String {
+    let Some(summary) = &fleet.recipes else {
+        return String::new();
+    };
+    let mut lines = Vec::new();
+    for (recipe, clusters) in sorted_summary(summary) {
+        for (_cluster, nodes) in clusters {
+            lines.push(fluent_i18n::t!(
+                "health-recipe-line",
+                { "recipe" => recipe, "nodes" => nodes.len() as u32, "list" => &nodes.join(", ") }
+            ));
+        }
+    }
+    let mut out = String::from("\n");
+    if lines.is_empty() {
+        out.push_str(&fluent_i18n::t!("health-no-recipes"));
+        out.push('\n');
+    } else {
+        out.push_str(&fluent_i18n::t!("health-running-recipes"));
+        out.push('\n');
+        for line in lines {
+            out.push_str(&line);
+            out.push('\n');
+        }
+    }
+    out
+}

+ 199 - 0
src/docker/health/tests.rs

@@ -137,3 +137,202 @@ async fn build_summary_excludes_down_clusters_and_sorts_nodes() {
     );
     assert!(!summary.by_recipe["ds4"].contains_key("down"));
 }
+
+/// Renderer tests for [`super::render`] — plain, markdown, and HTML —
+/// reusing the collection-model fixtures above.
+mod render {
+    use std::collections::HashMap;
+
+    use super::super::FleetHealth;
+    use super::super::render::render_fleet;
+    use super::{HealthReport, RecipesSummary, node, report};
+    use crate::cli::OutputFormat;
+    use crate::tmux::discover::TmuxHealthEntry;
+
+    fn fleet(clusters: Vec<HealthReport>, recipes: Option<RecipesSummary>) -> FleetHealth {
+        FleetHealth { clusters, recipes }
+    }
+
+    fn container(name: &str, status: &str, image: &str) -> String {
+        format!("{name}\t{status}\t{image}")
+    }
+
+    fn up_report() -> HealthReport {
+        let mut r = report("c1", false);
+        r.nodes = vec![node(
+            "n0",
+            true,
+            &[&container("app", "Up 2 hours", "img:tag")],
+        )];
+        r
+    }
+
+    fn down_report() -> HealthReport {
+        let mut r = report("down", false);
+        r.verdict.up = false;
+        r.verdict.free = 0;
+        r.nodes = vec![
+            node("alive", true, &[&container("secret", "Up", "img")]),
+            node("dead", false, &[]),
+        ];
+        r
+    }
+
+    fn tmux_entry(recipe: &str, node_name: &str, session: bool, running: bool) -> TmuxHealthEntry {
+        TmuxHealthEntry {
+            recipe_display: recipe.into(),
+            node_name: node_name.into(),
+            session_exists: session,
+            process_running: running,
+            api_ok: true,
+        }
+    }
+
+    fn tmux_report(entries: Vec<TmuxHealthEntry>) -> HealthReport {
+        let mut r = report("c1", false);
+        r.tmux = entries;
+        r
+    }
+
+    #[test]
+    fn render_fleet_handles_each_format() {
+        let f = fleet(vec![up_report()], None);
+        let plain = render_fleet(&f, OutputFormat::Plain);
+        let md = render_fleet(&f, OutputFormat::Md);
+        let html = render_fleet(&f, OutputFormat::Html);
+        assert!(plain.contains("=== c1 ==="));
+        // Container columns parsed out of the tab-separated lines.
+        assert!(plain.contains("app  Up 2 hours  img:tag"));
+        assert!(!plain.contains('\t'), "plain must not emit raw tab blobs");
+        assert!(md.contains("## c1"));
+        assert!(html.contains("<h2>c1</h2>"));
+        assert_ne!(plain, md);
+    }
+
+    #[test]
+    fn html_escapes_hostile_names() {
+        let mut r = report("a<b>&\"c'", false);
+        r.nodes = vec![node(
+            "<script>",
+            true,
+            &[&container("evil&img", "Up", "<x>")],
+        )];
+        let out = render_fleet(&fleet(vec![r], None), OutputFormat::Html);
+        assert!(
+            out.contains("&lt;script&gt;"),
+            "node name must be escaped: {out}"
+        );
+        assert!(
+            out.contains("evil&amp;img"),
+            "container name must be escaped: {out}"
+        );
+        assert!(!out.contains("<script>"));
+        assert!(!out.contains("evil&img"));
+    }
+
+    #[test]
+    fn md_renders_headings_and_tables() {
+        let out = render_fleet(&fleet(vec![up_report()], None), OutputFormat::Md);
+        assert!(out.contains("## c1"));
+        assert!(out.contains("| Node | Address | Status | Containers |"));
+        assert!(out.contains("| --- | --- | --- | --- |"));
+        assert!(out.contains("| app | n0 | Up 2 hours | img:tag |"));
+    }
+
+    #[test]
+    fn md_neutralizes_pipes_in_cells() {
+        let mut r = report("c1", false);
+        r.nodes = vec![node("n0", true, &[&container("bad|name", "Up", "img")])];
+        let out = render_fleet(&fleet(vec![r], None), OutputFormat::Md);
+        assert!(out.contains("bad\\|name"), "pipe must be escaped: {out}");
+        assert!(
+            out.contains("| bad\\|name | n0 | Up | img |"),
+            "table structure must stay intact: {out}"
+        );
+    }
+
+    #[test]
+    fn down_cluster_suppresses_container_data() {
+        for fmt in [OutputFormat::Plain, OutputFormat::Md, OutputFormat::Html] {
+            let out = render_fleet(&fleet(vec![down_report()], None), fmt);
+            assert!(
+                !out.contains("secret"),
+                "{fmt:?} must suppress containers: {out}"
+            );
+            assert!(
+                out.contains("offline"),
+                "{fmt:?} must show the offline node: {out}"
+            );
+        }
+    }
+
+    #[test]
+    fn recipes_none_renders_no_recipes_section() {
+        for fmt in [OutputFormat::Plain, OutputFormat::Md, OutputFormat::Html] {
+            let out = render_fleet(&fleet(vec![up_report()], None), fmt);
+            assert!(!out.contains("Running recipes:"), "{fmt:?}");
+        }
+    }
+
+    #[test]
+    fn tmux_all_stopped_shows_summary_line() {
+        for fmt in [OutputFormat::Plain, OutputFormat::Md, OutputFormat::Html] {
+            let r = tmux_report(vec![tmux_entry("rec", "dgx1", false, false)]);
+            let out = render_fleet(&fleet(vec![r], Some(RecipesSummary::default())), fmt);
+            assert!(out.contains("all stopped"), "{fmt:?}: {out}");
+        }
+    }
+
+    #[test]
+    fn tmux_running_entry_rendered_stopped_skipped() {
+        for fmt in [OutputFormat::Plain, OutputFormat::Md, OutputFormat::Html] {
+            let r = tmux_report(vec![
+                tmux_entry("rec", "dgx1", true, true),
+                tmux_entry("rec", "dgx2", false, false),
+            ]);
+            let out = render_fleet(&fleet(vec![r], None), fmt);
+            assert!(out.contains("rec \u{2014} dgx1"), "{fmt:?}: {out}");
+            assert!(!out.contains("dgx2"), "{fmt:?} must skip stopped: {out}");
+        }
+    }
+
+    #[test]
+    fn recipes_summary_is_sorted_and_deterministic() {
+        let build = || {
+            let mut summary = RecipesSummary::default();
+            let mut z = HashMap::new();
+            z.insert("zcluster".to_string(), vec!["n1".to_string()]);
+            summary.by_recipe.insert("zebra".to_string(), z);
+            let mut a = HashMap::new();
+            a.insert("c".to_string(), vec!["n2".to_string()]);
+            summary.by_recipe.insert("alpha".to_string(), a);
+            FleetHealth {
+                clusters: vec![],
+                recipes: Some(summary),
+            }
+        };
+        let first = render_fleet(&build(), OutputFormat::Plain);
+        let second = render_fleet(&build(), OutputFormat::Plain);
+        assert_eq!(first, second, "rendering must be deterministic");
+        let alpha = first.find("alpha").expect("alpha rendered");
+        let zebra = first.find("zebra").expect("zebra rendered");
+        assert!(alpha < zebra, "recipes must be sorted");
+    }
+
+    #[tokio::test]
+    async fn renders_in_active_locale() {
+        crate::locale::set_global_locale(Some("ru-RU".to_string()));
+        let out = crate::locale::spawn_with_locale(async {
+            render_fleet(
+                &fleet(vec![], Some(RecipesSummary::default())),
+                OutputFormat::Plain,
+            )
+        })
+        .await
+        .unwrap();
+        assert!(
+            out.contains("Нет запущенных"),
+            "expected Russian no-recipes line: {out}"
+        );
+    }
+}

+ 6 - 8
src/main.rs

@@ -31,7 +31,7 @@ mod ssh;
 mod tmux;
 mod web;
 
-use crate::cli::{Cli, Commands, PowerAction};
+use crate::cli::{Cli, Commands, OutputFormat, PowerAction};
 use crate::config::{
     Cluster, Config, DeployTarget, load_recipe, resolve_cluster, select_cluster_by_name,
 };
@@ -146,8 +146,9 @@ async fn main() -> Result<()> {
         Commands::Health {
             cluster: cluster_name,
             fingerprint,
+            format,
         } => {
-            handle_health(&config, cluster_name, fingerprint).await?;
+            handle_health(&config, cluster_name, fingerprint, format).await?;
         }
         Commands::Doctor {
             recipe,
@@ -391,6 +392,7 @@ async fn handle_health(
     config: &Config,
     cluster_name: Option<String>,
     fingerprint: bool,
+    format: OutputFormat,
 ) -> Result<()> {
     if fingerprint {
         let clusters_to_check: Vec<Cluster> = match &cluster_name {
@@ -419,15 +421,11 @@ async fn handle_health(
         match cluster_name {
             Some(ref name) => {
                 let cluster = select_cluster_by_name(config, name)?;
-                docker::health::health_check(config, &cluster, false).await?;
+                docker::health::health_check(config, &cluster, false, format).await?;
             }
             None => {
                 let fleet = docker::health::collect(config, None, false).await?;
-                for report in &fleet.clusters {
-                    docker::health::print_health(report);
-                    tmux::discover::print_tmux_health(&report.tmux);
-                }
-                docker::health::health_summary(&fleet);
+                docker::health::print_fleet(&fleet, format);
             }
         }
     }

+ 0 - 95
src/tmux/discover.rs

@@ -5,8 +5,6 @@
 //! checks each node for active sessions, and reports status alongside
 //! the Docker container listing.
 
-use std::collections::HashSet;
-
 use tracing::warn;
 
 use crate::config::{Cluster, Config, DeployTarget, Recipe};
@@ -87,58 +85,6 @@ pub async fn tmux_health_for_cluster(config: &Config, cluster: &Cluster) -> Vec<
     entries
 }
 
-/// Prints tmux health entries to stdout.
-///
-/// Only shows sessions that are running or degraded (session exists).
-/// If all sessions are stopped, prints a single summary line.
-/// Shows nothing if there are no tmux-deployed recipes at all.
-pub fn print_tmux_health(entries: &[TmuxHealthEntry]) {
-    if entries.is_empty() {
-        return;
-    }
-
-    // Only show running/degraded sessions — skip stopped ones to keep output readable.
-    let active: Vec<&TmuxHealthEntry> = entries.iter().filter(|e| e.session_exists).collect();
-
-    if active.is_empty() {
-        // All sessions stopped — show a one-line summary.
-        let recipe_count = entries
-            .iter()
-            .map(|e| e.recipe_display.as_str())
-            .collect::<HashSet<_>>()
-            .len();
-        println!();
-        println!(
-            "  {}",
-            fluent_i18n::t!("tmux-health-all-stopped", { "count" => recipe_count })
-        );
-        return;
-    }
-
-    println!();
-    println!("{}", fluent_i18n::t!("tmux-health-section"));
-    for entry in active {
-        let status = if entry.process_running {
-            let api_str = if entry.api_ok {
-                fluent_i18n::t!("tmux-api-ok")
-            } else {
-                fluent_i18n::t!("tmux-api-down")
-            };
-            fluent_i18n::t!("tmux-health-entry-running", {
-                "recipe" => &entry.recipe_display,
-                "node" => &entry.node_name,
-                "api" => &api_str,
-            })
-        } else {
-            fluent_i18n::t!("tmux-health-entry-degraded", {
-                "recipe" => &entry.recipe_display,
-                "node" => &entry.node_name,
-            })
-        };
-        println!("  {}", status);
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -172,45 +118,4 @@ mod tests {
             assert_eq!(recipe.deploy, DeployTarget::Tmux);
         }
     }
-
-    #[test]
-    fn print_tmux_health_empty_is_noop() {
-        // Should not panic or print anything
-        print_tmux_health(&[]);
-    }
-
-    #[test]
-    fn print_tmux_health_all_stopped_shows_summary() {
-        let entries = vec![TmuxHealthEntry {
-            recipe_display: "Test Recipe".to_string(),
-            node_name: "dgx1".to_string(),
-            session_exists: false,
-            process_running: false,
-            api_ok: false,
-        }];
-        // Should not panic — prints summary line
-        print_tmux_health(&entries);
-    }
-
-    #[test]
-    fn print_tmux_health_running_shows_entry() {
-        let entries = vec![
-            TmuxHealthEntry {
-                recipe_display: "Test Recipe".to_string(),
-                node_name: "dgx1".to_string(),
-                session_exists: true,
-                process_running: true,
-                api_ok: true,
-            },
-            TmuxHealthEntry {
-                recipe_display: "Test Recipe".to_string(),
-                node_name: "dgx2".to_string(),
-                session_exists: false,
-                process_running: false,
-                api_ok: false,
-            },
-        ];
-        // Should not panic — prints running entry, skips stopped
-        print_tmux_health(&entries);
-    }
 }