From a50245d0aca150403c76ad232865dad1f3ab6df6 Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 19:13:04 +0000 Subject: [PATCH] Implementer agent-oversikt i admin (/admin/agents) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ny admin-side som viser registrerte AI-agenter med status, token-forbruk, aktive jobber og kjørehistorikk. Støtter kill switch for å aktivere/deaktivere. --- frontend/src/lib/api.ts | 81 +++++ frontend/src/routes/admin/agents/+page.svelte | 291 ++++++++++++++++++ maskinrommet/src/agents_admin.rs | 288 +++++++++++++++++ maskinrommet/src/main.rs | 4 + 4 files changed, 664 insertions(+) create mode 100644 frontend/src/routes/admin/agents/+page.svelte create mode 100644 maskinrommet/src/agents_admin.rs diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 768eb3f..5ffc685 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1544,6 +1544,87 @@ export function setWebhookTemplate( }); } +// ============================================================================= +// Agent-oversikt (oppgave 063) +// ============================================================================= + +export interface AgentInfo { + node_id: string; + agent_key: string; + agent_type: string; + is_active: boolean; + config: Record; + created_at: string; +} + +export interface TokenStats { + prompt_tokens: number; + completion_tokens: number; + estimated_cost: number; +} + +export interface AgentUsageStats { + usage_last_hour: number; + usage_last_24h: number; + usage_total: number; + tokens_last_24h: TokenStats; +} + +export interface AgentCurrentJob { + job_id: string; + job_type: string; + status: string; + started_at: string | null; + communication_id: string | null; +} + +export interface AgentDetail { + agent: AgentInfo; + usage: AgentUsageStats; + current_jobs: AgentCurrentJob[]; +} + +export interface AgentHistoryEntry { + job_id: string; + status: string; + started_at: string | null; + completed_at: string | null; + duration_ms: number | null; + error_msg: string | null; + communication_id: string | null; + result_status: string | null; +} + +export interface AgentsOverviewResponse { + agents: AgentDetail[]; + history: AgentHistoryEntry[]; +} + +export interface ToggleAgentResponse { + node_id: string; + is_active: boolean; +} + +/** Hent agent-oversikt med status, forbruk og historikk. */ +export async function fetchAgentsOverview(accessToken: string): Promise { + const res = await fetch(`${BASE_URL}/admin/agents`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`agents overview failed (${res.status}): ${body}`); + } + return res.json(); +} + +/** Aktiver/deaktiver en agent (kill switch toggle). */ +export function toggleAgent( + accessToken: string, + nodeId: string +): Promise { + return post(accessToken, '/admin/agents/toggle', { node_id: nodeId }); +} + export async function setMixerRole( accessToken: string, roomId: string, diff --git a/frontend/src/routes/admin/agents/+page.svelte b/frontend/src/routes/admin/agents/+page.svelte new file mode 100644 index 0000000..38dcea1 --- /dev/null +++ b/frontend/src/routes/admin/agents/+page.svelte @@ -0,0 +1,291 @@ + + +
+
+
+
+ Admin + / +

Agenter

+
+
+
+ +
+ {#if !accessToken} +

Logg inn for tilgang.

+ {:else if !data} +

Laster agenter...

+ {:else} + + {#if error} +
+ {error} +
+ {/if} + + +
+

Registrerte agenter

+ {#each data.agents as detail (detail.agent.node_id)} +
+
+ +
+
+ + {detail.agent.agent_key} + + {#if detail.agent.is_active} + + + Aktiv + + {:else} + + + Deaktivert + + {/if} +
+
+ Type: {detail.agent.agent_type} + Modell: {modelAlias(detail)} + Rate: {detail.usage.usage_last_hour}/{rateLimit(detail)} per time +
+
+ {detail.agent.node_id} +
+
+ + + +
+ + +
+
+
{detail.usage.usage_last_hour}
+
Siste time
+
+
+
{detail.usage.usage_last_24h}
+
Siste 24t
+
+
+
{formatTokens(detail.usage.tokens_last_24h.prompt_tokens + detail.usage.tokens_last_24h.completion_tokens)}
+
Tokens 24t
+
+
+
{detail.usage.usage_total}
+
Totalt
+
+
+ + + {#if detail.usage.tokens_last_24h.prompt_tokens > 0 || detail.usage.tokens_last_24h.completion_tokens > 0} +
+ Inn: {formatTokens(detail.usage.tokens_last_24h.prompt_tokens)} + Ut: {formatTokens(detail.usage.tokens_last_24h.completion_tokens)} + {#if detail.usage.tokens_last_24h.estimated_cost > 0} + Kostnad: ${detail.usage.tokens_last_24h.estimated_cost.toFixed(4)} + {/if} +
+ {/if} + + + {#if detail.current_jobs.length > 0} +
+
Aktive jobber
+ {#each detail.current_jobs as job} +
+ + + {job.status} + + {job.job_id.slice(0, 8)} + {#if job.started_at} + {formatTime(job.started_at)} + {/if} +
+ {/each} +
+ {/if} +
+ {:else} +
+ Ingen agenter registrert. +
+ {/each} +
+ + +
+

Siste kjøringer

+
+ + + + + + + + + + + + {#each data.history as entry (entry.job_id)} + + + + + + + + {:else} + + + + {/each} + +
StatusJobbStartetVarighetResultat
+ + + {entry.status} + + + {entry.job_id.slice(0, 8)} + + {formatTime(entry.started_at)} + + {formatDuration(entry.duration_ms)} + + {resultStatusLabel(entry)} +
+ Ingen agent-kjøringer funnet. +
+
+
+ {/if} +
+
diff --git a/maskinrommet/src/agents_admin.rs b/maskinrommet/src/agents_admin.rs new file mode 100644 index 0000000..5de93a3 --- /dev/null +++ b/maskinrommet/src/agents_admin.rs @@ -0,0 +1,288 @@ +// 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, + })) +} diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 7bb1781..5c01558 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -1,4 +1,5 @@ pub mod agent; +mod agents_admin; pub mod ai_admin; mod api_keys_admin; pub mod crypto; @@ -317,6 +318,9 @@ async fn main() { .route("/intentions/set_mute", post(mixer::set_mute)) .route("/intentions/toggle_effect", post(mixer::toggle_effect)) .route("/intentions/set_mixer_role", post(mixer::set_mixer_role)) + // Agent-oversikt (oppgave 063) + .route("/admin/agents", get(agents_admin::agents_overview)) + .route("/admin/agents/toggle", post(agents_admin::toggle_agent)) // API-nøkler (oppgave 060) .route("/admin/api-keys", get(api_keys_admin::list_keys)) .route("/admin/api-keys/create", post(api_keys_admin::create_key))