diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5626a4d..3044d5b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.84.0" +version = "0.85.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 202d603..2aa00bc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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"] } diff --git a/backend/src/api/admin/mod.rs b/backend/src/api/admin/mod.rs index 4ef20fe..1f33ae5 100644 --- a/backend/src/api/admin/mod.rs +++ b/backend/src/api/admin/mod.rs @@ -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 { .merge(resync::routes()) .merge(storage::routes()) .merge(system::routes()) + .merge(overview::routes()) .merge(crawler::routes()) .merge(analysis::routes()) .merge(settings::routes()) diff --git a/backend/src/api/admin/overview.rs b/backend/src/api/admin/overview.rs new file mode 100644 index 0000000..f206495 --- /dev/null +++ b/backend/src/api/admin/overview.rs @@ -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 { + 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, + pub newest_created_at: Option>, +} + +#[derive(Debug, Serialize)] +pub struct AnalysisOverview { + pub analyzed_pages: i64, + pub total_pages: i64, +} + +async fn overview( + State(state): State, + _admin: RequireAdmin, +) -> AppResult> { + // 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, + }, + })) +} diff --git a/backend/src/api/admin/system.rs b/backend/src/api/admin/system.rs index ea5a576..f90c30b 100644 --- a/backend/src/api/admin/system.rs +++ b/backend/src/api/admin/system.rs @@ -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, 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, pub alerts: Vec, } @@ -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, +} + +#[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, + /// Vendor critical/shutdown threshold, when available. + pub critical_celsius: Option, } #[derive(Debug, Serialize)] @@ -76,6 +100,7 @@ async fn system( ) -> AppResult> { 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 { + 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 { 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) } diff --git a/backend/src/repo/admin_view.rs b/backend/src/repo/admin_view.rs index 63564f3..f7cf2d2 100644 --- a/backend/src/repo/admin_view.rs +++ b/backend/src/repo/admin_view.rs @@ -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, + pub newest_seen_at: Option>, +} + +/// 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 { + 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>)> = 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 diff --git a/backend/src/repo/page_analysis.rs b/backend/src/repo/page_analysis.rs index b93a210..4815c23 100644 --- a/backend/src/repo/page_analysis.rs +++ b/backend/src/repo/page_analysis.rs @@ -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, diff --git a/backend/src/repo/user.rs b/backend/src/repo/user.rs index 7e20c08..904d856 100644 --- a/backend/src/repo/user.rs +++ b/backend/src/repo/user.rs @@ -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)>> { + let row: Option<(String, DateTime)> = + 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 { let result = sqlx::query_as::<_, User>( r#" diff --git a/backend/tests/api_admin_overview.rs b/backend/tests/api_admin_overview.rs new file mode 100644 index 0000000..1f949ef --- /dev/null +++ b/backend/tests/api_admin_overview.rs @@ -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 { + 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)); +} diff --git a/backend/tests/api_admin_system.rs b/backend/tests/api_admin_system.rs index b055687..ad39708 100644 --- a/backend/tests/api_admin_system.rs +++ b/backend/tests/api_admin_system.rs @@ -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. diff --git a/frontend/package.json b/frontend/package.json index ad6e0ad..028bd1f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.84.0", + "version": "0.85.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/admin/temps.test.ts b/frontend/src/lib/admin/temps.test.ts new file mode 100644 index 0000000..2c1a57e --- /dev/null +++ b/frontend/src/lib/admin/temps.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { pickCpuTemp } from './temps'; +import type { TempStat } from '$lib/api/admin'; + +function t(label: string, celsius: number): TempStat { + return { label, celsius, max_celsius: celsius, critical_celsius: 100 }; +} + +describe('pickCpuTemp', () => { + it('returns null when there are no sensors', () => { + expect(pickCpuTemp([])).toBeNull(); + }); + + it('prefers the Intel package sensor over individual cores', () => { + const temps = [ + t('coretemp Core 0', 50), + t('coretemp Core 1', 49), + t('coretemp Package id 0', 51), + t('acpitz temp1', 28) + ]; + expect(pickCpuTemp(temps)?.label).toBe('coretemp Package id 0'); + }); + + it('falls back to the AMD Tctl/Tdie sensor when there is no package', () => { + const temps = [t('k10temp Tctl', 55), t('nvme Composite', 41)]; + expect(pickCpuTemp(temps)?.label).toBe('k10temp Tctl'); + }); + + it('falls back to the hottest core when only per-core sensors exist', () => { + const temps = [t('coretemp Core 0', 50), t('coretemp Core 3', 58), t('coretemp Core 1', 49)]; + expect(pickCpuTemp(temps)?.label).toBe('coretemp Core 3'); + }); + + it('falls back to any cpu/coretemp-labelled sensor as a last resort', () => { + const temps = [t('nvme Composite', 41), t('cpu_thermal', 47)]; + expect(pickCpuTemp(temps)?.label).toBe('cpu_thermal'); + }); + + it('returns null when nothing looks like a CPU sensor', () => { + const temps = [t('nvme Composite', 41), t('acpitz temp1', 28)]; + expect(pickCpuTemp(temps)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/admin/temps.ts b/frontend/src/lib/admin/temps.ts new file mode 100644 index 0000000..1d33dbc --- /dev/null +++ b/frontend/src/lib/admin/temps.ts @@ -0,0 +1,25 @@ +import type { TempStat } from '$lib/api/admin'; + +/** Pick the sensor that best represents the CPU die/package temperature + * from a heterogeneous component list. Intel exposes + * `coretemp Package id 0`; AMD exposes `k10temp Tctl`/`Tdie`. When neither + * is present we fall back to the hottest individual core, then to any + * CPU-ish sensor, and finally `null` (nothing CPU-related — e.g. only NVMe + * / chipset sensors are exposed). Used to keep the System tab focused on + * the one reading operators care about, with the rest tucked away. */ +export function pickCpuTemp(temps: TempStat[]): TempStat | null { + const byLabel = (re: RegExp) => temps.find((t) => re.test(t.label)) ?? null; + return ( + byLabel(/package/i) ?? + byLabel(/tctl|tdie|k10temp/i) ?? + hottestCore(temps) ?? + byLabel(/cpu|coretemp/i) ?? + null + ); +} + +function hottestCore(temps: TempStat[]): TempStat | null { + const cores = temps.filter((t) => /core\s*\d+/i.test(t.label)); + if (cores.length === 0) return null; + return cores.reduce((a, b) => (b.celsius > a.celsius ? b : a)); +} diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts index 8122802..df34aa6 100644 --- a/frontend/src/lib/api/admin.test.ts +++ b/frontend/src/lib/api/admin.test.ts @@ -15,6 +15,7 @@ import { listAdminMangas, listAdminChapters, getSystemStats, + getOverviewStats, getStorageStats, backfillStorage, resyncManga, @@ -88,7 +89,14 @@ const systemFixture = { percent_used: 50.0 }, memory: { total_bytes: 8_000_000, used_bytes: 4_000_000, percent_used: 50.0 }, - cpu: { percent_used: 12.3 }, + cpu: { + percent_used: 12.3, + load_avg: { one: 0.81, five: 0.66, fifteen: 0.59 }, + per_core: [10.0, 14.5, 8.2, 20.1] + }, + temperatures: [ + { label: 'CPU Package', celsius: 52.0, max_celsius: 71.0, critical_celsius: 100.0 } + ], alerts: [] }; @@ -258,6 +266,10 @@ describe('admin api client', () => { expect(s.disk?.percent_used).toBe(50); expect(s.memory.percent_used).toBe(50); expect(s.cpu.percent_used).toBe(12.3); + expect(s.cpu.load_avg.one).toBe(0.81); + expect(s.cpu.per_core).toHaveLength(4); + expect(s.temperatures[0].label).toBe('CPU Package'); + expect(s.temperatures[0].critical_celsius).toBe(100); expect(s.alerts).toEqual([]); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/admin\/system$/); @@ -269,6 +281,43 @@ describe('admin api client', () => { expect(s.disk).toBeNull(); }); + it('getSystemStats tolerates an empty temperatures array (sensors unavailable)', async () => { + fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, temperatures: [] })); + const s = await getSystemStats(); + expect(s.temperatures).toEqual([]); + }); + + it('getOverviewStats GETs /v1/admin/overview and parses the nested envelope', async () => { + const fixture = { + users: { + total: 7, + admins: 2, + newest_username: 'kenji', + newest_created_at: '2026-06-15T00:00:00Z' + }, + mangas: { + total: 12, + synced: 10, + in_progress: 1, + dropped: 1, + total_chapters: 340, + total_pages: 9001, + newest_title: 'Sakamoto Days', + newest_seen_at: '2026-06-16T00:00:00Z' + }, + analysis: { analyzed_pages: 5760, total_pages: 9001 } + }; + fetchSpy.mockResolvedValueOnce(ok(fixture)); + const s = await getOverviewStats(); + expect(s.users.total).toBe(7); + expect(s.users.admins).toBe(2); + expect(s.mangas.synced).toBe(10); + expect(s.mangas.total_pages).toBe(9001); + expect(s.analysis.analyzed_pages).toBe(5760); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/admin\/overview$/); + }); + // ---- force resync ---- it('resyncManga POSTs to /v1/admin/mangas/{id}/resync and returns the envelope', async () => { diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 5f10500..189d73c 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -160,8 +160,24 @@ export type MemoryStats = { percent_used: number; }; +export type LoadAvg = { + one: number; + five: number; + fifteen: number; +}; + export type CpuStats = { percent_used: number; + load_avg: LoadAvg; + /** Per-core usage percentages, one entry per logical core. */ + per_core: number[]; +}; + +export type TempStat = { + label: string; + celsius: number; + max_celsius: number | null; + critical_celsius: number | null; }; export type Alert = { @@ -173,6 +189,9 @@ export type SystemStats = { disk: DiskStats | null; memory: MemoryStats; cpu: CpuStats; + /** Hardware temperature sensors. Empty when none are exposed (e.g. + * inside a container without `/sys` access). */ + temperatures: TempStat[]; alerts: Alert[]; }; @@ -180,6 +199,41 @@ export async function getSystemStats(): Promise { return request('/v1/admin/system'); } +// ---- overview aggregates --------------------------------------------------- + +export type UsersOverview = { + total: number; + admins: number; + newest_username: string | null; + newest_created_at: string | null; +}; + +export type MangasOverview = { + total: number; + synced: number; + in_progress: number; + dropped: number; + total_chapters: number; + total_pages: number; + newest_title: string | null; + newest_seen_at: string | null; +}; + +export type AnalysisOverview = { + analyzed_pages: number; + total_pages: number; +}; + +export type OverviewStats = { + users: UsersOverview; + mangas: MangasOverview; + analysis: AnalysisOverview; +}; + +export async function getOverviewStats(): Promise { + return request('/v1/admin/overview'); +} + // ---- storage usage --------------------------------------------------------- export type TopManga = { diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 974fc08..707577d 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1,58 +1,431 @@

Overview

-{#if stats.alerts.length > 0} +{#if sys.alerts.length > 0 || extraAlerts.length > 0}
- {#each stats.alerts as a (a.message)} + {#each sys.alerts as a (a.message)}
{a.message}
{/each} + {#each extraAlerts as msg (msg)} +
{msg}
+ {/each}
{/if} -
- {#if stats.disk} -
-

Disk

-

{fmtPercent(stats.disk.percent_used)}

-

{fmtBytes(stats.disk.used_bytes)} of {fmtBytes(stats.disk.total_bytes)} used

-
- {:else} -
-

Disk

-

n/a (non-local storage)

-
- {/if} -
-

Memory

-

{fmtPercent(stats.memory.percent_used)}

-

{fmtBytes(stats.memory.used_bytes)} of {fmtBytes(stats.memory.total_bytes)} used

-
-
-

CPU

-

{fmtPercent(stats.cpu.percent_used)}

-

global load

-
-
+
+ +
+
+

System

+ View → +
+ {#if sys.disk} + {@render MeterRow( + 'Disk', + sys.disk.percent_used, + `${fmtBytes(sys.disk.used_bytes)} / ${fmtBytes(sys.disk.total_bytes)}` + )} + {:else} + {@render MeterRow('Disk', 0, 'n/a — non-local storage')} + {/if} + {@render MeterRow( + 'Memory', + sys.memory.percent_used, + `${fmtBytes(sys.memory.used_bytes)} / ${fmtBytes(sys.memory.total_bytes)}` + )} + {@render MeterRow('CPU', sys.cpu.percent_used, `load ${sys.cpu.load_avg.one.toFixed(2)}`)} +
+ + +
+
+

Crawler

+ + View → +
+ {#if crawler} +

+ + {crawler.daemon} · phase {crawler.phase?.state ?? 'idle'} · {crawler.worker_count} workers +

+

+ Queue pending {fmtInt(crawler.queue.pending)} · running {fmtInt(crawler.queue.running)} + · 0}>dead {fmtInt(crawler.queue.dead)} + · covers {fmtInt(crawler.covers_queued)} +

+

+ browser {crawler.browser} · session {crawler.session.configured ? '✓' : '—'} · last + pass +{fmtInt(crawler.last_pass.upserted)} / {fmtInt(crawler.last_pass.discovered)} seen + / {fmtInt(crawler.last_pass.mangas_failed)} failed +

+ {:else} +

Loading…

+ {/if} +
+ + +
+
+

Analysis

+ + View → +
+ {#if overview} + {@render MeterRow( + 'Coverage', + coverage, + `${fmtInt(overview.analysis.analyzed_pages)} / ${fmtInt(overview.analysis.total_pages)} pages` + )} + {/if} + {#if nowAnalyzing} +

+ {nowAnalyzing.phase === 'analyzing' ? 'now' : 'last'}: + {nowAnalyzing.title} ch.{nowAnalyzing.chapter} p.{nowAnalyzing.page} + · {nowAnalyzing.phase} +

+ {/if} + {#if metrics} +

+ last 24h {fmtInt(metrics.ok)} ok · {fmtInt(metrics.failed)} failed · ⌀ {secs( + metrics.avg_ms + )}/page +

+ {/if} +
+ + +
+
+

Mangas

+ View → +
+ {#if overview} +

+ {fmtInt(overview.mangas.total)} mangas · {fmtInt(overview.mangas.total_chapters)} chapters + · {fmtInt(overview.mangas.total_pages)} pages +

+

+ synced {fmtInt(overview.mangas.synced)} · in progress {fmtInt( + overview.mangas.in_progress + )} · dropped {fmtInt(overview.mangas.dropped)} +

+ {#if overview.mangas.newest_title} +

+ newest: {overview.mangas.newest_title}{overview.mangas.newest_seen_at + ? ` (seen ${ago(overview.mangas.newest_seen_at)})` + : ''} +

+ {/if} + {:else} +

Loading…

+ {/if} +
+ + +
+
+

Users

+ View → +
+ {#if overview} +

+ {fmtInt(overview.users.total)} users · {fmtInt(overview.users.admins)} admins +

+ {#if overview.users.newest_username} +

+ newest: {overview.users.newest_username}{overview.users.newest_created_at + ? ` (created ${ago(overview.users.newest_created_at)})` + : ''} +

+ {/if} + {:else} +

Loading…

+ {/if} +
+ + +
+
+ + + Crawler {crawlerDaemon === null ? '—' : crawlerDaemon ? 'enabled' : 'disabled'} + {#if crawlerPassAt}· pass {crawlerPassAt}{/if} + + | + + + Analysis {analysisEnabled === null ? '—' : analysisEnabled ? 'enabled' : 'disabled'} + {#if analysisModel}· {analysisModel}{/if} + {#if analysisEndpointOk !== null}· endpoint {analysisEndpointOk ? '✓' : '—'}{/if} + +
+ View → +
+
+ +{#snippet MeterRow(label: string, percent: number, sub: string)} +
+ {label} +
+
= 90} + class:mid={percent >= 70 && percent < 90} + style:width="{Math.min(100, Math.max(0, percent))}%" + >
+ {fmtPercent(percent)} +
+ {sub} +
+{/snippet} diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index de9138c..8d28f52 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -8,8 +8,16 @@ type StorageStats } from '$lib/api/admin'; import { formatBytes as fmtBytes } from '$lib/upload-validation'; + import { pickCpuTemp } from '$lib/admin/temps'; let stats: SystemStats | null = $state(null); + + // The component list is noisy (every core, chipset, NVMe…). Surface the + // CPU die/package reading and tuck the rest behind a disclosure. + const cpuTemp = $derived.by(() => (stats ? pickCpuTemp(stats.temperatures) : null)); + const otherTemps = $derived.by(() => + stats ? stats.temperatures.filter((t) => t !== cpuTemp) : [] + ); let error: string | null = $state(null); let timer: ReturnType | null = null; let storageTimer: ReturnType | null = null; @@ -132,6 +140,57 @@

CPU

{@render Bar({ percent: stats.cpu.percent_used })} +
+
Load avg
+
+ {stats.cpu.load_avg.one.toFixed(2)} / {stats.cpu.load_avg.five.toFixed(2)} / {stats.cpu.load_avg.fifteen.toFixed( + 2 + )} +
+
+ {#if stats.cpu.per_core.length > 0} +
+ {#each stats.cpu.per_core as core, i (i)} +
+
= 90} + class:mid={core >= 70 && core < 90} + style:height="{Math.min(100, Math.max(0, core))}%" + >
+
+ {/each} +
+ {/if} +
+ +
+

Temperatures

+ {#if stats.temperatures.length === 0} +

Sensor data unavailable (not exposed in this environment).

+ {:else if cpuTemp} + {@render Temp(cpuTemp)} + {#if otherTemps.length > 0} +
+ {otherTemps.length} other sensor{otherTemps.length === 1 ? '' : 's'} + {#each otherTemps as t (t.label)} + {@render Temp(t)} + {/each} +
+ {/if} + {:else} + {#each stats.temperatures as t (t.label)} + {@render Temp(t)} + {/each} + {/if}
@@ -231,6 +290,37 @@ {/if} +{#snippet Temp(t: { label: string; celsius: number; max_celsius: number | null; critical_celsius: number | null })} + {@const limit = t.critical_celsius ?? 100} + {@const pct = Math.min(100, Math.max(0, (t.celsius / limit) * 100))} +
+
+ {t.label} + {t.celsius.toFixed(0)}°C +
+
+
= 90} + class:mid={pct >= 70 && pct < 90} + style:width="{pct}%" + >
+
+

+ {#if t.max_celsius !== null}max {t.max_celsius.toFixed(0)}°C{/if} + {#if t.critical_celsius !== null} + · crit {t.critical_celsius.toFixed(0)}°C{/if} +

+
+{/snippet} + {#snippet Bar({ percent }: { percent: number })}