diff --git a/Cargo.lock b/Cargo.lock index 33d223a..40e17a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -805,7 +805,9 @@ dependencies = [ "tracing-error", "tracing-subscriber", "tui-big-text", + "tui-tree-widget", "ureq", + "uuid", ] [[package]] @@ -3021,6 +3023,17 @@ dependencies = [ "ratatui-core", ] +[[package]] +name = "tui-tree-widget" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deca119555009eee2e0cfb9c020f39f632444dc4579918d5fc009d51d75dff92" +dependencies = [ + "ratatui-core", + "ratatui-widgets", + "unicode-width", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3138,14 +3151,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "atomic", "getrandom 0.3.3", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index be6d886..8fffce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ tracing = "0.1.44" tracing-error = "0.2.1" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } ureq = { version = "3.2.0", features = ["json"] } +uuid = { version = "1.19.0", features = ["v4"] } # Ratatui Dependencies; these often need to be updated in lockstep ansi-to-tui = "8.0.1" @@ -48,6 +49,7 @@ ratatui = { version = "0.30.0", features = [ "unstable-rendered-line-info", ] } tui-big-text = "0.8.2" +tui-tree-widget = "0.24.0" [dev-dependencies] tempfile = "3.25.0" diff --git a/src/docker/container.rs b/src/docker/container.rs index 0090513..2c592a0 100644 --- a/src/docker/container.rs +++ b/src/docker/container.rs @@ -5,7 +5,6 @@ use bollard::query_parameters::{ use chrono::Local; use chrono::prelude::DateTime; use color_eyre::eyre::{Context, Result, bail}; -use serde::Serialize; use std::{ collections::HashMap, time::{Duration, UNIX_EPOCH}, @@ -14,9 +13,11 @@ use tokio::process::Command; use bollard::secret::ContainerSummary; +use crate::docker::traits::DescribeSection; + use super::traits::Describe; -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq)] pub struct DockerContainer { pub id: String, pub image_id: String, @@ -172,14 +173,19 @@ impl Describe for DockerContainer { fn get_name(&self) -> String { format!("container: {}", self.names) } - 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()) + fn describe(&self) -> Result> { + let mut summary = DescribeSection::new("Summary"); + summary + .item("ID", &self.id) + .item("Image", &self.image) + .item("Image ID", &self.image_id) + .item("Command", &self.command) + .item("Created", &self.created) + .item("Status", &self.status) + .item("Ports", &self.ports) + .item("Names", &self.names) + .item("Running", self.running); + Ok(vec![summary]) } } diff --git a/src/docker/image.rs b/src/docker/image.rs index 822e897..a199882 100644 --- a/src/docker/image.rs +++ b/src/docker/image.rs @@ -2,17 +2,18 @@ use bollard::query_parameters::{ListImagesOptionsBuilder, RemoveImageOptionsBuil use byte_unit::{Byte, UnitType}; use chrono::Local; use chrono::prelude::DateTime; -use color_eyre::eyre::{Context, Result, bail}; +use color_eyre::eyre::{Context, Result}; use itertools::Itertools; -use serde::Serialize; use std::collections::HashMap; use std::time::{Duration, UNIX_EPOCH}; use bollard::secret::ImageSummary; +use crate::docker::traits::DescribeSection; + use super::traits::Describe; -#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct DockerImage { pub id: String, pub name: String, @@ -112,13 +113,16 @@ impl Describe for DockerImage { fn get_name(&self) -> String { format!("image: {}", self.name) } - fn describe(&self) -> Result> { - let summary = match serde_yml::to_string(&self) { - Ok(s) => s, - Err(_) => { - bail!("failed to parse image summary") - } - }; - Ok(summary.lines().map(String::from).collect()) + fn describe(&self) -> Result> { + let mut summary = DescribeSection::new("Summary"); + summary + .item("ID", &self.id) + .item("Name", &self.name) + .item("Tag", &self.tag) + .item("Created", &self.created) + .item("Size", &self.size) + .item("Tags", self.tags.join(", ")) + .item("Digests", self.digests.join(", ")); + Ok(vec![summary]) } } diff --git a/src/docker/network.rs b/src/docker/network.rs index b210cf8..6277859 100644 --- a/src/docker/network.rs +++ b/src/docker/network.rs @@ -1,11 +1,12 @@ use bollard::query_parameters::ListNetworksOptionsBuilder; use bollard::secret::Network; -use color_eyre::eyre::{Result, bail}; -use serde::Serialize; +use color_eyre::eyre::Result; + +use crate::docker::traits::DescribeSection; use super::traits::Describe; -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq)] pub struct DockerNetwork { pub id: String, pub name: String, @@ -53,13 +54,16 @@ impl Describe for DockerNetwork { 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()) + fn describe(&self) -> Result> { + let mut summary = DescribeSection::new("Summary"); + summary + .item("ID", &self.id) + .item("Name", &self.name) + .item("Driver", &self.driver) + .item("Created At", &self.created_at) + .item("Scope", &self.scope) + .item_opt("Internal", self.internal) + .item_opt("Attachable", self.attachable); + Ok(vec![summary]) } } diff --git a/src/docker/traits.rs b/src/docker/traits.rs index df982ee..789fc00 100644 --- a/src/docker/traits.rs +++ b/src/docker/traits.rs @@ -2,6 +2,7 @@ use std::fmt; use color_eyre::eyre::Result; use dyn_clone::DynClone; +use uuid::Uuid; /// Provides an interface to describe the contents of the implementing /// struct in a human readable format. @@ -13,7 +14,58 @@ pub trait Describe: fmt::Debug + Send + Sync + DynClone { /// Get a human readable name of the resource being described fn get_name(&self) -> String; /// Get a human readable description of the resource being described - fn describe(&self) -> Result>; + fn describe(&self) -> Result>; } dyn_clone::clone_trait_object!(Describe); + +#[derive(Debug, PartialEq, Eq)] +pub struct DescribeSection { + pub(crate) id: Uuid, + pub(crate) name: String, + pub(crate) items: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct DescribeItem { + pub(crate) id: Uuid, + pub(crate) name: String, + pub(crate) value: String, +} + +impl DescribeItem { + pub fn new(name: N, value: V) -> Self { + Self { + id: Uuid::new_v4(), + name: name.to_string(), + value: value.to_string(), + } + } +} + +impl DescribeSection { + pub fn new(name: N) -> Self { + Self { + id: Uuid::new_v4(), + name: name.to_string(), + items: vec![], + } + } + + pub(crate) fn item(&mut self, name: N, value: V) -> &mut Self { + self.items.push(DescribeItem::new(name, value)); + self + } + + pub(crate) fn item_opt( + &mut self, + name: N, + value: Option, + ) -> &mut Self { + self.items.push(DescribeItem::new( + name, + value.map(|v| v.to_string()).unwrap_or_default(), + )); + self + } +} diff --git a/src/docker/volume.rs b/src/docker/volume.rs index 271084a..d4436da 100644 --- a/src/docker/volume.rs +++ b/src/docker/volume.rs @@ -2,12 +2,13 @@ use bollard::query_parameters::{ListVolumesOptionsBuilder, RemoveVolumeOptionsBu use bollard::secret::{Volume, VolumeScopeEnum}; use byte_unit::{Byte, UnitType}; use color_eyre::eyre::{Result, bail}; -use serde::Serialize; use std::collections::HashMap; +use crate::docker::traits::DescribeSection; + use super::traits::Describe; -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq)] pub struct DockerVolume { pub name: String, pub driver: String, @@ -88,13 +89,33 @@ impl Describe for DockerVolume { 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") + fn describe(&self) -> Result> { + let mut summary = DescribeSection::new("Summary"); + summary + .item("Name", &self.name) + .item("Driver", &self.driver) + .item("Mountpoint", &self.mountpoint) + .item_opt("Created At", self.created_at.as_ref()) + .item_opt("Scope", self.scope) + .item_opt("Reference Count", self.ref_count) + .item_opt("Size", self.size.as_ref()); + let mut result = vec![summary]; + + if !self.labels.is_empty() { + let mut label_section = DescribeSection::new("Labels"); + for (key, value) in &self.labels { + label_section.item(key, value); + } + result.push(label_section); + } + if !self.options.is_empty() { + let mut options_section = DescribeSection::new("Options"); + for (key, value) in &self.options { + options_section.item(key, value); } - }; - Ok(summary.lines().map(String::from).collect()) + result.push(options_section); + } + + Ok(result) } } diff --git a/src/pages/describe.rs b/src/pages/describe.rs index fc45812..86f58f7 100644 --- a/src/pages/describe.rs +++ b/src/pages/describe.rs @@ -4,15 +4,18 @@ use std::sync::{Arc, Mutex}; use bollard::Docker; use color_eyre::eyre::{Result, bail}; -use ratatui::style::Style; +use itertools::Itertools; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Scrollbar, ScrollbarOrientation}; use ratatui::{Frame, layout::Rect}; use tokio::sync::mpsc::Sender; +use tui_tree_widget::{Tree, TreeItem, TreeState}; +use uuid::Uuid; use crate::config::Config; use crate::context::AppContext; -use crate::docker::traits::Describe; +use crate::docker::traits::{Describe, DescribeSection}; use crate::traits::Close; use crate::{ components::help::{PageHelp, PageHelpBuilder}, @@ -35,11 +38,11 @@ pub struct DescribeContainer { _docker: Docker, config: Arc, thing: Option>, - thing_summary: Option>, + thing_summary: Option>, tx: Sender>, cx: Option, page_help: Arc>, - scroll: u16, + tree_state: TreeState, height: u16, } @@ -55,7 +58,7 @@ impl DescribeContainer { tx, cx: None, page_help: Arc::new(Mutex::new(page_help)), - scroll: 0, + tree_state: TreeState::default(), height: 0, } } @@ -70,28 +73,16 @@ impl DescribeContainer { } fn down(&mut self, amount: u16) { - self.scroll += amount; + for _ in 0..amount { + self.tree_state.scroll_down(1); + } } fn up(&mut self, amount: u16) { - if self.scroll > amount { - self.scroll -= amount; - } else { - self.scroll = 0; + for _ in 0..amount { + self.tree_state.scroll_up(1); } } - - fn resolve_scroll(&mut self, height: &u16, n_lines: &u16) -> u16 { - let max_scroll = if *n_lines < (height / 2) { - 0 - } else { - n_lines - (height / 2) - }; - if self.scroll > max_scroll { - self.scroll = max_scroll; - }; - self.scroll - } } #[async_trait::async_trait] @@ -165,39 +156,67 @@ impl Page for DescribeContainer { #[async_trait::async_trait] impl Close for DescribeContainer {} +fn section_to_tree_item<'a>( + state: &mut TreeState, + section: &'a DescribeSection, + section_style: &Style, + key_style: &Style, +) -> TreeItem<'a, Uuid> { + let items: Vec> = section + .items + .iter() + .map(|item| { + let line = Line::from(vec![ + Span::from(&item.name).style(*key_style), + Span::from(": "), + Span::from(&item.value), + ]); + TreeItem::new_leaf(item.id, line) + }) + .collect(); + + let item = TreeItem::new( + section.id, + Span::from(§ion.name).style(*section_style), + items, + ) + .expect("all items should be unique"); + state.open(vec![section.id]); + item +} + impl Component for DescribeContainer { fn draw(&mut self, f: &mut Frame<'_>, area: Rect) { self.height = area.height.saturating_sub(1); if self.thing_summary.is_none() { return; } - let container_summary = self.thing_summary.as_ref().unwrap(); - let lines: Vec = container_summary - .iter() - .map(|l| { - let l = l.clone(); - - let mut row = l.splitn(2, ':'); - let key = String::from(row.next().unwrap_or("")); - let val = String::from(row.next().unwrap_or("")); - - let key_style = Style::default().fg(self.config.theme.footer()); - - Line::from(vec![ - Span::from(key.clone()).style(key_style), - Span::from(":"), - Span::from(val.clone()), - ]) - }) - .collect(); - - let paragraph = Paragraph::new(lines); - - let n_lines = paragraph.line_count(area.width) as u16; - - let scroll = self.resolve_scroll(&area.height, &n_lines); - - let paragraph = paragraph.scroll((scroll, 0)); - f.render_widget(paragraph, area) + if let Some(summary) = &self.thing_summary { + let section_style = Style::default().fg(self.config.theme.footer()); + let key_style = Style::default().fg(self.config.theme.footer()); + let tree = summary + .iter() + .map(|section| { + section_to_tree_item(&mut self.tree_state, section, §ion_style, &key_style) + }) + .collect_vec(); + + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .track_symbol(None) + .end_symbol(None); + let widget = Tree::new(tree.as_slice()) + .expect("all item identifiers are unique") + .experimental_scrollbar(Some(scrollbar)) + .highlight_style( + Style::new() + .fg(Color::Black) + .bg(Color::LightGreen) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(""); + + f.render_stateful_widget(widget, area, &mut self.tree_state); + } } }