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:
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user