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}