use crate::status_types::WorkerStatusFromApi;
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};
pub struct WorkerTable<'a> {
workers: &'a [WorkerStatusFromApi],
context: OutputContext,
}
impl<'a> WorkerTable<'a> {
#[must_use]
pub fn new(workers: &'a [WorkerStatusFromApi], context: OutputContext) -> Self {
Self { workers, context }
}
#[must_use]
pub fn from_workers(workers: &'a [WorkerStatusFromApi]) -> Self {
Self::new(workers, OutputContext::detect())
}
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.workers.is_empty() {
self.render_empty_rich(console);
return;
}
let mut table = Table::new()
.title("Workers")
.border_style(RchTheme::secondary());
table = table
.with_column(Column::new("ID").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("Slots").header_style(RchTheme::table_header()))
.with_column(Column::new("Speed").header_style(RchTheme::table_header()))
.with_column(Column::new("Circuit").header_style(RchTheme::table_header()));
for worker in self.workers {
let status_text = self.format_status_rich(worker);
let slot_bar = format_slot_bar(worker.used_slots, worker.total_slots, self.context);
let slots_text = format!("{} {}/{}", slot_bar, worker.used_slots, worker.total_slots);
let speed_text = format_speed_score(worker.speed_score, self.context);
let circuit_text = self.format_circuit_state(worker);
let row = Row::new(vec![
Cell::new(worker.id.as_str()),
Cell::new(worker.host.as_str()),
Cell::new(status_text.as_str()),
Cell::new(slots_text.as_str()),
Cell::new(speed_text.as_str()),
Cell::new(circuit_text.as_str()),
]);
table = table.with_row(row);
}
console.print_renderable(&table);
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 configured\n\nAdd workers with: rch workers init",
info
);
let panel = Panel::from_text(&content)
.title("Workers")
.border_style(RchTheme::muted())
.rounded();
console.print_renderable(&panel);
}
#[cfg(all(feature = "rich-ui", unix))]
fn format_status_rich(&self, worker: &WorkerStatusFromApi) -> String {
match worker.status.as_str() {
"healthy" | "online" => format!("{} Online", Icons::status_healthy(self.context)),
"busy" => format!("{} Busy", Icons::worker(self.context)),
"degraded" => format!("{} Degraded", Icons::warning(self.context)),
"draining" => format!("{} Draining", Icons::info(self.context)),
"drained" => format!("{} Drained", Icons::info(self.context)),
"unreachable" | "offline" | "unhealthy" => {
format!("{} Offline", Icons::cross(self.context))
}
"disabled" => format!("{} Disabled", Icons::cross(self.context)),
other => other.to_string(),
}
}
fn format_circuit_state(&self, worker: &WorkerStatusFromApi) -> String {
match worker.circuit_state.as_str() {
"closed" => "OK".to_string(),
"half_open" | "half-open" => "Testing".to_string(),
"open" => {
if let Some(secs) = worker.recovery_in_secs {
format!("Open ({secs}s)")
} else {
"Open".to_string()
}
}
other => other.to_string(),
}
}
#[cfg(all(feature = "rich-ui", unix))]
fn render_summary_rich(&self, console: &RchConsole) {
console.print_plain(&self.summary());
}
fn summary(&self) -> String {
let online = self
.workers
.iter()
.filter(|w| w.status == "healthy" || w.status == "online")
.count();
let total = self.workers.len();
let total_slots: u64 = self.workers.iter().map(|w| u64::from(w.total_slots)).sum();
let available_slots: u64 = self
.workers
.iter()
.map(|w| u64::from(w.total_slots.saturating_sub(w.used_slots)))
.sum();
format!(
"Total: {} workers ({} online) | Slots: {}/{} available",
total, online, available_slots, total_slots
)
}
fn render_plain(&self, console: &RchConsole) {
if self.workers.is_empty() {
console.print_plain("=== Workers ===");
console.print_plain("");
let info = Icons::info(self.context);
console.print_plain(&format!("{info} No workers configured"));
console.print_plain(" Add workers with: rch workers init");
return;
}
console.print_plain("=== Workers ===");
console.print_plain("");
console.print_plain(&format!(
"{:12} {:20} {:12} {:16} {:8} {:10}",
"ID", "Host", "Status", "Slots", "Speed", "Circuit"
));
console.print_plain(&"-".repeat(80));
for worker in self.workers {
let status_text = self.format_status_plain(worker);
let slot_bar = format_slot_bar(worker.used_slots, worker.total_slots, self.context);
let slots_text = format!("{} {}/{}", slot_bar, worker.used_slots, worker.total_slots);
let speed_text = format_speed_score(worker.speed_score, self.context);
let circuit_text = self.format_circuit_state(worker);
console.print_plain(&format!(
"{:12} {:20} {:12} {:16} {:8} {:10}",
truncate_str(&worker.id, 12),
truncate_str(&worker.host, 20),
status_text,
slots_text,
speed_text,
circuit_text
));
if let Some(ref err) = worker.last_error {
let warning = Icons::warning(self.context);
console.print_plain(&format!(" {warning} {err}"));
}
}
console.print_plain("");
console.print_plain(&self.summary());
}
fn format_status_plain(&self, worker: &WorkerStatusFromApi) -> String {
let icon = match worker.status.as_str() {
"healthy" | "online" => Icons::status_healthy(self.context),
"busy" => Icons::worker(self.context),
"degraded" => Icons::warning(self.context),
"offline" | "unhealthy" => Icons::cross(self.context),
"draining" => Icons::info(self.context),
_ => " ",
};
let status_label = match worker.status.as_str() {
"healthy" | "online" => "Online",
"busy" => "Busy",
"degraded" => "Degraded",
"offline" | "unhealthy" => "Offline",
"draining" => "Draining",
other => other,
};
format!("{} {}", icon, status_label)
}
}
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_slot_bar(used: u32, total: u32, ctx: OutputContext) -> String {
if total == 0 {
return "-".to_string();
}
let filled = Icons::slot_filled(ctx);
let empty = Icons::slot_empty(ctx);
let bar_width = total.min(8) as usize;
let filled_count = if total <= 8 {
used as usize
} else {
((used as f64 / total as f64) * bar_width as f64).round() as usize
};
let mut bar = String::new();
for i in 0..bar_width {
if i < filled_count {
bar.push_str(filled);
} else {
bar.push_str(empty);
}
}
bar
}
fn format_speed_score(score: f64, ctx: OutputContext) -> String {
if score <= 0.0 {
return "-".to_string();
}
let indicator = if score >= 1.5 {
Icons::lightning(ctx) } else if score >= 1.0 {
"" } else if score >= 0.5 {
"~" } else {
"!" };
if indicator.is_empty() {
format!("{score:.1}x")
} else {
format!("{indicator}{score:.1}x")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_workers() -> Vec<WorkerStatusFromApi> {
vec![
WorkerStatusFromApi {
id: "worker-1".to_string(),
host: "192.168.1.10".to_string(),
user: "ubuntu".to_string(),
status: "healthy".to_string(),
circuit_state: "closed".to_string(),
used_slots: 2,
total_slots: 8,
speed_score: 1.5,
last_error: None,
consecutive_failures: 0,
recovery_in_secs: None,
failure_history: vec![],
pressure_state: None,
pressure_confidence: None,
pressure_reason_code: None,
pressure_policy_rule: None,
pressure_disk_free_gb: None,
pressure_disk_total_gb: None,
pressure_disk_free_ratio: None,
pressure_disk_io_util_pct: None,
pressure_memory_pressure: None,
pressure_telemetry_age_secs: None,
pressure_telemetry_fresh: None,
bypass: None,
},
WorkerStatusFromApi {
id: "worker-2".to_string(),
host: "192.168.1.11".to_string(),
user: "ubuntu".to_string(),
status: "offline".to_string(),
circuit_state: "open".to_string(),
used_slots: 0,
total_slots: 8,
speed_score: 0.0,
last_error: Some("Connection refused".to_string()),
consecutive_failures: 3,
recovery_in_secs: Some(45),
failure_history: vec![false, false, false],
pressure_state: None,
pressure_confidence: None,
pressure_reason_code: None,
pressure_policy_rule: None,
pressure_disk_free_gb: None,
pressure_disk_total_gb: None,
pressure_disk_free_ratio: None,
pressure_disk_io_util_pct: None,
pressure_memory_pressure: None,
pressure_telemetry_age_secs: None,
pressure_telemetry_fresh: None,
bypass: None,
},
]
}
#[test]
fn test_worker_table_creation() {
let workers = sample_workers();
let ctx = OutputContext::Plain;
let table = WorkerTable::new(&workers, ctx);
assert_eq!(table.context, OutputContext::Plain);
assert_eq!(table.workers.len(), 2);
}
#[test]
fn summary_preserves_other_workers_capacity_when_disk_limit_shrinks() {
let mut workers = sample_workers();
workers[0].total_slots = 1;
workers[0].used_slots = 4;
workers[1].status = "healthy".to_string();
workers[1].used_slots = 2;
let table = WorkerTable::new(&workers, OutputContext::Plain);
assert_eq!(
table.summary(),
"Total: 2 workers (2 online) | Slots: 6/9 available"
);
assert_eq!(
format_slot_bar(4, 1, OutputContext::Plain),
Icons::slot_filled(OutputContext::Plain)
);
workers[0].total_slots = 0;
let table = WorkerTable::new(&workers[..1], OutputContext::Plain);
assert_eq!(
table.summary(),
"Total: 1 workers (1 online) | Slots: 0/0 available"
);
assert_eq!(format_slot_bar(4, 0, OutputContext::Plain), "-");
}
#[test]
fn test_worker_table_from_workers() {
let workers = sample_workers();
let table = WorkerTable::from_workers(&workers);
let _ = table.context; }
#[test]
fn test_format_circuit_state_closed() {
let workers = sample_workers();
let table = WorkerTable::new(&workers, OutputContext::Plain);
let result = table.format_circuit_state(&workers[0]);
assert_eq!(result, "OK");
}
#[test]
fn test_format_circuit_state_open_with_recovery() {
let workers = sample_workers();
let table = WorkerTable::new(&workers, OutputContext::Plain);
let result = table.format_circuit_state(&workers[1]);
assert_eq!(result, "Open (45s)");
}
#[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_render_plain_mode() {
let workers = sample_workers();
let console = RchConsole::with_context(OutputContext::Plain);
let table = WorkerTable::new(&workers, OutputContext::Plain);
table.render(&console);
}
#[test]
fn test_render_machine_mode_no_output() {
let workers = sample_workers();
let console = RchConsole::with_context(OutputContext::Machine);
let table = WorkerTable::new(&workers, OutputContext::Machine);
table.render(&console);
}
#[test]
fn test_render_empty_workers_plain() {
let workers: Vec<WorkerStatusFromApi> = vec![];
let console = RchConsole::with_context(OutputContext::Plain);
let table = WorkerTable::new(&workers, OutputContext::Plain);
table.render(&console);
}
#[test]
fn test_format_status_plain_online() {
let workers = sample_workers();
let table = WorkerTable::new(&workers, OutputContext::Plain);
let result = table.format_status_plain(&workers[0]);
assert!(result.contains("Online"));
}
#[test]
fn test_format_status_plain_offline() {
let workers = sample_workers();
let table = WorkerTable::new(&workers, OutputContext::Plain);
let result = table.format_status_plain(&workers[1]);
assert!(result.contains("Offline"));
}
#[test]
fn test_format_slot_bar_full() {
let bar = format_slot_bar(8, 8, OutputContext::Plain);
assert!(!bar.is_empty());
assert!(!bar.contains('-')); }
#[test]
fn test_format_slot_bar_empty() {
let bar = format_slot_bar(0, 8, OutputContext::Plain);
assert!(!bar.is_empty());
assert!(!bar.contains('#')); }
#[test]
fn test_format_slot_bar_partial() {
let bar = format_slot_bar(2, 8, OutputContext::Plain);
assert_eq!(bar.len(), 8); assert!(bar.starts_with("##")); assert!(bar.ends_with("-")); }
#[test]
fn test_format_slot_bar_zero_total() {
let bar = format_slot_bar(0, 0, OutputContext::Plain);
assert_eq!(bar, "-");
}
#[test]
fn test_format_speed_score_fast() {
let speed = format_speed_score(1.8, OutputContext::Plain);
assert!(speed.contains("1.8x"));
assert!(speed.contains("!")); }
#[test]
fn test_format_speed_score_normal() {
let speed = format_speed_score(1.0, OutputContext::Plain);
assert_eq!(speed, "1.0x");
}
#[test]
fn test_format_speed_score_slow() {
let speed = format_speed_score(0.5, OutputContext::Plain);
assert!(speed.contains("0.5x"));
assert!(speed.contains("~")); }
#[test]
fn test_format_speed_score_zero() {
let speed = format_speed_score(0.0, OutputContext::Plain);
assert_eq!(speed, "-");
}
}