use axum::Json;
use axum::extract::State;
use chrono::{DateTime, Utc};
use serde::Serialize;
use tracing::debug;
use vti_common::error::AppError;
use crate::auth::AdminAuth;
use crate::registry::{HealthStatus, list_sync_jobs};
use crate::server::AppState;
#[derive(Serialize, utoipa::ToSchema)]
pub struct HealthResponse {
status: &'static str,
version: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
vtc_did: Option<String>,
}
pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
debug!("health check");
let vtc_did = state.config.read().await.vtc_did.clone();
Json(HealthResponse {
status: "ok",
version: env!("CARGO_PKG_VERSION"),
vtc_did,
})
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct DiagnosticsResponse {
pub registry_status: String,
pub queue_depth: u64,
pub rtbf_batched_count: u64,
pub failed_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub oldest_pending_age_seconds: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_success_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_failure_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vta_did: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mediator_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mediator_did: Option<String>,
pub syncer_enabled: bool,
pub syncer_running: bool,
pub syncer_restarts: u64,
}
#[utoipa::path(
get, path = "/health/diagnostics", tag = "health",
security(("bearer_jwt" = [])),
responses(
(status = 200, description = "Trust-registry reconciler diagnostics", body = DiagnosticsResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin"),
),
)]
pub async fn diagnostics(
State(state): State<AppState>,
_auth: AdminAuth,
) -> Result<Json<DiagnosticsResponse>, AppError> {
let jobs = list_sync_jobs(&state.sync_queue_ks).await?;
let now = Utc::now();
let queue_depth = jobs
.iter()
.filter(|j| {
matches!(
j.state,
crate::registry::SyncJobState::Pending | crate::registry::SyncJobState::InFlight
)
})
.count() as u64;
let rtbf_batched_count = jobs
.iter()
.filter(|j| j.rtbf_batched && j.state == crate::registry::SyncJobState::Pending)
.count() as u64;
let failed_count = jobs
.iter()
.filter(|j| j.state == crate::registry::SyncJobState::Failed)
.count() as u64;
let oldest_pending_age_seconds = jobs
.iter()
.filter(|j| j.state == crate::registry::SyncJobState::Pending && j.next_attempt_at <= now)
.map(|j| (now - j.created_at).num_seconds())
.max();
let snapshot = state.registry_health.snapshot().await;
let registry_status = match snapshot.status {
HealthStatus::Active => "active",
HealthStatus::Degraded => "degraded",
}
.to_string();
let syncer = state.syncer_health.snapshot();
let config = state.config.read().await;
let vta_did = config.vta_did.clone();
let (mediator_url, mediator_did) = config
.messaging
.as_ref()
.map(|m| (Some(m.mediator_url.clone()), Some(m.mediator_did.clone())))
.unwrap_or((None, None));
drop(config);
debug!(
queue_depth,
rtbf_batched_count,
failed_count,
registry_status = %registry_status,
"diagnostics queried"
);
Ok(Json(DiagnosticsResponse {
registry_status,
queue_depth,
rtbf_batched_count,
failed_count,
oldest_pending_age_seconds,
last_success_at: snapshot.last_success_at,
last_failure_at: snapshot.last_failure_at,
last_error: snapshot.last_error,
vta_did,
mediator_url,
mediator_did,
syncer_enabled: syncer.enabled,
syncer_running: syncer.running,
syncer_restarts: syncer.restarts,
}))
}