Initial Commit

This commit is contained in:
2022-04-18 20:53:57 +02:00
commit b9e6eeeb80
9 changed files with 1151 additions and 0 deletions

42
src/circle.rs Normal file
View File

@ -0,0 +1,42 @@
use std::f64::consts::PI;
use tui::style::Color;
use tui::widgets::canvas::{Painter, Shape};
pub struct Circle {
pub x: f64,
pub y: f64,
pub r: f64,
pub start: u16,
pub stop: u16,
pub color: Color,
}
impl Default for Circle {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
r: 1.0,
start: 0,
stop: 360,
color: Color::White,
}
}
}
impl Shape for Circle {
fn draw(&self, painter: &mut Painter) {
let (x, y) = (self.x, self.y - self.r);
for angle in (self.start..self.stop).map(|n| n as f64 / 180.0 * PI) {
let (x, y) = rotate(x, y, angle);
if let Some((x, y)) = painter.get_point(x, y) {
painter.paint(x, y, self.color);
}
}
}
}
fn rotate(x: f64, y: f64, a: f64) -> (f64, f64) {
((x * a.cos() - y * a.sin()), (x * a.sin() + y * a.cos()))
}

85
src/collector.rs Normal file
View File

@ -0,0 +1,85 @@
use crate::docker;
use crate::state::{StackStats, StateEvent};
use std::collections::HashMap;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
pub(crate) async fn start_collector(events: mpsc::Sender<StateEvent>) -> anyhow::Result<()> {
let mut old_stacks: HashMap<String, StackStats> = HashMap::new();
loop {
let new_stacks = collect_data()?;
for (name, stats) in &new_stacks {
let mut send_put = false;
if let Some(old_stats) = old_stacks.get(name) {
if old_stats != stats {
send_put = true;
}
} else {
send_put = true;
}
if send_put {
events
.send(StateEvent::Put {
name: name.clone(),
stats: stats.clone(),
})
.await?;
}
}
for name in old_stacks.keys() {
if !new_stacks.contains_key(name) {
events
.send(StateEvent::Delete { name: name.clone() })
.await?;
}
}
old_stacks = new_stacks;
sleep(Duration::from_secs(1)).await;
}
}
pub(crate) fn collect_data() -> anyhow::Result<HashMap<String, StackStats>> {
let docker_stacks = docker::list_stacks()?;
let mut out = HashMap::new();
for docker_stack in docker_stacks {
let containers = docker::list_containers(&docker_stack)?;
let processes = containers
.iter()
.map(|container| docker::list_processes(&docker_stack, &container))
.flatten(/* ignore errors */)
.flatten(/* flatten per-container process list */)
.collect::<Vec<_>>();
let memory = (1usize << 20) * 16;
let memory_usage = processes.iter().map(|proc| proc.memory_usage).sum();
let container_count = containers.len() as u32;
let running_containers = containers
.iter()
.filter(|c| c.state.contains("running"))
.count() as u32;
let stopped_containers = container_count - running_containers;
let stats = StackStats {
containers: container_count,
running_containers,
stopped_containers,
process_count: processes.len() as u32,
cpu_percent: 0.5, // TODO
memory_usage,
memory_percent: (memory_usage as f64 / memory as f64),
};
out.insert(docker_stack.name, stats);
}
Ok(out)
}

119
src/docker.rs Normal file
View File

@ -0,0 +1,119 @@
use serde::Deserialize;
use std::fs;
use std::process::Command;
#[derive(Deserialize)]
pub struct Stack {
#[serde(rename = "ConfigFiles")]
pub config_file: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Status")]
pub status: String,
}
#[derive(Deserialize)]
pub struct Container {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Service")]
pub service: String,
#[serde(rename = "State")]
pub state: String,
#[serde(rename = "Health")]
pub health: String,
#[serde(rename = "Project")]
pub project: String,
}
/// Run `docker compose ls` and parse the output
pub fn list_stacks() -> anyhow::Result<Vec<Stack>> {
let output = Command::new("docker")
.args(&["compose", "ls", "--format", "json"])
.output()?;
let stdout = std::str::from_utf8(&output.stdout)?;
Ok(serde_json::from_str(&stdout)?)
}
/// Run `docker compose ps` and parse the output
pub fn list_containers(stack: &Stack) -> anyhow::Result<Vec<Container>> {
let output = Command::new("docker")
.arg("compose")
.args(&["--file", &stack.config_file])
.args(&["ps", "--format", "json"])
.output()?;
let stdout = std::str::from_utf8(&output.stdout)?;
Ok(serde_json::from_str(&stdout)?)
}
pub struct Process {
pub uid: String,
pub pid: u32,
pub ppid: u32,
pub cmd: String,
/// Memory usage in KBs
pub memory_usage: usize,
}
/// Run `docker compose top` and parse the output
pub fn list_processes(stack: &Stack, container: &Container) -> anyhow::Result<Vec<Process>> {
let output = Command::new("docker")
.arg("compose")
.args(&["--file", &stack.config_file])
.args(&["top", &container.service])
.output()?;
let stdout = std::str::from_utf8(&output.stdout)?;
let mut processes = Vec::new();
for line in stdout.lines().skip(2) {
if line.trim().is_empty() {
continue;
}
let mut words = line.split_whitespace();
let err = || anyhow::format_err!("invalid docker top output");
let uid = words.next().ok_or_else(err)?.to_string();
let pid = words.next().ok_or_else(err)?.parse()?;
let ppid = words.next().ok_or_else(err)?.parse()?;
let _c = words.next().ok_or_else(err)?;
let _stime = words.next().ok_or_else(err)?;
let _tty = words.next().ok_or_else(err)?;
let _time = words.next().ok_or_else(err)?;
let cmd: String = words.collect();
let mut memory_usage = 0;
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 {
uid,
pid,
ppid,
cmd,
memory_usage,
})
}
Ok(processes)
}

26
src/main.rs Normal file
View File

@ -0,0 +1,26 @@
mod circle;
mod collector;
mod docker;
mod state;
mod ui;
use tokio::sync::mpsc;
use tokio::task;
use ui::Ui;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (event_tx, event_rx) = mpsc::channel(128);
let collector = task::spawn(collector::start_collector(event_tx));
let mut ui = Ui::new(event_rx);
if let Err(e) = task::spawn_blocking(move || ui.start()).await? {
println!("{e}");
}
collector.abort();
Ok(())
}

16
src/state.rs Normal file
View File

@ -0,0 +1,16 @@
#[derive(Clone, Debug, PartialEq)]
pub struct StackStats {
pub containers: u32,
pub running_containers: u32,
pub stopped_containers: u32,
pub process_count: u32,
pub cpu_percent: f64,
pub memory_usage: usize,
pub memory_percent: f64,
}
#[derive(Debug)]
pub enum StateEvent {
Put { name: String, stats: StackStats },
Delete { name: String },
}

197
src/ui.rs Normal file
View File

@ -0,0 +1,197 @@
use crate::circle::Circle;
use crate::state::{StackStats, StateEvent};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::collections::BTreeMap;
use std::time::Duration;
use std::{io, iter};
use tokio::sync::mpsc::{self, error::TryRecvError};
use tui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Span, Spans},
widgets::canvas::Canvas,
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
pub struct Ui {
stacks: BTreeMap<String, StackStats>,
events: mpsc::Receiver<StateEvent>,
}
impl Ui {
pub fn new(events: mpsc::Receiver<StateEvent>) -> Self {
Self {
stacks: Default::default(),
events,
}
}
pub fn start(&mut self) -> anyhow::Result<()> {
// setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// create app and run it
let result = self.run(&mut terminal);
// restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> anyhow::Result<()> {
loop {
terminal.draw(|f| self.draw(f))?;
loop {
let mut draw = false;
let timeout = Duration::from_millis(16);
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
draw = true;
if let KeyCode::Char('q') = key.code {
return Ok(());
}
}
}
match self.events.try_recv() {
Err(TryRecvError::Empty) => {}
Err(e) => return Err(e.into()),
Ok(event) => {
self.handle_event(event);
draw = true;
}
}
if draw {
break;
}
}
}
}
fn handle_event(&mut self, event: StateEvent) {
match event {
StateEvent::Delete { name } => {
self.stacks.remove(&name);
}
StateEvent::Put { name, stats } => {
self.stacks.insert(name, stats);
}
}
}
fn draw<B: Backend>(&self, f: &mut Frame<'_, B>) {
let size = f.size();
const BOX_HEIGHT: u16 = 9;
let fitted_boxes = size.height / BOX_HEIGHT;
let partial_box_size = size.height % BOX_HEIGHT;
let partial_box_exists = partial_box_size != 0;
let constraints: Vec<_> = iter::repeat(Constraint::Length(BOX_HEIGHT))
.take(fitted_boxes as usize)
.chain(partial_box_exists.then(|| Constraint::Length(partial_box_size)))
.collect();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(f.size());
for (i, (name, info)) in (0..fitted_boxes).zip(self.stacks.iter()) {
let area = chunks[i as usize];
self.draw_stack(f, area, name, info);
}
if partial_box_exists {
let block = Block::default()
.title("Partial")
.borders(Borders::ALL.difference(Borders::BOTTOM));
f.render_widget(block, chunks[chunks.len() - 1]);
}
}
fn draw_stack<B: Backend>(&self, f: &mut Frame<B>, area: Rect, name: &str, info: &StackStats) {
let title_style = Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.title(Span::styled(name, title_style))
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(20),
Constraint::Length(15),
Constraint::Length(15),
Constraint::Length(0), // pad with white-space
])
.split(inner);
let services = Paragraph::new(vec![
Spans::from(""),
Spans::from(format!("containers: {}", info.containers)),
if info.stopped_containers != 0 {
Span::styled(
format!("stopped: {} (!)", info.stopped_containers),
Style::default().fg(Color::Red),
)
.into()
} else {
Spans::from("")
},
Spans::from(""),
Spans::from(format!("processes: {}", info.process_count)),
Spans::from(format!("memory: {} KBs", info.memory_usage)),
]);
f.render_widget(services, chunks[0]);
let gauge_canvas = |percent: f64, name: &'static str| {
Canvas::default()
.x_bounds([-5.0, 5.0])
.y_bounds([-5.0, 5.0])
.paint(move |ctx| {
ctx.draw(&Circle {
r: 4.0,
color: Color::Blue,
..Default::default()
});
ctx.draw(&Circle {
r: 4.0,
color: Color::Green,
start: 360 - (percent * 360.0) as u16,
stop: 360,
..Default::default()
});
ctx.print(-0.5, 0.0, name);
})
};
f.render_widget(gauge_canvas(info.cpu_percent, "CPU"), chunks[1]);
f.render_widget(gauge_canvas(info.memory_percent, "MEM"), chunks[2]);
}
}