diff options
author | gennyble <gen@nyble.dev> | 2025-02-16 10:17:28 -0600 |
---|---|---|
committer | gennyble <gen@nyble.dev> | 2025-02-16 10:17:28 -0600 |
commit | 0c5986851dee23e09751ae594d8a43a84a0dab61 (patch) | |
tree | ed27d132aa04b86ecabd9cf5bb440b7d87f7bff3 /src | |
parent | e1b51d7dfc24c443af34410da2fed6aa6db52b62 (diff) | |
download | awake-0c5986851dee23e09751ae594d8a43a84a0dab61.tar.gz awake-0c5986851dee23e09751ae594d8a43a84a0dab61.zip |
Network stats
Diffstat (limited to 'src')
-rw-r--r-- | src/db.rs | 191 | ||||
-rw-r--r-- | src/gatherer.rs | 228 | ||||
-rw-r--r-- | src/griph/mod.rs | 98 | ||||
-rwxr-xr-x | src/main.rs | 113 |
4 files changed, 530 insertions, 100 deletions
diff --git a/src/db.rs b/src/db.rs index d4038cb..6edeba1 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4,44 +4,127 @@ use camino::Utf8PathBuf; use rusqlite::{params, Connection, OptionalExtension}; use time::OffsetDateTime; -use crate::Meminfo; +use crate::gatherer::Meminfo; - -pub struct Database{ - db_path: Utf8PathBuf, - conn: Mutex<Connection> +pub struct Database { + db_path: Utf8PathBuf, + conn: Mutex<Connection>, } impl Database { - pub fn new(db_path: Utf8PathBuf) -> Self { - Self { - conn:Mutex::new(Connection::open(&db_path).unwrap()), - db_path, - } - } - - pub fn create_tables(&self) { - let conn = self.conn.lock().unwrap(); - conn.execute(CREATE_TABLE_HOSTMEM, params![]).unwrap(); - } - - pub fn insert_host_meminfo(&self, meminfo: Meminfo) { - let conn = self.conn.lock().unwrap(); - - conn.execute("INSERT INTO stats_hostmem(total_kb, available_kb) VALUES (?1, ?2)", params![meminfo.total, meminfo.avaialable]).unwrap(); - } - - pub fn get_last_host_meminfo(&self) -> DbMeminfo { - let conn = self.conn.lock().unwrap(); - - conn.query_row("SELECT * FROM stats_hostmem ORDER BY stamp DESC LIMIT 1", [], |row| { - let (stamp, total_kb, available_kb) = row.try_into().unwrap(); - - Ok(DbMeminfo { - stamp, total_kb, available_kb - }) - }).optional().unwrap().unwrap() - } + pub fn new(db_path: Utf8PathBuf) -> Self { + Self { + conn: Mutex::new(Connection::open(&db_path).unwrap()), + db_path, + } + } + + pub fn create_tables(&self) { + let conn = self.conn.lock().unwrap(); + conn.execute(CREATE_TABLE_HOSTMEM, params![]).unwrap(); + conn.execute(CREATE_TABLE_HOSTNET, params![]).unwrap(); + } + + pub fn insert_host_meminfo(&self, meminfo: Meminfo) { + let conn = self.conn.lock().unwrap(); + + conn.execute( + "INSERT INTO stats_hostmem(total_kb, available_kb) VALUES (?1, ?2)", + params![meminfo.total, meminfo.avaialable], + ) + .unwrap(); + } + + pub fn get_last_host_meminfo(&self) -> DbMeminfo { + let conn = self.conn.lock().unwrap(); + + conn.query_row( + "SELECT * FROM stats_hostmem ORDER BY stamp DESC LIMIT 1", + [], + |row| { + let (stamp, total_kb, available_kb) = row.try_into().unwrap(); + + Ok(DbMeminfo { + stamp, + total_kb, + available_kb, + }) + }, + ) + .optional() + .unwrap() + .unwrap() + } + + pub fn get_last_n_host_meminfo(&self, count: usize) -> Vec<DbMeminfo> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT * FROM stats_hostmem ORDER BY stamp ASC LIMIT ?1") + .unwrap(); + + stmt.query_map(params![count], |row| { + Ok(DbMeminfo { + stamp: row.get(0)?, + total_kb: row.get(1)?, + available_kb: row.get(2)?, + }) + }) + .unwrap() + .map(|r| r.unwrap()) + .collect() + } + + pub fn get_date_bound_n_host_meminfo( + &self, + since: OffsetDateTime, + until: OffsetDateTime, + count: usize, + ) -> Vec<DbMeminfo> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT * FROM stats_hostmem WHERE stamp > ?1 AND stamp < ?2 ORDER BY stamp ASC LIMIT ?3") + .unwrap(); + + stmt.query_map(params![since, until, count], |row| { + Ok(DbMeminfo { + stamp: row.get(0)?, + total_kb: row.get(1)?, + available_kb: row.get(2)?, + }) + }) + .unwrap() + .map(|r| r.unwrap()) + .collect() + } + + pub fn insert_hostnet(&self, span_sec: usize, rx_delta: usize, tx_delta: usize) { + let conn = self.conn.lock().unwrap(); + + conn.execute( + "INSERT INTO stats_hostnet(span_sec, rx_delta, tx_delta) VALUES (?1, ?2, ?3)", + params![span_sec, rx_delta, tx_delta], + ) + .unwrap(); + } + + pub fn get_last_n_hostnet(&self, count: usize) -> Vec<DbNetinfo> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT * FROM stats_hostnet ORDER BY stamp ASC LIMIT ?1") + .unwrap(); + + stmt.query_map(params![count], |row| { + Ok(DbNetinfo { + stamp: row.get(0)?, + span_sec: row.get(1)?, + rx_delta: row.get(2)?, + tx_delta: row.get(3)?, + }) + }) + .unwrap() + .map(|r| r.unwrap()) + .collect() + } } pub const CREATE_TABLE_HOSTMEM: &'static str = "\ @@ -51,14 +134,40 @@ pub const CREATE_TABLE_HOSTMEM: &'static str = "\ available_kb INTEGER NOT NULL );"; +pub const CREATE_TABLE_HOSTNET: &'static str = "\ + CREATE TABLE IF NOT EXISTS stats_hostnet( + stamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + span_sec INTEGER NOT NULL, + rx_delta INTEGER NOT NULL, + tx_delta INTEGER NOT NULL + );"; + pub struct DbMeminfo { - pub stamp: OffsetDateTime, - pub total_kb: usize, - pub available_kb: usize + pub stamp: OffsetDateTime, + pub total_kb: usize, + pub available_kb: usize, } impl DbMeminfo { - pub fn usage(&self) -> usize { - self.total_kb - self.available_kb - } -} \ No newline at end of file + pub fn usage(&self) -> usize { + self.total_kb - self.available_kb + } +} + +#[derive(Copy, Clone, Debug)] +pub struct DbNetinfo { + pub stamp: OffsetDateTime, + pub span_sec: usize, + pub rx_delta: usize, + pub tx_delta: usize, +} + +impl DbNetinfo { + pub fn rx_bytes_per_sec(&self) -> f32 { + self.rx_delta as f32 / self.span_sec as f32 + } + + pub fn tx_bytes_per_sec(&self) -> f32 { + self.tx_delta as f32 / self.span_sec as f32 + } +} diff --git a/src/gatherer.rs b/src/gatherer.rs new file mode 100644 index 0000000..0cceea6 --- /dev/null +++ b/src/gatherer.rs @@ -0,0 +1,228 @@ +use std::{ + fs::File, + io::{BufRead, BufReader}, + sync::mpsc::Sender, + thread::JoinHandle, + time::Duration, +}; + +use regex_lite::Regex; +use time::OffsetDateTime; + +use crate::{griph, AwakeState}; + +pub struct Gatherer { + state: AwakeState, + hwnd: Option<JoinHandle<()>>, +} + +impl Gatherer { + pub fn new(state: AwakeState) -> Self { + Self { state, hwnd: None } + } + + pub fn start(&mut self) { + let state = self.state.clone(); + let hwnd = std::thread::spawn(|| task(state)); + self.hwnd = Some(hwnd); + } +} + +fn task(state: AwakeState) { + tracing::info!("starting gatherer thread"); + + // I just want a graph on first boot; don't care about divisions just yet + make_mem_graph(&state); + make_net_graph(&state); + + let mut last_netinfo: Option<Netinfo> = None; + + // this is a `let _` because otherwise the attribute was + // making the comiler mad + #[rustfmt::skip] + let _ = loop { + tracing::debug!("collecting stats"); + + // Gather data + let meminfo = Meminfo::current(); + let netinfo = Netinfo::current(); + + // Print traces, y'know, for tracing + tracing::trace!("memory: {}MB used / {}MB total", meminfo.usage() / 1000, meminfo.total / 1000); + tracing::trace!("net: rx {} / tx {}", data_human_fmt(netinfo.rx_bytes), data_human_fmt(netinfo.tx_bytes)); + + if let Some(lni) = last_netinfo { + let rx_delta = netinfo.rx_bytes - lni.rx_bytes; + let tx_delta = netinfo.tx_bytes - lni.tx_bytes; + + state.database.insert_hostnet(60, rx_delta, tx_delta); + } + last_netinfo = Some(netinfo); + + // Store stats in database + state.database.insert_host_meminfo(meminfo); + + // Only generate graphs every 15 minutes + let now = OffsetDateTime::now_utc(); + if now.minute() % 15 == 0 { + make_mem_graph(&state); + make_net_graph(&state); + } + + std::thread::sleep(Duration::from_secs(60)); + }; +} + +pub fn make_mem_graph(state: &AwakeState) { + tracing::debug!("generating meminfo graph"); + + let infos = state.database.get_last_n_host_meminfo(256); + let max = infos[0].total_kb; + let usages: Vec<usize> = infos.into_iter().map(|mi| mi.usage()).collect(); + + let gif = griph::make_1line(0, max, &usages); + + let path = state.cache_path.join("current_hostmeminfo.gif"); + gif.save(path).unwrap(); +} + +pub fn make_net_graph(state: &AwakeState) { + tracing::debug!("generating netinfo graph"); + + let infos = state.database.get_last_n_hostnet(256); + let rx_deltas: Vec<usize> = infos + .iter() + .map(|ni| ni.rx_bytes_per_sec() as usize / 1000) + .collect(); + let tx_deltas: Vec<usize> = infos + .iter() + .map(|ni| ni.tx_bytes_per_sec() as usize / 1000) + .collect(); + + for ahh in &tx_deltas { + tracing::trace!("ahh: {ahh} kbytes"); + } + + let gif = griph::make_2line(0, 1000, &rx_deltas, &tx_deltas); + + let path = state.cache_path.join("current_hostnetinfo.gif"); + gif.save(path).unwrap(); +} + +pub struct Meminfo { + pub total: usize, + pub free: usize, + pub avaialable: usize, +} + +impl Meminfo { + pub fn current() -> Self { + let procinfo = File::open("/proc/meminfo").unwrap(); + let bread = BufReader::new(procinfo); + + let mut meminfo = Meminfo { + total: 0, + free: 0, + avaialable: 0, + }; + + for line in bread.lines() { + let line = line.unwrap(); + + if let Some((raw_key, raw_value_kb)) = line.split_once(':') { + let value = if let Some(raw_value) = raw_value_kb.trim().strip_suffix(" kB") { + if let Ok(parsed) = raw_value.parse() { + parsed + } else { + continue; + } + } else { + continue; + }; + + match raw_key.trim() { + "MemTotal" => meminfo.total = value, + "MemFree" => meminfo.free = value, + "MemAvailable" => meminfo.avaialable = value, + _ => (), + } + } + } + + meminfo + } + + pub fn usage(&self) -> usize { + self.total - self.avaialable + } +} + +pub struct Netinfo { + rx_bytes: usize, + tx_bytes: usize, +} + +impl Netinfo { + pub fn current() -> Self { + let procinfo = File::open("/proc/net/dev").unwrap(); + let bread = BufReader::new(procinfo); + + let mut netinfo = Self { + rx_bytes: 0, + tx_bytes: 0, + }; + + let re = Regex::new(r"[ ]*(\d+)").unwrap(); + let interface = "eth0:"; + for line in bread.lines() { + let line = line.unwrap(); + let trim = line.trim(); + + let mut captures = if let Some(data) = trim.strip_prefix(interface) { + re.captures_iter(data) + } else { + continue; + }; + + netinfo.rx_bytes = captures + .next() + .unwrap() + .get(1) + .unwrap() + .as_str() + .parse() + .unwrap(); + netinfo.tx_bytes = captures + .skip(7) + .next() + .unwrap() + .get(1) + .unwrap() + .as_str() + .parse() + .unwrap(); + break; + } + + netinfo + } +} + +fn data_human_fmt(bytes: usize) -> String { + let (num, unit) = data_human(bytes); + format!("{num}{unit}") +} + +fn data_human(bytes: usize) -> (f32, &'static str) { + const UNITS: &[&str] = &["B", "kB", "MB", "GB", "TB"]; + + let mut wrk = bytes as f32; + let mut unit_idx = 0; + loop { + if wrk < 1500.0 || unit_idx == UNITS.len() - 1 { + return (wrk, UNITS[unit_idx]); + } + wrk /= 1000.0; + unit_idx += 1; + } +} diff --git a/src/griph/mod.rs b/src/griph/mod.rs new file mode 100644 index 0000000..b034207 --- /dev/null +++ b/src/griph/mod.rs @@ -0,0 +1,98 @@ +use gifed::{writer::ImageBuilder, Gif, StandardGif}; + +#[rustfmt::skip] +pub const DARK_PALETTE: &[u8] = &[ + 0, 0, 0, // Background - Black + 192, 192, 192, // Graphline - Mostly White + 64, 64, 64, // Gridlines - Dark gray + 32, 32, 32, // Minor Gridlines - Darker gray + 48, 48, 192, // Primary 2 Colour - Blue + 48, 192, 48, // Secondary 2 Colour - Green +]; + +const BACKGROUND: u8 = 0; +const LINE: u8 = 1; +const GRIDLINE: u8 = 2; +const MINOR_GRIDLINE: u8 = 3; +const LINE1: u8 = 4; +const LINE2: u8 = 5; + +const WIDTH: usize = 256; +const HEIGHT: usize = 160; +const SIZE: usize = WIDTH * HEIGHT; + +pub fn make_1line(min: usize, max: usize, values: &[usize]) -> Gif { + let range = max - min; + // this assumes a range of values that is >1 per pixel + let vpp = range / HEIGHT; + + let mut raster = vec![0; SIZE]; + draw_grid(&mut raster); + draw_line(&mut raster, values, vpp, LINE); + + let mut standard = Gif::new(WIDTH as u16, HEIGHT as u16); + standard.set_palette(Some(DARK_PALETTE[0..12].try_into().unwrap())); + standard.push( + ImageBuilder::new(WIDTH as u16, HEIGHT as u16) + .build(raster) + .unwrap(), + ); + + standard +} + +pub fn make_2line(min: usize, max: usize, values1: &[usize], values2: &[usize]) -> Gif { + let range = max - min; + // this assumes a range of values that is >1 per pixel + let vpp = range / HEIGHT; + + let mut raster = vec![0; SIZE]; + draw_grid(&mut raster); + draw_line(&mut raster, values1, vpp, LINE1); + draw_line(&mut raster, values2, vpp, LINE2); + + let mut standard = Gif::new(WIDTH as u16, HEIGHT as u16); + standard.set_palette(Some(DARK_PALETTE.try_into().unwrap())); + standard.push( + ImageBuilder::new(WIDTH as u16, HEIGHT as u16) + .build(raster) + .unwrap(), + ); + + standard +} + +fn draw_grid(raster: &mut [u8]) { + // Draw Divisions + // we want a gridline every 16 pixels, but not the bottom + // or top, so only 8. + for div in 1..=9 { + let y_val = div * 16; + + let grid = if div % 2 == 0 { + GRIDLINE + } else { + MINOR_GRIDLINE + }; + + for x in 0..WIDTH { + raster[y_val * WIDTH + x] = grid; + } + } +} + +fn draw_line(raster: &mut [u8], values: &[usize], vpp: usize, colour: u8) { + // Draw Line + // this will be discontinuous and i think that's okay. we + // could make it a proper line by keeping track what value + // was last and drawing the whole vertical there + for (x, value) in values.iter().enumerate() { + let value_height = value / vpp; + if value_height > (HEIGHT - 1) { + continue; + } + let y_val = (HEIGHT - 1) - value_height; + + raster[y_val * WIDTH + x] = colour; + } +} diff --git a/src/main.rs b/src/main.rs index 26e1cf0..cddd2f1 100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,26 @@ mod atom; +mod db; mod error; mod fs; +mod gatherer; +mod griph; mod ifc; mod markup; mod settings; mod templated; mod timeparse; mod util; -mod db; -use std::{fs::File, io::{BufRead, BufReader, Write}, os::unix::fs::MetadataExt, str::FromStr, sync::Arc, time::Duration}; +use std::{ + fs::File, + io::{BufRead, BufReader, Write}, + os::unix::fs::MetadataExt, + str::FromStr, + sync::Arc, + time::Duration, +}; +use ::time::OffsetDateTime; use axum::{ body::Body, extract::{Path, State}, @@ -25,6 +35,7 @@ use confindent::{Confindent, Node}; use db::Database; pub use error::RuntimeError; use fs::Filesystem; +use gatherer::Gatherer; use settings::Settings; use templated::Frontmatter; use tokio_util::io::ReaderStream; @@ -38,7 +49,8 @@ use crate::{ #[derive(Clone)] pub struct AwakeState { - pub database: Arc<Database> + pub database: Arc<Database>, + pub cache_path: Utf8PathBuf, } #[tokio::main] @@ -64,6 +76,7 @@ async fn main() { let templates = conf.child_value("Templates").unwrap(); let hostname = conf.child_owned("Hostname").unwrap(); let dbpath = conf.child_owned("Database").unwrap(); + let cache = conf.child_owned("Cache").unwrap(); let database = Database::new(dbpath.into()); database.create_tables(); @@ -71,18 +84,24 @@ async fn main() { let fs = Filesystem::new(&webroot); let state = AwakeState { - database: Arc::new(database) + database: Arc::new(database), + cache_path: cache.into(), }; - let mi_state = state.clone(); - let meminfo_thread = std::thread::spawn(move || { - loop { - let meminfo = Meminfo::current(); - mi_state.database.insert_host_meminfo(meminfo); - - std::thread::sleep(Duration::from_secs(60)); + match std::env::args().nth(1).as_deref() { + Some("graph") => { + gatherer::make_mem_graph(&state); } - }); + _ => (), + } + + let mut gatherer = Gatherer::new(state.clone()); + + #[cfg(target_os = "linux")] + gatherer.start(); + + #[cfg(not(target_os = "linux"))] + tracing::warn!("compilation target was not linux; not collecing stat data"); let settings = Settings { template_dir: Utf8PathBuf::from(webroot.join(templates)) @@ -93,9 +112,11 @@ async fn main() { let app = Router::new() .route("/", get(index_handler)) + .route("/api/stats/:name", get(stats)) .route("/*path", get(handler)) .layer(Extension(fs)) - .layer(Extension(settings)).with_state(state); + .layer(Extension(settings)) + .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:2560").await.unwrap(); axum::serve(listener, app).await.unwrap() @@ -190,6 +211,7 @@ fn redirect<S: Into<String>>(redirection: S) -> Response { const STREAM_AFTER: u64 = 20 * 1024 * 1024; async fn send_file(filepath: Utf8PathBuf) -> Result<Response, RuntimeError> { + tracing::debug!(target: "send_file", filepath = ?filepath); let ext = filepath.extension().unwrap_or_default(); let stem = filepath.file_stem().unwrap_or_default(); @@ -209,6 +231,7 @@ async fn send_file(filepath: Utf8PathBuf) -> Result<Response, RuntimeError> { "png" => "image/png", _ => "", }; + tracing::debug!(target: "send_file", mime = ?mime); let mut response = Response::builder(); if !mime.is_empty() { @@ -223,6 +246,8 @@ async fn send_file(filepath: Utf8PathBuf) -> Result<Response, RuntimeError> { let stream = ReaderStream::new(file); Ok(response.body(Body::from_stream(stream)).unwrap()) } else { + tracing::debug!("small file, sending entirely at once"); + let content = Filesystem::read(filepath).await?; Ok(response.body(Body::from(content)).unwrap()) } @@ -380,63 +405,33 @@ async fn send_template( fn template_content(state: AwakeState, frontmatter: &Frontmatter, marked: String) -> String { let Ok(mut doc) = Document::from_str(&marked, Options::default()) else { - return marked + return marked; }; if frontmatter.get("system-stats").is_some() { let mem = state.database.get_last_host_meminfo(); - doc.set("stats.mem.total", mem.total_kb / 1024); - doc.set("stats.mem.usage", mem.usage() / 1024); + doc.set("stats.mem.total", mem.total_kb / 1000); + doc.set("stats.mem.usage", mem.usage() / 1000); } doc.compile() } -struct Meminfo { - pub total: usize, - pub free: usize, - pub avaialable: usize -} - -impl Meminfo { - pub fn current() -> Self { - let procinfo = File::open("/proc/meminfo").unwrap(); - let bread = BufReader::new(procinfo); +async fn stats(State(state): State<AwakeState>, Path(name): Path<String>) -> Response { + const NAMES: &[&'static str] = &["current_hostmeminfo.gif", "current_hostnetinfo.gif"]; - let mut meminfo = Meminfo { - total: 0, - free: 0, - avaialable: 0 - }; - - for line in bread.lines() { - let line = line.unwrap(); - - if let Some((raw_key, raw_value_kb)) = line.split_once(':') { - let value = if let Some(raw_value) = raw_value_kb.trim().strip_suffix(" kB") { - if let Ok(parsed) = raw_value.parse() { - parsed - } else { - continue; - } - } else { - continue; - }; - - match raw_key.trim() { - "MemTotal" => meminfo.total = value, - "MemFree" => meminfo.free = value, - "MemAvailable" => meminfo.avaialable = value, - _ => () - } - } + if NAMES.contains(&name.as_str()) { + let path = state.cache_path.join(name); + match send_file(path).await { + Ok(resp) => resp, + Err(e) => Response::builder().body(Body::from(e.to_string())).unwrap(), } + } else { + tracing::debug!("invalid stat requested: {name}"); - meminfo - } - - pub fn usage(&self) -> usize { - self.total - self.avaialable + Response::builder() + .body(Body::from("that stat is not real")) + .unwrap() } -} \ No newline at end of file +} |