diff --git a/.github/screenshot_01.png b/.github/screenshot_01.png index 8355605..0a199b6 100644 Binary files a/.github/screenshot_01.png and b/.github/screenshot_01.png differ diff --git a/README.md b/README.md index 216972e..cefc768 100644 --- a/README.md +++ b/README.md @@ -168,10 +168,10 @@ cargo test Run some example docker images -using docker-compose.yml; +using docker/docker-compose.yml; ```shell -docker compose -f docker-compose.yml up -d +docker compose -f ./docker/docker-compose.yml up -d ``` or individually diff --git a/docker/Dockerfile.unhealthy b/docker/Dockerfile.unhealthy new file mode 100644 index 0000000..34835d0 --- /dev/null +++ b/docker/Dockerfile.unhealthy @@ -0,0 +1,17 @@ +# Use an official lightweight image as a base +FROM alpine:latest + +# Install a simple utility (e.g., curl) to run as a health check +RUN apk --no-cache add curl + +# Create a dummy file that we will use in our health check +RUN touch /tmp/healthy + +# Define a simple health check +HEALTHCHECK --interval=5s --timeout=3s --retries=3 \ + CMD [ ! -f /tmp/healthy ] || exit 1 + +# Start a basic loop that keeps the container running +CMD ["sh", "-c", "while :; do echo 'Container is running but will be unhealthy'; sleep 30; done"] + +# docker build -t unhealthy-container . -f Dockerfile.unhealthy; docker run -d --name unhealthy unhealthy-container \ No newline at end of file diff --git a/docker-compose.yml b/docker/docker-compose.yml similarity index 73% rename from docker-compose.yml rename to docker/docker-compose.yml index 9ccaf49..2b4c1ea 100644 --- a/docker-compose.yml +++ b/docker/docker-compose.yml @@ -39,5 +39,20 @@ services: resources: limits: memory: 512M + some_container: + container_name: some_container + image: some_container + build: + context: . + dockerfile: Dockerfile.unhealthy + ipc: private + restart: always + networks: + - oxker-example-net + deploy: + resources: + limits: + memory: 128M + diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs index 600f152..7eccb32 100644 --- a/src/app_data/container_state.rs +++ b/src/app_data/container_state.rs @@ -10,6 +10,8 @@ use ratatui::{ widgets::{ListItem, ListState}, }; +use crate::ui::ORANGE; + use super::Header; const ONE_KB: f64 = 1000.0; @@ -48,6 +50,9 @@ impl PartialOrd for ContainerId { } } +pub trait Contains { + fn contains(&self, input: &str) -> bool; +} /// ContainerName and ContainerImage are simple structs, used so can implement custom fmt functions to them macro_rules! unit_struct { ($name:ident) => { @@ -67,7 +72,7 @@ macro_rules! unit_struct { } } - impl$name { + impl $name { pub fn get(&self) -> &str { self.0.as_str() } @@ -75,26 +80,20 @@ macro_rules! unit_struct { pub fn set(&mut self, value: String) { self.0 = value; } + } - pub fn contains(&self, term: &str) -> bool { - self.0.to_lowercase().contains(term) + impl Contains for $name { + fn contains(&self, input: &str) -> bool { + self.0.to_lowercase().contains(input) } } impl std::fmt::Display for $name { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { if self.0.chars().count() >= 30 { - write!( - f, - "{}…", - self.0.chars().take(29).collect::() - ) + write!(f, "{}…", self.0.chars().take(29).collect::()) } else { - write!( - f, - "{}", - self.0 - ) + write!(f, "{}", self.0) } } } @@ -211,62 +210,108 @@ impl StatefulList { } } -/// States of the container +/// Store the containers status in a struct, so can then check for healthy/unhealthy status +/// It's usually something like "Up 1 hour", "Exited (0) 10 hours ago", "Up 10 minutes (unhealthy)" +#[derive(Debug, Clone, Eq, PartialEq, PartialOrd)] +pub struct ContainerStatus(String); + +impl From for ContainerStatus { + fn from(value: String) -> Self { + Self(value) + } +} + +impl ContainerStatus { + /// Check if a container is unhealthy + pub fn unhealthy(&self) -> bool { + self.contains("(unhealthy)") + } + + /// Get a reference to the source string + pub const fn get(&self) -> &String { + &self.0 + } +} + +impl Contains for ContainerStatus { + /// Check if the state contains a specific string + fn contains(&self, item: &str) -> bool { + self.0.to_lowercase().contains(item) + } +} + +/// By default a container's running status will be healthy #[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd)] +pub enum RunningState { + Healthy, + Unhealthy, +} +/// States of the container +#[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum State { Dead, Exited, Paused, Removing, Restarting, - Running, + Running(RunningState), Unknown, } impl State { pub const fn is_alive(self) -> bool { - matches!(self, Self::Running) + matches!(self, Self::Running(_)) } pub const fn get_color(self) -> Color { match self { Self::Paused => Color::Yellow, Self::Removing => Color::LightRed, Self::Restarting => Color::LightGreen, - Self::Running => Color::Green, + Self::Running(RunningState::Healthy) => Color::Green, + Self::Running(RunningState::Unhealthy) => ORANGE, _ => Color::Red, } } /// Dirty way to create order for the state, rather than impl Ord pub const fn order(self) -> u8 { match self { - Self::Running => 0, - Self::Paused => 1, - Self::Restarting => 2, - Self::Removing => 3, - Self::Exited => 4, - Self::Dead => 5, - Self::Unknown => 6, + Self::Running(RunningState::Healthy) => 0, + Self::Running(RunningState::Unhealthy) => 1, + Self::Paused => 2, + Self::Restarting => 3, + Self::Removing => 4, + Self::Exited => 5, + Self::Dead => 6, + Self::Unknown => 7, } } } -impl From<&str> for State { - fn from(input: &str) -> Self { +/// Need status, to check if container is unhealthy or not +impl From<(&str, &ContainerStatus)> for State { + fn from((input, status): (&str, &ContainerStatus)) -> Self { match input { "dead" => Self::Dead, "exited" => Self::Exited, "paused" => Self::Paused, "removing" => Self::Removing, "restarting" => Self::Restarting, - "running" => Self::Running, + "running" => { + if status.unhealthy() { + Self::Running(RunningState::Unhealthy) + } else { + Self::Running(RunningState::Healthy) + } + } _ => Self::Unknown, } } } -impl From> for State { - fn from(input: Option) -> Self { - input.map_or(Self::Unknown, |input| Self::from(input.as_str())) +/// Again, need status, to check if container is unhealthy or not +impl From<(Option, &ContainerStatus)> for State { + fn from((input, status): (Option, &ContainerStatus)) -> Self { + input.map_or(Self::Unknown, |input| Self::from((input.as_str(), status))) } } @@ -278,7 +323,8 @@ impl fmt::Display for State { Self::Paused => "॥ paused", Self::Removing => "removing", Self::Restarting => "↻ restarting", - Self::Running => "✓ running", + Self::Running(RunningState::Healthy) => "✓ running", + Self::Running(RunningState::Unhealthy) => "! running", Self::Unknown => "? unknown", }; write!(f, "{disp}") @@ -314,7 +360,7 @@ impl DockerControls { State::Dead | State::Exited => vec![Self::Start, Self::Restart, 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], + State::Running(_) => vec![Self::Pause, Self::Restart, Self::Stop, Self::Delete], _ => vec![Self::Delete], } } @@ -543,7 +589,7 @@ pub struct ContainerItem { pub ports: Vec, pub rx: ByteStats, pub state: State, - pub status: String, + pub status: ContainerStatus, pub tx: ByteStats, } @@ -572,7 +618,7 @@ impl ContainerItem { name: String, ports: Vec, state: State, - status: String, + status: ContainerStatus, ) -> Self { let mut docker_controls = StatefulList::new(DockerControls::gen_vec(state)); docker_controls.start(); @@ -686,11 +732,11 @@ mod tests { use ratatui::widgets::ListItem; use crate::{ - app_data::{ContainerImage, Logs}, + app_data::{ContainerImage, Logs, RunningState}, ui::log_sanitizer, }; - use super::{ByteStats, ContainerName, CpuStats, LogsTz}; + use super::{ByteStats, ContainerName, ContainerStatus, CpuStats, LogsTz, State}; #[test] /// Display CpuStats as a string @@ -774,4 +820,55 @@ mod tests { assert_eq!(logs.logs.items.len(), 2); } + + #[test] + /// check ContainerStatus unhealthy state + fn test_container_state_unhealthy() { + let input = ContainerStatus::from("Up 1 hour".to_owned()); + + assert!(!input.unhealthy()); + + let input = ContainerStatus::from("Up 1 hour (unhealthy)".to_owned()); + + assert!(input.unhealthy()); + } + + #[test] + /// Generate container State from a &str and &ContainerStatus + fn test_container_status_unhealthy() { + let healthy = ContainerStatus::from("Up 1 hour".to_owned()); + let unhealthy = ContainerStatus::from("Up 1 hour (unhealthy)".to_owned()); + + // Running and healthy + let input = State::from(("running", &healthy)); + assert_eq!(input, State::Running(RunningState::Healthy)); + + // Running and unhealthy + let input = State::from(("running", &unhealthy)); + assert_eq!(input, State::Running(RunningState::Unhealthy)); + + // Dead + let input = State::from(("dead", &healthy)); + assert_eq!(input, State::Dead); + + // Exited + let input = State::from(("exited", &healthy)); + assert_eq!(input, State::Exited); + + // Paused + let input = State::from(("paused", &healthy)); + assert_eq!(input, State::Paused); + + // Removing + let input = State::from(("removing", &healthy)); + assert_eq!(input, State::Removing); + + // Restarting + let input = State::from(("restarting", &healthy)); + assert_eq!(input, State::Restarting); + + // Unknown + let input = State::from(("oxker", &healthy)); + assert_eq!(input, State::Unknown); + } } diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index 002a09b..51027b3 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -179,11 +179,11 @@ impl AppData { FilterBy::All => { container.name.contains(&term) || container.image.contains(&term) - || container.status.to_lowercase().contains(&term) + || container.status.contains(&term) } FilterBy::Image => container.image.contains(&term), FilterBy::Name => container.name.contains(&term), - FilterBy::Status => container.status.to_lowercase().contains(&term), + FilterBy::Status => container.status.contains(&term), } }) } @@ -335,7 +335,8 @@ impl AppData { Header::Status => item_ord .0 .status - .cmp(&item_ord.1.status) + .get() + .cmp(item_ord.1.status.get()) .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())), Header::Cpu => item_ord .0 @@ -727,7 +728,7 @@ impl AppData { columns.net_rx.1 = columns.net_rx.1.max(count(&container.rx.to_string())); columns.net_tx.1 = columns.net_tx.1.max(count(&container.tx.to_string())); columns.state.1 = columns.state.1.max(count(&container.state.to_string())); - columns.status.1 = columns.status.1.max(count(&container.status)); + columns.status.1 = columns.status.1.max(count(container.status.get())); } } columns @@ -836,12 +837,13 @@ impl AppData { .as_ref() .map_or(false, |i| i.starts_with(ENTRY_POINT)); - let state = State::from(i.state.as_ref().map_or("dead", |z| z)); - let status = i - .status - .as_ref() - .map_or(String::new(), std::clone::Clone::clone); + let status = ContainerStatus::from( + i.status + .as_ref() + .map_or(String::new(), std::clone::Clone::clone), + ); + let state = State::from((i.state.as_ref().map_or("dead", |z| z), &status)); let image = i .image .as_ref() @@ -983,7 +985,7 @@ mod tests { i.state = State::Exited; } if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) { - i.state = State::Running; + i.state = State::Running(RunningState::Healthy); } if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) { i.state = State::Paused; @@ -1017,11 +1019,12 @@ mod tests { assert_eq!(result, &containers); if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) { - "Exited (0) 10 minutes ago".clone_into(&mut i.status); + ContainerStatus::from("Exited (0) 10 minutes ago".to_owned()).clone_into(&mut i.status); } if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) { - "Up 2 hours (Paused)".clone_into(&mut i.status); + // "Up 2 hours (Paused)".clone_into(&mut i.status); + ContainerStatus::from("Up 2 hours (Paused)".to_owned()).clone_into(&mut i.status); } // Sort by status @@ -1342,7 +1345,7 @@ mod tests { result, Some(( ContainerId::from("1"), - State::Running, + State::Running(RunningState::Healthy), "container_1".to_owned() )) ); @@ -1356,7 +1359,7 @@ mod tests { result, Some(( ContainerId::from("1"), - State::Running, + State::Running(RunningState::Healthy), "container_1".to_owned() )) ); @@ -1384,7 +1387,7 @@ mod tests { result, Some(( ContainerId::from("2"), - State::Running, + State::Running(RunningState::Healthy), "container_2".to_owned() )) ); @@ -1409,7 +1412,7 @@ mod tests { result, Some(( ContainerId::from("3"), - State::Running, + State::Running(RunningState::Healthy), "container_3".to_owned() )) ); @@ -1423,7 +1426,7 @@ mod tests { result, Some(( ContainerId::from("3"), - State::Running, + State::Running(RunningState::Healthy), "container_3".to_owned() )) ); @@ -1504,7 +1507,7 @@ mod tests { result, Some(( ContainerId::from("3"), - State::Running, + State::Running(RunningState::Healthy), "container_3".to_owned() )) ); @@ -1594,7 +1597,7 @@ mod tests { "container_1".to_owned(), vec![], state, - "Up 1 hour".to_owned(), + ContainerStatus::from("Up 1 hour".to_owned()), ) }; let mut app_data = gen_appdata(&[gen_item_state(state)]); @@ -1635,7 +1638,7 @@ mod tests { &mut vec![DockerControls::Stop, DockerControls::Delete], ); test_state( - State::Running, + State::Running(RunningState::Healthy), &mut vec![ DockerControls::Pause, DockerControls::Restart, @@ -1671,7 +1674,6 @@ mod tests { assert_eq!(post_len, 1); // Can insert checks against the current filter term - // todo!("fix me"); assert!(app_data.can_insert(&containers[1])); assert!(!app_data.can_insert(&containers[0])); assert!(!app_data.can_insert(&containers[2])); @@ -1710,7 +1712,7 @@ mod tests { /// Data is filtered correctly by status fn test_app_data_filter_by_status() { let (_, mut containers) = gen_containers(); - "Exited".clone_into(&mut containers[0].status); + ContainerStatus::from("Exited".to_owned()).clone_into(&mut containers[0].status); let mut app_data = gen_appdata(&containers); assert!(app_data.get_filter_term().is_none()); @@ -1738,7 +1740,7 @@ mod tests { /// Data is filtered correctly by all fn test_app_data_filter_by_all() { let (_, mut containers) = gen_containers(); - "Exited".clone_into(&mut containers[0].status); + ContainerStatus::from("Exited".to_owned()).clone_into(&mut containers[0].status); let mut app_data = gen_appdata(&containers); assert!(app_data.get_filter_term().is_none()); @@ -1767,7 +1769,7 @@ mod tests { /// Data is filtered correctly after various next() and previous() commands fn test_app_data_filter_prev() { let (_, mut containers) = gen_containers(); - "Exited".clone_into(&mut containers[0].status); + ContainerStatus::from("Exited".to_owned()).clone_into(&mut containers[0].status); let mut app_data = gen_appdata(&containers); assert!(app_data.get_filter_term().is_none()); @@ -2067,12 +2069,12 @@ mod tests { ( vec![(0.0, 1.1), (1.0, 1.2)], CpuStats::new(1.2), - State::Running + State::Running(RunningState::Healthy), ), ( vec![(0.0, 1.0), (1.0, 2.0)], ByteStats::new(2), - State::Running + State::Running(RunningState::Healthy), ) )) ); @@ -2188,7 +2190,7 @@ mod tests { public: None } ], - State::Running + State::Running(RunningState::Healthy), )) ); @@ -2197,7 +2199,10 @@ mod tests { app_data.containers.items[0].ports = vec![]; let result = app_data.get_selected_ports(); - assert_eq!(result, Some((vec![], State::Running))); + assert_eq!( + result, + Some((vec![], State::Running(RunningState::Healthy))) + ); } // ************** // diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index be3e05f..307d058 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -22,7 +22,7 @@ use tokio::{ use uuid::Uuid; use crate::{ - app_data::{AppData, ContainerId, DockerControls, State}, + app_data::{AppData, ContainerId, ContainerStatus, DockerControls, State}, app_error::AppError, parse_args::CliArgs, ui::{GuiState, Status}, @@ -236,7 +236,15 @@ impl DockerData { output .into_iter() .filter_map(|i| { - i.id.map(|id| (State::from(i.state), ContainerId::from(id.as_str()))) + i.id.map(|id| { + ( + State::from(( + i.state, + &ContainerStatus::from(i.status.map_or_else(String::new, |i| i)), + )), + ContainerId::from(id.as_str()), + ) + }) }) .collect::>() } diff --git a/src/exec.rs b/src/exec.rs index 2f3a9d2..1c3468e 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -18,7 +18,7 @@ use tokio::{ use tokio_util::sync::CancellationToken; use crate::{ - app_data::{AppData, ContainerId, State}, + app_data::{AppData, ContainerId, RunningState, State}, app_error::AppError, }; @@ -162,7 +162,12 @@ impl ExecMode { let container = app_data.lock().get_selected_container_id_state_name(); if let Some((id, state, _)) = container { - if state == State::Running { + if [ + State::Running(RunningState::Healthy), + State::Running(RunningState::Unhealthy), + ] + .contains(&state) + { if tty_readable() && !use_cli { if let Ok(exec) = docker .create_exec( diff --git a/src/main.rs b/src/main.rs index aeff8a4..aa5c2cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,7 +176,8 @@ mod tests { use crate::{ app_data::{ - AppData, ContainerId, ContainerItem, ContainerPorts, Filter, State, StatefulList, + AppData, ContainerId, ContainerItem, ContainerPorts, ContainerStatus, Filter, + RunningState, State, StatefulList, }, parse_args::CliArgs, }; @@ -208,8 +209,8 @@ mod tests { private: u16::try_from(index).unwrap_or(1) + 8000, public: None, }], - State::Running, - format!("Up {index} hour"), + State::Running(RunningState::Healthy), + ContainerStatus::from(format!("Up {index} hour")), ) } diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs index e193375..d98e5ee 100644 --- a/src/ui/draw_blocks.rs +++ b/src/ui/draw_blocks.rs @@ -21,7 +21,7 @@ use crate::{ use super::{ gui_state::{BoxLocation, DeleteButton, Region}, - FrameData, Status, + FrameData, Status, ORANGE, }; use super::{GuiState, SelectablePanel}; @@ -39,7 +39,6 @@ const NAME: &str = env!("CARGO_PKG_NAME"); const VERSION: &str = env!("CARGO_PKG_VERSION"); const REPO: &str = env!("CARGO_PKG_REPOSITORY"); const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); -const ORANGE: Color = Color::Rgb(255, 178, 36); const MARGIN: &str = " "; const RIGHT_ARROW: &str = "▶ "; const CIRCLE: &str = "⚪ "; @@ -163,7 +162,7 @@ fn format_containers<'a>(i: &ContainerItem, widths: &Columns) -> Line<'a> { Span::styled( format!( "{: "no ports", + State::Running(_) | State::Paused | State::Restarting => "no ports", _ => "", }; let paragraph = Paragraph::new(Span::from(text).add_modifier(Modifier::BOLD)) @@ -383,7 +382,7 @@ fn make_chart<'a, T: Stats + Display>( ) -> Chart<'a> { let title_color = state.get_color(); let label_color = match state { - State::Running => ORANGE, + State::Running(_) => ORANGE, _ => state.get_color(), }; Chart::new(dataset) @@ -1081,8 +1080,8 @@ mod tests { use crate::{ app_data::{ - AppData, ContainerId, ContainerImage, ContainerName, ContainerPorts, Header, - SortedOrder, State, StatefulList, + AppData, ContainerId, ContainerImage, ContainerName, ContainerPorts, ContainerStatus, + Header, SortedOrder, State, StatefulList, }, app_error::AppError, tests::{gen_appdata, gen_container_summary, gen_containers}, @@ -1782,6 +1781,68 @@ mod tests { } } + #[test] + /// When container state is unknown, correct colors displayed + fn test_draw_blocks_containers_unhealthy() { + let (w, h) = (130, 6); + let mut setup = test_setup(w, h, true, true); + + let status = ContainerStatus::from("Up 1 hour (unhealthy)".to_owned()); + setup.app_data.lock().containers.items[0].state = State::from(("running", &status)); + setup.app_data.lock().containers.items[0].status = status; + + let expected= [ + "╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│⚪ container_1 ! running Up 1 hour (unhealthy) 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │", + "│ │", + "╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" + ]; + let fd = FrameData::from((setup.app_data.lock(), setup.gui_state.lock())); + + setup + .terminal + .draw(|f| { + super::containers(&setup.app_data, setup.area, f, &fd, &setup.gui_state); + }) + .unwrap(); + + for (row_index, result_row) in get_result(&setup, w) { + let expected_row = expected_to_vec(&expected, row_index); + for (result_cell_index, result_cell) in result_row.iter().enumerate() { + assert_eq!(result_cell.symbol(), expected_row[result_cell_index]); + match (row_index, result_cell_index) { + // border + (0 | 5, _) | (1..=4, 0 | 129) => { + assert_eq!(result_cell.fg, Color::LightCyan); + } + // name, id, image column + (1..=3, 4..=17 | 83..=103) => { + assert_eq!(result_cell.fg, Color::Blue); + } + // state, status, cpu, memory column of the first row + (1, 18..=82) => { + assert_eq!(result_cell.fg, ORANGE); + } + // state, status, cpu, memory column + (2..=3, 18..=82) => { + assert_eq!(result_cell.fg, Color::Green); + } + // rx column + (1..=3, 104..=113) => { + assert_eq!(result_cell.fg, Color::Rgb(255, 233, 193)); + } + // tx column + (1..=3, 114..=123) => { + assert_eq!(result_cell.fg, Color::Rgb(205, 140, 140)); + } + _ => assert_eq!(result_cell.fg, Color::Reset), + } + } + } + } + #[test] /// When container state is unknown, correct colors displayed fn test_draw_blocks_containers_unknown() { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9fca730..99023f8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -32,6 +32,8 @@ use crate::{ input_handler::InputMessages, }; +pub const ORANGE: ratatui::style::Color = ratatui::style::Color::Rgb(255, 178, 36); + pub struct Ui { app_data: Arc>, gui_state: Arc>,