synops/frontend/src/lib/components/NodeUsage.svelte
vegard 5771d1eed6 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>
2026-03-18 04:42:47 +00:00

149 lines
4.1 KiB
Svelte

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