From 6a3a9add084ad60a3319d217a7034d1ee6ac8c21 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Wed, 18 Mar 2026 17:21:39 +0800 Subject: [PATCH 1/2] feat: redesign monitor view --- plans/046-monitor-nezha-redesign.md | 16 +- vite-frontend/src/pages/node/monitor-view.tsx | 787 ++++++------------ 2 files changed, 251 insertions(+), 552 deletions(-) diff --git a/plans/046-monitor-nezha-redesign.md b/plans/046-monitor-nezha-redesign.md index 9f7ad4a..d0e8277 100644 --- a/plans/046-monitor-nezha-redesign.md +++ b/plans/046-monitor-nezha-redesign.md @@ -1,15 +1,23 @@ # Monitor Nezha Redesign + ## Summary -Redesign the monitor overview to resemble Nezha dashboard by introducing a Server Matrix (Grid) containing cards with metrics visualisations. +Redesign the monitor overview to resemble Nezha dashboard. Phase 1 introduced ServerCard grid. Phase 2 hides node metrics from the overview and adds a detail view with charts and service monitor latency sparklines. ## Objectives -- [x] Analyze `monitor-view.tsx` layout boundaries. +- [x] Analyze `monitor-view.tsx` layout boundaries. - [x] Create `ServerCard` utilizing `Progress` component. -- [x] Render metrics (CPU, RAM, Disk, System Load, Connections, Network speeeds). +- [x] Render metrics (CPU, RAM, Disk, System Load, Connections, Network speeds). - [x] Implement robust real-time updates and seamless state linkage via existing hooks. - [x] Adjust layout positioning for impact at top-of-page. +- [x] Hide node metrics from overview — click node card to enter detail view. +- [x] Detail view: back button + node header + realtime KPI cards. +- [x] Detail view: node metrics chart (CPU/Memory/Disk/Network/Load/Connections). +- [x] Detail view: tunnel traffic chart. +- [x] Detail view: service monitors rendered as latency sparkline charts (Nezha-style). +- [x] Service monitor cards include status dot, type chip, target, interval, and dropdown actions. ## Technical Details -Injected a `ServerCard` inline component mapping over unified node metric states. Used the tailwind and hero UI components matching visual designs like progress bars and small monospace typograhy. Extracted utility color scales. +- Phase 1: Injected a `ServerCard` inline component with progress bars and real-time metrics. +- Phase 2: Added `detailNodeId` state for drill-down navigation. Grid view shows only summary bar + clickable server cards. Detail view shows KPI summary cards, node metrics chart (reusing existing recharts setup), tunnel traffic chart, and service monitors as a responsive grid of cards each containing a latency-over-time sparkline chart. Reduced file from 2108 to 1799 lines by consolidating the old overview card into the summary bar. ## Status: Complete diff --git a/vite-frontend/src/pages/node/monitor-view.tsx b/vite-frontend/src/pages/node/monitor-view.tsx index cd9d4c6..3d925c9 100644 --- a/vite-frontend/src/pages/node/monitor-view.tsx +++ b/vite-frontend/src/pages/node/monitor-view.tsx @@ -32,6 +32,7 @@ import { Cpu, Clock, Globe, + ArrowLeft, } from "lucide-react"; import toast from "react-hot-toast"; @@ -168,11 +169,11 @@ const getColorByUsage = (usage?: number) => { return "success"; }; -function ServerCard({ node, metric }: { node: any; metric: RealtimeNodeMetric | null }) { +function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) { const isOnline = node.connectionStatus === "online"; return ( - +
@@ -284,6 +285,7 @@ const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = { }; export function MonitorView({ nodeMap }: MonitorViewProps) { + const [detailNodeId, setDetailNodeId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const [metrics, setMetrics] = useState([]); const [metricsLoading, setMetricsLoading] = useState(false); @@ -1158,6 +1160,14 @@ export function MonitorView({ nodeMap }: MonitorViewProps) { return formatBytes(n); }; + const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null; + const detailRealtimeMetric = detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null; + + // Service monitors filtered for the detail node (0 = panel-executed, show all in that case) + const detailServiceMonitors = detailNodeId != null + ? serviceMonitors.filter((m) => m.nodeId === detailNodeId || m.nodeId === 0) + : serviceMonitors; + return (
{accessDenied && ( @@ -1175,628 +1185,309 @@ export function MonitorView({ nodeMap }: MonitorViewProps) { )} - {!accessDenied && ( + {/* ====== GRID VIEW ====== */} + {!accessDenied && !detailNodeId && ( <> -
- {nodes.map((node) => { - const metric = realtimeNodeMetrics[node.id] || null; - return ; - })} +
+
+
+ {wsConnected ? "实时已连接" : wsConnecting ? "实时连接中" : "实时未连接"} +
+ 节点在线 {onlineNodes.length}/{nodes.length} + 监控 成功 {monitorSummary.ok} / 失败 {monitorSummary.fail} + {monitorSummary.stale > 0 && 陈旧 {monitorSummary.stale}}
- - -

监控概览

-
-
+ {nodes.map((node) => { + const metric = realtimeNodeMetrics[node.id] || null; + return ( + { + setDetailNodeId(node.id); + setSelectedNodeId(node.id); + }} /> - - {wsConnected - ? "实时已连接" - : wsConnecting - ? "实时连接中" - : "实时未连接"} - -
- - -
- - 节点在线 {onlineNodes.length}/{nodes.length} - - - 服务监控 成功 {monitorSummary.ok} / 失败 {monitorSummary.fail} - - - 禁用 {monitorSummary.disabled} - - {monitorSummary.unknown > 0 ? ( - - 未检查 {monitorSummary.unknown} - - ) : null} - {monitorSummary.stale > 0 ? ( - - 陈旧 {monitorSummary.stale} - - ) : null} -
+ ); + })} +
+ + )} -
-
- 历史图表来自数据库,入库批量刷新约 30s(可能存在 0-30s 延迟) -
-
- 节点最近入库: - {latestPersistedNodeTs ? ( - - {formatDateTime(latestPersistedNodeTs)}(约{ - Math.max( - 0, - Math.round((Date.now() - latestPersistedNodeTs) / 1000), - )} - s前) - - ) : ( - "-" - )} -
-
- 实时数据更新时间: - {selectedRealtimeMetric ? ( - - {formatDateTime(selectedRealtimeMetric.receivedAt)} - - ) : ( - "-" - )} -
-
- -
+ {/* ====== DETAIL VIEW ====== */} + {!accessDenied && detailNodeId && ( + <> + {/* Header */} +
+ +
+ +

{detailNode?.name || `节点 #${detailNodeId}`}

+ + {detailNode?.connectionStatus === "online" ? "在线" : "离线"} + +
+
+
+ {wsConnected ? "实时已连接" : wsConnecting ? "实时连接中" : "实时未连接"} +
+
+ {/* Realtime KPI cards */} + {detailRealtimeMetric && ( +
+ {[ + { label: "CPU", value: `${detailRealtimeMetric.cpuUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.cpuUsage) }, + { label: "内存", value: `${detailRealtimeMetric.memoryUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.memoryUsage) }, + { label: "磁盘", value: `${detailRealtimeMetric.diskUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.diskUsage) }, + { label: "↓ 下行", value: formatBytesPerSecond(detailRealtimeMetric.netInSpeed), color: "success" as const }, + { label: "↑ 上行", value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), color: "primary" as const }, + { label: "运行时间", value: formatUptime(detailRealtimeMetric.uptime), color: "default" as const }, + ].map((item) => ( + + + {item.label} + {item.value} + + + ))} +
+ )} + + {/* Node metrics chart */}

节点指标图表

-
- -
- - +
- - {selectedNodeId && ( + +
+ + +
+ {([ + { key: "cpu", label: "CPU" }, + { key: "memory", label: "内存" }, + { key: "disk", label: "磁盘" }, + { key: "network", label: "网络" }, + { key: "load", label: "负载" }, + { key: "connections", label: "连接" }, + ] as { key: MetricType; label: string }[]).map((item) => ( - )} + ))}
- {selectedNodeId && ( + {metricsLoading ? ( +
+ ) : metricsError ? ( +
{metricsError}
+ ) : metrics.length > 0 ? ( <> -
-
-
- 实时 -
- {selectedRealtimeMetric ? ( - - CPU {selectedRealtimeMetric.cpuUsage.toFixed(1)}% · 内存{" "} - {selectedRealtimeMetric.memoryUsage.toFixed(1)}% · ↓{" "} - {formatBytesPerSecond(selectedRealtimeMetric.netInSpeed)} · ↑{" "} - {formatBytesPerSecond(selectedRealtimeMetric.netOutSpeed)} · 更新{" "} - {formatDateTime(selectedRealtimeMetric.receivedAt)} - - ) : ( - 暂无实时数据 - )} +
+ + + + + + + {chartConfig.lines.map((line) => ( + + ))} + +
- -
- 最近入库: - {latestPersistedNodeTs ? ( - - {formatDateTime(latestPersistedNodeTs)}(约 - {Math.max( - 0, - Math.round((Date.now() - latestPersistedNodeTs) / 1000), - )} - s前) - - ) : ( - "-" - )} -
- -
- {( - [ - { key: "cpu", label: "CPU" }, - { key: "memory", label: "内存" }, - { key: "disk", label: "磁盘" }, - { key: "network", label: "网络" }, - { key: "load", label: "负载" }, - { key: "connections", label: "连接" }, - ] as { key: MetricType; label: string }[] - ).map((item) => ( - - ))} -
- - {metricsLoading ? ( -
- -
- ) : metricsError ? ( -
- {metricsError} -
- ) : metrics.length > 0 ? ( - <> -
- - - - - - - {chartConfig.lines.map((line) => ( - - ))} - - -
- {metricsTruncated && ( -
- 数据点过多,已截断为最近 {METRICS_MAX_ROWS}{" "} - 条,建议缩小时间范围。 -
- )} - - ) : ( -
- 暂无指标数据 -
+ {metricsTruncated && ( +
数据点过多,已截断为最近 {METRICS_MAX_ROWS} 条,建议缩小时间范围。
)} - )} - - {!selectedNodeId && ( -
- 请选择一个在线节点查看指标 -
+ ) : ( +
暂无指标数据
)} + {/* Tunnel traffic chart */}

隧道流量趋势

-
- - {tunnelsError ? ( -
{tunnelsError}
- ) : null} - -
+
- - - {selectedTunnelId && ( - - )}
- - {selectedTunnelId && ( - <> -
- 最近入库: - {latestPersistedTunnelTs ? ( - - {formatDateTime(latestPersistedTunnelTs)}(约 - {Math.max( - 0, - Math.round((Date.now() - latestPersistedTunnelTs) / 1000), - )} - s前) - - ) : ( - "-" - )} + + + {selectedTunnelId ? ( + tunnelMetricsLoading ? ( +
+ ) : tunnelMetricsError ? ( +
{tunnelMetricsError}
+ ) : tunnelMetrics.length > 0 ? ( +
+ + + + + + + {tunnelChartConfig.lines.map((line) => ( + + ))} + +
- -
- {( - [{ key: "traffic", label: "流量" }] as { - key: TunnelMetricType; - label: string; - }[] - ).map((item) => ( - - ))} -
- - {tunnelMetricsLoading ? ( -
- -
- ) : tunnelMetricsError ? ( -
- {tunnelMetricsError} -
- ) : tunnelMetrics.length > 0 ? ( - <> -
- - - - - - - {tunnelChartConfig.lines.map((line) => ( - - ))} - - -
- {tunnelMetricsTruncated && ( -
- 数据点过多,已截断为最近 {METRICS_MAX_ROWS}{" "} - 条,建议缩小时间范围。 -
- )} - - ) : ( -
- 暂无指标数据 -
- )} - - )} - - {!selectedTunnelId && ( -
- 请选择一个隧道查看指标 -
+ ) : ( +
暂无指标数据
+ ) + ) : ( +
请选择一个隧道查看指标
)}
+ {/* Service monitors with latency charts */}

服务监控

-
- - {monitorsError ? ( -
{monitorsError}
- ) : null} - {latestResultsError ? ( -
- {latestResultsError} -
- ) : null} - + {monitorsLoading ? ( -
- -
- ) : serviceMonitors.length > 0 ? ( - - - 名称 - 类型 - 目标 - 间隔 - 启用 - 最新结果 - 操作 - - - {serviceMonitors.map((monitor) => { - const latestResult = getLatestResult(monitor.id); - const stale = isResultStale(monitor, latestResult); +
+ ) : detailServiceMonitors.length > 0 ? ( +
+ {detailServiceMonitors.map((monitor) => { + const latestResult = getLatestResult(monitor.id); + const stale = isResultStale(monitor, latestResult); + const results = monitorResults[monitor.id] || []; + const latencyData = [...results].reverse().map((r) => ({ + time: formatTimestamp(r.timestamp), + latency: r.success === 1 ? r.latencyMs : null, + success: r.success, + })); - return ( - - -
-
{monitor.name}
- {monitor.nodeId > 0 && ( -
- 节点:{" "} - {nodeMap.get(monitor.nodeId)?.name || - monitor.nodeId} -
- )} + return ( + + +
+
+ {monitor.name} + {monitor.type.toUpperCase()} + {stale ? 陈旧 : null} +
+ + + + + + } onPress={() => handleRunMonitor(monitor.id)}>立即检查 + } onPress={() => openResultsModal(monitor.id)}>查看记录 + } onPress={() => handleOpenEditModal(monitor)}>编辑 + } onPress={() => handleDeleteMonitor(monitor.id)}>删除 + + + + +
+ {monitor.target} + 间隔 {monitor.intervalSec}s + {latestResult && Number.isFinite(latestResult.latencyMs) ? ( + {latestResult.latencyMs.toFixed(0)}ms + ) : null} +
+ {latencyData.length > 0 ? ( +
+ + + + + `${Math.round(v)}ms`} /> + [`${Number(value).toFixed(0)}ms`, "延迟"]} + /> + + +
- - - - {monitor.type.toUpperCase()} - - - - - {monitor.target} - - - {monitor.intervalSec}s - - {monitor.enabled === 1 ? ( - - 启用 - - ) : ( - - 禁用 - - )} - - - {latestResult ? ( -
-
- {latestResult.success === 1 ? ( - - 成功 - - ) : ( - - 失败 - - )} - {stale ? ( - - 陈旧 - - ) : null} - - - {Number.isFinite(latestResult.latencyMs) - ? `${latestResult.latencyMs.toFixed(0)}ms` - : "-"} - - - - {formatDateTime(latestResult.timestamp)} - -
- - {latestResult.errorMessage ? ( -
- {latestResult.errorMessage} -
- ) : null} -
- ) : monitor.enabled === 1 ? ( - - 未检查 - - ) : ( - - - )} -
- - - - - - - } - onPress={() => handleRunMonitor(monitor.id)} - > - 立即检查 - - - } - onPress={() => openResultsModal(monitor.id)} - > - 查看记录 - - } - onPress={() => handleOpenEditModal(monitor)} - > - 编辑 - - } - onPress={() => - handleDeleteMonitor(monitor.id) - } - > - 删除 - - - - - - ); - })} - -
- ) : ( -
- 暂无服务监控,点击"添加监控"创建 + ) : ( +
暂无检查记录
+ )} + + + ); + })}
+ ) : ( +
暂无服务监控,点击"添加监控"创建
)}
From 58d29b440a70855a20501e358b0f9ec267772251 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Wed, 18 Mar 2026 17:39:09 +0800 Subject: [PATCH 2/2] fix(ci): remove unused variables to fix TS build --- vite-frontend/src/pages/node/monitor-view.tsx | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/vite-frontend/src/pages/node/monitor-view.tsx b/vite-frontend/src/pages/node/monitor-view.tsx index 3d925c9..155945b 100644 --- a/vite-frontend/src/pages/node/monitor-view.tsx +++ b/vite-frontend/src/pages/node/monitor-view.tsx @@ -271,7 +271,6 @@ type MetricType = | "network" | "load" | "connections"; -type TunnelMetricType = "traffic"; const METRICS_MAX_ROWS = 5000; @@ -297,24 +296,22 @@ export function MonitorView({ nodeMap }: MonitorViewProps) { const [tunnels, setTunnels] = useState([]); const [tunnelsLoading, setTunnelsLoading] = useState(false); - const [tunnelsError, setTunnelsError] = useState(null); + const [, setTunnelsError] = useState(null); const [selectedTunnelId, setSelectedTunnelId] = useState(null); const [tunnelMetrics, setTunnelMetrics] = useState([]); const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false); - const [tunnelMetricsTruncated, setTunnelMetricsTruncated] = useState(false); + const [, setTunnelMetricsTruncated] = useState(false); const [tunnelMetricsError, setTunnelMetricsError] = useState( null, ); const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000); - const [activeTunnelMetricType, setActiveTunnelMetricType] = - useState("traffic"); const [serviceMonitors, setServiceMonitors] = useState< ServiceMonitorApiItem[] >([]); const [monitorsLoading, setMonitorsLoading] = useState(false); - const [monitorsError, setMonitorsError] = useState(null); - const [latestResultsError, setLatestResultsError] = useState( + const [, setMonitorsError] = useState(null); + const [, setLatestResultsError] = useState( null, ); const [monitorResults, setMonitorResults] = useState< @@ -1030,15 +1027,6 @@ export function MonitorView({ nodeMap }: MonitorViewProps) { const modalResults = resultsMonitorId != null ? monitorResults[resultsMonitorId] || [] : []; - const selectedRealtimeMetric = - selectedNodeId != null ? realtimeNodeMetrics[selectedNodeId] || null : null; - const latestPersistedNodeTs = - metrics.length > 0 ? metrics[metrics.length - 1]?.timestamp : null; - const latestPersistedTunnelTs = - tunnelMetrics.length > 0 - ? tunnelMetrics[tunnelMetrics.length - 1]?.timestamp - : null; - const resolveMonitorIntervalSec = useCallback( (monitor: ServiceMonitorApiItem) => { let interval = Number(monitor.intervalSec ?? 0);