// Forbruksoversikt — aggregert ressursforbruk (oppgave 15.8) // // Admin-API for å se totalt forbruk per samling, per ressurstype, // per tidsperiode. Drill-down til jobbtype og modellnivå for AI. // // Spør mot resource_usage_log (oppgave 15.7) og ai_usage_log (oppgave 15.4). // // Ref: docs/features/ressursforbruk.md use axum::extract::State; use axum::http::StatusCode; use axum::Json; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use crate::auth::AdminUser; use crate::AppState; // ============================================================================= // Datatyper // ============================================================================= /// Aggregert forbruk per samling og ressurstype. #[derive(Serialize, sqlx::FromRow)] pub struct CollectionUsageSummary { pub collection_id: Option, pub collection_title: Option, pub resource_type: String, pub event_count: i64, /// Hovedmetrikk i naturlig enhet (tokens, sekunder, bytes, tegn, minutter). pub total_value: f64, /// Sekundær metrikk (f.eks. tokens_out for AI, 0 for andre). pub secondary_value: f64, } /// Drill-down for AI: forbruk per jobbtype og modellnivå. #[derive(Serialize, sqlx::FromRow)] pub struct AiDrillDown { pub collection_id: Option, pub collection_title: Option, pub job_type: Option, pub model_level: Option, pub tokens_in: i64, pub tokens_out: i64, pub event_count: i64, } /// Tidsserie: forbruk per dag for en gitt ressurstype. #[derive(Serialize, sqlx::FromRow)] pub struct DailyUsage { pub day: DateTime, pub resource_type: String, pub event_count: i64, pub total_value: f64, } /// Samlet respons for forbruksoversikten. #[derive(Serialize)] pub struct UsageOverviewResponse { pub by_collection: Vec, pub ai_drilldown: Vec, pub daily: Vec, } #[derive(Deserialize)] pub struct UsageOverviewParams { pub days: Option, pub collection_id: Option, } #[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/usage — forbruksoversikt // ============================================================================= pub async fn usage_overview( State(state): State, _admin: AdminUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let days = params.days.unwrap_or(30).clamp(1, 365); let by_collection = fetch_by_collection(&state.db, days, params.collection_id) .await .map_err(|e| internal_error(&format!("Feil i samlingsoversikt: {e}")))?; let ai_drilldown = fetch_ai_drilldown(&state.db, days, params.collection_id) .await .map_err(|e| internal_error(&format!("Feil i AI drill-down: {e}")))?; let daily = fetch_daily(&state.db, days, params.collection_id) .await .map_err(|e| internal_error(&format!("Feil i daglig oversikt: {e}")))?; Ok(Json(UsageOverviewResponse { by_collection, ai_drilldown, daily, })) } // ============================================================================= // Spørringer // ============================================================================= /// Aggregert forbruk per samling og ressurstype. /// /// Hovedmetrikk (total_value) er type-spesifikk: /// ai → tokens_in (detail->>'tokens_in') /// whisper → duration_seconds /// tts → characters /// cas → size_bytes (kun store) /// bandwidth→ size_bytes /// livekit → participant_minutes async fn fetch_by_collection( db: &PgPool, days: i32, collection_filter: Option, ) -> Result, sqlx::Error> { sqlx::query_as::<_, CollectionUsageSummary>( r#" SELECT r.collection_id, n.title AS collection_title, r.resource_type, COUNT(*)::BIGINT AS event_count, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8 WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8 WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8 WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8 ELSE 0 END ), 0) AS total_value, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_out')::FLOAT8 ELSE 0 END ), 0) AS secondary_value FROM resource_usage_log r LEFT JOIN nodes n ON n.id = r.collection_id WHERE r.created_at >= now() - make_interval(days := $1) AND ($2::UUID IS NULL OR r.collection_id = $2) GROUP BY r.collection_id, n.title, r.resource_type ORDER BY total_value DESC "#, ) .bind(days) .bind(collection_filter) .fetch_all(db) .await } /// AI drill-down: per jobbtype og modellnivå (fra resource_usage_log). async fn fetch_ai_drilldown( db: &PgPool, days: i32, collection_filter: Option, ) -> Result, sqlx::Error> { sqlx::query_as::<_, AiDrillDown>( r#" SELECT r.collection_id, n.title AS collection_title, r.detail->>'job_type' AS job_type, r.detail->>'model_level' AS model_level, COALESCE(SUM((r.detail->>'tokens_in')::BIGINT), 0)::BIGINT AS tokens_in, COALESCE(SUM((r.detail->>'tokens_out')::BIGINT), 0)::BIGINT AS tokens_out, COUNT(*)::BIGINT AS event_count FROM resource_usage_log r LEFT JOIN nodes n ON n.id = r.collection_id WHERE r.resource_type = 'ai' AND r.created_at >= now() - make_interval(days := $1) AND ($2::UUID IS NULL OR r.collection_id = $2) GROUP BY r.collection_id, n.title, r.detail->>'job_type', r.detail->>'model_level' ORDER BY tokens_in DESC "#, ) .bind(days) .bind(collection_filter) .fetch_all(db) .await } /// Daglig tidsserie per ressurstype. async fn fetch_daily( db: &PgPool, days: i32, collection_filter: Option, ) -> Result, sqlx::Error> { sqlx::query_as::<_, DailyUsage>( r#" SELECT date_trunc('day', r.created_at) AS day, r.resource_type, COUNT(*)::BIGINT AS event_count, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8 WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8 WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8 WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8 ELSE 0 END ), 0) AS total_value FROM resource_usage_log r WHERE r.created_at >= now() - make_interval(days := $1) AND ($2::UUID IS NULL OR r.collection_id = $2) GROUP BY date_trunc('day', r.created_at), r.resource_type ORDER BY day DESC, resource_type "#, ) .bind(days) .bind(collection_filter) .fetch_all(db) .await }