feat: unhealthy status, closes #43

Highlight an unhealthy container in Orange, and display "! running" as the state,

refactor: Move dev Docker files to docker directory
This commit is contained in:
Jack Wills
2024-08-01 14:46:58 +00:00
parent f408acfe9a
commit de87681816
11 changed files with 291 additions and 80 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+2 -2
View File
@@ -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
+17
View File
@@ -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
@@ -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
+133 -36
View File
@@ -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::<String>()
)
write!(f, "{}…", self.0.chars().take(29).collect::<String>())
} else {
write!(
f,
"{}",
self.0
)
write!(f, "{}", self.0)
}
}
}
@@ -211,62 +210,108 @@ impl<T> StatefulList<T> {
}
}
/// 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<String> 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<Option<String>> for State {
fn from(input: Option<String>) -> 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<String>, &ContainerStatus)> for State {
fn from((input, status): (Option<String>, &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<ContainerPorts>,
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<ContainerPorts>,
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);
}
}
+33 -28
View File
@@ -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)))
);
}
// ************** //
+10 -2
View File
@@ -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::<Vec<_>>()
}
+7 -2
View File
@@ -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(
+4 -3
View File
@@ -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")),
)
}
+68 -7
View File
@@ -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!(
"{:<width$}{MARGIN}",
i.status,
i.status.get(),
width = &widths.status.1.into()
),
state_style,
@@ -311,7 +310,7 @@ pub fn ports(
if ports.0.is_empty() {
let text = match ports.1 {
State::Running | State::Paused | State::Restarting => "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() {
+2
View File
@@ -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<Mutex<AppData>>,
gui_state: Arc<Mutex<GuiState>>,