Tre fikser funnet under validering: 1. SIKKERHET: Admin-endepunkter manglet autorisasjonssjekk. Alle /admin/*-endepunkter brukte kun AuthUser (autentisert), ikke admin-rolle. Ny AdminUser-extractor sjekker owner/admin-edge til samling — returnerer 403 Forbidden for ikke-admins. Berører: maintenance, jobs, resources, health, ai, usage. 2. Race condition i toggle_effect: les-modifiser-skriv uten transaksjon på active_effects JSON. Erstattet med atomisk PG jsonb-operasjon. 3. Manglende updated_by i set_gain, set_mute, set_mixer_role, toggle_effect. Nå spores hvem som endret mixer-tilstanden.
238 lines
7.9 KiB
Rust
238 lines
7.9 KiB
Rust
// 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<Uuid>,
|
|
pub collection_title: Option<String>,
|
|
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<Uuid>,
|
|
pub collection_title: Option<String>,
|
|
pub job_type: Option<String>,
|
|
pub model_level: Option<String>,
|
|
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<Utc>,
|
|
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<CollectionUsageSummary>,
|
|
pub ai_drilldown: Vec<AiDrillDown>,
|
|
pub daily: Vec<DailyUsage>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UsageOverviewParams {
|
|
pub days: Option<i32>,
|
|
pub collection_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ErrorResponse {
|
|
pub error: String,
|
|
}
|
|
|
|
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(ErrorResponse {
|
|
error: msg.to_string(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// GET /admin/usage — forbruksoversikt
|
|
// =============================================================================
|
|
|
|
pub async fn usage_overview(
|
|
State(state): State<AppState>,
|
|
_admin: AdminUser,
|
|
axum::extract::Query(params): axum::extract::Query<UsageOverviewParams>,
|
|
) -> Result<Json<UsageOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
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<Uuid>,
|
|
) -> Result<Vec<CollectionUsageSummary>, 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<Uuid>,
|
|
) -> Result<Vec<AiDrillDown>, 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<Uuid>,
|
|
) -> Result<Vec<DailyUsage>, 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
|
|
}
|