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:
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