From 5771d1eed625463449228f73e57775eb22bb1045 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 04:42:47 +0000 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8rer=20oppgave=2015.9:=20Brukersynlig?= =?UTF-8?q?=20forbruk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legger til to nye API-endepunkter for brukersynlig ressursforbruk: - GET /my/usage — brukerens eget forbruk (filtrert på triggered_by) - GET /query/node_usage — forbruk for én node (kun eier/admin) Frontend: - /profile — profilside med grafstatistikk og forbruksoversikt - NodeUsage-komponent integrert i samlings-detaljsiden - Brukernavn i header lenker nå til profilsiden Tilgangssjekk: nodeforbruk krever created_by eller owner/admin-edge. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/features/ressursforbruk.md | 24 ++ frontend/src/lib/api.ts | 73 ++++ frontend/src/lib/components/NodeUsage.svelte | 149 ++++++++ frontend/src/routes/+page.svelte | 2 +- .../src/routes/collection/[id]/+page.svelte | 8 + frontend/src/routes/profile/+page.svelte | 260 +++++++++++++ maskinrommet/src/main.rs | 4 + maskinrommet/src/user_usage.rs | 355 ++++++++++++++++++ tasks.md | 3 +- 9 files changed, 875 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/components/NodeUsage.svelte create mode 100644 frontend/src/routes/profile/+page.svelte create mode 100644 maskinrommet/src/user_usage.rs diff --git a/docs/features/ressursforbruk.md b/docs/features/ressursforbruk.md index 94b1c02..35daaec 100644 --- a/docs/features/ressursforbruk.md +++ b/docs/features/ressursforbruk.md @@ -231,6 +231,30 @@ Alle handlers bruker denne for konsistent logging. Backend: `maskinrommet/src/usage_overview.rs` → `GET /admin/usage?days=30&collection_id=` Frontend: `frontend/src/routes/admin/usage/+page.svelte` +### Brukersynlig forbruk (oppgave 15.9) + +To nye endepunkter i maskinrommet: + +- `GET /my/usage?days=30` — brukerens eget forbruk (filtrert på `triggered_by`) +- `GET /query/node_usage?node_id=&days=30` — forbruk for én node (kun eier) + +Begge returnerer `by_type` (aggregert per ressurstype) og `daily` (tidsserie). +`/my/usage` inkluderer også `graph` (noder/edges opprettet av brukeren). + +Tilgangssjekk for nodeforbruk: brukeren må ha opprettet noden (`created_by`) +eller ha `owner`/`admin`-edge til den. + +Frontend: + +- `/profile` — profilside med grafstatistikk, totalkort per ressurstype, + daglig tidsserie. Lenket fra brukernavnet i headeren. +- `NodeUsage.svelte` — kollapserbart panel som viser nodeforbruk. + Integrert i samlings-detaljsiden (`/collection/[id]`). + +Backend: `maskinrommet/src/user_usage.rs` +Frontend: `frontend/src/routes/profile/+page.svelte`, +`frontend/src/lib/components/NodeUsage.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 565c585..c8c9bf7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1093,3 +1093,76 @@ export async function fetchUsageOverview( } return res.json(); } + +// ============================================================================ +// Brukersynlig forbruk (oppgave 15.9) +// ============================================================================ + +export interface ResourceTypeSummary { + resource_type: string; + event_count: number; + total_value: number; + secondary_value: number; +} + +export interface UserDailyUsage { + day: string; + resource_type: string; + event_count: number; + total_value: number; +} + +export interface GraphStats { + nodes_created: number; + edges_created: number; +} + +export interface UserUsageResponse { + by_type: ResourceTypeSummary[]; + daily: UserDailyUsage[]; + graph: GraphStats; +} + +export interface NodeUsageResponse { + node_id: string; + node_title: string | null; + by_type: ResourceTypeSummary[]; + daily: UserDailyUsage[]; +} + +/** Hent innlogget brukers eget ressursforbruk. */ +export async function fetchMyUsage( + accessToken: string, + params: { days?: number } = {} +): Promise { + const sp = new URLSearchParams(); + if (params.days) sp.set('days', String(params.days)); + const qs = sp.toString(); + const res = await fetch(`${BASE_URL}/my/usage${qs ? `?${qs}` : ''}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`my usage failed (${res.status}): ${body}`); + } + return res.json(); +} + +/** Hent ressursforbruk for en spesifikk node (kun eier). */ +export async function fetchNodeUsage( + accessToken: string, + nodeId: string, + params: { days?: number } = {} +): Promise { + const sp = new URLSearchParams(); + sp.set('node_id', nodeId); + if (params.days) sp.set('days', String(params.days)); + const res = await fetch(`${BASE_URL}/query/node_usage?${sp.toString()}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`node usage failed (${res.status}): ${body}`); + } + return res.json(); +} diff --git a/frontend/src/lib/components/NodeUsage.svelte b/frontend/src/lib/components/NodeUsage.svelte new file mode 100644 index 0000000..6fe40e1 --- /dev/null +++ b/frontend/src/lib/components/NodeUsage.svelte @@ -0,0 +1,149 @@ + + +{#if error !== 'no-access'} +
+ + + {#if expanded} +
+ {#if loading} +

Laster...

+ {:else if error} +

{error}

+ {:else if data} + {#if data.by_type.length === 0} +

Ingen ressursforbruk registrert.

+ {:else} +
+ {#each data.by_type as row} +
+ + {resourceTypeLabel(row.resource_type)} + + + {formatResourceValue(row.resource_type, row.total_value, row.secondary_value)} + +
+ {/each} +
+
+ Siste {days} dager +
+ {/if} + {/if} +
+ {/if} +
+{/if} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index dd66464..4f3297e 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -310,7 +310,7 @@ {connectionState.current} {/if} {#if $page.data.session?.user} - {$page.data.session.user.name} + {$page.data.session.user.name} + + + {#if !data && !error} +

Laster...

+ {:else if data} + +
+

Graf

+
+
+
Noder opprettet
+
{data.graph.nodes_created.toLocaleString('nb-NO')}
+
+
+
Edges opprettet
+
{data.graph.edges_created.toLocaleString('nb-NO')}
+
+
+
+ + +
+

Mitt forbruk ({days} dager)

+
+ {#each data.by_type as row} +
+
+ + {resourceTypeIcon(row.resource_type)} + + {resourceTypeLabel(row.resource_type)} +
+
+ {formatResourceValue(row.resource_type, row.total_value, row.secondary_value)} +
+
+ {row.event_count.toLocaleString('nb-NO')} hendelser +
+
+ {/each} + {#if data.by_type.length === 0} +
+ Ingen ressursforbruk registrert i perioden. +
+ {/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 1a0399b..db11d80 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -24,6 +24,7 @@ pub mod tiptap; pub mod transcribe; pub mod tts; pub mod usage_overview; +pub mod user_usage; mod warmup; use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; @@ -237,6 +238,9 @@ async fn main() { .route("/admin/ai/delete_routing", post(ai_admin::delete_routing)) // Forbruksoversikt (oppgave 15.8) .route("/admin/usage", get(usage_overview::usage_overview)) + // Brukersynlig forbruk (oppgave 15.9) + .route("/my/usage", get(user_usage::my_usage)) + .route("/query/node_usage", get(user_usage::node_usage)) // 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/user_usage.rs b/maskinrommet/src/user_usage.rs new file mode 100644 index 0000000..d5e2078 --- /dev/null +++ b/maskinrommet/src/user_usage.rs @@ -0,0 +1,355 @@ +// Brukersynlig forbruk — oppgave 15.9 +// +// To endepunkter: +// GET /my/usage?days=30 — innlogget brukers eget forbruk +// GET /query/node_usage?node_id=&days=30 — forbruk for en spesifikk node (kun eier) +// +// Bygger på resource_usage_log (oppgave 15.7) med filtrering på +// triggered_by (bruker) og target_node_id (node). +// +// Ref: docs/features/ressursforbruk.md + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::AppState; + +// ============================================================================= +// Datatyper +// ============================================================================= + +/// Aggregert forbruk per ressurstype for én bruker eller node. +#[derive(Serialize, sqlx::FromRow)] +pub struct ResourceTypeSummary { + pub resource_type: String, + pub event_count: i64, + pub total_value: f64, + pub secondary_value: f64, +} + +/// Daglig tidsserie for bruker/node. +#[derive(Serialize, sqlx::FromRow)] +pub struct DailyUsage { + pub day: chrono::DateTime, + pub resource_type: String, + pub event_count: i64, + pub total_value: f64, +} + +/// Grafstatistikk: noder og edges opprettet. +#[derive(Serialize)] +pub struct GraphStats { + pub nodes_created: i64, + pub edges_created: i64, +} + +/// Samlet respons for brukerforbruk. +#[derive(Serialize)] +pub struct UserUsageResponse { + pub by_type: Vec, + pub daily: Vec, + pub graph: GraphStats, +} + +/// Samlet respons for node-forbruk. +#[derive(Serialize)] +pub struct NodeUsageResponse { + pub node_id: Uuid, + pub node_title: Option, + pub by_type: Vec, + pub daily: Vec, +} + +#[derive(Deserialize)] +pub struct UsageParams { + pub days: Option, +} + +#[derive(Deserialize)] +pub struct NodeUsageParams { + pub node_id: Uuid, + pub days: Option, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +fn err(status: StatusCode, msg: &str) -> (StatusCode, Json) { + (status, Json(ErrorResponse { error: msg.to_string() })) +} + +// ============================================================================= +// GET /my/usage — brukerens eget forbruk +// ============================================================================= + +pub async fn my_usage( + 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 user_id = user.node_id; + + let by_type = fetch_user_by_type(&state.db, user_id, days) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; + + let daily = fetch_user_daily(&state.db, user_id, days) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; + + let graph = fetch_graph_stats_for_user(&state.db, user_id) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; + + Ok(Json(UserUsageResponse { by_type, daily, graph })) +} + +// ============================================================================= +// GET /query/node_usage — forbruk for en spesifikk node +// ============================================================================= + +pub async fn node_usage( + 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 node_id = params.node_id; + + // Sjekk at brukeren eier noden (created_by) eller har owner/admin-edge + let is_owner = check_node_access(&state.db, node_id, user.node_id) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; + + if !is_owner { + return Err(err(StatusCode::FORBIDDEN, "Du har ikke tilgang til denne nodens forbruk")); + } + + let node_title: Option = sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1") + .bind(node_id) + .fetch_optional(&state.db) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))? + .flatten(); + + let by_type = fetch_node_by_type(&state.db, node_id, days) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; + + let daily = fetch_node_daily(&state.db, node_id, days) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; + + Ok(Json(NodeUsageResponse { node_id, node_title, by_type, daily })) +} + +// ============================================================================= +// Tilgangssjekk +// ============================================================================= + +/// Bruker har tilgang til nodeforbruk hvis: +/// 1. De opprettet noden (created_by), eller +/// 2. De har en owner- eller admin-edge til noden +async fn check_node_access(db: &PgPool, node_id: Uuid, user_id: Uuid) -> Result { + let row: Option<(bool,)> = sqlx::query_as( + r#" + SELECT EXISTS( + SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2 + UNION ALL + SELECT 1 FROM edges WHERE target_node_id = $1 AND source_node_id = $2 + AND edge_type IN ('owner', 'admin') + ) AS ok + "#, + ) + .bind(node_id) + .bind(user_id) + .fetch_optional(db) + .await?; + + Ok(row.map(|r| r.0).unwrap_or(false)) +} + +// ============================================================================= +// Spørringer — brukerforbruk +// ============================================================================= + +async fn fetch_user_by_type( + db: &PgPool, + user_id: Uuid, + days: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ResourceTypeSummary>( + r#" + SELECT + 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 + WHERE r.triggered_by = $1 + AND r.created_at >= now() - make_interval(days := $2) + GROUP BY r.resource_type + ORDER BY total_value DESC + "#, + ) + .bind(user_id) + .bind(days) + .fetch_all(db) + .await +} + +async fn fetch_user_daily( + db: &PgPool, + user_id: Uuid, + days: i32, +) -> 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.triggered_by = $1 + AND r.created_at >= now() - make_interval(days := $2) + GROUP BY date_trunc('day', r.created_at), r.resource_type + ORDER BY day DESC, resource_type + "#, + ) + .bind(user_id) + .bind(days) + .fetch_all(db) + .await +} + +async fn fetch_graph_stats_for_user(db: &PgPool, user_id: Uuid) -> Result { + let nodes: (i64,) = sqlx::query_as("SELECT COUNT(*)::BIGINT FROM nodes WHERE created_by = $1") + .bind(user_id) + .fetch_one(db) + .await?; + + let edges: (i64,) = sqlx::query_as( + "SELECT COUNT(*)::BIGINT FROM edges WHERE created_by = $1", + ) + .bind(user_id) + .fetch_one(db) + .await?; + + Ok(GraphStats { + nodes_created: nodes.0, + edges_created: edges.0, + }) +} + +// ============================================================================= +// Spørringer — nodeforbruk +// ============================================================================= + +async fn fetch_node_by_type( + db: &PgPool, + node_id: Uuid, + days: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ResourceTypeSummary>( + r#" + SELECT + 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 + WHERE r.target_node_id = $1 + AND r.created_at >= now() - make_interval(days := $2) + GROUP BY r.resource_type + ORDER BY total_value DESC + "#, + ) + .bind(node_id) + .bind(days) + .fetch_all(db) + .await +} + +async fn fetch_node_daily( + db: &PgPool, + node_id: Uuid, + days: i32, +) -> 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.target_node_id = $1 + AND r.created_at >= now() - make_interval(days := $2) + GROUP BY date_trunc('day', r.created_at), r.resource_type + ORDER BY day DESC, resource_type + "#, + ) + .bind(node_id) + .bind(days) + .fetch_all(db) + .await +} diff --git a/tasks.md b/tasks.md index 9909746..cec0ebe 100644 --- a/tasks.md +++ b/tasks.md @@ -171,8 +171,7 @@ Uavhengige faser kan fortsatt plukkes. - [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`. - [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. - > Påbegynt: 2026-03-18T04:35 +- [x] 15.9 Brukersynlig forbruk: hver bruker ser eget forbruk i profil/innstillinger. Per-node forbruk synlig i node-detaljer for eiere. ## Fase 16: Lydmixer