增加一键重启所有网关按钮
This commit is contained in:
@@ -7187,6 +7187,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/hermes/profiles/gateways/restart-all": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Create restart-all",
|
||||||
|
"description": "POST /api/hermes/profiles/gateways/restart-all",
|
||||||
|
"operationId": "restartAllGateways",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/hermes/profiles/import": {
|
"/api/hermes/profiles/import": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7213,6 +7239,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/hermes/profiles/restart-all": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Create restart-all",
|
||||||
|
"description": "POST /api/hermes/profiles/restart-all",
|
||||||
|
"operationId": "restartAllProfiles",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/BadRequest"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/hermes/profiles/runtime-statuses": {
|
"/api/hermes/profiles/runtime-statuses": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -111,6 +111,20 @@ export async function restartProfileRuntime(name: string): Promise<ProfileRuntim
|
|||||||
return res.status
|
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 {
|
export interface CreateProfileResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
/** clone=true 时被清理的独占平台凭据 KEY 名 */
|
/** clone=true 时被清理的独占平台凭据 KEY 名 */
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useMessage } from '@/composables/useMessage'
|
|||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
import {
|
import {
|
||||||
fetchProfileRuntimeStatusesWithMeta,
|
fetchProfileRuntimeStatusesWithMeta,
|
||||||
|
restartAllProfileGateways,
|
||||||
|
restartAllProfileRuntimes,
|
||||||
restartProfileGateway,
|
restartProfileGateway,
|
||||||
restartProfileRuntime,
|
restartProfileRuntime,
|
||||||
type HermesProfile,
|
type HermesProfile,
|
||||||
@@ -34,6 +36,8 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
|
|||||||
const gatewayRestarting = ref<Record<string, boolean>>({})
|
const gatewayRestarting = ref<Record<string, boolean>>({})
|
||||||
const profileRestarting = ref<Record<string, boolean>>({})
|
const profileRestarting = ref<Record<string, boolean>>({})
|
||||||
const profileSwitching = 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])))
|
const statusByProfile = computed(() => new Map(runtimeStatuses.value.map(status => [status.profile, status])))
|
||||||
let runtimeRefreshToken = 0
|
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) {
|
async function handleSwitchProfile(name: string) {
|
||||||
if (name === displayName.value) return
|
if (name === displayName.value) return
|
||||||
profileSwitching.value = { ...profileSwitching.value, [name]: true }
|
profileSwitching.value = { ...profileSwitching.value, [name]: true }
|
||||||
@@ -227,6 +265,7 @@ onMounted(() => {
|
|||||||
:visible="showProfileModal"
|
:visible="showProfileModal"
|
||||||
:footer="false"
|
:footer="false"
|
||||||
:width="720"
|
:width="720"
|
||||||
|
title-align="start"
|
||||||
:modal-style="{ maxWidth: 'calc(100vw - 32px)' }"
|
:modal-style="{ maxWidth: 'calc(100vw - 32px)' }"
|
||||||
modal-class="profile-manager-modal"
|
modal-class="profile-manager-modal"
|
||||||
@update:visible="handleProfileModalShowChange"
|
@update:visible="handleProfileModalShowChange"
|
||||||
@@ -237,10 +276,26 @@ onMounted(() => {
|
|||||||
<span class="profile-popover-name">{{ t('sidebar.profiles') }}</span>
|
<span class="profile-popover-name">{{ t('sidebar.profiles') }}</span>
|
||||||
<span class="profile-popover-subtitle">{{ t('profiles.runtime.activeProfile', { name: displayName }) }}</span>
|
<span class="profile-popover-subtitle">{{ t('profiles.runtime.activeProfile', { name: displayName }) }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-spin :loading="runtimeLoading" :size="14">
|
<a-spin :loading="runtimeLoading" :size="14" class="profile-runtime-spin">
|
||||||
<div class="profile-runtime-list">
|
<div class="profile-runtime-list">
|
||||||
<div
|
<div
|
||||||
v-for="profile in profilesStore.profiles"
|
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 {
|
.profile-popover-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -466,6 +537,13 @@ onMounted(() => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arco 的 .arco-spin 是 inline-block,会按内容宽度收缩,
|
||||||
|
// 短标签下卡片不撑满弹窗,右侧留出大片空白。
|
||||||
|
.profile-runtime-spin {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-runtime-item {
|
.profile-runtime-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1500,6 +1500,12 @@ export default {
|
|||||||
gatewayRestartFailed: 'Failed to restart gateway',
|
gatewayRestartFailed: 'Failed to restart gateway',
|
||||||
profileRestarted: 'Profile restarted: {name}',
|
profileRestarted: 'Profile restarted: {name}',
|
||||||
profileRestartFailed: 'Failed to restart profile',
|
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}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1492,6 +1492,12 @@ export default {
|
|||||||
gatewayRestartFailed: '重启网关失败',
|
gatewayRestartFailed: '重启网关失败',
|
||||||
profileRestarted: '配置已重启:{name}',
|
profileRestarted: '配置已重启:{name}',
|
||||||
profileRestartFailed: '重启配置失败',
|
profileRestartFailed: '重启配置失败',
|
||||||
|
restartAllGateways: '重启所有网关',
|
||||||
|
restartAllProfiles: '重启所有配置',
|
||||||
|
allGatewaysRestarted: '所有网关已重启',
|
||||||
|
allGatewaysRestartFailed: '部分网关重启失败:{names}',
|
||||||
|
allProfilesRestarted: '所有配置已重启',
|
||||||
|
allProfilesRestartFailed: '部分配置重启失败:{names}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getGatewayRuntimeStatusForProfile,
|
getGatewayRuntimeStatusForProfile,
|
||||||
prepareGatewayForProfileDelete,
|
prepareGatewayForProfileDelete,
|
||||||
restartGatewayForProfile as restartGatewayRuntimeForProfile,
|
restartGatewayForProfile as restartGatewayRuntimeForProfile,
|
||||||
|
restartGatewaysForProfiles,
|
||||||
} from '../../services/hermes/gateway-autostart'
|
} from '../../services/hermes/gateway-autostart'
|
||||||
import { logger } from '../../services/logger'
|
import { logger } from '../../services/logger'
|
||||||
import { smartCloneCleanup, copyModelProviderAuthForClone } from '../../services/hermes/profile-credentials'
|
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[] {
|
function listProfilesForStatusFast(): HermesProfile[] {
|
||||||
return filterVisibleProfiles(listProfilesFromDisk(getActiveProfileName()))
|
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) {
|
export async function remove(ctx: any) {
|
||||||
const { name } = ctx.params
|
const { name } = ctx.params
|
||||||
if (denyProfile(ctx, name)) return
|
if (denyProfile(ctx, name)) return
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export const profileRoutes = new Router()
|
|||||||
profileRoutes.get('/api/hermes/profiles', ctrl.list)
|
profileRoutes.get('/api/hermes/profiles', ctrl.list)
|
||||||
profileRoutes.post('/api/hermes/profiles', ctrl.create)
|
profileRoutes.post('/api/hermes/profiles', ctrl.create)
|
||||||
profileRoutes.get('/api/hermes/profiles/runtime-statuses', ctrl.runtimeStatuses)
|
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.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus)
|
||||||
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
|
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
|
||||||
profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile)
|
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> {
|
export async function ensureProfileGatewaysRunning(): Promise<void> {
|
||||||
const hermesBin = resolveHermesBin()
|
const hermesBin = resolveHermesBin()
|
||||||
const discoveredProfiles = listProfileNamesFromDisk()
|
const discoveredProfiles = listProfileNamesFromDisk()
|
||||||
|
|||||||
@@ -250,6 +250,32 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/hermes/profiles/gateways/restart-all') {
|
||||||
|
if (request.method() !== 'POST') {
|
||||||
|
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await route.fulfill(jsonResponse({
|
||||||
|
success: true,
|
||||||
|
restarted: ['default', 'research'],
|
||||||
|
failed: [],
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/hermes/profiles/restart-all') {
|
||||||
|
if (request.method() !== 'POST') {
|
||||||
|
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await route.fulfill(jsonResponse({
|
||||||
|
success: true,
|
||||||
|
restarted: ['default', 'research'],
|
||||||
|
failed: [],
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/api/hermes/profiles/active') {
|
if (pathname === '/api/hermes/profiles/active') {
|
||||||
if (request.method() !== 'PUT') {
|
if (request.method() !== 'PUT') {
|
||||||
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||||
|
|
||||||
|
test('profile manager modal fills its width and offers bulk restarts', async ({ page }) => {
|
||||||
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||||
|
const api = await mockHermesApi(page)
|
||||||
|
await mockChatSocket(page)
|
||||||
|
|
||||||
|
await page.goto('/#/hermes/models')
|
||||||
|
await page.locator('[data-testid="profile-selector-select"]').click()
|
||||||
|
|
||||||
|
const modal = page.locator('.profile-manager-modal')
|
||||||
|
await expect(modal).toBeVisible()
|
||||||
|
|
||||||
|
// The profile list must fill the modal body. Arco's .arco-spin is
|
||||||
|
// inline-block and collapses to its content width otherwise, which leaves a
|
||||||
|
// wide empty gutter next to the cards.
|
||||||
|
const widths = await page.evaluate(() => {
|
||||||
|
const list = document.querySelector('.profile-runtime-list') as HTMLElement
|
||||||
|
const body = document.querySelector('.profile-manager-modal .arco-modal-body') as HTMLElement
|
||||||
|
return {
|
||||||
|
list: Math.round(list.getBoundingClientRect().width),
|
||||||
|
bodyContent: Math.round(body.getBoundingClientRect().width - 40),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// The list scrolls, so it may be a scrollbar narrower than the body.
|
||||||
|
expect(widths.bodyContent - widths.list).toBeLessThanOrEqual(20)
|
||||||
|
expect(widths.list).toBeGreaterThan(widths.bodyContent * 0.9)
|
||||||
|
|
||||||
|
const restartGateways = page.getByRole('button', { name: 'Restart All Gateways' })
|
||||||
|
const restartProfiles = page.getByRole('button', { name: 'Restart All Profiles' })
|
||||||
|
await expect(restartGateways).toBeVisible()
|
||||||
|
await expect(restartProfiles).toBeVisible()
|
||||||
|
|
||||||
|
await restartGateways.click()
|
||||||
|
await restartProfiles.click()
|
||||||
|
|
||||||
|
const gatewayCalls = api.requests.filter((request) => request.pathname === '/api/hermes/profiles/gateways/restart-all')
|
||||||
|
const profileCalls = api.requests.filter((request) => request.pathname === '/api/hermes/profiles/restart-all')
|
||||||
|
expect(gatewayCalls).toHaveLength(1)
|
||||||
|
expect(gatewayCalls[0]?.method).toBe('POST')
|
||||||
|
expect(profileCalls).toHaveLength(1)
|
||||||
|
expect(profileCalls[0]?.method).toBe('POST')
|
||||||
|
expect(api.unexpectedRequests).toEqual([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
resolveGatewayRestartTargets,
|
||||||
|
restartGatewaysForProfiles,
|
||||||
|
} from '../../packages/server/src/services/hermes/gateway-autostart'
|
||||||
|
|
||||||
|
describe('batch gateway restart', () => {
|
||||||
|
it('maps each profile to its own gateway target in per-profile mode', () => {
|
||||||
|
expect(resolveGatewayRestartTargets(['default', 'work'], false)).toEqual([
|
||||||
|
{ targetProfile: 'default', requestedProfile: 'default' },
|
||||||
|
{ targetProfile: 'work', requestedProfile: 'work' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restarts a shared gateway once in unified mode', () => {
|
||||||
|
expect(resolveGatewayRestartTargets(['default', 'work', 'reviewer'], true)).toEqual([
|
||||||
|
{ targetProfile: 'default', requestedProfile: 'default' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the first requested profile for a duplicated target', () => {
|
||||||
|
expect(resolveGatewayRestartTargets(['work', 'default'], true)).toEqual([
|
||||||
|
{ targetProfile: 'default', requestedProfile: 'work' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default profile for a blank name', () => {
|
||||||
|
expect(resolveGatewayRestartTargets([''], false)).toEqual([
|
||||||
|
{ targetProfile: 'default', requestedProfile: 'default' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops, starts and waits for every restarted gateway', async () => {
|
||||||
|
const stopGateway = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const startGateway = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const waitForGateway = vi.fn().mockResolvedValue(true)
|
||||||
|
|
||||||
|
const results = await restartGatewaysForProfiles(['default', 'work'], {
|
||||||
|
unified: false,
|
||||||
|
stopGateway,
|
||||||
|
startGateway,
|
||||||
|
waitForGateway,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
{ profile: 'default', targetProfile: 'default', running: true },
|
||||||
|
{ profile: 'work', targetProfile: 'work', running: true },
|
||||||
|
])
|
||||||
|
expect(stopGateway).toHaveBeenCalledTimes(2)
|
||||||
|
expect(startGateway).toHaveBeenCalledTimes(2)
|
||||||
|
expect(waitForGateway).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restarts a shared gateway only once in unified mode', async () => {
|
||||||
|
const stopGateway = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const startGateway = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const waitForGateway = vi.fn().mockResolvedValue(true)
|
||||||
|
|
||||||
|
const results = await restartGatewaysForProfiles(['default', 'work'], {
|
||||||
|
unified: true,
|
||||||
|
stopGateway,
|
||||||
|
startGateway,
|
||||||
|
waitForGateway,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
{ profile: 'default', targetProfile: 'default', running: true },
|
||||||
|
])
|
||||||
|
expect(stopGateway).toHaveBeenCalledTimes(1)
|
||||||
|
expect(startGateway).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps going when one gateway fails and reports the error', async () => {
|
||||||
|
const stopGateway = vi.fn()
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error('stop failed'))
|
||||||
|
const startGateway = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const waitForGateway = vi.fn().mockResolvedValue(true)
|
||||||
|
|
||||||
|
const results = await restartGatewaysForProfiles(['default', 'work'], {
|
||||||
|
unified: false,
|
||||||
|
stopGateway,
|
||||||
|
startGateway,
|
||||||
|
waitForGateway,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
{ profile: 'default', targetProfile: 'default', running: true },
|
||||||
|
{ profile: 'work', targetProfile: 'work', running: false, error: 'stop failed' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a gateway that never reports running as failed', async () => {
|
||||||
|
const stopGateway = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const startGateway = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const waitForGateway = vi.fn().mockResolvedValue(false)
|
||||||
|
|
||||||
|
const results = await restartGatewaysForProfiles(['default'], {
|
||||||
|
unified: false,
|
||||||
|
stopGateway,
|
||||||
|
startGateway,
|
||||||
|
waitForGateway,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results[0]?.running).toBe(false)
|
||||||
|
expect(results[0]?.error).toContain('did not report running')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Koa from 'koa'
|
||||||
|
import bodyParser from '@koa/bodyparser'
|
||||||
|
import { createServer, type Server as HttpServer } from 'http'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const controllerMocks = vi.hoisted(() => ({
|
||||||
|
restartAllGateways: vi.fn(),
|
||||||
|
restartAllProfiles: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/controllers/hermes/profiles', async (importOriginal) => ({
|
||||||
|
...(await importOriginal() as any),
|
||||||
|
restartAllGateways: controllerMocks.restartAllGateways,
|
||||||
|
restartAllProfiles: controllerMocks.restartAllProfiles,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { profileRoutes } from '../../packages/server/src/routes/hermes/profiles'
|
||||||
|
|
||||||
|
function listen(server: HttpServer): Promise<string> {
|
||||||
|
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
|
||||||
|
const addr = server.address()
|
||||||
|
if (!addr || typeof addr === 'string') throw new Error('missing address')
|
||||||
|
resolve(`http://127.0.0.1:${addr.port}`)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('profile bulk restart routes', () => {
|
||||||
|
let httpServer: HttpServer
|
||||||
|
let baseUrl: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
controllerMocks.restartAllGateways.mockImplementation((ctx: any) => {
|
||||||
|
ctx.body = { success: true }
|
||||||
|
})
|
||||||
|
controllerMocks.restartAllProfiles.mockImplementation((ctx: any) => {
|
||||||
|
ctx.body = { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = new Koa()
|
||||||
|
app.use(bodyParser())
|
||||||
|
app.use(profileRoutes.routes())
|
||||||
|
app.use(profileRoutes.allowedMethods())
|
||||||
|
httpServer = createServer(app.callback())
|
||||||
|
baseUrl = await listen(httpServer)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((resolve) => httpServer.close(() => resolve()))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes the gateway bulk restart to its own handler', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/hermes/profiles/gateways/restart-all`, { method: 'POST' })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(controllerMocks.restartAllGateways).toHaveBeenCalledTimes(1)
|
||||||
|
expect(controllerMocks.restartAllProfiles).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes the profile bulk restart to its own handler', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/hermes/profiles/restart-all`, { method: 'POST' })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(controllerMocks.restartAllProfiles).toHaveBeenCalledTimes(1)
|
||||||
|
expect(controllerMocks.restartAllGateways).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user