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>
This commit is contained in:
parent
49b9afb64e
commit
5771d1eed6
9 changed files with 875 additions and 3 deletions
|
|
@ -231,6 +231,30 @@ Alle handlers bruker denne for konsistent logging.
|
||||||
Backend: `maskinrommet/src/usage_overview.rs` → `GET /admin/usage?days=30&collection_id=<uuid>`
|
Backend: `maskinrommet/src/usage_overview.rs` → `GET /admin/usage?days=30&collection_id=<uuid>`
|
||||||
Frontend: `frontend/src/routes/admin/usage/+page.svelte`
|
Frontend: `frontend/src/routes/admin/usage/+page.svelte`
|
||||||
|
|
||||||
|
### Brukersynlig forbruk (oppgave 15.9)
|
||||||
|
|
||||||
|
To nye endepunkter i maskinrommet:
|
||||||
|
|
||||||
|
- `GET /my/usage?days=30` — brukerens eget forbruk (filtrert på `triggered_by`)
|
||||||
|
- `GET /query/node_usage?node_id=<uuid>&days=30` — forbruk for én node (kun eier)
|
||||||
|
|
||||||
|
Begge returnerer `by_type` (aggregert per ressurstype) og `daily` (tidsserie).
|
||||||
|
`/my/usage` inkluderer også `graph` (noder/edges opprettet av brukeren).
|
||||||
|
|
||||||
|
Tilgangssjekk for nodeforbruk: brukeren må ha opprettet noden (`created_by`)
|
||||||
|
eller ha `owner`/`admin`-edge til den.
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- `/profile` — profilside med grafstatistikk, totalkort per ressurstype,
|
||||||
|
daglig tidsserie. Lenket fra brukernavnet i headeren.
|
||||||
|
- `NodeUsage.svelte` — kollapserbart panel som viser nodeforbruk.
|
||||||
|
Integrert i samlings-detaljsiden (`/collection/[id]`).
|
||||||
|
|
||||||
|
Backend: `maskinrommet/src/user_usage.rs`
|
||||||
|
Frontend: `frontend/src/routes/profile/+page.svelte`,
|
||||||
|
`frontend/src/lib/components/NodeUsage.svelte`
|
||||||
|
|
||||||
### Caddy-oppsett
|
### Caddy-oppsett
|
||||||
|
|
||||||
JSON access logging er konfigurert i Caddyfile for `sidelinja.org`
|
JSON access logging er konfigurert i Caddyfile for `sidelinja.org`
|
||||||
|
|
|
||||||
|
|
@ -1093,3 +1093,76 @@ export async function fetchUsageOverview(
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Brukersynlig forbruk (oppgave 15.9)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ResourceTypeSummary {
|
||||||
|
resource_type: string;
|
||||||
|
event_count: number;
|
||||||
|
total_value: number;
|
||||||
|
secondary_value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDailyUsage {
|
||||||
|
day: string;
|
||||||
|
resource_type: string;
|
||||||
|
event_count: number;
|
||||||
|
total_value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphStats {
|
||||||
|
nodes_created: number;
|
||||||
|
edges_created: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserUsageResponse {
|
||||||
|
by_type: ResourceTypeSummary[];
|
||||||
|
daily: UserDailyUsage[];
|
||||||
|
graph: GraphStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeUsageResponse {
|
||||||
|
node_id: string;
|
||||||
|
node_title: string | null;
|
||||||
|
by_type: ResourceTypeSummary[];
|
||||||
|
daily: UserDailyUsage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hent innlogget brukers eget ressursforbruk. */
|
||||||
|
export async function fetchMyUsage(
|
||||||
|
accessToken: string,
|
||||||
|
params: { days?: number } = {}
|
||||||
|
): Promise<UserUsageResponse> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params.days) sp.set('days', String(params.days));
|
||||||
|
const qs = sp.toString();
|
||||||
|
const res = await fetch(`${BASE_URL}/my/usage${qs ? `?${qs}` : ''}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`my usage failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hent ressursforbruk for en spesifikk node (kun eier). */
|
||||||
|
export async function fetchNodeUsage(
|
||||||
|
accessToken: string,
|
||||||
|
nodeId: string,
|
||||||
|
params: { days?: number } = {}
|
||||||
|
): Promise<NodeUsageResponse> {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
sp.set('node_id', nodeId);
|
||||||
|
if (params.days) sp.set('days', String(params.days));
|
||||||
|
const res = await fetch(`${BASE_URL}/query/node_usage?${sp.toString()}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`node usage failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
|
||||||
149
frontend/src/lib/components/NodeUsage.svelte
Normal file
149
frontend/src/lib/components/NodeUsage.svelte
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<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}
|
||||||
|
|
@ -310,7 +310,7 @@
|
||||||
<span class="text-xs text-gray-400">{connectionState.current}</span>
|
<span class="text-xs text-gray-400">{connectionState.current}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $page.data.session?.user}
|
{#if $page.data.session?.user}
|
||||||
<span class="text-sm text-gray-500">{$page.data.session.user.name}</span>
|
<a href="/profile" class="text-sm text-gray-500 hover:text-gray-700">{$page.data.session.user.name}</a>
|
||||||
<button
|
<button
|
||||||
onclick={() => signOut()}
|
onclick={() => signOut()}
|
||||||
class="rounded bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200"
|
class="rounded bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
|
import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
|
||||||
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
||||||
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
|
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
|
||||||
|
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
||||||
|
|
||||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
const nodeId = $derived(session?.nodeId as string | undefined);
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
||||||
|
|
@ -176,5 +177,12 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Ressursforbruk for denne noden (oppgave 15.9) -->
|
||||||
|
{#if accessToken && collectionId}
|
||||||
|
<div class="mt-6">
|
||||||
|
<NodeUsage nodeId={collectionId} {accessToken} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
260
frontend/src/routes/profile/+page.svelte
Normal file
260
frontend/src/routes/profile/+page.svelte
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Profil — Mitt forbruk (oppgave 15.9)
|
||||||
|
*
|
||||||
|
* Viser innlogget brukers eget ressursforbruk med
|
||||||
|
* totalkort, daglig tidsserie og grafstatistikk.
|
||||||
|
*/
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import {
|
||||||
|
fetchMyUsage,
|
||||||
|
type UserUsageResponse
|
||||||
|
} from '$lib/api';
|
||||||
|
|
||||||
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||||
|
const user = $derived(session?.user as Record<string, unknown> | undefined);
|
||||||
|
|
||||||
|
let data = $state<UserUsageResponse | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let days = $state(30);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (!accessToken) return;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
data = await fetchMyUsage(accessToken, { days });
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formateringshjelper (samme som admin/usage)
|
||||||
|
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-4xl mx-auto space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Min profil</h1>
|
||||||
|
{#if user?.name}
|
||||||
|
<p class="text-neutral-400 text-sm mt-1">{user.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<a href="/" class="text-sm text-neutral-400 hover:text-white">Tilbake</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}
|
||||||
|
|
||||||
|
<!-- Tidsperiode-velger -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
<!-- Grafstatistikk -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Graf</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-neutral-400 mb-1">Noder opprettet</div>
|
||||||
|
<div class="text-2xl font-mono">{data.graph.nodes_created.toLocaleString('nb-NO')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-neutral-400 mb-1">Edges opprettet</div>
|
||||||
|
<div class="text-2xl font-mono">{data.graph.edges_created.toLocaleString('nb-NO')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Totaler per ressurstype -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Mitt forbruk ({days} dager)</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{#each data.by_type as row}
|
||||||
|
<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(row.resource_type)}"
|
||||||
|
>
|
||||||
|
{resourceTypeIcon(row.resource_type)}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-neutral-400">{resourceTypeLabel(row.resource_type)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-mono">
|
||||||
|
{formatResourceValue(row.resource_type, row.total_value, row.secondary_value)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-neutral-500 mt-1">
|
||||||
|
{row.event_count.toLocaleString('nb-NO')} hendelser
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if data.by_type.length === 0}
|
||||||
|
<div class="col-span-full text-neutral-500 text-sm">
|
||||||
|
Ingen ressursforbruk registrert i perioden.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
@ -24,6 +24,7 @@ pub mod tiptap;
|
||||||
pub mod transcribe;
|
pub mod transcribe;
|
||||||
pub mod tts;
|
pub mod tts;
|
||||||
pub mod usage_overview;
|
pub mod usage_overview;
|
||||||
|
pub mod user_usage;
|
||||||
mod warmup;
|
mod warmup;
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router};
|
use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router};
|
||||||
|
|
@ -237,6 +238,9 @@ async fn main() {
|
||||||
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
|
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
|
||||||
// Forbruksoversikt (oppgave 15.8)
|
// Forbruksoversikt (oppgave 15.8)
|
||||||
.route("/admin/usage", get(usage_overview::usage_overview))
|
.route("/admin/usage", get(usage_overview::usage_overview))
|
||||||
|
// Brukersynlig forbruk (oppgave 15.9)
|
||||||
|
.route("/my/usage", get(user_usage::my_usage))
|
||||||
|
.route("/query/node_usage", get(user_usage::node_usage))
|
||||||
// Serverhelse-dashboard (oppgave 15.6)
|
// Serverhelse-dashboard (oppgave 15.6)
|
||||||
.route("/admin/health", get(health::health_dashboard))
|
.route("/admin/health", get(health::health_dashboard))
|
||||||
.route("/admin/health/logs", get(health::health_logs))
|
.route("/admin/health/logs", get(health::health_logs))
|
||||||
|
|
|
||||||
355
maskinrommet/src/user_usage.rs
Normal file
355
maskinrommet/src/user_usage.rs
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
// Brukersynlig forbruk — oppgave 15.9
|
||||||
|
//
|
||||||
|
// To endepunkter:
|
||||||
|
// GET /my/usage?days=30 — innlogget brukers eget forbruk
|
||||||
|
// GET /query/node_usage?node_id=<uuid>&days=30 — forbruk for en spesifikk node (kun eier)
|
||||||
|
//
|
||||||
|
// Bygger på resource_usage_log (oppgave 15.7) med filtrering på
|
||||||
|
// triggered_by (bruker) og target_node_id (node).
|
||||||
|
//
|
||||||
|
// Ref: docs/features/ressursforbruk.md
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Datatyper
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Aggregert forbruk per ressurstype for én bruker eller node.
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ResourceTypeSummary {
|
||||||
|
pub resource_type: String,
|
||||||
|
pub event_count: i64,
|
||||||
|
pub total_value: f64,
|
||||||
|
pub secondary_value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Daglig tidsserie for bruker/node.
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct DailyUsage {
|
||||||
|
pub day: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub resource_type: String,
|
||||||
|
pub event_count: i64,
|
||||||
|
pub total_value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grafstatistikk: noder og edges opprettet.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct GraphStats {
|
||||||
|
pub nodes_created: i64,
|
||||||
|
pub edges_created: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samlet respons for brukerforbruk.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UserUsageResponse {
|
||||||
|
pub by_type: Vec<ResourceTypeSummary>,
|
||||||
|
pub daily: Vec<DailyUsage>,
|
||||||
|
pub graph: GraphStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samlet respons for node-forbruk.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NodeUsageResponse {
|
||||||
|
pub node_id: Uuid,
|
||||||
|
pub node_title: Option<String>,
|
||||||
|
pub by_type: Vec<ResourceTypeSummary>,
|
||||||
|
pub daily: Vec<DailyUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UsageParams {
|
||||||
|
pub days: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NodeUsageParams {
|
||||||
|
pub node_id: Uuid,
|
||||||
|
pub days: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(status: StatusCode, msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
(status, Json(ErrorResponse { error: msg.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /my/usage — brukerens eget forbruk
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
pub async fn my_usage(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<UsageParams>,
|
||||||
|
) -> Result<Json<UserUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let days = params.days.unwrap_or(30).clamp(1, 365);
|
||||||
|
let user_id = user.node_id;
|
||||||
|
|
||||||
|
let by_type = fetch_user_by_type(&state.db, user_id, days)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
||||||
|
|
||||||
|
let daily = fetch_user_daily(&state.db, user_id, days)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
||||||
|
|
||||||
|
let graph = fetch_graph_stats_for_user(&state.db, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(UserUsageResponse { by_type, daily, graph }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /query/node_usage — forbruk for en spesifikk node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
pub async fn node_usage(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<NodeUsageParams>,
|
||||||
|
) -> Result<Json<NodeUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let days = params.days.unwrap_or(30).clamp(1, 365);
|
||||||
|
let node_id = params.node_id;
|
||||||
|
|
||||||
|
// Sjekk at brukeren eier noden (created_by) eller har owner/admin-edge
|
||||||
|
let is_owner = check_node_access(&state.db, node_id, user.node_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
||||||
|
|
||||||
|
if !is_owner {
|
||||||
|
return Err(err(StatusCode::FORBIDDEN, "Du har ikke tilgang til denne nodens forbruk"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let node_title: Option<String> = sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1")
|
||||||
|
.bind(node_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let by_type = fetch_node_by_type(&state.db, node_id, days)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
||||||
|
|
||||||
|
let daily = fetch_node_daily(&state.db, node_id, days)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(NodeUsageResponse { node_id, node_title, by_type, daily }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tilgangssjekk
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Bruker har tilgang til nodeforbruk hvis:
|
||||||
|
/// 1. De opprettet noden (created_by), eller
|
||||||
|
/// 2. De har en owner- eller admin-edge til noden
|
||||||
|
async fn check_node_access(db: &PgPool, node_id: Uuid, user_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||||
|
let row: Option<(bool,)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM edges WHERE target_node_id = $1 AND source_node_id = $2
|
||||||
|
AND edge_type IN ('owner', 'admin')
|
||||||
|
) AS ok
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(node_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row.map(|r| r.0).unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Spørringer — brukerforbruk
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
async fn fetch_user_by_type(
|
||||||
|
db: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
days: i32,
|
||||||
|
) -> Result<Vec<ResourceTypeSummary>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ResourceTypeSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
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
|
||||||
|
WHERE r.triggered_by = $1
|
||||||
|
AND r.created_at >= now() - make_interval(days := $2)
|
||||||
|
GROUP BY r.resource_type
|
||||||
|
ORDER BY total_value DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(days)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_user_daily(
|
||||||
|
db: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
days: i32,
|
||||||
|
) -> 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.triggered_by = $1
|
||||||
|
AND r.created_at >= now() - make_interval(days := $2)
|
||||||
|
GROUP BY date_trunc('day', r.created_at), r.resource_type
|
||||||
|
ORDER BY day DESC, resource_type
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(days)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_graph_stats_for_user(db: &PgPool, user_id: Uuid) -> Result<GraphStats, sqlx::Error> {
|
||||||
|
let nodes: (i64,) = sqlx::query_as("SELECT COUNT(*)::BIGINT FROM nodes WHERE created_by = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let edges: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*)::BIGINT FROM edges WHERE created_by = $1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(GraphStats {
|
||||||
|
nodes_created: nodes.0,
|
||||||
|
edges_created: edges.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Spørringer — nodeforbruk
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
async fn fetch_node_by_type(
|
||||||
|
db: &PgPool,
|
||||||
|
node_id: Uuid,
|
||||||
|
days: i32,
|
||||||
|
) -> Result<Vec<ResourceTypeSummary>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ResourceTypeSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
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
|
||||||
|
WHERE r.target_node_id = $1
|
||||||
|
AND r.created_at >= now() - make_interval(days := $2)
|
||||||
|
GROUP BY r.resource_type
|
||||||
|
ORDER BY total_value DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(node_id)
|
||||||
|
.bind(days)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_node_daily(
|
||||||
|
db: &PgPool,
|
||||||
|
node_id: Uuid,
|
||||||
|
days: i32,
|
||||||
|
) -> 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.target_node_id = $1
|
||||||
|
AND r.created_at >= now() - make_interval(days := $2)
|
||||||
|
GROUP BY date_trunc('day', r.created_at), r.resource_type
|
||||||
|
ORDER BY day DESC, resource_type
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(node_id)
|
||||||
|
.bind(days)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -171,8 +171,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
- [x] 15.6 Serverhelse-dashboard: tjeneste-status (PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit), metrikker (CPU, minne, disk), backup-status, logg-tilgang.
|
- [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`.
|
- [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`.
|
||||||
- [x] 15.8 Forbruksoversikt i admin: aggregert visning per samling, per ressurstype, per tidsperiode. Drill-down til jobbtype og modellnivå.
|
- [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.
|
- [x] 15.9 Brukersynlig forbruk: hver bruker ser eget forbruk i profil/innstillinger. Per-node forbruk synlig i node-detaljer for eiere.
|
||||||
> Påbegynt: 2026-03-18T04:35
|
|
||||||
|
|
||||||
## Fase 16: Lydmixer
|
## Fase 16: Lydmixer
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue