refactor(frontend): API 子模块化、大组件拆分与 TanStack Query hooks
P1: kubernetes/system API 拆为子目录模块,导出工具下沉到 lib/export-utils,
http 统一 buildApiUrl/getWsBase;HostMonitor、DeploymentEdit* 拆至子组件。
P2: 新增 hooks/api(queryKeys、useTerminalSessions、useHostMonitor),
TerminalSessionsTab 与 HostMonitor 接入 TanStack Query。
P3: 保留 services/api 路径,未迁移至 api/ 目录。
This commit is contained in:
1 parent
7fb2236b4d
commit
0b404b4611
226 files changed
+3040
-2596
No files matched your search
@@ -1,127 +1,62 @@
|
||||
// 主机监控详情 — 阿里云云监控风格 + v2.0 新增指标
|
||||
// 进程TOP / Swap / FD / 上下文切换
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
// 进程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 '@/services/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 {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Cpu, HardDrive, Network, Activity, Server, Loader2,
|
||||
Info, Monitor, Disc, Clock, List, HeartPulse, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
|
||||
Info, Monitor, Disc, Clock, List, HeartPulse, RefreshCw, XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Brush,
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// 时间范围配置
|
||||
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;
|
||||
|
||||
type AggType = 'avg' | 'max' | 'min';
|
||||
|
||||
interface BucketPoint {
|
||||
time: string; timestamp: number; avg: number; max: number; min: number; count: number;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function parseJSON( raw: any): any {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === 'object') return raw;
|
||||
try { return JSON.parse(raw); } catch { return {}; }
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
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';
|
||||
|
||||
export function HostMonitor() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const urlHost = searchParams.get('host') || '';
|
||||
const [hosts, setHosts] = useState<any[]>([]);
|
||||
const [selectedHost, setSelectedHost] = useState(urlHost);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [rangeHours, setRangeHours] = useState(1);
|
||||
const [aggType, setAggType] = useState<AggType>('avg');
|
||||
|
||||
const [history, setHistory] = useState<any[]>([]);
|
||||
const [latest, setLatest] = useState<any>(null);
|
||||
const [serverInfo, setServerInfo] = useState<any>(null);
|
||||
|
||||
// 健康探针
|
||||
const [probes, setProbes] = useState<any[]>([]);
|
||||
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
|
||||
const [editingProbe, setEditingProbe] = useState<any>(null);
|
||||
const [probeForm, setProbeForm] = useState({
|
||||
name: '', hostname: '', probe_type: 'http_get', http_url: '', http_method: 'GET',
|
||||
http_expect_code: 200, http_expect_body: '',
|
||||
tcp_host: '', tcp_port: 3306, exec_command: '', timeout: 10, interval: 60, description: '',
|
||||
});
|
||||
|
||||
// 加载探针
|
||||
|
||||
const fetchProbes = useCallback(() => {
|
||||
if (!selectedHost) return;
|
||||
agentApi.getHealthProbes({ hostname: selectedHost }).then(( data: any) => {
|
||||
if (Array.isArray(data)) setProbes(data);
|
||||
else if (data?.list) setProbes(data.list);
|
||||
else setProbes([]);
|
||||
}).catch(() => setProbes([]));
|
||||
}, [selectedHost]);
|
||||
|
||||
// 端口策略 // Logan.Li
|
||||
const [portPolicyMode, setPortPolicyMode] = useState('whitelist');
|
||||
const [portWhitelist, setPortWhitelist] = useState<number[]>([]);
|
||||
const [portPolicyDialogOpen, setPortPolicyDialogOpen] = useState(false);
|
||||
const [portPolicySaving, setPortPolicySaving] = useState(false);
|
||||
|
||||
const fetchPortPolicy = useCallback(() => {
|
||||
if (!selectedHost) return;
|
||||
agentApi.getPortPolicy(selectedHost).then(( data: any) => {
|
||||
if (data?.data) {
|
||||
setPortPolicyMode(data.data.port_filter_mode || 'blacklist');
|
||||
setPortWhitelist(data.data.port_whitelist || []);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [selectedHost]);
|
||||
const { data: hosts = [], isLoading: hostsLoading } = useHostList();
|
||||
const { data: history = [], isLoading: historyLoading } = useHostHistory(selectedHost, rangeHours);
|
||||
const { data: portPolicy, refetch: refetchPortPolicy } = usePortPolicy(selectedHost);
|
||||
|
||||
useEffect(() => { fetchPortPolicy(); }, [fetchPortPolicy]);
|
||||
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;
|
||||
@@ -132,121 +67,20 @@ export function HostMonitor() {
|
||||
port_whitelist: portWhitelist,
|
||||
});
|
||||
setPortPolicyDialogOpen(false);
|
||||
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.portPolicy(selectedHost) });
|
||||
} catch { } finally { setPortPolicySaving(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProbes();
|
||||
}, [fetchProbes]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
agentApi.getLatest().then(( data: any) => {
|
||||
const list = Array.isArray(data) ? data : (data?.data || data?.list || []);
|
||||
setHosts(list);
|
||||
if (list.length > 0 && !selectedHost) {
|
||||
const m = urlHost ? list.find(( h: any) => h.hostname === urlHost) : null;
|
||||
setSelectedHost(m?.hostname || list[0]?.hostname || '');
|
||||
}
|
||||
const cur = list.find(( h: any) => h.hostname === (selectedHost || urlHost));
|
||||
if (cur) { setLatest(cur); setServerInfo(cur); }
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
}, [selectedHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedHost) return;
|
||||
agentApi.getHistory(selectedHost, rangeHours)
|
||||
.then(( data: any) => {
|
||||
const list = Array.isArray(data) ? data : (data?.data || data?.list || []);
|
||||
setHistory(list);
|
||||
if (list.length > 0) {
|
||||
const fresh = list[list.length - 1];
|
||||
setLatest(( prev: any) => ({ ...prev, ...fresh }));
|
||||
}
|
||||
})
|
||||
.catch(() => setHistory([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedHost, rangeHours]);
|
||||
|
||||
// 历史指标提取
|
||||
const metrics = useMemo(() => {
|
||||
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[] = [];
|
||||
// ──── v2.2 新增指标 ────
|
||||
const rawIOWait: number[] = []; const rawMemAvail: number[] = [];
|
||||
const rawIoUtil: number[] = []; const rawConnTotal: number[] = [];
|
||||
const rawRxErr: number[] = []; const rawTxErr: number[] = [];
|
||||
const rawInode: number[] = []; // 根分区 inode
|
||||
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: any) => 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),
|
||||
// ──── v2.2 新增指标 ────
|
||||
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),
|
||||
};
|
||||
}, [history, timeConfig]);
|
||||
const metrics = useMemo(() => buildMetricsFromHistory(history, timeConfig), [history, timeConfig]);
|
||||
|
||||
const netInfo = parseJSON(latest?.net_info);
|
||||
const portInfo: Record<string, unknown>[] = Array.isArray(latest?.port_info) ? latest.port_info : (Array.isArray(parseJSON(latest?.port_info)) ? parseJSON(latest?.port_info) : []);
|
||||
@@ -257,22 +91,8 @@ export function HostMonitor() {
|
||||
const csInfo = parseJSON(latest?.cs_info);
|
||||
const heartbeatInfo = parseJSON(latest?.heartbeat_info);
|
||||
|
||||
const currentVal = (key: string, fallback = '--') => {
|
||||
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;
|
||||
};
|
||||
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>;
|
||||
@@ -280,7 +100,6 @@ export function HostMonitor() {
|
||||
|
||||
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" />
|
||||
@@ -289,7 +108,7 @@ export function HostMonitor() {
|
||||
<SelectValue placeholder="选择主机..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
{hosts.map(( h: any) => (
|
||||
{hosts.map((h: any) => (
|
||||
<SelectItem key={h.hostname} value={h.hostname}>{h.hostname}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -320,7 +139,6 @@ export function HostMonitor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类 Tab */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="h-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] gap-0 overflow-x-auto">
|
||||
{[
|
||||
@@ -338,7 +156,6 @@ export function HostMonitor() {
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* 基础监控 — 增加 Swap 卡片 */}
|
||||
<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} />
|
||||
@@ -354,14 +171,13 @@ export function HostMonitor() {
|
||||
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: any) => d.mount === '/') || dArr[0];
|
||||
const root = dArr.find((d: any) => 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} />
|
||||
@@ -374,7 +190,6 @@ export function HostMonitor() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 网络监控 — TCP连接 + 带宽 + 错误 */}
|
||||
<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" />}
|
||||
@@ -386,7 +201,6 @@ export function HostMonitor() {
|
||||
<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>
|
||||
{/* 网卡带宽吞吐 — 双线 rx/tx */}
|
||||
<DualLineChart
|
||||
title="网卡带宽吞吐"
|
||||
currentRx={netInfo?.net_rx_bps != null ? formatBPS(netInfo.net_rx_bps) : '--'}
|
||||
@@ -402,7 +216,6 @@ export function HostMonitor() {
|
||||
</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">
|
||||
@@ -420,7 +233,7 @@ export function HostMonitor() {
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-6 text-[10px] px-2"
|
||||
onClick={() => { fetchPortPolicy(); setPortPolicyDialogOpen(true); }}>
|
||||
onClick={() => { refetchPortPolicy(); setPortPolicyDialogOpen(true); }}>
|
||||
策略配置
|
||||
</Button>
|
||||
</span>
|
||||
@@ -441,7 +254,7 @@ export function HostMonitor() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portInfo.map(( p: any, i: number) => (
|
||||
{portInfo.map((p: any, 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">
|
||||
@@ -465,9 +278,7 @@ export function HostMonitor() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══ 进程监控 — 进程 TOP + FD 趋势 + 上下文切换 ═══ */}
|
||||
<TabsContent value="process" className="mt-3 space-y-3">
|
||||
{/* ── 进程 TOP 表格 ── */}
|
||||
<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">
|
||||
@@ -490,7 +301,7 @@ export function HostMonitor() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processTop.map(( p: any, i: number) => {
|
||||
{processTop.map((p: any, 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;
|
||||
@@ -498,10 +309,7 @@ export function HostMonitor() {
|
||||
<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}
|
||||
>
|
||||
<span className="font-mono text-[var(--dg-text-primary)] truncate max-w-[320px] inline-block align-middle" title={cmd}>
|
||||
{cmd}
|
||||
</span>
|
||||
<button
|
||||
@@ -512,7 +320,6 @@ export function HostMonitor() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = cmd; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
@@ -549,9 +356,7 @@ export function HostMonitor() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── FD 文件句柄 + 上下文切换 可视化 ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* FD 卡片 + 趋势图 */}
|
||||
<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">
|
||||
@@ -601,7 +406,6 @@ export function HostMonitor() {
|
||||
</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">
|
||||
@@ -625,7 +429,6 @@ export function HostMonitor() {
|
||||
<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>
|
||||
@@ -651,444 +454,27 @@ export function HostMonitor() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 系统信息 */}
|
||||
<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) : '—'],
|
||||
['监听端口数', portInfo.length > 0 ? `${portInfo.length}` : '—'],
|
||||
['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>
|
||||
<SystemInfoSection
|
||||
selectedHost={selectedHost}
|
||||
serverInfo={serverInfo}
|
||||
latest={latest}
|
||||
heartbeatInfo={heartbeatInfo}
|
||||
portInfoCount={portInfo.length}
|
||||
/>
|
||||
|
||||
{/* Load Average */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 磁盘分区 + 网络接口 (保持原样) */}
|
||||
{latest?.disk_info && (() => {
|
||||
const disk = parseJSON(latest.disk_info);
|
||||
const parts = Array.isArray(disk) ? disk : (disk.partitions || []);
|
||||
if (!parts.length) return null;
|
||||
return (
|
||||
<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: any, 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>
|
||||
|
||||
{/* 健康探针 — Agent 本地执行 + 结果上报 */}
|
||||
<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={fetchProbes}>
|
||||
<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={() => {
|
||||
setEditingProbe(null);
|
||||
setProbeForm({
|
||||
name: '', hostname: selectedHost, 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: '',
|
||||
});
|
||||
setProbeDialogOpen(true);
|
||||
}}>
|
||||
<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: any) => (
|
||||
<Card key={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)]">{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={() => {
|
||||
setEditingProbe(p);
|
||||
setProbeForm({
|
||||
name: p.name, hostname: p.hostname, probe_type: p.probe_type,
|
||||
http_url: p.http_url || '', http_method: p.http_method || 'GET',
|
||||
http_expect_code: p.http_expect_code || 200, http_expect_body: p.http_expect_body || '',
|
||||
tcp_host: p.tcp_host || '', tcp_port: p.tcp_port || 3306,
|
||||
exec_command: p.exec_command || '', timeout: p.timeout || 10,
|
||||
interval: p.interval || 60, description: p.description || '',
|
||||
});
|
||||
setProbeDialogOpen(true);
|
||||
}}>
|
||||
<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={async () => {
|
||||
if (confirm(`确定删除探针"${p.name}"?`)) {
|
||||
await agentApi.deleteHealthProbe(p.id);
|
||||
fetchProbes();
|
||||
}
|
||||
}}>
|
||||
<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: {p.http_url}</div>
|
||||
)}
|
||||
{p.probe_type === 'tcp_socket' && (
|
||||
<div>{p.tcp_host}:{p.tcp_port}</div>
|
||||
)}
|
||||
{p.probe_type === 'exec_command' && (
|
||||
<div className="truncate font-mono">{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 ? p.last_status.toUpperCase() : '未检测'}
|
||||
</span></span>
|
||||
{p.last_latency_ms > 0 && <span>延迟: {p.last_latency_ms}ms</span>}
|
||||
{p.fail_count > 0 && <span className="text-red-400">连续失败: {p.fail_count}次</span>}
|
||||
{p.last_checked_at && <span className="text-[10px]">{new Date(p.last_checked_at).toLocaleString('zh-CN')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<HealthProbeSection selectedHost={selectedHost} />
|
||||
</Tabs>
|
||||
|
||||
{/* 添加/编辑探针对话框 */}
|
||||
<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={async () => {
|
||||
if (!probeForm.name || !probeForm.hostname) return;
|
||||
try {
|
||||
if (editingProbe) {
|
||||
await agentApi.updateHealthProbe(editingProbe.id, probeForm);
|
||||
} else {
|
||||
await agentApi.createHealthProbe(probeForm);
|
||||
}
|
||||
setProbeDialogOpen(false);
|
||||
fetchProbes();
|
||||
} catch { alert('保存失败'); }
|
||||
}} disabled={!probeForm.name || !probeForm.hostname}>
|
||||
{editingProbe ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 端口策略对话框 */}
|
||||
<Dialog open={portPolicyDialogOpen} onOpenChange={setPortPolicyDialogOpen}>
|
||||
<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={() => setPortPolicyMode('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={() => setPortPolicyMode('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;
|
||||
const nums = val.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0 && n <= 65535);
|
||||
setPortWhitelist(nums);
|
||||
}}
|
||||
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={() => setPortPolicyDialogOpen(false)}>取消</Button>
|
||||
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={handleSavePortPolicy} disabled={portPolicySaving}>
|
||||
{portPolicySaving ? '保存中…' : '保存策略'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PortPolicyDialog
|
||||
open={portPolicyDialogOpen}
|
||||
onOpenChange={setPortPolicyDialogOpen}
|
||||
portPolicyMode={portPolicyMode}
|
||||
onModeChange={setPortPolicyMode}
|
||||
portWhitelist={portWhitelist}
|
||||
onWhitelistChange={setPortWhitelist}
|
||||
onSave={handleSavePortPolicy}
|
||||
saving={portPolicySaving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════ 指标图表卡片 ═══════
|
||||
function MetricChartCard({
|
||||
title, value, icon, data, color, unit, domain, aggType,
|
||||
}: {
|
||||
title: string; value: string; icon: React.ReactNode;
|
||||
data: BucketPoint[]; color: string; unit: string;
|
||||
domain: [number, number | 'auto']; aggType: AggType;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════ 双线图表(网卡带宽 rx+tx)═══════
|
||||
function DualLineChart({
|
||||
title, currentRx, currentTx, rxData, txData, aggType, unit,
|
||||
}: {
|
||||
title: string; currentRx: string; currentTx: string;
|
||||
rxData: BucketPoint[]; txData: BucketPoint[];
|
||||
aggType: AggType; unit: string;
|
||||
}) {
|
||||
// 合并 rx/tx 为统一时间序列
|
||||
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,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,223 @@
|
||||
// 健康探针 Tab + 对话框 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/services/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';
|
||||
|
||||
interface HealthProbeSectionProps {
|
||||
selectedHost: string;
|
||||
}
|
||||
|
||||
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
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 {
|
||||
alert('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProbe = async (p: Record<string, unknown>) => {
|
||||
if (confirm(`确定删除探针"${p.name}"?`)) {
|
||||
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 {
|
||||
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;
|
||||
const nums = val.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0 && n <= 65535);
|
||||
onWhitelistChange(nums);
|
||||
}}
|
||||
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,86 @@
|
||||
// 系统信息 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';
|
||||
|
||||
interface SystemInfoSectionProps {
|
||||
selectedHost: string;
|
||||
serverInfo: any;
|
||||
latest: any;
|
||||
heartbeatInfo: any;
|
||||
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: any, 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, any>;
|
||||
|
||||
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';
|
||||
|
||||
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): any {
|
||||
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: any[], 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: any) => 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: any, netInfo: any, diskIOInfo: any, swapInfo: any, fdInfo: any,
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user