mothership 0.0.100

Process supervisor with HTTP exposure - wrap, monitor, and expose your fleet
Documentation
//! TUI rendering

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};

/// Render the TUI
pub fn render(
    frame: &mut Frame,
    ships: &[ShipSnapshot],
    logs: &[LogEntry],
    selected_tab: usize,
    selected_ship: usize,
    log_scroll: u16,
) {
    let area = frame.area();

    // Main layout: header + content + footer
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3), // Header with tabs
            Constraint::Min(10),   // Content
            Constraint::Length(3), // Footer
        ])
        .split(area);

    // Header with tabs
    render_header(frame, chunks[0], selected_tab);

    // Content based on 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]),
        _ => {}
    }

    // Footer with keybindings
    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);

    // Ship list with group membership
    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()
            };

            // Show flags for critical/oneshot
            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]);

    // Ship detail panel
    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()
    };

    // Convert log entries to lines
    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);

    // Module list
    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]);

    // Module stats
    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);
}