1use std::collections::{HashMap, HashSet};
4
5use futures_util::StreamExt;
6use serde::Deserialize;
7
8use crate::compose::types::ComposeFile;
9use crate::error::{ComposeError, Result};
10use crate::libpod::types::container::ContainerListEntry;
11use crate::libpod::{parse_json_lines, urlencoded, API_PREFIX};
12
13use super::Engine;
14
15#[derive(Default)]
19pub struct StatsOptions {
20 pub no_stream: bool,
22 pub all: bool,
24 pub json: bool,
26 pub no_trunc: bool,
28}
29
30impl StatsOptions {
31 pub fn new(no_stream: bool, all: bool, no_trunc: bool, json: bool) -> Self {
36 Self {
37 no_stream,
38 all,
39 no_trunc,
40 json,
41 }
42 }
43}
44
45const NAME_WIDTH: usize = 32;
49
50fn containers_query(wanted: &HashSet<String>) -> String {
57 if wanted.is_empty() {
58 return String::new();
59 }
60 let mut names: Vec<&String> = wanted.iter().collect();
61 names.sort();
62 names
63 .iter()
64 .map(|n| format!("&containers={}", urlencoded(n)))
65 .collect::<String>()
66}
67
68fn null_default<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
72where
73 D: serde::Deserializer<'de>,
74 T: Default + Deserialize<'de>,
75{
76 Option::<T>::deserialize(d).map(|v| v.unwrap_or_default())
77}
78
79#[derive(Deserialize, Default)]
81struct StatsReport {
82 #[serde(rename = "Stats", default)]
83 stats: Vec<ContainerStat>,
84}
85
86#[derive(Deserialize, Default, Clone)]
88struct ContainerStat {
89 #[serde(rename = "Name", default)]
90 name: String,
91 #[serde(rename = "CPU", default)]
92 cpu: f64,
93 #[serde(rename = "MemUsage", default)]
94 mem_usage: u64,
95 #[serde(rename = "MemLimit", default)]
96 mem_limit: u64,
97 #[serde(rename = "MemPerc", default)]
98 mem_perc: f64,
99 #[serde(rename = "BlockInput", default)]
100 block_in: u64,
101 #[serde(rename = "BlockOutput", default)]
102 block_out: u64,
103 #[serde(rename = "PIDs", default)]
104 pids: u64,
105 #[serde(rename = "Network", default, deserialize_with = "null_default")]
109 network: HashMap<String, NetStat>,
110}
111
112#[derive(Deserialize, Default, Clone)]
114struct NetStat {
115 #[serde(rename = "RxBytes", default)]
116 rx: u64,
117 #[serde(rename = "TxBytes", default)]
118 tx: u64,
119}
120
121fn format_bytes(bytes: u64) -> String {
123 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
124 let mut value = bytes as f64;
125 let mut unit = 0;
126 while value >= 1024.0 && unit < UNITS.len() - 1 {
127 value /= 1024.0;
128 unit += 1;
129 }
130 if unit == 0 {
131 format!("{bytes}B")
132 } else {
133 format!("{value:.1}{}", UNITS[unit])
134 }
135}
136
137fn net_totals(s: &ContainerStat) -> (u64, u64) {
139 s.network
140 .values()
141 .fold((0u64, 0u64), |(rx, tx), n| (rx + n.rx, tx + n.tx))
142}
143
144fn truncate_name(name: &str, no_trunc: bool) -> String {
149 if no_trunc || name.chars().count() <= NAME_WIDTH {
150 return name.to_string();
151 }
152 let head: String = name.chars().take(NAME_WIDTH - 1).collect();
153 format!("{head}…")
154}
155
156fn format_row(s: &ContainerStat, no_trunc: bool) -> String {
160 let (rx, tx) = net_totals(s);
161 format!(
162 "{:<NAME_WIDTH$} {:>7.2}% {:>10} / {:<10} {:>6.2}% {:>9} / {:<9} {:>9} / {:<9} {:>5}",
163 truncate_name(&s.name, no_trunc),
164 s.cpu,
165 format_bytes(s.mem_usage),
166 format_bytes(s.mem_limit),
167 s.mem_perc,
168 format_bytes(rx),
169 format_bytes(tx),
170 format_bytes(s.block_in),
171 format_bytes(s.block_out),
172 s.pids,
173 )
174}
175
176fn stat_json_row(s: &ContainerStat) -> serde_json::Value {
180 let (rx, tx) = net_totals(s);
181 serde_json::json!({
182 "Name": s.name,
183 "CPUPerc": s.cpu,
184 "MemUsage": s.mem_usage,
185 "MemLimit": s.mem_limit,
186 "MemPerc": s.mem_perc,
187 "NetInput": rx,
188 "NetOutput": tx,
189 "BlockInput": s.block_in,
190 "BlockOutput": s.block_out,
191 "PIDs": s.pids,
192 })
193}
194
195const HEADER: &str = "NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS";
196
197impl Engine {
198 pub async fn stats(
202 &self,
203 file: &ComposeFile,
204 target_services: &[String],
205 no_stream: bool,
206 ) -> Result<()> {
207 self.stats_with_options(
208 file,
209 target_services,
210 StatsOptions {
211 no_stream,
212 ..StatsOptions::default()
213 },
214 )
215 .await
216 }
217
218 pub async fn stats_with_options(
224 &self,
225 file: &ComposeFile,
226 target_services: &[String],
227 opts: StatsOptions,
228 ) -> Result<()> {
229 if let Some(unknown) = first_unknown_service(file, target_services) {
232 return Err(ComposeError::ServiceNotFound(unknown.into()));
233 }
234 let targets = self.target_containers(file, target_services).await?;
235
236 let running: HashSet<String> = targets
241 .iter()
242 .filter(|t| t.running)
243 .map(|t| t.name.clone())
244 .collect();
245 let stopped: Vec<String> = if opts.all {
246 targets
247 .iter()
248 .filter(|t| !t.running)
249 .map(|t| t.name.clone())
250 .collect()
251 } else {
252 Vec::new()
253 };
254
255 let containers = containers_query(&running);
259
260 if opts.no_stream || running.is_empty() {
261 let report = if running.is_empty() {
265 StatsReport::default()
266 } else {
267 self.client
268 .get_json(&format!(
269 "{API_PREFIX}/containers/stats?stream=false{containers}"
270 ))
271 .await
272 .map_err(ComposeError::Podman)?
273 };
274 print_frame(&report, &running, &stopped, &opts);
275 return Ok(());
276 }
277
278 let resp = self
279 .client
280 .get_stream(&format!(
281 "{API_PREFIX}/containers/stats?stream=true{containers}"
282 ))
283 .await
284 .map_err(ComposeError::Podman)?;
285 let mut frames = parse_json_lines::<StatsReport>(resp.into_body());
286 while let Some(frame) = frames.next().await {
287 match frame {
288 Ok(report) => print_frame(&report, &running, &stopped, &opts),
289 Err(e) => {
290 tracing::debug!("stats stream ended: {e}");
291 break;
292 }
293 }
294 }
295 Ok(())
296 }
297
298 async fn target_containers(
304 &self,
305 file: &ComposeFile,
306 target_services: &[String],
307 ) -> Result<Vec<TargetContainer>> {
308 let filters = serde_json::json!({ "label": [format!("podup.project={}", self.project)] });
309 let path = format!(
310 "{API_PREFIX}/containers/json?all=true&filters={}",
311 urlencoded(&filters.to_string()),
312 );
313 let entries = self
314 .client
315 .get_json::<Vec<ContainerListEntry>>(&path)
316 .await
317 .map_err(ComposeError::Podman)?;
318
319 let mut out = Vec::new();
320 for e in entries {
321 let service = e
322 .labels
323 .get("podup.service")
324 .map(String::as_str)
325 .unwrap_or("");
326 if !file.services.contains_key(service) {
329 continue;
330 }
331 if !target_services.is_empty() && !target_services.iter().any(|t| t == service) {
332 continue;
333 }
334 if let Some(raw) = e.names.first() {
335 out.push(TargetContainer {
336 name: raw.trim_start_matches('/').to_string(),
337 running: e.state == "running",
338 });
339 }
340 }
341 Ok(out)
342 }
343}
344
345struct TargetContainer {
348 name: String,
349 running: bool,
350}
351
352fn first_unknown_service<'a>(file: &ComposeFile, targets: &'a [String]) -> Option<&'a str> {
355 targets
356 .iter()
357 .map(String::as_str)
358 .find(|t| !file.services.contains_key(*t))
359}
360
361fn frame_rows(
365 report: &StatsReport,
366 running: &HashSet<String>,
367 stopped: &[String],
368) -> Vec<ContainerStat> {
369 let mut rows: Vec<ContainerStat> = report
370 .stats
371 .iter()
372 .filter(|s| running.contains(&s.name))
373 .cloned()
374 .collect();
375 for name in stopped {
376 rows.push(ContainerStat {
377 name: name.clone(),
378 ..ContainerStat::default()
379 });
380 }
381 rows.sort_by(|a, b| a.name.cmp(&b.name));
382 rows
383}
384
385fn print_frame(
388 report: &StatsReport,
389 running: &HashSet<String>,
390 stopped: &[String],
391 opts: &StatsOptions,
392) {
393 let rows = frame_rows(report, running, stopped);
394
395 if opts.json {
396 let json: Vec<_> = rows.iter().map(stat_json_row).collect();
397 println!(
398 "{}",
399 serde_json::to_string_pretty(&json).unwrap_or_default()
400 );
401 return;
402 }
403
404 crate::ui::print_bold_header(HEADER);
405 for s in &rows {
406 println!("{}", format_row(s, opts.no_trunc));
407 }
408 println!();
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn format_bytes_scales_units() {
417 assert_eq!(format_bytes(512), "512B");
418 assert_eq!(format_bytes(1024), "1.0KiB");
419 assert_eq!(format_bytes(1536), "1.5KiB");
420 assert_eq!(format_bytes(1024 * 1024), "1.0MiB");
421 assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.0GiB");
422 }
423
424 #[test]
425 fn format_row_sums_network_and_shows_name() {
426 let mut network = HashMap::new();
427 network.insert("eth0".to_string(), NetStat { rx: 1024, tx: 2048 });
428 let s = ContainerStat {
429 name: "proj-web".into(),
430 cpu: 12.5,
431 mem_usage: 1024 * 1024,
432 mem_limit: 1024 * 1024 * 1024,
433 mem_perc: 0.1,
434 block_in: 0,
435 block_out: 0,
436 pids: 3,
437 network,
438 };
439 let row = format_row(&s, false);
440 assert!(row.contains("proj-web"));
441 assert!(row.contains("12.50%"));
442 assert!(row.contains("1.0MiB"));
443 assert!(row.contains('3'));
444 }
445
446 #[test]
447 fn truncate_name_shortens_long_names_with_ellipsis() {
448 let short = "proj-web-1";
449 assert_eq!(truncate_name(short, false), short);
450 let exact = "a".repeat(NAME_WIDTH);
452 assert_eq!(truncate_name(&exact, false), exact);
453 let long = "a".repeat(NAME_WIDTH + 10);
455 let cut = truncate_name(&long, false);
456 assert_eq!(cut.chars().count(), NAME_WIDTH);
457 assert!(cut.ends_with('…'));
458 assert_eq!(truncate_name(&long, true), long);
460 }
461
462 #[test]
463 fn format_row_truncates_long_name_but_no_trunc_keeps_it() {
464 let s = ContainerStat {
465 name: "really-long-project-web-container-name-1".into(),
466 ..Default::default()
467 };
468 let row = format_row(&s, false);
470 assert!(row.contains('…'));
471 assert!(!row.contains(&s.name));
472 let row_full = format_row(&s, true);
474 assert!(row_full.contains(&s.name));
475 }
476
477 #[test]
478 fn stat_json_row_emits_numeric_fields() {
479 let mut network = HashMap::new();
480 network.insert("eth0".to_string(), NetStat { rx: 100, tx: 200 });
481 let s = ContainerStat {
482 name: "proj-db-1".into(),
483 cpu: 5.5,
484 mem_usage: 2048,
485 mem_limit: 4096,
486 mem_perc: 50.0,
487 block_in: 1,
488 block_out: 2,
489 pids: 7,
490 network,
491 };
492 let row = stat_json_row(&s);
493 assert_eq!(row["Name"], "proj-db-1");
494 assert_eq!(row["CPUPerc"], 5.5);
495 assert_eq!(row["MemUsage"], 2048);
496 assert_eq!(row["MemLimit"], 4096);
497 assert_eq!(row["NetInput"], 100);
498 assert_eq!(row["NetOutput"], 200);
499 assert_eq!(row["PIDs"], 7);
500 }
501
502 #[test]
503 fn frame_rows_keeps_running_and_adds_stopped_zeros_sorted() {
504 let report = StatsReport {
505 stats: vec![
506 ContainerStat {
507 name: "proj-web-1".into(),
508 cpu: 1.0,
509 ..Default::default()
510 },
511 ContainerStat {
513 name: "other".into(),
514 ..Default::default()
515 },
516 ],
517 };
518 let mut running = HashSet::new();
519 running.insert("proj-web-1".to_string());
520 let stopped = vec!["proj-db-1".to_string()];
521 let rows = frame_rows(&report, &running, &stopped);
522 assert_eq!(rows.len(), 2);
524 assert_eq!(rows[0].name, "proj-db-1");
525 assert_eq!(rows[0].cpu, 0.0);
526 assert_eq!(rows[1].name, "proj-web-1");
527 assert_eq!(rows[1].cpu, 1.0);
528 }
529
530 #[test]
531 fn frame_rows_without_all_has_no_stopped_rows() {
532 let report = StatsReport::default();
533 let running = HashSet::new();
534 let rows = frame_rows(&report, &running, &[]);
536 assert!(rows.is_empty());
537 }
538
539 #[test]
540 fn stat_tolerates_null_network() {
541 let json = r#"{"Name":"proj-web","CPU":1.0,"Network":null}"#;
544 let stat: ContainerStat = serde_json::from_str(json).unwrap();
545 assert_eq!(stat.name, "proj-web");
546 assert!(stat.network.is_empty());
547 }
548
549 #[test]
550 fn stat_tolerates_missing_network() {
551 let json = r#"{"Name":"proj-web"}"#;
552 let stat: ContainerStat = serde_json::from_str(json).unwrap();
553 assert!(stat.network.is_empty());
554 }
555
556 #[test]
557 fn containers_query_repeats_param_per_container() {
558 let mut wanted = HashSet::new();
561 wanted.insert("proj-web-1".to_string());
562 wanted.insert("proj-db-1".to_string());
563 assert_eq!(
564 containers_query(&wanted),
565 "&containers=proj-db-1&containers=proj-web-1"
566 );
567 }
568
569 #[test]
570 fn containers_query_empty_when_none_wanted() {
571 assert_eq!(containers_query(&HashSet::new()), "");
572 }
573
574 #[test]
575 fn first_unknown_service_flags_typos() {
576 let file =
577 crate::parse_str("services:\n web:\n image: nginx\n db:\n image: postgres\n")
578 .unwrap();
579 assert_eq!(first_unknown_service(&file, &[]), None);
580 assert_eq!(
581 first_unknown_service(&file, &["web".into(), "db".into()]),
582 None
583 );
584 assert_eq!(
585 first_unknown_service(&file, &["web".into(), "bogus".into()]),
586 Some("bogus")
587 );
588 }
589}