Initial commit: Luminary AI Gateway
CI / docker (push) Successful in 2m4s

OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress,
ingress key governance, monitoring, and security controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-23 21:46:16 +08:00
commit 76ba500417
134 changed files with 18988 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Luminary AI Gateway</title>
<script type="module" crossorigin src="/assets/index-CF-14R2-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDyLXzvy.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Luminary AI Gateway</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2188
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "luminary-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-i18n": "^9.14.4",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.3",
"vite": "^6.0.11",
"vue-tsc": "^2.2.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f7a62"/>
<g transform="translate(4, 4)">
<path
d="M5 15V11a7 7 0 0 1 14 0v4"
stroke="#fafafa"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M4 15h5" stroke="#fafafa" stroke-width="2" stroke-linecap="round"/>
<path d="M15 15h5" stroke="#fafafa" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="2.25" fill="#fafafa"/>
<path d="M12 4.5V8" stroke="#fafafa" stroke-width="2" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 634 B

+16
View File
@@ -0,0 +1,16 @@
<template>
<el-config-provider :locale="epLocale">
<router-view />
</el-config-provider>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
const { locale } = useI18n()
const epLocale = computed(() => (locale.value === 'zh-CN' ? zhCn : en))
</script>
+113
View File
@@ -0,0 +1,113 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/admin',
withCredentials: true,
})
api.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401 && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
return Promise.reject(err)
},
)
export default api
export interface Provider {
id: number
name: string
type: string
category: string
base_url: string
allowed_endpoints_json: string
endpoint_paths_json: string
status: string
enabled?: boolean
keys?: ProviderKey[]
models?: ModelEntry[]
}
export interface ProviderKey {
id: number
provider_id: number
name: string
weight: number
models_json: string
enabled: boolean
max_requests_per_day: number
max_tokens_per_day: number
requests_today: number
tokens_today: number
}
export interface ModelEntry {
id: number
provider_id: number
model_id: string
alias?: string
display_name: string
workflow_type?: string
workflow_json?: string
input_binding_json?: string
version_hash?: string
enabled: boolean
created_at?: string
}
export interface IngressKey {
id: number
name: string
prefix: string
enabled: boolean
budget_limit: number
budget_used: number
rate_limit_rpm: number
rate_limit_tpm: number
allowed_providers_json: string
allowed_models_json: string
request_count: number
token_count: number
}
export interface IPRule {
id: number
cidr: string
type: 'allow' | 'deny'
scope: 'admin' | 'proxy' | 'all'
enabled: boolean
}
export interface DashboardOverview {
total_requests_24h: number
total_tokens_24h: number
error_rate: number
active_ingress_keys: number
providers: number
}
export interface ProviderModelUsage {
provider_id: number
provider_name: string
model: string
request_count: number
token_count: number
cost: number
}
export interface ClientIPUsage {
client_ip: string
request_count: number
token_count: number
cost: number
}
export interface RequestTrendBucket {
hour: string
requests: number
errors: number
tokens: number
}
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="app-card">
<div v-if="title || $slots.header" class="app-card__header">
<slot name="header">
<h3 class="app-card__title">{{ title }}</h3>
</slot>
</div>
<div :class="padded ? 'app-card__body--padded' : ''">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
padded?: boolean
}>()
</script>
+30
View File
@@ -0,0 +1,30 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<!-- Gateway arch -->
<path
d="M5 15V11a7 7 0 0 1 14 0v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Traffic flow through gateway -->
<path d="M4 15h5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M15 15h5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<!-- Luminous core -->
<circle cx="12" cy="12" r="2.25" fill="currentColor" />
<!-- Beacon ray -->
<path d="M12 4.5V8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ size?: number | string }>(), { size: 20 })
</script>
+47
View File
@@ -0,0 +1,47 @@
<template>
<div ref="root" class="chart-box" />
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { ECharts, EChartsOption } from 'echarts'
const props = defineProps<{
option: EChartsOption
}>()
const root = ref<HTMLDivElement>()
const chart = shallowRef<ECharts>()
let echartsApi: typeof import('echarts') | null = null
async function ensureChart() {
if (!root.value) return
if (!echartsApi) {
echartsApi = await import('echarts')
}
if (!chart.value) {
chart.value = echartsApi.init(root.value)
}
}
async function render() {
await ensureChart()
chart.value?.setOption(props.option, { notMerge: true })
}
function handleResize() {
chart.value?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', handleResize)
})
watch(() => props.option, render, { deep: true })
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chart.value?.dispose()
})
</script>
+22
View File
@@ -0,0 +1,22 @@
<template>
<el-dropdown trigger="click" @command="onSwitch">
<el-button text>{{ t('common.language') }}</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN">{{ t('common.zh') }}</el-dropdown-item>
<el-dropdown-item command="en">{{ t('common.en') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { setLocale } from '@/i18n'
const { t } = useI18n()
function onSwitch(locale: 'zh-CN' | 'en') {
setLocale(locale)
}
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="page-header">
<div>
<h1 class="page-title">{{ title }}</h1>
<p v-if="description" class="page-desc">{{ description }}</p>
</div>
<div v-if="$slots.actions" class="page-actions">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
description?: string
}>()
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="stat-card">
<div class="stat-card__label">{{ label }}</div>
<div class="stat-card__value">{{ value }}</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string
value: string | number
}>()
</script>
+187
View File
@@ -0,0 +1,187 @@
<template>
<el-dialog v-model="visible" :title="t('providers.workflowDialogTitle')" width="720px" destroy-on-close @closed="reset">
<el-form label-width="100px">
<el-form-item :label="t('providers.modelId')">
<el-input v-model="form.model_id" placeholder="luminary/flux-sdxl" />
</el-form-item>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="form.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
</el-form-item>
<el-form-item :label="t('providers.displayName')">
<el-input v-model="form.display_name" />
</el-form-item>
<el-form-item :label="t('providers.workflowType')">
<el-select v-model="form.workflow_type" @change="onTypeChange">
<el-option :label="t('providers.txt2img')" value="txt2img" />
<el-option :label="t('providers.img2img')" value="img2img" />
</el-select>
</el-form-item>
<el-form-item :label="t('providers.workflowJson')">
<el-input v-model="form.workflow_json" type="textarea" :rows="8" placeholder="{ ... ComfyUI API Format ... }" />
</el-form-item>
<el-form-item>
<el-button :loading="analyzing" @click="analyze">{{ t('providers.analyzeWorkflow') }}</el-button>
</el-form-item>
</el-form>
<el-table v-if="bindings.length" :data="bindings" size="small" class="u-mt-2">
<el-table-column prop="param" :label="t('providers.paramName')" width="140" />
<el-table-column :label="t('providers.bindEnabled')" width="90">
<template #default="{ row }">
<el-switch v-model="row.enabled" :disabled="row.required" />
</template>
</el-table-column>
<el-table-column :label="t('providers.targetNode')">
<template #default="{ row }">
<el-select v-model="row.node" clearable filterable :disabled="!row.enabled" @change="onNodeChange(row)">
<el-option
v-for="n in nodes"
:key="n.id"
:label="`${n.id} — ${n.class_type}`"
:value="n.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column :label="t('providers.targetField')">
<template #default="{ row }">
<el-select v-model="row.field" clearable :disabled="!row.enabled || !row.node">
<el-option v-for="f in fieldsFor(row.node)" :key="f" :label="f" :value="f" />
</el-select>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="save">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import api from '@/api/client'
const { t } = useI18n()
interface WorkflowNode {
id: string
class_type: string
injectable_fields: string[]
}
interface BindingRow {
param: string
required: boolean
enabled: boolean
node: string
field: string
}
const props = defineProps<{ providerId: number }>()
const emit = defineEmits<{ saved: [] }>()
const visible = defineModel<boolean>({ required: true })
const analyzing = ref(false)
const saving = ref(false)
const nodes = ref<WorkflowNode[]>([])
const bindings = ref<BindingRow[]>([])
const form = ref({
model_id: '',
alias: '',
display_name: '',
workflow_type: 'txt2img',
workflow_json: '',
})
const requiredParams = computed(() =>
form.value.workflow_type === 'img2img' ? ['prompt', 'image'] : ['prompt'],
)
function reset() {
form.value = { model_id: '', alias: '', display_name: '', workflow_type: 'txt2img', workflow_json: '' }
nodes.value = []
bindings.value = []
}
function fieldsFor(nodeId: string) {
return nodes.value.find(n => n.id === nodeId)?.injectable_fields || []
}
function onNodeChange(row: BindingRow) {
const fields = fieldsFor(row.node)
if (!fields.includes(row.field)) row.field = fields[0] || ''
}
async function onTypeChange() {
if (form.value.workflow_json) await analyze()
}
async function analyze() {
if (!form.value.workflow_json.trim()) {
ElMessage.warning(t('providers.workflowJsonRequired'))
return
}
analyzing.value = true
try {
const res = await api.post('/workflows/analyze', {
workflow_json: form.value.workflow_json,
workflow_type: form.value.workflow_type,
})
nodes.value = res.data.nodes || []
bindings.value = (res.data.suggestions || []).map((s: any) => ({
param: s.param,
required: requiredParams.value.includes(s.param),
enabled: requiredParams.value.includes(s.param) || !!(s.node && s.field),
node: s.node || '',
field: s.field || '',
}))
} catch (e: any) {
ElMessage.error(e.response?.data?.error || t('providers.analyzeFailed'))
} finally {
analyzing.value = false
}
}
function buildBinding() {
const out: Record<string, { node: string; field: string }> = {}
for (const row of bindings.value) {
if (!row.enabled || !row.node || !row.field) continue
out[row.param] = { node: row.node, field: row.field }
}
return out
}
async function save() {
if (!form.value.model_id.trim()) {
ElMessage.warning(t('providers.modelIdRequired'))
return
}
saving.value = true
try {
await api.post(`/providers/${props.providerId}/models`, {
model_id: form.value.model_id.trim(),
alias: form.value.alias.trim(),
display_name: form.value.display_name || form.value.model_id,
workflow_type: form.value.workflow_type,
workflow_json: form.value.workflow_json,
input_binding: buildBinding(),
})
ElMessage.success(t('providers.workflowSaved'))
visible.value = false
emit('saved')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || t('providers.updateFailed'))
} finally {
saving.value = false
}
}
watch(visible, (v) => {
if (v) reset()
})
</script>
+30
View File
@@ -0,0 +1,30 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import en from './locales/en'
const STORAGE_KEY = 'luminary-locale'
function detectLocale(): string {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'zh-CN' || saved === 'en') return saved
const lang = navigator.language.toLowerCase()
return lang.startsWith('zh') ? 'zh-CN' : 'en'
}
export const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
en,
},
})
export function setLocale(locale: 'zh-CN' | 'en') {
i18n.global.locale.value = locale
localStorage.setItem(STORAGE_KEY, locale)
document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en'
}
document.documentElement.lang = i18n.global.locale.value === 'zh-CN' ? 'zh-CN' : 'en'
+244
View File
@@ -0,0 +1,244 @@
export default {
common: {
brand: 'Luminary',
cancel: 'Cancel',
confirm: 'Confirm',
save: 'Save',
edit: 'Edit',
create: 'Create',
add: 'Add',
delete: 'Delete',
refresh: 'Refresh',
query: 'Search',
copy: 'Copy',
actions: 'Actions',
name: 'Name',
status: 'Status',
type: 'Type',
enabled: 'Enabled',
disabled: 'Disabled',
yes: 'Yes',
no: 'No',
all: 'All',
loading: 'Loading...',
empty: '(empty)',
dash: '—',
infinity: '∞',
confirmDelete: 'Delete this item?',
auto: 'Auto',
detail: 'Details',
model: 'Model',
requests: 'Requests',
tokens: 'Tokens',
cost: 'Cost ($)',
latencyMs: 'Latency (ms)',
latency: 'Latency',
stream: 'Stream',
error: 'Error',
time: 'Time',
username: 'Username',
password: 'Password',
weight: 'Weight',
priority: 'Priority',
category: 'Category',
budget: 'Budget',
soon: 'Coming soon',
whitelistMore: '{items} and {count} more',
language: 'Language',
zh: '中文',
en: 'English',
},
nav: {
dashboard: 'Dashboard',
providers: 'Egress',
ingress: 'Ingress',
logs: 'Request Logs',
ipRules: 'IP Rules',
settings: 'Settings',
security: 'Security',
logout: 'Sign out',
},
login: {
subtitle: 'AI Gateway Console',
title: 'Sign in',
username: 'Username',
passwordPlaceholder: 'Enter password',
submit: 'Sign in',
footer: 'Luminary Gateway — unified AI egress & ingress management',
success: 'Signed in successfully',
failed: 'Sign in failed. Check username and password.',
rateLimited: 'Too many attempts. Please try again later.',
rateLimitedSec: 'Too many attempts. Try again in {sec} seconds.',
ipBlocked: 'Your IP is not allowed to access the admin console. Check IP rules or contact an administrator.',
},
dashboard: {
title: 'Dashboard',
description: 'Real-time gateway requests, error rate, and provider health',
requests24h: '24h Requests',
tokens24h: '24h Tokens',
errorRate: 'Error Rate',
activeIngressKeys: 'Active Ingress Keys',
requestTrend: '24h Request Trend',
tokenTrend: '24h Token Trend',
seriesRequests: 'Requests',
seriesErrors: 'Errors',
seriesTokens: 'Tokens',
providerHealth: 'Provider Health',
ingressTop5: 'Top 5 Ingress Keys',
providerModelUsage: 'Provider + Model Usage (24h)',
clientIpUsage: 'Client IP Usage (24h)',
clientIp: 'Client IP',
requestCount: 'Requests',
},
providers: {
title: 'Egress Providers',
description: 'Configure upstream providers, API paths, and keys',
addProvider: 'Add Provider',
protocolType: 'Protocol',
baseUrl: 'Base URL',
baseUrlPlaceholder: 'Leave empty for default',
enabledEndpoints: 'Enabled Endpoints',
models: 'Models',
modelsEmpty: 'No models yet. Click Sync Models to pull from upstream.',
displayName: 'Display Name',
syncedAt: 'Synced At',
addDialogTitle: 'Add Provider',
endpointConfig: 'Endpoint Config',
endpointConfigTitle: 'Endpoint Config — {name}',
endpointDivider: 'Endpoints & Paths',
endpointEditDivider: 'Enabled Endpoints & Path Overrides',
pathHint: 'Path can be relative (based on Base URL) or a full http(s) URL',
keysTitle: 'Keys — {name}',
addKey: 'Add Key',
addKeyDialogTitle: 'Add Provider Key',
apiKey: 'API Key',
modelWhitelist: 'Model Whitelist',
modelWhitelistPlaceholder: '["*"] or gpt-4o-mini',
maxRequestsPerDay: 'Max Requests / Day',
maxTokensPerDay: 'Max Tokens / Day',
requestsToday: 'Requests Today',
modelsTitle: 'Models — {name}',
syncModels: 'Sync Models',
addModel: 'Add Model',
addModelDialogTitle: 'Add Model',
modelId: 'Model ID',
modelAlias: 'Ingress alias',
modelAliasPlaceholder: 'Optional; ingress API uses this name',
modelAliasHint: 'Leave empty to use upstream Model ID at ingress',
upstreamModelId: 'Upstream Model ID',
editModelDialogTitle: 'Edit model',
providerNameExists: 'Provider name already exists',
modelAliasExists: 'Model alias already exists',
modelIdRequired: 'Model ID is required',
modelAdded: 'Model added',
modelDeleted: 'Model deleted',
confirmDeleteModel: 'Delete this model?',
modelsSummary: '{total} models, {enabled} enabled',
modelsAction: 'Models',
endpointsAction: 'Endpoints',
keysAction: 'Keys',
modelNotLoaded: 'Model data not loaded. Refresh and try again.',
modelEnabled: 'Model enabled',
modelDisabled: 'Model disabled',
modelNotFound: 'Model not found. Sync models and try again.',
updateFailed: 'Update failed',
createSuccess: 'Created successfully',
saved: 'Saved',
deleted: 'Deleted',
syncedCount: 'Synced {count} models',
syncFailed: 'Sync failed',
keyAdded: 'Key added',
},
ingress: {
title: 'Ingress Keys',
description: 'Create virtual keys with budget, rate limits, and routing',
createKey: 'Create Ingress Key',
keyPrefix: 'Key Prefix',
providerWhitelist: 'Provider Whitelist',
modelWhitelist: 'Model Whitelist',
budgetLimit: 'Budget Limit',
rpmLimit: 'RPM Limit',
tpmLimit: 'TPM Limit',
whitelistAction: 'Whitelist',
routingAction: 'Routing',
createDialogTitle: 'Create Ingress Key',
providerWhitelistPlaceholder: 'Empty means all providers',
modelWhitelistPlaceholder: 'Empty means all models',
whitelistHint: 'Empty selection allows all',
modelWhitelistHint: 'Empty allows all; sync models in Egress first',
editWhitelistTitle: 'Edit Whitelist',
plainKeyTitle: 'Save your key (shown once)',
plainKeyWarning: 'Copy now — the full key cannot be viewed again after closing',
routingTitle: 'Routing Rules — {name}',
addRule: 'Add Rule',
modelPattern: 'Model Pattern',
modelPatternPlaceholder: 'gpt-4o* or claude-3-5-sonnet',
providerId: 'Provider',
providerIdPlaceholder: 'Select provider',
keyIdOptional: 'Provider key',
keyIdPlaceholder: 'Leave empty for auto load balancing',
providerRequired: 'Please select a provider',
addRuleTitle: 'Add Routing Rule',
whitelistUpdated: 'Whitelist updated',
confirmDeleteKey: 'Delete this key?',
copied: 'Copied',
ruleAdded: 'Rule added',
},
logs: {
title: 'Request Logs',
description: 'Latest 500 records including inference and admin login',
ingress: 'Ingress',
endpoint: 'Endpoint',
modelOrUser: 'Model / User',
modelOrUserPlaceholder: 'Model / username',
clientIpPlaceholder: 'Client IP',
statusSuccess: 'Success',
statusError: 'Error',
endpointAdminLogin: 'Admin Login',
pagerHint: '{total} total, up to {max} retained',
detailTitle: 'Request Details',
requestBody: 'Request Body',
responseBody: 'Response Body',
predictionStatus: 'Prediction status',
},
ipRules: {
title: 'IP Rules',
description: 'Whitelist / blacklist for admin console and inference API',
addRule: 'Add IP Rule',
cidr: 'CIDR / IP',
cidrPlaceholder: '192.168.1.0/24 or 127.0.0.1',
scope: 'Scope',
typeAllow: 'Allow',
typeDeny: 'Deny',
scopeProxy: 'Inference API',
scopeAdmin: 'Admin Console',
scopeAll: 'All (Admin + API)',
allowWarning: 'Allow rules restrict admin access. Ensure your current IP is included.',
added: 'Added',
},
settings: {
title: 'Settings',
description: 'All runtime parameters are managed here — no config file required',
securitySession: 'Security & Session',
gatewayMonitor: 'Gateway & Monitoring',
changePassword: 'Change Admin Password',
encryptionKey: 'Encryption Key',
encryptionKeyPlaceholder: 'Leave empty to keep unchanged; must match old key to decrypt existing provider keys',
encryptionKeyCurrent: 'Current: {hint}',
sessionTtl: 'Session TTL (seconds)',
loginMaxAttempts: 'Max Login Attempts',
loginLockout: 'Login Lockout (seconds)',
trustedProxies: 'Trusted Proxy IPs / CIDRs',
trustedProxiesPlaceholder: 'One per line, e.g. 127.0.0.1 or 10.0.0.0/8',
usageFlush: 'Usage Flush Interval (seconds)',
healthCheck: 'Health Check Interval (seconds)',
gatewayMaxRetries: 'Gateway Max Retries',
retryBackoff: 'Retry Backoff (ms)',
saveSettings: 'Save Settings',
oldPassword: 'Current Password',
newPassword: 'New Password',
savePassword: 'Save Password',
settingsSaved: 'Settings saved',
passwordUpdated: 'Password updated',
},
}
+265
View File
@@ -0,0 +1,265 @@
export default {
common: {
brand: 'Luminary',
cancel: '取消',
confirm: '确认',
save: '保存',
edit: '编辑',
create: '创建',
add: '添加',
delete: '删除',
refresh: '刷新',
query: '查询',
copy: '复制',
actions: '操作',
name: '名称',
status: '状态',
type: '类型',
enabled: '启用',
disabled: '禁用',
yes: '是',
no: '否',
all: '全部',
loading: '加载中...',
empty: '(空)',
dash: '—',
infinity: '∞',
confirmDelete: '确认删除?',
auto: '自动',
detail: '详情',
model: '模型',
requests: '请求',
tokens: 'Token',
cost: '费用 ($)',
latencyMs: '延迟 (ms)',
latency: '延迟',
stream: '流式',
error: '错误',
time: '时间',
username: '用户名',
password: '密码',
weight: '权重',
priority: '优先级',
category: '分类',
budget: '预算',
soon: '即将支持',
whitelistMore: '{items} 等 {count} 项',
language: '语言',
zh: '中文',
en: 'English',
},
nav: {
dashboard: '看板监控',
providers: '出口管理',
ingress: '入口管理',
logs: '请求日志',
ipRules: 'IP 规则',
settings: '系统设置',
security: '安全',
logout: '退出登录',
},
login: {
subtitle: 'AI 网关管理台',
title: '登录',
username: '用户名',
passwordPlaceholder: '请输入密码',
submit: '登录',
footer: 'Luminary Gateway — 统一 AI 出口与入口管理',
success: '登录成功',
failed: '登录失败,请检查用户名和密码',
rateLimited: '登录尝试过多,请稍后再试',
rateLimitedSec: '登录尝试过多,请 {sec} 秒后再试',
ipBlocked: '当前 IP 不在白名单内,无法访问管理台。请检查 IP 规则或联系管理员。',
},
dashboard: {
title: '看板监控',
description: '实时查看网关请求量、错误率与 Provider 健康状态',
requests24h: '24h 请求量',
tokens24h: '24h Token',
errorRate: '错误率',
activeIngressKeys: '活跃入口密钥',
requestTrend: '24h 请求趋势',
tokenTrend: '24h Token 趋势',
seriesRequests: '请求量',
seriesErrors: '错误数',
seriesTokens: 'Token',
providerHealth: 'Provider 健康状态',
ingressTop5: '入口密钥用量 Top 5',
providerModelUsage: 'Provider + 模型用量 (24h)',
clientIpUsage: '客户端 IP 用量 (24h)',
clientIp: '客户端 IP',
requestCount: '请求数',
},
providers: {
title: '出口管理',
description: '配置上游 Provider、接口路径与 API 密钥',
addProvider: '添加 Provider',
protocolType: '协议类型',
baseUrl: 'Base URL',
baseUrlPlaceholder: '留空使用默认',
enabledEndpoints: '已启用接口',
models: '模型',
modelsEmpty: '暂无模型,请点击「同步模型」从上游拉取',
displayName: '显示名称',
syncedAt: '同步时间',
addDialogTitle: '添加 Provider',
endpointConfig: '接口配置',
endpointConfigTitle: '接口配置 - {name}',
endpointDivider: '接口与 Path 配置',
endpointEditDivider: '启用接口与 Path 覆盖',
pathHint: 'Path 可为相对路径(基于 Base URL)或完整 URLhttp/https 开头)',
keysTitle: '密钥管理 - {name}',
addKey: '添加密钥',
addKeyDialogTitle: '添加 Provider 密钥',
apiKey: 'API Key',
modelWhitelist: '模型白名单',
modelWhitelistPlaceholder: '["*"] 或 gpt-4o-mini',
maxRequestsPerDay: '日请求上限',
maxTokensPerDay: '日 Token 上限',
requestsToday: '今日请求',
modelsTitle: '模型列表 - {name}',
syncModels: '同步模型',
modelsSummary: '共 {total} 个模型,已启用 {enabled} 个',
modelsAction: '模型',
endpointsAction: '接口配置',
keysAction: '密钥',
modelNotLoaded: '模型数据未加载,请刷新页面后重试',
modelEnabled: '模型已启用',
modelDisabled: '模型已禁用',
modelNotFound: '模型不存在,请同步模型后重试',
updateFailed: '更新失败',
createSuccess: '创建成功',
saved: '已保存',
deleted: '已删除',
syncedCount: '同步 {count} 个模型',
syncFailed: '同步失败',
keyAdded: '密钥已添加',
providerEnabled: '出口已启用',
providerDisabled: '出口已暂停',
addWorkflowModel: 'Add Workflow Model',
addModel: '添加模型',
addModelDialogTitle: '手动添加模型',
modelAdded: '模型已添加',
modelDeleted: '模型已删除',
confirmDeleteModel: '确认删除此模型?',
workflowDialogTitle: '注册 Workflow 模型',
modelId: 'Model ID',
modelAlias: '入口别名',
modelAliasPlaceholder: '可选,入口 API 使用此名称',
modelAliasHint: '留空则入口使用上游 Model ID',
upstreamModelId: '上游 Model ID',
editModelDialogTitle: '编辑模型',
providerNameExists: '出口名称已存在',
modelAliasExists: '模型别名已存在',
workflowType: '类型',
txt2img: '文生图',
img2img: '图生图',
workflowJson: 'Workflow JSON',
analyzeWorkflow: '解析 Workflow',
workflowJsonRequired: '请先粘贴 Workflow JSON',
analyzeFailed: '解析失败',
modelIdRequired: '请填写 Model ID',
workflowSaved: 'Workflow 模型已保存',
paramName: '参数',
bindEnabled: '绑定',
targetNode: '目标节点',
targetField: '目标字段',
},
ingress: {
title: '入口管理',
description: '创建与管理 Virtual Key,配置预算、限流与路由规则',
createKey: '创建入口密钥',
keyPrefix: '密钥前缀',
providerWhitelist: 'Provider 白名单',
modelWhitelist: 'Model 白名单',
budgetLimit: '预算上限',
rpmLimit: 'RPM 限制',
tpmLimit: 'TPM 限制',
whitelistAction: '白名单',
routingAction: '路由',
createDialogTitle: '创建入口密钥',
providerWhitelistPlaceholder: '不选表示允许全部 Provider',
modelWhitelistPlaceholder: '不选表示允许全部模型',
whitelistHint: '不选或清空表示允许全部',
modelWhitelistHint: '不选或清空表示允许全部;需先在出口管理同步模型',
editWhitelistTitle: '编辑白名单',
plainKeyTitle: '请保存密钥(仅显示一次)',
plainKeyWarning: '请立即复制保存,关闭后将无法再次查看完整密钥',
routingTitle: '路由规则 - {name}',
addRule: '添加规则',
modelPattern: '模型匹配',
modelPatternPlaceholder: 'gpt-4o* 或 claude-3-5-sonnet',
providerId: '出口',
providerIdPlaceholder: '选择出口',
keyIdOptional: '出口密钥',
keyIdPlaceholder: '留空则自动负载均衡',
providerRequired: '请选择出口',
addRuleTitle: '添加路由规则',
whitelistUpdated: '白名单已更新',
confirmDeleteKey: '确认删除此密钥?',
copied: '已复制',
ruleAdded: '规则已添加',
},
logs: {
title: '请求日志',
description: '最近保留 500 条记录,含推理请求与管理台登录日志',
ingress: '入口',
endpoint: '接口',
modelOrUser: '模型 / 用户',
modelOrUserPlaceholder: '模型 / 用户名',
clientIpPlaceholder: '客户端 IP',
statusSuccess: '成功',
statusError: '失败',
endpointAdminLogin: '管理台登录',
pagerHint: '共 {total} 条,最多保留 {max} 条',
detailTitle: '请求详情',
requestBody: '请求内容',
responseBody: '响应内容',
predictionStatus: '生图任务状态',
},
ipRules: {
title: 'IP 规则',
description: '配置管理台与推理 API 的 IP 白名单 / 黑名单',
addRule: '添加 IP 规则',
cidr: 'CIDR / IP',
cidrPlaceholder: '192.168.1.0/24 或 127.0.0.1',
scope: '范围',
typeAllow: '允许 (allow)',
typeDeny: '拒绝 (deny)',
scopeProxy: '推理 API',
scopeAdmin: '管理台',
scopeAll: '全部(管理台 + API',
allowWarning: '允许规则会限制管理台访问,请确保已包含你当前的 IP。',
added: '已添加',
},
settings: {
title: '系统设置',
description: '所有运行参数在管理台维护,无需配置文件即可启动',
securitySession: '安全与会话',
gatewayMonitor: '网关与监控',
changePassword: '修改管理员密码',
encryptionKey: '加密密钥',
encryptionKeyPlaceholder: '留空则不修改;修改后需与旧密钥一致才能解密已有 Provider Key',
encryptionKeyCurrent: '当前:{hint}',
sessionTtl: '会话有效期(秒)',
loginMaxAttempts: '登录最大尝试次数',
loginLockout: '登录锁定时长(秒)',
trustedProxies: '信任代理 IP/CIDR',
trustedProxiesPlaceholder: '每行一个,例如 127.0.0.1 或 10.0.0.0/8',
usageFlush: '用量刷盘间隔(秒)',
healthCheck: '健康检查间隔(秒)',
gatewayMaxRetries: '网关最大重试次数',
retryBackoff: '重试退避(毫秒)',
imageStorage: '图像存储',
imageStoragePath: '存储目录(相对 data-dir',
publicBaseUrl: '对外 Base URL',
signedUrlTtl: '签名 URL 有效期(秒)',
predictionWait: 'Prefer:wait 最长等待(秒)',
saveSettings: '保存系统设置',
oldPassword: '旧密码',
newPassword: '新密码',
savePassword: '保存密码',
settingsSaved: '系统设置已保存',
passwordUpdated: '密码已更新',
},
}
+97
View File
@@ -0,0 +1,97 @@
<template>
<div class="layout">
<aside class="sidebar custom-scrollbar">
<div class="sidebar-brand">
<div class="brand-icon">
<BrandIcon :size="20" />
</div>
<span class="brand-name">{{ t('common.brand') }}</span>
</div>
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: isActive(item.path) }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ t(item.labelKey) }}
</router-link>
<div class="nav-group-label">{{ t('nav.security') }}</div>
<router-link
v-for="item in securityItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: isActive(item.path) }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ t(item.labelKey) }}
</router-link>
</aside>
<div class="main-area">
<header class="topbar">
<span class="topbar-title">{{ currentTitle }}</span>
<div class="inline-actions">
<LocaleSwitcher />
<el-button text @click="logout">{{ t('nav.logout') }}</el-button>
</div>
</header>
<main class="main-content custom-scrollbar">
<router-view />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Connection, Document, Key, Lock, Odometer, Setting } from '@element-plus/icons-vue'
import api from '@/api/client'
import BrandIcon from '@/components/BrandIcon.vue'
import LocaleSwitcher from '@/components/LocaleSwitcher.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const navItems = [
{ path: '/dashboard', labelKey: 'nav.dashboard', icon: Odometer },
{ path: '/egress/providers', labelKey: 'nav.providers', icon: Connection },
{ path: '/ingress/keys', labelKey: 'nav.ingress', icon: Key },
{ path: '/logs', labelKey: 'nav.logs', icon: Document },
]
const securityItems = [
{ path: '/security/ip-rules', labelKey: 'nav.ipRules', icon: Lock },
{ path: '/security/settings', labelKey: 'nav.settings', icon: Setting },
]
const titleMap: Record<string, string> = {
'/dashboard': 'nav.dashboard',
'/egress/providers': 'nav.providers',
'/ingress/keys': 'nav.ingress',
'/logs': 'nav.logs',
'/security/ip-rules': 'nav.ipRules',
'/security/settings': 'nav.settings',
}
const currentTitle = computed(() => {
const key = titleMap[route.path]
return key ? t(key) : t('common.brand')
})
function isActive(path: string) {
return route.path === path || route.path.startsWith(path + '/')
}
async function logout() {
await api.post('/logout')
router.push('/login')
}
</script>
+29
View File
@@ -0,0 +1,29 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus, { ElDialog, ElDrawer } from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/theme.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { i18n } from './i18n'
function disableOverlayClose(component: typeof ElDialog) {
const props = component.props as Record<string, { default?: unknown }>
if (props.closeOnClickModal) {
props.closeOnClickModal.default = false
}
}
disableOverlayClose(ElDialog)
disableOverlayClose(ElDrawer)
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(i18n)
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+40
View File
@@ -0,0 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layouts/MainLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: 'egress/providers', name: 'providers', component: () => import('@/views/ProvidersView.vue') },
{ path: 'ingress/keys', name: 'ingress-keys', component: () => import('@/views/IngressKeysView.vue') },
{ path: 'logs', name: 'logs', component: () => import('@/views/RequestLogsView.vue') },
{ path: 'security/ip-rules', name: 'ip-rules', component: () => import('@/views/IPRulesView.vue') },
{ path: 'security/settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
],
},
],
})
router.beforeEach(async (to) => {
if (to.name === 'login') return true
try {
const res = await fetch('/api/admin/auth/status', { credentials: 'include' })
const data = await res.json()
if (!data.authenticated) return '/login'
} catch {
return '/login'
}
return true
})
export default router
+602
View File
@@ -0,0 +1,602 @@
:root {
--background: #f4f4f5;
--foreground: #18181b;
--card: #ffffff;
--card-foreground: #18181b;
--primary: #0f7a62;
--primary-hover: #0d6b56;
--primary-foreground: #fafafa;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--border: #e4e4e7;
--input: #e4e4e7;
--destructive: #dc2626;
--success: #047857;
--warning: #b45309;
--danger: #dc2626;
--info: #a1a1aa;
--sidebar: rgba(250, 250, 250, 0.85);
--sidebar-foreground: #18181b;
--sidebar-accent: #f4f4f5;
--sidebar-border: #e4e4e7;
--sidebar-width: 15rem;
--radius: 0.5rem;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--el-color-primary: #0f7a62;
--el-color-primary-light-3: #4da08c;
--el-color-primary-light-5: #76b8a8;
--el-color-primary-light-7: #a0d0c4;
--el-color-primary-light-8: #b8dcd2;
--el-color-primary-light-9: #d0e8e1;
--el-color-primary-dark-2: #0c624e;
--el-color-success: #047857;
--el-color-warning: #b45309;
--el-color-danger: #dc2626;
--el-color-info: #a1a1aa;
--el-border-radius-base: 0.5rem;
--el-border-color: #e4e4e7;
--el-bg-color-page: #f4f4f5;
--el-text-color-primary: #18181b;
--el-text-color-regular: #3f3f46;
--el-text-color-secondary: #71717a;
--el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-size: 14px;
font-family: var(--el-font-family);
background: var(--background);
color: var(--foreground);
}
.layout {
display: flex;
height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: var(--sidebar);
border-right: 1px solid var(--sidebar-border);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-4);
}
.brand-icon {
width: 32px;
height: 32px;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
display: flex;
align-items: center;
justify-content: center;
}
.brand-name {
font-size: 1.125rem;
font-weight: 600;
}
.nav-group-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted-foreground);
padding: var(--space-4) var(--space-4) var(--space-2);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin: 0 var(--space-2);
border-radius: var(--radius);
color: var(--muted-foreground);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.nav-item:hover { background: var(--sidebar-accent); }
.nav-item.active {
background: var(--sidebar-accent);
color: var(--primary);
font-weight: 600;
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.topbar {
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
background: var(--card);
border-bottom: 1px solid var(--border);
}
.topbar-title {
font-size: 0.875rem;
color: var(--muted-foreground);
}
.main-content {
flex: 1;
overflow-y: auto;
padding: var(--space-6);
}
.page {
max-width: 1400px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
}
.page-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 var(--space-1);
}
.page-desc {
font-size: 0.875rem;
color: var(--muted-foreground);
margin: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-5);
}
.stat-card__label {
font-size: 0.875rem;
color: var(--muted-foreground);
margin-bottom: var(--space-2);
}
.stat-card__value {
font-size: 1.75rem;
font-weight: 600;
}
.app-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-4);
}
.page > .app-card:last-child,
.page > .content-grid:last-child {
margin-bottom: 0;
}
.app-card__header {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.app-card__title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
}
.app-card__body--padded { padding: var(--space-5); }
.content-grid {
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; }
.content-grid--2 { grid-template-columns: 1fr 1fr; }
.content-grid--3 { grid-template-columns: repeat(3, 1fr); }
.card-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-5) 0;
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.card-toolbar--desc {
align-items: flex-start;
}
.card-pager {
padding: var(--space-4) var(--space-5);
display: flex;
justify-content: flex-end;
}
.card-footer-hint {
padding: 0 var(--space-5) var(--space-3);
margin: 0;
}
.card-footer-hint + .card-pager {
padding-top: var(--space-3);
}
.card-inset-block {
margin: 0 var(--space-5) var(--space-4);
padding: var(--space-3);
background: var(--muted);
border-radius: var(--radius);
font-size: 0.875rem;
}
.tabs-bar {
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border);
}
.tabs-bar .el-tabs__header { margin-bottom: 0; }
.page-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.inline-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.form-hint {
margin-top: 6px;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.form-hint--flush { margin-top: 0; }
.u-mt-3 { margin-top: var(--space-3); }
.u-mt-4 { margin-top: var(--space-4); }
.u-mb-3 { margin-bottom: var(--space-3); }
.u-mb-4 { margin-bottom: var(--space-4); }
.u-mb-6 { margin-bottom: var(--space-6); }
.text-center { text-align: center; }
.w-full { width: 100%; }
code.inline-code {
font-size: 0.75rem;
padding: 0 var(--space-1);
background: var(--muted);
border-radius: var(--radius);
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: var(--space-4);
}
.section-title:not(:first-child) { margin-top: var(--space-6); }
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--background);
padding: var(--space-6);
}
.login-wrap { max-width: 400px; width: 100%; text-align: center; }
.login-brand-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--primary);
color: var(--primary-foreground);
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-4);
}
.login-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-8);
margin-top: var(--space-6);
text-align: left;
}
.status-good { color: #047857; }
.status-warn { color: #b45309; }
.status-bad { color: #dc2626; }
.status-muted { color: var(--muted-foreground); }
.toolbar-select { width: 120px; }
.toolbar-select--country { width: 160px; }
.toolbar-select--wide { width: 280px; }
.toolbar-input { width: 200px; }
.toolbar-input--wide { width: 260px; }
.toolbar-input--grow { flex: 1; min-width: 200px; }
.table-cell-select { width: 100%; min-width: 200px; }
.el-form .el-select { width: 100%; }
.app-card .el-table {
--el-table-header-bg-color: var(--muted);
font-size: 0.875rem;
}
.app-card .el-table--small .el-table__cell {
padding: var(--space-2) var(--space-3);
}
.app-card .el-table--small th.el-table__cell {
font-size: 0.75rem;
font-weight: 600;
color: var(--muted-foreground);
}
.app-card .el-table--small td.el-table__cell {
color: var(--el-text-color-regular);
}
.app-card .el-table--small .el-table__row {
height: 40px;
}
.app-card .el-table .el-button.is-link {
padding: 0;
height: auto;
}
.app-card .el-table .cell {
line-height: 1.4;
}
.table-scroll--sm { max-height: 320px; }
.table-scroll--md { max-height: 360px; }
.table-scroll--lg { max-height: 480px; }
.card-toolbar .toolbar-input--grow {
flex: 1;
min-width: 200px;
}
.card-pager .el-pagination {
font-size: 0.875rem;
}
.kv-block p {
margin: 0 0 var(--space-2);
}
.kv-block p:last-child {
margin-bottom: 0;
}
.kv-block .label {
display: inline-block;
width: 5rem;
color: var(--muted-foreground);
}
.kv-block code {
font-size: 0.8125rem;
}
.cell-stack {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
}
.meta-list {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.8125rem;
color: var(--muted-foreground);
}
.meta-list li {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-1) 0;
}
.meta-list__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.meta-list__ok { color: var(--el-color-success); }
.meta-list__warn { color: var(--el-color-warning); }
.chart-box {
height: 280px;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #e4e4e7 transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e4e4e7;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
/* Luminary app extensions */
.page-form {
max-width: 800px;
}
.page-form--narrow {
max-width: 480px;
}
.page > .page-form + .app-card,
.page > .page-form + .page-form {
margin-top: var(--space-4);
}
.settings-form.el-form .el-form-item {
margin-bottom: var(--space-5);
}
.settings-form.el-form .el-input-number {
width: 200px;
}
.form-hint--destructive {
color: var(--destructive);
}
.drawer-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.el-drawer__body .card-inset-block {
margin-left: 0;
margin-right: 0;
}
.el-drawer__body pre.card-inset-block {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.5;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid var(--border);
}
.models-expand {
padding: var(--space-3) var(--space-6) var(--space-4) 48px;
background: var(--muted);
}
.models-empty {
color: var(--muted-foreground);
font-size: 0.875rem;
}
.models-table {
background: var(--card);
}
.endpoint-row {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--space-3);
align-items: center;
margin-bottom: var(--space-3);
}
.ep-tag {
margin: var(--space-1);
}
.soon-tag {
margin-left: var(--space-2);
}
@media (max-width: 1024px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.content-grid--2-1,
.content-grid--2,
.content-grid--3 { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.stat-grid { grid-template-columns: 1fr; }
.main-content { padding: var(--space-4); }
.page-header { flex-direction: column; }
}
+64
View File
@@ -0,0 +1,64 @@
import type { EChartsOption, LineSeriesOption } from 'echarts'
/** Design system §6.16 chart palette */
export const CHART_COLORS = {
primary: '#0f7a62',
secondary: '#71717a',
series: ['#0f7a62', '#71717a', '#4da08c', '#76b8a8', '#a0d0c4'] as const,
axis: '#71717a',
splitLine: '#e4e4e7',
}
export const CHART_GRID = { left: 40, right: 16, top: 16, bottom: 28 }
export function axisStyle() {
return {
axisLabel: { color: CHART_COLORS.axis, fontSize: 12 },
axisLine: { lineStyle: { color: CHART_COLORS.splitLine } },
}
}
export function lineSeries(
name: string,
data: number[],
colorIndex = 0,
): LineSeriesOption {
return {
name,
type: 'line',
smooth: true,
data,
color: CHART_COLORS.series[colorIndex] ?? CHART_COLORS.primary,
lineStyle: { width: 2 },
showSymbol: false,
}
}
export function buildTrendChartOption(params: {
labels: string[]
series: Array<{ name: string; data: number[]; colorIndex?: number }>
}): EChartsOption {
return {
color: [...CHART_COLORS.series],
grid: CHART_GRID,
tooltip: { trigger: 'axis' },
legend: {
bottom: 0,
textStyle: { fontSize: 12, color: CHART_COLORS.axis },
},
xAxis: {
type: 'category',
data: params.labels,
boundaryGap: false,
...axisStyle(),
},
yAxis: {
type: 'value',
...axisStyle(),
splitLine: { lineStyle: { color: CHART_COLORS.splitLine } },
},
series: params.series.map((s, i) =>
lineSeries(s.name, s.data, s.colorIndex ?? i),
),
}
}
+23
View File
@@ -0,0 +1,23 @@
export function formatDateTime(value?: string | null): string {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
export function formatChartHour(value: string, locale: string): string {
const d = new Date(value.replace(' ', 'T'))
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
if (locale === 'zh-CN') {
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:00`
}
return `${pad(d.getHours())}:00`
}
export function formatNumberCompact(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)
}
+166
View File
@@ -0,0 +1,166 @@
<template>
<div class="page">
<PageHeader
:title="t('dashboard.title')"
:description="t('dashboard.description')"
/>
<div class="stat-grid">
<StatCard :label="t('dashboard.requests24h')" :value="overview?.total_requests_24h ?? 0" />
<StatCard :label="t('dashboard.tokens24h')" :value="formatNumberCompact(overview?.total_tokens_24h ?? 0)" />
<StatCard :label="t('dashboard.errorRate')" :value="((overview?.error_rate ?? 0) * 100).toFixed(2) + '%'" />
<StatCard :label="t('dashboard.activeIngressKeys')" :value="overview?.active_ingress_keys ?? 0" />
</div>
<div class="content-grid content-grid--2">
<AppCard :title="t('dashboard.requestTrend')">
<ChartBox :option="requestChartOption" />
</AppCard>
<AppCard :title="t('dashboard.tokenTrend')">
<ChartBox :option="tokenChartOption" />
</AppCard>
</div>
<div class="content-grid content-grid--2-1">
<AppCard :title="t('dashboard.providerHealth')">
<el-table :data="providers" size="small">
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column prop="type" :label="t('common.type')" width="120" />
<el-table-column :label="t('common.status')" width="110">
<template #default="{ row }">
<el-tag :type="statusType(row.health?.status || row.status)" size="small">
{{ row.health?.status || row.status || 'unknown' }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.latencyMs')" width="100">
<template #default="{ row }">
<span :class="metricStatusClass(row.health?.latency_ms)">{{ row.health?.latency_ms ?? t('common.dash') }}</span>
</template>
</el-table-column>
</el-table>
</AppCard>
<AppCard :title="t('dashboard.ingressTop5')">
<el-table :data="ingressUsage" size="small">
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column prop="request_count" :label="t('common.requests')" width="80" />
<el-table-column prop="token_count" :label="t('common.tokens')" width="90" />
</el-table>
</AppCard>
</div>
<div class="content-grid content-grid--2-1">
<AppCard :title="t('dashboard.providerModelUsage')">
<el-table :data="providerModelUsage" size="small">
<el-table-column prop="provider_name" label="Provider" min-width="120" show-overflow-tooltip />
<el-table-column prop="model" :label="t('common.model')" min-width="160" show-overflow-tooltip />
<el-table-column prop="request_count" :label="t('common.requests')" width="80" />
<el-table-column prop="token_count" :label="t('common.tokens')" width="90" />
<el-table-column :label="t('common.cost')" width="100">
<template #default="{ row }">{{ row.cost.toFixed(4) }}</template>
</el-table-column>
</el-table>
</AppCard>
<AppCard :title="t('dashboard.clientIpUsage')">
<el-table :data="clientIPUsage" size="small">
<el-table-column prop="client_ip" :label="t('dashboard.clientIp')" min-width="140" />
<el-table-column prop="request_count" :label="t('common.requests')" width="80" />
<el-table-column prop="token_count" :label="t('common.tokens')" width="90" />
<el-table-column :label="t('common.cost')" width="100">
<template #default="{ row }">{{ row.cost.toFixed(4) }}</template>
</el-table-column>
</el-table>
</AppCard>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { EChartsOption } from 'echarts'
import api, {
type ClientIPUsage,
type DashboardOverview,
type IngressKey,
type ProviderModelUsage,
type RequestTrendBucket,
} from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import StatCard from '@/components/StatCard.vue'
import AppCard from '@/components/AppCard.vue'
import ChartBox from '@/components/ChartBox.vue'
import { buildTrendChartOption } from '@/utils/chartTheme'
import { formatChartHour, formatNumberCompact } from '@/utils/format'
const { t, locale } = useI18n()
const overview = ref<DashboardOverview | null>(null)
const providers = ref<any[]>([])
const ingressUsage = ref<IngressKey[]>([])
const providerModelUsage = ref<ProviderModelUsage[]>([])
const clientIPUsage = ref<ClientIPUsage[]>([])
const trend = ref<RequestTrendBucket[]>([])
let timer: number
const chartLabels = computed(() =>
trend.value.map((b) => formatChartHour(b.hour, locale.value)),
)
const requestChartOption = computed<EChartsOption>(() =>
buildTrendChartOption({
labels: chartLabels.value,
series: [
{ name: t('dashboard.seriesRequests'), data: trend.value.map((b) => b.requests), colorIndex: 0 },
{ name: t('dashboard.seriesErrors'), data: trend.value.map((b) => b.errors), colorIndex: 1 },
],
}),
)
const tokenChartOption = computed<EChartsOption>(() =>
buildTrendChartOption({
labels: chartLabels.value,
series: [
{ name: t('dashboard.seriesTokens'), data: trend.value.map((b) => b.tokens), colorIndex: 0 },
],
}),
)
function statusType(s: string) {
if (s === 'healthy') return 'success'
if (s === 'unhealthy') return 'danger'
return 'warning'
}
function metricStatusClass(ms?: number) {
if (ms == null) return 'status-muted'
if (ms < 500) return 'status-good'
if (ms < 2000) return 'status-warn'
return 'status-bad'
}
async function load() {
const [o, p, i, pm, ip, tr] = await Promise.all([
api.get('/dashboard/overview'),
api.get('/dashboard/providers'),
api.get('/dashboard/ingress-usage'),
api.get('/dashboard/provider-model-usage'),
api.get('/dashboard/client-ip-usage'),
api.get('/dashboard/request-trend'),
])
overview.value = o.data
providers.value = p.data.data
ingressUsage.value = [...i.data.data].sort((a, b) => b.request_count - a.request_count).slice(0, 5)
providerModelUsage.value = pm.data.data
clientIPUsage.value = ip.data.data
trend.value = tr.data.data
}
onMounted(() => {
load()
timer = window.setInterval(load, 15000)
})
onUnmounted(() => clearInterval(timer))
</script>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="page">
<PageHeader
:title="t('ipRules.title')"
:description="t('ipRules.description')"
>
<template #actions>
<el-button type="primary" @click="showCreate = true">{{ t('ipRules.addRule') }}</el-button>
</template>
</PageHeader>
<AppCard>
<el-table :data="rules" size="small">
<el-table-column prop="cidr" :label="t('ipRules.cidr')" min-width="160" />
<el-table-column prop="type" :label="t('common.type')" width="100" />
<el-table-column prop="scope" :label="t('ipRules.scope')" width="120" />
<el-table-column :label="t('common.enabled')" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.yes') : t('common.no') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog v-model="showCreate" :title="t('ipRules.addRule')" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('ipRules.cidr')"><el-input v-model="form.cidr" :placeholder="t('ipRules.cidrPlaceholder')" /></el-form-item>
<el-form-item :label="t('common.type')">
<el-select v-model="form.type">
<el-option :label="t('ipRules.typeAllow')" value="allow" />
<el-option :label="t('ipRules.typeDeny')" value="deny" />
</el-select>
</el-form-item>
<el-form-item :label="t('ipRules.scope')">
<el-select v-model="form.scope">
<el-option :label="t('ipRules.scopeProxy')" value="proxy" />
<el-option :label="t('ipRules.scopeAdmin')" value="admin" />
<el-option :label="t('ipRules.scopeAll')" value="all" />
</el-select>
<p v-if="form.type === 'allow' && form.scope !== 'proxy'" class="form-hint form-hint--destructive">
{{ t('ipRules.allowWarning') }}
</p>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="create">{{ t('common.add') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import api, { type IPRule } from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
const { t } = useI18n()
const rules = ref<IPRule[]>([])
const showCreate = ref(false)
const form = ref({ cidr: '', type: 'allow' as const, scope: 'proxy' as const })
async function load() {
const res = await api.get('/ip-rules')
rules.value = res.data.data
}
async function create() {
await api.post('/ip-rules', form.value)
showCreate.value = false
form.value = { cidr: '', type: 'allow', scope: 'proxy' }
ElMessage.success(t('ipRules.added'))
load()
}
async function remove(row: IPRule) {
await ElMessageBox.confirm(t('common.confirmDelete'))
await api.delete(`/ip-rules/${row.id}`)
load()
}
onMounted(load)
</script>
+462
View File
@@ -0,0 +1,462 @@
<template>
<div class="page">
<PageHeader
:title="t('ingress.title')"
:description="t('ingress.description')"
>
<template #actions>
<el-button type="primary" @click="openCreate">{{ t('ingress.createKey') }}</el-button>
</template>
</PageHeader>
<AppCard>
<el-table :data="keys" v-loading="loading" size="small">
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
<el-table-column prop="prefix" :label="t('ingress.keyPrefix')" width="140" />
<el-table-column prop="enabled" :label="t('common.status')" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.enabled') : t('common.disabled') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('ingress.providerWhitelist')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ formatWhitelist(row.allowed_providers_json) }}</template>
</el-table-column>
<el-table-column :label="t('ingress.modelWhitelist')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ formatWhitelist(row.allowed_models_json) }}</template>
</el-table-column>
<el-table-column :label="t('common.budget')" min-width="140">
<template #default="{ row }">
{{ row.budget_used.toFixed(4) }} / {{ row.budget_limit || t('common.infinity') }}
</template>
</el-table-column>
<el-table-column prop="request_count" :label="t('dashboard.requestCount')" width="90" />
<el-table-column prop="token_count" :label="t('common.tokens')" width="100" />
<el-table-column :label="t('common.actions')" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" link @click="openWhitelist(row)">{{ t('ingress.whitelistAction') }}</el-button>
<el-button size="small" link @click="openRouting(row)">{{ t('ingress.routingAction') }}</el-button>
<el-button size="small" link @click="toggle(row)">{{ row.enabled ? t('common.disabled') : t('common.enabled') }}</el-button>
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog v-model="showCreate" :title="t('ingress.createDialogTitle')" width="560px" destroy-on-close @open="loadCatalog">
<el-form label-width="120px">
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
<el-form-item :label="t('ingress.budgetLimit')"><el-input-number v-model="form.budget_limit" :min="0" :precision="4" class="w-full" /></el-form-item>
<el-form-item :label="t('ingress.rpmLimit')"><el-input-number v-model="form.rate_limit_rpm" :min="0" class="w-full" /></el-form-item>
<el-form-item :label="t('ingress.tpmLimit')"><el-input-number v-model="form.rate_limit_tpm" :min="0" class="w-full" /></el-form-item>
<el-form-item :label="t('ingress.providerWhitelist')">
<el-select
v-model="form.allowed_providers"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
:placeholder="t('ingress.providerWhitelistPlaceholder')"
>
<el-option v-for="p in catalogProviders" :key="p.name" :label="p.name" :value="p.name" />
</el-select>
<div class="form-hint">{{ t('ingress.whitelistHint') }}</div>
</el-form-item>
<el-form-item :label="t('ingress.modelWhitelist')">
<el-select
v-model="form.allowed_models"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
:placeholder="t('ingress.modelWhitelistPlaceholder')"
>
<el-option
v-for="m in filteredModelOptions(form.allowed_providers)"
:key="m.model_id + '@' + m.provider_name"
:label="m.label"
:value="m.model_id"
/>
</el-select>
<div class="form-hint">{{ t('ingress.modelWhitelistHint') }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="create">{{ t('common.create') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="showWhitelist" :title="t('ingress.editWhitelistTitle')" width="560px" destroy-on-close>
<el-form label-width="120px">
<el-form-item :label="t('ingress.providerWhitelist')">
<el-select
v-model="whitelistForm.allowed_providers"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
:placeholder="t('ingress.providerWhitelistPlaceholder')"
>
<el-option v-for="p in catalogProviders" :key="p.name" :label="p.name" :value="p.name" />
</el-select>
</el-form-item>
<el-form-item :label="t('ingress.modelWhitelist')">
<el-select
v-model="whitelistForm.allowed_models"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
:placeholder="t('ingress.modelWhitelistPlaceholder')"
>
<el-option
v-for="m in filteredModelOptions(whitelistForm.allowed_providers)"
:key="m.model_id + '@' + m.provider_name"
:label="m.label"
:value="m.model_id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showWhitelist = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveWhitelist">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="showPlainKey" :title="t('ingress.plainKeyTitle')" width="520px">
<el-alert type="warning" :closable="false" :title="t('ingress.plainKeyWarning')" />
<el-input v-model="plainKey" readonly class="u-mt-3">
<template #append><el-button @click="copyKey">{{ t('common.copy') }}</el-button></template>
</el-input>
</el-dialog>
<el-drawer v-model="routingVisible" :title="t('ingress.routingTitle', { name: current?.name || '' })" size="50%">
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="openRuleForm">{{ t('ingress.addRule') }}</el-button>
</div>
<el-table :data="routingRules" size="small">
<el-table-column prop="model_pattern" :label="t('ingress.modelPattern')" min-width="140" />
<el-table-column :label="t('ingress.providerId')" min-width="120">
<template #default="{ row }">{{ providerName(row.provider_id) }}</template>
</el-table-column>
<el-table-column :label="t('ingress.keyIdOptional')" min-width="120">
<template #default="{ row }">{{ keyName(row.provider_id, row.provider_key_id) }}</template>
</el-table-column>
<el-table-column prop="priority" :label="t('common.priority')" width="80" />
<el-table-column prop="enabled" :label="t('common.enabled')" width="70">
<template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.yes') : t('common.no') }}</el-tag></template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="80">
<template #default="{ row }">
<el-button size="small" type="danger" link @click="deleteRule(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog v-model="showRuleForm" :title="t('ingress.addRuleTitle')" width="480px" destroy-on-close @open="loadRoutingCatalog">
<el-form label-width="120px">
<el-form-item :label="t('ingress.modelPattern')">
<el-input v-model="ruleForm.model_pattern" :placeholder="t('ingress.modelPatternPlaceholder')" />
</el-form-item>
<el-form-item :label="t('ingress.providerId')" required>
<el-select
v-model="ruleForm.provider_id"
filterable
class="w-full"
:placeholder="t('ingress.providerIdPlaceholder')"
@change="onRuleProviderChange"
>
<el-option v-for="p in routingProviders" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('ingress.keyIdOptional')">
<el-select
v-model="ruleForm.provider_key_id"
filterable
clearable
class="w-full"
:placeholder="t('ingress.keyIdPlaceholder')"
:disabled="!ruleForm.provider_id"
>
<el-option :label="t('common.auto')" :value="0" />
<el-option
v-for="k in keysForRuleProvider"
:key="k.id"
:label="k.name"
:value="k.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('common.priority')"><el-input-number v-model="ruleForm.priority" class="w-full" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showRuleForm = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="createRule">{{ t('common.add') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import api, { type IngressKey, type Provider, type ProviderKey } from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
const { t } = useI18n()
interface CatalogProvider { id: number; name: string }
interface RoutingProvider { id: number; name: string; keys: { id: number; name: string }[] }
interface ModelOption { model_id: string; display_name: string; provider_name: string; label: string }
const keys = ref<IngressKey[]>([])
const loading = ref(false)
const showCreate = ref(false)
const showWhitelist = ref(false)
const showPlainKey = ref(false)
const plainKey = ref('')
const routingVisible = ref(false)
const showRuleForm = ref(false)
const current = ref<IngressKey | null>(null)
const routingRules = ref<any[]>([])
const ruleForm = ref({
model_pattern: '',
provider_id: undefined as number | undefined,
provider_key_id: 0,
priority: 10,
})
const routingProviders = ref<RoutingProvider[]>([])
const catalogProviders = ref<CatalogProvider[]>([])
const catalogModels = ref<ModelOption[]>([])
const form = ref({
name: '',
budget_limit: 0,
rate_limit_rpm: 0,
rate_limit_tpm: 0,
allowed_providers: [] as string[],
allowed_models: [] as string[],
})
const whitelistForm = ref({
allowed_providers: [] as string[],
allowed_models: [] as string[],
})
const keysForRuleProvider = computed(() => {
if (!ruleForm.value.provider_id) return []
return routingProviders.value.find(p => p.id === ruleForm.value.provider_id)?.keys ?? []
})
function providerName(id: number) {
return routingProviders.value.find(p => p.id === id)?.name ?? catalogProviders.value.find(p => p.id === id)?.name ?? `#${id}`
}
function keyName(providerId: number, keyId: number) {
if (!keyId) return t('common.auto')
const provider = routingProviders.value.find(p => p.id === providerId)
return provider?.keys.find(k => k.id === keyId)?.name ?? `#${keyId}`
}
function onRuleProviderChange() {
ruleForm.value.provider_key_id = 0
}
function parseJsonList(json: string): string[] {
try {
const v = JSON.parse(json || '[]')
return Array.isArray(v) ? v : ['*']
} catch {
return ['*']
}
}
function isAllowAll(list: string[]) {
return list.length === 0 || list.includes('*')
}
function formatWhitelist(json: string) {
const list = parseJsonList(json)
if (isAllowAll(list)) return t('common.all')
if (list.length <= 2) return list.join('、')
return t('common.whitelistMore', { items: list.slice(0, 2).join('、'), count: list.length })
}
function jsonListToSelection(json: string): string[] {
const list = parseJsonList(json)
return isAllowAll(list) ? [] : list
}
function selectionToPayload(list: string[]) {
return list.length === 0 ? [] : list
}
function filteredModelOptions(selectedProviders: string[]) {
if (isAllowAll(selectedProviders)) return catalogModels.value
return catalogModels.value.filter(m => selectedProviders.includes(m.provider_name))
}
async function loadCatalog() {
const res = await api.get('/providers')
const providers: Provider[] = res.data.data
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name }))
const models: ModelOption[] = []
for (const p of providers) {
for (const m of p.models || []) {
if (!m.enabled) continue
const ingressId = m.alias || m.model_id
models.push({
model_id: ingressId,
display_name: m.display_name || m.model_id,
provider_name: p.name,
label: m.alias
? `${m.alias}${m.model_id} (${p.name})`
: `${m.model_id} (${p.name})`,
})
}
}
catalogModels.value = models
}
async function loadRoutingCatalog() {
const res = await api.get('/providers')
const providers: Provider[] = res.data.data ?? []
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name }))
routingProviders.value = await Promise.all(providers.map(async (p) => {
const keysRes = await api.get(`/providers/${p.id}/keys`)
const keys = (keysRes.data.data as ProviderKey[] || [])
.filter(k => k.enabled)
.map(k => ({ id: k.id, name: k.name }))
return { id: p.id, name: p.name, keys }
}))
}
async function load() {
loading.value = true
try {
const res = await api.get('/ingress-keys')
keys.value = res.data.data
} finally {
loading.value = false
}
}
function openCreate() {
form.value = {
name: '',
budget_limit: 0,
rate_limit_rpm: 0,
rate_limit_tpm: 0,
allowed_providers: [],
allowed_models: [],
}
showCreate.value = true
}
async function create() {
const res = await api.post('/ingress-keys', {
name: form.value.name,
budget_limit: form.value.budget_limit,
rate_limit_rpm: form.value.rate_limit_rpm,
rate_limit_tpm: form.value.rate_limit_tpm,
allowed_providers: selectionToPayload(form.value.allowed_providers),
allowed_models: selectionToPayload(form.value.allowed_models),
})
showCreate.value = false
plainKey.value = res.data.plain_key
showPlainKey.value = true
load()
}
function openWhitelist(row: IngressKey) {
current.value = row
whitelistForm.value = {
allowed_providers: jsonListToSelection(row.allowed_providers_json),
allowed_models: jsonListToSelection(row.allowed_models_json),
}
showWhitelist.value = true
}
async function saveWhitelist() {
if (!current.value) return
await api.put(`/ingress-keys/${current.value.id}`, {
allowed_providers: selectionToPayload(whitelistForm.value.allowed_providers),
allowed_models: selectionToPayload(whitelistForm.value.allowed_models),
})
showWhitelist.value = false
ElMessage.success(t('ingress.whitelistUpdated'))
load()
}
async function toggle(row: IngressKey) {
await api.put(`/ingress-keys/${row.id}`, { enabled: !row.enabled })
load()
}
async function remove(row: IngressKey) {
await ElMessageBox.confirm(t('ingress.confirmDeleteKey'))
await api.delete(`/ingress-keys/${row.id}`)
ElMessage.success(t('providers.deleted'))
load()
}
function copyKey() {
navigator.clipboard.writeText(plainKey.value)
ElMessage.success(t('ingress.copied'))
}
async function openRouting(row: IngressKey) {
current.value = row
await loadRoutingCatalog()
const res = await api.get(`/ingress-keys/${row.id}/routing-rules`)
routingRules.value = res.data.data
routingVisible.value = true
}
function openRuleForm() {
ruleForm.value = {
model_pattern: '',
provider_id: undefined,
provider_key_id: 0,
priority: 10,
}
showRuleForm.value = true
}
async function createRule() {
if (!current.value) return
if (!ruleForm.value.model_pattern.trim()) {
ElMessage.warning(t('ingress.modelPatternPlaceholder'))
return
}
if (!ruleForm.value.provider_id) {
ElMessage.warning(t('ingress.providerRequired'))
return
}
await api.post(`/ingress-keys/${current.value.id}/routing-rules`, {
model_pattern: ruleForm.value.model_pattern.trim(),
provider_id: ruleForm.value.provider_id,
provider_key_id: ruleForm.value.provider_key_id || 0,
priority: ruleForm.value.priority,
})
showRuleForm.value = false
openRouting(current.value)
ElMessage.success(t('ingress.ruleAdded'))
}
async function deleteRule(row: any) {
await api.delete(`/routing-rules/${row.id}`)
if (current.value) openRouting(current.value)
}
onMounted(() => {
loadCatalog()
load()
})
</script>
+65
View File
@@ -0,0 +1,65 @@
<template>
<div class="login-page">
<div class="login-wrap">
<div class="login-brand-icon">
<BrandIcon :size="24" />
</div>
<h1 class="page-title">{{ t('common.brand') }}</h1>
<p class="page-desc">{{ t('login.subtitle') }}</p>
<div class="login-card">
<h3 class="app-card__title u-mb-6">{{ t('login.title') }}</h3>
<el-form size="large" @submit.prevent="submit">
<el-form-item :label="t('login.username')">
<el-input v-model="username" placeholder="admin" />
</el-form-item>
<el-form-item :label="t('common.password')">
<el-input v-model="password" type="password" show-password :placeholder="t('login.passwordPlaceholder')" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="w-full">
{{ t('login.submit') }}
</el-button>
</el-form>
<p class="form-hint u-mt-4 text-center">{{ t('login.footer') }}</p>
</div>
<div class="u-mt-4">
<LocaleSwitcher />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import api from '@/api/client'
import BrandIcon from '@/components/BrandIcon.vue'
import LocaleSwitcher from '@/components/LocaleSwitcher.vue'
const router = useRouter()
const { t } = useI18n()
const username = ref('admin')
const password = ref('admin123')
const loading = ref(false)
async function submit() {
loading.value = true
try {
await api.post('/login', { username: username.value, password: password.value })
ElMessage.success(t('login.success'))
router.push('/dashboard')
} catch (e: any) {
if (e.response?.status === 429) {
const sec = e.response?.data?.retry_after_sec
ElMessage.error(sec ? t('login.rateLimitedSec', { sec }) : t('login.rateLimited'))
} else if (e.response?.status === 403) {
ElMessage.error(t('login.ipBlocked'))
} else {
ElMessage.error(t('login.failed'))
}
} finally {
loading.value = false
}
}
</script>
+716
View File
@@ -0,0 +1,716 @@
<template>
<div class="page">
<PageHeader
:title="t('providers.title')"
:description="t('providers.description')"
>
<template #actions>
<el-button type="primary" :disabled="!currentCategorySupported" @click="openCreate">
{{ t('providers.addProvider') }}
</el-button>
</template>
</PageHeader>
<AppCard>
<div class="tabs-bar">
<el-tabs v-model="activeCategory" @tab-change="load">
<el-tab-pane v-for="cat in categories" :key="cat.key" :name="cat.key">
<template #label>
{{ cat.label }}
<el-tag v-if="!cat.supported" size="small" type="info" class="soon-tag">{{ t('common.soon') }}</el-tag>
</template>
</el-tab-pane>
</el-tabs>
</div>
<el-table :data="providers" v-loading="loading" row-key="id" size="small">
<el-table-column type="expand">
<template #default="{ row }">
<div class="models-expand">
<div v-if="!row.models?.length" class="models-empty">{{ t('providers.modelsEmpty') }}</div>
<el-table v-else :data="row.models" size="small" class="models-table">
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
<template #default="{ row: m }">{{ m.alias || '—' }}</template>
</el-table-column>
<el-table-column v-if="row.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
<el-table-column :label="t('common.enabled')" width="80">
<template #default="{ row: m }">
<el-switch
:model-value="m.enabled"
size="small"
@change="(v: boolean) => toggleModel(m, v, row)"
/>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="t('providers.syncedAt')" width="180">
<template #default="{ row: m }">{{ formatDateTime(m.created_at) }}</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
<el-table-column prop="type" :label="t('providers.protocolType')" width="130">
<template #default="{ row }">{{ protocolTypeLabel(row.type) }}</template>
</el-table-column>
<el-table-column :label="t('common.category')" width="100">
<template #default="{ row }">{{ categoryLabel(row.category) }}</template>
</el-table-column>
<el-table-column :label="t('providers.models')" width="120">
<template #default="{ row }">
<el-tag size="small" :type="enabledModelCount(row) > 0 ? 'success' : 'info'">
{{ enabledModelCount(row) }}/{{ row.models?.length || 0 }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="base_url" label="Base URL" show-overflow-tooltip min-width="160" />
<el-table-column :label="t('providers.enabledEndpoints')" min-width="180">
<template #default="{ row }">
<el-tag v-for="ep in parseList(row.allowed_endpoints_json)" :key="ep" size="small" class="ep-tag">{{ ep }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" :label="t('common.status')" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'healthy' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.enabled')" width="80">
<template #default="{ row }">
<el-switch :model-value="row.enabled !== false" size="small" @change="(v: boolean) => toggleProvider(row, v)" />
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="320" fixed="right">
<template #default="{ row }">
<el-button size="small" link @click="openModels(row)">{{ t('providers.modelsAction') }}</el-button>
<el-button size="small" link @click="openEndpoints(row)">{{ t('providers.endpointsAction') }}</el-button>
<el-button size="small" link @click="openKeys(row)">{{ t('providers.keysAction') }}</el-button>
<el-button v-if="row.category !== 'image' && providerHasListModels(row)" size="small" link @click="syncModels(row)">{{ t('providers.syncModels') }}</el-button>
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog v-model="showCreate" :title="t('providers.addDialogTitle')" width="640px" destroy-on-close>
<el-form label-width="110px">
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
<el-form-item :label="t('common.category')">
<el-select v-model="form.category" @change="onCategoryChange">
<el-option v-for="cat in categories" :key="cat.key" :label="cat.label" :value="cat.key" :disabled="!cat.supported" />
</el-select>
</el-form-item>
<el-form-item :label="t('providers.protocolType')">
<el-select v-model="form.type" @change="onProtocolTypeChange">
<el-option
v-for="pt in createProviderTypes"
:key="pt.key"
:label="pt.label"
:value="pt.key"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('providers.baseUrl')"><el-input v-model="form.base_url" :placeholder="t('providers.baseUrlPlaceholder')" /></el-form-item>
<template v-if="form.category !== 'image'">
<el-divider>{{ t('providers.endpointDivider') }}</el-divider>
<div v-for="ep in createEndpointMeta" :key="ep.key" class="endpoint-row">
<el-checkbox v-model="form.allowedMap[ep.key]">{{ ep.label }}</el-checkbox>
<el-input v-model="form.pathMap[ep.key]" :placeholder="ep.default_path" size="small" />
</div>
</template>
</el-form>
<template #footer>
<el-button @click="showCreate = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="create">{{ t('common.create') }}</el-button>
</template>
</el-dialog>
<el-drawer v-model="endpointVisible" :title="t('providers.endpointConfigTitle', { name: current?.name || '' })" size="50%">
<el-form label-width="110px">
<el-form-item :label="t('providers.baseUrl')"><el-input v-model="editForm.base_url" /></el-form-item>
<template v-if="current?.category !== 'image'">
<el-divider>{{ t('providers.endpointEditDivider') }}</el-divider>
<p class="form-hint">{{ t('providers.pathHint') }}</p>
<div v-for="ep in editEndpointMeta" :key="ep.key" class="endpoint-row">
<el-checkbox v-model="editForm.allowedMap[ep.key]">{{ ep.label }}</el-checkbox>
<el-input v-model="editForm.pathMap[ep.key]" :placeholder="ep.default_path" size="small" />
</div>
</template>
</el-form>
<template #footer>
<el-button type="primary" @click="saveEndpoints">{{ t('common.save') }}</el-button>
</template>
</el-drawer>
<el-drawer v-model="keysVisible" :title="t('providers.keysTitle', { name: current?.name || '' })" size="50%">
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="showKeyForm = true">{{ t('providers.addKey') }}</el-button>
</div>
<el-table :data="keys" size="small">
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column prop="weight" :label="t('common.weight')" width="80" />
<el-table-column prop="enabled" :label="t('common.enabled')" width="80">
<template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.yes') : t('common.no') }}</el-tag></template>
</el-table-column>
<el-table-column prop="requests_today" :label="t('providers.requestsToday')" />
<el-table-column :label="t('common.actions')" width="100">
<template #default="{ row }">
<el-button size="small" type="danger" link @click="deleteKey(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog v-model="showKeyForm" :title="t('providers.addKeyDialogTitle')" width="480px" destroy-on-close>
<el-form label-width="120px">
<el-form-item :label="t('common.name')"><el-input v-model="keyForm.name" /></el-form-item>
<el-form-item :label="t('providers.apiKey')"><el-input v-model="keyForm.api_key" type="password" show-password /></el-form-item>
<el-form-item :label="t('common.weight')"><el-input-number v-model="keyForm.weight" :min="0.1" :step="0.1" /></el-form-item>
<el-form-item :label="t('providers.modelWhitelist')"><el-input v-model="keyForm.models" :placeholder="t('providers.modelWhitelistPlaceholder')" /></el-form-item>
<el-form-item :label="t('providers.maxRequestsPerDay')"><el-input-number v-model="keyForm.max_requests_per_day" :min="0" /></el-form-item>
<el-form-item :label="t('providers.maxTokensPerDay')"><el-input-number v-model="keyForm.max_tokens_per_day" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showKeyForm = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="createKey">{{ t('common.add') }}</el-button>
</template>
</el-dialog>
<el-drawer v-model="modelsVisible" :title="t('providers.modelsTitle', { name: current?.name || '' })" size="50%">
<div class="drawer-toolbar">
<el-button v-if="current?.category === 'image'" type="primary" size="small" @click="showWorkflowDialog = true">
{{ t('providers.addWorkflowModel') }}
</el-button>
<template v-else-if="current">
<el-button v-if="providerHasListModels(current)" type="primary" size="small" @click="syncModels(current)">
{{ t('providers.syncModels') }}
</el-button>
<el-button type="primary" size="small" plain @click="openAddModel">{{ t('providers.addModel') }}</el-button>
</template>
<span class="form-hint u-mt-0">
{{ t('providers.modelsSummary', { total: providerModels.length, enabled: enabledProviderModelCount }) }}
</span>
</div>
<el-table :data="providerModels" size="small" v-loading="modelsLoading">
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.alias || '—' }}</template>
</el-table-column>
<el-table-column v-if="current?.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
<el-table-column :label="t('common.enabled')" width="80">
<template #default="{ row }">
<el-switch
:model-value="row.enabled"
size="small"
@change="(v: boolean) => toggleModel(row, v)"
/>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="t('providers.syncedAt')" width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="120">
<template #default="{ row }">
<el-button size="small" link @click="openEditModel(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="danger" link @click="deleteModel(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog v-model="showAddModel" :title="t('providers.addModelDialogTitle')" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('providers.modelId')" required>
<el-input v-model="addModelForm.model_id" placeholder="text-embedding-3-small" />
</el-form-item>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="addModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
</el-form-item>
<el-form-item :label="t('providers.displayName')">
<el-input v-model="addModelForm.display_name" :placeholder="t('providers.modelId')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddModel = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="createModel">{{ t('common.create') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="showEditModel" :title="t('providers.editModelDialogTitle')" width="480px" destroy-on-close>
<el-form label-width="110px">
<el-form-item :label="t('providers.upstreamModelId')">
<el-input v-model="editModelForm.model_id" />
</el-form-item>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="editModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
</el-form-item>
<el-form-item :label="t('providers.displayName')">
<el-input v-model="editModelForm.display_name" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditModel = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveEditModel">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
<WorkflowModelDialog
v-if="current"
v-model="showWorkflowDialog"
:provider-id="current.id"
@saved="current && openModels(current)"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import api, { type Provider, type ProviderKey, type ModelEntry } from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import WorkflowModelDialog from '@/components/WorkflowModelDialog.vue'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
interface CategoryMeta { key: string; label: string; supported: boolean }
interface ProviderTypeMeta { key: string; label: string }
interface EndpointMetaItem { key: string; label: string; method: string; ingress_path: string; default_path: string }
const categories = ref<CategoryMeta[]>([])
const createProviderTypes = ref<ProviderTypeMeta[]>([])
const createEndpointMeta = ref<EndpointMetaItem[]>([])
const editEndpointMeta = ref<EndpointMetaItem[]>([])
const providerTypeLabels = ref<Record<string, string>>({})
const activeCategory = ref('llm')
const providers = ref<Provider[]>([])
const loading = ref(false)
const showCreate = ref(false)
const form = ref({
name: '', type: 'openai', category: 'llm', base_url: '',
allowedMap: {} as Record<string, boolean>,
pathMap: {} as Record<string, string>,
})
const editForm = ref({
base_url: '',
allowedMap: {} as Record<string, boolean>,
pathMap: {} as Record<string, string>,
})
const endpointVisible = ref(false)
const current = ref<Provider | null>(null)
const keysVisible = ref(false)
const keys = ref<ProviderKey[]>([])
const showKeyForm = ref(false)
const modelsVisible = ref(false)
const modelsLoading = ref(false)
const providerModels = ref<ModelEntry[]>([])
const enabledProviderModelCount = computed(() =>
providerModels.value.filter(m => m.enabled).length,
)
const showWorkflowDialog = ref(false)
const showAddModel = ref(false)
const showEditModel = ref(false)
const addModelForm = ref({ model_id: '', alias: '', display_name: '' })
const editModelForm = ref({ id: 0, model_id: '', alias: '', display_name: '' })
const keyForm = ref({ name: '', api_key: '', weight: 1, models: '["*"]', max_requests_per_day: 0, max_tokens_per_day: 0 })
let createMetaSeq = 0
let editMetaSeq = 0
const currentCategorySupported = computed(() =>
categories.value.find(c => c.key === activeCategory.value)?.supported ?? true,
)
function categoryLabel(key: string) {
return categories.value.find(c => c.key === key)?.label || key
}
function protocolTypeLabel(key: string) {
return providerTypeLabels.value[key] || key
}
function defaultProviderType(category: string, types: ProviderTypeMeta[]) {
if (types.length > 0) return types[0].key
return category === 'image' ? 'comfyui' : 'openai'
}
function apiErrorMessage(error: string | undefined, fallback: string) {
if (error === 'provider name already exists') return t('providers.providerNameExists')
if (error === 'model alias already exists') return t('providers.modelAliasExists')
return error || fallback
}
function providerHasListModels(row: Provider) {
return parseList(row.allowed_endpoints_json).includes('list_models')
}
async function fetchEndpointMeta(category: string, type?: string) {
const res = await api.get('/endpoint-meta', { params: { category, type: type || undefined } })
return {
categories: (res.data.categories || []) as CategoryMeta[],
providerTypes: (res.data.provider_types || []) as ProviderTypeMeta[],
allProviderTypes: (res.data.all_provider_types || []) as ProviderTypeMeta[],
endpoints: (res.data.endpoints || []) as EndpointMetaItem[],
}
}
function applyDefaultEndpoints(
category: string,
endpoints: EndpointMetaItem[],
allowedMap: Record<string, boolean>,
pathMap: Record<string, string>,
reset: boolean,
) {
const defaultKeys = defaultEndpointsForCategory(category)
for (const ep of endpoints) {
if (reset || allowedMap[ep.key] === undefined) {
allowedMap[ep.key] = defaultKeys.includes(ep.key)
}
if (reset || pathMap[ep.key] === undefined) {
pathMap[ep.key] = pathMap[ep.key] || ''
}
}
}
async function loadCreateEndpointMeta(category: string, type: string, reset = false) {
const seq = ++createMetaSeq
const data = await fetchEndpointMeta(category, type)
if (seq !== createMetaSeq) return data
if (!categories.value.length) categories.value = data.categories
createProviderTypes.value = data.providerTypes
for (const pt of data.providerTypes) providerTypeLabels.value[pt.key] = pt.label
createEndpointMeta.value = data.endpoints
applyDefaultEndpoints(category, data.endpoints, form.value.allowedMap, form.value.pathMap, reset)
return data
}
async function loadEditEndpointMeta(category: string, type: string) {
const seq = ++editMetaSeq
const data = await fetchEndpointMeta(category, type)
if (seq !== editMetaSeq) return data
editEndpointMeta.value = data.endpoints
return data
}
function parseList(json: string) {
try { return JSON.parse(json || '[]') } catch { return [] }
}
function parsePaths(json: string): Record<string, string> {
try { return JSON.parse(json || '{}') } catch { return {} }
}
function enabledModelCount(row: Provider) {
return (row.models ?? []).filter(m => m.enabled).length
}
function syncModelState(m: ModelEntry, enabled: boolean, providerRow?: Provider) {
m.enabled = enabled
if (providerRow?.models) {
const item = providerRow.models.find(x => x.id === m.id)
if (item) item.enabled = enabled
}
if (current.value?.id === m.provider_id) {
const item = providerModels.value.find(x => x.id === m.id)
if (item) item.enabled = enabled
}
const provider = providers.value.find(p => p.id === m.provider_id)
if (provider?.models) {
const item = provider.models.find(x => x.id === m.id)
if (item) item.enabled = enabled
}
}
async function toggleModel(m: ModelEntry, enabled: boolean, providerRow?: Provider) {
if (!m.id || !m.provider_id) {
ElMessage.warning(t('providers.modelNotLoaded'))
return
}
const prev = m.enabled
syncModelState(m, enabled, providerRow)
try {
await api.put(`/providers/${m.provider_id}/models/${m.id}`, { enabled, model_id: m.model_id })
ElMessage.success(enabled ? t('providers.modelEnabled') : t('providers.modelDisabled'))
} catch (e: any) {
syncModelState(m, prev, providerRow)
const msg = e.response?.data?.error
ElMessage.error(msg === 'not found' || msg === 'model not found' ? t('providers.modelNotFound') : (msg || t('providers.updateFailed')))
}
}
async function onProtocolTypeChange() {
await nextTick()
const allowedMap = { ...form.value.allowedMap }
const pathMap = { ...form.value.pathMap }
await loadCreateEndpointMeta(form.value.category, form.value.type)
for (const ep of createEndpointMeta.value) {
if (allowedMap[ep.key] !== undefined) form.value.allowedMap[ep.key] = allowedMap[ep.key]
if (pathMap[ep.key] !== undefined) form.value.pathMap[ep.key] = pathMap[ep.key]
}
}
function defaultEndpointsForCategory(category: string) {
switch (category) {
case 'embedding':
return ['list_models', 'embeddings']
case 'rerank':
return ['list_models', 'rerank']
case 'image':
return []
default:
return ['list_models', 'chat_completion']
}
}
function onCategoryChange() {
void (async () => {
form.value.allowedMap = {}
form.value.pathMap = {}
const meta = await fetchEndpointMeta(form.value.category)
form.value.type = defaultProviderType(form.value.category, meta.providerTypes)
await loadCreateEndpointMeta(form.value.category, form.value.type, true)
})()
}
async function toggleProvider(row: Provider, enabled: boolean) {
await api.put(`/providers/${row.id}`, { enabled })
row.enabled = enabled
ElMessage.success(enabled ? t('providers.providerEnabled') : t('providers.providerDisabled'))
}
function buildPayload(baseURL: string, allowedMap: Record<string, boolean>, pathMap: Record<string, string>) {
const allowed_endpoints = Object.keys(allowedMap).filter(k => allowedMap[k])
const endpoint_paths: Record<string, string> = {}
for (const [k, v] of Object.entries(pathMap)) {
if (v.trim()) endpoint_paths[k] = v.trim()
}
return { base_url: baseURL, allowed_endpoints, endpoint_paths }
}
async function load() {
loading.value = true
try {
const res = await api.get('/providers', { params: { category: activeCategory.value } })
providers.value = (res.data.data ?? []).map((p: Provider) => ({
...p,
models: p.models ?? [],
}))
} finally {
loading.value = false
}
}
async function openCreate() {
form.value = {
name: '',
type: 'openai',
category: activeCategory.value,
base_url: '',
allowedMap: {},
pathMap: {},
}
const meta = await fetchEndpointMeta(activeCategory.value)
if (!categories.value.length) categories.value = meta.categories
for (const pt of meta.providerTypes) providerTypeLabels.value[pt.key] = pt.label
form.value.type = defaultProviderType(activeCategory.value, meta.providerTypes)
await loadCreateEndpointMeta(activeCategory.value, form.value.type, true)
showCreate.value = true
}
async function create() {
const payload = {
name: form.value.name,
type: form.value.type,
category: form.value.category,
...buildPayload(form.value.base_url, form.value.allowedMap, form.value.pathMap),
}
try {
await api.post('/providers', payload)
} catch (e: any) {
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showCreate.value = false
ElMessage.success(t('providers.createSuccess'))
load()
}
async function openEndpoints(row: Provider) {
current.value = row
await loadEditEndpointMeta(row.category || 'llm', row.type)
const allowed = parseList(row.allowed_endpoints_json)
const paths = parsePaths(row.endpoint_paths_json)
const allowedMap: Record<string, boolean> = {}
const pathMap: Record<string, string> = {}
for (const ep of editEndpointMeta.value) {
allowedMap[ep.key] = allowed.includes(ep.key)
pathMap[ep.key] = paths[ep.key] || ''
}
editForm.value = { base_url: row.base_url, allowedMap, pathMap }
endpointVisible.value = true
}
async function saveEndpoints() {
if (!current.value) return
await api.put(`/providers/${current.value.id}`, buildPayload(
editForm.value.base_url,
editForm.value.allowedMap,
editForm.value.pathMap,
))
endpointVisible.value = false
ElMessage.success(t('providers.saved'))
load()
}
async function remove(row: Provider) {
await ElMessageBox.confirm(t('common.confirmDelete'))
await api.delete(`/providers/${row.id}`)
ElMessage.success(t('providers.deleted'))
load()
}
async function openKeys(row: Provider) {
current.value = row
const res = await api.get(`/providers/${row.id}/keys`)
keys.value = res.data.data
keysVisible.value = true
}
async function openModels(row: Provider) {
current.value = row
modelsVisible.value = true
modelsLoading.value = true
providerModels.value = []
try {
const res = await api.get(`/providers/${row.id}/models`)
const models = res.data.data ?? []
providerModels.value = models
row.models = models
} finally {
modelsLoading.value = false
}
}
async function syncModels(row: Provider) {
try {
const res = await api.post(`/providers/${row.id}/sync-models`)
ElMessage.success(t('providers.syncedCount', { count: res.data.synced }))
await load()
const updated = providers.value.find(p => p.id === row.id)
if (updated) {
Object.assign(row, updated)
if (current.value?.id === row.id) {
current.value = updated
}
}
if (modelsVisible.value && current.value?.id === row.id) {
await openModels(row)
}
} catch (e: any) {
ElMessage.error(e.response?.data?.error || t('providers.syncFailed'))
}
}
async function createKey() {
if (!current.value) return
let models: string[] = ['*']
try { models = JSON.parse(keyForm.value.models) } catch { /* keep */ }
await api.post(`/providers/${current.value.id}/keys`, { ...keyForm.value, models })
showKeyForm.value = false
keyForm.value = { name: '', api_key: '', weight: 1, models: '["*"]', max_requests_per_day: 0, max_tokens_per_day: 0 }
openKeys(current.value)
ElMessage.success(t('providers.keyAdded'))
}
async function deleteKey(row: ProviderKey) {
await api.delete(`/provider-keys/${row.id}`)
if (current.value) openKeys(current.value)
}
function openAddModel() {
addModelForm.value = { model_id: '', alias: '', display_name: '' }
showAddModel.value = true
}
function openEditModel(m: ModelEntry) {
editModelForm.value = {
id: m.id,
model_id: m.model_id,
alias: m.alias || '',
display_name: m.display_name || '',
}
showEditModel.value = true
}
async function saveEditModel() {
if (!current.value || !editModelForm.value.id) return
try {
await api.put(`/providers/${current.value.id}/models/${editModelForm.value.id}`, {
model_id: editModelForm.value.model_id.trim(),
alias: editModelForm.value.alias.trim(),
display_name: editModelForm.value.display_name.trim(),
})
} catch (e: any) {
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showEditModel.value = false
ElMessage.success(t('providers.saved'))
await openModels(current.value)
await load()
}
async function createModel() {
if (!current.value) return
const modelId = addModelForm.value.model_id.trim()
if (!modelId) {
ElMessage.warning(t('providers.modelIdRequired'))
return
}
const displayName = addModelForm.value.display_name.trim() || modelId
try {
await api.post(`/providers/${current.value.id}/models`, {
model_id: modelId,
alias: addModelForm.value.alias.trim(),
display_name: displayName,
})
} catch (e: any) {
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showAddModel.value = false
ElMessage.success(t('providers.modelAdded'))
await openModels(current.value)
await load()
}
async function deleteModel(m: ModelEntry) {
if (!current.value || !m.id) return
await ElMessageBox.confirm(t('providers.confirmDeleteModel'))
try {
await api.delete(`/providers/${current.value.id}/models/${m.id}`)
} catch (e: any) {
ElMessage.error(e.response?.data?.error || t('providers.updateFailed'))
return
}
ElMessage.success(t('providers.modelDeleted'))
await openModels(current.value)
await load()
}
onMounted(async () => {
const meta = await fetchEndpointMeta('llm')
categories.value = meta.categories
for (const pt of (meta.allProviderTypes?.length ? meta.allProviderTypes : meta.providerTypes) ?? []) {
providerTypeLabels.value[pt.key] = pt.label
}
load()
})
</script>
+299
View File
@@ -0,0 +1,299 @@
<template>
<div class="page">
<PageHeader
:title="t('logs.title')"
:description="t('logs.description')"
>
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
</template>
</PageHeader>
<AppCard>
<div class="card-toolbar">
<el-select
v-model="filter.ingress_key_id"
:placeholder="t('logs.ingress')"
clearable
filterable
class="toolbar-select--country"
@change="onFilterChange"
>
<el-option
v-for="k in ingressKeys"
:key="k.id"
:label="k.name"
:value="String(k.id)"
/>
</el-select>
<el-select
v-model="filter.status"
:placeholder="t('common.status')"
clearable
class="toolbar-select"
@change="onFilterChange"
>
<el-option :label="t('logs.statusSuccess')" value="success" />
<el-option :label="t('logs.statusError')" value="error" />
</el-select>
<el-select
v-model="filter.endpoint"
:placeholder="t('logs.endpoint')"
clearable
class="toolbar-select--country"
@change="onFilterChange"
>
<el-option :label="t('logs.endpointAdminLogin')" value="admin_login" />
<el-option label="chat/completions" value="chat_completion" />
<el-option label="embeddings" value="embeddings" />
<el-option label="rerank" value="rerank" />
<el-option label="prediction" value="prediction" />
</el-select>
<el-input
v-model="filter.model"
:placeholder="t('logs.modelOrUserPlaceholder')"
clearable
class="toolbar-input"
@keyup.enter="onFilterChange"
@clear="onFilterChange"
/>
<el-input
v-model="filter.client_ip"
:placeholder="t('logs.clientIpPlaceholder')"
clearable
class="toolbar-select--country"
@keyup.enter="onFilterChange"
@clear="onFilterChange"
/>
<el-button type="primary" @click="onFilterChange">{{ t('common.query') }}</el-button>
</div>
<el-table :data="logs" v-loading="loading" size="small">
<el-table-column prop="created_at" :label="t('common.time')" width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="ingress_name" :label="t('logs.ingress')" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ingress_name || t('common.dash') }}</template>
</el-table-column>
<el-table-column prop="endpoint" :label="t('logs.endpoint')" width="130">
<template #default="{ row }">{{ endpointLabel(row.endpoint) }}</template>
</el-table-column>
<el-table-column prop="client_ip" :label="t('dashboard.clientIp')" width="130" show-overflow-tooltip />
<el-table-column prop="model" :label="t('logs.modelOrUser')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.model || t('common.dash') }}</template>
</el-table-column>
<el-table-column prop="latency_ms" :label="t('common.latencyMs')" width="100">
<template #default="{ row }">{{ row.endpoint === 'admin_login' ? t('common.dash') : row.latency_ms }}</template>
</el-table-column>
<el-table-column prop="stream" :label="t('common.stream')" width="70">
<template #default="{ row }">{{ row.endpoint === 'admin_login' ? t('common.dash') : (row.stream ? t('common.yes') : t('common.no')) }}</template>
</el-table-column>
<el-table-column prop="status" :label="t('common.status')" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error_msg" :label="t('common.error')" min-width="120" show-overflow-tooltip />
<el-table-column :label="t('common.actions')" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row.id)">{{ t('common.detail') }}</el-button>
</template>
</el-table-column>
</el-table>
<p class="card-footer-hint">{{ t('logs.pagerHint', { total, max: maxKept }) }}</p>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
small
@current-change="load"
@size-change="onSizeChange"
/>
</div>
</AppCard>
<el-drawer v-model="detailVisible" :title="t('logs.detailTitle')" size="50%">
<p v-if="detailLoading" class="form-hint text-center u-mb-4">{{ t('common.loading') }}</p>
<template v-else-if="detail">
<div class="kv-block u-mb-4">
<p><span class="label">{{ t('common.time') }}</span> {{ formatDateTime(detail.created_at) }}</p>
<p><span class="label">{{ t('logs.ingress') }}</span> {{ detail.ingress_name || t('common.dash') }}</p>
<p><span class="label">{{ t('logs.endpoint') }}</span> {{ endpointLabel(detail.endpoint) }}</p>
<p><span class="label">{{ detail.endpoint === 'admin_login' ? t('common.username') : t('common.model') }}</span> {{ detail.model || t('common.dash') }}</p>
<p><span class="label">{{ t('dashboard.clientIp') }}</span> {{ detail.client_ip || t('common.dash') }}</p>
<p v-if="detail.endpoint !== 'admin_login'"><span class="label">{{ t('common.latency') }}</span> {{ detail.latency_ms }} ms</p>
<p><span class="label">{{ t('common.status') }}</span>
<el-tag :type="detail.status === 'success' ? 'success' : 'danger'" size="small">
{{ detail.status }}
</el-tag>
</p>
<p v-if="detail.error_msg"><span class="label">{{ t('common.error') }}</span> <span class="status-bad">{{ detail.error_msg }}</span></p>
</div>
<div v-if="predictionDetail" class="u-mb-4">
<div class="section-title">{{ t('logs.predictionStatus') }}</div>
<p><el-tag size="small">{{ predictionDetail.status }}</el-tag></p>
<div v-if="predictionImages.length" class="image-grid u-mt-2">
<a v-for="img in predictionImages" :key="img.id" :href="`/api/admin/images/${img.id}`" target="_blank">
<img :src="`/api/admin/images/${img.id}`" class="thumb" :alt="img.filename" />
</a>
</div>
</div>
<div class="section-title">{{ t('logs.requestBody') }}</div>
<pre class="card-inset-block u-mb-4">{{ formatBody(detail.request_body) }}</pre>
<div class="section-title">{{ t('logs.responseBody') }}</div>
<pre class="card-inset-block">{{ formatBody(detail.response_body) }}</pre>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
interface RequestLog {
id: number
ingress_key_id: number
ingress_name: string
endpoint: string
client_ip: string
model: string
latency_ms: number
status: string
stream: boolean
error_msg: string
created_at: string
}
interface RequestLogDetail extends RequestLog {
request_body: string
response_body: string
}
interface IngressKey {
id: number
name: string
}
const logs = ref<RequestLog[]>([])
const ingressKeys = ref<IngressKey[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const maxKept = ref(500)
const filter = ref({
ingress_key_id: '',
status: '',
endpoint: '',
model: '',
client_ip: '',
})
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<RequestLogDetail | null>(null)
const predictionDetail = ref<{ status: string } | null>(null)
const predictionImages = ref<{ id: string; filename: string }[]>([])
function endpointLabel(endpoint: string) {
if (endpoint === 'admin_login') return t('logs.endpointAdminLogin')
if (endpoint === 'prediction') return 'prediction'
const labels: Record<string, string> = {
chat_completion: 'chat/completions',
embeddings: 'embeddings',
rerank: 'rerank',
responses: 'responses',
}
return labels[endpoint] || endpoint
}
function formatBody(raw: string) {
if (!raw) return t('common.empty')
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
function buildParams(): Record<string, string> {
const params: Record<string, string> = {
page: String(page.value),
page_size: String(pageSize.value),
}
const f = filter.value
if (f.ingress_key_id) params.ingress_key_id = f.ingress_key_id
if (f.status) params.status = f.status
if (f.endpoint) params.endpoint = f.endpoint
if (f.model) params.model = f.model
if (f.client_ip) params.client_ip = f.client_ip
return params
}
async function loadIngressKeys() {
const res = await api.get('/ingress-keys')
ingressKeys.value = res.data.data
}
async function load() {
loading.value = true
try {
const res = await api.get('/request-logs', { params: buildParams() })
logs.value = res.data.data
total.value = res.data.total
maxKept.value = res.data.max_kept ?? 500
} finally {
loading.value = false
}
}
function onFilterChange() {
page.value = 1
load()
}
function onSizeChange() {
page.value = 1
load()
}
async function openDetail(id: number) {
detailVisible.value = true
detailLoading.value = true
detail.value = null
predictionDetail.value = null
predictionImages.value = []
try {
const res = await api.get(`/request-logs/${id}`)
detail.value = res.data.data
if (detail.value?.endpoint === 'prediction' && detail.value.model) {
try {
const pred = await api.get(`/predictions/${detail.value.model}`)
predictionDetail.value = pred.data.prediction
predictionImages.value = pred.data.images || []
} catch { /* ignore */ }
}
} finally {
detailLoading.value = false
}
}
onMounted(async () => {
await loadIngressKeys()
await load()
})
</script>
+221
View File
@@ -0,0 +1,221 @@
<template>
<div class="page">
<PageHeader
:title="t('settings.title')"
:description="t('settings.description')"
/>
<div class="page-form">
<AppCard :title="t('settings.securitySession')" padded>
<el-form v-loading="loading" class="settings-form" label-width="120px">
<el-form-item :label="t('settings.encryptionKey')">
<el-input
v-model="form.encryption_key"
type="password"
show-password
:placeholder="t('settings.encryptionKeyPlaceholder')"
/>
<div class="form-hint">{{ t('settings.encryptionKeyCurrent', { hint: settings.encryption_key_hint }) }}</div>
</el-form-item>
<el-form-item :label="t('settings.sessionTtl')">
<el-input-number v-model="form.session_ttl_seconds" :min="300" :max="604800" />
</el-form-item>
<el-form-item :label="t('settings.loginMaxAttempts')">
<el-input-number v-model="form.login_max_attempts" :min="1" :max="100" />
</el-form-item>
<el-form-item :label="t('settings.loginLockout')">
<el-input-number v-model="form.login_lockout_seconds" :min="60" :max="86400" />
</el-form-item>
<el-form-item :label="t('settings.trustedProxies')">
<el-input
v-model="trustedProxiesText"
type="textarea"
:rows="3"
:placeholder="t('settings.trustedProxiesPlaceholder')"
/>
</el-form-item>
</el-form>
</AppCard>
<AppCard :title="t('settings.imageStorage')" padded>
<el-form class="settings-form" label-width="160px">
<el-form-item :label="t('settings.imageStoragePath')">
<el-input v-model="form.image_storage_path" placeholder="images" />
</el-form-item>
<el-form-item :label="t('settings.publicBaseUrl')">
<el-input v-model="form.public_base_url" placeholder="https://gateway.example.com" />
</el-form-item>
<el-form-item :label="t('settings.signedUrlTtl')">
<el-input-number v-model="form.signed_url_ttl_seconds" :min="60" :max="604800" />
</el-form-item>
<el-form-item :label="t('settings.predictionWait')">
<el-input-number v-model="form.prediction_wait_seconds" :min="5" :max="300" />
</el-form-item>
</el-form>
</AppCard>
<AppCard :title="t('settings.gatewayMonitor')" padded>
<el-form class="settings-form" label-width="120px">
<el-form-item :label="t('settings.usageFlush')">
<el-input-number v-model="form.usage_flush_seconds" :min="1" :max="300" />
</el-form-item>
<el-form-item :label="t('settings.healthCheck')">
<el-input-number v-model="form.health_check_seconds" :min="5" :max="3600" />
</el-form-item>
<el-form-item :label="t('settings.gatewayMaxRetries')">
<el-input-number v-model="form.gateway_max_retries" :min="1" :max="10" />
</el-form-item>
<el-form-item :label="t('settings.retryBackoff')">
<el-input-number v-model="form.gateway_retry_backoff_ms" :min="0" :max="10000" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveSettings">{{ t('settings.saveSettings') }}</el-button>
</el-form-item>
</el-form>
</AppCard>
</div>
<AppCard :title="t('settings.changePassword')" padded class="page-form page-form--narrow">
<el-form label-width="100px">
<el-form-item :label="t('settings.oldPassword')">
<el-input v-model="oldPassword" type="password" show-password />
</el-form-item>
<el-form-item :label="t('settings.newPassword')">
<el-input v-model="newPassword" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitPassword">{{ t('settings.savePassword') }}</el-button>
</el-form-item>
</el-form>
</AppCard>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
const { t } = useI18n()
interface SystemSettings {
encryption_key_set: boolean
encryption_key_hint: string
session_ttl_seconds: number
login_max_attempts: number
login_lockout_seconds: number
trusted_proxies: string[]
usage_flush_seconds: number
health_check_seconds: number
gateway_max_retries: number
gateway_retry_backoff_ms: number
image_storage_path?: string
public_base_url?: string
signed_url_ttl_seconds?: number
prediction_wait_seconds?: number
}
const loading = ref(false)
const saving = ref(false)
const oldPassword = ref('')
const newPassword = ref('')
const trustedProxiesText = ref('')
const settings = reactive<SystemSettings>({
encryption_key_set: false,
encryption_key_hint: '',
session_ttl_seconds: 86400,
login_max_attempts: 5,
login_lockout_seconds: 900,
trusted_proxies: [],
usage_flush_seconds: 5,
health_check_seconds: 30,
gateway_max_retries: 3,
gateway_retry_backoff_ms: 200,
})
const form = reactive({
encryption_key: '',
session_ttl_seconds: 86400,
login_max_attempts: 5,
login_lockout_seconds: 900,
usage_flush_seconds: 5,
health_check_seconds: 30,
gateway_max_retries: 3,
gateway_retry_backoff_ms: 200,
image_storage_path: 'images',
public_base_url: '',
signed_url_ttl_seconds: 3600,
prediction_wait_seconds: 60,
})
function applySettings(data: SystemSettings) {
Object.assign(settings, data)
form.session_ttl_seconds = data.session_ttl_seconds
form.login_max_attempts = data.login_max_attempts
form.login_lockout_seconds = data.login_lockout_seconds
form.usage_flush_seconds = data.usage_flush_seconds
form.health_check_seconds = data.health_check_seconds
form.gateway_max_retries = data.gateway_max_retries
form.gateway_retry_backoff_ms = data.gateway_retry_backoff_ms
form.image_storage_path = data.image_storage_path || 'images'
form.public_base_url = data.public_base_url || ''
form.signed_url_ttl_seconds = data.signed_url_ttl_seconds ?? 3600
form.prediction_wait_seconds = data.prediction_wait_seconds ?? 60
trustedProxiesText.value = (data.trusted_proxies || []).join('\n')
form.encryption_key = ''
}
async function loadSettings() {
loading.value = true
try {
const { data } = await api.get<SystemSettings>('/settings')
applySettings(data)
} finally {
loading.value = false
}
}
async function saveSettings() {
saving.value = true
try {
const payload: Record<string, unknown> = {
session_ttl_seconds: form.session_ttl_seconds,
login_max_attempts: form.login_max_attempts,
login_lockout_seconds: form.login_lockout_seconds,
trusted_proxies: trustedProxiesText.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean),
usage_flush_seconds: form.usage_flush_seconds,
health_check_seconds: form.health_check_seconds,
gateway_max_retries: form.gateway_max_retries,
gateway_retry_backoff_ms: form.gateway_retry_backoff_ms,
image_storage_path: form.image_storage_path,
public_base_url: form.public_base_url,
signed_url_ttl_seconds: form.signed_url_ttl_seconds,
prediction_wait_seconds: form.prediction_wait_seconds,
}
if (form.encryption_key.trim()) {
payload.encryption_key = form.encryption_key.trim()
}
const { data } = await api.put<SystemSettings>('/settings', payload)
applySettings(data)
ElMessage.success(t('settings.settingsSaved'))
} finally {
saving.value = false
}
}
async function submitPassword() {
await api.put('/password', { old_password: oldPassword.value, new_password: newPassword.value })
ElMessage.success(t('settings.passwordUpdated'))
oldPassword.value = ''
newPassword.value = ''
}
onMounted(loadSettings)
</script>
+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
}
+8
View File
@@ -0,0 +1,8 @@
package webembed
import "embed"
// Placeholder dist/index.html is committed for CI and go test; production UI comes from npm run build.
//
//go:embed all:dist
var WebFS embed.FS
+22
View File
@@ -0,0 +1,22 @@
{
"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/*"] },
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8293', changeOrigin: true },
'/v1': { target: 'http://localhost:8293', changeOrigin: true },
},
},
})