Fullfører oppgave 15.4: AI Gateway admin-UI
Implementerer /admin/ai med fire faner: - Modeller & fallback: oversikt over aliaser med fallback-kjeder, toggle aktiv/inaktiv, legg til/fjern modeller, endre prioritet - Ruting: jobbtype → modellalias mapping med dropdown-endring - Forbruk: aggregert tokenforbruk per samling/alias/jobbtype - API-nøkler: viser hvilke env-variabler som er satt (aldri verdier) Backend (maskinrommet/src/ai_admin.rs): - GET /admin/ai — full oversikt med aliaser, providers, ruting, forbruk - GET /admin/ai/usage — forbruk med filtre (dager, samling) - POST-endepunkter for CRUD på aliaser, providers og ruting-regler PG er single source of truth. API-nøkler lagres kun som env-variabler, admin-panelet viser bare om de er satt eller mangler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6accce99e6
commit
3fbe42f207
6 changed files with 1321 additions and 2 deletions
|
|
@ -805,3 +805,143 @@ export function cancelJob(
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
return post(accessToken, '/intentions/cancel_job', { job_id: jobId });
|
return post(accessToken, '/intentions/cancel_job', { job_id: jobId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AI Gateway-konfigurasjon (oppgave 15.4)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface AiModelAlias {
|
||||||
|
id: string;
|
||||||
|
alias: string;
|
||||||
|
description: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiModelProvider {
|
||||||
|
id: string;
|
||||||
|
alias_id: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
api_key_env: string;
|
||||||
|
priority: number;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiJobRouting {
|
||||||
|
job_type: string;
|
||||||
|
alias: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiUsageSummary {
|
||||||
|
collection_node_id: string | null;
|
||||||
|
collection_title: string | null;
|
||||||
|
model_alias: string;
|
||||||
|
job_type: string | null;
|
||||||
|
total_prompt_tokens: number;
|
||||||
|
total_completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
estimated_cost: number;
|
||||||
|
call_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyStatus {
|
||||||
|
env_var: string;
|
||||||
|
is_set: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiOverviewResponse {
|
||||||
|
aliases: AiModelAlias[];
|
||||||
|
providers: AiModelProvider[];
|
||||||
|
routing: AiJobRouting[];
|
||||||
|
usage: AiUsageSummary[];
|
||||||
|
api_key_status: ApiKeyStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hent AI Gateway-oversikt (aliaser, providers, ruting, forbruk). */
|
||||||
|
export async function fetchAiOverview(accessToken: string): Promise<AiOverviewResponse> {
|
||||||
|
const res = await fetch(`${BASE_URL}/admin/ai`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`ai overview failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hent AI-forbruksoversikt med filtre. */
|
||||||
|
export async function fetchAiUsage(
|
||||||
|
accessToken: string,
|
||||||
|
params: { days?: number; collection_id?: string } = {}
|
||||||
|
): Promise<AiUsageSummary[]> {
|
||||||
|
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/ai/usage${qs ? `?${qs}` : ''}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`ai usage failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oppdater modellalias (beskrivelse, aktiv-status). */
|
||||||
|
export function updateAiAlias(
|
||||||
|
accessToken: string,
|
||||||
|
data: { id: string; description: string | null; is_active: boolean }
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return post(accessToken, '/admin/ai/update_alias', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opprett nytt modellalias. */
|
||||||
|
export function createAiAlias(
|
||||||
|
accessToken: string,
|
||||||
|
data: { alias: string; description: string | null }
|
||||||
|
): Promise<{ id: string; success: boolean }> {
|
||||||
|
return post(accessToken, '/admin/ai/create_alias', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oppdater provider (prioritet, aktiv-status). */
|
||||||
|
export function updateAiProvider(
|
||||||
|
accessToken: string,
|
||||||
|
data: { id: string; priority?: number; is_active?: boolean }
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return post(accessToken, '/admin/ai/update_provider', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legg til ny provider på et alias. */
|
||||||
|
export function createAiProvider(
|
||||||
|
accessToken: string,
|
||||||
|
data: { alias_id: string; provider: string; model: string; api_key_env: string; priority: number }
|
||||||
|
): Promise<{ id: string; success: boolean }> {
|
||||||
|
return post(accessToken, '/admin/ai/create_provider', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slett en provider. */
|
||||||
|
export function deleteAiProvider(
|
||||||
|
accessToken: string,
|
||||||
|
id: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return post(accessToken, '/admin/ai/delete_provider', { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oppdater eller opprett rutingregel (jobbtype → alias). */
|
||||||
|
export function updateAiRouting(
|
||||||
|
accessToken: string,
|
||||||
|
data: { job_type: string; alias: string; description: string | null }
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return post(accessToken, '/admin/ai/update_routing', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slett en rutingregel. */
|
||||||
|
export function deleteAiRouting(
|
||||||
|
accessToken: string,
|
||||||
|
jobType: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return post(accessToken, '/admin/ai/delete_routing', { job_type: jobType });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,9 @@
|
||||||
<a href="/admin/jobs" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
|
<a href="/admin/jobs" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
|
||||||
Jobbkø
|
Jobbkø
|
||||||
</a>
|
</a>
|
||||||
|
<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>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
693
frontend/src/routes/admin/ai/+page.svelte
Normal file
693
frontend/src/routes/admin/ai/+page.svelte
Normal file
|
|
@ -0,0 +1,693 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Admin — AI Gateway-konfigurasjon (oppgave 15.4)
|
||||||
|
*
|
||||||
|
* Modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype,
|
||||||
|
* fallback-kjeder, forbruksoversikt per samling.
|
||||||
|
* Ref: docs/infra/ai_gateway.md
|
||||||
|
*/
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import {
|
||||||
|
fetchAiOverview,
|
||||||
|
fetchAiUsage,
|
||||||
|
updateAiAlias,
|
||||||
|
createAiAlias,
|
||||||
|
updateAiProvider,
|
||||||
|
createAiProvider,
|
||||||
|
deleteAiProvider,
|
||||||
|
updateAiRouting,
|
||||||
|
deleteAiRouting,
|
||||||
|
type AiOverviewResponse,
|
||||||
|
type AiModelAlias,
|
||||||
|
type AiModelProvider,
|
||||||
|
type AiJobRouting,
|
||||||
|
type AiUsageSummary,
|
||||||
|
type ApiKeyStatus
|
||||||
|
} 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<AiOverviewResponse | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let actionLoading = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
let activeTab = $state<'aliases' | 'routing' | 'usage' | 'keys'>('aliases');
|
||||||
|
|
||||||
|
// Forbruksperiode
|
||||||
|
let usageDays = $state(30);
|
||||||
|
|
||||||
|
// Nytt alias-skjema
|
||||||
|
let showNewAlias = $state(false);
|
||||||
|
let newAliasName = $state('');
|
||||||
|
let newAliasDesc = $state('');
|
||||||
|
|
||||||
|
// Ny provider-skjema
|
||||||
|
let showNewProvider = $state<string | null>(null); // alias_id
|
||||||
|
let newProviderName = $state('');
|
||||||
|
let newProviderModel = $state('');
|
||||||
|
let newProviderKeyEnv = $state('');
|
||||||
|
let newProviderPriority = $state(10);
|
||||||
|
|
||||||
|
// Ny ruting-skjema
|
||||||
|
let showNewRouting = $state(false);
|
||||||
|
let newRoutingJobType = $state('');
|
||||||
|
let newRoutingAlias = $state('');
|
||||||
|
let newRoutingDesc = $state('');
|
||||||
|
|
||||||
|
// Poll hvert 10. sekund (AI-config endres sjelden)
|
||||||
|
$effect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
data = await fetchAiOverview(accessToken);
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hjelpefunksjoner
|
||||||
|
function providersForAlias(alias: AiModelAlias): AiModelProvider[] {
|
||||||
|
if (!data) return [];
|
||||||
|
return data.providers
|
||||||
|
.filter((p) => p.alias_id === alias.id)
|
||||||
|
.sort((a, b) => a.priority - b.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyStatus(envVar: string): boolean {
|
||||||
|
if (!data) return false;
|
||||||
|
return data.api_key_status.find((k) => k.env_var === envVar)?.is_set ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(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 String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(cost: number): string {
|
||||||
|
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
||||||
|
return `$${cost.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlinger
|
||||||
|
async function toggleAlias(alias: AiModelAlias) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
actionLoading = `alias-${alias.id}`;
|
||||||
|
try {
|
||||||
|
await updateAiAlias(accessToken, {
|
||||||
|
id: alias.id,
|
||||||
|
description: alias.description,
|
||||||
|
is_active: !alias.is_active
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleProvider(provider: AiModelProvider) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
actionLoading = `provider-${provider.id}`;
|
||||||
|
try {
|
||||||
|
await updateAiProvider(accessToken, {
|
||||||
|
id: provider.id,
|
||||||
|
is_active: !provider.is_active
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeProviderPriority(provider: AiModelProvider, newPriority: number) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
actionLoading = `priority-${provider.id}`;
|
||||||
|
try {
|
||||||
|
await updateAiProvider(accessToken, {
|
||||||
|
id: provider.id,
|
||||||
|
priority: newPriority
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProvider(provider: AiModelProvider) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
actionLoading = `delete-${provider.id}`;
|
||||||
|
try {
|
||||||
|
await deleteAiProvider(accessToken, provider.id);
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateAlias() {
|
||||||
|
if (!accessToken || !newAliasName.trim()) return;
|
||||||
|
actionLoading = 'create-alias';
|
||||||
|
try {
|
||||||
|
await createAiAlias(accessToken, {
|
||||||
|
alias: newAliasName.trim(),
|
||||||
|
description: newAliasDesc.trim() || null
|
||||||
|
});
|
||||||
|
newAliasName = '';
|
||||||
|
newAliasDesc = '';
|
||||||
|
showNewAlias = false;
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateProvider() {
|
||||||
|
if (!accessToken || !showNewProvider || !newProviderModel.trim()) return;
|
||||||
|
actionLoading = 'create-provider';
|
||||||
|
try {
|
||||||
|
await createAiProvider(accessToken, {
|
||||||
|
alias_id: showNewProvider,
|
||||||
|
provider: newProviderName.trim(),
|
||||||
|
model: newProviderModel.trim(),
|
||||||
|
api_key_env: newProviderKeyEnv.trim(),
|
||||||
|
priority: newProviderPriority
|
||||||
|
});
|
||||||
|
newProviderName = '';
|
||||||
|
newProviderModel = '';
|
||||||
|
newProviderKeyEnv = '';
|
||||||
|
newProviderPriority = 10;
|
||||||
|
showNewProvider = null;
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateRouting(routing: AiJobRouting, newAlias: string) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
actionLoading = `routing-${routing.job_type}`;
|
||||||
|
try {
|
||||||
|
await updateAiRouting(accessToken, {
|
||||||
|
job_type: routing.job_type,
|
||||||
|
alias: newAlias,
|
||||||
|
description: routing.description
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateRouting() {
|
||||||
|
if (!accessToken || !newRoutingJobType.trim() || !newRoutingAlias.trim()) return;
|
||||||
|
actionLoading = 'create-routing';
|
||||||
|
try {
|
||||||
|
await updateAiRouting(accessToken, {
|
||||||
|
job_type: newRoutingJobType.trim(),
|
||||||
|
alias: newRoutingAlias.trim(),
|
||||||
|
description: newRoutingDesc.trim() || null
|
||||||
|
});
|
||||||
|
newRoutingJobType = '';
|
||||||
|
newRoutingAlias = '';
|
||||||
|
newRoutingDesc = '';
|
||||||
|
showNewRouting = false;
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRouting(jobType: string) {
|
||||||
|
if (!accessToken) return;
|
||||||
|
actionLoading = `delete-routing-${jobType}`;
|
||||||
|
try {
|
||||||
|
await deleteAiRouting(accessToken, jobType);
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<header class="border-b border-gray-200 bg-white">
|
||||||
|
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/admin" class="text-sm text-gray-500 hover:text-gray-700">Admin</a>
|
||||||
|
<span class="text-gray-300">/</span>
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900">AI Gateway</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-5xl px-4 py-6">
|
||||||
|
{#if !accessToken}
|
||||||
|
<p class="text-sm text-gray-400">Logg inn for tilgang.</p>
|
||||||
|
{:else if !data}
|
||||||
|
<p class="text-sm text-gray-400">Laster AI-konfigurasjon...</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Feilmelding -->
|
||||||
|
{#if error}
|
||||||
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="mb-6 flex gap-1 rounded-lg bg-gray-100 p-1">
|
||||||
|
{#each [
|
||||||
|
{ key: 'aliases', label: 'Modeller & fallback' },
|
||||||
|
{ key: 'routing', label: 'Ruting' },
|
||||||
|
{ key: 'usage', label: 'Forbruk' },
|
||||||
|
{ key: 'keys', label: 'API-nøkler' }
|
||||||
|
] as tab}
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {activeTab === tab.key ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-800'}"
|
||||||
|
onclick={() => (activeTab = tab.key as typeof activeTab)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === TAB: Modeller & fallback === -->
|
||||||
|
{#if activeTab === 'aliases'}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each data.aliases as alias}
|
||||||
|
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
|
<!-- Alias header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="h-2 w-2 rounded-full {alias.is_active ? 'bg-green-500' : 'bg-gray-300'}"
|
||||||
|
></span>
|
||||||
|
<span class="font-mono text-sm font-semibold text-gray-900"
|
||||||
|
>{alias.alias}</span
|
||||||
|
>
|
||||||
|
{#if alias.description}
|
||||||
|
<span class="text-xs text-gray-500">— {alias.description}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-1 text-xs {alias.is_active ? 'text-amber-600 hover:bg-amber-50' : 'text-green-600 hover:bg-green-50'}"
|
||||||
|
disabled={actionLoading === `alias-${alias.id}`}
|
||||||
|
onclick={() => toggleAlias(alias)}
|
||||||
|
>
|
||||||
|
{alias.is_active ? 'Deaktiver' : 'Aktiver'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Providers (fallback-kjede) -->
|
||||||
|
<div class="px-5 py-3">
|
||||||
|
<div class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||||
|
Fallback-kjede (prioritet)
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each providersForAlias(alias) as provider, idx}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 rounded-md border px-3 py-2 text-sm {provider.is_active ? 'border-gray-200 bg-gray-50' : 'border-gray-100 bg-gray-50/50 opacity-60'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-xs font-medium text-gray-600"
|
||||||
|
>{provider.priority}</span
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="font-mono text-xs text-gray-800">{provider.model}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400">({provider.provider})</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 text-xs {keyStatus(provider.api_key_env) ? 'text-green-600' : 'text-red-500'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full {keyStatus(provider.api_key_env) ? 'bg-green-500' : 'bg-red-400'}"
|
||||||
|
></span>
|
||||||
|
{provider.api_key_env}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Prioritet-knapper -->
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#if idx > 0}
|
||||||
|
<button
|
||||||
|
class="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-200"
|
||||||
|
title="Flytt opp"
|
||||||
|
onclick={() => changeProviderPriority(provider, provider.priority - 1)}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if idx < providersForAlias(alias).length - 1}
|
||||||
|
<button
|
||||||
|
class="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-200"
|
||||||
|
title="Flytt ned"
|
||||||
|
onclick={() => changeProviderPriority(provider, provider.priority + 1)}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-0.5 text-xs {provider.is_active ? 'text-amber-600 hover:bg-amber-50' : 'text-green-600 hover:bg-green-50'}"
|
||||||
|
onclick={() => toggleProvider(provider)}
|
||||||
|
disabled={actionLoading === `provider-${provider.id}`}
|
||||||
|
>
|
||||||
|
{provider.is_active ? 'Av' : 'På'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-0.5 text-xs text-red-500 hover:bg-red-50"
|
||||||
|
onclick={() => removeProvider(provider)}
|
||||||
|
disabled={actionLoading === `delete-${provider.id}`}
|
||||||
|
>
|
||||||
|
Slett
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legg til provider -->
|
||||||
|
{#if showNewProvider === alias.id}
|
||||||
|
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||||
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<input
|
||||||
|
bind:value={newProviderName}
|
||||||
|
placeholder="Leverandør (f.eks. gemini)"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newProviderModel}
|
||||||
|
placeholder="Modell (f.eks. gemini/gemini-2.5-flash)"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newProviderKeyEnv}
|
||||||
|
placeholder="Env-variabel (f.eks. GEMINI_API_KEY)"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newProviderPriority}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
placeholder="Prioritet"
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
onclick={handleCreateProvider}
|
||||||
|
disabled={actionLoading === 'create-provider' || !newProviderModel.trim()}
|
||||||
|
>
|
||||||
|
Legg til
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => (showNewProvider = null)}
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="mt-2 text-xs text-blue-600 hover:text-blue-800"
|
||||||
|
onclick={() => (showNewProvider = alias.id)}
|
||||||
|
>
|
||||||
|
+ Legg til modell
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Nytt alias -->
|
||||||
|
{#if showNewAlias}
|
||||||
|
<section class="rounded-lg border border-blue-200 bg-blue-50 p-5 shadow-sm">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-gray-800">Nytt modellalias</h3>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<input
|
||||||
|
bind:value={newAliasName}
|
||||||
|
placeholder="Alias (f.eks. sidelinja/lokal)"
|
||||||
|
class="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newAliasDesc}
|
||||||
|
placeholder="Beskrivelse"
|
||||||
|
class="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
onclick={handleCreateAlias}
|
||||||
|
disabled={actionLoading === 'create-alias' || !newAliasName.trim()}
|
||||||
|
>
|
||||||
|
Opprett
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-4 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => (showNewAlias = false)}
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-dashed border-gray-300 px-4 py-2 text-sm text-gray-500 hover:border-gray-400 hover:text-gray-700"
|
||||||
|
onclick={() => (showNewAlias = true)}
|
||||||
|
>
|
||||||
|
+ Nytt modellalias
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === TAB: Ruting === -->
|
||||||
|
{:else if activeTab === 'routing'}
|
||||||
|
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div class="border-b border-gray-100 px-5 py-3">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-800">Jobbtype → Modellalias</h2>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Hvilken modellalias brukes for hvilken jobbtype i jobbkøen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{#each data.routing as routing}
|
||||||
|
<div class="flex items-center gap-3 px-5 py-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="font-mono text-sm text-gray-800">{routing.job_type}</span>
|
||||||
|
{#if routing.description}
|
||||||
|
<span class="ml-2 text-xs text-gray-400">{routing.description}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
value={routing.alias}
|
||||||
|
onchange={(e) => handleUpdateRouting(routing, (e.target as HTMLSelectElement).value)}
|
||||||
|
disabled={actionLoading === `routing-${routing.job_type}`}
|
||||||
|
>
|
||||||
|
{#each data.aliases as alias}
|
||||||
|
<option value={alias.alias}>{alias.alias}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
||||||
|
onclick={() => handleDeleteRouting(routing.job_type)}
|
||||||
|
disabled={actionLoading === `delete-routing-${routing.job_type}`}
|
||||||
|
>
|
||||||
|
Slett
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if data.routing.length === 0}
|
||||||
|
<p class="px-5 py-4 text-sm text-gray-400">Ingen ruting-regler konfigurert.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ny ruting -->
|
||||||
|
{#if showNewRouting}
|
||||||
|
<div class="border-t border-blue-200 bg-blue-50 px-5 py-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
bind:value={newRoutingJobType}
|
||||||
|
placeholder="Jobbtype (f.eks. ai_text_process)"
|
||||||
|
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={newRoutingAlias}
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Velg alias...</option>
|
||||||
|
{#each data.aliases as alias}
|
||||||
|
<option value={alias.alias}>{alias.alias}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
bind:value={newRoutingDesc}
|
||||||
|
placeholder="Beskrivelse (valgfri)"
|
||||||
|
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
onclick={handleCreateRouting}
|
||||||
|
disabled={actionLoading === 'create-routing' ||
|
||||||
|
!newRoutingJobType.trim() ||
|
||||||
|
!newRoutingAlias.trim()}
|
||||||
|
>
|
||||||
|
Legg til
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||||
|
onclick={() => (showNewRouting = false)}
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="border-t border-gray-100 px-5 py-3">
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-800"
|
||||||
|
onclick={() => (showNewRouting = true)}
|
||||||
|
>
|
||||||
|
+ Ny rutingregel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- === TAB: Forbruk === -->
|
||||||
|
{:else if activeTab === 'usage'}
|
||||||
|
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-3">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-800">Forbruksoversikt</h2>
|
||||||
|
<select
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
bind:value={usageDays}
|
||||||
|
>
|
||||||
|
<option value={7}>Siste 7 dager</option>
|
||||||
|
<option value={30}>Siste 30 dager</option>
|
||||||
|
<option value={90}>Siste 90 dager</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.usage.length === 0}
|
||||||
|
<p class="px-5 py-8 text-center text-sm text-gray-400">Ingen AI-forbruk registrert.</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Totaler -->
|
||||||
|
{@const totalTokens = data.usage.reduce((s, u) => s + u.total_tokens, 0)}
|
||||||
|
{@const totalCost = data.usage.reduce((s, u) => s + u.estimated_cost, 0)}
|
||||||
|
{@const totalCalls = data.usage.reduce((s, u) => s + u.call_count, 0)}
|
||||||
|
<div class="grid grid-cols-3 gap-3 border-b border-gray-100 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Totalt tokens</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900">{formatTokens(totalTokens)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Estimert kostnad</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900">{formatCost(totalCost)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Antall kall</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900">{totalCalls}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabell -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-100 bg-gray-50 text-left text-xs text-gray-500">
|
||||||
|
<th class="px-5 py-2">Samling</th>
|
||||||
|
<th class="px-3 py-2">Alias</th>
|
||||||
|
<th class="px-3 py-2">Jobbtype</th>
|
||||||
|
<th class="px-3 py-2 text-right">Tokens</th>
|
||||||
|
<th class="px-3 py-2 text-right">Kostnad</th>
|
||||||
|
<th class="px-3 py-2 text-right">Kall</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
{#each data.usage as row}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-5 py-2 text-gray-700">
|
||||||
|
{row.collection_title ?? '(ingen samling)'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 font-mono text-xs text-gray-600">
|
||||||
|
{row.model_alias}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 font-mono text-xs text-gray-600">
|
||||||
|
{row.job_type ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right text-gray-700">
|
||||||
|
{formatTokens(row.total_tokens)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right text-gray-700">
|
||||||
|
{formatCost(row.estimated_cost)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right text-gray-700">{row.call_count}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- === TAB: API-nøkler === -->
|
||||||
|
{:else if activeTab === 'keys'}
|
||||||
|
<section class="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div class="border-b border-gray-100 px-5 py-3">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-800">API-nøkler</h2>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Nøklene er lagret som miljøvariabler på serveren. Verdier vises aldri i admin-panelet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{#each data.api_key_status as key}
|
||||||
|
<div class="flex items-center gap-3 px-5 py-3">
|
||||||
|
<span
|
||||||
|
class="h-2 w-2 rounded-full {key.is_set ? 'bg-green-500' : 'bg-red-400'}"
|
||||||
|
></span>
|
||||||
|
<span class="font-mono text-sm text-gray-800">{key.env_var}</span>
|
||||||
|
<span class="text-xs {key.is_set ? 'text-green-600' : 'text-red-500'}">
|
||||||
|
{key.is_set ? 'Satt' : 'Mangler'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if data.api_key_status.length === 0}
|
||||||
|
<p class="px-5 py-4 text-sm text-gray-400">Ingen API-nøkler konfigurert.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-100 px-5 py-3">
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
For å endre API-nøkler, oppdater <code class="rounded bg-gray-100 px-1">/srv/synops/.env</code> og restart maskinrommet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
473
maskinrommet/src/ai_admin.rs
Normal file
473
maskinrommet/src/ai_admin.rs
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
// AI Gateway-administrasjon (oppgave 15.4)
|
||||||
|
//
|
||||||
|
// Admin-API for modelloversikt, ruting-regler, fallback-kjeder og forbruksoversikt.
|
||||||
|
// PG er single source of truth — LiteLLM er stateløs proxy.
|
||||||
|
//
|
||||||
|
// Ref: docs/infra/ai_gateway.md
|
||||||
|
|
||||||
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Datatyper
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AiModelAlias {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub alias: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AiModelProvider {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub alias_id: Uuid,
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub priority: i16,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AiJobRouting {
|
||||||
|
pub job_type: String,
|
||||||
|
pub alias: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AiUsageSummary {
|
||||||
|
pub collection_node_id: Option<Uuid>,
|
||||||
|
pub collection_title: Option<String>,
|
||||||
|
pub model_alias: String,
|
||||||
|
pub job_type: Option<String>,
|
||||||
|
pub total_prompt_tokens: i64,
|
||||||
|
pub total_completion_tokens: i64,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub estimated_cost: f64,
|
||||||
|
pub call_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ApiKeyStatus {
|
||||||
|
pub env_var: String,
|
||||||
|
pub is_set: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AiOverviewResponse {
|
||||||
|
pub aliases: Vec<AiModelAlias>,
|
||||||
|
pub providers: Vec<AiModelProvider>,
|
||||||
|
pub routing: Vec<AiJobRouting>,
|
||||||
|
pub usage: Vec<AiUsageSummary>,
|
||||||
|
pub api_key_status: Vec<ApiKeyStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /admin/ai — oversikt
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
pub async fn ai_overview(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
) -> Result<Json<AiOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let aliases = sqlx::query_as::<_, AiModelAlias>(
|
||||||
|
"SELECT id, alias, description, is_active, created_at FROM ai_model_aliases ORDER BY alias"
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved henting av aliaser: {e}")))?;
|
||||||
|
|
||||||
|
let providers = sqlx::query_as::<_, AiModelProvider>(
|
||||||
|
"SELECT id, alias_id, provider, model, api_key_env, priority, is_active
|
||||||
|
FROM ai_model_providers ORDER BY alias_id, priority"
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved henting av providers: {e}")))?;
|
||||||
|
|
||||||
|
let routing = sqlx::query_as::<_, AiJobRouting>(
|
||||||
|
"SELECT job_type, alias, description FROM ai_job_routing ORDER BY job_type"
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved henting av ruting: {e}")))?;
|
||||||
|
|
||||||
|
let usage = fetch_usage_summary(&state.db, 30).await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved henting av forbruk: {e}")))?;
|
||||||
|
|
||||||
|
// Sjekk hvilke API-nøkler som er satt i miljøet
|
||||||
|
let env_vars: Vec<String> = providers.iter()
|
||||||
|
.map(|p| p.api_key_env.clone())
|
||||||
|
.collect::<std::collections::HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let mut api_key_status: Vec<ApiKeyStatus> = env_vars.into_iter().map(|env_var| {
|
||||||
|
let is_set = std::env::var(&env_var).map(|v| !v.is_empty()).unwrap_or(false);
|
||||||
|
ApiKeyStatus { env_var, is_set }
|
||||||
|
}).collect();
|
||||||
|
api_key_status.sort_by(|a, b| a.env_var.cmp(&b.env_var));
|
||||||
|
|
||||||
|
Ok(Json(AiOverviewResponse {
|
||||||
|
aliases,
|
||||||
|
providers,
|
||||||
|
routing,
|
||||||
|
usage,
|
||||||
|
api_key_status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_usage_summary(db: &PgPool, days: i32) -> Result<Vec<AiUsageSummary>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, AiUsageSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.collection_node_id,
|
||||||
|
n.title AS collection_title,
|
||||||
|
u.model_alias,
|
||||||
|
u.job_type,
|
||||||
|
COALESCE(SUM(u.prompt_tokens), 0)::BIGINT AS total_prompt_tokens,
|
||||||
|
COALESCE(SUM(u.completion_tokens), 0)::BIGINT AS total_completion_tokens,
|
||||||
|
COALESCE(SUM(u.total_tokens), 0)::BIGINT AS total_tokens,
|
||||||
|
COALESCE(SUM(u.estimated_cost)::FLOAT8, 0.0) AS estimated_cost,
|
||||||
|
COUNT(*)::BIGINT AS call_count
|
||||||
|
FROM ai_usage_log u
|
||||||
|
LEFT JOIN nodes n ON n.id = u.collection_node_id
|
||||||
|
WHERE u.created_at >= now() - make_interval(days := $1)
|
||||||
|
GROUP BY u.collection_node_id, n.title, u.model_alias, u.job_type
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(days)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/ai/update_alias — oppdater alias
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateAliasRequest {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_alias(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<UpdateAliasRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"UPDATE ai_model_aliases SET description = $1, is_active = $2 WHERE id = $3"
|
||||||
|
)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(req.is_active)
|
||||||
|
.bind(req.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved oppdatering av alias: {e}")))?;
|
||||||
|
|
||||||
|
if rows.rows_affected() == 0 {
|
||||||
|
return Err(bad_request("Alias ikke funnet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(alias_id = %req.id, user = %_user.node_id, "Admin: alias oppdatert");
|
||||||
|
Ok(Json(serde_json::json!({ "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/ai/create_alias — opprett nytt alias
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateAliasRequest {
|
||||||
|
pub alias: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_alias(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<CreateAliasRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
if req.alias.trim().is_empty() {
|
||||||
|
return Err(bad_request("Alias-navn kan ikke være tomt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
"INSERT INTO ai_model_aliases (alias, description) VALUES ($1, $2) RETURNING id"
|
||||||
|
)
|
||||||
|
.bind(&req.alias)
|
||||||
|
.bind(&req.description)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("unique") || e.to_string().contains("duplicate") {
|
||||||
|
bad_request(&format!("Alias '{}' finnes allerede", req.alias))
|
||||||
|
} else {
|
||||||
|
internal_error(&format!("Feil ved opprettelse av alias: {e}"))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(alias = %req.alias, user = %_user.node_id, "Admin: alias opprettet");
|
||||||
|
Ok(Json(serde_json::json!({ "id": id, "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/ai/update_provider — oppdater provider
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateProviderRequest {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub priority: Option<i16>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_provider(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<UpdateProviderRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
if let Some(priority) = req.priority {
|
||||||
|
sqlx::query("UPDATE ai_model_providers SET priority = $1 WHERE id = $2")
|
||||||
|
.bind(priority)
|
||||||
|
.bind(req.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved oppdatering av provider priority: {e}")))?;
|
||||||
|
}
|
||||||
|
if let Some(is_active) = req.is_active {
|
||||||
|
sqlx::query("UPDATE ai_model_providers SET is_active = $1 WHERE id = $2")
|
||||||
|
.bind(is_active)
|
||||||
|
.bind(req.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved oppdatering av provider status: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider oppdatert");
|
||||||
|
Ok(Json(serde_json::json!({ "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/ai/create_provider — legg til ny provider
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateProviderRequest {
|
||||||
|
pub alias_id: Uuid,
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub priority: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_provider(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<CreateProviderRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
if req.model.trim().is_empty() {
|
||||||
|
return Err(bad_request("Modellnavn kan ikke være tomt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
"INSERT INTO ai_model_providers (alias_id, provider, model, api_key_env, priority)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id"
|
||||||
|
)
|
||||||
|
.bind(req.alias_id)
|
||||||
|
.bind(&req.provider)
|
||||||
|
.bind(&req.model)
|
||||||
|
.bind(&req.api_key_env)
|
||||||
|
.bind(req.priority)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("unique") || e.to_string().contains("duplicate") {
|
||||||
|
bad_request("Denne modellen finnes allerede for dette aliaset")
|
||||||
|
} else if e.to_string().contains("foreign key") {
|
||||||
|
bad_request("Alias-ID finnes ikke")
|
||||||
|
} else {
|
||||||
|
internal_error(&format!("Feil ved opprettelse av provider: {e}"))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(model = %req.model, alias_id = %req.alias_id, user = %_user.node_id, "Admin: provider opprettet");
|
||||||
|
Ok(Json(serde_json::json!({ "id": id, "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/ai/delete_provider — fjern provider
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteProviderRequest {
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_provider(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<DeleteProviderRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rows = sqlx::query("DELETE FROM ai_model_providers WHERE id = $1")
|
||||||
|
.bind(req.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved sletting av provider: {e}")))?;
|
||||||
|
|
||||||
|
if rows.rows_affected() == 0 {
|
||||||
|
return Err(bad_request("Provider ikke funnet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider slettet");
|
||||||
|
Ok(Json(serde_json::json!({ "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/ai/update_routing — oppdater eller opprett rutingregel
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateRoutingRequest {
|
||||||
|
pub job_type: String,
|
||||||
|
pub alias: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_routing(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<UpdateRoutingRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
if req.job_type.trim().is_empty() || req.alias.trim().is_empty() {
|
||||||
|
return Err(bad_request("Jobbtype og alias kan ikke være tomme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ai_job_routing (job_type, alias, description)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (job_type) DO UPDATE SET alias = $2, description = $3"
|
||||||
|
)
|
||||||
|
.bind(&req.job_type)
|
||||||
|
.bind(&req.alias)
|
||||||
|
.bind(&req.description)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved oppdatering av ruting: {e}")))?;
|
||||||
|
|
||||||
|
tracing::info!(job_type = %req.job_type, alias = %req.alias, user = %_user.node_id, "Admin: ruting oppdatert");
|
||||||
|
Ok(Json(serde_json::json!({ "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /admin/ai/delete_routing — slett rutingregel
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteRoutingRequest {
|
||||||
|
pub job_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_routing(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<DeleteRoutingRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rows = sqlx::query("DELETE FROM ai_job_routing WHERE job_type = $1")
|
||||||
|
.bind(&req.job_type)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved sletting av ruting: {e}")))?;
|
||||||
|
|
||||||
|
if rows.rows_affected() == 0 {
|
||||||
|
return Err(bad_request("Rutingregel ikke funnet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(job_type = %req.job_type, user = %_user.node_id, "Admin: ruting slettet");
|
||||||
|
Ok(Json(serde_json::json!({ "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /admin/ai/usage — detaljert forbruksoversikt med filtre
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UsageQueryParams {
|
||||||
|
pub days: Option<i32>,
|
||||||
|
pub collection_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ai_usage(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<UsageQueryParams>,
|
||||||
|
) -> Result<Json<Vec<AiUsageSummary>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let days = params.days.unwrap_or(30).min(365);
|
||||||
|
|
||||||
|
let usage = if let Some(collection_id) = params.collection_id {
|
||||||
|
fetch_usage_for_collection(&state.db, collection_id, days).await
|
||||||
|
} else {
|
||||||
|
fetch_usage_summary(&state.db, days).await
|
||||||
|
};
|
||||||
|
|
||||||
|
usage
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved henting av forbruk: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_usage_for_collection(
|
||||||
|
db: &PgPool,
|
||||||
|
collection_id: Uuid,
|
||||||
|
days: i32,
|
||||||
|
) -> Result<Vec<AiUsageSummary>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, AiUsageSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.collection_node_id,
|
||||||
|
n.title AS collection_title,
|
||||||
|
u.model_alias,
|
||||||
|
u.job_type,
|
||||||
|
COALESCE(SUM(u.prompt_tokens), 0)::BIGINT AS total_prompt_tokens,
|
||||||
|
COALESCE(SUM(u.completion_tokens), 0)::BIGINT AS total_completion_tokens,
|
||||||
|
COALESCE(SUM(u.total_tokens), 0)::BIGINT AS total_tokens,
|
||||||
|
COALESCE(SUM(u.estimated_cost)::FLOAT8, 0.0) AS estimated_cost,
|
||||||
|
COUNT(*)::BIGINT AS call_count
|
||||||
|
FROM ai_usage_log u
|
||||||
|
LEFT JOIN nodes n ON n.id = u.collection_node_id
|
||||||
|
WHERE u.created_at >= now() - make_interval(days := $1)
|
||||||
|
AND u.collection_node_id = $2
|
||||||
|
GROUP BY u.collection_node_id, n.title, u.model_alias, u.job_type
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(days)
|
||||||
|
.bind(collection_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod ai_admin;
|
||||||
pub mod ai_edges;
|
pub mod ai_edges;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
|
@ -203,6 +204,16 @@ async fn main() {
|
||||||
.route("/admin/jobs", get(intentions::list_jobs))
|
.route("/admin/jobs", get(intentions::list_jobs))
|
||||||
.route("/intentions/retry_job", post(intentions::retry_job))
|
.route("/intentions/retry_job", post(intentions::retry_job))
|
||||||
.route("/intentions/cancel_job", post(intentions::cancel_job))
|
.route("/intentions/cancel_job", post(intentions::cancel_job))
|
||||||
|
// AI Gateway-konfigurasjon (oppgave 15.4)
|
||||||
|
.route("/admin/ai", get(ai_admin::ai_overview))
|
||||||
|
.route("/admin/ai/usage", get(ai_admin::ai_usage))
|
||||||
|
.route("/admin/ai/update_alias", post(ai_admin::update_alias))
|
||||||
|
.route("/admin/ai/create_alias", post(ai_admin::create_alias))
|
||||||
|
.route("/admin/ai/update_provider", post(ai_admin::update_provider))
|
||||||
|
.route("/admin/ai/create_provider", post(ai_admin::create_provider))
|
||||||
|
.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))
|
||||||
.route("/query/audio_info", get(intentions::audio_info))
|
.route("/query/audio_info", get(intentions::audio_info))
|
||||||
.route("/pub/{slug}/feed.xml", get(rss::generate_feed))
|
.route("/pub/{slug}/feed.xml", get(rss::generate_feed))
|
||||||
.route("/pub/{slug}", get(publishing::serve_index))
|
.route("/pub/{slug}", get(publishing::serve_index))
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -166,8 +166,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
- [x] 15.1 Systemvarsler: varslingsnode (`node_kind='system_announcement'`) med type (info/warning/critical), nedtelling og utløp. Frontend viser banner/toast for alle aktive klienter via STDB. Ref: `docs/concepts/adminpanelet.md`.
|
- [x] 15.1 Systemvarsler: varslingsnode (`node_kind='system_announcement'`) med type (info/warning/critical), nedtelling og utløp. Frontend viser banner/toast for alle aktive klienter via STDB. Ref: `docs/concepts/adminpanelet.md`.
|
||||||
- [x] 15.2 Graceful shutdown: admin setter vedlikeholdstidspunkt → nedtelling i frontend → nye LiveKit-rom blokkeres → jobbkø stopper → vent på aktive jobber → restart. Vis aktive sesjoner før bekreftelse.
|
- [x] 15.2 Graceful shutdown: admin setter vedlikeholdstidspunkt → nedtelling i frontend → nye LiveKit-rom blokkeres → jobbkø stopper → vent på aktive jobber → restart. Vis aktive sesjoner før bekreftelse.
|
||||||
- [x] 15.3 Jobbkø-oversikt: admin-UI for aktive, ventende og feilede jobber. Filtrer på type/samling/status. Manuell retry og avbryt.
|
- [x] 15.3 Jobbkø-oversikt: admin-UI for aktive, ventende og feilede jobber. Filtrer på type/samling/status. Manuell retry og avbryt.
|
||||||
- [~] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`.
|
- [x] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`.
|
||||||
> Påbegynt: 2026-03-18T03:42
|
|
||||||
- [ ] 15.5 Ressursstyring: prioritetsregler mellom jobbtyper, ressursgrenser per worker, ressurs-governor for automatisk nedprioritering under aktive LiveKit-sesjoner, disk-status med varsling.
|
- [ ] 15.5 Ressursstyring: prioritetsregler mellom jobbtyper, ressursgrenser per worker, ressurs-governor for automatisk nedprioritering under aktive LiveKit-sesjoner, disk-status med varsling.
|
||||||
- [ ] 15.6 Serverhelse-dashboard: tjeneste-status (PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit), metrikker (CPU, minne, disk), backup-status, logg-tilgang.
|
- [ ] 15.6 Serverhelse-dashboard: tjeneste-status (PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit), metrikker (CPU, minne, disk), backup-status, logg-tilgang.
|
||||||
- [ ] 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.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`.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue