modify: worker 管理
This commit is contained in:
Vendored
+17
@@ -16,6 +16,7 @@ declare module 'vue' {
|
|||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||||
ElFooter: typeof import('element-plus/es')['ElFooter']
|
ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
@@ -23,9 +24,15 @@ declare module 'vue' {
|
|||||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||||
@@ -33,6 +40,9 @@ declare module 'vue' {
|
|||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default']
|
WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default']
|
||||||
}
|
}
|
||||||
|
export interface GlobalDirectives {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For TSX support
|
// For TSX support
|
||||||
@@ -41,6 +51,7 @@ declare global {
|
|||||||
const ElButton: typeof import('element-plus/es')['ElButton']
|
const ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
const ElCard: typeof import('element-plus/es')['ElCard']
|
const ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
const ElContainer: typeof import('element-plus/es')['ElContainer']
|
const ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
|
const ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
const ElDivider: typeof import('element-plus/es')['ElDivider']
|
const ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||||
const ElFooter: typeof import('element-plus/es')['ElFooter']
|
const ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||||
const ElForm: typeof import('element-plus/es')['ElForm']
|
const ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
@@ -48,9 +59,15 @@ declare global {
|
|||||||
const ElHeader: typeof import('element-plus/es')['ElHeader']
|
const ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
const ElIcon: typeof import('element-plus/es')['ElIcon']
|
const ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
const ElInput: typeof import('element-plus/es')['ElInput']
|
const ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
const ElMain: typeof import('element-plus/es')['ElMain']
|
const ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
const ElMenu: typeof import('element-plus/es')['ElMenu']
|
const ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
const ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
const ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
const ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
const ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
const ElTag: typeof import('element-plus/es')['ElTag']
|
const ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
const ElUpload: typeof import('element-plus/es')['ElUpload']
|
const ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
const HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
const HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="zh-CN" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>Chongming 平台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+103
-27
@@ -1,24 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="layout">
|
||||||
<el-container class="app-container">
|
<el-container class="app-container">
|
||||||
<el-header class="app-header">
|
<el-header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-icon :size="28" color="#409EFF"><Cpu /></el-icon>
|
<div class="logo-badge">
|
||||||
<span class="app-title">Chongming 平台</span>
|
<el-icon :size="22" color="#4f8cff"><Cpu /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="brand">
|
||||||
|
<span class="app-title">Chongming</span>
|
||||||
|
<span class="app-subtitle">Zenoh 微服务网关平台</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="$route.path"
|
:default-active="$route.path"
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
router
|
router
|
||||||
class="header-menu"
|
class="header-menu"
|
||||||
background-color="transparent"
|
:ellipsis="false"
|
||||||
text-color="#333"
|
|
||||||
active-text-color="#409EFF"
|
|
||||||
>
|
>
|
||||||
<el-menu-item index="/">
|
<el-menu-item index="/">
|
||||||
<el-icon><HomeFilled /></el-icon> 豆包测试
|
<el-icon><HomeFilled /></el-icon>
|
||||||
|
<span>豆包测试</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/workers">
|
||||||
|
<el-icon><Monitor /></el-icon>
|
||||||
|
<span>Worker 管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/about">
|
<el-menu-item index="/about">
|
||||||
<el-icon><InfoFilled /></el-icon> 关于
|
<el-icon><InfoFilled /></el-icon>
|
||||||
|
<span>关于</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-header>
|
</el-header>
|
||||||
@@ -27,61 +38,126 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</el-main>
|
</el-main>
|
||||||
|
|
||||||
<el-footer class="app-footer" height="50px">
|
<el-footer class="app-footer" height="52px">
|
||||||
© 2026 Chongming · 基于 FastAPI + Vue 3 + Element Plus
|
<span>© 2026 Chongming · FastAPI + Zenoh + Vue 3 + Element Plus</span>
|
||||||
</el-footer>
|
</el-footer>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Cpu, HomeFilled, InfoFilled } from '@element-plus/icons-vue'
|
import { Cpu, HomeFilled, InfoFilled, Monitor } from '@element-plus/icons-vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--cm-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f7fa;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 顶部导航 ---------- */
|
||||||
.app-header {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
|
||||||
padding: 0 24px;
|
|
||||||
height: 64px;
|
height: 64px;
|
||||||
border-bottom: 1px solid #e4e7ed;
|
padding: 0 28px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(15, 20, 28, 0.75);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid var(--cm-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, rgba(79, 140, 255, 0.22), rgba(79, 140, 255, 0.06));
|
||||||
|
border: 1px solid rgba(79, 140, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
.app-title {
|
.app-title {
|
||||||
font-size: 20px;
|
font-size: 19px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #2c3e50;
|
color: var(--cm-text);
|
||||||
|
letter-spacing: 0.4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cm-text-muted);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 菜单 ---------- */
|
||||||
.header-menu {
|
.header-menu {
|
||||||
border-bottom: none;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
border-bottom: none;
|
||||||
|
background: transparent;
|
||||||
|
--el-menu-hover-bg-color: rgba(79, 140, 255, 0.08);
|
||||||
|
--el-menu-active-color: #4f8cff;
|
||||||
}
|
}
|
||||||
.header-menu .el-menu-item {
|
.header-menu .el-menu-item {
|
||||||
height: 64px;
|
height: 64px;
|
||||||
line-height: 64px;
|
line-height: 64px;
|
||||||
|
color: var(--cm-text-muted);
|
||||||
|
border-bottom: none;
|
||||||
|
margin: 0 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
.header-menu .el-menu-item:hover {
|
||||||
|
color: var(--cm-text);
|
||||||
|
background: rgba(79, 140, 255, 0.08);
|
||||||
|
}
|
||||||
|
.header-menu .el-menu-item.is-active {
|
||||||
|
color: #4f8cff;
|
||||||
|
background: rgba(79, 140, 255, 0.12);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.header-menu .el-menu-item i {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 主内容 ---------- */
|
||||||
.app-main {
|
.app-main {
|
||||||
padding: 24px;
|
padding: 28px;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 底部 ---------- */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
text-align: center;
|
display: flex;
|
||||||
color: #909399;
|
align-items: center;
|
||||||
font-size: 14px;
|
justify-content: center;
|
||||||
border-top: 1px solid #e4e7ed;
|
color: var(--cm-text-muted);
|
||||||
background: #ffffff;
|
font-size: 13px;
|
||||||
|
border-top: 1px solid var(--cm-border);
|
||||||
|
background: rgba(15, 20, 28, 0.6);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Worker 管理 API(手动封装,管理端点在网关 /admin/workers 下)
|
||||||
|
*
|
||||||
|
* 管理端点(重启)仅超级管理员可调用,故所有请求需携带管理员的 Bearer token。
|
||||||
|
* 未列入自动生成 SDK(openapi 之后可重新 gen 合并),此处用原生 fetch 保持轻量。
|
||||||
|
*/
|
||||||
|
const API_BASE: string = (import.meta.env.VITE_API_BASE_URL as string) || ''
|
||||||
|
|
||||||
|
export interface WorkerInstance {
|
||||||
|
worker_type: string
|
||||||
|
worker_id: string
|
||||||
|
mode: 'inline' | 'external'
|
||||||
|
alive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCmdReply {
|
||||||
|
status?: string
|
||||||
|
cmd?: string
|
||||||
|
worker_type?: string
|
||||||
|
worker_id?: string
|
||||||
|
exit_request?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestartAllResult {
|
||||||
|
worker_id: string
|
||||||
|
ok: boolean
|
||||||
|
result?: AdminCmdReply
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, token: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = `HTTP ${res.status}`
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
if (body?.detail) {
|
||||||
|
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore non-JSON error body */
|
||||||
|
}
|
||||||
|
throw new Error(detail)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 盘点全部 Worker 实例 */
|
||||||
|
export function getWorkers(token: string) {
|
||||||
|
return request<{ success: boolean; workers: WorkerInstance[] }>('/api/v1/admin/workers', token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 精细化重启单个 Worker 副本 */
|
||||||
|
export function restartWorker(token: string, workerType: string, workerId: string) {
|
||||||
|
const qs = new URLSearchParams({ worker_type: workerType, worker_id: workerId })
|
||||||
|
return request<{ success: boolean; result: AdminCmdReply }>(
|
||||||
|
`/api/v1/admin/workers/restart?${qs.toString()}`,
|
||||||
|
token,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重启某插件全部副本 */
|
||||||
|
export function restartAll(token: string, workerType: string) {
|
||||||
|
return request<{ success: boolean; total: number; failed: number; results: RestartAllResult[] }>(
|
||||||
|
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/restart`,
|
||||||
|
token,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigUpdateResult {
|
||||||
|
total: number
|
||||||
|
failed: number
|
||||||
|
ok: boolean
|
||||||
|
results: Array<{ worker_id: string; ok: boolean; reply?: AdminCmdReply; error?: string }>
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
username: string
|
||||||
|
is_superuser: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
success: boolean
|
||||||
|
result: string
|
||||||
|
message: string
|
||||||
|
token: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过 auth 插件登录接口换取管理 Token(无需手动填 JWT,返回体只有 token,用户信息需从 JWT 解析) */
|
||||||
|
export function login(username: string, password: string) {
|
||||||
|
return request<LoginResult>('/api/v1/auth/login', '', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 JWT payload 解析用户名与超管标识(仅本地展示用,权限校验以后端为准) */
|
||||||
|
export function parseJwt(token: string): AuthUser | null {
|
||||||
|
try {
|
||||||
|
const part = token.split('.')[1]
|
||||||
|
if (!part) return null
|
||||||
|
const b64 = part.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
|
||||||
|
const payload = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
username: String(payload.username ?? 'unknown'),
|
||||||
|
is_superuser: !!payload.is_superuser,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新某插件的运行时配置([workers.config],路由配置除外),由 worker 写回并重启 */
|
||||||
|
export function updateWorkerConfig(token: string, workerType: string, updates: Record<string, unknown>) {
|
||||||
|
return request<ConfigUpdateResult>(
|
||||||
|
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/config`,
|
||||||
|
token,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ updates }) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScaleResult {
|
||||||
|
success: boolean
|
||||||
|
result: {
|
||||||
|
worker_type: string
|
||||||
|
target: number
|
||||||
|
mode: 'inline' | 'external'
|
||||||
|
sent_to?: string
|
||||||
|
reply?: AdminCmdReply & { persist?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调整某类型 worker 副本数量(扩缩容,缩容走优雅排空),结果含持久化信息 */
|
||||||
|
export function scaleWorker(token: string, workerType: string, count: number) {
|
||||||
|
return request<ScaleResult>(
|
||||||
|
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/scale`,
|
||||||
|
token,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ count }) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOKEN_KEY = 'cm_admin_token'
|
||||||
+50
-59
@@ -1,53 +1,39 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
/* ============================================================
|
||||||
:root {
|
Chongming 深色主题基础样式
|
||||||
--vt-c-white: #ffffff;
|
始终使用深色配色(html.dark 已启用),并微调 Element Plus
|
||||||
--vt-c-white-soft: #f8f8f8;
|
深色变量以获得更统一的观感。
|
||||||
--vt-c-white-mute: #f2f2f2;
|
============================================================ */
|
||||||
|
|
||||||
--vt-c-black: #181818;
|
:root,
|
||||||
--vt-c-black-soft: #222222;
|
html.dark {
|
||||||
--vt-c-black-mute: #282828;
|
/* 背景层级 */
|
||||||
|
--cm-bg: #0d1117; /* 页面最底层 */
|
||||||
|
--cm-bg-soft: #161b22; /* 卡片/面板 */
|
||||||
|
--cm-bg-elevated: #1c2129; /* 悬浮/表头 */
|
||||||
|
--cm-border: #2a303a; /* 分割线 */
|
||||||
|
--cm-text: #e6edf3; /* 主文字 */
|
||||||
|
--cm-text-muted: #8b949e; /* 次要文字 */
|
||||||
|
|
||||||
--vt-c-indigo: #2c3e50;
|
/* Element Plus 深色变量覆盖:统一背景与文字 */
|
||||||
|
--el-bg-color: #161b22;
|
||||||
|
--el-bg-color-page: #0d1117;
|
||||||
|
--el-bg-color-overlay: #1c2129;
|
||||||
|
--el-fill-color-blank: #161b22;
|
||||||
|
--el-fill-color: #21262d;
|
||||||
|
--el-fill-color-light: #262c34;
|
||||||
|
--el-fill-color-lighter: #2a303a;
|
||||||
|
--el-fill-color-lightest: #2f3640;
|
||||||
|
--el-border-color: #2a303a;
|
||||||
|
--el-border-color-light: #2a303a;
|
||||||
|
--el-border-color-lighter: #30363d;
|
||||||
|
--el-border-color-extra-light: #353b45;
|
||||||
|
--el-text-color-primary: #e6edf3;
|
||||||
|
--el-text-color-regular: #c9d1d9;
|
||||||
|
--el-text-color-secondary: #8b949e;
|
||||||
|
--el-text-color-placeholder: #6e7681;
|
||||||
|
--el-color-primary: #4f8cff;
|
||||||
|
|
||||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
color-scheme: dark;
|
||||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
|
||||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
|
||||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
|
||||||
|
|
||||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
|
||||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
|
||||||
--vt-c-text-dark-1: var(--vt-c-white);
|
|
||||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-white);
|
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-light-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-light-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
|
||||||
--color-text: var(--vt-c-text-light-1);
|
|
||||||
|
|
||||||
--section-gap: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-black);
|
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -58,26 +44,31 @@
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: var(--color-text);
|
width: 100%;
|
||||||
background: var(--color-background);
|
}
|
||||||
transition:
|
|
||||||
color 0.5s,
|
body {
|
||||||
background-color 0.5s;
|
margin: 0;
|
||||||
|
color: var(--cm-text);
|
||||||
|
background: var(--cm-bg);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, rgba(79, 140, 255, 0.10), transparent 60%),
|
||||||
|
radial-gradient(1000px 500px at 110% 10%, rgba(79, 140, 255, 0.06), transparent 55%);
|
||||||
|
background-attachment: fixed;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-family:
|
font-family:
|
||||||
Inter,
|
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
'Segoe UI',
|
'Segoe UI',
|
||||||
Roboto,
|
Roboto,
|
||||||
Oxygen,
|
|
||||||
Ubuntu,
|
|
||||||
Cantarell,
|
|
||||||
'Fira Sans',
|
|
||||||
'Droid Sans',
|
|
||||||
'Helvetica Neue',
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
'PingFang SC',
|
||||||
|
'Microsoft YaHei',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
|||||||
+21
-24
@@ -1,35 +1,32 @@
|
|||||||
@import './base.css';
|
@import './base.css';
|
||||||
|
|
||||||
|
/* 应用全局兜底:字号与滚动条 */
|
||||||
#app {
|
#app {
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
a {
|
||||||
.green {
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: hsla(160, 100%, 37%, 1);
|
color: var(--el-color-primary);
|
||||||
transition: 0.4s;
|
transition: 0.2s;
|
||||||
padding: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
/* 全局深色细滚动条 */
|
||||||
a:hover {
|
* {
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
scrollbar-width: thin;
|
||||||
}
|
scrollbar-color: var(--cm-border) transparent;
|
||||||
}
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
@media (min-width: 1024px) {
|
width: 10px;
|
||||||
body {
|
height: 10px;
|
||||||
display: flex;
|
}
|
||||||
place-items: center;
|
*::-webkit-scrollbar-thumb {
|
||||||
}
|
background: var(--cm-border);
|
||||||
|
border-radius: 6px;
|
||||||
#app {
|
}
|
||||||
display: grid;
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
grid-template-columns: 1fr 1fr;
|
background: #3d4550;
|
||||||
padding: 0 2rem;
|
}
|
||||||
}
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import './assets/main.css'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const router = createRouter({
|
|||||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import('../views/AboutView.vue'),
|
component: () => import('../views/AboutView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/workers',
|
||||||
|
name: 'workers',
|
||||||
|
// 懒加载 Worker 管理页面(实例盘点 + 单/批量重启)
|
||||||
|
component: () => import('../views/WorkerView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+39
-7
@@ -1,16 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="about">
|
<div class="about">
|
||||||
<h1>关于页面</h1>
|
<el-card class="about-card" shadow="never">
|
||||||
<p>这里是后续扩展的示例页面。</p>
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<el-icon :size="20" color="#4f8cff"><InfoFilled /></el-icon>
|
||||||
|
<span class="title">关于 Chongming</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p>Chongming 是配置驱动的微服务网关平台,基于 <b>FastAPI + Zenoh + MQTT + Vue 3</b> 构建。</p>
|
||||||
|
<p>Web 请求经网关路由到对应的 Worker 插件(RPC 层走 Eclipse Zenoh),支持分布式、多语言插件部署。</p>
|
||||||
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<script setup lang="ts">
|
||||||
@media (min-width: 1024px) {
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
.about {
|
</script>
|
||||||
min-height: 100vh;
|
|
||||||
|
<style scoped>
|
||||||
|
.about {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.about-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--cm-border);
|
||||||
|
background: var(--cm-bg-soft);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--cm-text);
|
||||||
|
}
|
||||||
|
.about-card p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--cm-text-regular, var(--cm-text));
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.about-card b {
|
||||||
|
color: var(--cm-text);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -239,14 +239,15 @@ const copyResult = () => {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #dcdfe6;
|
border: 1px solid var(--cm-border);
|
||||||
}
|
}
|
||||||
.preview-actions {
|
.preview-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: rgba(22, 27, 34, 0.75);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
.action-bar {
|
.action-bar {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
@@ -255,11 +256,12 @@ const copyResult = () => {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.result-content {
|
.result-content {
|
||||||
background: #f5f7fa;
|
background: var(--el-fill-color-lighter);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
|
color: var(--cm-text);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<template>
|
||||||
|
<div class="worker-container">
|
||||||
|
<el-card class="toolbar-card" shadow="hover">
|
||||||
|
<template v-if="!token">
|
||||||
|
<el-form inline>
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input
|
||||||
|
v-model="username"
|
||||||
|
placeholder="用户名"
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="doLogin"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="密码"
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="doLogin"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="logging" style="margin-left: 8px" @click="doLogin">
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="通过 auth 插件登录领取管理 Token。仅超级管理员可配置/重启 Worker,权限不足将弹出提示。"
|
||||||
|
show-icon
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="login-bar">
|
||||||
|
<el-tag effect="plain" type="primary">
|
||||||
|
{{ currentUser?.username || '已登录' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="currentUser?.is_superuser" type="success" size="small">
|
||||||
|
超级管理员
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="danger" size="small">非超管(操作将被拒绝)</el-tag>
|
||||||
|
<el-button type="primary" :loading="loading" style="margin-left: 12px" @click="fetchWorkers">
|
||||||
|
刷新列表
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="warning"
|
||||||
|
:disabled="!workerType || selected.length === 0"
|
||||||
|
@click="batchRestart"
|
||||||
|
>
|
||||||
|
重启选中 ({{ selected.length }})
|
||||||
|
</el-button>
|
||||||
|
<el-button type="info" plain style="margin-left: 12px" @click="logout">退出</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="table-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<el-icon :size="20"><Monitor /></el-icon>
|
||||||
|
<span style="font-weight: 600; margin-left: 8px">Worker 实例列表</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="pagedWorkers"
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%"
|
||||||
|
@selection-change="onSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="50" />
|
||||||
|
<el-table-column prop="worker_type" label="插件类型" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag effect="plain">{{ row.worker_type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="worker_id" label="副本 ID" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="模式" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.mode === 'inline' ? 'success' : 'warning'" size="small">
|
||||||
|
{{ row.mode }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.alive ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.alive ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:loading="restartingId === row.worker_id"
|
||||||
|
@click="restartOne(row)"
|
||||||
|
>
|
||||||
|
重启单副本
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pager-bar">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
background
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="workerTypes.length" style="margin-top: 12px">
|
||||||
|
<el-divider content-position="left">插件级批量操作</el-divider>
|
||||||
|
<el-select v-model="workerType" placeholder="选择插件类型" style="width: 200px">
|
||||||
|
<el-option
|
||||||
|
v-for="t in workerTypes"
|
||||||
|
:key="t"
|
||||||
|
:label="`${t}(${countByType(t)} 副本)`"
|
||||||
|
:value="t"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button
|
||||||
|
type="warning"
|
||||||
|
style="margin-left: 12px"
|
||||||
|
:loading="restartingType === workerType"
|
||||||
|
:disabled="!workerType"
|
||||||
|
@click="restartAllForType"
|
||||||
|
>
|
||||||
|
重启该插件全部副本
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
style="margin-left: 12px"
|
||||||
|
:disabled="!workerType"
|
||||||
|
@click="openConfigDialog"
|
||||||
|
>
|
||||||
|
更新插件配置
|
||||||
|
</el-button>
|
||||||
|
<el-divider style="margin: 14px 0" />
|
||||||
|
<div class="scale-bar">
|
||||||
|
<span style="color: var(--cm-text-muted)"
|
||||||
|
>副本数量:当前 <strong>{{ workerType ? countByType(workerType) : '—' }}</strong></span
|
||||||
|
>
|
||||||
|
<el-input-number
|
||||||
|
v-model="scaleTarget"
|
||||||
|
:min="0"
|
||||||
|
:max="50"
|
||||||
|
:disabled="!workerType || scalingType === workerType"
|
||||||
|
style="margin-left: 12px"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
style="margin-left: 12px"
|
||||||
|
:loading="scalingType === workerType"
|
||||||
|
:disabled="!workerType"
|
||||||
|
@click="applyScale"
|
||||||
|
>
|
||||||
|
调整数量
|
||||||
|
</el-button>
|
||||||
|
<span class="scale-tip"
|
||||||
|
>调小会<strong>优雅排空</strong>:先隔离新请求,等当前请求处理完再关闭对应副本;数量将写入配置,重启网关后保持。</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="configDialogVisible"
|
||||||
|
title="更新插件运行时配置"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="写入插件 config.toml 的 [workers.config](路由配置除外)。由 worker 写回并重启所有对应副本。"
|
||||||
|
style="margin-bottom: 14px"
|
||||||
|
/>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="插件类型">
|
||||||
|
<el-tag effect="plain">{{ workerType || '-' }}</el-tag>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="现有配置值">
|
||||||
|
<el-table
|
||||||
|
:data="currentConfig"
|
||||||
|
v-loading="configLoading"
|
||||||
|
size="small"
|
||||||
|
max-height="220"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="(row: any) => row && selectConfigEntry(row)"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-table-column prop="key" label="键" width="160" />
|
||||||
|
<el-table-column prop="value" label="当前值" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="type" label="类型" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="info">{{ row.type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<span style="color: var(--cm-text-muted)">暂无 `[workers.config]` 配置项,可直接新增</span>
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</el-form-item>
|
||||||
|
<el-divider style="margin: 4px 0 14px" />
|
||||||
|
<el-form-item label="配置键" required>
|
||||||
|
<el-input v-model="configKey" placeholder="如 data_dir(点击上方行可带入)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="配置值">
|
||||||
|
<el-select
|
||||||
|
v-model="configValueType"
|
||||||
|
style="width: 120px; margin-right: 8px"
|
||||||
|
>
|
||||||
|
<el-option label="字符串" value="string" />
|
||||||
|
<el-option label="数字" value="number" />
|
||||||
|
<el-option label="布尔" value="boolean" />
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="configValue"
|
||||||
|
:style="{ width: 'calc(100% - 128px)' }"
|
||||||
|
placeholder="配置值"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="configDialogVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="configUpdating"
|
||||||
|
:disabled="!configKey"
|
||||||
|
@click="submitConfig"
|
||||||
|
>
|
||||||
|
下发并重启
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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 {
|
||||||
|
getWorkers,
|
||||||
|
restartWorker,
|
||||||
|
restartAll,
|
||||||
|
getWorkerConfig,
|
||||||
|
updateWorkerConfig,
|
||||||
|
scaleWorker,
|
||||||
|
login,
|
||||||
|
parseJwt,
|
||||||
|
TOKEN_KEY,
|
||||||
|
type WorkerInstance,
|
||||||
|
type AuthUser,
|
||||||
|
} from '@/api/workerAdmin'
|
||||||
|
|
||||||
|
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
|
||||||
|
const currentUser = ref<AuthUser | null>(null)
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const logging = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const workers = ref<WorkerInstance[]>([])
|
||||||
|
const selected = ref<WorkerInstance[]>([])
|
||||||
|
const restartingId = ref('')
|
||||||
|
const restartingType = ref('')
|
||||||
|
const workerType = ref('')
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = computed(() => workers.value.length)
|
||||||
|
const pagedWorkers = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
return workers.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
// 数据刷新/变化时回到第一页
|
||||||
|
watch(workers, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 配置编辑
|
||||||
|
const configDialogVisible = ref(false)
|
||||||
|
const configKey = ref('')
|
||||||
|
const configValue = ref('')
|
||||||
|
const configValueType = ref<'string' | 'number' | 'boolean'>('string')
|
||||||
|
const configUpdating = ref(false)
|
||||||
|
const configLoading = ref(false)
|
||||||
|
const currentConfig = ref<Array<{ key: string; value: string; type: 'string' | 'number' | 'boolean' }>>([])
|
||||||
|
|
||||||
|
const doLogin = async () => {
|
||||||
|
const name = username.value.trim()
|
||||||
|
if (!name || !password.value) {
|
||||||
|
ElMessage.warning('请输入用户名和密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logging.value = true
|
||||||
|
try {
|
||||||
|
const data = await login(name, password.value)
|
||||||
|
// 后端登录失败仍返回 HTTP 200,仅 result=fail + 空 token
|
||||||
|
if (data.result !== 'success' || !data.token) {
|
||||||
|
ElMessage.error(`登录失败:${data.message || '用户名或密码错误'}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token.value = data.token
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.token)
|
||||||
|
username.value = ''
|
||||||
|
password.value = ''
|
||||||
|
const info = parseJwt(data.token)
|
||||||
|
currentUser.value = info
|
||||||
|
ElMessage.success(`登录成功:${(info?.username || '').trim() || '未知用户'}`)
|
||||||
|
if (info?.is_superuser) {
|
||||||
|
await fetchWorkers()
|
||||||
|
startPolling()
|
||||||
|
} else {
|
||||||
|
ElMessage.error('权限不足:仅超级管理员可管理 Worker,操作将被拒绝')
|
||||||
|
workers.value = []
|
||||||
|
selected.value = []
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(`登录失败:${e.message}`)
|
||||||
|
} finally {
|
||||||
|
logging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
stopPolling()
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
token.value = ''
|
||||||
|
currentUser.value = null
|
||||||
|
workers.value = []
|
||||||
|
selected.value = []
|
||||||
|
currentPage.value = 1
|
||||||
|
ElMessage.success('已退出登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动轮询:让扩缩容/停止/重启等操作的结果(副本关闭、模式变化)无需手动刷新即反映到列表
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
const startPolling = () => {
|
||||||
|
stopPolling()
|
||||||
|
if (!token.value.trim()) return
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (!loading.value) fetchWorkers()
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWorkers = async () => {
|
||||||
|
const t = token.value.trim()
|
||||||
|
if (!t) {
|
||||||
|
ElMessage.warning('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getWorkers(t)
|
||||||
|
// auth 插件为核心鉴权,其配置不可更改,不在 Worker 管理列表展示
|
||||||
|
workers.value = (data.workers || []).filter((w) => w.worker_type !== 'auth')
|
||||||
|
if (!workerType.value && workers.value.length) {
|
||||||
|
workerType.value = workers.value[0]!.worker_type
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(`获取 Worker 列表失败:${e.message}`)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerTypes = computed(() => {
|
||||||
|
return Array.from(new Set(workers.value.map((w) => w.worker_type))).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const countByType = (t: string) => workers.value.filter((w) => w.worker_type === t).length
|
||||||
|
|
||||||
|
// 副本数量调整(扩缩容)
|
||||||
|
const scaleTarget = ref(0)
|
||||||
|
const scalingType = ref('')
|
||||||
|
watch(workerType, (t) => {
|
||||||
|
scaleTarget.value = t ? countByType(t) : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyScale = async () => {
|
||||||
|
const t = workerType.value
|
||||||
|
if (!t) return
|
||||||
|
const target = Math.floor(scaleTarget.value)
|
||||||
|
if (Number.isNaN(target) || target < 0) return
|
||||||
|
const cur = countByType(t)
|
||||||
|
if (target === cur) {
|
||||||
|
ElMessage.info('目标数量与当前相同,未调整')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
target > cur
|
||||||
|
? `扩容插件「${t}」到 ${target} 个副本(新增 ${target - cur} 个)?将持久化到配置。`
|
||||||
|
: `缩容插件「${t}」到 ${target} 个副本(关闭 ${cur - target} 个)?将优雅排空:先隔离新请求,等待当前请求处理完毕后再关闭对应副本。`,
|
||||||
|
'调整副本数量',
|
||||||
|
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scalingType.value = t
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(`调整副本数量失败:${e.message}`)
|
||||||
|
} finally {
|
||||||
|
scalingType.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restartOne = async (row: WorkerInstance) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定重启副本「${row.worker_id}」吗?重启后将生成新副本 ID,期间该副本短暂不可用。`,
|
||||||
|
'重启确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '重启', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
restartingId.value = row.worker_id
|
||||||
|
try {
|
||||||
|
await restartWorker(token.value.trim(), row.worker_type, row.worker_id)
|
||||||
|
ElMessage.success(`已下发重启指令:${row.worker_id}`)
|
||||||
|
// 重启后等新副本注册完成再刷新
|
||||||
|
setTimeout(fetchWorkers, 1200)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(`下发重启指令失败:${e.message}`)
|
||||||
|
} finally {
|
||||||
|
restartingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchRestart = async () => {
|
||||||
|
if (!selected.value.length) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定重启选中的 ${selected.value.length} 个副本吗?`,
|
||||||
|
'批量重启确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '重启', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ok = 0
|
||||||
|
let fail = 0
|
||||||
|
for (const w of selected.value) {
|
||||||
|
try {
|
||||||
|
await restartWorker(token.value.trim(), w.worker_type, w.worker_id)
|
||||||
|
ok++
|
||||||
|
} catch {
|
||||||
|
fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage[fail ? 'warning' : 'success'](`批量重启完成:成功 ${ok},失败 ${fail}`)
|
||||||
|
setTimeout(fetchWorkers, 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restartAllForType = async () => {
|
||||||
|
const t = workerType.value
|
||||||
|
if (!t) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定重启插件「${t}」的全部 ${countByType(t)} 个副本吗?`,
|
||||||
|
'插件级重启确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '重启', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
restartingType.value = t
|
||||||
|
try {
|
||||||
|
const data = await restartAll(token.value.trim(), t)
|
||||||
|
ElMessage[data.failed ? 'warning' : 'success'](
|
||||||
|
`插件 ${t} 重启完成:成功 ${data.total - data.failed}/${data.total}`,
|
||||||
|
)
|
||||||
|
setTimeout(fetchWorkers, 1200)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(`重启插件失败:${e.message}`)
|
||||||
|
} finally {
|
||||||
|
restartingType.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectionChange = (rows: WorkerInstance[]) => {
|
||||||
|
selected.value = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueToDisplay = (v: unknown): { value: string; type: 'string' | 'number' | 'boolean' } => {
|
||||||
|
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' }
|
||||||
|
return { value: String(v ?? ''), type: 'string' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const openConfigDialog = async () => {
|
||||||
|
configKey.value = ''
|
||||||
|
configValue.value = ''
|
||||||
|
configValueType.value = 'string'
|
||||||
|
currentConfig.value = []
|
||||||
|
configDialogVisible.value = true
|
||||||
|
// 展示现有值:读取 [workers.config]
|
||||||
|
const t = token.value.trim()
|
||||||
|
const type = workerType.value
|
||||||
|
if (t && type) {
|
||||||
|
configLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getWorkerConfig(t, type)
|
||||||
|
currentConfig.value = Object.entries(data.config || {}).map(([k, v]) => ({
|
||||||
|
key: k,
|
||||||
|
...valueToDisplay(v),
|
||||||
|
}))
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.warning(`读取现有配置失败(不影响编辑):${e.message}`)
|
||||||
|
} finally {
|
||||||
|
configLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectConfigEntry = (row: { key: string; value: string; type: 'string' | 'number' | 'boolean' }) => {
|
||||||
|
configKey.value = row.key
|
||||||
|
configValue.value = row.value
|
||||||
|
configValueType.value = row.type
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitConfig = async () => {
|
||||||
|
const t = token.value.trim()
|
||||||
|
const type = workerType.value
|
||||||
|
const key = configKey.value.trim()
|
||||||
|
if (!t) {
|
||||||
|
ElMessage.warning('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!type || !key) {
|
||||||
|
ElMessage.warning('请选择插件类型并填写配置键')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 按所选类型转换值
|
||||||
|
let value: string | number | boolean = configValue.value
|
||||||
|
if (configValueType.value === 'number') {
|
||||||
|
if (configValue.value === '' || Number.isNaN(Number(configValue.value))) {
|
||||||
|
ElMessage.warning('数字类型配置值不合法')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value = Number(configValue.value)
|
||||||
|
} else if (configValueType.value === 'boolean') {
|
||||||
|
value = String(configValue.value).toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定将「${type}」的配置 ${key}=${String(value)} 下发并重启所有对应副本吗?`,
|
||||||
|
'配置更新确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '下发并重启', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configUpdating.value = true
|
||||||
|
try {
|
||||||
|
const result = await updateWorkerConfig(t, type, { [key]: value })
|
||||||
|
if (result.ok) {
|
||||||
|
ElMessage.success(`配置已下发并生效(成功 ${result.total}/${result.total} 副本)`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(
|
||||||
|
`配置更新部分失败(成功 ${result.total - result.failed}/${result.total}).\n${result.errors.join('\n')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
configDialogVisible.value = false
|
||||||
|
setTimeout(fetchWorkers, 1500)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(`配置更新失败:${e.message}`)
|
||||||
|
} finally {
|
||||||
|
configUpdating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (token.value.trim()) {
|
||||||
|
// 刷新/重新进入时从持久化 token 恢复身份(登录后 currentUser 只在此处与 doLogin 设置)
|
||||||
|
currentUser.value = parseJwt(token.value.trim())
|
||||||
|
fetchWorkers()
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onUnmounted(() => stopPolling())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.worker-container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.toolbar-card,
|
||||||
|
.table-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--cm-border);
|
||||||
|
background: var(--cm-bg-soft);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--cm-text);
|
||||||
|
}
|
||||||
|
.login-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pager-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.scale-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.scale-tip {
|
||||||
|
margin-left: 12px;
|
||||||
|
color: var(--cm-text-muted);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user