Explain gateway stopped states with Web UI diagnostics (#663)
* feat: add gateway diagnostics to Web UI status * fix: improve gateway diagnostics mobile layout
This commit is contained in:
@@ -7,6 +7,16 @@ export interface GatewayStatus {
|
|||||||
url: string
|
url: string
|
||||||
running: boolean
|
running: boolean
|
||||||
pid?: number
|
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<GatewayStatus[]> {
|
export async function fetchGateways(): Promise<GatewayStatus[]> {
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ async function handleToggle(name: string, running: boolean) {
|
|||||||
<span class="meta-item">{{ gw.host }}:{{ gw.port }}</span>
|
<span class="meta-item">{{ gw.host }}:{{ gw.port }}</span>
|
||||||
<span v-if="gw.pid" class="meta-item">PID: {{ gw.pid }}</span>
|
<span v-if="gw.pid" class="meta-item">PID: {{ gw.pid }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="gw.diagnostics" class="gateway-diagnostics">
|
||||||
|
<span class="diag-item">{{ gw.diagnostics.reason }}</span>
|
||||||
|
<span class="diag-item">PID: {{ gw.diagnostics.pid_path }}</span>
|
||||||
|
<span class="diag-item">Config: {{ gw.diagnostics.config_path }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gateway-actions">
|
<div class="gateway-actions">
|
||||||
<NTag :type="gw.running ? 'success' : 'default'" size="small" round>
|
<NTag :type="gw.running ? 'success' : 'default'" size="small" round>
|
||||||
@@ -99,6 +104,7 @@ async function handleToggle(name: string, running: boolean) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
background-color: $bg-card;
|
background-color: $bg-card;
|
||||||
border: 1px solid $border-color;
|
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 {
|
.gateway-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -119,17 +130,62 @@ async function handleToggle(name: string, running: boolean) {
|
|||||||
|
|
||||||
.gateway-meta {
|
.gateway-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gateway-diagnostics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-item {
|
.meta-item {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $text-muted;
|
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 {
|
.gateway-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -131,6 +131,18 @@ export interface GatewayStatus {
|
|||||||
url: string
|
url: string
|
||||||
running: boolean
|
running: boolean
|
||||||
pid?: number
|
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 {
|
interface ManagedGateway {
|
||||||
@@ -508,16 +520,36 @@ export class GatewayManager {
|
|||||||
const pid = this.readPidFile(name)
|
const pid = this.readPidFile(name)
|
||||||
const { port, host } = this.readProfilePort(name)
|
const { port, host } = this.readProfilePort(name)
|
||||||
const url = buildHttpUrl(host, port)
|
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 文件:如果存在且进程存活且健康,则标记为运行
|
// 首先检查 PID 文件:如果存在且进程存活且健康,则标记为运行
|
||||||
if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) {
|
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 })
|
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 的网关
|
// 没有 PID 文件时不认领端口上的未知网关,避免误判其他 profile 的网关
|
||||||
this.gateways.delete(name)
|
this.gateways.delete(name)
|
||||||
return { profile: name, port, host, url, running: false }
|
return { profile: name, port, host, url, running: false, diagnostics }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 检测所有 profile 的网关状态 */
|
/** 检测所有 profile 的网关状态 */
|
||||||
|
|||||||
@@ -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<typeof import('fs')>()
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user