-
最后上报
-
+
最后上报
+
{stats && isOnline
? new Date(stats.updated_at).toLocaleString()
: "N/A"}
diff --git a/src/pages/instance/LoadCharts.tsx b/src/pages/instance/LoadCharts.tsx
index 54c6f66..8557596 100644
--- a/src/pages/instance/LoadCharts.tsx
+++ b/src/pages/instance/LoadCharts.tsx
@@ -1,4 +1,4 @@
-import { memo, useCallback, useState, useEffect, useRef } from "react";
+import { memo, useCallback, useRef } from "react";
import {
AreaChart,
Area,
@@ -13,410 +13,352 @@ import { ChartContainer } from "@/components/ui/chart";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import type { NodeData, NodeStats } from "@/types/node";
import { formatBytes } from "@/utils";
-import fillMissingTimePoints, { type RecordFormat } from "@/utils/RecordHelper";
import { Flex } from "@radix-ui/themes";
import Loading from "@/components/loading";
-import { useNodeData } from "@/contexts/NodeDataContext";
+import { useLoadCharts } from "@/hooks/useLoadCharts";
interface LoadChartsProps {
node: NodeData;
hours: number;
- data?: RecordFormat[];
liveData?: NodeStats;
}
-const LoadCharts = memo(
- ({ node, hours, data = [], liveData }: LoadChartsProps) => {
- const { getLoadHistory } = useNodeData();
- const [historicalData, setHistoricalData] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
+ const { loading, error, chartData, memoryChartData } = useLoadCharts(
+ node,
+ hours
+ );
- const isRealtime = hours === 0;
+ const chartDataLengthRef = useRef(0);
+ chartDataLengthRef.current = chartData.length;
- // 获取历史数据
- useEffect(() => {
- if (!isRealtime) {
- const fetchHistoricalData = async () => {
- setLoading(true);
- setError(null);
- try {
- const data = await getLoadHistory(node.uuid, hours);
- setHistoricalData(data?.records || []);
- } catch (err: any) {
- setError(err.message || "获取历史数据失败");
- } finally {
- setLoading(false);
- }
- };
- fetchHistoricalData();
- } else {
- setLoading(false);
- }
- }, [node.uuid, hours, getLoadHistory, isRealtime]);
+ // 格式化函数
+ const timeFormatter = useCallback((value: any, index: number) => {
+ if (chartDataLengthRef.current === 0) return "";
+ if (index === 0 || index === chartDataLengthRef.current - 1) {
+ return new Date(value).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ }
+ return "";
+ }, []);
- // 准备图表数据
- const minute = 60;
- const hour = minute * 60;
- const chartData = isRealtime
- ? data
- : hours === 1
- ? fillMissingTimePoints(historicalData ?? [], minute, hour, minute * 2)
- : (() => {
- const interval = hours > 120 ? hour : minute * 15;
- const maxGap = interval * 2;
- return fillMissingTimePoints(
- historicalData ?? [],
- interval,
- hour * hours,
- maxGap
- );
- })();
-
- const chartDataLengthRef = useRef(0);
- chartDataLengthRef.current = chartData.length;
-
- // 内存图表数据转换
- const memoryChartData = chartData.map((item) => ({
- ...item,
- ram: ((item.ram ?? 0) / (node?.mem_total ?? 1)) * 100,
- ram_raw: item.ram,
- swap: ((item.swap ?? 0) / (node?.swap_total ?? 1)) * 100,
- swap_raw: item.swap,
- }));
-
- // 格式化函数
- const timeFormatter = useCallback((value: any, index: number) => {
- if (chartDataLengthRef.current === 0) return "";
- if (index === 0 || index === chartDataLengthRef.current - 1) {
- return new Date(value).toLocaleTimeString([], {
+ const labelFormatter = useCallback(
+ (value: any) => {
+ const date = new Date(value);
+ if (hours === 0) {
+ return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
+ second: "2-digit",
});
}
- return "";
- }, []);
+ return date.toLocaleString([], {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ },
+ [hours]
+ );
- const labelFormatter = useCallback(
- (value: any) => {
- const date = new Date(value);
- if (hours === 0) {
- return date.toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- });
- }
- return date.toLocaleString([], {
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- });
- },
- [hours]
- );
+ // 样式和颜色
+ const cn = "flex flex-col w-full h-full gap-4 justify-between";
+ const chartMargin = { top: 10, right: 16, bottom: 10, left: 16 };
+ const colors = ["#F38181", "#FCE38A", "#EAFFD0", "#95E1D3"];
- // 样式和颜色
- const cn = "flex flex-col w-full h-full gap-4 justify-between";
- const chartMargin = { top: 10, right: 16, bottom: 10, left: 16 };
- const colors = ["#F38181", "#FCE38A", "#EAFFD0", "#95E1D3"];
-
- // 图表配置
- const chartConfigs = [
- {
- id: "cpu",
- title: "CPU",
- type: "area",
- value: liveData?.cpu?.usage ? `${liveData.cpu.usage.toFixed(2)}%` : "-",
- dataKey: "cpu",
- yAxisDomain: [0, 100],
- yAxisFormatter: (value: number, index: number) =>
- index !== 0 ? `${value}%` : "",
- color: colors[0],
- data: chartData,
- tooltipFormatter: (value: number) => `${value.toFixed(2)}%`,
- tooltipLabel: "CPU 使用率",
- },
- {
- id: "memory",
- title: "内存",
- type: "area",
- value: (
-
-
-
-
- ),
- series: [
- {
- dataKey: "ram",
- color: colors[0],
- tooltipLabel: "内存",
- tooltipFormatter: (value: number, raw: any) =>
- `${formatBytes(raw?.ram_raw || 0)} (${value.toFixed(0)}%)`,
- },
- {
- dataKey: "swap",
- color: colors[1],
- tooltipLabel: "交换",
- tooltipFormatter: (value: number, raw: any) =>
- `${formatBytes(raw?.swap_raw || 0)} (${value.toFixed(0)}%)`,
- },
- ],
- yAxisDomain: [0, 100],
- yAxisFormatter: (value: number, index: number) =>
- index !== 0 ? `${value}%` : "",
- data: memoryChartData,
- },
- {
- id: "disk",
- title: "磁盘",
- type: "area",
- value: liveData?.disk?.used
- ? `${formatBytes(liveData.disk.used)} / ${formatBytes(
- node?.disk_total || 0
- )}`
- : "-",
- dataKey: "disk",
- yAxisDomain: [0, node?.disk_total || 100],
- yAxisFormatter: (value: number, index: number) =>
- index !== 0 ? formatBytes(value) : "",
- color: colors[0],
- data: chartData,
- tooltipFormatter: (value: number) => formatBytes(value),
- tooltipLabel: "磁盘使用",
- },
- {
- id: "network",
- title: "网络",
- type: "line",
- value: (
- <>
-
- ↑ {formatBytes(liveData?.network.up || 0)}/s
- ↓ {formatBytes(liveData?.network.down || 0)}/s
-
- >
- ),
- series: [
- {
- dataKey: "net_in",
- color: colors[0],
- tooltipLabel: "下载",
- tooltipFormatter: (value: number) => `${formatBytes(value)}/s`,
- },
- {
- dataKey: "net_out",
- color: colors[3],
- tooltipLabel: "上传",
- tooltipFormatter: (value: number) => `${formatBytes(value)}/s`,
- },
- ],
- yAxisFormatter: (value: number, index: number) =>
- index !== 0 ? formatBytes(value) : "",
- data: chartData,
- },
- {
- id: "connections",
- title: "连接数",
- type: "line",
- value: (
+ // 图表配置
+ const chartConfigs = [
+ {
+ id: "cpu",
+ title: "CPU",
+ type: "area",
+ value: liveData?.cpu?.usage ? `${liveData.cpu.usage.toFixed(2)}%` : "-",
+ dataKey: "cpu",
+ yAxisDomain: [0, 100],
+ yAxisFormatter: (value: number, index: number) =>
+ index !== 0 ? `${value}%` : "",
+ color: colors[0],
+ data: chartData,
+ tooltipFormatter: (value: number) => `${value.toFixed(2)}%`,
+ tooltipLabel: "CPU 使用率",
+ },
+ {
+ id: "memory",
+ title: "内存",
+ type: "area",
+ value: (
+
+
+
+
+ ),
+ series: [
+ {
+ dataKey: "ram",
+ color: colors[0],
+ tooltipLabel: "内存",
+ tooltipFormatter: (value: number, raw: any) =>
+ `${formatBytes(raw?.ram_raw || 0)} (${value.toFixed(0)}%)`,
+ },
+ {
+ dataKey: "swap",
+ color: colors[1],
+ tooltipLabel: "交换",
+ tooltipFormatter: (value: number, raw: any) =>
+ `${formatBytes(raw?.swap_raw || 0)} (${value.toFixed(0)}%)`,
+ },
+ ],
+ yAxisDomain: [0, 100],
+ yAxisFormatter: (value: number, index: number) =>
+ index !== 0 ? `${value}%` : "",
+ data: memoryChartData,
+ },
+ {
+ id: "disk",
+ title: "磁盘",
+ type: "area",
+ value: liveData?.disk?.used
+ ? `${formatBytes(liveData.disk.used)} / ${formatBytes(
+ node?.disk_total || 0
+ )}`
+ : "-",
+ dataKey: "disk",
+ yAxisDomain: [0, node?.disk_total || 100],
+ yAxisFormatter: (value: number, index: number) =>
+ index !== 0 ? formatBytes(value) : "",
+ color: colors[0],
+ data: chartData,
+ tooltipFormatter: (value: number) => formatBytes(value),
+ tooltipLabel: "磁盘使用",
+ },
+ {
+ id: "network",
+ title: "网络",
+ type: "line",
+ value: (
+ <>
- TCP: {liveData?.connections.tcp}
- UDP: {liveData?.connections.udp}
+ ↑ {formatBytes(liveData?.network.up || 0)}/s
+ ↓ {formatBytes(liveData?.network.down || 0)}/s
- ),
- series: [
- {
- dataKey: "connections",
- color: colors[0],
- tooltipLabel: "TCP 连接",
- },
- {
- dataKey: "connections_udp",
- color: colors[1],
- tooltipLabel: "UDP 连接",
- },
- ],
- data: chartData,
- },
- {
- id: "process",
- title: "进程数",
- type: "line",
- value: liveData?.process || "-",
- dataKey: "process",
- color: colors[0],
- data: chartData,
- tooltipLabel: "进程数",
- },
- ];
+ >
+ ),
+ series: [
+ {
+ dataKey: "net_in",
+ color: colors[0],
+ tooltipLabel: "下载",
+ tooltipFormatter: (value: number) => `${formatBytes(value)}/s`,
+ },
+ {
+ dataKey: "net_out",
+ color: colors[3],
+ tooltipLabel: "上传",
+ tooltipFormatter: (value: number) => `${formatBytes(value)}/s`,
+ },
+ ],
+ yAxisFormatter: (value: number, index: number) =>
+ index !== 0 ? formatBytes(value) : "",
+ data: chartData,
+ },
+ {
+ id: "connections",
+ title: "连接数",
+ type: "line",
+ value: (
+
+ TCP: {liveData?.connections.tcp}
+ UDP: {liveData?.connections.udp}
+
+ ),
+ series: [
+ {
+ dataKey: "connections",
+ color: colors[0],
+ tooltipLabel: "TCP 连接",
+ },
+ {
+ dataKey: "connections_udp",
+ color: colors[1],
+ tooltipLabel: "UDP 连接",
+ },
+ ],
+ data: chartData,
+ },
+ {
+ id: "process",
+ title: "进程数",
+ type: "line",
+ value: liveData?.process || "-",
+ dataKey: "process",
+ color: colors[0],
+ data: chartData,
+ tooltipLabel: "进程数",
+ },
+ ];
- // 通用提示组件
- const CustomTooltip = ({ active, payload, label, chartConfig }: any) => {
- if (!active || !payload || !payload.length) return null;
-
- return (
-
-
- {labelFormatter(label)}
-
-
- {payload.map((item: any, index: number) => {
- const series = chartConfig.series
- ? chartConfig.series.find(
- (s: any) => s.dataKey === item.dataKey
- )
- : {
- dataKey: chartConfig.dataKey,
- tooltipLabel: chartConfig.tooltipLabel,
- tooltipFormatter: chartConfig.tooltipFormatter,
- };
-
- let value = item.value;
- if (series?.tooltipFormatter) {
- value = series.tooltipFormatter(value, item.payload);
- } else {
- value = value.toString();
- }
-
- return (
-
-
-
-
- {series?.tooltipLabel || item.dataKey}:
-
-
-
{value}
-
- );
- })}
-
-
- );
- };
-
- // 根据配置渲染图表
- const renderChart = (config: any) => {
- const ChartComponent = config.type === "area" ? AreaChart : LineChart;
- const DataComponent =
- config.type === "area" ? Area : (Line as React.ComponentType);
-
- const chartConfig = config.series
- ? config.series.reduce((acc: any, series: any) => {
- acc[series.dataKey] = {
- label: series.tooltipLabel || series.dataKey,
- color: series.color,
- };
- return acc;
- }, {})
- : {
- [config.dataKey]: {
- label: config.tooltipLabel || config.dataKey,
- color: config.color,
- },
- };
-
- return (
-
-
-
- {config.title}
-
- {config.value}
-
-
-
-
-
-
- (
-
- )}
- />
- {config.series ? (
- config.series.map((series: any) => (
-
- ))
- ) : (
-
- )}
-
-
-
- );
- };
+ // 通用提示组件
+ const CustomTooltip = ({ active, payload, label, chartConfig }: any) => {
+ if (!active || !payload || !payload.length) return null;
return (
-
- {loading && (
-
-
-
- )}
- {error && (
-
- )}
-
- {chartConfigs.map(renderChart)}
+
+
+ {labelFormatter(label)}
+
+
+ {payload.map((item: any, index: number) => {
+ const series = chartConfig.series
+ ? chartConfig.series.find((s: any) => s.dataKey === item.dataKey)
+ : {
+ dataKey: chartConfig.dataKey,
+ tooltipLabel: chartConfig.tooltipLabel,
+ tooltipFormatter: chartConfig.tooltipFormatter,
+ };
+
+ let value = item.value;
+ if (series?.tooltipFormatter) {
+ value = series.tooltipFormatter(value, item.payload);
+ } else {
+ value = value.toString();
+ }
+
+ return (
+
+
+
+
+ {series?.tooltipLabel || item.dataKey}:
+
+
+
{value}
+
+ );
+ })}
);
- }
-);
+ };
+
+ // 根据配置渲染图表
+ const renderChart = (config: any) => {
+ const ChartComponent = config.type === "area" ? AreaChart : LineChart;
+ const DataComponent =
+ config.type === "area" ? Area : (Line as React.ComponentType
);
+
+ const chartConfig = config.series
+ ? config.series.reduce((acc: any, series: any) => {
+ acc[series.dataKey] = {
+ label: series.tooltipLabel || series.dataKey,
+ color: series.color,
+ };
+ return acc;
+ }, {})
+ : {
+ [config.dataKey]: {
+ label: config.tooltipLabel || config.dataKey,
+ color: config.color,
+ },
+ };
+
+ return (
+
+
+ {config.title}
+ {config.value}
+
+
+
+
+
+
+ (
+
+ )}
+ />
+ {config.series ? (
+ config.series.map((series: any) => (
+
+ ))
+ ) : (
+
+ )}
+
+
+
+ );
+ };
+
+ return (
+
+ {loading && (
+
+
+
+ )}
+ {error && (
+
+ )}
+
+ {chartConfigs.map(renderChart)}
+
+
+ );
+});
export default LoadCharts;
diff --git a/src/pages/instance/PingChart.tsx b/src/pages/instance/PingChart.tsx
index a2c8ca9..7d6aee7 100644
--- a/src/pages/instance/PingChart.tsx
+++ b/src/pages/instance/PingChart.tsx
@@ -17,6 +17,7 @@ import Loading from "@/components/loading";
import { usePingChart } from "@/hooks/usePingChart";
import fillMissingTimePoints, { cutPeakValues } from "@/utils/RecordHelper";
import { Button } from "@/components/ui/button";
+import { useConfigItem } from "@/config";
interface PingChartProps {
node: NodeData;
@@ -28,6 +29,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
const [visiblePingTasks, setVisiblePingTasks] = useState([]);
const [timeRange, setTimeRange] = useState<[number, number] | null>(null);
const [cutPeak, setCutPeak] = useState(false);
+ const maxPointsToRender = useConfigItem("pingChartMaxPoints") || 0; // 0表示不限制
useEffect(() => {
if (pingHistory?.tasks) {
@@ -106,12 +108,11 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
}
// 添加渲染硬限制以防止崩溃,即使在间隔调整后也是如此
- const MAX_POINTS_TO_RENDER = 0;
- if (full.length > MAX_POINTS_TO_RENDER && MAX_POINTS_TO_RENDER > 0) {
+ if (full.length > maxPointsToRender && maxPointsToRender > 0) {
console.log(
- `数据量过大 (${full.length}), 降采样至 ${MAX_POINTS_TO_RENDER} 个点。`
+ `数据量过大 (${full.length}), 降采样至 ${maxPointsToRender} 个点。`
);
- const samplingFactor = Math.ceil(full.length / MAX_POINTS_TO_RENDER);
+ const samplingFactor = Math.ceil(full.length / maxPointsToRender);
const sampledData = [];
for (let i = 0; i < full.length; i += samplingFactor) {
sampledData.push(full[i]);
@@ -125,7 +126,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
}
return full;
- }, [pingHistory, hours, cutPeak]);
+ }, [pingHistory, hours, maxPointsToRender, cutPeak]);
const handleTaskVisibilityToggle = (taskId: number) => {
setVisiblePingTasks((prev) =>
diff --git a/src/pages/instance/index.tsx b/src/pages/instance/index.tsx
index dfbc503..ad107c4 100644
--- a/src/pages/instance/index.tsx
+++ b/src/pages/instance/index.tsx
@@ -10,6 +10,7 @@ const LoadCharts = lazy(() => import("./LoadCharts"));
const PingChart = lazy(() => import("./PingChart"));
import Loading from "@/components/loading";
import Flag from "@/components/sections/Flag";
+import { useConfigItem } from "@/config";
const InstancePage = () => {
const { uuid } = useParams<{ uuid: string }>();
@@ -20,12 +21,13 @@ const InstancePage = () => {
loading: nodesLoading,
} = useNodeData();
const { liveData } = useLiveData();
- const { getRecentLoadHistory } = useNodeData();
+ useNodeData();
const [staticNode, setStaticNode] = useState(null);
const [chartType, setChartType] = useState<"load" | "ping">("load");
const [loadHours, setLoadHours] = useState(0);
const [pingHours, setPingHours] = useState(1); // 默认1小时
- const [realtimeChartData, setRealtimeChartData] = useState([]);
+ const enableInstanceDetail = useConfigItem("enableInstanceDetail");
+ const enablePingChart = useConfigItem("enablePingChart");
const maxRecordPreserveTime = publicSettings?.record_preserve_time || 0; // 默认0表示关闭
const maxPingRecordPreserveTime =
@@ -76,57 +78,6 @@ const InstancePage = () => {
setStaticNode(foundNode || null);
}, [staticNodes, uuid]);
- // Effect for fetching initial realtime data
- useEffect(() => {
- if (uuid && loadHours === 0) {
- const fetchInitialData = async () => {
- try {
- const data = await getRecentLoadHistory(uuid);
- setRealtimeChartData(data?.records || []);
- } catch (error) {
- console.error("Failed to fetch initial realtime chart data:", error);
- setRealtimeChartData([]);
- }
- };
- fetchInitialData();
- }
- }, [uuid, loadHours, getRecentLoadHistory]);
-
- // Effect for handling live data updates
- useEffect(() => {
- if (loadHours !== 0 || !liveData?.data || !uuid || !liveData.data[uuid]) {
- return;
- }
-
- const stats = liveData.data[uuid];
- const newRecord = {
- client: uuid,
- time: new Date(stats.updated_at).toISOString(),
- cpu: stats.cpu.usage,
- ram: stats.ram.used,
- disk: stats.disk.used,
- load: stats.load.load1,
- net_in: stats.network.down,
- net_out: stats.network.up,
- process: stats.process,
- connections: stats.connections.tcp,
- gpu: 0,
- ram_total: stats.ram.total,
- swap: stats.swap.used,
- swap_total: stats.swap.total,
- temp: 0,
- disk_total: stats.disk.total,
- net_total_up: stats.network.totalUp,
- net_total_down: stats.network.totalDown,
- connections_udp: stats.connections.udp,
- };
-
- setRealtimeChartData((prev) => {
- const updated = [...prev, newRecord];
- return updated.length > 600 ? updated.slice(-600) : updated;
- });
- }, [liveData, uuid, loadHours]);
-
const node = useMemo(() => {
if (!staticNode) return null;
const isOnline = liveData?.online.includes(staticNode.uuid) ?? false;
@@ -174,45 +125,49 @@ const InstancePage = () => {
-
+ {enableInstanceDetail && }
-
-
-
+
+
+
+ {enablePingChart && (
+
+ )}
+
+ {chartType === "load" ? (
+
+ {loadTimeRanges.map((range) => (
+
+ ))}
+
+ ) : (
+
+ {pingTimeRanges.map((range) => (
+
+ ))}
+
+ )}
- {chartType === "load" ? (
-
- {loadTimeRanges.map((range) => (
-
- ))}
-
- ) : (
-
- {pingTimeRanges.map((range) => (
-
- ))}
-
- )}
{
) : chartType === "ping" && staticNode ? (
diff --git a/src/types/node.d.ts b/src/types/node.d.ts
index 138a100..9e81f9c 100644
--- a/src/types/node.d.ts
+++ b/src/types/node.d.ts
@@ -18,6 +18,8 @@ export interface NodeData {
expired_at: string | null;
group: string;
tags: string;
+ traffic_limit?: number;
+ traffic_limit_type?: "sum" | "max" | "min" | "up" | "down";
created_at: string;
updated_at: string;
}