feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
This commit was merged in pull request #6.
This commit is contained in:
@@ -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
|
||||
|
||||
63
backend/src/api/admin/crawler/history.rs
Normal file
63
backend/src/api/admin/crawler/history.rs
Normal 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,
|
||||
)))
|
||||
}
|
||||
88
backend/src/api/admin/crawler/metrics.rs
Normal file
88
backend/src/api/admin/crawler/metrics.rs
Normal 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,
|
||||
)))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user