init commit

This commit is contained in:
Jack Wills
2022-04-25 02:44:39 +00:00
commit 5101f60aaa
28 changed files with 3289 additions and 0 deletions
+425
View File
@@ -0,0 +1,425 @@
use std::{cmp::Ordering, collections::VecDeque, fmt};
use tui::{
style::Color,
widgets::{ListItem, ListState},
};
#[derive(Debug, Clone)]
pub struct StatefulList<T> {
pub state: ListState,
pub items: Vec<T>,
}
impl<T> StatefulList<T> {
pub fn new(items: Vec<T>) -> Self {
Self {
state: ListState::default(),
items,
}
}
pub fn end(&mut self) {
let len = self.items.len();
if len > 0 {
self.state.select(Some(self.items.len() - 1));
}
}
pub fn start(&mut self) {
self.state.select(Some(0));
}
pub fn next(&mut self) {
if !self.items.is_empty() {
let i = match self.state.selected() {
Some(i) => {
if i < self.items.len() - 1 {
i + 1
} else {
i
}
}
None => 0,
};
self.state.select(Some(i));
}
}
pub fn previous(&mut self) {
if !self.items.is_empty() {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
0
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
}
pub fn get_state_title(&self) -> String {
if self.items.is_empty() {
String::from("")
} else {
let len = self.items.len();
let c = if let Some(value) = self.state.selected() {
if len > 0 {
value + 1
} else {
value
}
} else {
0
};
format!("{}/{}", c, self.items.len())
}
}
}
/// States of the container
#[derive(Clone, Debug, PartialEq, PartialOrd)]
pub enum State {
Dead,
Exited,
Paused,
Removing,
Restarting,
Running,
Unknown,
}
impl State {
pub fn get_color(&self) -> Color {
match self {
Self::Running => Color::Green,
Self::Removing => Color::LightRed,
Self::Restarting => Color::LightGreen,
Self::Paused => Color::Yellow,
_ => Color::Red,
}
}
}
impl From<&str> for State {
fn from(input: &str) -> Self {
match input {
"dead" => Self::Dead,
"exited" => Self::Exited,
"paused" => Self::Paused,
"removing" => Self::Removing,
"restarting" => Self::Restarting,
"running" => Self::Running,
_ => Self::Unknown,
}
}
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let disp = match self {
Self::Dead => "✖ dead",
Self::Exited => "✖ exited",
Self::Paused => "॥ paused",
Self::Removing => "removing",
Self::Restarting => "↻ restarting",
Self::Running => "✓ running",
Self::Unknown => "? unknown",
};
write!(f, "{}", disp)
}
}
/// Items for the container control list
/// Should probably have a vec for each container
/// so that can remove Pause if container currently Paused etc
#[derive(Debug, Clone)]
pub enum DockerControls {
Pause,
Unpause,
Restart,
Stop,
Start,
}
impl DockerControls {
pub fn get_color(&self) -> Color {
match self {
Self::Start => Color::Green,
Self::Stop => Color::Red,
Self::Restart => Color::Magenta,
Self::Pause => Color::Yellow,
Self::Unpause => Color::Blue,
}
}
pub fn gen_vec(state: &State) -> Vec<Self> {
match state {
State::Dead | State::Exited => vec![Self::Start, Self::Restart],
State::Paused => vec![Self::Unpause, Self::Stop],
State::Running => vec![Self::Pause, Self::Restart, Self::Stop],
_ => vec![],
}
}
}
impl fmt::Display for DockerControls {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let disp = match self {
Self::Pause => "pause",
Self::Unpause => "unpause",
Self::Restart => "restart",
Self::Stop => "stop",
Self::Start => "start",
};
write!(f, "{}", disp)
}
}
pub trait Stats {
fn get_value(&self) -> f64;
}
/// Struct for frequently updated CPU stats
/// So can use custom display formatter
/// Use trait Stats for use as generic in draw_chart function
#[derive(Clone, Debug)]
pub struct CpuStats {
value: f64,
}
impl CpuStats {
pub fn new(value: f64) -> Self {
Self { value }
}
}
impl Eq for CpuStats {}
impl PartialEq for CpuStats {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
impl PartialOrd for CpuStats {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.value.partial_cmp(&other.value)
}
}
impl Ord for CpuStats {
fn cmp(&self, other: &Self) -> Ordering {
if self.value > other.value {
Ordering::Greater
} else if self.value == other.value {
Ordering::Equal
} else {
Ordering::Less
}
}
}
impl Stats for CpuStats {
fn get_value(&self) -> f64 {
self.value
}
}
impl fmt::Display for CpuStats {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let disp = format!("{:05.2}%", self.value);
write!(f, "{:>x$}", disp, x = f.width().unwrap_or(1))
}
}
/// Struct for frequently updated memory usage stats
/// So can use custom display formatter
/// Use trait Stats for use as generic in draw_chart function
#[derive(Clone, Debug, Eq)]
pub struct ByteStats {
value: u64,
}
impl PartialEq for ByteStats {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
impl PartialOrd for ByteStats {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.value.partial_cmp(&other.value)
}
}
impl Ord for ByteStats {
fn cmp(&self, other: &Self) -> Ordering {
self.value.cmp(&other.value)
}
}
impl ByteStats {
pub fn new(value: u64) -> Self {
Self { value }
}
pub fn update(&mut self, value: u64) {
self.value = value;
}
}
impl Stats for ByteStats {
fn get_value(&self) -> f64 {
self.value as f64
}
}
// convert from bytes to kb, mb, gb etc
impl fmt::Display for ByteStats {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let one_kb = 1000.0;
let one_mb = one_kb * one_kb;
let one_gb = one_mb * 1000.0;
let as_f64 = self.value as f64;
let p = match as_f64 {
x if x >= one_gb => format!("{y:.2} GB", y = as_f64 / one_gb),
x if x >= one_kb => format!("{y:.2} MB", y = as_f64 / one_mb),
x if x >= one_mb => format!("{y:.2} kB", y = as_f64 / one_kb),
_ => format!("{} B", self.value),
};
write!(f, "{:>x$}", p, x = f.width().unwrap_or(1))
}
}
/// Info for each container
#[derive(Debug, Clone)]
pub struct ContainerItem {
pub cpu_stats: VecDeque<CpuStats>,
pub docker_controls: StatefulList<DockerControls>,
pub id: String,
pub image: String,
pub last_updated: u64,
pub logs: StatefulList<ListItem<'static>>,
pub mem_limit: ByteStats,
pub mem_stats: VecDeque<ByteStats>,
pub name: String,
pub net_rx: ByteStats,
pub net_tx: ByteStats,
pub state: State,
pub status: String,
}
pub type MemTuple = (Vec<(f64, f64)>, ByteStats, State);
pub type CpuTuple = (Vec<(f64, f64)>, CpuStats, State);
impl ContainerItem {
/// Create a new container item
pub fn new(id: String, status: String, image: String, state: State, name: String) -> Self {
let mut docker_controls = StatefulList::new(DockerControls::gen_vec(&state));
docker_controls.start();
Self {
cpu_stats: VecDeque::with_capacity(60),
docker_controls,
id,
image,
last_updated: 0,
logs: StatefulList::new(vec![]),
mem_limit: ByteStats::new(0),
mem_stats: VecDeque::with_capacity(60),
name,
net_rx: ByteStats::new(0),
net_tx: ByteStats::new(0),
state,
status,
}
}
/// Find the max value in the last 30 items in the cpu stats vec
fn max_cpu_stats(&self) -> CpuStats {
match self.cpu_stats.iter().max() {
Some(value) => value.to_owned(),
None => CpuStats::new(0.0),
}
}
/// Find the max value in the last 30 items in the mem stats vec
fn max_mem_stats(&self) -> ByteStats {
match self.mem_stats.iter().max() {
Some(value) => value.to_owned(),
None => ByteStats::new(0),
}
}
/// Convert cpu stats into a vec for the charts function
fn get_cpu_dataset(&self) -> Vec<(f64, f64)> {
self.cpu_stats
.iter()
.enumerate()
.map(|i| (i.0 as f64, i.1.value))
.collect::<Vec<_>>()
}
/// Convert mem stats into a vec for the charts function
fn get_mem_dataset(&self) -> Vec<(f64, f64)> {
self.mem_stats
.iter()
.enumerate()
.map(|i| (i.0 as f64, i.1.value as f64))
.collect::<Vec<_>>()
}
/// Get all cpu chart data
fn get_cpu_chart_data(&self) -> CpuTuple {
(
self.get_cpu_dataset(),
self.max_cpu_stats(),
self.state.clone(),
)
}
/// Get all mem chart data
fn get_mem_chart_data(&self) -> MemTuple {
(
self.get_mem_dataset(),
self.max_mem_stats(),
self.state.clone(),
)
}
/// Get chart info for cpu & memory in one function
/// So only need to call .lock() once
pub fn get_chart_data(&self) -> (CpuTuple, MemTuple) {
(self.get_cpu_chart_data(), self.get_mem_chart_data())
}
}
/// Container information panel headings + widths, for nice pretty formatting
#[derive(Debug)]
pub struct Columns {
pub cpu: (String, usize),
pub image: (String, usize),
pub name: (String, usize),
pub state: (String, usize),
pub status: (String, usize),
pub mem: (String, usize),
pub net_rx: (String, usize),
pub net_tx: (String, usize),
}
impl Columns {
pub fn new() -> Self {
Self {
// 7 to allow for 100.00%
cpu: (String::from("cpu"), 7),
image: (String::from("image"), 5),
name: (String::from("name"), 4),
state: (String::from("state"), 11),
status: (String::from("status"), 16),
mem: (String::from("mem/limit"), 9),
net_rx: (String::from("↓ rx"), 5),
net_tx: (String::from("↑ tx"), 5),
}
}
}
+397
View File
@@ -0,0 +1,397 @@
use bollard::models::ContainerSummary;
use std::time::{SystemTime, UNIX_EPOCH};
use tui::widgets::ListItem;
mod container_state;
use crate::{app_error::AppError, parse_args::CliArgs, ui::log_sanitizer};
pub use container_state::*;
/// Global app_state, stored in an Arc<Mutex>
#[derive(Debug)]
pub struct AppData {
args: CliArgs,
error: Option<AppError>,
logs_parsed: bool,
pub containers: StatefulList<ContainerItem>,
pub init: bool,
pub show_error: bool,
}
impl AppData {
/// Generate a default app_state
pub fn default(args: CliArgs) -> Self {
Self {
args,
containers: StatefulList::new(vec![]),
error: None,
init: false,
logs_parsed: false,
show_error: false,
}
}
// Current time as unix timestamp
fn get_systemtime(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("In our known reality, this error should never occur")
.as_secs()
}
/// Get the current select docker command
/// So know which command to execute
pub fn get_docker_command(&self) -> Option<DockerControls> {
let mut output = None;
if let Some(index) = self.containers.state.selected() {
if let Some(control_index) = self.containers.items[index]
.docker_controls
.state
.selected()
{
output =
Some(self.containers.items[index].docker_controls.items[control_index].clone())
}
}
output
}
/// Change selected choice of docker commands of selected container
pub fn docker_command_next(&mut self) {
if let Some(index) = self.containers.state.selected() {
self.containers.items[index].docker_controls.next()
}
}
/// Change selected choice of docker commands of selected container
pub fn docker_command_previous(&mut self) {
if let Some(index) = self.containers.state.selected() {
self.containers.items[index].docker_controls.previous()
}
}
/// Change selected choice of docker commands of selected container
pub fn docker_command_start(&mut self) {
if let Some(index) = self.containers.state.selected() {
self.containers.items[index].docker_controls.start()
}
}
/// Change selected choice of docker commands of selected container
pub fn docker_command_end(&mut self) {
if let Some(index) = self.containers.state.selected() {
self.containers.items[index].docker_controls.end()
}
}
/// return single app_state error
pub fn get_error(&self) -> Option<AppError> {
self.error.clone()
}
/// remove single app_state error
pub fn remove_error(&mut self) {
self.error = None;
}
/// insert single app_state error
pub fn set_error(&mut self, error: AppError) {
self.error = Some(error);
}
/// Find the if of the currently selected container
/// If any containers on system, will always return
/// Only returns None when no containers found
pub fn get_selected_container_id(&self) -> Option<String> {
let mut output = None;
if let Some(index) = self.containers.state.selected() {
let id = self
.containers
.items
.iter()
.skip(index)
.take(1)
.map(|i| i.id.to_owned())
.collect::<String>();
output = Some(id)
}
output
}
/// Find the index of the currently selected single log line
pub fn get_selected_log_index(&self) -> Option<usize> {
let mut output = None;
if let Some(id) = self.get_selected_container_id() {
if let Some(index) = self.containers.items.iter().position(|i| i.id == id) {
output = Some(index);
}
}
output
}
/// Get the title for log panel for selected container
/// will be "logs x/x"
pub fn get_log_title(&self) -> String {
if let Some(index) = self.get_selected_log_index() {
self.containers.items[index].logs.get_state_title()
} else {
String::from("")
}
}
/// select next selected log line
pub fn log_next(&mut self) {
if let Some(index) = self.get_selected_log_index() {
self.containers.items[index].logs.next()
}
}
/// select previous selected log line
pub fn log_previous(&mut self) {
if let Some(index) = self.get_selected_log_index() {
self.containers.items[index].logs.previous()
}
}
/// select last selected log line
pub fn log_end(&mut self) {
if let Some(index) = self.get_selected_log_index() {
self.containers.items[index].logs.end()
}
}
/// select first selected log line
pub fn log_start(&mut self) {
if let Some(index) = self.get_selected_log_index() {
self.containers.items[index].logs.start()
}
}
pub fn initialised(&mut self, all_ids: &[(bool, String)]) -> bool {
let count_is_running = all_ids.iter().filter(|i| i.0).count();
let number_with_cpu_status = self
.containers
.items
.iter()
.filter(|i| !i.cpu_stats.is_empty())
.count();
self.logs_parsed && count_is_running == number_with_cpu_status
}
/// Just get the total number of containers
pub fn get_container_len(&self) -> usize {
self.containers.items.len()
}
/// Find the widths for the strings in the containers panel
/// So can display nicely and evenly
pub fn get_width(&self) -> Columns {
let mut output = Columns::new();
let count = |x: &String| x.chars().count();
for container in self.containers.items.iter() {
let cpu_count = count(
&container
.cpu_stats
.back()
.unwrap_or(&CpuStats::new(0.0))
.to_string(),
);
let mem_count = count(&format!(
"{} / {}",
container.mem_stats.back().unwrap_or(&ByteStats::new(0)),
container.mem_limit
));
let net_rx_count = count(&container.net_rx.to_string());
let net_tx_count = count(&container.net_tx.to_string());
let image_count = count(&container.image);
let name_count = count(&container.name);
let state_count = count(&container.state.to_string());
let status_count = count(&container.status);
if cpu_count > output.cpu.1 {
output.cpu.1 = cpu_count;
};
if image_count > output.image.1 {
output.image.1 = image_count;
};
if mem_count > output.mem.1 {
output.mem.1 = mem_count;
};
if name_count > output.name.1 {
output.name.1 = name_count;
};
if state_count > output.state.1 {
output.state.1 = state_count;
};
if status_count > output.status.1 {
output.status.1 = status_count;
};
if net_rx_count > output.net_rx.1 {
output.net_rx.1 = net_rx_count;
};
if net_tx_count > output.net_tx.1 {
output.net_tx.1 = net_tx_count;
};
}
output
}
/// Get all containers ids
pub fn get_all_ids(&self) -> Vec<String> {
self.containers
.items
.iter()
.map(|i| i.id.to_owned())
.collect::<Vec<_>>()
}
/// find container given id
fn get_container_by_id(&mut self, id: &str) -> Option<&mut ContainerItem> {
self.containers.items.iter_mut().find(|i| i.id == id)
}
/// Update container mem + cpu stats, in single function so only need to call .lock() once
pub fn update_stats(
&mut self,
id: String,
cpu_stat: Option<f64>,
mem_stat: Option<u64>,
mem_limit: u64,
rx: u64,
tx: u64,
) {
if let Some(container) = self.get_container_by_id(&id) {
if container.cpu_stats.len() >= 60 {
container.cpu_stats.pop_front();
}
if container.mem_stats.len() >= 60 {
container.mem_stats.pop_front();
}
if let Some(cpu) = cpu_stat {
container.cpu_stats.push_back(CpuStats::new(cpu));
}
if let Some(mem) = mem_stat {
container.mem_stats.push_back(ByteStats::new(mem));
}
container.net_rx.update(rx);
container.net_tx.update(tx);
container.mem_limit.update(mem_limit);
}
}
/// Update, or insert, containers
pub fn update_containers(&mut self, containers: &[ContainerSummary]) {
let all_ids = self.get_all_ids();
if !containers.is_empty() && self.containers.state.selected().is_none() {
self.containers.start();
}
for (index, id) in all_ids.iter().enumerate() {
if !containers
.iter()
.map(|i| i.id.as_ref().unwrap())
.any(|x| x == id)
{
// If removed container is currently selected, then change selected to previous
// This will default to 0 in any edge cases
if self.containers.state.selected().is_some() {
self.containers.previous();
}
self.containers.items.remove(index);
}
}
for i in containers.iter() {
let id = i.id.as_ref().unwrap().to_owned();
let mut name = i
.names
.as_ref()
.unwrap_or(&vec!["".to_owned()])
.get(0)
.unwrap()
.to_owned();
if let Some(c) = name.chars().next() {
if c == '/' {
name.remove(0);
}
}
let state = State::from(i.state.as_ref().unwrap_or(&"dead".to_owned()).trim());
let status = i
.status
.as_ref()
.unwrap_or(&"".to_owned())
.trim()
.to_owned();
let image = i.image.as_ref().unwrap_or(&"".to_owned()).trim().to_owned();
if let Some(current_container) = self.get_container_by_id(&id) {
if current_container.name != name {
current_container.name = name
};
if current_container.status != status {
current_container.status = status
};
if current_container.state != state {
current_container.docker_controls.items = DockerControls::gen_vec(&state);
// Update the list state, needs to be None if the gen_vec returns an empty vec
match state {
State::Removing | State::Restarting | State::Unknown => {
current_container.docker_controls.state.select(None)
}
_ => current_container.docker_controls.start(),
};
current_container.state = state;
};
if current_container.image != image {
current_container.image = image
};
} else {
let mut container = ContainerItem::new(id, status, image, state, name);
container.logs.end();
self.containers.items.push(container);
}
}
}
/// update logs of a given container, based on index not id
pub fn update_log_by_index(&mut self, output: Vec<String>, index: usize) {
let tz = self.get_systemtime();
if let Some(container) = self.containers.items.get_mut(index) {
container.last_updated = tz;
let current_len = container.logs.items.len();
output.iter().for_each(|i| {
let lines = if self.args.color {
log_sanitizer::colorize_logs(i.to_owned())
} else if self.args.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() + 1 == current_len
{
container.logs.end();
}
}
self.logs_parsed = true;
}
pub fn update_all_logs(&mut self, all_logs: Vec<Vec<String>>) {
for (index, output) in all_logs.into_iter().enumerate() {
self.update_log_by_index(output, index);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
use core::fmt;
use tracing::error;
use crate::app_data::DockerControls;
/// app errors to set in global state
#[allow(unused)]
#[derive(Debug, Clone)]
pub enum AppError {
DockerConnect,
DockerInterval,
InputPoll,
DockerCommand(DockerControls),
Terminal,
}
impl AppError {
/// for handling errors from terminal
pub fn disp(&self) {
match self {
Self::DockerConnect => error!("Unable to access docker daemon"),
Self::DockerInterval => error!("Docker update interval needs to be greater than 0"),
Self::InputPoll => error!("Unable to poll user input"),
Self::Terminal => error!("Unable to draw to terminal"),
Self::DockerCommand(s) => {
let error = format!("Unable to {} container", s);
error!(%error);
}
}
}
}
/// Convert errors into strings to display
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let disp = match self {
Self::DockerConnect => "Unable to access docker daemon".to_owned(),
Self::DockerInterval => "Docker update interval needs to be greater than 0".to_owned(),
Self::InputPoll => "Unable to poll user input".to_owned(),
Self::Terminal => "Unable to draw to terminal".to_owned(),
Self::DockerCommand(s) => format!("Unable to {} container", s),
};
write!(f, "{}", disp)
}
}
+277
View File
@@ -0,0 +1,277 @@
use bollard::{
container::{ListContainersOptions, LogsOptions, Stats, StatsOptions},
Docker,
};
use futures_util::{future::join_all, StreamExt};
use parking_lot::Mutex;
use std::{
sync::Arc,
time::{Duration, Instant},
};
use crate::{app_data::AppData, parse_args::CliArgs, ui::GuiState};
pub struct DockerData {
app_data: Arc<Mutex<AppData>>,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
initialised: bool,
sleep_duration: Duration,
timestamps: bool,
}
impl DockerData {
/// Use docker stats for work out current cpu usage
fn calculate_usage(stats: &Stats) -> f64 {
let mut cpu_percentage = 0.0;
let previous_cpu = stats.precpu_stats.cpu_usage.total_usage;
let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64 - previous_cpu as f64;
if stats.cpu_stats.system_cpu_usage.is_some()
&& stats.precpu_stats.system_cpu_usage.is_some()
{
let system_delta = (stats.cpu_stats.system_cpu_usage.unwrap()
- stats.precpu_stats.system_cpu_usage.unwrap())
as f64;
let online_cpus = stats.cpu_stats.online_cpus.unwrap_or_else(|| {
stats
.cpu_stats
.cpu_usage
.percpu_usage
.clone()
.unwrap_or_default()
.len() as u64
}) as f64;
if system_delta > 0.0 && cpu_delta > 0.0 {
cpu_percentage = (cpu_delta / system_delta) * online_cpus * 100.0;
}
}
cpu_percentage
}
/// 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 on thread
async fn update_container_stat(
docker: Arc<Docker>,
id: String,
app_data: Arc<Mutex<AppData>>,
is_running: bool,
) {
let mut stream = docker
.stats(
&id,
Some(StatsOptions {
stream: false,
one_shot: !is_running,
}),
)
.take(1);
while let Some(Ok(stats)) = stream.next().await {
let mem_stat = stats.memory_stats.usage.unwrap_or(0);
let mem_limit = stats.memory_stats.limit.unwrap_or(0);
let key = if let Some(networks) = &stats.networks {
networks.keys().next().map(|x| x.to_owned())
} else {
None
};
let cpu_stats = Self::calculate_usage(&stats);
let (rx, tx) = if let Some(k) = key {
let ii = stats.networks.unwrap();
let v = ii.get(&k).unwrap();
(v.rx_bytes.to_owned(), v.tx_bytes.to_owned())
} else {
(0, 0)
};
if is_running {
app_data.lock().update_stats(
id.clone(),
Some(cpu_stats),
Some(mem_stat),
mem_limit,
rx,
tx,
);
} else {
app_data
.lock()
.update_stats(id.clone(), None, None, mem_limit, rx, tx);
}
}
}
/// Update all stats, spawn each container into own tokio::spawn thread
async fn update_all_container_stats(&mut self, all_ids: &[(bool, String)]) {
for (is_running, id) in all_ids.iter() {
let docker = Arc::clone(&self.docker);
let app_data = Arc::clone(&self.app_data);
let is_running = *is_running;
let id = id.to_owned();
tokio::spawn(async move {
Self::update_container_stat(docker, id, app_data, is_running).await
});
}
}
/// 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
pub async fn update_all_containers(&mut self) -> Vec<(bool, String)> {
let containers = self
.docker
.list_containers(Some(ListContainersOptions::<String> {
all: true,
..Default::default()
}))
.await
.unwrap();
let mut output = vec![];
// iter over containers, to only send ones which have an id, as use ID for extensivley!
// alternative is to create my own container struct, and will out with details
containers.iter().filter(|i| i.id.is_some()).for_each(|c| {
output.push(c.to_owned());
});
self.app_data.lock().update_containers(&output);
output
.iter()
.map(|i| {
(
i.state.as_ref().unwrap() == "running",
i.id.as_ref().unwrap().to_owned(),
)
})
.collect::<Vec<_>>()
}
/// Update single container logs
/// don't take &self, so that can tokio::spawn into it's on thread
async fn update_log(
docker: Arc<Docker>,
id: String,
timestamps: bool,
since: i64,
) -> Vec<String> {
let options = Some(LogsOptions::<String> {
stdout: true,
timestamps,
since,
..Default::default()
});
let mut logs = docker.logs(&id, options);
let mut output = vec![];
while let Some(value) = logs.next().await {
if let Ok(data) = value {
let log_string = data.to_string();
if !log_string.trim().is_empty() {
output.push(log_string);
}
}
}
output
}
/// Update all logs, spawn each container into own tokio::spawn thread
// rename init all logs, as only gets run once
async fn update_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 all_logs = join_all(handles).await;
self.app_data.lock().update_all_logs(all_logs);
}
async fn update_everything(&mut self) {
let all_ids = self.update_all_containers().await;
let op_index = self.app_data.lock().get_selected_log_index();
if let Some(index) = op_index {
let docker = Arc::clone(&self.docker);
let since = self.app_data.lock().containers.items[index].last_updated as i64;
let timestamps = self.timestamps;
let id = self.app_data.lock().containers.items[index].id.to_owned();
let logs = Self::update_log(docker, id, timestamps, since).await;
self.app_data.lock().update_log_by_index(logs, index);
};
self.update_all_container_stats(&all_ids).await;
}
/// Initialise self, and start the updated loop
pub async fn init(
args: CliArgs,
app_data: Arc<Mutex<AppData>>,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
) {
if app_data.lock().get_error().is_none() {
let mut inner = Self {
app_data,
docker,
gui_state,
initialised: false,
sleep_duration: Duration::from_millis(args.docker as u64),
timestamps: args.timestamp,
};
inner.initialise_container_data().await;
inner.update_loop().await;
}
}
async fn initialise_container_data(&mut self) {
let gui_state = Arc::clone(&self.gui_state);
// could also just loop while init is false, would need to move an arc mutex into here
// so instead just abort at end of function
let loading_spin = tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
gui_state.lock().next_loading();
}
});
let all_ids = self.update_all_containers().await;
self.update_all_container_stats(&all_ids).await;
// Maybe only do a single one at first?
self.update_all_logs(&all_ids).await;
if all_ids.is_empty() {
self.initialised = true;
}
// wait until all logs have initialised
while !self.initialised {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
self.initialised = self.app_data.lock().initialised(&all_ids);
}
self.app_data.lock().init = true;
loading_spin.abort();
self.gui_state.lock().reset_loading();
}
/// Update all items, wait until all complete
/// sleep for CliArgs.docker ms before updating next
async fn update_loop(&mut self) {
loop {
let start = Instant::now();
self.update_everything().await;
let elapsed = start.elapsed();
if elapsed < self.sleep_duration {
tokio::time::sleep(self.sleep_duration - elapsed).await;
}
}
}
}
// tests, use redis-test container, check logs exists, and selector of logs, and that it increases, and matches end, when you run restart on the docker containers
+7
View File
@@ -0,0 +1,7 @@
use crossterm::event::{KeyCode, MouseEvent};
#[derive(Debug, Clone)]
pub enum InputMessages {
ButtonPress(KeyCode),
MouseEvent(MouseEvent),
}
+262
View File
@@ -0,0 +1,262 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use bollard::{container::StartContainerOptions, Docker};
use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind};
use parking_lot::Mutex;
use tokio::sync::broadcast::Receiver;
use tui::layout::Rect;
mod message;
use crate::{
app_data::{AppData, DockerControls},
app_error::AppError,
ui::{GuiState, SelectablePanel},
};
pub use message::InputMessages;
/// Handle all input events
#[derive(Debug)]
pub struct InputHandler {
app_data: Arc<Mutex<AppData>>,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
rec: Receiver<InputMessages>,
}
impl InputHandler {
/// Initialize self, and running the message handling loop
pub async fn init(
app_data: Arc<Mutex<AppData>>,
rec: Receiver<InputMessages>,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
) {
let mut inner = Self {
app_data,
docker,
gui_state,
is_running,
rec,
};
inner.start().await;
}
/// check for incoming messages
async fn start(&mut self) {
while let Ok(message) = self.rec.recv().await {
match message {
InputMessages::ButtonPress(key_code) => self.button_press(key_code).await,
InputMessages::MouseEvent(mouse_event) => {
let show_error = self.app_data.lock().show_error;
let show_info = self.gui_state.lock().show_help;
if !show_error && !show_info {
self.mouse_press(mouse_event);
}
}
}
if !self.is_running.load(Ordering::SeqCst) {
break;
}
}
}
/// Handle any keyboard button events
async fn button_press(&mut self, key_code: KeyCode) {
let show_error = self.app_data.lock().show_error;
let show_info = self.gui_state.lock().show_help;
if show_error {
match key_code {
KeyCode::Char('q') => {
self.is_running.store(false, Ordering::SeqCst);
}
KeyCode::Char('c') => {
self.app_data.lock().show_error = false;
self.app_data.lock().remove_error();
}
_ => (),
}
} else if show_info {
match key_code {
KeyCode::Char('q') => {
self.is_running.store(false, Ordering::SeqCst);
}
KeyCode::Char('h') => {
self.gui_state.lock().show_help = false;
}
_ => (),
}
} else {
match key_code {
KeyCode::Char('q') => {
self.is_running.store(false, Ordering::SeqCst);
}
KeyCode::Char('h') => {
self.gui_state.lock().show_help = true;
}
KeyCode::Tab => self.gui_state.lock().next_panel(),
KeyCode::BackTab => self.gui_state.lock().previous_panel(),
KeyCode::Home => {
let mut locked_data = self.app_data.lock();
match self.gui_state.lock().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();
match self.gui_state.lock().selected_panel {
SelectablePanel::Containers => locked_data.containers.end(),
SelectablePanel::Logs => locked_data.log_end(),
SelectablePanel::Commands => locked_data.docker_command_end(),
}
}
KeyCode::Up => self.previous(),
KeyCode::PageUp => {
for _ in 0..=6 {
self.previous()
}
}
KeyCode::Down => self.next(),
KeyCode::PageDown => {
for _ in 0..=6 {
self.next()
}
}
KeyCode::Enter => {
// Does is matter though?
// This isn't great, just means you can't send docker commands before full initialization of the program
// could change to to if loading = true, although at the moment don't have a loading bool
let panel = self.gui_state.lock().selected_panel;
if panel == SelectablePanel::Commands {
let command = self.app_data.lock().get_docker_command();
if command.is_some() {
let id = self.app_data.lock().get_selected_container_id();
let app_data = Arc::clone(&self.app_data);
let docker = Arc::clone(&self.docker);
if id.is_some() {
let id = id.unwrap();
match command.unwrap() {
DockerControls::Pause => {
tokio::spawn(async move {
docker.pause_container(&id).await.unwrap_or_else(
|_| {
app_data.lock().set_error(
AppError::DockerCommand(
DockerControls::Pause,
),
)
},
);
});
}
DockerControls::Unpause => {
tokio::spawn(async move {
docker.unpause_container(&id).await.unwrap_or_else(
|_| {
app_data.lock().set_error(
AppError::DockerCommand(
DockerControls::Unpause,
),
)
},
);
});
}
DockerControls::Start => {
tokio::spawn(async move {
docker
.start_container(
&id,
None::<StartContainerOptions<String>>,
)
.await
.unwrap_or_else(|_| {
app_data.lock().set_error(
AppError::DockerCommand(
DockerControls::Start,
),
)
});
});
}
DockerControls::Stop => {
tokio::spawn(async move {
docker.stop_container(&id, None).await.unwrap_or_else(
|_| {
app_data.lock().set_error(
AppError::DockerCommand(
DockerControls::Stop,
),
)
},
);
});
}
DockerControls::Restart => {
tokio::spawn(async move {
docker
.restart_container(&id, None)
.await
.unwrap_or_else(|_| {
app_data.lock().set_error(
AppError::DockerCommand(
DockerControls::Restart,
),
)
});
});
}
}
}
}
}
}
_ => (),
}
}
}
/// Handle mouse button events
fn mouse_press(&mut self, mouse_event: MouseEvent) {
match mouse_event.kind {
MouseEventKind::ScrollUp => self.previous(),
MouseEventKind::ScrollDown => self.next(),
MouseEventKind::Down(MouseButton::Left) => {
self.gui_state.lock().rect_insersects(Rect::new(
mouse_event.column,
mouse_event.row,
1,
1,
));
}
_ => (),
}
}
/// Change state of selected container
fn next(&mut self) {
let mut locked_data = self.app_data.lock();
match self.gui_state.lock().selected_panel {
SelectablePanel::Containers => locked_data.containers.next(),
SelectablePanel::Logs => locked_data.log_next(),
SelectablePanel::Commands => locked_data.docker_command_next(),
};
}
/// Change state of selected container
fn previous(&mut self) {
let mut locked_data = self.app_data.lock();
match self.gui_state.lock().selected_panel {
SelectablePanel::Containers => locked_data.containers.previous(),
SelectablePanel::Logs => locked_data.log_previous(),
SelectablePanel::Commands => locked_data.docker_command_previous(),
}
}
}
+76
View File
@@ -0,0 +1,76 @@
use app_data::AppData;
use app_error::AppError;
use bollard::Docker;
use docker_data::DockerData;
use parking_lot::Mutex;
use parse_args::CliArgs;
use std::sync::{atomic::AtomicBool, Arc};
use tracing::{info, Level};
mod app_data;
mod app_error;
mod docker_data;
mod input_handler;
mod parse_args;
mod ui;
use ui::{create_ui, GuiState};
fn setup_tracing() {
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
}
#[tokio::main]
async fn main() {
setup_tracing();
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 docker_args = args.clone();
let docker_app_data = Arc::clone(&app_data);
let docker_gui_state = Arc::clone(&gui_state);
// Create docker daemon handler, and only spawn up the docker data handler if ping returns non-error
let docker = Arc::new(Docker::connect_with_socket_defaults().unwrap());
match docker.ping().await {
Ok(_) => {
let docker = Arc::clone(&docker);
tokio::spawn(async move {
DockerData::init(docker_args, docker_app_data, docker, docker_gui_state).await;
});
}
Err(_) => app_data.lock().set_error(AppError::DockerConnect),
}
let input_app_data = Arc::clone(&app_data);
let (s, r) = tokio::sync::broadcast::channel(16);
let input_docker = Arc::clone(&docker);
let is_running = Arc::new(AtomicBool::new(true));
let input_is_running = Arc::clone(&is_running);
let input_gui_state = Arc::clone(&gui_state);
// Spawn input handling into own tokio thread
tokio::spawn(async {
input_handler::InputHandler::init(
input_app_data,
r,
input_docker,
input_gui_state,
input_is_running,
)
.await;
});
// Debug mode for testing, mostly pointless, doesn't take terminal nor draw gui
if !args.gui {
loop {
info!("in debug mode");
tokio::time::sleep(std::time::Duration::from_millis(5000)).await;
}
} else {
create_ui(app_data, s, is_running, gui_state).await.unwrap();
}
}
+50
View File
@@ -0,0 +1,50 @@
use std::process;
use clap::Parser;
use tracing::error;
#[derive(Parser, Debug, Clone)]
#[clap(about, version, author)]
pub struct CliArgs {
/// Docker update interval in ms, minimum 1, reccomended 500+
#[clap(short = 'd', default_value_t = 1000)]
pub docker: u32,
/// Don't draw gui - for debugging - mostly pointless
#[clap(short = 'g')]
pub gui: bool,
/// Remove timestamps from Docker logs
#[clap(short = 't')]
pub timestamp: bool,
/// Show raw logs, default is to remove ansi formatting
#[clap(short = 'r', conflicts_with = "color")]
pub raw: bool,
/// Attempt to colorize the logs
#[clap(short = 'c', conflicts_with = "raw")]
pub color: bool,
}
impl CliArgs {
/// Parse cli arguments
pub fn new() -> Self {
let args = CliArgs::parse();
// Quit the program if the docker update argument is 0
// Should maybe change it to check if less than 100
if args.docker == 0 {
error!("docker args needs to be greater than 0");
process::exit(1)
}
Self {
color: args.color,
docker: args.docker,
gui: !args.gui,
raw: args.raw,
timestamp: !args.timestamp,
}
}
}
+77
View File
@@ -0,0 +1,77 @@
pub mod log_sanitizer {
use cansi::{categorise_text, Color as CansiColor, Intensity};
use tui::{
style::{Color, Modifier, Style},
text::{Span, Spans},
};
/// Attempt to colorize the given string to tui-rs standars
pub fn colorize_logs(input: String) -> Vec<Spans<'static>> {
vec![Spans::from(
categorise_text(&input)
.into_iter()
.map(|i| {
let fg_color = color_ansi_to_tui(i.fg_colour);
let bg_color = color_ansi_to_tui(i.bg_colour);
let style = Style::default().bg(bg_color).fg(fg_color);
if i.blink {
style.add_modifier(Modifier::SLOW_BLINK);
}
if i.underline {
style.add_modifier(Modifier::UNDERLINED);
}
if i.reversed {
style.add_modifier(Modifier::REVERSED);
}
if i.intensity == Intensity::Bold {
style.add_modifier(Modifier::BOLD);
}
if i.hidden {
style.add_modifier(Modifier::HIDDEN);
}
if i.strikethrough {
style.add_modifier(Modifier::CROSSED_OUT);
}
Span::styled(i.text.to_owned(), style)
})
.collect::<Vec<_>>(),
)]
}
/// Remove all ansi formatting from a given string and create tui-rs spans
pub fn remove_ansi(input: String) -> Vec<Spans<'static>> {
let mut output = String::from("");
for i in categorise_text(&input) {
output.push_str(i.text)
}
raw(output)
}
/// create tui-rs spans that exactly match the given strings
pub fn raw(input: String) -> Vec<Spans<'static>> {
vec![Spans::from(Span::raw(input))]
}
/// Change from ansi to tui colors
fn color_ansi_to_tui(color: CansiColor) -> Color {
match color {
CansiColor::Black => Color::Black,
CansiColor::Red => Color::Red,
CansiColor::Green => Color::Green,
CansiColor::Yellow => Color::Yellow,
CansiColor::Blue => Color::Blue,
CansiColor::Magenta => Color::Magenta,
CansiColor::Cyan => Color::Cyan,
CansiColor::White => Color::White,
CansiColor::BrightBlack => Color::Black,
CansiColor::BrightRed => Color::LightRed,
CansiColor::BrightGreen => Color::LightGreen,
CansiColor::BrightYellow => Color::LightYellow,
CansiColor::BrightBlue => Color::LightBlue,
CansiColor::BrightMagenta => Color::LightMagenta,
CansiColor::BrightCyan => Color::LightCyan,
CansiColor::BrightWhite => Color::White,
}
}
}
+598
View File
@@ -0,0 +1,598 @@
use parking_lot::Mutex;
use std::default::Default;
use std::{fmt::Display, sync::Arc};
use tui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Span, Spans},
widgets::{
Axis, Block, BorderType, Borders, Chart, Clear, Dataset, GraphType, List, ListItem,
Paragraph,
},
Frame,
};
use crate::{
app_data::{AppData, ByteStats, Columns, CpuStats, State, Stats},
app_error::AppError,
};
use super::{GuiState, SelectablePanel};
const NAME_TEXT: &str = r#"
88
88
88
,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba,
a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8
8b d8 )888( 8888[ 8PP""""""" 88
"8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88
`"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 "#;
const NAME: &str = env!("CARGO_PKG_NAME");
const VERSION: &str = env!("CARGO_PKG_VERSION");
const REPO: &str = env!("CARGO_PKG_REPOSITORY");
const ORANGE: Color = Color::Rgb(255, 178, 36);
const MARGIN: &str = " ";
/// Generate block, add a bored if is the selected panel,
/// add custom title based on state of each panel
fn generate_block<'a>(
selectable_panel: Option<SelectablePanel>,
app_data: &Arc<Mutex<AppData>>,
selected_panel: &SelectablePanel,
) -> Block<'a> {
let mut block = Block::default().borders(Borders::ALL);
if let Some(panel) = selectable_panel {
let title = match panel {
SelectablePanel::Containers => {
format!(
" {} {} ",
panel.title(),
app_data.lock().containers.get_state_title()
)
}
SelectablePanel::Logs => {
format!(" {} {} ", panel.title(), app_data.lock().get_log_title())
}
_ => String::from(""),
};
block = block.title(title);
if selected_panel == &panel {
let selected_style = Style::default().fg(Color::LightCyan);
let selected_border = BorderType::Plain;
block = block
.border_style(selected_style)
.border_type(selected_border);
}
}
block
}
/// Draw the selectable panels
pub fn draw_commands<B: Backend>(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
f: &mut Frame<'_, B>,
gui_state: &Arc<Mutex<GuiState>>,
index: Option<usize>,
selected_panel: &SelectablePanel,
) {
let panel = SelectablePanel::Commands;
let block = generate_block(Some(panel), app_data, selected_panel);
gui_state.lock().insert_into_area_map(panel, area);
if let Some(i) = index {
let items = app_data.lock().containers.items[i]
.docker_controls
.items
.iter()
.map(|i| {
let lines = Spans::from(vec![Span::styled(
i.to_string(),
Style::default().fg(i.get_color()),
)]);
ListItem::new(lines)
})
.collect::<Vec<_>>();
let items = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("");
f.render_stateful_widget(
items,
area,
&mut app_data.lock().containers.items[i].docker_controls.state,
);
} else {
let debug_text = String::from("");
let paragraph = Paragraph::new(debug_text)
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, area)
}
}
/// Draw the selectable panels
pub fn draw_containers<B: Backend>(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
f: &mut Frame<'_, B>,
gui_state: &Arc<Mutex<GuiState>>,
selected_panel: &SelectablePanel,
widths: &Columns,
) {
let panel = SelectablePanel::Containers;
let block = generate_block(Some(panel), app_data, selected_panel);
gui_state.lock().insert_into_area_map(panel, area);
let items = app_data
.lock()
.containers
.items
.iter()
.map(|i| {
let state_style = Style::default().fg(i.state.get_color());
let blue = Style::default().fg(Color::Blue);
let mems = format!(
"{:>1} / {:>1}",
i.mem_stats.back().unwrap_or(&ByteStats::new(0)),
i.mem_limit
);
let lines = Spans::from(vec![
Span::styled(
format!("{:<width$}", i.state.to_string(), width = widths.state.1),
state_style,
),
Span::styled(
format!("{}{:>width$}", MARGIN, i.status, width = widths.status.1),
state_style,
),
Span::styled(
format!(
"{}{:>width$}",
MARGIN,
i.cpu_stats.back().unwrap_or(&CpuStats::new(0.0)),
width = widths.cpu.1
),
state_style,
),
Span::styled(
format!("{}{:>width$}", MARGIN, mems, width = widths.mem.1),
state_style,
),
Span::styled(
format!("{}{:>width$}", MARGIN, i.name, width = widths.name.1),
blue,
),
Span::styled(
format!("{}{:>width$}", MARGIN, i.image, width = widths.image.1),
blue,
),
Span::styled(
format!("{}{:>width$}", MARGIN, i.net_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),
Style::default().fg(Color::Rgb(205, 140, 140)),
),
]);
ListItem::new(lines)
})
.collect::<Vec<_>>();
if items.is_empty() {
let debug_text = String::from("no containers running");
let paragraph = Paragraph::new(debug_text)
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, area)
} else {
let items = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("");
f.render_stateful_widget(items, area, &mut app_data.lock().containers.state);
}
}
/// Draw the selectable panels
pub fn draw_logs<B: Backend>(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
f: &mut Frame<'_, B>,
gui_state: &Arc<Mutex<GuiState>>,
index: Option<usize>,
selected_panel: &SelectablePanel,
) {
let panel = SelectablePanel::Logs;
gui_state.lock().insert_into_area_map(panel, area);
let block = generate_block(Some(panel), app_data, selected_panel);
let init = app_data.lock().init;
if !init {
let icon = gui_state.lock().get_loading();
let parsing_logs = format!("parsing logs {}", icon);
let paragraph = Paragraph::new(parsing_logs)
.style(Style::default())
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, area)
} else if let Some(index) = index {
let items = app_data.lock().containers.items[index]
.logs
.items
.iter()
.enumerate()
.map(|i| i.1.to_owned())
.collect::<Vec<_>>();
let items = List::new(items)
.block(block)
.highlight_symbol("")
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
f.render_stateful_widget(
items,
area,
&mut app_data.lock().containers.items[index].logs.state,
);
} else {
let debug_text = String::from("no logs found");
let paragraph = Paragraph::new(debug_text)
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, area)
}
}
/// Draw the cpu + mem charts
pub fn draw_chart<B: Backend>(
f: &mut Frame<'_, B>,
area: Rect,
app_data: &Arc<Mutex<AppData>>,
index: Option<usize>,
) {
if let Some(index) = index {
let area = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(area);
let (cpu, mem) = app_data.lock().containers.items[index].get_chart_data();
let cpu_dataset = vec![Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(Color::Magenta))
.graph_type(GraphType::Line)
.data(&cpu.0)];
let mem_dataset = vec![Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(Color::Cyan))
.graph_type(GraphType::Line)
.data(&mem.0)];
let cpu_chart = make_chart(
cpu.2,
String::from("cpu"),
cpu_dataset,
CpuStats::new(cpu.0.last().unwrap_or(&(0.00, 0.00)).1),
cpu.1,
);
let mem_chart = make_chart(
mem.2,
String::from("memory"),
mem_dataset,
ByteStats::new(mem.0.last().unwrap_or(&(0.0, 0.0)).1 as u64),
mem.1,
);
f.render_widget(cpu_chart, area[0]);
f.render_widget(mem_chart, area[1]);
}
}
/// Create charts
fn make_chart<T: Stats + Display>(
state: State,
name: String,
dataset: Vec<Dataset>,
current: T,
max: T,
) -> Chart {
let title_color = match state {
State::Running => Color::Green,
_ => state.get_color(),
};
let label_color = match state {
State::Running => ORANGE,
_ => state.get_color(),
};
Chart::new(dataset)
.block(
Block::default()
.title_alignment(Alignment::Center)
.title(Span::styled(
format!(" {} {} ", name, current),
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Plain),
)
.x_axis(
Axis::default()
.style(Style::default().fg(title_color))
.bounds([0.00, 60.0]),
)
.y_axis(
Axis::default()
.labels(vec![
Span::styled("", Style::default().fg(label_color)),
Span::styled(
format!("{}", max),
Style::default()
.add_modifier(Modifier::BOLD)
.fg(label_color),
),
])
// add 0.01, for cases when the value is 0
.bounds([0.0, max.get_value() +0.01]),
)
}
/// Show error popup over whole screen
pub fn draw_info_bar<B: Backend>(
area: Rect,
columns: &Columns,
f: &mut Frame<'_, B>,
has_containers: bool,
info_visible: bool,
) {
let block = || Block::default().style(Style::default().bg(Color::Magenta).fg(Color::Black));
f.render_widget(block(), area);
let mut column_headings = format!(" {:>width$}", columns.state.0, width = columns.state.1);
column_headings.push_str(
format!(
"{} {:>width$}",
MARGIN,
columns.status.0,
width = columns.status.1
)
.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.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(),
);
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 splits = if has_containers {
vec![
Constraint::Min(column_width as u16),
Constraint::Min(info_width as u16),
]
} else {
vec![Constraint::Percentage(100)]
};
let split_bar = Layout::default()
.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 paragraph = Paragraph::new(info_text)
.block(block())
.alignment(Alignment::Right);
let index = if has_containers { 1 } else { 0 };
f.render_widget(paragraph, split_bar[index]);
}
/// Show error popup over whole screen
pub fn draw_help_box<B: Backend>(f: &mut Frame<'_, B>) {
let title = format!(" {} ", VERSION);
let mut description_text =
String::from("\n A basic docker container information viewer and controller.");
description_text.push_str("\n Tab or Alt+Tab to change panels, arrows to change lines, enter to send docker container commands.");
description_text.push_str("\n Mouse input also available.");
description_text.push_str("\n ( q ) to quit at any time.");
description_text
.push_str("\n\n currenty an early work in progress, all and any input appreciated");
description_text.push_str(format!("\n {}", REPO.trim()).as_str());
let mut max_line_width = 0;
let all_text = format!("{}{}", NAME_TEXT, description_text);
all_text.lines().into_iter().for_each(|line| {
let width = line.chars().count();
if width > max_line_width {
max_line_width = width;
}
});
let mut lines = all_text.lines().count();
// Add some vertical and horizontal padding to the info box
lines += 3;
max_line_width += 4;
let name_paragraph = Paragraph::new(NAME_TEXT)
.style(Style::default().bg(Color::Magenta).fg(Color::White))
.block(Block::default())
.alignment(Alignment::Center);
let description_paragraph = Paragraph::new(description_text.as_str())
.style(Style::default().bg(Color::Magenta).fg(Color::Black))
.block(Block::default())
.alignment(Alignment::Left);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Black));
let area = centered_info(lines as u16, max_line_width as u16, f.size());
let split_popup = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Max(NAME_TEXT.lines().count() as u16),
Constraint::Max(description_text.lines().count() as u16),
]
.as_ref(),
)
.split(area);
// Order is important here
f.render_widget(Clear, area);
f.render_widget(name_paragraph, split_popup[0]);
f.render_widget(description_paragraph, split_popup[1]);
f.render_widget(block, area);
}
/// Show error popup over whole screen
pub fn draw_error<B: Backend>(f: &mut Frame<'_, B>, error: AppError, seconds: Option<u8>) {
let block = Block::default()
.title(" Error ")
.border_type(BorderType::Rounded)
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let to_push = match error {
AppError::DockerConnect => {
format!(
"\n\n {}::v{} closing in {:02} seconds",
NAME,
VERSION,
seconds.unwrap_or(5)
)
}
_ => String::from("\n\n ( c ) to clear error\n ( q ) to quit oxker"),
};
let mut text = format!("\n{}", error);
text.push_str(to_push.as_str());
let mut max_line_width = 0;
text.lines().into_iter().for_each(|line| {
let width = line.chars().count();
if width > max_line_width {
max_line_width = width;
}
});
let mut lines = text.lines().count();
// Add some horizontal & vertical margins
max_line_width += 8;
lines += 3;
let paragraph = Paragraph::new(text)
.style(Style::default().bg(Color::Red).fg(Color::White))
.block(block)
.alignment(Alignment::Center);
let area = centered_info(lines as u16, max_line_width as u16, f.size());
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
/// draw a box in the center of the screen, based on max line width + number of lines
fn centered_info(number_lines: u16, max_line_width: u16, r: Rect) -> Rect {
// This can panic if number_lines or max_line_width is larger than r.height or r.width
let blank_vertical = (r.height - number_lines) / 2;
let blank_horizontal = (r.width - max_line_width) / 2;
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Max(blank_vertical),
Constraint::Max(number_lines),
Constraint::Max(blank_vertical),
]
.as_ref(),
)
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Max(blank_horizontal),
Constraint::Max(max_line_width),
Constraint::Max(blank_horizontal),
]
.as_ref(),
)
.split(popup_layout[1])[1]
}
+161
View File
@@ -0,0 +1,161 @@
use std::{collections::HashMap, fmt};
use tui::layout::Rect;
#[derive(Debug, PartialEq, std::hash::Hash, std::cmp::Eq, Clone, Copy)]
pub enum SelectablePanel {
Containers,
Commands,
Logs,
}
#[derive(Debug)]
pub enum Loading {
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
Nine,
Ten,
}
impl Loading {
pub fn next(&self) -> Self {
match self {
Self::One => Self::Two,
Self::Two => Self::Three,
Self::Three => Self::Four,
Self::Four => Self::Five,
Self::Five => Self::Six,
Self::Six => Self::Seven,
Self::Seven => Self::Eight,
Self::Eight => Self::Nine,
Self::Nine => Self::Ten,
Self::Ten => Self::One,
// Self::Five => Self::One
}
}
}
// "⠋",
// "⠙",
// "⠹",
// "⠸",
// "⠼",
// "⠴",
// "⠦",
// "⠧",
// "⠇",
// "⠏"
impl fmt::Display for Loading {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let disp = match self {
Self::One => "",
Self::Two => "",
Self::Three => "",
Self::Four => "",
Self::Five => "",
Self::Six => "",
Self::Seven => "",
Self::Eight => "",
Self::Nine => "",
Self::Ten => "",
};
write!(f, "{}", disp)
}
}
impl SelectablePanel {
pub fn title(self) -> &'static str {
match self {
Self::Containers => "Containers",
Self::Logs => "Logs",
_ => "",
}
}
pub fn next(self) -> Self {
match self {
Self::Containers => Self::Commands,
Self::Commands => Self::Logs,
Self::Logs => Self::Containers,
}
}
pub fn prev(self) -> Self {
match self {
Self::Containers => Self::Logs,
Self::Commands => Self::Containers,
Self::Logs => Self::Commands,
}
}
}
/// Global gui_state, stored in an Arc<Mutex>
#[derive(Debug)]
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<SelectablePanel, Rect>,
loading: Loading,
pub selected_panel: SelectablePanel,
pub show_help: bool,
}
impl GuiState {
/// Generate a default gui_state
pub fn default() -> Self {
Self {
area_map: HashMap::new(),
loading: Loading::One,
selected_panel: SelectablePanel::Containers,
show_help: false,
}
}
/// 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();
}
/// Check if a given Rect (a clicked area of 1x1), interacts with any known panels
pub fn rect_insersects(&mut self, rect: Rect) {
if let Some(data) = self
.area_map
.iter()
.filter(|i| i.1.intersects(rect))
.collect::<Vec<_>>()
.get(0)
{
self.selected_panel = *data.0;
}
}
/// 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);
}
/// Change to next selectable panel
pub fn next_panel(&mut self) {
self.selected_panel = self.selected_panel.next();
}
/// Change to previous selectable panel
pub fn previous_panel(&mut self) {
self.selected_panel = self.selected_panel.prev();
}
pub fn next_loading(&mut self) {
self.loading = self.loading.next()
}
pub fn get_loading(&mut self) -> String {
self.loading.to_string()
}
pub fn reset_loading(&mut self) {
self.loading = Loading::One;
}
}
+215
View File
@@ -0,0 +1,215 @@
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use parking_lot::Mutex;
use std::sync::atomic::AtomicBool;
use std::{
io,
sync::{atomic::Ordering, Arc},
};
use tokio::sync::broadcast::Sender;
use tui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
Frame, Terminal,
};
mod color_match;
mod draw_blocks;
mod gui_state;
pub use self::color_match::*;
pub use self::gui_state::{GuiState, SelectablePanel};
use crate::{app_data::AppData, app_error::AppError, input_handler::InputMessages};
use draw_blocks::*;
/// Take control of the terminal in order to draw gui
pub async fn create_ui(
app_data: Arc<Mutex<AppData>>,
sender: Sender<InputMessages>,
is_running: Arc<AtomicBool>,
gui_state: Arc<Mutex<GuiState>>,
) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_app(&mut terminal, app_data, sender, is_running, gui_state).await;
disable_raw_mode().unwrap();
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor().unwrap();
if let Err(err) = res {
err.disp()
}
Ok(())
}
/// Run a loop to draw the gui
async fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
app_data: Arc<Mutex<AppData>>,
sender: Sender<InputMessages>,
is_running: Arc<AtomicBool>,
gui_state: Arc<Mutex<GuiState>>,
) -> Result<(), AppError> {
let input_poll_rate = std::time::Duration::from_millis(75);
// Check for docker connect errors before attempting to draw the gui
let e = app_data.lock().get_error();
if let Some(error) = e {
if let AppError::DockerConnect = error {
let mut seconds = 5;
loop {
if seconds < 1 {
is_running.store(false, Ordering::SeqCst);
break;
}
terminal
.draw(|f| draw_error(f, AppError::DockerConnect, Some(seconds)))
.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
seconds -= 1;
}
}
} else {
loop {
terminal.draw(|f| ui(f, &app_data, &gui_state)).unwrap();
if crossterm::event::poll(input_poll_rate).unwrap() {
let event = event::read().unwrap();
if let Event::Key(key) = event {
sender
.send(InputMessages::ButtonPress(key.code))
.unwrap_or(0);
} else if let Event::Mouse(m) = event {
sender.send(InputMessages::MouseEvent(m)).unwrap_or(0);
} else if let Event::Resize(_, _) = event {
gui_state.lock().clear_area_map();
terminal.autoresize().unwrap_or(());
}
}
if !is_running.load(Ordering::SeqCst) {
break;
}
}
}
Ok(())
}
fn ui<B: Backend>(
f: &mut Frame<'_, B>,
app_data: &Arc<Mutex<AppData>>,
gui_state: &Arc<Mutex<GuiState>>,
) {
// set max height for container section, needs +4 to deal with docker commands list and borders
let mut height = app_data.lock().get_container_len();
if height < 12 {
height += 4;
} else {
height = 12
}
let column_widths = app_data.lock().get_width();
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 selected_panel = gui_state.lock().selected_panel;
let show_help = gui_state.lock().show_help;
let whole_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Min(100)].as_ref())
.split(f.size());
// Split into 3, containers+controls, logs, then graphs
let upper_main = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Max(height as u16), Constraint::Percentage(50)].as_ref())
.split(whole_layout[1]);
let top_split = if has_containers {
vec![Constraint::Percentage(90), Constraint::Percentage(10)]
} else {
vec![Constraint::Percentage(100)]
};
// Containers + docker commands
let top_panel = Layout::default()
.direction(Direction::Horizontal)
.constraints(top_split.as_ref())
.split(upper_main[0]);
let lower_split = if has_containers {
vec![Constraint::Percentage(75), Constraint::Percentage(25)]
} else {
vec![Constraint::Percentage(100)]
};
// Split into 3, containers+controls, logs, then graphs
let lower_main = Layout::default()
.direction(Direction::Vertical)
.constraints(lower_split.as_ref())
.split(upper_main[1]);
draw_containers(
app_data,
top_panel[0],
f,
gui_state,
&selected_panel,
&column_widths,
);
if has_containers {
draw_commands(
app_data,
top_panel[1],
f,
gui_state,
log_index,
&selected_panel,
);
}
draw_logs(
app_data,
lower_main[0],
f,
gui_state,
log_index,
&selected_panel,
);
draw_info_bar(
whole_layout[0],
&column_widths,
f,
has_containers,
show_help,
);
// only draw charts if there are containers
if has_containers {
draw_chart(f, lower_main[1], app_data, log_index);
}
// Check if error, and show popup if so
if show_help {
draw_help_box(f);
}
if let Some(error) = has_error {
app_data.lock().show_error = true;
draw_error(f, error, None);
}
}