feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
Some checks failed
deploy / test-backend (push) Failing after 19m59s
deploy / test-frontend (push) Successful in 9m54s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped

This commit was merged in pull request #6.
This commit is contained in:
2026-06-16 12:21:13 +00:00
parent 790549636f
commit d51ab2a049
41 changed files with 3655 additions and 21 deletions

View File

@@ -26,7 +26,8 @@ use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::domain::page_analysis::{
ChapterCoverage, MangaCoverage, PageAnalysisDetail, PageStatusItem,
AnalysisHistoryRow, AnalysisMetrics, ChapterCoverage, MangaCoverage, PageAnalysisDetail,
PageStatusItem,
};
use crate::error::{AppError, AppResult};
use crate::repo;
@@ -41,6 +42,8 @@ pub fn routes() -> Router<AppState> {
.route("/admin/analysis/mangas/:id/chapters", get(coverage_chapters))
.route("/admin/analysis/chapters/:id/pages", get(chapter_pages))
.route("/admin/analysis/pages/:id", get(page_detail))
.route("/admin/analysis/history", get(list_history))
.route("/admin/analysis/metrics", get(metrics))
.route("/admin/analysis/status/stream", get(stream_status))
}
@@ -149,6 +152,79 @@ struct ItemsResponse<T> {
items: Vec<T>,
}
/// Status/NSFW filters + pagination/search for the analysis history list.
#[derive(Debug, Deserialize, Default)]
pub struct HistoryParams {
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub nsfw: bool,
#[serde(default)]
pub search: Option<String>,
#[serde(default = "default_coverage_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
async fn list_history(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<HistoryParams>,
) -> AppResult<Json<PagedResponse<AnalysisHistoryRow>>> {
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let status = params
.status
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let search = params
.search
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let (items, total) = repo::page_analysis::list_history(
&state.db,
repo::page_analysis::AnalysisHistoryFilter {
status,
nsfw_only: params.nsfw,
search,
},
limit,
offset,
)
.await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
/// `?days=` window for the metrics endpoints. `0` (or absent) = all time.
#[derive(Debug, Deserialize, Default)]
pub struct MetricsParams {
#[serde(default)]
pub days: i64,
}
/// Convert a `days` window param into a lower-bound timestamp. `<= 0` → all
/// time (`None`); otherwise `now - days` (capped at a year).
pub(super) fn window_since(days: i64) -> Option<chrono::DateTime<chrono::Utc>> {
if days <= 0 {
None
} else {
Some(chrono::Utc::now() - chrono::Duration::days(days.min(365)))
}
}
async fn metrics(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<MetricsParams>,
) -> AppResult<Json<AnalysisMetrics>> {
let since = window_since(params.days);
let m = repo::page_analysis::analysis_metrics(&state.db, since).await?;
Ok(Json(m))
}
#[derive(Debug, Deserialize)]
pub struct ReenqueueBody {
/// Skip pages that already have a `done` analysis row. Defaults to

View File

@@ -0,0 +1,63 @@
//! GET /admin/crawler/history — unified, searchable, filterable job log.
//!
//! A pure DB-derived read over the `crawler_jobs` table across every state
//! and kind. Drives the "History" tab on the crawler dashboard. Depth is
//! bounded by the done-job reaper (recent window); `dead` jobs persist.
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::error::AppResult;
use crate::repo;
use crate::repo::crawler::{JobHistoryFilter, JobHistoryRow};
use super::default_limit;
pub(super) fn routes() -> Router<AppState> {
Router::new().route("/admin/crawler/history", get(list_history))
}
/// State + kind filters and pagination/search for the history list.
#[derive(Debug, Deserialize, Default)]
struct HistoryParams {
#[serde(default)]
state: Option<String>,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
search: Option<String>,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
async fn list_history(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<HistoryParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<JobHistoryRow>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let state_f = params.state.filter(|s| !s.trim().is_empty());
let kind_f = params.kind.filter(|s| !s.trim().is_empty());
let search_f = params.search.filter(|s| !s.trim().is_empty());
let (items, total) = repo::crawler::list_job_history(
&state.db,
JobHistoryFilter {
state: state_f.as_deref(),
kind: kind_f.as_deref(),
search: search_f.as_deref(),
},
limit,
offset,
)
.await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}

View File

@@ -0,0 +1,88 @@
//! GET /admin/crawler/metrics — per-type average durations + success
//! GET /admin/crawler/metrics/ops — paginated recent timed-operations log
//!
//! Pure DB reads over the durable `crawl_metrics` table. Drive the "Metrics"
//! tab on the crawler dashboard. `days` windows the rows (`0`/absent = all).
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use crate::app::AppState;
use crate::auth::extractor::RequireAdmin;
use crate::domain::crawl_metrics::{OpRow, OpSummary};
use crate::error::AppResult;
use crate::repo;
use crate::repo::crawl_metrics::OpFilter;
use super::default_limit;
// Reuse the analysis handler's window helper so both endpoints clamp `days`
// identically.
use crate::api::admin::analysis::window_since;
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/admin/crawler/metrics", get(summary))
.route("/admin/crawler/metrics/ops", get(list_ops))
}
#[derive(Debug, Deserialize, Default)]
struct SummaryParams {
#[serde(default)]
days: i64,
}
#[derive(Debug, Serialize)]
struct SummaryResponse {
summary: Vec<OpSummary>,
}
async fn summary(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<SummaryParams>,
) -> AppResult<Json<SummaryResponse>> {
let since = window_since(params.days);
let summary = repo::crawl_metrics::summary(&state.db, since).await?;
Ok(Json(SummaryResponse { summary }))
}
#[derive(Debug, Deserialize, Default)]
struct OpsParams {
#[serde(default)]
op: Option<String>,
#[serde(default)]
outcome: Option<String>,
#[serde(default)]
days: i64,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
offset: i64,
}
async fn list_ops(
State(state): State<AppState>,
_admin: RequireAdmin,
Query(params): Query<OpsParams>,
) -> AppResult<Json<crate::api::pagination::PagedResponse<OpRow>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let op = params.op.filter(|s| !s.trim().is_empty());
let outcome = params.outcome.filter(|s| !s.trim().is_empty());
let (items, total) = repo::crawl_metrics::list_ops(
&state.db,
OpFilter {
op: op.as_deref(),
outcome: outcome.as_deref(),
since: window_since(params.days),
},
limit,
offset,
)
.await?;
Ok(Json(crate::api::pagination::PagedResponse::with_total(
items, limit, offset, total,
)))
}

View File

@@ -16,6 +16,8 @@
mod backlog;
mod control;
mod dead_jobs;
mod history;
mod metrics;
mod status;
use axum::Router;
@@ -29,6 +31,8 @@ pub fn routes() -> Router<AppState> {
.merge(control::routes())
.merge(dead_jobs::routes())
.merge(backlog::routes())
.merge(history::routes())
.merge(metrics::routes())
}
/// Default page size for the backlog list endpoints.