From d7dffa06e645b646070128dc18e2cd55bd1a7abe Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 04:34:08 +0000 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8rer=20oppgave=2015.8:=20Forbruksover?= =?UTF-8?q?sikt=20i=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregert ressursforbruk-dashboard som spør mot resource_usage_log (oppgave 15.7). Tre visninger: totaler per ressurstype, per samling, og daglig tidsserie. AI drill-down viser forbruk per jobbtype og modellnivå (fast/smart/deep). Backend: GET /admin/usage med days- og collection_id-filtre. Frontend: /admin/usage med filterbare tabeller og fargekodede kort. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/features/ressursforbruk.md | 13 + frontend/src/lib/api.ts | 55 +++ frontend/src/routes/admin/+page.svelte | 3 + frontend/src/routes/admin/usage/+page.svelte | 435 +++++++++++++++++++ maskinrommet/src/main.rs | 3 + maskinrommet/src/usage_overview.rs | 238 ++++++++++ tasks.md | 3 +- 7 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/admin/usage/+page.svelte create mode 100644 maskinrommet/src/usage_overview.rs diff --git a/docs/features/ressursforbruk.md b/docs/features/ressursforbruk.md index ab288e5..94b1c02 100644 --- a/docs/features/ressursforbruk.md +++ b/docs/features/ressursforbruk.md @@ -218,6 +218,19 @@ Følgende ressurstyper logges til `resource_usage_log`: `resource_usage.rs` tilbyr `log()` og `find_collection_for_node()`. Alle handlers bruker denne for konsistent logging. +### Admin-dashboard (oppgave 15.8) + +`/admin/usage` viser aggregert forbruksoversikt: + +- **Totalkort** per ressurstype med naturlige enheter (tokens, timer, GB, tegn, minutter) +- **Per samling**-tabell: filtrerbar på ressurstype og tidsperiode (7/30/90/365 dager) +- **AI drill-down**: per jobbtype og modellnivå (fast/smart/deep), tokens inn/ut +- **Daglig tidsserie**: aktivitet per dag og ressurstype +- Samlings- og ressurstype-filtre med live-oppdatering + +Backend: `maskinrommet/src/usage_overview.rs` → `GET /admin/usage?days=30&collection_id=` +Frontend: `frontend/src/routes/admin/usage/+page.svelte` + ### Caddy-oppsett JSON access logging er konfigurert i Caddyfile for `sidelinja.org` diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 18b889f..565c585 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1038,3 +1038,58 @@ export async function fetchHealthLogs( } return res.json(); } + +// ============================================================================= +// Forbruksoversikt (oppgave 15.8) +// ============================================================================= + +export interface CollectionUsageSummary { + collection_id: string | null; + collection_title: string | null; + resource_type: string; + event_count: number; + total_value: number; + secondary_value: number; +} + +export interface AiDrillDown { + collection_id: string | null; + collection_title: string | null; + job_type: string | null; + model_level: string | null; + tokens_in: number; + tokens_out: number; + event_count: number; +} + +export interface DailyUsage { + day: string; + resource_type: string; + event_count: number; + total_value: number; +} + +export interface UsageOverviewResponse { + by_collection: CollectionUsageSummary[]; + ai_drilldown: AiDrillDown[]; + daily: DailyUsage[]; +} + +/** Hent aggregert forbruksoversikt for admin. */ +export async function fetchUsageOverview( + accessToken: string, + params: { days?: number; collection_id?: string } = {} +): Promise { + const sp = new URLSearchParams(); + if (params.days) sp.set('days', String(params.days)); + if (params.collection_id) sp.set('collection_id', params.collection_id); + const qs = sp.toString(); + const res = await fetch(`${BASE_URL}/admin/usage${qs ? `?${qs}` : ''}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`usage overview failed (${res.status}): ${body}`); + } + return res.json(); +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 62eeab8..bd30540 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -103,6 +103,9 @@ AI Gateway + + Forbruk + diff --git a/frontend/src/routes/admin/usage/+page.svelte b/frontend/src/routes/admin/usage/+page.svelte new file mode 100644 index 0000000..7723757 --- /dev/null +++ b/frontend/src/routes/admin/usage/+page.svelte @@ -0,0 +1,435 @@ + + +
+
+ +
+

Forbruksoversikt

+ Tilbake til admin +
+ + {#if error} +
+ {error} +
+ {/if} + + +
+ + + {#if collections().length > 0} + + {/if} + + {#if resourceTypes().length > 0} + + {/if} + + +
+ + {#if !data && !error} +

Laster...

+ {:else if data} + +
+

Totalt forbruk ({days} dager)

+
+ {#each [...totalsPerType().entries()] as [type, totals]} +
+
+ + {resourceTypeIcon(type)} + + {resourceTypeLabel(type)} +
+
+ {formatResourceValue(type, totals.total, totals.secondary)} +
+
+ {totals.count.toLocaleString('nb-NO')} hendelser +
+
+ {/each} + {#if totalsPerType().size === 0} +
+ Ingen ressursforbruk registrert i perioden. +
+ {/if} +
+
+ + + {#if filteredByCollection().length > 0} +
+

Per samling

+
+
+ + + + + + + + + + + {#each filteredByCollection() as row} + + + + + + + {/each} + +
SamlingRessurstypeForbrukHendelser
+ {row.collection_title || 'Uten samling'} + + + {resourceTypeIcon(row.resource_type)} + + {resourceTypeLabel(row.resource_type)} + + {formatResourceValue(row.resource_type, row.total_value, row.secondary_value)} + + {row.event_count.toLocaleString('nb-NO')} +
+
+
+
+ {/if} + + +
+
+

AI Drill-down

+ +
+ + {#if showAiDrilldown} + {#if filteredAiDrilldown().length > 0} +
+
+ + + + + + + + + + + + + {#each filteredAiDrilldown() as row} + + + + + + + + + {/each} + +
SamlingJobbtypeModellnivaTokens innTokens utKall
{row.collection_title || 'Uten samling'} + + {row.job_type || 'ukjent'} + + + {#if row.model_level === 'fast'} + fast + {:else if row.model_level === 'smart'} + smart + {:else if row.model_level === 'deep'} + deep + {:else} + {row.model_level || '—'} + {/if} + + {formatNumber(row.tokens_in)} + + {formatNumber(row.tokens_out)} + + {row.event_count.toLocaleString('nb-NO')} +
+
+
+ {:else} +
+ Ingen AI-forbruk registrert i perioden. +
+ {/if} + {/if} +
+ + + {#if data.daily.length > 0} +
+

Daglig aktivitet

+
+
+ + + + + + + + + + + {#each data.daily as row} + + + + + + + {/each} + +
DatoRessurstypeForbrukHendelser
+ {new Date(row.day).toLocaleDateString('nb-NO')} + + + {resourceTypeIcon(row.resource_type)} + + {resourceTypeLabel(row.resource_type)} + + {formatResourceValue(row.resource_type, row.total_value)} + + {row.event_count.toLocaleString('nb-NO')} +
+
+
+
+ {/if} + {/if} +
+
diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 49c19a0..1a0399b 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -23,6 +23,7 @@ pub mod summarize; pub mod tiptap; pub mod transcribe; pub mod tts; +pub mod usage_overview; mod warmup; use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; @@ -234,6 +235,8 @@ async fn main() { .route("/admin/ai/delete_provider", post(ai_admin::delete_provider)) .route("/admin/ai/update_routing", post(ai_admin::update_routing)) .route("/admin/ai/delete_routing", post(ai_admin::delete_routing)) + // Forbruksoversikt (oppgave 15.8) + .route("/admin/usage", get(usage_overview::usage_overview)) // Serverhelse-dashboard (oppgave 15.6) .route("/admin/health", get(health::health_dashboard)) .route("/admin/health/logs", get(health::health_logs)) diff --git a/maskinrommet/src/usage_overview.rs b/maskinrommet/src/usage_overview.rs new file mode 100644 index 0000000..dc48d34 --- /dev/null +++ b/maskinrommet/src/usage_overview.rs @@ -0,0 +1,238 @@ +// 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::AuthUser; +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, + _user: AuthUser, + 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 +} diff --git a/tasks.md b/tasks.md index 2e8f858..0400cfe 100644 --- a/tasks.md +++ b/tasks.md @@ -170,8 +170,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 15.5 Ressursstyring: prioritetsregler mellom jobbtyper, ressursgrenser per worker, ressurs-governor for automatisk nedprioritering under aktive LiveKit-sesjoner, disk-status med varsling. - [x] 15.6 Serverhelse-dashboard: tjeneste-status (PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit), metrikker (CPU, minne, disk), backup-status, logg-tilgang. - [x] 15.7 Ressursforbruk-logging: `resource_usage_log`-tabell i PG. Maskinrommet logger AI-tokens (inn/ut, modellnivå), Whisper-tid (sek), TTS-tegn, CAS-lagring (bytes), LiveKit-tid (deltaker-min). Båndbredde via Caddy-logg-parsing. Ref: `docs/features/ressursforbruk.md`. -- [~] 15.8 Forbruksoversikt i admin: aggregert visning per samling, per ressurstype, per tidsperiode. Drill-down til jobbtype og modellnivå. - > Påbegynt: 2026-03-18T04:26 +- [x] 15.8 Forbruksoversikt i admin: aggregert visning per samling, per ressurstype, per tidsperiode. Drill-down til jobbtype og modellnivå. - [ ] 15.9 Brukersynlig forbruk: hver bruker ser eget forbruk i profil/innstillinger. Per-node forbruk synlig i node-detaljer for eiere. ## Fase 16: Lydmixer