diff --git a/Cargo.lock b/Cargo.lock index 06e18b7..e8d5c65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -906,6 +906,36 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +[[package]] +name = "jiff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba926fdd8e5b5e7f9700355b0831d8c416afe94b014b1023424037a187c9c582" +dependencies = [ + "jiff-tzdb", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2cec2f5d266af45a071ece48b1fb89f3b00b2421ac3a5fe10285a6caaa60d3" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a63c62e404e7b92979d2792352d885a7f8f83fd1d0d31eea582d77b2ceca697e" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1065,6 +1095,7 @@ dependencies = [ "crossterm", "directories", "futures-util", + "jiff", "parking_lot", "ratatui", "serde", @@ -1125,6 +1156,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index ac217d8..bc01325 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ clap = { version = "4.5", features = ["color", "derive", "unicode"] } crossterm = "0.28" directories = "6.0" futures-util = "0.3" +jiff = { version = "0.2", features = ["tzdb-bundle-always"] } parking_lot = { version = "0.12" } ratatui = "0.29" serde = { version = "1.0", features = ["derive"] } @@ -45,6 +46,7 @@ tracing = "0.1" tracing-subscriber = "0.3" uuid = { version = "1.12", features = ["fast-rng", "v4"] } + [profile.release] lto = true codegen-units = 1 diff --git a/README.md b/README.md index 1b1fba9..83097fd 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,7 @@ curl https://raw.githubusercontent.com/mrjackwills/oxker/main/install.sh | bash ```shell oxker ``` - -In application controls +In application controls, these can be customized with the [config file](#Config-File) | button| result| |--|--| | ```( tab )``` or ```( shift+tab )``` | Change panel, clicking on a panel also changes the selected panel.| @@ -118,6 +117,7 @@ In application controls | ```( esc )``` | Close dialog.| Available command line arguments + | argument|result| |--|--| |```-d [number > 0]```| Set the minimum update interval for docker information in milliseconds. Defaults to 1000 (1 second).| @@ -126,11 +126,30 @@ Available command line arguments |```-t```| Remove timestamps from each log entry.| |```-s```| If running via Docker, will display the oxker container.| |```-g```| No TUI, essentially a debugging mode with limited functionality, for now.| +|```--config-file [string]```| Location of a `config.toml`/`config.json`/`config.jsonc`.| |```--host [string]```| Connect to Docker with a custom hostname. Defaults to `/var/run/docker.sock`. Will use `$DOCKER_HOST` environment variable if set.| |```--no-stderr```| Do not include stderr output in logs.| |```--save-dir [string]```| Save exported logs into a custom directory. Defaults to `$HOME`.| +|```--timezone [string]```| Display the Docker logs timestamps in a given [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Defaults to `Etc/UTC`.| |```--use-cli```| Use the Docker application when exec-ing into a container, instead of the Docker API.| +### Config File + + +A config file enables the user to persist settings, it also enables the user to create a custom keymap, and set the color scheme used by the application. +
+
+By default, if not found, `oxker` will create a config file in the user's local config directory. Command line arguments take priority over values from the config file. +
+
+`oxker` supports `.toml`,`.json`, and `.jsonc` file formats. Examples of each can be found in the [example_config](https://github.com/mrjackwills/oxker/tree/main/example_config) directory. + +If running an `oxker` container, the default config location will be `/config.toml` rather than the automatically detected platform-specific local config directory. + +```shell +docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro -v /some/location/config.toml:/config.toml:ro oxker +``` + ## Build step ### x86_64 @@ -163,8 +182,10 @@ If no memory information available, try appending either ```/boot/cmdline.txt``` see https://forums.raspberrypi.com/viewtopic.php?t=203128 and https://github.com/docker/for-linux/issues/1112 + ### Untested on other platforms + ## Tests ~~As of yet untested, needs work~~ diff --git a/containerised/Dockerfile_dev b/containerised/Dockerfile_dev index 8a47e04..db0c8b2 100644 --- a/containerised/Dockerfile_dev +++ b/containerised/Dockerfile_dev @@ -13,9 +13,13 @@ COPY ./target/x86_64-unknown-linux-musl/release/oxker /app/ # this is used in the application itself, to stop itself show when running from a docker container, so DO NOT EDIT ENTRYPOINT [ "/app/oxker"] + # Dev build for testing # docker build -t oxker_dev -f containerised/Dockerfile_dev . && docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev +# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro --volume ./example_config/example.config.toml:/config.toml:ro oxker_dev --config-file /config.toml + +# docker run --rm -it --volume /etc/timezone:/etc/timezone:ro --volume /etc/localtime:/etc/localtime:ro --volume /var/run/docker.sock:/var/run/docker.sock:ro --volume ./example_config/example.config.toml:/config.toml:ro oxker_dev --config-file /config.toml # Dev build one liner, x86 host # docker image prune -a; cargo build --release --target x86_64-unknown-linux-musl && docker build -t oxker_dev -f containerised/Dockerfile_dev . && docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev diff --git a/create_release.sh b/create_release.sh index 8008aae..0d69f16 100755 --- a/create_release.sh +++ b/create_release.sh @@ -266,6 +266,40 @@ check_allow_unused() { fi } +# build container for amd64 platform +build_container_amd64() { + docker image rm oxker_amd64:latest + docker builder prune -a + echo -e "${YELLOW}docker build --platform linux/amd64 -t oxker_amd64 -f containerised/Dockerfile .; docker save -o /tmp/oxker_amd64.tar oxker_amd64${RESET}" + docker build --platform linux/amd64 -t oxker_amd64 -f containerised/Dockerfile . + docker save -o /tmp/oxker_amd64.tar oxker_amd64 +} +# build container for aarm64 platform +build_container_arm64() { + docker image rm oxker_arm64:latest + docker builder prune -a + echo -e "${YELLOW}docker build --platform linux/arm64 -t oxker_arm64 -f containerised/Dockerfile .; docker save -o /tmp/oxker_arm64.tar oxker_arm64${RESET}" + docker build --platform linux/arm64 -t oxker_arm64 -f containerised/Dockerfile . + docker save -o /tmp/oxker_arm64.tar oxker_arm64 +} +# build container for armv6 platform +build_container_armv6() { + docker image rm oxker_armv6:latest + docker builder prune -a + echo -e "${YELLOW}docker build --platform linux/arm/v6 -t oxker_armv6 -f containerised/Dockerfile .; docker save -o /tmp/oxker_armv6.tar oxker_armv6${RESET}" + docker build --platform linux/arm/v6 -t oxker_armv6 -f containerised/Dockerfile . + docker save -o /tmp/oxker_armv6.tar oxker_armv6 +} + +# Build all the containers, this get executed in the github action +build_container_all() { + build_container_amd64 + ask_continue + build_container_arm64 + ask_continue + build_container_armv6 +} + # Full flow to create a new release release_flow() { check_allow_unused @@ -276,6 +310,7 @@ release_flow() { cargo_test cross_build_all + build_container_all cargo_publish_dry_run cd "${CWD}" || error_close "Can't find ${CWD}" @@ -379,6 +414,45 @@ build_choice() { ;; esac done +} + +build_container_choice() { + cmd=(dialog --backtitle "Choose option" --radiolist "choose" 14 80 16) + options=( + 1 "x86 " off + 2 "aarch64" off + 3 "armv6" off + 4 "all" off + ) + choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty) + exitStatus=$? + clear + if [ $exitStatus -ne 0 ]; then + exit + fi + for choice in $choices; do + case $choice in + 0) + exit + ;; + 1) + build_container_amd64 + exit + ;; + 2) + build_container_arm64 + exit + ;; + 3) + build_container_armv6 + exit + ;; + 4) + build_container_all + exit + ;; + esac + done } @@ -388,6 +462,7 @@ main() { 1 "test" off 2 "release" off 3 "build" off + 4 "docker builds" off ) choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty) exitStatus=$? @@ -414,6 +489,11 @@ main() { main break ;; + 4) + build_container_choice + main + break + ;; esac done } diff --git a/example_config/example.config.json b/example_config/example.config.json index 37ef0bf..bf382a3 100644 --- a/example_config/example.config.json +++ b/example_config/example.config.json @@ -7,6 +7,8 @@ "show_self": false, "show_std_err": false, "show_timestamp": true, + "timezone": "Etc/UTC", + "timestamp_format":"%Y-%m-%dT%H:%M:%S.%8f", "use_cli": false, "colors": { "borders": { @@ -68,6 +70,10 @@ "text": "black", "text_selected": "gray" }, + "logs": { + "background": "reset", + "text": "reset" + }, "popup_delete": { "background": "white", "text": "black", diff --git a/example_config/example.config.jsonc b/example_config/example.config.jsonc index db7e7e5..6ceb44d 100644 --- a/example_config/example.config.jsonc +++ b/example_config/example.config.jsonc @@ -1,7 +1,7 @@ { // Example JSONC config file // This needs to be renamed to "config.jsonc" ("config.json" will also work, even if the file is actually a jsonc) in order for oxker to automatically load - // oxker will also read .jsonc and .json files which use the same key/value structure & format as this file + // oxker will also read .toml and .json files which use the same key/value structure & format as this file // Every key is optional, with defaults that oxker will choose if missing or invalid // The `--config-file` cli argument can be used to load configuration files from any readable location // Docker update interval in ms, minimum effectively 1000 @@ -20,6 +20,11 @@ "gui": true, // Docker host location "host": "/var/run/docker.sock", + // Display the timestamp in a custom format, if given option is invalid, it will default to %Y-%m-%dT%H:%M:%S.%8f -> 2025-02-18T12:34:56.01234567 + // *Should* accept any valid strftime string up to 32 chars + "timestamp_format":"%Y-%m-%dT%H:%M:%S.%8f", + // Display the container logs timestamp with a given timezone, if timezone is unknown, defaults to UTC + "timezone": "Etc/UTC", // Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows // "save_dir": "$HOME", // Force use of docker cli when execing into containers, honestly mostly pointless @@ -246,6 +251,13 @@ // Ports & IP listing text "text": "white" }, + // The logs panel, will only be applied if color_logs is false + "logs": { + // Background color of panel + "background": "reset", + // text color + "text": "reset" + }, // The help popup "popup_help": { // Background color diff --git a/example_config/example.config.toml b/example_config/example.config.toml index feb3da1..8115b9c 100644 --- a/example_config/example.config.toml +++ b/example_config/example.config.toml @@ -1,8 +1,5 @@ -# Example toml config file - -# This needs to be renamed to "config.toml" in order for oxker to automatically load +# oxker config file # oxker will also read .jsonc and .json files which use the same key/value structure & format as this file - # Every key is optional, with defaults that oxker will choose if missing or invalid # The `--config-file` cli argument can be used to load configuration files from any readable location @@ -30,6 +27,13 @@ gui = true # Docker host location host = "/var/run/docker.sock" +# Display the container logs timestamp with a given timezone, if timezone is unknown, defaults to UTC +timezone = "Etc/UTC" + +# Display the timestamp in a custom format, if given option is invalid, it will default to %Y-%m-%dT%H:%M:%S.%8f -> 2025-02-18T12:34:56.012345678Z +# *Should* accept any valid strftime string up to 32 chars +timestamp_format="%Y-%m-%dT%H:%M:%S.%8f" + # Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows # save_dir = "$HOME" @@ -141,6 +145,13 @@ text_rx="#FFE9C1" # Text color of the TX column text_tx="#CD8C8C" +# The logs panel, will only be applied if color_logs is false +[colors.logs] +# Background color of panel +background = "reset" +# text color +text="reset" + # Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section [colors.container_state] dead="red" diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs index f888ec1..a510e61 100644 --- a/src/app_data/container_state.rs +++ b/src/app_data/container_state.rs @@ -6,6 +6,7 @@ use std::{ }; use bollard::service::Port; +use jiff::{tz::TimeZone, Timestamp}; use ratatui::{ style::Color, widgets::{ListItem, ListState}, @@ -34,6 +35,7 @@ impl ContainerId { } /// Only return first 8 chars of id, is usually more than enough for uniqueness + /// TODO container id is a hex string, so can assume that 0..=8 will always return a 8 char ascii &str - need to update tests to use real ids, or atleast strings of the correct-ish length pub fn get_short(&self) -> String { self.0.chars().take(8).collect::() } @@ -513,21 +515,34 @@ pub type CpuTuple = (Vec<(f64, f64)>, CpuStats, State); #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct LogsTz(String); -/// The docker log, which should always contain a timestamp, is in the format `2023-01-14T19:13:30.783138328Z Lorem ipsum dolor sit amet` -/// So just split at the inclusive index of the first space, needs to be inclusive, hence the use of format to at the space, so that we can remove the whole thing when the `-t` flag is set -/// Need to make sure that this isn't an empty string?! -impl From<&str> for LogsTz { - fn from(value: &str) -> Self { - Self(value.split_inclusive(' ').take(1).collect::()) - } -} - impl fmt::Display for LogsTz { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } } +impl LogsTz { + /// With a given &str, split into a logtz and content, so that we only need to `use split_once()` once + /// The docker log, which should always contain a timestamp, is in the format `2023-01-14T19:13:30.783138328Z Lorem ipsum dolor sit amet` + pub fn splitter(input: &str) -> (Self, String) { + let (tz, content) = input.split_once(' ').unwrap_or_default(); + (Self(tz.to_owned()), content.to_owned()) + } + + /// Display the timestamp in a given format, and if provided, with a timezone offset + pub fn display_with_formatter(&self, tz: Option<&TimeZone>, format: &str) -> Option { + self.0.parse::().map_or(None, |t| { + if let Some(tz) = tz.as_ref() { + let tz = tz.iana_name()?; + let z = t.in_tz(tz).ok()?; + Some(z.strftime(format).to_string()) + } else { + Some(t.strftime(format).to_string()) + } + }) + } +} + /// Store the logs alongside a HashSet, each log *should* generate a unique timestamp, /// so if we store the timestamp separately in a HashSet, we can then check if we should insert a log line into the /// stateful list dependent on whethere the timestamp is in the HashSet or not @@ -745,15 +760,18 @@ impl Columns { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { + + use jiff::tz::TimeZone; use ratatui::widgets::ListItem; use crate::{ - app_data::{ContainerImage, Logs, RunningState}, + app_data::{ContainerImage, Logs, LogsTz, RunningState}, ui::log_sanitizer, }; - use super::{ByteStats, ContainerName, ContainerStatus, CpuStats, LogsTz, State}; + use super::{ByteStats, ContainerName, ContainerStatus, CpuStats, State}; #[test] /// Display CpuStats as a string @@ -813,11 +831,76 @@ mod tests { assert_eq!(result, "name_01_name_01_name_01_name_01_"); } + #[test] + /// LogzTz correctly splits a line by timestamp + fn test_container_state_logz_splitter() { + let input = "2023-01-14T12:01:20.012345678Z Lorem ipsum dolor sit amet"; + let log_tz = LogsTz::splitter(input); + + assert_eq!( + log_tz.0, + super::LogsTz("2023-01-14T12:01:20.012345678Z".to_owned()) + ); + assert_eq!(log_tz.1, "Lorem ipsum dolor sit amet"); + } + + #[test] + /// LogsTz display correctly formats with a given timestamp string + fn test_container_state_logz_display() { + let input = "2023-01-14T12:01:20.012345678Z Lorem ipsum dolor sit amet"; + let log_tz = LogsTz::splitter(input); + + let result = log_tz + .0 + .display_with_formatter(None, "%Y-%m-%dT%H:%M:%S.%8f"); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result, "2023-01-14T12:01:20.01234567"); + + let result = log_tz.0.display_with_formatter(None, "%Y-%m-%d %H:%M:%S"); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result, "2023-01-14 12:01:20"); + + let result = log_tz.0.display_with_formatter(None, "%Y-%j"); + assert!(result.is_some()); + let result = result.unwrap(); + + assert_eq!(result, "2023-014"); + } + + #[test] + /// LogsTz display correctly formats with a given timestamp string & timezone + fn test_container_state_logz_display_with_timezone() { + let input = "2023-01-14T12:01:20.012345678Z Lorem ipsum dolor sit amet"; + let log_tz = LogsTz::splitter(input); + + let timezone = Some(TimeZone::get("Asia/Tokyo").unwrap()); + let result = log_tz + .0 + .display_with_formatter(timezone.as_ref(), "%Y-%m-%dT%H:%M:%S.%8f"); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result, "2023-01-14T21:01:20.01234567"); + + let result = log_tz + .0 + .display_with_formatter(timezone.as_ref(), "%Y-%m-%d %H:%M:%S"); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result, "2023-01-14 21:01:20"); + + let result = log_tz.0.display_with_formatter(timezone.as_ref(), "%Y-%j"); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result, "2023-014"); + } + #[test] /// Logs can only contain 1 entry per LogzTz fn test_container_state_logz() { let input = "2023-01-14T19:13:30.783138328Z Lorem ipsum dolor sit amet"; - let tz = LogsTz::from(input); + let (tz, _) = LogsTz::splitter(input); let mut logs = Logs::default(); let line = log_sanitizer::remove_ansi(input); @@ -828,7 +911,7 @@ mod tests { assert_eq!(logs.logs.items.len(), 1); let input = "2023-01-15T19:13:30.783138328Z Lorem ipsum dolor sit amet"; - let tz = LogsTz::from(input); + let (tz, _) = LogsTz::splitter(input); let line = log_sanitizer::remove_ansi(input); logs.insert(ListItem::new(line.clone()), tz.clone()); diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index 1822cb8..731c818 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -585,7 +585,7 @@ impl AppData { /// Get the title for log panel for selected container, will be either /// 1) "logs x/x - container_name - container_image" /// 2) "logs - container_name - container_image" when no logs found - /// 3) "" no container currently selected - aka no containers on system + /// 3) " " no container currently selected - aka no containers on system pub fn get_log_title(&self) -> String { self.get_selected_container() .map_or_else(String::new, |ci| { @@ -879,18 +879,27 @@ impl AppData { pub fn update_log_by_id(&mut self, logs: Vec, id: &ContainerId) { let color = self.config.color_logs; let raw = self.config.raw_logs; + let format = self.config.timestamp_format.clone(); + let config_tz = self.config.timezone.clone(); - let timestamp = self.config.show_timestamp; + let show_timestamp = self.config.show_timestamp; if let Some(container) = self.get_any_container_by_id(id) { if !container.is_oxker { container.last_updated = Self::get_systemtime(); let current_len = container.logs.len(); - for mut i in logs { - let tz = LogsTz::from(i.as_str()); - if !timestamp { - i = i.replace(&tz.to_string(), ""); + let (log_tz, log_content) = LogsTz::splitter(i.as_str()); + if show_timestamp { + i = format!( + "{} {}", + log_tz + .display_with_formatter(config_tz.as_ref(), &format) + .unwrap_or_else(|| log_tz.to_string()), + log_content + ); + } else { + i = log_content; } let lines = if color { log_sanitizer::colorize_logs(&i) @@ -899,7 +908,7 @@ impl AppData { } else { log_sanitizer::remove_ansi(&i) }; - container.logs.insert(ListItem::new(lines), tz); + container.logs.insert(ListItem::new(lines), log_tz); } // Set the logs selected row for each container @@ -1819,7 +1828,7 @@ mod tests { assert_eq!(result, " - container_1 - image_1"); // On last line of logs - let logs = (1..=3).map(|i| format!("{i}")).collect::>(); + let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>(); app_data.update_log_by_id(logs, &ids[0]); let result = app_data.get_log_title(); assert_eq!(result, " 3/3 - container_1 - image_1"); @@ -1851,7 +1860,7 @@ mod tests { assert_eq!(result, " - container_2 - image_2"); // On last line of logs - let logs = (1..=3).map(|i| format!("{i}")).collect::>(); + let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>(); app_data.update_log_by_id(logs, &ids[1]); let result = app_data.get_log_title(); assert_eq!(result, " 3/3 - container_2 - image_2"); diff --git a/src/config/color_parser.rs b/src/config/color_parser.rs index bdf7f82..a591cbd 100644 --- a/src/config/color_parser.rs +++ b/src/config/color_parser.rs @@ -172,6 +172,12 @@ impl From> for AppColors { Self::map_color(cc.start.as_deref(), &mut app_colors.commands.start); } + // Logs panel + if let Some(cl) = config_colors.logs { + Self::map_color(cl.background.as_deref(), &mut app_colors.logs.background); + Self::map_color(cl.text.as_deref(), &mut app_colors.logs.text); + } + // Container State if let Some(cs) = config_colors.container_state { Self::map_color(cs.dead.as_deref(), &mut app_colors.container_state.dead); @@ -215,7 +221,8 @@ optional_config_struct!( ConfigCommands, background, pause, restart, stop, delete, resume, start; ConfigContainers, background, icon, text, text_rx, text_tx; ConfigContainerState, background, dead, exited, paused, removing, restarting, running_healthy, running_unhealthy, unknown; - ConfigHeadersBar, background, loading_spinner, text, text_selected + ConfigHeadersBar, background, loading_spinner, text, text_selected; + ConfigLogs, background, text ); config_struct!( @@ -227,6 +234,7 @@ config_struct!( Containers, background, icon, text, text_rx, text_tx; ContainerState, dead, exited, paused, removing, restarting, running_healthy, running_unhealthy, unknown; HeadersBar, background, text_selected, loading_spinner, text; + Logs, background, text; PopupDelete, background, text, text_highlight; PopupError, background, text; PopupHelp, background, text, text_highlight; @@ -243,6 +251,7 @@ pub struct ConfigColors { container_state: Option, containers: Option, headers_bar: Option, + logs: Option, popup_delete: Option, popup_error: Option, popup_help: Option, @@ -355,6 +364,17 @@ impl ContainerState { } } } + +/// Default colours for the logs panel, only applied if color_logs is false +impl Logs { + const fn new() -> Self { + Self { + background: Color::Reset, + text: Color::Reset, + } + } +} + /// Default colours for the Error popup impl PopupError { const fn new() -> Self { @@ -407,6 +427,7 @@ pub struct AppColors { pub container_state: ContainerState, pub containers: Containers, pub headers_bar: HeadersBar, + pub logs: Logs, pub popup_delete: PopupDelete, pub popup_error: PopupError, pub popup_help: PopupHelp, @@ -424,6 +445,7 @@ impl AppColors { container_state: ContainerState::new(), containers: Containers::new(), headers_bar: HeadersBar::new(), + logs: Logs::new(), popup_delete: PopupDelete::new(), popup_error: PopupError::new(), popup_help: PopupHelp::new(), diff --git a/src/config/config.toml b/src/config/config.toml index feb3da1..e5deda8 100644 --- a/src/config/config.toml +++ b/src/config/config.toml @@ -1,8 +1,5 @@ -# Example toml config file - -# This needs to be renamed to "config.toml" in order for oxker to automatically load +# oxker config file # oxker will also read .jsonc and .json files which use the same key/value structure & format as this file - # Every key is optional, with defaults that oxker will choose if missing or invalid # The `--config-file` cli argument can be used to load configuration files from any readable location @@ -30,6 +27,13 @@ gui = true # Docker host location host = "/var/run/docker.sock" +# Display the container logs timestamp with a given timezone, if timezone is unknown, defaults to UTC +timezone = "Etc/UTC" + +# Display the timestamp in a custom format, if given option is invalid, it will default to %Y-%m-%dT%H:%M:%S.%8f -> 2025-02-18T12:34:56.01234567 +# *Should* accept any valid strftime string up to 32 chars +timestamp_format="%Y-%m-%dT%H:%M:%S.%8f" + # Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows # save_dir = "$HOME" @@ -141,6 +145,13 @@ text_rx="#FFE9C1" # Text color of the TX column text_tx="#CD8C8C" +# The logs panel, will only be applied if color_logs is false +[colors.logs] +# Background color of panel +background = "reset" +# text color +text="reset" + # Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section [colors.container_state] dead="red" diff --git a/src/config/mod.rs b/src/config/mod.rs index 12a6130..2c4ed74 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use clap::Parser; +use jiff::tz::TimeZone; use parse_args::Args; use parse_config_file::ConfigFile; mod color_parser; @@ -27,17 +28,19 @@ pub struct Config { pub show_self: bool, pub show_std_err: bool, pub show_timestamp: bool, + pub timezone: Option, + pub timestamp_format: String, pub use_cli: bool, } -impl From for Config { - fn from(args: Args) -> Self { +impl From<&Args> for Config { + fn from(args: &Args) -> Self { Self { app_colors: AppColors::new(), color_logs: args.color, docker_interval: args.docker_interval, gui: !args.gui, - host: args.host, + host: args.host.clone(), in_container: Self::check_if_in_container(), keymap: Keymap::new(), raw_logs: args.raw, @@ -45,6 +48,8 @@ impl From for Config { show_self: !args.show_self, show_std_err: !args.no_std_err, show_timestamp: !args.timestamp, + timezone: Self::parse_timezone(args.timezone.clone()), + timestamp_format: Self::parse_timestamp_format(None), use_cli: args.use_cli, } } @@ -65,12 +70,43 @@ impl From for Config { show_self: config_file.show_self.unwrap_or(false), show_std_err: config_file.show_std_err.unwrap_or(true), show_timestamp: config_file.show_timestamp.unwrap_or(true), + timezone: Self::parse_timezone(config_file.timezone), + timestamp_format: Self::parse_timestamp_format(config_file.timestamp_format), use_cli: config_file.use_cli.unwrap_or(false), } } } impl Config { + /// A basic timestampt format parser, will only take 32 chars, and checks if the parsed timestamp isn't identical to the given formatter + fn parse_timestamp_format(input: Option) -> String { + let default = || "%Y-%m-%dT%H:%M:%S.%8f".to_owned(); + input.map_or_else(default, |input| { + if input.chars().count() >= 32 + || jiff::Timestamp::now().strftime(&input).to_string() == input + { + default() + } else { + input + } + }) + } + + /// Attempt to parse a timezone into a jiff::tz::TimeZone + /// Also return a format to display the timesampt in + fn parse_timezone(input: Option) -> Option { + let timezone_str = input?; + let Ok(tz) = jiff::tz::TimeZone::get(&timezone_str) else { + return None; + }; + let current_ts = jiff::Timestamp::now(); + let offset = tz.to_offset(current_ts); + if jiff::tz::TimeZone::UTC.to_offset(current_ts) == offset { + None + } else { + Some(tz) + } + } /// Check if oxker is running inside of a container fn check_if_in_container() -> bool { std::env::var(ENV_KEY).is_ok_and(|i| i == ENV_VALUE) @@ -89,28 +125,126 @@ impl Config { directories::BaseDirs::new().map(|base_dirs| base_dirs.home_dir().to_owned()) } + /// Combine config from CLI into config file, the cli take priority + /// make sure color_logs and raw_logs can't clash + fn merge_args(mut self, config_from_cli: Self) -> Self { + self.color_logs = config_from_cli.color_logs; + self.docker_interval = config_from_cli.docker_interval; + self.gui = config_from_cli.gui; + self.raw_logs = config_from_cli.raw_logs; + self.show_self = config_from_cli.show_self; + self.show_std_err = config_from_cli.show_std_err; + self.show_timestamp = config_from_cli.show_timestamp; + self.use_cli = config_from_cli.use_cli; + + if config_from_cli.docker_interval < 1000 { + self.docker_interval = 1000; + } + + if let Some(host) = config_from_cli.host { + self.host = Some(host); + } + + if let Some(x) = config_from_cli.save_dir { + self.save_dir = Some(x); + } + + if let Some(tz) = config_from_cli.timezone { + self.timezone = Some(tz); + } + + if config_from_cli.raw_logs { + self.color_logs = false; + } + if config_from_cli.color_logs { + self.raw_logs = false; + } + self + } + /// Generate a new config file /// First check cli args, /// then if a config file location is given check then /// Else check the default location /// else just return the default config + the cli args + /// cli args will take precedence over config settings pub fn new() -> Self { let in_container = Self::check_if_in_container(); let args = Args::parse(); + let config_from_cli = Self::from(&args); if let Some(config_file) = &args.config_file { if let Some(config_file) = parse_config_file::ConfigFile::try_parse_from_file(config_file) { - return Self::from(config_file); + return Self::from(config_file).merge_args(config_from_cli); } } if let Some(config_file) = parse_config_file::ConfigFile::try_parse(in_container) { - return Self::from(config_file); + return Self::from(config_file).merge_args(config_from_cli); + } + config_from_cli + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use jiff::tz::TimeZone; + + /// Test the basic timestamp_format parsing/checker function + #[test] + fn test_config_parse_timestamp_format() { + let default = "%Y-%m-%dT%H:%M:%S.%8f"; + + let result = super::Config::parse_timestamp_format(None); + assert_eq!(result, default); + + let result = super::Config::parse_timestamp_format(Some(String::new())); + assert_eq!(result, default); + + let result = super::Config::parse_timestamp_format(Some(" ".to_owned())); + assert_eq!(result, default); + + let result = super::Config::parse_timestamp_format(Some(" ".to_owned())); + assert_eq!(result, default); + + let result = + super::Config::parse_timestamp_format(Some("not a valid formatter".to_owned())); + assert_eq!(result, default); + + let result = super::Config::parse_timestamp_format(Some( + "%A, %B %d, %Y %I:%M %p %A, %B %d, %Y %I:%M %p".to_owned(), + )); + assert_eq!(result, default); + + let input = "%Y-%m-%d %H:%M:%S"; + let result = super::Config::parse_timestamp_format(Some(input.to_owned())); + assert_eq!(result, input); + + let input = "%Y-%j"; + let result = super::Config::parse_timestamp_format(Some(input.to_owned())); + assert_eq!(result, input); + } + + #[test] + /// Test various timezones get parsed correctly + fn test_config_parse_timezone() { + assert!(super::Config::parse_timezone(None).is_none()); + + // Timezone with no offset just return None + for i in ["Europe/London", "Africa/Accra"] { + assert!(super::Config::parse_timezone(Some(i.to_owned())).is_none()); + } + + let expected = Some(TimeZone::get("Asia/Tokyo").unwrap()); + // string case ignored + for i in ["ASIA/TOKYO", "asia/tokyo", "aSiA/tOkYo"] { + let result = super::Config::parse_timezone(Some(i.to_owned())); + assert!(result.is_some()); + assert_eq!(result, expected); } - - Self::from(args) } } diff --git a/src/config/parse_args.rs b/src/config/parse_args.rs index da24118..c8cedc2 100644 --- a/src/config/parse_args.rs +++ b/src/config/parse_args.rs @@ -37,6 +37,10 @@ pub struct Args { #[clap(long = "no-stderr")] pub no_std_err: bool, + /// Display the container logs timestamp with a given timezone, default is UTC + #[clap(long="timezone", short = None)] + pub timezone: Option, + /// Directory for saving exported logs, defaults to `$HOME` #[clap(long="save-dir", short = None)] pub save_dir: Option, diff --git a/src/config/parse_config_file.rs b/src/config/parse_config_file.rs index b1452ce..a444a07 100644 --- a/src/config/parse_config_file.rs +++ b/src/config/parse_config_file.rs @@ -20,6 +20,7 @@ enum ConfigFileType { impl TryFrom<&PathBuf> for ConfigFileType { type Error = AppError; + /// Only allow toml, json, or jsonc files fn try_from(value: &PathBuf) -> Result { let err = || AppError::IO(format!("Can't parse give config file: {}", value.display())); let Some(ext) = value.extension() else { @@ -47,8 +48,8 @@ impl ConfigFileType { .map(|base_dirs| base_dirs.config_local_dir().join(env!("CARGO_PKG_NAME"))) } } - // should take in a pathbuf as well? - fn get_default_filename(self, in_container: bool) -> PathBuf { + /// Return the default filename + path for a given filetype + fn get_default_path_name(self, in_container: bool) -> PathBuf { let suffix = match self { Self::Json | Self::JsoncAsJson => "config.json", Self::Jsonc => "config.jsonc", @@ -58,34 +59,34 @@ impl ConfigFileType { } } -// impl ConfigFileType - #[derive(Debug, Deserialize, Clone, PartialEq, Eq)] pub struct ConfigFile { pub color_logs: Option, + pub colors: Option, pub docker_interval: Option, pub gui: Option, pub host: Option, + pub keymap: Option, pub raw_logs: Option, - pub show_timestamp: Option, pub save_dir: Option, pub show_self: Option, pub show_std_err: Option, + pub show_timestamp: Option, + pub timestamp_format: Option, + pub timezone: Option, pub use_cli: Option, - pub colors: Option, - pub keymap: Option, } impl ConfigFile { - /// Attempt to create an example.config.toml file, will attempt to recursively create the directories as well - fn create_example_file(in_container: bool) -> Result<(), AppError> { + /// Attempt to create a config.toml file, will attempt to recursively create the directories as well + fn crate_config_file(in_container: bool) -> Result<(), AppError> { if in_container { return Ok(()); } let config_dir = ConfigFileType::get_config_dir(in_container) .ok_or_else(|| AppError::IO("config_dir".to_owned()))?; - let file_name = config_dir.join("example.config.toml"); + let file_name = config_dir.join("config.toml"); if !std::fs::exists(&file_name).map_err(|i| AppError::IO(i.to_string()))? { if !std::fs::exists(&config_dir).map_err(|i| AppError::IO(i.to_string()))? { @@ -134,6 +135,7 @@ impl ConfigFile { /// Resolve conflict in the args, this is handled automatically by Clap, basically just by rejecting it /// But here we can just change the options - although maybe should be also reject to follow the same behaviour as Clap? + /// TODO I think this is duplicated with the merge_args fn fn resolve_conflict(&mut self) { if let Some(color) = self.color_logs.as_ref() { if *color { @@ -171,7 +173,7 @@ impl ConfigFile { ConfigFileType::Json, ] { if let Ok(mut config_file) = - Self::parse_config_file(file_type, &file_type.get_default_filename(in_container)) + Self::parse_config_file(file_type, &file_type.get_default_path_name(in_container)) { Self::resolve_conflict(&mut config_file); @@ -181,7 +183,7 @@ impl ConfigFile { } if config.is_none() { - Self::create_example_file(in_container).ok(); + Self::crate_config_file(in_container).ok(); } config diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index f48a8b5..eeb082b 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -46,7 +46,7 @@ impl SpawnId { /// Cpu & Mem stats take twice as long as the update interval to get a value, so will have two being executed at the same time /// SpawnId::Stats takes container_id and binate value to enable both cycles of the same container_id to be inserted into the hashmap /// Binate value is toggled when all handles have been spawned off -/// Also effectively means that the docker_update interval minimum will be 1000ms +/// Also effectively means that the minimum docker_update interval will be 1000ms #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] enum Binate { One, @@ -64,8 +64,8 @@ impl Binate { pub struct DockerData { app_data: Arc>, - config: Config, binate: Binate, + config: Config, docker: Arc, gui_state: Arc>, receiver: Receiver, diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index a5f91fe..01f1f70 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -37,17 +37,17 @@ pub struct InputHandler { gui_state: Arc>, is_running: Arc, mouse_capture: bool, - rec: Receiver, + rx: Receiver, } impl InputHandler { /// Initialize self, and running the message handling loop pub async fn start( app_data: Arc>, - rec: Receiver, docker_tx: Sender, gui_state: Arc>, is_running: Arc, + rx: Receiver, ) { let keymap = app_data.lock().config.keymap.clone(); let mut inner = Self { @@ -56,7 +56,7 @@ impl InputHandler { gui_state, is_running, keymap, - rec, + rx, mouse_capture: true, }; inner.message_handler().await; @@ -64,7 +64,7 @@ impl InputHandler { /// check for incoming messages async fn message_handler(&mut self) { - while let Some(message) = self.rec.recv().await { + while let Some(message) = self.rx.recv().await { match message { InputMessages::ButtonPress(key) => self.button_press(key.0, key.1).await, InputMessages::MouseEvent(mouse_event) => { @@ -603,8 +603,6 @@ impl InputHandler { /// Handle mouse button events fn mouse_press(&self, mouse_event: MouseEvent) { - // If in help panel, ignore? - let status = self.gui_state.lock().get_status(); if status.contains(&Status::Help) { let mouse_point = Rect::new(mouse_event.column, mouse_event.row, 1, 1); diff --git a/src/main.rs b/src/main.rs index 44cb4fd..fb78459 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,10 +87,10 @@ fn handler_init( ) { tokio::spawn(input_handler::InputHandler::start( Arc::clone(app_data), - input_rx, docker_sx.clone(), Arc::clone(gui_state), Arc::clone(is_running), + input_rx, )); } @@ -159,7 +159,7 @@ mod tests { }; /// Default test config, has timestamps turned off - pub const fn gen_config() -> Config { + pub fn gen_config() -> Config { Config { color_logs: false, docker_interval: 1000, @@ -172,8 +172,10 @@ mod tests { show_self: false, app_colors: AppColors::new(), keymap: Keymap::new(), + timestamp_format: "HH:MM:SS.NNNNN dd-mm-yyyy".to_owned(), show_timestamp: false, use_cli: false, + timezone: None, } } diff --git a/src/ui/color_match.rs b/src/ui/color_match.rs index 5c6c75b..a065a8e 100644 --- a/src/ui/color_match.rs +++ b/src/ui/color_match.rs @@ -7,7 +7,6 @@ pub mod log_sanitizer { }; /// Attempt to colorize the given string to ratatui standards - /// TODO this is somewhat slow/cpu intensive pub fn colorize_logs<'a>(input: &str) -> Vec> { vec![Line::from( categorise_text(input) @@ -92,7 +91,7 @@ mod tests { #[test] /// Return test raw, as in show escape codes - fn color_match_raw() { + fn test_color_match_raw() { let result = log_sanitizer::raw(INPUT); let expected = vec![Line { spans: [Span { @@ -110,7 +109,7 @@ mod tests { #[test] /// Use the escape codes to colorize the text - fn color_match_colorize() { + fn test_color_match_colorize() { let result = log_sanitizer::colorize_logs(INPUT); let expected = vec![Line { spans: vec![ @@ -143,7 +142,7 @@ mod tests { #[test] /// Remove all escape ansi codes from given input - fn color_match_remove_ansi() { + fn test_color_match_remove_ansi() { let result = log_sanitizer::remove_ansi(INPUT); let expected = vec![Line { spans: vec![Span { diff --git a/src/ui/draw_blocks/charts.rs b/src/ui/draw_blocks/charts.rs index a09defe..f35454b 100644 --- a/src/ui/draw_blocks/charts.rs +++ b/src/ui/draw_blocks/charts.rs @@ -73,8 +73,6 @@ impl ChartType { } } -// mem_stats, mem_dataset, mem.1, "", cpu.2 -// current, dataset, max, name, state /// Create charts fn make_chart<'a, T: Stats + Display>( chart_type: ChartType, @@ -98,7 +96,6 @@ fn make_chart<'a, T: Stats + Display>( .fg(chart_type.get_title_color(colors, state)) .add_modifier(Modifier::BOLD), )) - // .bg(chart_type.get_bg_color(colors)) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(chart_type.get_border_color(colors))), @@ -113,16 +110,10 @@ fn make_chart<'a, T: Stats + Display>( Style::default().add_modifier(Modifier::BOLD).fg(max_color), ), ]) - .style( - Style::new() - // .bg(chart_type.get_bg_color(colors)) - .fg(chart_type.get_y_axis_color(colors)), - ) + .style(Style::new().fg(chart_type.get_y_axis_color(colors))) // Add 0.01, so that max point is always visible? .bounds([0.0, max.get_value() + 0.01]), ) - - // .style(Style::new().bg(chart_type.get_bg_color(colors))) } /// Draw the cpu + mem charts @@ -440,7 +431,7 @@ mod tests { #[test] /// Custom colos correctly applied to each part of the charts - fn test_custom_colors() { + fn test_draw_blocks_charts_custom_colors() { let mut colors = AppColors::new(); colors.chart_cpu.background = Color::White; diff --git a/src/ui/draw_blocks/error.rs b/src/ui/draw_blocks/error.rs index bf64ad7..c21b857 100644 --- a/src/ui/draw_blocks/error.rs +++ b/src/ui/draw_blocks/error.rs @@ -16,11 +16,11 @@ use super::popup; /// Draw an error popup over whole screen pub fn draw( - f: &mut Frame, + colors: AppColors, error: &AppError, + f: &mut Frame, keymap: &Keymap, seconds: Option, - colors: AppColors, ) { let block = Block::default() .title(" Error ") @@ -106,16 +106,20 @@ mod tests { #[test] /// Test that the error popup is centered, red background, white border, white text, and displays the correct text - fn test_draw_blocks_docker_connect_error() { + fn test_draw_blocks_error_docker_connect_error() { let (w, h) = (46, 9); let mut setup = test_setup(w, h, true, true); - let app_colors = setup.app_data.lock().config.app_colors; - let keymap = &setup.app_data.lock().config.keymap; setup .terminal .draw(|f| { - super::draw(f, &AppError::DockerConnect, keymap, Some(4), app_colors); + super::draw( + AppColors::new(), + &AppError::DockerConnect, + f, + &Keymap::new(), + Some(4), + ); }) .unwrap(); @@ -153,17 +157,20 @@ mod tests { #[test] /// Test that the clearable error popup is centered, red background, white border, white text, and displays the correct text - fn test_draw_blocks_clearable_error() { + fn test_draw_blocks_error_clearable_error() { let (w, h) = (39, 11); let mut setup = test_setup(w, h, true, true); - let app_colors = setup.app_data.lock().config.app_colors; - let keymap = &setup.app_data.lock().config.keymap; - setup .terminal .draw(|f| { - super::draw(f, &AppError::DockerExec, keymap, Some(4), app_colors); + super::draw( + AppColors::new(), + &AppError::DockerExec, + f, + &Keymap::new(), + Some(4), + ); }) .unwrap(); @@ -203,12 +210,10 @@ mod tests { #[test] /// Custom colors applied to the error popup correctly - fn test_draw_blocks_clearable_error_custom_colors() { + fn test_draw_blocks_error_custom_colors() { let (w, h) = (39, 11); let mut setup = test_setup(w, h, true, true); - let keymap = &setup.app_data.lock().config.keymap; - let mut colors = AppColors::new(); colors.popup_error.background = Color::Yellow; colors.popup_error.text = Color::Black; @@ -216,7 +221,7 @@ mod tests { setup .terminal .draw(|f| { - super::draw(f, &AppError::DockerExec, keymap, Some(4), colors); + super::draw(colors, &AppError::DockerExec, f, &Keymap::new(), Some(4)); }) .unwrap(); @@ -256,7 +261,7 @@ mod tests { #[test] /// Custom keymap applied correct with both 1 and 2 definitions - fn test_draw_blocks_clearable_error_custom_keymap() { + fn test_draw_blocks_error_custom_keymap() { let (w, h) = (39, 11); let mut setup = test_setup(w, h, true, true); @@ -267,7 +272,7 @@ mod tests { setup .terminal .draw(|f| { - super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new()); + super::draw(AppColors::new(), &AppError::DockerExec, f, &keymap, None); }) .unwrap(); @@ -299,7 +304,7 @@ mod tests { setup .terminal .draw(|f| { - super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new()); + super::draw(AppColors::new(), &AppError::DockerExec, f, &keymap, None); }) .unwrap(); @@ -330,7 +335,7 @@ mod tests { setup .terminal .draw(|f| { - super::draw(f, &AppError::DockerExec, &keymap, None, AppColors::new()); + super::draw(AppColors::new(), &AppError::DockerExec, f, &keymap, None); }) .unwrap(); diff --git a/src/ui/draw_blocks/headers.rs b/src/ui/draw_blocks/headers.rs index 1f8c40f..3450deb 100644 --- a/src/ui/draw_blocks/headers.rs +++ b/src/ui/draw_blocks/headers.rs @@ -458,7 +458,7 @@ mod tests { #[test] /// Custom colors are applied correctly - fn test_draw_blocks_headers_cusomt_colors() { + fn test_draw_blocks_headers_custom_colors() { let (w, h) = (140, 1); let mut setup = test_setup(w, h, true, true); let uuid = Uuid::new_v4(); diff --git a/src/ui/draw_blocks/help.rs b/src/ui/draw_blocks/help.rs index a7777b6..f01ddca 100644 --- a/src/ui/draw_blocks/help.rs +++ b/src/ui/draw_blocks/help.rs @@ -1,4 +1,5 @@ use crossterm::event::KeyCode; +use jiff::tz::TimeZone; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, @@ -84,13 +85,13 @@ impl HelpInfo { } /// Generate the button information span + metadata - fn gen_keymap_info(colors: AppColors) -> Self { + fn gen_keymap_info(colors: AppColors, zone: Option<&TimeZone>, show_timestamp: bool) -> Self { let button_item = |x: &str| Self::highlighted_text_span(&format!(" ( {x} ) "), colors); let button_desc = |x: &str| Self::text_span(x, colors); let or = || button_desc("or"); let space = || button_desc(" "); - let lines = [ + let descriptions = [ Line::from(vec![ space(), button_item("tab"), @@ -163,10 +164,23 @@ impl HelpInfo { ]), ]; + let mut lines = if show_timestamp { + Vec::from([ + Self::custom_text(colors, &Keymap::new(), zone), + Self::empty_span(), + ]) + } else { + vec![] + }; + + lines.extend_from_slice(&descriptions); + let width = Self::calc_width(&lines); + let height = lines.len(); + Self { - lines: lines.to_vec(), - width: Self::calc_width(&lines), - height: lines.len(), + lines, + width, + height, } } @@ -193,8 +207,31 @@ impl HelpInfo { } } + /// Display timezone in timestamps are visible + /// Has ability to display if keymap or colors are customized, but currently not in use + fn custom_text<'a>(colors: AppColors, _keymap: &Keymap, zone: Option<&TimeZone>) -> Line<'a> { + let highlighted = |x: &str| Self::highlighted_text_span(x, colors); + let text = |x: &str| Self::text_span(x, colors); + + // if keymap != &Keymap::new() { + // op.push(highlighted("customised keymap, ")); + // } + + // if colors != AppColors::new() { + // op.push(highlighted("customised app colors, ")); + // }; + + let zone = zone.and_then(|i| i.iana_name()).unwrap_or("Etc/UTC"); + Line::from(Vec::from([text("logs timezone: "), highlighted(zone)])).centered() + } + /// Generate the display information when a custom keymap is being used - fn gen_custom_keymap_info(colors: AppColors, km: &Keymap) -> Self { + fn gen_custom_keymap_info( + colors: AppColors, + km: &Keymap, + zone: Option<&TimeZone>, + show_timestamp: bool, + ) -> Self { let button_item = |x: &str| Self::highlighted_text_span(&format!(" ( {x} ) "), colors); let button_desc = |x: &str| Self::text_span(x, colors); let or = || button_desc("or"); @@ -220,11 +257,7 @@ impl HelpInfo { }, ) }; - - let lines = [ - Line::from(vec![Span::from("Custom keymap config in use\n")]) - .alignment(Alignment::Center) - .style(Style::default().fg(colors.popup_help.text_highlight)), + let descriptions = [ or_secondary(km.select_next_panel, "select next panel"), or_secondary(km.select_previous_panel, "select previous panel"), or_secondary(km.scroll_down_one, "scroll list down by one"), @@ -266,16 +299,32 @@ impl HelpInfo { or_secondary(km.quit, "quit at any time"), ]; + let mut lines = if show_timestamp { + Vec::from([Self::custom_text(colors, km, zone), Self::empty_span()]) + } else { + vec![] + }; + + lines.extend_from_slice(&descriptions); + let width = Self::calc_width(&lines); + let height = lines.len(); + Self { - lines: lines.to_vec(), - width: Self::calc_width(&lines), - height: lines.len(), + lines, + width, + height, } } } /// Draw the help box in the centre of the screen -pub fn draw(f: &mut Frame, colors: AppColors, keymap: &Keymap) { +pub fn draw( + colors: AppColors, + f: &mut Frame, + keymap: &Keymap, + show_timestamp: bool, + zone: Option<&TimeZone>, +) { let title = format!(" {VERSION} "); let name_info = HelpInfo::gen_name(colors); @@ -283,9 +332,9 @@ pub fn draw(f: &mut Frame, colors: AppColors, keymap: &Keymap) { let final_info = HelpInfo::gen_final(colors); let button_info = if keymap == &Keymap::new() { - HelpInfo::gen_keymap_info(colors) + HelpInfo::gen_keymap_info(colors, zone, show_timestamp) } else { - HelpInfo::gen_custom_keymap_info(colors, keymap) + HelpInfo::gen_custom_keymap_info(colors, keymap, zone, show_timestamp) }; let max_line_width = [ @@ -364,13 +413,14 @@ pub fn draw(f: &mut Frame, colors: AppColors, keymap: &Keymap) { } #[cfg(test)] -#[allow(clippy::unwrap_used)] +#[allow(clippy::unwrap_used, clippy::too_many_lines)] mod tests { use crate::{ config::{AppColors, Keymap}, ui::draw_blocks::VERSION, }; use crossterm::event::KeyCode; + use jiff::tz::TimeZone; use ratatui::style::{Color, Modifier}; use crate::ui::draw_blocks::tests::{expected_to_vec, get_result, test_setup}; @@ -380,12 +430,18 @@ mod tests { fn test_draw_blocks_help() { let (w, h) = (87, 33); let mut setup = test_setup(w, h, true, true); - let colors = setup.app_data.lock().config.app_colors; + let tz = setup.app_data.lock().config.timezone.clone(); setup .terminal .draw(|f| { - super::draw(f, colors, &setup.app_data.lock().config.keymap); + super::draw( + AppColors::new(), + f, + &setup.app_data.lock().config.keymap, + false, + tz.as_ref(), + ); }) .unwrap(); @@ -476,6 +532,7 @@ mod tests { let (w, h) = (87, 33); let mut setup = test_setup(w, h, true, true); let mut colors = AppColors::new(); + let tz = setup.app_data.lock().config.timezone.clone(); colors.popup_help.background = Color::Black; colors.popup_help.text = Color::Red; @@ -484,7 +541,13 @@ mod tests { setup .terminal .draw(|f| { - super::draw(f, colors, &setup.app_data.lock().config.keymap); + super::draw( + colors, + f, + &setup.app_data.lock().config.keymap, + false, + tz.as_ref(), + ); }) .unwrap(); @@ -571,10 +634,9 @@ mod tests { #[test] /// Help panel will show custom keymap if in use, with one definition for each entry - fn test_draw_blocks_custom_keymap_one_definition() { - let (w, h) = (98, 48); + fn test_draw_blocks_help_custom_keymap_one_definition() { + let (w, h) = (98, 47); let mut setup = test_setup(w, h, true, true); - let colors = setup.app_data.lock().config.app_colors; let input = Keymap { clear: (KeyCode::Char('a'), None), @@ -609,7 +671,7 @@ mod tests { setup .terminal .draw(|f| { - super::draw(f, colors, &input); + super::draw(AppColors::new(), f, &input, false, None); }) .unwrap(); @@ -629,7 +691,6 @@ mod tests { " │ │ ", " │ A simple tui to view & control docker containers │ ", " │ │ ", - " │ Custom keymap config in use │ ", " │ ( 0 ) select next panel │ ", " │ ( 2 ) select previous panel │ ", " │ ( q ) scroll list down by one │ ", @@ -669,21 +730,17 @@ mod tests { 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]); - if row_index == 14 && (36..=62).contains(&result_cell_index) { - assert_eq!(result_cell.fg, Color::White); - } } } } #[test] /// Help panel will show custom keymap if in use, with two definition for each entry - fn test_draw_blocks_custom_keymap_two_definitions() { - let (w, h) = (110, 48); + fn test_draw_blocks_help_custom_keymap_two_definitions() { + let (w, h) = (110, 47); let mut setup = test_setup(w, h, true, true); - let colors = setup.app_data.lock().config.app_colors; - let input = Keymap { + let keymap = Keymap { clear: (KeyCode::Char('a'), Some(KeyCode::Char('b'))), delete_deny: (KeyCode::Char('c'), Some(KeyCode::Char('d'))), delete_confirm: (KeyCode::Char('e'), Some(KeyCode::Char('f'))), @@ -716,7 +773,7 @@ mod tests { setup .terminal .draw(|f| { - super::draw(f, colors, &input); + super::draw(AppColors::new(), f, &keymap, false, None); }) .unwrap(); @@ -736,7 +793,6 @@ mod tests { " │ │ ", " │ A simple tui to view & control docker containers │ ", " │ │ ", - " │ Custom keymap config in use │ ", " │ ( 0 ) or ( 1 ) select next panel │ ", " │ ( 2 ) or ( 3 ) select previous panel │ ", " │ ( q ) or ( r ) scroll list down by one │ ", @@ -782,12 +838,11 @@ mod tests { #[test] /// Help panel will show custom keymap if in use, with either one or two definition for each entry - fn test_draw_blocks_custom_keymap_one_and_two_definitions() { - let (w, h) = (110, 48); + fn test_draw_blocks_help_one_and_two_definitions() { + let (w, h) = (110, 47); let mut setup = test_setup(w, h, true, true); - let colors = setup.app_data.lock().config.app_colors; - let input = Keymap { + let keymap = Keymap { clear: (KeyCode::Char('a'), Some(KeyCode::Char('b'))), delete_deny: (KeyCode::Char('c'), None), delete_confirm: (KeyCode::Char('e'), Some(KeyCode::Char('f'))), @@ -817,10 +872,12 @@ mod tests { toggle_mouse_capture: (KeyCode::PageDown, Some(KeyCode::PageUp)), }; + let tz = setup.app_data.lock().config.timezone.clone(); + setup .terminal .draw(|f| { - super::draw(f, colors, &input); + super::draw(AppColors::new(), f, &keymap, false, tz.as_ref()); }) .unwrap(); @@ -840,7 +897,6 @@ mod tests { " │ │ ", " │ A simple tui to view & control docker containers │ ", " │ │ ", - " │ Custom keymap config in use │ ", " │ ( 0 ) select next panel │ ", " │ ( 2 ) or ( 3 ) select previous panel │ ", " │ ( q ) or ( r ) scroll list down by one │ ", @@ -883,4 +939,78 @@ mod tests { } } } + + #[test] + fn test_draw_blocks_help_show_timezone() { + let (w, h) = (87, 35); + let mut setup = test_setup(w, h, true, true); + + setup + .terminal + .draw(|f| { + super::draw( + AppColors::new(), + f, + &Keymap::new(), + true, + Some(&TimeZone::get("asia/tokyo").unwrap()), + ); + }) + .unwrap(); + + let version_row = format!(" ╭ {VERSION} ────────────────────────────────────────────────────────────────────────────╮ "); + let expected = [ + " ", + version_row.as_str(), + " │ │ ", + " │ 88 │ ", + " │ 88 │ ", + " │ 88 │ ", + " │ ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, │ ", + r#" │ a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 │ "#, + r#" │ 8b d8 )888( 8888[ 8PP""""""" 88 │ "#, + r#" │ "8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 │ "#, + r#" │ `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 │ "#, + " │ │ ", + " │ A simple tui to view & control docker containers │ ", + " │ │ ", + " │ logs timezone: Asia/Tokyo │ ", + " │ │ ", + " │ ( tab ) or ( shift+tab ) change panels │ ", + " │ ( ↑ ↓ ) or ( j k ) or ( PgUp PgDown ) or ( Home End ) change selected line │ ", + " │ ( enter ) send docker container command │ ", + " │ ( e ) exec into a container │ ", + " │ ( h ) toggle this help information - or click heading │ ", + " │ ( s ) save logs to file │ ", + " │ ( m ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ", + " │ ( F1 ) or ( / ) enter filter mode │ ", + " │ ( 0 ) stop sort │ ", + " │ ( 1 - 9 ) sort by header - or click header │ ", + " │ ( esc ) close dialog │ ", + " │ ( q ) quit at any time │ ", + " │ │ ", + " │ currently an early work in progress, all and any input appreciated │ ", + " │ https://github.com/mrjackwills/oxker │ ", + " │ │ ", + " │ │ ", + " ╰───────────────────────────────────────────────────────────────────────────────────╯ ", + " " + ]; + + 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() { + match (row_index, result_cell_index) { + (14, 31..=45) => { + assert_eq!(result_cell.fg, AppColors::new().popup_help.text); + } + (14, 46..=55) => { + assert_eq!(result_cell.fg, AppColors::new().popup_help.text_highlight); + } + _ => (), + } + assert_eq!(result_cell.symbol(), expected_row[result_cell_index]); + } + } + } } diff --git a/src/ui/draw_blocks/logs.rs b/src/ui/draw_blocks/logs.rs index 4ecff2a..8ca623b 100644 --- a/src/ui/draw_blocks/logs.rs +++ b/src/ui/draw_blocks/logs.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use parking_lot::Mutex; use ratatui::{ layout::{Alignment, Rect}, - style::{Modifier, Style}, + style::{Modifier, Style, Stylize}, widgets::{List, Paragraph}, Frame, }; @@ -25,22 +25,41 @@ pub fn draw( fd: &FrameData, gui_state: &Arc>, ) { - let block = generate_block(area, colors, fd, gui_state, SelectablePanel::Logs); + let mut block = generate_block(area, colors, fd, gui_state, SelectablePanel::Logs); + if !fd.color_logs { + block = block.bg(colors.logs.background); + } + if fd.status.contains(&Status::Init) { - let paragraph = Paragraph::new(format!("parsing logs {}", fd.loading_icon)) - .style(Style::default()) + let mut paragraph = Paragraph::new(format!("parsing logs {}", fd.loading_icon)) .block(block) .alignment(Alignment::Center); + if !fd.color_logs { + paragraph = paragraph.fg(colors.logs.text); + } f.render_widget(paragraph, area); } else { let logs = app_data.lock().get_logs(); if logs.is_empty() { - let paragraph = Paragraph::new("no logs found") + let mut paragraph = Paragraph::new("no logs found") .block(block) .alignment(Alignment::Center); + if !fd.color_logs { + paragraph = paragraph.fg(colors.logs.text); + } f.render_widget(paragraph, area); + } else if fd.color_logs { + let items = List::new(logs) + .block(block) + .highlight_symbol(RIGHT_ARROW) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + // This should always return Some, as logs is not empty + if let Some(log_state) = app_data.lock().get_log_state() { + f.render_stateful_widget(items, area, log_state); + } } else { let items = List::new(logs) + .fg(colors.logs.text) .block(block) .highlight_symbol(RIGHT_ARROW) .highlight_style(Style::default().add_modifier(Modifier::BOLD)); @@ -60,6 +79,7 @@ mod tests { use crate::{ app_data::{ContainerImage, ContainerName}, + config::AppColors, ui::{ draw_blocks::tests::{ expected_to_vec, get_result, insert_logs, test_setup, BORDER_CHARS, @@ -164,7 +184,6 @@ mod tests { let mut fd = FrameData::from((&setup.app_data, &setup.gui_state)); fd.status.insert(Status::Init); - let colors = setup.app_data.lock().config.app_colors; setup .terminal @@ -172,7 +191,7 @@ mod tests { super::draw( &setup.app_data, setup.area, - colors, + AppColors::new(), f, &fd, &setup.gui_state, @@ -218,7 +237,7 @@ mod tests { super::draw( &setup.app_data, setup.area, - colors, + AppColors::new(), f, &fd, &setup.gui_state, @@ -251,7 +270,6 @@ mod tests { let mut setup = test_setup(w, h, true, true); insert_logs(&setup); - let colors = setup.app_data.lock().config.app_colors; let fd = FrameData::from((&setup.app_data, &setup.gui_state)); setup @@ -260,7 +278,7 @@ mod tests { super::draw( &setup.app_data, setup.area, - colors, + AppColors::new(), f, &fd, &setup.gui_state, @@ -303,7 +321,7 @@ mod tests { super::draw( &setup.app_data, setup.area, - colors, + AppColors::new(), f, &fd, &setup.gui_state, @@ -360,7 +378,230 @@ mod tests { ]; let fd = FrameData::from((&setup.app_data, &setup.gui_state)); - let colors = setup.app_data.lock().config.app_colors; + + setup + .terminal + .draw(|f| { + super::draw( + &setup.app_data, + setup.area, + AppColors::new(), + 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]); + } + } + } + + #[test] + fn test_draw_blocks_logs_custom_colors_parsing() { + let (w, h) = (32, 6); + let mut setup = test_setup(w, h, true, true); + let uuid = Uuid::new_v4(); + setup.gui_state.lock().next_loading(uuid); + + let expected = [ + "╭ Logs - container_1 - image_1 ╮", + "│ parsing logs ⠙ │", + "│ │", + "│ │", + "│ │", + "╰──────────────────────────────╯", + ]; + + let mut fd = FrameData::from((&setup.app_data, &setup.gui_state)); + fd.status.insert(Status::Init); + + let mut colors = AppColors::new(); + colors.logs.background = Color::Green; + colors.logs.text = Color::Black; + + setup + .terminal + .draw(|f| { + super::draw( + &setup.app_data, + setup.area, + colors, + 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]); + assert_eq!(result_cell.bg, Color::Green); + if let (1..=4, 1..=29) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Black); + } + } + } + + fd.color_logs = true; + + setup + .terminal + .draw(|f| { + super::draw( + &setup.app_data, + setup.area, + colors, + 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]); + assert_eq!(result_cell.bg, Color::Reset); + if let (1..=4, 1..=29) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Reset); + } + } + } + } + + #[test] + + fn test_draw_blocks_logs_custom_colors_no_logs() { + let (w, h) = (35, 6); + let mut setup = test_setup(w, h, true, true); + + let expected = [ + "╭ Logs - container_1 - image_1 ───╮", + "│ no logs found │", + "│ │", + "│ │", + "│ │", + "╰─────────────────────────────────╯", + ]; + let mut colors = AppColors::new(); + colors.logs.background = Color::Green; + colors.logs.text = Color::Black; + + setup + .terminal + .draw(|f| { + super::draw( + &setup.app_data, + setup.area, + colors, + f, + &setup.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]); + assert_eq!(result_cell.bg, Color::Green); + if let (1..=4, 1..=29) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Black); + } + } + } + + setup.fd.color_logs = true; + setup + .terminal + .draw(|f| { + super::draw( + &setup.app_data, + setup.area, + colors, + f, + &setup.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]); + assert_eq!(result_cell.bg, Color::Reset); + if let (1..=4, 1..=29) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Reset); + } + } + } + } + + #[test] + /// Logs correct displayed with custom colors + fn test_draw_blocks_logs_custom_colors_logs() { + let (w, h) = (36, 6); + let mut setup = test_setup(w, h, true, true); + insert_logs(&setup); + + let mut colors = setup.app_data.lock().config.app_colors; + colors.logs.background = Color::Green; + colors.logs.text = Color::Black; + let mut fd = FrameData::from((&setup.app_data, &setup.gui_state)); + fd.color_logs = true; + + // Standard colors when color_logs is true + setup + .terminal + .draw(|f| { + super::draw( + &setup.app_data, + setup.area, + colors, + f, + &fd, + &setup.gui_state, + ); + }) + .unwrap(); + let expected = [ + "╭ Logs 3/3 - container_1 - image_1 ╮", + "│ line 1 │", + "│ line 2 │", + "│▶ line 3 │", + "│ │", + "╰──────────────────────────────────╯", + ]; + + 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]); + assert_eq!(result_cell.bg, Color::Reset); + if let (1..=4, 1..=34) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Reset); + if row_index == 3 && (1..=34).contains(&result_cell_index) { + assert_eq!(result_cell.modifier, Modifier::BOLD); + } else { + assert!(result_cell.modifier.is_empty()); + } + } + } + } + + fd.color_logs = false; setup .terminal @@ -380,6 +621,15 @@ mod tests { 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]); + assert_eq!(result_cell.bg, Color::Green); + if let (1..=4, 1..=34) = (row_index, result_cell_index) { + assert_eq!(result_cell.fg, Color::Black); + if row_index == 3 && (1..=34).contains(&result_cell_index) { + assert_eq!(result_cell.modifier, Modifier::BOLD); + } else { + assert!(result_cell.modifier.is_empty()); + } + } } } } diff --git a/src/ui/draw_blocks/mod.rs b/src/ui/draw_blocks/mod.rs index 39d1de7..51d084c 100644 --- a/src/ui/draw_blocks/mod.rs +++ b/src/ui/draw_blocks/mod.rs @@ -142,6 +142,7 @@ pub mod tests { pub const COLOR_TX: Color = Color::Rgb(205, 140, 140); pub const COLOR_ORANGE: Color = Color::Rgb(255, 178, 36); + /// Create a FrameData struct from two Arc's, instead of from UI impl From<(&Arc>, &Arc>)> for FrameData { fn from(data: (&Arc>, &Arc>)) -> Self { let (app_data, gui_data) = (data.0.lock(), data.1.lock()); @@ -158,6 +159,7 @@ pub mod tests { Self { chart_data: app_data.get_chart_data(), columns: app_data.get_width(), + color_logs: app_data.config.color_logs, container_title: app_data.get_container_title(), delete_confirm: gui_data.get_delete_container(), filter_by, @@ -216,6 +218,7 @@ pub mod tests { .collect::>() } + /// Just a shorthand for when enumerating over result cells pub fn get_result( setup: &TuiTestSetup, w: u16, diff --git a/src/ui/draw_blocks/ports.rs b/src/ui/draw_blocks/ports.rs index 41cfb08..d4f62a8 100644 --- a/src/ui/draw_blocks/ports.rs +++ b/src/ui/draw_blocks/ports.rs @@ -24,12 +24,12 @@ pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) { .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Style::new().fg(colors.chart_ports.border)) - // .bg(colors.chart_ports.border)) .title_alignment(Alignment::Center) .title(Span::styled( " ports ", Style::default() .fg(get_port_title_color(colors, ports.1)) + .bg(colors.chart_ports.background) .add_modifier(Modifier::BOLD), )); @@ -42,7 +42,8 @@ pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) { }; let paragraph = Paragraph::new(Span::from(text).add_modifier(Modifier::BOLD)) .alignment(Alignment::Center) - .block(block); + .block(block) + .bg(colors.chart_ports.background); f.render_widget(paragraph, area); } else { let mut output = vec![Line::from( @@ -62,7 +63,9 @@ pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) { ]; output.push(Line::from(line)); } - let paragraph = Paragraph::new(output).block(block); + let paragraph = Paragraph::new(output) + .block(block) + .bg(colors.chart_ports.background); f.render_widget(paragraph, area); } } @@ -76,9 +79,12 @@ mod tests { use ratatui::style::{Color, Modifier}; use crate::{ - app_data::{ContainerPorts, State}, + app_data::{ContainerPorts, RunningState, State}, + config::AppColors, ui::{ - draw_blocks::tests::{expected_to_vec, get_result, test_setup}, + draw_blocks::tests::{ + expected_to_vec, get_result, test_setup, COLOR_ORANGE, COLOR_RX, COLOR_TX, + }, FrameData, }, }; @@ -317,4 +323,126 @@ mod tests { } } } + + #[test] + /// Custom colors applied to ports panel + fn test_draw_blocks_ports_custom_colors() { + let (w, h) = (32, 8); + let mut setup = test_setup(w, h, true, true); + + let mut colors = AppColors::new(); + colors.chart_ports.background = Color::Black; + colors.chart_ports.border = Color::Yellow; + colors.chart_ports.headings = Color::Red; + colors.chart_ports.text = Color::Green; + colors.chart_ports.title = Color::Magenta; + + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + setup + .terminal + .draw(|f| { + super::draw(setup.area, colors, f, &fd); + }) + .unwrap(); + + let expected = [ + "╭─────────── ports ────────────╮", + "│ ip private public │", + "│ 8001 │", + "│ │", + "│ │", + "│ │", + "│ │", + "╰──────────────────────────────╯", + ]; + + 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]); + assert_eq!(result_cell.bg, Color::Black); + + match (row_index, result_cell_index) { + // title => { + (0, 12..=18) => { + assert_eq!(result_cell.fg, Color::Magenta); + } + // title + (1, 1..=24) => { + assert_eq!(result_cell.fg, Color::Red); + } + // text + (2, 1..=24) => { + assert_eq!(result_cell.fg, Color::Green); + } + // border & everything else + _ => { + assert_eq!(result_cell.fg, Color::Yellow); + } + } + } + } + } + + #[test] + // Custom state color applied to ports panel title + fn test_draw_blocks_ports_custom_colors_state() { + let (w, h) = (32, 8); + let mut setup = test_setup(w, h, true, true); + + let mut colors = AppColors::new(); + colors.container_state.dead = Color::Green; + colors.container_state.exited = Color::Magenta; + colors.container_state.paused = Color::Gray; + colors.container_state.removing = COLOR_ORANGE; + colors.container_state.restarting = COLOR_RX; + colors.container_state.running_healthy = COLOR_TX; + colors.container_state.running_unhealthy = Color::Cyan; + colors.container_state.unknown = Color::LightMagenta; + + colors.chart_ports.title = Color::DarkGray; + + let expected = [ + "╭─────────── ports ────────────╮", + "│ ip private public │", + "│ 8001 │", + "│ │", + "│ │", + "│ │", + "│ │", + "╰──────────────────────────────╯", + ]; + + for i in [ + (State::Dead, Color::Green), + (State::Exited, Color::Magenta), + (State::Paused, Color::Gray), + (State::Removing, COLOR_ORANGE), + (State::Restarting, COLOR_RX), + (State::Unknown, Color::LightMagenta), + (State::Running(RunningState::Healthy), Color::DarkGray), + (State::Running(RunningState::Unhealthy), Color::DarkGray), + ] { + setup.app_data.lock().containers.items[0].state = i.0; + + let fd = FrameData::from((&setup.app_data, &setup.gui_state)); + setup + .terminal + .draw(|f| { + super::draw(setup.area, colors, f, &fd); + }) + .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]); + + if row_index == 0 && (12..=18).contains(&result_cell_index) { + assert_eq!(result_cell.fg, i.1); + } + } + } + } + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c3dd246..3b17ea7 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -139,11 +139,11 @@ impl Ui { .terminal .draw(|f| { draw_blocks::error::draw( - f, + colors, &AppError::DockerConnect, + f, &keymap, Some(seconds), - colors, ); }) .is_err() @@ -237,8 +237,8 @@ impl Ui { /// Frequent data required by multiple frame drawing functions, can reduce mutex reads by placing it all in here #[derive(Debug, Clone)] pub struct FrameData { - // app_colors: AppColors, chart_data: Option<(CpuTuple, MemTuple)>, + color_logs: bool, columns: Columns, container_title: String, delete_confirm: Option, @@ -272,8 +272,8 @@ impl From<&Ui> for FrameData { let (filter_by, filter_term) = app_data.get_filter(); Self { - // app_colors: app_data.config.app_colors, chart_data: app_data.get_chart_data(), + color_logs: app_data.config.color_logs, columns: app_data.get_width(), container_title: app_data.get_container_title(), delete_confirm: gui_data.get_delete_container(), @@ -303,7 +303,6 @@ fn draw_frame( f: &mut Frame, fd: &FrameData, gui_state: &Arc>, - // should pass in the colors here, then I only need to get it once from app+data ) { let whole_constraints = if fd.status.contains(&Status::Filter) { vec![Constraint::Max(1), Constraint::Min(1), Constraint::Max(1)] @@ -393,10 +392,17 @@ fn draw_frame( // Check if error, and show popup if so if fd.status.contains(&Status::Help) { - draw_blocks::help::draw(f, colors, keymap); + let tz = app_data.lock().config.timezone.clone(); + draw_blocks::help::draw( + colors, + f, + keymap, + app_data.lock().config.show_timestamp, + tz.as_ref(), + ); } if let Some(error) = fd.has_error.as_ref() { - draw_blocks::error::draw(f, error, keymap, None, colors); + draw_blocks::error::draw(colors, error, f, keymap, None); } }