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
+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() {
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 { 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 <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>
)
}