diff --git a/packages/client/src/api/hermes/gateways.ts b/packages/client/src/api/hermes/gateways.ts index 24cf9c3..cb031ee 100644 --- a/packages/client/src/api/hermes/gateways.ts +++ b/packages/client/src/api/hermes/gateways.ts @@ -7,6 +7,16 @@ export interface GatewayStatus { url: string running: boolean pid?: number + diagnostics?: { + pid_path: string + config_path: string + pid_file_exists: boolean + config_exists: boolean + health_url: string + health_checked_at: string + health_ok?: boolean + reason: string + } } export async function fetchGateways(): Promise { diff --git a/packages/client/src/views/hermes/GatewaysView.vue b/packages/client/src/views/hermes/GatewaysView.vue index 7ba7e17..e4ea3e0 100644 --- a/packages/client/src/views/hermes/GatewaysView.vue +++ b/packages/client/src/views/hermes/GatewaysView.vue @@ -47,6 +47,11 @@ async function handleToggle(name: string, running: boolean) { {{ gw.host }}:{{ gw.port }} PID: {{ gw.pid }} +
+ {{ gw.diagnostics.reason }} + PID: {{ gw.diagnostics.pid_path }} + Config: {{ gw.diagnostics.config_path }} +
@@ -99,6 +104,7 @@ async function handleToggle(name: string, running: boolean) { display: flex; align-items: center; justify-content: space-between; + gap: 16px; padding: 16px 20px; background-color: $bg-card; border: 1px solid $border-color; @@ -110,6 +116,11 @@ async function handleToggle(name: string, running: boolean) { } } +.gateway-info { + min-width: 0; + flex: 1; +} + .gateway-name { font-size: 14px; font-weight: 600; @@ -119,17 +130,62 @@ async function handleToggle(name: string, running: boolean) { .gateway-meta { display: flex; + flex-wrap: wrap; gap: 12px; } +.gateway-diagnostics { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} + .meta-item { font-size: 12px; color: $text-muted; } +.diag-item { + max-width: 100%; + font-size: 12px; + color: $text-muted; + background: rgba(127, 127, 127, 0.08); + padding: 2px 8px; + border-radius: 999px; + overflow-wrap: anywhere; + line-height: 1.5; +} + .gateway-actions { display: flex; align-items: center; + flex-shrink: 0; gap: 8px; } + +@media (max-width: 640px) { + .gateways-content { + padding: 16px; + } + + .gateway-card { + align-items: stretch; + flex-direction: column; + padding: 16px; + } + + .gateway-diagnostics { + flex-direction: column; + gap: 6px; + } + + .diag-item { + border-radius: $radius-sm; + } + + .gateway-actions { + justify-content: flex-start; + } +} diff --git a/packages/server/src/services/hermes/gateway-manager.ts b/packages/server/src/services/hermes/gateway-manager.ts index 2f90328..7ce7908 100644 --- a/packages/server/src/services/hermes/gateway-manager.ts +++ b/packages/server/src/services/hermes/gateway-manager.ts @@ -131,6 +131,18 @@ export interface GatewayStatus { url: string running: boolean pid?: number + diagnostics?: GatewayDiagnostics +} + +export interface GatewayDiagnostics { + pid_path: string + config_path: string + pid_file_exists: boolean + config_exists: boolean + health_url: string + health_checked_at: string + health_ok?: boolean + reason: string } interface ManagedGateway { @@ -508,16 +520,36 @@ export class GatewayManager { const pid = this.readPidFile(name) const { port, host } = this.readProfilePort(name) const url = buildHttpUrl(host, port) + const pidPath = join(this.profileDir(name), 'gateway.pid') + const configPath = join(this.profileDir(name), 'config.yaml') + const diagnostics: GatewayDiagnostics = { + pid_path: pidPath, + config_path: configPath, + pid_file_exists: existsSync(pidPath), + config_exists: existsSync(configPath), + health_url: `${url.replace(/\/$/, '')}/health`, + health_checked_at: new Date().toISOString(), + reason: 'stopped', + } // 首先检查 PID 文件:如果存在且进程存活且健康,则标记为运行 if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) { + diagnostics.health_ok = true + diagnostics.reason = 'pid alive and health check passed' this.gateways.set(name, { pid, port, host, url, owned: false }) - return { profile: name, port, host, url, running: true, pid } + return { profile: name, port, host, url, running: true, pid, diagnostics } + } + + if (pid) { + diagnostics.health_ok = false + diagnostics.reason = this.isProcessAlive(pid) ? 'pid alive but health check failed' : 'stale pid file' + } else if (!diagnostics.pid_file_exists) { + diagnostics.reason = 'missing pid file' } // 没有 PID 文件时不认领端口上的未知网关,避免误判其他 profile 的网关 this.gateways.delete(name) - return { profile: name, port, host, url, running: false } + return { profile: name, port, host, url, running: false, diagnostics } } /** 检测所有 profile 的网关状态 */ diff --git a/tests/server/gateway-manager-diagnostics.test.ts b/tests/server/gateway-manager-diagnostics.test.ts new file mode 100644 index 0000000..1d895e6 --- /dev/null +++ b/tests/server/gateway-manager-diagnostics.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const readFileSyncMock = vi.fn() +const existsSyncMock = vi.fn() + +vi.mock('fs', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + } +}) + +vi.mock('../../packages/server/src/services/hermes/hermes-path', () => ({ + detectHermesHome: () => 'C:/Users/test/.hermes', + getHermesBin: () => 'hermes' +})) + +describe('GatewayManager diagnostics', () => { + afterEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + it('includes read-only diagnostics when a profile is stopped', async () => { + const yamlText = [ + 'platforms:', + ' api_server:', + ' extra:', + ' host: 127.0.0.1', + ' port: 8643', + ].join('\n') + + existsSyncMock.mockImplementation((input: unknown) => { + const text = String(input) + return text.endsWith('config.yaml') + }) + readFileSyncMock.mockImplementation((input: unknown) => { + const text = String(input) + if (text.endsWith('config.yaml')) { + return yamlText + } + return '' + }) + + const { GatewayManager } = await import('../../packages/server/src/services/hermes/gateway-manager') + const manager = new GatewayManager('default') + const status = await manager.detectStatus('default') + + expect(status.running).toBe(false) + expect(status.diagnostics?.config_exists).toBe(true) + expect(status.diagnostics?.pid_file_exists).toBe(false) + expect(status.diagnostics?.reason).toBe('missing pid file') + expect(status.diagnostics?.health_url).toContain('/health') + }) +})