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:
parent
973ccece35
commit
d7dffa06e6
7 changed files with 748 additions and 2 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
435
frontend/src/routes/admin/usage/+page.svelte
Normal file
435
frontend/src/routes/admin/usage/+page.svelte
Normal 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>
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
238
maskinrommet/src/usage_overview.rs
Normal file
238
maskinrommet/src/usage_overview.rs
Normal 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
|
||||
}
|
||||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue