소스 검색

task: Render the sm121 health cluster report readably in plain format from the unified HealthReport (TASK_0003)

Alexander Korolev 1 개월 전
부모
커밋
6e3b31bccd
4개의 변경된 파일과 228개의 추가작업 그리고 60개의 파일을 삭제
  1. 5 5
      locales/en/main.ftl
  2. 5 5
      locales/ru/main.ftl
  3. 94 50
      src/docker/health/render/plain.rs
  4. 124 0
      src/docker/health/tests.rs

+ 5 - 5
locales/en/main.ftl

@@ -35,7 +35,7 @@ health-switch-off = 🚫 switch off
 health-interface-down = 🌐 interface down
 health-all-online = ✅ all online
 health-partial-online =  ({ $online } online, { $offline } offline)
-health-node-online-cluster-down =   ◍ { $name } ({ $ip }){ $net } — online (cluster offline)
+health-node-online-cluster-down = ◍ { $name } ({ $ip }){ $net } — online (cluster offline)
 health-free-count = — { $free } free
 health-cluster-down = ⏻ cluster offline
 health-soft-off = ⚙ { $list } off (non-blocking)
@@ -133,9 +133,9 @@ tmux-no-pty = (russh does not support interactive PTY. Use the following command
 sync-digest-match = '{ $name }' OK (digest matches)
 sync-syncing = { $name } ({ $ip }): syncing...
 sync-synced = { $name } OK
-health-node-offline =   ✗ { $name } ({ $ip }) — offline
-health-node-idle =   ○ { $name } ({ $ip }){ $net }
-health-node-online =   ● { $name } ({ $ip }){ $net }
+health-node-offline = ✗ { $name } ({ $ip }) — offline
+health-node-idle = ○ { $name } ({ $ip }){ $net }
+health-node-online = ● { $name } ({ $ip }){ $net }
 health-container-count = , { $count } { $count ->
         [one] container
        *[other] containers
@@ -203,4 +203,4 @@ health-col-cluster = Cluster
 health-col-nodes = Nodes
 health-status-online = ● online
 health-status-offline = ✗ offline
-health-container-line =     { $name }  { $status }  { $image }
+health-container-line = { $name }  { $status }  { $image }

+ 5 - 5
locales/ru/main.ftl

@@ -37,7 +37,7 @@ health-switch-off = 🚫 переключатель выключен
 health-interface-down = 🌐 интерфейс недоступен
 health-all-online = ✅ все онлайн
 health-partial-online =  ({ $online } онлайн, { $offline } офлайн)
-health-node-online-cluster-down =   ◍ { $name } ({ $ip }){ $net } — онлайн (кластер офлайн)
+health-node-online-cluster-down = ◍ { $name } ({ $ip }){ $net } — онлайн (кластер офлайн)
 health-free-count = — { $free } свободно
 health-cluster-down = ⏻ кластер офлайн
 health-soft-off = ⚙ { $list } выкл. (не блокирует)
@@ -136,9 +136,9 @@ tmux-no-pty = (russh не поддерживает интерактивный PT
 sync-digest-match = '{ $name }' OK (дайджест совпадает)
 sync-syncing = { $name } ({ $ip }): синхронизация...
 sync-synced = { $name } OK
-health-node-offline =   ✗ { $name } ({ $ip }) — офлайн
-health-node-idle =   ○ { $name } ({ $ip }){ $net }
-health-node-online =   ● { $name } ({ $ip }){ $net }
+health-node-offline = ✗ { $name } ({ $ip }) — офлайн
+health-node-idle = ○ { $name } ({ $ip }){ $net }
+health-node-online = ● { $name } ({ $ip }){ $net }
 health-container-count = , { $count } { $count ->
         [one] контейнер
         [few] контейнера
@@ -214,4 +214,4 @@ health-col-cluster = Кластер
 health-col-nodes = Ноды
 health-status-online = ● онлайн
 health-status-offline = ✗ офлайн
-health-container-line =     { $name }  { $status }  { $image }
+health-container-line = { $name }  { $status }  { $image }

+ 94 - 50
src/docker/health/render/plain.rs

@@ -1,5 +1,7 @@
-//! Plain-text renderer — closest to the historical ad-hoc output, with
-//! container columns split out of the raw tab-separated lines.
+//! Plain-text renderer — one header/verdict line per cluster, one line
+//! per node, container rows split out of the raw tab-separated lines
+//! and padded to report-global column widths so they read as one
+//! continuous table.
 
 use super::{
     FleetHealth, HealthReport, free_and_containers, sorted_summary, tmux_active, tmux_entry_line,
@@ -8,51 +10,76 @@ use super::{
 use crate::tmux::discover::TmuxHealthEntry;
 
 pub(super) fn fleet(fleet: &FleetHealth) -> String {
-    let mut out = String::new();
+    let widths = container_widths(&fleet.clusters);
+    let mut sections = Vec::new();
     for r in &fleet.clusters {
-        out.push_str(&cluster(r));
-        out.push_str(&tmux(&r.tmux));
+        sections.push(cluster(r, widths));
+        sections.push(tmux(&r.tmux));
     }
-    out.push_str(&recipes(fleet));
-    out
+    sections.push(recipes(fleet));
+    sections
+        .into_iter()
+        .filter(|s| !s.is_empty())
+        .collect::<Vec<_>>()
+        .join("\n")
 }
 
 /// One cluster: header/verdict line + per-node rows with container
-/// columns padded for alignment (no raw tab blobs).
-fn cluster(r: &HealthReport) -> String {
+/// columns padded to the report-global widths (no raw tab blobs).
+fn cluster(r: &HealthReport, (wn, ws): (usize, usize)) -> 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 }
+            // Node rows are indented under the header; container rows
+            // one level deeper. The FTL parser trims leading pattern
+            // whitespace, so indents are emitted here, Rust-side.
+            lines.push(format!(
+                "  {}",
+                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 {
+        // Net state is a per-node fact: shown whenever it is down, even
+        // when the whole cluster is down.
+        let net = if nh.net_up {
             String::new()
+        } else {
+            format!(
+                " {}",
+                fluent_i18n::t!("health-net-status", { "details" => nh.net_details.join(", ") })
+            )
         };
         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 }
+            lines.push(format!(
+                "  {}",
+                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 }
+            lines.push(format!(
+                "  {}",
+                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.push(format!(
+                "  {}",
+                fluent_i18n::t!(
+                    "health-node-online",
+                    { "name" => &nh.name, "ip" => &nh.ib_ip, "net" => &net }
+                )
             ));
-            lines.extend(container_lines(&nh.containers));
+            lines.extend(container_lines(&nh.containers, wn, ws));
         }
     }
     let mut out = lines.join("\n");
@@ -62,17 +89,19 @@ fn cluster(r: &HealthReport) -> String {
 
 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 total = r.nodes.len() as u32;
+    let online = r.nodes.iter().filter(|n| n.online).count() as u32;
     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(' ');
         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(' ');
         out.push_str(&free_and_containers(r));
     } else if v.switch_off {
         out.push(' ');
@@ -95,29 +124,45 @@ fn header(r: &HealthReport) -> String {
     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
+/// Name/status column widths padded across every rendered container row
+/// in the report, so all rows align as one continuous table. Rows only
+/// exist for up clusters (down clusters suppress containers).
+fn container_widths(clusters: &[HealthReport]) -> (usize, usize) {
+    let mut widths = (0usize, 0usize);
+    for r in clusters {
+        if !r.verdict.up {
+            continue;
+        }
+        for n in &r.nodes {
+            for c in &n.containers {
+                let mut parts = c.split('\t');
+                if let Some(name) = parts.next() {
+                    widths.0 = widths.0.max(name.chars().count());
+                }
+                if let Some(status) = parts.next() {
+                    widths.1 = widths.1.max(status.chars().count());
+                }
+            }
+        }
+    }
+    widths
+}
+
+/// Container rows for one node: 4-space indent under the node line,
+/// name/status padded to the report-wide widths (two-space separators
+/// come from the FTL line).
+fn container_lines(containers: &[String], wn: usize, ws: usize) -> Vec<String> {
+    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("");
+        .map(|c| {
+            let mut parts = c.split('\t');
+            let name = parts.next().unwrap_or("");
+            let status = parts.next().unwrap_or("");
+            let image = parts.next().unwrap_or("");
             fluent_i18n::t!(
                 "health-container-line",
                 {
-                    "name" => format!("{:<wn$}", name),
+                    "name" => format!("    {:<wn$}", name),
                     "status" => format!("{:<ws$}", status),
                     "image" => image.to_string(),
                 }
@@ -131,7 +176,7 @@ fn tmux(entries: &[TmuxHealthEntry]) -> String {
         return String::new();
     }
     let active = tmux_active(entries);
-    let mut out = String::from("\n");
+    let mut out = String::new();
     if active.is_empty() {
         out.push_str(&format!(
             "  {}\n",
@@ -160,7 +205,7 @@ fn recipes(fleet: &FleetHealth) -> String {
             ));
         }
     }
-    let mut out = String::from("\n");
+    let mut out = String::new();
     if lines.is_empty() {
         out.push_str(&fluent_i18n::t!("health-no-recipes"));
         out.push('\n');
@@ -168,8 +213,7 @@ fn recipes(fleet: &FleetHealth) -> String {
         out.push_str(&fluent_i18n::t!("health-running-recipes"));
         out.push('\n');
         for line in lines {
-            out.push_str(&line);
-            out.push('\n');
+            out.push_str(&format!("  {line}\n"));
         }
     }
     out

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

@@ -319,6 +319,130 @@ mod render {
         assert!(alpha < zebra, "recipes must be sorted");
     }
 
+    /// The improved plain layout: headers + verdict tails, one line per
+    /// node, report-globally aligned container columns, down-cluster
+    /// suppression, tmux collapse, sorted recipes.
+    #[test]
+    fn plain_layout_is_aligned_and_suppressed() {
+        let mut up = report("up1", false);
+        let mut n2 = node("n2", true, &[]);
+        n2.net_up = false;
+        n2.net_details = vec!["socket:DOWN".to_string()];
+        up.nodes = vec![
+            node(
+                "n0",
+                true,
+                &[
+                    &container("web-a", "Up 5 minutes", "img/one:1"),
+                    &container("web-b-long", "Exited (0) 3 hours ago", "img/two:22"),
+                ],
+            ),
+            node(
+                "n1",
+                true,
+                &[&container("web-c", "Up 2 hours", "img/three:3")],
+            ),
+            n2,
+        ];
+        up.tmux = vec![
+            tmux_entry("rec", "dgx1", true, true),
+            tmux_entry("rec", "dgx2", false, false),
+        ];
+        let mut down = down_report();
+        let mut alive = node("alive", true, &[&container("hidden", "Up", "img")]);
+        alive.net_up = false;
+        alive.net_details = vec!["socket:DOWN".to_string()];
+        down.nodes = vec![alive, node("dead", false, &[])];
+        down.tmux = vec![tmux_entry("stopped_rec", "dgx3", false, false)];
+        let mut summary = RecipesSummary::default();
+        summary
+            .by_recipe
+            .entry("zebra".to_string())
+            .or_default()
+            .insert("zc".to_string(), vec!["n9".to_string()]);
+        summary
+            .by_recipe
+            .entry("alpha".to_string())
+            .or_default()
+            .insert("ac".to_string(), vec!["n8".to_string()]);
+        let out = render_fleet(&fleet(vec![up, down], Some(summary)), OutputFormat::Plain);
+
+        // One header + verdict tail per cluster.
+        assert!(out.contains("=== up1 ==="), "{out}");
+        assert!(out.contains("=== down ==="), "{out}");
+        assert!(out.contains("cluster offline"), "down verdict tail: {out}");
+        assert!(
+            out.contains("interface down"),
+            "net-down tail on the up cluster: {out}"
+        );
+
+        // One line per node; offline nodes get an explicit offline
+        // line; node/container rows are indented under the header.
+        assert!(out.contains("dead (10.0.0.1)"), "{out}");
+        assert!(out.contains("— offline"), "{out}");
+        assert!(
+            out.lines().any(|l| l.starts_with("  ● ")),
+            "node indent: {out}"
+        );
+        assert!(
+            out.lines().any(|l| l.starts_with("  ✗ dead")),
+            "offline node indent: {out}"
+        );
+        assert!(
+            out.lines().any(|l| l.starts_with("    web-a ")),
+            "container indent: {out}"
+        );
+
+        // Net details whenever the interface is down — on the up
+        // cluster (n2) and on the down cluster (alive).
+        assert_eq!(
+            out.matches("socket:DOWN").count(),
+            2,
+            "net details must show on down nodes in both clusters: {out}"
+        );
+
+        // Down cluster suppresses container data entirely.
+        assert!(!out.contains("hidden"), "{out}");
+
+        // Container rows from different nodes align on report-global
+        // widths: the image column starts at the same offset everywhere.
+        let image_col = |needle: &str| {
+            out.lines()
+                .find(|l| l.contains(needle))
+                .unwrap_or_else(|| panic!("{needle} missing: {out}"))
+                .find(needle)
+                .unwrap()
+        };
+        assert_eq!(
+            image_col("img/one:1"),
+            image_col("img/three:3"),
+            "container rows must align across nodes: {out}"
+        );
+        assert_eq!(image_col("img/one:1"), image_col("img/two:22"), "{out}");
+        assert!(!out.contains('\t'), "plain output must have no tabs: {out}");
+
+        // tmux: active entries render, stopped ones are skipped; an
+        // all-stopped cluster collapses to one count line.
+        assert!(out.contains("rec — dgx1"), "{out}");
+        assert!(!out.contains("dgx2"), "{out}");
+        assert!(out.contains("all stopped"), "{out}");
+
+        // Recipes summary sorted by recipe.
+        let alpha = out.find("alpha").expect("alpha rendered");
+        let zebra = out.find("zebra").expect("zebra rendered");
+        assert!(alpha < zebra, "recipes sorted: {out}");
+
+        // Single-cluster runs omit the recipes section.
+        let solo = render_fleet(
+            &fleet(vec![report("solo", false)], None),
+            OutputFormat::Plain,
+        );
+        assert!(
+            !solo.contains("Running recipes:"),
+            "single-cluster run omits recipes: {solo}"
+        );
+    }
+
     #[tokio::test]
     async fn renders_in_active_locale() {
         crate::locale::set_global_locale(Some("ru-RU".to_string()));