feat: Gateway health monitoring (Hermes CLI, API, frontend)

- Hermes CLI wrapper using subprocess.run for gateway status
- Gateway API: status list (with filter), events (placeholder), start/stop/restart with audit logging
- Frontend GatewayPage: timeline, filter bar, status table with 10s polling, operation modals
- usePolling hook for periodic data refresh

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 17:05:41 +08:00
co-authored by Claude
parent 76045538bb
commit 6d4a5c955b
6 changed files with 553 additions and 3 deletions
+120
View File
@@ -0,0 +1,120 @@
"""Gateway API — 状态、事件、启停操作。"""
import logging
import subprocess
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.middleware.auth import require_auth, require_role
from app.models.audit import AuditLog
from app.models.user import User
from app.services import hermes_cli
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/gateway", tags=["gateway"])
# ── 占位事件数据(TODO: 从 hermes 日志或系统日志解析) ──
PLACEHOLDER_EVENTS = [
{"time": "2026-07-22T18:45", "profile": "monitor", "type": "heartbeat_timeout",
"detail": "连续 3 次心跳无响应"},
{"time": "2026-07-22T14:20", "profile": "feishu-gw", "type": "feishu_disconnect",
"detail": "WebSocket 断开 8 分钟"},
{"time": "2026-07-22T10:30", "profile": "gateway-monitor", "type": "restart",
"detail": "Gateway 重启"},
]
@router.get("/status")
async def get_gateway_status(
status: str | None = Query(None, pattern="^(online|offline|warn)$"),
_user: User = Depends(require_auth),
):
"""返回全部 Profile 的 Gateway 状态列表,支持 ?status= 筛选。"""
gateways = hermes_cli.gateway_status_all()
for gw in gateways:
gw.setdefault("downtime_minutes", 0)
gw.setdefault("restarts_today", 0)
gw.setdefault("last_event", "")
gw.setdefault("profile_type", "worker")
if status:
gateways = [g for g in gateways if g.get("status") == status]
return gateways
@router.get("/events")
async def get_gateway_events(
_user: User = Depends(require_auth),
):
"""返回最近 24h 异常事件。"""
# TODO: 从 hermes CLI 日志或系统日志解析真实事件
return PLACEHOLDER_EVENTS
async def _operate_gateway(
profile: str,
action: str,
user: User,
db: AsyncSession,
) -> dict:
"""执行 hermes gateway {action} --profile {profile} 并写入审计日志。"""
try:
proc = subprocess.run(
[settings.hermes_cli_path, "gateway", action, "--profile", profile],
capture_output=True,
text=True,
timeout=30,
)
success = proc.returncode == 0
detail = (proc.stdout or proc.stderr).strip()
except Exception as e:
success = False
detail = str(e)
audit = AuditLog(
user_id=user.id,
username=user.username,
action=f"gateway:{action}",
target=f"profile:{profile}",
detail=detail,
)
db.add(audit)
await db.commit()
if not success:
raise HTTPException(status_code=500, detail=f"Gateway {action} 失败: {detail}")
return {"success": True, "action": action, "profile": profile, "detail": detail}
@router.post("/{profile}/start")
async def start_gateway(
profile: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""启动 Gateway。"""
return await _operate_gateway(profile, "start", user, db)
@router.post("/{profile}/stop")
async def stop_gateway(
profile: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""停止 Gateway。"""
return await _operate_gateway(profile, "stop", user, db)
@router.post("/{profile}/restart")
async def restart_gateway(
profile: str,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""重启 Gateway。"""
return await _operate_gateway(profile, "restart", user, db)
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base from app.database import engine, Base
from app.api import auth, users, audit, dashboard from app.api import auth, users, audit, dashboard, gateway
from app.middleware.audit import AuditMiddleware from app.middleware.audit import AuditMiddleware
@@ -35,6 +35,7 @@ app.include_router(auth.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(dashboard.router, prefix="/api/v1") app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(gateway.router, prefix="/api/v1")
@app.get("/api/v1/health") @app.get("/api/v1/health")
+43
View File
@@ -0,0 +1,43 @@
"""Hermes CLI 封装 — 通过 subprocess 调用 hermes 命令。"""
import json
import logging
import subprocess
from app.config import settings
logger = logging.getLogger(__name__)
def _run_hermes(*args: str, timeout: int = 15) -> subprocess.CompletedProcess:
"""执行 hermes CLI 命令,返回 CompletedProcess。"""
return subprocess.run(
[settings.hermes_cli_path, *args],
capture_output=True,
text=True,
timeout=timeout,
)
def gateway_status_all() -> list[dict]:
"""执行 `hermes gateway status`,返回解析后的 Gateway 状态列表。
出错时返回空列表并记录告警日志。
"""
try:
proc = _run_hermes("gateway", "status")
if proc.returncode != 0:
logger.warning("hermes gateway status 返回非零: %s", proc.stderr.strip())
return []
data = json.loads(proc.stdout)
if isinstance(data, list):
return data
return data.get("gateways", [])
except FileNotFoundError:
logger.warning("Hermes CLI 未找到: %s", settings.hermes_cli_path)
except json.JSONDecodeError as e:
logger.warning("解析 hermes gateway status 输出失败: %s", e)
except subprocess.TimeoutExpired:
logger.warning("hermes gateway status 执行超时")
except Exception as e:
logger.warning("调用 hermes gateway status 异常: %s", e)
return []
+46
View File
@@ -0,0 +1,46 @@
import client from './client'
export interface GatewayInfo {
id: string
name: string
status: 'online' | 'offline' | 'warn'
downtime_minutes: number
restarts_today: number
last_event: string
profile_type: string
}
export interface GatewayEvent {
time: string
profile: string
type: string
detail: string
}
export interface OperationResult {
success: boolean
action: string
profile: string
detail: string
}
export function getGatewayStatus(status?: string) {
const params = status ? { status } : undefined
return client.get<GatewayInfo[]>('/gateway/status', { params })
}
export function getGatewayEvents() {
return client.get<GatewayEvent[]>('/gateway/events')
}
export function startGateway(profile: string) {
return client.post<OperationResult>(`/gateway/${profile}/start`)
}
export function stopGateway(profile: string) {
return client.post<OperationResult>(`/gateway/${profile}/stop`)
}
export function restartGateway(profile: string) {
return client.post<OperationResult>(`/gateway/${profile}/restart`)
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useRef } from 'react'
export function usePolling(
callback: () => void,
intervalMs: number,
enabled: boolean = true,
) {
const savedCallback = useRef(callback)
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
if (!enabled) return
savedCallback.current()
const id = setInterval(() => savedCallback.current(), intervalMs)
return () => clearInterval(id)
}, [intervalMs, enabled])
}
+323 -2
View File
@@ -1,3 +1,324 @@
export default function GatewayPage() { import { useState, useCallback, useEffect } from 'react'
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Gateway 管理</p></div> import { RotateCcw, Play, Square, AlertTriangle } from 'lucide-react'
import Table, { type TableColumn } from '../components/ui/Table'
import Badge from '../components/ui/Badge'
import Button from '../components/ui/Button'
import Modal from '../components/ui/Modal'
import Skeleton from '../components/ui/Skeleton'
import Timeline, { type TimelineEvent } from '../components/ui/Timeline'
import toast from '../components/ui/Toast'
import { useAuth } from '../hooks/useAuth'
import { usePolling } from '../hooks/usePolling'
import {
getGatewayStatus,
getGatewayEvents,
startGateway,
stopGateway,
restartGateway,
type GatewayInfo,
type GatewayEvent,
} from '../api/gateway'
/* ---------- helpers ---------- */
const statBadge = (s: string) => {
if (s === 'online') return <Badge variant="success">在线</Badge>
if (s === 'offline') return <Badge variant="danger">离线</Badge>
if (s === 'warn') return <Badge variant="warning">异常</Badge>
return <Badge variant="outline">{s}</Badge>
}
const eventToTimeline = (e: GatewayEvent): TimelineEvent => {
const colorMap: Record<string, string> = {
heartbeat_timeout: 'var(--red)',
feishu_disconnect: 'var(--orange)',
restart: 'var(--blue)',
}
const labelMap: Record<string, string> = {
heartbeat_timeout: '心跳超时',
feishu_disconnect: '飞书断连',
restart: 'Gateway 重启',
}
return {
time: e.time,
type: e.type,
label: (
<div className="flex flex-col items-center text-[11px]">
<span className="font-medium" style={{ color: 'var(--text)' }}>{labelMap[e.type] || e.type}</span>
<span style={{ color: 'var(--text-dim)' }}>{e.profile}</span>
</div>
),
color: colorMap[e.type],
}
}
const isDangerRow = (gw: GatewayInfo) =>
gw.restarts_today >= 3 || gw.downtime_minutes >= 30
/* ---------- page ---------- */
export default function GatewayPage() {
const { user } = useAuth()
const canOperate = user?.role === 'owner' || user?.role === 'operator'
const [gateways, setGateways] = useState<GatewayInfo[]>([])
const [events, setEvents] = useState<GatewayEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [statusFilter, setStatusFilter] = useState<string>('all')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [operating, setOperating] = useState<string | null>(null)
const [modalAction, setModalAction] = useState<{ profile: string; action: 'start' | 'stop' | 'restart' } | null>(null)
const fetchStatus = useCallback(async () => {
try {
const res = await getGatewayStatus()
setGateways(res.data)
setError(null)
} catch {
setError('加载 Gateway 状态失败')
} finally {
setLoading(false)
}
}, [])
const fetchEvents = useCallback(async () => {
try {
const res = await getGatewayEvents()
setEvents(res.data)
} catch {
// event fetch failure is non-critical
}
}, [])
useEffect(() => { fetchEvents() }, [fetchEvents])
usePolling(fetchStatus, 10_000)
/* ---- operate ---- */
const handleOperate = useCallback(async () => {
if (!modalAction) return
const { profile, action } = modalAction
setOperating(profile)
setModalAction(null)
try {
if (action === 'start') await startGateway(profile)
else if (action === 'stop') await stopGateway(profile)
else await restartGateway(profile)
toast.success(`Gateway "${profile}" ${action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'}成功`)
fetchStatus()
} catch {
toast.error(`Gateway "${profile}" ${action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'}失败`)
} finally {
setOperating(null)
}
}, [modalAction, fetchStatus])
/* ---- filter ---- */
const filtered = gateways.filter((g) => {
if (statusFilter !== 'all' && g.status !== statusFilter) return false
if (typeFilter !== 'all' && g.profile_type !== typeFilter) return false
return true
})
const cols: TableColumn[] = [
{ key: 'name', label: 'Profile 名' },
{ key: 'status', label: '状态' },
{ key: 'downtime', label: '掉线时长' },
{ key: 'restarts', label: '今日重启' },
{ key: 'last_event', label: '最近异常' },
]
if (canOperate) cols.push({ key: 'actions', label: '操作' })
const tableData = filtered.map((gw) => ({
key: gw.id || gw.name,
name: <span style={{ fontWeight: 500, color: 'var(--text)' }}>{gw.name}</span>,
status: statBadge(gw.status),
downtime: gw.downtime_minutes > 0 ? `${gw.downtime_minutes} 分钟` : '-',
restarts: gw.restarts_today > 0 ? (
<span style={{ color: gw.restarts_today >= 3 ? 'var(--red)' : 'var(--yellow)' }}>
{gw.restarts_today} 次
</span>
) : '-',
last_event: gw.last_event || '-',
actions: canOperate ? (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
loading={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'restart' })}
title="重启"
>
<RotateCcw size={13} />
</Button>
{gw.status === 'online' ? (
<Button
size="sm"
variant="ghost"
loading={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'stop' })}
title="停止"
>
<Square size={13} />
</Button>
) : (
<Button
size="sm"
variant="ghost"
loading={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'start' })}
title="启动"
>
<Play size={13} />
</Button>
)}
</div>
) : null,
}))
const rowClassName = (record: Record<string, unknown>) => {
const gw = gateways.find((g) => g.name === record.name)
return gw && isDangerRow(gw) ? 'row-danger' : ''
}
const timelineEvents = events.map(eventToTimeline)
/* ---------- render ---------- */
if (loading) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 管理</h2>
<Skeleton variant="card" count={1} className="mt-4" />
<Skeleton variant="table" count={5} className="mt-4" />
</div>
)
}
if (error && gateways.length === 0) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 管理</h2>
<div
className="mt-4 p-5 rounded-[var(--radius)] text-center"
style={{ background: 'var(--bg-card)' }}
>
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
<Button variant="primary" onClick={fetchStatus}>重试</Button>
</div>
</div>
)
}
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 管理</h2>
{/* ---- event timeline ---- */}
{timelineEvents.length > 0 && (
<div
className="mt-4 rounded-[var(--radius)] p-4"
style={{ background: 'var(--bg-card)' }}
>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
异常事件时间线 (24h)
</h3>
<div className="overflow-x-auto pb-2">
<Timeline events={timelineEvents} className="min-w-[600px]" />
</div>
<div className="flex gap-4 mt-3 text-[12px]" style={{ color: 'var(--text-dim)' }}>
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
<span><span style={{ color: 'var(--blue)' }}>●</span> Gateway 重启</span>
</div>
</div>
)}
{/* ---- filter bar ---- */}
<div className="mt-4 flex items-center gap-3 flex-wrap">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1.5 text-[13px] rounded-[var(--radius-sm)] border cursor-pointer"
style={{
background: 'var(--bg-card)',
color: 'var(--text)',
borderColor: 'var(--border)',
}}
>
<option value="all">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="warn">异常</option>
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-1.5 text-[13px] rounded-[var(--radius-sm)] border cursor-pointer"
style={{
background: 'var(--bg-card)',
color: 'var(--text)',
borderColor: 'var(--border)',
}}
>
<option value="all">全部蜂型</option>
<option value="worker">工蜂</option>
<option value="queen">蜂王</option>
</select>
{gateways.length > 0 && (
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>
共 {filtered.length} / {gateways.length} 个 Gateway
</span>
)}
</div>
{/* ---- table ---- */}
<div className="mt-3">
{filtered.length === 0 && !loading ? (
<div
className="rounded-[var(--radius)] p-8 text-center"
style={{ background: 'var(--bg-card)' }}
>
<p style={{ color: 'var(--text-dim)', fontSize: 14 }}>全部 Gateway 正常运行 🎉</p>
</div>
) : (
<Table
columns={cols}
data={tableData}
rowKey="key"
rowClassName={rowClassName}
/>
)}
</div>
{/* ---- confirm modal ---- */}
<Modal
open={!!modalAction}
onClose={() => setModalAction(null)}
title="确认操作"
confirmText={modalAction?.action === 'start' ? '启动' : modalAction?.action === 'stop' ? '停止' : '重启'}
confirmDanger
onConfirm={handleOperate}
>
<div className="flex items-start gap-3">
<AlertTriangle size={20} style={{ color: 'var(--red)', flexShrink: 0, marginTop: 2 }} />
<div>
<p style={{ color: 'var(--text)', fontSize: 14, marginBottom: 8 }}>
确认{modalAction?.action === 'start' ? '启动' : modalAction?.action === 'stop' ? '停止' : '重启'}
<strong> {modalAction?.profile}</strong> Gateway?
</p>
<p style={{ color: 'var(--text-dim)', fontSize: 12 }}>
{modalAction?.action === 'stop'
? '停止后该 Gateway 上的所有连接将立即断开。'
: modalAction?.action === 'restart'
? '重启期间服务将暂时不可用。'
: '启动后将恢复服务。'}
</p>
</div>
</div>
</Modal>
</div>
)
} }