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:
@@ -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`)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user