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:
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
Vendored
+14
@@ -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>
|
||||
@@ -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>
|
||||
Generated
+2188
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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)或完整 URL(http/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: '密码已更新',
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user