refactor(frontend): api/components/pages 目录架构统一对齐菜单域 + 日志中心修复

1. 目录架构统一(API = components = 菜单域,一域一目录 + barrel)
   - log/ 提为独立顶层域(日志中心在菜单中与监控中心平级,不再塞在 monitor/)
   - task/ 拆分原 automation.ts → cron-job / script / remote-exec
   - aiops/ 合并 ai.ts + agentCli.ts → assistant + agent-cli
   - agent/ approval/ audit/ 提升为域目录
   - auth 拆分:authApi(认证自服务) + system/user.ts(userApi+ldapApi 管理端)
   - components/automation → components/task,菜单"自动化运维"→"作业任务"
   - pages/ 按域分子目录(approval/audit/monitor),删孤儿 AuditPage.tsx
   - 所有 API 符号名不变,组件经 @/api 聚合引用,运行时行为零变化

2. Loki 查询无返回修复:aiops 容器 DNS 解析外网域名失败
   (宿主机 systemd-resolved 转发链路异常),docker-compose 显式配 dns 上游

3. ES 查询框布局:工具栏单行挤 7 控件(ES 多 150px 索引框),拆两行——
   查询输入独占一行(+查询按钮),次级控件换行 flex-wrap

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
attackerandClaude committed 2026-06-28 06:06:48 +00:00
1 parent da01cbb119
commit c0ee8e4ddd
74 files changed
+229 -216

No files matched your search

+481
View File
@@ -0,0 +1,481 @@
// 主机监控详情 — 阿里云云监控风格 + v2.0 新增指标
// 进程TOP / Swap / FD / 上下文切换 // Logan.Li
import { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api';
import { useHostList, useHostHistory, usePortPolicy } from '@/hooks/api/useHostMonitor';
import { queryKeys } from '@/hooks/api/queryKeys';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Cpu, HardDrive, Network, Activity, Server, Loader2,
Info, Monitor, Disc, Clock, List, HeartPulse, RefreshCw, XCircle,
} from 'lucide-react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { TIME_RANGES, type AggType, type HostRecord } from './host-monitor/types';
import { parseJSON, formatBPS, buildMetricsFromHistory, getCurrentVal } from './host-monitor/utils';
import { MetricChartCard } from './host-monitor/MetricChartCard';
import { DualLineChart } from './host-monitor/DualLineChart';
import { HealthProbeSection } from './host-monitor/HealthProbeSection';
import { PortPolicyDialog } from './host-monitor/PortPolicyDialog';
import { SystemInfoSection } from './host-monitor/SystemInfoSection';
import type { JsonData } from '@/types/common';
export function HostMonitor() {
const qc = useQueryClient();
const [searchParams] = useSearchParams();
const urlHost = searchParams.get('host') || '';
const [selectedHost, setSelectedHost] = useState(urlHost);
const [activeTab, setActiveTab] = useState('basic');
const [rangeHours, setRangeHours] = useState(1);
const [aggType, setAggType] = useState<AggType>('avg');
const [portPolicyMode, setPortPolicyMode] = useState('whitelist');
const [portWhitelist, setPortWhitelist] = useState<number[]>([]);
const [portPolicyDialogOpen, setPortPolicyDialogOpen] = useState(false);
const [portPolicySaving, setPortPolicySaving] = useState(false);
const { data: hosts = [], isLoading: hostsLoading } = useHostList();
const { data: history = [], isLoading: historyLoading } = useHostHistory(selectedHost, rangeHours);
const { data: portPolicy, refetch: refetchPortPolicy } = usePortPolicy(selectedHost);
useEffect(() => {
if (portPolicy) {
setPortPolicyMode(portPolicy.port_filter_mode);
setPortWhitelist(portPolicy.port_whitelist);
}
}, [portPolicy]);
useEffect(() => {
if (hosts.length > 0 && !selectedHost) {
const m = urlHost ? hosts.find((h) => h.hostname === urlHost) : null;
setSelectedHost(String(m?.hostname || hosts[0]?.hostname || ''));
}
}, [hosts, selectedHost, urlHost]);
const handleSavePortPolicy = async () => {
if (!selectedHost) return;
setPortPolicySaving(true);
try {
await agentApi.savePortPolicy(selectedHost, {
port_filter_mode: portPolicyMode,
port_whitelist: portWhitelist,
});
setPortPolicyDialogOpen(false);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.portPolicy(selectedHost) });
} catch { } finally { setPortPolicySaving(false); }
};
const timeConfig = TIME_RANGES.find(tr => tr.hours === rangeHours) || TIME_RANGES[0];
const loading = hostsLoading || historyLoading;
const serverInfo = (hosts.find((h) => h.hostname === selectedHost) || null) as HostRecord | null;
const latest = useMemo((): HostRecord => {
const base = serverInfo || {};
if (history.length === 0) return base;
return { ...base, ...(history[history.length - 1] as HostRecord) };
}, [serverInfo, history]);
const metrics = useMemo(() => buildMetricsFromHistory(history, timeConfig), [history, timeConfig]);
const netInfo = parseJSON(latest?.net_info);
const portInfo: JsonData = Array.isArray(latest?.port_info) ? latest.port_info : (Array.isArray(parseJSON(latest?.port_info)) ? parseJSON(latest?.port_info) : []);
const diskIOInfo = parseJSON(latest?.disk_io_info);
const swapInfo = parseJSON(latest?.swap_info);
const processTop: JsonData = Array.isArray(parseJSON(latest?.process_top_info)) ? parseJSON(latest?.process_top_info) : [];
const fdInfo = parseJSON(latest?.fd_info);
const csInfo = parseJSON(latest?.cs_info);
const heartbeatInfo = parseJSON(latest?.heartbeat_info);
const currentVal = (key: string, fallback = '--') =>
getCurrentVal(latest, netInfo, diskIOInfo, swapInfo, fdInfo, key, fallback);
if (loading && !history.length) {
return <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-slate-500" /></div>;
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-blue-400" />
<Select value={selectedHost} onValueChange={setSelectedHost}>
<SelectTrigger className="w-52 h-8 text-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<SelectValue placeholder="选择主机..." />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{hosts.map((h: JsonData) => (
<SelectItem key={h.hostname} value={h.hostname}>{h.hostname}</SelectItem>
))}
</SelectContent>
</Select>
{serverInfo && (
<span className="text-xs text-[var(--dg-text-muted)] ml-2">
{String(serverInfo.os || '')}{serverInfo.cpu_cores ? ` | ${serverInfo.cpu_cores as number | string} 核` : ''}{serverInfo.mem_total ? ` | ${(serverInfo.mem_total as number).toFixed(0)}GB` : ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Select value={aggType} onValueChange={(v) => setAggType(v as AggType)}>
<SelectTrigger className="w-24 h-7 text-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
<SelectItem value="avg">平均值</SelectItem>
<SelectItem value="max">最大值</SelectItem>
<SelectItem value="min">最小值</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1">
{TIME_RANGES.map(tr => (
<Button key={tr.hours} variant={rangeHours === tr.hours ? 'default' : 'outline'} size="sm"
className="h-7 text-xs px-2.5" onClick={() => setRangeHours(tr.hours)}>{tr.label}</Button>
))}
</div>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="h-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] gap-0 overflow-x-auto">
{[
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3 h-3" /> },
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3 h-3" /> },
{ id: 'network', label: '网络监控', icon: <Network className="w-3 h-3" /> },
{ id: 'ports', label: '端口监控', icon: <Server className="w-3 h-3" /> },
{ id: 'process', label: '进程监控', icon: <List className="w-3 h-3" /> },
{ id: 'system', label: '系统信息', icon: <Info className="w-3 h-3" /> },
{ id: 'health_probes', label: '健康探针', icon: <HeartPulse className="w-3 h-3" /> },
].map(tab => (
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1 data-[state=active]:bg-[var(--dg-bg-primary)]">
{tab.icon}{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="basic" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricChartCard title="CPU 使用率(%)" value={currentVal('cpu')} icon={<Cpu className="h-4 w-4 text-blue-400" />}
data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]} aggType={aggType} />
<MetricChartCard title="CPU IO等待(%)" value={latest?.cpu_iowait != null ? `${(latest.cpu_iowait as number).toFixed(1)}%` : '--'} icon={<Clock className="h-4 w-4 text-orange-400" />}
data={metrics.iowait} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
<MetricChartCard title="内存使用率(%)" value={currentVal('mem')} icon={<Server className="h-4 w-4 text-emerald-400" />}
data={metrics.mem} color="#10b981" unit="%" domain={[0, 100]} aggType={aggType} />
<MetricChartCard title="可用内存(GB)" value={latest?.mem_available != null ? `${(latest.mem_available as number).toFixed(1)}GB` : '--'} icon={<Server className="h-4 w-4 text-emerald-400" />}
data={metrics.memAvail} color="#34d399" unit="GB" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="Swap 使用率(%)" value={currentVal('swap')} icon={<HardDrive className="h-4 w-4 text-red-400" />}
data={metrics.swap} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
<MetricChartCard title="系统负载(1m)" value={currentVal('load1')} icon={<Activity className="h-4 w-4 text-purple-400" />}
data={metrics.load1} color="#8b5cf6" unit="" domain={[0, 'auto']} aggType={aggType} />
</TabsContent>
<TabsContent value="disk" className="mt-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricChartCard title="磁盘使用率(%)" value={currentVal('disk')} icon={<HardDrive className="h-4 w-4 text-orange-400" />}
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
<MetricChartCard title="Inode 使用率(%)" value={(() => {
const dArr = Array.isArray(parseJSON(latest?.disk_info)) ? parseJSON(latest?.disk_info) : [];
const root = dArr.find((d: JsonData) => d.mount === '/') || dArr[0];
return root?.inode_usage_pct != null ? `${root.inode_usage_pct.toFixed(1)}%` : '--';
})()} icon={<HardDrive className="h-4 w-4 text-purple-400" />}
data={metrics.inode} color="#8b5cf6" unit="%" domain={[0, 100]} aggType={aggType} />
<MetricChartCard title="磁盘读取(KB/s)" value={currentVal('ioR')} icon={<Disc className="h-4 w-4 text-blue-400" />}
data={metrics.ioRead} color="#3b82f6" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="磁盘写入(KB/s)" value={currentVal('ioW')} icon={<Disc className="h-4 w-4 text-emerald-400" />}
data={metrics.ioWrite} color="#10b981" unit="KB/s" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="磁盘IO繁忙度(%)" value={diskIOInfo?.util != null ? `${diskIOInfo.util.toFixed(1)}%` : '--'} icon={<Disc className="h-4 w-4 text-red-400" />}
data={metrics.ioUtil} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
</div>
</TabsContent>
<TabsContent value="network" className="mt-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<MetricChartCard title="TCP EST" value={currentVal('tcp')} icon={<Network className="h-4 w-4 text-purple-400" />}
data={metrics.tcpEst} color="#8b5cf6" unit="个" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock className="h-4 w-4 text-amber-400" />}
data={metrics.tcpTW} color="#f59e0b" unit="个" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="TCP CLOSE_WAIT" value={netInfo?.tcp_close_wait != null ? String(netInfo.tcp_close_wait) : '--'} icon={<Clock className="h-4 w-4 text-red-400" />}
data={metrics.tcpCW} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="TCP 总连接" value={netInfo ? String((netInfo.tcp_established||0)+(netInfo.tcp_time_wait||0)+(netInfo.tcp_close_wait||0)) : '--'} icon={<Network className="h-4 w-4 text-cyan-400" />}
data={metrics.connTotal} color="#22d3ee" unit="个" domain={[0, 'auto']} aggType={aggType} />
</div>
<DualLineChart
title="网卡带宽吞吐"
currentRx={netInfo?.net_rx_bps != null ? formatBPS(netInfo.net_rx_bps) : '--'}
currentTx={netInfo?.net_tx_bps != null ? formatBPS(netInfo.net_tx_bps) : '--'}
rxData={metrics.rx} txData={metrics.tx}
aggType={aggType} unit="KB/s"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<MetricChartCard title="网卡接收错误" value={netInfo?.net_rx_errors != null ? String(netInfo.net_rx_errors) : '--'} icon={<XCircle className="h-4 w-4 text-red-400" />}
data={metrics.rxErr} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} />
<MetricChartCard title="网卡发送错误" value={netInfo?.net_tx_errors != null ? String(netInfo.net_tx_errors) : '--'} icon={<XCircle className="h-4 w-4 text-orange-400" />}
data={metrics.txErr} color="#f97316" unit="个" domain={[0, 'auto']} aggType={aggType} />
</div>
</TabsContent>
<TabsContent value="ports" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<Server className="h-4 w-4 text-emerald-400" />监听端口 {portInfo.length}
<span className="ml-auto flex items-center gap-2">
{portPolicyMode === 'blacklist' && portWhitelist.length > 0 && (
<Badge variant="outline" className="text-[10px] text-amber-400 border-amber-500/30">
黑名单: {portWhitelist.join(',')}
</Badge>
)}
{portPolicyMode === 'whitelist' && (
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-500/30">
白名单模式
</Badge>
)}
<Button variant="outline" size="sm" className="h-6 text-[10px] px-2"
onClick={() => { refetchPortPolicy(); setPortPolicyDialogOpen(true); }}>
策略配置
</Button>
</span>
</h3>
{portInfo.length === 0 ? (
<div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
暂无端口数据
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-1.5 text-left font-medium">端口</th>
<th className="py-1.5 text-left font-medium">协议</th>
<th className="py-1.5 text-left font-medium">进程</th>
<th className="py-1.5 text-right font-medium">状态</th>
</tr>
</thead>
<tbody>
{portInfo.map((p: JsonData, i: number) => (
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
<td className="py-1.5 font-mono text-[var(--dg-text-primary)]">{p.port}</td>
<td className="py-1.5">
<Badge variant="outline" className="text-[10px] font-mono">
{p.protocol || 'tcp'}
</Badge>
</td>
<td className="py-1.5 font-mono text-[var(--dg-text-muted)]">{p.process || '—'}</td>
<td className="py-1.5 text-right">
<Badge variant="default" className="text-[10px] bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
LISTEN
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="process" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<List className="h-4 w-4 text-cyan-400" />进程 TOP {processTop.length}
</h3>
{processTop.length === 0 ? (
<div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
Agent v2.0+ 支持,等待数据上报…
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-1.5 text-left font-medium w-[40%]">进程名称 / CMD</th>
<th className="py-1.5 text-right font-medium w-[8%]">PID</th>
<th className="py-1.5 text-right font-medium w-[16%]">内存使用率(%)</th>
<th className="py-1.5 text-right font-medium w-[16%]">CPU使用率(%)</th>
<th className="py-1.5 text-right font-medium w-[20%]">打开文件数</th>
</tr>
</thead>
<tbody>
{processTop.map((p: JsonData, i: number) => {
const cmd = p.cmdline || p.name || '?';
const cpup = p.cpu_pct != null ? Number(p.cpu_pct) : null;
const memp = p.mem_pct != null ? Number(p.mem_pct) : null;
return (
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
<td className="py-1.5 pr-2">
<div className="flex items-center gap-1 group/cmd">
<span className="font-mono text-[var(--dg-text-primary)] truncate max-w-[320px] inline-block align-middle" title={cmd}>
{cmd}
</span>
<button
className="opacity-0 group-hover/cmd:opacity-100 transition-opacity p-0.5 hover:text-cyan-400 shrink-0"
title="复制命令行"
onClick={async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(cmd);
} catch {
const ta = document.createElement('textarea');
ta.value = cmd; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
}}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</td>
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.pid}</td>
<td className="py-1.5 text-right font-mono">
<span className={memp != null && memp > 50 ? 'text-rose-400' : 'text-cyan-400'}>
{memp != null ? memp.toFixed(2) : '--'}
</span>
</td>
<td className="py-1.5 text-right font-mono">
<span className={cpup != null && cpup > 50 ? 'text-rose-400' : 'text-[var(--dg-text-primary)]'}>
{cpup != null ? cpup.toFixed(2) : '--'}
</span>
</td>
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.fd_count ?? '--'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<HardDrive className="h-4 w-4 text-violet-400" />FD 文件句柄
</h3>
{fdInfo?.allocated != null ? (
<>
<div className="flex items-center gap-4 mb-3 text-xs">
<div>
<span className="text-[var(--dg-text-muted)]">已分配 </span>
<span className="font-mono text-[var(--dg-text-primary)] font-semibold">{fdInfo.allocated.toLocaleString()}</span>
</div>
<div>
<span className="text-[var(--dg-text-muted)]">上限 </span>
<span className="font-mono text-[var(--dg-text-primary)] font-semibold">{fdInfo.max === -1 ? '无限制' : fdInfo.max?.toLocaleString() || '—'}</span>
</div>
<div>
<span className="text-[var(--dg-text-muted)]">使用率 </span>
<span className={`font-mono font-semibold ${fdInfo.usage_pct > 80 ? 'text-rose-400' : 'text-violet-400'}`}>
{fdInfo.usage_pct != null ? `${fdInfo.usage_pct.toFixed(1)}%` : '—'}
</span>
</div>
</div>
{metrics.fd.length > 0 && (
<div className="h-[120px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={metrics.fd}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} unit="%" domain={[0, 'auto']} />
<Tooltip contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 6, fontSize: 11 }} />
<defs>
<linearGradient id="fdGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#a78bfa" stopOpacity={0.3} />
<stop offset="100%" stopColor="#a78bfa" stopOpacity={0.05} />
</linearGradient>
</defs>
<Area type="monotone" dataKey={aggType} stroke="#a78bfa" strokeWidth={1.5} fill="url(#fdGrad)" name="FD使用率" />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</>
) : (
<div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">等待数据…</div>
)}
</CardContent>
</Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-emerald-400" />上下文切换
</h3>
{csInfo?.ctx_per_sec != null ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">CTX 切换/秒</div>
<div className="text-lg font-bold font-mono text-emerald-400">
{(csInfo.ctx_per_sec / 1e6).toFixed(1)}<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-0.5">M</span>
</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1 font-mono">{csInfo.ctx_per_sec?.toLocaleString()} 次</div>
</div>
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-3 text-center">
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">IRQ 中断/秒</div>
<div className="text-lg font-bold font-mono text-amber-400">
{(csInfo.irq_per_sec / 1e6).toFixed(1)}<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-0.5">M</span>
</div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1 font-mono">{csInfo.irq_per_sec?.toLocaleString()} 次</div>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-emerald-400">CTX</span>
<span className="text-amber-400">IRQ</span>
</div>
<div className="h-2 bg-[var(--dg-bg-primary)] rounded-full overflow-hidden flex">
{csInfo.ctx_per_sec + csInfo.irq_per_sec > 0 && (
<>
<div className="h-full bg-emerald-500/60 rounded-l-full transition-all"
style={{ width: `${(csInfo.ctx_per_sec / (csInfo.ctx_per_sec + csInfo.irq_per_sec) * 100).toFixed(1)}%` }} />
<div className="h-full bg-amber-500/60 rounded-r-full transition-all"
style={{ width: `${(csInfo.irq_per_sec / (csInfo.ctx_per_sec + csInfo.irq_per_sec) * 100).toFixed(1)}%` }} />
</>
)}
</div>
</div>
</div>
) : (
<div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">等待数据…</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<SystemInfoSection
selectedHost={selectedHost}
serverInfo={serverInfo}
latest={latest}
heartbeatInfo={heartbeatInfo}
portInfoCount={portInfo.length}
/>
<HealthProbeSection selectedHost={selectedHost} />
</Tabs>
<PortPolicyDialog
open={portPolicyDialogOpen}
onOpenChange={setPortPolicyDialogOpen}
portPolicyMode={portPolicyMode}
onModeChange={setPortPolicyMode}
portWhitelist={portWhitelist}
onWhitelistChange={setPortWhitelist}
onSave={handleSavePortPolicy}
saving={portPolicySaving}
/>
</div>
);
}
@@ -0,0 +1,284 @@
// 指标浏览器 — 按分类/主机浏览所有指标,支持叠加对比 // 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 { metricsApi } from '@/api';
import type { JsonData } from '@/types/common';
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: JsonData = await metricsApi.getTargets({ page_size: 200 });
setTargets(res?.list || []);
} catch { /* empty */ }
finally { setLoading(false); }
}, []);
// Load metric names
const loadMetricNames = useCallback(async () => {
try {
const names: string[] = await metricsApi.getNames();
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 =>
metricsApi.query({ target_id: selectedTarget, metric_name: mn, from: String(from), to: String(to), limit: 200 })
.then((data: JsonData) => (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;
@@ -0,0 +1,302 @@
// 中间件监控 — Redis / MySQL 指标采集(实时 API 数据)
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Database, Server, Activity, Loader2, Circle } from 'lucide-react';
import { metricsApi } from '@/api';
import type { JsonData } from '@/types/common';
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 metricsApi.getTargets({ type: 'middleware', page_size: 200 }) as TargetsResponse;
setTargets(data?.list || []);
} catch (err) {
setError((err as JsonData).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">
{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>
{!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 卡片 */}
<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>
{!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>
);
}
@@ -0,0 +1,69 @@
// 监控中心 — 4个监控标签页的容器
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Cpu, Database, Radio, Globe } from 'lucide-react';
import { HostMonitor } from './HostMonitor';
import { MiddlewareMonitor } from './MiddlewareMonitor';
import { SNMPMonitor } from './SNMPMonitor';
import { SiteMonitor } from './SiteMonitor';
type TabId = 'host' | 'middleware' | 'snmp' | 'site';
interface Tab {
id: TabId;
name: string;
icon: React.ElementType;
description: string;
}
const tabs: Tab[] = [
{ id: 'host', name: '主机监控', icon: Cpu, description: 'CPU / 内存 / 磁盘 / 网络 / 磁盘IO' },
{ id: 'middleware', name: '中间件监控', icon: Database, description: 'Redis / MySQL 指标采集' },
{ id: 'snmp', name: 'SNMP 监控', icon: Radio, description: '网络设备 / 服务器硬件 (OID 模板)' },
{ id: 'site', name: '站点拨测', icon: Globe, description: 'HTTP / PING / DNS / TCP / WS 拨测' },
];
export function MonitorCenter() {
const [activeTab, setActiveTab] = useState<TabId>('host');
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">监控中心</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">全面的基础设施与应用监控</p>
</div>
{/* Tab Bar */}
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] p-1 rounded-lg border border-[var(--dg-border)]">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-md text-sm font-medium transition-all',
activeTab === tab.id
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] shadow-sm'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'
)}
>
<tab.icon className="h-4 w-4" />
<span className="hidden sm:inline">{tab.name}</span>
</button>
))}
</div>
{/* Tab Description */}
<p className="text-sm text-[var(--dg-text-muted)] -mt-4">
{tabs.find(t => t.id === activeTab)?.description}
</p>
{/* Tab Content */}
<div className="min-h-[400px]">
{activeTab === 'host' && <HostMonitor />}
{activeTab === 'middleware' && <MiddlewareMonitor />}
{activeTab === 'snmp' && <SNMPMonitor />}
{activeTab === 'site' && <SiteMonitor />}
</div>
</div>
);
}
+428
View File
@@ -0,0 +1,428 @@
// SNMP 监控 — 网络设备 / 服务器硬件 (OID 模板采集)
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
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 { snmpApi } from '@/api';
// ---------- 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 snmpApi.getStats() as SnmpStats;
setStats(data);
} catch {
// backend unavailable — leave defaults
}
}, []);
const loadDevices = useCallback(async () => {
try {
const data = await snmpApi.getDevices() as DeviceListResponse;
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 snmpApi.createDevice({
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 snmpApi.deleteDevice(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">
{/* 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">
<div className="flex items-center gap-2 mb-3">
<Router className="h-4 w-4 text-cyan-400" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">网络设备</h3>
</div>
<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">{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">{stats.online}</span>
</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-2 mb-3">
<HardDrive className="h-4 w-4 text-purple-400" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">服务器硬件</h3>
</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-red-400 font-mono">{stats.abnormal}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Template Info */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Radio className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">OID 监控模板</h3>
</div>
<p className="text-sm text-[var(--dg-text-muted)]">
支持 Zabbix 兼容的 SNMP OID 模板(XML 格式),内置思科/H3C/华为交换机模板、服务器硬件监控模板。
</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)]"
>
{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>
);
}
+543
View File
@@ -0,0 +1,543 @@
// 站点拨测 — HTTP / HTTPS / TCP / DNS / PING 主动监控,连接真实后端 API
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
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 '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import type { JsonData } from '@/types/common';
// 拨测协议类型(与后端 model 对齐:http, https, tcp, dns, ping)
const probeTypes = [
{ 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: 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 JsonData)?.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) {
toast.error((e as JsonData).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) {
toast.error((e as JsonData).message || '删除失败');
}
};
const handleToggle = async (task: SiteTask) => {
try {
await siteApi.toggleTask(task.id, !task.enabled);
toast.success(task.enabled ? '已停用' : '已启用');
loadTasks();
loadStats();
} catch (e) {
toast.error((e as JsonData).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 ===== */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{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 Stats ===== */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<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-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>
);
}
@@ -0,0 +1,77 @@
// 双线图表(网卡带宽 rx+tx)// Logan.Li
import { Network } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Brush,
} from 'recharts';
import type { AggType, BucketPoint } from './types';
interface DualLineChartProps {
title: string;
currentRx: string;
currentTx: string;
rxData: BucketPoint[];
txData: BucketPoint[];
aggType: AggType;
unit: string;
}
export function DualLineChart({
title, currentRx, currentTx, rxData, txData, aggType, unit,
}: DualLineChartProps) {
const chartData = rxData.map((d, i) => ({
time: d.time,
timestamp: d.timestamp,
rx: d[aggType] ?? 0,
tx: txData[i]?.[aggType] ?? 0,
}));
const rxGradId = `grad-rx-${title.replace(/\s/g, '')}`;
const txGradId = `grad-tx-${title.replace(/\s/g, '')}`;
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<Network className="h-4 w-4 text-blue-400" />
<span className="text-xs text-[var(--dg-text-primary)] font-medium">{title}</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />接收 <b className="text-blue-300">{currentRx}</b></span>
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />发送 <b className="text-emerald-300">{currentTx}</b></span>
</div>
</div>
<div className="h-56">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
<defs>
<linearGradient id={rxGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id={txGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
<XAxis dataKey="time" hide />
<YAxis hide domain={[0, 'auto']} />
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
formatter={(v: number, name: string) => [`${v.toFixed(2)} ${unit}`, name === 'rx' ? '接收' : '发送']}
labelFormatter={(label) => `时间: ${label}`} />
<Area type="monotone" dataKey="rx" stroke="#3b82f6" strokeWidth={1.5} fill={`url(#${rxGradId})`} name="rx" />
<Area type="monotone" dataKey="tx" stroke="#10b981" strokeWidth={1.5} fill={`url(#${txGradId})`} name="tx" />
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,231 @@
// 健康探针 Tab + 对话框 // Logan.Li
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api';
import { useHealthProbes } from '@/hooks/api/useHostMonitor';
import { queryKeys } from '@/hooks/api/queryKeys';
import { Card, CardContent } from '@/components/ui/card';
import { TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Clock, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
} from 'lucide-react';
import { DEFAULT_PROBE_FORM, type ProbeForm } from './types';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
interface HealthProbeSectionProps {
selectedHost: string;
}
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
const confirm = useConfirm();
const qc = useQueryClient();
const { data: probes = [], refetch } = useHealthProbes(selectedHost);
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
const [editingProbe, setEditingProbe] = useState<Record<string, unknown> | null>(null);
const [probeForm, setProbeForm] = useState<ProbeForm>(DEFAULT_PROBE_FORM());
const openCreateDialog = () => {
setEditingProbe(null);
setProbeForm(DEFAULT_PROBE_FORM(selectedHost));
setProbeDialogOpen(true);
};
const openEditDialog = (p: Record<string, unknown>) => {
setEditingProbe(p);
setProbeForm({
name: String(p.name || ''),
hostname: String(p.hostname || ''),
probe_type: String(p.probe_type || 'http_get'),
http_url: String(p.http_url || ''),
http_method: String(p.http_method || 'GET'),
http_expect_code: Number(p.http_expect_code) || 200,
http_expect_body: String(p.http_expect_body || ''),
tcp_host: String(p.tcp_host || ''),
tcp_port: Number(p.tcp_port) || 3306,
exec_command: String(p.exec_command || ''),
timeout: Number(p.timeout) || 10,
interval: Number(p.interval) || 60,
description: String(p.description || ''),
});
setProbeDialogOpen(true);
};
const handleSaveProbe = async () => {
if (!probeForm.name || !probeForm.hostname) return;
try {
if (editingProbe) {
await agentApi.updateHealthProbe(editingProbe.id as number, probeForm);
} else {
await agentApi.createHealthProbe(probeForm);
}
setProbeDialogOpen(false);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
} catch {
toast.error('保存失败');
}
};
const handleDeleteProbe = async (p: Record<string, unknown>) => {
const ok = await confirm({
title: '删除探针',
description: `确定删除探针「${p.name}」?`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
await agentApi.deleteHealthProbe(p.id as number);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
};
return (
<>
<TabsContent value="health_probes" className="mt-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">探针列表(Agent 在目标主机本地执行探测)</h3>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => refetch()}>
<RefreshCw className="w-3 h-3 mr-1" />刷新
</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={openCreateDialog}>
<Plus className="w-3 h-3 mr-1" />添加探针
</Button>
</div>
</div>
{probes.length === 0 ? (
<Card className="bg-[var(--dg-bg-secondary)] border-dashed border-[var(--dg-border)]">
<CardContent className="p-8 text-center text-xs text-[var(--dg-text-muted)]">
暂无健康探针,点击"添加探针"创建
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{probes.map((p) => (
<Card key={String(p.id)} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${p.last_status === 'down' ? 'border-red-700/50' : ''}`}>
<CardContent className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{p.last_status === 'up' ? <CheckCircle className="w-4 h-4 text-emerald-400" /> :
p.last_status === 'down' ? <XCircle className="w-4 h-4 text-red-400" /> :
<Clock className="w-4 h-4 text-slate-500" />}
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{String(p.name)}</span>
<Badge variant="outline" className="text-[10px] h-5 px-1.5 capitalize">
{p.probe_type === 'http_get' ? 'HTTP' : p.probe_type === 'tcp_socket' ? 'TCP' : 'Exec'}
</Badge>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => openEditDialog(p)}>
<Edit className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" onClick={() => handleDeleteProbe(p)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="space-y-1 text-[11px] text-[var(--dg-text-muted)]">
{p.probe_type === 'http_get' && (
<div className="truncate">URL: {String(p.http_url)}</div>
)}
{p.probe_type === 'tcp_socket' && (
<div>{String(p.tcp_host)}:{String(p.tcp_port)}</div>
)}
{p.probe_type === 'exec_command' && (
<div className="truncate font-mono">{String(p.exec_command)}</div>
)}
<div className="flex items-center gap-3">
<span>状态: <span className={p.last_status === 'up' ? 'text-emerald-400' : p.last_status === 'down' ? 'text-red-400' : 'text-slate-400'}>
{p.last_status ? String(p.last_status).toUpperCase() : '未检测'}
</span></span>
{Number(p.last_latency_ms) > 0 && <span>延迟: {String(p.last_latency_ms)}ms</span>}
{Number(p.fail_count) > 0 && <span className="text-red-400">连续失败: {String(p.fail_count)}次</span>}
{p.last_checked_at && <span className="text-[10px]">{new Date(String(p.last_checked_at)).toLocaleString('zh-CN')}</span>}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<Dialog open={probeDialogOpen} onOpenChange={setProbeDialogOpen}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingProbe ? '编辑探针' : '添加探针'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{editingProbe ? '修改健康探针配置' : '创建新的健康探针,Agent 在目标主机本地执行探测'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针名称 <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.name} onChange={e => setProbeForm({ ...probeForm, name: e.target.value })} placeholder="如:cex-agent 健康检查" />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">目标主机</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.hostname} onChange={e => setProbeForm({ ...probeForm, hostname: e.target.value })} />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针类型</label>
<select className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.probe_type} onChange={e => setProbeForm({ ...probeForm, probe_type: e.target.value })}>
<option value="http_get">HTTP GET</option>
<option value="tcp_socket">TCP Socket</option>
<option value="exec_command">Exec Command</option>
</select>
</div>
{probeForm.probe_type === 'http_get' && (
<>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">HTTP URL <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_url} onChange={e => setProbeForm({ ...probeForm, http_url: e.target.value })} placeholder="http://localhost:8080/health" /></div>
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望状态码</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_code} onChange={e => setProbeForm({ ...probeForm, http_expect_code: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望响应内容(可选)</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_body} onChange={e => setProbeForm({ ...probeForm, http_expect_body: e.target.value })} /></div>
</div>
</>
)}
{probeForm.probe_type === 'tcp_socket' && (
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 目标地址</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_host} onChange={e => setProbeForm({ ...probeForm, tcp_host: e.target.value })} placeholder="localhost" /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 端口</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_port} onChange={e => setProbeForm({ ...probeForm, tcp_port: Number(e.target.value) })} /></div>
</div>
)}
{probeForm.probe_type === 'exec_command' && (
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">执行命令 <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs font-mono text-[var(--dg-text-primary)]" value={probeForm.exec_command} onChange={e => setProbeForm({ ...probeForm, exec_command: e.target.value })} placeholder="pgrep -x mysqld" /></div>
)}
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">超时(秒)</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.timeout} onChange={e => setProbeForm({ ...probeForm, timeout: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">间隔(秒)</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.interval} onChange={e => setProbeForm({ ...probeForm, interval: Number(e.target.value) })} /></div>
</div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">描述</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.description} onChange={e => setProbeForm({ ...probeForm, description: e.target.value })} /></div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setProbeDialogOpen(false)}>取消</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={handleSaveProbe} disabled={!probeForm.name || !probeForm.hostname}>
{editingProbe ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,63 @@
// 指标图表卡片 // Logan.Li
import type { ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Brush,
} from 'recharts';
import type { AggType, BucketPoint } from './types';
interface MetricChartCardProps {
title: string;
value: string;
icon: ReactNode;
data: BucketPoint[];
color: string;
unit: string;
domain: [number, number | 'auto'];
aggType: AggType;
}
export function MetricChartCard({
title, value, icon, data, color, unit, domain, aggType,
}: MetricChartCardProps) {
const chartData = data.map(d => ({ ...d, value: d[aggType] }));
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">{icon}<span className="text-xs text-[var(--dg-text-primary)] font-medium truncate">{title}</span></div>
</div>
<div className="mb-2">
<span className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums">{value}</span>
{unit && <span className="text-xs text-[var(--dg-text-muted)] ml-1">{unit}</span>}
</div>
<div className="h-48">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
<defs>
<linearGradient id={`grad-${title}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
<XAxis dataKey="time" hide />
<YAxis hide domain={domain[1] === 'auto' ? [0, (max: number) => max * 1.1] : domain as [number, number]} />
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, title.replace(/\(.+\)/, '')]}
labelFormatter={(label) => `时间: ${label}`} />
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={1.5} fill={`url(#grad-${title})`} />
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,98 @@
// 端口策略对话框 // Logan.Li
import { Button } from '@/components/ui/button';
import { parsePortList } from '@/utils/validate';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
interface PortPolicyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
portPolicyMode: string;
onModeChange: (mode: string) => void;
portWhitelist: number[];
onWhitelistChange: (ports: number[]) => void;
onSave: () => void;
saving: boolean;
}
export function PortPolicyDialog({
open,
onOpenChange,
portPolicyMode,
onModeChange,
portWhitelist,
onWhitelistChange,
onSave,
saving,
}: PortPolicyDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">端口监控策略</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">配置 Agent 端口上报过滤规则</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">过滤模式</label>
<div className="flex gap-2">
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'whitelist' ? 'bg-blue-500/20 border-blue-500 text-blue-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => onModeChange('whitelist')}
>
白名单 ─ 全量上报
</button>
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'blacklist' ? 'bg-amber-500/20 border-amber-500 text-amber-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => onModeChange('blacklist')}
>
黑名单 ─ 仅上报选中
</button>
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1.5 leading-relaxed">
{portPolicyMode === 'whitelist'
? '所有发现的端口都上报,任意端口变化都会触发告警 + 恢复通知。'
: '仅上报选中端口的变化,其余端口变化静默忽略(不告警)。系统进程端口(sshd/rpcbind/systemd等)永远忽略。'}
</p>
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">
端口列表 <span className="text-[var(--dg-text-muted)]">(逗号分隔,如: 80,443,3306,6379)</span>
{portPolicyMode === 'whitelist' && <span className="text-blue-400 ml-1">— 白名单模式无需配置</span>}
</label>
<textarea
className={`w-full h-20 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs font-mono resize-none ${
portPolicyMode === 'whitelist' ? 'text-[var(--dg-text-muted)] opacity-50 cursor-not-allowed' : 'text-[var(--dg-text-primary)]'
}`}
value={portWhitelist.join(',')}
onChange={e => {
if (portPolicyMode === 'whitelist') return;
const val = e.target.value;
onWhitelistChange(parsePortList(val));
}}
placeholder={portPolicyMode === 'whitelist' ? '白名单模式下所有端口自动上报' : '80,443,3306,6379'}
readOnly={portPolicyMode === 'whitelist'}
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
已输入 {portWhitelist.length} 个端口
</p>
</div>
<div className="bg-amber-500/5 border border-amber-500/20 rounded p-2.5">
<p className="text-[10px] text-amber-300/80 leading-relaxed">
⚠️ 无论选择哪种模式,以下系统进程端口<b>永远不上报</b>:sshd、rpcbind、master、systemd、dbus-daemon、rsyslogd 等。Agent 采集时自动跳过。
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => onOpenChange(false)}>取消</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={onSave} disabled={saving}>
{saving ? '保存中…' : '保存策略'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,87 @@
// 系统信息 Tab 内容 // Logan.Li
import { Activity, HardDrive } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { TabsContent } from '@/components/ui/tabs';
import { parseJSON } from './utils';
import type { JsonData } from '@/types/common';
interface SystemInfoSectionProps {
selectedHost: string;
serverInfo?: JsonData;
latest?: JsonData;
heartbeatInfo?: JsonData;
portInfoCount: number;
}
export function SystemInfoSection({
selectedHost, serverInfo, latest, heartbeatInfo, portInfoCount,
}: SystemInfoSectionProps) {
const disk = latest?.disk_info ? parseJSON(latest.disk_info) : null;
const parts = disk ? (Array.isArray(disk) ? disk : (disk.partitions || [])) : [];
return (
<TabsContent value="system" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-xs">
{[
['主机名', serverInfo?.hostname || selectedHost],
['IP 地址', serverInfo?.ip || '—'],
['操作系统', serverInfo?.os || '—'],
['内核版本', serverInfo?.kernel || '—'],
['CPU 核心数', serverInfo?.cpu_cores || '—'],
['总内存', serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'],
['已用内存', serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'],
['运行时长', serverInfo?.uptime ? `${Math.floor(serverInfo.uptime / 86400)}天 ${Math.floor((serverInfo.uptime % 86400) / 3600)}时` : '—'],
['Agent 版本', heartbeatInfo?.version || serverInfo?.agent_version || '—'],
['Agent 序列号', heartbeatInfo?.report_seq != null ? String(heartbeatInfo.report_seq) : '—'],
['监听端口数', portInfoCount > 0 ? `${portInfoCount}` : '—'],
['Agent RSS', heartbeatInfo?.self_rss_mb ? `${heartbeatInfo.self_rss_mb.toFixed(1)} MB` : '—'],
].map(([label, val]) => (
<div key={label as string}>
<span className="text-[var(--dg-text-muted)]">{label}:</span>
<span className="text-[var(--dg-text-primary)] font-mono">{val}</span>
</div>
))}
</div>
</CardContent>
</Card>
{latest && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">系统负载 (Load Average)</h3>
</div>
<div className="flex items-center gap-6 text-xs">
<div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-slate-200">{latest.load1 ?? '--'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">5min </span><span className="text-sm font-bold text-slate-200">{latest.load5 ?? '--'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">15min </span><span className="text-sm font-bold text-slate-200">{latest.load15 ?? '--'}</span></div>
</div>
</CardContent>
</Card>
)}
{parts.length > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-2 flex items-center gap-2"><HardDrive className="h-4 w-4" />磁盘分区</h3>
<div className="space-y-1.5">
{parts.map((p: JsonData, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="text-[var(--dg-text-muted)] w-24 font-mono truncate">{p.mount || p.mountpoint || p.device || `disk${i}`}</span>
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.min(p.usage_percent || p.used_percent || 0, 100)}%` }} />
</div>
<span className="text-[var(--dg-text-primary)] w-28 text-right tabular-nums whitespace-nowrap">{(p.used_gb ?? 0).toFixed(1)}/{(p.total_gb ?? 0).toFixed(0)}GB</span>
<span className="text-blue-400 w-14 text-right tabular-nums whitespace-nowrap">{(p.usage_percent ?? p.used_percent ?? 0).toFixed(1)}%</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
);
}
@@ -0,0 +1,57 @@
// 主机监控类型定义 // Logan.Li
export const TIME_RANGES = [
{ label: '1小时', hours: 1, window: 60, buckets: 60 },
{ label: '6小时', hours: 6, window: 300, buckets: 72 },
{ label: '12小时', hours: 12, window: 600, buckets: 72 },
{ label: '1天', hours: 24, window: 1800, buckets: 96 },
{ label: '3天', hours: 72, window: 3600, buckets: 72 },
{ label: '7天', hours: 168, window: 10800, buckets: 56 },
{ label: '14天', hours: 336, window: 21600, buckets: 48 },
] as const;
export type AggType = 'avg' | 'max' | 'min';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HostRecord = Record<string, unknown>;
export interface BucketPoint {
time: string;
timestamp: number;
avg: number;
max: number;
min: number;
count: number;
}
export interface ProbeForm {
name: string;
hostname: string;
probe_type: string;
http_url: string;
http_method: string;
http_expect_code: number;
http_expect_body: string;
tcp_host: string;
tcp_port: number;
exec_command: string;
timeout: number;
interval: number;
description: string;
}
export const DEFAULT_PROBE_FORM = (hostname = ''): ProbeForm => ({
name: '',
hostname,
probe_type: 'http_get',
http_url: '',
http_method: 'GET',
http_expect_code: 200,
http_expect_body: '',
tcp_host: 'localhost',
tcp_port: 3306,
exec_command: '',
timeout: 10,
interval: 60,
description: '',
});
@@ -0,0 +1,138 @@
// 主机监控工具函数 // Logan.Li
import type { BucketPoint } from './types';
import type { JsonData } from '@/types/common';
export function bucketWithStats(raw: number[], timestamps: number[], targetBuckets: number): BucketPoint[] {
if (!raw || raw.length === 0) return [];
const perBucket = Math.max(1, Math.ceil(raw.length / targetBuckets));
const result: BucketPoint[] = [];
for (let i = 0; i < raw.length; i += perBucket) {
const slice = raw.slice(i, i + perBucket);
const tsSlice = timestamps.slice(i, i + perBucket);
const sum = slice.reduce((a, b) => a + b, 0);
const avg = sum / slice.length;
const max = Math.max(...slice);
const min = Math.min(...slice);
const ts = tsSlice[Math.floor(tsSlice.length / 2)] || 0;
result.push({
time: new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
timestamp: ts,
avg: Number(avg.toFixed(2)),
max: Number(max.toFixed(2)),
min: Number(min.toFixed(2)),
count: slice.length,
});
}
return result.slice(-targetBuckets);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseJSON(raw: unknown): JsonData {
if (!raw) return {};
if (typeof raw === 'object') return raw;
try { return JSON.parse(raw as string); } catch { return {}; }
}
export function formatBPS(bps: number): string {
if (bps >= 1024 * 1024 * 1024) return `${(bps / 1024 / 1024 / 1024).toFixed(1)} GB/s`;
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${bps.toFixed(1)} B/s`;
}
interface TimeConfig { buckets: number; }
export function buildMetricsFromHistory(history: JsonData[], timeConfig: TimeConfig) {
const rawCpu: number[] = []; const rawMem: number[] = []; const rawLoad1: number[] = [];
const rawDisk: number[] = []; const rawTcp: number[] = []; const rawTw: number[] = []; const rawCw: number[] = [];
const rawRx: number[] = []; const rawTx: number[] = [];
const rawIoR: number[] = []; const rawIoW: number[] = [];
const rawSwap: number[] = []; const rawFD: number[] = [];
const rawIOWait: number[] = []; const rawMemAvail: number[] = [];
const rawIoUtil: number[] = []; const rawConnTotal: number[] = [];
const rawRxErr: number[] = []; const rawTxErr: number[] = [];
const rawInode: number[] = [];
const timestamps: number[] = [];
for (const r of history) {
const ts = r.report_time ? new Date(r.report_time).getTime() / 1000 : (r.created_at ? new Date(r.created_at).getTime() / 1000 : 0);
timestamps.push(ts);
rawCpu.push(r.cpu_usage || 0);
rawIOWait.push(r.cpu_iowait || 0);
rawMem.push(r.mem_usage || 0);
rawMemAvail.push(r.mem_available || 0);
rawLoad1.push(r.load1 || 0);
rawDisk.push(r.disk_usage || 0);
const diskInfoArr = parseJSON(r.disk_info);
if (Array.isArray(diskInfoArr) && diskInfoArr.length > 0) {
const rootDisk = diskInfoArr.find((d: JsonData) => d.mount === '/') || diskInfoArr[0];
rawInode.push(rootDisk?.inode_usage_pct || 0);
} else {
rawInode.push(0);
}
const net = parseJSON(r.net_info);
rawTcp.push(net.tcp_established || 0);
rawTw.push(net.tcp_time_wait || 0);
rawCw.push(net.tcp_close_wait || 0);
rawConnTotal.push((net.tcp_established || 0) + (net.tcp_time_wait || 0) + (net.tcp_close_wait || 0));
rawRx.push(net.net_rx_bps || 0);
rawTx.push(net.net_tx_bps || 0);
rawRxErr.push(net.net_rx_errors || 0);
rawTxErr.push(net.net_tx_errors || 0);
const dio = parseJSON(r.disk_io_info);
rawIoR.push(dio.read_bps || 0);
rawIoW.push(dio.write_bps || 0);
rawIoUtil.push(dio.util || 0);
const swap = parseJSON(r.swap_info);
rawSwap.push(swap?.usage_percent || 0);
const fd = parseJSON(r.fd_info);
rawFD.push(fd?.usage_pct || 0);
}
return {
cpu: bucketWithStats(rawCpu, timestamps, timeConfig.buckets),
mem: bucketWithStats(rawMem, timestamps, timeConfig.buckets),
load1: bucketWithStats(rawLoad1, timestamps, timeConfig.buckets),
disk: bucketWithStats(rawDisk, timestamps, timeConfig.buckets),
tcpEst: bucketWithStats(rawTcp, timestamps, timeConfig.buckets),
tcpTW: bucketWithStats(rawTw, timestamps, timeConfig.buckets),
tcpCW: bucketWithStats(rawCw, timestamps, timeConfig.buckets),
rx: bucketWithStats(rawRx.map(v => v / 1024), timestamps, timeConfig.buckets),
tx: bucketWithStats(rawTx.map(v => v / 1024), timestamps, timeConfig.buckets),
ioRead: bucketWithStats(rawIoR.map(v => v / 1024), timestamps, timeConfig.buckets),
ioWrite: bucketWithStats(rawIoW.map(v => v / 1024), timestamps, timeConfig.buckets),
swap: bucketWithStats(rawSwap, timestamps, timeConfig.buckets),
fd: bucketWithStats(rawFD, timestamps, timeConfig.buckets),
iowait: bucketWithStats(rawIOWait, timestamps, timeConfig.buckets),
memAvail: bucketWithStats(rawMemAvail, timestamps, timeConfig.buckets),
ioUtil: bucketWithStats(rawIoUtil, timestamps, timeConfig.buckets),
connTotal: bucketWithStats(rawConnTotal, timestamps, timeConfig.buckets),
rxErr: bucketWithStats(rawRxErr, timestamps, timeConfig.buckets),
txErr: bucketWithStats(rawTxErr, timestamps, timeConfig.buckets),
inode: bucketWithStats(rawInode, timestamps, timeConfig.buckets),
};
}
export function getCurrentVal(latest: JsonData, netInfo: JsonData, diskIOInfo: JsonData, swapInfo: JsonData, fdInfo: JsonData,
key: string, fallback = '--',
): string {
if (!latest) return fallback;
if (key === 'cpu') return latest.cpu_usage != null ? `${latest.cpu_usage.toFixed(1)}%` : fallback;
if (key === 'mem') return latest.mem_usage != null ? `${latest.mem_usage.toFixed(1)}%` : fallback;
if (key === 'disk') return latest.disk_usage != null ? `${latest.disk_usage.toFixed(1)}%` : fallback;
if (key === 'load1') return latest.load1 != null ? String(latest.load1) : fallback;
if (key === 'tcp') return netInfo.tcp_established != null ? String(netInfo.tcp_established) : fallback;
if (key === 'tw') return netInfo.tcp_time_wait != null ? String(netInfo.tcp_time_wait) : fallback;
if (key === 'rx') return netInfo.net_rx_bps != null ? `${(netInfo.net_rx_bps / 1024).toFixed(1)} KB/s` : fallback;
if (key === 'tx') return netInfo.net_tx_bps != null ? `${(netInfo.net_tx_bps / 1024).toFixed(1)} KB/s` : fallback;
if (key === 'ioR') return diskIOInfo.read_bps != null ? `${formatBPS(diskIOInfo.read_bps)}` : fallback;
if (key === 'ioW') return diskIOInfo.write_bps != null ? `${formatBPS(diskIOInfo.write_bps)}` : fallback;
if (key === 'swap') return swapInfo?.usage_percent != null ? `${swapInfo.usage_percent.toFixed(1)}%` : fallback;
if (key === 'fd') return fdInfo?.usage_pct != null ? `${fdInfo.usage_pct.toFixed(1)}%` : fallback;
return fallback;
}