v1 implemented
it compiles
This commit is contained in:
104
src/collectors.rs
Normal file
104
src/collectors.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, Networks, RefreshKind, System};
|
||||
|
||||
use crate::state::{DiskSample, Snapshot, Totals};
|
||||
|
||||
pub struct Collector {
|
||||
system: System,
|
||||
networks: Networks,
|
||||
disks: Disks,
|
||||
host: String,
|
||||
last_sample_at: Instant,
|
||||
}
|
||||
|
||||
impl Collector {
|
||||
pub fn new() -> Self {
|
||||
let mut system = System::new_with_specifics(
|
||||
RefreshKind::new()
|
||||
.with_cpu(CpuRefreshKind::everything())
|
||||
.with_memory(MemoryRefreshKind::everything()),
|
||||
);
|
||||
system.refresh_cpu();
|
||||
system.refresh_memory();
|
||||
system.refresh_processes();
|
||||
|
||||
let mut networks = Networks::new_with_refreshed_list();
|
||||
networks.refresh();
|
||||
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
|
||||
let host = System::host_name().unwrap_or_else(|| "unknown-host".to_string());
|
||||
|
||||
Self {
|
||||
system,
|
||||
networks,
|
||||
disks,
|
||||
host,
|
||||
last_sample_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sample(&mut self) -> Result<(Snapshot, Duration)> {
|
||||
self.system.refresh_cpu();
|
||||
self.system.refresh_memory();
|
||||
self.system.refresh_processes();
|
||||
self.networks.refresh();
|
||||
self.disks.refresh();
|
||||
|
||||
let elapsed = self.last_sample_at.elapsed();
|
||||
self.last_sample_at = Instant::now();
|
||||
|
||||
let cpu_total_percent = self.system.global_cpu_info().cpu_usage();
|
||||
let cpu_per_core_percent = self
|
||||
.system
|
||||
.cpus()
|
||||
.iter()
|
||||
.map(|cpu| cpu.cpu_usage())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mem_total_bytes = self.system.total_memory();
|
||||
let mem_used_bytes = self.system.used_memory();
|
||||
let swap_total_bytes = self.system.total_swap();
|
||||
let swap_used_bytes = self.system.used_swap();
|
||||
|
||||
let disk_samples = self
|
||||
.disks
|
||||
.iter()
|
||||
.map(|disk| {
|
||||
let total = disk.total_space();
|
||||
let used = total.saturating_sub(disk.available_space());
|
||||
DiskSample {
|
||||
name: disk.name().to_string_lossy().into_owned(),
|
||||
total_bytes: total,
|
||||
used_bytes: used,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (rx_bytes, tx_bytes) = self.networks.iter().fold((0_u64, 0_u64), |acc, (_, data)| {
|
||||
(
|
||||
acc.0.saturating_add(data.total_received()),
|
||||
acc.1.saturating_add(data.total_transmitted()),
|
||||
)
|
||||
});
|
||||
|
||||
let snapshot = Snapshot {
|
||||
host: self.host.clone(),
|
||||
uptime_seconds: System::uptime(),
|
||||
load_avg_1m: System::load_average().one,
|
||||
cpu_total_percent,
|
||||
cpu_per_core_percent,
|
||||
mem_used_bytes,
|
||||
mem_total_bytes,
|
||||
swap_used_bytes,
|
||||
swap_total_bytes,
|
||||
disk_samples,
|
||||
process_count: self.system.processes().len(),
|
||||
network_totals: Totals { rx_bytes, tx_bytes },
|
||||
};
|
||||
|
||||
Ok((snapshot, elapsed))
|
||||
}
|
||||
}
|
||||
16
src/config.rs
Normal file
16
src/config.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use clap::Parser;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "sloptop", about = "A lightweight TUI resource monitor")]
|
||||
pub struct Config {
|
||||
/// Polling interval in milliseconds
|
||||
#[arg(short, long, default_value_t = 1000, value_parser = clap::value_parser!(u64).range(200..=5000))]
|
||||
pub interval_ms: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn refresh_interval(&self) -> Duration {
|
||||
Duration::from_millis(self.interval_ms)
|
||||
}
|
||||
}
|
||||
91
src/main.rs
Normal file
91
src/main.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
mod collectors;
|
||||
mod config;
|
||||
mod state;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use collectors::Collector;
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use state::{compute_throughput, AppState, Throughput};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cfg = config::Config::parse();
|
||||
run(cfg)
|
||||
}
|
||||
|
||||
fn run(cfg: config::Config) -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut collector = Collector::new();
|
||||
let (first_snapshot, _) = collector.sample()?;
|
||||
|
||||
let mut app = AppState {
|
||||
snapshot: first_snapshot,
|
||||
network_rate: Throughput {
|
||||
rx_bps: 0.0,
|
||||
tx_bps: 0.0,
|
||||
},
|
||||
paused: false,
|
||||
detailed_view: false,
|
||||
refresh_interval: cfg.refresh_interval(),
|
||||
};
|
||||
|
||||
let mut last_refresh = Instant::now();
|
||||
|
||||
let result = loop {
|
||||
terminal.draw(|f| ui::draw(f, &app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
|
||||
KeyCode::Char('p') | KeyCode::Char(' ') => app.paused = !app.paused,
|
||||
KeyCode::Char('d') => app.detailed_view = !app.detailed_view,
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
app.refresh_interval = (app.refresh_interval + Duration::from_millis(200))
|
||||
.min(Duration::from_millis(5_000));
|
||||
}
|
||||
KeyCode::Char('-') => {
|
||||
app.refresh_interval = app
|
||||
.refresh_interval
|
||||
.saturating_sub(Duration::from_millis(200))
|
||||
.max(Duration::from_millis(200));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !app.paused && last_refresh.elapsed() >= app.refresh_interval {
|
||||
let prev_totals = app.snapshot.network_totals;
|
||||
let (new_snapshot, elapsed) = collector.sample()?;
|
||||
app.network_rate =
|
||||
compute_throughput(prev_totals, new_snapshot.network_totals, elapsed);
|
||||
app.snapshot = new_snapshot;
|
||||
last_refresh = Instant::now();
|
||||
}
|
||||
};
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
95
src/state.rs
Normal file
95
src/state.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Totals {
|
||||
pub rx_bytes: u64,
|
||||
pub tx_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Throughput {
|
||||
pub rx_bps: f64,
|
||||
pub tx_bps: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskSample {
|
||||
pub name: String,
|
||||
pub total_bytes: u64,
|
||||
pub used_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Snapshot {
|
||||
pub host: String,
|
||||
pub uptime_seconds: u64,
|
||||
pub load_avg_1m: f64,
|
||||
pub cpu_total_percent: f32,
|
||||
pub cpu_per_core_percent: Vec<f32>,
|
||||
pub mem_used_bytes: u64,
|
||||
pub mem_total_bytes: u64,
|
||||
pub swap_used_bytes: u64,
|
||||
pub swap_total_bytes: u64,
|
||||
pub disk_samples: Vec<DiskSample>,
|
||||
pub process_count: usize,
|
||||
pub network_totals: Totals,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
pub snapshot: Snapshot,
|
||||
pub network_rate: Throughput,
|
||||
pub paused: bool,
|
||||
pub detailed_view: bool,
|
||||
pub refresh_interval: Duration,
|
||||
}
|
||||
|
||||
pub fn compute_throughput(prev: Totals, curr: Totals, elapsed: Duration) -> Throughput {
|
||||
let seconds = elapsed.as_secs_f64().max(0.001);
|
||||
let rx_delta = curr.rx_bytes.saturating_sub(prev.rx_bytes) as f64;
|
||||
let tx_delta = curr.tx_bytes.saturating_sub(prev.tx_bytes) as f64;
|
||||
|
||||
Throughput {
|
||||
rx_bps: rx_delta / seconds,
|
||||
tx_bps: tx_delta / seconds,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn throughput_is_delta_over_time() {
|
||||
let prev = Totals {
|
||||
rx_bytes: 1_000,
|
||||
tx_bytes: 2_000,
|
||||
};
|
||||
let curr = Totals {
|
||||
rx_bytes: 3_000,
|
||||
tx_bytes: 5_000,
|
||||
};
|
||||
|
||||
let rate = compute_throughput(prev, curr, Duration::from_secs(2));
|
||||
|
||||
assert_eq!(rate.rx_bps, 1_000.0);
|
||||
assert_eq!(rate.tx_bps, 1_500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throughput_saturates_on_counter_reset() {
|
||||
let prev = Totals {
|
||||
rx_bytes: 10_000,
|
||||
tx_bytes: 8_000,
|
||||
};
|
||||
let curr = Totals {
|
||||
rx_bytes: 1_000,
|
||||
tx_bytes: 2_000,
|
||||
};
|
||||
|
||||
let rate = compute_throughput(prev, curr, Duration::from_secs(1));
|
||||
|
||||
assert_eq!(rate.rx_bps, 0.0);
|
||||
assert_eq!(rate.tx_bps, 0.0);
|
||||
}
|
||||
}
|
||||
263
src/ui.rs
Normal file
263
src/ui.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Gauge, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
let root = frame.size();
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(7),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(root);
|
||||
|
||||
draw_header(frame, chunks[0], state);
|
||||
draw_body(frame, chunks[1], state);
|
||||
draw_footer(frame, chunks[2], state);
|
||||
}
|
||||
|
||||
fn draw_header(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let uptime = format_uptime(state.snapshot.uptime_seconds);
|
||||
let refresh_ms = state.refresh_interval.as_millis();
|
||||
let title = format!(
|
||||
" {} | uptime {} | proc {} | load(1m) {:.2} | {}ms {}",
|
||||
state.snapshot.host,
|
||||
uptime,
|
||||
state.snapshot.process_count,
|
||||
state.snapshot.load_avg_1m,
|
||||
refresh_ms,
|
||||
if state.paused { "[PAUSED]" } else { "" }
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(title)
|
||||
.block(Block::default().borders(Borders::ALL).title("sloptop"))
|
||||
.alignment(Alignment::Left),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_body(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
if area.width < 90 {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(6),
|
||||
Constraint::Min(4),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_cpu(frame, chunks[0], state);
|
||||
draw_memory(frame, chunks[1], state);
|
||||
draw_network(frame, chunks[2], state);
|
||||
draw_disks(frame, chunks[3], state);
|
||||
return;
|
||||
}
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(area);
|
||||
|
||||
let left_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(5),
|
||||
])
|
||||
.split(chunks[0]);
|
||||
|
||||
draw_cpu(frame, left_chunks[0], state);
|
||||
draw_memory(frame, left_chunks[1], state);
|
||||
draw_disks(frame, left_chunks[2], state);
|
||||
|
||||
draw_network(frame, chunks[1], state);
|
||||
}
|
||||
|
||||
fn draw_cpu(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let gauge = Gauge::default()
|
||||
.block(Block::default().title("CPU").borders(Borders::ALL))
|
||||
.gauge_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.percent(state.snapshot.cpu_total_percent.clamp(0.0, 100.0) as u16)
|
||||
.label(format!("{:>5.1}%", state.snapshot.cpu_total_percent));
|
||||
frame.render_widget(gauge, area);
|
||||
}
|
||||
|
||||
fn draw_memory(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let mem_pct = percent(
|
||||
state.snapshot.mem_used_bytes,
|
||||
state.snapshot.mem_total_bytes,
|
||||
);
|
||||
let swap_pct = percent(
|
||||
state.snapshot.swap_used_bytes,
|
||||
state.snapshot.swap_total_bytes,
|
||||
);
|
||||
|
||||
let text = vec![Line::from(vec![
|
||||
Span::raw(format!("mem {:>5.1}% ", mem_pct)),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{} / {}",
|
||||
fmt_bytes(state.snapshot.mem_used_bytes),
|
||||
fmt_bytes(state.snapshot.mem_total_bytes)
|
||||
),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::raw(format!(
|
||||
" swap {:>5.1}% {} / {}",
|
||||
swap_pct,
|
||||
fmt_bytes(state.snapshot.swap_used_bytes),
|
||||
fmt_bytes(state.snapshot.swap_total_bytes)
|
||||
)),
|
||||
])];
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(text)
|
||||
.block(Block::default().title("Memory").borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_network(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let mut lines = vec![
|
||||
Line::from(format!(
|
||||
"RX: {}/s",
|
||||
fmt_bytes(state.network_rate.rx_bps as u64)
|
||||
)),
|
||||
Line::from(format!(
|
||||
"TX: {}/s",
|
||||
fmt_bytes(state.network_rate.tx_bps as u64)
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Total RX: {}",
|
||||
fmt_bytes(state.snapshot.network_totals.rx_bytes)
|
||||
)),
|
||||
Line::from(format!(
|
||||
"Total TX: {}",
|
||||
fmt_bytes(state.snapshot.network_totals.tx_bytes)
|
||||
)),
|
||||
];
|
||||
|
||||
if state.detailed_view {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("Per-core CPU:"));
|
||||
for (idx, usage) in state
|
||||
.snapshot
|
||||
.cpu_per_core_percent
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(10)
|
||||
{
|
||||
lines.push(Line::from(format!("core {:02}: {:>5.1}%", idx, usage)));
|
||||
}
|
||||
if state.snapshot.cpu_per_core_percent.len() > 10 {
|
||||
lines.push(Line::from(format!(
|
||||
"... and {} more cores",
|
||||
state.snapshot.cpu_per_core_percent.len() - 10
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.title("Network / Details")
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_disks(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let mut lines = Vec::new();
|
||||
if state.snapshot.disk_samples.is_empty() {
|
||||
lines.push(Line::from("No disks reported"));
|
||||
} else {
|
||||
for disk in &state.snapshot.disk_samples {
|
||||
let pct = percent(disk.used_bytes, disk.total_bytes);
|
||||
lines.push(Line::from(format!(
|
||||
"{} {:>5.1}% {} / {}",
|
||||
disk.name,
|
||||
pct,
|
||||
fmt_bytes(disk.used_bytes),
|
||||
fmt_bytes(disk.total_bytes)
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(Block::default().title("Disk").borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_footer(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let mode = if state.detailed_view {
|
||||
"detail:on"
|
||||
} else {
|
||||
"detail:off"
|
||||
};
|
||||
let help = format!("q quit | p pause | +/- interval | d toggle detail | {mode}");
|
||||
frame.render_widget(Paragraph::new(help), area);
|
||||
}
|
||||
|
||||
fn percent(used: u64, total: u64) -> f64 {
|
||||
if total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(used as f64 / total as f64) * 100.0
|
||||
}
|
||||
|
||||
fn fmt_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
if bytes == 0 {
|
||||
return "0 B".to_string();
|
||||
}
|
||||
|
||||
let mut value = bytes as f64;
|
||||
let mut idx = 0;
|
||||
while value >= 1024.0 && idx < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if idx == 0 {
|
||||
format!("{} {}", bytes, UNITS[idx])
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[idx])
|
||||
}
|
||||
}
|
||||
|
||||
fn format_uptime(seconds: u64) -> String {
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
let mins = (seconds % 3_600) / 60;
|
||||
let secs = seconds % 60;
|
||||
|
||||
if days > 0 {
|
||||
format!("{days}d {hours:02}:{mins:02}:{secs:02}")
|
||||
} else {
|
||||
format!("{hours:02}:{mins:02}:{secs:02}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user