From cc4ec76d172228eddb189e90ea66bd1f0df634c3 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 30 May 2026 21:45:06 +0200 Subject: [PATCH] feat(api): admin system metrics endpoint with disk/mem/cpu alerts (0.40.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/v1/admin/system returning disk (scoped to storage_dir via statvfs), memory, CPU, and a server-side alerts array that fires at >90% disk or memory. Disk uses nix::sys::statvfs directly rather than sysinfo's Disks API to avoid mountpoint-matching gymnastics for the storage_dir. A new `Storage::local_root() -> Option<&Path>` trait method exposes the root; the default returns None so a future S3Storage gets `disk: null` in the response instead of fabricated numbers. CPU is sampled inline (refresh → 250ms sleep → refresh → read) so the endpoint adds 250ms of latency per call. No background-cache yet — admin traffic is low-volume and the moving parts aren't worth it until polling shows up. Alerts are evaluated server-side so the frontend can render them without re-implementing the thresholds. --- backend/Cargo.lock | 123 ++++++++++++++++++++-- backend/Cargo.toml | 4 +- backend/src/api/admin/mod.rs | 6 +- backend/src/api/admin/system.rs | 163 ++++++++++++++++++++++++++++++ backend/src/storage/local.rs | 4 + backend/src/storage/mod.rs | 11 ++ backend/tests/api_admin_system.rs | 96 ++++++++++++++++++ frontend/package.json | 2 +- 8 files changed, 400 insertions(+), 9 deletions(-) create mode 100644 backend/src/api/admin/system.rs create mode 100644 backend/tests/api_admin_system.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 179fb59..b9a2768 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1202,7 +1202,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.39.0" +version = "0.40.0" dependencies = [ "anyhow", "argon2", @@ -1488,6 +1488,7 @@ dependencies = [ "http-body-util", "infer", "mime", + "nix 0.29.0", "rand 0.8.6", "reqwest", "scraper", @@ -1496,6 +1497,7 @@ dependencies = [ "sha2", "sqlx", "subtle", + "sysinfo", "tempfile", "thiserror 1.0.69", "time", @@ -1603,6 +1605,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -1615,6 +1629,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1855,7 +1878,7 @@ checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.31.3", "objc2", "objc2-foundation", "objc2-ui-kit", @@ -2985,6 +3008,19 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3606,19 +3642,74 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3630,6 +3721,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -3647,6 +3749,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ba8a897..9a5989c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.39.0" +version = "0.40.0" edition = "2021" default-run = "mangalord" @@ -45,6 +45,8 @@ futures-core = "0.3" futures-util = "0.3" bytes = "1" chromiumoxide = { version = "0.7", features = ["tokio-runtime", "_fetcher-rusttls-tokio"], default-features = false } +sysinfo = { version = "0.32", default-features = false, features = ["system"] } +nix = { version = "0.29", features = ["fs"] } scraper = "0.20" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks", "cookies", "stream"] } diff --git a/backend/src/api/admin/mod.rs b/backend/src/api/admin/mod.rs index 5b5dc40..86c583c 100644 --- a/backend/src/api/admin/mod.rs +++ b/backend/src/api/admin/mod.rs @@ -5,6 +5,7 @@ //! `crate::auth::extractor::RequireAdmin`). pub mod mangas; +pub mod system; pub mod users; use axum::Router; @@ -12,5 +13,8 @@ use axum::Router; use crate::app::AppState; pub fn routes() -> Router { - Router::new().merge(users::routes()).merge(mangas::routes()) + Router::new() + .merge(users::routes()) + .merge(mangas::routes()) + .merge(system::routes()) } diff --git a/backend/src/api/admin/system.rs b/backend/src/api/admin/system.rs new file mode 100644 index 0000000..31126e0 --- /dev/null +++ b/backend/src/api/admin/system.rs @@ -0,0 +1,163 @@ +//! System metrics for the admin dashboard. +//! +//! Disk is `statvfs(storage_dir)` so the number reflects the volume the +//! app actually writes to (not the root filesystem of the host). When the +//! storage backend doesn't expose a local path (e.g. a future S3 impl) +//! the disk fields are `null` rather than fabricated. +//! +//! Memory and CPU come from `sysinfo`. CPU requires two refreshes with +//! at least 200ms between them to compute a meaningful delta; the +//! handler eats the 250ms wall-clock cost on each request. Admin +//! traffic is low-volume so a background cache isn't worth the moving +//! parts yet — revisit if polling becomes frequent. + +use std::path::Path; +use std::time::Duration; + +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Serialize; +use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System}; + +use crate::app::AppState; +use crate::auth::extractor::RequireAdmin; +use crate::error::AppResult; + +const ALERT_THRESHOLD_PERCENT: f64 = 90.0; + +pub fn routes() -> Router { + Router::new().route("/admin/system", get(system)) +} + +#[derive(Debug, Serialize)] +pub struct SystemStats { + pub disk: Option, + pub memory: MemoryStats, + pub cpu: CpuStats, + pub alerts: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DiskStats { + pub total_bytes: u64, + pub used_bytes: u64, + pub free_bytes: u64, + pub percent_used: f64, +} + +#[derive(Debug, Serialize)] +pub struct MemoryStats { + pub total_bytes: u64, + pub used_bytes: u64, + pub percent_used: f64, +} + +#[derive(Debug, Serialize)] +pub struct CpuStats { + pub percent_used: f64, +} + +#[derive(Debug, Serialize)] +pub struct Alert { + pub level: AlertLevel, + pub message: String, +} + +#[derive(Debug, Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum AlertLevel { + Warning, +} + +async fn system( + State(state): State, + _admin: RequireAdmin, +) -> AppResult> { + let disk = state.storage.local_root().and_then(disk_stats_for); + let (memory, cpu) = memory_and_cpu().await; + let mut alerts = Vec::new(); + if let Some(d) = &disk { + if d.percent_used >= ALERT_THRESHOLD_PERCENT { + alerts.push(Alert { + level: AlertLevel::Warning, + message: format!( + "disk near full ({:.0}% used)", + d.percent_used + ), + }); + } + } + if memory.percent_used >= ALERT_THRESHOLD_PERCENT { + alerts.push(Alert { + level: AlertLevel::Warning, + message: format!( + "memory near full ({:.0}% used)", + memory.percent_used + ), + }); + } + Ok(Json(SystemStats { + disk, + memory, + cpu, + alerts, + })) +} + +fn disk_stats_for(root: &Path) -> Option { + let s = nix::sys::statvfs::statvfs(root).ok()?; + // statvfs reports `f_frsize * f_blocks` for total bytes. `f_bavail` + // is "free to non-root callers" which is what an operator actually + // cares about — `f_bfree` includes blocks reserved for root. + let block = s.fragment_size(); + let total = block * s.blocks(); + let avail = block * s.blocks_available(); + let used = total.saturating_sub(avail); + let percent_used = if total > 0 { + (used as f64) * 100.0 / (total as f64) + } else { + 0.0 + }; + Some(DiskStats { + total_bytes: total, + used_bytes: used, + free_bytes: avail, + percent_used, + }) +} + +async fn memory_and_cpu() -> (MemoryStats, CpuStats) { + // sysinfo's CPU sampling needs two refreshes with a delay between + // them — the first seeds the delta counters, the second measures. + // We do this once per request; admin traffic is low enough that the + // 250ms cost is invisible. + let mut sys = System::new_with_specifics( + RefreshKind::new() + .with_cpu(CpuRefreshKind::everything()) + .with_memory(MemoryRefreshKind::everything()), + ); + sys.refresh_cpu_all(); + // Yield the runtime instead of blocking it for the gap. + tokio::time::sleep(Duration::from_millis(250)).await; + sys.refresh_cpu_all(); + sys.refresh_memory(); + + let total = sys.total_memory(); + let used = sys.used_memory(); + let mem_pct = if total > 0 { + (used as f64) * 100.0 / (total as f64) + } else { + 0.0 + }; + let memory = MemoryStats { + total_bytes: total, + used_bytes: used, + percent_used: mem_pct, + }; + + let cpu = CpuStats { + percent_used: sys.global_cpu_usage() as f64, + }; + (memory, cpu) +} diff --git a/backend/src/storage/local.rs b/backend/src/storage/local.rs index 77a4fa2..fed5af8 100644 --- a/backend/src/storage/local.rs +++ b/backend/src/storage/local.rs @@ -86,6 +86,10 @@ impl Storage for LocalStorage { let path: &Path = &self.resolve(key)?; Ok(fs::try_exists(path).await?) } + + fn local_root(&self) -> Option<&Path> { + Some(&self.root) + } } #[cfg(test)] diff --git a/backend/src/storage/mod.rs b/backend/src/storage/mod.rs index c3c42a7..c00d803 100644 --- a/backend/src/storage/mod.rs +++ b/backend/src/storage/mod.rs @@ -9,6 +9,8 @@ mod local; use std::io; use std::pin::Pin; +use std::path::Path; + use async_trait::async_trait; use bytes::Bytes; use futures_core::Stream; @@ -44,4 +46,13 @@ pub trait Storage: Send + Sync { async fn get_stream(&self, key: &str) -> Result; async fn delete(&self, key: &str) -> Result<(), StorageError>; async fn exists(&self, key: &str) -> Result; + + /// Filesystem path the backend is rooted at, when introspectable. + /// Returns `None` for backends that aren't a local filesystem (e.g. + /// a future `S3Storage`). The admin system endpoint uses this to + /// statvfs the data dir; backends that return `None` get a `disk: + /// null` payload instead of fabricated numbers. + fn local_root(&self) -> Option<&Path> { + None + } } diff --git a/backend/tests/api_admin_system.rs b/backend/tests/api_admin_system.rs new file mode 100644 index 0000000..7b6f802 --- /dev/null +++ b/backend/tests/api_admin_system.rs @@ -0,0 +1,96 @@ +//! PR 4 (feat/admin-system-api) integration tests. +//! +//! Shape-only assertions — we don't mock the system, just call the +//! endpoint and check the response envelope. Threshold-triggering of +//! alerts would require faking statvfs / sysinfo, which is more +//! plumbing than the test gives back. + +mod common; + +use axum::http::StatusCode; +use axum::Router; +use sqlx::PgPool; +use tower::ServiceExt; + +use mangalord::repo; + +async fn seed_admin(pool: &PgPool, app: &Router) -> String { + let (username, cookie) = common::register_user(app).await; + let u = repo::user::find_by_username(pool, &username) + .await + .unwrap() + .unwrap(); + repo::user::set_is_admin(pool, u.id, true).await.unwrap(); + cookie +} + +#[sqlx::test(migrations = "./migrations")] +async fn requires_admin(pool: PgPool) { + let h = common::harness(pool); + let (_u, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/admin/system", &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[sqlx::test(migrations = "./migrations")] +async fn unauthenticated_request_is_rejected(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::get("/api/v1/admin/system")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "./migrations")] +async fn returns_disk_memory_cpu_alerts_shape(pool: PgPool) { + let h = common::harness(pool.clone()); + let cookie = seed_admin(&pool, &h.app).await; + + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/admin/system", &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + + // Disk: harness uses LocalStorage on a tempdir, so disk SHOULD be + // populated. Validate the field shape and percent range. + let disk = body + .get("disk") + .expect("disk key present") + .as_object() + .expect("disk is an object (LocalStorage exposes a path)"); + assert!(disk["total_bytes"].as_u64().unwrap() > 0); + let pct = disk["percent_used"].as_f64().unwrap(); + assert!( + (0.0..=100.0).contains(&pct), + "percent_used outside [0,100]: {pct}" + ); + + let mem = body.get("memory").expect("memory key").as_object().unwrap(); + assert!(mem["total_bytes"].as_u64().unwrap() > 0); + let mpct = mem["percent_used"].as_f64().unwrap(); + assert!((0.0..=100.0).contains(&mpct)); + + let cpu = body.get("cpu").expect("cpu key").as_object().unwrap(); + let cpu_pct = cpu["percent_used"].as_f64().unwrap(); + assert!( + (0.0..=100.0).contains(&cpu_pct), + "cpu out of range: {cpu_pct}" + ); + + let alerts = body.get("alerts").expect("alerts key").as_array().unwrap(); + // Don't assert on length — the box may genuinely be >90% on memory + // when the test runs. Just confirm shape of any present entry. + for alert in alerts { + assert!(alert["level"].is_string()); + assert!(alert["message"].is_string()); + } +} diff --git a/frontend/package.json b/frontend/package.json index a84448a..f016478 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.39.0", + "version": "0.40.0", "private": true, "type": "module", "scripts": {