diff --git a/Cargo.lock b/Cargo.lock index 2dacf6b..b388a8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,6 +322,27 @@ dependencies = [ "serde", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "either" version = "1.9.0" @@ -639,6 +660,17 @@ version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.4.1", + "libc", + "redox_syscall", +] + [[package]] name = "lock_api" version = "0.4.11" @@ -735,6 +767,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -750,6 +788,7 @@ dependencies = [ "cansi", "clap", "crossterm", + "directories", "futures-util", "parking_lot", "ratatui", @@ -913,6 +952,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "rustc-demangle" version = "0.1.23" diff --git a/Cargo.toml b/Cargo.toml index 4f8c703..e938ac5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ tracing = "0.1" tracing-subscriber = "0.3" ratatui = "0.24" uuid = { version = "1.5", features = ["v4", "fast-rng"] } +directories = "5.0.1" [dev-dependencies] diff --git a/README.md b/README.md index fd6f0d5..00f8b97 100644 --- a/README.md +++ b/README.md @@ -92,28 +92,30 @@ 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 | -| ```( e )``` | (attempt) to exec into the selected container | -| ```( h )``` | toggle help menu | -| ```( m )``` | toggle mouse capture - if disabled, text on screen can be selected| -| ```( q )``` | to quit at any time | +| ```( 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 )```| Run selected docker command.| +| ```( 1-9 )``` | Sort containers by heading, clicking on headings also sorts the selected column. | +| ```( 0 )``` | Stop sorting.| +| ```( e )``` | Attempt to exec into the selected container.| +| ```( h )``` | Toggle help menu.| +| ```( m )``` | Toggle mouse capture - if disabled, text on screen can be selected.| +| ```( q )``` | Quit.| +| ```( s )``` | Save logs to `$HOME/[container_name]_[timestamp].log`, or the directory set by `--logs-dir`.| Available command line arguments | argument|result| |--|--| |```-d [number > 0]```| Set the minimum update interval for docker information in milliseconds. Defaults to 1000 (1 second).| -|```--host [hostname]```| Connect to Docker with a custom hostname. Defaults to `/var/run/docker.sock`. Will use `$DOCKER_HOST` environment variable if set.| -|```--use-cli```| When executing into a container, use the external Docker CLI application.| |```-r```| Show raw logs. By default, removes ANSI formatting (conflicts with `-c`).| |```-c```| Attempt to color the logs (conflicts with `-r`).| |```-t```| Remove timestamps from each log entry.| |```-s```| If running via Docker, will display the oxker container.| |```-g```| No TUI, essentially a debugging mode with limited functionality, for now.| +|```--host [hostname]```| Connect to Docker with a custom hostname. Defaults to `/var/run/docker.sock`. Will use `$DOCKER_HOST` environment variable if set.| +|```--use-cli```| When executing into a container, use the external Docker CLI application.| +|```--logs-dir```| Set a custom location to save exportings logs into. Defaults to `$HOME`.| ## Build step diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index 7695058..22d9a17 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -510,9 +510,9 @@ impl AppData { } /// Get the Id and State for the currently selected container - used by the exec check method - pub fn get_selected_container_id_state(&self) -> Option<(ContainerId, State)> { + pub fn get_selected_container_id_state_name(&self) -> Option<(ContainerId, State, String)> { self.get_selected_container() - .map(|i| (i.id.clone(), i.state)) + .map(|i| (i.id.clone(), i.state, i.name.clone())) } /// Update container mem, cpu, & network stats, in single function so only need to call .lock() once diff --git a/src/app_error.rs b/src/app_error.rs index e001645..ba0d66f 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -7,6 +7,7 @@ use std::fmt; pub enum AppError { DockerCommand(DockerControls), DockerExec, + DockerLogs, DockerConnect, DockerInterval, InputPoll, @@ -20,6 +21,7 @@ impl fmt::Display for AppError { match self { Self::DockerCommand(s) => write!(f, "Unable to {s} container"), Self::DockerExec => write!(f, "Unable to exec into container"), + Self::DockerLogs => write!(f, "Unable to save logs"), Self::DockerConnect => write!(f, "Unable to access docker daemon"), Self::DockerInterval => write!(f, "Docker update interval needs to be greater than 0"), Self::InputPoll => write!(f, "Unable to poll user input"), diff --git a/src/exec.rs b/src/exec.rs index db37bff..6a36d94 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -133,9 +133,9 @@ impl ExecMode { } let use_cli = app_data.lock().args.use_cli; - let container = app_data.lock().get_selected_container_id_state(); + let container = app_data.lock().get_selected_container_id_state_name(); - if let Some((id, state)) = container { + if let Some((id, state, _)) = container { if state == State::Running { if tty_readable() && !use_cli { if let Ok(exec) = docker diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index aaf812d..6c7e52d 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -1,13 +1,21 @@ -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, +use std::{ + fs::OpenOptions, + io::{BufWriter, Write}, + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::SystemTime, }; -use bollard::Docker; +use bollard::{container::LogsOptions, Docker}; +use cansi::v3::categorise_text; use crossterm::{ event::{DisableMouseCapture, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}, execute, }; +use futures_util::StreamExt; use parking_lot::Mutex; use ratatui::layout::Rect; use tokio::{ @@ -87,48 +95,6 @@ impl InputHandler { } } - /// Toggle the mouse capture (via input of the 'm' key) - fn m_key(&mut self) { - if self.mouse_capture { - if execute!(std::io::stdout(), DisableMouseCapture).is_ok() { - self.gui_state - .lock() - .set_info_box("✖ mouse capture disabled"); - } else { - self.app_data.lock().set_error( - AppError::MouseCapture(false), - &self.gui_state, - Status::Error, - ); - } - } else if Ui::enable_mouse_capture().is_ok() { - self.gui_state - .lock() - .set_info_box("✓ mouse capture enabled"); - } else { - self.app_data.lock().set_error( - AppError::MouseCapture(true), - &self.gui_state, - Status::Error, - ); - }; - - // If the info box sleep handle is currently being executed, as in 'm' is pressed twice within a 4000ms window - // then cancel the first handle, as a new handle will be invoked - if let Some(info_sleep_timer) = self.info_sleep.as_ref() { - info_sleep_timer.abort(); - } - - let gui_state = Arc::clone(&self.gui_state); - // Show the info box - with "mouse capture enabled / disabled", for 4000 ms - self.info_sleep = Some(tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(4000)).await; - gui_state.lock().reset_info_box(); - })); - - self.mouse_capture = !self.mouse_capture; - } - /// Sort the containers by a given header fn sort(&self, selected_header: Header) { self.app_data.lock().set_sort_by_header(selected_header); @@ -191,9 +157,216 @@ impl InputHandler { } } - /// Handle any keyboard button events - // TODO refactor this - #[allow(clippy::too_many_lines)] + /// Toggle the mouse capture (via input of the 'm' key) + fn m_key(&mut self) { + if self.mouse_capture { + if execute!(std::io::stdout(), DisableMouseCapture).is_ok() { + self.gui_state + .lock() + .set_info_box("✖ mouse capture disabled"); + } else { + self.app_data.lock().set_error( + AppError::MouseCapture(false), + &self.gui_state, + Status::Error, + ); + } + } else if Ui::enable_mouse_capture().is_ok() { + self.gui_state + .lock() + .set_info_box("✓ mouse capture enabled"); + } else { + self.app_data.lock().set_error( + AppError::MouseCapture(true), + &self.gui_state, + Status::Error, + ); + }; + + self.mouse_capture = !self.mouse_capture; + } + + /// Save the currently selected containers logs into a `[container_name]_[timestamp].log` file + async fn s_key(&mut self) { + /// This is the inner workings, *inlined* here to return a Result + async fn save_logs( + app_data: &Arc>, + gui_state: &Arc>, + docker_sender: &Sender, + ) -> Result<(), Box> { + let args = app_data.lock().args.clone(); + let container = app_data.lock().get_selected_container_id_state_name(); + if let Some((id, _, name)) = container { + if let Some(log_path) = args.logs_dir { + let (sx, rx) = tokio::sync::oneshot::channel::>(); + docker_sender.send(DockerMessage::Exec(sx)).await?; + + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |i| i.as_secs()); + + let path = log_path.join(format!("{name}_{now}.log")); + + let docker = rx.await?; + let options = Some(LogsOptions:: { + stdout: true, + timestamps: args.timestamp, + since: 0, + ..Default::default() + }); + let mut logs = docker.logs(id.get(), options); + let mut output = vec![]; + + while let Some(Ok(value)) = logs.next().await { + let data = value.to_string(); + if !data.trim().is_empty() { + output.push( + categorise_text(&data) + .into_iter() + .map(|i| i.text) + .collect::(), + ); + } + } + if !output.is_empty() { + let mut stream = BufWriter::new( + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path)?, + ); + + for line in &output { + stream.write_all(line.as_bytes())?; + } + stream.flush()?; + + gui_state + .lock() + .set_info_box(&format!("logs saved to {}", path.display())); + } + } + } + Ok(()) + } + + let log_status = Status::Logs; + let status = self.gui_state.lock().status_contains(&[log_status]); + if !status { + self.gui_state.lock().status_push(log_status); + + let uuid = Uuid::new_v4(); + let handle = GuiState::start_loading_animation(&self.gui_state, uuid); + if save_logs(&self.app_data, &self.gui_state, &self.docker_sender) + .await + .is_err() + { + self.app_data.lock().set_error( + AppError::DockerLogs, + &self.gui_state, + Status::Error, + ); + } + self.gui_state.lock().status_del(log_status); + self.gui_state.lock().stop_loading_animation(&handle, uuid); + } + } + + /// Send docker command, if the Commands panel is selected + async fn enter_key(&mut self) { + // This isn't great, just means you can't send docker commands before full initialization of the program + let panel = self.gui_state.lock().get_selected_panel(); + if panel == SelectablePanel::Commands { + let option_command = self.app_data.lock().selected_docker_command(); + + if let Some(command) = option_command { + // Poor way of disallowing commands to be sent to a containerised okxer + if self.app_data.lock().is_oxker() { + return; + }; + let option_id = self.app_data.lock().get_selected_container_id(); + if let Some(id) = option_id { + match command { + DockerControls::Delete => self + .docker_sender + .send(DockerMessage::ConfirmDelete(id)) + .await + .ok(), + DockerControls::Pause => { + self.docker_sender.send(DockerMessage::Pause(id)).await.ok() + } + DockerControls::Unpause => self + .docker_sender + .send(DockerMessage::Unpause(id)) + .await + .ok(), + DockerControls::Start => { + self.docker_sender.send(DockerMessage::Start(id)).await.ok() + } + DockerControls::Stop => { + self.docker_sender.send(DockerMessage::Stop(id)).await.ok() + } + DockerControls::Restart => self + .docker_sender + .send(DockerMessage::Restart(id)) + .await + .ok(), + }; + } + } + } + } + + /// Change the the "next" seletable panel + fn tab_key(&mut self) { + let is_containers = + self.gui_state.lock().get_selected_panel() == SelectablePanel::Containers; + let count = if self.app_data.lock().get_container_len() == 0 && is_containers { + 2 + } else { + 1 + }; + for _ in 0..count { + self.gui_state.lock().next_panel(); + } + } + + /// Change to previously selected panel + fn back_tab_key(&mut self) { + let is_containers = self.gui_state.lock().get_selected_panel() == SelectablePanel::Logs; + let count = if self.app_data.lock().get_container_len() == 0 && is_containers { + 2 + } else { + 1 + }; + for _ in 0..count { + self.gui_state.lock().previous_panel(); + } + } + + fn home_key(&mut self) { + let mut locked_data = self.app_data.lock(); + let selected_panel = self.gui_state.lock().get_selected_panel(); + match selected_panel { + SelectablePanel::Containers => locked_data.containers_start(), + SelectablePanel::Logs => locked_data.log_start(), + SelectablePanel::Commands => locked_data.docker_command_start(), + } + } + + /// Go to end of the list of the currently selected panel + fn end_key(&mut self) { + let mut locked_data = self.app_data.lock(); + let selected_panel = self.gui_state.lock().get_selected_panel(); + match selected_panel { + SelectablePanel::Containers => locked_data.containers_end(), + SelectablePanel::Logs => locked_data.log_end(), + SelectablePanel::Commands => locked_data.docker_command_end(), + } + } + + /// Handle keyboard button events async fn button_press(&mut self, key_code: KeyCode, key_modififer: KeyModifiers) { let contains_delete = self .gui_state @@ -246,52 +419,11 @@ impl InputHandler { KeyCode::Char('e' | 'E') => self.e_key().await, KeyCode::Char('h' | 'H') => self.gui_state.lock().status_push(Status::Help), KeyCode::Char('m' | 'M') => self.m_key(), - KeyCode::Tab => { - // Skip control panel if no containers, could be refactored - let is_containers = self.gui_state.lock().get_selected_panel() - == SelectablePanel::Containers; - let count = - if self.app_data.lock().get_container_len() == 0 && 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 is_containers = - self.gui_state.lock().get_selected_panel() == SelectablePanel::Logs; - let count = - if self.app_data.lock().get_container_len() == 0 && 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(); - let selected_panel = self.gui_state.lock().get_selected_panel(); - match selected_panel { - SelectablePanel::Containers => locked_data.containers_start(), - SelectablePanel::Logs => locked_data.log_start(), - SelectablePanel::Commands => locked_data.docker_command_start(), - } - } - KeyCode::End => { - let mut locked_data = self.app_data.lock(); - let selected_panel = self.gui_state.lock().get_selected_panel(); - match selected_panel { - SelectablePanel::Containers => locked_data.containers_end(), - SelectablePanel::Logs => locked_data.log_end(), - SelectablePanel::Commands => locked_data.docker_command_end(), - } - } + KeyCode::Char('s' | 'S') => self.s_key().await, + KeyCode::Tab => self.tab_key(), + KeyCode::BackTab => self.back_tab_key(), + KeyCode::Home => self.home_key(), + KeyCode::End => self.end_key(), KeyCode::Up | KeyCode::Char('k' | 'K') => self.previous(), KeyCode::PageUp => { for _ in 0..=6 { @@ -304,55 +436,7 @@ impl InputHandler { self.next(); } } - KeyCode::Enter => { - // This isn't great, just means you can't send docker commands before full initialization of the program - let panel = self.gui_state.lock().get_selected_panel(); - if panel == SelectablePanel::Commands { - let option_command = self.app_data.lock().selected_docker_command(); - - if let Some(command) = option_command { - // Poor way of disallowing commands to be sent to a containerised okxer - if self.app_data.lock().is_oxker() { - return; - }; - let option_id = self.app_data.lock().get_selected_container_id(); - if let Some(id) = option_id { - match command { - DockerControls::Delete => self - .docker_sender - .send(DockerMessage::ConfirmDelete(id)) - .await - .ok(), - DockerControls::Pause => self - .docker_sender - .send(DockerMessage::Pause(id)) - .await - .ok(), - DockerControls::Unpause => self - .docker_sender - .send(DockerMessage::Unpause(id)) - .await - .ok(), - DockerControls::Start => self - .docker_sender - .send(DockerMessage::Start(id)) - .await - .ok(), - DockerControls::Stop => self - .docker_sender - .send(DockerMessage::Stop(id)) - .await - .ok(), - DockerControls::Restart => self - .docker_sender - .send(DockerMessage::Restart(id)) - .await - .ok(), - }; - } - } - } - } + KeyCode::Enter => self.enter_key().await, _ => (), } } diff --git a/src/parse_args.rs b/src/parse_args.rs index 0b4e96d..a5d007d 100644 --- a/src/parse_args.rs +++ b/src/parse_args.rs @@ -1,4 +1,4 @@ -use std::process; +use std::{path::PathBuf, process}; use clap::Parser; use tracing::error; @@ -40,6 +40,10 @@ pub struct Args { /// Use "docker" cli for execing #[clap(long="use-cli", short = None)] pub use_cli: bool, + + /// Directory for exporting logs, defaults to `$HOME` + #[clap(long="logs-dir", short = None)] + pub logs_dir: Option, } #[derive(Debug, Clone)] @@ -47,13 +51,14 @@ pub struct Args { pub struct CliArgs { pub color: bool, pub docker_interval: u32, - pub use_cli: bool, pub gui: bool, pub host: Option, pub in_container: bool, + pub logs_dir: Option, pub raw: bool, pub show_self: bool, pub timestamp: bool, + pub use_cli: bool, } impl CliArgs { @@ -72,6 +77,11 @@ impl CliArgs { pub fn new() -> Self { let args = Args::parse(); + let logs_dir = args.logs_dir.map_or_else( + || directories::BaseDirs::new().map(|base_dirs| base_dirs.home_dir().to_owned()), + |logs_dir| Some(std::path::Path::new(&logs_dir).to_owned()), + ); + // Quit the program if the docker update argument is 0 // Should maybe change it to check if less than 100 if args.docker_interval == 0 { @@ -85,6 +95,7 @@ impl CliArgs { gui: !args.gui, host: args.host, in_container: Self::check_if_in_container(), + logs_dir, raw: args.raw, show_self: !args.show_self, timestamp: !args.timestamp, diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs index c6b3359..5008d3c 100644 --- a/src/ui/draw_blocks.rs +++ b/src/ui/draw_blocks.rs @@ -10,7 +10,7 @@ use ratatui::{ }, Frame, }; -use std::default::Default; +use std::{default::Default, time::Instant}; use std::{fmt::Display, sync::Arc}; use crate::app_data::{ContainerItem, Header, SortedOrder}; @@ -20,7 +20,7 @@ use crate::{ }; use super::{ - gui_state::{BoxLocation, DeleteButton, Region}, + gui_state::{self, BoxLocation, DeleteButton, Region}, FrameData, }; use super::{GuiState, SelectablePanel}; @@ -877,7 +877,7 @@ pub fn error(f: &mut Frame, error: AppError, seconds: Option) { } /// Draw info box in one of the 9 BoxLocations -pub fn info(f: &mut Frame, text: &str) { +pub fn info(f: &mut Frame, text: &str, instant: Instant, gui_state: &Arc>) { let block = Block::default() .title("") .title_alignment(Alignment::Center) @@ -898,6 +898,9 @@ pub fn info(f: &mut Frame, text: &str) { let area = popup(lines, max_line_width, f.size(), BoxLocation::BottomRight); f.render_widget(Clear, area); f.render_widget(paragraph, area); + if instant.elapsed().as_millis() > 4000 { + gui_state.lock().reset_info_box(); + } } /// draw a box in the one of the BoxLocations, based on max line width + number of lines diff --git a/src/ui/gui_state.rs b/src/ui/gui_state.rs index a7240f2..fa2d229 100644 --- a/src/ui/gui_state.rs +++ b/src/ui/gui_state.rs @@ -3,6 +3,7 @@ use ratatui::layout::{Constraint, Rect}; use std::{ collections::{HashMap, HashSet}, sync::Arc, + time::Instant, }; use tokio::task::JoinHandle; use uuid::Uuid; @@ -158,12 +159,13 @@ const FRAMES_LEN: u8 = 9; /// Various functions (e.g input handler), operate differently depending upon current Status #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub enum Status { - Exec, DeleteConfirm, DockerConnect, Error, + Exec, Help, Init, + Logs, } /// Global gui_state, stored in an Arc @@ -178,7 +180,7 @@ pub struct GuiState { selected_panel: SelectablePanel, status: HashSet, exec_mode: Option, - pub info_box_text: Option, + pub info_box_text: Option<(String, Instant)>, } impl GuiState { /// Clear panels hash map, so on resize can fix the sizes for mouse clicks @@ -366,7 +368,7 @@ impl GuiState { /// Set info box content pub fn set_info_box(&mut self, text: &str) { - self.info_box_text = Some(text.to_owned()); + self.info_box_text = Some((text.to_owned(), std::time::Instant::now())); } /// Remove info box content diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 97f0225..d5aeac5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -247,7 +247,7 @@ pub struct FrameData { height: u16, help_visible: bool, init: bool, - info_text: Option, + info_text: Option<(String, Instant)>, loading_icon: String, selected_panel: SelectablePanel, sorted_by: Option<(Header, SortedOrder)>, @@ -347,8 +347,8 @@ fn draw_frame(f: &mut Frame, app_data: &Arc>, gui_state: &Arc