增加一键重启所有网关按钮
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-10-21 17:21:11 +08:00
parent 8fdec02a1c
commit 421ffa0714
12 changed files with 553 additions and 1 deletions
@@ -111,6 +111,20 @@ export async function restartProfileRuntime(name: string): Promise<ProfileRuntim
return res.status
}
export interface BatchRestartResult {
success: boolean
restarted: string[]
failed: Array<{ profile: string; error?: string }>
}
export async function restartAllProfileGateways(): Promise<BatchRestartResult> {
return request<BatchRestartResult>('/api/hermes/profiles/gateways/restart-all', { method: 'POST' })
}
export async function restartAllProfileRuntimes(): Promise<BatchRestartResult> {
return request<BatchRestartResult>('/api/hermes/profiles/restart-all', { method: 'POST' })
}
export interface CreateProfileResult {
success: boolean
/** clone=true 时被清理的独占平台凭据 KEY 名 */
@@ -4,6 +4,8 @@ import { useMessage } from '@/composables/useMessage'
import { useProfilesStore } from '@/stores/hermes/profiles'
import {
fetchProfileRuntimeStatusesWithMeta,
restartAllProfileGateways,
restartAllProfileRuntimes,
restartProfileGateway,
restartProfileRuntime,
type HermesProfile,
@@ -34,6 +36,8 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
const gatewayRestarting = ref<Record<string, boolean>>({})
const profileRestarting = ref<Record<string, boolean>>({})
const profileSwitching = ref<Record<string, boolean>>({})
const allGatewaysRestarting = ref(false)
const allProfilesRestarting = ref(false)
const statusByProfile = computed(() => new Map(runtimeStatuses.value.map(status => [status.profile, status])))
let runtimeRefreshToken = 0
@@ -193,6 +197,40 @@ async function handleRestartProfile(name: string) {
}
}
async function handleRestartAllGateways() {
allGatewaysRestarting.value = true
try {
const result = await restartAllProfileGateways()
if (result.failed.length > 0) {
message.error(t('profiles.runtime.allGatewaysRestartFailed', { names: result.failed.map(item => item.profile).join('、') }))
} else {
message.success(t('profiles.runtime.allGatewaysRestarted'))
}
await loadRuntimeStatuses({ background: true })
} catch (err: any) {
message.error(err?.message || t('profiles.runtime.gatewayRestartFailed'))
} finally {
allGatewaysRestarting.value = false
}
}
async function handleRestartAllProfiles() {
allProfilesRestarting.value = true
try {
const result = await restartAllProfileRuntimes()
if (result.failed.length > 0) {
message.error(t('profiles.runtime.allProfilesRestartFailed', { names: result.failed.map(item => item.profile).join('、') }))
} else {
message.success(t('profiles.runtime.allProfilesRestarted'))
}
await loadRuntimeStatuses({ background: true })
} catch (err: any) {
message.error(err?.message || t('profiles.runtime.profileRestartFailed'))
} finally {
allProfilesRestarting.value = false
}
}
async function handleSwitchProfile(name: string) {
if (name === displayName.value) return
profileSwitching.value = { ...profileSwitching.value, [name]: true }
@@ -227,6 +265,7 @@ onMounted(() => {
:visible="showProfileModal"
:footer="false"
:width="720"
title-align="start"
:modal-style="{ maxWidth: 'calc(100vw - 32px)' }"
modal-class="profile-manager-modal"
@update:visible="handleProfileModalShowChange"
@@ -237,10 +276,26 @@ onMounted(() => {
<span class="profile-popover-name">{{ t('sidebar.profiles') }}</span>
<span class="profile-popover-subtitle">{{ t('profiles.runtime.activeProfile', { name: displayName }) }}</span>
</div>
<div class="profile-modal-bulk-actions">
<a-button
size="small"
:loading="allGatewaysRestarting"
@click="handleRestartAllGateways"
>
{{ t('profiles.runtime.restartAllGateways') }}
</a-button>
<a-button
size="small"
:loading="allProfilesRestarting"
@click="handleRestartAllProfiles"
>
{{ t('profiles.runtime.restartAllProfiles') }}
</a-button>
</div>
</div>
</template>
<a-spin :loading="runtimeLoading" :size="14">
<a-spin :loading="runtimeLoading" :size="14" class="profile-runtime-spin">
<div class="profile-runtime-list">
<div
v-for="profile in profilesStore.profiles"
@@ -426,6 +481,22 @@ onMounted(() => {
}
}
.profile-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
// 给 header 右侧的关闭按钮让位,否则批量按钮会压住关闭图标。
padding-right: 24px;
}
.profile-modal-bulk-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
}
.profile-popover-title {
min-width: 0;
display: flex;
@@ -466,6 +537,13 @@ onMounted(() => {
overflow-y: auto;
}
// Arco 的 .arco-spin 是 inline-block,会按内容宽度收缩,
// 短标签下卡片不撑满弹窗,右侧留出大片空白。
.profile-runtime-spin {
display: block;
width: 100%;
}
.profile-runtime-item {
display: flex;
flex-direction: column;
+6
View File
@@ -1500,6 +1500,12 @@ export default {
gatewayRestartFailed: 'Failed to restart gateway',
profileRestarted: 'Profile restarted: {name}',
profileRestartFailed: 'Failed to restart profile',
restartAllGateways: 'Restart All Gateways',
restartAllProfiles: 'Restart All Profiles',
allGatewaysRestarted: 'All gateways restarted',
allGatewaysRestartFailed: 'Some gateways failed to restart: {names}',
allProfilesRestarted: 'All profiles restarted',
allProfilesRestartFailed: 'Some profiles failed to restart: {names}',
},
},
+6
View File
@@ -1492,6 +1492,12 @@ export default {
gatewayRestartFailed: '重启网关失败',
profileRestarted: '配置已重启:{name}',
profileRestartFailed: '重启配置失败',
restartAllGateways: '重启所有网关',
restartAllProfiles: '重启所有配置',
allGatewaysRestarted: '所有网关已重启',
allGatewaysRestartFailed: '部分网关重启失败:{names}',
allProfilesRestarted: '所有配置已重启',
allProfilesRestartFailed: '部分配置重启失败:{names}',
},
},
@@ -10,6 +10,7 @@ import {
getGatewayRuntimeStatusForProfile,
prepareGatewayForProfileDelete,
restartGatewayForProfile as restartGatewayRuntimeForProfile,
restartGatewaysForProfiles,
} from '../../services/hermes/gateway-autostart'
import { logger } from '../../services/logger'
import { smartCloneCleanup, copyModelProviderAuthForClone } from '../../services/hermes/profile-credentials'
@@ -334,6 +335,14 @@ function setRuntimeStatusCache(status: RuntimeStatus, checkedAt = Date.now()): v
})
}
/**
* 批量重启后让缓存立即过期,下一次状态查询会重新探测真实运行状态。
*/
function setRuntimeStatusCacheDirty(): void {
runtimeStatusMinimumFreshAt = Date.now()
scheduleRuntimeStatusRefresh()
}
function listProfilesForStatusFast(): HermesProfile[] {
return filterVisibleProfiles(listProfilesFromDisk(getActiveProfileName()))
}
@@ -644,6 +653,67 @@ export async function restartProfileRuntime(ctx: any) {
}
}
export async function restartAllGateways(ctx: any) {
const profiles = filterProfilesForUser(ctx, listProfilesForStatusFast())
const names = profiles.map(profile => profile.name).filter(name => !isForbiddenProfileName(name))
if (names.length === 0) {
ctx.status = 400
ctx.body = { error: 'No profiles available for restart' }
return
}
try {
const results = await restartGatewaysForProfiles(names)
for (const result of results) {
if (!result.running) continue
try {
await bridgeCleanupClient().destroyProfile(result.profile)
} catch (err) {
logger.warn(err, '[profiles] failed to destroy bridge sessions after batch gateway restart profile=%s', result.profile)
}
}
const failed = results.filter(result => !result.running)
if (failed.length > 0) {
setRuntimeStatusCacheDirty()
}
ctx.body = {
success: failed.length === 0,
restarted: results.filter(result => result.running).map(result => result.profile),
failed: failed.map(result => ({ profile: result.profile, error: result.error })),
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function restartAllProfiles(ctx: any) {
const profiles = filterProfilesForUser(ctx, listProfilesForStatusFast())
const names = profiles.map(profile => profile.name).filter(name => !isForbiddenProfileName(name))
if (names.length === 0) {
ctx.status = 400
ctx.body = { error: 'No profiles available for restart' }
return
}
try {
const restarted: string[] = []
const failed: Array<{ profile: string; error: string }> = []
for (const name of names) {
try {
await bridgeCleanupClient().destroyProfile(name)
restarted.push(name)
} catch (err: any) {
logger.warn(err, '[profiles] failed to destroy bridge sessions after batch profile restart profile=%s', name)
failed.push({ profile: name, error: err?.message || String(err) })
}
}
setRuntimeStatusCacheDirty()
ctx.body = { success: failed.length === 0, restarted, failed }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function remove(ctx: any) {
const { name } = ctx.params
if (denyProfile(ctx, name)) return
@@ -7,6 +7,9 @@ export const profileRoutes = new Router()
profileRoutes.get('/api/hermes/profiles', ctrl.list)
profileRoutes.post('/api/hermes/profiles', ctrl.create)
profileRoutes.get('/api/hermes/profiles/runtime-statuses', ctrl.runtimeStatuses)
// 批量路由必须排在 /:name 之前,否则 "gateways" 会被当成 profile 名。
profileRoutes.post('/api/hermes/profiles/gateways/restart-all', ctrl.restartAllGateways)
profileRoutes.post('/api/hermes/profiles/restart-all', ctrl.restartAllProfiles)
profileRoutes.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus)
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile)
@@ -516,6 +516,85 @@ export async function restartGatewayForProfile(profile: string): Promise<{
}
}
export interface BatchGatewayRestartResult {
profile: string
targetProfile: string
running: boolean
error?: string
}
/**
* 把请求的 profile 列表映射成需要重启的网关 target 列表。
* 统一网关模式下多个 profile 指向同一个 target,这里按 target 去重,
* 避免同一个网关被反复重启。
*/
export function resolveGatewayRestartTargets(
profiles: string[],
unified: boolean,
): Array<{ targetProfile: string; requestedProfile: string }> {
const targets = new Map<string, string>()
for (const profile of profiles) {
const requested = String(profile || '').trim() || 'default'
const target = resolveGatewayTargetProfile(requested, unified)
if (!targets.has(target.targetProfile)) {
targets.set(target.targetProfile, requested)
}
}
return Array.from(targets, ([targetProfile, requestedProfile]) => ({ targetProfile, requestedProfile }))
}
/**
* 重启多个 profile 的网关。统一网关模式下多个 profile 会指向同一个
* target profile,这里按 target 去重,避免同一个网关被反复重启。
*/
export async function restartGatewaysForProfiles(
profiles: string[],
opts: {
unified?: boolean
stopGateway?: (profile: string, profileDir: string) => Promise<void>
startGateway?: (profile: string, profileDir: string) => Promise<void>
waitForGateway?: (profile: string, profileDir: string) => Promise<boolean>
} = {},
): Promise<BatchGatewayRestartResult[]> {
const hermesBin = resolveHermesBin()
let unified = opts.unified
if (unified === undefined) {
const { gatewayAutoStart } = await readAppConfig()
unified = shouldUseUnifiedGatewayManagement(gatewayAutoStart)
}
const stopGateway = opts.stopGateway || ((profile: string, profileDir: string) =>
stopGatewayForProfile(hermesBin, profile, profileDir))
const startGateway = opts.startGateway || ((profile: string, profileDir: string) =>
startGatewayForProfile(hermesBin, profile, profileDir, { managedRun: shouldUseManagedGatewayRun() }))
const waitForGateway = opts.waitForGateway || ((profile: string, profileDir: string) =>
waitForGatewayRunning(hermesBin, profile, profileDir))
const results: BatchGatewayRestartResult[] = []
for (const { targetProfile, requestedProfile } of resolveGatewayRestartTargets(profiles, unified)) {
const profileDir = getProfileDir(targetProfile)
try {
await stopGateway(targetProfile, profileDir)
await startGateway(targetProfile, profileDir)
const running = await waitForGateway(targetProfile, profileDir)
if (!running) {
throw new Error('Hermes gateway start completed but gateway did not report running within timeout')
}
results.push({ profile: requestedProfile, targetProfile, running: true })
} catch (err: any) {
logger.error(
err,
'[gateway-autostart] batch gateway restart failed requestedProfile=%s targetProfile=%s home=%s',
requestedProfile,
targetProfile,
profileDir,
)
results.push({ profile: requestedProfile, targetProfile, running: false, error: err?.message || String(err) })
}
}
return results
}
export async function ensureProfileGatewaysRunning(): Promise<void> {
const hermesBin = resolveHermesBin()
const discoveredProfiles = listProfileNamesFromDisk()