diff --git a/.github/release-body.md b/.github/release-body.md index bf70699..97a8a25 100644 --- a/.github/release-body.md +++ b/.github/release-body.md @@ -1,19 +1,11 @@ -### 2022-07-06 +### 2022-07-23 -### Docs -+ readme update, [f29e29ad151ddf424ba630e6d33edf19acfd7636] -+ comments improved, [1674db8a20aafa447732deb2e44ac8b97cf0471b] -+ readme logo size, [a733efa65865e04d9ec86c7ca8785dfbae635695] - -### Fixes -+ Remove unwraps(), [61db81ecfe5684ddb8a360715f43357a042162c0] -+ Help menu alt+tab > shift+tab typo, thanks [siph](https://github.com/siph), [04466803481b75feb7d7f275248279fdb8729862] - -### Refactors -+ tokio spawns, [1fd230f2f3cf4e376058359515e76f4fa6e425c2] -+ max_line_width(), [a5d7dabbd68dc15a081df33352ce3b55d9a9891c] -+ create_release dead code removed, [297979c197c2defd409053d8da724f922b0bba1b] +### Chores ++ dependencies updated, [cf7e02dde94f69832a2e485b99785afc66a5bc15] +### Features ++ Enable sorting of containers by each, and every, heading. Either via keyboard or mouse, closes [#3], [a6c296f2cde56cf241bcd696cab8bd477270e5f4] ++ Spawn & track docker information update requests, multiple identical requests cannot be executed, [740c059b276f35acd1cb03f1030134646bf8a07d] see CHANGELOG.md for more details diff --git a/.github/screenshot_01.jpg b/.github/screenshot_01.jpg index 44ece7b..fc7d9a6 100644 Binary files a/.github/screenshot_01.jpg and b/.github/screenshot_01.jpg differ diff --git a/CHANGELOG.md b/CHANGELOG.md index c90a51e..6bf452f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# v0.1.0 +### 2022-07-23 + +### Chores ++ dependencies updated, [cf7e02dd](https://github.com/mrjackwills/oxker/commit/cf7e02dde94f69832a2e485b99785afc66a5bc15), + +### Features ++ Enable sorting of containers by each, and every, heading. Either via keyboard or mouse, closes [#3](https://github.com/mrjackwills/oxker/issues/3), [a6c296f2](https://github.com/mrjackwills/oxker/commit/a6c296f2cde56cf241bcd696cab8bd477270e5f4), ++ Spawn & track docker information update requests, multiple identical requests cannot be executed, [740c059b](https://github.com/mrjackwills/oxker/commit/740c059b276f35acd1cb03f1030134646bf8a07d), + # v0.0.6 ### 2022-07-06 diff --git a/Cargo.toml b/Cargo.toml index 0f64201..44f4180 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxker" -version = "0.0.6" +version = "0.1.0" edition = "2021" authors = ["Jack Wills "] description = "a simple tui to view & control docker containers" @@ -10,13 +10,13 @@ readme = "README.md" [dependencies] anyhow = "1.0" -bollard = "0.12.0" +bollard = "0.13" cansi = "2.1" -clap={version="3.1", features = ["derive", "unicode"] } -crossterm = "0.23" +clap={version="3.2", features = ["derive", "unicode"] } +crossterm = "0.24" futures-util = "0.3" parking_lot = {version= "0.12"} -tokio = {version = "1.19", features=["full"]} +tokio = {version = "1.20", features=["full"]} tracing = "0.1" tracing-subscriber = "0.3" tui = "0.18" diff --git a/README.md b/README.md index c4e7f8e..09b1fd8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

-## Download & install +## Download & install See releases @@ -38,10 +38,23 @@ rm oxker_linux_x86_64.tar.gz oxker ```oxker``` +In application controls +| button| result| +|--|--| +| ```( tab )``` or ```( shift+tab )``` | Change panel, clicking on a panel also changes the selected panel| +| ```( ↑ ↓ )``` or ```( j k )``` or ```(PgUp PgDown)``` or ```(Home End)```| Change selected line in selected panel, mouse scroll also changes selected line | +| ```( enter )```| execute selected docker command| +| ```( 1-9 )``` | sort containers by heading, clicking on headings also sorts the selected column | +| ```( 0 )``` | stop sorting | +| ```( h )``` | Show help menu | +| ```( m )``` | toggle mouse capture - if disabled, text on screen can be selected| +| ```( q )``` | to quit at any time | + + available command line arguments | argument|result| |--|--| -|```-d [number > 0]```| set the update interval for docker information, in ms, defaults to 1000 (1 second) | +|```-d [number > 0]```| set the minimum update interval for docker information, in ms, defaults to 1000 (1 second) | |```-r```| Show raw logs, by default oxker will remove ANSI formatting (conflicts with -c) | |```-c```| Attempt to color the logs (conflicts with -r) | |```-t```| Remove timestamps from each log entry | diff --git a/create_release.sh b/create_release.sh index db8ccf8..50a90e9 100755 --- a/create_release.sh +++ b/create_release.sh @@ -183,7 +183,6 @@ cargo_test () { release_flow() { check_git get_git_remote_url - cargo fmt cargo_test cd "${CWD}" || error_close "Can't find ${CWD}" check_tag @@ -195,6 +194,7 @@ release_flow() { ask_changelog_update git checkout -b "$RELEASE_BRANCH" update_version_number_in_files + cargo fmt git add . git commit -m "chore: release $NEW_TAG_WITH_V" diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs index c8b241a..4dbb32b 100644 --- a/src/app_data/container_state.rs +++ b/src/app_data/container_state.rs @@ -5,6 +5,8 @@ use tui::{ widgets::{ListItem, ListState}, }; +use super::Header; + #[derive(Debug, Clone)] pub struct StatefulList { pub state: ListState, @@ -102,6 +104,18 @@ impl State { _ => Color::Red, } } + // Dirty way to create order for the state, rather than impl Ord + pub fn order(&self) -> &'static str { + match self { + Self::Running => "a", + Self::Paused => "b", + Self::Restarting => "c", + Self::Removing => "d", + Self::Exited => "e", + Self::Dead => "f", + Self::Unknown => "g", + } + } } impl From<&str> for State { @@ -304,8 +318,8 @@ pub struct ContainerItem { pub mem_limit: ByteStats, pub mem_stats: VecDeque, pub name: String, - pub net_rx: ByteStats, - pub net_tx: ByteStats, + pub rx: ByteStats, + pub tx: ByteStats, pub state: State, pub status: String, } @@ -328,8 +342,8 @@ impl ContainerItem { mem_limit: ByteStats::new(0), mem_stats: VecDeque::with_capacity(60), name, - net_rx: ByteStats::new(0), - net_tx: ByteStats::new(0), + rx: ByteStats::new(0), + tx: ByteStats::new(0), state, status, } @@ -397,31 +411,31 @@ impl ContainerItem { /// Container information panel headings + widths, for nice pretty formatting #[derive(Debug)] pub struct Columns { - pub state: (String, usize), - pub status: (String, usize), - pub cpu: (String, usize), - pub mem: (String, usize), - pub id: (String, usize), - pub name: (String, usize), - pub image: (String, usize), - pub net_rx: (String, usize), - pub net_tx: (String, usize), + pub state: (Header, usize), + pub status: (Header, usize), + pub cpu: (Header, usize), + pub mem: (Header, usize), + pub id: (Header, usize), + pub name: (Header, usize), + pub image: (Header, usize), + pub net_rx: (Header, usize), + pub net_tx: (Header, usize), } impl Columns { - //. (Column titles, minimum header string length) + // (Column titles, minimum header string length) pub fn new() -> Self { Self { - state: (String::from("state"), 11), - status: (String::from("status"), 16), + state: (Header::State, 11), + status: (Header::Status, 16), // 7 to allow for "100.00%" - cpu: (String::from("cpu"), 7), - mem: (String::from("memory/limit"), 12), - id: (String::from("id"), 8), - name: (String::from("name"), 4), - image: (String::from("image"), 5), - net_rx: (String::from("↓ rx"), 5), - net_tx: (String::from("↑ tx"), 5), + cpu: (Header::Cpu, 7), + mem: (Header::Memory, 12), + id: (Header::Id, 8), + name: (Header::Name, 4), + image: (Header::Image, 5), + net_rx: (Header::Rx, 5), + net_tx: (Header::Tx, 5), } } } diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index 5cec71a..e2ed0da 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -1,4 +1,5 @@ use bollard::models::ContainerSummary; +use core::fmt; use std::time::{SystemTime, UNIX_EPOCH}; use tui::widgets::ListItem; @@ -16,9 +17,63 @@ pub struct AppData { pub containers: StatefulList, pub init: bool, pub show_error: bool, + sorted_by: Option<(Header, SortedOrder)>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SortedOrder { + Asc, + Desc, +} + +#[derive(Debug, Clone, PartialEq, Hash, Eq)] +pub enum Header { + State, + Status, + Cpu, + Memory, + Id, + Name, + Image, + Rx, + Tx, +} + +/// Convert errors into strings to display +impl fmt::Display for Header { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let disp = match self { + Self::State => "state", + Self::Status => "status", + Self::Cpu => "cpu", + Self::Memory => "memory/limit", + Self::Id => "id", + Self::Name => "name", + Self::Image => "image", + Self::Rx => "↓ rx", + Self::Tx => "↑ tx", + }; + write!(f, "{:>x$}", disp, x = f.width().unwrap_or(1)) + } } impl AppData { + pub fn get_sorted(&self) -> Option<(Header, SortedOrder)> { + self.sorted_by.clone() + } + + /// Change the sorted order, also set the selected container state to match new order + pub fn set_sorted(&mut self, x: Option<(Header, SortedOrder)>) { + self.sorted_by = x; + let id = self.get_selected_container_id(); + self.sort_containers(); + self.containers.state.select( + self.containers + .items + .iter() + .position(|i| Some(i.id.to_owned()) == id), + ); + } /// Generate a default app_state pub fn default(args: CliArgs) -> Self { Self { @@ -28,6 +83,7 @@ impl AppData { init: false, logs_parsed: false, show_error: false, + sorted_by: None, } } @@ -118,6 +174,76 @@ impl AppData { output } + /// Sort the containers vec, based on a heading, either ascending or descending + pub fn sort_containers(&mut self) { + if let Some((head, so)) = self.sorted_by.as_ref() { + match head { + Header::State => match so { + SortedOrder::Desc => self + .containers + .items + .sort_by(|a, b| a.state.order().cmp(b.state.order())), + SortedOrder::Asc => self + .containers + .items + .sort_by(|a, b| b.state.order().cmp(a.state.order())), + }, + Header::Status => match so { + SortedOrder::Asc => self + .containers + .items + .sort_by(|a, b| a.status.cmp(&b.status)), + SortedOrder::Desc => self + .containers + .items + .sort_by(|a, b| b.status.cmp(&a.status)), + }, + Header::Cpu => match so { + SortedOrder::Asc => self + .containers + .items + .sort_by(|a, b| a.cpu_stats.back().cmp(&b.cpu_stats.back())), + SortedOrder::Desc => self + .containers + .items + .sort_by(|a, b| b.cpu_stats.back().cmp(&a.cpu_stats.back())), + }, + Header::Memory => match so { + SortedOrder::Asc => self + .containers + .items + .sort_by(|a, b| a.mem_stats.back().cmp(&b.mem_stats.back())), + SortedOrder::Desc => self + .containers + .items + .sort_by(|a, b| b.mem_stats.back().cmp(&a.mem_stats.back())), + }, + Header::Id => match so { + SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.id.cmp(&b.id)), + SortedOrder::Desc => self.containers.items.sort_by(|a, b| b.id.cmp(&a.id)), + }, + Header::Image => match so { + SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.image.cmp(&b.image)), + SortedOrder::Desc => { + self.containers.items.sort_by(|a, b| b.image.cmp(&a.image)) + } + }, + Header::Name => match so { + SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.name.cmp(&b.name)), + SortedOrder::Desc => self.containers.items.sort_by(|a, b| b.name.cmp(&a.name)), + }, + Header::Rx => match so { + SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.rx.cmp(&b.rx)), + SortedOrder::Desc => self.containers.items.sort_by(|a, b| b.rx.cmp(&a.rx)), + }, + Header::Tx => match so { + SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.tx.cmp(&b.tx)), + SortedOrder::Desc => self.containers.items.sort_by(|a, b| b.tx.cmp(&a.tx)), + }, + } + } + } + /// Find the index of the currently selected single log line pub fn get_selected_log_index(&self) -> Option { let mut output = None; @@ -203,8 +329,8 @@ impl AppData { container.mem_limit )); - let net_rx_count = count(&container.net_rx.to_string()); - let net_tx_count = count(&container.net_tx.to_string()); + let net_rx_count = count(&container.rx.to_string()); + let net_tx_count = count(&container.tx.to_string()); let image_count = count(&container.image); let name_count = count(&container.name); let state_count = count(&container.state.to_string()); @@ -277,8 +403,8 @@ impl AppData { container.mem_stats.push_back(ByteStats::new(mem)); } - container.net_rx.update(rx); - container.net_tx.update(tx); + container.rx.update(rx); + container.tx.update(tx); container.mem_limit.update(mem_limit); } } @@ -364,22 +490,27 @@ impl AppData { } } - /// update logs of a given container, based on index not id - pub fn update_log_by_index(&mut self, output: Vec, index: usize) { + /// update logs of a given container, based on id + pub fn update_log_by_id(&mut self, output: Vec, id: String) { let tz = self.get_systemtime(); - if let Some(container) = self.containers.items.get_mut(index) { + let color = self.args.color; + let raw = self.args.raw; + + if let Some(container) = self.get_container_by_id(&id) { container.last_updated = tz; let current_len = container.logs.items.len(); + output.iter().for_each(|i| { - let lines = if self.args.color { + let lines = if color { log_sanitizer::colorize_logs(i.to_owned()) - } else if self.args.raw { + } else if raw { log_sanitizer::raw(i.to_owned()) } else { log_sanitizer::remove_ansi(i.to_owned()) }; container.logs.items.push(ListItem::new(lines)); }); + if container.logs.state.selected().is_none() || container.logs.state.selected().unwrap_or_default() + 1 == current_len { @@ -388,11 +519,4 @@ impl AppData { } self.logs_parsed = true; } - - /// Update all containers logs, should only be used on first initialisation - pub fn update_all_logs(&mut self, all_logs: Vec>) { - for (index, output) in all_logs.into_iter().enumerate() { - self.update_log_by_index(output, index); - } - } } diff --git a/src/docker_data/message.rs b/src/docker_data/message.rs index 3d008f2..a730830 100644 --- a/src/docker_data/message.rs +++ b/src/docker_data/message.rs @@ -6,4 +6,5 @@ pub enum DockerMessage { Pause(String), Unpause(String), Stop(String), + Quit, } diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index 0776794..6cfe776 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -2,9 +2,15 @@ use bollard::{ container::{ListContainersOptions, LogsOptions, StartContainerOptions, Stats, StatsOptions}, Docker, }; -use futures_util::{future::join_all, StreamExt}; +use futures_util::StreamExt; use parking_lot::Mutex; -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; use tokio::{sync::mpsc::Receiver, task::JoinHandle}; use crate::{ @@ -21,7 +27,9 @@ pub struct DockerData { docker: Arc, gui_state: Arc>, initialised: bool, + is_running: Arc, receiver: Receiver, + spawns: Arc>>>, timestamps: bool, } @@ -55,11 +63,13 @@ impl DockerData { /// Get a single docker stat in order to update mem and cpu usage /// don't take &self, so that can tokio::spawn into it's own thread + /// remove if from spawns hashmap when complete async fn update_container_stat( docker: Arc, id: String, app_data: Arc>, is_running: bool, + spawns: Arc>>>, ) { let mut stream = docker .stats( @@ -107,6 +117,7 @@ impl DockerData { .lock() .update_stats(id.clone(), None, None, mem_limit, rx, tx); } + spawns.lock().remove(&id); } } @@ -115,17 +126,26 @@ impl DockerData { for (is_running, id) in all_ids.iter() { let docker = Arc::clone(&self.docker); let app_data = Arc::clone(&self.app_data); + let spawns = Arc::clone(&self.spawns); let is_running = *is_running; let id = id.to_owned(); - tokio::spawn(Self::update_container_stat( - docker, id, app_data, is_running, + + let spawn_contains_id = spawns.lock().contains_key(&id); + let s = tokio::spawn(Self::update_container_stat( + docker, + id.to_owned(), + app_data, + is_running, + spawns, )); + if !spawn_contains_id { + self.spawns.lock().insert(id, s); + } } } /// Get all current containers, handle into ContainerItem in the app_data struct rather than here /// Just make sure that items sent are guaranteed to have an id - /// return Vec<(is_running, id)> pub async fn update_all_containers(&mut self) -> Vec<(bool, String)> { let containers = self .docker @@ -144,6 +164,10 @@ impl DockerData { .for_each(|c| output.push(c.to_owned())); self.app_data.lock().update_containers(&output); + + let current_sort = self.app_data.lock().get_sorted(); + self.app_data.lock().set_sorted(current_sort); + output .iter() .filter_map(|i| { @@ -159,12 +183,15 @@ impl DockerData { /// Update single container logs /// don't take &self, so that can tokio::spawn into it's own thread + /// remove if from spawns hashmap when complete async fn update_log( docker: Arc, id: String, timestamps: bool, since: i64, - ) -> Vec { + app_data: Arc>, + spawns: Arc>>>, + ) { let options = Some(LogsOptions:: { stdout: true, timestamps, @@ -184,21 +211,25 @@ impl DockerData { } } } - output + spawns.lock().remove(&id); + app_data.lock().update_log_by_id(output, id.to_owned()); } /// Update all logs, spawn each container into own tokio::spawn thread async fn init_all_logs(&mut self, all_ids: &[(bool, String)]) { - let mut handles = vec![]; - for (_, id) in all_ids.iter() { let docker = Arc::clone(&self.docker); let timestamps = self.timestamps; let id = id.to_owned(); - handles.push(Self::update_log(docker, id, timestamps, 0)); + let app_data = Arc::clone(&self.app_data); + let spawns = Arc::clone(&self.spawns); + self.spawns.lock().insert( + id.to_owned(), + tokio::spawn(Self::update_log( + docker, id, timestamps, 0, app_data, spawns, + )), + ); } - let all_logs = join_all(handles).await; - self.app_data.lock().update_all_logs(all_logs); } async fn update_everything(&mut self) { @@ -206,11 +237,26 @@ impl DockerData { let optional_index = self.app_data.lock().get_selected_log_index(); if let Some(index) = optional_index { let id = self.app_data.lock().containers.items[index].id.to_owned(); - let since = self.app_data.lock().containers.items[index].last_updated as i64; - let docker = Arc::clone(&self.docker); - let timestamps = self.timestamps; - let logs = Self::update_log(docker, id, timestamps, since).await; - self.app_data.lock().update_log_by_index(logs, index); + + let running = self.spawns.lock().contains_key(&id); + + if !running { + let since = self.app_data.lock().containers.items[index].last_updated as i64; + let docker = Arc::clone(&self.docker); + let timestamps = self.timestamps; + + let app_data = Arc::clone(&self.app_data); + let spawns = Arc::clone(&self.spawns); + let s = tokio::spawn(Self::update_log( + docker, + id.to_owned(), + timestamps, + since, + app_data, + spawns, + )); + self.spawns.lock().insert(id, s); + } }; self.update_all_container_stats(&all_ids).await; @@ -315,6 +361,14 @@ impl DockerData { self.update_everything().await } DockerMessage::Update => self.update_everything().await, + DockerMessage::Quit => { + self.spawns + .lock() + .values() + .into_iter() + .for_each(|i| i.abort()); + self.is_running.store(false, Ordering::SeqCst); + } } } } @@ -326,6 +380,7 @@ impl DockerData { docker: Arc, gui_state: Arc>, receiver: Receiver, + is_running: Arc, ) { if app_data.lock().get_error().is_none() { let mut inner = Self { @@ -334,7 +389,9 @@ impl DockerData { gui_state, initialised: false, receiver, + spawns: Arc::new(Mutex::new(HashMap::new())), timestamps: args.timestamp, + is_running, }; inner.initialise_container_data().await; diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index ec613f0..9bbdbcc 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -18,7 +18,7 @@ use tui::layout::Rect; mod message; use crate::{ - app_data::{AppData, DockerControls}, + app_data::{AppData, DockerControls, Header, SortedOrder}, app_error::AppError, docker_data::DockerMessage, ui::{GuiState, SelectablePanel}, @@ -77,6 +77,7 @@ impl InputHandler { } } + /// Mouse button fn m_button(&mut self) { if self.mouse_capture { match execute!(std::io::stdout(), DisableMouseCapture) { @@ -115,6 +116,26 @@ impl InputHandler { self.mouse_capture = !self.mouse_capture; } + /// Sort containers based on a given header, switch asc to desc if already sorted, else always desc + fn sort(&self, header: Header) { + let mut output = Some((header.to_owned(), SortedOrder::Desc)); + let mut locked_data = self.app_data.lock(); + if let Some((h, order)) = locked_data.get_sorted().as_ref() { + if &SortedOrder::Desc == order && h == &header { + output = Some((header, SortedOrder::Asc)) + } + } + locked_data.set_sorted(output) + } + + /// Send a quit message to docker, to abort all spawns, if error, quit here instead + async fn quit(&self) { + match self.docker_sender.send(DockerMessage::Quit).await { + Ok(_) => (), + Err(_) => self.is_running.store(false, Ordering::SeqCst), + } + } + /// Handle any keyboard button events async fn button_press(&mut self, key_code: KeyCode) { let show_error = self.app_data.lock().show_error; @@ -122,9 +143,7 @@ impl InputHandler { if show_error { match key_code { - KeyCode::Char('q') => { - self.is_running.store(false, Ordering::SeqCst); - } + KeyCode::Char('q') => self.quit().await, KeyCode::Char('c') => { self.app_data.lock().show_error = false; self.app_data.lock().remove_error(); @@ -133,18 +152,54 @@ impl InputHandler { } } else if show_info { match key_code { - KeyCode::Char('q') => self.is_running.store(false, Ordering::SeqCst), + KeyCode::Char('q') => self.quit().await, KeyCode::Char('h') => self.gui_state.lock().show_help = false, KeyCode::Char('m') => self.m_button(), _ => (), } } else { match key_code { - KeyCode::Char('q') => self.is_running.store(false, Ordering::SeqCst), + KeyCode::Char('0') => self.app_data.lock().set_sorted(None), + KeyCode::Char('1') => self.sort(Header::State), + KeyCode::Char('2') => self.sort(Header::Status), + KeyCode::Char('3') => self.sort(Header::Cpu), + KeyCode::Char('4') => self.sort(Header::Memory), + KeyCode::Char('5') => self.sort(Header::Id), + KeyCode::Char('6') => self.sort(Header::Name), + KeyCode::Char('7') => self.sort(Header::Image), + KeyCode::Char('8') => self.sort(Header::Rx), + KeyCode::Char('9') => self.sort(Header::Tx), + KeyCode::Char('q') => self.quit().await, KeyCode::Char('h') => self.gui_state.lock().show_help = true, KeyCode::Char('m') => self.m_button(), - KeyCode::Tab => self.gui_state.lock().next_panel(), - KeyCode::BackTab => self.gui_state.lock().previous_panel(), + KeyCode::Tab => { + // Skip control panel if no containers, could be refactored + let has_containers = self.app_data.lock().get_container_len() == 0; + let is_containers = + self.gui_state.lock().selected_panel == SelectablePanel::Containers; + let count = if has_containers && is_containers { + 2 + } else { + 1 + }; + for _ in 0..count { + self.gui_state.lock().next_panel(); + } + } + KeyCode::BackTab => { + // Skip control panel if no containers, could be refactored + let has_containers = self.app_data.lock().get_container_len() == 0; + let is_containers = + self.gui_state.lock().selected_panel == SelectablePanel::Logs; + let count = if has_containers && is_containers { + 2 + } else { + 1 + }; + for _ in 0..count { + self.gui_state.lock().previous_panel(); + } + } KeyCode::Home => { let mut locked_data = self.app_data.lock(); match self.gui_state.lock().selected_panel { @@ -224,7 +279,18 @@ impl InputHandler { MouseEventKind::ScrollUp => self.previous(), MouseEventKind::ScrollDown => self.next(), MouseEventKind::Down(MouseButton::Left) => { - self.gui_state.lock().rect_insersects(Rect::new( + let header_intersects = self.gui_state.lock().header_intersect(Rect::new( + mouse_event.column, + mouse_event.row, + 1, + 1, + )); + + if let Some(header) = header_intersects { + self.sort(header); + } + + self.gui_state.lock().panel_intersect(Rect::new( mouse_event.column, mouse_event.row, 1, @@ -235,7 +301,7 @@ impl InputHandler { } } - /// Change state of selected container + /// Change state to next, depending which panel is currently in focus fn next(&mut self) { let mut locked_data = self.app_data.lock(); match self.gui_state.lock().selected_panel { @@ -245,7 +311,7 @@ impl InputHandler { }; } - /// Change state of selected container + /// Change state to previous, depending which panel is currently in focus fn previous(&mut self) { let mut locked_data = self.app_data.lock(); match self.gui_state.lock().selected_panel { diff --git a/src/main.rs b/src/main.rs index 8a5e9df..30c2141 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ async fn main() { let args = CliArgs::new(); let app_data = Arc::new(Mutex::new(AppData::default(args.clone()))); let gui_state = Arc::new(Mutex::new(GuiState::default())); + let is_running = Arc::new(AtomicBool::new(true)); let docker_args = args.clone(); let docker_app_data = Arc::clone(&app_data); @@ -38,12 +39,14 @@ async fn main() { match docker.ping().await { Ok(_) => { let docker = Arc::clone(&docker); + let is_running = Arc::clone(&is_running); tokio::spawn(DockerData::init( docker_args, docker_app_data, docker, docker_gui_state, docker_rx, + is_running, )); } Err(_) => app_data.lock().set_error(AppError::DockerConnect), @@ -53,7 +56,6 @@ async fn main() { let (input_sx, input_rx) = tokio::sync::mpsc::channel(16); - let is_running = Arc::new(AtomicBool::new(true)); let input_is_running = Arc::clone(&is_running); let input_gui_state = Arc::clone(&gui_state); let input_docker_sender = docker_sx.clone(); diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs index 4a2135d..3e4b86c 100644 --- a/src/ui/draw_blocks.rs +++ b/src/ui/draw_blocks.rs @@ -14,12 +14,13 @@ use tui::{ Frame, }; +use crate::app_data::{Header, SortedOrder}; use crate::{ app_data::{AppData, ByteStats, Columns, CpuStats, State, Stats}, app_error::AppError, }; -use super::gui_state::BoxLocation; +use super::gui_state::{BoxLocation, Region}; use super::{GuiState, SelectablePanel}; const NAME_TEXT: &str = r#" @@ -47,7 +48,7 @@ fn generate_block<'a>( gui_state: &Arc>, panel: SelectablePanel, ) -> Block<'a> { - gui_state.lock().insert_into_area_map(panel, area); + gui_state.lock().update_map(Region::Panel(panel), area); let mut block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded); @@ -145,7 +146,7 @@ pub fn draw_containers( state_style, ), Span::styled( - format!("{}{:>width$}", MARGIN, i.status, width = widths.status.1), + format!("{}{:>width$}", MARGIN, i.status, width = &widths.status.1), state_style, ), Span::styled( @@ -153,12 +154,12 @@ pub fn draw_containers( "{}{:>width$}", MARGIN, i.cpu_stats.back().unwrap_or(&CpuStats::new(0.0)), - width = widths.cpu.1 + width = &widths.cpu.1 ), state_style, ), Span::styled( - format!("{}{:>width$}", MARGIN, mems, width = widths.mem.1), + format!("{}{:>width$}", MARGIN, mems, width = &widths.mem.1), state_style, ), Span::styled( @@ -166,7 +167,7 @@ pub fn draw_containers( "{}{:>width$}", MARGIN, i.id.chars().take(8).collect::(), - width = widths.id.1 + width = &widths.id.1 ), blue, ), @@ -179,18 +180,17 @@ pub fn draw_containers( blue, ), Span::styled( - format!("{}{:>width$}", MARGIN, i.net_rx, width = widths.net_rx.1), + format!("{}{:>width$}", MARGIN, i.rx, width = widths.net_rx.1), Style::default().fg(Color::Rgb(255, 233, 193)), ), Span::styled( - format!("{}{:>width$}", MARGIN, i.net_tx, width = widths.net_tx.1), + format!("{}{:>width$}", MARGIN, i.tx, width = widths.net_tx.1), Style::default().fg(Color::Rgb(205, 140, 140)), ), ]); ListItem::new(lines) }) .collect::>(); - if items.is_empty() { let debug_text = String::from("no containers running"); let paragraph = Paragraph::new(debug_text) @@ -357,81 +357,104 @@ pub fn draw_heading_bar( f: &mut Frame<'_, B>, has_containers: bool, loading_icon: String, - info_visible: bool, + sorted_by: Option<(Header, SortedOrder)>, + gui_state: &Arc>, ) { let block = || Block::default().style(Style::default().bg(Color::Magenta).fg(Color::Black)); + let info_visible = gui_state.lock().show_help; f.render_widget(block(), area); - let mut column_headings = format!( - " {}{:>width$}", - loading_icon, - columns.state.0, - width = columns.state.1 - ); - column_headings.push_str( - format!( - "{} {:>width$}", - MARGIN, - columns.status.0, - width = columns.status.1 + // Generate a bloack for the header, if the header is currently being used to sort a column, then highlight it white + let header_block = |x: &Header| { + let mut color = Color::Black; + let mut suffix = ""; + let mut suffix_margin = 0; + if let Some((a, b)) = sorted_by.as_ref() { + if x == a { + match b { + SortedOrder::Asc => suffix = " ⌃", + SortedOrder::Desc => suffix = " ⌄", + } + suffix_margin = 2; + color = Color::White + }; + }; + ( + Block::default().style(Style::default().bg(Color::Magenta).fg(color)), + suffix, + suffix_margin, ) - .as_str(), - ); - column_headings - .push_str(format!("{}{:>width$}", MARGIN, columns.cpu.0, width = columns.cpu.1).as_str()); - column_headings - .push_str(format!("{}{:>width$}", MARGIN, columns.mem.0, width = columns.mem.1).as_str()); - column_headings - .push_str(format!("{}{:>width$}", MARGIN, columns.id.0, width = columns.id.1).as_str()); - column_headings.push_str( - format!( - "{}{:>width$}", - MARGIN, - columns.name.0, - width = columns.name.1 - ) - .as_str(), - ); - column_headings.push_str( - format!( - "{}{:>width$}", - MARGIN, - columns.image.0, - width = columns.image.1 - ) - .as_str(), - ); - column_headings.push_str( - format!( - "{}{:>width$}", - MARGIN, - columns.net_rx.0, - width = columns.net_rx.1 - ) - .as_str(), - ); - column_headings.push_str( - format!( - "{}{:>width$}", - MARGIN, - columns.net_tx.0, - width = columns.net_tx.1 - ) - .as_str(), - ); + }; + + // Generate block for the headers, state and status has a specific layout, others all equal + // width is dependant on it that column is selected to sort - or not + let gen_header = |header: &Header, width: usize| { + let block = header_block(header); + let text = match header { + Header::State => format!( + " {}{:>width$}{ic}", + loading_icon, + header, + ic = block.1, + width = width - block.2, + ), + Header::Status => format!( + "{} {:>width$}{ic}", + MARGIN, + header, + ic = block.1, + width = width - block.2 + ), + + _ => format!( + "{}{:>width$}{ic}", + MARGIN, + header, + ic = block.1, + width = width - block.2 + ), + }; + let count = text.chars().count() as u16; + let status = Paragraph::new(text) + .block(block.0) + .alignment(Alignment::Left); + (status, count) + }; + + // Meta data for iterate over to create blocks and correct widths + let header_meta = [ + (Header::State, columns.state.1), + (Header::Status, columns.status.1), + (Header::Cpu, columns.cpu.1), + (Header::Memory, columns.mem.1), + (Header::Id, columns.id.1), + (Header::Name, columns.name.1), + (Header::Image, columns.image.1), + (Header::Rx, columns.net_rx.1), + (Header::Tx, columns.net_tx.1), + ]; + + let header_data = header_meta + .iter() + .map(|i| { + let header_block = gen_header(&i.0, i.1); + ( + header_block.0, + i.0.to_owned(), + Constraint::Max(header_block.1), + ) + }) + .collect::>(); let suffix = if info_visible { "exit" } else { "show" }; - let info_text = format!("( h ) to {} help {}", suffix, MARGIN); - let info_width = info_text.chars().count(); - - let column_width = column_headings.chars().count(); + let info_text = format!("( h ) {} help {}", suffix, MARGIN); + let info_width = info_text.chars().count() as u16; + let column_width = area.width - info_width; + let column_width = if column_width > 0 { column_width } else { 1 }; let splits = if has_containers { - vec![ - Constraint::Min(column_width as u16), - Constraint::Min(info_width as u16), - ] + vec![Constraint::Min(column_width), Constraint::Min(info_width)] } else { vec![Constraint::Percentage(100)] }; @@ -440,12 +463,20 @@ pub fn draw_heading_bar( .direction(Direction::Horizontal) .constraints(splits.as_ref()) .split(area); - if has_containers { - let paragraph = Paragraph::new(column_headings) - .block(block()) - .alignment(Alignment::Left); - f.render_widget(paragraph, split_bar[0]); + let container_splits = header_data.iter().map(|i| i.2).collect::>(); + + let headers_section = Layout::default() + .direction(Direction::Horizontal) + .constraints(container_splits.as_ref()) + .split(split_bar[0]); + + // draw the actual header blocks + for (index, (paragraph, header, _)) in header_data.into_iter().enumerate() { + let rect = headers_section[index]; + gui_state.lock().update_map(Region::Header(header), rect); + f.render_widget(paragraph, rect); + } } let paragraph = Paragraph::new(info_text) @@ -479,6 +510,8 @@ pub fn draw_help_box(f: &mut Frame<'_, B>) { .push_str("\n ( ↑ ↓ ) or ( j k ) or (PgUp PgDown) or (Home End) to change selected line"); help_text.push_str("\n ( enter ) to send docker container commands"); help_text.push_str("\n ( h ) to toggle this help information"); + help_text.push_str("\n ( 0 ) stop sort"); + help_text.push_str("\n ( 1 - 9 ) sort by header - or click header"); help_text.push_str( "\n ( m ) to toggle mouse capture - if disabled, text on screen can be selected & copied", ); diff --git a/src/ui/gui_state.rs b/src/ui/gui_state.rs index 97983da..34add3e 100644 --- a/src/ui/gui_state.rs +++ b/src/ui/gui_state.rs @@ -1,6 +1,8 @@ use std::{collections::HashMap, fmt}; use tui::layout::{Constraint, Rect}; +use crate::app_data::Header; + #[derive(Debug, PartialEq, std::hash::Hash, std::cmp::Eq, Clone, Copy)] pub enum SelectablePanel { Containers, @@ -8,6 +10,11 @@ pub enum SelectablePanel { Logs, } +pub enum Region { + Panel(SelectablePanel), + Header(Header), +} + #[allow(unused)] #[derive(Debug, Clone, Copy)] pub enum BoxLocation { @@ -37,7 +44,7 @@ impl BoxLocation { } } - // Should combine and just return a tupple? + // Should combine and just return a tuple? pub fn get_horizontal_constraints( &self, blank_vertical: u16, @@ -165,7 +172,8 @@ pub struct GuiState { // Think this should be a BMapTree, so can define order when iterating over potential intersects // Is an issue if two panels are in the same space, sush as a smaller panel embedded, yet infront of, a larger panel // If a BMapTree think it would mean have to implement ordering for SelectablePanel - area_map: HashMap, + panel_map: HashMap, + heading_map: HashMap, loading_icon: Loading, // Should be a vec, each time loading add a new to the vec, and reset remove from vec // for for if is_loading just check if vec is empty or not @@ -174,12 +182,12 @@ pub struct GuiState { pub show_help: bool, pub info_box_text: Option, } - impl GuiState { /// Generate a default gui_state pub fn default() -> Self { Self { - area_map: HashMap::new(), + panel_map: HashMap::new(), + heading_map: HashMap::new(), loading_icon: Loading::One, selected_panel: SelectablePanel::Containers, show_help: false, @@ -190,13 +198,13 @@ impl GuiState { /// clear panels hash map, so on resize can fix the sizes for mouse clicks pub fn clear_area_map(&mut self) { - self.area_map.clear(); + self.panel_map.clear(); } /// Check if a given Rect (a clicked area of 1x1), interacts with any known panels - pub fn rect_insersects(&mut self, rect: Rect) { + pub fn panel_intersect(&mut self, rect: Rect) { if let Some(data) = self - .area_map + .panel_map .iter() .filter(|i| i.1.intersects(rect)) .collect::>() @@ -206,9 +214,30 @@ impl GuiState { } } - /// Insert selectable gui panel into area map - pub fn insert_into_area_map(&mut self, panel: SelectablePanel, area: Rect) { - self.area_map.entry(panel).or_insert(area); + /// Check if a given Rect (a clicked area of 1x1), interacts with any known panels + pub fn header_intersect(&mut self, rect: Rect) -> Option
{ + self.heading_map + .iter() + .filter(|i| i.1.intersects(rect)) + .collect::>() + .get(0) + .map(|data| data.0.to_owned()) + } + + /// Insert, or updatem header area panel into heading_map + pub fn update_map(&mut self, region: Region, area: Rect) { + match region { + Region::Header(header) => self + .heading_map + .entry(header) + .and_modify(|w| *w = area) + .or_insert(area), + Region::Panel(panel) => self + .panel_map + .entry(panel) + .and_modify(|w| *w = area) + .or_insert(area), + }; } /// Change to next selectable panel diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8a41d72..8b7504a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -154,6 +154,8 @@ fn ui( let has_containers = !app_data.lock().containers.items.is_empty(); let has_error = app_data.lock().get_error(); let log_index = app_data.lock().get_selected_log_index(); + let sorted_by = app_data.lock().get_sorted(); + let show_help = gui_state.lock().show_help; let info_text = gui_state.lock().info_box_text.clone(); let loading_icon = gui_state.lock().get_loading(); @@ -213,7 +215,8 @@ fn ui( f, has_containers, loading_icon, - show_help, + sorted_by, + gui_state, ); // only draw charts if there are containers