feat: add multi-gateway management with auto port detection

- Add GatewayManager for multi-profile gateway lifecycle management
- Auto-detect running gateways on startup via PID + health check
- Port conflict detection: check managed gateways, allocated ports, and
  system-level port availability (TCP bind test)
- Two-phase startup: sequential port resolution, parallel process launch
- Use `gateway start/restart` on normal systems, `gateway run --replace`
  on WSL/Docker
- Wait for health check before returning start/stop responses
- Add Gateways page with card-based layout showing profile status
- Reorganize sidebar navigation into collapsible groups
- Hide API server settings (now auto-managed by GatewayManager)
- Profile switch reloads page; Ctrl+C no longer stops gateways
- Remove redundant ensureApiServerConfig from index.ts and profiles.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-18 13:07:12 +08:00
parent 35481e452d
commit 4b6de351bd
15 changed files with 1170 additions and 467 deletions
@@ -0,0 +1,71 @@
import Router from '@koa/router'
export const gatewayRoutes = new Router()
// Get singleton instance — set during bootstrap
let manager: any = null
export function setGatewayManager(mgr: any) {
manager = mgr
}
export function getGatewayManager(): any {
return manager
}
// List all gateway statuses
gatewayRoutes.get('/api/hermes/gateways', async (ctx) => {
if (!manager) {
ctx.status = 503
ctx.body = { error: 'GatewayManager not initialized' }
return
}
const gateways = await manager.listAll()
ctx.body = { gateways }
})
// Start a profile's gateway
gatewayRoutes.post('/api/hermes/gateways/:name/start', async (ctx) => {
if (!manager) {
ctx.status = 503
ctx.body = { error: 'GatewayManager not initialized' }
return
}
const { name } = ctx.params
try {
const status = await manager.start(name)
ctx.body = { success: true, gateway: status }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// Stop a profile's gateway
gatewayRoutes.post('/api/hermes/gateways/:name/stop', async (ctx) => {
if (!manager) {
ctx.status = 503
ctx.body = { error: 'GatewayManager not initialized' }
return
}
const { name } = ctx.params
try {
await manager.stop(name)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
// Check a profile's gateway health
gatewayRoutes.get('/api/hermes/gateways/:name/health', async (ctx) => {
if (!manager) {
ctx.status = 503
ctx.body = { error: 'GatewayManager not initialized' }
return
}
const { name } = ctx.params
const status = await manager.detectStatus(name)
ctx.body = { gateway: status }
})
@@ -6,6 +6,7 @@ import { fsRoutes } from './filesystem'
import { logRoutes } from './logs'
import { weixinRoutes } from './weixin'
import { codexAuthRoutes } from './codex-auth'
import { gatewayRoutes } from './gateways'
import { proxyRoutes, proxyMiddleware } from './proxy'
import { setupTerminalWebSocket } from './terminal'
@@ -18,6 +19,7 @@ hermesRoutes.use(fsRoutes.routes())
hermesRoutes.use(logRoutes.routes())
hermesRoutes.use(weixinRoutes.routes())
hermesRoutes.use(codexAuthRoutes.routes())
hermesRoutes.use(gatewayRoutes.routes())
hermesRoutes.use(proxyRoutes.routes())
export { setupTerminalWebSocket, proxyMiddleware }
+17 -78
View File
@@ -5,36 +5,7 @@ import { basename, join } from 'path'
import { tmpdir, homedir } from 'os'
import YAML from 'js-yaml'
import * as hermesCli from '../../services/hermes/hermes-cli'
const apiServerDefaults = {
enabled: true,
host: '127.0.0.1',
port: 8642,
key: '',
cors_origins: '*',
}
function ensureApiServerConfig(profilePath: string) {
const configPath = join(profilePath, 'config.yaml')
try {
if (!existsSync(configPath)) {
// Profile has no config.yaml — run hermes setup --reset to generate full defaults,
// then inject api_server config (setup itself doesn't add it)
console.log(`[Profile] No config.yaml for ${profilePath}, running setup --reset`)
return { needSetup: true, path: profilePath }
}
const content = readFileSync(configPath, 'utf-8')
const cfg = YAML.load(content) as any || {}
if (!cfg.platforms) cfg.platforms = {}
if (!cfg.platforms.api_server) {
cfg.platforms.api_server = { ...apiServerDefaults }
writeFileSync(configPath, YAML.dump(cfg), 'utf-8')
console.log(`[Profile] Ensured api_server config for: ${profilePath}`)
}
return { needSetup: false, path: profilePath }
} catch { }
return { needSetup: false, path: profilePath }
}
import { getGatewayManager } from './gateways'
export const profileRoutes = new Router()
@@ -92,6 +63,12 @@ profileRoutes.delete('/api/hermes/profiles/:name', async (ctx) => {
}
try {
// Stop gateway for this profile before deleting
const mgr = getGatewayManager()
if (mgr) {
try { await mgr.stop(name) } catch { }
}
const ok = await hermesCli.deleteProfile(name)
if (ok) {
ctx.body = { success: true }
@@ -141,49 +118,26 @@ profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
}
try {
// 1. Stop gateway
try { await hermesCli.stopGateway() } catch { }
// 2. Kill gateway by port if still running
try {
const { execSync } = await import('child_process')
const isWin = process.platform === 'win32'
let pids = ''
if (isWin) {
const out = execSync('netstat -aon | findstr :8642', { encoding: 'utf-8', timeout: 5000 }).trim()
const lines = out.split('\n').filter(l => l.includes('LISTENING'))
pids = Array.from(new Set(lines.map(l => l.trim().split(/\s+/).pop()).filter(Boolean))).join(' ')
} else {
pids = execSync('lsof -ti:8642', { encoding: 'utf-8', timeout: 5000 }).trim()
}
if (pids) {
if (isWin) {
execSync(`taskkill /F /PID ${pids.split(' ').join(' /PID ')}`, { timeout: 5000 })
} else {
execSync(`kill -9 ${pids}`, { timeout: 5000 })
}
await new Promise(r => setTimeout(r, 2000))
}
} catch { }
// 3. Switch profile
// 1. Switch profile only (no gateway stop/restart)
const output = await hermesCli.useProfile(name)
await new Promise(r => setTimeout(r, 1000))
// 4. Ensure api_server config for new profile
// 2. Update GatewayManager active profile
const mgr = getGatewayManager()
if (mgr) {
mgr.setActiveProfile(name)
}
// 3. Ensure api_server config for new profile
try {
const detail = await hermesCli.getProfile(name)
console.log(`[Profile] detail.path = ${detail.path}`)
const result = ensureApiServerConfig(detail.path)
if (result?.needSetup) {
// No config.yaml — run setup --reset to create full default config,
// then ensure api_server is present
if (!existsSync(join(detail.path, 'config.yaml'))) {
// No config.yaml — run setup --reset to create full default config
try { await hermesCli.setupReset() } catch { }
ensureApiServerConfig(detail.path)
}
// Create .env if target has none
const profileEnv = join(detail.path, '.env')
console.log(`[Profile] .env exists: ${existsSync(profileEnv)}, path: ${profileEnv}`)
if (!existsSync(profileEnv)) {
writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8')
console.log(`[Profile] Created .env for: ${detail.path}`)
@@ -192,21 +146,6 @@ profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
console.error(`[Profile] Ensure config failed:`, err.message)
}
// 5. Start gateway
try {
await hermesCli.startGateway()
console.log('[Profile] Gateway started')
} catch {
// Fallback: background mode (for WSL etc.)
try {
const pid = await hermesCli.startGatewayBackground()
await new Promise(r => setTimeout(r, 3000))
console.log(`[Profile] Gateway started in background mode (PID: ${pid})`)
} catch (err: any) {
console.error('[Profile] Gateway start failed:', err.message)
}
}
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
@@ -1,5 +1,6 @@
import type { Context } from 'koa'
import { config } from '../../config'
import { getGatewayManager } from './gateways'
function isTransientGatewayError(err: any): boolean {
const msg = String(err?.message || '')
@@ -27,8 +28,24 @@ async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000):
return false
}
/** Resolve upstream URL for a request based on profile header/query */
function resolveUpstream(ctx: Context): string {
const mgr = getGatewayManager()
if (mgr) {
// Check X-Hermes-Profile header or ?profile= query param
const profile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
if (profile) {
return mgr.getUpstream(profile)
}
// Default to active profile's upstream
return mgr.getUpstream()
}
// Fallback: static upstream from config
return config.upstream.replace(/\/$/, '')
}
export async function proxy(ctx: Context) {
const upstream = config.upstream.replace(/\/$/, '')
const upstream = resolveUpstream(ctx)
// Rewrite path for upstream gateway:
// /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix)
// /api/hermes/* -> /api/* (upstream uses /api/ prefix)