Implement stuff

This commit is contained in:
2022-04-19 01:04:59 +02:00
parent b9e6eeeb80
commit 7ff5f62413
6 changed files with 208 additions and 86 deletions

2
Cargo.lock generated
View File

@ -89,7 +89,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "composetui" name = "composers"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",

View File

@ -1,5 +1,5 @@
[package] [package]
name = "composetui" name = "composers"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"

View File

@ -2,13 +2,14 @@ use crate::docker;
use crate::state::{StackStats, StateEvent}; use crate::state::{StackStats, StateEvent};
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
pub(crate) async fn start_collector(events: mpsc::Sender<StateEvent>) -> anyhow::Result<()> { pub(crate) async fn start_collector(events: mpsc::Sender<StateEvent>) -> anyhow::Result<()> {
let mut old_stacks: HashMap<String, StackStats> = HashMap::new(); let mut old_stacks: HashMap<String, StackStats> = HashMap::new();
loop { loop {
let new_stacks = collect_data()?; let new_stacks = collect_data().await?;
for (name, stats) in &new_stacks { for (name, stats) in &new_stacks {
let mut send_put = false; let mut send_put = false;
@ -44,19 +45,31 @@ pub(crate) async fn start_collector(events: mpsc::Sender<StateEvent>) -> anyhow:
} }
} }
pub(crate) fn collect_data() -> anyhow::Result<HashMap<String, StackStats>> { pub(crate) async fn collect_data() -> anyhow::Result<HashMap<String, StackStats>> {
let docker_stacks = docker::list_stacks()?; let docker_stacks = docker::list_stacks().await?;
let mut out = HashMap::new();
let mut stack_jobs = Vec::new();
for docker_stack in docker_stacks { for docker_stack in docker_stacks {
let containers = docker::list_containers(&docker_stack)?; stack_jobs.push(task::spawn(collect_stack_data(docker_stack)));
let processes = containers }
.iter()
.map(|container| docker::list_processes(&docker_stack, &container)) let mut out = HashMap::with_capacity(stack_jobs.len());
.flatten(/* ignore errors */)
.flatten(/* flatten per-container process list */) for stack_job in stack_jobs {
.collect::<Vec<_>>(); let (name, stats) = stack_job.await??;
out.insert(name, stats);
}
Ok(out)
}
async fn collect_stack_data(docker_stack: docker::Stack) -> anyhow::Result<(String, StackStats)> {
let containers = docker::list_containers(&docker_stack).await?;
let mut processes = Vec::new();
for container in &containers {
processes.append(&mut docker::list_processes(&docker_stack, container).await?);
}
let memory = (1usize << 20) * 16; let memory = (1usize << 20) * 16;
let memory_usage = processes.iter().map(|proc| proc.memory_usage).sum(); let memory_usage = processes.iter().map(|proc| proc.memory_usage).sum();
@ -78,8 +91,5 @@ pub(crate) fn collect_data() -> anyhow::Result<HashMap<String, StackStats>> {
memory_percent: (memory_usage as f64 / memory as f64), memory_percent: (memory_usage as f64 / memory as f64),
}; };
out.insert(docker_stack.name, stats); Ok((docker_stack.name, stats))
}
Ok(out)
} }

View File

@ -1,6 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use std::fs; use tokio::fs;
use std::process::Command; use tokio::process::Command;
use tokio::task;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Stack { pub struct Stack {
@ -33,21 +34,23 @@ pub struct Container {
} }
/// Run `docker compose ls` and parse the output /// Run `docker compose ls` and parse the output
pub fn list_stacks() -> anyhow::Result<Vec<Stack>> { pub async fn list_stacks() -> anyhow::Result<Vec<Stack>> {
let output = Command::new("docker") let output = Command::new("docker")
.args(&["compose", "ls", "--format", "json"]) .args(&["compose", "ls", "--format", "json"])
.output()?; .output()
.await?;
let stdout = std::str::from_utf8(&output.stdout)?; let stdout = std::str::from_utf8(&output.stdout)?;
Ok(serde_json::from_str(&stdout)?) Ok(serde_json::from_str(&stdout)?)
} }
/// Run `docker compose ps` and parse the output /// Run `docker compose ps` and parse the output
pub fn list_containers(stack: &Stack) -> anyhow::Result<Vec<Container>> { pub async fn list_containers(stack: &Stack) -> anyhow::Result<Vec<Container>> {
let output = Command::new("docker") let output = Command::new("docker")
.arg("compose") .arg("compose")
.args(&["--file", &stack.config_file]) .args(&["--file", &stack.config_file])
.args(&["ps", "--format", "json"]) .args(&["ps", "--format", "json"])
.output()?; .output()
.await?;
let stdout = std::str::from_utf8(&output.stdout)?; let stdout = std::str::from_utf8(&output.stdout)?;
Ok(serde_json::from_str(&stdout)?) Ok(serde_json::from_str(&stdout)?)
@ -64,16 +67,19 @@ pub struct Process {
} }
/// Run `docker compose top` and parse the output /// Run `docker compose top` and parse the output
pub fn list_processes(stack: &Stack, container: &Container) -> anyhow::Result<Vec<Process>> { pub async fn list_processes(stack: &Stack, container: &Container) -> anyhow::Result<Vec<Process>> {
let output = Command::new("docker") let output = Command::new("docker")
.arg("compose") .arg("compose")
.args(&["--file", &stack.config_file]) .args(&["--file", &stack.config_file])
.args(&["top", &container.service]) .args(&["top", &container.service])
.output()?; .output()
.await?;
let stdout = std::str::from_utf8(&output.stdout)?; let stdout = std::str::from_utf8(&output.stdout)?;
let mut processes = Vec::new(); let mut processes = Vec::new();
let mut proc_info_set = Vec::new();
for line in stdout.lines().skip(2) { for line in stdout.lines().skip(2) {
if line.trim().is_empty() { if line.trim().is_empty() {
continue; continue;
@ -91,29 +97,32 @@ pub fn list_processes(stack: &Stack, container: &Container) -> anyhow::Result<Ve
let _time = words.next().ok_or_else(err)?; let _time = words.next().ok_or_else(err)?;
let cmd: String = words.collect(); let cmd: String = words.collect();
let mut memory_usage = 0; proc_info_set.push(task::spawn(fs::read_to_string(format!(
"/proc/{pid}/status"
let proc_info = fs::read_to_string(format!("/proc/{pid}/status"))?; ))));
for (key, value) in proc_info.lines().flat_map(|line| line.split_once(':')) {
let value = value.trim();
match key {
"VmRSS" => {
memory_usage = value.trim_end_matches(" kB").parse()?;
}
_ => {}
}
}
processes.push(Process { processes.push(Process {
uid, uid,
pid, pid,
ppid, ppid,
cmd, cmd,
memory_usage, memory_usage: 0,
}) })
} }
for (process, proc_info) in processes.iter_mut().zip(proc_info_set.into_iter()) {
let proc_info = proc_info.await??;
for (key, value) in proc_info.lines().flat_map(|line| line.split_once(':')) {
let value = value.trim();
match key {
"VmRSS" => {
process.memory_usage = value.trim_end_matches(" kB").parse()?;
}
_ => {}
}
}
}
Ok(processes) Ok(processes)
} }

View File

@ -17,10 +17,13 @@ async fn main() -> anyhow::Result<()> {
let mut ui = Ui::new(event_rx); let mut ui = Ui::new(event_rx);
if let Err(e) = task::spawn_blocking(move || ui.start()).await? { if let Err(e) = task::spawn_blocking(move || ui.start()).await? {
println!("{e}"); println!("Error: {e}");
} }
collector.abort(); collector.abort();
if let Ok(Err(e)) = collector.await {
println!("Error: {e}");
}
Ok(()) Ok(())
} }

156
src/ui.rs
View File

@ -7,7 +7,7 @@ use crossterm::{
}; };
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::time::Duration; use std::time::Duration;
use std::{io, iter}; use std::{cmp, io, iter};
use tokio::sync::mpsc::{self, error::TryRecvError}; use tokio::sync::mpsc::{self, error::TryRecvError};
use tui::{ use tui::{
backend::{Backend, CrosstermBackend}, backend::{Backend, CrosstermBackend},
@ -19,15 +19,24 @@ use tui::{
Frame, Terminal, Frame, Terminal,
}; };
const KEYS_DOWN: &[KeyCode] = &[KeyCode::Down, KeyCode::PageDown, KeyCode::Char('j')];
const KEYS_UP: &[KeyCode] = &[KeyCode::Up, KeyCode::PageUp, KeyCode::Char('k')];
pub struct Ui { pub struct Ui {
stacks: BTreeMap<String, StackStats>, stacks: BTreeMap<String, StackStats>,
events: mpsc::Receiver<StateEvent>, events: mpsc::Receiver<StateEvent>,
event_log: [String; 7],
event_count: usize,
scroll: usize,
} }
impl Ui { impl Ui {
pub fn new(events: mpsc::Receiver<StateEvent>) -> Self { pub fn new(events: mpsc::Receiver<StateEvent>) -> Self {
Self { Self {
stacks: Default::default(), stacks: Default::default(),
scroll: 0,
event_log: Default::default(),
event_count: 0,
events, events,
} }
} }
@ -63,14 +72,20 @@ impl Ui {
let mut draw = false; let mut draw = false;
let timeout = Duration::from_millis(16); let timeout = Duration::from_millis(16);
if crossterm::event::poll(timeout)? { if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
draw = true; draw = true;
if let Event::Key(key) = event::read()? {
if let KeyCode::Char('q') = key.code { if let KeyCode::Char('q') = key.code {
return Ok(()); return Ok(());
} else if KEYS_UP.contains(&key.code) {
self.scroll = self.scroll.saturating_sub(1);
} else if KEYS_DOWN.contains(&key.code) {
self.scroll += 1;
} }
} }
} }
self.scroll = cmp::min(self.stacks.len(), self.scroll);
match self.events.try_recv() { match self.events.try_recv() {
Err(TryRecvError::Empty) => {} Err(TryRecvError::Empty) => {}
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
@ -88,53 +103,116 @@ impl Ui {
} }
fn handle_event(&mut self, event: StateEvent) { fn handle_event(&mut self, event: StateEvent) {
let log_msg;
match event { match event {
StateEvent::Delete { name } => { StateEvent::Delete { name } => {
log_msg = format!("{:.4}: DELETE {name}", self.event_count);
self.stacks.remove(&name); self.stacks.remove(&name);
} }
StateEvent::Put { name, stats } => { StateEvent::Put { name, stats } => {
log_msg = format!("{:.4}: UPDATE {name}", self.event_count);
self.stacks.insert(name, stats); self.stacks.insert(name, stats);
} }
} }
self.event_log.rotate_left(1);
self.event_log[self.event_log.len() - 1] = log_msg;
self.event_count += 1;
} }
fn draw<B: Backend>(&self, f: &mut Frame<'_, B>) { fn draw<B: Backend>(&self, f: &mut Frame<'_, B>) {
let size = f.size(); let size = f.size();
const BOX_HEIGHT: u16 = 9; const BOX_HEIGHT: u16 = 9;
let fitted_boxes = size.height / BOX_HEIGHT; const BOX_WIDTH: u16 = 52;
let fitted_boxes_x = cmp::max(1, size.width / BOX_WIDTH);
let fitted_boxes_y = size.height / BOX_HEIGHT;
let partial_box_size = size.height % BOX_HEIGHT; let partial_box_size = size.height % BOX_HEIGHT;
let partial_box_exists = partial_box_size != 0; let partial_box_exists = partial_box_size != 0;
let constraints: Vec<_> = iter::repeat(Constraint::Length(BOX_HEIGHT)) let x_constraints = vec![Constraint::Length(BOX_WIDTH); fitted_boxes_x as usize];
.take(fitted_boxes as usize)
.chain(partial_box_exists.then(|| Constraint::Length(partial_box_size)))
.collect();
let chunks = Layout::default() let x_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(x_constraints);
let y_constraints: Vec<_> = iter::repeat(Constraint::Length(BOX_HEIGHT))
.take(fitted_boxes_y as usize)
.chain(iter::once(Constraint::Length(0))) // padding
//.chain(partial_box_exists.then(|| Constraint::Length(partial_box_size)))
.collect();
let y_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(y_constraints)
.split(f.size()); .split(f.size());
for (i, (name, info)) in (0..fitted_boxes).zip(self.stacks.iter()) { //self.draw_info(f, chunks[0]);
let area = chunks[i as usize];
self.draw_stack(f, area, name, info); let mut stacks = self
.stacks
.iter()
.skip((self.scroll * fitted_boxes_x as usize).saturating_sub(1));
let mut first = self.scroll == 0;
'outer: for &y_chunk in &y_chunks[..fitted_boxes_y as usize] {
for x_chunk in x_layout.split(y_chunk) {
if first {
first = false;
self.draw_info(f, x_chunk);
} else if let Some((name, info)) = stacks.next() {
self.draw_stack(f, x_chunk, name, info);
} else {
break 'outer;
}
}
}
} }
if partial_box_exists { fn draw_info<B: Backend>(&self, f: &mut Frame<B>, area: Rect) {
let block = Block::default() let block = Block::default().borders(Borders::ALL);
.title("Partial") let inner = block.inner(area);
.borders(Borders::ALL.difference(Borders::BOTTOM)); f.render_widget(block, area);
f.render_widget(block, chunks[chunks.len() - 1]);
} let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(20), Constraint::Min(10)])
.split(inner);
let unhealthy_stacks = self
.stacks
.values()
.filter(|stack| stack.stopped_containers > 0)
.count();
let notices = Paragraph::new(vec![
Spans::from("Status"),
Spans::from(format!("stacks: {}", self.stacks.len())),
Spans::from(""),
if unhealthy_stacks > 0 {
let style = Style::default().fg(Color::Red);
Span::styled(format!("unhealthy: {}", unhealthy_stacks), style).into()
} else {
Spans::from("")
},
]);
f.render_widget(notices, chunks[0]);
let log_style = Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::ITALIC);
let event_log = Paragraph::new(
self.event_log
.clone()
.map(|msg| Span::styled(msg, log_style))
.map(Spans::from)
.to_vec(),
);
f.render_widget(event_log, chunks[1]);
} }
fn draw_stack<B: Backend>(&self, f: &mut Frame<B>, area: Rect, name: &str, info: &StackStats) { fn draw_stack<B: Backend>(&self, f: &mut Frame<B>, area: Rect, name: &str, info: &StackStats) {
let title_style = Style::default() let title_style = Style::default().fg(Color::LightMagenta).bg(Color::Black);
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD);
let block = Block::default() let block = Block::default()
.title(Span::styled(name, title_style)) .title(Span::styled(name, title_style))
@ -162,11 +240,11 @@ impl Ui {
) )
.into() .into()
} else { } else {
Spans::from("") Spans::default()
}, },
Spans::from(""), Spans::default(),
Spans::from(format!("processes: {}", info.process_count)), Spans::from(format!("processes: {}", info.process_count)),
Spans::from(format!("memory: {} KBs", info.memory_usage)), Spans::from(format!("memory: {}", fmt_kilobytes(info.memory_usage))),
]); ]);
f.render_widget(services, chunks[0]); f.render_widget(services, chunks[0]);
@ -175,19 +253,28 @@ impl Ui {
.x_bounds([-5.0, 5.0]) .x_bounds([-5.0, 5.0])
.y_bounds([-5.0, 5.0]) .y_bounds([-5.0, 5.0])
.paint(move |ctx| { .paint(move |ctx| {
let (bg, fg, scaled_percent) = if percent < 0.33 {
(Color::Blue, Color::Green, percent * 3.0)
} else if percent < 0.67 {
(Color::Green, Color::Yellow, (percent - 0.33) * 3.0)
} else {
(Color::Yellow, Color::Red, (percent - 0.67) * 3.0)
};
ctx.draw(&Circle { ctx.draw(&Circle {
r: 4.0, r: 4.0,
color: Color::Blue, color: bg,
..Default::default() ..Default::default()
}); });
ctx.draw(&Circle { ctx.draw(&Circle {
r: 4.0, r: 4.0,
color: Color::Green, color: fg,
start: 360 - (percent * 360.0) as u16, start: 360 - (scaled_percent * 360.0) as u16,
stop: 360, stop: 360,
..Default::default() ..Default::default()
}); });
ctx.print(-0.5, 0.0, name); ctx.print(-0.5, 0.0, name);
ctx.print(-0.5, -2.0, format!("{:02.0}%", percent * 100.0));
}) })
}; };
@ -195,3 +282,16 @@ impl Ui {
f.render_widget(gauge_canvas(info.memory_percent, "MEM"), chunks[2]); f.render_widget(gauge_canvas(info.memory_percent, "MEM"), chunks[2]);
} }
} }
fn fmt_kilobytes<'a>(kbs: usize) -> String {
let gibi = 1 << 20;
let mebi = 1 << 10;
if kbs > gibi * 2 {
format!("{} GBs", kbs / gibi)
} else if kbs > mebi * 10 {
format!("{} MBs", kbs / mebi)
} else {
format!("{} KBs", kbs)
}
}