feat(admin): overview dashboard sections + system temperature/load sensors (0.85.0) (#11)
This commit was merged in pull request #11.
This commit is contained in:
@@ -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())
|
||||
|
||||
81
backend/src/api/admin/overview.rs
Normal file
81
backend/src/api/admin/overview.rs
Normal 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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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#"
|
||||
|
||||
Reference in New Issue
Block a user