diff --git a/src/app.rs b/src/app.rs index 675fbcc..7f57636 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,12 +5,15 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use tokio::sync::mpsc::{self, UnboundedSender}; use crate::action::Action; +use crate::components::composes::Composes; use crate::components::containers::Containers; use crate::components::images::Images; use crate::components::networks::Networks; use crate::components::volumes::Volumes; use crate::components::Component; -use crate::runtime::{get_suggestions, RuntimeSummary, CONTAINERS, IMAGES, NETWORKS, VOLUMES}; +use crate::runtime::{ + get_suggestions, RuntimeSummary, COMPOSES, CONTAINERS, IMAGES, NETWORKS, VOLUMES, +}; use crate::tui; use crate::utils::{default_layout, help_screen, toast}; @@ -72,7 +75,7 @@ impl App { tui.frame_rate(self.frame_rate); tui.enter()?; - let mut main: Component = Component::Containers(Containers::new(None)); + let mut main: Component = Component::Containers(Containers::new(Default::default())); main.register_action_handler(action_tx.clone()); let info = crate::runtime::get_runtime_info().await?; @@ -339,7 +342,13 @@ impl App { match self.suggestion { Some(CONTAINERS) => { self.reset_input(); - Some(Action::Screen(Component::Containers(Containers::new(None)))) + Some(Action::Screen(Component::Containers(Containers::new( + Default::default(), + )))) + } + Some(COMPOSES) => { + self.reset_input(); + Some(Action::Screen(Component::Composes(Composes::new()))) } Some(IMAGES) => { self.reset_input(); @@ -347,11 +356,15 @@ impl App { } Some(VOLUMES) => { self.reset_input(); - Some(Action::Screen(Component::Volumes(Volumes::new()))) + Some(Action::Screen(Component::Volumes(Volumes::new( + Default::default(), + )))) } Some(NETWORKS) => { self.reset_input(); - Some(Action::Screen(Component::Networks(Networks::new()))) + Some(Action::Screen(Component::Networks(Networks::new( + Default::default(), + )))) } _ => None, } diff --git a/src/components.rs b/src/components.rs index ff86db6..85032a2 100644 --- a/src/components.rs +++ b/src/components.rs @@ -6,6 +6,7 @@ use tokio::sync::mpsc::UnboundedSender; use crate::action::Action; +use crate::components::composes::Composes; use crate::components::container_exec::ContainerExec; use crate::components::container_inspect::ContainerDetails; use crate::components::container_logs::ContainerLogs; @@ -19,6 +20,7 @@ use crate::components::volume_inspect::VolumeInspect; use crate::components::volumes::Volumes; use crate::tui; +pub mod composes; pub mod container_exec; pub mod container_inspect; pub mod container_logs; @@ -38,6 +40,7 @@ pub(crate) enum Component { ContainerInspect(ContainerDetails), ContainerLogs(ContainerLogs), ContainerView(ContainerView), + Composes(Composes), Images(Images), ImageInspect(ImageInspect), Networks(Networks), @@ -81,6 +84,7 @@ impl Component { ContainerInspect, ContainerLogs, ContainerView, + Composes, Images, ImageInspect, Networks, @@ -100,6 +104,7 @@ impl Component { ContainerInspect, ContainerLogs, ContainerView, + Composes, Images, ImageInspect, Networks, @@ -119,6 +124,7 @@ impl Component { ContainerInspect, ContainerLogs, ContainerView, + Composes, Images, ImageInspect, Networks, @@ -147,6 +153,7 @@ impl Component { ContainerInspect, ContainerLogs, ContainerView, + Composes, Images, ImageInspect, Networks, @@ -179,6 +186,7 @@ impl Component { Containers, ContainerLogs, ContainerView, + Composes, Images, Networks, Volumes @@ -190,7 +198,15 @@ impl Component { pub(crate) fn get_action(&self, k: &KeyEvent) -> Option { component_delegate!( self.get_action(k), - [Containers, ContainerLogs, ContainerView, Images], + [ + Containers, + ContainerLogs, + ContainerView, + Composes, + Images, + Networks, + Volumes + ], None ) } diff --git a/src/components/composes.rs b/src/components/composes.rs new file mode 100644 index 0000000..08f0b9b --- /dev/null +++ b/src/components/composes.rs @@ -0,0 +1,166 @@ +use color_eyre::Result; +use crossterm::event::{self, KeyCode}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Style, Stylize}, + widgets::TableState, + Frame, +}; + +use tokio::sync::mpsc::UnboundedSender; + +use crate::{ + action::Action, + runtime::{list_compose_projects, Compose, Filter}, + utils::table, +}; + +use super::{containers::Containers, networks::Networks, volumes::Volumes, Component}; + +const COMPOSES_CONSTRAINTS: [Constraint; 4] = [ + Constraint::Min(20), + Constraint::Max(12), + Constraint::Max(12), + Constraint::Max(12), +]; + +#[derive(Clone, Debug)] +pub struct Composes { + composes: Vec, + action_tx: Option>, + state: TableState, +} + +impl Composes { + pub fn new() -> Self { + Composes { + composes: Vec::new(), + action_tx: None, + state: TableState::default(), + } + } + + fn previous(&mut self) { + if !self.composes.is_empty() { + let i = match self.state.selected() { + Some(i) => { + if i == 0 { + self.composes.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + } + + fn next(&mut self) { + if !self.composes.is_empty() { + let i = match self.state.selected() { + Some(i) => { + if i >= self.composes.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + } + + fn get_selected_compose_info(&self) -> Option { + self.state + .selected() + .and_then(|i| self.composes.get(i).cloned()) + .map(|c| c.project) + } + + pub(crate) fn get_name(&self) -> &'static str { + "Compose projects" + } + + pub(crate) fn register_action_handler(&mut self, tx: UnboundedSender) { + self.action_tx = Some(tx); + } + + pub(crate) async fn update(&mut self, action: Action) -> Result<()> { + let tx = self + .action_tx + .clone() + .expect("Action tx queue not initialized"); + match action { + Action::Tick => { + self.composes = match list_compose_projects().await { + Ok(composes) => composes, + Err(e) => { + tx.send(Action::Error(format!( + "Error getting container list: {}", + e + )))?; + Vec::new() + } + }; + self.composes.sort_by(|a, b| a.project.cmp(&b.project)); + if self.state.selected().is_none() { + self.state.select(Some(0)); + } + } + Action::Down => { + self.next(); + } + Action::Up => { + self.previous(); + } + _ => {} + } + Ok(()) + } + + pub(crate) fn draw(&mut self, f: &mut Frame<'_>, area: Rect) { + let rects = Layout::default() + .constraints([Constraint::Percentage(100)]) + .split(area); + let t = table( + self.get_name().to_string(), + ["Project", "Containers", "Volumes", "Networks"], + self.composes.iter().map(|c| c.into()).collect(), + &COMPOSES_CONSTRAINTS, + Some(Style::new().gray()), + ); + f.render_stateful_widget(t, rects[0], &mut self.state); + } + + pub(crate) fn get_bindings(&self) -> Option<&[(&str, &str)]> { + Some(&[ + ("Enter", "Compose details"), + ("c", "Containers"), + ("v", "Volumes"), + ("n", "Networks"), + ]) + } + + pub(crate) fn get_action(&self, k: &event::KeyEvent) -> Option { + if let Some(project) = self.get_selected_compose_info() { + let filter = Filter::default().compose_project(project); + match k.code { + KeyCode::Enter => Some(Action::Ok), + KeyCode::Char('c') => Some(Action::Screen(Component::Containers(Containers::new( + filter, + )))), + KeyCode::Char('v') => { + Some(Action::Screen(Component::Volumes(Volumes::new(filter)))) + } + KeyCode::Char('n') => { + Some(Action::Screen(Component::Networks(Networks::new(filter)))) + } + _ => None, + } + } else { + None + } + } +} diff --git a/src/components/container_exec.rs b/src/components/container_exec.rs index c793515..7854a69 100644 --- a/src/components/container_exec.rs +++ b/src/components/container_exec.rs @@ -62,7 +62,9 @@ impl ContainerExec { self.should_stop = true; tx.send(Action::Resume)?; - tx.send(Action::Screen(Component::Containers(Containers::new(None))))?; + tx.send(Action::Screen(Component::Containers(Containers::new( + Default::default(), + ))))?; if let Err(e) = res { tx.send(Action::Error(format!( "Unable to execute command \"{}\" in container \"{}\"\n{}", diff --git a/src/components/container_inspect.rs b/src/components/container_inspect.rs index e81942b..4d30333 100644 --- a/src/components/container_inspect.rs +++ b/src/components/container_inspect.rs @@ -54,7 +54,9 @@ impl ContainerDetails { match action { Action::PreviousScreen => { if let Some(tx) = self.action_tx.clone() { - tx.send(Action::Screen(Component::Containers(Containers::new(None))))?; + tx.send(Action::Screen(Component::Containers(Containers::new( + Default::default(), + ))))?; } } Action::Up => { diff --git a/src/components/container_logs.rs b/src/components/container_logs.rs index 8110786..31e4ce1 100644 --- a/src/components/container_logs.rs +++ b/src/components/container_logs.rs @@ -141,7 +141,9 @@ impl ContainerLogs { match action { Action::PreviousScreen => { self.cancel()?; - tx.send(Action::Screen(Component::Containers(Containers::new(None))))?; + tx.send(Action::Screen(Component::Containers(Containers::new( + Default::default(), + ))))?; } Action::Up => { self.auto_scroll = false; diff --git a/src/components/container_view.rs b/src/components/container_view.rs index f2cf56a..1303808 100644 --- a/src/components/container_view.rs +++ b/src/components/container_view.rs @@ -49,7 +49,9 @@ impl ContainerView { let tx = self.action_tx.clone().expect("No action sender"); match action { Action::PreviousScreen => { - tx.send(Action::Screen(Component::Containers(Containers::new(None))))?; + tx.send(Action::Screen(Component::Containers(Containers::new( + Default::default(), + ))))?; } Action::Tick => match get_container_details(&self.id).await { Ok(details) => self.details = Some(details), diff --git a/src/components/containers.rs b/src/components/containers.rs index 1f36733..35d7f36 100644 --- a/src/components/containers.rs +++ b/src/components/containers.rs @@ -11,7 +11,7 @@ use ratatui::{ use tokio::sync::mpsc::UnboundedSender; use crate::runtime::{ - delete_container, get_container, list_containers, validate_container_filters, + delete_container, get_container, list_containers, validate_container_filters, Filter, }; use crate::{action::Action, utils::centered_rect}; use crate::{runtime::ContainerSummary, utils::table}; @@ -77,11 +77,11 @@ pub struct Containers { show_popup: Popup, action_tx: Option>, sort_by: SortColumn, - filter: Option, + filter: Filter, } impl Containers { - pub fn new(filter: Option) -> Self { + pub fn new(filter: Filter) -> Self { Containers { all: false, state: Default::default(), @@ -297,12 +297,12 @@ impl Containers { (Action::SetFilter(filter), Popup::None) => { if let Some(filter) = filter { if validate_container_filters(&filter).await { - self.filter = Some(filter); + self.filter = filter.into(); } else { tx.send(Action::Error(format!("Invalid filter: {}", filter)))?; } } else { - self.filter = filter; + self.filter = Default::default(); } } (Action::Inspect, Popup::None) => { @@ -411,11 +411,7 @@ impl Containers { "{} ({}{})", self.get_name(), if self.all { "All" } else { "Running" }, - if let Some(filter) = &self.filter { - format!(" - Filter: {}", filter) - } else { - "".to_string() - } + self.filter.format() ), ["Id", "Name", "Image", "Status", "Age"], self.containers.iter().map(|c| c.into()).collect(), diff --git a/src/components/images.rs b/src/components/images.rs index 015b58c..b8b13b6 100644 --- a/src/components/images.rs +++ b/src/components/images.rs @@ -9,7 +9,7 @@ use ratatui::Frame; use tokio::sync::mpsc::UnboundedSender; use crate::action::Action; -use crate::runtime::{delete_image, get_image, list_images, ImageSummary}; +use crate::runtime::{delete_image, get_image, list_images, Filter, ImageSummary}; use crate::components::{containers::Containers, image_inspect::ImageInspect, Component}; use crate::utils::{centered_rect, table}; @@ -266,7 +266,7 @@ impl Images { if let KeyCode::Char('c') = k.code { if let Some((id, _)) = self.get_selected_image_info() { Some(Action::Screen(Component::Containers(Containers::new( - Some(format!("ancestor={}", id).to_string()), + Filter::default().filter("ancestor".to_string(), id), )))) } else { None diff --git a/src/components/network_inspect.rs b/src/components/network_inspect.rs index 8a55144..aaac1d0 100644 --- a/src/components/network_inspect.rs +++ b/src/components/network_inspect.rs @@ -54,7 +54,9 @@ impl NetworkInspect { match action { Action::PreviousScreen => { if let Some(tx) = self.action_tx.clone() { - tx.send(Action::Screen(Component::Networks(Networks::new())))?; + tx.send(Action::Screen(Component::Networks(Networks::new( + Default::default(), + ))))?; } } Action::Up => { diff --git a/src/components/networks.rs b/src/components/networks.rs index 56eb2d6..a29e3f5 100644 --- a/src/components/networks.rs +++ b/src/components/networks.rs @@ -1,5 +1,6 @@ use color_eyre::Result; +use crossterm::event::{self, KeyCode}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span}; @@ -9,7 +10,7 @@ use tokio::sync::mpsc::UnboundedSender; use crate::action::Action; use crate::components::{network_inspect::NetworkInspect, Component}; -use crate::runtime::{delete_network, get_network, list_networks, NetworkSummary}; +use crate::runtime::{delete_network, get_network, list_networks, Filter, NetworkSummary}; use crate::utils::{centered_rect, table}; const NETWORK_CONSTRAINTS: [Constraint; 4] = [ @@ -46,18 +47,18 @@ pub struct Networks { show_popup: Popup, action_tx: Option>, sort_by: SortColumn, - filter: Option, + filter: Filter, } impl Networks { - pub fn new() -> Self { + pub fn new(filter: Filter) -> Self { Networks { state: Default::default(), networks: Vec::new(), show_popup: Popup::None, action_tx: None, sort_by: SortColumn::Name(SortOrder::Asc), - filter: None, + filter, } } @@ -191,7 +192,7 @@ impl Networks { }; } Action::SetFilter(filter) => { - self.filter = filter; + self.filter = filter.into(); } Action::Delete => { if let Some((id, _)) = self.get_selected_network_info() { @@ -236,14 +237,7 @@ impl Networks { .constraints([Constraint::Percentage(100)]) .split(area); let t = table( - format!( - "{}{}", - self.get_name(), - match &self.filter { - Some(f) => format!(" - Filter: {}", f), - None => "".to_string(), - } - ), + format!("{}{}", self.get_name(), self.filter.format()), ["Id", "Name", "Driver", "Age"], self.networks.iter().map(|n| n.into()).collect(), &NETWORK_CONSTRAINTS, @@ -265,6 +259,13 @@ impl Networks { ]) } + pub(crate) fn get_action(&self, k: &event::KeyEvent) -> Option { + match k.code { + KeyCode::Char('i') => Some(Action::Inspect), + _ => None, + } + } + pub(crate) fn has_filter(&self) -> bool { true } diff --git a/src/components/volume_inspect.rs b/src/components/volume_inspect.rs index 21fd3dc..ff8af3e 100644 --- a/src/components/volume_inspect.rs +++ b/src/components/volume_inspect.rs @@ -53,7 +53,9 @@ impl VolumeInspect { match action { Action::PreviousScreen => { if let Some(tx) = self.action_tx.clone() { - tx.send(Action::Screen(Component::Volumes(Volumes::new())))?; + tx.send(Action::Screen(Component::Volumes(Volumes::new( + Default::default(), + ))))?; } } Action::Up => { diff --git a/src/components/volumes.rs b/src/components/volumes.rs index cbce2ba..9aa2467 100644 --- a/src/components/volumes.rs +++ b/src/components/volumes.rs @@ -1,5 +1,6 @@ use color_eyre::Result; +use crossterm::event::{self, KeyCode}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span}; @@ -9,7 +10,7 @@ use tokio::sync::mpsc::UnboundedSender; use crate::action::Action; use crate::components::{Component, VolumeInspect}; -use crate::runtime::{delete_volume, get_volume, list_volumes, VolumeSummary}; +use crate::runtime::{delete_volume, get_volume, list_volumes, Filter, VolumeSummary}; use crate::utils::{centered_rect, table}; const VOLUME_CONSTRAINTS: [Constraint; 3] = [ @@ -44,18 +45,18 @@ pub struct Volumes { show_popup: Popup, action_tx: Option>, sort_by: SortColumn, - filter: Option, + filter: Filter, } impl Volumes { - pub fn new() -> Self { + pub fn new(filter: Filter) -> Self { Volumes { state: Default::default(), volumes: Vec::new(), show_popup: Popup::None, action_tx: None, sort_by: SortColumn::Id(SortOrder::Asc), - filter: None, + filter, } } @@ -151,7 +152,7 @@ impl Volumes { pub(crate) async fn update(&mut self, action: Action) -> Result<()> { let tx = self.action_tx.clone().expect("No action sender available"); match action { - Action::Tick => match list_volumes().await { + Action::Tick => match list_volumes(&self.filter).await { Ok(volumes) => { self.volumes = volumes; self.sort(); @@ -184,7 +185,7 @@ impl Volumes { }; } Action::SetFilter(filter) => { - self.filter = filter; + self.filter = filter.into(); } Action::Delete => { if let Some(id) = self.get_selected_volume_info() { @@ -227,14 +228,7 @@ impl Volumes { .constraints([Constraint::Percentage(100)]) .split(area); let t = table( - format!( - "{}{}", - self.get_name(), - match &self.filter { - Some(f) => format!(" - Filter: {}", f), - None => "".to_string(), - } - ), + format!("{}{}", self.get_name(), self.filter.format(),), ["Id", "Driver", "Age"], self.volumes.iter().map(|v| v.into()).collect(), &VOLUME_CONSTRAINTS, @@ -251,11 +245,17 @@ impl Volumes { ("i", "Inspect/View details"), ("F1", "Sort by volume id"), ("F2", "Sort by volume driver"), - ("F3", "Sort by volume size"), - ("F4", "Sort by volume age"), + ("F3", "Sort by volume age"), ]) } + pub(crate) fn get_action(&self, k: &event::KeyEvent) -> Option { + match k.code { + KeyCode::Char('i') => Some(Action::Inspect), + _ => None, + } + } + pub(crate) fn has_filter(&self) -> bool { true } diff --git a/src/runtime.rs b/src/runtime.rs index 3558e79..0caed9b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -22,6 +22,7 @@ lazy_static! { } pub const CONTAINERS: &str = "containers"; +pub const COMPOSES: &str = "composes"; pub const IMAGES: &str = "images"; pub const NETWORKS: &str = "networks"; pub const VOLUMES: &str = "volumes"; @@ -31,7 +32,7 @@ pub(crate) async fn get_suggestions() -> &'static [&'static str] { match *client { Some(ref conn) => match &conn.client { #[cfg(feature = "docker")] - Client::Docker(_) => &[CONTAINERS, IMAGES, NETWORKS, VOLUMES], + Client::Docker(_) => &[CONTAINERS, COMPOSES, IMAGES, NETWORKS, VOLUMES], #[cfg(feature = "cri")] Client::Cri(_) => &[CONTAINERS, IMAGES], }, @@ -129,12 +130,12 @@ pub async fn init(config: Option) -> Result<()> { } } -pub(crate) async fn list_volumes() -> Result> { +pub(crate) async fn list_volumes(filter: &Filter) -> Result> { let client = CLIENT.lock().await; match *client { Some(ref conn) => match &conn.client { #[cfg(feature = "docker")] - Client::Docker(client) => client.list_volumes().await, + Client::Docker(client) => client.list_volumes(filter).await, #[cfg(feature = "cri")] _ => unimplemented!(), }, @@ -169,7 +170,7 @@ pub(crate) async fn delete_volume(id: &str) -> Result<()> { } } -pub(crate) async fn list_networks(filter: &Option) -> Result> { +pub(crate) async fn list_networks(filter: &Filter) -> Result> { let client = CLIENT.lock().await; match *client { Some(ref conn) => match &conn.client { @@ -260,10 +261,7 @@ pub(crate) async fn delete_container(cid: &str) -> Result<()> { } } -pub(crate) async fn list_containers( - all: bool, - filter: &Option, -) -> Result> { +pub(crate) async fn list_containers(all: bool, filter: &Filter) -> Result> { let mut client = CLIENT.lock().await; match *client { Some(ref mut conn) => match &mut conn.client { @@ -294,7 +292,7 @@ pub(crate) async fn get_container_details(cid: &str) -> Result match *client { Some(ref mut conn) => match &mut conn.client { #[cfg(feature = "docker")] - Client::Docker(client) => client.get_container_details(cid).await, + Client::Docker(client) => client.get_container_details(cid.to_string()).await, #[cfg(feature = "cri")] Client::Cri(_client) => unimplemented!(), }, @@ -331,6 +329,19 @@ pub(crate) async fn container_exec(cid: &str, cmd: &str) -> Result<()> { } } +pub(crate) async fn list_compose_projects() -> Result> { + let client = CLIENT.lock().await; + match *client { + Some(ref conn) => match &conn.client { + #[cfg(feature = "docker")] + Client::Docker(client) => client.list_compose_projects().await, + #[cfg(feature = "cri")] + _ => unimplemented!(), + }, + _ => Err(eyre!("Not initialized")), + } +} + pub(crate) async fn get_runtime_info() -> Result { let mut client = CLIENT.lock().await; let (name, version) = match *client { diff --git a/src/runtime/docker.rs b/src/runtime/docker.rs index c48866d..d3466e1 100644 --- a/src/runtime/docker.rs +++ b/src/runtime/docker.rs @@ -30,7 +30,8 @@ use tokio_util::sync::CancellationToken; use crate::utils::get_or_not_found; use super::{ - ContainerDetails, ContainerSummary, Filter, ImageSummary, NetworkSummary, VolumeSummary, + Compose, ContainerDetails, ContainerSummary, Filter, ImageSummary, NetworkSummary, + VolumeSummary, }; const DEFAULT_TIMEOUT: u64 = 120; @@ -49,6 +50,15 @@ const AVAILABLE_CONTAINER_FILTERS: [&str; 14] = [ "network", "publish", "since", "status", "volume", ]; +const DOCKER_COMPOSE_PROJECT: &str = "com.docker.compose.project"; +const DOCKER_COMPOSE_SERVICE: &str = "com.docker.compose.service"; +const DOCKER_COMPOSE_CONTAINER_RANK: &str = "com.docker.compose.container-number"; +const DOCKER_COMPOSE_WORKING_DIR: &str = "com.docker.compose.project.working_dir"; +const DOCKER_COMPOSE_CONFIG: &str = "com.docker.compose.project.config_files"; +const DOCKER_COMPOSE_ENV: &str = "com.docker.compose.project.environment_file"; +const DOCKER_COMPOSE_VOLUME: &str = "com.docker.compose.volume"; +const DOCKER_COMPOSE_NETWORK: &str = "com.docker.compose.network"; + #[derive(Clone, Debug)] pub enum ConnectionConfig { Ssl(String, String), @@ -157,8 +167,10 @@ pub struct Client { } impl Client { - pub(crate) async fn list_volumes(&self) -> Result> { - let options: ListVolumesOptions = Default::default(); + pub(crate) async fn list_volumes(&self, filter: &Filter) -> Result> { + let options: ListVolumesOptions = ListVolumesOptions { + filters: filter.clone().into(), + }; let result = self.client.list_volumes(Some(options)).await?; let volumes = result .volumes @@ -176,6 +188,7 @@ impl Client { .timestamp() }) .unwrap_or_default(), + labels: v.labels.clone(), }) .collect(); Ok(volumes) @@ -193,11 +206,10 @@ impl Client { Ok(()) } - pub(crate) async fn list_networks( - &self, - filter: &Option, - ) -> Result> { - let options: ListNetworksOptions = Default::default(); + pub(crate) async fn list_networks(&self, filter: &Filter) -> Result> { + let options = ListNetworksOptions { + filters: filter.clone().into(), + }; let networks = self.client.list_networks(Some(options)).await?; let networks = networks .iter() @@ -214,10 +226,7 @@ impl Client { .timestamp() }) .unwrap_or_default(), - }) - .filter(|n| match filter { - Some(f) => n.name.contains(f), - None => true, + labels: n.labels.clone().unwrap_or_default(), }) .collect(); Ok(networks) @@ -288,12 +297,11 @@ impl Client { pub(crate) async fn list_containers( &self, all: bool, - filter: &Option, + filter: &Filter, ) -> Result> { - let filters = container_filters(filter); let options: ListContainersOptions = ListContainersOptions { all, - filters, + filters: filter.clone().into(), ..Default::default() }; let containers = self.client.list_containers(Some(options)).await?; @@ -304,6 +312,7 @@ impl Client { name: get_or_not_found!(c.names, |c| c.first().and_then(|s| s.split('/').last())), image: get_or_not_found!(c.image, |i| i.split('@').next()), image_id: get_or_not_found!(c.image_id), + labels: c.labels.clone().unwrap_or_default(), status: c.state.clone().unwrap_or("unknown".into()).into(), age: c.created.unwrap_or_default(), }) @@ -319,10 +328,10 @@ impl Client { Ok(serde_json::to_string_pretty(&container_details)?) } - pub(crate) async fn get_container_details(&self, cid: &str) -> Result { + pub(crate) async fn get_container_details(&self, cid: String) -> Result { let container_details = self .client - .inspect_container(cid, Some(InspectContainerOptions { size: false })) + .inspect_container(&cid, Some(InspectContainerOptions { size: false })) .await?; let config = container_details .config @@ -331,7 +340,10 @@ impl Client { let container_top = match status { super::ContainerStatus::Running => self .client - .top_processes(cid, Some(bollard::container::TopOptions { ps_args: "aux" })) + .top_processes( + &cid, + Some(bollard::container::TopOptions { ps_args: "aux" }), + ) .await .ok(), _ => None, @@ -342,6 +354,7 @@ impl Client { age: parse_created(container_details.created), image: config.image, image_id: container_details.image, + labels: config.labels.unwrap_or_default(), entrypoint: config.entrypoint, command: config.cmd, status, @@ -365,6 +378,85 @@ impl Client { })) } + pub(crate) async fn list_compose_projects(&self) -> Result> { + let c: Vec = futures::future::try_join_all( + self.list_containers(true, &Filter::default().compose()) + .await? + .into_iter() + .filter(|c| c.labels.get(DOCKER_COMPOSE_PROJECT).is_some()) + .map(|c| self.get_container_details(c.id.to_string())), + ) + .await?; + let v: Vec = self + .list_volumes(&Filter::default().compose()) + .await? + .into_iter() + .filter(|v| v.labels.get(DOCKER_COMPOSE_PROJECT).is_some()) + .collect(); + let n: Vec = self + .list_networks(&Filter::default().compose()) + .await? + .into_iter() + .filter(|n| n.labels.get(DOCKER_COMPOSE_PROJECT).is_some()) + .collect(); + + let projects = c + .into_iter() + .fold(HashMap::::new(), |mut projects, c| { + let p = c + .labels + .get(DOCKER_COMPOSE_PROJECT) + .expect("Should not happen because it's been filtered"); + let (service, num) = extract_compose_service_info(&c.labels); + let compose = if let Some(compose_ref) = projects.get_mut(p) { + compose_ref + } else { + let (config, wd, env) = extract_compose_info(&c.labels); + let compose = Compose::new(p.to_string(), config, wd, env); + projects.insert(p.to_string(), compose); + projects.get_mut(p).expect("We just put it there") + }; + compose.services.insert((service, num), c); + projects + }); + let projects = v.into_iter().fold(projects, |mut projects, v| { + let p = v + .labels + .get(DOCKER_COMPOSE_PROJECT) + .expect("Should not happend because it's been filtered"); + let volume = extract_compose_volume_info(&v.labels); + let compose = if let Some(compose_ref) = projects.get_mut(p) { + compose_ref + } else { + let (config, wd, env) = extract_compose_info(&v.labels); + let compose = Compose::new(p.to_string(), config, wd, env); + projects.insert(p.to_string(), compose); + projects.get_mut(p).expect("We just put it there") + }; + compose.volumes.insert(volume, v); + projects + }); + let projects = n.into_iter().fold(projects, |mut projects, n| { + let p = n + .labels + .get(DOCKER_COMPOSE_PROJECT) + .expect("Should not happend because it's been filtered"); + let network = extract_compose_network_info(&n.labels); + let compose = if let Some(compose_ref) = projects.get_mut(p) { + compose_ref + } else { + let (config, wd, env) = extract_compose_info(&n.labels); + let compose = Compose::new(p.to_string(), config, wd, env); + projects.insert(p.to_string(), compose); + projects.get_mut(p).expect("We just put it there") + }; + compose.networks.insert(network, n); + projects + }); + + Ok(projects.into_values().collect()) + } + pub(crate) async fn container_exec(&self, cid: &str, cmd: &str) -> Result<()> { let cancellation_token = CancellationToken::new(); let _cancellation_token = cancellation_token.clone(); @@ -450,6 +542,43 @@ impl Client { } } +fn extract_compose_network_info(labels: &HashMap) -> String { + labels + .get(DOCKER_COMPOSE_NETWORK) + .expect("Already filtered") + .to_string() +} + +fn extract_compose_volume_info(labels: &HashMap) -> String { + labels + .get(DOCKER_COMPOSE_VOLUME) + .expect("Already filtered") + .to_string() +} + +fn extract_compose_service_info(labels: &HashMap) -> (String, String) { + ( + labels + .get(DOCKER_COMPOSE_SERVICE) + .expect("No service label") + .to_string(), + labels + .get(DOCKER_COMPOSE_CONTAINER_RANK) + .expect("No container rank label") + .to_string(), + ) +} + +fn extract_compose_info( + labels: &HashMap, +) -> (Option, Option, Option) { + ( + labels.get(DOCKER_COMPOSE_CONFIG).cloned(), + labels.get(DOCKER_COMPOSE_WORKING_DIR).cloned(), + labels.get(DOCKER_COMPOSE_ENV).cloned(), + ) +} + fn parse_processes(processes: Option>>) -> Vec<(String, String, String)> { processes .map(|ps| { @@ -543,22 +672,6 @@ fn parse_env(env: Option>) -> Vec<(String, String)> { envs } -pub fn container_filters(filter: &Option) -> HashMap> { - match filter { - None => HashMap::new(), - Some(s) => { - let mut split = s.split('='); - match (split.next(), split.next()) { - (Some(k), Some(v)) => Filter::default() - .filter(k.to_string(), v.to_string()) - .into(), - (None, Some(_)) => Filter::default().into(), - (Some(_), None) | (None, None) => Filter::default().name(s.to_string()).into(), - } - } - } -} - pub(crate) fn connect(config: &ConnectionConfig) -> Result { let docker = match config { ConnectionConfig::Ssl(host, certs_path) => { diff --git a/src/runtime/model.rs b/src/runtime/model.rs index c01113a..81febed 100644 --- a/src/runtime/model.rs +++ b/src/runtime/model.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, fmt::Display}; use humansize::{FormatSizeI, BINARY}; @@ -35,17 +35,25 @@ impl Filter { pub fn compose(self) -> Self { self.filter( - "labels".to_string(), + "label".to_string(), "com.docker.compose.project".to_string(), ) } pub fn compose_project(self, project: String) -> Self { self.filter( - "labels".to_string(), + "label".to_string(), format!("{}={}", "com.docker.compose.project", project).to_string(), ) } + + pub fn format(&self) -> String { + if self.filter.is_empty() { + String::new() + } else { + format!(" - Filters: {}", self) + } + } } impl From for HashMap> { @@ -58,6 +66,48 @@ impl From for HashMap> { } } +impl From for String { + fn from(value: Filter) -> Self { + value + .filter + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("&") + } +} + +impl Display for Filter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = self + .filter + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("&"); + f.write_str(&s) + } +} + +impl From> for Filter { + fn from(value: Option) -> Self { + match value { + None => Filter::default(), + Some(s) => s.into(), + } + } +} + +impl From for Filter { + fn from(value: String) -> Self { + match value.split_once('=') { + Some((k, "")) => Filter::default().name(k.to_string()), + Some((k, v)) => Filter::default().filter(k.to_string(), v.to_string()), + None => Filter::default(), + } + } +} + #[derive(Clone, Debug)] pub struct RuntimeSummary { pub name: String, @@ -65,11 +115,12 @@ pub struct RuntimeSummary { pub config: Option, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct VolumeSummary { pub id: String, pub driver: String, pub created: i64, + pub labels: HashMap, } impl<'a> From<&VolumeSummary> for Row<'a> { @@ -78,17 +129,19 @@ impl<'a> From<&VolumeSummary> for Row<'a> { id, driver, created, + .. } = value.clone(); Row::new(vec![id.gray(), driver.gray(), created.age().gray()]) } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct NetworkSummary { pub id: String, pub name: String, pub driver: String, pub created: i64, + pub labels: HashMap, } impl<'a> From<&NetworkSummary> for Row<'a> { @@ -98,6 +151,7 @@ impl<'a> From<&NetworkSummary> for Row<'a> { name, driver, created, + .. } = value.clone(); Row::new(vec![ id.gray(), @@ -194,12 +248,13 @@ impl ContainerStatus { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct ContainerSummary { pub id: String, pub name: String, pub image: String, pub image_id: String, + pub labels: HashMap, pub status: ContainerStatus, pub age: i64, } @@ -224,12 +279,13 @@ impl<'a> From<&ContainerSummary> for Row<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct ContainerDetails { pub id: String, pub name: String, pub image: Option, pub image_id: Option, + pub labels: HashMap, pub status: ContainerStatus, pub age: Option, pub ports: Vec<(String, String)>, @@ -339,3 +395,44 @@ impl<'a> From<&ContainerDetails> for Vec> { text } } + +#[derive(Clone, Debug, PartialEq)] +pub struct Compose { + pub project: String, + pub config_file: Option, + pub working_dir: Option, + pub environment_files: Option, + pub services: HashMap<(String, String), ContainerDetails>, + pub volumes: HashMap, + pub networks: HashMap, +} + +impl Compose { + pub fn new( + project: String, + config_file: Option, + working_dir: Option, + environment_files: Option, + ) -> Self { + Compose { + project, + config_file, + working_dir, + environment_files, + services: HashMap::new(), + volumes: HashMap::new(), + networks: HashMap::new(), + } + } +} + +impl<'a> From<&Compose> for Row<'a> { + fn from(value: &Compose) -> Row<'a> { + Row::new(vec![ + value.project.to_string(), + value.services.len().to_string(), + value.volumes.len().to_string(), + value.networks.len().to_string(), + ]) + } +}