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>
435 lines
14 KiB
Svelte
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>
|