Initial commit: LlamaLink Go rewrite

Complete rewrite from Python/FastAPI to Go/Gin:
- Go backend: auth (API keys + bcrypt), llama.cpp subprocess manager,
  hot-swap multi-model, rate limiting, quota system, webhooks
- Vue 3 SPA admin panel (src/) with Tailwind CSS
- Deployment: Docker multi-stage, docker-compose, nginx, systemd
- GORM/SQLite models: ApiKey, Model, UsageLog, Quota, Webhook
- REST API: /api/v1/admin/* (keys, models, chat, usage, health)
- Embedded frontend via go:embed (build output at web/dist/)

Removed legacy Python artifacts (app/, tests/, pyproject.toml, etc.)
This commit is contained in:
2026-07-30 10:58:55 -04:00
commit 4c9ed3c24b
52 changed files with 5105 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LlamaLink Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body class="bg-background text-text">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{
"name": "llamalink-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"axios": "^1.7.0",
"zod": "^3.23.0",
"@vueuse/core": "^12.0.0",
"chart.js": "^4.4.0",
"vue-chartjs": "^5.3.0",
"lucide-vue-next": "^0.460.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"vite": "^6.0.0",
"vue-tsc": "^2.2.0",
"typescript": "~5.6.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"@nuxtjs/tailwindcss": "^8.0.0",
"@types/node": "^22.0.0",
"eslint": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint-plugin-vue": "^9.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+20
View File
@@ -0,0 +1,20 @@
import axios from 'axios'
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('admin_token')
window.location.href = '/admin/login'
}
return Promise.reject(error)
}
)
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+61
View File
@@ -0,0 +1,61 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/admin/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { guest: true },
},
{
path: '/admin/',
component: () => import('@/views/Layout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
},
{
path: 'keys',
name: 'ApiKeys',
component: () => import('@/views/ApiKeys.vue'),
},
{
path: 'models',
name: 'Models',
component: () => import('@/views/Models.vue'),
},
{
path: 'usage',
name: 'Usage',
component: () => import('@/views/Usage.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/admin/',
},
]
const router = createRouter({
history: createWebHistory('/admin'),
routes,
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'Login' })
} else if (to.meta.guest && authStore.isAuthenticated) {
next({ name: 'Dashboard' })
} else {
next()
}
})
export default router
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/lib/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('admin_token'))
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
async function login(adminToken: string) {
loading.value = true
error.value = null
try {
const response = await api.post('/api/v1/admin/login', { admin_token: adminToken })
token.value = response.data.token
localStorage.setItem('admin_token', response.data.token)
api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`
return true
} catch (err: any) {
error.value = err.response?.data?.error?.message || 'Login failed'
return false
} finally {
loading.value = false
}
}
function logout() {
token.value = null
localStorage.removeItem('admin_token')
delete api.defaults.headers.common['Authorization']
}
function init() {
if (token.value) {
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
}
}
return { token, loading, error, isAuthenticated, login, logout, init }
})
+108
View File
@@ -0,0 +1,108 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: 'Inter', system-ui, sans-serif;
}
body {
background-color: #0d1117;
color: #e6edf3;
min-height: 100vh;
}
code, pre {
font-family: 'JetBrains Mono', Consolas, monospace;
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}
.btn-primary {
@apply bg-primary hover:bg-primary-hover text-white;
}
.btn-secondary {
@apply bg-surface border border-border hover:bg-border text-text;
}
.btn-danger {
@apply bg-error hover:bg-red-600 text-white;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.input {
@apply w-full px-3 py-2 bg-surface border border-border rounded-lg text-text placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent;
}
.card {
@apply bg-surface border border-border rounded-xl p-6;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-success/15 text-success;
}
.badge-warning {
@apply bg-warning/15 text-warning;
}
.badge-error {
@apply bg-error/15 text-error;
}
.badge-info {
@apply bg-primary/15 text-primary;
}
.table {
@apply w-full text-left;
}
.table th {
@apply px-4 py-3 text-xs font-medium text-text-muted uppercase tracking-wider border-b border-border;
}
.table td {
@apply px-4 py-3 border-b border-border;
}
.table tr:hover td {
@apply bg-surface;
}
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0d1117;
}
::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Plus, Trash2, Copy, Check } from 'lucide-vue-next'
interface ApiKey {
id: string
name: string
key_prefix: string
scopes: string[]
is_admin: boolean
is_active: boolean
owner_label: string | null
created_at: string
last_used_at: string | null
}
const keys = ref<ApiKey[]>([])
const loading = ref(true)
const showCreateDialog = ref(false)
const newKeyName = ref('')
const createdKey = ref<{ key: string } | null>(null)
const copied = ref(false)
async function fetchKeys() {
loading.value = true
try {
const res = await api.get('/api/v1/admin/keys')
keys.value = res.data
} catch (err) {
console.error('Failed to fetch keys:', err)
} finally {
loading.value = false
}
}
async function createKey() {
try {
const res = await api.post('/api/v1/admin/keys', { name: newKeyName.value })
createdKey.value = res.data
showCreateDialog.value = false
newKeyName.value = ''
await fetchKeys()
} catch (err) {
console.error('Failed to create key:', err)
}
}
async function revokeKey(id: string) {
if (!confirm('Are you sure you want to revoke this key?')) return
try {
await api.delete(`/api/v1/admin/keys/${id}`)
await fetchKeys()
} catch (err) {
console.error('Failed to revoke key:', err)
}
}
async function copyKey(key: string) {
await navigator.clipboard.writeText(key)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
function formatDate(date: string | null) {
if (!date) return 'Never'
return new Date(date).toLocaleString()
}
onMounted(fetchKeys)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">API Keys</h1>
<button @click="showCreateDialog = true" class="btn btn-primary">
<Plus class="w-4 h-4 mr-2" />
New Key
</button>
</div>
<div v-if="loading" class="text-text-muted">Loading...</div>
<!-- Created Key Dialog -->
<div v-if="createdKey" class="card mb-6 bg-success/5 border-success/20">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-success">API Key Created</h3>
<p class="text-sm text-text-muted mt-1">
Copy this key now. You won't be able to see it again.
</p>
</div>
<button @click="copyKey(createdKey.key)" class="btn btn-secondary">
<Copy class="w-4 h-4 mr-2" />
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
<div class="mt-4 p-3 bg-background rounded-lg font-mono text-sm break-all">
{{ createdKey.key }}
</div>
<button @click="createdKey = null" class="mt-4 text-sm text-text-muted hover:text-text">
Close
</button>
</div>
<!-- Keys Table -->
<div class="card">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Scopes</th>
<th>Owner</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="key in keys" :key="key.id">
<td>{{ key.name }}</td>
<td class="font-mono text-text-muted">{{ key.key_prefix }}...</td>
<td>
<span v-for="scope in key.scopes" :key="scope" class="badge mr-1">
{{ scope }}
</span>
</td>
<td>{{ key.owner_label || '-' }}</td>
<td>{{ formatDate(key.created_at) }}</td>
<td>{{ formatDate(key.last_used_at) }}</td>
<td>
<button
v-if="!key.is_admin"
@click="revokeKey(key.id)"
class="btn btn-danger btn-sm"
>
<Trash2 class="w-4 h-4" />
</button>
<span v-else class="badge badge-info">Admin</span>
</td>
</tr>
<tr v-if="keys.length === 0">
<td colspan="7" class="text-center text-text-muted py-8">
No API keys yet. Create one to get started.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card w-full max-w-md">
<h2 class="text-lg font-semibold mb-4">Create API Key</h2>
<form @submit.prevent="createKey">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Key Name</label>
<input v-model="newKeyName" type="text" class="input" placeholder="My API Key" required />
</div>
<div class="flex gap-3 justify-end">
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
</div>
</template>
+219
View File
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Activity, Cpu, Key, Clock } from 'lucide-vue-next'
interface DashboardStats {
total_requests: number
total_tokens: number
avg_latency_ms: number
active_keys: number
total_models: number
}
interface ModelStatus {
status: string
current_model: string | null
loaded_at: string | null
last_error: string | null
}
interface Model {
id: string
name: string
alias: string
is_default: boolean
is_active: boolean
loaded_at: string | null
}
const stats = ref<DashboardStats>({
total_requests: 0,
total_tokens: 0,
avg_latency_ms: 0,
active_keys: 0,
total_models: 0,
})
const modelStatus = ref<ModelStatus>({
status: 'stopped',
current_model: null,
loaded_at: null,
last_error: null,
})
const models = ref<Model[]>([])
const loading = ref(true)
const error = ref('')
async function fetchDashboard() {
loading.value = true
error.value = ''
try {
const [statsRes, modelsRes, statusRes] = await Promise.all([
api.get('/api/v1/admin/dashboard'),
api.get('/api/v1/admin/models'),
api.get('/api/v1/admin/status'),
])
stats.value = statsRes.data.stats
models.value = modelsRes.data.data
modelStatus.value = statusRes.data
} catch (err: any) {
error.value = err.response?.data?.error?.message || 'Failed to load dashboard'
} finally {
loading.value = false
}
}
async function loadModel(name: string) {
try {
await api.post(`/api/v1/admin/models/${name}/load`)
await fetchDashboard()
} catch (err: any) {
error.value = err.response?.data?.error?.message || 'Failed to load model'
}
}
onMounted(fetchDashboard)
</script>
<template>
<div>
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div v-if="loading" class="text-text-muted">Loading...</div>
<div v-else-if="error" class="p-4 bg-error/10 border border-error/20 rounded-lg text-error">
{{ error }}
</div>
<template v-else>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center">
<Activity class="w-6 h-6 text-primary" />
</div>
<div>
<p class="text-text-muted text-sm">Total Requests</p>
<p class="text-2xl font-bold">{{ stats.total_requests.toLocaleString() }}</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-success/10 rounded-xl flex items-center justify-center">
<Cpu class="w-6 h-6 text-success" />
</div>
<div>
<p class="text-text-muted text-sm">Total Tokens</p>
<p class="text-2xl font-bold">{{ stats.total_tokens.toLocaleString() }}</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-warning/10 rounded-xl flex items-center justify-center">
<Clock class="w-6 h-6 text-warning" />
</div>
<div>
<p class="text-text-muted text-sm">Avg Latency</p>
<p class="text-2xl font-bold">{{ stats.avg_latency_ms.toFixed(0) }}ms</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-info/10 rounded-xl flex items-center justify-center">
<Key class="w-6 h-6 text-info" />
</div>
<div>
<p class="text-text-muted text-sm">Active Keys</p>
<p class="text-2xl font-bold">{{ stats.active_keys }}</p>
</div>
</div>
</div>
</div>
<!-- Model Status -->
<div class="card mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Model Status</h2>
<span
class="badge"
:class="{
'badge-success': modelStatus.status === 'ready',
'badge-warning': modelStatus.status === 'loading' || modelStatus.status === 'swapping',
'badge-error': modelStatus.status === 'failed',
}"
>
{{ modelStatus.status }}
</span>
</div>
<div v-if="modelStatus.current_model" class="mb-4">
<p class="text-text-muted text-sm">Current Model</p>
<p class="text-lg font-mono">{{ modelStatus.current_model }}</p>
</div>
<div v-if="modelStatus.last_error" class="p-3 bg-error/10 border border-error/20 rounded-lg text-error text-sm">
{{ modelStatus.last_error }}
</div>
</div>
<!-- Models Table -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Models</h2>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Alias</th>
<th>Default</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="model in models" :key="model.id">
<td class="font-mono">{{ model.name }}</td>
<td class="font-mono text-text-muted">{{ model.alias }}</td>
<td>
<span v-if="model.is_default" class="badge badge-info">Yes</span>
<span v-else class="badge">No</span>
</td>
<td>
<span
v-if="model.is_active"
class="badge badge-success"
>Active</span>
<span v-else class="badge">Inactive</span>
</td>
<td>
<button
v-if="!model.is_active"
@click="loadModel(model.name)"
class="btn btn-primary btn-sm"
>
Load
</button>
<span v-else class="badge badge-success">Loaded</span>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="5" class="text-center text-text-muted py-8">
No models configured. Add models via the API.
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { LayoutDashboard, Key, Cpu, BarChart3, LogOut } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const navItems = [
{ name: 'Dashboard', path: '/admin/', icon: LayoutDashboard },
{ name: 'API Keys', path: '/admin/keys', icon: Key },
{ name: 'Models', path: '/admin/models', icon: Cpu },
{ name: 'Usage', path: '/admin/usage', icon: BarChart3 },
]
function handleLogout() {
authStore.logout()
router.push('/admin/login')
}
</script>
<template>
<div class="min-h-screen bg-background">
<!-- Sidebar -->
<aside class="fixed left-0 top-0 h-full w-64 bg-surface border-r border-border flex flex-col">
<!-- Logo -->
<div class="p-6 border-b border-border">
<h1 class="text-xl font-bold text-primary">LlamaLink</h1>
<p class="text-xs text-text-muted mt-1">Admin Panel</p>
</div>
<!-- Navigation -->
<nav class="flex-1 p-4 space-y-1">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors"
:class="route.path === item.path || (item.path !== '/admin/' && route.path.startsWith(item.path))
? 'bg-primary/10 text-primary'
: 'text-text-muted hover:text-text hover:bg-border'"
>
<component :is="item.icon" class="w-5 h-5" />
<span>{{ item.name }}</span>
</RouterLink>
</nav>
<!-- Logout -->
<div class="p-4 border-t border-border">
<button
@click="handleLogout"
class="flex items-center gap-3 w-full px-4 py-3 rounded-lg text-text-muted hover:text-error hover:bg-error/10 transition-colors"
>
<LogOut class="w-5 h-5" />
<span>Logout</span>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="ml-64 min-h-screen p-8">
<RouterView />
</main>
</div>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Key } from 'lucide-vue-next'
const router = useRouter()
const authStore = useAuthStore()
const adminToken = ref('')
const error = ref('')
async function handleLogin() {
if (!adminToken.value.trim()) {
error.value = 'Admin token is required'
return
}
const success = await authStore.login(adminToken.value)
if (success) {
router.push('/admin/')
} else {
error.value = authStore.error || 'Login failed'
}
}
</script>
<template>
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="w-full max-w-md">
<div class="card">
<div class="flex items-center gap-3 mb-6">
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center">
<Key class="w-6 h-6 text-primary" />
</div>
<div>
<h1 class="text-2xl font-bold">LlamaLink</h1>
<p class="text-text-muted text-sm">Admin Login</p>
</div>
</div>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Admin Token</label>
<input
v-model="adminToken"
type="password"
class="input"
placeholder="Enter your admin token"
autocomplete="current-password"
/>
</div>
<div v-if="error" class="p-3 bg-error/10 border border-error/20 rounded-lg text-error text-sm">
{{ error }}
</div>
<button
type="submit"
class="btn btn-primary w-full"
:disabled="authStore.loading"
>
<span v-if="authStore.loading">Logging in...</span>
<span v-else>Login</span>
</button>
</form>
</div>
</div>
</div>
</template>
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Plus, Upload } from 'lucide-vue-next'
interface Model {
id: string
name: string
model_path: string
alias: string
ctx_size: number
n_gpu_layers: number
is_default: boolean
is_active: boolean
loaded_at: string | null
}
const models = ref<Model[]>([])
const loading = ref(true)
const showCreateDialog = ref(false)
const newModel = ref({
name: '',
model_path: '',
alias: '',
ctx_size: 8192,
n_gpu_layers: -1,
is_default: false,
})
async function fetchModels() {
loading.value = true
try {
const res = await api.get('/api/v1/models')
models.value = res.data.data
} catch (err) {
console.error('Failed to fetch models:', err)
} finally {
loading.value = false
}
}
async function createModel() {
try {
await api.post('/api/v1/models', newModel.value)
showCreateDialog.value = false
Object.assign(newModel.value, { name: '', model_path: '', alias: '', ctx_size: 8192, n_gpu_layers: -1, is_default: false })
await fetchModels()
} catch (err) {
console.error('Failed to create model:', err)
}
}
async function loadModel(name: string) {
try {
await api.post(`/api/v1/models/${name}/load`)
await fetchModels()
} catch (err) {
console.error('Failed to load model:', err)
}
}
onMounted(fetchModels)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Models</h1>
<button @click="showCreateDialog = true" class="btn btn-primary">
<Plus class="w-4 h-4 mr-2" />
Add Model
</button>
</div>
<div v-if="loading" class="text-text-muted">Loading...</div>
<div class="card">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Alias</th>
<th>Context</th>
<th>GPU Layers</th>
<th>Default</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="model in models" :key="model.id">
<td class="font-mono">{{ model.name }}</td>
<td class="font-mono text-text-muted text-sm">{{ model.model_path }}</td>
<td class="font-mono">{{ model.alias }}</td>
<td>{{ model.ctx_size.toLocaleString() }}</td>
<td>{{ model.n_gpu_layers }}</td>
<td>
<span v-if="model.is_default" class="badge badge-info">Default</span>
<span v-else class="badge">No</span>
</td>
<td>
<span v-if="model.is_active" class="badge badge-success">Active</span>
<span v-else class="badge">Inactive</span>
</td>
<td>
<button
v-if="!model.is_active"
@click="loadModel(model.name)"
class="btn btn-primary btn-sm"
>
<Upload class="w-4 h-4 mr-1" />
Load
</button>
<span v-else class="badge badge-success">Loaded</span>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="8" class="text-center text-text-muted py-8">
No models configured. Add one to get started.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card w-full max-w-lg">
<h2 class="text-lg font-semibold mb-4">Add Model</h2>
<form @submit.prevent="createModel" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Name</label>
<input v-model="newModel.name" type="text" class="input" placeholder="llama-3.2-1b" required />
</div>
<div>
<label class="block text-sm font-medium mb-2">Model Path</label>
<input v-model="newModel.model_path" type="text" class="input" placeholder="/models/llama-3.2-1b.q4_k_m.gguf" required />
</div>
<div>
<label class="block text-sm font-medium mb-2">Alias</label>
<input v-model="newModel.alias" type="text" class="input" placeholder="llama-3.2-1b" required />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2">Context Size</label>
<input v-model.number="newModel.ctx_size" type="number" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-2">GPU Layers</label>
<input v-model.number="newModel.n_gpu_layers" type="number" class="input" />
</div>
</div>
<div class="flex items-center gap-2">
<input v-model="newModel.is_default" type="checkbox" id="is_default" class="w-4 h-4 rounded" />
<label for="is_default" class="text-sm">Set as default model</label>
</div>
<div class="flex gap-3 justify-end pt-2">
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
</div>
</template>
+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
interface UsageData {
period: { start: string; end: string }
usage: {
total_requests: number
total_tokens: number
avg_latency_ms: number
}
quota: {
tokens_used: number
tokens_limit: number
}
logs: any[]
}
const usageData = ref<UsageData | null>(null)
const loading = ref(true)
const period = ref('month')
const chartData = {
labels: ['Requests', 'Tokens (÷1000)'],
datasets: [
{
label: 'Usage',
data: [] as number[],
backgroundColor: ['#6366f1', '#22c55e'],
},
],
}
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: { grid: { color: '#30363d' }, ticks: { color: '#8b949e' } },
x: { grid: { display: false }, ticks: { color: '#8b949e' } },
},
}
async function fetchUsage() {
loading.value = true
try {
const res = await api.get(`/api/v1/admin/usage?period=${period.value}`)
usageData.value = res.data
if (res.data.usage) {
chartData.datasets[0].data = [
res.data.usage.total_requests,
Math.round(res.data.usage.total_tokens / 1000),
]
}
} catch (err) {
console.error('Failed to fetch usage:', err)
} finally {
loading.value = false
}
}
function formatNumber(n: number) {
return n.toLocaleString()
}
function formatDate(date: string) {
return new Date(date).toLocaleString()
}
onMounted(fetchUsage)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Usage</h1>
<select v-model="period" @change="fetchUsage" class="input w-auto">
<option value="week">Last 7 days</option>
<option value="month">This month</option>
<option value="year">This year</option>
</select>
</div>
<div v-if="loading" class="text-text-muted">Loading...</div>
<template v-else-if="usageData">
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card">
<p class="text-text-muted text-sm mb-2">Total Requests</p>
<p class="text-3xl font-bold">{{ formatNumber(usageData.usage?.total_requests || 0) }}</p>
</div>
<div class="card">
<p class="text-text-muted text-sm mb-2">Total Tokens</p>
<p class="text-3xl font-bold">{{ formatNumber(usageData.usage?.total_tokens || 0) }}</p>
</div>
<div class="card">
<p class="text-text-muted text-sm mb-2">Avg Latency</p>
<p class="text-3xl font-bold">{{ (usageData.usage?.avg_latency_ms || 0).toFixed(0) }}ms</p>
</div>
</div>
<!-- Quota -->
<div class="card mb-8" v-if="usageData.quota">
<h2 class="text-lg font-semibold mb-4">Monthly Quota</h2>
<div class="mb-4">
<div class="flex justify-between text-sm mb-2">
<span>{{ formatNumber(usageData.quota.tokens_used) }} / {{ formatNumber(usageData.quota.tokens_limit) }} tokens</span>
<span v-if="usageData.quota.tokens_limit > 0">
{{ Math.round((usageData.quota.tokens_used / usageData.quota.tokens_limit) * 100) }}%
</span>
</div>
<div class="h-3 bg-border rounded-full overflow-hidden">
<div
class="h-full transition-all"
:class="{
'bg-success': (usageData.quota.tokens_used / usageData.quota.tokens_limit) < 0.8,
'bg-warning': (usageData.quota.tokens_used / usageData.quota.tokens_limit) >= 0.8,
'bg-error': (usageData.quota.tokens_used / usageData.quota.tokens_limit) >= 0.95,
}"
:style="{ width: `${Math.min((usageData.quota.tokens_used / usageData.quota.tokens_limit) * 100, 100)}%` }"
/>
</div>
</div>
</div>
<!-- Chart -->
<div class="card mb-8">
<h2 class="text-lg font-semibold mb-4">Usage Overview</h2>
<div class="h-64">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
<!-- Recent Logs -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Recent Requests</h2>
<table class="table">
<thead>
<tr>
<th>Time</th>
<th>Model</th>
<th>Endpoint</th>
<th>Tokens</th>
<th>Latency</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="log in (usageData.logs || []).slice(0, 20)" :key="log.id">
<td>{{ formatDate(log.created_at) }}</td>
<td class="font-mono">{{ log.model_name }}</td>
<td class="font-mono text-text-muted">{{ log.endpoint }}</td>
<td>{{ log.total_tokens }}</td>
<td>{{ log.latency_ms }}ms</td>
<td>
<span
class="badge"
:class="{
'badge-success': log.status === 'success',
'badge-error': log.status === 'error',
'badge-warning': log.status === 'quota_exceeded',
}"
>
{{ log.status }}
</span>
</td>
</tr>
<tr v-if="!usageData.logs?.length">
<td colspan="6" class="text-center text-text-muted py-8">
No requests yet.
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
background: '#0d1117',
surface: '#161b22',
border: '#30363d',
primary: '#6366f1',
'primary-hover': '#818cf8',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
text: '#e6edf3',
'text-muted': '#8b949e',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Consolas', 'monospace'],
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/admin': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: '../dist',
emptyOutDir: true,
},
})