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,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
@@ -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")
|
||||||
|
|||||||
@@ -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 []
|
||||||
@@ -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() {
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user