Fullfører oppgave 15.9: Brukersynlig forbruk

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) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 04:42:47 +00:00
parent 49b9afb64e
commit 5771d1eed6
9 changed files with 875 additions and 3 deletions

View file

@ -231,6 +231,30 @@ Alle handlers bruker denne for konsistent logging.
Backend: `maskinrommet/src/usage_overview.rs``GET /admin/usage?days=30&collection_id=<uuid>` Backend: `maskinrommet/src/usage_overview.rs``GET /admin/usage?days=30&collection_id=<uuid>`
Frontend: `frontend/src/routes/admin/usage/+page.svelte` 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=<uuid>&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 ### Caddy-oppsett
JSON access logging er konfigurert i Caddyfile for `sidelinja.org` JSON access logging er konfigurert i Caddyfile for `sidelinja.org`

View file

@ -1093,3 +1093,76 @@ export async function fetchUsageOverview(
} }
return res.json(); 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<UserUsageResponse> {
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<NodeUsageResponse> {
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();
}

View file

@ -0,0 +1,149 @@
<script lang="ts">
/**
* NodeUsage — Ressursforbruk for en spesifikk node (oppgave 15.9)
*
* Viser aggregert forbruk per ressurstype for en node.
* Kun synlig for eiere (backend sjekker tilgang).
* Kollapset som standard — åpnes ved klikk.
*/
import { fetchNodeUsage, type NodeUsageResponse } from '$lib/api';
let {
nodeId,
accessToken
}: {
nodeId: string;
accessToken: string;
} = $props();
let data = $state<NodeUsageResponse | null>(null);
let error = $state<string | null>(null);
let loading = $state(false);
let expanded = $state(false);
let days = $state(30);
async function loadData() {
if (!accessToken || !nodeId) return;
loading = true;
error = null;
try {
data = await fetchNodeUsage(accessToken, nodeId, { days });
} catch (e) {
const msg = String(e);
// 403 betyr at brukeren ikke er eier — skjul komponenten stille
if (msg.includes('403')) {
data = null;
error = 'no-access';
} else {
error = msg;
}
} finally {
loading = false;
}
}
function toggle() {
expanded = !expanded;
if (expanded && !data && error !== 'no-access') {
loadData();
}
}
function formatResourceValue(type: string, value: number, secondary?: number): string {
switch (type) {
case 'ai': {
const tokIn = fmt(value);
const tokOut = secondary ? fmt(secondary) : '0';
return `${tokIn} inn / ${tokOut} ut tokens`;
}
case 'whisper': {
const hours = value / 3600;
if (hours >= 1) return `${hours.toFixed(1)} timer`;
return `${(value / 60).toFixed(0)} min`;
}
case 'tts':
return `${fmt(value)} tegn`;
case 'cas':
case 'bandwidth':
return formatBytes(value);
case 'livekit': {
const hours = value / 60;
if (hours >= 1) return `${hours.toFixed(1)} timer`;
return `${value.toFixed(0)} min`;
}
default:
return fmt(value);
}
}
function fmt(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return n.toFixed(0);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
function resourceTypeLabel(type: string): string {
const labels: Record<string, string> = {
ai: 'AI', whisper: 'Whisper', tts: 'TTS',
cas: 'Lagring', bandwidth: 'Bandbredde', livekit: 'LiveKit'
};
return labels[type] || type;
}
function resourceTypeColor(type: string): string {
const colors: Record<string, string> = {
ai: 'text-purple-600', whisper: 'text-blue-600', tts: 'text-green-600',
cas: 'text-amber-600', bandwidth: 'text-cyan-600', livekit: 'text-pink-600'
};
return colors[type] || 'text-gray-600';
}
</script>
{#if error !== 'no-access'}
<div class="rounded-lg border border-gray-200 bg-white">
<button
onclick={toggle}
class="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-gray-700 hover:bg-gray-50"
>
<span>Ressursforbruk</span>
<span class="text-xs text-gray-400">{expanded ? 'Skjul' : 'Vis'}</span>
</button>
{#if expanded}
<div class="border-t border-gray-100 px-4 py-3">
{#if loading}
<p class="text-xs text-gray-400">Laster...</p>
{:else if error}
<p class="text-xs text-red-500">{error}</p>
{:else if data}
{#if data.by_type.length === 0}
<p class="text-xs text-gray-400">Ingen ressursforbruk registrert.</p>
{:else}
<div class="space-y-2">
{#each data.by_type as row}
<div class="flex items-center justify-between text-sm">
<span class="font-medium {resourceTypeColor(row.resource_type)}">
{resourceTypeLabel(row.resource_type)}
</span>
<span class="font-mono text-gray-600">
{formatResourceValue(row.resource_type, row.total_value, row.secondary_value)}
</span>
</div>
{/each}
</div>
<div class="mt-2 text-xs text-gray-400">
Siste {days} dager
</div>
{/if}
{/if}
</div>
{/if}
</div>
{/if}

View file

@ -310,7 +310,7 @@
<span class="text-xs text-gray-400">{connectionState.current}</span> <span class="text-xs text-gray-400">{connectionState.current}</span>
{/if} {/if}
{#if $page.data.session?.user} {#if $page.data.session?.user}
<span class="text-sm text-gray-500">{$page.data.session.user.name}</span> <a href="/profile" class="text-sm text-gray-500 hover:text-gray-700">{$page.data.session.user.name}</a>
<button <button
onclick={() => signOut()} onclick={() => signOut()}
class="rounded bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200" class="rounded bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200"

View file

@ -16,6 +16,7 @@
import StudioTrait from '$lib/components/traits/StudioTrait.svelte'; import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte'; import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte'; import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
import NodeUsage from '$lib/components/NodeUsage.svelte';
const session = $derived($page.data.session as Record<string, unknown> | undefined); const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined); const nodeId = $derived(session?.nodeId as string | undefined);
@ -176,5 +177,12 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Ressursforbruk for denne noden (oppgave 15.9) -->
{#if accessToken && collectionId}
<div class="mt-6">
<NodeUsage nodeId={collectionId} {accessToken} />
</div>
{/if}
</main> </main>
</div> </div>

View file

@ -0,0 +1,260 @@
<script lang="ts">
/**
* Profil — Mitt forbruk (oppgave 15.9)
*
* Viser innlogget brukers eget ressursforbruk med
* totalkort, daglig tidsserie og grafstatistikk.
*/
import { page } from '$app/stores';
import {
fetchMyUsage,
type UserUsageResponse
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
const user = $derived(session?.user as Record<string, unknown> | undefined);
let data = $state<UserUsageResponse | null>(null);
let error = $state<string | null>(null);
let loading = $state(false);
let days = $state(30);
$effect(() => {
if (!accessToken) return;
loadData();
});
async function loadData() {
if (!accessToken) return;
loading = true;
error = null;
try {
data = await fetchMyUsage(accessToken, { days });
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
// Formateringshjelper (samme som admin/usage)
function formatResourceValue(type: string, value: number, secondary?: number): string {
switch (type) {
case 'ai': {
const tokIn = formatNumber(value);
const tokOut = secondary ? formatNumber(secondary) : '0';
return `${tokIn} inn / ${tokOut} ut tokens`;
}
case 'whisper': {
const hours = value / 3600;
if (hours >= 1) return `${hours.toFixed(1)} timer`;
const mins = value / 60;
return `${mins.toFixed(0)} min`;
}
case 'tts':
return `${formatNumber(value)} tegn`;
case 'cas':
case 'bandwidth':
return formatBytes(value);
case 'livekit': {
const hours = value / 60;
if (hours >= 1) return `${hours.toFixed(1)} timer`;
return `${value.toFixed(0)} min`;
}
default:
return formatNumber(value);
}
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return n.toFixed(0);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
function resourceTypeLabel(type: string): string {
const labels: Record<string, string> = {
ai: 'AI (LLM)',
whisper: 'Whisper (transkripsjon)',
tts: 'TTS (tekst-til-tale)',
cas: 'CAS (lagring)',
bandwidth: 'Bandbredde',
livekit: 'LiveKit (sanntid)'
};
return labels[type] || type;
}
function resourceTypeIcon(type: string): string {
const icons: Record<string, string> = {
ai: 'AI',
whisper: 'STT',
tts: 'TTS',
cas: 'CAS',
bandwidth: 'BW',
livekit: 'LK'
};
return icons[type] || type.slice(0, 3).toUpperCase();
}
function resourceTypeColor(type: string): string {
const colors: Record<string, string> = {
ai: 'bg-purple-500/20 text-purple-300 border-purple-500/30',
whisper: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
tts: 'bg-green-500/20 text-green-300 border-green-500/30',
cas: 'bg-amber-500/20 text-amber-300 border-amber-500/30',
bandwidth: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30',
livekit: 'bg-pink-500/20 text-pink-300 border-pink-500/30'
};
return colors[type] || 'bg-neutral-500/20 text-neutral-300 border-neutral-500/30';
}
const daysOptions = [
{ value: 7, label: '7 dager' },
{ value: 30, label: '30 dager' },
{ value: 90, label: '90 dager' },
{ value: 365, label: '1 ar' }
];
</script>
<div class="min-h-screen bg-neutral-950 text-neutral-100 p-4 sm:p-8">
<div class="max-w-4xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold">Min profil</h1>
{#if user?.name}
<p class="text-neutral-400 text-sm mt-1">{user.name}</p>
{/if}
</div>
<a href="/" class="text-sm text-neutral-400 hover:text-white">Tilbake</a>
</div>
{#if error}
<div class="bg-red-900/30 border border-red-700 rounded-lg p-4 text-red-300 text-sm">
{error}
</div>
{/if}
<!-- Tidsperiode-velger -->
<div class="flex flex-wrap gap-3 items-center">
<select
bind:value={days}
class="bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm"
onchange={() => loadData()}
>
{#each daysOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<button
class="bg-neutral-800 hover:bg-neutral-700 px-3 py-1.5 rounded text-sm"
onclick={() => loadData()}
disabled={loading}
>
{loading ? 'Laster...' : 'Oppdater'}
</button>
</div>
{#if !data && !error}
<p class="text-neutral-500">Laster...</p>
{:else if data}
<!-- Grafstatistikk -->
<section>
<h2 class="text-lg font-semibold mb-3">Graf</h2>
<div class="grid grid-cols-2 gap-3">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<div class="text-sm text-neutral-400 mb-1">Noder opprettet</div>
<div class="text-2xl font-mono">{data.graph.nodes_created.toLocaleString('nb-NO')}</div>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<div class="text-sm text-neutral-400 mb-1">Edges opprettet</div>
<div class="text-2xl font-mono">{data.graph.edges_created.toLocaleString('nb-NO')}</div>
</div>
</div>
</section>
<!-- Totaler per ressurstype -->
<section>
<h2 class="text-lg font-semibold mb-3">Mitt forbruk ({days} dager)</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{#each data.by_type as row}
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span
class="text-xs font-bold px-2 py-0.5 rounded border {resourceTypeColor(row.resource_type)}"
>
{resourceTypeIcon(row.resource_type)}
</span>
<span class="text-sm text-neutral-400">{resourceTypeLabel(row.resource_type)}</span>
</div>
<div class="text-xl font-mono">
{formatResourceValue(row.resource_type, row.total_value, row.secondary_value)}
</div>
<div class="text-xs text-neutral-500 mt-1">
{row.event_count.toLocaleString('nb-NO')} hendelser
</div>
</div>
{/each}
{#if data.by_type.length === 0}
<div class="col-span-full text-neutral-500 text-sm">
Ingen ressursforbruk registrert i perioden.
</div>
{/if}
</div>
</section>
<!-- Daglig tidsserie -->
{#if data.daily.length > 0}
<section>
<h2 class="text-lg font-semibold mb-3">Daglig aktivitet</h2>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-neutral-800 text-left text-neutral-400">
<th class="px-4 py-3 font-medium">Dato</th>
<th class="px-4 py-3 font-medium">Ressurstype</th>
<th class="px-4 py-3 font-medium text-right">Forbruk</th>
<th class="px-4 py-3 font-medium text-right">Hendelser</th>
</tr>
</thead>
<tbody>
{#each data.daily as row}
<tr class="border-b border-neutral-800/50 hover:bg-neutral-800/30">
<td class="px-4 py-2.5 font-mono text-xs">
{new Date(row.day).toLocaleDateString('nb-NO')}
</td>
<td class="px-4 py-2.5">
<span
class="text-xs font-bold px-2 py-0.5 rounded border {resourceTypeColor(row.resource_type)}"
>
{resourceTypeIcon(row.resource_type)}
</span>
<span class="ml-2 text-neutral-300">{resourceTypeLabel(row.resource_type)}</span>
</td>
<td class="px-4 py-2.5 text-right font-mono text-neutral-200">
{formatResourceValue(row.resource_type, row.total_value)}
</td>
<td class="px-4 py-2.5 text-right text-neutral-400">
{row.event_count.toLocaleString('nb-NO')}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</section>
{/if}
{/if}
</div>
</div>

View file

@ -24,6 +24,7 @@ pub mod tiptap;
pub mod transcribe; pub mod transcribe;
pub mod tts; pub mod tts;
pub mod usage_overview; pub mod usage_overview;
pub mod user_usage;
mod warmup; mod warmup;
use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; 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)) .route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
// Forbruksoversikt (oppgave 15.8) // Forbruksoversikt (oppgave 15.8)
.route("/admin/usage", get(usage_overview::usage_overview)) .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) // Serverhelse-dashboard (oppgave 15.6)
.route("/admin/health", get(health::health_dashboard)) .route("/admin/health", get(health::health_dashboard))
.route("/admin/health/logs", get(health::health_logs)) .route("/admin/health/logs", get(health::health_logs))

View file

@ -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=<uuid>&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<chrono::Utc>,
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<ResourceTypeSummary>,
pub daily: Vec<DailyUsage>,
pub graph: GraphStats,
}
/// Samlet respons for node-forbruk.
#[derive(Serialize)]
pub struct NodeUsageResponse {
pub node_id: Uuid,
pub node_title: Option<String>,
pub by_type: Vec<ResourceTypeSummary>,
pub daily: Vec<DailyUsage>,
}
#[derive(Deserialize)]
pub struct UsageParams {
pub days: Option<i32>,
}
#[derive(Deserialize)]
pub struct NodeUsageParams {
pub node_id: Uuid,
pub days: Option<i32>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn err(status: StatusCode, msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(status, Json(ErrorResponse { error: msg.to_string() }))
}
// =============================================================================
// GET /my/usage — brukerens eget forbruk
// =============================================================================
pub async fn my_usage(
State(state): State<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<UsageParams>,
) -> Result<Json<UserUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<NodeUsageParams>,
) -> Result<Json<NodeUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
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<String> = 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<bool, sqlx::Error> {
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<Vec<ResourceTypeSummary>, 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<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.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<GraphStats, sqlx::Error> {
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<Vec<ResourceTypeSummary>, 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<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.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
}

View file

@ -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.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.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å. - [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. - [x] 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
## Fase 16: Lydmixer ## Fase 16: Lydmixer