use bytesize::ByteSize;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph, Sparkline},
Frame,
};
use crate::app::AppState;
use crate::models::{ContainerData, ContainerStatus};
use crate::ui::theme::Theme;
pub fn render(
f: &mut Frame,
area: Rect,
container: &ContainerData,
confirm: Option<&ConfirmAction>,
state: &AppState,
) {
let theme = Theme::default_theme();
let block = Block::default()
.title(Span::styled(
format!(" Contenedor: {} ", container.name),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent));
let inner = block.inner(area);
f.render_widget(block, area);
let inner_height = inner.height;
let remaining_height = inner_height.saturating_sub(7); let metadata_reserved = 6;
let charts_total_height = remaining_height.saturating_sub(metadata_reserved);
let chart_height = if state.history_mode {
(charts_total_height / 4).max(3)
} else {
3
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), Constraint::Length(chart_height), Constraint::Length(chart_height), Constraint::Length(chart_height), Constraint::Length(chart_height), Constraint::Min(1), Constraint::Length(2), ])
.split(inner);
let uptime_str = container
.uptime_secs
.map(format_uptime)
.unwrap_or_else(|| "—".to_string());
let status_color = match &container.status {
ContainerStatus::Running => Color::Green,
ContainerStatus::Paused => Color::Yellow,
ContainerStatus::Restarting => Color::Magenta,
ContainerStatus::Exited => Color::DarkGray,
ContainerStatus::Dead => Color::Red,
ContainerStatus::Unknown => Color::Gray,
};
let id_short = if container.id.len() > 12 {
&container.id[..12]
} else {
&container.id
};
let info_lines = vec![
Line::from(vec![
Span::styled("ID: ", Style::default().fg(theme.muted)),
Span::styled(
id_short,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Imagen: ", Style::default().fg(theme.muted)),
Span::styled(container.image.clone(), Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled("Estado: ", Style::default().fg(theme.muted)),
Span::styled(
container.status.as_str(),
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Uptime: ", Style::default().fg(theme.muted)),
Span::styled(uptime_str, Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled("RAM: ", Style::default().fg(theme.muted)),
Span::styled(
format!(
"{} / {}",
ByteSize(container.memory_bytes),
ByteSize(container.memory_limit_bytes)
),
Style::default().fg(Color::White),
),
]),
Line::from(vec![
Span::styled("Red Tot: ", Style::default().fg(theme.muted)),
Span::styled(
format!(
"↓ {} · ↑ {}",
ByteSize(container.net_recv_total),
ByteSize(container.net_sent_total)
),
Style::default().fg(Color::White),
),
Span::raw(" "),
Span::styled("Disk Tot: ", Style::default().fg(theme.muted)),
Span::styled(
format!(
"R {} · W {}",
ByteSize(container.disk_read_total),
ByteSize(container.disk_write_total)
),
Style::default().fg(Color::White),
),
]),
];
f.render_widget(Paragraph::new(info_lines), chunks[0]);
let cpu_pct = container.cpu_pct.clamp(0.0, 100.0);
if state.history_mode {
let cpu_data: Vec<u64> = state
.container_history
.iter()
.skip(
state
.container_history
.len()
.saturating_sub(state.history_range.samples()),
)
.map(|s| s.cpu_pct as u64)
.collect();
let cpu_block = Block::default()
.title(Span::styled(
format!(
" CPU Historial ({}) · Último: {:.1}% ",
state.history_range.label(),
cpu_pct
),
Style::default().fg(theme.muted),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted));
let inner_area = cpu_block.inner(chunks[1]);
f.render_widget(cpu_block, chunks[1]);
let cpu_spark = Sparkline::default().data(&cpu_data).max(100).style(
Style::default()
.fg(Theme::color_for_pct(cpu_pct))
.bg(Color::DarkGray),
);
f.render_widget(cpu_spark, inner_area);
} else {
let cpu_gauge = Gauge::default()
.block(
Block::default()
.title(Span::styled(
format!(" CPU {:.1}% ", cpu_pct),
Style::default().fg(theme.muted),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted)),
)
.gauge_style(
Style::default()
.fg(Theme::color_for_pct(cpu_pct))
.bg(Color::DarkGray),
)
.ratio(cpu_pct / 100.0);
f.render_widget(cpu_gauge, chunks[1]);
}
let mem_pct = container.memory_pct.clamp(0.0, 100.0);
if state.history_mode {
let mem_data: Vec<u64> = state
.container_history
.iter()
.skip(
state
.container_history
.len()
.saturating_sub(state.history_range.samples()),
)
.map(|s| s.mem_pct as u64)
.collect();
let mem_block = Block::default()
.title(Span::styled(
format!(
" Memoria Historial ({}) · Último: {:.1}% ",
state.history_range.label(),
mem_pct
),
Style::default().fg(theme.muted),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted));
let inner_area = mem_block.inner(chunks[2]);
f.render_widget(mem_block, chunks[2]);
let mem_spark = Sparkline::default().data(&mem_data).max(100).style(
Style::default()
.fg(Theme::color_for_pct(mem_pct))
.bg(Color::DarkGray),
);
f.render_widget(mem_spark, inner_area);
} else {
let mem_gauge = Gauge::default()
.block(
Block::default()
.title(Span::styled(
format!(" Memoria {:.1}% ", mem_pct),
Style::default().fg(theme.muted),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted)),
)
.gauge_style(
Style::default()
.fg(Theme::color_for_pct(mem_pct))
.bg(Color::DarkGray),
)
.ratio(mem_pct / 100.0);
f.render_widget(mem_gauge, chunks[2]);
}
let net_recv = container.net_recv_per_sec;
let net_sent = container.net_sent_per_sec;
if state.history_mode {
let net_data: Vec<u64> = state
.container_history
.iter()
.skip(
state
.container_history
.len()
.saturating_sub(state.history_range.samples()),
)
.map(|s| s.net_recv_bps.max(s.net_sent_bps) as u64)
.collect();
let net_max = state
.container_history
.iter()
.skip(
state
.container_history
.len()
.saturating_sub(state.history_range.samples()),
)
.map(|s| s.net_recv_bps.max(s.net_sent_bps))
.fold(0.0_f64, f64::max)
.max(1.0);
let net_block = Block::default()
.title(Span::styled(
format!(
" Red Historial ({}) · Último: ↓{}/s · ↑{}/s ",
state.history_range.label(),
ByteSize(net_recv as u64),
ByteSize(net_sent as u64)
),
Style::default().fg(theme.muted),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted));
let inner_area = net_block.inner(chunks[3]);
f.render_widget(net_block, chunks[3]);
let net_spark = Sparkline::default()
.data(&net_data)
.max(net_max as u64)
.style(Style::default().fg(Color::Cyan).bg(Color::DarkGray));
f.render_widget(net_spark, inner_area);
} else {
let net_ratio = (net_recv.max(net_sent) / 10_000_000.0).clamp(0.0, 1.0);
let net_gauge = Gauge::default()
.block(
Block::default()
.title(Span::styled(
format!(
" Red ↓{}/s (Total: {}) ↑{}/s (Total: {}) ",
ByteSize(net_recv as u64),
ByteSize(container.net_recv_total),
ByteSize(net_sent as u64),
ByteSize(container.net_sent_total)
),
Style::default().fg(theme.muted),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted)),
)
.gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
.ratio(net_ratio);
f.render_widget(net_gauge, chunks[3]);
}
let disk_r = container.disk_read_per_sec;
let disk_w = container.disk_write_per_sec;
if state.history_mode {
let disk_data: Vec<u64> = state
.container_history
.iter()
.skip(
state
.container_history
.len()
.saturating_sub(state.history_range.samples()),
)
.map(|s| s.disk_read_bps.max(s.disk_write_bps) as u64)
.collect();
let disk_max = state
.container_history
.iter()
.skip(
state
.container_history
.len()
.saturating_sub(state.history_range.samples()),
)
.map(|s| s.disk_read_bps.max(s.disk_write_bps))
.fold(0.0_f64, f64::max)
.max(1.0);
let disk_block = Block::default()
.title(Span::styled(
format!(
" Disco Historial ({}) · Último: R:{}/s · W:{}/s ",
state.history_range.label(),
ByteSize(disk_r as u64),
ByteSize(disk_w as u64)
),
Style::default().fg(theme.muted),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted));
let inner_area = disk_block.inner(chunks[4]);
f.render_widget(disk_block, chunks[4]);
let disk_spark = Sparkline::default()
.data(&disk_data)
.max(disk_max as u64)
.style(Style::default().fg(Color::Yellow).bg(Color::DarkGray));
f.render_widget(disk_spark, inner_area);
} else {
let disk_ratio = (disk_r.max(disk_w) / 100_000_000.0).clamp(0.0, 1.0);
let disk_gauge = Gauge::default()
.block(
Block::default()
.title(Span::styled(
format!(
" Disco R:{}/s (Total: {}) W:{}/s (Total: {}) ",
ByteSize(disk_r as u64),
ByteSize(container.disk_read_total),
ByteSize(disk_w as u64),
ByteSize(container.disk_write_total)
),
Style::default().fg(theme.muted),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted)),
)
.gauge_style(Style::default().fg(Color::Yellow).bg(Color::DarkGray))
.ratio(disk_ratio);
f.render_widget(disk_gauge, chunks[4]);
}
{
let meta_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.muted));
let meta_inner = meta_block.inner(chunks[5]);
f.render_widget(meta_block, chunks[5]);
let mut lines: Vec<Line> = Vec::new();
let muted = theme.muted;
lines.push(Line::from(Span::styled(
"── Puertos ",
Style::default().fg(muted),
)));
if container.ports.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("—", Style::default().fg(Color::DarkGray)),
]));
} else {
for p in &container.ports {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(p.clone(), Style::default().fg(Color::Cyan)),
]));
}
}
lines.push(Line::from(Span::styled(
"── Volúmenes ",
Style::default().fg(muted),
)));
if container.volumes.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("—", Style::default().fg(Color::DarkGray)),
]));
} else {
for v in &container.volumes {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(v.clone(), Style::default().fg(Color::Yellow)),
]));
}
}
lines.push(Line::from(Span::styled(
"── Redes ",
Style::default().fg(muted),
)));
if container.networks.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("—", Style::default().fg(Color::DarkGray)),
]));
} else {
for n in &container.networks {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(n.clone(), Style::default().fg(Color::Magenta)),
]));
}
}
lines.push(Line::from(Span::styled(
"── Variables de entorno ",
Style::default().fg(muted),
)));
if container.env_vars.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("—", Style::default().fg(Color::DarkGray)),
]));
} else {
for e in &container.env_vars {
if let Some((key, val)) = e.split_once('=') {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}=", key),
Style::default().fg(Color::Rgb(165, 213, 102)),
),
Span::styled(val.to_string(), Style::default().fg(Color::White)),
]));
} else {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(e.clone(), Style::default().fg(Color::White)),
]));
}
}
}
f.render_widget(Paragraph::new(lines), meta_inner);
}
let hint = Line::from(vec![
Span::styled(
" [ESC] ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled("Volver ", Style::default().fg(theme.muted)),
Span::styled(
"[L] ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled("Logs ", Style::default().fg(theme.muted)),
Span::styled(
"[R] ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled("Reiniciar ", Style::default().fg(theme.muted)),
Span::styled(
"[S] ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled("Detener ", Style::default().fg(theme.muted)),
Span::styled(
"[H] ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled("Historial ", Style::default().fg(theme.muted)),
Span::styled(
"[T] ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("Rango ({})", state.history_range.label()),
Style::default().fg(theme.muted),
),
]);
f.render_widget(Paragraph::new(hint), chunks[6]);
if let Some(action) = confirm {
render_confirm_dialog(f, area, action);
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ConfirmAction {
Restart(String), Stop(String),
}
impl ConfirmAction {
pub fn label(&self) -> &str {
match self {
ConfirmAction::Restart(_) => "reiniciar",
ConfirmAction::Stop(_) => "detener",
}
}
#[allow(dead_code)]
pub fn container_id(&self) -> &str {
match self {
ConfirmAction::Restart(id) | ConfirmAction::Stop(id) => id,
}
}
}
fn render_confirm_dialog(f: &mut Frame, area: Rect, action: &ConfirmAction) {
let theme = Theme::default_theme();
let dialog_w = 50u16.min(area.width.saturating_sub(4));
let dialog_h = 5u16;
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
x,
y,
width: dialog_w,
height: dialog_h,
};
let block = Block::default()
.title(Span::styled(
" Confirmar ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner = block.inner(dialog_area);
f.render_widget(ratatui::widgets::Clear, dialog_area);
f.render_widget(block, dialog_area);
let msg = format!("¿Seguro que quieres {} este contenedor?", action.label());
let lines = vec![
Line::from(Span::styled(msg, Style::default().fg(Color::White))),
Line::from(vec![]),
Line::from(vec![
Span::styled(
"[Enter] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("Confirmar ", Style::default().fg(theme.muted)),
Span::styled(
"[ESC] ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled("Cancelar", Style::default().fg(theme.muted)),
]),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn format_uptime(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
let h = secs / 3600;
let m = (secs % 3600) / 60;
format!("{}h {}m", h, m)
}
}