flow-iron 0.4.12

Infrastructure-as-code CLI — deploy Docker Compose apps with Caddy reverse proxy and Cloudflare DNS
Documentation
use anyhow::Result;
use comfy_table::{
    Cell, CellAlignment, Color, ContentArrangement, Table, modifiers::UTF8_ROUND_CORNERS,
    presets::UTF8_FULL_CONDENSED,
};
use console::style;
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers};
use futures::StreamExt;
use std::collections::HashMap;
use std::fmt::Write;
use std::io::Write as IoWrite;
use std::time::Duration;

use crate::config::Fleet;
use crate::ssh::SshPool;
use crate::ui;

pub struct Columns {
    pub image: bool,
    pub ports: bool,
    pub size: bool,
}

pub async fn run(
    fleet: &Fleet,
    server_filter: Option<&str>,
    follow: bool,
    cols: Columns,
) -> Result<()> {
    let filtered: HashMap<String, _> = fleet
        .servers
        .iter()
        .filter(|(name, _)| server_filter.is_none() || server_filter == Some(name.as_str()))
        .map(|(k, v)| (k.clone(), v.clone()))
        .collect();

    if filtered.is_empty() {
        anyhow::bail!("No matching server found");
    }

    let sp = ui::spinner("Connecting...");
    let pool = SshPool::connect(&filtered).await?;
    sp.finish_and_clear();

    if follow {
        follow_loop(&pool, &filtered, &cols).await?;
        pool.close().await?;
    } else {
        print_status(&pool, &filtered, &cols).await?;
        pool.close().await?;
    }
    Ok(())
}

async fn follow_loop(
    pool: &SshPool,
    servers: &HashMap<String, crate::config::Server>,
    cols: &Columns,
) -> Result<()> {
    crossterm::terminal::enable_raw_mode()?;
    print!("\x1b[?25l");

    let result = follow_inner(pool, servers, cols).await;

    crossterm::terminal::disable_raw_mode()?;
    print!("\x1b[?25h");
    std::io::stdout().flush()?;

    result
}

async fn follow_inner(
    pool: &SshPool,
    servers: &HashMap<String, crate::config::Server>,
    cols: &Columns,
) -> Result<()> {
    let mut events = EventStream::new();
    let mut show_esc_hint = false;

    print!("\x1b[2J");
    std::io::stdout().flush()?;

    loop {
        let buf = render_status(pool, servers, cols).await?;
        let hint = if show_esc_hint {
            format!("\n{}", style("(press esc to quit)").dim())
        } else {
            String::new()
        };
        let cleared = buf.replace('\n', "\x1b[K\r\n");
        print!("\x1b[H{cleared}{hint}\x1b[K\x1b[J");
        std::io::stdout().flush()?;

        loop {
            tokio::select! {
                () = tokio::time::sleep(Duration::from_secs(1)) => break,
                event = events.next() => {
                    match event {
                        Some(Ok(Event::Key(KeyEvent { code: KeyCode::Esc, .. }))) => {
                            if show_esc_hint {
                                return Ok(());
                            }
                            show_esc_hint = true;
                            break;
                        }
                        Some(Ok(Event::Key(KeyEvent {
                            code: KeyCode::Char('c'),
                            modifiers,
                            ..
                        }))) if modifiers.contains(KeyModifiers::CONTROL) => {
                            return Ok(());
                        }
                        _ => {}
                    }
                }
            }
        }
    }
}

const DOCKER_CMD: &str = "\
docker ps -a --format '{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}\t{{.Size}}' 2>/dev/null; \
echo '---STATS---'; \
docker stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' 2>/dev/null";

struct ContainerPs {
    status: String,
    image: String,
    ports: String,
    size: String,
}

struct ContainerStats {
    cpu: String,
    mem: String,
}

fn parse_output(
    output: &str,
) -> (
    HashMap<String, ContainerPs>,
    HashMap<String, ContainerStats>,
    Vec<String>,
) {
    let mut ps_map = HashMap::new();
    let mut stats_map = HashMap::new();
    let mut order = Vec::new();
    let mut in_stats = false;

    for line in output.lines() {
        if line.is_empty() {
            continue;
        }
        if line == "---STATS---" {
            in_stats = true;
            continue;
        }
        if in_stats {
            let parts: Vec<&str> = line.splitn(3, '\t').collect();
            if parts.len() == 3 {
                stats_map.insert(
                    parts[0].to_string(),
                    ContainerStats {
                        cpu: parts[1].to_string(),
                        mem: parts[2].to_string(),
                    },
                );
            }
        } else {
            let parts: Vec<&str> = line.splitn(5, '\t').collect();
            if parts.len() == 5 {
                order.push(parts[0].to_string());
                ps_map.insert(
                    parts[0].to_string(),
                    ContainerPs {
                        status: parts[1].to_string(),
                        image: parts[2].to_string(),
                        ports: parts[3].to_string(),
                        size: parts[4].to_string(),
                    },
                );
            }
        }
    }

    (ps_map, stats_map, order)
}

fn build_table(
    ps_map: &HashMap<String, ContainerPs>,
    stats_map: &HashMap<String, ContainerStats>,
    order: &[String],
    cols: &Columns,
) -> Table {
    let mut header: Vec<Cell> = vec![
        Cell::new("Container"),
        Cell::new("Status"),
        Cell::new("CPU"),
        Cell::new("Memory"),
    ];
    if cols.image {
        header.push(Cell::new("Image"));
    }
    if cols.ports {
        header.push(Cell::new("Ports"));
    }
    if cols.size {
        header.push(Cell::new("Size"));
    }

    let mut table = Table::new();
    table
        .load_preset(UTF8_FULL_CONDENSED)
        .apply_modifier(UTF8_ROUND_CORNERS)
        .set_content_arrangement(ContentArrangement::Dynamic)
        .set_header(header);

    for name in order {
        let Some(ps) = ps_map.get(name) else {
            continue;
        };
        let stats = stats_map.get(name);
        let status_color = if ps.status.starts_with("Up") {
            Color::Green
        } else {
            Color::Red
        };

        let cpu = stats.map_or("", |s| &s.cpu);
        let mem = stats.map_or("", |s| &s.mem);

        let mut row: Vec<Cell> = vec![
            Cell::new(name),
            Cell::new(&ps.status).fg(status_color),
            Cell::new(cpu).set_alignment(CellAlignment::Right),
            Cell::new(mem).set_alignment(CellAlignment::Right),
        ];
        if cols.image {
            row.push(Cell::new(&ps.image));
        }
        if cols.ports {
            row.push(Cell::new(&ps.ports));
        }
        if cols.size {
            row.push(Cell::new(&ps.size).set_alignment(CellAlignment::Right));
        }

        table.add_row(row);
    }

    table
}

async fn render_status(
    pool: &SshPool,
    servers: &HashMap<String, crate::config::Server>,
    cols: &Columns,
) -> Result<String> {
    let mut buf = String::new();
    for name in servers.keys() {
        writeln!(
            buf,
            "\n{}",
            style(format!("Server: {name}")).bold().underlined()
        )?;

        let output = pool.exec(name, DOCKER_CMD).await?;
        let (ps_map, stats_map, order) = parse_output(&output);
        let table = build_table(&ps_map, &stats_map, &order, cols);
        writeln!(buf, "{table}")?;
    }
    Ok(buf)
}

async fn print_status(
    pool: &SshPool,
    servers: &HashMap<String, crate::config::Server>,
    cols: &Columns,
) -> Result<()> {
    for name in servers.keys() {
        ui::header(&format!("Server: {name}"));

        let output = pool.exec(name, DOCKER_CMD).await?;
        let (ps_map, stats_map, order) = parse_output(&output);
        let table = build_table(&ps_map, &stats_map, &order, cols);
        println!("{table}");
    }
    Ok(())
}