use chrono::Utc;
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap},
Frame,
};
use crate::process::ProcessStatus;
use crate::tui::app::{App, LogSource};
pub fn render(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1), ])
.split(area);
render_title(frame, chunks[0], app);
render_body(frame, chunks[1], app);
render_statusbar(frame, chunks[2], app);
}
fn render_title(frame: &mut Frame, area: Rect, app: &App) {
let online = app
.processes
.iter()
.filter(|p| p.status == ProcessStatus::Running)
.count();
let stopped = app
.processes
.iter()
.filter(|p| p.status == ProcessStatus::Stopped)
.count();
let errored = app
.processes
.iter()
.filter(|p| p.status == ProcessStatus::Errored)
.count();
let title_line = Line::from(vec![
Span::styled(" PROSES ", Style::default().bold().fg(Color::White)),
Span::styled(" ", Style::default()),
Span::styled(
format!("● {online} online"),
Style::default().fg(Color::Green).bold(),
),
Span::styled(" · ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("○ {stopped} stopped"),
if stopped > 0 {
Style::default().fg(Color::Gray)
} else {
Style::default().fg(Color::DarkGray)
},
),
Span::styled(" · ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("✕ {errored} errored"),
if errored > 0 {
Style::default().fg(Color::Red).bold()
} else {
Style::default().fg(Color::DarkGray)
},
),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(title_line)
.title_alignment(Alignment::Left)
.title_bottom(
Line::from(Span::styled(
" proses v0.1.0 ",
Style::default().fg(Color::DarkGray),
))
.right_aligned(),
);
frame.render_widget(block, area);
}
fn render_body(frame: &mut Frame, area: Rect, app: &mut App) {
let chunks = Layout::vertical([Constraint::Percentage(50), Constraint::Fill(1)]).split(area);
render_process_table(frame, chunks[0], app);
render_log_pane(frame, chunks[1], app);
}
fn render_process_table(frame: &mut Frame, area: Rect, app: &mut App) {
let header_style = Style::default().bold().fg(Color::Cyan);
let selected_style = Style::default().add_modifier(Modifier::REVERSED);
let header = Row::new(vec![
Cell::from(" ID"),
Cell::from("Name"),
Cell::from("Status"),
Cell::from("PID"),
Cell::from("↺"),
Cell::from("Uptime"),
Cell::from("Command"),
])
.style(header_style)
.bottom_margin(0);
let rows: Vec<Row> = app
.processes
.iter()
.map(|p| {
let (symbol, status_style) = match p.status {
ProcessStatus::Running => ("● online ", Style::default().fg(Color::Green).bold()),
ProcessStatus::Stopped => ("○ stopped", Style::default().fg(Color::DarkGray)),
ProcessStatus::Errored => ("✕ errored", Style::default().fg(Color::Red).bold()),
};
let uptime = if p.status == ProcessStatus::Running && p.pid > 0 {
format_uptime(p.started_at)
} else {
"–".to_string()
};
let pid_str = if p.pid > 0 {
p.pid.to_string()
} else {
"–".to_string()
};
Row::new(vec![
Cell::from(format!(" {}", p.id)),
Cell::from(p.name.clone()),
Cell::from(Span::styled(symbol, status_style)),
Cell::from(pid_str),
Cell::from(p.restarts.to_string()),
Cell::from(uptime),
Cell::from(truncate(&p.command, 48)),
])
})
.collect();
let widths = [
Constraint::Length(5), Constraint::Length(18), Constraint::Length(11), Constraint::Length(8), Constraint::Length(4), Constraint::Length(10), Constraint::Fill(1), ];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
" Processes ",
Style::default().fg(Color::White).bold(),
)),
)
.row_highlight_style(selected_style)
.highlight_symbol("▶ ");
frame.render_stateful_widget(table, area, &mut app.table_state);
}
fn render_log_pane(frame: &mut Frame, area: Rect, app: &App) {
let proc_name = app
.selected_process()
.map(|p| p.name.as_str())
.unwrap_or("none");
let src_label = match app.log_source {
LogSource::Stdout => "stdout",
LogSource::Stderr => "stderr",
};
let title = format!(" Logs: {proc_name} [{src_label}] ");
let content: Vec<Line> = if app.log_lines.is_empty() {
vec![Line::from(Span::styled(
" (no output yet)",
Style::default().fg(Color::DarkGray),
))]
} else {
app.log_lines
.iter()
.map(|l| Line::from(Span::raw(format!(" {l}"))))
.collect()
};
let total_lines = content.len() as u16;
let inner_height = area.height.saturating_sub(2); let scroll_offset = total_lines.saturating_sub(inner_height);
let para = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
title,
Style::default().fg(Color::White).bold(),
)),
)
.wrap(Wrap { trim: false })
.scroll((scroll_offset, 0));
frame.render_widget(para, area);
}
fn render_statusbar(frame: &mut Frame, area: Rect, app: &App) {
let line = if app.confirm_delete {
Line::from(vec![Span::styled(
" ⚠ Press d again to confirm deletion · Esc to cancel",
Style::default().fg(Color::Yellow).bold(),
)])
} else if let Some((msg, is_error)) = app.status_text() {
let color = if is_error { Color::Red } else { Color::Green };
Line::from(Span::styled(format!(" {msg}"), Style::default().fg(color)))
} else {
help_line()
};
frame.render_widget(Paragraph::new(line), area);
}
fn help_line<'a>() -> Line<'a> {
let dim = Style::default().fg(Color::DarkGray);
let key = Style::default().fg(Color::White);
Line::from(vec![
Span::styled(" ", dim),
Span::styled("↑↓", key),
Span::styled(" navigate", dim),
Span::styled(" r", key),
Span::styled(" restart", dim),
Span::styled(" s", key),
Span::styled(" stop/start", dim),
Span::styled(" d", key),
Span::styled(" delete", dim),
Span::styled(" e", key),
Span::styled(" stderr/stdout", dim),
Span::styled(" q", key),
Span::styled(" quit", dim),
])
}
fn truncate(s: &str, max: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
s.to_string()
} else {
let truncated: String = chars[..max - 1].iter().collect();
format!("{truncated}…")
}
}
fn format_uptime(started_at: chrono::DateTime<Utc>) -> String {
let secs = (Utc::now() - started_at).num_seconds().max(0) as u64;
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
let m = secs / 60;
let s = secs % 60;
format!("{m}m {s}s")
} else if secs < 86_400 {
let h = secs / 3600;
let m = (secs % 3600) / 60;
format!("{h}h {m}m")
} else {
let d = secs / 86_400;
let h = (secs % 86_400) / 3600;
format!("{d}d {h}h")
}
}