use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Tabs},
};
use crate::fleet::{LogEntry, ShipSnapshot, ShipStatus};
pub fn render(
frame: &mut Frame,
ships: &[ShipSnapshot],
logs: &[LogEntry],
selected_tab: usize,
selected_ship: usize,
log_scroll: u16,
) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
.split(area);
render_header(frame, chunks[0], selected_tab);
match selected_tab {
0 => render_overview(frame, chunks[1], ships, selected_ship),
1 => render_logs(frame, chunks[1], ships, logs, selected_ship, log_scroll),
2 => render_modules(frame, chunks[1]),
_ => {}
}
render_footer(frame, chunks[2]);
}
fn render_header(frame: &mut Frame, area: Rect, selected_tab: usize) {
let titles = vec!["Overview", "Logs", "Modules"];
let tabs = Tabs::new(titles)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Mothership Fleet Dashboard "),
)
.select(selected_tab)
.style(Style::default().fg(Color::White))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(tabs, area);
}
fn render_overview(frame: &mut Frame, area: Rect, ships: &[ShipSnapshot], selected_ship: usize) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
let items: Vec<ListItem> = ships
.iter()
.enumerate()
.map(|(i, ship)| {
let status_icon = match ship.status() {
ShipStatus::Pending => "⏳",
ShipStatus::Starting => "🚀",
ShipStatus::Running => "✅",
ShipStatus::Unhealthy => "⚠️",
ShipStatus::Backoff => "⏸️",
ShipStatus::Stopped => "⏹️",
ShipStatus::Failed => "❌",
};
let style = if i == selected_ship {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let flags = format!(
"{}{}",
if !ship.is_critical() { " ○" } else { "" },
if ship.is_oneshot() { " ⚡" } else { "" }
);
ListItem::new(Line::from(vec![
Span::raw(format!("{} ", status_icon)),
Span::styled(
format!("[{}] ", ship.group()),
Style::default().fg(Color::DarkGray),
),
Span::styled(ship.name(), style),
Span::styled(flags, Style::default().fg(Color::Magenta)),
]))
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(" Fleet Status "),
);
frame.render_widget(list, chunks[0]);
let detail_content = if selected_ship < ships.len() {
let ship = &ships[selected_ship];
let status_color = match ship.status() {
ShipStatus::Running => Color::Green,
ShipStatus::Unhealthy => Color::Yellow,
ShipStatus::Backoff => Color::Blue,
ShipStatus::Failed => Color::Red,
_ => Color::White,
};
vec![
Line::from(vec![
Span::raw("Name: "),
Span::styled(ship.name(), Style::default().add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::raw("Group: "),
Span::styled(ship.group(), Style::default().fg(Color::Cyan)),
]),
Line::from(""),
Line::from(vec![
Span::raw("Status: "),
Span::styled(
format!("{:?}", ship.status()),
Style::default().fg(status_color),
),
]),
Line::from(""),
Line::from(vec![Span::raw("Command: "), Span::raw(ship.command())]),
Line::from(vec![
Span::raw("PID: "),
Span::raw(
ship.pid()
.map(|p| p.to_string())
.unwrap_or_else(|| "-".to_string()),
),
]),
Line::from(""),
Line::from(vec![
Span::raw("Healthcheck: "),
Span::raw(ship.healthcheck().unwrap_or("-")),
]),
Line::from(vec![
Span::raw("Restarts: "),
Span::raw(ship.restart_count().to_string()),
]),
Line::from(""),
Line::from(vec![
Span::raw("Critical: "),
Span::styled(
if ship.is_critical() { "yes" } else { "no" },
Style::default().fg(if ship.is_critical() {
Color::Red
} else {
Color::Green
}),
),
Span::raw(" Oneshot: "),
Span::styled(
if ship.is_oneshot() { "yes" } else { "no" },
Style::default().fg(if ship.is_oneshot() {
Color::Magenta
} else {
Color::White
}),
),
]),
Line::from(""),
Line::from(vec![
Span::raw("Routes: "),
if ship.routes().is_empty() {
Span::styled("-", Style::default().fg(Color::DarkGray))
} else {
Span::styled(ship.routes().join(", "), Style::default().fg(Color::Cyan))
},
]),
]
} else {
vec![Line::from("No ship selected")]
};
let detail = Paragraph::new(detail_content).block(
Block::default()
.borders(Borders::ALL)
.title(" Ship Details "),
);
frame.render_widget(detail, chunks[1]);
}
fn render_logs(
frame: &mut Frame,
area: Rect,
ships: &[ShipSnapshot],
logs: &[LogEntry],
selected_ship: usize,
log_scroll: u16,
) {
let title = if selected_ship < ships.len() {
format!(
" Logs: {} ({} lines) ",
ships[selected_ship].name(),
logs.len()
)
} else {
" Logs ".to_string()
};
let log_lines: Vec<Line> = if logs.is_empty() {
vec![
Line::from("No logs yet."),
Line::from(""),
Line::from("Process output will appear here as it's generated."),
]
} else {
logs.iter()
.skip(log_scroll as usize)
.map(|entry| {
let style = if entry.stream == "stderr" {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
Line::styled(&entry.message, style)
})
.collect()
};
let logs_widget =
Paragraph::new(log_lines).block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(logs_widget, area);
}
fn render_modules(frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(5)])
.split(area);
let module_help = "\
No WASM modules loaded.
Configure modules in ship-manifest.toml:
[[modules]]
name = \"rate-limiter\"
wasm = \"./modules/rate_limiter.wasm\"
routes = [\"/api/.*\"]";
let modules = Paragraph::new(module_help).block(
Block::default()
.borders(Borders::ALL)
.title(" WASM Modules "),
);
frame.render_widget(modules, chunks[0]);
let stats = Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(" Module Processing "),
)
.gauge_style(Style::default().fg(Color::Cyan))
.ratio(0.0)
.label("0 requests processed");
frame.render_widget(stats, chunks[1]);
}
fn render_footer(frame: &mut Frame, area: Rect) {
let help = Line::from(vec![
Span::styled("q", Style::default().fg(Color::Yellow)),
Span::raw(" quit "),
Span::styled("Tab", Style::default().fg(Color::Yellow)),
Span::raw(" switch tab "),
Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
Span::raw(" navigate "),
Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
Span::raw(" scroll logs"),
]);
let footer = Paragraph::new(help).block(Block::default().borders(Borders::ALL));
frame.render_widget(footer, area);
}