use crate::commands::WorkerProbeResult;
use crate::ui::console::RchConsole;
#[cfg(all(feature = "rich-ui", unix))]
use rich_rust::prelude::*;
#[cfg(all(feature = "rich-ui", unix))]
use rich_rust::renderables::{Column, Row, Table};
use rch_common::ui::{Icons, OutputContext, RchTheme};
const LATENCY_EXCELLENT_MS: u64 = 50;
const LATENCY_ACCEPTABLE_MS: u64 = 200;
#[derive(Debug, Clone)]
pub struct DetailedProbeResult {
pub basic: WorkerProbeResult,
pub ssh_ok: bool,
pub rsync_available: Option<bool>,
pub disk_space_bytes: Option<u64>,
pub cpu_count: Option<u32>,
pub rust_version: Option<String>,
}
impl DetailedProbeResult {
pub fn from_basic(basic: WorkerProbeResult, ssh_ok: bool) -> Self {
Self {
basic,
ssh_ok,
rsync_available: None,
disk_space_bytes: None,
cpu_count: None,
rust_version: None,
}
}
pub fn is_success(&self) -> bool {
self.basic.status == "ok" || self.basic.status == "healthy"
}
pub fn latency_category(&self) -> LatencyCategory {
match self.basic.latency_ms {
Some(ms) if ms < LATENCY_EXCELLENT_MS => LatencyCategory::Excellent,
Some(ms) if ms < LATENCY_ACCEPTABLE_MS => LatencyCategory::Acceptable,
Some(_) => LatencyCategory::Degraded,
None => LatencyCategory::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LatencyCategory {
Excellent,
Acceptable,
Degraded,
Unknown,
}
impl LatencyCategory {
#[cfg(all(feature = "rich-ui", unix))]
pub fn color(&self) -> &'static str {
match self {
Self::Excellent => RchTheme::SUCCESS,
Self::Acceptable => RchTheme::WARNING,
Self::Degraded => RchTheme::ERROR,
Self::Unknown => RchTheme::MUTED,
}
}
}
pub struct ProbeResultTable<'a> {
results: &'a [DetailedProbeResult],
context: OutputContext,
verbose: bool,
}
impl<'a> ProbeResultTable<'a> {
#[must_use]
pub fn new(results: &'a [DetailedProbeResult], context: OutputContext) -> Self {
Self {
results,
context,
verbose: false,
}
}
#[must_use]
pub fn from_results(results: &'a [DetailedProbeResult]) -> Self {
Self::new(results, OutputContext::detect())
}
#[must_use]
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn render(&self, console: &RchConsole) {
if console.is_machine() {
return;
}
#[cfg(all(feature = "rich-ui", unix))]
if console.is_rich() {
self.render_rich(console);
return;
}
self.render_plain(console);
}
#[cfg(all(feature = "rich-ui", unix))]
fn render_rich(&self, console: &RchConsole) {
if self.results.is_empty() {
self.render_empty_rich(console);
return;
}
let mut table = Table::new()
.title("Probe Results")
.border_style(RchTheme::secondary());
table = table
.with_column(Column::new("Worker").header_style(RchTheme::table_header()))
.with_column(Column::new("Host").header_style(RchTheme::table_header()))
.with_column(Column::new("Status").header_style(RchTheme::table_header()))
.with_column(Column::new("Latency").header_style(RchTheme::table_header()))
.with_column(Column::new("SSH").header_style(RchTheme::table_header()));
if self.verbose {
table = table
.with_column(Column::new("rsync").header_style(RchTheme::table_header()))
.with_column(Column::new("CPUs").header_style(RchTheme::table_header()))
.with_column(Column::new("Disk").header_style(RchTheme::table_header()));
}
for result in self.results {
let status_text = self.format_status_rich(result);
let latency_text = self.format_latency_rich(result);
let ssh_text = self.format_bool_status(result.ssh_ok);
let mut cells = vec![
Cell::new(result.basic.id.as_str()),
Cell::new(result.basic.host.as_str()),
Cell::new(status_text.as_str()),
Cell::new(latency_text.as_str()),
Cell::new(ssh_text.as_str()),
];
if self.verbose {
let rsync_text = result
.rsync_available
.map(|b| self.format_bool_status(b))
.unwrap_or_else(|| "-".to_string());
let cpu_text = result
.cpu_count
.map(|c| c.to_string())
.unwrap_or_else(|| "-".to_string());
let disk_text = result
.disk_space_bytes
.map(format_bytes)
.unwrap_or_else(|| "-".to_string());
cells.push(Cell::new(rsync_text.as_str()));
cells.push(Cell::new(cpu_text.as_str()));
cells.push(Cell::new(disk_text.as_str()));
}
table = table.with_row(Row::new(cells));
}
console.print_renderable(&table);
self.render_errors_rich(console);
self.render_summary_rich(console);
}
#[cfg(all(feature = "rich-ui", unix))]
fn render_empty_rich(&self, console: &RchConsole) {
let info = Icons::info(self.context);
let content = format!(
"{} No workers to probe\n\nAdd workers with: rch workers init",
info
);
let panel = Panel::from_text(&content)
.title("Probe Results")
.border_style(RchTheme::muted())
.rounded();
console.print_renderable(&panel);
}
#[cfg(all(feature = "rich-ui", unix))]
fn render_errors_rich(&self, console: &RchConsole) {
let errors: Vec<_> = self
.results
.iter()
.filter(|r| r.basic.error.is_some())
.collect();
if errors.is_empty() {
return;
}
console.line();
for result in errors {
if let Some(ref err) = result.basic.error {
let icon = Icons::cross(self.context);
console.print_or_plain(
&format!(
"[{}]{} {}[/]: {}",
RchTheme::ERROR,
icon,
result.basic.id,
err
),
&format!("{} {}: {}", icon, result.basic.id, err),
);
}
}
}
#[cfg(all(feature = "rich-ui", unix))]
fn format_status_rich(&self, result: &DetailedProbeResult) -> String {
if result.is_success() {
format!("{} OK", Icons::check(self.context))
} else {
format!("{} FAIL", Icons::cross(self.context))
}
}
#[cfg(all(feature = "rich-ui", unix))]
fn format_latency_rich(&self, result: &DetailedProbeResult) -> String {
match result.basic.latency_ms {
Some(ms) => {
let category = result.latency_category();
let color = category.color();
format!("[{}]{}ms[/]", color, ms)
}
None => "-".to_string(),
}
}
fn format_bool_status(&self, value: bool) -> String {
if value {
Icons::check(self.context).to_string()
} else {
Icons::cross(self.context).to_string()
}
}
#[cfg(all(feature = "rich-ui", unix))]
fn render_summary_rich(&self, console: &RchConsole) {
let (passed, failed) = self.count_results();
let total = self.results.len();
let avg_latency = self.average_latency();
let summary = ProbeSummary {
total,
passed,
failed,
average_latency_ms: avg_latency,
};
console.line();
summary.render(console, self.context);
}
fn render_plain(&self, console: &RchConsole) {
if self.results.is_empty() {
console.print_plain("=== Probe Results ===");
console.print_plain("");
let info = Icons::info(self.context);
console.print_plain(&format!("{info} No workers to probe"));
console.print_plain(" Add workers with: rch workers init");
return;
}
console.print_plain("=== Probe Results ===");
console.print_plain("");
if self.verbose {
console.print_plain(&format!(
"{:12} {:20} {:8} {:10} {:5} {:6} {:5} {:10}",
"Worker", "Host", "Status", "Latency", "SSH", "rsync", "CPUs", "Disk"
));
console.print_plain(&"-".repeat(80));
} else {
console.print_plain(&format!(
"{:12} {:20} {:8} {:10} {:5}",
"Worker", "Host", "Status", "Latency", "SSH"
));
console.print_plain(&"-".repeat(60));
}
for result in self.results {
let status_text = self.format_status_plain(result);
let latency_text = self.format_latency_plain(result);
let ssh_text = if result.ssh_ok {
Icons::check(self.context)
} else {
Icons::cross(self.context)
};
if self.verbose {
let rsync_text = result
.rsync_available
.map(|b| {
if b {
Icons::check(self.context)
} else {
Icons::cross(self.context)
}
})
.unwrap_or("-");
let cpu_text = result
.cpu_count
.map(|c| c.to_string())
.unwrap_or_else(|| "-".to_string());
let disk_text = result
.disk_space_bytes
.map(format_bytes)
.unwrap_or_else(|| "-".to_string());
console.print_plain(&format!(
"{:12} {:20} {:8} {:10} {:5} {:6} {:5} {:10}",
truncate_str(&result.basic.id, 12),
truncate_str(&result.basic.host, 20),
status_text,
latency_text,
ssh_text,
rsync_text,
cpu_text,
disk_text
));
} else {
console.print_plain(&format!(
"{:12} {:20} {:8} {:10} {:5}",
truncate_str(&result.basic.id, 12),
truncate_str(&result.basic.host, 20),
status_text,
latency_text,
ssh_text
));
}
if let Some(ref err) = result.basic.error {
let warning = Icons::warning(self.context);
console.print_plain(&format!(" {warning} {err}"));
}
}
console.print_plain("");
let (passed, failed) = self.count_results();
let total = self.results.len();
let avg_latency = self.average_latency();
let summary = ProbeSummary {
total,
passed,
failed,
average_latency_ms: avg_latency,
};
summary.render(console, self.context);
}
fn format_status_plain(&self, result: &DetailedProbeResult) -> String {
let icon = if result.is_success() {
Icons::check(self.context)
} else {
Icons::cross(self.context)
};
let label = if result.is_success() { "OK" } else { "FAIL" };
format!("{} {}", icon, label)
}
fn format_latency_plain(&self, result: &DetailedProbeResult) -> String {
match result.basic.latency_ms {
Some(ms) => {
let indicator = match result.latency_category() {
LatencyCategory::Excellent => Icons::status_healthy(self.context),
LatencyCategory::Acceptable => Icons::warning(self.context),
LatencyCategory::Degraded => Icons::cross(self.context),
LatencyCategory::Unknown => " ",
};
format!("{} {}ms", indicator, ms)
}
None => "-".to_string(),
}
}
fn count_results(&self) -> (usize, usize) {
let passed = self.results.iter().filter(|r| r.is_success()).count();
let failed = self.results.len() - passed;
(passed, failed)
}
fn average_latency(&self) -> Option<u64> {
let latencies: Vec<u64> = self
.results
.iter()
.filter_map(|r| r.basic.latency_ms)
.collect();
if latencies.is_empty() {
None
} else {
Some(latencies.iter().sum::<u64>() / latencies.len() as u64)
}
}
}
#[derive(Debug, Clone)]
pub struct ProbeSummary {
pub total: usize,
pub passed: usize,
pub failed: usize,
pub average_latency_ms: Option<u64>,
}
impl ProbeSummary {
pub fn render(&self, console: &RchConsole, context: OutputContext) {
let status_icon = if self.failed == 0 {
Icons::check(context)
} else {
Icons::warning(context)
};
let latency_str = self
.average_latency_ms
.map(|ms| format!(" | Avg latency: {}ms", ms))
.unwrap_or_default();
let summary = format!(
"{} Passed: {}/{} | Failed: {}{}",
status_icon, self.passed, self.total, self.failed, latency_str
);
console.print_plain(&summary);
self.render_recommendations(console, context);
}
fn render_recommendations(&self, console: &RchConsole, context: OutputContext) {
if self.failed > 0 {
let info = Icons::info(context);
console.print_plain(&format!(
"\n{} Tip: Use 'rch workers probe --verbose' for detailed diagnostics",
info
));
if self.failed == self.total {
let warning = Icons::warning(context);
console.print_plain(&format!(
"{} All workers failed. Check SSH connectivity and worker configuration.",
warning
));
}
}
if let Some(avg_ms) = self.average_latency_ms
&& avg_ms > LATENCY_ACCEPTABLE_MS
{
let warning = Icons::warning(context);
console.print_plain(&format!(
"{} High average latency ({}ms). Consider using workers with lower latency.",
warning, avg_ms
));
}
}
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
const TB: u64 = GB * 1024;
if bytes >= TB {
format!("{:.1}TB", bytes as f64 / TB as f64)
} else if bytes >= GB {
format!("{:.1}GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1}MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1}KB", bytes as f64 / KB as f64)
} else {
format!("{}B", bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::WorkerProbeResult;
fn sample_probe_results() -> Vec<DetailedProbeResult> {
vec![
DetailedProbeResult {
basic: WorkerProbeResult {
id: "worker-1".to_string(),
host: "192.168.1.10".to_string(),
status: "ok".to_string(),
latency_ms: Some(25),
error: None,
error_code: None,
capabilities: None,
missing_components: Vec::new(),
disk: Default::default(),
},
ssh_ok: true,
rsync_available: Some(true),
disk_space_bytes: Some(100 * 1024 * 1024 * 1024), cpu_count: Some(8),
rust_version: Some("1.75.0".to_string()),
},
DetailedProbeResult {
basic: WorkerProbeResult {
id: "worker-2".to_string(),
host: "192.168.1.11".to_string(),
status: "ok".to_string(),
latency_ms: Some(120),
error: None,
error_code: None,
capabilities: None,
missing_components: Vec::new(),
disk: Default::default(),
},
ssh_ok: true,
rsync_available: Some(true),
disk_space_bytes: Some(50 * 1024 * 1024 * 1024), cpu_count: Some(4),
rust_version: Some("1.74.0".to_string()),
},
DetailedProbeResult {
basic: WorkerProbeResult {
id: "worker-3".to_string(),
host: "192.168.1.12".to_string(),
status: "failed".to_string(),
latency_ms: None,
error: Some("Connection refused".to_string()),
error_code: Some("RCH-E108".to_string()),
capabilities: None,
missing_components: Vec::new(),
disk: Default::default(),
},
ssh_ok: false,
rsync_available: None,
disk_space_bytes: None,
cpu_count: None,
rust_version: None,
},
]
}
#[test]
fn test_probe_result_table_creation() {
let results = sample_probe_results();
let ctx = OutputContext::Plain;
let table = ProbeResultTable::new(&results, ctx);
assert_eq!(table.context, OutputContext::Plain);
assert_eq!(table.results.len(), 3);
}
#[test]
fn test_probe_result_from_results() {
let results = sample_probe_results();
let table = ProbeResultTable::from_results(&results);
let _ = table.context; }
#[test]
fn test_detailed_probe_result_is_success() {
let results = sample_probe_results();
assert!(results[0].is_success());
assert!(results[1].is_success());
assert!(!results[2].is_success());
}
#[test]
fn test_latency_category_excellent() {
let result = DetailedProbeResult {
basic: WorkerProbeResult {
id: "test".to_string(),
host: "test".to_string(),
status: "ok".to_string(),
latency_ms: Some(25),
error: None,
error_code: None,
capabilities: None,
missing_components: Vec::new(),
disk: Default::default(),
},
ssh_ok: true,
rsync_available: None,
disk_space_bytes: None,
cpu_count: None,
rust_version: None,
};
assert_eq!(result.latency_category(), LatencyCategory::Excellent);
}
#[test]
fn test_latency_category_acceptable() {
let result = DetailedProbeResult {
basic: WorkerProbeResult {
id: "test".to_string(),
host: "test".to_string(),
status: "ok".to_string(),
latency_ms: Some(100),
error: None,
error_code: None,
capabilities: None,
missing_components: Vec::new(),
disk: Default::default(),
},
ssh_ok: true,
rsync_available: None,
disk_space_bytes: None,
cpu_count: None,
rust_version: None,
};
assert_eq!(result.latency_category(), LatencyCategory::Acceptable);
}
#[test]
fn test_latency_category_degraded() {
let result = DetailedProbeResult {
basic: WorkerProbeResult {
id: "test".to_string(),
host: "test".to_string(),
status: "ok".to_string(),
latency_ms: Some(300),
error: None,
error_code: None,
capabilities: None,
missing_components: Vec::new(),
disk: Default::default(),
},
ssh_ok: true,
rsync_available: None,
disk_space_bytes: None,
cpu_count: None,
rust_version: None,
};
assert_eq!(result.latency_category(), LatencyCategory::Degraded);
}
#[test]
fn test_latency_category_unknown() {
let result = DetailedProbeResult {
basic: WorkerProbeResult {
id: "test".to_string(),
host: "test".to_string(),
status: "failed".to_string(),
latency_ms: None,
error: Some("Error".to_string()),
error_code: None,
capabilities: None,
missing_components: Vec::new(),
disk: Default::default(),
},
ssh_ok: false,
rsync_available: None,
disk_space_bytes: None,
cpu_count: None,
rust_version: None,
};
assert_eq!(result.latency_category(), LatencyCategory::Unknown);
}
#[test]
fn test_count_results() {
let results = sample_probe_results();
let table = ProbeResultTable::new(&results, OutputContext::Plain);
let (passed, failed) = table.count_results();
assert_eq!(passed, 2);
assert_eq!(failed, 1);
}
#[test]
fn test_average_latency() {
let results = sample_probe_results();
let table = ProbeResultTable::new(&results, OutputContext::Plain);
let avg = table.average_latency();
assert_eq!(avg, Some(72));
}
#[test]
fn test_truncate_str_short() {
assert_eq!(truncate_str("hello", 10), "hello");
}
#[test]
fn test_truncate_str_long() {
let result = truncate_str("hello_world_this_is_long", 10);
assert!(result.len() <= 10);
assert!(result.ends_with("..."));
}
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(500), "500B");
assert_eq!(format_bytes(1024), "1.0KB");
assert_eq!(format_bytes(1024 * 1024), "1.0MB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0GB");
assert_eq!(format_bytes(1024u64 * 1024 * 1024 * 1024), "1.0TB");
}
#[test]
fn test_render_plain_mode() {
let results = sample_probe_results();
let console = RchConsole::with_context(OutputContext::Plain);
let table = ProbeResultTable::new(&results, OutputContext::Plain);
table.render(&console);
}
#[test]
fn test_render_verbose_mode() {
let results = sample_probe_results();
let console = RchConsole::with_context(OutputContext::Plain);
let table = ProbeResultTable::new(&results, OutputContext::Plain).verbose(true);
table.render(&console);
}
#[test]
fn test_render_machine_mode_no_output() {
let results = sample_probe_results();
let console = RchConsole::with_context(OutputContext::Machine);
let table = ProbeResultTable::new(&results, OutputContext::Machine);
table.render(&console);
}
#[test]
fn test_render_empty_results_plain() {
let results: Vec<DetailedProbeResult> = vec![];
let console = RchConsole::with_context(OutputContext::Plain);
let table = ProbeResultTable::new(&results, OutputContext::Plain);
table.render(&console);
}
#[test]
fn test_probe_summary_render() {
let summary = ProbeSummary {
total: 3,
passed: 2,
failed: 1,
average_latency_ms: Some(72),
};
let console = RchConsole::with_context(OutputContext::Plain);
summary.render(&console, OutputContext::Plain);
}
#[test]
fn test_probe_summary_all_failed() {
let summary = ProbeSummary {
total: 3,
passed: 0,
failed: 3,
average_latency_ms: None,
};
let console = RchConsole::with_context(OutputContext::Plain);
summary.render(&console, OutputContext::Plain);
}
#[test]
fn test_detailed_probe_from_basic() {
let basic = WorkerProbeResult {
id: "test".to_string(),
host: "test".to_string(),
status: "ok".to_string(),
latency_ms: Some(50),
error: None,
error_code: None,
capabilities: None,
missing_components: Vec::new(),
disk: Default::default(),
};
let detailed = DetailedProbeResult::from_basic(basic, true);
assert!(detailed.ssh_ok);
assert!(detailed.rsync_available.is_none());
}
}