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

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

View File

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

View File

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

View File

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