use futures_util::StreamExt;
use serde_json::Value;
use crate::error::{ComposeError, Result};
use crate::libpod::{urlencoded, API_PREFIX};
use super::Engine;
#[derive(Debug, Clone, Default)]
pub struct EventsOptions {
pub since: Option<String>,
pub until: Option<String>,
pub filters: Vec<String>,
}
impl Engine {
pub async fn stream_events(&self, json: bool) -> Result<()> {
self.stream_events_with_options(json, &EventsOptions::default())
.await
}
pub async fn stream_events_with_options(&self, json: bool, opts: &EventsOptions) -> Result<()> {
let filters = build_event_filters(&self.project, &opts.filters)?;
let mut path = format!(
"{API_PREFIX}/events?stream=true&filters={}",
urlencoded(&filters.to_string()),
);
if let Some(since) = &opts.since {
path.push_str(&format!("&since={}", urlencoded(since)));
}
if let Some(until) = &opts.until {
path.push_str(&format!("&until={}", urlencoded(until)));
}
let resp = self
.client
.get_stream(&path)
.await
.map_err(ComposeError::Podman)?;
let mut stream = crate::libpod::parse_json_lines::<Value>(resp.into_body());
while let Some(event) = stream.next().await {
match event {
Ok(value) => println!("{}", format_event(&value, json)),
Err(e) => {
tracing::warn!("events: stream ended early [{}]: {e}", e.stream_end_kind())
}
}
}
Ok(())
}
}
fn build_event_filters(project: &str, user_filters: &[String]) -> Result<Value> {
use serde_json::{Map, Value};
let mut map: Map<String, Value> = Map::new();
map.insert(
"label".to_string(),
Value::Array(vec![Value::String(format!("podup.project={project}"))]),
);
for f in user_filters {
let Some((key, value)) = f.split_once('=') else {
return Err(ComposeError::Unsupported(format!(
"malformed events filter {f:?}: expected KEY=VALUE (e.g. event=start)"
)));
};
match map
.entry(key.to_string())
.or_insert_with(|| Value::Array(Vec::new()))
{
Value::Array(arr) => arr.push(Value::String(value.to_string())),
other => *other = Value::Array(vec![Value::String(value.to_string())]),
}
}
Ok(Value::Object(map))
}
fn format_event(value: &Value, json: bool) -> String {
if json {
return serde_json::to_string(value).unwrap_or_default();
}
let typ = value.get("Type").and_then(Value::as_str).unwrap_or("");
let action = value
.get("Action")
.or_else(|| value.get("status"))
.and_then(Value::as_str)
.unwrap_or("");
let name = value
.pointer("/Actor/Attributes/name")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.unwrap_or("");
format_event_line(typ, action, name, crate::ui::stdout_colored() && !json)
}
fn format_event_line(typ: &str, action: &str, name: &str, colour: bool) -> String {
use crate::ui::{identity_style, paint, Style};
let typ = paint(Style::new().dimmed(), typ, colour);
let action = match crate::ui::action_or_status_style(action) {
Some(style) => paint(style, action, colour),
None => action.to_string(),
};
let name = paint(identity_style(name), name, colour && !name.is_empty());
format!("{typ} {action} {name}").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::{build_event_filters, format_event};
use serde_json::json;
#[test]
fn build_event_filters_scopes_to_project_label() {
let f = build_event_filters("demo", &[]).unwrap();
assert_eq!(f, json!({ "label": ["podup.project=demo"] }));
}
#[test]
fn build_event_filters_merges_user_predicates() {
let f = build_event_filters(
"demo",
&[
"event=start".to_string(),
"event=die".to_string(),
"type=container".to_string(),
],
)
.unwrap();
assert_eq!(
f,
json!({
"label": ["podup.project=demo"],
"event": ["start", "die"],
"type": ["container"],
})
);
}
#[test]
fn malformed_filter_is_rejected_not_dropped() {
let err = build_event_filters("demo", &["bogus".to_string()])
.expect_err("a filter with no `=` must not be silently ignored");
assert!(format!("{err}").contains("bogus"), "got {err}");
}
#[test]
fn formats_docker_compat_shape() {
let ev = json!({
"Type": "container",
"Action": "start",
"Actor": { "Attributes": { "name": "web-1" } },
});
assert_eq!(format_event(&ev, false), "container start web-1");
}
#[test]
fn formats_libpod_native_shape() {
let ev = json!({ "Type": "container", "status": "die", "id": "abc123" });
assert_eq!(format_event(&ev, false), "container die abc123");
}
#[test]
fn json_mode_emits_raw_object() {
let ev = json!({ "Type": "container", "Action": "start" });
let out = format_event(&ev, true);
assert!(out.contains("\"Type\":\"container\""));
assert!(out.contains("\"Action\":\"start\""));
}
}
#[cfg(test)]
mod event_colour_tests {
use super::format_event_line;
#[test]
fn plain_output_is_unchanged() {
assert_eq!(
format_event_line("container", "start", "proj-web-1", false),
"container start proj-web-1"
);
}
#[test]
fn action_and_name_are_tinted_apart() {
let died = format_event_line("container", "die", "proj-web-1", true);
let started = format_event_line("container", "start", "proj-web-1", true);
assert_ne!(
died,
started.replace("start", "die"),
"die and start must differ by more than the verb"
);
}
#[test]
fn an_empty_name_is_not_painted() {
let out = format_event_line("network", "create", "", true);
assert!(
out.ends_with("create\u{1b}[0m") || !out.ends_with("\u{1b}[0m "),
"{out:?}"
);
}
}