feat(admin): overview dashboard sections + system temperature/load sensors (0.85.0) (#11)
All checks were successful
deploy / test-backend (push) Successful in 25m9s
deploy / test-frontend (push) Successful in 10m13s
deploy / build-and-push (push) Successful in 10m25s
deploy / deploy (push) Successful in 12s

This commit was merged in pull request #11.
This commit is contained in:
2026-06-16 18:46:28 +00:00
parent 8445f338f6
commit cde4aca98b
17 changed files with 1270 additions and 59 deletions

View File

@@ -0,0 +1,125 @@
//! Integration tests for the admin overview aggregates endpoint
//! (`GET /api/v1/admin/overview`). Auth gating mirrors the system tab;
//! the aggregate counts are asserted against seeded data.
mod common;
use axum::http::StatusCode;
use axum::Router;
use serde_json::json;
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_unchecked(pool, u.id, true).await.unwrap();
cookie
}
/// Seed a manga → chapter with `n` pages; mark the first `analyzed` of
/// them as a `done` page_analysis. Returns the page ids.
async fn seed_manga_pages(pool: &PgPool, title: &str, n: i32, analyzed: i32) -> Vec<uuid::Uuid> {
let manga_id: uuid::Uuid =
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
.bind(title)
.fetch_one(pool)
.await
.unwrap();
let chapter_id: uuid::Uuid =
sqlx::query_scalar("INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id")
.bind(manga_id)
.fetch_one(pool)
.await
.unwrap();
let mut ids = Vec::new();
for i in 1..=n {
let id: uuid::Uuid = sqlx::query_scalar(
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
VALUES ($1, $2, $3, 'image/png') RETURNING id",
)
.bind(chapter_id)
.bind(i)
.bind(format!("k/{manga_id}/{i}.png"))
.fetch_one(pool)
.await
.unwrap();
if i <= analyzed {
sqlx::query("INSERT INTO page_analysis (page_id, status) VALUES ($1, 'done')")
.bind(id)
.execute(pool)
.await
.unwrap();
}
ids.push(id);
}
ids
}
#[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/overview", &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/overview"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "./migrations")]
async fn aggregates_users_mangas_and_coverage(pool: PgPool) {
let h = common::harness(pool.clone());
// seed_admin registers one user and promotes it; register a second
// (non-admin) user so the counts have a known split.
let cookie = seed_admin(&pool, &h.app).await;
let _ = common::register_user(&h.app).await;
// Two mangas: 3 pages with 2 analyzed, and 2 pages with 0 analyzed.
seed_manga_pages(&pool, "Alpha", 3, 2).await;
seed_manga_pages(&pool, "Beta", 2, 0).await;
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/admin/overview", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let users = body.get("users").expect("users key");
assert_eq!(users["total"], json!(2));
assert_eq!(users["admins"], json!(1));
assert!(users["newest_username"].is_string());
let mangas = body.get("mangas").expect("mangas key");
assert_eq!(mangas["total"], json!(2));
// Uploaded mangas (no manga_sources) classify as synced.
assert_eq!(mangas["synced"], json!(2));
assert_eq!(mangas["in_progress"], json!(0));
assert_eq!(mangas["dropped"], json!(0));
assert_eq!(mangas["total_chapters"], json!(2));
assert_eq!(mangas["total_pages"], json!(5));
assert!(mangas["newest_title"].is_string());
let analysis = body.get("analysis").expect("analysis key");
assert_eq!(analysis["total_pages"], json!(5));
assert_eq!(analysis["analyzed_pages"], json!(2));
}

View File

@@ -86,6 +86,34 @@ async fn returns_disk_memory_cpu_alerts_shape(pool: PgPool) {
"cpu out of range: {cpu_pct}"
);
// Load average: three numeric keys. Values can legitimately be 0 on
// an idle box, so only assert presence + non-negativity.
let load = cpu["load_avg"].as_object().expect("cpu.load_avg object");
for k in ["one", "five", "fifteen"] {
let v = load[k].as_f64().unwrap_or_else(|| panic!("load_avg.{k} numeric"));
assert!(v >= 0.0, "load_avg.{k} negative: {v}");
}
// Per-core usage: an array of percentages in [0,100].
let cores = cpu["per_core"].as_array().expect("cpu.per_core array");
assert!(!cores.is_empty(), "expected at least one core");
for c in cores {
let p = c.as_f64().unwrap();
assert!((0.0..=100.0).contains(&p), "per_core out of range: {p}");
}
// Temperatures: an array (possibly empty in CI / containers without
// /sys sensors). Each present entry has a string label + numeric °C.
let temps = body
.get("temperatures")
.expect("temperatures key")
.as_array()
.expect("temperatures is an array");
for t in temps {
assert!(t["label"].is_string(), "temp label is string");
assert!(t["celsius"].as_f64().is_some(), "temp celsius is numeric");
}
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.