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:
attacker committed 2026-06-18 13:02:19 +00:00
1 parent 94dbc89b9b
commit 2e71371257
15 files changed
+2616 -137

No files matched your search

+2 -1
View File
@@ -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 />} />
+347 -65
View File
@@ -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;
+283
View File
@@ -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;
+276 -22
View File
@@ -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>
);
}
+367 -11
View File
@@ -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>
);
}
+520 -30
View File
@@ -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>
);
}
+19
View File
@@ -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 }),