feat: monitor page redesign and node card cleanup (#340)

This commit is contained in:
sagit
2026-03-18 18:57:23 +08:00
committed by GitHub
parent 7134253b2c
commit 555039e028
4 changed files with 172 additions and 358 deletions
@@ -0,0 +1,7 @@
# 046 - Node Card Cleanup & Monitor Redesign
## Tasks
- [x] 1. Remove duplicate system metrics from node cards in `node.tsx` (CPU, memory, upload/download speed, upload/download traffic, disk, load)
- [x] 2. Redesign monitor `ServerCard` in `monitor-view.tsx` to match the node card style from `node.tsx`
- [x] 3. Make all charts in monitor view update incrementally (streaming) instead of full page reload
+8 -7
View File
@@ -21,8 +21,9 @@ export default function MonitorPage() {
const [nodesLoading, setNodesLoading] = useState(false);
const [nodesError, setNodesError] = useState<string | null>(null);
const loadNodes = useCallback(async () => {
setNodesLoading(true);
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setNodesLoading(true);
try {
const response = await getMonitorNodes();
@@ -40,11 +41,11 @@ export default function MonitorPage() {
return;
}
toast.error(response.msg || "加载节点失败");
if (!silent) toast.error(response.msg || "加载节点失败");
} catch {
toast.error("加载节点失败");
if (!silent) toast.error("加载节点失败");
} finally {
setNodesLoading(false);
if (!silent) setNodesLoading(false);
}
}, []);
@@ -54,7 +55,7 @@ export default function MonitorPage() {
useEffect(() => {
const timer = window.setInterval(() => {
void loadNodes();
void loadNodes({ silent: true });
}, 30_000);
return () => window.clearInterval(timer);
@@ -86,7 +87,7 @@ export default function MonitorPage() {
isLoading={nodesLoading}
size="sm"
variant="flat"
onPress={loadNodes}
onPress={() => loadNodes()}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新节点
-247
View File
@@ -683,15 +683,7 @@ export default function NodePage() {
};
// 格式化流量
const formatTraffic = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const formatFlow = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) {
@@ -719,17 +711,6 @@ export default function NodePage() {
return "未知链路";
};
// 获取进度条颜色
const getProgressColor = (
value: number,
offline = false,
): "default" | "primary" | "secondary" | "success" | "warning" | "danger" => {
if (offline) return "default";
if (value <= 50) return "success";
if (value <= 80) return "warning";
return "danger";
};
// IPv4/IPv6 格式验证(仅用于判定地址族)
const ipv4Regex =
@@ -2162,235 +2143,7 @@ export default function NodePage() {
</div>
)}
{!isRemoteNode && (
<>
{/* 系统监控 */}
<div className="space-y-3 mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="CPU使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="内存使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
上传
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(
node.systemInfo.uploadSpeed,
)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
下载
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(
node.systemInfo.downloadSpeed,
)
: "-"}
</div>
</div>
</div>
{/* 流量统计 */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
↑ 上行流量
</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.uploadTraffic,
)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<div className="text-success-600 dark:text-success-400 mb-0.5">
↓ 下行流量
</div>
<div className="font-mono text-success-700 dark:text-success-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.downloadTraffic,
)
: "-"}
</div>
</div>
</div>
{/* 扩展指标:磁盘/负载/连接 */}
{node.connectionStatus === "online" &&
node.systemInfo && (
<div className="space-y-2">
{node.systemInfo.diskUsage !==
undefined && (
<div>
<div className="flex justify-between text-xs mb-1">
<span>磁盘</span>
<span className="font-mono">
{node.systemInfo.diskUsage.toFixed(
1,
)}
%
</span>
</div>
<Progress
aria-label="磁盘使用率"
color={getProgressColor(
node.systemInfo.diskUsage,
false,
)}
size="sm"
value={node.systemInfo.diskUsage}
/>
</div>
)}
{(node.systemInfo.load1 !== undefined ||
node.systemInfo.load5 !== undefined ||
node.systemInfo.load15 !==
undefined) && (
<div className="grid grid-cols-3 gap-1 text-xs">
{node.systemInfo.load1 !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
负载 1m
</div>
<div className="font-mono">
{node.systemInfo.load1.toFixed(
2,
)}
</div>
</div>
)}
{node.systemInfo.load5 !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
负载 5m
</div>
<div className="font-mono">
{node.systemInfo.load5.toFixed(
2,
)}
</div>
</div>
)}
{node.systemInfo.load15 !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
负载 15m
</div>
<div className="font-mono">
{node.systemInfo.load15.toFixed(
2,
)}
</div>
</div>
)}
</div>
)}
{(node.systemInfo.tcpConns !==
undefined ||
node.systemInfo.udpConns !==
undefined) && (
<div className="grid grid-cols-2 gap-2 text-xs">
{node.systemInfo.tcpConns !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
TCP 连接
</div>
<div className="font-mono">
{node.systemInfo.tcpConns}
</div>
</div>
)}
{node.systemInfo.udpConns !==
undefined && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">
UDP 连接
</div>
<div className="font-mono">
{node.systemInfo.udpConns}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</>
)}
<div className="mt-auto space-y-3">
{/* 操作按钮 */}
+157 -104
View File
@@ -27,11 +27,7 @@ import {
Activity,
Play,
Server,
Zap,
HardDrive,
Cpu,
Clock,
Globe,
ArrowLeft,
} from "lucide-react";
import toast from "react-hot-toast";
@@ -173,91 +169,142 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
const isOnline = node.connectionStatus === "online";
return (
<Card className={`border-t-4 transition-all hover:scale-[1.02] hover:shadow-lg cursor-pointer ${isOnline ? "border-t-success" : "border-t-danger"}`} onClick={onPress}>
<CardHeader className="pb-2 flex justify-between items-center">
<div className="flex items-center gap-2">
<Server className={`w-5 h-5 ${isOnline ? "text-success" : "text-default-400"}`} />
<div className="font-semibold text-base truncate max-w-[150px]" title={node.name}>{node.name}</div>
</div>
<Chip size="sm" color={isOnline ? "success" : "danger"} variant="flat">
{isOnline ? "在线" : "离线"}
</Chip>
</CardHeader>
<CardBody className="pt-0 space-y-4">
<div className="grid grid-cols-2 gap-y-2 gap-x-4 text-xs mt-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-default-500">
<Clock className="w-3 h-3" /> 运行
<Card
className="group relative shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col cursor-pointer"
onClick={onPress}
>
<CardHeader className="pb-3">
<div className="flex justify-between items-start w-full gap-3">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm leading-5">{node.name}</h3>
<span
className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${isOnline ? "bg-emerald-500" : "bg-rose-500"}`}
title={isOnline ? "在线" : "离线"}
/>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-default-500 flex-shrink-0">
<Clock className="w-3 h-3" />
<span className="font-mono text-[11px]">{metric ? formatUptime(metric.uptime) : "-"}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-default-500">
<Activity className="w-3 h-3" /> 负载
</div>
<span className="font-mono text-[11px]">{metric ? metric.load1.toFixed(2) : "-"}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-default-500">
<Globe className="w-3 h-3" /> 连接
</div>
<span className="font-mono text-[11px]">{metric ? metric.tcpConns : "-"}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-default-500">
<Zap className="w-3 h-3" /> UDP
</div>
<span className="font-mono text-[11px]">{metric ? metric.udpConns : "-"}</span>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3 flex-1 flex flex-col">
{/* IP & Version row */}
<div className="space-y-2 mb-3">
<div className="flex justify-between text-sm">
<span className="text-default-600">版本</span>
<span className="text-xs font-mono">{metric ? "在线" : "-"}</span>
</div>
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<div className="flex items-center gap-1 text-default-600"><Cpu className="w-3.5 h-3.5" /> CPU</div>
<span className="font-mono text-[11px]">{metric ? metric.cpuUsage.toFixed(1) : "0.0"}%</span>
{/* System metrics */}
<div className="space-y-3 mb-3">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>CPU</span>
<span className="font-mono">{isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"}</span>
</div>
<Progress
aria-label="CPU使用率"
color={getColorByUsage(metric?.cpuUsage)}
size="sm"
value={isOnline && metric ? metric.cpuUsage : 0}
/>
</div>
<Progress value={metric ? metric.cpuUsage : 0} color={getColorByUsage(metric?.cpuUsage)} className="h-1.5" />
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<div className="flex items-center gap-1 text-default-600"><HardDrive className="w-3.5 h-3.5" /> RAM</div>
<span className="font-mono text-[11px]">{metric ? metric.memoryUsage.toFixed(1) : "0.0"}%</span>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">{isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"}</span>
</div>
<Progress
aria-label="内存使用率"
color={getColorByUsage(metric?.memoryUsage)}
size="sm"
value={isOnline && metric ? metric.memoryUsage : 0}
/>
</div>
<Progress value={metric ? metric.memoryUsage : 0} color={getColorByUsage(metric?.memoryUsage)} className="h-1.5" />
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<div className="flex items-center gap-1 text-default-600"><HardDrive className="w-3.5 h-3.5" /> Disk</div>
<span className="font-mono text-[11px]">{metric ? metric.diskUsage.toFixed(1) : "0.0"}%</span>
</div>
<Progress value={metric ? metric.diskUsage : 0} color={getColorByUsage(metric?.diskUsage)} className="h-1.5" />
</div>
</div>
<div className="flex justify-between items-center pt-3 border-t border-default-200/50 text-xs">
<div className="flex flex-col gap-0.5 w-[50%] border-r border-default-200/50 pr-2">
<div className="text-default-500 flex justify-between">
<span>网络 ↓</span>
<span className="font-mono text-success">{metric ? formatBytesPerSecond(metric.netInSpeed) : "0 B/s"}</span>
</div>
<div className="text-default-400 font-mono flex justify-between">
<span>总计</span>
<span className="text-[11px]">{metric ? formatBytes(metric.netInBytes) : "0 B"}</span>
</div>
</div>
<div className="flex flex-col gap-0.5 w-[50%] pl-2">
<div className="text-default-500 flex justify-between">
<span>网络 ↑</span>
<span className="font-mono text-primary">{metric ? formatBytesPerSecond(metric.netOutSpeed) : "0 B/s"}</span>
</div>
<div className="text-default-400 font-mono flex justify-between">
<span>总计</span>
<span className="text-[11px]">{metric ? formatBytes(metric.netOutBytes) : "0 B"}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">上传</div>
<div className="font-mono">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">下载</div>
<div className="font-mono">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</div>
</div>
</div>
{/* Traffic stats */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<div className="text-primary-600 dark:text-primary-400 mb-0.5">↑ 上行流量</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}
</div>
</div>
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<div className="text-success-600 dark:text-success-400 mb-0.5">↓ 下行流量</div>
<div className="font-mono text-success-700 dark:text-success-300">
{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}
</div>
</div>
</div>
{/* Extended metrics */}
{isOnline && metric && (
<div className="space-y-2">
{metric.diskUsage > 0 && (
<div>
<div className="flex justify-between text-xs mb-1">
<span>磁盘</span>
<span className="font-mono">{metric.diskUsage.toFixed(1)}%</span>
</div>
<Progress
aria-label="磁盘使用率"
color={getColorByUsage(metric.diskUsage)}
size="sm"
value={metric.diskUsage}
/>
</div>
)}
{(metric.load1 > 0 || metric.load5 > 0 || metric.load15 > 0) && (
<div className="grid grid-cols-3 gap-1 text-xs">
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">负载 1m</div>
<div className="font-mono">{metric.load1.toFixed(2)}</div>
</div>
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">负载 5m</div>
<div className="font-mono">{metric.load5.toFixed(2)}</div>
</div>
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">负载 15m</div>
<div className="font-mono">{metric.load15.toFixed(2)}</div>
</div>
</div>
)}
{(metric.tcpConns > 0 || metric.udpConns > 0) && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">TCP 连接</div>
<div className="font-mono">{metric.tcpConns}</div>
</div>
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-500 text-[10px]">UDP 连接</div>
<div className="font-mono">{metric.udpConns}</div>
</div>
</div>
)}
</div>
)}
</div>
</CardBody>
</Card>
@@ -453,8 +500,9 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
}
}, [preferredNodeId, selectedNodeId]);
const loadTunnels = useCallback(async () => {
setTunnelsLoading(true);
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setTunnelsLoading(true);
try {
const response = await getMonitorTunnels();
@@ -473,12 +521,14 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
return;
}
setTunnelsError(response.msg || "加载隧道列表失败");
toast.error(response.msg || "加载隧道列表失败");
if (!silent) toast.error(response.msg || "加载隧道列表失败");
} catch {
setTunnelsError("加载隧道列表失败");
toast.error("加载隧道列表失败");
if (!silent) {
setTunnelsError("加载隧道列表失败");
toast.error("加载隧道列表失败");
}
} finally {
setTunnelsLoading(false);
if (!silent) setTunnelsLoading(false);
}
}, []);
@@ -488,7 +538,7 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
useEffect(() => {
const timer = window.setInterval(() => {
void loadTunnels();
void loadTunnels({ silent: true });
}, 60_000);
return () => window.clearInterval(timer);
@@ -501,8 +551,9 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
}, [selectedTunnelId, tunnels]);
const loadTunnelMetrics = useCallback(
async (tunnelId: number) => {
setTunnelMetricsLoading(true);
async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setTunnelMetricsLoading(true);
try {
const end = Date.now();
const start = end - tunnelRangeMs;
@@ -529,12 +580,12 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
}
setTunnelMetricsTruncated(false);
setTunnelMetricsError(response.msg || "加载隧道指标失败");
toast.error(response.msg || "加载隧道指标失败");
if (!silent) toast.error(response.msg || "加载隧道指标失败");
} catch {
setTunnelMetricsTruncated(false);
setTunnelMetricsError("加载隧道指标失败");
if (!silent) setTunnelMetricsError("加载隧道指标失败");
} finally {
setTunnelMetricsLoading(false);
if (!silent) setTunnelMetricsLoading(false);
}
},
[tunnelRangeMs],
@@ -549,15 +600,16 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
useEffect(() => {
if (!selectedTunnelId) return;
const timer = window.setInterval(() => {
void loadTunnelMetrics(selectedTunnelId);
void loadTunnelMetrics(selectedTunnelId, { silent: true });
}, 30_000);
return () => window.clearInterval(timer);
}, [selectedTunnelId, loadTunnelMetrics]);
const loadMetrics = useCallback(
async (nodeId: number) => {
setMetricsLoading(true);
async (nodeId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setMetricsLoading(true);
try {
const end = Date.now();
const start = end - metricsRangeMs;
@@ -584,19 +636,20 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
}
setMetricsTruncated(false);
setMetricsError(response.msg || "加载指标失败");
toast.error(response.msg || "加载指标失败");
if (!silent) toast.error(response.msg || "加载指标失败");
} catch {
setMetricsTruncated(false);
setMetricsError("加载指标失败");
if (!silent) setMetricsError("加载指标失败");
} finally {
setMetricsLoading(false);
if (!silent) setMetricsLoading(false);
}
},
[metricsRangeMs],
);
const loadServiceMonitors = useCallback(async () => {
setMonitorsLoading(true);
const loadServiceMonitors = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setMonitorsLoading(true);
try {
const response = await getServiceMonitorList();
@@ -614,11 +667,11 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
return;
}
setMonitorsError(response.msg || "加载服务监控失败");
toast.error(response.msg || "加载服务监控失败");
if (!silent) toast.error(response.msg || "加载服务监控失败");
} catch {
setMonitorsError("加载服务监控失败");
if (!silent) setMonitorsError("加载服务监控失败");
} finally {
setMonitorsLoading(false);
if (!silent) setMonitorsLoading(false);
}
}, []);
@@ -731,7 +784,7 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
useEffect(() => {
const timer = window.setInterval(() => {
void loadServiceMonitors();
void loadServiceMonitors({ silent: true });
void loadLatestMonitorResults();
}, 30_000);
@@ -747,7 +800,7 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
useEffect(() => {
if (!selectedNodeId) return;
const timer = window.setInterval(() => {
void loadMetrics(selectedNodeId);
void loadMetrics(selectedNodeId, { silent: true });
}, 15_000);
return () => window.clearInterval(timer);