// Admin-API for agent-oversikt (oppgave 063) // // Viser registrerte agenter, deres status, token-forbruk og kjørehistorikk. // Støtter aktivering/deaktivering (kill switch) og visning av aktive jobber. use axum::{extract::State, http::StatusCode, Json}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::auth::AdminUser; use crate::AppState; #[derive(Serialize)] pub struct ErrorResponse { pub error: String, } fn internal_error(msg: &str) -> (StatusCode, Json) { (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() })) } // ============================================================================= // GET /admin/agents — agent-oversikt // ============================================================================= #[derive(Serialize, sqlx::FromRow)] pub struct AgentInfo { pub node_id: Uuid, pub agent_key: String, pub agent_type: String, pub is_active: bool, pub config: serde_json::Value, pub created_at: DateTime, } #[derive(Serialize)] pub struct AgentUsageStats { pub usage_last_hour: i64, pub usage_last_24h: i64, pub usage_total: i64, pub tokens_last_24h: TokenStats, } #[derive(Serialize)] pub struct TokenStats { pub prompt_tokens: i64, pub completion_tokens: i64, pub estimated_cost: f64, } #[derive(Serialize)] pub struct AgentCurrentJob { pub job_id: Uuid, pub job_type: String, pub status: String, pub started_at: Option>, pub communication_id: Option, } #[derive(Serialize)] pub struct AgentDetail { pub agent: AgentInfo, pub usage: AgentUsageStats, pub current_jobs: Vec, } #[derive(Serialize)] pub struct AgentHistoryEntry { pub job_id: Uuid, pub status: String, pub started_at: Option>, pub completed_at: Option>, pub duration_ms: Option, pub error_msg: Option, pub communication_id: Option, pub result_status: Option, } #[derive(Serialize)] pub struct AgentsOverviewResponse { pub agents: Vec, pub history: Vec, } pub async fn agents_overview( State(state): State, _admin: AdminUser, ) -> Result, (StatusCode, Json)> { // Hent alle registrerte agenter let agents = sqlx::query_as::<_, AgentInfo>( r#"SELECT node_id, agent_key, agent_type, is_active, config, created_at FROM agent_identities ORDER BY agent_key"#, ) .fetch_all(&state.db) .await .map_err(|e| internal_error(&format!("Feil ved henting av agenter: {e}")))?; let mut agent_details = Vec::new(); for agent in agents { // Bruksstatistikk let usage_last_hour: i64 = sqlx::query_scalar::<_, Option>( "SELECT COUNT(*) FROM ai_usage_log WHERE agent_node_id = $1 AND created_at > now() - interval '1 hour'", ) .bind(agent.node_id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?.unwrap_or(0); let usage_last_24h: i64 = sqlx::query_scalar::<_, Option>( "SELECT COUNT(*) FROM ai_usage_log WHERE agent_node_id = $1 AND created_at > now() - interval '24 hours'", ) .bind(agent.node_id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?.unwrap_or(0); let usage_total: i64 = sqlx::query_scalar::<_, Option>( "SELECT COUNT(*) FROM ai_usage_log WHERE agent_node_id = $1", ) .bind(agent.node_id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?.unwrap_or(0); // Token-statistikk siste 24 timer #[derive(sqlx::FromRow)] struct TokenRow { prompt_tokens: i64, completion_tokens: i64, estimated_cost: f64, } let token_row = sqlx::query_as::<_, TokenRow>( r#"SELECT COALESCE(SUM(prompt_tokens), 0)::BIGINT as prompt_tokens, COALESCE(SUM(completion_tokens), 0)::BIGINT as completion_tokens, COALESCE(SUM(estimated_cost)::FLOAT8, 0.0) as estimated_cost FROM ai_usage_log WHERE agent_node_id = $1 AND created_at > now() - interval '24 hours'"#, ) .bind(agent.node_id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; // Aktive jobber for denne agenten #[derive(sqlx::FromRow)] struct CurrentJobRow { id: Uuid, job_type: String, status: String, started_at: Option>, payload: serde_json::Value, } let current_jobs = sqlx::query_as::<_, CurrentJobRow>( r#"SELECT id, job_type, status::text as status, started_at, payload FROM job_queue WHERE job_type = 'agent_respond' AND payload->>'agent_node_id' = $1::text AND status IN ('running', 'pending') ORDER BY created_at DESC"#, ) .bind(agent.node_id) .fetch_all(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; let current_jobs: Vec = current_jobs.into_iter().map(|j| { AgentCurrentJob { job_id: j.id, job_type: j.job_type, status: j.status, started_at: j.started_at, communication_id: j.payload.get("communication_id").and_then(|v| v.as_str()).map(String::from), } }).collect(); agent_details.push(AgentDetail { agent, usage: AgentUsageStats { usage_last_hour, usage_last_24h, usage_total, tokens_last_24h: TokenStats { prompt_tokens: token_row.prompt_tokens, completion_tokens: token_row.completion_tokens, estimated_cost: token_row.estimated_cost, }, }, current_jobs, }); } // Historikk: siste 50 agent_respond-jobber #[derive(sqlx::FromRow)] struct HistoryRow { id: Uuid, status: String, started_at: Option>, completed_at: Option>, error_msg: Option, payload: serde_json::Value, result: Option, } let history_rows = sqlx::query_as::<_, HistoryRow>( r#"SELECT id, status::text as status, started_at, completed_at, error_msg, payload, result FROM job_queue WHERE job_type = 'agent_respond' ORDER BY created_at DESC LIMIT 50"#, ) .fetch_all(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; let history: Vec = history_rows.into_iter().map(|h| { let duration_ms = match (h.started_at, h.completed_at) { (Some(start), Some(end)) => Some((end - start).num_milliseconds()), _ => None, }; AgentHistoryEntry { job_id: h.id, status: h.status, started_at: h.started_at, completed_at: h.completed_at, duration_ms, error_msg: h.error_msg, communication_id: h.payload.get("communication_id").and_then(|v| v.as_str()).map(String::from), result_status: h.result.as_ref().and_then(|r| r.get("status")).and_then(|s| s.as_str()).map(String::from), } }).collect(); Ok(Json(AgentsOverviewResponse { agents: agent_details, history })) } // ============================================================================= // POST /admin/agents/toggle — aktiver/deaktiver agent (kill switch) // ============================================================================= #[derive(Deserialize)] pub struct ToggleAgentRequest { pub node_id: Uuid, } #[derive(Serialize)] pub struct ToggleAgentResponse { pub node_id: Uuid, pub is_active: bool, } pub async fn toggle_agent( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let result = sqlx::query( "UPDATE agent_identities SET is_active = NOT is_active WHERE node_id = $1", ) .bind(req.node_id) .execute(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; if result.rows_affected() == 0 { return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Agent finnes ikke".to_string(), }))); } let is_active: bool = sqlx::query_scalar("SELECT is_active FROM agent_identities WHERE node_id = $1") .bind(req.node_id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; tracing::info!(agent_node_id = %req.node_id, is_active, "Agent-status endret"); Ok(Json(ToggleAgentResponse { node_id: req.node_id, is_active, })) }