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>
149 lines
4.1 KiB
Svelte
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}
|