|
|
@@ -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
|