synops/frontend/src/routes/admin/usage/+page.svelte
vegard d7dffa06e6 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>
2026-03-18 04:34:08 +00:00

435 lines
14 KiB
Svelte

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