use super::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json},
};
use lopi_core::CustomerTier;
use serde_json::{json, Value};
pub(super) async fn get_quality_trend(
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
State(s): State<AppState>,
) -> impl IntoResponse {
let repo_str = params
.get("repo")
.cloned()
.unwrap_or_else(|| s.repo_path.to_string_lossy().to_string());
let limit: i64 = params
.get("limit")
.and_then(|v| v.parse().ok())
.unwrap_or(20);
match s.store.load_quality_trend(&repo_str, limit).await {
Ok(rows) => Json(json!({
"repo": repo_str,
"runs": rows.iter().map(|r| json!({
"id": r.id,
"spec_items": r.spec_items,
"passing": r.passing,
"failing": r.failing,
"gaps": r.gaps,
"score": r.score,
"run_at": r.run_at,
})).collect::<Vec<_>>(),
}))
.into_response(),
Err(e) => {
tracing::warn!("quality_trend query failed: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
}
}
}
pub(super) async fn get_agent_dag(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
match s.store.task_exists(&id).await {
Ok(true) => {}
Ok(false) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "unknown task id", "task_id": id})),
)
.into_response();
}
Err(e) => {
tracing::warn!("task_exists failed: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
}
}
match s.store.load_dag_nodes(&id).await {
Ok(rows) => Json(lopi_memory::dag_graph_json(&id, &rows)).into_response(),
Err(e) => {
tracing::warn!("agent dag query failed: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
}
}
}
pub(super) async fn metrics(State(s): State<AppState>) -> impl IntoResponse {
let stats = s.pool.stats();
let mut body = format!(
"# HELP lopi_agents_running Currently running agents\n\
# TYPE lopi_agents_running gauge\n\
lopi_agents_running {running}\n\
# HELP lopi_agents_queued Tasks waiting in queue\n\
# TYPE lopi_agents_queued gauge\n\
lopi_agents_queued {queued}\n\
# HELP lopi_tasks_succeeded_total Tasks completed successfully\n\
# TYPE lopi_tasks_succeeded_total counter\n\
lopi_tasks_succeeded_total {succeeded}\n\
# HELP lopi_tasks_failed_total Tasks that failed after all retries\n\
# TYPE lopi_tasks_failed_total counter\n\
lopi_tasks_failed_total {failed}\n\
# HELP lopi_uptime_seconds Seconds since lopi sail started\n\
# TYPE lopi_uptime_seconds counter\n\
lopi_uptime_seconds {uptime}\n",
running = stats.running,
queued = stats.queued,
succeeded = stats.succeeded,
failed = stats.failed,
uptime = stats.uptime_secs,
);
let violations = lopi_core::schema_violations_snapshot();
if !violations.is_empty() {
body.push_str("# HELP lopi_schema_violations_total Output schema validation failures\n");
body.push_str("# TYPE lopi_schema_violations_total counter\n");
for (kind, count) in violations {
body.push_str(&format!(
"lopi_schema_violations_total{{kind=\"{kind}\"}} {count}\n"
));
}
}
match s.store.count_audit().await {
Ok(audit_total) => {
body.push_str("# HELP lopi_audit_log_total Rows recorded in the audit log\n");
body.push_str("# TYPE lopi_audit_log_total counter\n");
body.push_str(&format!("lopi_audit_log_total {audit_total}\n"));
}
Err(e) => tracing::warn!("count_audit failed: {e}"),
}
(
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
body,
)
}
pub(super) async fn get_plans() -> Json<Value> {
let plans: Vec<Value> = [
CustomerTier::Free,
CustomerTier::Starter,
CustomerTier::Growth,
CustomerTier::Enterprise,
]
.iter()
.map(|&tier| {
json!({
"id": tier.as_str(),
"name": tier.display_name(),
"price_usd_per_month": tier.price_usd_cents_per_month() / 100,
"max_agents": tier.max_agents(),
"features": tier.features(),
})
})
.collect();
Json(json!({ "plans": plans }))
}