diff --git a/docs/openapi.json b/docs/openapi.json index cf901c4..ca653d5 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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": { "post": { "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": { "get": { "tags": [ diff --git a/packages/client/src/api/hermes/profiles.ts b/packages/client/src/api/hermes/profiles.ts index 4c47d7c..e8fbe00 100644 --- a/packages/client/src/api/hermes/profiles.ts +++ b/packages/client/src/api/hermes/profiles.ts @@ -111,6 +111,20 @@ export async function restartProfileRuntime(name: string): Promise +} + +export async function restartAllProfileGateways(): Promise { + return request('/api/hermes/profiles/gateways/restart-all', { method: 'POST' }) +} + +export async function restartAllProfileRuntimes(): Promise { + return request('/api/hermes/profiles/restart-all', { method: 'POST' }) +} + export interface CreateProfileResult { success: boolean /** clone=true 时被清理的独占平台凭据 KEY 名 */ diff --git a/packages/client/src/components/layout/ProfileSelector.vue b/packages/client/src/components/layout/ProfileSelector.vue index 7502d01..d6da8ea 100644 --- a/packages/client/src/components/layout/ProfileSelector.vue +++ b/packages/client/src/components/layout/ProfileSelector.vue @@ -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(null) const gatewayRestarting = ref>({}) const profileRestarting = ref>({}) const profileSwitching = ref>({}) +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(() => { {{ t('sidebar.profiles') }} {{ t('profiles.runtime.activeProfile', { name: displayName }) }} +
+ + {{ t('profiles.runtime.restartAllGateways') }} + + + {{ t('profiles.runtime.restartAllProfiles') }} + +
- +
{ } } +.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; diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 2592097..d3e335b 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -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}', }, }, diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 2331d60..59c2964 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -1492,6 +1492,12 @@ export default { gatewayRestartFailed: '重启网关失败', profileRestarted: '配置已重启:{name}', profileRestartFailed: '重启配置失败', + restartAllGateways: '重启所有网关', + restartAllProfiles: '重启所有配置', + allGatewaysRestarted: '所有网关已重启', + allGatewaysRestartFailed: '部分网关重启失败:{names}', + allProfilesRestarted: '所有配置已重启', + allProfilesRestartFailed: '部分配置重启失败:{names}', }, }, diff --git a/packages/server/src/controllers/hermes/profiles.ts b/packages/server/src/controllers/hermes/profiles.ts index f461fa7..478d257 100644 --- a/packages/server/src/controllers/hermes/profiles.ts +++ b/packages/server/src/controllers/hermes/profiles.ts @@ -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 diff --git a/packages/server/src/routes/hermes/profiles.ts b/packages/server/src/routes/hermes/profiles.ts index 163bed7..3f83282 100644 --- a/packages/server/src/routes/hermes/profiles.ts +++ b/packages/server/src/routes/hermes/profiles.ts @@ -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) diff --git a/packages/server/src/services/hermes/gateway-autostart.ts b/packages/server/src/services/hermes/gateway-autostart.ts index 61d5412..597b256 100644 --- a/packages/server/src/services/hermes/gateway-autostart.ts +++ b/packages/server/src/services/hermes/gateway-autostart.ts @@ -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() + 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 + startGateway?: (profile: string, profileDir: string) => Promise + waitForGateway?: (profile: string, profileDir: string) => Promise + } = {}, +): Promise { + 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 { const hermesBin = resolveHermesBin() const discoveredProfiles = listProfileNamesFromDisk() diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 1fa49f6..dbb843a 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -250,6 +250,32 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions = 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 (request.method() !== 'PUT') { await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405)) diff --git a/tests/e2e/profile-selector.spec.ts b/tests/e2e/profile-selector.spec.ts new file mode 100644 index 0000000..e02d5c3 --- /dev/null +++ b/tests/e2e/profile-selector.spec.ts @@ -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([]) +}) diff --git a/tests/server/gateway-batch-restart.test.ts b/tests/server/gateway-batch-restart.test.ts new file mode 100644 index 0000000..8093094 --- /dev/null +++ b/tests/server/gateway-batch-restart.test.ts @@ -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') + }) +}) diff --git a/tests/server/profiles-bulk-routes.test.ts b/tests/server/profiles-bulk-routes.test.ts new file mode 100644 index 0000000..54e35f4 --- /dev/null +++ b/tests/server/profiles-bulk-routes.test.ts @@ -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 { + 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((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() + }) +})