diff --git a/README.md b/README.md index 9214e2d..0bc6705 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ The following commands are supported: | `images` | `image` | Open the `Images` top level page | | `containers` | `container` | Open the `Containers` top level page | | `volumes` | `volume` | Open the `Volumes` top level page | +| `networks` | `network` | Open the `Networks` top level page | | `quit` | `q` | Close the application | @@ -126,6 +127,15 @@ The following actions are available on the Volumes page: | `Ctrl+d` | Delete the currently selected volume | | `d` | Describe the currently selected volume | +#### Networks + +The following actions are available on the Volumes page: + +| Hotkey | Action | +| -------- | -------------------------------------- | +| `Ctrl+d` | Delete the currently selected volume | +| `d` | Describe the currently selected volume | + #### Logs The following actions are available on the Logs page: diff --git a/src/callbacks/delete_network.rs b/src/callbacks/delete_network.rs new file mode 100644 index 0000000..4aee6df --- /dev/null +++ b/src/callbacks/delete_network.rs @@ -0,0 +1,23 @@ +use crate::{docker::network::DockerNetwork, traits::Callback}; +use async_trait::async_trait; +use color_eyre::eyre::Result; + +#[derive(Debug)] +pub struct DeleteNetwork { + docker: bollard::Docker, + network: DockerNetwork, +} + +impl DeleteNetwork { + pub fn new(docker: bollard::Docker, network: DockerNetwork) -> Self { + Self { docker, network } + } +} + +#[async_trait] +impl Callback for DeleteNetwork { + async fn call(&self) -> Result<()> { + let _ = self.network.delete(&self.docker).await?; + Ok(()) + } +} diff --git a/src/callbacks/empty_callable.rs b/src/callbacks/empty_callable.rs new file mode 100644 index 0000000..5ae1f84 --- /dev/null +++ b/src/callbacks/empty_callable.rs @@ -0,0 +1,19 @@ +use crate::traits::Callback; +use async_trait::async_trait; +use color_eyre::eyre::Result; + +#[derive(Debug)] +pub struct EmptyCallable {} + +impl EmptyCallable { + pub fn new() -> Self { + Self {} + } +} + +#[async_trait] +impl Callback for EmptyCallable { + async fn call(&self) -> Result<()> { + Ok(()) + } +} diff --git a/src/callbacks/mod.rs b/src/callbacks/mod.rs index e2e32f8..18c320b 100644 --- a/src/callbacks/mod.rs +++ b/src/callbacks/mod.rs @@ -1,4 +1,6 @@ pub mod delete_container; pub mod delete_image; +pub mod delete_network; pub mod delete_volume; +pub mod empty_callable; pub use delete_container::DeleteContainer; diff --git a/src/components/command_input.rs b/src/components/command_input.rs index fb92405..695f0e2 100644 --- a/src/components/command_input.rs +++ b/src/components/command_input.rs @@ -22,6 +22,8 @@ const CONTAINER: &str = "container"; const CONTAINERS: &str = "containers"; const VOLUME: &str = "volume"; const VOLUMES: &str = "volumes"; +const NETWORK: &str = "network"; +const NETWORKS: &str = "networks"; #[derive(Debug)] pub struct CommandInput { @@ -33,7 +35,7 @@ pub struct CommandInput { impl CommandInput { pub fn new(tx: Sender>, prompt: String) -> Self { let ac: Autocomplete = Autocomplete::from(vec![ - QUIT, Q, IMAGE, IMAGES, CONTAINER, CONTAINERS, VOLUME, VOLUMES, + QUIT, Q, IMAGE, IMAGES, CONTAINER, CONTAINERS, VOLUME, VOLUMES, NETWORK, NETWORKS, ]); Self { tx, @@ -93,6 +95,7 @@ impl CommandInput { IMAGE | IMAGES => Some(Transition::ToImagePage(AppContext::default())), CONTAINER | CONTAINERS => Some(Transition::ToContainerPage(AppContext::default())), VOLUME | VOLUMES => Some(Transition::ToVolumePage(AppContext::default())), + NETWORK | NETWORKS => Some(Transition::ToNetworkPage(AppContext::default())), _ => None, }; diff --git a/src/context.rs b/src/context.rs index ac838f2..007831a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,6 +1,7 @@ use crate::{ docker::{ - container::DockerContainer, image::DockerImage, traits::Describe, volume::DockerVolume, + container::DockerContainer, image::DockerImage, network::DockerNetwork, traits::Describe, + volume::DockerVolume, }, events::Transition, }; @@ -17,6 +18,7 @@ pub struct AppContext { pub docker_container: Option, pub docker_image: Option, pub docker_volume: Option, + pub docker_network: Option, pub describable: Option>, } diff --git a/src/docker/mod.rs b/src/docker/mod.rs index 0aae9df..9ea5168 100644 --- a/src/docker/mod.rs +++ b/src/docker/mod.rs @@ -1,6 +1,7 @@ pub mod container; pub mod image; pub mod logs; +pub mod network; pub mod traits; pub mod util; pub mod volume; diff --git a/src/docker/network.rs b/src/docker/network.rs new file mode 100644 index 0000000..3566e3f --- /dev/null +++ b/src/docker/network.rs @@ -0,0 +1,70 @@ +use bollard::{ + secret::{Network, NetworkContainer, Volume, VolumeScopeEnum}, + volume::RemoveVolumeOptions, +}; +use byte_unit::{Byte, UnitType}; +use color_eyre::eyre::{bail, Result}; +use serde::Serialize; +use std::collections::HashMap; + +use super::traits::Describe; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct DockerNetwork { + pub id: String, + pub name: String, + pub driver: String, + pub created_at: String, + pub scope: String, + pub internal: Option, + pub attachable: Option, + pub containers: Option>, +} + +impl DockerNetwork { + pub fn from(v: Network) -> Self { + Self { + id: v.id.unwrap_or_default(), + name: v.name.unwrap_or_default(), + driver: v.driver.unwrap_or_default(), + created_at: v.created.unwrap_or_default(), + scope: v.scope.unwrap_or_default(), + internal: v.internal, + attachable: v.attachable, + containers: v.containers, + } + } + + pub async fn list(docker: &bollard::Docker) -> Result> { + let networks = docker.list_networks::(None).await?; + let mut network: Vec = networks.into_iter().map(Self::from).collect(); + + network.sort_by_key(|v| v.name.clone()); + + Ok(network) + } + + pub async fn delete(&self, docker: &bollard::Docker) -> Result<()> { + docker.remove_network(&self.get_name()).await?; + Ok(()) + } +} + +impl Describe for DockerNetwork { + fn get_id(&self) -> String { + self.get_name() + } + fn get_name(&self) -> String { + self.name.clone() + } + + fn describe(&self) -> Result> { + let summary = match serde_yml::to_string(&self) { + Ok(s) => s, + Err(_) => { + bail!("failed to parse container summary") + } + }; + Ok(summary.lines().map(String::from).collect()) + } +} diff --git a/src/events/transition.rs b/src/events/transition.rs index a4a83ba..0debfaa 100644 --- a/src/events/transition.rs +++ b/src/events/transition.rs @@ -19,6 +19,7 @@ pub enum Transition { ToDescribeContainerPage(AppContext), ToAttach(AppContext), ToVolumePage(AppContext), + ToNetworkPage(AppContext), } pub async fn send_transition( diff --git a/src/pages/mod.rs b/src/pages/mod.rs index cef909c..efc05e3 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -3,4 +3,5 @@ pub mod containers; pub mod describe; pub mod images; pub mod logs; -pub mod volume; +pub mod networks; +pub mod volumes; diff --git a/src/pages/networks.rs b/src/pages/networks.rs new file mode 100644 index 0000000..ef3229e --- /dev/null +++ b/src/pages/networks.rs @@ -0,0 +1,327 @@ +use bollard::Docker; +use color_eyre::eyre::{bail, Context, ContextCompat, Result}; +use futures::lock::Mutex as FutureMutex; +use ratatui::{ + layout::Rect, + prelude::*, + style::Style, + widgets::{Row, Table, TableState}, + Frame, +}; +use ratatui_macros::constraints; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; +use tokio::sync::mpsc::Sender; + +use crate::{ + // callbacks::delete_network::DeleteNetwork, + callbacks::{delete_network::DeleteNetwork, empty_callable::EmptyCallable}, + components::{ + boolean_modal::{BooleanModal, ModalState}, + help::{PageHelp, PageHelpBuilder}, + }, + config::Config, + context::AppContext, + docker::network::DockerNetwork, + events::{message::MessageResponse, Key, Message, Transition}, + traits::{Close, Component, ModalComponent, Page}, +}; + +const NAME: &str = "Networks"; + +const UP_KEY: Key = Key::Up; +const DOWN_KEY: Key = Key::Down; + +const J_KEY: Key = Key::Char('j'); +const K_KEY: Key = Key::Char('k'); +const CTRL_D_KEY: Key = Key::Ctrl('d'); +const SHIFT_D_KEY: Key = Key::Char('D'); +const D_KEY: Key = Key::Char('d'); +const G_KEY: Key = Key::Char('g'); +const SHIFT_G_KEY: Key = Key::Char('G'); + +#[derive(Debug)] +enum ModalTypes { + DeleteNetwork, + FailedToDeleteNetwork, +} + +#[derive(Debug)] +pub struct Network { + pub name: String, + tx: Sender>, + page_help: Arc>, + docker: Docker, + networks: Vec, + list_state: TableState, + modal: Option>, + show_dangling: bool, +} + +#[async_trait::async_trait] +impl Page for Network { + async fn update(&mut self, message: Key) -> Result { + self.refresh().await?; + + let res = self.update_modal(message).await?; + if res == MessageResponse::Consumed { + return Ok(res); + } + + let result = match message { + UP_KEY | K_KEY => { + self.decrement_list(); + MessageResponse::Consumed + } + DOWN_KEY | J_KEY => { + self.increment_list(); + MessageResponse::Consumed + } + SHIFT_D_KEY => { + self.show_dangling = !self.show_dangling; + MessageResponse::Consumed + } + G_KEY => { + self.list_state.select(Some(0)); + MessageResponse::Consumed + } + SHIFT_G_KEY => { + self.list_state.select(Some(self.networks.len() - 1)); + MessageResponse::Consumed + } + CTRL_D_KEY => match self.delete_network() { + Ok(()) => MessageResponse::Consumed, + Err(_) => MessageResponse::NotConsumed, + }, + D_KEY => { + self.tx + .send(Message::Transition(Transition::ToDescribeContainerPage( + self.get_context()?, + ))) + .await?; + MessageResponse::Consumed + } + _ => MessageResponse::NotConsumed, + }; + Ok(result) + } + + async fn initialise(&mut self, cx: AppContext) -> Result<()> { + self.list_state = TableState::default(); + self.list_state.select(Some(0)); + + self.refresh().await.context("unable to refresh networks")?; + + let network_id: String; + if let Some(network) = cx.docker_network { + network_id = network.name; + } else if let Some(thing) = cx.describable { + network_id = thing.get_id(); + } else { + return Ok(()); + } + + for (idx, c) in self.networks.iter().enumerate() { + if c.name == network_id { + self.list_state.select(Some(idx)); + break; + } + } + + Ok(()) + } + + fn get_help(&self) -> Arc> { + self.page_help.clone() + } +} + +#[async_trait::async_trait] +impl Close for Network {} + +impl Network { + #[must_use] + pub fn new(docker: Docker, tx: Sender>, config: Box) -> Self { + let page_help = PageHelpBuilder::new(NAME.into(), config.clone()) + .add_input(format!("{CTRL_D_KEY}"), "delete".into()) + .add_input(format!("{G_KEY}"), "top".into()) + .add_input(format!("{SHIFT_G_KEY}"), "bottom".into()) + // .add_input(format!("{SHIFT_D_KEY}"), "dangling".into()) + .add_input(format!("{D_KEY}"), "describe".into()) + .build(); + + Self { + name: String::from(NAME), + tx, + page_help: Arc::new(Mutex::new(page_help)), + docker, + networks: vec![], + list_state: TableState::default(), + modal: None, + show_dangling: false, + } + } + + async fn refresh(&mut self) -> Result<(), color_eyre::eyre::Error> { + let mut filters: HashMap> = HashMap::new(); + filters.insert("dangling".into(), vec!["false".into()]); + + self.networks = DockerNetwork::list(&self.docker) + .await + .context("unable to retrieve list of networks")?; + Ok(()) + } + + async fn update_modal(&mut self, message: Key) -> Result { + // Due to the fact only 1 thing should be operating at a time, we can do this to reduce unnecessary nesting + if self.modal.is_none() { + return Ok(MessageResponse::NotConsumed); + } + let m = self.modal.as_mut().context( + "a modal magically vanished between the check that it exists and the operation on it", + )?; + + if let ModalState::Open(_) = m.state { + match m.update(message).await { + Ok(_) => { + if let ModalState::Closed = m.state { + self.modal = None; + } + } + Err(e) => { + if let ModalTypes::DeleteNetwork = m.discriminator { + let msg = + "An error occurred deleting this network. It is likely still in use. Will not try again."; + let mut modal = BooleanModal::::new( + "Failed Deletion".into(), + ModalTypes::FailedToDeleteNetwork, + ); + + modal.initialise( + msg.into(), + Some(Arc::new(FutureMutex::new(EmptyCallable::new()))), + ); + self.modal = Some(modal) + } else { + return Err(e); + } + } + } + Ok(MessageResponse::Consumed) + } else { + Ok(MessageResponse::NotConsumed) + } + } + + fn increment_list(&mut self) { + let current_idx = self.list_state.selected(); + match current_idx { + None => self.list_state.select(Some(0)), + Some(current_idx) => { + if !self.networks.is_empty() && current_idx < self.networks.len() - 1 { + self.list_state.select(Some(current_idx + 1)); + } + } + } + } + + fn decrement_list(&mut self) { + let current_idx = self.list_state.selected(); + match current_idx { + None => self.list_state.select(Some(0)), + Some(current_idx) => { + if current_idx > 0 { + self.list_state.select(Some(current_idx - 1)); + } + } + } + } + + fn get_network(&self) -> Result<&DockerNetwork> { + if let Some(network_idx) = self.list_state.selected() { + if let Some(network) = self.networks.get(network_idx) { + return Ok(network); + } + } + bail!("no container id found"); + } + + fn get_context(&self) -> Result { + let network = self.get_network()?; + + let then = Some(Box::new(Transition::ToNetworkPage(AppContext { + docker_network: Some(network.clone()), + ..Default::default() + }))); + + let cx = AppContext { + describable: Some(Box::new(network.clone())), + then, + ..Default::default() + }; + + Ok(cx) + } + + fn delete_network(&mut self) -> Result<()> { + if let Ok(network) = self.get_network() { + let name = network.name.clone(); + + let cb = Arc::new(FutureMutex::new(DeleteNetwork::new( + self.docker.clone(), + network.clone(), + ))); + + let mut modal = + BooleanModal::::new("Delete".into(), ModalTypes::DeleteNetwork); + + modal.initialise( + format!("Are you sure you wish to delete network {name})?"), + Some(cb), + ); + self.modal = Some(modal); + } else { + bail!("failed to setup deletion modal") + } + Ok(()) + } +} + +impl Component for Network { + fn draw(&mut self, f: &mut Frame<'_>, area: Rect) { + let rows = get_network_rows(&self.networks); + let columns = Row::new(vec!["Id", "Name", "Driver", "Created", "Scope"]); + + let widths = constraints![==30%, ==25%, ==15%, ==15%, ==15%]; + + let table = Table::new(rows.clone(), widths) + .header(columns.clone().style(Style::new().bold())) + .highlight_style(Style::new().reversed()); + + f.render_stateful_widget(table, area, &mut self.list_state); + + if let Some(m) = self.modal.as_mut() { + if let ModalState::Open(_) = m.state { + m.draw(f, area); + } + } + } +} + +fn get_network_rows(networks: &[DockerNetwork]) -> Vec { + let rows = networks + .iter() + .map(|c| { + Row::new(vec![ + c.id.clone(), + c.name.clone(), + c.driver.clone(), + c.created_at.clone(), + c.scope.clone(), + ]) + }) + .collect::>(); + rows +} diff --git a/src/pages/volume.rs b/src/pages/volumes.rs similarity index 100% rename from src/pages/volume.rs rename to src/pages/volumes.rs diff --git a/src/state.rs b/src/state.rs index e95c489..bc84e5e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -21,6 +21,7 @@ pub enum CurrentPage { Volumes, Logs, Attach, + Network, DescribeContainer, } diff --git a/src/ui/page_manager.rs b/src/ui/page_manager.rs index a5cb403..b380d00 100644 --- a/src/ui/page_manager.rs +++ b/src/ui/page_manager.rs @@ -13,7 +13,7 @@ use crate::{ events::{message::MessageResponse, Key, Message, Transition}, pages::{ attach::Attach, containers::Containers, describe::DescribeContainer, images::Images, - logs::Logs, volume::Volume, + logs::Logs, networks::Network, volumes::Volume, }, state, traits::{Component, Page}, @@ -89,6 +89,11 @@ impl PageManager { .await?; MessageResponse::Consumed } + Transition::ToNetworkPage(cx) => { + self.set_current_page(state::CurrentPage::Network, cx) + .await?; + MessageResponse::Consumed + } _ => MessageResponse::NotConsumed, }; Ok(result) @@ -152,6 +157,13 @@ impl PageManager { self.config.clone(), )) } + state::CurrentPage::Network => { + self.page = Box::new(Network::new( + self.docker.clone(), + self.tx.clone(), + self.config.clone(), + )) + } }; self.page.initialise(cx).await?;