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