feat(api): admin system metrics endpoint with disk/mem/cpu alerts (0.40.0)
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.
This commit is contained in:
123
backend/Cargo.lock
generated
123
backend/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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<AppState> {
|
||||
Router::new().merge(users::routes()).merge(mangas::routes())
|
||||
Router::new()
|
||||
.merge(users::routes())
|
||||
.merge(mangas::routes())
|
||||
.merge(system::routes())
|
||||
}
|
||||
|
||||
163
backend/src/api/admin/system.rs
Normal file
163
backend/src/api/admin/system.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new().route("/admin/system", get(system))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SystemStats {
|
||||
pub disk: Option<DiskStats>,
|
||||
pub memory: MemoryStats,
|
||||
pub cpu: CpuStats,
|
||||
pub alerts: Vec<Alert>,
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
) -> AppResult<Json<SystemStats>> {
|
||||
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<DiskStats> {
|
||||
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)
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<StreamingFile, StorageError>;
|
||||
async fn delete(&self, key: &str) -> Result<(), StorageError>;
|
||||
async fn exists(&self, key: &str) -> Result<bool, StorageError>;
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
96
backend/tests/api_admin_system.rs
Normal file
96
backend/tests/api_admin_system.rs
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user