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

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.84.0"
version = "0.85.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.84.0"
version = "0.85.0"
edition = "2021"
default-run = "mangalord"
@@ -48,7 +48,7 @@ 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"] }
sysinfo = { version = "0.32", default-features = false, features = ["system", "component"] }
nix = { version = "0.29", features = ["fs"] }
scraper = "0.20"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks", "cookies", "stream", "json"] }

View File

@@ -7,6 +7,7 @@
pub mod analysis;
pub mod crawler;
pub mod mangas;
pub mod overview;
pub mod resync;
pub mod settings;
pub mod storage;
@@ -24,6 +25,7 @@ pub fn routes() -> Router<AppState> {
.merge(resync::routes())
.merge(storage::routes())
.merge(system::routes())
.merge(overview::routes())
.merge(crawler::routes())
.merge(analysis::routes())
.merge(settings::routes())

View File

@@ -0,0 +1,81 @@
//! Admin overview dashboard aggregates.
//!
//! One composed endpoint feeding the Overview tab's per-section summary
//! cards (Users, Mangas, Analysis). Pure DB aggregates — no system
//! sampling — so it's cheap enough to poll. The System and Crawler cards
//! reuse their own existing endpoints (`/admin/system`, `/admin/crawler`),
//! and the Settings strip reuses the settings endpoints; only these three
//! sections needed new aggregation, so they live together here.
//!
//! Admin-only (`RequireAdmin`, cookie-only).
use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::AppResult;
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new().route("/admin/overview", get(overview))
}
#[derive(Debug, Serialize)]
pub struct OverviewStats {
pub users: UsersOverview,
pub mangas: repo::admin_view::MangaStats,
pub analysis: AnalysisOverview,
}
#[derive(Debug, Serialize)]
pub struct UsersOverview {
pub total: i64,
pub admins: i64,
pub newest_username: Option<String>,
pub newest_created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub struct AnalysisOverview {
pub analyzed_pages: i64,
pub total_pages: i64,
}
async fn overview(
State(state): State<AppState>,
_admin: RequireAdmin,
) -> AppResult<Json<OverviewStats>> {
// Independent reads — run concurrently so latency is the slowest
// query, not their sum (mirrors the storage handler).
let (user_counts, user_newest, mangas, coverage) = tokio::try_join!(
repo::user::counts(&state.db),
repo::user::newest(&state.db),
repo::admin_view::manga_stats(&state.db),
repo::page_analysis::library_coverage(&state.db),
)?;
let (total, admins) = user_counts;
let (newest_username, newest_created_at) = match user_newest {
Some((username, created_at)) => (Some(username), Some(created_at)),
None => (None, None),
};
let (analyzed_pages, total_pages) = coverage;
Ok(Json(OverviewStats {
users: UsersOverview {
total,
admins,
newest_username,
newest_created_at,
},
mangas,
analysis: AnalysisOverview {
analyzed_pages,
total_pages,
},
}))
}

View File

@@ -18,7 +18,7 @@ use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use serde::Serialize;
use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
use sysinfo::{Components, CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
@@ -35,6 +35,10 @@ pub struct SystemStats {
pub disk: Option<DiskStats>,
pub memory: MemoryStats,
pub cpu: CpuStats,
/// Hardware temperature sensors. Empty when the platform doesn't
/// expose any (common inside containers without `/sys` access) —
/// the frontend renders an "unavailable" state, not an error.
pub temperatures: Vec<TempStat>,
pub alerts: Vec<Alert>,
}
@@ -56,6 +60,26 @@ pub struct MemoryStats {
#[derive(Debug, Serialize)]
pub struct CpuStats {
pub percent_used: f64,
pub load_avg: LoadAvg,
/// Per-core usage percentages, one entry per logical core.
pub per_core: Vec<f64>,
}
#[derive(Debug, Serialize)]
pub struct LoadAvg {
pub one: f64,
pub five: f64,
pub fifteen: f64,
}
#[derive(Debug, Serialize)]
pub struct TempStat {
pub label: String,
pub celsius: f64,
/// Highest reading seen since boot, when the kernel exposes it.
pub max_celsius: Option<f64>,
/// Vendor critical/shutdown threshold, when available.
pub critical_celsius: Option<f64>,
}
#[derive(Debug, Serialize)]
@@ -76,6 +100,7 @@ async fn system(
) -> AppResult<Json<SystemStats>> {
let disk = state.storage.local_root().and_then(disk_stats_for);
let (memory, cpu) = memory_and_cpu().await;
let temperatures = temperatures();
let mut alerts = Vec::new();
if let Some(d) = &disk {
if d.percent_used >= ALERT_THRESHOLD_PERCENT {
@@ -97,14 +122,53 @@ async fn system(
),
});
}
for t in &temperatures {
if let Some(crit) = t.critical_celsius {
if t.celsius >= crit {
alerts.push(Alert {
level: AlertLevel::Warning,
message: format!(
"{} temperature critical ({:.0}°C / {:.0}°C)",
t.label, t.celsius, crit
),
});
}
}
}
Ok(Json(SystemStats {
disk,
memory,
cpu,
temperatures,
alerts,
}))
}
/// Hardware temperature sensors via `sysinfo`'s `Components`. Components
/// whose temperature can't be read report `f32::NAN` on Linux — we skip
/// those so the dashboard never shows a bogus "NaN°C" row. Returns an
/// empty Vec when no sensors are exposed at all.
fn temperatures() -> Vec<TempStat> {
let components = Components::new_with_refreshed_list();
components
.list()
.iter()
.filter_map(|c| {
let celsius = c.temperature();
if celsius.is_nan() {
return None;
}
let max = c.max();
Some(TempStat {
label: c.label().to_string(),
celsius: celsius as f64,
max_celsius: (!max.is_nan()).then_some(max as f64),
critical_celsius: c.critical().map(|v| v as f64),
})
})
.collect()
}
pub(crate) 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`
@@ -156,8 +220,15 @@ async fn memory_and_cpu() -> (MemoryStats, CpuStats) {
percent_used: mem_pct,
};
let load = System::load_average();
let cpu = CpuStats {
percent_used: sys.global_cpu_usage() as f64,
load_avg: LoadAvg {
one: load.one,
five: load.five,
fifteen: load.fifteen,
},
per_core: sys.cpus().iter().map(|c| c.cpu_usage() as f64).collect(),
};
(memory, cpu)
}

View File

@@ -83,6 +83,77 @@ const MANGA_SYNC_STATE_CASE: &str = r#"
END
"#;
/// Library shape for the admin overview: total mangas split by derived
/// sync state, plus library-wide chapter/page totals and the newest manga.
#[derive(Debug, Serialize)]
pub struct MangaStats {
pub total: i64,
pub synced: i64,
pub in_progress: i64,
pub dropped: i64,
pub total_chapters: i64,
pub total_pages: i64,
pub newest_title: Option<String>,
pub newest_seen_at: Option<DateTime<Utc>>,
}
/// Aggregate the manga library for the overview dashboard. Sync-state
/// counts reuse `MANGA_SYNC_STATE_CASE` so they can't drift from the
/// per-row classification on the Mangas tab.
pub async fn manga_stats(pool: &PgPool) -> AppResult<MangaStats> {
let counts_sql = format!(
r#"
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE s = 'synced')::bigint AS synced,
COUNT(*) FILTER (WHERE s = 'in_progress')::bigint AS in_progress,
COUNT(*) FILTER (WHERE s = 'dropped')::bigint AS dropped
FROM (SELECT {case} AS s FROM mangas m) q
"#,
case = MANGA_SYNC_STATE_CASE
);
let (total, synced, in_progress, dropped): (i64, i64, i64, i64) =
sqlx::query_as(&counts_sql).fetch_one(pool).await?;
let (total_chapters,): (i64,) =
sqlx::query_as("SELECT COUNT(*)::bigint FROM chapters")
.fetch_one(pool)
.await?;
let (total_pages,): (i64,) = sqlx::query_as("SELECT COUNT(*)::bigint FROM pages")
.fetch_one(pool)
.await?;
// Newest by creation, with its own latest non-dropped crawl sighting
// (NULL for user uploads with no source rows).
let newest: Option<(String, Option<DateTime<Utc>>)> = sqlx::query_as(
r#"
SELECT m.title,
(SELECT MAX(ms.last_seen_at) FROM manga_sources ms
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL)
FROM mangas m
ORDER BY m.created_at DESC
LIMIT 1
"#,
)
.fetch_optional(pool)
.await?;
let (newest_title, newest_seen_at) = match newest {
Some((title, seen)) => (Some(title), seen),
None => (None, None),
};
Ok(MangaStats {
total,
synced,
in_progress,
dropped,
total_chapters,
total_pages,
newest_title,
newest_seen_at,
})
}
/// Paginated admin manga list with derived sync state and total count.
/// Filters by `search` (substring on title, case-insensitive) and
/// `sync_state` (post-derivation). The CTE keeps the case expression

View File

@@ -159,6 +159,24 @@ pub async fn manga_coverage(
Ok((rows, total))
}
/// Library-wide analysis coverage `(analyzed_pages, total_pages)` for the
/// admin overview. `total_pages` is every page; `analyzed_pages` are those
/// with a `done` analysis row.
pub async fn library_coverage(pool: &PgPool) -> AppResult<(i64, i64)> {
let row: (i64, i64) = sqlx::query_as(
r#"
SELECT
count(*) FILTER (WHERE pa.status = 'done')::bigint AS analyzed,
count(p.id)::bigint AS total
FROM pages p
LEFT JOIN page_analysis pa ON pa.page_id = p.id
"#,
)
.fetch_one(pool)
.await?;
Ok(row)
}
/// Per-chapter analysis coverage for one manga, ordered by chapter number.
pub async fn chapter_coverage(
pool: &PgPool,

View File

@@ -1,11 +1,31 @@
//! User persistence.
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::User;
use crate::error::{AppError, AppResult};
/// `(total, admins)` user counts for the admin overview dashboard.
pub async fn counts(pool: &PgPool) -> AppResult<(i64, i64)> {
let row: (i64, i64) = sqlx::query_as(
"SELECT COUNT(*)::bigint, COUNT(*) FILTER (WHERE is_admin)::bigint FROM users",
)
.fetch_one(pool)
.await?;
Ok(row)
}
/// Most recently created user (username, created_at), if any exist.
pub async fn newest(pool: &PgPool) -> AppResult<Option<(String, DateTime<Utc>)>> {
let row: Option<(String, DateTime<Utc>)> =
sqlx::query_as("SELECT username, created_at FROM users ORDER BY created_at DESC LIMIT 1")
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppResult<User> {
let result = sqlx::query_as::<_, User>(
r#"

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.