feat(admin): overview dashboard sections + system temperature/load sensors (0.85.0) #11
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.84.0"
|
||||
version = "0.85.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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#"
|
||||
|
||||
125
backend/tests/api_admin_overview.rs
Normal file
125
backend/tests/api_admin_overview.rs
Normal 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));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.84.0",
|
||||
"version": "0.85.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<SystemStats> {
|
||||
return request<SystemStats>('/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<OverviewStats> {
|
||||
return request<OverviewStats>('/v1/admin/overview');
|
||||
}
|
||||
|
||||
// ---- storage usage ---------------------------------------------------------
|
||||
|
||||
export type TopManga = {
|
||||
|
||||
@@ -1,58 +1,431 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
getSystemStats,
|
||||
getOverviewStats,
|
||||
getCrawlerStatus,
|
||||
crawlerStatusStreamUrl,
|
||||
getAnalysisMetrics,
|
||||
analysisStatusStreamUrl,
|
||||
getCrawlerSettings,
|
||||
getAnalysisSettings,
|
||||
type SystemStats,
|
||||
type OverviewStats,
|
||||
type CrawlerStatus,
|
||||
type AnalysisMetrics,
|
||||
type AnalysisEvent
|
||||
} from '$lib/api/admin';
|
||||
import { formatBytes as fmtBytes } from '$lib/upload-validation';
|
||||
import type { LayoutData } from './$types';
|
||||
let { data }: { data: LayoutData } = $props();
|
||||
const stats = $derived(data.stats);
|
||||
|
||||
function fmtPercent(n: number): string {
|
||||
return `${n.toFixed(1)}%`;
|
||||
let { data }: { data: LayoutData } = $props();
|
||||
|
||||
// System stats are loaded once by the layout gate; seed from there and
|
||||
// keep polling so the Overview's System card stays live. Capturing the
|
||||
// initial `data.stats` is intentional — we own `sys` after mount.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let sys: SystemStats = $state(data.stats);
|
||||
let overview: OverviewStats | null = $state(null);
|
||||
let crawler: CrawlerStatus | null = $state(null);
|
||||
let metrics: AnalysisMetrics | null = $state(null);
|
||||
let nowAnalyzing: {
|
||||
title: string;
|
||||
chapter: number;
|
||||
page: number;
|
||||
phase: 'analyzing' | 'done' | 'failed';
|
||||
} | null = $state(null);
|
||||
// Settings strip — daemon state + a couple of effective values.
|
||||
let crawlerDaemon = $state<boolean | null>(null);
|
||||
let crawlerPassAt = $state<string | null>(null);
|
||||
let analysisEnabled = $state<boolean | null>(null);
|
||||
let analysisModel = $state<string | null>(null);
|
||||
let analysisEndpointOk = $state<boolean | null>(null);
|
||||
|
||||
let liveCrawler = $state(false);
|
||||
let liveAnalysis = $state(false);
|
||||
|
||||
let sysTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let overviewTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let crawlerSource: EventSource | null = null;
|
||||
let analysisSource: EventSource | null = null;
|
||||
|
||||
const coverage = $derived.by(() => {
|
||||
if (!overview) return 0;
|
||||
const a = overview.analysis;
|
||||
return a.total_pages > 0 ? (a.analyzed_pages / a.total_pages) * 100 : 0;
|
||||
});
|
||||
|
||||
// Alerts rail: system thresholds plus crawler health signals.
|
||||
const extraAlerts = $derived.by(() => {
|
||||
const out: string[] = [];
|
||||
if (crawler) {
|
||||
if (crawler.queue.dead > 0) out.push(`crawler: ${crawler.queue.dead} dead job(s)`);
|
||||
if (crawler.session.expired) out.push('crawler: session expired');
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
async function refreshSys() {
|
||||
try {
|
||||
sys = await getSystemStats();
|
||||
} catch {
|
||||
// keep the last good snapshot; transient errors self-heal next poll
|
||||
}
|
||||
}
|
||||
async function refreshOverview() {
|
||||
try {
|
||||
overview = await getOverviewStats();
|
||||
} catch {
|
||||
// ignore; retried on the next tick
|
||||
}
|
||||
}
|
||||
async function refreshMetrics() {
|
||||
try {
|
||||
metrics = await getAnalysisMetrics(1);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const c = await getCrawlerSettings();
|
||||
crawlerDaemon = c.editable.daemon_enabled;
|
||||
crawlerPassAt = c.editable.daily_at;
|
||||
} catch {
|
||||
// strip degrades to "—"
|
||||
}
|
||||
try {
|
||||
const a = await getAnalysisSettings();
|
||||
analysisEnabled = a.editable.enabled;
|
||||
analysisModel = a.editable.model;
|
||||
analysisEndpointOk = a.env_only.api_key_configured && a.editable.endpoint.length > 0;
|
||||
} catch {
|
||||
// strip degrades to "—"
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
function applyAnalysisEvent(ev: AnalysisEvent) {
|
||||
if (ev.kind === 'enqueued') return;
|
||||
const phase =
|
||||
ev.kind === 'started' ? 'analyzing' : ev.kind === 'completed' ? 'done' : 'failed';
|
||||
nowAnalyzing = {
|
||||
title: ev.manga_title,
|
||||
chapter: ev.chapter_number,
|
||||
page: ev.page_number,
|
||||
phase
|
||||
};
|
||||
}
|
||||
|
||||
function openCrawlerStream() {
|
||||
if (crawlerSource) return;
|
||||
const es = new EventSource(crawlerStatusStreamUrl(), { withCredentials: true });
|
||||
es.addEventListener('status', (e) => {
|
||||
try {
|
||||
crawler = JSON.parse((e as MessageEvent).data) as CrawlerStatus;
|
||||
liveCrawler = true;
|
||||
} catch {
|
||||
// skip a malformed frame; the next replaces it
|
||||
}
|
||||
});
|
||||
es.onopen = () => (liveCrawler = true);
|
||||
es.onerror = () => (liveCrawler = false);
|
||||
crawlerSource = es;
|
||||
}
|
||||
function openAnalysisStream() {
|
||||
if (analysisSource) return;
|
||||
const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
|
||||
es.addEventListener('analysis', (e) => {
|
||||
try {
|
||||
applyAnalysisEvent(JSON.parse((e as MessageEvent).data) as AnalysisEvent);
|
||||
liveAnalysis = true;
|
||||
} catch {
|
||||
// skip malformed frame
|
||||
}
|
||||
});
|
||||
// Dropped frames — pull a fresh coverage snapshot. (The 30s poll
|
||||
// would catch up anyway; this just tightens the gap.)
|
||||
es.addEventListener('lagged', () => {
|
||||
refreshOverview();
|
||||
refreshMetrics();
|
||||
});
|
||||
es.onopen = () => (liveAnalysis = true);
|
||||
es.onerror = () => (liveAnalysis = false);
|
||||
analysisSource = es;
|
||||
}
|
||||
function closeStreams() {
|
||||
crawlerSource?.close();
|
||||
crawlerSource = null;
|
||||
analysisSource?.close();
|
||||
analysisSource = null;
|
||||
liveCrawler = false;
|
||||
liveAnalysis = false;
|
||||
}
|
||||
function onVisibilityChange() {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (document.visibilityState === 'hidden') {
|
||||
closeStreams();
|
||||
} else {
|
||||
openCrawlerStream();
|
||||
openAnalysisStream();
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
// Close the streams entering BFCache and reopen on restore, so a
|
||||
// back/forward navigation doesn't leave a stale connection behind.
|
||||
function onPageHide() {
|
||||
closeStreams();
|
||||
}
|
||||
function onPageShow() {
|
||||
openCrawlerStream();
|
||||
openAnalysisStream();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refreshSys();
|
||||
refreshOverview();
|
||||
refreshMetrics();
|
||||
loadSettings();
|
||||
getCrawlerStatus()
|
||||
.then((s) => (crawler = s))
|
||||
.catch(() => {});
|
||||
openCrawlerStream();
|
||||
openAnalysisStream();
|
||||
sysTimer = setInterval(refreshSys, 5000);
|
||||
overviewTimer = setInterval(() => {
|
||||
refreshOverview();
|
||||
refreshMetrics();
|
||||
}, 30000);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
window.addEventListener('pageshow', onPageShow);
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (sysTimer) clearInterval(sysTimer);
|
||||
if (overviewTimer) clearInterval(overviewTimer);
|
||||
closeStreams();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
window.removeEventListener('pageshow', onPageShow);
|
||||
}
|
||||
});
|
||||
|
||||
function fmtPercent(pct: number): string {
|
||||
return `${pct.toFixed(0)}%`;
|
||||
}
|
||||
function fmtInt(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
function ago(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (s < 60) return `${Math.floor(s)}s ago`;
|
||||
const mins = s / 60;
|
||||
if (mins < 60) return `${Math.floor(mins)}m ago`;
|
||||
const hrs = mins / 60;
|
||||
if (hrs < 24) return `${Math.floor(hrs)}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
function secs(ms: number | null): string {
|
||||
return ms === null ? '—' : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Overview</h1>
|
||||
|
||||
{#if stats.alerts.length > 0}
|
||||
{#if sys.alerts.length > 0 || extraAlerts.length > 0}
|
||||
<section class="alerts" data-testid="admin-alerts">
|
||||
{#each stats.alerts as a (a.message)}
|
||||
{#each sys.alerts as a (a.message)}
|
||||
<div class="alert" data-level={a.level}>{a.message}</div>
|
||||
{/each}
|
||||
{#each extraAlerts as msg (msg)}
|
||||
<div class="alert" data-level="warning">{msg}</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="cards">
|
||||
{#if stats.disk}
|
||||
<article class="card">
|
||||
<h3>Disk</h3>
|
||||
<p class="metric">{fmtPercent(stats.disk.percent_used)}</p>
|
||||
<p class="sub">{fmtBytes(stats.disk.used_bytes)} of {fmtBytes(stats.disk.total_bytes)} used</p>
|
||||
</article>
|
||||
{:else}
|
||||
<article class="card muted">
|
||||
<h3>Disk</h3>
|
||||
<p>n/a (non-local storage)</p>
|
||||
</article>
|
||||
{/if}
|
||||
<article class="card">
|
||||
<h3>Memory</h3>
|
||||
<p class="metric">{fmtPercent(stats.memory.percent_used)}</p>
|
||||
<p class="sub">{fmtBytes(stats.memory.used_bytes)} of {fmtBytes(stats.memory.total_bytes)} used</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>CPU</h3>
|
||||
<p class="metric">{fmtPercent(stats.cpu.percent_used)}</p>
|
||||
<p class="sub">global load</p>
|
||||
</article>
|
||||
</section>
|
||||
<div class="sections">
|
||||
<!-- System -->
|
||||
<section class="card">
|
||||
<header>
|
||||
<h2>System</h2>
|
||||
<a class="view" href="/admin/system">View →</a>
|
||||
</header>
|
||||
{#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)}`)}
|
||||
</section>
|
||||
|
||||
<!-- Crawler -->
|
||||
<section class="card">
|
||||
<header>
|
||||
<h2>Crawler</h2>
|
||||
<span
|
||||
class="live"
|
||||
class:on={liveCrawler}
|
||||
title={liveCrawler ? 'live' : 'reconnecting'}
|
||||
></span>
|
||||
<a class="view" href="/admin/crawler">View →</a>
|
||||
</header>
|
||||
{#if crawler}
|
||||
<p class="line">
|
||||
<span class="dot" class:ok={crawler.daemon === 'running'}></span>
|
||||
{crawler.daemon} · phase {crawler.phase?.state ?? 'idle'} · {crawler.worker_count} workers
|
||||
</p>
|
||||
<p class="line">
|
||||
Queue pending {fmtInt(crawler.queue.pending)} · running {fmtInt(crawler.queue.running)}
|
||||
· <span class:warn={crawler.queue.dead > 0}>dead {fmtInt(crawler.queue.dead)}</span>
|
||||
· covers {fmtInt(crawler.covers_queued)}
|
||||
</p>
|
||||
<p class="line muted">
|
||||
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
|
||||
</p>
|
||||
{:else}
|
||||
<p class="muted">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Analysis -->
|
||||
<section class="card">
|
||||
<header>
|
||||
<h2>Analysis</h2>
|
||||
<span
|
||||
class="live"
|
||||
class:on={liveAnalysis}
|
||||
title={liveAnalysis ? 'live' : 'reconnecting'}
|
||||
></span>
|
||||
<a class="view" href="/admin/analysis">View →</a>
|
||||
</header>
|
||||
{#if overview}
|
||||
{@render MeterRow(
|
||||
'Coverage',
|
||||
coverage,
|
||||
`${fmtInt(overview.analysis.analyzed_pages)} / ${fmtInt(overview.analysis.total_pages)} pages`
|
||||
)}
|
||||
{/if}
|
||||
{#if nowAnalyzing}
|
||||
<p class="line">
|
||||
{nowAnalyzing.phase === 'analyzing' ? 'now' : 'last'}:
|
||||
<strong>{nowAnalyzing.title}</strong> ch.{nowAnalyzing.chapter} p.{nowAnalyzing.page}
|
||||
· {nowAnalyzing.phase}
|
||||
</p>
|
||||
{/if}
|
||||
{#if metrics}
|
||||
<p class="line muted">
|
||||
last 24h {fmtInt(metrics.ok)} ok · {fmtInt(metrics.failed)} failed · ⌀ {secs(
|
||||
metrics.avg_ms
|
||||
)}/page
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Mangas -->
|
||||
<section class="card">
|
||||
<header>
|
||||
<h2>Mangas</h2>
|
||||
<a class="view" href="/admin/mangas">View →</a>
|
||||
</header>
|
||||
{#if overview}
|
||||
<p class="line big">
|
||||
{fmtInt(overview.mangas.total)} mangas · {fmtInt(overview.mangas.total_chapters)} chapters
|
||||
· {fmtInt(overview.mangas.total_pages)} pages
|
||||
</p>
|
||||
<p class="line">
|
||||
synced {fmtInt(overview.mangas.synced)} · in progress {fmtInt(
|
||||
overview.mangas.in_progress
|
||||
)} · dropped {fmtInt(overview.mangas.dropped)}
|
||||
</p>
|
||||
{#if overview.mangas.newest_title}
|
||||
<p class="line muted">
|
||||
newest: {overview.mangas.newest_title}{overview.mangas.newest_seen_at
|
||||
? ` (seen ${ago(overview.mangas.newest_seen_at)})`
|
||||
: ''}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Users -->
|
||||
<section class="card">
|
||||
<header>
|
||||
<h2>Users</h2>
|
||||
<a class="view" href="/admin/users">View →</a>
|
||||
</header>
|
||||
{#if overview}
|
||||
<p class="line big">
|
||||
{fmtInt(overview.users.total)} users · {fmtInt(overview.users.admins)} admins
|
||||
</p>
|
||||
{#if overview.users.newest_username}
|
||||
<p class="line muted">
|
||||
newest: {overview.users.newest_username}{overview.users.newest_created_at
|
||||
? ` (created ${ago(overview.users.newest_created_at)})`
|
||||
: ''}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Settings (thin daemon-status strip) -->
|
||||
<section class="card strip">
|
||||
<div class="strip-body">
|
||||
<span>
|
||||
<span class="dot" class:ok={crawlerDaemon === true}></span>
|
||||
Crawler {crawlerDaemon === null ? '—' : crawlerDaemon ? 'enabled' : 'disabled'}
|
||||
{#if crawlerPassAt}· pass {crawlerPassAt}{/if}
|
||||
</span>
|
||||
<span class="sep">|</span>
|
||||
<span>
|
||||
<span class="dot" class:ok={analysisEnabled === true}></span>
|
||||
Analysis {analysisEnabled === null ? '—' : analysisEnabled ? 'enabled' : 'disabled'}
|
||||
{#if analysisModel}· {analysisModel}{/if}
|
||||
{#if analysisEndpointOk !== null}· endpoint {analysisEndpointOk ? '✓' : '—'}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<a class="view" href="/admin/settings">View →</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#snippet MeterRow(label: string, percent: number, sub: string)}
|
||||
<div class="meter">
|
||||
<span class="meter-label">{label}</span>
|
||||
<div
|
||||
class="bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-label="{label} {fmtPercent(percent)}"
|
||||
>
|
||||
<div
|
||||
class="fill"
|
||||
class:high={percent >= 90}
|
||||
class:mid={percent >= 70 && percent < 90}
|
||||
style:width="{Math.min(100, Math.max(0, percent))}%"
|
||||
></div>
|
||||
<span class="bar-label">{fmtPercent(percent)}</span>
|
||||
</div>
|
||||
<span class="meter-sub">{sub}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
@@ -68,37 +441,141 @@
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-elevated);
|
||||
border-left: 4px solid var(--warning, #f59e0b);
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.card {
|
||||
padding: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
.card.muted {
|
||||
opacity: 0.6;
|
||||
.card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
.card header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.metric {
|
||||
margin: 0;
|
||||
font-size: var(--font-xl, 1.5rem);
|
||||
font-weight: var(--weight-semibold);
|
||||
.view {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.sub {
|
||||
margin: var(--space-1) 0 0 0;
|
||||
.view:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.live {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.live.on {
|
||||
background: #22c55e;
|
||||
opacity: 1;
|
||||
}
|
||||
.meter {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 1fr minmax(8rem, max-content);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.meter:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.meter-label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.meter-sub {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-align: right;
|
||||
}
|
||||
.bar {
|
||||
position: relative;
|
||||
background: var(--surface-elevated);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
height: 1.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: #22c55e;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
.fill.mid {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.fill.high {
|
||||
background: #dc2626;
|
||||
}
|
||||
.bar-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: var(--font-xs, 0.75rem);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text);
|
||||
}
|
||||
.line {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.line:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.line.big {
|
||||
font-size: var(--font-md, 1rem);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.warn {
|
||||
color: #f59e0b;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
.dot.ok {
|
||||
background: #22c55e;
|
||||
}
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.strip-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.strip .sep {
|
||||
color: var(--border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -132,6 +132,74 @@
|
||||
<article>
|
||||
<h2>CPU</h2>
|
||||
{@render Bar({ percent: stats.cpu.percent_used })}
|
||||
<dl>
|
||||
<dt>Load avg</dt>
|
||||
<dd>
|
||||
{stats.cpu.load_avg.one.toFixed(2)} / {stats.cpu.load_avg.five.toFixed(2)} / {stats.cpu.load_avg.fifteen.toFixed(
|
||||
2
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
{#if stats.cpu.per_core.length > 0}
|
||||
<div class="cores" aria-label="per-core usage">
|
||||
{#each stats.cpu.per_core as core, i (i)}
|
||||
<div
|
||||
class="core"
|
||||
title="core {i}: {core.toFixed(0)}%"
|
||||
role="progressbar"
|
||||
aria-valuenow={core}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-label="core {i} {core.toFixed(0)}%"
|
||||
>
|
||||
<div
|
||||
class="core-fill"
|
||||
class:high={core >= 90}
|
||||
class:mid={core >= 70 && core < 90}
|
||||
style:height="{Math.min(100, Math.max(0, core))}%"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>Temperatures</h2>
|
||||
{#if stats.temperatures.length === 0}
|
||||
<p class="muted">Sensor data unavailable (not exposed in this environment).</p>
|
||||
{:else}
|
||||
{#each stats.temperatures as t (t.label)}
|
||||
{@const limit = t.critical_celsius ?? 100}
|
||||
{@const pct = Math.min(100, Math.max(0, (t.celsius / limit) * 100))}
|
||||
<div class="temp">
|
||||
<div class="temp-head">
|
||||
<span class="temp-label">{t.label}</span>
|
||||
<span class="temp-val">{t.celsius.toFixed(0)}°C</span>
|
||||
</div>
|
||||
<div
|
||||
class="bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={t.celsius}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={limit}
|
||||
aria-label="{t.label} {t.celsius.toFixed(0)}°C"
|
||||
>
|
||||
<div
|
||||
class="fill"
|
||||
class:high={pct >= 90}
|
||||
class:mid={pct >= 70 && pct < 90}
|
||||
style:width="{pct}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="sub">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -300,6 +368,52 @@
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text);
|
||||
}
|
||||
.cores {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 2.5rem;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.core {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 4px;
|
||||
height: 100%;
|
||||
background: var(--surface-elevated);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.core-fill {
|
||||
width: 100%;
|
||||
background: #22c55e;
|
||||
transition: height 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
.core-fill.mid {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.core-fill.high {
|
||||
background: #dc2626;
|
||||
}
|
||||
.temp + .temp {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.temp-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.temp-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.temp-val {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
|
||||
Reference in New Issue
Block a user