Skip to main content

iron/
status.rs

1use anyhow::Result;
2use comfy_table::{
3    Cell, CellAlignment, Color, ContentArrangement, Table, modifiers::UTF8_ROUND_CORNERS,
4    presets::UTF8_FULL_CONDENSED,
5};
6use console::style;
7use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers};
8use futures::StreamExt;
9use std::collections::HashMap;
10use std::fmt::Write;
11use std::io::Write as IoWrite;
12use std::time::Duration;
13
14use crate::config::Fleet;
15use crate::ghcr::PackageRelease;
16use crate::ssh::SshPool;
17use crate::ui;
18
19pub struct Columns {
20    pub image: bool,
21    pub ports: bool,
22    pub size: bool,
23}
24
25pub async fn run(
26    fleet: &Fleet,
27    server_filter: Option<&str>,
28    follow: bool,
29    cols: Columns,
30) -> Result<()> {
31    let filtered: HashMap<String, _> = fleet
32        .servers
33        .iter()
34        .filter(|(name, _)| server_filter.is_none() || server_filter == Some(name.as_str()))
35        .map(|(k, v)| (k.clone(), v.clone()))
36        .collect();
37
38    if filtered.is_empty() {
39        anyhow::bail!("No matching server found");
40    }
41
42    let sp = ui::spinner("Connecting...");
43    let token = fleet.secrets.gh_token.as_deref();
44    let (pool, releases) = tokio::join!(
45        SshPool::connect(&filtered),
46        crate::ghcr::fetch_releases(token, &fleet.apps)
47    );
48    let pool = pool?;
49    sp.finish_and_clear();
50
51    let release_map = build_release_map(fleet, &releases);
52
53    if follow {
54        follow_loop(&pool, &filtered, &cols, &release_map).await?;
55        pool.close().await?;
56    } else {
57        print_status(&pool, &filtered, &cols, &release_map).await?;
58        pool.close().await?;
59    }
60    Ok(())
61}
62
63fn build_release_map<'a>(
64    fleet: &Fleet,
65    releases: &'a HashMap<String, PackageRelease>,
66) -> HashMap<String, &'a PackageRelease> {
67    let mut map = HashMap::new();
68    for (app_name, release) in releases {
69        if fleet.apps.contains_key(app_name) {
70            let prefix = format!("{app_name}-{app_name}-");
71            map.insert(prefix, release);
72        }
73    }
74    map
75}
76
77async fn follow_loop(
78    pool: &SshPool,
79    servers: &HashMap<String, crate::config::Server>,
80    cols: &Columns,
81    release_map: &HashMap<String, &PackageRelease>,
82) -> Result<()> {
83    crossterm::terminal::enable_raw_mode()?;
84    print!("\x1b[?25l");
85
86    let result = follow_inner(pool, servers, cols, release_map).await;
87
88    crossterm::terminal::disable_raw_mode()?;
89    print!("\x1b[?25h");
90    std::io::stdout().flush()?;
91
92    result
93}
94
95async fn follow_inner(
96    pool: &SshPool,
97    servers: &HashMap<String, crate::config::Server>,
98    cols: &Columns,
99    release_map: &HashMap<String, &PackageRelease>,
100) -> Result<()> {
101    let mut events = EventStream::new();
102    let mut show_esc_hint = false;
103
104    print!("\x1b[2J");
105    std::io::stdout().flush()?;
106
107    loop {
108        let buf = render_status(pool, servers, cols, release_map).await?;
109        let hint = if show_esc_hint {
110            format!("\n{}", style("(press esc to quit)").dim())
111        } else {
112            String::new()
113        };
114        let cleared = buf.replace('\n', "\x1b[K\r\n");
115        print!("\x1b[H{cleared}{hint}\x1b[K\x1b[J");
116        std::io::stdout().flush()?;
117
118        loop {
119            tokio::select! {
120                () = tokio::time::sleep(Duration::from_secs(1)) => break,
121                event = events.next() => {
122                    match event {
123                        Some(Ok(Event::Key(KeyEvent { code: KeyCode::Esc, .. }))) => {
124                            if show_esc_hint {
125                                return Ok(());
126                            }
127                            show_esc_hint = true;
128                            break;
129                        }
130                        Some(Ok(Event::Key(KeyEvent {
131                            code: KeyCode::Char('c'),
132                            modifiers,
133                            ..
134                        }))) if modifiers.contains(KeyModifiers::CONTROL) => {
135                            return Ok(());
136                        }
137                        _ => {}
138                    }
139                }
140            }
141        }
142    }
143}
144
145const DOCKER_CMD: &str = "\
146docker ps -a --format '{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}\t{{.Size}}' 2>/dev/null; \
147echo '---STATS---'; \
148docker stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' 2>/dev/null";
149
150struct ContainerPs {
151    status: String,
152    image: String,
153    ports: String,
154    size: String,
155}
156
157struct ContainerStats {
158    cpu: String,
159    mem: String,
160}
161
162fn parse_output(
163    output: &str,
164) -> (
165    HashMap<String, ContainerPs>,
166    HashMap<String, ContainerStats>,
167    Vec<String>,
168) {
169    let mut ps_map = HashMap::new();
170    let mut stats_map = HashMap::new();
171    let mut order = Vec::new();
172    let mut in_stats = false;
173
174    for line in output.lines() {
175        if line.is_empty() {
176            continue;
177        }
178        if line == "---STATS---" {
179            in_stats = true;
180            continue;
181        }
182        if in_stats {
183            let parts: Vec<&str> = line.splitn(3, '\t').collect();
184            if parts.len() == 3 {
185                stats_map.insert(
186                    parts[0].to_string(),
187                    ContainerStats {
188                        cpu: parts[1].to_string(),
189                        mem: parts[2].to_string(),
190                    },
191                );
192            }
193        } else {
194            let parts: Vec<&str> = line.splitn(5, '\t').collect();
195            if parts.len() == 5 {
196                order.push(parts[0].to_string());
197                ps_map.insert(
198                    parts[0].to_string(),
199                    ContainerPs {
200                        status: parts[1].to_string(),
201                        image: parts[2].to_string(),
202                        ports: parts[3].to_string(),
203                        size: parts[4].to_string(),
204                    },
205                );
206            }
207        }
208    }
209
210    (ps_map, stats_map, order)
211}
212
213fn build_table(
214    ps_map: &HashMap<String, ContainerPs>,
215    stats_map: &HashMap<String, ContainerStats>,
216    order: &[String],
217    cols: &Columns,
218    release_map: &HashMap<String, &PackageRelease>,
219) -> Table {
220    let mut header: Vec<Cell> = vec![
221        Cell::new("Container"),
222        Cell::new("Status"),
223        Cell::new("CPU"),
224        Cell::new("Memory"),
225        Cell::new("Latest"),
226        Cell::new("Published"),
227    ];
228    if cols.image {
229        header.push(Cell::new("Image"));
230    }
231    if cols.ports {
232        header.push(Cell::new("Ports"));
233    }
234    if cols.size {
235        header.push(Cell::new("Size"));
236    }
237
238    let mut table = Table::new();
239    table
240        .load_preset(UTF8_FULL_CONDENSED)
241        .apply_modifier(UTF8_ROUND_CORNERS)
242        .set_content_arrangement(ContentArrangement::Dynamic)
243        .set_header(header);
244
245    for name in order {
246        let Some(ps) = ps_map.get(name) else {
247            continue;
248        };
249        let stats = stats_map.get(name);
250        let status_color = if ps.status.starts_with("Up") {
251            Color::Green
252        } else {
253            Color::Red
254        };
255
256        let cpu = stats.map_or("—", |s| &s.cpu);
257        let mem = stats.map_or("—", |s| &s.mem);
258
259        let release = release_map
260            .iter()
261            .find(|(prefix, _)| name.starts_with(prefix.as_str()))
262            .map(|(_, r)| *r);
263
264        let tag = release.map_or("", |r| &r.tag);
265        let published = release.map_or("", |r| &r.published);
266
267        let mut row: Vec<Cell> = vec![
268            Cell::new(name),
269            Cell::new(&ps.status).fg(status_color),
270            Cell::new(cpu).set_alignment(CellAlignment::Right),
271            Cell::new(mem).set_alignment(CellAlignment::Right),
272            Cell::new(tag),
273            Cell::new(published),
274        ];
275        if cols.image {
276            row.push(Cell::new(&ps.image));
277        }
278        if cols.ports {
279            row.push(Cell::new(&ps.ports));
280        }
281        if cols.size {
282            row.push(Cell::new(&ps.size).set_alignment(CellAlignment::Right));
283        }
284
285        table.add_row(row);
286    }
287
288    table
289}
290
291async fn render_status(
292    pool: &SshPool,
293    servers: &HashMap<String, crate::config::Server>,
294    cols: &Columns,
295    release_map: &HashMap<String, &PackageRelease>,
296) -> Result<String> {
297    let mut buf = String::new();
298    for name in servers.keys() {
299        writeln!(
300            buf,
301            "\n{}",
302            style(format!("Server: {name}")).bold().underlined()
303        )?;
304
305        let output = pool.exec(name, DOCKER_CMD).await?;
306        let (ps_map, stats_map, order) = parse_output(&output);
307        let table = build_table(&ps_map, &stats_map, &order, cols, release_map);
308        writeln!(buf, "{table}")?;
309    }
310    Ok(buf)
311}
312
313async fn print_status(
314    pool: &SshPool,
315    servers: &HashMap<String, crate::config::Server>,
316    cols: &Columns,
317    release_map: &HashMap<String, &PackageRelease>,
318) -> Result<()> {
319    for name in servers.keys() {
320        ui::header(&format!("Server: {name}"));
321
322        let output = pool.exec(name, DOCKER_CMD).await?;
323        let (ps_map, stats_map, order) = parse_output(&output);
324        let table = build_table(&ps_map, &stats_map, &order, cols, release_map);
325        println!("{table}");
326    }
327    Ok(())
328}