Add admin UI
Some checks failed
CI / Test (pull_request) Failing after 1m33s
CI / Lint (pull_request) Failing after 13s
CI / Build (pull_request) Has been skipped
CI / Security Scan (pull_request) Failing after 4m47s
CI / Build and Push Docker Image (pull_request) Has been skipped

This commit is contained in:
2026-03-05 23:08:34 +00:00
parent 667217e66b
commit 7025ec746c
31 changed files with 5905 additions and 3 deletions

24
frontend/admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

13
frontend/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/admin/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LLM Gateway Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1698
frontend/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "llm-gateway-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"axios": "^1.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.0"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,26 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup lang="ts">
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f5f5f5;
color: #333;
}
#app {
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,51 @@
import axios, { AxiosInstance } from 'axios'
import type { APIResponse } from '../types/api'
class APIClient {
private client: AxiosInstance
constructor() {
this.client = axios.create({
baseURL: '/admin/api/v1',
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor for auth
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error)
return Promise.reject(error)
}
)
}
async get<T>(url: string): Promise<T> {
const response = await this.client.get<APIResponse<T>>(url)
if (response.data.success && response.data.data) {
return response.data.data
}
throw new Error(response.data.error?.message || 'Unknown error')
}
async post<T>(url: string, data: any): Promise<T> {
const response = await this.client.post<APIResponse<T>>(url, data)
if (response.data.success && response.data.data) {
return response.data.data
}
throw new Error(response.data.error?.message || 'Unknown error')
}
}
export const apiClient = new APIClient()

View File

@@ -0,0 +1,8 @@
import { apiClient } from './client'
import type { ConfigResponse } from '../types/api'
export const configAPI = {
async getConfig(): Promise<ConfigResponse> {
return apiClient.get<ConfigResponse>('/config')
},
}

View File

@@ -0,0 +1,8 @@
import { apiClient } from './client'
import type { ProviderInfo } from '../types/api'
export const providersAPI = {
async getProviders(): Promise<ProviderInfo[]> {
return apiClient.get<ProviderInfo[]>('/providers')
},
}

View File

@@ -0,0 +1,12 @@
import { apiClient } from './client'
import type { SystemInfo, HealthCheckResponse } from '../types/api'
export const systemAPI = {
async getInfo(): Promise<SystemInfo> {
return apiClient.get<SystemInfo>('/system/info')
},
async getHealth(): Promise<HealthCheckResponse> {
return apiClient.get<HealthCheckResponse>('/system/health')
},
}

View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from './views/Dashboard.vue'
const router = createRouter({
history: createWebHistory('/admin/'),
routes: [
{
path: '/',
name: 'dashboard',
component: Dashboard
}
]
})
export default router

View File

@@ -0,0 +1,82 @@
export interface APIResponse<T = any> {
success: boolean
data?: T
error?: APIError
}
export interface APIError {
code: string
message: string
}
export interface SystemInfo {
version: string
build_time: string
git_commit: string
go_version: string
platform: string
uptime: string
}
export interface HealthCheck {
status: string
message?: string
}
export interface HealthCheckResponse {
status: string
timestamp: string
checks: Record<string, HealthCheck>
}
export interface SanitizedProvider {
type: string
api_key: string
endpoint?: string
api_version?: string
project?: string
location?: string
}
export interface ModelEntry {
name: string
provider: string
provider_model_id?: string
}
export interface ConfigResponse {
server: {
address: string
max_request_body_size: number
}
providers: Record<string, SanitizedProvider>
models: ModelEntry[]
auth: {
enabled: boolean
issuer: string
audience: string
}
conversations: {
store: string
ttl: string
dsn: string
driver: string
}
logging: {
format: string
level: string
}
rate_limit: {
enabled: boolean
requests_per_second: number
burst: number
}
observability: any
}
export interface ProviderInfo {
name: string
type: string
models: string[]
status: string
}

View File

@@ -0,0 +1,385 @@
<template>
<div class="dashboard">
<header class="header">
<h1>LLM Gateway Admin</h1>
</header>
<div class="container">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="grid">
<!-- System Info Card -->
<div class="card">
<h2>System Information</h2>
<div class="info-grid" v-if="systemInfo">
<div class="info-item">
<span class="label">Version:</span>
<span class="value">{{ systemInfo.version }}</span>
</div>
<div class="info-item">
<span class="label">Platform:</span>
<span class="value">{{ systemInfo.platform }}</span>
</div>
<div class="info-item">
<span class="label">Go Version:</span>
<span class="value">{{ systemInfo.go_version }}</span>
</div>
<div class="info-item">
<span class="label">Uptime:</span>
<span class="value">{{ systemInfo.uptime }}</span>
</div>
<div class="info-item">
<span class="label">Build Time:</span>
<span class="value">{{ systemInfo.build_time }}</span>
</div>
<div class="info-item">
<span class="label">Git Commit:</span>
<span class="value code">{{ systemInfo.git_commit }}</span>
</div>
</div>
</div>
<!-- Health Status Card -->
<div class="card">
<h2>Health Status</h2>
<div v-if="health">
<div class="health-overall">
<span class="label">Overall Status:</span>
<span :class="['badge', health.status]">{{ health.status }}</span>
</div>
<div class="health-checks">
<div v-for="(check, name) in health.checks" :key="name" class="health-check">
<span class="check-name">{{ name }}:</span>
<span :class="['badge', check.status]">{{ check.status }}</span>
<span v-if="check.message" class="check-message">{{ check.message }}</span>
</div>
</div>
</div>
</div>
<!-- Providers Card -->
<div class="card full-width">
<h2>Providers</h2>
<div v-if="providers && providers.length > 0" class="providers-grid">
<div v-for="provider in providers" :key="provider.name" class="provider-card">
<div class="provider-header">
<h3>{{ provider.name }}</h3>
<span :class="['badge', provider.status]">{{ provider.status }}</span>
</div>
<div class="provider-info">
<div class="info-item">
<span class="label">Type:</span>
<span class="value">{{ provider.type }}</span>
</div>
<div class="info-item">
<span class="label">Models:</span>
<span class="value">{{ provider.models.length }}</span>
</div>
</div>
<div v-if="provider.models.length > 0" class="models-list">
<span v-for="model in provider.models" :key="model" class="model-tag">
{{ model }}
</span>
</div>
</div>
</div>
<div v-else class="empty-state">No providers configured</div>
</div>
<!-- Config Card -->
<div class="card full-width collapsible">
<div class="card-header" @click="configExpanded = !configExpanded">
<h2>Configuration</h2>
<span class="expand-icon">{{ configExpanded ? '' : '+' }}</span>
</div>
<div v-if="configExpanded && config" class="config-content">
<pre class="config-json">{{ JSON.stringify(config, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { systemAPI } from '../api/system'
import { configAPI } from '../api/config'
import { providersAPI } from '../api/providers'
import type { SystemInfo, HealthCheckResponse, ConfigResponse, ProviderInfo } from '../types/api'
const loading = ref(true)
const error = ref<string | null>(null)
const systemInfo = ref<SystemInfo | null>(null)
const health = ref<HealthCheckResponse | null>(null)
const config = ref<ConfigResponse | null>(null)
const providers = ref<ProviderInfo[] | null>(null)
const configExpanded = ref(false)
let refreshInterval: number | null = null
async function loadData() {
try {
loading.value = true
error.value = null
const [info, healthData, configData, providersData] = await Promise.all([
systemAPI.getInfo(),
systemAPI.getHealth(),
configAPI.getConfig(),
providersAPI.getProviders(),
])
systemInfo.value = info
health.value = healthData
config.value = configData
providers.value = providersData
} catch (err: any) {
error.value = err.message || 'Failed to load data'
console.error('Error loading data:', err)
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
// Auto-refresh every 30 seconds
refreshInterval = window.setInterval(loadData, 30000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<style scoped>
.dashboard {
min-height: 100vh;
background-color: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header h1 {
font-size: 2rem;
font-weight: 600;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.loading,
.error {
text-align: center;
padding: 3rem;
font-size: 1.2rem;
}
.error {
color: #e53e3e;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
}
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.full-width {
grid-column: 1 / -1;
}
.card h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
color: #2d3748;
}
.info-grid {
display: grid;
gap: 0.75rem;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #e2e8f0;
}
.info-item:last-child {
border-bottom: none;
}
.label {
font-weight: 500;
color: #4a5568;
}
.value {
color: #2d3748;
}
.code {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
}
.badge.healthy {
background-color: #c6f6d5;
color: #22543d;
}
.badge.unhealthy {
background-color: #fed7d7;
color: #742a2a;
}
.badge.active {
background-color: #bee3f8;
color: #2c5282;
}
.health-overall {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background-color: #f7fafc;
border-radius: 6px;
margin-bottom: 1rem;
}
.health-checks {
display: grid;
gap: 0.75rem;
}
.health-check {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.check-name {
font-weight: 500;
color: #4a5568;
text-transform: capitalize;
}
.check-message {
color: #718096;
font-size: 0.875rem;
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.provider-card {
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 1rem;
background-color: #f7fafc;
}
.provider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.provider-header h3 {
font-size: 1.125rem;
font-weight: 600;
color: #2d3748;
}
.provider-info {
display: grid;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.models-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.model-tag {
background-color: #edf2f7;
color: #4a5568;
padding: 0.25rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #718096;
}
.collapsible .card-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.expand-icon {
font-size: 1.5rem;
font-weight: bold;
color: #4a5568;
}
.config-content {
margin-top: 1rem;
}
.config-json {
background-color: #2d3748;
color: #e2e8f0;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
font-size: 0.875rem;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/admin/',
server: {
port: 5173,
proxy: {
'/admin/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
emptyOutDir: true,
}
})