feat: Phase 2-5 监控中心全栈实现 — 指标浏览器/SNMP/站点拨测/中间件
Phase 2 指标监控: - 修复 /metrics 路由(原指向 AlertManager → 现指向 MetricsExplorer) - 新建 MetricsHandler (4 API): targets/list/query/names - 新建 MetricsExplorer 前端(170行): 目标选择器+指标分类树+多指标叠加SVG图表 Phase 3 SNMP设备: - 新建 SNMPHandler (9 API): devices CRUD/templates/stats - SNMPMonitor 接入真实API(428行): 设备表格+添加对话框+删除 Phase 4 站点拨测: - 新建 SiteHandler (8 API): tasks CRUD/results/stats - SiteMonitor 接入真实API(542行): 任务表+添加对话框+拨测类型网格 - 新增 siteApi 到 api.ts Phase 5 中间件采集: - Agent 新增 collector/middleware.go: Redis INFO + MySQL handshake 采集 - Agent CLI 新增 -redis/-redis-auth/-mysql/-mysql-user/-mysql-pass - MiddlewareMonitor 接入真实API(301行): 实例卡片+指标面板 基础设施: - AutoMigrate 新增 23 张监控表 - 前后端容器构建部署验证通过
This commit is contained in:
1 parent
94dbc89b9b
commit
2e71371257
15 files changed
+2616
-137
No files matched your search
@@ -32,6 +32,7 @@ import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
|
||||
import TraceManager from '@/components/monitor/trace/TraceManager';
|
||||
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard';
|
||||
import { MonitorCenter } from '@/pages/MonitorCenter';
|
||||
import { MetricsExplorer } from '@/pages/MetricsExplorer';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -69,7 +70,7 @@ function App() {
|
||||
<Route path="releases" element={<ReleaseManager />} />
|
||||
<Route path="deploy" element={<AppReleaseManager />} />
|
||||
{/* 监控告警 */}
|
||||
<Route path="metrics" element={<AlertManager />} />
|
||||
<Route path="metrics" element={<MetricsExplorer />} />
|
||||
<Route path="alert-center" element={<AlertCenter />} />
|
||||
<Route path="trace" element={<TraceManager />} />
|
||||
<Route path="grafana" element={<GrafanaDashboard />} />
|
||||
|
||||
@@ -1,90 +1,326 @@
|
||||
// 主机监控 — Agent 采集指标展示 (CPU/内存/磁盘/网络/磁盘IO)
|
||||
// 主机监控 — Agent 采集指标展示 (CPU/内存/TCP/磁盘IO/趋势图) // Logan.Li
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { agentApi } from '@/services/api';
|
||||
import { useUserStore } from '@/store';
|
||||
import { asList, isOnline, timeAgo } from '@/components/agent/agentUtils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Cpu, HardDrive, Network, Activity, Server } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Cpu, HardDrive, Network, Activity, Server, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// Tailwind 不支持动态 class,用颜色映射表
|
||||
const colorMap: Record<string, string> = {
|
||||
blue: 'bg-blue-500/10',
|
||||
emerald: 'bg-emerald-500/10',
|
||||
purple: 'bg-purple-500/10',
|
||||
orange: 'bg-orange-500/10',
|
||||
};
|
||||
|
||||
function parseJSON(raw: string): Record<string, any> {
|
||||
if (!raw) return {};
|
||||
try { return JSON.parse(raw); } catch { return {}; }
|
||||
}
|
||||
|
||||
function fmtBps(bps: number): string {
|
||||
if (!bps || bps === 0) return '--';
|
||||
if (bps > 1e9) return `${(bps / 1e9).toFixed(1)} GB/s`;
|
||||
if (bps > 1e6) return `${(bps / 1e6).toFixed(1)} MB/s`;
|
||||
if (bps > 1e3) return `${(bps / 1e3).toFixed(1)} KB/s`;
|
||||
return `${bps.toFixed(1)} B/s`;
|
||||
}
|
||||
|
||||
function fmtNum(n: number): string {
|
||||
return n != null && !Number.isNaN(n) ? String(Math.round(n)) : '--';
|
||||
}
|
||||
|
||||
export function HostMonitor() {
|
||||
const [hosts, setHosts] = useState<any[]>([]);
|
||||
const [selectedHost, setSelectedHost] = useState('');
|
||||
const [latest, setLatest] = useState<any>(null);
|
||||
const [cpuKlines, setCpuKlines] = useState<any[]>([]);
|
||||
const [memKlines, setMemKlines] = useState<any[]>([]);
|
||||
const [cardLoading, setCardLoading] = useState(true);
|
||||
const [chartLoading, setChartLoading] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Step 1: 加载主机列表
|
||||
const loadHosts = useCallback(async () => {
|
||||
try {
|
||||
const data = await agentApi.getLatest();
|
||||
const list = asList(data);
|
||||
setHosts(list);
|
||||
if (list.length > 0 && !selectedHost) {
|
||||
setSelectedHost(list[0].hostname);
|
||||
}
|
||||
} catch { /* 后端不可用时显示空状态 */ }
|
||||
finally { setCardLoading(false); }
|
||||
}, [selectedHost]);
|
||||
|
||||
useEffect(() => { loadHosts(); }, []);
|
||||
|
||||
// Step 2: 选中主机后加载 K 线
|
||||
useEffect(() => {
|
||||
if (!selectedHost) return;
|
||||
setChartLoading(true);
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
// JS toISOString() 包含毫秒 (2026-06-18T12:50:00.000Z),Go time.RFC3339 不解析毫秒
|
||||
const toISO = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
Promise.all([
|
||||
agentApi.getKline({ hostname: selectedHost, metric: 'cpu', interval: '5m', from: toISO(from), to: toISO(now) }),
|
||||
agentApi.getKline({ hostname: selectedHost, metric: 'mem', interval: '5m', from: toISO(from), to: toISO(now) }),
|
||||
])
|
||||
.then(([cpu, mem]) => {
|
||||
setCpuKlines(asList(cpu));
|
||||
setMemKlines(asList(mem));
|
||||
})
|
||||
.catch(() => { setCpuKlines([]); setMemKlines([]); })
|
||||
.finally(() => setChartLoading(false));
|
||||
|
||||
// 从 hosts 列表中同步 selected host 的最新指标
|
||||
const current = hosts.find((h: any) => h.hostname === selectedHost);
|
||||
if (current) setLatest(current);
|
||||
}, [selectedHost, hosts]);
|
||||
|
||||
// Step 3: WebSocket 实时推送
|
||||
useEffect(() => {
|
||||
const token = useUserStore.getState().token;
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/metrics?token=${token}`;
|
||||
|
||||
let cancelled = false;
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'metrics_update' && msg.hostname === selectedHost) {
|
||||
setLatest((prev: any) => {
|
||||
if (!prev) return prev;
|
||||
const netInfo = parseJSON(prev.net_info);
|
||||
const diskIO = parseJSON(prev.disk_io_info);
|
||||
return {
|
||||
...prev,
|
||||
cpu_usage: msg.cpu_usage ?? prev.cpu_usage,
|
||||
mem_usage: msg.memory_usage ?? prev.mem_usage,
|
||||
disk_info: prev.disk_info, // 保留原有磁盘信息
|
||||
net_info: JSON.stringify({
|
||||
...netInfo,
|
||||
tcp_established: msg.tcp_established ?? netInfo.tcp_established,
|
||||
tcp_time_wait: msg.tcp_time_wait ?? netInfo.tcp_time_wait,
|
||||
net_rx_bps: msg.net_rx_bps ?? netInfo.net_rx_bps,
|
||||
net_tx_bps: msg.net_tx_bps ?? netInfo.net_tx_bps,
|
||||
}),
|
||||
disk_io_info: JSON.stringify({
|
||||
...diskIO,
|
||||
read_bps: msg.disk_read_bps ?? diskIO.read_bps,
|
||||
write_bps: msg.disk_write_bps ?? diskIO.write_bps,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch { /* WS 消息解析失败静默 */ }
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!cancelled) reconnectRef.current = setTimeout(connect, 3000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [selectedHost]);
|
||||
|
||||
// 解析最新 JSON 字段
|
||||
const netInfo = parseJSON(latest?.net_info);
|
||||
const diskIOInfo = parseJSON(latest?.disk_io_info);
|
||||
|
||||
// 合并 K 线为图表数据
|
||||
const chartData = cpuKlines.map((k: any, i: number) => ({
|
||||
time: new Date(k.bucket_time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
cpu: k.avg ?? k.close ?? 0,
|
||||
mem: memKlines[i]?.avg ?? memKlines[i]?.close ?? 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">CPU 使用率</p>
|
||||
<p className="text-xl font-bold text-[var(--dg-text-primary)]">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
||||
<Server className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">内存使用率</p>
|
||||
<p className="text-xl font-bold text-[var(--dg-text-primary)]">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
|
||||
<Network className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">TCP 连接数</p>
|
||||
<p className="text-xl font-bold text-[var(--dg-text-primary)]">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-500/10 flex items-center justify-center">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">磁盘 IO</p>
|
||||
<p className="text-xl font-bold text-[var(--dg-text-primary)]">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 主机选择器 */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<label className="text-sm text-[var(--dg-text-muted)] shrink-0">选择主机:</label>
|
||||
<Select value={selectedHost} onValueChange={setSelectedHost}>
|
||||
<SelectTrigger className="w-64 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder={cardLoading ? '加载中...' : '选择主机...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
{hosts.map((h: any) => (
|
||||
<SelectItem key={h.hostname} value={h.hostname}>
|
||||
<span className="flex items-center gap-2">
|
||||
{h.hostname}
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full ${
|
||||
isOnline(h.report_time) ? 'bg-emerald-400' : 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{latest && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
最后上报: {timeAgo(latest.report_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Sections */}
|
||||
{/* 概览卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
icon={<Cpu className="h-5 w-5 text-blue-400" />}
|
||||
label="CPU 使用率"
|
||||
value={latest?.cpu_usage != null ? `${latest.cpu_usage.toFixed(1)}%` : '--'}
|
||||
color="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Server className="h-5 w-5 text-emerald-400" />}
|
||||
label="内存使用率"
|
||||
value={latest?.mem_usage != null ? `${latest.mem_usage.toFixed(1)}%` : '--'}
|
||||
color="emerald"
|
||||
detail={latest?.mem_total ? `${(latest.mem_used ?? 0).toFixed(1)} / ${latest.mem_total.toFixed(0)} GB` : undefined}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Network className="h-5 w-5 text-purple-400" />}
|
||||
label="TCP 连接数"
|
||||
value={fmtNum(netInfo.tcp_established)}
|
||||
color="purple"
|
||||
detail={netInfo.tcp_time_wait != null ? `TIME_WAIT ${netInfo.tcp_time_wait}` : undefined}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<HardDrive className="h-5 w-5 text-orange-400" />}
|
||||
label="磁盘 IO"
|
||||
value={fmtBps((diskIOInfo.read_bps ?? 0) + (diskIOInfo.write_bps ?? 0))}
|
||||
color="orange"
|
||||
detail={diskIOInfo.read_iops != null ? `IOPS R${Math.round(diskIOInfo.read_iops)}/W${Math.round(diskIOInfo.write_iops ?? 0)}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 趋势图 + 网络/磁盘 IO 面板 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* CPU/Memory Trends */}
|
||||
{/* CPU / 内存趋势 */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">CPU / 内存 趋势</h3>
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
CPU / 内存 趋势(5m)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="h-64 flex items-center justify-center text-[var(--dg-text-muted)] text-sm border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
趋势图表开发中...
|
||||
<div className="h-64">
|
||||
{chartLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
{selectedHost ? '暂无 K 线数据 — 请确认 Agent 已上报' : '请先选择主机'}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<defs>
|
||||
<linearGradient id="hCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="hMem" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="time" stroke="#475569" fontSize={11} />
|
||||
<YAxis stroke="#475569" fontSize={11} domain={[0, 100]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0f172a',
|
||||
border: '1px solid #1e293b',
|
||||
borderRadius: '6px',
|
||||
color: '#f8fafc',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#hCpu)" name="CPU %" />
|
||||
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#hMem)" name="内存 %" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network */}
|
||||
{/* 网络 / 磁盘 IO 速率面板 */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Network className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">网络流量</h3>
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">网络 / 磁盘 IO 实时速率</h3>
|
||||
</div>
|
||||
<div className="h-64 flex items-center justify-center text-[var(--dg-text-muted)] text-sm border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
网络流量图表开发中...
|
||||
<div className="space-y-3">
|
||||
{/* 网卡 */}
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">
|
||||
网卡{netInfo.nic_name ? ` (${netInfo.nic_name})` : ''} — RX / TX
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<RateItem label="↓ 接收" value={fmtBps(netInfo.net_rx_bps)} color="text-blue-400" />
|
||||
<RateItem label="↑ 发送" value={fmtBps(netInfo.net_tx_bps)} color="text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 磁盘 IO */}
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">磁盘 IO — 读 / 写</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<RateItem label="读" value={fmtBps(diskIOInfo.read_bps)} color="text-orange-400" />
|
||||
<RateItem label="写" value={fmtBps(diskIOInfo.write_bps)} color="text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
{/* CPU Load */}
|
||||
{latest && (
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">系统负载 (Load Average)</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-xs text-slate-500">1m</span>
|
||||
<span className="text-sm font-mono text-slate-300">{latest.load1 ?? '--'}</span>
|
||||
<span className="text-xs text-slate-500">5m</span>
|
||||
<span className="text-sm font-mono text-slate-300">{latest.load5 ?? '--'}</span>
|
||||
<span className="text-xs text-slate-500">15m</span>
|
||||
<span className="text-sm font-mono text-slate-300">{latest.load15 ?? '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -92,3 +328,49 @@ export function HostMonitor() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 概览卡片 */
|
||||
function MetricCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
detail,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${colorMap[color] ?? 'bg-slate-500/10'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] truncate">{label}</p>
|
||||
<p className="text-xl font-bold text-[var(--dg-text-primary)]">{value}</p>
|
||||
{detail && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] truncate">{detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 速率标签 */
|
||||
function RateItem({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-slate-500">{label}</span>
|
||||
<span className={`text-sm font-mono font-medium ${color}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMonitor;
|
||||
@@ -0,0 +1,283 @@
|
||||
// 指标浏览器 — 按分类/主机浏览所有指标,支持叠加对比 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Activity, Search, Filter, BarChart3, Cpu, HardDrive, Network, Database, Plus } from 'lucide-react';
|
||||
import { http } from '@/services/http';
|
||||
|
||||
interface MetricName {
|
||||
name: string;
|
||||
category: string;
|
||||
data_source: string;
|
||||
}
|
||||
|
||||
interface TargetInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
ip: string;
|
||||
status: string;
|
||||
latest_metrics: Record<string, number>;
|
||||
}
|
||||
|
||||
interface MetricPoint {
|
||||
id: number;
|
||||
target_id: number;
|
||||
metric_name: string;
|
||||
value: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const METRIC_CATEGORIES: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
cpu: { label: 'CPU', icon: <Cpu className="h-4 w-4" /> },
|
||||
memory: { label: '内存', icon: <HardDrive className="h-4 w-4" /> },
|
||||
disk: { label: '磁盘', icon: <HardDrive className="h-4 w-4" /> },
|
||||
network: { label: '网络', icon: <Network className="h-4 w-4" /> },
|
||||
middleware: { label: '中间件', icon: <Database className="h-4 w-4" /> },
|
||||
device: { label: '设备', icon: <Activity className="h-4 w-4" /> },
|
||||
};
|
||||
|
||||
export function MetricsExplorer() {
|
||||
const [targets, setTargets] = useState<TargetInfo[]>([]);
|
||||
const [metricNames, setMetricNames] = useState<string[]>([]);
|
||||
const [selectedTarget, setSelectedTarget] = useState('');
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([]);
|
||||
const [queryResults, setQueryResults] = useState<MetricPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchKw, setSearchKw] = useState('');
|
||||
|
||||
// Load targets
|
||||
const loadTargets = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await http.get('/metrics/targets?page_size=200');
|
||||
setTargets(res?.list || []);
|
||||
} catch { /* empty */ }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
// Load metric names
|
||||
const loadMetricNames = useCallback(async () => {
|
||||
try {
|
||||
const names: any = await http.get('/metrics/names');
|
||||
setMetricNames(names || []);
|
||||
} catch { /* empty */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTargets(); loadMetricNames(); }, []);
|
||||
|
||||
// Query metric data when target + metrics selected
|
||||
useEffect(() => {
|
||||
if (!selectedTarget || selectedMetrics.length === 0) return;
|
||||
const target = targets.find(t => String(t.id) === selectedTarget);
|
||||
if (!target) return;
|
||||
|
||||
const to = Math.floor(Date.now() / 1000);
|
||||
const from = to - 3600; // last hour
|
||||
|
||||
Promise.all(
|
||||
selectedMetrics.map(mn =>
|
||||
http.get('/metrics/query', { target_id: selectedTarget, metric_name: mn, from: String(from), to: String(to), limit: 200 })
|
||||
.then((data: any) => (Array.isArray(data) ? data : []))
|
||||
.catch(() => [])
|
||||
)
|
||||
).then(results => {
|
||||
setQueryResults(results.flat());
|
||||
});
|
||||
}, [selectedTarget, selectedMetrics, targets]);
|
||||
|
||||
// Filter metric names by search
|
||||
const filteredMetrics = searchKw
|
||||
? metricNames.filter(n => n.toLowerCase().includes(searchKw.toLowerCase()))
|
||||
: metricNames;
|
||||
|
||||
// Selected target info
|
||||
const target = targets.find(t => String(t.id) === selectedTarget);
|
||||
|
||||
// Group metrics by category
|
||||
const grouped = filteredMetrics.reduce((acc: Record<string, string[]>, name) => {
|
||||
const cat = name.split('_')[0] || 'other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(name);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Merge query results into time-aligned data
|
||||
const timeMap: Record<number, Record<string, number>> = {};
|
||||
queryResults.forEach(pt => {
|
||||
if (!timeMap[pt.timestamp]) timeMap[pt.timestamp] = {};
|
||||
timeMap[pt.timestamp][pt.metric_name] = pt.value;
|
||||
});
|
||||
const sortedTimes = Object.keys(timeMap).map(Number).sort((a, b) => a - b);
|
||||
const maxVal = Math.max(1, ...Object.values(timeMap).flatMap(m => Object.values(m)));
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Select value={selectedTarget} onValueChange={setSelectedTarget}>
|
||||
<SelectTrigger className="w-64 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder={loading ? '加载中...' : '选择监控目标...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200 max-h-80">
|
||||
{targets.map(t => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${t.status === 'online' ? 'bg-emerald-400' : 'bg-slate-500'}`} />
|
||||
{t.name} ({t.ip || t.type})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
className="pl-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"
|
||||
placeholder="搜索指标..."
|
||||
value={searchKw}
|
||||
onChange={(e) => setSearchKw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setSelectedMetrics([]); setQueryResults([]); }}>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedTarget ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Filter className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<p className="text-[var(--dg-text-muted)]">请先选择监控目标,然后勾选要查看的指标</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Metric selector */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] lg:col-span-1">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BarChart3 className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">指标列表</h3>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">{selectedMetrics.length} 已选</span>
|
||||
</div>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{Object.entries(grouped).map(([cat, names]) => (
|
||||
<div key={cat}>
|
||||
<div className="flex items-center gap-1.5 mb-1.5 text-xs text-[var(--dg-text-muted)]">
|
||||
{METRIC_CATEGORIES[cat]?.icon}
|
||||
<span>{METRIC_CATEGORIES[cat]?.label || cat}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{names.map(name => {
|
||||
const selected = selectedMetrics.includes(name);
|
||||
return (
|
||||
<label key={name} className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer text-xs transition-colors ${selected ? 'bg-blue-500/10 text-blue-300' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-slate-600 bg-slate-800 accent-blue-500"
|
||||
checked={selected}
|
||||
onChange={() => {
|
||||
setSelectedMetrics(prev =>
|
||||
selected ? prev.filter(m => m !== name) : [...prev, name]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chart area */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] lg:col-span-2">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
{target?.name || ''} — 最近 1 小时
|
||||
</h3>
|
||||
</div>
|
||||
{selectedMetrics.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
<div className="text-center">
|
||||
<Plus className="h-8 w-8 text-slate-600 mx-auto mb-2" />
|
||||
请从左侧勾选指标
|
||||
</div>
|
||||
</div>
|
||||
) : sortedTimes.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-80 overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${Math.max(sortedTimes.length * 4, 400)} 300`} className="w-full h-full min-w-[400px]">
|
||||
{/* Grid lines */}
|
||||
{[0, 25, 50, 75, 100].map(pct => (
|
||||
<line key={pct} x1="0" y1={300 - (pct / 100) * 260} x2={Math.max(sortedTimes.length * 4, 400)} y2={300 - (pct / 100) * 260} stroke="#1e293b" strokeDasharray="3 3" />
|
||||
))}
|
||||
{/* Lines per metric */}
|
||||
{selectedMetrics.map((mn, mi) => {
|
||||
const points = sortedTimes
|
||||
.map((t, i) => ({ x: i * 4 + 2, y: 300 - ((timeMap[t][mn] ?? 0) / maxVal) * 260 }))
|
||||
.filter(p => timeMap[sortedTimes[Math.round((p.x - 2) / 4)]]?.[mn] != null);
|
||||
if (points.length < 2) return null;
|
||||
const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x} ${p.y}`).join(' ');
|
||||
return (
|
||||
<g key={mn}>
|
||||
<path d={d} fill="none" stroke={COLORS[mi % COLORS.length]} strokeWidth="1.5" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* X axis time labels */}
|
||||
{sortedTimes.filter((_, i) => i % 15 === 0).map((t, i) => (
|
||||
<text key={t} x={i * 60 + 2} y="295" fill="#475569" fontSize="9">
|
||||
{new Date(t * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* Legend */}
|
||||
{selectedMetrics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3 mt-3">
|
||||
{selectedMetrics.map((mn, mi) => (
|
||||
<div key={mn} className="flex items-center gap-1.5 text-xs text-[var(--dg-text-secondary)]">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: COLORS[mi % COLORS.length] }} />
|
||||
{mn}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target metrics overview */}
|
||||
{target && Object.keys(target.latest_metrics || {}).length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{Object.entries(target.latest_metrics).map(([key, val]) => (
|
||||
<div key={key} className="bg-[var(--dg-bg-secondary)] rounded-lg p-3 border border-[var(--dg-border)] text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] truncate">{key}</p>
|
||||
<p className="text-lg font-bold text-[var(--dg-text-primary)]">
|
||||
{typeof val === 'number' ? val.toFixed(1) : '--'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricsExplorer;
|
||||
@@ -1,47 +1,301 @@
|
||||
// 中间件监控 — Redis / MySQL 指标采集(需配置认证信息)
|
||||
// 中间件监控 — Redis / MySQL 指标采集(实时 API 数据)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Database, Server } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Database, Server, Activity, Loader2, Circle } from 'lucide-react';
|
||||
import { http } from '@/services/http';
|
||||
|
||||
interface TargetInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
ip: string;
|
||||
status: string;
|
||||
latest_metrics: Record<string, number>;
|
||||
}
|
||||
|
||||
interface TargetsResponse {
|
||||
list: TargetInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 格式化内存值 (bytes → human readable) */
|
||||
function fmtMemory(bytes: number): string {
|
||||
if (bytes == null || bytes === 0) return '--';
|
||||
if (bytes > 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
if (bytes > 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
if (bytes > 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/** 格式化命中率 (0-1 float → %) */
|
||||
function fmtHitRate(v: number): string {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** 格式化数值 */
|
||||
function fmtVal(v: number, precision = 0): string {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
return v.toFixed(precision);
|
||||
}
|
||||
|
||||
/** 状态指示器颜色 */
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'text-emerald-400';
|
||||
case 'warning': return 'text-amber-400';
|
||||
case 'error': return 'text-red-400';
|
||||
default: return 'text-slate-500';
|
||||
}
|
||||
}
|
||||
|
||||
function statusBg(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-emerald-400';
|
||||
case 'warning': return 'bg-amber-400';
|
||||
case 'error': return 'bg-red-400';
|
||||
default: return 'bg-slate-500';
|
||||
}
|
||||
}
|
||||
|
||||
export function MiddlewareMonitor() {
|
||||
const [targets, setTargets] = useState<TargetInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadTargets = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const data = await http.get<TargetsResponse>('/metrics/targets', { type: 'middleware', page_size: 200 });
|
||||
setTargets(data?.list || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载失败');
|
||||
setTargets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTargets(); }, []);
|
||||
|
||||
// 按类型分组
|
||||
const redisTargets = targets.filter(t => t.type === 'redis' || t.name?.toLowerCase().includes('redis'));
|
||||
const mysqlTargets = targets.filter(t => t.type === 'mysql' || t.name?.toLowerCase().includes('mysql'));
|
||||
|
||||
// 聚合 Redis 指标 (取第一个在线实例)
|
||||
const redisTarget = redisTargets.find(t => t.status === 'online') || redisTargets[0];
|
||||
const redisMetrics = redisTarget?.latest_metrics || {};
|
||||
|
||||
// 聚合 MySQL 指标
|
||||
const mysqlTarget = mysqlTargets.find(t => t.status === 'online') || mysqlTargets[0];
|
||||
const mysqlMetrics = mysqlTarget?.latest_metrics || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Redis */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500 mr-2" />
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">正在加载中间件指标...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-red-500/30">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Activity className="h-8 w-8 text-red-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用且已配置中间件采集</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && targets.length === 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Database className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">暂无中间件监控目标</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">需在 Agent 配置 Redis / MySQL 连接信息后启用采集</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Redis 卡片 */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Database className="h-4 w-4 text-red-400" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">Redis 监控</h3>
|
||||
{redisTarget && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{redisTarget.name} ({redisTarget.ip})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-4">需在 Agent 配置 Redis 连接地址和密码后启用采集。</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{['连接数', '内存', '命中率', 'QPS'].map((label) => (
|
||||
<div key={label} className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className="text-lg font-bold text-[var(--dg-text-primary)]">--</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!redisTarget ? (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-4">需在 Agent 配置 Redis 连接地址和密码后启用采集。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricTile
|
||||
label="连接数"
|
||||
value={fmtVal(redisMetrics.redis_connected_clients)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="内存"
|
||||
value={fmtMemory(redisMetrics.redis_used_memory)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="命中率"
|
||||
value={fmtHitRate(redisMetrics.redis_hit_rate)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="QPS"
|
||||
value={fmtVal(redisMetrics.redis_qps, 0)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* MySQL */}
|
||||
{/* MySQL 卡片 */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Server className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">MySQL 监控</h3>
|
||||
{mysqlTarget && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{mysqlTarget.name} ({mysqlTarget.ip})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-4">需在 Agent 配置 MySQL 连接地址、用户名和密码后启用采集。</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{['连接数', 'QPS', '慢查询', '缓冲池命中率'].map((label) => (
|
||||
<div key={label} className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className="text-lg font-bold text-[var(--dg-text-primary)]">--</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!mysqlTarget ? (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-4">需在 Agent 配置 MySQL 连接地址、用户名和密码后启用采集。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricTile
|
||||
label="连接数"
|
||||
value={fmtVal(mysqlMetrics.mysql_connections)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="QPS"
|
||||
value={fmtVal(mysqlMetrics.mysql_qps, 1)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="慢查询"
|
||||
value={fmtVal(mysqlMetrics.mysql_slow_queries)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="缓冲池命中率"
|
||||
value={fmtHitRate(mysqlMetrics.mysql_buffer_hit_rate)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 中间件实例列表 */}
|
||||
{targets.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
中间件实例({targets.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{targets.map(target => (
|
||||
<InstanceCard key={target.id} target={target} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 其他中间件类型的扩展指标 */}
|
||||
{targets.filter(t => !redisTargets.includes(t) && !mysqlTargets.includes(t)).length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{targets
|
||||
.filter(t => !redisTargets.includes(t) && !mysqlTargets.includes(t))
|
||||
.map(target => (
|
||||
<Card key={target.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Circle className={`h-3 w-3 ${statusColor(target.status)}`} />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] capitalize">{target.type}</h3>
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{target.name} ({target.ip})
|
||||
</Badge>
|
||||
</div>
|
||||
{Object.keys(target.latest_metrics || {}).length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Object.entries(target.latest_metrics).map(([key, val]) => (
|
||||
<MetricTile key={key} label={key} value={fmtVal(val, 1)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">暂无指标数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 指标小方块 */
|
||||
function MetricTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className="text-lg font-bold text-[var(--dg-text-primary)]">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 实例状态卡片 */
|
||||
function InstanceCard({ target }: { target: TargetInfo }) {
|
||||
const metrics = target.latest_metrics || {};
|
||||
const metricCount = Object.keys(metrics).length;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 border border-[var(--dg-border)] hover:border-[var(--dg-border)]/80 transition-colors">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${statusBg(target.status)}`} />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{target.name}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] capitalize ${target.status === 'online' ? 'border-emerald-400/50 text-emerald-400' : 'border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
|
||||
>
|
||||
{target.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">
|
||||
{target.ip} · {target.type}
|
||||
</div>
|
||||
{metricCount > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(metrics).slice(0, 4).map(([key, val]) => (
|
||||
<span key={key} className="inline-flex items-center gap-1 text-[10px] bg-[var(--dg-bg-primary)] rounded px-1.5 py-0.5 text-[var(--dg-text-secondary)]">
|
||||
<span className="text-[var(--dg-text-muted)]">{key}</span>
|
||||
<span className="font-mono font-medium text-[var(--dg-text-primary)]">
|
||||
{typeof val === 'number' ? val.toFixed(1) : val}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{metricCount > 4 && (
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">+{metricCount - 4} 项</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">暂无指标</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,160 @@
|
||||
// SNMP 监控 — 网络设备 / 服务器硬件 (OID 模板采集)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Radio, Router, HardDrive } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Radio, Router, HardDrive, Plus, Trash2 } from 'lucide-react';
|
||||
import http from '@/services/http';
|
||||
|
||||
// ---------- types ----------
|
||||
interface SnmpStats {
|
||||
total: number;
|
||||
online: number;
|
||||
abnormal: number;
|
||||
}
|
||||
|
||||
interface SnmpDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
snmp_version: string;
|
||||
community: string;
|
||||
template_id: number;
|
||||
status?: string; // "online" | "offline" | "abnormal"
|
||||
}
|
||||
|
||||
interface DeviceListResponse {
|
||||
list: SnmpDevice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------- initial form state ----------
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
ip: '',
|
||||
port: 161,
|
||||
snmp_version: 'v2c' as 'v2c' | 'v3',
|
||||
community: 'public',
|
||||
template_id: '',
|
||||
};
|
||||
|
||||
export function SNMPMonitor() {
|
||||
// stats
|
||||
const [stats, setStats] = useState<SnmpStats>({ total: 0, online: 0, abnormal: 0 });
|
||||
|
||||
// device list
|
||||
const [devices, setDevices] = useState<SnmpDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...emptyForm });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await http.get<SnmpStats>('/snmp/stats');
|
||||
setStats(data);
|
||||
} catch {
|
||||
// backend unavailable — leave defaults
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
const data = await http.get<DeviceListResponse>('/snmp/devices');
|
||||
setDevices(data.list ?? []);
|
||||
} catch {
|
||||
setDevices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
loadDevices();
|
||||
}, [loadStats, loadDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// ---------- CRUD ----------
|
||||
const handleAdd = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await http.post('/snmp/devices', {
|
||||
name: form.name.trim(),
|
||||
ip: form.ip.trim(),
|
||||
port: Number(form.port),
|
||||
snmp_version: form.snmp_version,
|
||||
community: form.community.trim(),
|
||||
template_id: form.template_id ? Number(form.template_id) : 0,
|
||||
});
|
||||
setDialogOpen(false);
|
||||
setForm({ ...emptyForm });
|
||||
refresh();
|
||||
} catch {
|
||||
// silently fail — user can retry
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await http.delete(`/snmp/devices/${id}`);
|
||||
refresh();
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- status badge ----------
|
||||
const statusBadge = (status?: string) => {
|
||||
if (status === 'online') {
|
||||
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">在线</Badge>;
|
||||
}
|
||||
if (status === 'abnormal') {
|
||||
return <Badge className="bg-red-500/15 text-red-400 border-red-500/30">异常</Badge>;
|
||||
}
|
||||
return <Badge className="bg-slate-500/15 text-slate-400 border-slate-500/30">离线</Badge>;
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Device Types */}
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
@@ -16,12 +165,12 @@ export function SNMPMonitor() {
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">交换机 / 路由器 / 防火墙</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">设备数</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-mono">--</span>
|
||||
<span className="text-[var(--dg-text-muted)]">设备总数</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-mono">{stats.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">在线</span>
|
||||
<span className="text-emerald-400 font-mono">--</span>
|
||||
<span className="text-emerald-400 font-mono">{stats.online}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -35,13 +184,9 @@ export function SNMPMonitor() {
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">iLO / iDRAC / IPMI</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">设备数</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-mono">--</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">异常</span>
|
||||
<span className="text-red-400 font-mono">--</span>
|
||||
<span className="text-red-400 font-mono">{stats.abnormal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -60,13 +205,224 @@ export function SNMPMonitor() {
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-[var(--dg-text-secondary)]">
|
||||
{['思科交换机', 'H3C S5000', '华为交换机', 'iLO/Dell'].map((t) => (
|
||||
<div key={t} className="bg-[var(--dg-bg-tertiary)] rounded px-2 py-1 text-center border border-[var(--dg-border)]">
|
||||
<div
|
||||
key={t}
|
||||
className="bg-[var(--dg-bg-tertiary)] rounded px-2 py-1 text-center border border-[var(--dg-border)]"
|
||||
>
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Router className="h-4 w-4 text-cyan-400" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
SNMP 设备列表
|
||||
{!loading && (
|
||||
<span className="ml-2 text-xs text-[var(--dg-text-muted)] font-normal">
|
||||
({devices.length} 台)
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Add Device Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border border-cyan-500/30">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-slate-900 border-[var(--dg-border)] text-[var(--dg-text-primary)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">添加 SNMP 设备</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
输入设备信息以开始 SNMP 监控采集。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{/* Name */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-name" className="text-[var(--dg-text-muted)]">
|
||||
设备名称
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-name"
|
||||
placeholder="例如: 核心交换机"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* IP */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-ip" className="text-[var(--dg-text-muted)]">
|
||||
IP 地址
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-ip"
|
||||
placeholder="例如: 192.168.1.1"
|
||||
value={form.ip}
|
||||
onChange={(e) => setForm({ ...form, ip: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-port" className="text-[var(--dg-text-muted)]">
|
||||
SNMP 端口
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-port"
|
||||
type="number"
|
||||
placeholder="161"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 161 })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SNMP Version */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-version" className="text-[var(--dg-text-muted)]">
|
||||
SNMP 版本
|
||||
</Label>
|
||||
<Select
|
||||
value={form.snmp_version}
|
||||
onValueChange={(v) => setForm({ ...form, snmp_version: v as 'v2c' | 'v3' })}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="snmp-version"
|
||||
className="w-full bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectItem value="v2c">v2c</SelectItem>
|
||||
<SelectItem value="v3">v3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Community */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-community" className="text-[var(--dg-text-muted)]">
|
||||
Community
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-community"
|
||||
placeholder="public"
|
||||
value={form.community}
|
||||
onChange={(e) => setForm({ ...form, community: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template ID */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-template" className="text-[var(--dg-text-muted)]">
|
||||
模板 ID
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-template"
|
||||
type="number"
|
||||
placeholder="模板 ID"
|
||||
value={form.template_id}
|
||||
onChange={(e) => setForm({ ...form, template_id: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-muted)] hover:bg-slate-800"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={submitting || !form.name.trim() || !form.ip.trim()}
|
||||
className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border border-cyan-500/30"
|
||||
>
|
||||
{submitting ? '添加中...' : '确认添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-sm text-[var(--dg-text-muted)]">加载中...</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
暂无 SNMP 设备,点击「添加设备」开始监控
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">IP 地址</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">端口</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">版本</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Community</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">模板 ID</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-16">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow key={d.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-[var(--dg-text-primary)] font-medium">
|
||||
{d.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] font-mono text-xs">
|
||||
{d.ip}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)]">{d.port}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{d.snmp_version}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] font-mono text-xs">
|
||||
{d.community}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)]">{d.template_id || '--'}</TableCell>
|
||||
<TableCell>{statusBadge(d.status)}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
className="text-slate-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
title="删除设备"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,542 @@
|
||||
// 站点拨测 — HTTP / PING / DNS / TCP / WebSocket 等协议主动监控
|
||||
// 站点拨测 — HTTP / HTTPS / TCP / DNS / PING 主动监控,连接真实后端 API
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Globe, Wifi, Server, Mail, ArrowUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Globe,
|
||||
Server,
|
||||
ArrowUpDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { siteApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
|
||||
// 拨测协议类型(与后端 model 对齐:http, https, tcp, dns, ping)
|
||||
const probeTypes = [
|
||||
{ icon: Globe, name: 'HTTP(S)', color: 'text-blue-400', desc: 'URL 状态码 & 响应时间' },
|
||||
{ icon: ArrowUpDown, name: 'PING', color: 'text-emerald-400', desc: 'ICMP 连通性 & 延迟' },
|
||||
{ icon: Wifi, name: 'WebSocket', color: 'text-purple-400', desc: 'WS/WSS 连接心跳' },
|
||||
{ icon: Globe, name: 'HTTP', color: 'text-blue-400', desc: 'HTTP URL 状态码 & 响应时间' },
|
||||
{ icon: Globe, name: 'HTTPS', color: 'text-emerald-400', desc: 'HTTPS SSL 证书 & 响应时间' },
|
||||
{ icon: Server, name: 'TCP', color: 'text-orange-400', desc: 'TCP 端口连通性检测' },
|
||||
{ icon: Globe, name: 'DNS', color: 'text-cyan-400', desc: '域名解析延迟 & 结果验证' },
|
||||
{ icon: Server, name: 'TCP', color: 'text-orange-400', desc: '端口连通性检测' },
|
||||
{ icon: Server, name: 'UDP', color: 'text-yellow-400', desc: '端口连通性检测' },
|
||||
{ icon: Mail, name: 'SMTP', color: 'text-pink-400', desc: '邮件服务连通性' },
|
||||
{ icon: Mail, name: 'POP3', color: 'text-indigo-400', desc: '邮件接收服务' },
|
||||
{ icon: Globe, name: 'FTP', color: 'text-teal-400', desc: '文件传输服务' },
|
||||
];
|
||||
{ icon: ArrowUpDown, name: 'PING', color: 'text-purple-400', desc: 'ICMP 连通性 & 延迟' },
|
||||
] as const;
|
||||
|
||||
const PROBE_TYPE_OPTIONS = ['HTTP', 'HTTPS', 'TCP', 'DNS', 'PING'] as const;
|
||||
type ProbeType = (typeof PROBE_TYPE_OPTIONS)[number];
|
||||
|
||||
interface SiteStats {
|
||||
total_tasks: number;
|
||||
enabled_tasks: number;
|
||||
up_count: number;
|
||||
down_count: number;
|
||||
availability: number;
|
||||
}
|
||||
|
||||
interface SiteTask {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
type: string;
|
||||
frequency_sec: number;
|
||||
timeout_sec: number;
|
||||
expected_status_code: number;
|
||||
enabled: boolean;
|
||||
last_status?: string;
|
||||
last_response_time_ms?: number;
|
||||
last_checked_at?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface CreateTaskForm {
|
||||
name: string;
|
||||
url: string;
|
||||
type: ProbeType;
|
||||
frequency_sec: number;
|
||||
timeout_sec: number;
|
||||
expected_status_code: number;
|
||||
}
|
||||
|
||||
const emptyForm: CreateTaskForm = {
|
||||
name: '',
|
||||
url: '',
|
||||
type: 'HTTP',
|
||||
frequency_sec: 60,
|
||||
timeout_sec: 10,
|
||||
expected_status_code: 200,
|
||||
};
|
||||
|
||||
function statusIcon(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <CheckCircle2 className="h-4 w-4 text-emerald-400" />;
|
||||
case 'down':
|
||||
return <XCircle className="h-4 w-4 text-red-400" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">正常</Badge>;
|
||||
case 'down':
|
||||
return <Badge className="bg-red-500/15 text-red-400 border-red-500/30">异常</Badge>;
|
||||
default:
|
||||
return <Badge className="bg-gray-500/15 text-gray-400 border-gray-500/30">未知</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtMs(ms?: number): string {
|
||||
if (ms == null || ms === 0) return '--';
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string): string {
|
||||
if (!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function SiteMonitor() {
|
||||
const [stats, setStats] = useState<SiteStats | null>(null);
|
||||
const [tasks, setTasks] = useState<SiteTask[]>([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState<CreateTaskForm>({ ...emptyForm });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
|
||||
const _confirm = useConfirm();
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await siteApi.getStats();
|
||||
setStats(data as SiteStats);
|
||||
} catch {
|
||||
// 后端不可用时保留空状态
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await siteApi.getTasks();
|
||||
setTasks((data as any)?.list || []);
|
||||
} catch {
|
||||
// 后端不可用时保留空状态
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadTasks();
|
||||
}, [loadStats, loadTasks]);
|
||||
|
||||
// ---------- filtered tasks ----------
|
||||
const filteredTasks = selectedType
|
||||
? tasks.filter((t) => t.type?.toUpperCase() === selectedType.toUpperCase())
|
||||
: tasks;
|
||||
|
||||
// ---------- actions ----------
|
||||
const openCreate = () => {
|
||||
setForm({ ...emptyForm });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error('名称和 URL 不能为空');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await siteApi.createTask({
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
type: form.type.toLowerCase(),
|
||||
frequency_sec: Number(form.frequency_sec) || 60,
|
||||
timeout_sec: Number(form.timeout_sec) || 10,
|
||||
expected_status_code: Number(form.expected_status_code) || 200,
|
||||
});
|
||||
toast.success('拨测任务已创建');
|
||||
setFormOpen(false);
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '创建失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (task: SiteTask) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除拨测任务',
|
||||
description: `确定删除「${task.name}」吗?此操作不可恢复。`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await siteApi.deleteTask(task.id);
|
||||
toast.success('任务已删除');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (task: SiteTask) => {
|
||||
try {
|
||||
await siteApi.toggleTask(task.id, !task.enabled);
|
||||
toast.success(task.enabled ? '已停用' : '已启用');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
const availStr =
|
||||
stats?.availability != null ? `${(stats.availability * 100).toFixed(1)}%` : '--';
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Probe Type Grid */}
|
||||
{/* ===== Probe Type Grid ===== */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{probeTypes.map((p) => (
|
||||
<Card key={p.name} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] hover:border-[var(--dg-blue)]/30 transition-colors cursor-pointer">
|
||||
<CardContent className="p-3 text-center">
|
||||
<p.icon className={`h-5 w-5 mx-auto mb-1.5 ${p.color}`} />
|
||||
<p className="text-xs font-medium text-[var(--dg-text-primary)]">{p.name}</p>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{p.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{probeTypes.map((p) => {
|
||||
const active = selectedType === p.name;
|
||||
return (
|
||||
<Card
|
||||
key={p.name}
|
||||
onClick={() => setSelectedType(active ? null : p.name)}
|
||||
className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] transition-colors cursor-pointer ${
|
||||
active ? 'border-[var(--dg-blue)]/60 ring-1 ring-[var(--dg-blue)]/30' : 'hover:border-[var(--dg-blue)]/30'
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-3 text-center">
|
||||
<p.icon className={`h-5 w-5 mx-auto mb-1.5 ${p.color}`} />
|
||||
<p className="text-xs font-medium text-[var(--dg-text-primary)]">{p.name}</p>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{p.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{/* ===== Summary Stats ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">拨测任务概览</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">拨测任务概览</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => { loadStats(); loadTasks(); }}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{['总任务数', '正常', '异常', '可用率'].map((label) => (
|
||||
<div key={label} className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className="text-lg font-bold text-[var(--dg-text-primary)]">--</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: '总任务数', value: stats?.total_tasks ?? '--' },
|
||||
{ label: '已启用', value: stats?.enabled_tasks ?? '--' },
|
||||
{ label: '正常', value: stats?.up_count ?? '--', color: 'text-emerald-400' },
|
||||
{ label: '异常', value: stats?.down_count ?? '--', color: 'text-red-400' },
|
||||
{ label: '可用率', value: availStr, color: 'text-blue-400' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-lg font-bold ${item.color || 'text-[var(--dg-text-primary)]'}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== Task Table ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
拨测任务列表
|
||||
{selectedType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-2 text-[10px] cursor-pointer border-[var(--dg-blue)]/40 text-[var(--dg-blue)] h-5"
|
||||
onClick={() => setSelectedType(null)}
|
||||
>
|
||||
{selectedType} ×
|
||||
</Badge>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加任务
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
{tasks.length === 0
|
||||
? '暂无拨测任务,点击「添加任务」创建第一个'
|
||||
: '当前筛选条件下无任务'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">URL</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">类型</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">间隔</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">延迟</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">上次检测</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">启用</TableHead>
|
||||
<TableHead className="text-xs text-[var(--dg-text-muted)]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTasks.map((task) => (
|
||||
<TableRow key={task.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-sm text-[var(--dg-text-primary)] font-medium">
|
||||
{task.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)] max-w-[200px] truncate">
|
||||
{task.url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[11px] border-[var(--dg-border)]">
|
||||
{task.type?.toUpperCase()}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{task.frequency_sec}s
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusIcon(task.last_status)}
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">
|
||||
{statusBadge(task.last_status)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{fmtMs(task.last_response_time_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{fmtTime(task.last_checked_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={task.enabled}
|
||||
onCheckedChange={() => handleToggle(task)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(task)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== Create Task Dialog ===== */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加拨测任务</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
配置站点探活任务,系统将按指定频率自动检测
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{/* 名称 */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="task-name" className="text-[var(--dg-text-primary)]">任务名称</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
placeholder="例如:官网首页检测"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="task-url" className="text-[var(--dg-text-primary)]">目标 URL</Label>
|
||||
<Input
|
||||
id="task-url"
|
||||
placeholder="例如:https://example.com 或 192.168.1.1"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型 */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">协议类型</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(v) => setForm({ ...form, type: v as ProbeType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROBE_TYPE_OPTIONS.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 频率 & 超时 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="freq" className="text-[var(--dg-text-primary)]">
|
||||
检测间隔 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="freq"
|
||||
type="number"
|
||||
min={10}
|
||||
max={86400}
|
||||
value={form.frequency_sec}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, frequency_sec: Number(e.target.value) || 60 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="timeout" className="text-[var(--dg-text-primary)]">
|
||||
超时 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={form.timeout_sec}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, timeout_sec: Number(e.target.value) || 10 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 期望状态码 (仅 HTTP/HTTPS 显示) */}
|
||||
{(form.type === 'HTTP' || form.type === 'HTTPS') && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="status-code" className="text-[var(--dg-text-primary)]">
|
||||
期望状态码
|
||||
</Label>
|
||||
<Input
|
||||
id="status-code"
|
||||
type="number"
|
||||
min={100}
|
||||
max={599}
|
||||
value={form.expected_status_code}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
创建任务
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -648,6 +648,25 @@ export const alertCenterApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 站点拨测 API ====================
|
||||
export const siteApi = {
|
||||
getStats: async () => {
|
||||
return http.get('/site/stats');
|
||||
},
|
||||
getTasks: async () => {
|
||||
return http.get<{ list: any[]; total: number }>('/site/tasks');
|
||||
},
|
||||
createTask: async (data: { name: string; url: string; type: string; frequency_sec?: number; timeout_sec?: number; expected_status_code?: number }) => {
|
||||
return http.post('/site/tasks', data);
|
||||
},
|
||||
deleteTask: async (id: number | string) => {
|
||||
return http.delete(`/site/tasks/${id}`);
|
||||
},
|
||||
toggleTask: async (id: number | string, enabled: boolean) => {
|
||||
return http.put(`/site/tasks/${id}`, { enabled });
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 链路追踪 API(暂无后端,返回空结果优雅降级) ====================
|
||||
export const traceApi = {
|
||||
getTraces: async (_params: any) => ({ data: [], total: 0 }),
|
||||
|
||||
Reference in new issue
Block a user