Initial Commit
This commit is contained in:
42
src/circle.rs
Normal file
42
src/circle.rs
Normal 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
85
src/collector.rs
Normal 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
119
src/docker.rs
Normal 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
26
src/main.rs
Normal 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
16
src/state.rs
Normal 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
197
src/ui.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user