Fullfører oppgave 15.8: Forbruksoversikt i admin

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) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 04:34:08 +00:00
parent 973ccece35
commit d7dffa06e6
7 changed files with 748 additions and 2 deletions

View file

@ -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=<uuid>`
Frontend: `frontend/src/routes/admin/usage/+page.svelte`
### Caddy-oppsett
JSON access logging er konfigurert i Caddyfile for `sidelinja.org`

View file

@ -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<UsageOverviewResponse> {
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();
}

View file

@ -103,6 +103,9 @@
<a href="/admin/ai" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
AI Gateway
</a>
<a href="/admin/usage" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
Forbruk
</a>
</nav>
</div>
</header>

View file

@ -0,0 +1,435 @@
<script lang="ts">
/**
* Admin — Forbruksoversikt (oppgave 15.8)
*
* Aggregert visning av ressursforbruk per samling, per ressurstype,
* per tidsperiode. Drill-down til jobbtype og modellniva for AI.
*/
import { page } from '$app/stores';
import {
fetchUsageOverview,
type UsageOverviewResponse,
type CollectionUsageSummary,
type AiDrillDown
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let data = $state<UsageOverviewResponse | null>(null);
let error = $state<string | null>(null);
let loading = $state(false);
// Filtre
let days = $state(30);
let selectedCollection = $state<string>('');
let selectedResourceType = $state<string>('');
let showAiDrilldown = $state(false);
$effect(() => {
if (!accessToken) return;
loadData();
});
async function loadData() {
if (!accessToken) return;
loading = true;
error = null;
try {
data = await fetchUsageOverview(accessToken, {
days,
collection_id: selectedCollection || undefined
});
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
// Avledede data
const collections = $derived((): { id: string | null; title: string }[] => {
if (!data) return [];
const seen = new Map<string | null, string>();
for (const row of data.by_collection) {
if (!seen.has(row.collection_id)) {
seen.set(row.collection_id, row.collection_title || 'Ukjent');
}
}
return [...seen.entries()].map(([id, title]) => ({ id, title }));
});
const resourceTypes = $derived((): string[] => {
if (!data) return [];
const types = new Set<string>();
for (const row of data.by_collection) types.add(row.resource_type);
return [...types].sort();
});
const filteredByCollection = $derived((): CollectionUsageSummary[] => {
if (!data) return [];
let rows = data.by_collection;
if (selectedResourceType) {
rows = rows.filter((r) => r.resource_type === selectedResourceType);
}
return rows;
});
// Summeringsrad per ressurstype (pa tvers av samlinger)
const totalsPerType = $derived((): Map<string, { total: number; secondary: number; count: number }> => {
const map = new Map<string, { total: number; secondary: number; count: number }>();
if (!data) return map;
for (const row of data.by_collection) {
const existing = map.get(row.resource_type) || { total: 0, secondary: 0, count: 0 };
existing.total += row.total_value;
existing.secondary += row.secondary_value;
existing.count += row.event_count;
map.set(row.resource_type, existing);
}
return map;
});
const filteredAiDrilldown = $derived((): AiDrillDown[] => {
if (!data) return [];
return data.ai_drilldown;
});
// Formateringshjelper
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-6xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold">Forbruksoversikt</h1>
<a href="/admin" class="text-sm text-neutral-400 hover:text-white">Tilbake til admin</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}
<!-- Filtre -->
<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>
{#if collections().length > 0}
<select
bind:value={selectedCollection}
class="bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm"
onchange={() => loadData()}
>
<option value="">Alle samlinger</option>
{#each collections() as col}
<option value={col.id || ''}>{col.title}</option>
{/each}
</select>
{/if}
{#if resourceTypes().length > 0}
<select
bind:value={selectedResourceType}
class="bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm"
>
<option value="">Alle ressurstyper</option>
{#each resourceTypes() as rt}
<option value={rt}>{resourceTypeLabel(rt)}</option>
{/each}
</select>
{/if}
<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}
<!-- Totaler per ressurstype -->
<section>
<h2 class="text-lg font-semibold mb-3">Totalt forbruk ({days} dager)</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{#each [...totalsPerType().entries()] as [type, totals]}
<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(type)}"
>
{resourceTypeIcon(type)}
</span>
<span class="text-sm text-neutral-400">{resourceTypeLabel(type)}</span>
</div>
<div class="text-xl font-mono">
{formatResourceValue(type, totals.total, totals.secondary)}
</div>
<div class="text-xs text-neutral-500 mt-1">
{totals.count.toLocaleString('nb-NO')} hendelser
</div>
</div>
{/each}
{#if totalsPerType().size === 0}
<div class="col-span-full text-neutral-500 text-sm">
Ingen ressursforbruk registrert i perioden.
</div>
{/if}
</div>
</section>
<!-- Per samling -->
{#if filteredByCollection().length > 0}
<section>
<h2 class="text-lg font-semibold mb-3">Per samling</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">Samling</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 filteredByCollection() as row}
<tr class="border-b border-neutral-800/50 hover:bg-neutral-800/30">
<td class="px-4 py-2.5">
{row.collection_title || 'Uten samling'}
</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, row.secondary_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}
<!-- AI Drill-down -->
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">AI Drill-down</h2>
<button
class="text-sm text-neutral-400 hover:text-white"
onclick={() => (showAiDrilldown = !showAiDrilldown)}
>
{showAiDrilldown ? 'Skjul' : 'Vis detaljer'}
</button>
</div>
{#if showAiDrilldown}
{#if filteredAiDrilldown().length > 0}
<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">Samling</th>
<th class="px-4 py-3 font-medium">Jobbtype</th>
<th class="px-4 py-3 font-medium">Modellniva</th>
<th class="px-4 py-3 font-medium text-right">Tokens inn</th>
<th class="px-4 py-3 font-medium text-right">Tokens ut</th>
<th class="px-4 py-3 font-medium text-right">Kall</th>
</tr>
</thead>
<tbody>
{#each filteredAiDrilldown() as row}
<tr class="border-b border-neutral-800/50 hover:bg-neutral-800/30">
<td class="px-4 py-2.5">{row.collection_title || 'Uten samling'}</td>
<td class="px-4 py-2.5">
<span class="font-mono text-xs bg-neutral-800 px-2 py-0.5 rounded">
{row.job_type || 'ukjent'}
</span>
</td>
<td class="px-4 py-2.5">
{#if row.model_level === 'fast'}
<span class="text-green-400 text-xs font-medium">fast</span>
{:else if row.model_level === 'smart'}
<span class="text-blue-400 text-xs font-medium">smart</span>
{:else if row.model_level === 'deep'}
<span class="text-purple-400 text-xs font-medium">deep</span>
{:else}
<span class="text-neutral-500 text-xs">{row.model_level || '—'}</span>
{/if}
</td>
<td class="px-4 py-2.5 text-right font-mono text-neutral-200">
{formatNumber(row.tokens_in)}
</td>
<td class="px-4 py-2.5 text-right font-mono text-neutral-200">
{formatNumber(row.tokens_out)}
</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>
{:else}
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4 text-neutral-500 text-sm">
Ingen AI-forbruk registrert i perioden.
</div>
{/if}
{/if}
</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

@ -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))

View file

@ -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<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>,
_user: AuthUser,
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
}

View file

@ -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