From 6d4a5c955bef31304bf2753287f3c7fd5fe6a267 Mon Sep 17 00:00:00 2001 From: enzo Date: Wed, 22 Jul 2026 17:05:41 +0800 Subject: [PATCH] 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 --- backend/app/api/gateway.py | 120 +++++++++++ backend/app/main.py | 3 +- backend/app/services/hermes_cli.py | 43 ++++ frontend/src/api/gateway.ts | 46 ++++ frontend/src/hooks/usePolling.ts | 19 ++ frontend/src/pages/GatewayPage.tsx | 325 ++++++++++++++++++++++++++++- 6 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/gateway.py create mode 100644 backend/app/services/hermes_cli.py create mode 100644 frontend/src/api/gateway.ts create mode 100644 frontend/src/hooks/usePolling.ts diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py new file mode 100644 index 0000000..2f366db --- /dev/null +++ b/backend/app/api/gateway.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 0b5043f..bbb182c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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 @@ -35,6 +35,7 @@ app.include_router(auth.router) app.include_router(users.router) app.include_router(audit.router) app.include_router(dashboard.router, prefix="/api/v1") +app.include_router(gateway.router, prefix="/api/v1") @app.get("/api/v1/health") diff --git a/backend/app/services/hermes_cli.py b/backend/app/services/hermes_cli.py new file mode 100644 index 0000000..aa06708 --- /dev/null +++ b/backend/app/services/hermes_cli.py @@ -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 [] diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts new file mode 100644 index 0000000..f23252f --- /dev/null +++ b/frontend/src/api/gateway.ts @@ -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('/gateway/status', { params }) +} + +export function getGatewayEvents() { + return client.get('/gateway/events') +} + +export function startGateway(profile: string) { + return client.post(`/gateway/${profile}/start`) +} + +export function stopGateway(profile: string) { + return client.post(`/gateway/${profile}/stop`) +} + +export function restartGateway(profile: string) { + return client.post(`/gateway/${profile}/restart`) +} diff --git a/frontend/src/hooks/usePolling.ts b/frontend/src/hooks/usePolling.ts new file mode 100644 index 0000000..e2d52ff --- /dev/null +++ b/frontend/src/hooks/usePolling.ts @@ -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]) +} diff --git a/frontend/src/pages/GatewayPage.tsx b/frontend/src/pages/GatewayPage.tsx index b569934..347a256 100644 --- a/frontend/src/pages/GatewayPage.tsx +++ b/frontend/src/pages/GatewayPage.tsx @@ -1,3 +1,324 @@ -export default function GatewayPage() { - return

Gateway

Gateway 管理

+import { useState, useCallback, useEffect } from 'react' +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 在线 + if (s === 'offline') return 离线 + if (s === 'warn') return 异常 + return {s} +} + +const eventToTimeline = (e: GatewayEvent): TimelineEvent => { + const colorMap: Record = { + heartbeat_timeout: 'var(--red)', + feishu_disconnect: 'var(--orange)', + restart: 'var(--blue)', + } + const labelMap: Record = { + heartbeat_timeout: '心跳超时', + feishu_disconnect: '飞书断连', + restart: 'Gateway 重启', + } + return { + time: e.time, + type: e.type, + label: ( +
+ {labelMap[e.type] || e.type} + {e.profile} +
+ ), + 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([]) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [statusFilter, setStatusFilter] = useState('all') + const [typeFilter, setTypeFilter] = useState('all') + + const [operating, setOperating] = useState(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: {gw.name}, + status: statBadge(gw.status), + downtime: gw.downtime_minutes > 0 ? `${gw.downtime_minutes} 分钟` : '-', + restarts: gw.restarts_today > 0 ? ( + = 3 ? 'var(--red)' : 'var(--yellow)' }}> + {gw.restarts_today} 次 + + ) : '-', + last_event: gw.last_event || '-', + actions: canOperate ? ( +
+ + {gw.status === 'online' ? ( + + ) : ( + + )} +
+ ) : null, + })) + + const rowClassName = (record: Record) => { + const gw = gateways.find((g) => g.name === record.name) + return gw && isDangerRow(gw) ? 'row-danger' : '' + } + + const timelineEvents = events.map(eventToTimeline) + + /* ---------- render ---------- */ + + if (loading) { + return ( +
+

Gateway 管理

+ + +
+ ) + } + + if (error && gateways.length === 0) { + return ( +
+

Gateway 管理

+
+

{error}

+ +
+
+ ) + } + + return ( +
+

Gateway 管理

+ + {/* ---- event timeline ---- */} + {timelineEvents.length > 0 && ( +
+

+ 异常事件时间线 (24h) +

+
+ +
+
+ ● 心跳超时 + ● 飞书断连 + ● Gateway 重启 +
+
+ )} + + {/* ---- filter bar ---- */} +
+ + + {gateways.length > 0 && ( + + 共 {filtered.length} / {gateways.length} 个 Gateway + + )} +
+ + {/* ---- table ---- */} +
+ {filtered.length === 0 && !loading ? ( +
+

全部 Gateway 正常运行 🎉

+
+ ) : ( + + )} + + + {/* ---- confirm modal ---- */} + setModalAction(null)} + title="确认操作" + confirmText={modalAction?.action === 'start' ? '启动' : modalAction?.action === 'stop' ? '停止' : '重启'} + confirmDanger + onConfirm={handleOperate} + > +
+ +
+

+ 确认{modalAction?.action === 'start' ? '启动' : modalAction?.action === 'stop' ? '停止' : '重启'} + {modalAction?.profile} Gateway? +

+

+ {modalAction?.action === 'stop' + ? '停止后该 Gateway 上的所有连接将立即断开。' + : modalAction?.action === 'restart' + ? '重启期间服务将暂时不可用。' + : '启动后将恢复服务。'} +

+
+
+
+ + ) }