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

@@ -774,6 +774,139 @@ pub async fn list_active_jobs(
Ok((items, total))
}
// ---------------------------------------------------------------------------
// Job history: unified, searchable, filterable view over the queue table.
// ---------------------------------------------------------------------------
/// A `crawler_jobs` row resolved to human context for the admin history
/// table. Works across all job kinds: the target columns are best-effort
/// `Option`s resolved through whichever payload reference the kind carries
/// (`chapter_id`, `manga_id`, or `page_id` via the page breadcrumb), so a
/// `sync_manga` job (which has neither yet) simply leaves them `None` and
/// falls back to `source_key`.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct JobHistoryRow {
pub id: Uuid,
/// `pending` | `running` | `done` | `dead` (the live queue states).
pub state: String,
/// `sync_manga` | `sync_chapter_list` | `sync_chapter_content` | `analyze_page`.
pub kind: Option<String>,
pub manga_id: Option<Uuid>,
pub manga_title: Option<String>,
pub chapter_id: Option<Uuid>,
pub chapter_number: Option<i32>,
/// Set only for `analyze_page` (resolved through the page breadcrumb).
pub page_number: Option<i32>,
/// Source-side key, the only target a `sync_manga` job carries.
pub source_key: Option<String>,
pub attempts: i32,
pub max_attempts: i32,
pub last_error: Option<String>,
pub updated_at: DateTime<Utc>,
/// How long the job's work took, when a timing was recorded: the latest
/// `chapter` crawl-metric for a chapter job, else the page's analysis
/// duration for an `analyze_page` job. `None` for kinds we don't time or
/// jobs not yet completed.
pub duration_ms: Option<i64>,
}
/// Filters for [`list_job_history`]. All optional; `None` widens the scope.
#[derive(Debug, Default, Clone)]
pub struct JobHistoryFilter<'a> {
/// Exact queue state (`done`, `dead`, `running`, `pending`).
pub state: Option<&'a str>,
/// Exact payload `kind`.
pub kind: Option<&'a str>,
/// Case-insensitive manga-title substring.
pub search: Option<&'a str>,
}
/// Paginated, newest-first view of the job queue across every state and
/// kind — the searchable/filterable history surface. Joins each job to its
/// manga/chapter/page context (best-effort) so the table can label rows.
/// Returns the page slice plus the filtered total for pagination.
///
/// History depth is bounded by the done-job reaper (`reap_done`): completed
/// jobs older than the retention window are gone. Terminal `dead` jobs
/// persist until requeued.
pub async fn list_job_history(
pool: &PgPool,
filter: JobHistoryFilter<'_>,
limit: i64,
offset: i64,
) -> sqlx::Result<(Vec<JobHistoryRow>, i64)> {
let search_pat = filter
.search
.map(|s| format!("%{}%", s.trim()))
.filter(|p| p.len() > 2);
// The same FROM/JOIN/WHERE drives both the page slice and the count, so
// they stay in lockstep. `pg` resolves analyze_page → page → chapter;
// `COALESCE` lets one set of joins serve every kind.
let items: Vec<JobHistoryRow> = sqlx::query_as(
r#"
SELECT
cj.id,
cj.state,
cj.payload->>'kind' AS kind,
COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid) AS manga_id,
m.title AS manga_title,
COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id) AS chapter_id,
c.number AS chapter_number,
pg.page_number AS page_number,
cj.payload->>'source_manga_key' AS source_key,
cj.attempts,
cj.max_attempts,
cj.last_error,
cj.updated_at,
COALESCE(cm.duration_ms, pa.duration_ms) AS duration_ms
FROM crawler_jobs cj
LEFT JOIN pages pg ON pg.id = (cj.payload->>'page_id')::uuid
LEFT JOIN chapters c ON c.id = COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id)
LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
LEFT JOIN page_analysis pa ON pa.page_id = (cj.payload->>'page_id')::uuid
LEFT JOIN LATERAL (
SELECT duration_ms FROM crawl_metrics
WHERE op = 'chapter'
AND chapter_id = (cj.payload->>'chapter_id')::uuid
ORDER BY finished_at DESC LIMIT 1
) cm ON true
WHERE ($1::text IS NULL OR cj.state = $1)
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
AND ($3::text IS NULL OR m.title ILIKE $3)
ORDER BY cj.updated_at DESC
LIMIT $4 OFFSET $5
"#,
)
.bind(filter.state)
.bind(filter.kind)
.bind(&search_pat)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let total: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM crawler_jobs cj
LEFT JOIN pages pg ON pg.id = (cj.payload->>'page_id')::uuid
LEFT JOIN chapters c ON c.id = COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id)
LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
WHERE ($1::text IS NULL OR cj.state = $1)
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
AND ($3::text IS NULL OR m.title ILIKE $3)
"#,
)
.bind(filter.state)
.bind(filter.kind)
.bind(&search_pat)
.fetch_one(pool)
.await?;
Ok((items, total))
}
/// A manga whose cover is still missing (queued for cover fetch).
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct MissingCoverRow {