feat: set log timezone, closes #56

Implement a CLI arg, and config file setting, for changing the timezone of the Docker logs timestamp
This commit is contained in:
Jack Wills
2025-02-21 11:03:19 +00:00
parent 8305e6fda6
commit 17a5e7a258
27 changed files with 1122 additions and 163 deletions
+96 -13
View File
@@ -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::<String>()
}
@@ -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::<String>())
}
}
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<String> {
self.0.parse::<Timestamp>().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());
+18 -9
View File
@@ -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<String>, 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::<Vec<_>>();
let logs = (1..=3).map(|i| format!("{i} {i}")).collect::<Vec<_>>();
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::<Vec<_>>();
let logs = (1..=3).map(|i| format!("{i} {i}")).collect::<Vec<_>>();
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");
+23 -1
View File
@@ -172,6 +172,12 @@ impl From<Option<ConfigColors>> 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<ConfigContainerState>,
containers: Option<ConfigContainers>,
headers_bar: Option<ConfigHeadersBar>,
logs: Option<ConfigLogs>,
popup_delete: Option<ConfigBackgroundTextHighlight>,
popup_error: Option<ConfigBackgroundText>,
popup_help: Option<ConfigBackgroundTextHighlight>,
@@ -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(),
+15 -4
View File
@@ -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"
+141 -7
View File
@@ -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<TimeZone>,
pub timestamp_format: String,
pub use_cli: bool,
}
impl From<Args> 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<Args> 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<ConfigFile> 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>) -> 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<String>) -> Option<TimeZone> {
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)
}
}
+4
View File
@@ -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<String>,
/// Directory for saving exported logs, defaults to `$HOME`
#[clap(long="save-dir", short = None)]
pub save_dir: Option<String>,
+14 -12
View File
@@ -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<Self, AppError> {
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<bool>,
pub colors: Option<ConfigColors>,
pub docker_interval: Option<u32>,
pub gui: Option<bool>,
pub host: Option<String>,
pub keymap: Option<ConfigKeymap>,
pub raw_logs: Option<bool>,
pub show_timestamp: Option<bool>,
pub save_dir: Option<String>,
pub show_self: Option<bool>,
pub show_std_err: Option<bool>,
pub show_timestamp: Option<bool>,
pub timestamp_format: Option<String>,
pub timezone: Option<String>,
pub use_cli: Option<bool>,
pub colors: Option<ConfigColors>,
pub keymap: Option<ConfigKeymap>,
}
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
+2 -2
View File
@@ -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<Mutex<AppData>>,
config: Config,
binate: Binate,
config: Config,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
receiver: Receiver<DockerMessage>,
+4 -6
View File
@@ -37,17 +37,17 @@ pub struct InputHandler {
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
mouse_capture: bool,
rec: Receiver<InputMessages>,
rx: Receiver<InputMessages>,
}
impl InputHandler {
/// Initialize self, and running the message handling loop
pub async fn start(
app_data: Arc<Mutex<AppData>>,
rec: Receiver<InputMessages>,
docker_tx: Sender<DockerMessage>,
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
rx: Receiver<InputMessages>,
) {
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);
+4 -2
View File
@@ -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,
}
}
+3 -4
View File
@@ -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<Line<'a>> {
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 {
+2 -11
View File
@@ -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;
+24 -19
View File
@@ -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<u8>,
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();
+1 -1
View File
@@ -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();
+171 -41
View File
@@ -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]);
}
}
}
}
+262 -12
View File
@@ -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<Mutex<GuiState>>,
) {
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());
}
}
}
}
}
+3
View File
@@ -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<mutex>'s, instead of from UI
impl From<(&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)> for FrameData {
fn from(data: (&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)) -> 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::<Vec<_>>()
}
/// Just a shorthand for when enumerating over result cells
pub fn get_result(
setup: &TuiTestSetup,
w: u16,
+133 -5
View File
@@ -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);
}
}
}
}
}
}
+13 -7
View File
@@ -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<ContainerId>,
@@ -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<Mutex<GuiState>>,
// 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);
}
}