feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分
告警中心 - 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog) - 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则 - 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp) 监控采集 - SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect) - 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed) - 站点探测 pkg(siteprobe) + 站点视图(site_view) 自动化任务 - cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task) 审计与凭证 - 审计设置(audit_setting) + 历史清理(auditcleaner) 前端 - 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化 - 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染 - 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
092d0e4b9e
commit
471353e3d7
190 files changed
+16640
-3484
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// 主机监控详情 — 阿里云云监控风格 + v2.0 新增指标
|
||||
// 进程TOP / Swap / FD / 上下文切换 // Logan.Li
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/api';
|
||||
@@ -17,16 +17,19 @@ import {
|
||||
Cpu, HardDrive, Network, Activity, Server,
|
||||
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 { ZoomableAreaChart, type ZoomableChartHandle } from './host-monitor/ZoomableAreaChart';
|
||||
import { ChartToolbar } from './host-monitor/ChartToolbar';
|
||||
import { MetricIconBox } from './host-monitor/MetricIconBox';
|
||||
import { DEFAULT_TIME_RANGE, type AggType, type HostRecord, type HostMonitorTimeRange } from './host-monitor/types';
|
||||
import { HostTimeRangePicker } from './host-monitor/HostTimeRangePicker';
|
||||
import { getRangeSpanMinutes } from './host-monitor/timeRange';
|
||||
import { parseJSON, formatBPS, buildMetricsFromHistory, getCurrentVal, filterHistoryByRange, getActualDataSpan } 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 { HostCoreMetricsStrip } from './host-monitor/HostCoreMetricsStrip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
@@ -35,16 +38,18 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
const urlHost = searchParams.get('host') || '';
|
||||
const [selectedHost, setSelectedHost] = useState(urlHost);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [rangeHours, setRangeHours] = useState(1);
|
||||
const [timeRange, setTimeRange] = useState<HostMonitorTimeRange>(DEFAULT_TIME_RANGE);
|
||||
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 fdChartRef = useRef<ZoomableChartHandle>(null);
|
||||
const [fdChartZoomed, setFdChartZoomed] = useState(false);
|
||||
|
||||
const { data: hosts = [], isLoading: hostsLoading } = useHostList();
|
||||
const { data: history = [], isLoading: historyLoading } = useHostHistory(selectedHost, rangeHours);
|
||||
const { data: rawHistory = [], isLoading: historyLoading } = useHostHistory(selectedHost, timeRange);
|
||||
const { data: portPolicy, refetch: refetchPortPolicy } = usePortPolicy(selectedHost);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,7 +79,19 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
} catch { } finally { setPortPolicySaving(false); }
|
||||
};
|
||||
|
||||
const timeConfig = TIME_RANGES.find(tr => tr.hours === rangeHours) || TIME_RANGES[0];
|
||||
const history = useMemo(
|
||||
() => filterHistoryByRange(rawHistory, timeRange),
|
||||
[rawHistory, timeRange],
|
||||
);
|
||||
const spanMinutes = getRangeSpanMinutes(timeRange);
|
||||
const timeConfig = useMemo(
|
||||
() => ({ buckets: timeRange.buckets, spanMinutes }),
|
||||
[timeRange.buckets, spanMinutes],
|
||||
);
|
||||
const actualDataSpan = useMemo(
|
||||
() => getActualDataSpan(history, timeRange),
|
||||
[history, timeRange],
|
||||
);
|
||||
const loading = hostsLoading || historyLoading;
|
||||
const serverInfo = (hosts.find((h) => h.hostname === selectedHost) || null) as HostRecord | null;
|
||||
const latest = useMemo((): HostRecord => {
|
||||
@@ -97,6 +114,11 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
const currentVal = (key: string, fallback = '--') =>
|
||||
getCurrentVal(latest, netInfo, diskIOInfo, swapInfo, fdInfo, key, fallback);
|
||||
|
||||
const pctRefLines = [
|
||||
{ value: 80, label: '80%', color: 'var(--dg-yellow)' },
|
||||
{ value: 90, label: '90%', color: 'var(--dg-red)' },
|
||||
];
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -127,12 +149,11 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
<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 ${rangeHours === tr.hours ? 'dg-btn-primary' : 'dg-btn-secondary'}`}
|
||||
onClick={() => setRangeHours(tr.hours)}>{tr.label}</Button>
|
||||
))}
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<HostTimeRangePicker value={timeRange} onChange={setTimeRange} />
|
||||
{actualDataSpan && (
|
||||
<span className="text-[10px] dg-text-muted tabular-nums">数据 {actualDataSpan}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,63 +163,64 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
<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" /> },
|
||||
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3.5 h-3.5" /> },
|
||||
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3.5 h-3.5" /> },
|
||||
{ id: 'network', label: '网络监控', icon: <Network className="w-3.5 h-3.5" /> },
|
||||
{ id: 'ports', label: '端口监控', icon: <Server className="w-3.5 h-3.5" /> },
|
||||
{ id: 'process', label: '进程监控', icon: <List className="w-3.5 h-3.5" /> },
|
||||
{ id: 'system', label: '系统信息', icon: <Info className="w-3.5 h-3.5" /> },
|
||||
{ id: 'health_probes', label: '健康探针', icon: <HeartPulse className="w-3.5 h-3.5" /> },
|
||||
].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 key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1.5 px-3 data-[state=active]:bg-[var(--dg-bg-primary)]">
|
||||
<span className="shrink-0 opacity-80">{tab.icon}</span>
|
||||
{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 dg-text-blue" />}
|
||||
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 dg-text-orange" />}
|
||||
data={metrics.iowait} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="内存使用率(%)" value={currentVal('mem')} icon={<Server className="h-4 w-4 dg-text-green" />}
|
||||
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 dg-text-green" />}
|
||||
<MetricChartCard title="CPU 使用率(%)" value={currentVal('cpu')} icon={<Cpu />} iconTone="blue"
|
||||
data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="CPU IO等待(%)" value={latest?.cpu_iowait != null ? `${(latest.cpu_iowait as number).toFixed(1)}%` : '--'} icon={<Clock />} iconTone="orange"
|
||||
data={metrics.iowait} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="内存使用率(%)" value={currentVal('mem')} icon={<Server />} iconTone="green"
|
||||
data={metrics.mem} color="#10b981" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="可用内存(GB)" value={latest?.mem_available != null ? `${(latest.mem_available as number).toFixed(1)}GB` : '--'} icon={<Server />} iconTone="green"
|
||||
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 dg-text-red" />}
|
||||
<MetricChartCard title="Swap 使用率(%)" value={currentVal('swap')} icon={<HardDrive />} iconTone="red"
|
||||
data={metrics.swap} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="系统负载(1m)" value={currentVal('load1')} icon={<Activity className="h-4 w-4 dg-text-purple" />}
|
||||
<MetricChartCard title="系统负载(1m)" value={currentVal('load1')} icon={<Activity />} iconTone="purple"
|
||||
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 dg-text-orange" />}
|
||||
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘使用率(%)" value={currentVal('disk')} icon={<HardDrive />} iconTone="orange"
|
||||
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<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 dg-text-purple" />}
|
||||
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 dg-text-blue" />}
|
||||
})()} icon={<HardDrive />} iconTone="purple"
|
||||
data={metrics.inode} color="#8b5cf6" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
<MetricChartCard title="磁盘读取(KB/s)" value={currentVal('ioR')} icon={<Disc />} iconTone="blue"
|
||||
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 dg-text-green" />}
|
||||
<MetricChartCard title="磁盘写入(KB/s)" value={currentVal('ioW')} icon={<Disc />} iconTone="green"
|
||||
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 dg-text-red" />}
|
||||
data={metrics.ioUtil} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
|
||||
<MetricChartCard title="磁盘IO繁忙度(%)" value={diskIOInfo?.util != null ? `${diskIOInfo.util.toFixed(1)}%` : '--'} icon={<Disc />} iconTone="red"
|
||||
data={metrics.ioUtil} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} referenceLines={pctRefLines} />
|
||||
</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 dg-text-purple" />}
|
||||
<MetricChartCard title="TCP EST" value={currentVal('tcp')} icon={<Network />} iconTone="purple"
|
||||
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 dg-text-yellow" />}
|
||||
<MetricChartCard title="TCP TIME_WAIT" value={currentVal('tw')} icon={<Clock />} iconTone="yellow"
|
||||
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 dg-text-red" />}
|
||||
<MetricChartCard title="TCP CLOSE_WAIT" value={netInfo?.tcp_close_wait != null ? String(netInfo.tcp_close_wait) : '--'} icon={<Clock />} iconTone="red"
|
||||
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 dg-text-cyan" />}
|
||||
<MetricChartCard title="TCP 总连接" value={netInfo ? String((netInfo.tcp_established||0)+(netInfo.tcp_time_wait||0)+(netInfo.tcp_close_wait||0)) : '--'} icon={<Network />} iconTone="cyan"
|
||||
data={metrics.connTotal} color="#22d3ee" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
</div>
|
||||
<DualLineChart
|
||||
@@ -209,9 +231,9 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
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 dg-text-red" />}
|
||||
<MetricChartCard title="网卡接收错误" value={netInfo?.net_rx_errors != null ? String(netInfo.net_rx_errors) : '--'} icon={<XCircle />} iconTone="red"
|
||||
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 dg-text-orange" />}
|
||||
<MetricChartCard title="网卡发送错误" value={netInfo?.net_tx_errors != null ? String(netInfo.net_tx_errors) : '--'} icon={<XCircle />} iconTone="orange"
|
||||
data={metrics.txErr} color="#f97316" unit="个" domain={[0, 'auto']} aggType={aggType} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -359,9 +381,19 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
<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 dg-text-purple" />FD 文件句柄
|
||||
</h3>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] flex items-center gap-2 leading-8">
|
||||
<MetricIconBox icon={<HardDrive />} tone="purple" />
|
||||
FD 文件句柄
|
||||
</h3>
|
||||
{metrics.fd.length > 0 && (
|
||||
<ChartToolbar
|
||||
zoomed={fdChartZoomed}
|
||||
onExpand={() => fdChartRef.current?.expand()}
|
||||
onResetZoom={() => fdChartRef.current?.resetZoom()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{fdInfo?.allocated != null ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-3 text-xs">
|
||||
@@ -381,23 +413,20 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
</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>
|
||||
<ZoomableAreaChart
|
||||
ref={fdChartRef}
|
||||
data={metrics.fd.map((d) => ({ ...d, value: d[aggType] }))}
|
||||
series={[{ dataKey: 'value', color: '#a78bfa', label: 'FD 使用率' }]}
|
||||
yDomain={[0, 'auto']}
|
||||
unit="%"
|
||||
chartId="fd-usage"
|
||||
title="FD 文件句柄使用率"
|
||||
subtitle={fdInfo?.usage_pct != null ? `当前 ${fdInfo.usage_pct.toFixed(1)}%` : undefined}
|
||||
height={160}
|
||||
toolbarPlacement="header"
|
||||
onZoomedChange={setFdChartZoomed}
|
||||
showHint={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -483,6 +512,14 @@ export function HostMonitor({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{toolbar}
|
||||
{!loading && history.length > 0 && (
|
||||
<HostCoreMetricsStrip
|
||||
latest={latest}
|
||||
netInfo={netInfo}
|
||||
diskIOInfo={diskIOInfo}
|
||||
onJump={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
{loading && !history.length ? <PageLoading /> : monitorBody}
|
||||
{dialogs}
|
||||
</div>
|
||||
|
||||
@@ -1,119 +1,91 @@
|
||||
// 中间件监控 — Redis / MySQL 指标采集(后端直连模式 M15)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// 中间件监控 — Redis(单机/集群)/ MySQL 可视化可观测 // Logan.Li
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||
DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Database, Server, Activity, Loader2, Circle, Plus,
|
||||
Trash2, Pencil, Zap, RefreshCw, Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Database, Server, Activity, Loader2, Plus, Zap, RefreshCw } from 'lucide-react';
|
||||
import { middlewareMonitorApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { MwOverviewCharts } from './middleware-monitor/MwOverviewCharts';
|
||||
import { MwTargetChartsPanel } from './middleware-monitor/MwTargetChartsPanel';
|
||||
import { InstanceCard } from './middleware-monitor/InstanceCard';
|
||||
import { MiddlewareDialog } from './middleware-monitor/MiddlewareDialog';
|
||||
import type { MwTarget } from './middleware-monitor/helpers';
|
||||
import { REDIS_CHARTS, MYSQL_CHARTS, evalMwHealth, worstHealthLevel } from './middleware-monitor/helpers';
|
||||
|
||||
interface MwTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
description: string; // 子类型 redis/mysql
|
||||
latest_metrics: Record<string, number>;
|
||||
last_seen_at?: string;
|
||||
}
|
||||
type TabKey = 'redis' | 'mysql';
|
||||
const REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
interface MwListResponse {
|
||||
list: MwTarget[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
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 'dg-text-green';
|
||||
case 'warning': return 'dg-text-yellow';
|
||||
case 'error':
|
||||
case 'offline': return 'dg-text-red';
|
||||
default: return 'dg-text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
function statusBg(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-emerald-400';
|
||||
case 'warning': return 'bg-amber-400';
|
||||
case 'error':
|
||||
case 'offline': return 'bg-red-400';
|
||||
default: return 'bg-[var(--dg-bg-active)]';
|
||||
}
|
||||
}
|
||||
interface MwListResponse { list: MwTarget[]; total: number }
|
||||
|
||||
export function MiddlewareMonitor() {
|
||||
const confirm = useConfirm();
|
||||
const [targets, setTargets] = useState<MwTarget[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [tab, setTab] = useState<TabKey>('redis');
|
||||
const [selectedRedis, setSelectedRedis] = useState<MwTarget | null>(null);
|
||||
const [selectedMysql, setSelectedMysql] = useState<MwTarget | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<MwTarget | null>(null);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<Date | null>(null);
|
||||
const hasLoaded = useRef(false);
|
||||
|
||||
const loadTargets = useCallback(async () => {
|
||||
const syncSelection = useCallback((list: MwTarget[]) => {
|
||||
const redis = list.filter((t) => t.description === 'redis');
|
||||
const mysql = list.filter((t) => t.description === 'mysql');
|
||||
setSelectedRedis((prev) => {
|
||||
if (prev && redis.some((t) => t.id === prev.id)) return redis.find((t) => t.id === prev.id) || null;
|
||||
return redis.find((t) => t.status === 'online') || redis[0] || null;
|
||||
});
|
||||
setSelectedMysql((prev) => {
|
||||
if (prev && mysql.some((t) => t.id === prev.id)) return mysql.find((t) => t.id === prev.id) || null;
|
||||
return mysql.find((t) => t.status === 'online') || mysql[0] || null;
|
||||
});
|
||||
if (redis.length === 0 && mysql.length > 0) setTab('mysql');
|
||||
}, []);
|
||||
|
||||
const loadTargets = useCallback(async (silent = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!silent) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
setError('');
|
||||
const data = await middlewareMonitorApi.list() as MwListResponse;
|
||||
setTargets(data?.list || []);
|
||||
const list = data?.list || [];
|
||||
setTargets(list);
|
||||
syncSelection(list);
|
||||
setLastRefreshAt(new Date());
|
||||
hasLoaded.current = true;
|
||||
} catch (err) {
|
||||
const msg = (err as { message?: string }).message || '加载失败';
|
||||
setError(msg);
|
||||
setTargets([]);
|
||||
if (!silent) {
|
||||
setError((err as { message?: string }).message || '加载失败');
|
||||
setTargets([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
}, [syncSelection]);
|
||||
|
||||
useEffect(() => { loadTargets(); }, [loadTargets]);
|
||||
|
||||
// 按子类型分组
|
||||
const redisTargets = targets.filter(t => t.description === 'redis');
|
||||
const mysqlTargets = targets.filter(t => t.description === 'mysql');
|
||||
const otherTargets = targets.filter(t => t.description !== 'redis' && t.description !== 'mysql');
|
||||
// 30s 静默刷新,与采集周期对齐 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!hasLoaded.current || targets.length === 0) return;
|
||||
const timer = setInterval(() => loadTargets(true), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [targets.length, loadTargets]);
|
||||
|
||||
const redisTarget = redisTargets.find(t => t.status === 'online') || redisTargets[0];
|
||||
const redisMetrics = redisTarget?.latest_metrics || {};
|
||||
const mysqlTarget = mysqlTargets.find(t => t.status === 'online') || mysqlTargets[0];
|
||||
const mysqlMetrics = mysqlTarget?.latest_metrics || {};
|
||||
const redisTargets = targets.filter((t) => t.description === 'redis');
|
||||
const mysqlTargets = targets.filter((t) => t.description === 'mysql');
|
||||
const onlineCount = targets.filter((t) => t.status === 'online').length;
|
||||
const warnCount = targets.filter((t) => worstHealthLevel(evalMwHealth(t)) !== 'ok').length;
|
||||
|
||||
const handleDelete = async (target: MwTarget) => {
|
||||
const ok = await confirm({
|
||||
title: '删除中间件监控',
|
||||
description: `确认删除 "${target.name}"?相关指标历史也将清除。`,
|
||||
confirmText: '删除',
|
||||
});
|
||||
const ok = await confirm({ title: '删除监控', description: `确认删除 "${target.name}"?`, confirmText: '删除' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await middlewareMonitorApi.delete(target.id);
|
||||
@@ -128,12 +100,8 @@ export function MiddlewareMonitor() {
|
||||
setTesting(target.id);
|
||||
try {
|
||||
const res = await middlewareMonitorApi.test(target.id) as { success: boolean; message: string };
|
||||
if (res.success) {
|
||||
toast.success('连接成功', { description: res.message });
|
||||
} else {
|
||||
toast.error('连接失败', { description: res.message });
|
||||
}
|
||||
if (res.success) loadTargets();
|
||||
if (res.success) { toast.success('连接成功', { description: res.message }); loadTargets(true); }
|
||||
else toast.error('连接失败', { description: res.message });
|
||||
} catch (err) {
|
||||
toast.error('测试失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
@@ -141,450 +109,179 @@ export function MiddlewareMonitor() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeedDefaults = async () => {
|
||||
setSeeding(true);
|
||||
try {
|
||||
const res = await middlewareMonitorApi.seedDefaults() as { created: number; message: string; failed?: string[] };
|
||||
await loadTargets();
|
||||
if (res.failed?.length) toast.error('部分失败', { description: res.message });
|
||||
else if (res.created > 0) toast.success('已添加默认监控', { description: res.message });
|
||||
else toast.success('默认目标已存在');
|
||||
} catch (err) {
|
||||
toast.error('添加失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollectNow = async () => {
|
||||
try {
|
||||
const res = await middlewareMonitorApi.collectNow() as { collected: number; message: string };
|
||||
const res = await middlewareMonitorApi.collectNow() as { message: string };
|
||||
toast.success('采集完成', { description: res.message });
|
||||
loadTargets();
|
||||
loadTargets(true);
|
||||
} catch (err) {
|
||||
toast.error('采集失败', { description: (err as { message?: string }).message });
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = (type?: TabKey) => {
|
||||
setEditTarget(null);
|
||||
if (type) setTab(type);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const currentTargets = tab === 'redis' ? redisTargets : mysqlTargets;
|
||||
const selected = tab === 'redis' ? selectedRedis : selectedMysql;
|
||||
const setSelected = tab === 'redis' ? setSelectedRedis : setSelectedMysql;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">中间件监控</h2>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">中间件监控</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
Redis 单机/集群 · MySQL · 30s 自动刷新
|
||||
{lastRefreshAt && (
|
||||
<span className="ml-2">
|
||||
上次更新 {lastRefreshAt.toLocaleTimeString()}
|
||||
{refreshing && ' · 刷新中...'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCollectNow} className="gap-1.5">
|
||||
<Zap className="h-4 w-4" />
|
||||
立即采集
|
||||
<Zap className="h-4 w-4" />立即采集
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={loadTargets} className="gap-1.5">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
<Button variant="outline" size="sm" onClick={() => loadTargets(true)} disabled={refreshing} className="gap-1.5">
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditTarget(null); setDialogOpen(true); }} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加监控
|
||||
{targets.length === 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSeedDefaults} disabled={seeding} className="gap-1.5">
|
||||
{seeding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
|
||||
添加平台默认
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => openCreate()} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />添加监控
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin dg-text-muted mr-2" />
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">正在加载中间件指标...</span>
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />加载中...
|
||||
</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 dg-text-red mx-auto mb-2" />
|
||||
<p className="text-sm dg-text-red">{error}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-red-500/30"><CardContent className="p-6 text-center dg-text-red text-sm">{error}</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 dg-text-muted mx-auto mb-4" />
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">暂无中间件监控目标</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">点击「添加监控」配置 Redis / MySQL 连接</p>
|
||||
<Button size="sm" className="mt-4 gap-1.5" onClick={() => { setEditTarget(null); setDialogOpen(true); }}>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加第一个监控
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Redis 概览卡片 */}
|
||||
{redisTargets.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">
|
||||
<Database className="h-4 w-4 dg-text-red" />
|
||||
<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}:{redisTarget.port})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{redisTarget ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 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, 1)} />
|
||||
<MetricTile label="Key总数" value={fmtVal(redisMetrics.redis_db_size)} />
|
||||
<MetricTile label="阻塞客户端" value={fmtVal(redisMetrics.redis_blocked_clients)} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">无在线 Redis 实例</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* MySQL 概览卡片 */}
|
||||
{mysqlTargets.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">
|
||||
<Server className="h-4 w-4 dg-text-blue" />
|
||||
<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}:{mysqlTarget.port})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mysqlTarget ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<MetricTile label="连接数" value={fmtVal(mysqlMetrics.mysql_connections)} />
|
||||
<MetricTile label="活跃线程" value={fmtVal(mysqlMetrics.mysql_threads_running)} />
|
||||
<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)} />
|
||||
<MetricTile label="发送流量" value={fmtMemory(mysqlMetrics.mysql_bytes_sent)} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">无在线 MySQL 实例</p>
|
||||
)}
|
||||
</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">
|
||||
<Settings2 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}
|
||||
onEdit={() => { setEditTarget(target); setDialogOpen(true); }}
|
||||
onDelete={() => handleDelete(target)}
|
||||
onTest={() => handleTest(target)}
|
||||
testing={testing === target.id}
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button size="sm" onClick={handleSeedDefaults} disabled={seeding}>添加平台默认</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openCreate()}>手动添加</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 其他类型 */}
|
||||
{otherTargets.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{otherTargets.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.description}</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)} />
|
||||
))}
|
||||
{!loading && !error && targets.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: '监控实例', value: targets.length, icon: Activity },
|
||||
{ label: '在线', value: onlineCount, icon: Database, color: 'dg-text-green' },
|
||||
{ label: '告警', value: warnCount, icon: Activity, color: warnCount > 0 ? 'dg-text-yellow' : 'dg-text-green' },
|
||||
{ label: 'Redis', value: redisTargets.length, icon: Database, color: 'dg-text-red' },
|
||||
{ label: 'MySQL', value: mysqlTargets.length, icon: Server, color: 'dg-text-blue' },
|
||||
].map((item) => (
|
||||
<Card key={item.label} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<item.icon className={`h-4 w-4 ${item.color || 'text-[var(--dg-text-muted)]'}`} />
|
||||
<div>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-lg font-bold ${item.color || ''}`}>{item.value}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">暂无指标数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MwOverviewCharts targets={targets} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ key: 'redis' as TabKey, label: 'Redis', count: redisTargets.length, icon: Database },
|
||||
{ key: 'mysql' as TabKey, label: 'MySQL', count: mysqlTargets.length, icon: Server },
|
||||
]).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === t.key
|
||||
? 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="h-4 w-4" />{t.label}
|
||||
<span className="text-xs opacity-60">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{currentTargets.length === 0 ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
暂无 {tab === 'redis' ? 'Redis' : 'MySQL'} 实例,
|
||||
<button type="button" className="dg-text-cyan ml-1 underline" onClick={() => openCreate(tab)}>立即添加</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑弹框 */}
|
||||
<MiddlewareDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editTarget={editTarget}
|
||||
onSuccess={loadTargets}
|
||||
/>
|
||||
</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, onEdit, onDelete, onTest, testing,
|
||||
}: {
|
||||
target: MwTarget;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
}) {
|
||||
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 dg-text-green' : '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.port} · {target.description}
|
||||
</div>
|
||||
{metricCount > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{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.replace(/^(redis|mysql)_/, '')}</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)] mb-3">暂无指标</p>
|
||||
)}
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={onTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}
|
||||
测试
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={onEdit}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 dg-text-red hover:dg-text-red" onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 添加/编辑中间件弹框 */
|
||||
function MiddlewareDialog({
|
||||
open, onOpenChange, editTarget, onSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
editTarget: MwTarget | null;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '', type: 'redis', ip: '', port: 6379,
|
||||
password: '', username: '', database: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editTarget) {
|
||||
setForm({
|
||||
name: editTarget.name || '',
|
||||
type: editTarget.description || 'redis',
|
||||
ip: editTarget.ip || '',
|
||||
port: editTarget.port || 6379,
|
||||
password: '', // 不回显密码
|
||||
username: '',
|
||||
database: '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
name: '', type: 'redis', ip: '', port: 6379,
|
||||
password: '', username: '', database: '',
|
||||
});
|
||||
}
|
||||
}, [editTarget, open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) {
|
||||
toast.error('请填写名称和 IP');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
ip: form.ip.trim(),
|
||||
port: Number(form.port) || (form.type === 'redis' ? 6379 : 3306),
|
||||
interval: 30,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
if (form.type === 'mysql') {
|
||||
if (form.username) payload.username = form.username;
|
||||
if (form.database) payload.database = form.database;
|
||||
}
|
||||
|
||||
if (editTarget) {
|
||||
await middlewareMonitorApi.update(editTarget.id, payload);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
await middlewareMonitorApi.create(payload);
|
||||
toast.success('创建成功,正在采集...');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error(editTarget ? '更新失败' : '创建失败', {
|
||||
description: (err as { message?: string }).message,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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)]">
|
||||
{editTarget ? '编辑中间件监控' : '添加中间件监控'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
配置 Redis 或 MySQL 连接信息,平台将自动采集指标
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* 类型选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">中间件类型</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['redis', 'mysql'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({
|
||||
...f, type: t,
|
||||
port: t === 'redis' ? 6379 : 3306,
|
||||
}))}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
form.type === t
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{t === 'redis' ? 'Redis' : 'MySQL'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">名称 *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder={form.type === 'redis' ? '生产 Redis' : '生产 MySQL'}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{/* IP + Port */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">IP 地址 *</Label>
|
||||
<Input
|
||||
value={form.ip}
|
||||
onChange={e => setForm(f => ({ ...f, ip: e.target.value }))}
|
||||
placeholder="10.10.10.75"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">端口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={e => setForm(f => ({ ...f, port: Number(e.target.value) }))}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 密码 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
密码 {editTarget && <span className="text-xs text-[var(--dg-text-muted)]">(留空不修改)</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="输入密码"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{/* MySQL 专属字段 */}
|
||||
{form.type === 'mysql' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">用户名</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
|
||||
placeholder="root"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col xl:flex-row gap-4 items-start">
|
||||
<div className="w-full xl:w-[min(260px,24%)] xl:shrink-0 space-y-2">
|
||||
{currentTargets.map((t) => (
|
||||
<InstanceCard
|
||||
key={t.id}
|
||||
target={t}
|
||||
selected={selected?.id === t.id}
|
||||
onSelect={() => setSelected(t)}
|
||||
onEdit={() => { setEditTarget(t); setDialogOpen(true); }}
|
||||
onDelete={() => handleDelete(t)}
|
||||
onTest={() => handleTest(t)}
|
||||
testing={testing === t.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">数据库</Label>
|
||||
<Input
|
||||
value={form.database}
|
||||
onChange={e => setForm(f => ({ ...f, database: e.target.value }))}
|
||||
placeholder="information_schema"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<MwTargetChartsPanel
|
||||
target={selected}
|
||||
chartDefs={tab === 'redis' ? REDIS_CHARTS : MYSQL_CHARTS}
|
||||
accent={tab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editTarget ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MiddlewareDialog open={dialogOpen} onOpenChange={setDialogOpen} editTarget={editTarget} onSuccess={() => loadTargets(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// 监控中心 — 4个监控标签页的容器 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { HostMonitor } from './HostMonitor';
|
||||
import { MiddlewareMonitor } from './MiddlewareMonitor';
|
||||
@@ -11,13 +12,31 @@ type TabId = 'host' | 'middleware' | 'snmp' | 'site';
|
||||
|
||||
const tabs: { id: TabId; name: string; description: string }[] = [
|
||||
{ id: 'host', name: '主机监控', description: 'CPU / 内存 / 磁盘 / 网络 / 磁盘IO' },
|
||||
{ id: 'middleware', name: '中间件监控', description: 'Redis / MySQL 指标采集' },
|
||||
{ id: 'snmp', name: 'SNMP 监控', description: '网络设备 / 服务器硬件 (OID 模板)' },
|
||||
{ id: 'site', name: '站点拨测', description: 'HTTP / PING / DNS / TCP / WS 拨测' },
|
||||
{ id: 'middleware', name: '中间件', description: 'Redis 单机·集群 / MySQL 指标与趋势' },
|
||||
{ id: 'snmp', name: 'SNMP', description: '网络设备 / BMC 采集与趋势' },
|
||||
{ id: 'site', name: '站点拨测', description: 'HTTP Web · SSL 证书 · TCP / DNS / PING' },
|
||||
];
|
||||
|
||||
const TAB_IDS: TabId[] = ['host', 'middleware', 'snmp', 'site'];
|
||||
|
||||
function parseTab(raw: string | null): TabId {
|
||||
return TAB_IDS.includes(raw as TabId) ? (raw as TabId) : 'host';
|
||||
}
|
||||
|
||||
export function MonitorCenter() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('host');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<TabId>(() => parseTab(searchParams.get('tab')));
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(parseTab(searchParams.get('tab')));
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = (id: string) => {
|
||||
const tab = id as TabId;
|
||||
setActiveTab(tab);
|
||||
setSearchParams({ tab }, { replace: true });
|
||||
};
|
||||
|
||||
const current = tabs.find((t) => t.id === activeTab);
|
||||
|
||||
return (
|
||||
@@ -25,10 +44,10 @@ export function MonitorCenter() {
|
||||
variant="list"
|
||||
title="监控中心"
|
||||
subtitle={current?.description ?? '全面的基础设施与应用监控'}
|
||||
icon={<Cpu className="h-5 w-5" />}
|
||||
icon={<Activity className="h-5 w-5" />}
|
||||
tabs={tabs.map((t) => ({ id: t.id, label: t.name }))}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => setActiveTab(id as TabId)}
|
||||
onTabChange={handleTabChange}
|
||||
contentClassName="min-h-[400px]"
|
||||
>
|
||||
{activeTab === 'host' && <HostMonitor embedded />}
|
||||
|
||||
@@ -1,428 +1,394 @@
|
||||
// SNMP 监控 — 网络设备 / 服务器硬件 (OID 模板采集)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// SNMP 监控 — 设备管理 + 指标可视化(主区域优先) // Logan.Li
|
||||
import { useState, useEffect, useCallback, type MouseEvent } 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,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Radio, Router, HardDrive, Plus, Trash2 } from 'lucide-react';
|
||||
import { Router, RefreshCw, Plus, Trash2, Zap, Eye, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { snmpApi } from '@/api';
|
||||
import { SnmpOverviewCharts } from './snmp-monitor/SnmpOverviewCharts';
|
||||
import { SnmpDeviceChartsPanel } from './snmp-monitor/SnmpDeviceChartsPanel';
|
||||
import { SnmpTemplatesCollapsible, type SnmpTemplate } from './snmp-monitor/SnmpTemplatesCollapsible';
|
||||
import type { SnmpDevice, SnmpMetric, SnmpOverviewItem } from './snmp-monitor/helpers';
|
||||
|
||||
// ---------- types ----------
|
||||
interface SnmpStats {
|
||||
total: number;
|
||||
online: number;
|
||||
abnormal: number;
|
||||
}
|
||||
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,
|
||||
name: '', ip: '', port: 161,
|
||||
snmp_version: 'v2c' as 'v2c' | 'v3',
|
||||
community: 'public',
|
||||
template_id: '',
|
||||
community: 'public', template_id: '',
|
||||
};
|
||||
|
||||
const keyMetrics = [
|
||||
'h3c_cpu_usage', 'hw_cpu_usage', 'cisco_cpu_5min', 'ruijie_cpu_usage',
|
||||
'inspur_cpu_usage', 'dell_cpu_status', 'h3c_mem_usage', 'hw_mem_usage',
|
||||
'h3c_temperature', 'hw_temperature', 'if_in_octets', 'if_out_octets',
|
||||
];
|
||||
|
||||
export function SNMPMonitor() {
|
||||
// stats
|
||||
const [stats, setStats] = useState<SnmpStats>({ total: 0, online: 0, abnormal: 0 });
|
||||
|
||||
// device list
|
||||
const [devices, setDevices] = useState<SnmpDevice[]>([]);
|
||||
const [overview, setOverview] = useState<SnmpOverviewItem[]>([]);
|
||||
const [templates, setTemplates] = useState<SnmpTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...emptyForm });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [collectingId, setCollectingId] = useState<number | null>(null);
|
||||
const [selectedDevice, setSelectedDevice] = useState<SnmpDevice | null>(null);
|
||||
const [metricsOpen, setMetricsOpen] = useState(false);
|
||||
const [metricsDevice, setMetricsDevice] = useState<SnmpDevice | null>(null);
|
||||
const [metrics, setMetrics] = useState<SnmpMetric[]>([]);
|
||||
const [metricsLoading, setMetricsLoading] = useState(false);
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getStats() as SnmpStats;
|
||||
setStats(data);
|
||||
} catch {
|
||||
// backend unavailable — leave defaults
|
||||
}
|
||||
const res = await snmpApi.getTemplates() as { list?: SnmpTemplate[] };
|
||||
setTemplates(res?.list || []);
|
||||
} catch { setTemplates([]); }
|
||||
}, []);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try { setStats(await snmpApi.getStats() as SnmpStats); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getOverviewMetrics() as { list?: SnmpOverviewItem[] };
|
||||
setOverview(data?.list || []);
|
||||
} catch { setOverview([]); }
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getDevices() as DeviceListResponse;
|
||||
setDevices(data.list ?? []);
|
||||
const data = await snmpApi.getDevices() as { list?: SnmpDevice[] };
|
||||
const list = data.list ?? [];
|
||||
setDevices(list);
|
||||
setSelectedDevice((prev) => {
|
||||
if (prev && list.some((d) => d.id === prev.id)) return list.find((d) => d.id === prev.id) || null;
|
||||
return list.find((d) => d.status === 'online') || list[0] || null;
|
||||
});
|
||||
} catch {
|
||||
setDevices([]);
|
||||
setSelectedDevice(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
loadDevices();
|
||||
}, [loadStats, loadDevices]);
|
||||
loadStats(); loadDevices(); loadTemplates(); loadOverview();
|
||||
}, [loadStats, loadDevices, loadTemplates, loadOverview]);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// 30s 静默刷新,与采集周期对齐 // Logan.Li
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
if (loading && devices.length === 0) return;
|
||||
const timer = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh, loading, devices.length]);
|
||||
|
||||
const templateName = (id: number) => templates.find((t) => t.id === id)?.name || `#${id}`;
|
||||
|
||||
// ---------- CRUD ----------
|
||||
const handleAdd = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) return;
|
||||
if (!form.name.trim() || !form.ip.trim() || !form.template_id) {
|
||||
toast.error('请填写设备名称、IP 并选择 OID 模板');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tpl = templates.find((t) => String(t.id) === form.template_id);
|
||||
await snmpApi.createDevice({
|
||||
name: form.name.trim(),
|
||||
ip: form.ip.trim(),
|
||||
port: Number(form.port),
|
||||
snmp_version: form.snmp_version,
|
||||
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,
|
||||
template_id: Number(form.template_id),
|
||||
vendor: tpl?.vendor || '', device_type: tpl?.device_type || 'switch',
|
||||
});
|
||||
toast.success('设备已添加');
|
||||
setDialogOpen(false);
|
||||
setForm({ ...emptyForm });
|
||||
refresh();
|
||||
} catch {
|
||||
// silently fail — user can retry
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleCollect = async (d: SnmpDevice, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setCollectingId(d.id);
|
||||
try {
|
||||
await snmpApi.deleteDevice(id);
|
||||
await snmpApi.collectDevice(d.id);
|
||||
toast.success(`${d.name} 采集成功`);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error((err as { message?: string }).message || '采集失败');
|
||||
refresh();
|
||||
} finally {
|
||||
setCollectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openMetrics = async (d: SnmpDevice, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setMetricsDevice(d);
|
||||
setMetricsOpen(true);
|
||||
setMetricsLoading(true);
|
||||
try {
|
||||
const res = await snmpApi.getDeviceMetrics(d.id) as { list?: SnmpMetric[] };
|
||||
setMetrics(res?.list || []);
|
||||
} catch {
|
||||
// silently fail
|
||||
setMetrics([]);
|
||||
} finally {
|
||||
setMetricsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await snmpApi.deleteDevice(id);
|
||||
toast.success('已删除');
|
||||
if (selectedDevice?.id === id) setSelectedDevice(null);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- status badge ----------
|
||||
const statusBadge = (status?: string) => {
|
||||
if (status === 'online') {
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">在线</Badge>;
|
||||
}
|
||||
if (status === 'abnormal') {
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
}
|
||||
if (status === 'online') return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">在线</Badge>;
|
||||
if (status === 'error') return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
return <Badge className="bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]">离线</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 dg-text-cyan" />
|
||||
<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="dg-text-green 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 dg-text-purple" />
|
||||
<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="dg-text-red 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 dg-text-cyan" />
|
||||
<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 dg-text-cyan hover:bg-cyan-500/20 border border-cyan-500/30">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] 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="space-y-4">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">SNMP 监控</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">5 分钟采集 · 指标保留 7 天</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={refresh} className="h-8">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-cyan-500/10 dg-text-cyan border border-cyan-500/30 h-8">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 SNMP 设备</DialogTitle>
|
||||
<DialogDescription>选择厂商 OID 模板,配置管理 IP 与 Community</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>设备名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="核心交换机-A" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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)]"
|
||||
/>
|
||||
<Label>IP 地址</Label>
|
||||
<Input value={form.ip} onChange={(e) => setForm({ ...form, ip: e.target.value })} placeholder="192.168.1.1" />
|
||||
</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)]"
|
||||
/>
|
||||
<Label>端口</Label>
|
||||
<Input type="number" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 161 })} />
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>OID 模板</Label>
|
||||
<Select value={form.template_id} onValueChange={(v) => setForm({ ...form, template_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="选择模板" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>{t.name} ({t.vendor})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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="dg-input">
|
||||
<Label>SNMP 版本</Label>
|
||||
<Select value={form.snmp_version} onValueChange={(v) => setForm({ ...form, snmp_version: v as 'v2c' | 'v3' })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<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)]"
|
||||
/>
|
||||
<Label>Community</Label>
|
||||
<Input value={form.community} onChange={(e) => setForm({ ...form, community: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAdd} disabled={submitting}>
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin mr-1" />}确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={submitting || !form.name.trim() || !form.ip.trim()}
|
||||
className="bg-cyan-500/10 dg-text-cyan hover:bg-cyan-500/20 border border-cyan-500/30"
|
||||
>
|
||||
{submitting ? '添加中...' : '确认添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 紧凑统计 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: '设备总数', value: stats.total, color: '' },
|
||||
{ label: '在线', value: stats.online, color: 'dg-text-green' },
|
||||
{ label: '异常', value: stats.abnormal, color: 'dg-text-red' },
|
||||
{ label: 'OID 模板', value: templates.length, color: 'dg-text-cyan' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-base font-bold font-mono ${item.color}`}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</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 设备,点击「添加设备」开始监控
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="py-12 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Router className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||
暂无 SNMP 设备,点击「添加设备」开始监控
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<SnmpOverviewCharts devices={devices} overview={overview} />
|
||||
|
||||
{/* 主监控区:设备列表 + 趋势图 */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-start">
|
||||
<Card className="xl:col-span-5 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<h3 className="text-sm font-medium mb-3">设备列表 ({devices.length})</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">名称</TableHead>
|
||||
<TableHead className="text-xs">IP</TableHead>
|
||||
<TableHead className="text-xs">状态</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow
|
||||
key={d.id}
|
||||
className={`cursor-pointer ${selectedDevice?.id === d.id ? 'bg-[var(--dg-bg-tertiary)]' : ''}`}
|
||||
onClick={() => setSelectedDevice(d)}
|
||||
>
|
||||
<TableCell className="text-sm font-medium py-2">{d.name}</TableCell>
|
||||
<TableCell className="font-mono text-[10px] py-2">{d.ip}</TableCell>
|
||||
<TableCell className="py-2">{statusBadge(d.status)}</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon-sm" disabled={collectingId === d.id} onClick={(e) => handleCollect(d, e)}>
|
||||
{collectingId === d.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Zap className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={(e) => openMetrics(d, e)}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={(e) => handleDelete(d.id, e)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="xl:col-span-7">
|
||||
<SnmpDeviceChartsPanel
|
||||
device={selectedDevice}
|
||||
templateName={selectedDevice ? templateName(selectedDevice.template_id) : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* OID 模板 — 默认折叠 */}
|
||||
<SnmpTemplatesCollapsible templates={templates} />
|
||||
|
||||
{/* 指标详情弹窗 */}
|
||||
<Dialog open={metricsOpen} onOpenChange={setMetricsOpen}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto bg-[var(--dg-bg-secondary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{metricsDevice?.name} — 采集指标</DialogTitle>
|
||||
<DialogDescription>{metricsDevice?.ip} · {templateName(metricsDevice?.template_id || 0)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{metricsLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">加载中...</div>
|
||||
) : metrics.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
暂无指标,请先采集
|
||||
{metricsDevice?.last_error && <p className="mt-2 text-xs dg-text-red">{metricsDevice.last_error}</p>}
|
||||
</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="dg-text-muted hover:dg-text-red hover:bg-red-500/10"
|
||||
title="删除设备"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<div className="space-y-4">
|
||||
{metrics.filter((m) => keyMetrics.includes(m.name)).length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{metrics.filter((m) => keyMetrics.includes(m.name)).map((m) => (
|
||||
<div key={`${m.name}-${m.index}`} className="rounded border border-[var(--dg-border)] p-2 text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{m.name}</p>
|
||||
<p className="text-lg font-mono">{m.raw_value || m.value}{m.unit ? ` ${m.unit}` : ''}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>指标</TableHead><TableHead>索引</TableHead><TableHead>值</TableHead><TableHead>OID</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{metrics.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="text-xs">{m.name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{m.index || '-'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{m.raw_value || m.value}{m.unit ? ` ${m.unit}` : ''}</TableCell>
|
||||
<TableCell className="font-mono text-[10px] text-muted-foreground truncate max-w-[160px]">{m.oid}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,49 @@
|
||||
// 站点拨测 — HTTP / HTTPS / TCP / DNS / PING 主动监控,连接真实后端 API
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// 站点拨测 — 任务列表 + 详情可观测 // Logan.Li
|
||||
import { useState, useEffect, useCallback, useMemo } 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 { 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,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Globe,
|
||||
Server,
|
||||
ArrowUpDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
Plus, Trash2, Pencil, Loader2, RefreshCw, Activity, ShieldAlert, ChevronRight,
|
||||
} 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: 'dg-text-blue', desc: 'HTTP URL 状态码 & 响应时间' },
|
||||
{ icon: Globe, name: 'HTTPS', color: 'dg-text-green', desc: 'HTTPS SSL 证书 & 响应时间' },
|
||||
{ icon: Server, name: 'TCP', color: 'dg-text-orange', desc: 'TCP 端口连通性检测' },
|
||||
{ icon: Globe, name: 'DNS', color: 'dg-text-cyan', desc: '域名解析延迟 & 结果验证' },
|
||||
{ icon: ArrowUpDown, name: 'PING', color: 'dg-text-purple', 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;
|
||||
}
|
||||
import { SiteProtocolPieChart } from './site-monitor/SiteProtocolPieChart';
|
||||
import { SiteTaskDetailDialog } from './site-monitor/SiteTaskDetailDialog';
|
||||
import {
|
||||
PROBE_TYPE_OPTIONS, type ProbeType, type SiteStats, type SiteTask,
|
||||
probeTypeMeta, fmtAvailResponse, fmtFrequency, isSslProbeTask, isSslExpiringSoon, normalizeProbeType,
|
||||
} from './site-monitor/helpers';
|
||||
|
||||
interface CreateTaskForm {
|
||||
name: string;
|
||||
url: string;
|
||||
type: ProbeType;
|
||||
frequency_sec: number;
|
||||
timeout_sec: number;
|
||||
expected_status_code: number;
|
||||
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,
|
||||
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 dg-text-green" />;
|
||||
case 'down':
|
||||
return <XCircle className="h-4 w-4 dg-text-red" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 dg-text-secondary" />;
|
||||
const FILTER_TYPES: Array<ProbeType | 'ALL'> = ['ALL', ...PROBE_TYPE_OPTIONS];
|
||||
|
||||
function alertStatusBadge(status?: string) {
|
||||
if (status === 'alerting') {
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30 text-[10px]">告警中</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status?: string) {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">正常</Badge>;
|
||||
case 'down':
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
default:
|
||||
return <Badge className="dg-badge-muted">未知</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())}`;
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30 text-[10px]">正常</Badge>;
|
||||
}
|
||||
|
||||
export function SiteMonitor() {
|
||||
@@ -142,108 +52,124 @@ export function SiteMonitor() {
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingTaskId, setEditingTaskId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<CreateTaskForm>({ ...emptyForm });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
|
||||
const [selectedType, setSelectedType] = useState<ProbeType | 'ALL'>('ALL');
|
||||
const [detailTask, setDetailTask] = useState<SiteTask | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const _confirm = useConfirm();
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await siteApi.getStats();
|
||||
setStats(data as SiteStats);
|
||||
} catch {
|
||||
// 后端不可用时保留空状态
|
||||
}
|
||||
try { setStats(await siteApi.getStats() as SiteStats); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await siteApi.getTasks();
|
||||
setTasks((data as JsonData)?.list || []);
|
||||
} catch {
|
||||
// 后端不可用时保留空状态
|
||||
} finally {
|
||||
const list = (data as JsonData)?.list as SiteTask[] || [];
|
||||
setTasks(list);
|
||||
setDetailTask((prev) => {
|
||||
if (!prev) return null;
|
||||
return list.find((t) => t.id === prev.id) || null;
|
||||
});
|
||||
} catch { /* ignore */ } finally {
|
||||
setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadStats(); loadTasks(); }, [loadStats, loadTasks]);
|
||||
|
||||
// 30s 静默刷新 // Logan.Li
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadTasks();
|
||||
}, [loadStats, loadTasks]);
|
||||
if (initialLoading && tasks.length === 0) return;
|
||||
const timer = setInterval(() => { loadStats(); loadTasks(); }, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadStats, loadTasks, initialLoading, tasks.length]);
|
||||
|
||||
// ---------- filtered tasks ----------
|
||||
const filteredTasks = selectedType
|
||||
? tasks.filter((t) => t.type?.toUpperCase() === selectedType.toUpperCase())
|
||||
: tasks;
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of PROBE_TYPE_OPTIONS) counts[t] = 0;
|
||||
for (const t of tasks) {
|
||||
const key = normalizeProbeType(t.type);
|
||||
counts[key]++;
|
||||
}
|
||||
return counts;
|
||||
}, [tasks]);
|
||||
|
||||
// ---------- actions ----------
|
||||
const openCreate = () => {
|
||||
setForm({ ...emptyForm });
|
||||
const filteredTasks = selectedType === 'ALL'
|
||||
? tasks
|
||||
: tasks.filter((t) => normalizeProbeType(t.type) === selectedType);
|
||||
|
||||
const openCreate = (type?: ProbeType) => {
|
||||
setEditingTaskId(null);
|
||||
setForm({ ...emptyForm, type: type || (selectedType !== 'ALL' ? selectedType : 'HTTP') });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error('名称和 URL 不能为空');
|
||||
return;
|
||||
}
|
||||
const openEdit = (task: SiteTask) => {
|
||||
setEditingTaskId(task.id);
|
||||
setForm({
|
||||
name: task.name, url: task.url,
|
||||
type: normalizeProbeType(task.type),
|
||||
frequency_sec: task.frequency_sec || 60,
|
||||
timeout_sec: task.timeout_sec || 10,
|
||||
expected_status_code: task.expected_status_code || 200,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openDetail = (task: SiteTask) => {
|
||||
setDetailTask(task);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.url.trim()) { toast.error('名称和地址不能为空'); return; }
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
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,
|
||||
};
|
||||
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();
|
||||
if (editingTaskId) { await siteApi.updateTask(editingTaskId, payload); toast.success('任务已更新'); }
|
||||
else { await siteApi.createTask(payload); toast.success('拨测任务已创建'); }
|
||||
setFormOpen(false); setEditingTaskId(null);
|
||||
loadTasks(); loadStats();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData).message || '创建失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
toast.error((e as JsonData).message || (editingTaskId ? '更新失败' : '创建失败'));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (task: SiteTask) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除拨测任务',
|
||||
description: `确定删除「${task.name}」吗?此操作不可恢复。`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
const ok = await _confirm({ title: '删除拨测任务', description: `确定删除「${task.name}」吗?`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await siteApi.deleteTask(task.id);
|
||||
if (detailTask?.id === task.id) {
|
||||
setDetailTask(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
toast.success('任务已删除');
|
||||
loadTasks();
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData).message || '删除失败');
|
||||
}
|
||||
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 || '操作失败');
|
||||
}
|
||||
loadTasks(); loadStats();
|
||||
} catch (e) { toast.error((e as JsonData).message || '操作失败'); }
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
const availStr =
|
||||
stats?.availability != null ? `${(stats.availability * 100).toFixed(1)}%` : '--';
|
||||
const targetMeta = probeTypeMeta[form.type];
|
||||
const availStr = stats?.availability != null ? `${(stats.availability * 100).toFixed(1)}%` : '--';
|
||||
const probeCountStr = stats?.probe_count != null ? String(stats.probe_count) : '--';
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
@@ -254,290 +180,199 @@ export function SiteMonitor() {
|
||||
}
|
||||
|
||||
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 className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">站点拨测</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">HTTP Web 拨测 · SSL 证书检测 · TCP / DNS / PING</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={() => { loadStats(); loadTasks(); }} disabled={refreshing}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${refreshing ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 gap-1.5" onClick={() => openCreate()}>
|
||||
<Plus className="h-3.5 w-3.5" />添加任务
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-start">
|
||||
<div className="xl:col-span-8 space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ label: '总任务数', value: stats?.total_tasks ?? '--' },
|
||||
{ label: '已启用', value: stats?.enabled_tasks ?? '--' },
|
||||
{ label: '总任务', value: stats?.total_tasks ?? '--' },
|
||||
{ label: '正常', value: stats?.up_count ?? '--', color: 'dg-text-green' },
|
||||
{ label: '异常', value: stats?.down_count ?? '--', color: 'dg-text-red' },
|
||||
{ label: '可用率', value: availStr, color: 'dg-text-blue' },
|
||||
{ label: '可用率(24h)', value: availStr, color: 'dg-text-blue' },
|
||||
{ label: '探测次数', value: probeCountStr },
|
||||
].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 key={item.label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-base 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>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium">拨测任务</h3>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">点击行查看详情</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{FILTER_TYPES.map((type) => {
|
||||
const count = type === 'ALL' ? tasks.length : typeCounts[type] || 0;
|
||||
const active = selectedType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--dg-blue)]/15 text-[var(--dg-blue)] border border-[var(--dg-blue)]/30'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-transparent hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{type === 'ALL' ? '全部' : type} {count > 0 && count}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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 dg-text-red hover:dg-text-red hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(task)}
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="text-center py-10 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">名称</TableHead>
|
||||
<TableHead className="text-xs">类型</TableHead>
|
||||
<TableHead className="text-xs">频率</TableHead>
|
||||
<TableHead className="text-xs">报警状态</TableHead>
|
||||
<TableHead className="text-xs">可用率/响应时间</TableHead>
|
||||
<TableHead className="text-xs">启用</TableHead>
|
||||
<TableHead className="text-xs w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTasks.map((task) => (
|
||||
<TableRow
|
||||
key={task.id}
|
||||
className="border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-tertiary)]/60"
|
||||
onClick={() => openDetail(task)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TableCell className="py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{task.name}</span>
|
||||
{isSslProbeTask(task) && isSslExpiringSoon(task.ssl_days_remaining) && (
|
||||
<Badge className="bg-amber-500/15 dg-text-yellow border-amber-500/30 text-[10px] shrink-0 gap-0.5">
|
||||
<ShieldAlert className="h-3 w-3" />证书
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] truncate max-w-[220px]">{task.url}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<Badge variant="outline" className="text-[10px]">{normalizeProbeType(task.type)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] py-2">{fmtFrequency(task.frequency_sec)}</TableCell>
|
||||
<TableCell className="py-2">{alertStatusBadge(task.alert_status)}</TableCell>
|
||||
<TableCell className="text-xs font-mono py-2">{fmtAvailResponse(task)}</TableCell>
|
||||
<TableCell className="py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={task.enabled} onCheckedChange={() => handleToggle(task)} />
|
||||
</TableCell>
|
||||
<TableCell className="py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(task)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" className="dg-text-red" onClick={() => handleDelete(task)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== 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)]">
|
||||
<div className="xl:col-span-4">
|
||||
<SiteProtocolPieChart tasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteTaskDetailDialog
|
||||
task={detailTask}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
onProbed={() => { loadTasks(); loadStats(); }}
|
||||
/>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={(open) => { setFormOpen(open); if (!open) setEditingTaskId(null); }}>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加拨测任务</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
配置站点探活任务,系统将按指定频率自动检测
|
||||
</DialogDescription>
|
||||
<DialogTitle>{editingTaskId ? '编辑拨测任务' : '添加拨测任务'}</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 })}
|
||||
/>
|
||||
<Label>任务名称</Label>
|
||||
<Input 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>
|
||||
<Label>协议类型</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>
|
||||
))}
|
||||
{PROBE_TYPE_OPTIONS.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{targetMeta.hint}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{targetMeta.label}</Label>
|
||||
<Input placeholder={targetMeta.placeholder} value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
||||
</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 })
|
||||
}
|
||||
/>
|
||||
<Label>检测间隔 (秒)</Label>
|
||||
<Input 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 })
|
||||
}
|
||||
/>
|
||||
<Label>超时 (秒)</Label>
|
||||
<Input 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') && (
|
||||
{form.type === 'HTTP' && (
|
||||
<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 })
|
||||
}
|
||||
/>
|
||||
<Label>期望状态码</Label>
|
||||
<Input 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}>
|
||||
<Button variant="outline" onClick={() => { setFormOpen(false); setEditingTaskId(null); }} disabled={saving}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
创建任务
|
||||
{editingTaskId ? '保存' : '创建任务'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 监控图表工具栏 — 固定卡片/弹框右上角 // Logan.Li
|
||||
import { Maximize2, ZoomOut } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
zoomed?: boolean;
|
||||
onExpand: () => void;
|
||||
onResetZoom?: () => void;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChartToolbar({
|
||||
zoomed, onExpand, onResetZoom, size = 'sm', className,
|
||||
}: Props) {
|
||||
const btn = size === 'md' ? 'h-8 w-8' : 'h-7 w-7';
|
||||
const icon = size === 'md' ? 'h-4 w-4' : 'h-3.5 w-3.5';
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-0.5 shrink-0', className)}>
|
||||
{zoomed && onResetZoom && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(btn, 'dg-text-secondary hover:dg-text-primary hover:bg-[var(--dg-bg-hover)]')}
|
||||
title="重置缩放"
|
||||
onClick={onResetZoom}
|
||||
>
|
||||
<ZoomOut className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(btn, 'dg-text-secondary hover:dg-text-blue hover:bg-[var(--dg-bg-hover)]')}
|
||||
title="放大查看"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<Maximize2 className={icon} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// 双线图表(网卡带宽 rx+tx)// Logan.Li
|
||||
// 双线图表(网卡带宽 rx+tx)— 工具栏标题栏右上角 + 宽屏弹框 // Logan.Li
|
||||
import { useRef, useState } from 'react';
|
||||
import { Network } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Brush,
|
||||
} from 'recharts';
|
||||
import { ZoomableAreaChart, type ZoomableChartHandle } from './ZoomableAreaChart';
|
||||
import { ChartToolbar } from './ChartToolbar';
|
||||
import { MetricIconBox } from './MetricIconBox';
|
||||
import type { AggType, BucketPoint } from './types';
|
||||
|
||||
interface DualLineChartProps {
|
||||
@@ -20,57 +20,58 @@ interface DualLineChartProps {
|
||||
export function DualLineChart({
|
||||
title, currentRx, currentTx, rxData, txData, aggType, unit,
|
||||
}: DualLineChartProps) {
|
||||
const chartRef = useRef<ZoomableChartHandle>(null);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
|
||||
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 dg-text-blue" />
|
||||
<span className="text-xs text-[var(--dg-text-primary)] font-medium">{title}</span>
|
||||
<div className="flex items-start justify-between gap-2 mb-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<MetricIconBox icon={<Network />} tone="blue" />
|
||||
<span className="text-xs text-[var(--dg-text-primary)] font-medium leading-8">{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="dg-text-green">{currentTx}</b></span>
|
||||
<div className="flex items-center gap-3 flex-wrap justify-end">
|
||||
<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="dg-text-green">{currentTx}</b>
|
||||
</span>
|
||||
</div>
|
||||
<ChartToolbar
|
||||
zoomed={zoomed}
|
||||
onExpand={() => chartRef.current?.expand()}
|
||||
onResetZoom={() => chartRef.current?.resetZoom()}
|
||||
/>
|
||||
</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>
|
||||
<ZoomableAreaChart
|
||||
ref={chartRef}
|
||||
data={chartData}
|
||||
series={[
|
||||
{ dataKey: 'rx', color: '#3b82f6', label: '接收' },
|
||||
{ dataKey: 'tx', color: '#10b981', label: '发送' },
|
||||
]}
|
||||
yDomain={[0, 'auto']}
|
||||
unit={unit}
|
||||
chartId={`dual-${title.replace(/\s/g, '')}`}
|
||||
title={title}
|
||||
subtitle={`接收 ${currentRx} · 发送 ${currentTx}`}
|
||||
height={208}
|
||||
toolbarPlacement="header"
|
||||
onZoomedChange={setZoomed}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 健康探针 Tab + 对话框 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/api';
|
||||
import { useHealthProbes } from '@/hooks/api/useHostMonitor';
|
||||
@@ -25,7 +26,7 @@ interface HealthProbeSectionProps {
|
||||
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
const confirm = useConfirm();
|
||||
const qc = useQueryClient();
|
||||
const { data: probes = [], refetch } = useHealthProbes(selectedHost);
|
||||
const { data: probes = [], isFetching, 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());
|
||||
@@ -89,8 +90,8 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
<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 size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-3 h-3 mr-1', isFetching && 'animate-spin')} />刷新
|
||||
</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" />添加探针
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 主机核心指标条 — CPU/内存/磁盘/网络/磁盘IO 一览 // Logan.Li
|
||||
import { Cpu, HardDrive, Network, Disc, Server } from 'lucide-react';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { formatBPS } from './utils';
|
||||
|
||||
interface Props {
|
||||
latest: JsonData;
|
||||
netInfo: JsonData;
|
||||
diskIOInfo: JsonData;
|
||||
onJump?: (tab: string) => void;
|
||||
}
|
||||
|
||||
function tone(val: number, warn: number, crit: number): string {
|
||||
if (val >= crit) return 'dg-text-red';
|
||||
if (val >= warn) return 'dg-text-yellow';
|
||||
return 'text-[var(--dg-text-primary)]';
|
||||
}
|
||||
|
||||
export function HostCoreMetricsStrip({ latest, netInfo, diskIOInfo, onJump }: Props) {
|
||||
const cpu = Number(latest?.cpu_usage ?? latest?.cpu ?? 0);
|
||||
const mem = Number(latest?.memory_usage ?? latest?.mem_usage ?? latest?.mem ?? 0);
|
||||
const disk = Number(latest?.disk_usage ?? latest?.disk ?? 0);
|
||||
const rx = netInfo?.net_rx_bps != null ? formatBPS(netInfo.net_rx_bps as number) : '--';
|
||||
const tx = netInfo?.net_tx_bps != null ? formatBPS(netInfo.net_tx_bps as number) : '--';
|
||||
const ioR = diskIOInfo?.read_bps != null ? formatBPS(diskIOInfo.read_bps as number) : '--';
|
||||
const ioW = diskIOInfo?.write_bps != null ? formatBPS(diskIOInfo.write_bps as number) : '--';
|
||||
|
||||
const items = [
|
||||
{ tab: 'basic', icon: Cpu, label: 'CPU', value: `${cpu.toFixed(1)}%`, cls: tone(cpu, 80, 90) },
|
||||
{ tab: 'basic', icon: Server, label: '内存', value: `${mem.toFixed(1)}%`, cls: tone(mem, 85, 90) },
|
||||
{ tab: 'disk', icon: HardDrive, label: '磁盘', value: `${disk.toFixed(1)}%`, cls: tone(disk, 80, 90) },
|
||||
{ tab: 'network', icon: Network, label: '网络', value: `↓${rx} ↑${tx}`, cls: 'text-[var(--dg-text-primary)]' },
|
||||
{ tab: 'disk', icon: Disc, label: '磁盘IO', value: `↓${ioR} ↑${ioW}`, cls: 'text-[var(--dg-text-primary)]' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => onJump?.(item.tab)}
|
||||
className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-2.5 py-2 text-left hover:bg-[var(--dg-bg-tertiary)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<item.icon className="h-3 w-3 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</span>
|
||||
</div>
|
||||
<p className={`text-sm font-mono font-semibold truncate ${item.cls}`}>{item.value}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// 主机监控时间范围选择器 — 预设 + 自定义 // Logan.Li
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Calendar, ChevronDown, Timer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
TIME_RANGE_PRESETS,
|
||||
type HostMonitorTimeRange,
|
||||
type TimeRangePresetId,
|
||||
} from './types';
|
||||
import { presetToRange, toDatetimeLocalValue, formatRangeSummary } from './timeRange';
|
||||
|
||||
interface Props {
|
||||
value: HostMonitorTimeRange;
|
||||
onChange: (range: HostMonitorTimeRange) => void;
|
||||
}
|
||||
|
||||
export function HostTimeRangePicker({ value, onChange }: Props) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const now = new Date();
|
||||
const [customStart, setCustomStart] = useState(toDatetimeLocalValue(new Date(now.getTime() - 3600_000)));
|
||||
const [customEnd, setCustomEnd] = useState(toDatetimeLocalValue(now));
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (popRef.current && !popRef.current.contains(e.target as Node)) setCustomOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const selectPreset = (id: TimeRangePresetId) => {
|
||||
if (id === 'custom') return;
|
||||
onChange(presetToRange(id));
|
||||
setCustomOpen(false);
|
||||
};
|
||||
|
||||
const applyCustom = () => {
|
||||
const start = new Date(customStart);
|
||||
const end = new Date(customEnd);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return;
|
||||
const s = start.getTime() <= end.getTime() ? start : end;
|
||||
const e = start.getTime() <= end.getTime() ? end : start;
|
||||
onChange({
|
||||
preset: 'custom',
|
||||
label: '自定义',
|
||||
start: s.toISOString(),
|
||||
end: e.toISOString(),
|
||||
buckets: Math.min(120, Math.max(36, Math.ceil((e.getTime() - s.getTime()) / 60_000 / 2))),
|
||||
});
|
||||
setCustomOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] dg-text-muted hidden sm:inline-flex items-center gap-1">
|
||||
<Timer className="h-3 w-3" />
|
||||
时间范围
|
||||
</span>
|
||||
<div className="flex items-center gap-1 rounded-md border border-[var(--dg-border)] p-0.5 bg-[var(--dg-bg-secondary)] flex-wrap">
|
||||
{TIME_RANGE_PRESETS.map((p) => (
|
||||
<Button
|
||||
key={p.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 text-[11px] px-2 rounded ${value.preset === p.id ? 'dg-btn-primary shadow-sm' : 'dg-text-secondary hover:dg-text-primary'}`}
|
||||
onClick={() => selectPreset(p.id)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
<div className="relative" ref={popRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 text-[11px] px-2 rounded gap-1 ${value.preset === 'custom' ? 'dg-btn-primary shadow-sm' : 'dg-text-secondary hover:dg-text-primary'}`}
|
||||
onClick={() => setCustomOpen((v) => !v)}
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
自定义
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${customOpen ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
{customOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-72 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 shadow-lg">
|
||||
<p className="text-xs font-medium text-[var(--dg-text-primary)] mb-2">自定义时间范围</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-[10px] dg-text-muted">开始</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={customStart}
|
||||
onChange={(e) => setCustomStart(e.target.value)}
|
||||
className="h-8 text-xs dg-input mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] dg-text-muted">结束</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={customEnd}
|
||||
onChange={(e) => setCustomEnd(e.target.value)}
|
||||
className="h-8 text-xs dg-input mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setCustomOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="h-7 text-xs dg-btn-primary" onClick={applyCustom}>
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] dg-text-muted tabular-nums hidden md:inline">
|
||||
{formatRangeSummary(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,71 @@
|
||||
// 指标图表卡片 // Logan.Li
|
||||
// 指标图表卡片 — 工具栏固定标题栏右上角 // Logan.Li
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Brush,
|
||||
} from 'recharts';
|
||||
import { ZoomableAreaChart, type ZoomableChartHandle, type ChartReferenceLine } from './ZoomableAreaChart';
|
||||
import { ChartToolbar } from './ChartToolbar';
|
||||
import { MetricIconBox, type Tone } from './MetricIconBox';
|
||||
import type { AggType, BucketPoint } from './types';
|
||||
|
||||
interface MetricChartCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
iconTone?: Tone;
|
||||
data: BucketPoint[];
|
||||
color: string;
|
||||
unit: string;
|
||||
domain: [number, number | 'auto'];
|
||||
aggType: AggType;
|
||||
referenceLines?: ChartReferenceLine[];
|
||||
}
|
||||
|
||||
function safeChartId(title: string) {
|
||||
return title.replace(/[^a-zA-Z0-9]/g, '-').slice(0, 32);
|
||||
}
|
||||
|
||||
export function MetricChartCard({
|
||||
title, value, icon, data, color, unit, domain, aggType,
|
||||
title, value, icon, iconTone = 'blue', data, color, unit, domain, aggType, referenceLines,
|
||||
}: MetricChartCardProps) {
|
||||
const chartData = data.map(d => ({ ...d, value: d[aggType] }));
|
||||
const chartRef = useRef<ZoomableChartHandle>(null);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const chartData = data.map((d) => ({ ...d, value: d[aggType] }));
|
||||
const shortTitle = title.replace(/\(.+\)/, '').trim();
|
||||
|
||||
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 className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<MetricIconBox icon={icon} tone={iconTone} />
|
||||
<span className="text-xs text-[var(--dg-text-primary)] font-medium truncate leading-8">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<ChartToolbar
|
||||
zoomed={zoomed}
|
||||
onExpand={() => chartRef.current?.expand()}
|
||||
onResetZoom={() => chartRef.current?.resetZoom()}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 pl-10">
|
||||
<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>
|
||||
<ZoomableAreaChart
|
||||
ref={chartRef}
|
||||
data={chartData}
|
||||
series={[{ dataKey: 'value', color, label: shortTitle }]}
|
||||
yDomain={domain}
|
||||
unit={unit}
|
||||
chartId={safeChartId(title)}
|
||||
title={title}
|
||||
subtitle={`当前 ${value}${unit ? ` ${unit}` : ''}`}
|
||||
height={188}
|
||||
toolbarPlacement="header"
|
||||
onZoomedChange={setZoomed}
|
||||
referenceLines={referenceLines}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 监控指标图标容器 — 统一尺寸与色块 // Logan.Li
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type Tone = 'blue' | 'green' | 'orange' | 'red' | 'purple' | 'cyan' | 'yellow';
|
||||
|
||||
const toneClass: Record<Tone, string> = {
|
||||
blue: 'border-blue-500/25 bg-blue-500/10 text-blue-400',
|
||||
green: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-400',
|
||||
orange: 'border-orange-500/25 bg-orange-500/10 text-orange-400',
|
||||
red: 'border-red-500/25 bg-red-500/10 text-red-400',
|
||||
purple: 'border-purple-500/25 bg-purple-500/10 text-purple-400',
|
||||
cyan: 'border-cyan-500/25 bg-cyan-500/10 text-cyan-400',
|
||||
yellow: 'border-amber-500/25 bg-amber-500/10 text-amber-400',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
icon: ReactNode;
|
||||
tone?: Tone;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricIconBox({ icon, tone = 'blue', className }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-8 h-8 rounded-lg border shrink-0',
|
||||
'[&_svg]:h-4 [&_svg]:w-4',
|
||||
toneClass[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// 可缩放面积图 — 光标拖拽选区 / 宽屏弹框展开 // Logan.Li
|
||||
import {
|
||||
useState, useMemo, useCallback, useId, useEffect,
|
||||
forwardRef, useImperativeHandle,
|
||||
} from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceArea, ReferenceLine,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ChartToolbar } from './ChartToolbar';
|
||||
import { MonitorChartTooltip } from '../shared/monitorChartTheme';
|
||||
import { ChartRangeHint } from '../shared/ChartRangeHint';
|
||||
|
||||
export interface ChartSeries {
|
||||
dataKey: string;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ChartReferenceLine {
|
||||
value: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ZoomableChartHandle {
|
||||
expand: () => void;
|
||||
resetZoom: () => void;
|
||||
}
|
||||
|
||||
interface ZoomRange {
|
||||
left: string;
|
||||
right: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Record<string, unknown>[];
|
||||
series: ChartSeries[];
|
||||
xDataKey?: string;
|
||||
height?: number | string;
|
||||
unit?: string;
|
||||
yDomain?: [number, number | 'auto'];
|
||||
chartId: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showHint?: boolean;
|
||||
/** header=按钮由父组件放在卡片标题栏;overlay=浮在图表右上角(无卡片场景) // Logan.Li */
|
||||
toolbarPlacement?: 'header' | 'overlay';
|
||||
onZoomedChange?: (zoomed: boolean) => void;
|
||||
referenceLines?: ChartReferenceLine[];
|
||||
}
|
||||
|
||||
const dialogClass =
|
||||
'!max-w-[min(96vw,88rem)] sm:!max-w-[min(96vw,88rem)] w-[min(96vw,88rem)] max-h-[92vh] flex flex-col gap-2 p-4 sm:p-5 bg-[var(--dg-bg-primary)] border-[var(--dg-border)] overflow-hidden';
|
||||
|
||||
function ChartBody({
|
||||
displayData,
|
||||
series,
|
||||
xDataKey,
|
||||
height,
|
||||
unit,
|
||||
yDomain,
|
||||
gradPrefix,
|
||||
zoomRange,
|
||||
refLeft,
|
||||
refRight,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
compact,
|
||||
referenceLines,
|
||||
}: {
|
||||
displayData: Record<string, unknown>[];
|
||||
series: ChartSeries[];
|
||||
xDataKey: string;
|
||||
height: number | string;
|
||||
unit: string;
|
||||
yDomain: [number, number | 'auto'];
|
||||
gradPrefix: string;
|
||||
zoomRange: ZoomRange | null;
|
||||
refLeft: string;
|
||||
refRight: string;
|
||||
onMouseDown: (e: { activeLabel?: string }) => void;
|
||||
onMouseMove: (e: { activeLabel?: string }) => void;
|
||||
onMouseUp: () => void;
|
||||
compact?: boolean;
|
||||
referenceLines?: ChartReferenceLine[];
|
||||
}) {
|
||||
return (
|
||||
<div style={{ height }} className="select-none w-full min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={displayData}
|
||||
margin={{ top: 6, right: compact ? 12 : 8, bottom: compact ? 12 : 8, left: 2 }}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
style={{ cursor: 'crosshair' }}
|
||||
>
|
||||
<defs>
|
||||
{series.map((s) => (
|
||||
<linearGradient key={s.dataKey} id={`${gradPrefix}-${s.dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={s.color} stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor={s.color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" strokeOpacity={0.6} />
|
||||
<XAxis
|
||||
dataKey={xDataKey}
|
||||
tick={{ fontSize: compact ? 11 : 10, fill: 'var(--dg-text-muted)' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'var(--dg-border)' }}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={compact ? 56 : 40}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: compact ? 11 : 10, fill: 'var(--dg-text-muted)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={compact ? 44 : 36}
|
||||
domain={yDomain[1] === 'auto' ? [0, (max: number) => max * 1.15] : yDomain as [number, number]}
|
||||
tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(Math.round(v * 10) / 10))}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(
|
||||
<MonitorChartTooltip
|
||||
nameFormatter={(name) => series.find((s) => s.dataKey === name)?.label || name}
|
||||
valueFormatter={(v) => `${Number(v).toFixed(2)}${unit ? ` ${unit}` : ''}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{referenceLines?.map((line) => (
|
||||
<ReferenceLine
|
||||
key={`ref-${line.value}-${line.label || ''}`}
|
||||
y={line.value}
|
||||
stroke={line.color || 'var(--dg-yellow)'}
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.75}
|
||||
label={{
|
||||
value: line.label || String(line.value),
|
||||
position: 'insideTopRight',
|
||||
fontSize: compact ? 10 : 9,
|
||||
fill: line.color || 'var(--dg-yellow)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{series.map((s) => (
|
||||
<Area
|
||||
key={s.dataKey}
|
||||
type="monotone"
|
||||
dataKey={s.dataKey}
|
||||
stroke={s.color}
|
||||
strokeWidth={compact ? 2 : 1.5}
|
||||
fill={`url(#${gradPrefix}-${s.dataKey})`}
|
||||
name={s.dataKey}
|
||||
activeDot={{ r: compact ? 5 : 4, strokeWidth: 0 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
{refLeft && refRight ? (
|
||||
<ReferenceArea
|
||||
x1={refLeft}
|
||||
x2={refRight}
|
||||
strokeOpacity={0.4}
|
||||
fill="var(--dg-blue)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--dg-blue)"
|
||||
/>
|
||||
) : null}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ZoomableAreaChart = forwardRef<ZoomableChartHandle, Props>(function ZoomableAreaChart({
|
||||
data,
|
||||
series,
|
||||
xDataKey = 'time',
|
||||
height = 192,
|
||||
unit = '',
|
||||
yDomain = [0, 'auto'],
|
||||
chartId,
|
||||
title,
|
||||
subtitle,
|
||||
showHint = true,
|
||||
toolbarPlacement = 'header',
|
||||
onZoomedChange,
|
||||
referenceLines,
|
||||
}, ref) {
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const gradPrefix = `grad-${chartId}-${uid}`;
|
||||
|
||||
const [zoomRange, setZoomRange] = useState<ZoomRange | null>(null);
|
||||
const [refLeft, setRefLeft] = useState('');
|
||||
const [refRight, setRefRight] = useState('');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const dataFingerprint = useMemo(() => {
|
||||
if (data.length === 0) return 'empty';
|
||||
const first = data[0]?.[xDataKey];
|
||||
const last = data[data.length - 1]?.[xDataKey];
|
||||
return `${data.length}|${String(first)}|${String(last)}`;
|
||||
}, [data, xDataKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoomRange(null);
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
setExpanded(false);
|
||||
}, [dataFingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
onZoomedChange?.(!!zoomRange);
|
||||
}, [zoomRange, onZoomedChange]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
if (!zoomRange || data.length === 0) return data;
|
||||
const li = data.findIndex((d) => d[xDataKey] === zoomRange.left);
|
||||
const ri = data.findIndex((d) => d[xDataKey] === zoomRange.right);
|
||||
if (li < 0 || ri < 0) return data;
|
||||
const start = Math.min(li, ri);
|
||||
const end = Math.max(li, ri);
|
||||
if (end - start < 2) return data;
|
||||
return data.slice(start, end + 1);
|
||||
}, [data, zoomRange, xDataKey]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoomRange(null);
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
expand: () => setExpanded(true),
|
||||
resetZoom: zoomOut,
|
||||
}), [zoomOut]);
|
||||
|
||||
const onMouseDown = useCallback((e: { activeLabel?: string }) => {
|
||||
if (!e?.activeLabel) return;
|
||||
setRefLeft(String(e.activeLabel));
|
||||
setRefRight(String(e.activeLabel));
|
||||
}, []);
|
||||
|
||||
const onMouseMove = useCallback((e: { activeLabel?: string }) => {
|
||||
if (!refLeft || !e?.activeLabel) return;
|
||||
setRefRight(String(e.activeLabel));
|
||||
}, [refLeft]);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (!refLeft || !refRight || refLeft === refRight) {
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
return;
|
||||
}
|
||||
setZoomRange({ left: refLeft, right: refRight });
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
}, [refLeft, refRight]);
|
||||
|
||||
const chartProps = {
|
||||
displayData,
|
||||
series,
|
||||
xDataKey,
|
||||
unit,
|
||||
yDomain,
|
||||
gradPrefix,
|
||||
zoomRange,
|
||||
refLeft,
|
||||
refRight,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
referenceLines,
|
||||
};
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-xs dg-text-muted border border-dashed border-[var(--dg-border)] rounded-md w-full"
|
||||
style={{ height }}
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
{toolbarPlacement === 'overlay' && (
|
||||
<div className="absolute top-1 right-1 z-10">
|
||||
<ChartToolbar
|
||||
zoomed={!!zoomRange}
|
||||
onExpand={() => setExpanded(true)}
|
||||
onResetZoom={zoomOut}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ChartBody {...chartProps} height={height} />
|
||||
{showHint && toolbarPlacement === 'header' && (
|
||||
<ChartRangeHint
|
||||
label={zoomRange ? `${zoomRange.left} — ${zoomRange.right}` : null}
|
||||
zoomed={!!zoomRange}
|
||||
onReset={zoomOut}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={expanded} onOpenChange={setExpanded}>
|
||||
<DialogContent className={dialogClass}>
|
||||
<DialogHeader className="shrink-0 flex-row items-start justify-between gap-4 space-y-0 pr-10">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base truncate">{title || '指标详情'}</DialogTitle>
|
||||
{subtitle && (
|
||||
<p className="text-xs dg-text-muted mt-0.5 truncate">{subtitle}</p>
|
||||
)}
|
||||
<DialogDescription className="sr-only">放大查看监控指标趋势</DialogDescription>
|
||||
</div>
|
||||
<ChartToolbar
|
||||
size="md"
|
||||
zoomed={!!zoomRange}
|
||||
onExpand={() => setExpanded(true)}
|
||||
onResetZoom={zoomOut}
|
||||
/>
|
||||
</DialogHeader>
|
||||
|
||||
{series.length > 1 && (
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs shrink-0 px-0.5">
|
||||
{series.map((s) => (
|
||||
<span key={s.dataKey} className="inline-flex items-center gap-1.5 dg-text-secondary">
|
||||
<span className="w-3 h-0.5 rounded-full" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 宽屏趋势区:21:8 比例,监控弹框标准形态 // Logan.Li */}
|
||||
<div className="w-full flex-1 min-h-[260px] max-h-[min(48vh,480px)] aspect-[21/8]">
|
||||
<ChartBody {...chartProps} height="100%" compact />
|
||||
</div>
|
||||
|
||||
{zoomRange && (
|
||||
<div className="shrink-0 border-t border-[var(--dg-border)] pt-2">
|
||||
<ChartRangeHint
|
||||
label={`${zoomRange.left} — ${zoomRange.right}`}
|
||||
zoomed
|
||||
onReset={zoomOut}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// 主机监控时间范围工具 // Logan.Li
|
||||
import {
|
||||
TIME_RANGE_PRESETS,
|
||||
DEFAULT_TIME_RANGE,
|
||||
type HostMonitorTimeRange,
|
||||
type TimeRangePresetId,
|
||||
} from './types';
|
||||
|
||||
export function presetToRange(id: TimeRangePresetId): HostMonitorTimeRange {
|
||||
const p = TIME_RANGE_PRESETS.find((x) => x.id === id);
|
||||
if (!p) return DEFAULT_TIME_RANGE;
|
||||
return { preset: p.id, label: p.label, minutes: p.minutes, buckets: p.buckets };
|
||||
}
|
||||
|
||||
export function getRangeSpanMinutes(range: HostMonitorTimeRange): number {
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
const diff = new Date(range.end).getTime() - new Date(range.start).getTime();
|
||||
return Math.max(1, Math.round(diff / 60_000));
|
||||
}
|
||||
return range.minutes ?? 60;
|
||||
}
|
||||
|
||||
/** Query 缓存 key // Logan.Li */
|
||||
export function rangeQueryKey(range: HostMonitorTimeRange): string {
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
return `custom:${range.start}:${range.end}`;
|
||||
}
|
||||
return `preset:${range.preset}:${range.minutes}`;
|
||||
}
|
||||
|
||||
export function toDatetimeLocalValue(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatRangeSummary(range: HostMonitorTimeRange): string {
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
const fmt = (s: string) => {
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
};
|
||||
return `${fmt(range.start)} — ${fmt(range.end)}`;
|
||||
}
|
||||
return range.label;
|
||||
}
|
||||
|
||||
export function buildHistoryQuery(range: HostMonitorTimeRange): {
|
||||
minutes?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
hours?: number;
|
||||
} {
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
return { start: range.start, end: range.end };
|
||||
}
|
||||
if (range.minutes != null) {
|
||||
return { minutes: range.minutes };
|
||||
}
|
||||
return { hours: 1 };
|
||||
}
|
||||
@@ -1,15 +1,44 @@
|
||||
// 主机监控类型定义 // 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 },
|
||||
/** 预设时间范围(分钟粒度,支持 15/30 分钟) // Logan.Li */
|
||||
export const TIME_RANGE_PRESETS = [
|
||||
{ id: '15m', label: '15分钟', minutes: 15, buckets: 30 },
|
||||
{ id: '30m', label: '30分钟', minutes: 30, buckets: 36 },
|
||||
{ id: '1h', label: '1小时', minutes: 60, buckets: 60 },
|
||||
{ id: '6h', label: '6小时', minutes: 360, buckets: 72 },
|
||||
{ id: '12h', label: '12小时', minutes: 720, buckets: 72 },
|
||||
{ id: '1d', label: '1天', minutes: 1440, buckets: 96 },
|
||||
{ id: '7d', label: '7天', minutes: 10080, buckets: 84 },
|
||||
] as const;
|
||||
|
||||
export type TimeRangePresetId = (typeof TIME_RANGE_PRESETS)[number]['id'] | 'custom';
|
||||
|
||||
export interface HostMonitorTimeRange {
|
||||
preset: TimeRangePresetId;
|
||||
label: string;
|
||||
/** 相对范围分钟数(预设) // Logan.Li */
|
||||
minutes?: number;
|
||||
/** 自定义绝对范围 ISO 字符串 // Logan.Li */
|
||||
start?: string;
|
||||
end?: string;
|
||||
buckets: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TIME_RANGE: HostMonitorTimeRange = {
|
||||
preset: '1h',
|
||||
label: '1小时',
|
||||
minutes: 60,
|
||||
buckets: 60,
|
||||
};
|
||||
|
||||
/** @deprecated 兼容旧引用 */
|
||||
export const TIME_RANGES = TIME_RANGE_PRESETS.map((p) => ({
|
||||
label: p.label,
|
||||
hours: p.minutes / 60,
|
||||
window: 60,
|
||||
buckets: p.buckets,
|
||||
}));
|
||||
|
||||
export type AggType = 'avg' | 'max' | 'min';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -1,8 +1,54 @@
|
||||
// 主机监控工具函数 // Logan.Li
|
||||
import type { BucketPoint } from './types';
|
||||
import type { BucketPoint, HostMonitorTimeRange } from './types';
|
||||
import type { JsonData } from '@/types/common';
|
||||
/** 按时间跨度格式化 X 轴标签(避免 7 天范围只显示时分) // Logan.Li */
|
||||
export function formatAxisTime(ts: number, spanMinutes: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
if (spanMinutes <= 180) {
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
if (spanMinutes <= 2880) {
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
}
|
||||
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
export function bucketWithStats(raw: number[], timestamps: number[], targetBuckets: number): BucketPoint[] {
|
||||
function recordTimestamp(r: JsonData): number {
|
||||
const raw = r.report_time || r.created_at;
|
||||
if (!raw) return 0;
|
||||
const t = new Date(raw as string).getTime();
|
||||
return Number.isNaN(t) ? 0 : t / 1000;
|
||||
}
|
||||
|
||||
/** 客户端二次过滤,确保图表与所选时间一致 // Logan.Li */
|
||||
export function filterHistoryByRange(history: JsonData[], range: HostMonitorTimeRange): JsonData[] {
|
||||
if (!history.length) return history;
|
||||
const now = Date.now();
|
||||
let sinceMs: number;
|
||||
let untilMs = now;
|
||||
|
||||
if (range.preset === 'custom' && range.start && range.end) {
|
||||
sinceMs = new Date(range.start).getTime();
|
||||
untilMs = new Date(range.end).getTime();
|
||||
} else {
|
||||
const mins = range.minutes ?? 60;
|
||||
sinceMs = now - mins * 60_000;
|
||||
}
|
||||
|
||||
return history.filter((r) => {
|
||||
const ts = recordTimestamp(r) * 1000;
|
||||
return ts >= sinceMs && ts <= untilMs;
|
||||
});
|
||||
}
|
||||
|
||||
export function bucketWithStats(
|
||||
raw: number[],
|
||||
timestamps: number[],
|
||||
targetBuckets: number,
|
||||
spanMinutes: number,
|
||||
): BucketPoint[] {
|
||||
if (!raw || raw.length === 0) return [];
|
||||
const perBucket = Math.max(1, Math.ceil(raw.length / targetBuckets));
|
||||
const result: BucketPoint[] = [];
|
||||
@@ -15,7 +61,7 @@ export function bucketWithStats(raw: number[], timestamps: number[], targetBucke
|
||||
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' }),
|
||||
time: formatAxisTime(ts, spanMinutes),
|
||||
timestamp: ts,
|
||||
avg: Number(avg.toFixed(2)),
|
||||
max: Number(max.toFixed(2)),
|
||||
@@ -40,9 +86,12 @@ export function formatBPS(bps: number): string {
|
||||
return `${bps.toFixed(1)} B/s`;
|
||||
}
|
||||
|
||||
interface TimeConfig { buckets: number; }
|
||||
interface TimeConfig { buckets: number; spanMinutes: number; }
|
||||
|
||||
export function buildMetricsFromHistory(history: JsonData[], timeConfig: TimeConfig) {
|
||||
const spanMinutes = timeConfig.spanMinutes;
|
||||
const bucket = (raw: number[], ts: number[]) =>
|
||||
bucketWithStats(raw, ts, timeConfig.buckets, spanMinutes);
|
||||
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[] = [];
|
||||
@@ -55,7 +104,7 @@ export function buildMetricsFromHistory(history: JsonData[], timeConfig: TimeCon
|
||||
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);
|
||||
const ts = recordTimestamp(r);
|
||||
timestamps.push(ts);
|
||||
rawCpu.push(r.cpu_usage || 0);
|
||||
rawIOWait.push(r.cpu_iowait || 0);
|
||||
@@ -95,29 +144,40 @@ export function buildMetricsFromHistory(history: JsonData[], timeConfig: TimeCon
|
||||
}
|
||||
|
||||
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),
|
||||
cpu: bucket(rawCpu, timestamps),
|
||||
mem: bucket(rawMem, timestamps),
|
||||
load1: bucket(rawLoad1, timestamps),
|
||||
disk: bucket(rawDisk, timestamps),
|
||||
tcpEst: bucket(rawTcp, timestamps),
|
||||
tcpTW: bucket(rawTw, timestamps),
|
||||
tcpCW: bucket(rawCw, timestamps),
|
||||
rx: bucket(rawRx.map((v) => v / 1024), timestamps),
|
||||
tx: bucket(rawTx.map((v) => v / 1024), timestamps),
|
||||
ioRead: bucket(rawIoR.map((v) => v / 1024), timestamps),
|
||||
ioWrite: bucket(rawIoW.map((v) => v / 1024), timestamps),
|
||||
swap: bucket(rawSwap, timestamps),
|
||||
fd: bucket(rawFD, timestamps),
|
||||
iowait: bucket(rawIOWait, timestamps),
|
||||
memAvail: bucket(rawMemAvail, timestamps),
|
||||
ioUtil: bucket(rawIoUtil, timestamps),
|
||||
connTotal: bucket(rawConnTotal, timestamps),
|
||||
rxErr: bucket(rawRxErr, timestamps),
|
||||
txErr: bucket(rawTxErr, timestamps),
|
||||
inode: bucket(rawInode, timestamps),
|
||||
};
|
||||
}
|
||||
|
||||
/** 从过滤后历史计算实际数据时间跨度(用于展示) // Logan.Li */
|
||||
export function getActualDataSpan(history: JsonData[], range: HostMonitorTimeRange): string | null {
|
||||
if (!history.length) return null;
|
||||
const ts = history.map(recordTimestamp).filter((t) => t > 0);
|
||||
if (!ts.length) return null;
|
||||
const min = Math.min(...ts);
|
||||
const max = Math.max(...ts);
|
||||
const spanMin = Math.max(1, Math.round((max - min) / 60));
|
||||
return `${formatAxisTime(min, spanMin)} — ${formatAxisTime(max, spanMin)} · ${history.length} 点`;
|
||||
}
|
||||
|
||||
export function getCurrentVal(latest: JsonData, netInfo: JsonData, diskIOInfo: JsonData, swapInfo: JsonData, fdInfo: JsonData,
|
||||
key: string, fallback = '--',
|
||||
): string {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 中间件实例卡片 — 紧凑列表项,详情见右侧可观测面板 // Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Loader2, Pencil, Trash2, Zap } from 'lucide-react';
|
||||
import type { MwTarget } from './helpers';
|
||||
import {
|
||||
evalMwHealth, fmtLastSeen, fmtVal,
|
||||
healthLevelColor, redisModeLabel, worstHealthLevel,
|
||||
} from './helpers';
|
||||
|
||||
interface Props {
|
||||
target: MwTarget;
|
||||
selected?: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
}
|
||||
|
||||
export function InstanceCard({ target, selected, onSelect, onEdit, onDelete, onTest, testing }: Props) {
|
||||
const m = target.latest_metrics || {};
|
||||
const isRedis = target.description === 'redis';
|
||||
const issues = evalMwHealth(target);
|
||||
const health = worstHealthLevel(issues);
|
||||
const healthColor = healthLevelColor(health);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`w-full text-left rounded-lg px-2.5 py-2 border transition-colors ${
|
||||
selected
|
||||
? 'border-cyan-500/50 bg-[var(--dg-bg-tertiary)] ring-1 ring-cyan-500/20'
|
||||
: 'border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] hover:border-[var(--dg-border)]/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 mb-1">
|
||||
<span className="text-sm font-medium truncate">{target.name}</span>
|
||||
{health !== 'ok' && (
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" style={{ color: healthColor }} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 truncate">
|
||||
{target.ip}:{target.port}
|
||||
{isRedis && ` · ${redisModeLabel(target.config_public?.redis_mode)}`}
|
||||
{' · '}{fmtLastSeen(target.last_seen_at)}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{isRedis ? (
|
||||
<>
|
||||
<MetricPill label="QPS" value={fmtVal(m.redis_qps, 1)} />
|
||||
<MetricPill label="Key" value={fmtVal(m.redis_db_size)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MetricPill label="QPS" value={fmtVal(m.mysql_qps, 1)} />
|
||||
<MetricPill label="线程" value={fmtVal(m.mysql_threads_running)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={onTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}测试
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={onEdit}>
|
||||
<Pencil className="h-3 w-3" />编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] dg-text-red" onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />删除
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] bg-[var(--dg-bg-primary)] rounded px-1.5 py-0.5">
|
||||
<span className="text-[var(--dg-text-muted)]">{label}</span>
|
||||
<span className="font-mono font-medium">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 中间件添加/编辑弹框 // Logan.Li
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { middlewareMonitorApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import type { MwTarget } from './helpers';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
editTarget: MwTarget | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function MiddlewareDialog({ open, onOpenChange, editTarget, onSuccess }: Props) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '', type: 'redis' as 'redis' | 'mysql', ip: '', port: 6379,
|
||||
password: '', username: '', database: '',
|
||||
redis_mode: 'standalone' as 'standalone' | 'cluster',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editTarget) {
|
||||
setForm({
|
||||
name: editTarget.name || '',
|
||||
type: editTarget.description === 'mysql' ? 'mysql' : 'redis',
|
||||
ip: editTarget.ip || '',
|
||||
port: editTarget.port || 6379,
|
||||
password: '',
|
||||
username: editTarget.config_public?.username || '',
|
||||
database: editTarget.config_public?.database || '',
|
||||
redis_mode: editTarget.config_public?.redis_mode || 'standalone',
|
||||
});
|
||||
} else {
|
||||
setForm({ name: '', type: 'redis', ip: '', port: 6379, password: '', username: '', database: '', redis_mode: 'standalone' });
|
||||
}
|
||||
}, [editTarget, open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) {
|
||||
toast.error('请填写名称和 IP');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(), type: form.type, ip: form.ip.trim(),
|
||||
port: Number(form.port) || (form.type === 'redis' ? 6379 : 3306), interval: 30,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
if (form.type === 'redis') payload.redis_mode = form.redis_mode;
|
||||
if (form.type === 'mysql') {
|
||||
if (form.username) payload.username = form.username;
|
||||
if (form.database) payload.database = form.database;
|
||||
}
|
||||
if (editTarget) {
|
||||
await middlewareMonitorApi.update(editTarget.id, payload);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
await middlewareMonitorApi.create(payload);
|
||||
toast.success('创建成功');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error(editTarget ? '更新失败' : '创建失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? '编辑中间件' : '添加中间件'}</DialogTitle>
|
||||
<DialogDescription>配置 Redis(单机/集群)或 MySQL 连接,平台自动采集指标</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-2">
|
||||
<Label>类型</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['redis', 'mysql'] as const).map((t) => (
|
||||
<button key={t} type="button" onClick={() => setForm((f) => ({ ...f, type: t, port: t === 'redis' ? 6379 : 3306 }))}
|
||||
className={`flex-1 py-2 rounded-lg text-sm ${form.type === t ? 'bg-blue-600 text-white' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
{t === 'redis' ? 'Redis' : 'MySQL'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{form.type === 'redis' && (
|
||||
<div className="space-y-2">
|
||||
<Label>部署模式</Label>
|
||||
<div className="flex gap-2">
|
||||
{[{ value: 'standalone' as const, label: '单机' }, { value: 'cluster' as const, label: '集群' }].map((opt) => (
|
||||
<button key={opt.value} type="button" onClick={() => setForm((f) => ({ ...f, redis_mode: opt.value }))}
|
||||
className={`flex-1 py-2 rounded-lg text-sm ${form.redis_mode === opt.value ? 'bg-blue-600 text-white' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label>IP *</Label>
|
||||
<Input value={form.ip} onChange={(e) => setForm((f) => ({ ...f, ip: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>端口</Label>
|
||||
<Input type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>密码 {editTarget && <span className="text-xs text-muted-foreground">(留空不修改)</span>}</Label>
|
||||
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
|
||||
</div>
|
||||
{form.type === 'mysql' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>用户名</Label>
|
||||
<Input value={form.username} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>数据库</Label>
|
||||
<Input value={form.database} onChange={(e) => setForm((f) => ({ ...f, database: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editTarget ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// 中间件指标进度条 — 内存/连接/命中率可视化 // Logan.Li
|
||||
import type { HealthLevel } from './helpers';
|
||||
import { healthLevelColor } from './helpers';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
display: string;
|
||||
level?: HealthLevel;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function MwGaugeBar({ label, value, display, level = 'ok', hint }: Props) {
|
||||
const pct = Math.min(100, Math.max(0, value));
|
||||
const color = healthLevelColor(level);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-[var(--dg-text-muted)]">{label}</span>
|
||||
<span className="font-mono text-[var(--dg-text-primary)]">{display}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[var(--dg-bg-primary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
{hint && <p className="text-[9px] text-[var(--dg-text-muted)]">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// 中间件健康告警条 // Logan.Li
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import type { MwHealthIssue } from './helpers';
|
||||
import { worstHealthLevel, healthLevelColor } from './helpers';
|
||||
|
||||
interface Props {
|
||||
issues: MwHealthIssue[];
|
||||
}
|
||||
|
||||
export function MwHealthStrip({ issues }: Props) {
|
||||
const level = worstHealthLevel(issues);
|
||||
const color = healthLevelColor(level);
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 dg-text-green shrink-0" />
|
||||
<span className="text-[var(--dg-text-muted)]">指标正常,无告警</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-xs"
|
||||
style={{ borderColor: `${color}40`, backgroundColor: `${color}10` }}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" style={{ color }} />
|
||||
{issues.map((issue) => (
|
||||
<span
|
||||
key={issue.message}
|
||||
className="rounded px-1.5 py-0.5 font-medium"
|
||||
style={{ color: healthLevelColor(issue.level), backgroundColor: `${healthLevelColor(issue.level)}15` }}
|
||||
>
|
||||
{issue.message}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 中间件概览图表 — 状态 + 命中率 + QPS/连接(支持放大)// Logan.Li
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, Cell, PieChart, Pie,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { BarChart3, PieChart as PieChartIcon, Percent } from 'lucide-react';
|
||||
import type { MwTarget } from './helpers';
|
||||
import { fmtHitRate, fmtVal } from './helpers';
|
||||
import { ChartExpandShell } from '../shared/ChartExpandShell';
|
||||
import { MonitorChartTooltip, MONITOR_PIE_STROKE, MonitorPieTooltip } from '../shared/monitorChartTheme';
|
||||
|
||||
interface Props {
|
||||
targets: MwTarget[];
|
||||
}
|
||||
|
||||
function shortName(name: string) {
|
||||
return name.length > 10 ? `${name.slice(0, 10)}…` : name;
|
||||
}
|
||||
|
||||
export function MwOverviewCharts({ targets }: Props) {
|
||||
const statusData = useMemo(() => {
|
||||
const online = targets.filter((t) => t.status === 'online').length;
|
||||
const offline = targets.length - online;
|
||||
return [
|
||||
{ name: '在线', value: online, fill: 'var(--dg-green)' },
|
||||
{ name: '离线', value: offline, fill: 'var(--dg-red)' },
|
||||
].filter((d) => d.value > 0);
|
||||
}, [targets]);
|
||||
|
||||
const redisHitData = useMemo(() => targets
|
||||
.filter((t) => t.description === 'redis' && t.latest_metrics?.redis_hit_rate > 0)
|
||||
.map((t) => ({
|
||||
name: shortName(t.name), fullName: t.name,
|
||||
value: (t.latest_metrics.redis_hit_rate || 0) * 100,
|
||||
fill: (t.latest_metrics.redis_hit_rate || 0) >= 0.9 ? 'var(--dg-green)' : 'var(--dg-yellow)',
|
||||
}))
|
||||
.sort((a, b) => a.value - b.value), [targets]);
|
||||
|
||||
const redisQpsData = useMemo(() => targets
|
||||
.filter((t) => t.description === 'redis' && t.latest_metrics?.redis_qps > 0)
|
||||
.map((t) => ({ name: shortName(t.name), fullName: t.name, value: t.latest_metrics.redis_qps, fill: 'var(--dg-blue)' }))
|
||||
.sort((a, b) => b.value - a.value), [targets]);
|
||||
|
||||
const mysqlConnData = useMemo(() => targets
|
||||
.filter((t) => t.description === 'mysql' && t.latest_metrics?.mysql_connections > 0)
|
||||
.map((t) => ({ name: shortName(t.name), fullName: t.name, value: t.latest_metrics.mysql_connections, fill: 'var(--dg-blue)' }))
|
||||
.sort((a, b) => b.value - a.value), [targets]);
|
||||
|
||||
if (targets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<OverviewChartCard title="实例状态" icon={<PieChartIcon className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={statusData.length === 0}>
|
||||
{(h) => (
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<PieChart>
|
||||
<Pie data={statusData} dataKey="value" nameKey="name" cx="50%" cy="50%"
|
||||
innerRadius={h * 0.22} outerRadius={h * 0.34} paddingAngle={2} isAnimationActive={false}>
|
||||
{statusData.map((e) => <Cell key={e.name} fill={e.fill} stroke={MONITOR_PIE_STROKE} strokeWidth={2} />)}
|
||||
</Pie>
|
||||
<Tooltip content={<MonitorPieTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</OverviewChartCard>
|
||||
|
||||
<OverviewChartCard title="Redis 命中率" icon={<Percent className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={redisHitData.length === 0}>
|
||||
{(h) => (
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<BarChart data={redisHitData} layout="vertical" margin={{ left: 0, right: 8 }}>
|
||||
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickFormatter={(v) => `${v}%`} />
|
||||
<YAxis type="category" dataKey="name" width={64} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtHitRate(v / 100)} nameFormatter={() => '命中率'} />} />
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={14} isAnimationActive={false}>
|
||||
{redisHitData.map((e) => <Cell key={e.fullName} fill={e.fill} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</OverviewChartCard>
|
||||
|
||||
<OverviewChartCard title="Redis QPS" icon={<BarChart3 className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={redisQpsData.length === 0}>
|
||||
{(h) => (
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<BarChart data={redisQpsData} layout="vertical" margin={{ left: 0, right: 8 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} />
|
||||
<YAxis type="category" dataKey="name" width={64} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtVal(v, 1)} nameFormatter={() => 'QPS'} />} />
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={14} isAnimationActive={false}>
|
||||
{redisQpsData.map((e) => <Cell key={e.fullName} fill={e.fill} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</OverviewChartCard>
|
||||
|
||||
<OverviewChartCard title="MySQL 连接" icon={<BarChart3 className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} empty={mysqlConnData.length === 0}>
|
||||
{(h) => (
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<BarChart data={mysqlConnData} layout="vertical" margin={{ left: 0, right: 8 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} />
|
||||
<YAxis type="category" dataKey="name" width={64} tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtVal(v)} nameFormatter={() => '连接'} />} />
|
||||
<Bar dataKey="value" radius={[0, 3, 3, 0]} maxBarSize={14} isAnimationActive={false}>
|
||||
{mysqlConnData.map((e) => <Cell key={e.fullName} fill={e.fill} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</OverviewChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewChartCard({
|
||||
title, icon, empty, children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
empty?: boolean;
|
||||
children: (height: number) => ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<ChartExpandShell
|
||||
title={title}
|
||||
icon={icon}
|
||||
className="bg-transparent p-0"
|
||||
compactHeight={140}
|
||||
expandedHeight={360}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</ChartExpandShell>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// 中间件实例可观测面板 — 批量拉取 + 健康度 + 仪表盘 // Logan.Li
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Activity, LineChart, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { middlewareMonitorApi } from '@/api';
|
||||
import type { MwChartDef, MwTarget, MwTimeRange } from './helpers';
|
||||
import {
|
||||
MW_TIME_RANGE_HOURS, fmtMetricDisplay, fmtMemory, fmtHitRate, fmtVal, fmtLastSeen,
|
||||
mysqlConnPct, redisMemoryPct, redisModeLabel, groupHistoryByMetric, counterToDelta,
|
||||
evalMwHealth, gaugeTone, gaugeToneLow, REDIS_PRIMARY_KEYS, MYSQL_PRIMARY_KEYS,
|
||||
} from './helpers';
|
||||
import { MwTrendChart } from './MwTrendChart';
|
||||
import { MwGaugeBar } from './MwGaugeBar';
|
||||
import { MwHealthStrip } from './MwHealthStrip';
|
||||
import { ChartExpandShell } from '../shared/ChartExpandShell';
|
||||
|
||||
interface Props {
|
||||
target: MwTarget | null;
|
||||
chartDefs: MwChartDef[];
|
||||
accent: 'redis' | 'mysql';
|
||||
}
|
||||
|
||||
export function MwTargetChartsPanel({ target, chartDefs, accent }: Props) {
|
||||
const [timeRange, setTimeRange] = useState<MwTimeRange>('6h');
|
||||
const [historyMap, setHistoryMap] = useState<Record<string, Array<{ timestamp: number; value: number }>>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const primaryKeys = accent === 'redis' ? REDIS_PRIMARY_KEYS : MYSQL_PRIMARY_KEYS;
|
||||
const visibleDefs = useMemo(
|
||||
() => (showAll ? chartDefs : chartDefs.filter((d) => primaryKeys.includes(d.key))),
|
||||
[showAll, chartDefs, primaryKeys],
|
||||
);
|
||||
|
||||
const loadCharts = useCallback(async () => {
|
||||
if (!target) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await middlewareMonitorApi.getMetrics(target.id, {
|
||||
hours: MW_TIME_RANGE_HOURS[timeRange],
|
||||
}) as { list?: Array<{ metric_name: string; timestamp: number; value: number }> };
|
||||
setHistoryMap(groupHistoryByMetric(res?.list || []));
|
||||
} catch {
|
||||
setHistoryMap({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [target, timeRange]);
|
||||
|
||||
useEffect(() => { loadCharts(); }, [loadCharts]);
|
||||
|
||||
// 30s 静默刷新趋势 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
const timer = setInterval(loadCharts, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [target, loadCharts]);
|
||||
|
||||
if (!target) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-full">
|
||||
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
选择左侧实例查看趋势与健康度
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const m = target.latest_metrics || {};
|
||||
const memPct = redisMemoryPct(m);
|
||||
const connPct = mysqlConnPct(m);
|
||||
const healthIssues = evalMwHealth(target);
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-full">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-2 mb-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />
|
||||
<h3 className="text-sm font-medium truncate">可观测 · {target.name}</h3>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{target.ip}:{target.port}</Badge>
|
||||
{accent === 'redis' && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{redisModeLabel(target.config_public?.redis_mode)}</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">更新 {fmtLastSeen(target.last_seen_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? '核心指标' : '全部指标'}
|
||||
</Button>
|
||||
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as MwTimeRange)}>
|
||||
<SelectTrigger className="h-7 w-[92px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">近 1 小时</SelectItem>
|
||||
<SelectItem value="6h">近 6 小时</SelectItem>
|
||||
<SelectItem value="24h">近 24 小时</SelectItem>
|
||||
<SelectItem value="7d">近 7 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={loadCharts} disabled={loading}>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4"><MwHealthStrip issues={healthIssues} /></div>
|
||||
|
||||
{/* 仪表盘 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
|
||||
{accent === 'redis' && (
|
||||
<>
|
||||
<MwGaugeBar
|
||||
label="命中率"
|
||||
value={(m.redis_hit_rate || 0) * 100}
|
||||
display={fmtHitRate(m.redis_hit_rate)}
|
||||
level={m.redis_hit_rate > 0 ? gaugeToneLow((m.redis_hit_rate || 0) * 100, 90, 80) : 'ok'}
|
||||
/>
|
||||
<MwGaugeBar
|
||||
label="内存占用"
|
||||
value={memPct ?? (m.redis_used_memory > 0 ? 50 : 0)}
|
||||
display={fmtMemory(m.redis_used_memory)}
|
||||
level={memPct != null ? gaugeTone(memPct, 80, 90) : 'ok'}
|
||||
hint={memPct != null ? `已用 ${memPct.toFixed(0)}%` : '未配置 maxmemory'}
|
||||
/>
|
||||
<MwGaugeBar
|
||||
label="连接数"
|
||||
value={Math.min(100, (m.redis_connected_clients || 0) / 2)}
|
||||
display={fmtVal(m.redis_connected_clients)}
|
||||
level={(m.redis_blocked_clients || 0) > 0 ? 'warn' : 'ok'}
|
||||
hint={m.redis_cluster_known_nodes ? `集群 ${fmtVal(m.redis_cluster_known_nodes)} 节点` : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{accent === 'mysql' && (
|
||||
<>
|
||||
<MwGaugeBar
|
||||
label="缓冲池命中率"
|
||||
value={(m.mysql_buffer_hit_rate || 0) * 100}
|
||||
display={fmtHitRate(m.mysql_buffer_hit_rate)}
|
||||
level={m.mysql_buffer_hit_rate > 0 ? gaugeToneLow((m.mysql_buffer_hit_rate || 0) * 100, 95, 90) : 'ok'}
|
||||
/>
|
||||
<MwGaugeBar
|
||||
label="连接占用"
|
||||
value={connPct ?? 0}
|
||||
display={`${fmtVal(m.mysql_connections)} / ${fmtVal(m.mysql_max_connections)}`}
|
||||
level={connPct != null ? gaugeTone(connPct, 80, 90) : 'ok'}
|
||||
/>
|
||||
<MwGaugeBar
|
||||
label="活跃线程"
|
||||
value={Math.min(100, (m.mysql_threads_running || 0) * 5)}
|
||||
display={fmtVal(m.mysql_threads_running)}
|
||||
level={(m.mysql_threads_running || 0) > 20 ? 'warn' : 'ok'}
|
||||
hint={`慢查询 ${fmtVal(m.mysql_slow_queries)}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 核心摘要 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
{accent === 'redis' ? (
|
||||
<>
|
||||
<SummaryTile label="QPS" value={fmtVal(m.redis_qps, 1)} />
|
||||
<SummaryTile label="Key 数" value={fmtVal(m.redis_db_size)} />
|
||||
<SummaryTile label="逐出 Key" value={fmtVal(m.redis_evicted_keys)} warn={(m.redis_evicted_keys || 0) > 0} />
|
||||
<SummaryTile label="碎片率" value={m.redis_mem_fragmentation_ratio ? fmtVal(m.redis_mem_fragmentation_ratio, 2) : '--'} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SummaryTile label="QPS" value={fmtVal(m.mysql_qps, 1)} />
|
||||
<SummaryTile label="接收流量" value={fmtMemory(m.mysql_bytes_received)} />
|
||||
<SummaryTile label="发送流量" value={fmtMemory(m.mysql_bytes_sent)} />
|
||||
<SummaryTile label="运行时长" value={m.mysql_uptime ? `${Math.floor(m.mysql_uptime / 3600)}h` : '--'} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-10 flex items-center justify-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载趋势...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{visibleDefs.map((def) => {
|
||||
const series = def.counterDelta ? counterToDelta(historyMap[def.key] || []) : (historyMap[def.key] || []);
|
||||
return (
|
||||
<ChartExpandShell
|
||||
key={def.key}
|
||||
className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-3"
|
||||
title={`${def.label} · ${fmtMetricDisplay(def, m[def.key] ?? 0)}`}
|
||||
icon={<LineChart className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />}
|
||||
compactHeight={150}
|
||||
expandedHeight={400}
|
||||
empty={series.length === 0}
|
||||
>
|
||||
{(h) => (
|
||||
<MwTrendChart
|
||||
data={series}
|
||||
color={def.color}
|
||||
unit={def.unit}
|
||||
percent={def.percent}
|
||||
formatter={def.formatter}
|
||||
height={h}
|
||||
/>
|
||||
)}
|
||||
</ChartExpandShell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-lg p-3 text-center ${warn ? 'bg-amber-500/10' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className={`text-sm font-mono font-semibold mt-0.5 ${warn ? 'dg-text-yellow' : ''}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 中间件指标趋势图 — 光标拖拽选区 // Logan.Li
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
ReferenceArea,
|
||||
} from 'recharts';
|
||||
import { fmtChartTime, fmtHitRate, fmtVal } from './helpers';
|
||||
import { MonitorChartTooltip, MONITOR_CHART_GRID, MONITOR_CHART_TICK } from '../shared/monitorChartTheme';
|
||||
import { useChartRangeZoom } from '../shared/useChartRangeZoom';
|
||||
import { ChartRangeHint } from '../shared/ChartRangeHint';
|
||||
|
||||
interface Props {
|
||||
data: Array<{ timestamp: number; value: number }>;
|
||||
color: string;
|
||||
unit?: string;
|
||||
percent?: boolean;
|
||||
formatter?: (v: number) => string;
|
||||
height?: number;
|
||||
showRangeHint?: boolean;
|
||||
}
|
||||
|
||||
function fmtValue(v: number, percent?: boolean, formatter?: (v: number) => string, unit?: string): string {
|
||||
if (formatter) return formatter(v);
|
||||
if (percent) return fmtHitRate(v);
|
||||
const base = fmtVal(v, unit ? 1 : 0);
|
||||
return unit ? `${base} ${unit}` : base;
|
||||
}
|
||||
|
||||
export function MwTrendChart({
|
||||
data, color, unit, percent, formatter, height = 150, showRangeHint = true,
|
||||
}: Props) {
|
||||
const chartData = useMemo(
|
||||
() => data.map((p) => ({ time: fmtChartTime(p.timestamp), value: p.value })),
|
||||
[data],
|
||||
);
|
||||
|
||||
const {
|
||||
displayData, refLeft, refRight, selectionLabel, resetZoom, chartHandlers, zoomed,
|
||||
} = useChartRangeZoom(chartData, 'time');
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center text-xs text-[var(--dg-text-muted)]" style={{ height }}>
|
||||
暂无趋势数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gradId = `mw-grad-${color.replace(/[^a-z]/gi, '')}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={displayData} margin={{ top: 4, right: 6, left: 0, bottom: 4 }} {...chartHandlers}>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
|
||||
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" minTickGap={32} />
|
||||
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={42} domain={percent ? [0, 1] : ['auto', 'auto']} tickFormatter={(v: number) => (percent ? `${(v * 100).toFixed(0)}%` : fmtVal(v, 0))} />
|
||||
<Tooltip
|
||||
content={(
|
||||
<MonitorChartTooltip
|
||||
valueFormatter={(v) => fmtValue(v, percent, formatter, unit)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{refLeft && refRight && refLeft !== refRight && (
|
||||
<ReferenceArea x1={refLeft} x2={refRight} strokeOpacity={0.4} fill="var(--dg-blue)" fillOpacity={0.12} />
|
||||
)}
|
||||
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={2} fill={`url(#${gradId})`} dot={false} activeDot={{ r: 3, fill: color, stroke: 'var(--dg-bg-primary)', strokeWidth: 2 }} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
{showRangeHint && (
|
||||
<ChartRangeHint label={selectionLabel} zoomed={zoomed} onReset={resetZoom} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// 中间件监控可视化辅助 // Logan.Li
|
||||
|
||||
export interface MwConfigPublic {
|
||||
username?: string;
|
||||
database?: string;
|
||||
redis_mode?: 'standalone' | 'cluster';
|
||||
}
|
||||
|
||||
export interface MwTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
description: string;
|
||||
sub_type?: string;
|
||||
config_public?: MwConfigPublic;
|
||||
latest_metrics: Record<string, number>;
|
||||
last_seen_at?: string;
|
||||
}
|
||||
|
||||
export interface MwHistoryPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
metric_name?: string;
|
||||
}
|
||||
|
||||
export type MwTimeRange = '1h' | '6h' | '24h' | '7d';
|
||||
|
||||
export const MW_TIME_RANGE_HOURS: Record<MwTimeRange, number> = {
|
||||
'1h': 1,
|
||||
'6h': 6,
|
||||
'24h': 24,
|
||||
'7d': 168,
|
||||
};
|
||||
|
||||
export interface MwChartDef {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
unit?: string;
|
||||
percent?: boolean;
|
||||
formatter?: (v: number) => string;
|
||||
/** 累计 counter 转周期增量 // Logan.Li */
|
||||
counterDelta?: boolean;
|
||||
}
|
||||
|
||||
export type HealthLevel = 'ok' | 'warn' | 'crit';
|
||||
|
||||
export interface MwHealthIssue {
|
||||
level: HealthLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const REDIS_PRIMARY_KEYS = ['redis_qps', 'redis_used_memory', 'redis_hit_rate', 'redis_connected_clients'];
|
||||
export const MYSQL_PRIMARY_KEYS = ['mysql_qps', 'mysql_connections', 'mysql_buffer_hit_rate', 'mysql_slow_queries'];
|
||||
|
||||
export const REDIS_CHARTS: MwChartDef[] = [
|
||||
{ key: 'redis_qps', label: 'QPS', color: 'var(--dg-blue)', unit: 'ops/s' },
|
||||
{ key: 'redis_used_memory', label: '内存占用', color: 'var(--dg-red)', formatter: fmtMemory },
|
||||
{ key: 'redis_hit_rate', label: '命中率', color: 'var(--dg-green)', percent: true },
|
||||
{ key: 'redis_connected_clients', label: '连接数', color: 'var(--dg-cyan)' },
|
||||
{ key: 'redis_db_size', label: 'Key 总数', color: 'var(--dg-purple)' },
|
||||
{ key: 'redis_blocked_clients', label: '阻塞客户端', color: 'var(--dg-orange)' },
|
||||
];
|
||||
|
||||
export const MYSQL_CHARTS: MwChartDef[] = [
|
||||
{ key: 'mysql_qps', label: 'QPS', color: 'var(--dg-blue)', unit: 'q/s' },
|
||||
{ key: 'mysql_connections', label: '连接数', color: 'var(--dg-cyan)' },
|
||||
{ key: 'mysql_threads_running', label: '活跃线程', color: 'var(--dg-purple)' },
|
||||
{ key: 'mysql_buffer_hit_rate', label: '缓冲池命中率', color: 'var(--dg-green)', percent: true },
|
||||
{ key: 'mysql_slow_queries', label: '慢查询速率', color: 'var(--dg-orange)', unit: '次/周期', counterDelta: true },
|
||||
{ key: 'mysql_bytes_sent', label: '发送流量', color: 'var(--dg-amber)', formatter: fmtMemory },
|
||||
];
|
||||
|
||||
export 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`;
|
||||
}
|
||||
|
||||
export function fmtHitRate(v: number): string {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function fmtVal(v: number, precision = 0): string {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
return v.toFixed(precision);
|
||||
}
|
||||
|
||||
export function fmtChartTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
export function fmtMetricDisplay(def: MwChartDef, value: number): string {
|
||||
if (def.formatter) return def.formatter(value);
|
||||
if (def.percent) return fmtHitRate(value);
|
||||
return fmtVal(value, def.key.includes('qps') ? 1 : 0);
|
||||
}
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'var(--dg-green)';
|
||||
case 'warning': return 'var(--dg-yellow)';
|
||||
case 'error':
|
||||
case 'offline': return 'var(--dg-red)';
|
||||
default: return 'var(--dg-text-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusTextClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'dg-text-green';
|
||||
case 'warning': return 'dg-text-yellow';
|
||||
case 'error':
|
||||
case 'offline': return 'dg-text-red';
|
||||
default: return 'dg-text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusBg(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-emerald-400';
|
||||
case 'warning': return 'bg-amber-400';
|
||||
case 'error':
|
||||
case 'offline': return 'bg-red-400';
|
||||
default: return 'bg-[var(--dg-bg-active)]';
|
||||
}
|
||||
}
|
||||
|
||||
export function redisModeLabel(mode?: string): string {
|
||||
return mode === 'cluster' ? '集群' : '单机';
|
||||
}
|
||||
|
||||
export function redisMemoryPct(metrics: Record<string, number>): number | null {
|
||||
const used = metrics.redis_used_memory;
|
||||
const max = metrics.redis_maxmemory;
|
||||
if (!used || !max || max <= 0) return null;
|
||||
return Math.min(100, (used / max) * 100);
|
||||
}
|
||||
|
||||
export function mysqlConnPct(metrics: Record<string, number>): number | null {
|
||||
const conn = metrics.mysql_connections;
|
||||
const max = metrics.mysql_max_connections;
|
||||
if (!conn || !max || max <= 0) return null;
|
||||
return Math.min(100, (conn / max) * 100);
|
||||
}
|
||||
|
||||
/** 累计 counter 转周期增量(如慢查询)// Logan.Li */
|
||||
export function counterToDelta(
|
||||
points: Array<{ timestamp: number; value: number }>,
|
||||
): Array<{ timestamp: number; value: number }> {
|
||||
if (points.length < 2) return [];
|
||||
const out: Array<{ timestamp: number; value: number }> = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const cur = points[i];
|
||||
const delta = cur.value - prev.value;
|
||||
if (delta >= 0) out.push({ timestamp: cur.timestamp, value: delta });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按指标名分组历史时序 // Logan.Li */
|
||||
export function groupHistoryByMetric(
|
||||
rows: Array<{ metric_name: string; timestamp: number; value: number }>,
|
||||
): Record<string, Array<{ timestamp: number; value: number }>> {
|
||||
const out: Record<string, Array<{ timestamp: number; value: number }>> = {};
|
||||
for (const row of rows) {
|
||||
if (!out[row.metric_name]) out[row.metric_name] = [];
|
||||
out[row.metric_name].push({ timestamp: row.timestamp, value: row.value });
|
||||
}
|
||||
for (const key of Object.keys(out)) {
|
||||
out[key].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function gaugeTone(pct: number, warn = 70, crit = 85): HealthLevel {
|
||||
if (pct >= crit) return 'crit';
|
||||
if (pct >= warn) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function gaugeToneLow(pct: number, warn = 90, crit = 80): HealthLevel {
|
||||
if (pct < crit) return 'crit';
|
||||
if (pct < warn) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function healthLevelColor(level: HealthLevel): string {
|
||||
if (level === 'crit') return 'var(--dg-red)';
|
||||
if (level === 'warn') return 'var(--dg-yellow)';
|
||||
return 'var(--dg-green)';
|
||||
}
|
||||
|
||||
/** 评估实例健康告警 // Logan.Li */
|
||||
export function evalMwHealth(target: MwTarget): MwHealthIssue[] {
|
||||
const m = target.latest_metrics || {};
|
||||
const issues: MwHealthIssue[] = [];
|
||||
if (target.status !== 'online') {
|
||||
issues.push({ level: 'crit', message: '实例离线' });
|
||||
return issues;
|
||||
}
|
||||
if (target.description === 'redis') {
|
||||
if (m.redis_hit_rate > 0 && m.redis_hit_rate < 0.8) issues.push({ level: 'crit', message: '命中率 < 80%' });
|
||||
else if (m.redis_hit_rate > 0 && m.redis_hit_rate < 0.9) issues.push({ level: 'warn', message: '命中率 < 90%' });
|
||||
const memPct = redisMemoryPct(m);
|
||||
if (memPct != null && memPct > 90) issues.push({ level: 'crit', message: '内存 > 90%' });
|
||||
else if (memPct != null && memPct > 80) issues.push({ level: 'warn', message: '内存 > 80%' });
|
||||
if (m.redis_blocked_clients > 0) issues.push({ level: 'warn', message: '阻塞客户端' });
|
||||
if (m.redis_evicted_keys > 0) issues.push({ level: 'warn', message: 'Key 逐出' });
|
||||
} else if (target.description === 'mysql') {
|
||||
if (m.mysql_buffer_hit_rate > 0 && m.mysql_buffer_hit_rate < 0.9) {
|
||||
issues.push({ level: 'crit', message: '缓冲池命中 < 90%' });
|
||||
} else if (m.mysql_buffer_hit_rate > 0 && m.mysql_buffer_hit_rate < 0.95) {
|
||||
issues.push({ level: 'warn', message: '缓冲池命中 < 95%' });
|
||||
}
|
||||
const connPct = mysqlConnPct(m);
|
||||
if (connPct != null && connPct > 90) issues.push({ level: 'crit', message: '连接 > 90% 上限' });
|
||||
else if (connPct != null && connPct > 80) issues.push({ level: 'warn', message: '连接 > 80% 上限' });
|
||||
if (m.mysql_threads_running > 20) issues.push({ level: 'warn', message: '活跃线程偏多' });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function worstHealthLevel(issues: MwHealthIssue[]): HealthLevel {
|
||||
if (issues.some((i) => i.level === 'crit')) return 'crit';
|
||||
if (issues.some((i) => i.level === 'warn')) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function fmtLastSeen(iso?: string): string {
|
||||
if (!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '--';
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 60) return `${diff}s 前`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m 前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h 前`;
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// 监控图表放大壳 — 复用主机监控 ChartToolbar + 宽屏弹框 // Logan.Li
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ChartToolbar } from '../host-monitor/ChartToolbar';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
/** 紧凑视图高度 */
|
||||
compactHeight?: number;
|
||||
/** 弹框内图表高度 */
|
||||
expandedHeight?: number;
|
||||
/** 无数据时不显示放大按钮 */
|
||||
empty?: boolean;
|
||||
className?: string;
|
||||
children: (height: number) => ReactNode;
|
||||
}
|
||||
|
||||
export function ChartExpandShell({
|
||||
title,
|
||||
icon,
|
||||
compactHeight = 180,
|
||||
expandedHeight = 420,
|
||||
empty = false,
|
||||
className = 'rounded-lg bg-[var(--dg-bg-tertiary)] p-3',
|
||||
children,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{icon}
|
||||
<span className="text-xs text-[var(--dg-text-muted)] truncate">{title}</span>
|
||||
</div>
|
||||
{!empty && <ChartToolbar onExpand={() => setExpanded(true)} />}
|
||||
</div>
|
||||
{empty ? (
|
||||
<p className="text-xs text-center py-10 text-[var(--dg-text-muted)]">暂无数据</p>
|
||||
) : (
|
||||
children(compactHeight)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={expanded} onOpenChange={setExpanded}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">{title}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">放大查看监控趋势与指标细节</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pt-2">{children(expandedHeight)}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 图表选区提示 — 仅缩放后显示恢复入口 // Logan.Li
|
||||
import { ZoomOut } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
label: string | null;
|
||||
zoomed: boolean;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ChartRangeHint({ label, zoomed, onReset }: Props) {
|
||||
if (!zoomed) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--dg-text-muted)] mt-1 px-0.5">
|
||||
<span>已选时段: <span className="text-[var(--dg-text-primary)] font-mono">{label}</span></span>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-6 text-[10px] px-2" onClick={onReset}>
|
||||
<ZoomOut className="h-3 w-3 mr-1" />恢复全量
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 监控图表选区框架 — 光标拖拽缩放 // Logan.Li
|
||||
import type { ReactElement } from 'react';
|
||||
import { ResponsiveContainer } from 'recharts';
|
||||
import { ChartRangeHint } from './ChartRangeHint';
|
||||
import { useChartRangeZoom } from './useChartRangeZoom';
|
||||
|
||||
export interface RangeChartContext<T extends Record<string, unknown>> {
|
||||
displayData: T[];
|
||||
chartHandlers: ReturnType<typeof useChartRangeZoom<T>>['chartHandlers'];
|
||||
refLeft: string;
|
||||
refRight: string;
|
||||
}
|
||||
|
||||
interface Props<T extends Record<string, unknown>> {
|
||||
data: T[];
|
||||
xKey: string;
|
||||
height: number;
|
||||
children: (ctx: RangeChartContext<T>) => ReactElement;
|
||||
}
|
||||
|
||||
export function MonitorRangeChartFrame<T extends Record<string, unknown>>({
|
||||
data,
|
||||
xKey,
|
||||
height,
|
||||
children,
|
||||
}: Props<T>) {
|
||||
const {
|
||||
displayData,
|
||||
refLeft,
|
||||
refRight,
|
||||
selectionLabel,
|
||||
resetZoom,
|
||||
chartHandlers,
|
||||
zoomed,
|
||||
} = useChartRangeZoom(data, xKey);
|
||||
|
||||
return (
|
||||
<div className="select-none w-full">
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{children({
|
||||
displayData,
|
||||
chartHandlers,
|
||||
refLeft,
|
||||
refRight,
|
||||
})}
|
||||
</ResponsiveContainer>
|
||||
<ChartRangeHint label={selectionLabel} zoomed={zoomed} onReset={resetZoom} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 监控图表主题 — 暗黑主题 Tooltip / 饼图高对比 // Logan.Li
|
||||
import type { TooltipProps } from 'recharts';
|
||||
|
||||
type TipPayload = { name?: string; value?: number | string; color?: string; dataKey?: string };
|
||||
|
||||
export const MONITOR_CHART_TICK = { fontSize: 10, fill: 'var(--dg-text-secondary)' };
|
||||
export const MONITOR_CHART_GRID = 'var(--dg-border)';
|
||||
export const MONITOR_PIE_STROKE = 'var(--dg-bg-secondary)';
|
||||
|
||||
/** 折线/柱状图 Tooltip — 高对比暗黑主题 // Logan.Li */
|
||||
export function MonitorChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
valueFormatter,
|
||||
nameFormatter,
|
||||
}: TooltipProps<number, string> & {
|
||||
valueFormatter?: (v: number, name: string) => string;
|
||||
nameFormatter?: (name: string) => string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border shadow-lg px-3 py-2 min-w-[120px]"
|
||||
style={{
|
||||
backgroundColor: 'var(--dg-bg-primary)',
|
||||
borderColor: 'var(--dg-border-strong, var(--dg-border))',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
{label != null && label !== '' && (
|
||||
<p className="text-[11px] font-medium text-[var(--dg-text-primary)] mb-1.5 border-b border-[var(--dg-border)] pb-1">
|
||||
{String(label)}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{(payload as TipPayload[]).map((p) => {
|
||||
const raw = typeof p.value === 'number' ? p.value : Number(p.value);
|
||||
const text = valueFormatter && !Number.isNaN(raw)
|
||||
? valueFormatter(raw, String(p.name ?? p.dataKey ?? ''))
|
||||
: String(p.value ?? '--');
|
||||
return (
|
||||
<div key={`${p.name}-${p.dataKey}`} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-sm shrink-0 ring-1 ring-[var(--dg-border)]"
|
||||
style={{ backgroundColor: p.color || 'var(--dg-blue)' }}
|
||||
/>
|
||||
<span className="text-[var(--dg-text-secondary)]">
|
||||
{nameFormatter
|
||||
? nameFormatter(String(p.name ?? p.dataKey ?? ''))
|
||||
: (p.name ?? p.dataKey)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono font-semibold text-[var(--dg-text-primary)]">{text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 饼图 Tooltip — 避免默认白底低对比 // Logan.Li */
|
||||
export function MonitorPieTooltip({ active, payload }: TooltipProps<number, string>) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0] as TipPayload;
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--dg-bg-primary)',
|
||||
borderColor: 'var(--dg-border-strong, var(--dg-border))',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full shrink-0 ring-2 ring-[var(--dg-bg-primary)]"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{p.name}</span>
|
||||
<span className="font-mono font-bold text-[var(--dg-text-primary)]">{p.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// 图表光标拖拽选区 — 主机/中间件/SNMP/站点拨测通用 // Logan.Li
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface ChartZoomRange {
|
||||
left: string;
|
||||
right: string;
|
||||
}
|
||||
|
||||
export function useChartRangeZoom<T extends Record<string, unknown>>(
|
||||
data: T[],
|
||||
xKey: string,
|
||||
) {
|
||||
const [zoomRange, setZoomRange] = useState<ChartZoomRange | null>(null);
|
||||
const [refLeft, setRefLeft] = useState('');
|
||||
const [refRight, setRefRight] = useState('');
|
||||
|
||||
const fingerprint = useMemo(() => {
|
||||
if (data.length === 0) return 'empty';
|
||||
return `${data.length}|${String(data[0]?.[xKey])}|${String(data[data.length - 1]?.[xKey])}`;
|
||||
}, [data, xKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoomRange(null);
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
}, [fingerprint]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
if (!zoomRange || data.length === 0) return data;
|
||||
const li = data.findIndex((d) => String(d[xKey]) === zoomRange.left);
|
||||
const ri = data.findIndex((d) => String(d[xKey]) === zoomRange.right);
|
||||
if (li < 0 || ri < 0) return data;
|
||||
const start = Math.min(li, ri);
|
||||
const end = Math.max(li, ri);
|
||||
if (end - start < 1) return data;
|
||||
return data.slice(start, end + 1);
|
||||
}, [data, zoomRange, xKey]);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setZoomRange(null);
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
}, []);
|
||||
|
||||
const onMouseDown = useCallback((e: { activeLabel?: string | number }) => {
|
||||
if (e?.activeLabel == null) return;
|
||||
const label = String(e.activeLabel);
|
||||
setRefLeft(label);
|
||||
setRefRight(label);
|
||||
}, []);
|
||||
|
||||
const onMouseMove = useCallback((e: { activeLabel?: string | number }) => {
|
||||
if (!refLeft || e?.activeLabel == null) return;
|
||||
setRefRight(String(e.activeLabel));
|
||||
}, [refLeft]);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (!refLeft || !refRight || refLeft === refRight) {
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
return;
|
||||
}
|
||||
setZoomRange({ left: refLeft, right: refRight });
|
||||
setRefLeft('');
|
||||
setRefRight('');
|
||||
}, [refLeft, refRight]);
|
||||
|
||||
const chartHandlers = {
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
style: { cursor: 'crosshair' as const },
|
||||
};
|
||||
|
||||
const selectionLabel = zoomRange ? `${zoomRange.left} — ${zoomRange.right}` : null;
|
||||
|
||||
return {
|
||||
displayData,
|
||||
zoomRange,
|
||||
refLeft,
|
||||
refRight,
|
||||
selectionLabel,
|
||||
resetZoom,
|
||||
chartHandlers,
|
||||
zoomed: !!zoomRange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 拨测协议数量分布饼图 // Logan.Li
|
||||
import { useMemo } from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { MONITOR_PIE_STROKE, MonitorPieTooltip } from '../shared/monitorChartTheme';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PieChart as PieChartIcon } from 'lucide-react';
|
||||
import { PROBE_TYPE_OPTIONS, normalizeProbeType } from './helpers';
|
||||
import type { SiteTask } from './helpers';
|
||||
|
||||
interface Props {
|
||||
tasks: SiteTask[];
|
||||
}
|
||||
|
||||
const PROTOCOL_COLORS: Record<string, string> = {
|
||||
HTTP: 'var(--dg-blue)',
|
||||
SSL: '#a855f7',
|
||||
TCP: '#06b6d4',
|
||||
DNS: 'var(--dg-green)',
|
||||
PING: '#f59e0b',
|
||||
};
|
||||
|
||||
export function SiteProtocolPieChart({ tasks }: Props) {
|
||||
const protocolData = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of PROBE_TYPE_OPTIONS) counts[t] = 0;
|
||||
for (const t of tasks) {
|
||||
const key = normalizeProbeType(t.type);
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
return PROBE_TYPE_OPTIONS
|
||||
.map((name) => ({ name, value: counts[name] || 0, fill: PROTOCOL_COLORS[name] || 'var(--dg-text-muted)' }))
|
||||
.filter((d) => d.value > 0);
|
||||
}, [tasks]);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-6 text-center text-sm text-[var(--dg-text-muted)]">暂无任务</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<PieChartIcon 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-[10px] text-[var(--dg-text-muted)]">共 {tasks.length} 个任务</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={protocolData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={48}
|
||||
outerRadius={76}
|
||||
paddingAngle={2}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{protocolData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.fill} stroke={MONITOR_PIE_STROKE} strokeWidth={2} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<MonitorPieTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 mt-1">
|
||||
{protocolData.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: d.fill }} />
|
||||
{d.name} {d.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// 拨测可观测面板 — 按协议展示专属指标与事件 // Logan.Li
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Activity, Loader2, RefreshCw, ShieldAlert, Zap } from 'lucide-react';
|
||||
import { siteApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import type { SiteResult, SiteTask, SiteTimeRange } from './helpers';
|
||||
import {
|
||||
fmtMs, fmtTime, fmtSslDays, isSslProbeTask, isSslExpiringSoon,
|
||||
SSL_EXPIRY_WARN_DAYS, normalizeProbeType,
|
||||
SITE_TIME_RANGE_HOURS, SITE_TIME_RANGE_LABELS,
|
||||
} from './helpers';
|
||||
import { SiteProtocolObs } from './observability/SiteProtocolObs';
|
||||
|
||||
interface Props {
|
||||
task: SiteTask | null;
|
||||
onProbed: () => void;
|
||||
/** 嵌入详情弹框时去掉外层 Card // Logan.Li */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const ok = status === 'success';
|
||||
const timeout = status === 'timeout';
|
||||
return (
|
||||
<Badge className={ok
|
||||
? 'bg-emerald-500/15 dg-text-green border-emerald-500/30 text-[10px]'
|
||||
: 'bg-red-500/15 dg-text-red border-red-500/30 text-[10px]'}>
|
||||
{ok ? '成功' : timeout ? '超时' : '失败'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function httpCodeBadge(code?: number, expected = 200) {
|
||||
if (!code) return <span className="text-[var(--dg-text-muted)]">--</span>;
|
||||
const ok = code === expected || (expected === 200 && code >= 200 && code < 300);
|
||||
const tone = ok ? 'dg-text-green' : code >= 500 ? 'dg-text-red' : code >= 400 ? 'dg-text-yellow' : 'dg-text-red';
|
||||
return <span className={`font-mono text-xs font-medium ${tone}`}>{code}</span>;
|
||||
}
|
||||
|
||||
export function SiteResultsPanel({ task, onProbed, embedded = false }: Props) {
|
||||
const [results, setResults] = useState<SiteResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [timeRange, setTimeRange] = useState<SiteTimeRange>('6h');
|
||||
|
||||
const rangeLabel = SITE_TIME_RANGE_LABELS[timeRange];
|
||||
|
||||
const loadResults = useCallback(async () => {
|
||||
if (!task) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await siteApi.getResults(task.id, {
|
||||
hours: SITE_TIME_RANGE_HOURS[timeRange],
|
||||
}) as { list: SiteResult[] };
|
||||
setResults(data?.list || []);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [task, timeRange]);
|
||||
|
||||
useEffect(() => { loadResults(); }, [loadResults]);
|
||||
|
||||
const handleProbeNow = async () => {
|
||||
if (!task) return;
|
||||
setProbing(true);
|
||||
try {
|
||||
await siteApi.probeNow(task.id);
|
||||
toast.success('拨测完成');
|
||||
loadResults();
|
||||
onProbed();
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '拨测失败');
|
||||
} finally {
|
||||
setProbing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const availSummary = useMemo(() => {
|
||||
if (results.length === 0) return '--';
|
||||
const successCount = results.filter((r) => r.status === 'success').length;
|
||||
return `${(successCount / results.length * 100).toFixed(1)}%`;
|
||||
}, [results]);
|
||||
|
||||
if (!task) {
|
||||
const empty = (
|
||||
<div className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
选择任务查看协议专属可观测指标
|
||||
</div>
|
||||
);
|
||||
return embedded ? empty : (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent>{empty}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const probeType = normalizeProbeType(task.type);
|
||||
const isSsl = isSslProbeTask(task);
|
||||
const sslDays = results.find((r) => r.ssl_days_remaining != null)?.ssl_days_remaining ?? task.ssl_days_remaining;
|
||||
const sslInfo = fmtSslDays(sslDays);
|
||||
const sslWarn = isSsl && isSslExpiringSoon(sslDays);
|
||||
|
||||
const renderTableHead = () => {
|
||||
switch (probeType) {
|
||||
case 'HTTP':
|
||||
return (
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs">时间</TableHead>
|
||||
<TableHead className="text-xs">状态</TableHead>
|
||||
<TableHead className="text-xs">状态码</TableHead>
|
||||
<TableHead className="text-xs">延迟</TableHead>
|
||||
<TableHead className="text-xs">说明</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
case 'SSL':
|
||||
return (
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs">时间</TableHead>
|
||||
<TableHead className="text-xs">握手</TableHead>
|
||||
<TableHead className="text-xs">到期日</TableHead>
|
||||
<TableHead className="text-xs">剩余</TableHead>
|
||||
<TableHead className="text-xs">签发者</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
case 'TCP':
|
||||
return (
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs">时间</TableHead>
|
||||
<TableHead className="text-xs">连通</TableHead>
|
||||
<TableHead className="text-xs">延迟</TableHead>
|
||||
<TableHead className="text-xs">远端</TableHead>
|
||||
<TableHead className="text-xs">说明</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-xs">时间</TableHead>
|
||||
<TableHead className="text-xs">状态</TableHead>
|
||||
<TableHead className="text-xs">延迟</TableHead>
|
||||
<TableHead className="text-xs">解析/主机</TableHead>
|
||||
<TableHead className="text-xs">说明</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTableRow = (r: SiteResult) => {
|
||||
switch (probeType) {
|
||||
case 'HTTP':
|
||||
return (
|
||||
<TableRow key={r.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{fmtTime(r.checked_at)}</TableCell>
|
||||
<TableCell>{statusBadge(r.status)}</TableCell>
|
||||
<TableCell>{httpCodeBadge(r.http_status_code, task.expected_status_code)}</TableCell>
|
||||
<TableCell className="text-xs">{fmtMs(r.response_time || r.total_time)}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[160px] truncate">{r.error_message || '--'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
case 'SSL': {
|
||||
const info = fmtSslDays(r.ssl_days_remaining);
|
||||
return (
|
||||
<TableRow key={r.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{fmtTime(r.checked_at)}</TableCell>
|
||||
<TableCell>{statusBadge(r.status)}</TableCell>
|
||||
<TableCell className="text-xs font-mono">{r.ssl_cert_expiry?.slice(0, 10) || '--'}</TableCell>
|
||||
<TableCell className={`text-xs font-mono ${info.tone}`}>{info.text}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[140px] truncate">{r.ssl_cert_issuer || '--'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
case 'TCP':
|
||||
return (
|
||||
<TableRow key={r.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{fmtTime(r.checked_at)}</TableCell>
|
||||
<TableCell>{statusBadge(r.status)}</TableCell>
|
||||
<TableCell className="text-xs">{fmtMs(r.response_time || r.total_time)}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] truncate max-w-[120px]">{r.remote_ip || '--'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[160px] truncate">{r.error_message || (r.status === 'success' ? '连通' : '--')}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<TableRow key={r.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{fmtTime(r.checked_at)}</TableCell>
|
||||
<TableCell>{statusBadge(r.status)}</TableCell>
|
||||
<TableCell className="text-xs">{fmtMs(r.response_time || r.total_time)}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] truncate max-w-[120px]">{r.remote_ip || '--'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[160px] truncate">{r.error_message || '--'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{!embedded && (
|
||||
<>
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] truncate">可观测 · {task.name}</h3>
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] shrink-0">{probeType}</Badge>
|
||||
</>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">
|
||||
{rangeLabel} · {results.length} 次 · 可用率 {availSummary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as SiteTimeRange)}>
|
||||
<SelectTrigger className="h-7 w-[92px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">近 1 小时</SelectItem>
|
||||
<SelectItem value="6h">近 6 小时</SelectItem>
|
||||
<SelectItem value="24h">近 24 小时</SelectItem>
|
||||
<SelectItem value="7d">近 7 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={loadResults} disabled={loading}>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={handleProbeNow} disabled={probing}>
|
||||
{probing ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Zap className="h-3 w-3 mr-1" />}立即拨测
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sslWarn && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-xs">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0 mt-0.5 dg-text-yellow" />
|
||||
<div>
|
||||
<p className="font-medium text-[var(--dg-text-primary)]">SSL 证书预警</p>
|
||||
<p className="mt-0.5 text-[var(--dg-text-muted)]">
|
||||
到期 <span className="font-mono">{task.ssl_cert_expiry?.slice(0, 10) || '--'}</span>,剩余 <span className={sslInfo.tone}>{sslInfo.text}</span>(阈值 {SSL_EXPIRY_WARN_DAYS} 天)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SiteProtocolObs task={task} results={results} rangeLabel={rangeLabel} />
|
||||
<div className="mt-4 overflow-x-auto max-h-56">
|
||||
{results.length === 0 ? (
|
||||
<p className="text-center py-6 text-sm text-[var(--dg-text-muted)]">该时段暂无拨测记录</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>{renderTableHead()}</TableHeader>
|
||||
<TableBody>{results.map(renderTableRow)}</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <div className="p-1">{body}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">{body}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 拨测任务详情弹框 — 可观测指标与历史记录 // Logan.Li
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { SiteTask } from './helpers';
|
||||
import { fmtFrequency, normalizeProbeType } from './helpers';
|
||||
import { SiteResultsPanel } from './SiteResultsPanel';
|
||||
|
||||
const dialogClass =
|
||||
'!max-w-[min(96vw,88rem)] sm:!max-w-[min(96vw,88rem)] w-[min(96vw,88rem)] max-h-[92vh] flex flex-col gap-2 p-4 sm:p-5 bg-[var(--dg-bg-primary)] border-[var(--dg-border)] overflow-hidden';
|
||||
|
||||
interface Props {
|
||||
task: SiteTask | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onProbed: () => void;
|
||||
}
|
||||
|
||||
export function SiteTaskDetailDialog({ task, open, onOpenChange, onProbed }: Props) {
|
||||
if (!task) return null;
|
||||
|
||||
const probeType = normalizeProbeType(task.type);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={dialogClass}>
|
||||
<DialogHeader className="shrink-0 space-y-1 pr-8">
|
||||
<DialogTitle className="text-base truncate">{task.name}</DialogTitle>
|
||||
<DialogDescription className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="truncate max-w-full font-mono text-[var(--dg-text-muted)]">{task.url}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{probeType}</Badge>
|
||||
<span className="text-[var(--dg-text-muted)]">频率 {fmtFrequency(task.frequency_sec)}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SiteResultsPanel task={task} onProbed={onProbed} embedded />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// 站点拨测辅助函数 // Logan.Li
|
||||
|
||||
export const PROBE_TYPE_OPTIONS = ['HTTP', 'SSL', 'TCP', 'DNS', 'PING'] as const;
|
||||
export type ProbeType = (typeof PROBE_TYPE_OPTIONS)[number];
|
||||
|
||||
export 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;
|
||||
success_rate?: number;
|
||||
alert_status?: 'normal' | 'alerting';
|
||||
created_at: string;
|
||||
ssl_days_remaining?: number;
|
||||
ssl_cert_expiry?: string;
|
||||
}
|
||||
|
||||
export interface SiteResult {
|
||||
id: number;
|
||||
task_id: number;
|
||||
status: string;
|
||||
http_status_code?: number;
|
||||
response_time?: number;
|
||||
total_time: number;
|
||||
remote_ip?: string;
|
||||
error_message?: string;
|
||||
checked_at: string;
|
||||
ssl_days_remaining?: number;
|
||||
ssl_cert_expiry?: string;
|
||||
ssl_cert_issuer?: string;
|
||||
}
|
||||
|
||||
export interface SiteStats {
|
||||
total_tasks: number;
|
||||
enabled_tasks: number;
|
||||
up_count: number;
|
||||
down_count: number;
|
||||
availability: number;
|
||||
probe_count: number;
|
||||
}
|
||||
|
||||
export type SiteTimeRange = '1h' | '6h' | '24h' | '7d';
|
||||
|
||||
export const SITE_TIME_RANGE_HOURS: Record<SiteTimeRange, number> = {
|
||||
'1h': 1,
|
||||
'6h': 6,
|
||||
'24h': 24,
|
||||
'7d': 168,
|
||||
};
|
||||
|
||||
export const SITE_TIME_RANGE_LABELS: Record<SiteTimeRange, string> = {
|
||||
'1h': '近 1 小时',
|
||||
'6h': '近 6 小时',
|
||||
'24h': '近 24 小时',
|
||||
'7d': '近 7 天',
|
||||
};
|
||||
|
||||
export const probeTypeMeta: Record<ProbeType, { label: string; placeholder: string; hint: string }> = {
|
||||
HTTP: { label: '目标 URL', placeholder: 'https://example.com/health', hint: 'Web 请求(支持 http/https),检测状态码与响应时间' },
|
||||
SSL: { label: 'SSL 地址', placeholder: 'example.com:443 或 https://example.com', hint: 'TLS 握手检测证书(支持 host:port / https:// 域名,不发起 HTTP 请求)' },
|
||||
TCP: { label: '主机:端口', placeholder: '192.168.1.1:8080 或 example.com:443', hint: 'TCP 端口连通性' },
|
||||
DNS: { label: '域名', placeholder: 'example.com', hint: 'DNS 解析延迟与结果' },
|
||||
PING: { label: '主机地址', placeholder: '192.168.1.1 或 example.com', hint: 'ICMP 连通性与延迟' },
|
||||
};
|
||||
|
||||
/** 拨测频率展示 // Logan.Li */
|
||||
export function fmtFrequency(sec?: number): string {
|
||||
if (!sec) return '--';
|
||||
if (sec < 60) return `${sec}秒`;
|
||||
if (sec < 3600) return `${Math.round(sec / 60)}分钟`;
|
||||
return `${(sec / 3600).toFixed(1)}小时`;
|
||||
}
|
||||
|
||||
/** 可用率 / 响应时间组合展示 // Logan.Li */
|
||||
export function fmtAvailResponse(task: Pick<SiteTask, 'success_rate' | 'last_response_time_ms'>): string {
|
||||
const avail = task.success_rate != null && task.success_rate >= 0
|
||||
? `${task.success_rate.toFixed(1)}%`
|
||||
: '--';
|
||||
return `${avail} / ${fmtMs(task.last_response_time_ms)}`;
|
||||
}
|
||||
|
||||
export function fmtMs(ms?: number): string {
|
||||
if (ms == null) return '--';
|
||||
if (ms < 1) return '0ms';
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
/** 列表时间:MM-DD HH:mm // Logan.Li */
|
||||
export 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())}`;
|
||||
}
|
||||
|
||||
/** SSL 证书预警阈值(天)// Logan.Li */
|
||||
export const SSL_EXPIRY_WARN_DAYS = 30;
|
||||
|
||||
/** 是否为 SSL 证书拨测任务 // Logan.Li */
|
||||
export function isSslProbeTask(task: Pick<SiteTask, 'type'>): boolean {
|
||||
const t = task.type?.toUpperCase();
|
||||
return t === 'SSL' || t === 'HTTPS';
|
||||
}
|
||||
|
||||
/** 证书是否进入预警期(默认 < 30 天)// Logan.Li */
|
||||
export function isSslExpiringSoon(days?: number, threshold = SSL_EXPIRY_WARN_DAYS): boolean {
|
||||
return days != null && days < threshold;
|
||||
}
|
||||
|
||||
/** SSL 证书剩余天数展示 // Logan.Li */
|
||||
export function fmtSslDays(days?: number): { text: string; tone: string } {
|
||||
if (days == null) return { text: '--', tone: 'text-[var(--dg-text-muted)]' };
|
||||
if (days < 0) return { text: '已过期', tone: 'dg-text-red' };
|
||||
if (days <= 7) return { text: `${days}天`, tone: 'dg-text-red' };
|
||||
if (days <= 30) return { text: `${days}天`, tone: 'dg-text-yellow' };
|
||||
return { text: `${days}天`, tone: 'dg-text-green' };
|
||||
}
|
||||
|
||||
/** 归一化拨测协议类型 // Logan.Li */
|
||||
export function normalizeProbeType(raw?: string): ProbeType {
|
||||
const upper = (raw || 'HTTP').toUpperCase();
|
||||
if (upper === 'HTTPS') return 'HTTP';
|
||||
if (PROBE_TYPE_OPTIONS.includes(upper as ProbeType)) return upper as ProbeType;
|
||||
return 'HTTP';
|
||||
}
|
||||
|
||||
export function isHttpProbeTask(task: Pick<SiteTask, 'type'>): boolean {
|
||||
return normalizeProbeType(task.type) === 'HTTP';
|
||||
}
|
||||
|
||||
export function fmtChartTime(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 拨测异常事件时间线 // Logan.Li
|
||||
import { AlertTriangle, Info, XCircle } from 'lucide-react';
|
||||
import type { ProbeEvent } from './analytics';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
events: ProbeEvent[];
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
info: Info,
|
||||
warn: AlertTriangle,
|
||||
error: XCircle,
|
||||
};
|
||||
|
||||
const toneMap = {
|
||||
info: 'text-[var(--dg-text-muted)]',
|
||||
warn: 'dg-text-yellow',
|
||||
error: 'dg-text-red',
|
||||
};
|
||||
|
||||
export function SiteEventTimeline({ title, events, emptyText = '近期无异常事件' }: Props) {
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-2">{title}</p>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-4 text-center">{emptyText}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 max-h-36 overflow-y-auto">
|
||||
{events.map((e) => {
|
||||
const Icon = iconMap[e.severity];
|
||||
return (
|
||||
<li key={e.id} className="flex items-start gap-2 text-xs">
|
||||
<Icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${toneMap[e.severity]}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[var(--dg-text-muted)] font-mono">{e.timeLabel}</span>
|
||||
<span className="mx-1.5 text-[var(--dg-text-primary)]">{e.title}</span>
|
||||
{e.detail && <span className="text-[var(--dg-text-muted)] truncate block">{e.detail}</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// 站点拨测 — 按协议可观测图表(拖拽选区 + 暗黑 Tooltip)// Logan.Li
|
||||
import {
|
||||
Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line,
|
||||
ReferenceArea, ReferenceLine, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { Globe, Network, Radio, Shield, Wifi } from 'lucide-react';
|
||||
import type { SiteResult, SiteTask } from '../helpers';
|
||||
import { fmtMs, fmtSslDays, normalizeProbeType } from '../helpers';
|
||||
import {
|
||||
analyzeDnsResults, analyzeHttpResults, analyzePingResults,
|
||||
analyzeSslResults, analyzeTcpResults,
|
||||
} from './analytics';
|
||||
import { SiteEventTimeline } from './SiteEventTimeline';
|
||||
import { ChartExpandShell } from '../../shared/ChartExpandShell';
|
||||
import { MonitorRangeChartFrame } from '../../shared/MonitorRangeChartFrame';
|
||||
import { MonitorChartTooltip, MONITOR_CHART_GRID, MONITOR_CHART_TICK } from '../../shared/monitorChartTheme';
|
||||
|
||||
interface Props {
|
||||
task: SiteTask;
|
||||
results: SiteResult[];
|
||||
rangeLabel?: string;
|
||||
}
|
||||
|
||||
function SelectionArea({ left, right }: { left: string; right: string }) {
|
||||
if (!left || !right || left === right) return null;
|
||||
return (
|
||||
<ReferenceArea
|
||||
x1={left}
|
||||
x2={right}
|
||||
strokeOpacity={0.4}
|
||||
fill="var(--dg-blue)"
|
||||
fillOpacity={0.12}
|
||||
stroke="var(--dg-blue)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpObs({ task, results, rangeLabel = '近 6 小时' }: Props) {
|
||||
const s = analyzeHttpResults(results, task);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: `期望 ${s.expectedCode}`, value: s.okCount, tone: 'dg-text-green' },
|
||||
{ label: '4xx', value: s.err4xx, tone: 'dg-text-yellow' },
|
||||
{ label: '5xx', value: s.err5xx, tone: 'dg-text-red' },
|
||||
{ label: '超时/其他', value: s.otherErr, tone: 'dg-text-red' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-base font-bold ${item.tone}`}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ChartExpandShell
|
||||
icon={<Globe className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
|
||||
title={`HTTP 状态码时序(${rangeLabel})`}
|
||||
empty={s.chart.length === 0}
|
||||
>
|
||||
{(h) => (
|
||||
<MonitorRangeChartFrame data={s.chart} xKey="time" height={h}>
|
||||
{({ displayData, chartHandlers, refLeft, refRight }) => (
|
||||
<BarChart data={displayData} margin={{ top: 4, right: 8, left: 0, bottom: 4 }} {...chartHandlers}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
|
||||
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" />
|
||||
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={36} domain={[0, 'auto']} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => `HTTP ${v}`} nameFormatter={() => '状态码'} />} />
|
||||
<ReferenceLine y={s.expectedCode} stroke="var(--dg-green)" strokeDasharray="4 4" label={{ value: `期望${s.expectedCode}`, fontSize: 10, fill: 'var(--dg-text-muted)' }} />
|
||||
<Bar dataKey="code" radius={[2, 2, 0, 0]} maxBarSize={14} isAnimationActive={false}>
|
||||
{displayData.map((d, i) => (
|
||||
<Cell key={`${d.time}-${i}`} fill={d.ok ? 'var(--dg-green)' : d.code >= 500 ? 'var(--dg-red)' : d.code >= 400 ? '#eab308' : 'var(--dg-red)'} />
|
||||
))}
|
||||
</Bar>
|
||||
<SelectionArea left={refLeft} right={refRight} />
|
||||
</BarChart>
|
||||
)}
|
||||
</MonitorRangeChartFrame>
|
||||
)}
|
||||
</ChartExpandShell>
|
||||
<SiteEventTimeline title="4xx / 5xx / 超时事件" events={s.events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SslObs({ task, results }: Props) {
|
||||
const s = analyzeSslResults(results, task);
|
||||
const sslInfo = fmtSslDays(s.days);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: '证书到期', value: s.expiry || '--', tone: 'text-[var(--dg-text-primary)]' },
|
||||
{ label: '剩余天数', value: sslInfo.text, tone: sslInfo.tone },
|
||||
{ label: '签发者', value: s.issuer ? (s.issuer.length > 16 ? `${s.issuer.slice(0, 16)}…` : s.issuer) : '--', tone: 'text-[var(--dg-text-primary)]' },
|
||||
{ label: '握手延迟', value: fmtMs(s.handshakeMs), tone: 'text-[var(--dg-text-primary)]' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-sm font-bold truncate ${item.tone}`} title={String(item.value)}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ChartExpandShell
|
||||
icon={<Shield className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
|
||||
title="证书剩余天数趋势"
|
||||
compactHeight={160}
|
||||
empty={s.chart.length === 0}
|
||||
>
|
||||
{(h) => (
|
||||
<MonitorRangeChartFrame data={s.chart} xKey="time" height={h}>
|
||||
{({ displayData, chartHandlers, refLeft, refRight }) => (
|
||||
<ComposedChart data={displayData} margin={{ top: 4, right: 8, left: 0, bottom: 4 }} {...chartHandlers}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
|
||||
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} />
|
||||
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={32} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => `${v} 天`} nameFormatter={() => '剩余'} />} />
|
||||
<ReferenceLine y={30} stroke="#eab308" strokeDasharray="4 4" label={{ value: '30天', fontSize: 10, fill: 'var(--dg-text-muted)' }} />
|
||||
<ReferenceLine y={7} stroke="var(--dg-red)" strokeDasharray="4 4" label={{ value: '7天', fontSize: 10, fill: 'var(--dg-text-muted)' }} />
|
||||
<Line type="monotone" dataKey="days" stroke="var(--dg-blue)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<SelectionArea left={refLeft} right={refRight} />
|
||||
</ComposedChart>
|
||||
)}
|
||||
</MonitorRangeChartFrame>
|
||||
)}
|
||||
</ChartExpandShell>
|
||||
<SiteEventTimeline title="证书 / 握手异常" events={s.events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyObs({ results, mode, rangeLabel = '近 6 小时' }: Props & { mode: 'PING' | 'DNS' }) {
|
||||
const s = mode === 'PING' ? analyzePingResults(results) : analyzeDnsResults(results);
|
||||
const label = mode === 'PING' ? 'PING 延迟波动' : 'DNS 解析延迟';
|
||||
const Icon = mode === 'PING' ? Wifi : Radio;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ label: '平均', value: fmtMs(s.avg) },
|
||||
{ label: '最小', value: fmtMs(s.min) },
|
||||
{ label: '最大', value: fmtMs(s.max) },
|
||||
{ label: 'P95', value: fmtMs(s.p95) },
|
||||
{ label: '波动次数', value: String(s.spikeCount), tone: s.spikeCount > 0 ? 'dg-text-yellow' : undefined },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-sm font-bold ${item.tone || 'text-[var(--dg-text-primary)]'}`}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ChartExpandShell
|
||||
icon={<Icon className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
|
||||
title={`${label}(${rangeLabel})`}
|
||||
empty={s.chart.length === 0}
|
||||
>
|
||||
{(h) => (
|
||||
<MonitorRangeChartFrame data={s.chart} xKey="time" height={h}>
|
||||
{({ displayData, chartHandlers, refLeft, refRight }) => (
|
||||
<ComposedChart data={displayData} margin={{ top: 4, right: 8, left: 0, bottom: 4 }} {...chartHandlers}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
|
||||
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" />
|
||||
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={42} tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`)} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v, name) => (name === 'latency' ? fmtMs(v) : String(v))} nameFormatter={(name) => (name === 'latency' ? '延迟' : name)} />} />
|
||||
<ReferenceLine y={s.avg} stroke="var(--dg-blue)" strokeDasharray="4 4" label={{ value: '均值', fontSize: 10, fill: 'var(--dg-text-muted)' }} />
|
||||
<Line type="monotone" dataKey="latency" stroke="var(--dg-blue)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<SelectionArea left={refLeft} right={refRight} />
|
||||
</ComposedChart>
|
||||
)}
|
||||
</MonitorRangeChartFrame>
|
||||
)}
|
||||
</ChartExpandShell>
|
||||
<SiteEventTimeline title="延迟尖峰 / 失败事件" events={s.events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TcpObs({ results, rangeLabel = '近 6 小时' }: Props) {
|
||||
const s = analyzeTcpResults(results);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: `连通率(${rangeLabel})`, value: s.upRate, tone: 'dg-text-green' },
|
||||
{ label: '不通次数', value: String(s.failCount), tone: s.failCount > 0 ? 'dg-text-red' : 'text-[var(--dg-text-primary)]' },
|
||||
{ label: '最近失败', value: s.lastFailAt || '--', tone: 'text-[var(--dg-text-primary)]' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-sm font-bold ${item.tone}`}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ChartExpandShell
|
||||
icon={<Network className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
|
||||
title="TCP 连通状态时序"
|
||||
compactHeight={120}
|
||||
empty={s.chart.length === 0}
|
||||
>
|
||||
{(h) => (
|
||||
<MonitorRangeChartFrame data={s.chart} xKey="time" height={h}>
|
||||
{({ displayData, chartHandlers, refLeft, refRight }) => (
|
||||
<ComposedChart data={displayData} margin={{ top: 4, right: 8, left: 0, bottom: 4 }} {...chartHandlers}>
|
||||
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" />
|
||||
<YAxis hide domain={[0, 100]} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => (v >= 100 ? '连通' : '不通')} nameFormatter={() => '状态'} />} />
|
||||
<Area type="stepAfter" dataKey="up" stroke="var(--dg-green)" fill="var(--dg-green)" fillOpacity={0.2} isAnimationActive={false} />
|
||||
<SelectionArea left={refLeft} right={refRight} />
|
||||
</ComposedChart>
|
||||
)}
|
||||
</MonitorRangeChartFrame>
|
||||
)}
|
||||
</ChartExpandShell>
|
||||
<SiteEventTimeline title="TCP 不通时间点" events={s.events} emptyText="近期 TCP 均连通" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteProtocolObs({ task, results, rangeLabel }: Props) {
|
||||
const type = normalizeProbeType(task.type);
|
||||
if (type === 'SSL') return <SslObs task={task} results={results} rangeLabel={rangeLabel} />;
|
||||
if (type === 'PING') return <LatencyObs task={task} results={results} mode="PING" rangeLabel={rangeLabel} />;
|
||||
if (type === 'DNS') return <LatencyObs task={task} results={results} mode="DNS" rangeLabel={rangeLabel} />;
|
||||
if (type === 'TCP') return <TcpObs task={task} results={results} rangeLabel={rangeLabel} />;
|
||||
return <HttpObs task={task} results={results} rangeLabel={rangeLabel} />;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// 站点拨测可观测分析 — 按协议聚合指标与异常事件 // Logan.Li
|
||||
import type { SiteResult, SiteTask } from '../helpers';
|
||||
import { fmtMs, fmtTime, normalizeProbeType } from '../helpers';
|
||||
|
||||
export interface ProbeEvent {
|
||||
id: string;
|
||||
at: string;
|
||||
timeLabel: string;
|
||||
severity: 'info' | 'warn' | 'error';
|
||||
title: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface HttpObsSummary {
|
||||
expectedCode: number;
|
||||
okCount: number;
|
||||
err4xx: number;
|
||||
err5xx: number;
|
||||
otherErr: number;
|
||||
lastCode?: number;
|
||||
lastCodeTone: string;
|
||||
events: ProbeEvent[];
|
||||
chart: Array<{ time: string; code: number; tone: string; ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface SslObsSummary {
|
||||
expiry?: string;
|
||||
issuer?: string;
|
||||
days?: number;
|
||||
daysTone: string;
|
||||
handshakeMs: number;
|
||||
events: ProbeEvent[];
|
||||
chart: Array<{ time: string; days: number }>;
|
||||
}
|
||||
|
||||
export interface LatencyObsSummary {
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
p95: number;
|
||||
jitter: number;
|
||||
spikeCount: number;
|
||||
events: ProbeEvent[];
|
||||
chart: Array<{ time: string; latency: number; failed: boolean; spike: boolean }>;
|
||||
}
|
||||
|
||||
export interface ConnectivityObsSummary {
|
||||
upRate: string;
|
||||
failCount: number;
|
||||
lastFailAt?: string;
|
||||
events: ProbeEvent[];
|
||||
chart: Array<{ time: string; up: number; failed: boolean }>;
|
||||
}
|
||||
|
||||
const tooltipOrder = (results: SiteResult[]) => [...results].reverse();
|
||||
|
||||
function httpCodeTone(code: number, expected: number): string {
|
||||
if (code === expected || (expected === 200 && code >= 200 && code < 300)) return 'dg-text-green';
|
||||
if (code >= 500) return 'dg-text-red';
|
||||
if (code >= 400) return 'dg-text-yellow';
|
||||
return 'dg-text-red';
|
||||
}
|
||||
|
||||
function pushEvent(events: ProbeEvent[], r: SiteResult, title: string, severity: ProbeEvent['severity'], detail?: string) {
|
||||
events.push({
|
||||
id: `${r.id}-${title}`,
|
||||
at: r.checked_at,
|
||||
timeLabel: fmtTime(r.checked_at),
|
||||
severity,
|
||||
title,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
export function analyzeHttpResults(results: SiteResult[], task: SiteTask): HttpObsSummary {
|
||||
const expected = task.expected_status_code || 200;
|
||||
const chart = tooltipOrder(results).map((r) => {
|
||||
const code = r.http_status_code ?? (r.status === 'success' ? expected : 0);
|
||||
const ok = r.status === 'success' && (code === expected || (expected === 200 && code >= 200 && code < 300));
|
||||
return { time: fmtTime(r.checked_at).slice(-5), code, tone: code > 0 ? httpCodeTone(code, expected) : 'dg-text-red', ok };
|
||||
});
|
||||
let okCount = 0;
|
||||
let err4xx = 0;
|
||||
let err5xx = 0;
|
||||
let otherErr = 0;
|
||||
const events: ProbeEvent[] = [];
|
||||
for (const r of results) {
|
||||
const code = r.http_status_code ?? 0;
|
||||
if (r.status === 'success' && (code === expected || (expected === 200 && code >= 200 && code < 300))) {
|
||||
okCount++;
|
||||
continue;
|
||||
}
|
||||
if (code >= 500) {
|
||||
err5xx++;
|
||||
pushEvent(events, r, `HTTP ${code}`, 'error', r.error_message || '服务端错误');
|
||||
} else if (code >= 400) {
|
||||
err4xx++;
|
||||
pushEvent(events, r, `HTTP ${code}`, 'warn', r.error_message || '客户端/鉴权错误');
|
||||
} else if (r.status === 'timeout') {
|
||||
otherErr++;
|
||||
pushEvent(events, r, '请求超时', 'error', r.error_message);
|
||||
} else {
|
||||
otherErr++;
|
||||
pushEvent(events, r, code > 0 ? `HTTP ${code}` : '请求失败', 'error', r.error_message);
|
||||
}
|
||||
}
|
||||
const latest = results[0];
|
||||
const lastCode = latest?.http_status_code;
|
||||
return {
|
||||
expectedCode: expected,
|
||||
okCount,
|
||||
err4xx,
|
||||
err5xx,
|
||||
otherErr,
|
||||
lastCode,
|
||||
lastCodeTone: lastCode ? httpCodeTone(lastCode, expected) : 'dg-text-red',
|
||||
events: events.slice(0, 8),
|
||||
chart,
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeSslResults(results: SiteResult[], task: SiteTask): SslObsSummary {
|
||||
const latest = results.find((r) => r.ssl_days_remaining != null) ?? results[0];
|
||||
const days = latest?.ssl_days_remaining ?? task.ssl_days_remaining;
|
||||
const expiry = latest?.ssl_cert_expiry?.slice(0, 10) ?? task.ssl_cert_expiry?.slice(0, 10);
|
||||
const issuer = latest?.ssl_cert_issuer;
|
||||
const events: ProbeEvent[] = [];
|
||||
for (const r of results) {
|
||||
if (r.status !== 'success') {
|
||||
pushEvent(events, r, 'TLS 握手失败', 'error', r.error_message);
|
||||
} else if (r.ssl_days_remaining != null && r.ssl_days_remaining < 30) {
|
||||
pushEvent(events, r, `证书剩余 ${r.ssl_days_remaining} 天`, r.ssl_days_remaining <= 7 ? 'error' : 'warn', expiry);
|
||||
}
|
||||
}
|
||||
const chart = tooltipOrder(results)
|
||||
.filter((r) => r.ssl_days_remaining != null)
|
||||
.map((r) => ({ time: fmtTime(r.checked_at).slice(-5), days: r.ssl_days_remaining as number }));
|
||||
const tone = days == null ? 'text-[var(--dg-text-muted)]' : days <= 7 ? 'dg-text-red' : days <= 30 ? 'dg-text-yellow' : 'dg-text-green';
|
||||
const handshakeMs = results.length
|
||||
? results.reduce((s, r) => s + (r.response_time || r.total_time || 0), 0) / results.length
|
||||
: 0;
|
||||
return { expiry, issuer, days, daysTone: tone, handshakeMs, events: events.slice(0, 8), chart };
|
||||
}
|
||||
|
||||
function analyzeLatencyResults(results: SiteResult[], label: string): LatencyObsSummary {
|
||||
const latencies = results.map((r) => r.response_time ?? r.total_time ?? 0).filter((v) => v > 0);
|
||||
const sorted = [...latencies].sort((a, b) => a - b);
|
||||
const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
||||
const min = sorted[0] ?? 0;
|
||||
const max = sorted[sorted.length - 1] ?? 0;
|
||||
const p95 = sorted.length ? sorted[Math.floor(sorted.length * 0.95)] ?? max : 0;
|
||||
const jitter = max - min;
|
||||
const spikeThreshold = Math.max(avg * 1.8, avg + 50);
|
||||
const events: ProbeEvent[] = [];
|
||||
let spikeCount = 0;
|
||||
const chart = tooltipOrder(results).map((r) => {
|
||||
const latency = r.response_time ?? r.total_time ?? 0;
|
||||
const failed = r.status !== 'success';
|
||||
const spike = !failed && latency > spikeThreshold;
|
||||
if (failed) pushEvent(events, r, `${label}失败`, 'error', r.error_message || r.remote_ip);
|
||||
else if (spike) {
|
||||
spikeCount++;
|
||||
pushEvent(events, r, `${label}延迟波动`, 'warn', fmtMs(latency));
|
||||
}
|
||||
return { time: fmtTime(r.checked_at).slice(-5), latency, failed, spike };
|
||||
});
|
||||
return { avg, min, max, p95, jitter, spikeCount, events: events.slice(0, 8), chart };
|
||||
}
|
||||
|
||||
export function analyzePingResults(results: SiteResult[]): LatencyObsSummary {
|
||||
return analyzeLatencyResults(results, 'PING');
|
||||
}
|
||||
|
||||
export function analyzeDnsResults(results: SiteResult[]): LatencyObsSummary {
|
||||
return analyzeLatencyResults(results, 'DNS 解析');
|
||||
}
|
||||
|
||||
export function analyzeTcpResults(results: SiteResult[]): ConnectivityObsSummary {
|
||||
const successCount = results.filter((r) => r.status === 'success').length;
|
||||
const failCount = results.length - successCount;
|
||||
const upRate = results.length > 0 ? `${(successCount / results.length * 100).toFixed(1)}%` : '--';
|
||||
const events: ProbeEvent[] = [];
|
||||
let lastFailAt: string | undefined;
|
||||
const chart = tooltipOrder(results).map((r) => {
|
||||
const failed = r.status !== 'success';
|
||||
if (failed) {
|
||||
lastFailAt = r.checked_at;
|
||||
pushEvent(events, r, 'TCP 不通', 'error', r.error_message || '连接失败');
|
||||
}
|
||||
return { time: fmtTime(r.checked_at).slice(-5), up: failed ? 0 : 100, failed };
|
||||
});
|
||||
return { upRate, failCount, lastFailAt: lastFailAt ? fmtTime(lastFailAt) : undefined, events: events.slice(0, 8), chart };
|
||||
}
|
||||
|
||||
export function getProbeObsSummary(task: SiteTask, results: SiteResult[]) {
|
||||
const type = normalizeProbeType(task.type);
|
||||
switch (type) {
|
||||
case 'HTTP': return { type, http: analyzeHttpResults(results, task) } as const;
|
||||
case 'SSL': return { type, ssl: analyzeSslResults(results, task) } as const;
|
||||
case 'PING': return { type, latency: analyzePingResults(results) } as const;
|
||||
case 'DNS': return { type, latency: analyzeDnsResults(results) } as const;
|
||||
case 'TCP': return { type, tcp: analyzeTcpResults(results) } as const;
|
||||
default: return { type: 'HTTP' as const, http: analyzeHttpResults(results, task) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// SNMP 设备可观测面板 — 多指标趋势图 // Logan.Li
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Activity, LineChart, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { snmpApi } from '@/api';
|
||||
import type {
|
||||
SnmpDevice, SnmpHistoryPoint, SnmpMetricSeries, SnmpTimeRange,
|
||||
} from './helpers';
|
||||
import {
|
||||
SNMP_TIME_RANGE_HOURS, fmtMetricValue, isSnmpCounterMetric, metricColor, pickSeriesByCategory,
|
||||
} from './helpers';
|
||||
import { SnmpMetricTrendChart } from './SnmpMetricTrendChart';
|
||||
|
||||
interface Props {
|
||||
device: SnmpDevice | null;
|
||||
templateName: string;
|
||||
}
|
||||
|
||||
interface ChartBlock {
|
||||
series: SnmpMetricSeries;
|
||||
data: SnmpHistoryPoint[];
|
||||
unit?: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SnmpDeviceChartsPanel({ device, templateName }: Props) {
|
||||
const [timeRange, setTimeRange] = useState<SnmpTimeRange>('24h');
|
||||
const [series, setSeries] = useState<SnmpMetricSeries[]>([]);
|
||||
const [charts, setCharts] = useState<ChartBlock[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadCharts = useCallback(async () => {
|
||||
if (!device) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const seriesRes = await snmpApi.getDeviceMetricSeries(device.id) as { list?: SnmpMetricSeries[] };
|
||||
const allSeries = seriesRes?.list || [];
|
||||
setSeries(allSeries);
|
||||
const picked = pickSeriesByCategory(allSeries);
|
||||
const hours = SNMP_TIME_RANGE_HOURS[timeRange];
|
||||
|
||||
const blocks = await Promise.all(
|
||||
picked.map(async (s) => {
|
||||
try {
|
||||
const histRes = await snmpApi.getDeviceMetricHistory(device.id, {
|
||||
metric_name: s.name,
|
||||
index: s.index || undefined,
|
||||
hours,
|
||||
}) as { list?: SnmpHistoryPoint[]; rate_unit?: string };
|
||||
const unit = histRes?.rate_unit || (isSnmpCounterMetric(s.name) ? 'bps' : s.unit);
|
||||
return {
|
||||
series: s,
|
||||
data: histRes?.list || [],
|
||||
unit,
|
||||
loading: false,
|
||||
};
|
||||
} catch {
|
||||
return { series: s, data: [], loading: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
setCharts(blocks);
|
||||
} catch {
|
||||
setSeries([]);
|
||||
setCharts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [device, timeRange]);
|
||||
|
||||
useEffect(() => { loadCharts(); }, [loadCharts]);
|
||||
|
||||
// 30s 静默刷新趋势 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!device) return;
|
||||
const timer = setInterval(loadCharts, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [device, loadCharts]);
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
点击设备行查看 CPU / 内存 / 温度 / 风扇趋势图
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const latestCpu = series.find((s) => s.name.includes('cpu'));
|
||||
const latestMem = series.find((s) => s.name.includes('mem'));
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] truncate">
|
||||
可观测 · {device.name}
|
||||
</h3>
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] shrink-0">
|
||||
{templateName}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as SnmpTimeRange)}>
|
||||
<SelectTrigger className="h-7 w-[88px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="6h">近 6 小时</SelectItem>
|
||||
<SelectItem value="24h">近 24 小时</SelectItem>
|
||||
<SelectItem value="7d">近 7 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={loadCharts} disabled={loading}>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{[
|
||||
{ label: '最新 CPU', value: latestCpu ? fmtMetricValue(latestCpu.latest_value, latestCpu.unit || '%') : '--' },
|
||||
{ label: '最新内存', value: latestMem ? fmtMetricValue(latestMem.latest_value, latestMem.unit || '%') : '--' },
|
||||
{ label: '数据保留', value: '7 天' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className="text-sm font-mono font-medium text-[var(--dg-text-primary)] mt-0.5">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 flex items-center justify-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载趋势...
|
||||
</div>
|
||||
) : charts.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
暂无可视化指标,请先完成至少一次采集
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{charts.map((block) => (
|
||||
<div
|
||||
key={`${block.series.name}-${block.series.index || ''}`}
|
||||
className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<LineChart className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{block.series.label}</span>
|
||||
<span className="text-xs font-mono text-[var(--dg-text-muted)] ml-auto">
|
||||
{block.data.length > 0
|
||||
? fmtMetricValue(block.data[block.data.length - 1].value, block.unit)
|
||||
: fmtMetricValue(block.series.latest_value, block.series.unit)}
|
||||
</span>
|
||||
</div>
|
||||
<SnmpMetricTrendChart
|
||||
data={block.data}
|
||||
color={metricColor(block.series.name)}
|
||||
unit={block.unit}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SNMP 指标趋势折线图 — 光标拖拽选区 // Logan.Li
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
ReferenceArea,
|
||||
} from 'recharts';
|
||||
import type { SnmpHistoryPoint } from './helpers';
|
||||
import { fmtChartTime, fmtMetricValue } from './helpers';
|
||||
import { MonitorChartTooltip, MONITOR_CHART_GRID, MONITOR_CHART_TICK } from '../shared/monitorChartTheme';
|
||||
import { useChartRangeZoom } from '../shared/useChartRangeZoom';
|
||||
import { ChartRangeHint } from '../shared/ChartRangeHint';
|
||||
|
||||
interface Props {
|
||||
data: SnmpHistoryPoint[];
|
||||
color: string;
|
||||
unit?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function SnmpMetricTrendChart({ data, color, unit, height = 160 }: Props) {
|
||||
const chartData = useMemo(
|
||||
() => data.map((p) => ({ time: fmtChartTime(p.timestamp), value: p.value })),
|
||||
[data],
|
||||
);
|
||||
|
||||
const {
|
||||
displayData, refLeft, refRight, selectionLabel, resetZoom, chartHandlers, zoomed,
|
||||
} = useChartRangeZoom(chartData, 'time');
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center text-xs text-[var(--dg-text-muted)]" style={{ height }}>
|
||||
暂无趋势数据,等待后台采集
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gradId = `snmp-grad-${color.replace(/[^a-z]/gi, '')}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={displayData} margin={{ top: 6, right: 8, left: 0, bottom: 4 }} {...chartHandlers}>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
|
||||
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" minTickGap={36} />
|
||||
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={40} domain={['auto', 'auto']} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => fmtMetricValue(v, unit)} />} />
|
||||
{refLeft && refRight && refLeft !== refRight && (
|
||||
<ReferenceArea x1={refLeft} x2={refRight} strokeOpacity={0.4} fill="var(--dg-blue)" fillOpacity={0.12} />
|
||||
)}
|
||||
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={2} fill={`url(#${gradId})`} dot={false} activeDot={{ r: 3, fill: color, stroke: 'var(--dg-bg-primary)', strokeWidth: 2 }} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<ChartRangeHint label={selectionLabel} zoomed={zoomed} onReset={resetZoom} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// SNMP 概览图表 — 设备状态分布 + CPU 对比 // Logan.Li
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Cell, PieChart, Pie,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { BarChart3, PieChart as PieChartIcon } from 'lucide-react';
|
||||
import type { SnmpDevice, SnmpOverviewItem } from './helpers';
|
||||
import { fmtMetricValue, statusColor } from './helpers';
|
||||
import { MonitorChartTooltip, MONITOR_PIE_STROKE, MonitorPieTooltip } from '../shared/monitorChartTheme';
|
||||
|
||||
interface Props {
|
||||
devices: SnmpDevice[];
|
||||
overview: SnmpOverviewItem[];
|
||||
}
|
||||
|
||||
export function SnmpOverviewCharts({ devices, overview }: Props) {
|
||||
const statusData = useMemo(() => {
|
||||
const online = devices.filter((d) => d.status === 'online').length;
|
||||
const error = devices.filter((d) => d.status === 'error').length;
|
||||
const offline = devices.length - online - error;
|
||||
return [
|
||||
{ name: '在线', value: online, fill: 'var(--dg-green)' },
|
||||
{ name: '异常', value: error, fill: 'var(--dg-red)' },
|
||||
{ name: '离线', value: offline, fill: 'var(--dg-text-muted)' },
|
||||
].filter((d) => d.value > 0);
|
||||
}, [devices]);
|
||||
|
||||
const cpuData = useMemo(() => {
|
||||
return overview
|
||||
.filter((o) => o.cpu != null && o.cpu > 0)
|
||||
.map((o) => ({
|
||||
name: o.name.length > 10 ? `${o.name.slice(0, 10)}…` : o.name,
|
||||
fullName: o.name,
|
||||
cpu: o.cpu ?? 0,
|
||||
fill: statusColor(o.status),
|
||||
}))
|
||||
.sort((a, b) => b.cpu - a.cpu);
|
||||
}, [overview]);
|
||||
|
||||
if (devices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
<Card className="lg:col-span-2 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<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)]">设备 CPU 对比</h3>
|
||||
</div>
|
||||
{cpuData.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-8 text-center">暂无 CPU 指标,请先完成采集</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, cpuData.length * 36)}>
|
||||
<BarChart data={cpuData} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'var(--dg-border)' }}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={72}
|
||||
tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(
|
||||
<MonitorChartTooltip
|
||||
valueFormatter={(v) => fmtMetricValue(v, '%')}
|
||||
nameFormatter={() => 'CPU'}
|
||||
/>
|
||||
)}
|
||||
labelFormatter={(_, payload) => {
|
||||
const item = payload?.[0]?.payload as { fullName?: string } | undefined;
|
||||
return item?.fullName ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="cpu" radius={[0, 4, 4, 0]} maxBarSize={20} isAnimationActive={false}>
|
||||
{cpuData.map((entry) => (
|
||||
<Cell key={entry.fullName} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</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">
|
||||
<PieChartIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">设备状态</h3>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={42}
|
||||
outerRadius={68}
|
||||
paddingAngle={2}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{statusData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.fill} stroke={MONITOR_PIE_STROKE} strokeWidth={2} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<MonitorPieTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-1">
|
||||
{statusData.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: d.fill }} />
|
||||
{d.name} {d.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// SNMP 内置 OID 模板 — 默认折叠,不占用主监控区域 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, Radio } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SnmpTemplateOID {
|
||||
id: number; name: string; oid: string; description?: string; unit?: string;
|
||||
}
|
||||
|
||||
export interface SnmpTemplate {
|
||||
id: number; name: string; description?: string; vendor?: string;
|
||||
device_type?: string; oids?: SnmpTemplateOID[];
|
||||
}
|
||||
|
||||
const VENDOR_COLORS: Record<string, string> = {
|
||||
H3C: 'text-blue-400', Huawei: 'text-red-400', Cisco: 'text-cyan-400',
|
||||
Ruijie: 'text-green-400', Dell: 'text-purple-400', Inspur: 'text-amber-400',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
templates: SnmpTemplate[];
|
||||
}
|
||||
|
||||
export function SnmpTemplatesCollapsible({ templates }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between gap-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-left hover:bg-[var(--dg-bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">内置 OID 模板</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5">{templates.length} 个</Badge>
|
||||
<span className="hidden sm:inline">H3C / 华为 / Cisco / 锐捷 / Dell / 浪潮</span>
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 shrink-0 text-[var(--dg-text-muted)] transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{templates.map((t) => (
|
||||
<div key={t.id} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-2.5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium">{t.name}</span>
|
||||
{t.vendor && (
|
||||
<Badge variant="outline" className={`text-[9px] ${VENDOR_COLORS[t.vendor] || ''}`}>{t.vendor}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mb-1.5 line-clamp-1">{t.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(t.oids || []).slice(0, 5).map((o) => (
|
||||
<Badge key={o.id} variant="secondary" className="text-[9px]">{o.name}</Badge>
|
||||
))}
|
||||
{(t.oids?.length || 0) > 5 && (
|
||||
<Badge variant="secondary" className="text-[9px]">+{(t.oids?.length || 0) - 5}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] col-span-2 py-4 text-center">模板尚未初始化,重启服务后自动种子</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// SNMP 监控可视化辅助 — 指标标签 / 时间格式化 // Logan.Li
|
||||
|
||||
export interface SnmpDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
version?: string;
|
||||
snmp_version?: string;
|
||||
community: string;
|
||||
template_id: number;
|
||||
vendor?: string;
|
||||
status?: string;
|
||||
last_error?: string;
|
||||
last_collect_at?: string;
|
||||
}
|
||||
|
||||
export interface SnmpMetric {
|
||||
id: number;
|
||||
name: string;
|
||||
oid: string;
|
||||
index?: string;
|
||||
value: number;
|
||||
raw_value?: string;
|
||||
unit?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface SnmpMetricSeries {
|
||||
name: string;
|
||||
index?: string;
|
||||
unit?: string;
|
||||
label: string;
|
||||
latest_value: number;
|
||||
}
|
||||
|
||||
export interface SnmpHistoryPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface SnmpOverviewItem {
|
||||
device_id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
status?: string;
|
||||
cpu?: number;
|
||||
mem?: number;
|
||||
temp?: number;
|
||||
}
|
||||
|
||||
export type SnmpTimeRange = '6h' | '24h' | '7d';
|
||||
|
||||
export const SNMP_TIME_RANGE_HOURS: Record<SnmpTimeRange, number> = {
|
||||
'6h': 6,
|
||||
'24h': 24,
|
||||
'7d': 168,
|
||||
};
|
||||
|
||||
export const METRIC_COLORS: Record<string, string> = {
|
||||
cpu: 'var(--dg-blue)',
|
||||
mem: 'var(--dg-purple)',
|
||||
temp: 'var(--dg-orange)',
|
||||
fan: 'var(--dg-cyan)',
|
||||
traffic: 'var(--dg-green)',
|
||||
default: 'var(--dg-green)',
|
||||
};
|
||||
|
||||
const CPU_PATTERNS = ['cpu_usage', 'cpu_5min', 'cpu_status'];
|
||||
const MEM_PATTERNS = ['mem_usage', 'mem_used'];
|
||||
const TEMP_PATTERNS = ['temperature', 'sensor_value'];
|
||||
const FAN_PATTERNS = ['fan_speed'];
|
||||
const TRAFFIC_PATTERNS = ['if_in_octets', 'if_out_octets'];
|
||||
|
||||
export function metricCategory(name: string): 'cpu' | 'mem' | 'temp' | 'fan' | 'traffic' | 'other' {
|
||||
if (CPU_PATTERNS.some((p) => name.includes(p))) return 'cpu';
|
||||
if (MEM_PATTERNS.some((p) => name.includes(p))) return 'mem';
|
||||
if (TEMP_PATTERNS.some((p) => name.includes(p))) return 'temp';
|
||||
if (FAN_PATTERNS.some((p) => name.includes(p))) return 'fan';
|
||||
if (TRAFFIC_PATTERNS.some((p) => name.includes(p))) return 'traffic';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function metricColor(name: string): string {
|
||||
return METRIC_COLORS[metricCategory(name)] || METRIC_COLORS.default;
|
||||
}
|
||||
|
||||
export function fmtChartTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
/** 格式化比特速率 // Logan.Li */
|
||||
export function fmtBps(bps: number): string {
|
||||
if (bps == null || Number.isNaN(bps) || bps < 0) return '--';
|
||||
if (bps >= 1e9) return `${(bps / 1e9).toFixed(2)} Gbps`;
|
||||
if (bps >= 1e6) return `${(bps / 1e6).toFixed(2)} Mbps`;
|
||||
if (bps >= 1e3) return `${(bps / 1e3).toFixed(1)} Kbps`;
|
||||
return `${bps.toFixed(0)} bps`;
|
||||
}
|
||||
|
||||
export function fmtMetricValue(value: number, unit?: string): string {
|
||||
if (value == null || Number.isNaN(value)) return '--';
|
||||
if (unit === 'bps') return fmtBps(value);
|
||||
const v = Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
return unit ? `${v} ${unit}` : v;
|
||||
}
|
||||
|
||||
export function statusColor(status?: string): string {
|
||||
if (status === 'online') return 'var(--dg-green)';
|
||||
if (status === 'error') return 'var(--dg-red)';
|
||||
return 'var(--dg-text-muted)';
|
||||
}
|
||||
|
||||
/** 从序列列表中按类别挑选优先展示的指标(每类取最新值最高的一条) // Logan.Li */
|
||||
export function pickSeriesByCategory(series: SnmpMetricSeries[]): SnmpMetricSeries[] {
|
||||
const order: Array<'cpu' | 'mem' | 'temp' | 'fan' | 'traffic'> = ['cpu', 'mem', 'temp', 'fan', 'traffic'];
|
||||
const picked: SnmpMetricSeries[] = [];
|
||||
for (const cat of order) {
|
||||
const candidates = series.filter((s) => metricCategory(s.name) === cat);
|
||||
if (candidates.length === 0) continue;
|
||||
if (cat === 'traffic') {
|
||||
const inn = candidates.find((s) => s.name.includes('in_octets'));
|
||||
const out = candidates.find((s) => s.name.includes('out_octets'));
|
||||
if (inn) picked.push(inn);
|
||||
if (out) picked.push(out);
|
||||
continue;
|
||||
}
|
||||
candidates.sort((a, b) => b.latest_value - a.latest_value);
|
||||
picked.push(candidates[0]);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
export function isSnmpCounterMetric(name: string): boolean {
|
||||
return name === 'if_in_octets' || name === 'if_out_octets';
|
||||
}
|
||||
Reference in new issue
Block a user