| 477 |
477 |
|
pub note: String,
|
| 478 |
478 |
|
}
|
| 479 |
479 |
|
|
|
480 |
+ |
// ─── Public health endpoint (for PoM) ────────────────────────────────────────
|
|
481 |
+ |
|
|
482 |
+ |
#[derive(serde::Serialize)]
|
|
483 |
+ |
struct LayerHealthJson {
|
|
484 |
+ |
layer: String,
|
|
485 |
+ |
total_1h: i64,
|
|
486 |
+ |
success_1h: i64,
|
|
487 |
+ |
error_1h: i64,
|
|
488 |
+ |
last_clean_secs_ago: Option<i64>,
|
|
489 |
+ |
}
|
|
490 |
+ |
|
|
491 |
+ |
#[derive(serde::Serialize)]
|
|
492 |
+ |
struct ScanPipelineHealth {
|
|
493 |
+ |
queue_pending: i64,
|
|
494 |
+ |
queue_running: i64,
|
|
495 |
+ |
queue_stuck: i64,
|
|
496 |
+ |
held_versions: i64,
|
|
497 |
+ |
held_items: i64,
|
|
498 |
+ |
held_media: i64,
|
|
499 |
+ |
held_total: i64,
|
|
500 |
+ |
layers: Vec<LayerHealthJson>,
|
|
501 |
+ |
generated_at: String,
|
|
502 |
+ |
}
|
|
503 |
+ |
|
|
504 |
+ |
/// Aggregate scan-pipeline health stats for external monitors (PoM).
|
|
505 |
+ |
///
|
|
506 |
+ |
/// **Unauthenticated** — returns counts only, no PII. The data is the same
|
|
507 |
+ |
/// shape the admin dashboard's health panel uses; the JSON contract is
|
|
508 |
+ |
/// stable for PoM threshold rules. Stuck threshold matches the worker's
|
|
509 |
+ |
/// reaper (300s).
|
|
510 |
+ |
#[tracing::instrument(skip_all, name = "admin::scan_health_json")]
|
|
511 |
+ |
pub(super) async fn scan_health_json(
|
|
512 |
+ |
State(state): State<AppState>,
|
|
513 |
+ |
) -> Result<impl IntoResponse> {
|
|
514 |
+ |
let queue_pending = db::scan_jobs::queued_count(&state.db).await.unwrap_or(0);
|
|
515 |
+ |
let queue_running = db::scan_jobs::running_count(&state.db).await.unwrap_or(0);
|
|
516 |
+ |
let queue_stuck = db::scan_jobs::stuck_count(&state.db, 300).await.unwrap_or(0);
|
|
517 |
+ |
let held = db::scanning::held_counts(&state.db).await
|
|
518 |
+ |
.unwrap_or(db::scanning::HeldCounts { held_versions: 0, held_items: 0, held_media: 0 });
|
|
519 |
+ |
let held_total = held.held_versions + held.held_items + held.held_media;
|
|
520 |
+ |
|
|
521 |
+ |
let rows = db::scanning::layer_health_window(&state.db, 1).await.unwrap_or_default();
|
|
522 |
+ |
let now = chrono::Utc::now();
|
|
523 |
+ |
let layers: Vec<LayerHealthJson> = rows.into_iter().map(|r| {
|
|
524 |
+ |
let success = r.pass_count + r.skip_count;
|
|
525 |
+ |
let total = success + r.fail_count + r.error_count;
|
|
526 |
+ |
LayerHealthJson {
|
|
527 |
+ |
layer: r.layer,
|
|
528 |
+ |
total_1h: total,
|
|
529 |
+ |
success_1h: success,
|
|
530 |
+ |
error_1h: r.error_count,
|
|
531 |
+ |
last_clean_secs_ago: r.last_pass_or_skip.map(|t| (now - t).num_seconds()),
|
|
532 |
+ |
}
|
|
533 |
+ |
}).collect();
|
|
534 |
+ |
|
|
535 |
+ |
let body = ScanPipelineHealth {
|
|
536 |
+ |
queue_pending,
|
|
537 |
+ |
queue_running,
|
|
538 |
+ |
queue_stuck,
|
|
539 |
+ |
held_versions: held.held_versions,
|
|
540 |
+ |
held_items: held.held_items,
|
|
541 |
+ |
held_media: held.held_media,
|
|
542 |
+ |
held_total,
|
|
543 |
+ |
layers,
|
|
544 |
+ |
generated_at: now.to_rfc3339(),
|
|
545 |
+ |
};
|
|
546 |
+ |
Ok(axum::Json(body))
|
|
547 |
+ |
}
|
|
548 |
+ |
|
| 480 |
549 |
|
// ─── Live partials ───────────────────────────────────────────────────────────
|
| 481 |
550 |
|
|
| 482 |
551 |
|
/// HTMX partial: current pending + running counts. Polled by the dashboard.
|