use super::*;
pub(crate) fn render_host_detail(
frame: &mut Frame,
app: &App,
area: Rect,
alias: &str,
total: usize,
running: usize,
) {
if area.width == 0 || area.height == 0 {
return;
}
let lines = build_host_detail_lines(app, alias, total, running, area.width, area.height);
frame.render_widget(Paragraph::new(lines), area);
}
pub(crate) fn build_host_detail_lines(
app: &App,
alias: &str,
total: usize,
running: usize,
width: u16,
height: u16,
) -> Vec<Line<'static>> {
let box_width = width as usize;
let max_value_width = box_width
.saturating_sub(4)
.saturating_sub(design::SECTION_LABEL_W as usize);
let mut lines: Vec<Line<'static>> = Vec::new();
let entry = app.container_state.cache_entry(alias);
let host = app.hosts_state.list().iter().find(|h| h.alias == alias);
let collapsed = app.containers_overview.collapsed_hosts.contains(alias);
let now = current_unix_secs();
design::section_open(&mut lines, "STATUS", box_width);
push_ping_field(&mut lines, app, alias, max_value_width, box_width);
if let Some(e) = entry {
let age_secs = now.saturating_sub(e.timestamp);
let age_text = crate::messages::relative_age(std::time::Duration::from_secs(age_secs));
let style = if age_secs > 300 {
theme::warning()
} else {
theme::muted()
};
design::section_field_styled(
&mut lines,
"Sync age",
&age_text,
style,
max_value_width,
box_width,
);
let runtime_label = match e.runtime {
crate::containers::ContainerRuntime::Docker => "Docker",
crate::containers::ContainerRuntime::Podman => "Podman",
};
let runtime_value = match e.engine_version.as_deref() {
Some(v) if !v.is_empty() => format!("{} {}", runtime_label, v),
_ => runtime_label.to_string(),
};
design::section_field(
&mut lines,
"Runtime",
&runtime_value,
max_value_width,
box_width,
);
}
if let Some(hist) = app.history.entry(alias) {
let ago = crate::history::ConnectionHistory::format_time_ago(hist.last_connected);
if !ago.is_empty() {
design::section_field(
&mut lines,
"Last SSH",
&format!("{} ago", ago),
max_value_width,
box_width,
);
}
}
design::section_close(&mut lines, box_width);
design::section_open(&mut lines, "FLEET", box_width);
let exited = total.saturating_sub(running);
let counts = entry
.map(|e| count_states(&e.containers))
.unwrap_or_default();
let dead = counts.dead;
let paused = counts.paused;
let restarting = counts.restarting;
push_state_dots(&mut lines, running, exited, dead, paused, box_width);
design::section_field(
&mut lines,
"Total",
&format!("{}", total),
max_value_width,
box_width,
);
if let Some(e) = entry {
let exit_nonzero = e
.containers
.iter()
.filter(|c| container_has_nonzero_exit(app, c))
.count();
if exit_nonzero > 0 {
design::section_field_styled(
&mut lines,
"Exit ne 0",
&exit_nonzero.to_string(),
theme::warning(),
max_value_width,
box_width,
);
}
}
let tunnel_active = app.tunnels.active_contains(alias);
if tunnel_active {
design::section_field_styled(
&mut lines,
"Tunnels",
"active",
theme::online_dot(),
max_value_width,
box_width,
);
} else if let Some(h) = host {
if h.tunnel_count > 0 {
design::section_field(
&mut lines,
"Tunnels",
&h.tunnel_count.to_string(),
max_value_width,
box_width,
);
}
}
if collapsed {
design::section_field_styled(
&mut lines,
"Group",
"folded",
theme::warning(),
max_value_width,
box_width,
);
}
design::section_close(&mut lines, box_width);
let stale_listing = entry
.map(|e| now.saturating_sub(e.timestamp) > 300)
.unwrap_or(false);
let inspect_signals = entry
.map(|e| collect_inspect_signals(app, &e.containers))
.unwrap_or_default();
let attention_needed = dead > 0
|| restarting > 0
|| stale_listing
|| !inspect_signals.restart_loops.is_empty()
|| inspect_signals.oom_count > 0
|| entry
.map(|e| {
e.containers
.iter()
.any(|c| container_has_nonzero_exit(app, c))
})
.unwrap_or(false);
if attention_needed {
design::section_open(&mut lines, "ATTENTION", box_width);
if dead > 0 {
design::section_field_styled(
&mut lines,
"Dead",
&format!("{} K to restart all running", dead),
theme::error(),
max_value_width,
box_width,
);
}
if restarting > 0 {
design::section_field_styled(
&mut lines,
"Restarting",
&restarting.to_string(),
theme::warning(),
max_value_width,
box_width,
);
}
if let Some(e) = entry {
let bad_exit = e
.containers
.iter()
.filter(|c| container_has_nonzero_exit(app, c))
.count();
if bad_exit > 0 {
design::section_field_styled(
&mut lines,
"Exit ne 0",
&format!("{} r to refresh", bad_exit),
theme::warning(),
max_value_width,
box_width,
);
}
}
for (name, count) in inspect_signals
.restart_loops
.iter()
.take(ATTENTION_RESTART_LOOP_CAP)
{
let label = "Restart loop";
let value = format!("{} ({})", name, count);
design::section_field_styled(
&mut lines,
label,
&value,
theme::warning(),
max_value_width,
box_width,
);
}
if inspect_signals.oom_count > 0 {
design::section_field_styled(
&mut lines,
"OOM kills",
&inspect_signals.oom_count.to_string(),
theme::error(),
max_value_width,
box_width,
);
}
if stale_listing {
let ago = crate::messages::relative_age(std::time::Duration::from_secs(
entry.map(|e| now.saturating_sub(e.timestamp)).unwrap_or(0),
));
design::section_field_styled(
&mut lines,
"Stale",
&format!("listing {} r to refresh", ago),
theme::warning(),
max_value_width,
box_width,
);
}
design::section_close(&mut lines, box_width);
}
design::section_open(&mut lines, "ACTIONS", box_width);
push_action_row(
&mut lines,
"K",
"Restart running on host",
running,
running > 0,
box_width,
);
push_action_row(
&mut lines,
"S",
"Stop running on host",
running,
running > 0,
box_width,
);
let refresh_qual = entry
.map(|e| {
let age = now.saturating_sub(e.timestamp);
crate::messages::relative_age(std::time::Duration::from_secs(age))
})
.unwrap_or_else(|| "never synced".to_string());
let r_qual = format!("last sync {}", refresh_qual);
push_action_text_row(&mut lines, "r", "Refresh listing", &r_qual, true, box_width);
let space_label = if collapsed {
"Expand group"
} else {
"Collapse group"
};
push_action_text_row(&mut lines, "Space", space_label, "", true, box_width);
design::section_close(&mut lines, box_width);
design::section_open(&mut lines, "HOST", box_width);
if let Some(h) = host {
let addr = if h.port != 22 {
format!("{}:{}", h.hostname, h.port)
} else {
h.hostname.clone()
};
if !addr.is_empty() {
design::section_field(&mut lines, "Address", &addr, max_value_width, box_width);
}
if !h.user.is_empty() {
design::section_field(&mut lines, "User", &h.user, max_value_width, box_width);
}
if let Some(provider_name) = h.provider.as_deref() {
let display = crate::providers::provider_display_name(provider_name);
let region = h
.provider_meta
.iter()
.find(|(k, _)| k == "region" || k == "zone" || k == "datacenter")
.map(|(_, v)| v.clone());
let value = match region {
Some(r) if !r.is_empty() => format!("{} · {}", display, r),
_ => display.to_string(),
};
design::section_field(&mut lines, "Provider", &value, max_value_width, box_width);
}
if !h.tags.is_empty() || !h.provider_tags.is_empty() {
let combined: Vec<String> = h
.provider_tags
.iter()
.chain(h.tags.iter())
.cloned()
.collect();
let joined = combined.join(", ");
design::section_field(&mut lines, "Tags", &joined, max_value_width, box_width);
}
} else {
design::section_field(&mut lines, "Alias", alias, max_value_width, box_width);
}
design::section_close(&mut lines, box_width);
design::stretch_last_card(&mut lines, height as usize, box_width);
lines
}
pub(crate) fn push_state_dots(
lines: &mut Vec<Line<'static>>,
running: usize,
exited: usize,
dead: usize,
paused: usize,
box_width: usize,
) {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(
format!(
"{:<width$}",
"State",
width = design::SECTION_LABEL_W as usize
),
theme::muted(),
));
spans.push(Span::styled(
format!("{} ", design::ICON_ONLINE),
theme::online_dot(),
));
spans.push(Span::styled(
format!("{} running ", running),
theme::bold(),
));
spans.push(Span::styled(
format!("{} ", design::ICON_STOPPED),
theme::muted(),
));
spans.push(Span::styled(format!("{} exited ", exited), theme::bold()));
if dead > 0 {
spans.push(Span::styled(
format!("{} ", design::ICON_ERROR),
theme::error(),
));
spans.push(Span::styled(format!("{} dead", dead), theme::error()));
}
if paused > 0 {
if dead > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
format!("{} ", design::ICON_PAUSED),
theme::warning(),
));
spans.push(Span::styled(format!("{} paused", paused), theme::warning()));
}
design::section_line(lines, spans, box_width);
}
pub(crate) fn push_ping_field(
lines: &mut Vec<Line<'static>>,
app: &App,
alias: &str,
_max_value_width: usize,
box_width: usize,
) {
let label_span = Span::styled(
format!(
"{:<width$}",
"Ping",
width = design::SECTION_LABEL_W as usize
),
theme::muted(),
);
let value_spans: Vec<Span<'static>> = match app.ping.status_of(alias) {
Some(crate::app::PingStatus::Reachable { rtt_ms }) => vec![
Span::styled(format!("{} ", design::ICON_ONLINE), theme::online_dot()),
Span::styled(host_list::format_rtt(*rtt_ms), theme::online_dot()),
],
Some(crate::app::PingStatus::Slow { rtt_ms }) => vec![
Span::styled(format!("{} ", design::ICON_STOPPED), theme::warning()),
Span::styled(
format!("slow {}", host_list::format_rtt(*rtt_ms)),
theme::warning(),
),
],
Some(crate::app::PingStatus::Unreachable) => vec![
Span::styled(format!("{} ", design::ICON_ERROR), theme::error()),
Span::styled("unreachable", theme::error()),
],
Some(crate::app::PingStatus::Checking) => {
vec![Span::styled("checking", theme::muted())]
}
Some(crate::app::PingStatus::Skipped) | None => {
vec![Span::styled("--", theme::muted())]
}
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(1 + value_spans.len());
spans.push(label_span);
spans.extend(value_spans);
design::section_line(lines, spans, box_width);
}
pub(crate) fn push_action_row(
lines: &mut Vec<Line<'static>>,
key: &str,
verb: &str,
count: usize,
enabled: bool,
box_width: usize,
) {
let qualifier = if !enabled {
"nothing running".to_string()
} else if count == 1 {
"1 container".to_string()
} else {
format!("{} containers", count)
};
push_action_text_row(lines, key, verb, &qualifier, enabled, box_width);
}
pub(crate) fn push_action_text_row(
lines: &mut Vec<Line<'static>>,
key: &str,
verb: &str,
qualifier: &str,
enabled: bool,
box_width: usize,
) {
let key_style = if enabled {
theme::accent_bold()
} else {
theme::muted()
};
let verb_style = if enabled {
theme::bold()
} else {
theme::muted()
};
let key_field = format!("{:<width$}", key, width = design::SECTION_LABEL_W as usize);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(key_field, key_style));
spans.push(Span::styled(verb.to_string(), verb_style));
if !qualifier.is_empty() {
spans.push(Span::styled(format!(" {}", qualifier), theme::muted()));
}
design::section_line(lines, spans, box_width);
}
#[derive(Default, Debug, PartialEq)]
pub(crate) struct StateCounts {
pub(crate) running: usize,
pub(crate) exited: usize,
pub(crate) dead: usize,
pub(crate) paused: usize,
pub(crate) restarting: usize,
pub(crate) created: usize,
}
pub(crate) fn count_states(containers: &[crate::containers::ContainerInfo]) -> StateCounts {
let mut c = StateCounts::default();
for ci in containers {
match ci.state.as_str() {
"running" => c.running += 1,
"exited" => c.exited += 1,
"dead" => c.dead += 1,
"paused" => c.paused += 1,
"restarting" => c.restarting += 1,
"created" => c.created += 1,
_ => {}
}
}
c
}
pub(crate) fn parse_exit_code_from_status(status: &str) -> Option<i32> {
design::parse_container_exit_code(status)
}
pub(crate) fn container_has_nonzero_exit(app: &App, c: &crate::containers::ContainerInfo) -> bool {
if let Some(code) = parse_exit_code_from_status(&c.status) {
return code != 0;
}
if c.state != "exited" && c.state != "stopped" {
return false;
}
app.containers_overview
.inspect_cache
.entries
.get(&c.id)
.and_then(|e| e.result.as_ref().ok())
.map(|i| i.exit_code != 0)
.unwrap_or(false)
}
#[derive(Default, Debug)]
pub(crate) struct InspectSignals {
pub(crate) restart_loops: Vec<(String, u32)>,
pub(crate) oom_count: usize,
}
pub(crate) const RESTART_LOOP_THRESHOLD: u32 = 5;
pub(crate) const ATTENTION_RESTART_LOOP_CAP: usize = 3;
pub(crate) fn collect_inspect_signals(
app: &App,
containers: &[crate::containers::ContainerInfo],
) -> InspectSignals {
let mut out = InspectSignals::default();
for c in containers {
let Some(entry) = app.containers_overview.inspect_cache.entries.get(&c.id) else {
continue;
};
let Ok(insp) = entry.result.as_ref() else {
continue;
};
if insp.oom_killed {
out.oom_count += 1;
}
if insp.restart_count > RESTART_LOOP_THRESHOLD {
out.restart_loops
.push((clean_name(&c.names), insp.restart_count));
}
}
out
}