chore: merge release-v0.7.0 into main

This commit is contained in:
Jack Wills
2024-08-01 21:59:46 +00:00
22 changed files with 2529 additions and 1433 deletions
+1 -1
View File
@@ -25,11 +25,11 @@
"extensions": [ "extensions": [
"bmuskalla.vscode-tldr", "bmuskalla.vscode-tldr",
"christian-kohler.path-intellisense", "christian-kohler.path-intellisense",
"fill-labs.dependi",
"foxundermoon.shell-format", "foxundermoon.shell-format",
"mutantdino.resourcemonitor", "mutantdino.resourcemonitor",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"rust-lang.rust-analyzer", "rust-lang.rust-analyzer",
"serayuzgur.crates",
"tamasfe.even-better-toml", "tamasfe.even-better-toml",
"timonwong.shellcheck", "timonwong.shellcheck",
"vadimcn.vscode-lldb" "vadimcn.vscode-lldb"
+21 -4
View File
@@ -1,12 +1,29 @@
### 2024-05-25 ### 2024-08-01
### Chores ### Chores
+ Dependencies updated, [51fdd26be5b3166bcff5c26ece6d6ec0d893381e], [c1be658b8cc4786a9a7f2e0a88568019b3995c14] + .devcontainer extensions updated, [0288cbc8146cde1dd40ceaec9550198b635bb8f5]
+ dependencies updated, [1df4f78dc41013c33d901925933b1ccb29ad4bc8], [5ae253b8734ba0495e4e8149b17d5228b3d86f8d], [7a517db9f7c14c35e56ff70cf76ffb608fd30e17], [9c291cd9c81b6d9a02085878588ed3b845fd0046], [0e90f4eb55ac5fb5d45e7d212c3686027dd3913e], [fe71cbfb00f166b7c02a6e28e64650ed1b47d15d]
+ docker-compose alpine version bump, [51ceab3ebdb09356cd401d2f268840239255126f]
+ Rust 1.80 linting, [93e1279b1fc77019442a385e2e36be2fe438e828]
+ create_release v0.5.6, [f408acfe9a9f5a976735b8a8a51500fd7b865daf]
### Docs ### Docs
+ exec mode "not available on Windows", in both README.md and help panel, [df449a85376bbeec87215952d6a9196721f7132e] + screenshot updated, [6975ebe70f7058229c232e4a56b090f55247d2a2]
### Features
+ left align all text, [e0d421c4918a17c9e0e21fd214edb99d71281c9d]
+ place image name in logs panel title, [12f24357a68abe871f44d871d95b6e2ef062181e]
+ distinguish between unhealthy & healthy running containers, closes #43, [de8768181631c6d961ce0e4dacb50c2ed02abc36]
+ filter containers, use `F1` or `/` to enter filter mode, closes #37, thanks to [MohammadShabaniSBU](https://github.com/MohammadShabaniSBU) for the original PR, [d5d8a0dbc5437ff3b17f34b9dbb9589bb56b4a3e], [[7ee1f06f804683e3395953a02138d4e9da115ea9]]
+ place image name in logs panel title, [ef19b9cf89a881d0a7ac818885317ce2bd683dfc]
### Fixes ### Fixes
+ closes #36 Double key strokes on Windows, [9b7d575a76398cbe19e17f6494baf802dbb512b9] + log_sanitizer `raw()` & `remove_ansi()` now functioning as intended, [0dc98dfc8113869b81be9d697ca77418c919e4bf]
+ Dockerfile command use uppercase, [068e4025a5d6049a9a6951a0480a6bdef7379f88]
+ heading section help margin, [0e927aae178c1d8f60561b93607a26d45a1d9331]
+ install.sh use curl, [197a031b8cf356f49f08e04472d0d1c489699415]
### Tests
+ fix layout tests with new left alignment, [dfced564278eafdbb8a5b95badbae3a7c4bf87b3]
see <a href='https://github.com/mrjackwills/oxker/blob/main/CHANGELOG.md'>CHANGELOG.md</a> for more details see <a href='https://github.com/mrjackwills/oxker/blob/main/CHANGELOG.md'>CHANGELOG.md</a> for more details
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+29
View File
@@ -1,3 +1,32 @@
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.7.0'>v0.7.0</a>
### 2024-08-01
### Chores
+ .devcontainer extensions updated, [0288cbc8](https://github.com/mrjackwills/oxker/commit/0288cbc8146cde1dd40ceaec9550198b635bb8f5)
+ dependencies updated, [1df4f78d](https://github.com/mrjackwills/oxker/commit/1df4f78dc41013c33d901925933b1ccb29ad4bc8), [5ae253b8](https://github.com/mrjackwills/oxker/commit/5ae253b8734ba0495e4e8149b17d5228b3d86f8d), [7a517db9](https://github.com/mrjackwills/oxker/commit/7a517db9f7c14c35e56ff70cf76ffb608fd30e17), [9c291cd9](https://github.com/mrjackwills/oxker/commit/9c291cd9c81b6d9a02085878588ed3b845fd0046), [0e90f4eb](https://github.com/mrjackwills/oxker/commit/0e90f4eb55ac5fb5d45e7d212c3686027dd3913e), [fe71cbfb](https://github.com/mrjackwills/oxker/commit/fe71cbfb00f166b7c02a6e28e64650ed1b47d15d)
+ docker-compose alpine version bump, [51ceab3e](https://github.com/mrjackwills/oxker/commit/51ceab3ebdb09356cd401d2f268840239255126f)
+ Rust 1.80 linting, [93e1279b](https://github.com/mrjackwills/oxker/commit/93e1279b1fc77019442a385e2e36be2fe438e828)
+ create_release v0.5.6, [f408acfe](https://github.com/mrjackwills/oxker/commit/f408acfe9a9f5a976735b8a8a51500fd7b865daf)
### Docs
+ screenshot updated, [6975ebe7](https://github.com/mrjackwills/oxker/commit/6975ebe70f7058229c232e4a56b090f55247d2a2)
### Features
+ left align all text, [e0d421c4](https://github.com/mrjackwills/oxker/commit/e0d421c4918a17c9e0e21fd214edb99d71281c9d)
+ place image name in logs panel title, [12f24357](https://github.com/mrjackwills/oxker/commit/12f24357a68abe871f44d871d95b6e2ef062181e)
+ distinguish between unhealthy & healthy running containers, closes [#43](https://github.com/mrjackwills/oxker/issues/43), [de876818](https://github.com/mrjackwills/oxker/commit/de8768181631c6d961ce0e4dacb50c2ed02abc36)
+ filter containers, use `F1` or `/` to enter filter mode, closes [#37](https://github.com/mrjackwills/oxker/issues/37), thanks to [MohammadShabaniSBU](https://github.com/MohammadShabaniSBU) for the original PR, [d5d8a0db](https://github.com/mrjackwills/oxker/commit/d5d8a0dbc5437ff3b17f34b9dbb9589bb56b4a3e), [[7ee1f06f804683e3395953a02138d4e9da115ea9]]
+ place image name in logs panel title, [ef19b9cf](https://github.com/mrjackwills/oxker/commit/ef19b9cf89a881d0a7ac818885317ce2bd683dfc)
### Fixes
+ log_sanitizer `raw()` & `remove_ansi()` now functioning as intended, [0dc98dfc](https://github.com/mrjackwills/oxker/commit/0dc98dfc8113869b81be9d697ca77418c919e4bf)
+ Dockerfile command use uppercase, [068e4025](https://github.com/mrjackwills/oxker/commit/068e4025a5d6049a9a6951a0480a6bdef7379f88)
+ heading section help margin, [0e927aae](https://github.com/mrjackwills/oxker/commit/0e927aae178c1d8f60561b93607a26d45a1d9331)
+ install.sh use curl, [197a031b](https://github.com/mrjackwills/oxker/commit/197a031b8cf356f49f08e04472d0d1c489699415)
### Tests
+ fix layout tests with new left alignment, [dfced564](https://github.com/mrjackwills/oxker/commit/dfced564278eafdbb8a5b95badbae3a7c4bf87b3)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.6.4'>v0.6.4</a> # <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.6.4'>v0.6.4</a>
### 2024-05-25 ### 2024-05-25
Generated
+266 -184
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "oxker" name = "oxker"
version = "0.6.4" version = "0.7.0"
edition = "2021" edition = "2021"
authors = ["Jack Wills <email@mrjackwills.com>"] authors = ["Jack Wills <email@mrjackwills.com>"]
description = "A simple tui to view & control docker containers" description = "A simple tui to view & control docker containers"
@@ -27,19 +27,19 @@ similar_names = "allow"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
bollard = "0.16" bollard = "0.17"
cansi = "2.2" cansi = "2.2"
clap = { version = "4.5", features = ["color", "derive", "unicode"] } clap = { version = "4.5", features = ["color", "derive", "unicode"] }
crossterm = "0.27" crossterm = "0.28"
directories = "5.0" directories = "5.0"
futures-util = "0.3" futures-util = "0.3"
parking_lot = { version = "0.12" } parking_lot = { version = "0.12" }
ratatui = "0.26" ratatui = "0.27"
tokio = { version = "1.37", features = ["full"] } tokio = { version = "1.39", features = ["full"] }
tokio-util = "0.7" tokio-util = "0.7"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
uuid = { version = "1.8", features = ["fast-rng", "v4"] } uuid = { version = "1.10", features = ["fast-rng", "v4"] }
[profile.release] [profile.release]
lto = true lto = true
+3 -2
View File
@@ -101,6 +101,7 @@ In application controls
| ```( enter )```| Run selected docker command.| | ```( enter )```| Run selected docker command.|
| ```( 1-9 )``` | Sort containers by heading, clicking on headings also sorts the selected column. | | ```( 1-9 )``` | Sort containers by heading, clicking on headings also sorts the selected column. |
| ```( 0 )``` | Stop sorting.| | ```( 0 )``` | Stop sorting.|
| ```( F1 )``` or ```( / )``` | Enter filter mode. |
| ```( e )``` | Exec into the selected container - not available on Windows.| | ```( e )``` | Exec into the selected container - not available on Windows.|
| ```( h )``` | Toggle help menu.| | ```( h )``` | Toggle help menu.|
| ```( m )``` | Toggle mouse capture - if disabled, text on screen can be selected.| | ```( m )``` | Toggle mouse capture - if disabled, text on screen can be selected.|
@@ -167,10 +168,10 @@ cargo test
Run some example docker images Run some example docker images
using docker-compose.yml; using docker/docker-compose.yml;
```shell ```shell
docker compose -f docker-compose.yml up -d docker compose -f ./docker/docker-compose.yml up -d
``` ```
or individually or individually
+3 -3
View File
@@ -2,7 +2,7 @@
## Builder ## ## Builder ##
############# #############
FROM --platform=linux/amd64 rust:slim as BUILDER FROM --platform=linux/amd64 rust:slim AS builder
ARG TARGETARCH ARG TARGETARCH
@@ -49,12 +49,12 @@ RUN cp /usr/src/oxker/target/$(cat /.platform)/release/oxker /
## Runtime ## ## Runtime ##
############# #############
FROM scratch as RUNTIME FROM scratch
# Set an ENV to indicate that we're running in a container # Set an ENV to indicate that we're running in a container
ENV OXKER_RUNTIME=container ENV OXKER_RUNTIME=container
COPY --from=BUILDER /oxker /app/ COPY --from=builder /oxker /app/
# Run the application # Run the application
# this is used in the application itself so DO NOT EDIT # this is used in the application itself so DO NOT EDIT
+17 -16
View File
@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
# rust create_release v0.5.5 # rust create_release v0.5.6
# 2024-07-27
STAR_LINE='****************************************' STAR_LINE='****************************************'
CWD=$(pwd) CWD=$(pwd)
@@ -191,25 +192,25 @@ check_cross() {
fi fi
} }
cargo_build_x86_linux() { cross_build_x86_linux() {
check_cross check_cross
echo -e "${YELLOW}cross build --target x86_64-unknown-linux-musl --release${RESET}" echo -e "${YELLOW}cross build --target x86_64-unknown-linux-musl --release${RESET}"
cross build --target x86_64-unknown-linux-musl --release cross build --target x86_64-unknown-linux-musl --release
} }
cargo_build_aarch64_linux() { cross_build_aarch64_linux() {
check_cross check_cross
echo -e "${YELLOW}cross build --target aarch64-unknown-linux-musl --release${RESET}" echo -e "${YELLOW}cross build --target aarch64-unknown-linux-musl --release${RESET}"
cross build --target aarch64-unknown-linux-musl --release cross build --target aarch64-unknown-linux-musl --release
} }
cargo_build_armv6_linux() { cross_build_armv6_linux() {
check_cross check_cross
echo -e "${YELLOW}cross build --target arm-unknown-linux-musleabihf --release${RESET}" echo -e "${YELLOW}cross build --target arm-unknown-linux-musleabihf --release${RESET}"
cross build --target arm-unknown-linux-musleabihf --release cross build --target arm-unknown-linux-musleabihf --release
} }
cargo_build_x86_windows() { cross_build_x86_windows() {
check_cross check_cross
echo -e "${YELLOW}cross build --target x86_64-pc-windows-gnu --release${RESET}" echo -e "${YELLOW}cross build --target x86_64-pc-windows-gnu --release${RESET}"
cross build --target x86_64-pc-windows-gnu --release cross build --target x86_64-pc-windows-gnu --release
@@ -217,15 +218,15 @@ cargo_build_x86_windows() {
# Build all releases that GitHub workflow would # Build all releases that GitHub workflow would
# This will download GB's of docker images # This will download GB's of docker images
cargo_build_all() { cross_build_all() {
cargo clean cargo clean
cargo_build_armv6_linux cross_build_armv6_linux
ask_continue ask_continue
cargo_build_aarch64_linux cross_build_aarch64_linux
ask_continue ask_continue
cargo_build_x86_linux cross_build_x86_linux
ask_continue ask_continue
cargo_build_x86_windows cross_build_x86_windows
ask_continue ask_continue
} }
@@ -264,7 +265,7 @@ release_flow() {
get_git_remote_url get_git_remote_url
cargo_test cargo_test
cargo_build_all cross_build_all
cargo_publish cargo_publish
cd "${CWD}" || error_close "Can't find ${CWD}" cd "${CWD}" || error_close "Can't find ${CWD}"
@@ -347,23 +348,23 @@ build_choice() {
exit exit
;; ;;
1) 1)
cargo_build_x86_linux cross_build_x86_linux
exit exit
;; ;;
2) 2)
cargo_build_aarch64_linux cross_build_aarch64_linux
exit exit
;; ;;
3) 3)
cargo_build_armv6_linux cross_build_armv6_linux
exit exit
;; ;;
4) 4)
cargo_build_x86_windows cross_build_x86_windows
exit exit
;; ;;
5) 5)
cargo_build_all cross_build_all
exit exit
;; ;;
esac esac
+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
@@ -4,7 +4,7 @@ networks:
name: oxker-examaple-net name: oxker-examaple-net
services: services:
postgres: postgres:
image: postgres:alpine3.19 image: postgres:alpine3.20
container_name: postgres container_name: postgres
environment: environment:
- POSTGRES_PASSWORD=never_use_this_password_in_production - POSTGRES_PASSWORD=never_use_this_password_in_production
@@ -18,7 +18,7 @@ services:
limits: limits:
memory: 1024M memory: 1024M
redis: redis:
image: redis:alpine3.19 image: redis:alpine3.20
container_name: redis container_name: redis
ipc: private ipc: private
restart: always restart: always
@@ -39,5 +39,20 @@ services:
resources: resources:
limits: limits:
memory: 512M 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
+1 -1
View File
@@ -9,7 +9,7 @@ esac
if [ -n "$SUFFIX" ]; then if [ -n "$SUFFIX" ]; then
OXKER_GZ="oxker_linux_${SUFFIX}.tar.gz" OXKER_GZ="oxker_linux_${SUFFIX}.tar.gz"
wget "https://github.com/mrjackwills/oxker/releases/latest/download/${OXKER_GZ}" curl -L -O "https://github.com/mrjackwills/oxker/releases/latest/download/${OXKER_GZ}"
tar xzvf "${OXKER_GZ}" oxker tar xzvf "${OXKER_GZ}" oxker
install -Dm 755 oxker -t "${HOME}/.local/bin" install -Dm 755 oxker -t "${HOME}/.local/bin"
rm "${OXKER_GZ}" oxker rm "${OXKER_GZ}" oxker
+140 -39
View File
@@ -10,6 +10,8 @@ use ratatui::{
widgets::{ListItem, ListState}, widgets::{ListItem, ListState},
}; };
use crate::ui::ORANGE;
use super::Header; use super::Header;
const ONE_KB: f64 = 1000.0; 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 /// ContainerName and ContainerImage are simple structs, used so can implement custom fmt functions to them
macro_rules! unit_struct { macro_rules! unit_struct {
($name:ident) => { ($name:ident) => {
@@ -67,7 +72,7 @@ macro_rules! unit_struct {
} }
} }
impl$name { impl $name {
pub fn get(&self) -> &str { pub fn get(&self) -> &str {
self.0.as_str() self.0.as_str()
} }
@@ -77,20 +82,18 @@ macro_rules! unit_struct {
} }
} }
impl Contains for $name {
fn contains(&self, input: &str) -> bool {
self.0.to_lowercase().contains(input)
}
}
impl std::fmt::Display for $name { impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
if self.0.chars().count() >= 30 { if self.0.chars().count() >= 30 {
write!( write!(f, "{}…", self.0.chars().take(29).collect::<String>())
f,
"{}…",
self.0.chars().take(29).collect::<String>()
)
} else { } else {
write!( write!(f, "{}", self.0)
f,
"{}",
self.0
)
} }
} }
} }
@@ -207,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)] #[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 { pub enum State {
Dead, Dead,
Exited, Exited,
Paused, Paused,
Removing, Removing,
Restarting, Restarting,
Running, Running(RunningState),
Unknown, Unknown,
} }
impl State { impl State {
pub const fn is_alive(self) -> bool { pub const fn is_alive(self) -> bool {
matches!(self, Self::Running) matches!(self, Self::Running(_))
} }
pub const fn get_color(self) -> Color { pub const fn get_color(self) -> Color {
match self { match self {
Self::Paused => Color::Yellow, Self::Paused => Color::Yellow,
Self::Removing => Color::LightRed, Self::Removing => Color::LightRed,
Self::Restarting => Color::LightGreen, Self::Restarting => Color::LightGreen,
Self::Running => Color::Green, Self::Running(RunningState::Healthy) => Color::Green,
Self::Running(RunningState::Unhealthy) => ORANGE,
_ => Color::Red, _ => Color::Red,
} }
} }
/// Dirty way to create order for the state, rather than impl Ord /// Dirty way to create order for the state, rather than impl Ord
pub const fn order(self) -> u8 { pub const fn order(self) -> u8 {
match self { match self {
Self::Running => 0, Self::Running(RunningState::Healthy) => 0,
Self::Paused => 1, Self::Running(RunningState::Unhealthy) => 1,
Self::Restarting => 2, Self::Paused => 2,
Self::Removing => 3, Self::Restarting => 3,
Self::Exited => 4, Self::Removing => 4,
Self::Dead => 5, Self::Exited => 5,
Self::Unknown => 6, Self::Dead => 6,
Self::Unknown => 7,
} }
} }
} }
impl From<&str> for State { /// Need status, to check if container is unhealthy or not
fn from(input: &str) -> Self { impl From<(&str, &ContainerStatus)> for State {
fn from((input, status): (&str, &ContainerStatus)) -> Self {
match input { match input {
"dead" => Self::Dead, "dead" => Self::Dead,
"exited" => Self::Exited, "exited" => Self::Exited,
"paused" => Self::Paused, "paused" => Self::Paused,
"removing" => Self::Removing, "removing" => Self::Removing,
"restarting" => Self::Restarting, "restarting" => Self::Restarting,
"running" => Self::Running, "running" => {
if status.unhealthy() {
Self::Running(RunningState::Unhealthy)
} else {
Self::Running(RunningState::Healthy)
}
}
_ => Self::Unknown, _ => Self::Unknown,
} }
} }
} }
impl From<Option<String>> for State { /// Again, need status, to check if container is unhealthy or not
fn from(input: Option<String>) -> Self { impl From<(Option<String>, &ContainerStatus)> for State {
input.map_or(Self::Unknown, |input| Self::from(input.as_str())) fn from((input, status): (Option<String>, &ContainerStatus)) -> Self {
input.map_or(Self::Unknown, |input| Self::from((input.as_str(), status)))
} }
} }
@@ -274,7 +323,8 @@ impl fmt::Display for State {
Self::Paused => "॥ paused", Self::Paused => "॥ paused",
Self::Removing => "removing", Self::Removing => "removing",
Self::Restarting => "↻ restarting", Self::Restarting => "↻ restarting",
Self::Running => "✓ running", Self::Running(RunningState::Healthy) => "✓ running",
Self::Running(RunningState::Unhealthy) => "! running",
Self::Unknown => "? unknown", Self::Unknown => "? unknown",
}; };
write!(f, "{disp}") write!(f, "{disp}")
@@ -310,7 +360,7 @@ impl DockerControls {
State::Dead | State::Exited => vec![Self::Start, Self::Restart, Self::Delete], State::Dead | State::Exited => vec![Self::Start, Self::Restart, Self::Delete],
State::Paused => vec![Self::Resume, Self::Stop, Self::Delete], State::Paused => vec![Self::Resume, Self::Stop, Self::Delete],
State::Restarting => vec![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], _ => vec![Self::Delete],
} }
} }
@@ -539,7 +589,7 @@ pub struct ContainerItem {
pub ports: Vec<ContainerPorts>, pub ports: Vec<ContainerPorts>,
pub rx: ByteStats, pub rx: ByteStats,
pub state: State, pub state: State,
pub status: String, pub status: ContainerStatus,
pub tx: ByteStats, pub tx: ByteStats,
} }
@@ -568,7 +618,7 @@ impl ContainerItem {
name: String, name: String,
ports: Vec<ContainerPorts>, ports: Vec<ContainerPorts>,
state: State, state: State,
status: String, status: ContainerStatus,
) -> Self { ) -> Self {
let mut docker_controls = StatefulList::new(DockerControls::gen_vec(state)); let mut docker_controls = StatefulList::new(DockerControls::gen_vec(state));
docker_controls.start(); docker_controls.start();
@@ -665,14 +715,14 @@ impl Columns {
pub const fn new() -> Self { pub const fn new() -> Self {
Self { Self {
name: (Header::Name, 4), name: (Header::Name, 4),
state: (Header::State, 11), state: (Header::State, 5),
status: (Header::Status, 16), status: (Header::Status, 6),
cpu: (Header::Cpu, 7), cpu: (Header::Cpu, 3),
mem: (Header::Memory, 7, 7), mem: (Header::Memory, 7, 7),
id: (Header::Id, 8), id: (Header::Id, 8),
image: (Header::Image, 5), image: (Header::Image, 5),
net_rx: (Header::Rx, 7), net_rx: (Header::Rx, 4),
net_tx: (Header::Tx, 7), net_tx: (Header::Tx, 4),
} }
} }
} }
@@ -682,11 +732,11 @@ mod tests {
use ratatui::widgets::ListItem; use ratatui::widgets::ListItem;
use crate::{ use crate::{
app_data::{ContainerImage, Logs}, app_data::{ContainerImage, Logs, RunningState},
ui::log_sanitizer, ui::log_sanitizer,
}; };
use super::{ByteStats, ContainerName, CpuStats, LogsTz}; use super::{ByteStats, ContainerName, ContainerStatus, CpuStats, LogsTz, State};
#[test] #[test]
/// Display CpuStats as a string /// Display CpuStats as a string
@@ -770,4 +820,55 @@ mod tests {
assert_eq!(logs.logs.items.len(), 2); 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);
}
} }
+513 -111
View File
File diff suppressed because it is too large Load Diff
+27 -20
View File
@@ -22,7 +22,7 @@ use tokio::{
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app_data::{AppData, ContainerId, DockerControls, State}, app_data::{AppData, ContainerId, ContainerStatus, DockerControls, State},
app_error::AppError, app_error::AppError,
parse_args::CliArgs, parse_args::CliArgs,
ui::{GuiState, Status}, ui::{GuiState, Status},
@@ -201,7 +201,7 @@ impl DockerData {
/// Get all current containers, handle into ContainerItem in the app_data struct rather than here /// Get all current containers, handle into ContainerItem in the app_data struct rather than here
/// Just make sure that items sent are guaranteed to have an id /// Just make sure that items sent are guaranteed to have an id
/// If in a containerised runtime, will ignore any container that uses the `/app/oxker` as an entry point, unless the `-s` flag is set /// If in a containerised runtime, will ignore any container that uses the `/app/oxker` as an entry point, unless the `-s` flag is set
pub async fn update_all_containers(&mut self) -> Vec<(State, ContainerId)> { pub async fn update_all_containers(&self) -> Vec<(State, ContainerId)> {
let containers = self let containers = self
.docker .docker
.list_containers(Some(ListContainersOptions::<String> { .list_containers(Some(ListContainersOptions::<String> {
@@ -236,7 +236,15 @@ impl DockerData {
output output
.into_iter() .into_iter()
.filter_map(|i| { .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<_>>() .collect::<Vec<_>>()
} }
@@ -271,7 +279,7 @@ impl DockerData {
} }
/// Update all logs, spawn each container into own tokio::spawn thread /// Update all logs, spawn each container into own tokio::spawn thread
fn init_all_logs(&mut self, all_ids: &[(State, ContainerId)]) { fn init_all_logs(&self, all_ids: &[(State, ContainerId)]) {
for (_, id) in all_ids { for (_, id) in all_ids {
let docker = Arc::clone(&self.docker); let docker = Arc::clone(&self.docker);
let app_data = Arc::clone(&self.app_data); let app_data = Arc::clone(&self.app_data);
@@ -303,13 +311,14 @@ impl DockerData {
}; };
self.update_all_container_stats(&all_ids); self.update_all_container_stats(&all_ids);
self.app_data.lock().sort_containers(); self.app_data.lock().sort_containers();
self.gui_state.lock().stop_loading_animation(Uuid::nil());
} }
/// Initialize docker container data, before any messages are received /// Initialize docker container data, before any messages are received
async fn initialise_container_data(&mut self) { async fn initialise_container_data(&mut self) {
self.gui_state.lock().status_push(Status::Init); self.gui_state.lock().status_push(Status::Init);
let loading_uuid = Uuid::new_v4(); let loading_uuid = Uuid::new_v4();
let loading_handle = GuiState::start_loading_animation(&self.gui_state, loading_uuid); GuiState::start_loading_animation(&self.gui_state, loading_uuid);
let all_ids = self.update_all_containers().await; let all_ids = self.update_all_containers().await;
self.update_all_container_stats(&all_ids); self.update_all_container_stats(&all_ids);
@@ -323,9 +332,7 @@ impl DockerData {
self.init = None; self.init = None;
} }
} }
self.gui_state self.gui_state.lock().stop_loading_animation(loading_uuid);
.lock()
.stop_loading_animation(&loading_handle, loading_uuid);
self.gui_state.lock().status_del(Status::Init); self.gui_state.lock().status_del(Status::Init);
} }
@@ -356,27 +363,27 @@ impl DockerData {
} }
DockerMessage::Pause(id) => { DockerMessage::Pause(id) => {
tokio::spawn(async move { tokio::spawn(async move {
let handle = GuiState::start_loading_animation(&gui_state, uuid); GuiState::start_loading_animation(&gui_state, uuid);
if docker.pause_container(id.get()).await.is_err() { if docker.pause_container(id.get()).await.is_err() {
Self::set_error(&app_data, DockerControls::Pause, &gui_state); Self::set_error(&app_data, DockerControls::Pause, &gui_state);
} }
gui_state.lock().stop_loading_animation(&handle, uuid); gui_state.lock().stop_loading_animation(uuid);
}); });
self.update_everything().await; self.update_everything().await;
} }
DockerMessage::Restart(id) => { DockerMessage::Restart(id) => {
tokio::spawn(async move { tokio::spawn(async move {
let handle = GuiState::start_loading_animation(&gui_state, uuid); GuiState::start_loading_animation(&gui_state, uuid);
if docker.restart_container(id.get(), None).await.is_err() { if docker.restart_container(id.get(), None).await.is_err() {
Self::set_error(&app_data, DockerControls::Restart, &gui_state); Self::set_error(&app_data, DockerControls::Restart, &gui_state);
} }
gui_state.lock().stop_loading_animation(&handle, uuid); gui_state.lock().stop_loading_animation(uuid);
}); });
self.update_everything().await; self.update_everything().await;
} }
DockerMessage::Start(id) => { DockerMessage::Start(id) => {
tokio::spawn(async move { tokio::spawn(async move {
let handle = GuiState::start_loading_animation(&gui_state, uuid); GuiState::start_loading_animation(&gui_state, uuid);
if docker if docker
.start_container(id.get(), None::<StartContainerOptions<String>>) .start_container(id.get(), None::<StartContainerOptions<String>>)
.await .await
@@ -384,33 +391,33 @@ impl DockerData {
{ {
Self::set_error(&app_data, DockerControls::Start, &gui_state); Self::set_error(&app_data, DockerControls::Start, &gui_state);
} }
gui_state.lock().stop_loading_animation(&handle, uuid); gui_state.lock().stop_loading_animation(uuid);
}); });
self.update_everything().await; self.update_everything().await;
} }
DockerMessage::Stop(id) => { DockerMessage::Stop(id) => {
tokio::spawn(async move { tokio::spawn(async move {
let handle = GuiState::start_loading_animation(&gui_state, uuid); GuiState::start_loading_animation(&gui_state, uuid);
if docker.stop_container(id.get(), None).await.is_err() { if docker.stop_container(id.get(), None).await.is_err() {
Self::set_error(&app_data, DockerControls::Stop, &gui_state); Self::set_error(&app_data, DockerControls::Stop, &gui_state);
} }
gui_state.lock().stop_loading_animation(&handle, uuid); gui_state.lock().stop_loading_animation(uuid);
}); });
self.update_everything().await; self.update_everything().await;
} }
DockerMessage::Resume(id) => { DockerMessage::Resume(id) => {
tokio::spawn(async move { tokio::spawn(async move {
let handle = GuiState::start_loading_animation(&gui_state, uuid); GuiState::start_loading_animation(&gui_state, uuid);
if docker.unpause_container(id.get()).await.is_err() { if docker.unpause_container(id.get()).await.is_err() {
Self::set_error(&app_data, DockerControls::Resume, &gui_state); Self::set_error(&app_data, DockerControls::Resume, &gui_state);
} }
gui_state.lock().stop_loading_animation(&handle, uuid); gui_state.lock().stop_loading_animation(uuid);
}); });
self.update_everything().await; self.update_everything().await;
} }
DockerMessage::Delete(id) => { DockerMessage::Delete(id) => {
tokio::spawn(async move { tokio::spawn(async move {
let handle = GuiState::start_loading_animation(&gui_state, uuid); GuiState::start_loading_animation(&gui_state, uuid);
if docker if docker
.remove_container( .remove_container(
id.get(), id.get(),
@@ -425,7 +432,7 @@ impl DockerData {
{ {
Self::set_error(&app_data, DockerControls::Stop, &gui_state); Self::set_error(&app_data, DockerControls::Stop, &gui_state);
} }
gui_state.lock().stop_loading_animation(&handle, uuid); gui_state.lock().stop_loading_animation(uuid);
}); });
self.update_everything().await; self.update_everything().await;
self.gui_state.lock().set_delete_container(None); self.gui_state.lock().set_delete_container(None);
+7 -2
View File
@@ -18,7 +18,7 @@ use tokio::{
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::{ use crate::{
app_data::{AppData, ContainerId, State}, app_data::{AppData, ContainerId, RunningState, State},
app_error::AppError, app_error::AppError,
}; };
@@ -162,7 +162,12 @@ impl ExecMode {
let container = app_data.lock().get_selected_container_id_state_name(); let container = app_data.lock().get_selected_container_id_state_name();
if let Some((id, state, _)) = container { 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 tty_readable() && !use_cli {
if let Ok(exec) = docker if let Ok(exec) = docker
.create_exec( .create_exec(
+122 -69
View File
@@ -71,6 +71,7 @@ impl InputHandler {
Status::Error, Status::Error,
Status::Help, Status::Help,
Status::DeleteConfirm, Status::DeleteConfirm,
Status::Filter,
]) { ]) {
self.mouse_press(mouse_event); self.mouse_press(mouse_event);
} }
@@ -125,7 +126,7 @@ impl InputHandler {
let is_oxker = self.app_data.lock().is_oxker(); let is_oxker = self.app_data.lock().is_oxker();
if !is_oxker && tty_readable() { if !is_oxker && tty_readable() {
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let handle = GuiState::start_loading_animation(&self.gui_state, uuid); GuiState::start_loading_animation(&self.gui_state, uuid);
let (sx, rx) = tokio::sync::oneshot::channel::<Arc<Docker>>(); let (sx, rx) = tokio::sync::oneshot::channel::<Arc<Docker>>();
self.docker_tx.send(DockerMessage::Exec(sx)).await.ok(); self.docker_tx.send(DockerMessage::Exec(sx)).await.ok();
@@ -143,7 +144,7 @@ impl InputHandler {
}, },
); );
} }
self.gui_state.lock().stop_loading_animation(&handle, uuid); self.gui_state.lock().stop_loading_animation(uuid);
} }
} }
@@ -177,7 +178,7 @@ impl InputHandler {
} }
/// Save the currently selected containers logs into a `[container_name]_[timestamp].log` file /// Save the currently selected containers logs into a `[container_name]_[timestamp].log` file
async fn s_key(&mut self) { async fn s_key(&self) {
/// This is the inner workings, *inlined* here to return a Result /// This is the inner workings, *inlined* here to return a Result
async fn save_logs( async fn save_logs(
app_data: &Arc<Mutex<AppData>>, app_data: &Arc<Mutex<AppData>>,
@@ -248,7 +249,7 @@ impl InputHandler {
self.gui_state.lock().status_push(log_status); self.gui_state.lock().status_push(log_status);
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let handle = GuiState::start_loading_animation(&self.gui_state, uuid); GuiState::start_loading_animation(&self.gui_state, uuid);
if save_logs(&self.app_data, &self.gui_state, &self.docker_tx) if save_logs(&self.app_data, &self.gui_state, &self.docker_tx)
.await .await
.is_err() .is_err()
@@ -260,12 +261,12 @@ impl InputHandler {
); );
} }
self.gui_state.lock().status_del(log_status); self.gui_state.lock().status_del(log_status);
self.gui_state.lock().stop_loading_animation(&handle, uuid); self.gui_state.lock().stop_loading_animation(uuid);
} }
} }
/// Send docker command, if the Commands panel is selected /// Send docker command, if the Commands panel is selected
async fn enter_key(&mut self) { async fn enter_key(&self) {
// This isn't great, just means you can't send docker commands before full initialization of the program // This isn't great, just means you can't send docker commands before full initialization of the program
let panel = self.gui_state.lock().get_selected_panel(); let panel = self.gui_state.lock().get_selected_panel();
if panel == SelectablePanel::Commands { if panel == SelectablePanel::Commands {
@@ -306,7 +307,7 @@ impl InputHandler {
} }
/// Change the the "next" selectable panel /// Change the the "next" selectable panel
fn tab_key(&mut self) { fn tab_key(&self) {
let is_containers = let is_containers =
self.gui_state.lock().get_selected_panel() == SelectablePanel::Containers; self.gui_state.lock().get_selected_panel() == SelectablePanel::Containers;
let count = if self.app_data.lock().get_container_len() == 0 && is_containers { let count = if self.app_data.lock().get_container_len() == 0 && is_containers {
@@ -320,7 +321,7 @@ impl InputHandler {
} }
/// Change to previously selected panel /// Change to previously selected panel
fn back_tab_key(&mut self) { fn back_tab_key(&self) {
let is_containers = self.gui_state.lock().get_selected_panel() == SelectablePanel::Logs; let is_containers = self.gui_state.lock().get_selected_panel() == SelectablePanel::Logs;
let count = if self.app_data.lock().get_container_len() == 0 && is_containers { let count = if self.app_data.lock().get_container_len() == 0 && is_containers {
2 2
@@ -332,7 +333,7 @@ impl InputHandler {
} }
} }
fn home_key(&mut self) { fn home_key(&self) {
let mut locked_data = self.app_data.lock(); let mut locked_data = self.app_data.lock();
let selected_panel = self.gui_state.lock().get_selected_panel(); let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel { match selected_panel {
@@ -343,7 +344,7 @@ impl InputHandler {
} }
/// Go to end of the list of the currently selected panel /// Go to end of the list of the currently selected panel
fn end_key(&mut self) { fn end_key(&self) {
let mut locked_data = self.app_data.lock(); let mut locked_data = self.app_data.lock();
let selected_panel = self.gui_state.lock().get_selected_panel(); let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel { match selected_panel {
@@ -353,6 +354,104 @@ impl InputHandler {
} }
} }
/// Actions to take when in Help status active
fn handle_help(&mut self, key_code: KeyCode) {
match key_code {
KeyCode::Esc | KeyCode::Char('h' | 'H') => {
self.gui_state.lock().status_del(Status::Help);
}
KeyCode::Char('m' | 'M') => self.m_key(),
_ => (),
}
}
/// Actions to take when Error status active
fn handle_error(&self, key_code: KeyCode) {
match key_code {
KeyCode::Esc | KeyCode::Char('c' | 'C') => {
self.app_data.lock().remove_error();
self.gui_state.lock().status_del(Status::Error);
}
_ => (),
}
}
/// Actions to take when Delete status active
async fn handle_delete(&self, key_code: KeyCode) {
match key_code {
KeyCode::Char('y' | 'Y') => self.confirm_delete().await,
KeyCode::Esc | KeyCode::Char('n' | 'N') => self.clear_delete(),
_ => (),
}
}
/// Actions to take when Filter status active
fn handle_filter(&self, key_code: KeyCode) {
match key_code {
KeyCode::Esc => {
self.app_data.lock().filter_term_clear();
self.gui_state.lock().status_del(Status::Filter);
}
KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('/') => {
self.gui_state.lock().status_del(Status::Filter);
}
KeyCode::Backspace => {
self.app_data.lock().filter_term_pop();
}
KeyCode::Char(x) => {
self.app_data.lock().filter_term_push(x);
}
KeyCode::Right => {
self.app_data.lock().filter_by_next();
}
KeyCode::Left => {
self.app_data.lock().filter_by_prev();
}
_ => (),
}
}
/// Handle button presses in all other scenarios
async fn handle_others(&mut self, key_code: KeyCode) {
match key_code {
KeyCode::Char('0') => self.app_data.lock().reset_sorted(),
KeyCode::Char('1') => self.sort(Header::Name),
KeyCode::Char('2') => self.sort(Header::State),
KeyCode::Char('3') => self.sort(Header::Status),
KeyCode::Char('4') => self.sort(Header::Cpu),
KeyCode::Char('5') => self.sort(Header::Memory),
KeyCode::Char('6') => self.sort(Header::Id),
KeyCode::Char('7') => self.sort(Header::Image),
KeyCode::Char('8') => self.sort(Header::Rx),
KeyCode::Char('9') => self.sort(Header::Tx),
KeyCode::Char('e' | 'E') => self.e_key().await,
KeyCode::Char('h' | 'H') => self.gui_state.lock().status_push(Status::Help),
KeyCode::Char('m' | 'M') => self.m_key(),
KeyCode::Char('s' | 'S') => self.s_key().await,
KeyCode::Tab => self.tab_key(),
KeyCode::BackTab => self.back_tab_key(),
KeyCode::Home => self.home_key(),
KeyCode::End => self.end_key(),
KeyCode::Up | KeyCode::Char('k' | 'K') => self.previous(),
KeyCode::PageUp => {
for _ in 0..=6 {
self.previous();
}
}
KeyCode::F(1) | KeyCode::Char('/') => {
self.gui_state.lock().status_push(Status::Filter);
self.docker_tx.send(DockerMessage::Update).await.ok();
}
KeyCode::Down | KeyCode::Char('j' | 'J') => self.next(),
KeyCode::PageDown => {
for _ in 0..=6 {
self.next();
}
}
KeyCode::Enter => self.enter_key().await,
_ => (),
}
}
/// Handle keyboard button events /// Handle keyboard button events
async fn button_press(&mut self, key_code: KeyCode, key_modifier: KeyModifiers) { async fn button_press(&mut self, key_code: KeyCode, key_modifier: KeyModifiers) {
let contains_delete = self let contains_delete = self
@@ -365,78 +464,32 @@ impl InputHandler {
let contains_error = contains(Status::Error); let contains_error = contains(Status::Error);
let contains_help = contains(Status::Help); let contains_help = contains(Status::Help);
let contains_exec = contains(Status::Exec); let contains_exec = contains(Status::Exec);
let contains_filter: bool = contains(Status::Filter);
if !contains_exec { if !contains_exec {
// Always just quit on Ctrl + c/C or q/Q
let is_c = || key_code == KeyCode::Char('c') || key_code == KeyCode::Char('C'); let is_c = || key_code == KeyCode::Char('c') || key_code == KeyCode::Char('C');
let is_q = || key_code == KeyCode::Char('q') || key_code == KeyCode::Char('Q'); let is_q = || key_code == KeyCode::Char('q') || key_code == KeyCode::Char('Q');
if key_modifier == KeyModifiers::CONTROL && is_c() || is_q() { if key_modifier == KeyModifiers::CONTROL && is_c() || is_q() && !contains_filter {
// Always just quit on Ctrl + c/C or q/Q, unless in FIlter status active
self.quit().await; self.quit().await;
} }
if contains_error { if contains_error {
match key_code { self.handle_error(key_code);
KeyCode::Esc | KeyCode::Char('c' | 'C') => {
self.app_data.lock().remove_error();
self.gui_state.lock().status_del(Status::Error);
}
_ => (),
}
} else if contains_help { } else if contains_help {
match key_code { self.handle_help(key_code);
KeyCode::Esc | KeyCode::Char('h' | 'H') => { } else if contains_filter {
self.gui_state.lock().status_del(Status::Help); self.handle_filter(key_code);
}
KeyCode::Char('m' | 'M') => self.m_key(),
_ => (),
}
} else if contains_delete { } else if contains_delete {
match key_code { self.handle_delete(key_code).await;
KeyCode::Char('y' | 'Y') => self.confirm_delete().await,
KeyCode::Esc | KeyCode::Char('n' | 'N') => self.clear_delete(),
_ => (),
}
} else { } else {
match key_code { self.handle_others(key_code).await;
KeyCode::Char('0') => self.app_data.lock().reset_sorted(),
KeyCode::Char('1') => self.sort(Header::Name),
KeyCode::Char('2') => self.sort(Header::State),
KeyCode::Char('3') => self.sort(Header::Status),
KeyCode::Char('4') => self.sort(Header::Cpu),
KeyCode::Char('5') => self.sort(Header::Memory),
KeyCode::Char('6') => self.sort(Header::Id),
KeyCode::Char('7') => self.sort(Header::Image),
KeyCode::Char('8') => self.sort(Header::Rx),
KeyCode::Char('9') => self.sort(Header::Tx),
KeyCode::Char('e' | 'E') => self.e_key().await,
KeyCode::Char('h' | 'H') => self.gui_state.lock().status_push(Status::Help),
KeyCode::Char('m' | 'M') => self.m_key(),
KeyCode::Char('s' | 'S') => self.s_key().await,
KeyCode::Tab => self.tab_key(),
KeyCode::BackTab => self.back_tab_key(),
KeyCode::Home => self.home_key(),
KeyCode::End => self.end_key(),
KeyCode::Up | KeyCode::Char('k' | 'K') => self.previous(),
KeyCode::PageUp => {
for _ in 0..=6 {
self.previous();
}
}
KeyCode::Down | KeyCode::Char('j' | 'J') => self.next(),
KeyCode::PageDown => {
for _ in 0..=6 {
self.next();
}
}
KeyCode::Enter => self.enter_key().await,
_ => (),
}
} }
} }
} }
/// Check if a button press interacts with either the yes or no buttons in the delete container confirm window /// Check if a button press interacts with either the yes or no buttons in the delete container confirm window
async fn button_intersect(&mut self, mouse_event: MouseEvent) { async fn button_intersect(&self, mouse_event: MouseEvent) {
if mouse_event.kind == MouseEventKind::Down(MouseButton::Left) { if mouse_event.kind == MouseEventKind::Down(MouseButton::Left) {
let intersect = self.gui_state.lock().button_intersect(Rect::new( let intersect = self.gui_state.lock().button_intersect(Rect::new(
mouse_event.column, mouse_event.column,
@@ -455,7 +508,7 @@ impl InputHandler {
} }
/// Handle mouse button events /// Handle mouse button events
fn mouse_press(&mut self, mouse_event: MouseEvent) { fn mouse_press(&self, mouse_event: MouseEvent) {
match mouse_event.kind { match mouse_event.kind {
MouseEventKind::ScrollUp => self.previous(), MouseEventKind::ScrollUp => self.previous(),
MouseEventKind::ScrollDown => self.next(), MouseEventKind::ScrollDown => self.next(),
@@ -481,7 +534,7 @@ impl InputHandler {
} }
/// Change state to next, depending which panel is currently in focus /// Change state to next, depending which panel is currently in focus
fn next(&mut self) { fn next(&self) {
let mut locked_data = self.app_data.lock(); let mut locked_data = self.app_data.lock();
let selected_panel = self.gui_state.lock().get_selected_panel(); let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel { match selected_panel {
@@ -492,7 +545,7 @@ impl InputHandler {
} }
/// Change state to previous, depending which panel is currently in focus /// Change state to previous, depending which panel is currently in focus
fn previous(&mut self) { fn previous(&self) {
let mut locked_data = self.app_data.lock(); let mut locked_data = self.app_data.lock();
let selected_panel = self.gui_state.lock().get_selected_panel(); let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel { match selected_panel {
+13 -3
View File
@@ -167,10 +167,18 @@ async fn main() {
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used, clippy::many_single_char_names, unused)] #[allow(clippy::unwrap_used, clippy::many_single_char_names, unused)]
mod tests { mod tests {
use std::{
collections::{HashSet, VecDeque},
vec,
};
use bollard::service::{ContainerSummary, Port}; use bollard::service::{ContainerSummary, Port};
use crate::{ use crate::{
app_data::{AppData, ContainerId, ContainerItem, ContainerPorts, State, StatefulList}, app_data::{
AppData, ContainerId, ContainerItem, ContainerPorts, ContainerStatus, Filter,
RunningState, State, StatefulList,
},
parse_args::CliArgs, parse_args::CliArgs,
}; };
@@ -201,16 +209,18 @@ mod tests {
private: u16::try_from(index).unwrap_or(1) + 8000, private: u16::try_from(index).unwrap_or(1) + 8000,
public: None, public: None,
}], }],
State::Running, State::Running(RunningState::Healthy),
format!("Up {index} hour"), ContainerStatus::from(format!("Up {index} hour")),
) )
} }
pub fn gen_appdata(containers: &[ContainerItem]) -> AppData { pub fn gen_appdata(containers: &[ContainerItem]) -> AppData {
AppData { AppData {
containers: StatefulList::new(containers.to_vec()), containers: StatefulList::new(containers.to_vec()),
hidden_containers: vec![],
error: None, error: None,
sorted_by: None, sorted_by: None,
filter: Filter::new(),
args: gen_args(), args: gen_args(),
} }
} }
+12 -8
View File
@@ -41,15 +41,19 @@ pub mod log_sanitizer {
/// Remove all ansi formatting from a given string and create ratatui Lines /// Remove all ansi formatting from a given string and create ratatui Lines
pub fn remove_ansi<'a>(input: &str) -> Vec<Line<'a>> { pub fn remove_ansi<'a>(input: &str) -> Vec<Line<'a>> {
raw(&categorise_text(input) vec![Line::from(
.into_iter() categorise_text(input)
.map(|i| i.text) .into_iter()
.collect::<String>()) .map(|i| i.text)
.collect::<String>()
.trim()
.to_owned(),
)]
} }
/// create ratatui Lines that exactly match the given strings /// create ratatui Lines that exactly match the given strings
pub fn raw<'a>(input: &str) -> Vec<Line<'a>> { pub fn raw<'a>(input: &str) -> Vec<Line<'a>> {
vec![Line::from(Span::raw(input.to_owned()))] vec![Line::from(input.escape_debug().collect::<String>())]
} }
/// Change from ansi to tui colors /// Change from ansi to tui colors
@@ -62,7 +66,7 @@ pub mod log_sanitizer {
CansiColor::Blue => Color::Blue, CansiColor::Blue => Color::Blue,
CansiColor::Magenta => Color::Magenta, CansiColor::Magenta => Color::Magenta,
CansiColor::Cyan => Color::Cyan, CansiColor::Cyan => Color::Cyan,
CansiColor::White | CansiColor::BrightWhite => Color::White, CansiColor::White | CansiColor::BrightWhite => Color::Gray,
CansiColor::BrightRed => Color::LightRed, CansiColor::BrightRed => Color::LightRed,
CansiColor::BrightGreen => Color::LightGreen, CansiColor::BrightGreen => Color::LightGreen,
CansiColor::BrightYellow => Color::LightYellow, CansiColor::BrightYellow => Color::LightYellow,
@@ -92,7 +96,7 @@ mod tests {
let expected = vec![Line { let expected = vec![Line {
spans: [Span { spans: [Span {
content: std::borrow::Cow::Borrowed( content: std::borrow::Cow::Borrowed(
"\x1b[31;47mo\x1b[32;40mx\x1b[33;41mk\x1b[34;42me\x1b[35;43mr\x1b[0m", "\\u{1b}[31;47mo\\u{1b}[32;40mx\\u{1b}[33;41mk\\u{1b}[34;42me\\u{1b}[35;43mr\\u{1b}[0m",
), ),
style: Style::default(), style: Style::default(),
}] }]
@@ -111,7 +115,7 @@ mod tests {
spans: vec![ spans: vec![
Span { Span {
content: std::borrow::Cow::Borrowed("o"), content: std::borrow::Cow::Borrowed("o"),
style: Style::default().fg(Color::Red).bg(Color::White), style: Style::default().fg(Color::Red).bg(Color::Gray),
}, },
Span { Span {
content: std::borrow::Cow::Borrowed("x"), content: std::borrow::Cow::Borrowed("x"),
+1263 -926
View File
File diff suppressed because it is too large Load Diff
+34 -31
View File
@@ -163,19 +163,21 @@ pub enum Status {
DockerConnect, DockerConnect,
Error, Error,
Exec, Exec,
Filter,
Help, Help,
Init, Init,
Logs, Logs,
} }
/// Global gui_state, stored in an Arc<Mutex> /// Global gui_state, stored in an Arc<Mutex>
#[derive(Debug, Default, Clone)] #[derive(Debug, Default)]
pub struct GuiState { pub struct GuiState {
delete_container: Option<ContainerId>, delete_container: Option<ContainerId>,
delete_map: HashMap<DeleteButton, Rect>, delete_map: HashMap<DeleteButton, Rect>,
heading_map: HashMap<Header, Rect>, heading_map: HashMap<Header, Rect>,
is_loading: HashSet<Uuid>, loading_handle: Option<JoinHandle<()>>,
loading_index: u8, loading_index: u8,
loading_set: HashSet<Uuid>,
panel_map: HashMap<SelectablePanel, Rect>, panel_map: HashMap<SelectablePanel, Rect>,
selected_panel: SelectablePanel, selected_panel: SelectablePanel,
status: HashSet<Status>, status: HashSet<Status>,
@@ -207,7 +209,7 @@ impl GuiState {
} }
/// Check if a given Rect (a clicked area of 1x1), interacts with any known delete button /// Check if a given Rect (a clicked area of 1x1), interacts with any known delete button
pub fn button_intersect(&mut self, rect: Rect) -> Option<DeleteButton> { pub fn button_intersect(&self, rect: Rect) -> Option<DeleteButton> {
self.delete_map self.delete_map
.iter() .iter()
.filter(|i| i.1.intersects(rect)) .filter(|i| i.1.intersects(rect))
@@ -217,7 +219,7 @@ impl GuiState {
} }
/// Check if a given Rect (a clicked area of 1x1), interacts with any known panels /// Check if a given Rect (a clicked area of 1x1), interacts with any known panels
pub fn header_intersect(&mut self, rect: Rect) -> Option<Header> { pub fn header_intersect(&self, rect: Rect) -> Option<Header> {
self.heading_map self.heading_map
.iter() .iter()
.filter(|i| i.1.intersects(rect)) .filter(|i| i.1.intersects(rect))
@@ -293,7 +295,7 @@ impl GuiState {
self.status.insert(Status::Exec); self.status.insert(Status::Exec);
} }
pub fn get_exec_mode(&mut self) -> Option<ExecMode> { pub fn get_exec_mode(&self) -> Option<ExecMode> {
self.exec_mode.clone() self.exec_mode.clone()
} }
@@ -325,45 +327,46 @@ impl GuiState {
} else { } else {
self.loading_index += 1; self.loading_index += 1;
} }
self.is_loading.insert(uuid); self.loading_set.insert(uuid);
} }
pub fn is_loading(&self) -> bool {
!self.loading_set.is_empty()
}
/// If is_loading has any entries, return the char at FRAMES[index], else an empty char, which needs to take up the same space, hence ' ' /// If is_loading has any entries, return the char at FRAMES[index], else an empty char, which needs to take up the same space, hence ' '
pub fn get_loading(&self) -> char { pub fn get_loading(&self) -> char {
if self.is_loading.is_empty() { if self.is_loading() {
' '
} else {
FRAMES[usize::from(self.loading_index)] FRAMES[usize::from(self.loading_index)]
} } else {
} ' '
/// Remove a loading_uuid from the is_loading HashSet, if empty, reset loading_index to 0
fn remove_loading(&mut self, uuid: Uuid) {
self.is_loading.remove(&uuid);
if self.is_loading.is_empty() {
self.loading_index = 0;
} }
} }
/// Animate the loading icon in its own Tokio thread /// Animate the loading icon in its own Tokio thread
pub fn start_loading_animation( /// This should only be able to executed once, rather than multiple spawns
gui_state: &Arc<Mutex<Self>>, pub fn start_loading_animation(gui_state: &Arc<Mutex<Self>>, loading_uuid: Uuid) {
loading_uuid: Uuid, if !gui_state.lock().is_loading() {
) -> JoinHandle<()> { let inner_state = Arc::clone(gui_state);
gui_state.lock().loading_handle = Some(tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
inner_state.lock().next_loading(loading_uuid);
}
}));
}
gui_state.lock().next_loading(loading_uuid); gui_state.lock().next_loading(loading_uuid);
let gui_state = Arc::clone(gui_state);
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
gui_state.lock().next_loading(loading_uuid);
}
})
} }
/// Stop the loading_spin function, and reset gui loading status /// Stop the loading_spin function, and reset gui loading status
pub fn stop_loading_animation(&mut self, handle: &JoinHandle<()>, loading_uuid: Uuid) { pub fn stop_loading_animation(&mut self, loading_uuid: Uuid) {
handle.abort(); self.loading_set.remove(&loading_uuid);
self.remove_loading(loading_uuid); if self.loading_set.is_empty() {
self.loading_index = 0;
if let Some(h) = &self.loading_handle {
h.abort();
}
self.loading_handle = None;
}
} }
/// Set info box content /// Set info box content
+17 -5
View File
@@ -32,6 +32,8 @@ use crate::{
input_handler::InputMessages, input_handler::InputMessages,
}; };
pub const ORANGE: ratatui::style::Color = ratatui::style::Color::Rgb(255, 178, 36);
pub struct Ui { pub struct Ui {
app_data: Arc<Mutex<AppData>>, app_data: Arc<Mutex<AppData>>,
gui_state: Arc<Mutex<GuiState>>, gui_state: Arc<Mutex<GuiState>>,
@@ -64,7 +66,6 @@ impl Ui {
is_running: Arc<AtomicBool>, is_running: Arc<AtomicBool>,
) { ) {
if let Ok(mut terminal) = Self::setup_terminal() { if let Ok(mut terminal) = Self::setup_terminal() {
// let args = app_data.lock().args.clone();
let cursor_position = terminal.get_cursor().unwrap_or_default(); let cursor_position = terminal.get_cursor().unwrap_or_default();
let mut ui = Self { let mut ui = Self {
app_data, app_data,
@@ -264,14 +265,20 @@ impl From<(MutexGuard<'_, AppData>, MutexGuard<'_, GuiState>)> for FrameData {
/// Draw the main ui to a frame of the terminal /// Draw the main ui to a frame of the terminal
fn draw_frame(f: &mut Frame, app_data: &Arc<Mutex<AppData>>, gui_state: &Arc<Mutex<GuiState>>) { fn draw_frame(f: &mut Frame, app_data: &Arc<Mutex<AppData>>, gui_state: &Arc<Mutex<GuiState>>) {
let fd = FrameData::from((app_data.lock(), gui_state.lock())); let fd = FrameData::from((app_data.lock(), gui_state.lock()));
let contains_filter = gui_state.lock().status_contains(&[Status::Filter]);
let whole_constraints = if contains_filter {
vec![Constraint::Max(1), Constraint::Min(1), Constraint::Max(1)]
} else {
vec![Constraint::Max(1), Constraint::Min(1)]
};
let whole_layout = Layout::default() let whole_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Max(1), Constraint::Min(1)].as_ref()) .constraints(whole_constraints)
.split(f.size()); .split(f.size());
// Split into 3, containers+controls, logs, then graphs // Split into 3, containers+controls, logs, then graphs
// This one is the issue!
let upper_main = Layout::default() let upper_main = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Max(fd.height), Constraint::Min(1)].as_ref()) .constraints([Constraint::Max(fd.height), Constraint::Min(1)].as_ref())
@@ -306,6 +313,11 @@ fn draw_frame(f: &mut Frame, app_data: &Arc<Mutex<AppData>>, gui_state: &Arc<Mut
draw_blocks::heading_bar(whole_layout[0], f, &fd, gui_state); draw_blocks::heading_bar(whole_layout[0], f, &fd, gui_state);
// Draw filter bar
if let Some(rect) = whole_layout.get(2) {
draw_blocks::filter_bar(*rect, f, app_data);
}
if let Some(id) = fd.delete_confirm.as_ref() { if let Some(id) = fd.delete_confirm.as_ref() {
app_data.lock().get_container_name_by_id(id).map_or_else( app_data.lock().get_container_name_by_id(id).map_or_else(
|| { || {
@@ -320,8 +332,8 @@ fn draw_frame(f: &mut Frame, app_data: &Arc<Mutex<AppData>>, gui_state: &Arc<Mut
} }
// only draw commands + charts if there are containers // only draw commands + charts if there are containers
if fd.has_containers { if let Some(rect) = top_panel.get(1) {
draw_blocks::commands(app_data, top_panel[1], f, &fd, gui_state); draw_blocks::commands(app_data, *rect, f, &fd, gui_state);
// Can calculate the max string length here, and then use that to keep the ports section as small as possible (+4 for some padding + border) // Can calculate the max string length here, and then use that to keep the ports section as small as possible (+4 for some padding + border)
let max_lens = app_data.lock().get_longest_port(); let max_lens = app_data.lock().get_longest_port();