diff --git a/Cargo.toml b/Cargo.toml index c3d3d63..d1000f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,9 @@ env_logger = "0.10.1" log = "0.4.20" log4rs = "1.2.0" pretty_assertions = "1.4.0" + ratatui = { version = "0.25.0", features = ["serde", "macros", "unstable-rendered-line-info"] } + serde = { version = "1.0.188", features = ["derive"] } serde_json = "1.0.107" signal-hook = "0.3.17" diff --git a/doc/preview.gif b/doc/preview.gif index 9cc0967..a36c21a 100644 Binary files a/doc/preview.gif and b/doc/preview.gif differ diff --git a/doc/preview.tape b/doc/preview.tape index b872b9c..e535315 100644 --- a/doc/preview.tape +++ b/doc/preview.tape @@ -110,7 +110,27 @@ Sleep 8s Escape -# -- Show change resource/autocomplete +# -- Show change resource/autocomplete and compose list +Type ":" +Sleep 1s +Type "comp" + +Sleep 1s +Enter + +# -- Show compose detail +Sleep 2s +Enter + +PageDown +Sleep 800ms +PageDown +Sleep 800ms + +Sleep 8s +Escape + +# -- Show image list Type ":" Sleep 1s Type "im" diff --git a/src/components.rs b/src/components.rs index 85032a2..a78842e 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::compose_view::ComposeView; use crate::components::composes::Composes; use crate::components::container_exec::ContainerExec; use crate::components::container_inspect::ContainerDetails; @@ -20,6 +21,7 @@ use crate::components::volume_inspect::VolumeInspect; use crate::components::volumes::Volumes; use crate::tui; +pub mod compose_view; pub mod composes; pub mod container_exec; pub mod container_inspect; @@ -41,6 +43,7 @@ pub(crate) enum Component { ContainerLogs(ContainerLogs), ContainerView(ContainerView), Composes(Composes), + ComposeView(ComposeView), Images(Images), ImageInspect(ImageInspect), Networks(Networks), @@ -85,6 +88,7 @@ impl Component { ContainerLogs, ContainerView, Composes, + ComposeView, Images, ImageInspect, Networks, @@ -105,6 +109,7 @@ impl Component { ContainerLogs, ContainerView, Composes, + ComposeView, Images, ImageInspect, Networks, @@ -125,6 +130,7 @@ impl Component { ContainerLogs, ContainerView, Composes, + ComposeView, Images, ImageInspect, Networks, @@ -154,6 +160,7 @@ impl Component { ContainerLogs, ContainerView, Composes, + ComposeView, Images, ImageInspect, Networks, diff --git a/src/components/compose_view.rs b/src/components/compose_view.rs new file mode 100644 index 0000000..56bfdbb --- /dev/null +++ b/src/components/compose_view.rs @@ -0,0 +1,92 @@ +use color_eyre::Result; + +use ratatui::{ + style::{Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Paragraph, ScrollbarState}, +}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::{action::Action, runtime::Compose}; + +use super::{composes::Composes, Component}; + +#[derive(Clone, Debug)] +pub struct ComposeView { + compose: Compose, + action_tx: Option>, + vertical_scroll_state: ScrollbarState, + vertical_scroll: usize, +} + +impl ComposeView { + pub fn new(compose: Compose) -> Self { + ComposeView { + compose, + action_tx: None, + vertical_scroll_state: Default::default(), + vertical_scroll: 0, + } + } + + pub(crate) fn get_name(&self) -> &'static str { + "ComposeView" + } + + pub(crate) fn register_action_handler(&mut self, action_tx: UnboundedSender) { + self.action_tx = Some(action_tx); + } + + fn down(&mut self, qty: usize) { + self.vertical_scroll = self.vertical_scroll.saturating_add(qty); + self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll); + } + + fn up(&mut self, qty: usize) { + self.vertical_scroll = self.vertical_scroll.saturating_sub(qty); + self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll); + } + + pub(crate) async fn update(&mut self, action: Action) -> Result<()> { + let tx = self.action_tx.clone().expect("No action sender"); + match action { + Action::PreviousScreen => { + tx.send(Action::Screen(Component::Composes(Composes::new())))?; + } + Action::Up => { + self.up(1); + } + Action::Down => { + self.down(1); + } + Action::PageUp => { + self.up(15); + } + Action::PageDown => { + self.down(15); + } + _ => {} + } + Ok(()) + } + + pub(crate) fn draw( + &mut self, + f: &mut ratatui::prelude::Frame<'_>, + area: ratatui::prelude::Rect, + ) { + let text: Vec = (&self.compose).into(); + let details = Paragraph::new(Text::from(text)).block( + Block::default().borders(Borders::ALL).title(Span::styled( + format!( + "Inspecting compose project: \"{}\" (press 'ESC' to previous screen, 'q' to quit)", + self.compose.project + ), + Style::default().add_modifier(Modifier::BOLD), + )), + ) + .scroll((self.vertical_scroll as u16, 0)); + + f.render_widget(details, area); + } +} diff --git a/src/components/composes.rs b/src/components/composes.rs index 08f0b9b..c5c0b64 100644 --- a/src/components/composes.rs +++ b/src/components/composes.rs @@ -15,7 +15,10 @@ use crate::{ utils::table, }; -use super::{containers::Containers, networks::Networks, volumes::Volumes, Component}; +use super::{ + compose_view::ComposeView, containers::Containers, networks::Networks, volumes::Volumes, + Component, +}; const COMPOSES_CONSTRAINTS: [Constraint; 4] = [ Constraint::Min(20), @@ -72,11 +75,10 @@ impl Composes { } } - fn get_selected_compose_info(&self) -> Option { + 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 { @@ -115,6 +117,7 @@ impl Composes { Action::Up => { self.previous(); } + Action::Ok => {} _ => {} } Ok(()) @@ -144,10 +147,12 @@ impl Composes { } 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); + if let Some(compose) = self.get_selected_compose_info() { + let filter = Filter::default().compose_project(compose.project.clone()); match k.code { - KeyCode::Enter => Some(Action::Ok), + KeyCode::Enter => Some(Action::Screen(Component::ComposeView(ComposeView::new( + compose, + )))), KeyCode::Char('c') => Some(Action::Screen(Component::Containers(Containers::new( filter, )))), diff --git a/src/components/images.rs b/src/components/images.rs index b8b13b6..94c2662 100644 --- a/src/components/images.rs +++ b/src/components/images.rs @@ -263,16 +263,18 @@ impl Images { } pub(crate) fn get_action(&self, k: &crossterm::event::KeyEvent) -> Option { - if let KeyCode::Char('c') = k.code { - if let Some((id, _)) = self.get_selected_image_info() { - Some(Action::Screen(Component::Containers(Containers::new( - Filter::default().filter("ancestor".to_string(), id), - )))) - } else { - None + match k.code { + KeyCode::Char('c') => { + if let Some((id, _)) = self.get_selected_image_info() { + Some(Action::Screen(Component::Containers(Containers::new( + Filter::default().filter("ancestor".to_string(), id), + )))) + } else { + None + } } - } else { - None + KeyCode::Char('i') => Some(Action::Inspect), + _ => None, } } diff --git a/src/runtime/docker.rs b/src/runtime/docker.rs index 42daa9d..4ddfd14 100644 --- a/src/runtime/docker.rs +++ b/src/runtime/docker.rs @@ -56,8 +56,6 @@ 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 { @@ -424,7 +422,6 @@ impl Client { .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 { @@ -433,7 +430,7 @@ impl Client { projects.insert(p.to_string(), compose); projects.get_mut(p).expect("We just put it there") }; - compose.volumes.insert(volume, v); + compose.volumes.insert(v.id.to_string(), v); projects }); let projects = n.into_iter().fold(projects, |mut projects, n| { @@ -441,7 +438,6 @@ impl Client { .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 { @@ -450,7 +446,7 @@ impl Client { projects.insert(p.to_string(), compose); projects.get_mut(p).expect("We just put it there") }; - compose.networks.insert(network, n); + compose.networks.insert(n.name.to_string(), n); projects }); @@ -542,20 +538,6 @@ 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 diff --git a/src/runtime/model.rs b/src/runtime/model.rs index d0122de..50160b4 100644 --- a/src/runtime/model.rs +++ b/src/runtime/model.rs @@ -136,6 +136,21 @@ impl<'a> From<&VolumeSummary> for Row<'a> { } } +fn volume_detail_to_lines<'a>(val: &VolumeSummary, indent: usize) -> Vec> { + vec![Line::from(format!( + "{:indent$}Driver: {}", + "", + val.driver, + indent = indent + ))] +} + +impl<'a> From<&VolumeSummary> for Vec> { + fn from(val: &VolumeSummary) -> Vec> { + volume_detail_to_lines(val, 2) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct NetworkSummary { pub id: String, @@ -163,6 +178,21 @@ impl<'a> From<&NetworkSummary> for Row<'a> { } } +fn network_detail_to_lines<'a>(val: &NetworkSummary, indent: usize) -> Vec> { + vec![Line::from(format!( + "{:indent$}Driver: {}", + "", + val.driver, + indent = indent + ))] +} + +impl<'a> From<&NetworkSummary> for Vec> { + fn from(val: &NetworkSummary) -> Vec> { + network_detail_to_lines(val, 2) + } +} + #[derive(Clone, Debug)] pub struct ImageSummary { pub id: String, @@ -315,6 +345,172 @@ impl<'a> From<&ContainerSummary> for Row<'a> { } } +fn details_to_lines<'a>(val: &ContainerDetails, indent: usize) -> Vec> { + let style = Style::default().gray(); + let mut text: Vec = vec![ + Line::styled( + format!("{:indent$}Id: {}", "", &val.id[0..12], indent = indent).to_string(), + style, + ), + Line::styled( + format!("{:indent$}Name: {}", "", val.name, indent = indent).to_string(), + style, + ), + Line::from(vec![ + Span::styled(format!("{:indent$}Status: ", "", indent = indent), style), + val.status.format(), + ]), + ]; + if let Some(age) = val.age { + text.push(Line::styled( + format!("{:indent$}Created: {}", "", age.age(), indent = indent), + style, + )); + } + match (val.image.as_ref(), val.image_id.as_ref()) { + (Some(image), Some(_image_id)) => text.push(Line::styled( + format!("{:indent$}Image: {}", "", image, indent = indent), + style, + )), + (Some(image), None) => text.push(Line::styled( + format!("{:indent$}Image: {}", "", image, indent = indent), + style, + )), + (None, Some(image_id)) => text.push(Line::styled( + format!("{:indent$}Image: {}", "", image_id, indent = indent), + style, + )), + (None, None) => {} + } + if let Some(entrypoint) = &val.entrypoint { + if !entrypoint.is_empty() { + text.push(Line::styled( + format!("{:indent$}Entrypoint:", "", indent = indent), + style, + )); + text.append( + &mut entrypoint + .iter() + .map(|entry| { + Line::styled( + format!("{:indent$} - {}", "", entry, indent = indent).to_string(), + style, + ) + }) + .collect(), + ); + } + } + if let Some(command) = &val.command { + if !command.is_empty() { + text.push(Line::styled( + format!("{:indent$}Command:", "", indent = indent), + style, + )); + text.append( + &mut command + .iter() + .map(|cmd| { + Line::styled( + format!("{:indent$} - {}", "", cmd, indent = indent).to_string(), + style, + ) + }) + .collect(), + ); + } + } + if !val.env.is_empty() { + text.push(Line::styled( + format!("{:indent$}Environment:", "", indent = indent), + style, + )); + text.append( + &mut val + .env + .iter() + .map(|(k, v)| { + Line::styled( + format!("{:indent$} {}: {}", "", k, v, indent = indent), + style, + ) + }) + .collect(), + ); + } + if !val.volumes.is_empty() { + text.push(Line::styled( + format!("{:indent$}Volumes:", "", indent = indent), + style, + )); + text.append( + &mut val + .volumes + .iter() + .map(|(s, d)| { + Line::styled( + format!("{:indent$} - {}:{}", "", s, d, indent = indent), + style, + ) + }) + .collect(), + ); + } + if !val.network.is_empty() { + text.push(Line::styled( + format!("{:indent$}Networks:", "", indent = indent), + style, + )); + text.append( + &mut val + .network + .iter() + .flat_map(|(n, ip)| match ip { + None => vec![Line::styled( + format!("{:indent$} - Name: {}", "", n, indent = indent), + style, + )], + Some(ip) if ip.is_empty() => { + vec![Line::styled( + format!("{:indent$} - Name: {}", "", n, indent = indent), + style, + )] + } + Some(ip) => vec![ + Line::styled( + format!("{:indent$} - Name: {}", "", n, indent = indent), + style, + ), + Line::styled( + format!("{:indent$} IPAddress: {}", "", ip, indent = indent), + style, + ), + ], + }) + .collect(), + ); + } + if !val.ports.is_empty() { + text.push(Line::styled( + format!("{:indent$}Ports:", "", indent = indent), + style, + )); + text.append( + &mut val + .ports + .iter() + .map(|(h, c)| { + Line::styled( + format!("{:indent$} - {}:{}", "", h, c, indent = indent), + style, + ) + }) + .collect(), + ); + } + text +} + #[derive(Clone, Debug, PartialEq)] pub struct ContainerDetails { pub id: String, @@ -335,103 +531,7 @@ pub struct ContainerDetails { impl<'a> From<&ContainerDetails> for Vec> { fn from(val: &ContainerDetails) -> Self { - let style = Style::default().gray(); - let mut text: Vec = vec![ - Line::styled(format!("Id: {}", &val.id[0..12]).to_string(), style), - Line::styled(format!("Name: {}", val.name).to_string(), style), - Line::from(vec![Span::styled("Status: ", style), val.status.format()]), - ]; - if let Some(age) = val.age { - text.push(Line::styled( - format!("Created: {}", age.age()).to_string(), - style, - )); - } - match (val.image.as_ref(), val.image_id.as_ref()) { - (Some(image), Some(_image_id)) => { - text.push(Line::styled(format!("Image: {}", image).to_string(), style)) - } - (Some(image), None) => { - text.push(Line::styled(format!("Image: {}", image).to_string(), style)) - } - (None, Some(image_id)) => text.push(Line::styled( - format!("Image: {}", image_id).to_string(), - style, - )), - (None, None) => {} - } - if let Some(entrypoint) = &val.entrypoint { - if !entrypoint.is_empty() { - text.push(Line::styled("Entrypoint:", style)); - text.append( - &mut entrypoint - .iter() - .map(|entry| Line::styled(format!(" - {}", entry).to_string(), style)) - .collect(), - ); - } - } - if let Some(command) = &val.command { - if !command.is_empty() { - text.push(Line::styled("Command:".to_string(), style)); - text.append( - &mut command - .iter() - .map(|cmd| Line::styled(format!(" - {}", cmd).to_string(), style)) - .collect(), - ); - } - } - if !val.env.is_empty() { - text.push(Line::styled("Environment:".to_string(), style)); - text.append( - &mut val - .env - .iter() - .map(|(k, v)| Line::styled(format!(" {}: {}", k, v), style)) - .collect(), - ); - } - if !val.volumes.is_empty() { - text.push(Line::styled("Volumes:".to_string(), style)); - text.append( - &mut val - .volumes - .iter() - .map(|(s, d)| Line::styled(format!(" - {}:{}", s, d), style)) - .collect(), - ); - } - if !val.network.is_empty() { - text.push(Line::styled("Networks:".to_string(), style)); - text.append( - &mut val - .network - .iter() - .flat_map(|(n, ip)| match ip { - None => vec![Line::styled(format!(" - Name: {}", n), style)], - Some(ip) if ip.is_empty() => { - vec![Line::styled(format!(" - Name: {}", n), style)] - } - Some(ip) => vec![ - Line::styled(format!(" - Name: {}", n), style), - Line::styled(format!(" IPAddress: {}", ip), style), - ], - }) - .collect(), - ); - } - if !val.ports.is_empty() { - text.push(Line::styled("Ports:".to_string(), style)); - text.append( - &mut val - .ports - .iter() - .map(|(h, c)| Line::styled(format!(" - {}:{}", h, c), style)) - .collect(), - ); - } - text + details_to_lines(val, 0) } } @@ -475,3 +575,62 @@ impl<'a> From<&Compose> for Row<'a> { ]) } } + +impl<'a> From<&Compose> for Vec> { + fn from(val: &Compose) -> Self { + let mut text = vec![Line::from(format!("Compose project: {}", val.project))]; + if let Some(config_file) = &val.config_file { + text.push(Line::from(format!("Config file: {}", config_file))); + } + if let Some(working_dir) = &val.working_dir { + text.push(Line::from(format!("Working directory: {}", working_dir))); + } + if let Some(env_file) = &val.environment_files { + text.push(Line::from(format!("Environment file: {}", env_file))); + } + if !val.services.is_empty() { + text.push(Line::from("Services:".to_string())); + let mut svc_text = val + .services + .iter() + .flat_map(|((svc, num), c)| { + let mut svc_text = vec![Line::from(format!(" {} - {}", svc, num))]; + let mut svc_content = details_to_lines(c, 4); + svc_text.append(&mut svc_content); + svc_text + }) + .collect(); + text.append(&mut svc_text); + } + if !val.networks.is_empty() { + text.push(Line::from("Networks:".to_string())); + let mut net_text = val + .networks + .iter() + .flat_map(|(name, net)| { + let mut net_text = vec![Line::from(format!("- Name: {}", name))]; + let mut net_content = net.into(); + net_text.append(&mut net_content); + net_text + }) + .collect(); + text.append(&mut net_text); + } + if !val.volumes.is_empty() { + text.push(Line::from("Volumes:".to_string())); + let mut vol_text = val + .volumes + .iter() + .flat_map(|(id, vol)| { + let mut vol_text = vec![Line::from(format!("- Id: {}", id))]; + let mut vol_content = vol.into(); + vol_text.append(&mut vol_content); + vol_text + }) + .collect(); + text.append(&mut vol_text); + } + + text + } +} diff --git a/src/utils.rs b/src/utils.rs index 8f79d2d..d682567 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,6 +9,12 @@ use lazy_static::lazy_static; #[cfg(feature = "otel")] use opentelemetry::global; +use tracing::error; +use tracing_error::ErrorLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; + +use crate::components::Component; + use ratatui::{ prelude::*, widgets::{ @@ -69,12 +75,8 @@ macro_rules! get_or_not_found { .to_string() }; } -pub(crate) use get_or_not_found; -use tracing::error; -use tracing_error::ErrorLayer; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; -use crate::components::Component; +pub(crate) use get_or_not_found; pub(crate) fn table<'a, const SIZE: usize>( title: String,