diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs index 2b6fddb..53976d6 100644 --- a/src/app_data/container_state.rs +++ b/src/app_data/container_state.rs @@ -4,6 +4,7 @@ use std::{ fmt, }; +use bollard::service::Port; use ratatui::{ style::Color, widgets::{ListItem, ListState}, @@ -100,6 +101,47 @@ macro_rules! unit_struct { unit_struct!(ContainerName); unit_struct!(ContainerImage); +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerPorts { + pub ip: Option, + pub private: u16, + pub public: Option, +} + +impl From<&Port> for ContainerPorts { + fn from(value: &Port) -> Self { + Self { + ip: value.ip.clone(), + private: value.private_port, + public: value.public_port, + } + } +} + +impl ContainerPorts { + pub fn len_ip(&self) -> usize { + self.ip.as_ref().unwrap_or(&String::new()).chars().count() + } + pub fn len_private(&self) -> usize { + format!("{}", self.private).chars().count() + } + pub fn len_public(&self) -> usize { + format!("{}", self.public.unwrap_or_default()) + .chars() + .count() + } + + pub fn print(&self) -> (String, String, String) { + ( + self.ip + .as_ref() + .map_or(String::new(), std::borrow::ToOwned::to_owned), + format!("{}", self.private), + self.public.map_or(String::new(), |s| s.to_string()), + ) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatefulList { pub state: ListState, @@ -247,7 +289,7 @@ pub enum DockerControls { Restart, Start, Stop, - Unpause, + Resume, Delete, } @@ -259,7 +301,7 @@ impl DockerControls { Self::Start => Color::Green, Self::Stop => Color::Red, Self::Delete => Color::Gray, - Self::Unpause => Color::Blue, + Self::Resume => Color::Blue, } } @@ -267,7 +309,7 @@ impl DockerControls { pub fn gen_vec(state: State) -> Vec { match state { State::Dead | State::Exited => vec![Self::Start, Self::Restart, Self::Delete], - State::Paused => vec![Self::Unpause, Self::Stop, Self::Delete], + State::Paused => vec![Self::Resume, Self::Stop, Self::Delete], State::Restarting => vec![Self::Stop, Self::Delete], State::Running => vec![Self::Pause, Self::Restart, Self::Stop, Self::Delete], _ => vec![Self::Delete], @@ -283,7 +325,7 @@ impl fmt::Display for DockerControls { Self::Restart => "restart", Self::Start => "start", Self::Stop => "stop", - Self::Unpause => "resume", + Self::Resume => "resume", }; write!(f, "{disp}") } @@ -484,21 +526,23 @@ impl Logs { /// Info for each container #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContainerItem { - pub created: u64, pub cpu_stats: VecDeque, + pub created: u64, pub docker_controls: StatefulList, pub id: ContainerId, pub image: ContainerImage, + pub is_oxker: bool, pub last_updated: u64, pub logs: Logs, pub mem_limit: ByteStats, pub mem_stats: VecDeque, pub name: ContainerName, + // todo remove option, can be empty vec + pub ports: Vec, pub rx: ByteStats, pub state: State, pub status: String, pub tx: ByteStats, - pub is_oxker: bool, } /// Basic display information, for when running in debug mode @@ -516,6 +560,7 @@ impl fmt::Display for ContainerItem { } impl ContainerItem { + #[allow(clippy::too_many_arguments)] /// Create a new container item pub fn new( created: u64, @@ -523,14 +568,16 @@ impl ContainerItem { image: String, is_oxker: bool, name: String, + ports: Vec, state: State, status: String, ) -> Self { let mut docker_controls = StatefulList::new(DockerControls::gen_vec(state)); docker_controls.start(); + Self { - created, cpu_stats: VecDeque::with_capacity(60), + created, docker_controls, id, image: image.into(), @@ -540,6 +587,7 @@ impl ContainerItem { mem_limit: ByteStats::default(), mem_stats: VecDeque::with_capacity(60), name: name.into(), + ports, rx: ByteStats::default(), state, status, diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index feb88cc..7250f78 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -254,6 +254,51 @@ impl AppData { .and_then(|i| self.containers.items.get(i)) } + /// Find the longest port when it's transformed into a string, defaults are header lens (ip, private, public) + pub fn get_longest_port(&self) -> (usize, usize, usize) { + let mut longest_ip = 5; + let mut longest_private = 10; + let mut longest_public = 9; + + for item in &self.containers.items { + // if let Some(ports) = item.ports.as_ref() { + longest_ip = longest_ip.max( + item.ports + .iter() + .map(ContainerPorts::len_ip) + .max() + .unwrap_or(3), + ); + longest_private = longest_private.max( + item.ports + .iter() + .map(ContainerPorts::len_private) + .max() + .unwrap_or(8), + ); + longest_public = longest_public.max( + item.ports + .iter() + .map(ContainerPorts::len_public) + .max() + .unwrap_or(6), + ); + } + // } + + (longest_ip, longest_private, longest_public) + // ) + } + /// Get Option of the current selected container's ports, sorted by private port + pub fn get_selected_ports(&mut self) -> Option<(Vec, State)> { + if let Some(item) = self.get_mut_selected_container() { + let mut ports = item.ports.clone(); + ports.sort_by(|a, b| a.private.cmp(&b.private)); + return Some((ports, item.state)); + } + None + } + /// Get mutable Option of the current selected container fn get_mut_selected_container(&mut self) -> Option<&mut ContainerItem> { self.containers @@ -571,6 +616,10 @@ impl AppData { }) }); + let ports = i.ports.as_ref().map_or(vec![], |i| { + i.iter().map(ContainerPorts::from).collect::>() + }); + let id = ContainerId::from(id.as_str()); let is_oxker = i @@ -611,13 +660,17 @@ impl AppData { }; item.state = state; }; + + item.ports = ports; + if item.image.get() != image { item.image.set(image); }; } else { // container not known, so make new ContainerItem and push into containers Vec - let container = - ContainerItem::new(created, id, image, is_oxker, name, state, status); + let container = ContainerItem::new( + created, id, image, is_oxker, name, ports, state, status, + ); self.containers.items.push(container); } } @@ -1325,6 +1378,7 @@ mod tests { "image_1".to_owned(), false, "container_1".to_owned(), + vec![], state, "Up 1 hour".to_owned(), ) @@ -1356,7 +1410,7 @@ mod tests { test_state( State::Paused, &mut vec![ - DockerControls::Unpause, + DockerControls::Resume, DockerControls::Stop, DockerControls::Delete, ], @@ -1652,9 +1706,9 @@ mod tests { ); } - // ********** // - // Chart data // - // ********** // + // ************* // + // Header Widths // + // ************* // #[test] /// Header widths return correctly @@ -1677,6 +1731,77 @@ mod tests { assert_eq!(result, expected); } + // ************* // + // Header Widths // + // ************* // + + #[test] + /// Returns selected containers ports ordered by private ip + fn test_app_data_get_selected_ports() { + let (_ids, containers) = gen_containers(); + let mut app_data = gen_appdata(&containers); + + app_data.containers.items[0].ports.push(ContainerPorts { + ip: None, + private: 10, + public: Some(1), + }); + app_data.containers.items[0].ports.push(ContainerPorts { + ip: None, + private: 11, + public: Some(3), + }); + app_data.containers.items[0].ports.push(ContainerPorts { + ip: None, + private: 4, + public: Some(2), + }); + + // No containers selected + let result = app_data.get_selected_ports(); + assert!(result.is_none()); + + // Selected container & ports + app_data.containers_start(); + let result = app_data.get_selected_ports(); + + assert_eq!( + result, + Some(( + vec![ + ContainerPorts { + ip: None, + private: 4, + public: Some(2) + }, + ContainerPorts { + ip: None, + private: 10, + public: Some(1) + }, + ContainerPorts { + ip: None, + private: 11, + public: Some(3) + }, + ContainerPorts { + ip: None, + private: 8001, + public: None + } + ], + State::Running + )) + ); + + // Selected container & no ports + app_data.containers_start(); + app_data.containers.items[0].ports = vec![]; + let result = app_data.get_selected_ports(); + + assert_eq!(result, Some((vec![], State::Running))); + } + // ************** // // Update mtehods // // ************** // diff --git a/src/docker_data/message.rs b/src/docker_data/message.rs index e1066c1..866aada 100644 --- a/src/docker_data/message.rs +++ b/src/docker_data/message.rs @@ -14,6 +14,6 @@ pub enum DockerMessage { Restart(ContainerId), Start(ContainerId), Stop(ContainerId), - Unpause(ContainerId), + Resume(ContainerId), Update, } diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index caa5a18..be8f1f2 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -387,11 +387,11 @@ impl DockerData { }); self.update_everything().await; } - DockerMessage::Unpause(id) => { + DockerMessage::Resume(id) => { tokio::spawn(async move { let handle = GuiState::start_loading_animation(&gui_state, uuid); if docker.unpause_container(id.get()).await.is_err() { - Self::set_error(&app_data, DockerControls::Unpause, &gui_state); + Self::set_error(&app_data, DockerControls::Resume, &gui_state); } gui_state.lock().stop_loading_animation(&handle, uuid); }); diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index ef630b9..8f9cc36 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -286,8 +286,8 @@ impl InputHandler { DockerControls::Pause => { self.docker_tx.send(DockerMessage::Pause(id)).await.ok() } - DockerControls::Unpause => { - self.docker_tx.send(DockerMessage::Unpause(id)).await.ok() + DockerControls::Resume => { + self.docker_tx.send(DockerMessage::Resume(id)).await.ok() } DockerControls::Start => { self.docker_tx.send(DockerMessage::Start(id)).await.ok() diff --git a/src/main.rs b/src/main.rs index a6d1b6d..7eb6851 100644 --- a/src/main.rs +++ b/src/main.rs @@ -168,10 +168,10 @@ async fn main() { #[cfg(test)] #[allow(clippy::unwrap_used, clippy::many_single_char_names, unused)] mod tests { - use bollard::service::ContainerSummary; + use bollard::service::{ContainerSummary, Port}; use crate::{ - app_data::{AppData, ContainerId, ContainerItem, State, StatefulList}, + app_data::{AppData, ContainerId, ContainerItem, ContainerPorts, State, StatefulList}, parse_args::CliArgs, }; @@ -197,6 +197,11 @@ mod tests { format!("image_{index}"), false, format!("container_{index}"), + vec![ContainerPorts { + ip: None, + private: u16::try_from(index).unwrap_or(1) + 8000, + public: None, + }], State::Running, format!("Up {index} hour"), ) @@ -231,7 +236,12 @@ mod tests { image_id: Some(format!("{index}")), command: None, created: Some(i64::try_from(index).unwrap()), - ports: None, + ports: Some(vec![Port { + ip: None, + private_port: u16::try_from(index).unwrap_or(1) + 8000, + public_port: None, + typ: None, + }]), size_rw: None, size_root_fs: None, labels: None, diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs index 024ffcd..370114e 100644 --- a/src/ui/draw_blocks.rs +++ b/src/ui/draw_blocks.rs @@ -1,7 +1,7 @@ use parking_lot::Mutex; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, + style::{Color, Modifier, Style, Stylize}, symbols, text::{Line, Span}, widgets::{ @@ -269,6 +269,61 @@ pub fn logs( } } +// Display the ports in a formatted list +pub fn ports( + f: &mut Frame, + area: Rect, + app_data: &Arc>, + max_lens: (usize, usize, usize), +) { + if let Some(ports) = app_data.lock().get_selected_ports() { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title_alignment(Alignment::Center) + .title(Span::styled( + " ports ", + Style::default() + .fg(ports.1.get_color()) + .add_modifier(Modifier::BOLD), + )); + + let (ip, private, public) = max_lens; + + if ports.0.is_empty() { + let paragraph = Paragraph::new(Span::from("no ports").add_modifier(Modifier::BOLD)) + .alignment(Alignment::Center) + .block(block); + f.render_widget(paragraph, area); + } else { + let mut output = vec![Line::from( + Span::from(format!( + "{:>ip$}{:>private$}{:>public$}", + "ip", "private", "public" + )) + .fg(Color::Yellow), + )]; + for (index, item) in ports.0.iter().enumerate() { + let fg = if index % 2 == 0 { + Color::White + } else { + Color::Magenta + }; + let strings = item.print(); + + let line = vec![ + Span::from(format!("{:>ip$}", strings.0)).fg(fg), + Span::from(format!("{:>private$}", strings.1)).fg(fg), + Span::from(format!("{:>public$}", strings.2)).fg(fg), + ]; + output.push(Line::from(line)); + } + let paragraph = Paragraph::new(output).block(block); + f.render_widget(paragraph, area); + } + } +} + /// Draw the cpu + mem charts pub fn chart(f: &mut Frame, area: Rect, app_data: &Arc>) { if let Some((cpu, mem)) = app_data.lock().get_chart_data() { @@ -307,10 +362,7 @@ fn make_chart<'a, T: Stats + Display>( current: &'a T, max: &'a T, ) -> Chart<'a> { - let title_color = match state { - State::Running => Color::Green, - _ => state.get_color(), - }; + let title_color = state.get_color(); let label_color = match state { State::Running => ORANGE, _ => state.get_color(), @@ -966,8 +1018,8 @@ mod tests { use crate::{ app_data::{ - AppData, ContainerId, ContainerImage, ContainerName, Header, SortedOrder, State, - StatefulList, + AppData, ContainerId, ContainerImage, ContainerName, ContainerPorts, Header, + SortedOrder, State, StatefulList, }, app_error::AppError, tests::{gen_appdata, gen_container_summary, gen_containers}, @@ -2687,49 +2739,260 @@ mod tests { } } + #[test] + // Port section when container has no ports + fn test_draw_blocks_ports_no_ports() { + let (w, h) = (30, 8); + let mut setup = test_setup(w, h, true, true); + setup.app_data.lock().containers.items[0].ports = vec![]; + + let max_lens = setup.app_data.lock().get_longest_port(); + setup + .terminal + .draw(|f| { + super::ports(f, setup.area, &setup.app_data, max_lens); + }) + .unwrap(); + + let expected = [ + "╭────────── ports ───────────╮", + "│ no ports │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "╰────────────────────────────╯", + ]; + + let result = &setup.terminal.backend().buffer().content; + for (row_index, row) in expected.iter().enumerate() { + for (char_index, expected_char) in row.chars().enumerate() { + let index = row_index * usize::from(w) + char_index; + let result_cell = &result[index]; + + assert_eq!(expected_char.to_string(), result_cell.symbol()); + if row_index == 0 && !BORDER_CHARS.contains(&result_cell.symbol()) { + assert_eq!(result_cell.fg, Color::Green); + assert_eq!(result_cell.modifier, Modifier::BOLD); + } else { + assert_eq!(result_cell.fg, Color::Reset); + } + } + } + } + + #[test] + // Port section when container has multiple ports + fn test_draw_blocks_ports_multiple_ports() { + let (w, h) = (32, 8); + let mut setup = test_setup(w, h, true, true); + setup.app_data.lock().containers.items[0] + .ports + .push(ContainerPorts { + ip: None, + private: 8002, + public: None, + }); + setup.app_data.lock().containers.items[0] + .ports + .push(ContainerPorts { + ip: Some("127.0.0.1".to_owned()), + private: 8003, + public: Some(8003), + }); + + let max_lens = setup.app_data.lock().get_longest_port(); + + setup + .terminal + .draw(|f| { + super::ports(f, setup.area, &setup.app_data, max_lens); + }) + .unwrap(); + + let expected = [ + "╭─────────── ports ────────────╮", + "│ ip private public │", + "│ 8001 │", + "│ 8002 │", + "│127.0.0.1 8003 8003 │", + "│ │", + "│ │", + "╰──────────────────────────────╯", + ]; + + let result = &setup.terminal.backend().buffer().content; + for (row_index, row) in expected.iter().enumerate() { + for (char_index, expected_char) in row.chars().enumerate() { + let index = row_index * usize::from(w) + char_index; + let result_cell = &result[index]; + + assert_eq!(expected_char.to_string(), result_cell.symbol()); + + let result_cell_as_char = result_cell + .symbol() + .chars() + .next() + .unwrap() + .is_ascii_alphanumeric(); + if row_index == 0 && result_cell_as_char { + assert_eq!(result_cell.fg, Color::Green); + } + if row_index == 1 && result_cell_as_char { + assert_eq!(result_cell.fg, Color::Yellow); + } + if row_index == 2 && result_cell_as_char { + assert_eq!(result_cell.fg, Color::White); + } + if row_index == 3 && result_cell_as_char { + assert_eq!(result_cell.fg, Color::Magenta); + } + if row_index == 4 && result_cell_as_char { + assert_eq!(result_cell.fg, Color::White); + } + } + } + } + + #[test] + // Port section title color correct dependant on state + fn test_draw_blocks_ports_container_state() { + let (w, h) = (32, 8); + let mut setup = test_setup(w, h, true, true); + let max_lens = setup.app_data.lock().get_longest_port(); + + setup.app_data.lock().containers.items[0].state = State::Paused; + setup + .terminal + .draw(|f| { + super::ports(f, setup.area, &setup.app_data, max_lens); + }) + .unwrap(); + + let expected = [ + "╭─────────── ports ────────────╮", + "│ ip private public │", + "│ 8001 │", + "│ │", + "│ │", + "│ │", + "│ │", + "╰──────────────────────────────╯", + ]; + + let result = &setup.terminal.backend().buffer().content; + for (row_index, row) in expected.iter().enumerate() { + for (char_index, expected_char) in row.chars().enumerate() { + let index = row_index * usize::from(w) + char_index; + let result_cell = &result[index]; + + assert_eq!(expected_char.to_string(), result_cell.symbol()); + + if row_index == 0 + && result_cell + .symbol() + .chars() + .next() + .unwrap() + .is_ascii_alphanumeric() + { + assert_eq!(result_cell.fg, Color::Yellow); + } + } + } + + setup.app_data.lock().containers.items[0].state = State::Dead; + setup + .terminal + .draw(|f| { + super::ports(f, setup.area, &setup.app_data, max_lens); + }) + .unwrap(); + + let expected = [ + "╭─────────── ports ────────────╮", + "│ ip private public │", + "│ 8001 │", + "│ │", + "│ │", + "│ │", + "│ │", + "╰──────────────────────────────╯", + ]; + + let result = &setup.terminal.backend().buffer().content; + for (row_index, row) in expected.iter().enumerate() { + for (char_index, expected_char) in row.chars().enumerate() { + let index = row_index * usize::from(w) + char_index; + let result_cell = &result[index]; + + assert_eq!(expected_char.to_string(), result_cell.symbol()); + + if row_index == 0 + && result_cell + .symbol() + .chars() + .next() + .unwrap() + .is_ascii_alphanumeric() + { + assert_eq!(result_cell.fg, Color::Red); + } + } + } + } + // *************** // // The whole layout // // **************** // #[test] // Check that the whole layout is drawn correctly - fn test_draw_blocks_the_whole_layout() { + fn test_draw_blocks_whole_layout() { let (w, h) = (160, 30); let mut setup = test_setup(w, h, true, true); insert_chart_data(&setup); insert_logs(&setup); + setup.app_data.lock().containers.items[0] + .ports + .push(ContainerPorts { + ip: Some("127.0.0.1".to_owned()), + private: 8003, + public: Some(8003), + }); let expected = [ " name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ", - "╭ Containers 1/3 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮", - "│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │", - "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │", - "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │", - "│ ││ delete │", - "│ ││ │", - "│ ││ │", - "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯", - "╭ Logs 3/3 - container_1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", - "│ line 1 │", - "│ line 2 │", - "│▶ line 3 │", - "│ │", - "│ │", - "│ │", - "│ │", - "│ │", - "│ │", - "│ │", - "│ │", - "│ │", - "│ │", - "│ │", - "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", - "╭───────────────────────────────── cpu 03.00% ─────────────────────────────────╮╭────────────────────────────── memory 30.00 kB ───────────────────────────────╮", - "│10.00%│ •••••• ││100.00 kB│ •••••• │", - "│ │••••• •••• ││ │••••• ••• │", - "│ │ ││ │ │", - "╰──────────────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────────────╯", + "╭ Containers 1/3 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮", + "│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │", + "│ ││ delete │", + "│ ││ │", + "│ ││ │", + "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯", + "╭ Logs 3/3 - container_1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│ line 1 │", + "│ line 2 │", + "│▶ line 3 │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + "╭───────────────────────── cpu 03.00% ──────────────────────────╮╭─────────────────────── memory 30.00 kB ───────────────────────╮╭────────── ports ───────────╮", + "│10.00%│ •••• ││100.00 kB│ ••• ││ ip private public│", + "│ │ ••• • ││ │ ••• • ││ 8001 │", + "│ │•• ••• ││ │•• ••• ││127.0.0.1 8003 8003│", + "│ │ ││ │ ││ │", + "╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯", ]; setup .terminal @@ -2744,7 +3007,7 @@ mod tests { let index = row_index * usize::from(w) + char_index; let result_cell = &result[index]; - assert_eq!(expected_char.to_string(), result_cell.symbol()); + assert_eq!(result_cell.symbol(), expected_char.to_string(),); } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index eb2586d..79c3cb9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -286,7 +286,7 @@ fn draw_frame(f: &mut Frame, app_data: &Arc>, gui_state: &Arc>, gui_state: &Arc