modify 修复前端展示配置与实际插件配置不一致的bug

This commit is contained in:
2026-09-17 15:34:18 +08:00
parent 5c52b750e8
commit 55fe2621f8
2 changed files with 413 additions and 20 deletions
+58 -4
View File
@@ -22,6 +22,12 @@ export interface AdminCmdReply {
exit_request?: string
}
/** 插件配置优先来源模式:toml=以 config.toml 为准(启动时覆盖缓存);cache=以缓存为准 */
export type ConfigMode = 'toml' | 'cache'
/** 展示值的实际来源:cache=运行时缓存;toml=缓存缺失或副本未响应,回退读配置文件 */
export type ConfigSource = 'cache' | 'toml'
export interface RestartAllResult {
worker_id: string
ok: boolean
@@ -125,10 +131,16 @@ export function parseJwt(token: string): AuthUser | null {
/** 读取某插件的现有运行时配置([workers.config]) */
export function getWorkerConfig(token: string, workerType: string) {
return request<{ success: boolean; worker_type: string; config: Record<string, unknown> }>(
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/config`,
token,
)
return request<{
success: boolean
worker_type: string
worker_id?: string
/** 展示值实际来自哪一侧(后端如实回报,勿混用缓存/文件值) */
source?: ConfigSource
/** 该类插件的配置优先来源模式 */
mode?: ConfigMode
config: Record<string, unknown>
}>(`/api/v1/admin/workers/${encodeURIComponent(workerType)}/config`, token)
}
/** 更新某插件的运行时配置([workers.config],路由配置除外),由 worker 写回并重启 */
@@ -160,4 +172,46 @@ export function scaleWorker(token: string, workerType: string, count: number) {
)
}
export interface WorkerInstanceGroup {
worker_type: string
instance_id: string
mode: 'inline' | 'external'
replicas: number
worker_ids: string[]
}
export interface ScaleInstanceResult {
success: boolean
result: {
worker_type: string
instance_id: string
target: number
mode: 'inline' | 'external'
sent_to?: string
reply?: AdminCmdReply & { persist?: string }
}
}
/** 按「外部进程实例」分组列出某类型的 worker(精细扩缩寻址) */
export function listWorkerInstances(token: string, workerType: string) {
return request<{ success: boolean; result: WorkerInstanceGroup[] }>(
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/instances`,
token,
)
}
/** 把某实例精确扩缩到指定副本数(绝对数,不影响其它实例) */
export function scaleWorkerInstance(
token: string,
workerType: string,
instanceId: string,
count: number,
) {
return request<ScaleInstanceResult>(
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/scale_instance`,
token,
{ method: 'POST', body: JSON.stringify({ instance_id: instanceId, count }) },
)
}
export const TOKEN_KEY = 'cm_admin_token'
+355 -16
View File
@@ -65,6 +65,34 @@
</div>
</template>
<div class="filter-bar">
<el-input
v-model="searchText"
clearable
:prefix-icon="Search"
placeholder="搜索:插件类型 / 副本ID / 实例ID"
style="width: 280px"
/>
<el-select v-model="modeFilter" style="width: 150px; margin-left: 12px">
<el-option label="全部模式" value="all" />
<el-option label="inline" value="inline" />
<el-option label="external" value="external" />
</el-select>
<div class="replica-filter" style="margin-left: 12px; display: flex; align-items: center">
<span class="scale-tip" style="margin-right: 6px">副本数 ≥</span>
<el-input-number
v-model="minReplicas"
:min="0"
:max="50"
controls-position="right"
style="width: 130px"
/>
</div>
<span class="scale-tip" style="margin-left: 12px"
>筛选同时作用于下方「逐实例精细扩缩」分组表(副本数仅对实例分组表生效)</span
>
</div>
<el-table
:data="pagedWorkers"
v-loading="loading"
@@ -166,9 +194,52 @@
调整数量
</el-button>
<span class="scale-tip"
>调小会<strong>优雅排空</strong>:先隔离新请求,等当前请求处理完再关闭对应副本;数量将写入配置,重启网关后保持。</span
>调小会<strong>优雅排空</strong>:先隔离新请求,等当前请求处理完再关闭对应副本;数量写入运行时缓存——cache
模式下重启网关后保持,TOML 模式下重启会被 config.toml 的 count 覆盖。</span
>
</div>
<el-divider style="margin: 14px 0">逐实例精细扩缩</el-divider>
<div v-loading="instancesLoading">
<div class="scale-tip" style="margin-bottom: 10px">
把某个<strong>外部进程/实例</strong>精确调整到指定副本数(绝对数,不影响其它实例);「调整数量」全局缩放会清除这些定制、回到均匀分摊。
</div>
<el-table :data="filteredInstances" size="small" style="width: 100%">
<el-table-column prop="instance_id" label="实例 (instance_id)" show-overflow-tooltip />
<el-table-column prop="mode" label="模式" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.mode === 'inline' ? 'success' : 'warning'">
{{ row.mode }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="replicas" label="当前副本数" width="100" />
<el-table-column label="目标副本数" width="150">
<template #default="{ row }">
<el-input-number
v-model="row._target"
:min="0"
:max="50"
size="small"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
type="primary"
size="small"
link
:loading="scalingInstanceId === row.instance_id"
:disabled="!row.instance_id"
@click="applyInstanceScale(row as any)"
>
应用
</el-button>
</template>
</el-table-column>
<template #empty>该类型暂无可用实例</template>
</el-table>
</div>
</div>
</el-card>
@@ -182,7 +253,7 @@
type="info"
:closable="false"
show-icon
title="写入插件 config.toml 的 [workers.config](路由配置除外)。由 worker 写回并重启所有对应副本。"
:title="configAlertTitle"
style="margin-bottom: 14px"
/>
<el-form label-position="top">
@@ -190,6 +261,17 @@
<el-tag effect="plain">{{ workerType || '-' }}</el-tag>
</el-form-item>
<el-form-item label="现有配置值">
<div v-if="configMode || configSource" class="config-source">
<el-tag size="small" :type="configMode === 'cache' ? 'success' : 'info'">
模式:{{ configMode === 'cache' ? '缓存为准' : 'TOML 为准' }}
</el-tag>
<el-tag size="small" :type="configSource === 'cache' ? 'info' : 'warning'">
来源:{{ configSource === 'cache' ? '运行时缓存' : 'config.toml(回退读取)' }}
</el-tag>
<span v-if="configSource === 'toml'" class="config-source-tip">
副本未响应或缓存中无该键,以上取自配置文件,可能不是实际生效值
</span>
</div>
<el-table
:data="currentConfig"
v-loading="configLoading"
@@ -219,16 +301,38 @@
<el-select
v-model="configValueType"
style="width: 120px; margin-right: 8px"
@change="onConfigValueTypeChange"
>
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔" value="boolean" />
<el-option label="对象 (JSON)" value="object" />
<el-option label="数组 (JSON)" value="array" />
</el-select>
<el-input
v-if="!isJsonType"
v-model="configValue"
:style="{ width: 'calc(100% - 128px)' }"
placeholder="配置值"
/>
<div v-else class="json-editor">
<el-input
v-model="configValue"
type="textarea"
:rows="6"
placeholder="请输入 JSON 文本(如对象 { } 或数组 [ ])"
@input="validateConfigJson"
/>
<div class="json-editor-bar">
<el-button size="small" text type="primary" @click="formatConfigJson">
格式化
</el-button>
<span v-if="configJsonInvalid" class="json-err">
JSON 不合法:{{ configJsonError }}
</span>
<span v-else-if="configValue" class="json-ok">JSON 合法</span>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -249,7 +353,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Monitor } from '@element-plus/icons-vue'
import { Monitor, Search } from '@element-plus/icons-vue'
import {
getWorkers,
restartWorker,
@@ -257,11 +361,16 @@ import {
getWorkerConfig,
updateWorkerConfig,
scaleWorker,
listWorkerInstances,
scaleWorkerInstance,
login,
parseJwt,
TOKEN_KEY,
type WorkerInstance,
type WorkerInstanceGroup,
type AuthUser,
type ConfigMode,
type ConfigSource,
} from '@/api/workerAdmin'
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
@@ -279,24 +388,74 @@ const workerType = ref('')
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
const total = computed(() => workers.value.length)
// 查询与筛选(作用于总副本列表与逐实例分组表两份数据)
const searchText = ref('')
const modeFilter = ref<'all' | 'inline' | 'external'>('all')
const minReplicas = ref(0)
const filteredWorkers = computed(() => {
const kw = searchText.value.trim().toLowerCase()
return workers.value.filter((w) => {
if (modeFilter.value !== 'all' && w.mode !== modeFilter.value) return false
if (!kw) return true
return (
w.worker_type.toLowerCase().includes(kw) ||
w.worker_id.toLowerCase().includes(kw)
)
})
})
const filteredInstances = computed(() => {
const kw = searchText.value.trim().toLowerCase()
const minR = Math.max(0, Math.floor(minReplicas.value || 0))
return instances.value.filter((g) => {
if (modeFilter.value !== 'all' && g.mode !== modeFilter.value) return false
if (g.replicas < minR) return false
if (!kw) return true
return g.instance_id.toLowerCase().includes(kw)
})
})
const total = computed(() => filteredWorkers.value.length)
const pagedWorkers = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return workers.value.slice(start, start + pageSize.value)
return filteredWorkers.value.slice(start, start + pageSize.value)
})
// 数据刷新/变化时回到第一页
// 数据刷新/筛选条件变化时回到第一页
watch(workers, () => {
currentPage.value = 1
})
watch([searchText, modeFilter, minReplicas], () => {
currentPage.value = 1
})
// 配置编辑
type ConfigValueType = 'string' | 'number' | 'boolean' | 'object' | 'array'
const configDialogVisible = ref(false)
const configKey = ref('')
const configValue = ref('')
const configValueType = ref<'string' | 'number' | 'boolean'>('string')
const configValueType = ref<ConfigValueType>('string')
const configUpdating = ref(false)
const configLoading = ref(false)
const currentConfig = ref<Array<{ key: string; value: string; type: 'string' | 'number' | 'boolean' }>>([])
const configJsonInvalid = ref(false)
const configJsonError = ref('')
const isJsonType = computed(
() => configValueType.value === 'object' || configValueType.value === 'array',
)
const currentConfig = ref<Array<{ key: string; value: string; type: ConfigValueType }>>([])
// 后端如实回报:mode=该类插件的配置优先来源模式,source=本次展示值实际来自哪一侧
const configMode = ref<ConfigMode | ''>('')
const configSource = ref<ConfigSource | ''>('')
const configAlertTitle = computed(() => {
if (configMode.value === 'cache') {
return '写入插件运行时配置 [workers.config](路由配置除外),以运行时缓存为准:下发并重启对应副本,网关重启后保持。'
}
if (configMode.value === 'toml') {
return '写入插件运行时配置 [workers.config](路由配置除外)。当前 TOML 模式(以 config.toml 为准):改动仅本次运行有效,网关重启后会被 config.toml 覆盖。'
}
return '写入插件运行时配置 [workers.config](路由配置除外),下发并重启所有对应副本。'
})
const doLogin = async () => {
const name = username.value.trim()
@@ -346,13 +505,15 @@ const logout = () => {
ElMessage.success('已退出登录')
}
// 自动轮询:让扩缩容/停止/重启等操作的结果(副本关闭、模式变化)无需手动刷新即反映到列表
// 自动轮询:让扩缩容/停止/重启/逐实例精细扩缩等操作的结果(副本关闭、模式、实例分组数)
// 无需手动刷新即反映到列表。逐实例分组与总列表一并刷新,避免扩缩后实例表停留旧值。
let pollTimer: ReturnType<typeof setInterval> | undefined
const startPolling = () => {
stopPolling()
if (!token.value.trim()) return
pollTimer = setInterval(() => {
if (!loading.value) fetchWorkers()
if (!instancesLoading.value) fetchInstances()
}, 10000)
}
const stopPolling = () => {
@@ -394,8 +555,68 @@ const scaleTarget = ref(0)
const scalingType = ref('')
watch(workerType, (t) => {
scaleTarget.value = t ? countByType(t) : 0
fetchInstances()
})
// 逐实例精细扩缩
type InstanceGroupWithTarget = WorkerInstanceGroup & { _target: number }
const instances = ref<InstanceGroupWithTarget[]>([])
const instancesLoading = ref(false)
const scalingInstanceId = ref('')
const fetchInstances = async () => {
const t = workerType.value
const tok = token.value.trim()
if (!t || !tok) {
instances.value = []
return
}
instancesLoading.value = true
try {
const data = await listWorkerInstances(tok, t)
instances.value = (data.result || []).map((g) => ({ ...g, _target: g.replicas }))
} catch (e: any) {
// 逐实例扩缩为增强能力:旧版运行时/网关不支持时静默降级,不影响主流程
instances.value = []
console.warn('获取实例分组失败', e)
} finally {
instancesLoading.value = false
}
}
const applyInstanceScale = async (g: InstanceGroupWithTarget) => {
const t = workerType.value
if (!t) return
const target = Math.floor(g._target)
if (Number.isNaN(target) || target < 0) return
if (target === g.replicas) {
ElMessage.info('目标数量与当前相同,未调整')
return
}
try {
await ElMessageBox.confirm(
target > g.replicas
? `扩缩实例「${g.instance_id}」到 ${target} 个副本(新增 ${target - g.replicas} 个)?仅影响该实例,不影响其它实例。`
: `缩容实例「${g.instance_id}」到 ${target} 个副本(关闭 ${g.replicas - target} 个)?将优雅排空并只影响该实例。`,
'逐实例精细扩缩',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' },
)
} catch {
return
}
scalingInstanceId.value = g.instance_id
try {
const res = await scaleWorkerInstance(token.value.trim(), t, g.instance_id, target)
ElMessage.success(`已把实例「${g.instance_id}」调整至 ${target}(${res.result.mode})`)
setTimeout(() => {
fetchWorkers()
fetchInstances()
}, target > g.replicas ? 1200 : 2600)
} catch (e: any) {
ElMessage.error(`逐实例扩缩失败:${e.message}`)
} finally {
scalingInstanceId.value = ''
}
}
const applyScale = async () => {
const t = workerType.value
if (!t) return
@@ -422,7 +643,10 @@ const applyScale = async () => {
const res = await scaleWorker(token.value.trim(), t, target)
ElMessage.success(`已调整插件「${t}」副本数至 ${target}(${res.result.mode})`)
scaleTarget.value = target
setTimeout(fetchWorkers, target > cur ? 1200 : 2600)
setTimeout(() => {
fetchWorkers()
fetchInstances()
}, target > cur ? 1200 : 2600)
} catch (e: any) {
ElMessage.error(`调整副本数量失败:${e.message}`)
} finally {
@@ -508,18 +732,73 @@ const onSelectionChange = (rows: WorkerInstance[]) => {
selected.value = rows
}
const valueToDisplay = (v: unknown): { value: string; type: 'string' | 'number' | 'boolean' } => {
const valueToDisplay = (v: unknown): { value: string; type: ConfigValueType } => {
if (typeof v === 'boolean') return { value: String(v), type: 'boolean' }
if (typeof v === 'number') return { value: String(v), type: 'number' }
if (typeof v === 'object' && v !== null) return { value: JSON.stringify(v), type: 'string' }
if (Array.isArray(v)) return { value: JSON.stringify(v, null, 2), type: 'array' }
if (typeof v === 'object' && v !== null)
return { value: JSON.stringify(v, null, 2), type: 'object' }
return { value: String(v ?? ''), type: 'string' }
}
// 对象/数组等嵌套配置以 JSON 文本编辑:实时校验 + 一键格式化,避免手写不可靠的 JSON 字符串
const validateConfigJson = () => {
if (!isJsonType.value) {
configJsonInvalid.value = false
configJsonError.value = ''
return
}
const text = configValue.value.trim()
if (!text) {
configJsonInvalid.value = true
configJsonError.value = '内容不能为空'
return
}
try {
const parsed: unknown = JSON.parse(text)
if (configValueType.value === 'array' && !Array.isArray(parsed)) {
configJsonInvalid.value = true
configJsonError.value = '应为数组 ([])'
return
}
if (
configValueType.value === 'object' &&
(typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
) {
configJsonInvalid.value = true
configJsonError.value = '应为对象 ({})'
return
}
configJsonInvalid.value = false
configJsonError.value = ''
} catch (e: any) {
configJsonInvalid.value = true
configJsonError.value = e?.message || '解析失败'
}
}
const formatConfigJson = () => {
if (!isJsonType.value) return
try {
configValue.value = JSON.stringify(JSON.parse(configValue.value), null, 2)
validateConfigJson()
} catch {
validateConfigJson()
}
}
const onConfigValueTypeChange = () => {
// 切到对象/数组类型时,若当前文本是合法 JSON 则重新美化排版,便于编辑嵌套结构
formatConfigJson()
}
const openConfigDialog = async () => {
configKey.value = ''
configValue.value = ''
configValueType.value = 'string'
currentConfig.value = []
configMode.value = ''
configSource.value = ''
configDialogVisible.value = true
// 展示现有值:读取 [workers.config]
const t = token.value.trim()
@@ -528,6 +807,8 @@ const openConfigDialog = async () => {
configLoading.value = true
try {
const data = await getWorkerConfig(t, type)
configMode.value = data.mode || ''
configSource.value = data.source || ''
currentConfig.value = Object.entries(data.config || {}).map(([k, v]) => ({
key: k,
...valueToDisplay(v),
@@ -540,10 +821,11 @@ const openConfigDialog = async () => {
}
}
const selectConfigEntry = (row: { key: string; value: string; type: 'string' | 'number' | 'boolean' }) => {
const selectConfigEntry = (row: { key: string; value: string; type: ConfigValueType }) => {
configKey.value = row.key
configValue.value = row.value
configValueType.value = row.type
validateConfigJson()
}
const submitConfig = async () => {
@@ -559,20 +841,36 @@ const submitConfig = async () => {
return
}
// 按所选类型转换值
let value: string | number | boolean = configValue.value
let value: unknown = configValue.value
if (configValueType.value === 'number') {
if (configValue.value === '' || Number.isNaN(Number(configValue.value))) {
if (configValue.value.trim() === '' || Number.isNaN(Number(configValue.value))) {
ElMessage.warning('数字类型配置值不合法')
return
}
value = Number(configValue.value)
} else if (configValueType.value === 'boolean') {
value = String(configValue.value).toLowerCase() === 'true'
} else if (isJsonType.value) {
// 对象/数组:以真正的 JSON 结构下发,而不是字符串
validateConfigJson()
if (configJsonInvalid.value) {
ElMessage.warning(`JSON 不合法:${configJsonError.value}`)
return
}
try {
value = JSON.parse(configValue.value)
} catch {
ElMessage.warning('JSON 解析失败,请检查格式')
return
}
}
const displayValue =
typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value)
try {
await ElMessageBox.confirm(
`确定将「${type}」的配置 ${key}=${String(value)} 下发并重启所有对应副本吗?`,
`确定将「${type}」的配置 ${key}=${displayValue.length > 120 ? displayValue.slice(0, 120) + '…' : displayValue} 下发并重启所有对应副本吗?`,
'配置更新确认',
{ type: 'warning', confirmButtonText: '下发并重启', cancelButtonText: '取消' },
)
@@ -644,15 +942,56 @@ onUnmounted(() => stopPolling())
flex-wrap: wrap;
gap: 4px;
}
.filter-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 14px;
gap: 4px;
}
.scale-tip {
margin-left: 12px;
color: var(--cm-text-muted);
font-size: 12px;
}
.config-source {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
width: 100%;
margin-bottom: 8px;
}
.config-source-tip {
color: var(--el-color-warning);
font-size: 12px;
}
:deep(.el-table) {
--el-table-header-bg-color: var(--cm-bg-elevated);
--el-table-tr-bg-color: var(--cm-bg-soft);
--el-table-row-hover-bg-color: rgba(79, 140, 255, 0.06);
background: transparent;
}
.json-editor {
width: calc(100% - 128px);
}
.json-editor :deep(.el-textarea__inner) {
font-family: ui-monospace, 'SF Mono', Consolas, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
}
.json-editor-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.json-err {
color: var(--el-color-danger);
font-size: 12px;
}
.json-ok {
color: var(--el-color-success);
font-size: 12px;
}
</style>