modify 修复前端展示配置与实际插件配置不一致的bug
This commit is contained in:
+58
-4
@@ -22,6 +22,12 @@ export interface AdminCmdReply {
|
|||||||
exit_request?: string
|
exit_request?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 插件配置优先来源模式:toml=以 config.toml 为准(启动时覆盖缓存);cache=以缓存为准 */
|
||||||
|
export type ConfigMode = 'toml' | 'cache'
|
||||||
|
|
||||||
|
/** 展示值的实际来源:cache=运行时缓存;toml=缓存缺失或副本未响应,回退读配置文件 */
|
||||||
|
export type ConfigSource = 'cache' | 'toml'
|
||||||
|
|
||||||
export interface RestartAllResult {
|
export interface RestartAllResult {
|
||||||
worker_id: string
|
worker_id: string
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -125,10 +131,16 @@ export function parseJwt(token: string): AuthUser | null {
|
|||||||
|
|
||||||
/** 读取某插件的现有运行时配置([workers.config]) */
|
/** 读取某插件的现有运行时配置([workers.config]) */
|
||||||
export function getWorkerConfig(token: string, workerType: string) {
|
export function getWorkerConfig(token: string, workerType: string) {
|
||||||
return request<{ success: boolean; worker_type: string; config: Record<string, unknown> }>(
|
return request<{
|
||||||
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/config`,
|
success: boolean
|
||||||
token,
|
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 写回并重启 */
|
/** 更新某插件的运行时配置([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'
|
export const TOKEN_KEY = 'cm_admin_token'
|
||||||
+355
-16
@@ -65,6 +65,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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
|
<el-table
|
||||||
:data="pagedWorkers"
|
:data="pagedWorkers"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
@@ -166,9 +194,52 @@
|
|||||||
调整数量
|
调整数量
|
||||||
</el-button>
|
</el-button>
|
||||||
<span class="scale-tip"
|
<span class="scale-tip"
|
||||||
>调小会<strong>优雅排空</strong>:先隔离新请求,等当前请求处理完再关闭对应副本;数量将写入配置,重启网关后保持。</span
|
>调小会<strong>优雅排空</strong>:先隔离新请求,等当前请求处理完再关闭对应副本;数量写入运行时缓存——cache
|
||||||
|
模式下重启网关后保持,TOML 模式下重启会被 config.toml 的 count 覆盖。</span
|
||||||
>
|
>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -182,7 +253,7 @@
|
|||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="写入插件 config.toml 的 [workers.config](路由配置除外)。由 worker 写回并重启所有对应副本。"
|
:title="configAlertTitle"
|
||||||
style="margin-bottom: 14px"
|
style="margin-bottom: 14px"
|
||||||
/>
|
/>
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
@@ -190,6 +261,17 @@
|
|||||||
<el-tag effect="plain">{{ workerType || '-' }}</el-tag>
|
<el-tag effect="plain">{{ workerType || '-' }}</el-tag>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="现有配置值">
|
<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
|
<el-table
|
||||||
:data="currentConfig"
|
:data="currentConfig"
|
||||||
v-loading="configLoading"
|
v-loading="configLoading"
|
||||||
@@ -219,16 +301,38 @@
|
|||||||
<el-select
|
<el-select
|
||||||
v-model="configValueType"
|
v-model="configValueType"
|
||||||
style="width: 120px; margin-right: 8px"
|
style="width: 120px; margin-right: 8px"
|
||||||
|
@change="onConfigValueTypeChange"
|
||||||
>
|
>
|
||||||
<el-option label="字符串" value="string" />
|
<el-option label="字符串" value="string" />
|
||||||
<el-option label="数字" value="number" />
|
<el-option label="数字" value="number" />
|
||||||
<el-option label="布尔" value="boolean" />
|
<el-option label="布尔" value="boolean" />
|
||||||
|
<el-option label="对象 (JSON)" value="object" />
|
||||||
|
<el-option label="数组 (JSON)" value="array" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input
|
<el-input
|
||||||
|
v-if="!isJsonType"
|
||||||
v-model="configValue"
|
v-model="configValue"
|
||||||
:style="{ width: 'calc(100% - 128px)' }"
|
:style="{ width: 'calc(100% - 128px)' }"
|
||||||
placeholder="配置值"
|
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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -249,7 +353,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Monitor } from '@element-plus/icons-vue'
|
import { Monitor, Search } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
getWorkers,
|
getWorkers,
|
||||||
restartWorker,
|
restartWorker,
|
||||||
@@ -257,11 +361,16 @@ import {
|
|||||||
getWorkerConfig,
|
getWorkerConfig,
|
||||||
updateWorkerConfig,
|
updateWorkerConfig,
|
||||||
scaleWorker,
|
scaleWorker,
|
||||||
|
listWorkerInstances,
|
||||||
|
scaleWorkerInstance,
|
||||||
login,
|
login,
|
||||||
parseJwt,
|
parseJwt,
|
||||||
TOKEN_KEY,
|
TOKEN_KEY,
|
||||||
type WorkerInstance,
|
type WorkerInstance,
|
||||||
|
type WorkerInstanceGroup,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
|
type ConfigMode,
|
||||||
|
type ConfigSource,
|
||||||
} from '@/api/workerAdmin'
|
} from '@/api/workerAdmin'
|
||||||
|
|
||||||
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
|
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
|
||||||
@@ -279,24 +388,74 @@ const workerType = ref('')
|
|||||||
// 分页
|
// 分页
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(10)
|
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 pagedWorkers = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
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, () => {
|
watch(workers, () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
})
|
})
|
||||||
|
watch([searchText, modeFilter, minReplicas], () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
// 配置编辑
|
// 配置编辑
|
||||||
|
type ConfigValueType = 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||||
const configDialogVisible = ref(false)
|
const configDialogVisible = ref(false)
|
||||||
const configKey = ref('')
|
const configKey = ref('')
|
||||||
const configValue = ref('')
|
const configValue = ref('')
|
||||||
const configValueType = ref<'string' | 'number' | 'boolean'>('string')
|
const configValueType = ref<ConfigValueType>('string')
|
||||||
const configUpdating = ref(false)
|
const configUpdating = ref(false)
|
||||||
const configLoading = 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 doLogin = async () => {
|
||||||
const name = username.value.trim()
|
const name = username.value.trim()
|
||||||
@@ -346,13 +505,15 @@ const logout = () => {
|
|||||||
ElMessage.success('已退出登录')
|
ElMessage.success('已退出登录')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动轮询:让扩缩容/停止/重启等操作的结果(副本关闭、模式变化)无需手动刷新即反映到列表
|
// 自动轮询:让扩缩容/停止/重启/逐实例精细扩缩等操作的结果(副本关闭、模式、实例分组数)
|
||||||
|
// 无需手动刷新即反映到列表。逐实例分组与总列表一并刷新,避免扩缩后实例表停留旧值。
|
||||||
let pollTimer: ReturnType<typeof setInterval> | undefined
|
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
if (!token.value.trim()) return
|
if (!token.value.trim()) return
|
||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
if (!loading.value) fetchWorkers()
|
if (!loading.value) fetchWorkers()
|
||||||
|
if (!instancesLoading.value) fetchInstances()
|
||||||
}, 10000)
|
}, 10000)
|
||||||
}
|
}
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
@@ -394,8 +555,68 @@ const scaleTarget = ref(0)
|
|||||||
const scalingType = ref('')
|
const scalingType = ref('')
|
||||||
watch(workerType, (t) => {
|
watch(workerType, (t) => {
|
||||||
scaleTarget.value = t ? countByType(t) : 0
|
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 applyScale = async () => {
|
||||||
const t = workerType.value
|
const t = workerType.value
|
||||||
if (!t) return
|
if (!t) return
|
||||||
@@ -422,7 +643,10 @@ const applyScale = async () => {
|
|||||||
const res = await scaleWorker(token.value.trim(), t, target)
|
const res = await scaleWorker(token.value.trim(), t, target)
|
||||||
ElMessage.success(`已调整插件「${t}」副本数至 ${target}(${res.result.mode})`)
|
ElMessage.success(`已调整插件「${t}」副本数至 ${target}(${res.result.mode})`)
|
||||||
scaleTarget.value = target
|
scaleTarget.value = target
|
||||||
setTimeout(fetchWorkers, target > cur ? 1200 : 2600)
|
setTimeout(() => {
|
||||||
|
fetchWorkers()
|
||||||
|
fetchInstances()
|
||||||
|
}, target > cur ? 1200 : 2600)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(`调整副本数量失败:${e.message}`)
|
ElMessage.error(`调整副本数量失败:${e.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -508,18 +732,73 @@ const onSelectionChange = (rows: WorkerInstance[]) => {
|
|||||||
selected.value = rows
|
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 === 'boolean') return { value: String(v), type: 'boolean' }
|
||||||
if (typeof v === 'number') return { value: String(v), type: 'number' }
|
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' }
|
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 () => {
|
const openConfigDialog = async () => {
|
||||||
configKey.value = ''
|
configKey.value = ''
|
||||||
configValue.value = ''
|
configValue.value = ''
|
||||||
configValueType.value = 'string'
|
configValueType.value = 'string'
|
||||||
currentConfig.value = []
|
currentConfig.value = []
|
||||||
|
configMode.value = ''
|
||||||
|
configSource.value = ''
|
||||||
configDialogVisible.value = true
|
configDialogVisible.value = true
|
||||||
// 展示现有值:读取 [workers.config]
|
// 展示现有值:读取 [workers.config]
|
||||||
const t = token.value.trim()
|
const t = token.value.trim()
|
||||||
@@ -528,6 +807,8 @@ const openConfigDialog = async () => {
|
|||||||
configLoading.value = true
|
configLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await getWorkerConfig(t, type)
|
const data = await getWorkerConfig(t, type)
|
||||||
|
configMode.value = data.mode || ''
|
||||||
|
configSource.value = data.source || ''
|
||||||
currentConfig.value = Object.entries(data.config || {}).map(([k, v]) => ({
|
currentConfig.value = Object.entries(data.config || {}).map(([k, v]) => ({
|
||||||
key: k,
|
key: k,
|
||||||
...valueToDisplay(v),
|
...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
|
configKey.value = row.key
|
||||||
configValue.value = row.value
|
configValue.value = row.value
|
||||||
configValueType.value = row.type
|
configValueType.value = row.type
|
||||||
|
validateConfigJson()
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitConfig = async () => {
|
const submitConfig = async () => {
|
||||||
@@ -559,20 +841,36 @@ const submitConfig = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 按所选类型转换值
|
// 按所选类型转换值
|
||||||
let value: string | number | boolean = configValue.value
|
let value: unknown = configValue.value
|
||||||
if (configValueType.value === 'number') {
|
if (configValueType.value === 'number') {
|
||||||
if (configValue.value === '' || Number.isNaN(Number(configValue.value))) {
|
if (configValue.value.trim() === '' || Number.isNaN(Number(configValue.value))) {
|
||||||
ElMessage.warning('数字类型配置值不合法')
|
ElMessage.warning('数字类型配置值不合法')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
value = Number(configValue.value)
|
value = Number(configValue.value)
|
||||||
} else if (configValueType.value === 'boolean') {
|
} else if (configValueType.value === 'boolean') {
|
||||||
value = String(configValue.value).toLowerCase() === 'true'
|
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 {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定将「${type}」的配置 ${key}=${String(value)} 下发并重启所有对应副本吗?`,
|
`确定将「${type}」的配置 ${key}=${displayValue.length > 120 ? displayValue.slice(0, 120) + '…' : displayValue} 下发并重启所有对应副本吗?`,
|
||||||
'配置更新确认',
|
'配置更新确认',
|
||||||
{ type: 'warning', confirmButtonText: '下发并重启', cancelButtonText: '取消' },
|
{ type: 'warning', confirmButtonText: '下发并重启', cancelButtonText: '取消' },
|
||||||
)
|
)
|
||||||
@@ -644,15 +942,56 @@ onUnmounted(() => stopPolling())
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
.scale-tip {
|
.scale-tip {
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
color: var(--cm-text-muted);
|
color: var(--cm-text-muted);
|
||||||
font-size: 12px;
|
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) {
|
:deep(.el-table) {
|
||||||
--el-table-header-bg-color: var(--cm-bg-elevated);
|
--el-table-header-bg-color: var(--cm-bg-elevated);
|
||||||
--el-table-tr-bg-color: var(--cm-bg-soft);
|
--el-table-tr-bg-color: var(--cm-bg-soft);
|
||||||
--el-table-row-hover-bg-color: rgba(79, 140, 255, 0.06);
|
--el-table-row-hover-bg-color: rgba(79, 140, 255, 0.06);
|
||||||
background: transparent;
|
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>
|
</style>
|
||||||
Reference in New Issue
Block a user