mirror of
https://github.com/fankes/komari-theme-purcarte.git
synced 2025-10-19 20:09:24 +08:00
init: 初始化
This commit is contained in:
145
src/pages/Home.tsx
Normal file
145
src/pages/Home.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatsBar } from "@/components/sections/StatsBar";
|
||||
import { NodeCard } from "@/components/sections/NodeCard";
|
||||
import { NodeListHeader } from "@/components/sections/NodeListHeader";
|
||||
import { NodeListItem } from "@/components/sections/NodeListItem";
|
||||
import Loading from "@/components/loading";
|
||||
import type { NodeWithStatus } from "@/types/node";
|
||||
import { useNodeData } from "@/contexts/NodeDataContext";
|
||||
import { useLiveData } from "@/contexts/LiveDataContext";
|
||||
|
||||
interface HomePageProps {
|
||||
viewMode: "card" | "list";
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
|
||||
const { nodes: staticNodes, loading, getGroups } = useNodeData();
|
||||
const { liveData } = useLiveData();
|
||||
const [selectedGroup, setSelectedGroup] = useState("所有");
|
||||
const [displayOptions, setDisplayOptions] = useState({
|
||||
time: true,
|
||||
online: true,
|
||||
regions: true,
|
||||
traffic: true,
|
||||
speed: true,
|
||||
});
|
||||
const [currentTime] = useState(new Date());
|
||||
|
||||
const combinedNodes = useMemo<NodeWithStatus[]>(() => {
|
||||
if (!staticNodes) return [];
|
||||
return staticNodes.map((node) => {
|
||||
const isOnline = liveData?.online.includes(node.uuid) ?? false;
|
||||
const stats = isOnline ? liveData?.data[node.uuid] : undefined;
|
||||
|
||||
return {
|
||||
...node,
|
||||
status: isOnline ? "online" : "offline",
|
||||
stats: stats,
|
||||
};
|
||||
});
|
||||
}, [staticNodes, liveData]);
|
||||
|
||||
const groups = useMemo(() => ["所有", ...getGroups()], [getGroups]);
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
return combinedNodes
|
||||
.filter(
|
||||
(node: NodeWithStatus) =>
|
||||
selectedGroup === "所有" || node.group === selectedGroup
|
||||
)
|
||||
.filter((node: NodeWithStatus) =>
|
||||
node.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [combinedNodes, selectedGroup, searchTerm]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
onlineCount: filteredNodes.filter((n) => n.status === "online").length,
|
||||
totalCount: filteredNodes.length,
|
||||
uniqueRegions: new Set(filteredNodes.map((n) => n.region)).size,
|
||||
totalTrafficUp: filteredNodes.reduce(
|
||||
(acc, node) => acc + (node.stats?.network.totalUp || 0),
|
||||
0
|
||||
),
|
||||
totalTrafficDown: filteredNodes.reduce(
|
||||
(acc, node) => acc + (node.stats?.network.totalDown || 0),
|
||||
0
|
||||
),
|
||||
currentSpeedUp: filteredNodes.reduce(
|
||||
(acc, node) => acc + (node.stats?.network.up || 0),
|
||||
0
|
||||
),
|
||||
currentSpeedDown: filteredNodes.reduce(
|
||||
(acc, node) => acc + (node.stats?.network.down || 0),
|
||||
0
|
||||
),
|
||||
};
|
||||
}, [filteredNodes]);
|
||||
|
||||
return (
|
||||
<div className="w-[90%] max-w-screen-2xl mx-auto flex-1 flex flex-col pb-10">
|
||||
<StatsBar
|
||||
displayOptions={displayOptions}
|
||||
setDisplayOptions={setDisplayOptions}
|
||||
stats={stats}
|
||||
loading={loading}
|
||||
currentTime={currentTime}
|
||||
/>
|
||||
|
||||
<main className="flex-1 px-4 pb-4">
|
||||
<div className="flex overflow-auto whitespace-nowrap overflow-x-auto items-center min-w-[300px] text-secondary-foreground box-border border space-x-4 px-4 rounded-lg mb-4 bg-card backdrop-blur-[10px]">
|
||||
<span>分组</span>
|
||||
{groups.map((group: string) => (
|
||||
<Button
|
||||
key={group}
|
||||
variant={selectedGroup === group ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedGroup(group)}>
|
||||
{group}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<Loading text="正在努力获取数据中..." />
|
||||
) : filteredNodes.length > 0 ? (
|
||||
<div
|
||||
className={
|
||||
viewMode === "card"
|
||||
? ""
|
||||
: "space-y-2 bg-card overflow-auto backdrop-blur-[10px] rounded-lg p-2"
|
||||
}>
|
||||
<div
|
||||
className={
|
||||
viewMode === "card"
|
||||
? "grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4"
|
||||
: "min-w-[1080px]"
|
||||
}>
|
||||
{viewMode === "list" && <NodeListHeader />}
|
||||
{filteredNodes.map((node: NodeWithStatus) =>
|
||||
viewMode === "card" ? (
|
||||
<NodeCard key={node.uuid} node={node} />
|
||||
) : (
|
||||
<NodeListItem key={node.uuid} node={node} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg font-bold">没有结果</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请尝试更改筛选条件
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
31
src/pages/NotFound.tsx
Normal file
31
src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function NotFound() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">404 - Not Found</CardTitle>
|
||||
<CardDescription>
|
||||
The page you are looking for does not exist.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button onClick={() => navigate("/")} className="w-full">
|
||||
Go to Home
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
142
src/pages/instance/Instance.tsx
Normal file
142
src/pages/instance/Instance.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { NodeWithStatus } from "@/types/node";
|
||||
import { useMemo, memo } from "react";
|
||||
|
||||
interface InstanceProps {
|
||||
node: NodeWithStatus;
|
||||
}
|
||||
|
||||
const formatUptime = (uptime: number) => {
|
||||
if (!uptime) return "N/A";
|
||||
const days = Math.floor(uptime / 86400);
|
||||
const hours = Math.floor((uptime % 86400) / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = Math.floor(uptime % 60);
|
||||
|
||||
let result = "";
|
||||
if (days > 0) result += `${days} 天 `;
|
||||
if (hours > 0 || days > 0) result += `${hours} 时 `;
|
||||
if (minutes > 0 || hours > 0 || days > 0) result += `${minutes} 分 `;
|
||||
result += `${seconds} 秒`;
|
||||
|
||||
return result.trim();
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number, unit: "KB" | "MB" | "GB" = "GB") => {
|
||||
if (bytes === 0) return `0 ${unit}`;
|
||||
const k = 1024;
|
||||
switch (unit) {
|
||||
case "KB":
|
||||
return `${(bytes / k).toFixed(2)} KB`;
|
||||
case "MB":
|
||||
return `${(bytes / (k * k)).toFixed(2)} MB`;
|
||||
case "GB":
|
||||
return `${(bytes / (k * k * k)).toFixed(2)} GB`;
|
||||
default:
|
||||
return `${bytes} B`;
|
||||
}
|
||||
};
|
||||
|
||||
const Instance = memo(({ node }: InstanceProps) => {
|
||||
const { stats, isOnline } = useMemo(() => {
|
||||
return {
|
||||
stats: node.stats,
|
||||
isOnline: node.status === "online",
|
||||
};
|
||||
}, [node]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>详细信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground">CPU</p>
|
||||
<p>{`${node.cpu_name} (x${node.cpu_cores})`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">架构</p>
|
||||
<p>{node.arch}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">虚拟化</p>
|
||||
<p>{node.virtualization}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">GPU</p>
|
||||
<p>{node.gpu_name || "N/A"}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-muted-foreground">操作系统</p>
|
||||
<p>{node.os}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">内存</p>
|
||||
<p>
|
||||
{stats && isOnline
|
||||
? `${formatBytes(stats.ram.used)} / ${formatBytes(
|
||||
node.mem_total
|
||||
)}`
|
||||
: `N/A / ${formatBytes(node.mem_total)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">交换</p>
|
||||
<p>
|
||||
{stats && isOnline
|
||||
? `${formatBytes(stats.swap.used, "MB")} / ${formatBytes(
|
||||
node.swap_total
|
||||
)}`
|
||||
: `N/A / ${formatBytes(node.swap_total)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">磁盘</p>
|
||||
<p>
|
||||
{stats && isOnline
|
||||
? `${formatBytes(stats.disk.used)} / ${formatBytes(
|
||||
node.disk_total
|
||||
)}`
|
||||
: `N/A / ${formatBytes(node.disk_total)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-muted-foreground">网络</p>
|
||||
<p>
|
||||
{stats && isOnline
|
||||
? `↑ ${formatBytes(stats.network.up, "KB")}/s ↓ ${formatBytes(
|
||||
stats.network.down,
|
||||
"KB"
|
||||
)}/s`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">总流量</p>
|
||||
<p>
|
||||
{stats && isOnline
|
||||
? `↑ ${formatBytes(stats.network.totalUp)} ↓ ${formatBytes(
|
||||
stats.network.totalDown
|
||||
)}`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">运行时间</p>
|
||||
<p>{formatUptime(stats?.uptime || 0)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">最后上报</p>
|
||||
<p>
|
||||
{stats && isOnline
|
||||
? new Date(stats.updated_at).toLocaleString()
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default Instance;
|
634
src/pages/instance/LoadCharts.tsx
Normal file
634
src/pages/instance/LoadCharts.tsx
Normal file
@@ -0,0 +1,634 @@
|
||||
import { memo, useCallback, useMemo, useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
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";
|
||||
|
||||
interface LoadChartsProps {
|
||||
node: NodeData;
|
||||
hours: number;
|
||||
data?: RecordFormat[];
|
||||
liveData?: NodeStats;
|
||||
}
|
||||
|
||||
const ChartTitle = (text: string, left: React.ReactNode) => {
|
||||
return (
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{text}</CardTitle>
|
||||
<span className="text-sm font-bold">{left}</span>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadCharts = memo(
|
||||
({ node, hours, data = [], liveData: live_data }: LoadChartsProps) => {
|
||||
const { getLoadHistory } = useNodeData();
|
||||
const [historicalData, setHistoricalData] = useState<RecordFormat[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isRealtime = hours === 0;
|
||||
|
||||
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 || "Failed to fetch historical data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchHistoricalData();
|
||||
} else {
|
||||
// For realtime, we expect data to be passed via props.
|
||||
// We can set loading to false if data is present.
|
||||
setLoading(false);
|
||||
}
|
||||
}, [node.uuid, hours, getLoadHistory, isRealtime]);
|
||||
|
||||
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 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 lableFormatter = 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: 10,
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
};
|
||||
const colors = ["#F38181", "#FCE38A", "#EAFFD0", "#95E1D3"];
|
||||
const primaryColor = colors[0];
|
||||
const secondaryColor = colors[1];
|
||||
const percentageFormatter = (value: number) => {
|
||||
return `${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const memoryChartData = useMemo(() => {
|
||||
return chartData.map((item) => ({
|
||||
time: item.time,
|
||||
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,
|
||||
}));
|
||||
}, [chartData, node?.mem_total, node?.swap_total]);
|
||||
|
||||
// 通用自定义 Tooltip 组件
|
||||
const CustomTooltip = ({ active, payload, label, chartType }: any) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-background/80 p-3 border rounded-lg shadow-lg max-w-xs">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{lableFormatter(label)}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{payload.map((item: any, index: number) => {
|
||||
let value = item.value;
|
||||
let displayName = item.name || item.dataKey;
|
||||
|
||||
// 根据图表类型和 dataKey 格式化值和显示名称
|
||||
switch (chartType) {
|
||||
case "cpu":
|
||||
value = percentageFormatter(value);
|
||||
displayName = "CPU 使用率";
|
||||
break;
|
||||
|
||||
case "memory":
|
||||
if (item.dataKey === "ram") {
|
||||
const rawValue = item.payload?.ram_raw;
|
||||
if (rawValue !== undefined) {
|
||||
value = `${formatBytes(rawValue)} (${value.toFixed(0)}%)`;
|
||||
} else {
|
||||
value = percentageFormatter(value);
|
||||
}
|
||||
displayName = "内存";
|
||||
} else if (item.dataKey === "swap") {
|
||||
const rawValue = item.payload?.swap_raw;
|
||||
if (rawValue !== undefined) {
|
||||
value = `${formatBytes(rawValue)} (${value.toFixed(0)}%)`;
|
||||
} else {
|
||||
value = percentageFormatter(value);
|
||||
}
|
||||
displayName = "交换";
|
||||
}
|
||||
break;
|
||||
|
||||
case "disk":
|
||||
value = formatBytes(value);
|
||||
displayName = "磁盘使用";
|
||||
break;
|
||||
|
||||
case "network":
|
||||
if (item.dataKey === "net_in") {
|
||||
value = `${formatBytes(value)}/s`;
|
||||
displayName = "下载";
|
||||
} else if (item.dataKey === "net_out") {
|
||||
value = `${formatBytes(value)}/s`;
|
||||
displayName = "上传";
|
||||
}
|
||||
break;
|
||||
|
||||
case "connections":
|
||||
if (item.dataKey === "connections") {
|
||||
displayName = "TCP 连接";
|
||||
} else if (item.dataKey === "connections_udp") {
|
||||
displayName = "UDP 连接";
|
||||
}
|
||||
value = value.toString();
|
||||
break;
|
||||
|
||||
case "process":
|
||||
displayName = "进程数";
|
||||
value = value.toString();
|
||||
break;
|
||||
|
||||
default:
|
||||
value = value.toString();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.dataKey}-${index}`}
|
||||
className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{displayName}:
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold ml-2">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
||||
<Loading text="正在加载图表数据..." />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
||||
<p className="text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* CPU */}
|
||||
<Card className={cn}>
|
||||
{ChartTitle(
|
||||
"CPU",
|
||||
live_data?.cpu?.usage ? `${live_data.cpu.usage.toFixed(2)}%` : "-"
|
||||
)}
|
||||
<ChartContainer
|
||||
config={{
|
||||
cpu: {
|
||||
label: "CPU",
|
||||
color: primaryColor,
|
||||
},
|
||||
}}>
|
||||
<AreaChart data={chartData} margin={chartMargin}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="2 4"
|
||||
vertical={false}
|
||||
stroke="var(--gray-a3)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={timeFormatter}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value, index) =>
|
||||
index !== 0 ? `${value}%` : ""
|
||||
}
|
||||
orientation="left"
|
||||
type="number"
|
||||
tick={{ dx: -10 }}
|
||||
mirror={true}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={(props) => (
|
||||
<CustomTooltip {...props} chartType="cpu" />
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
dataKey="cpu"
|
||||
animationDuration={0}
|
||||
stroke={primaryColor}
|
||||
fill={primaryColor}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</Card>
|
||||
|
||||
{/* Ram */}
|
||||
<Card className={cn}>
|
||||
{ChartTitle(
|
||||
"内存",
|
||||
<Flex gap="0" direction="column" align="end" className="text-sm">
|
||||
<label>
|
||||
{live_data?.ram?.used
|
||||
? `${formatBytes(live_data.ram.used)} / ${formatBytes(
|
||||
node?.mem_total || 0
|
||||
)}`
|
||||
: "-"}
|
||||
</label>
|
||||
<label>
|
||||
{live_data?.swap?.used
|
||||
? `${formatBytes(live_data.swap.used)} / ${formatBytes(
|
||||
node?.swap_total || 0
|
||||
)}`
|
||||
: "-"}
|
||||
</label>
|
||||
</Flex>
|
||||
)}
|
||||
<ChartContainer
|
||||
config={{
|
||||
ram: {
|
||||
label: "Ram",
|
||||
color: primaryColor,
|
||||
},
|
||||
swap: {
|
||||
label: "Swap",
|
||||
color: secondaryColor,
|
||||
},
|
||||
}}>
|
||||
<AreaChart data={memoryChartData} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={timeFormatter}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value, index) =>
|
||||
index !== 0 ? `${value}%` : ""
|
||||
}
|
||||
orientation="left"
|
||||
type="number"
|
||||
tick={{ dx: -10 }}
|
||||
mirror={true}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={(props) => (
|
||||
<CustomTooltip {...props} chartType="memory" />
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
dataKey="ram"
|
||||
animationDuration={0}
|
||||
stroke={primaryColor}
|
||||
fill={primaryColor}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="swap"
|
||||
animationDuration={0}
|
||||
stroke={secondaryColor}
|
||||
fill={secondaryColor}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</Card>
|
||||
|
||||
{/* Disk */}
|
||||
<Card className={cn}>
|
||||
{ChartTitle(
|
||||
"磁盘",
|
||||
live_data?.disk?.used
|
||||
? `${formatBytes(live_data.disk.used)} / ${formatBytes(
|
||||
node?.disk_total || 0
|
||||
)}`
|
||||
: "-"
|
||||
)}
|
||||
<ChartContainer
|
||||
config={{
|
||||
disk: {
|
||||
label: "Disk",
|
||||
color: primaryColor,
|
||||
},
|
||||
}}>
|
||||
<AreaChart data={chartData} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={timeFormatter}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, node?.disk_total || 100]}
|
||||
tickFormatter={(value, index) =>
|
||||
index !== 0 ? `${formatBytes(value)}` : ""
|
||||
}
|
||||
orientation="left"
|
||||
type="number"
|
||||
tick={{ dx: -10 }}
|
||||
mirror={true}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={(props) => (
|
||||
<CustomTooltip {...props} chartType="disk" />
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
dataKey="disk"
|
||||
animationDuration={0}
|
||||
stroke={primaryColor}
|
||||
fill={primaryColor}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</Card>
|
||||
|
||||
{/* Network */}
|
||||
<Card className={cn}>
|
||||
{ChartTitle(
|
||||
"网络",
|
||||
<Flex gap="0" align="end" direction="column" className="text-sm">
|
||||
<span>↑ {formatBytes(live_data?.network.up || 0)}/s</span>
|
||||
<span>↓ {formatBytes(live_data?.network.down || 0)}/s</span>
|
||||
</Flex>
|
||||
)}
|
||||
<ChartContainer
|
||||
config={{
|
||||
net_in: {
|
||||
label: "网络下载",
|
||||
color: primaryColor,
|
||||
},
|
||||
net_out: {
|
||||
label: "网络上传",
|
||||
color: colors[3],
|
||||
},
|
||||
}}>
|
||||
<LineChart data={chartData} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={timeFormatter}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value, index) =>
|
||||
index !== 0 ? `${formatBytes(value)}` : ""
|
||||
}
|
||||
orientation="left"
|
||||
type="number"
|
||||
tick={{ dx: -10 }}
|
||||
mirror={true}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={(props) => (
|
||||
<CustomTooltip {...props} chartType="network" />
|
||||
)}
|
||||
/>
|
||||
<Line
|
||||
dataKey="net_in"
|
||||
animationDuration={0}
|
||||
stroke={primaryColor}
|
||||
fill={primaryColor}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="net_out"
|
||||
animationDuration={0}
|
||||
stroke={colors[3]}
|
||||
fill={colors[3]}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</Card>
|
||||
|
||||
{/* Connections */}
|
||||
<Card className={cn}>
|
||||
{ChartTitle(
|
||||
"连接数",
|
||||
<Flex gap="0" align="end" direction="column" className="text-sm">
|
||||
<span>TCP: {live_data?.connections.tcp}</span>
|
||||
<span>UDP: {live_data?.connections.udp}</span>
|
||||
</Flex>
|
||||
)}
|
||||
<ChartContainer
|
||||
config={{
|
||||
connections: {
|
||||
label: "TCP",
|
||||
color: primaryColor,
|
||||
},
|
||||
connections_udp: {
|
||||
label: "UDP",
|
||||
color: colors[3],
|
||||
},
|
||||
}}>
|
||||
<LineChart data={chartData} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={timeFormatter}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value, index) =>
|
||||
index !== 0 ? `${value}` : ""
|
||||
}
|
||||
orientation="left"
|
||||
type="number"
|
||||
tick={{ dx: -10 }}
|
||||
mirror={true}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={(props) => (
|
||||
<CustomTooltip {...props} chartType="connections" />
|
||||
)}
|
||||
/>
|
||||
<Line
|
||||
dataKey="connections"
|
||||
animationDuration={0}
|
||||
stroke={primaryColor}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
name="TCP"
|
||||
/>
|
||||
<Line
|
||||
dataKey="connections_udp"
|
||||
animationDuration={0}
|
||||
stroke={secondaryColor}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
name="UDP"
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</Card>
|
||||
|
||||
{/* Process */}
|
||||
<Card className={cn}>
|
||||
{ChartTitle("进程数", live_data?.process || "-")}
|
||||
<ChartContainer
|
||||
config={{
|
||||
process: {
|
||||
label: "进程数",
|
||||
color: primaryColor,
|
||||
},
|
||||
}}>
|
||||
<LineChart data={chartData} margin={chartMargin}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={timeFormatter}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value, index) =>
|
||||
index !== 0 ? `${value}` : ""
|
||||
}
|
||||
orientation="left"
|
||||
type="number"
|
||||
tick={{ dx: -10 }}
|
||||
mirror={true}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={(props) => (
|
||||
<CustomTooltip {...props} chartType="process" />
|
||||
)}
|
||||
/>
|
||||
<Line
|
||||
dataKey="process"
|
||||
animationDuration={0}
|
||||
stroke={primaryColor}
|
||||
opacity={0.8}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default LoadCharts;
|
284
src/pages/instance/PingChart.tsx
Normal file
284
src/pages/instance/PingChart.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { memo, useState, useMemo, useCallback, useEffect } from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Brush,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import type { NodeData } from "@/types/node";
|
||||
import Loading from "@/components/loading";
|
||||
import { usePingChart } from "@/hooks/usePingChart";
|
||||
import fillMissingTimePoints, { cutPeakValues } from "@/utils/RecordHelper";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface PingChartProps {
|
||||
node: NodeData;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
const { loading, error, pingHistory } = usePingChart(node, hours);
|
||||
const [visiblePingTasks, setVisiblePingTasks] = useState<number[]>([]);
|
||||
const [timeRange, setTimeRange] = useState<[number, number] | null>(null);
|
||||
const [cutPeak, setCutPeak] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (pingHistory?.tasks) {
|
||||
setVisiblePingTasks(pingHistory.tasks.map((t) => t.id));
|
||||
}
|
||||
}, [pingHistory]);
|
||||
|
||||
const lableFormatter = 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 chartData = useMemo(() => {
|
||||
if (!pingHistory || !pingHistory.records || !pingHistory.tasks) return [];
|
||||
|
||||
const grouped: Record<string, any> = {};
|
||||
const timeKeys: number[] = [];
|
||||
|
||||
for (const rec of pingHistory.records) {
|
||||
const t = new Date(rec.time).getTime();
|
||||
let foundKey = null;
|
||||
for (const key of timeKeys) {
|
||||
if (Math.abs(key - t) <= 1500) {
|
||||
foundKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const useKey = foundKey !== null ? foundKey : t;
|
||||
if (!grouped[useKey]) {
|
||||
grouped[useKey] = { time: useKey };
|
||||
if (foundKey === null) timeKeys.push(useKey);
|
||||
}
|
||||
grouped[useKey][rec.task_id] = rec.value === -1 ? null : rec.value;
|
||||
}
|
||||
|
||||
let full = Object.values(grouped).sort((a: any, b: any) => a.time - b.time);
|
||||
|
||||
if (hours !== 0) {
|
||||
const task = pingHistory.tasks;
|
||||
let interval = task[0]?.interval || 60;
|
||||
let maxGap = interval * 1.2;
|
||||
const selectedHours = timeRange
|
||||
? (timeRange[1] - timeRange[0]) / (1000 * 60 * 60)
|
||||
: hours;
|
||||
|
||||
if (selectedHours > 24) {
|
||||
interval *= 60;
|
||||
}
|
||||
|
||||
full = fillMissingTimePoints(full, interval, hours * 60 * 60, maxGap);
|
||||
|
||||
full = full.map((d: any) => ({
|
||||
...d,
|
||||
time: new Date(d.time).getTime(),
|
||||
}));
|
||||
}
|
||||
|
||||
if (cutPeak && pingHistory.tasks.length > 0) {
|
||||
const taskKeys = pingHistory.tasks.map((task) => String(task.id));
|
||||
full = cutPeakValues(full, taskKeys);
|
||||
}
|
||||
|
||||
return full;
|
||||
}, [pingHistory, hours, cutPeak, timeRange]);
|
||||
|
||||
const handleTaskVisibilityToggle = (taskId: number) => {
|
||||
setVisiblePingTasks((prev) =>
|
||||
prev.includes(taskId)
|
||||
? prev.filter((id) => id !== taskId)
|
||||
: [...prev, taskId]
|
||||
);
|
||||
};
|
||||
|
||||
const stringToColor = useCallback((str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += ("00" + value.toString(16)).substr(-2);
|
||||
}
|
||||
return color;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
||||
<Loading text="正在加载图表数据..." />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
||||
<p className="text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">Ping 延迟</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Switch
|
||||
id="peak-shaving"
|
||||
checked={cutPeak}
|
||||
onCheckedChange={setCutPeak}
|
||||
/>
|
||||
<Label htmlFor="peak-shaving">开启削峰</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{(pingHistory?.tasks || []).map((task) => {
|
||||
const values = chartData
|
||||
.map((d) => d[task.id])
|
||||
.filter((v) => v !== null && v !== undefined) as number[];
|
||||
const loss =
|
||||
chartData.length > 0
|
||||
? (1 - values.length / chartData.length) * 100
|
||||
: 0;
|
||||
const min = values.length > 0 ? Math.min(...values) : 0;
|
||||
const isVisible = visiblePingTasks.includes(task.id);
|
||||
|
||||
return (
|
||||
<div key={task.id} className="flex flex-col items-center">
|
||||
<Button
|
||||
variant={isVisible ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={() => handleTaskVisibilityToggle(task.id)}
|
||||
style={{
|
||||
backgroundColor: isVisible
|
||||
? stringToColor(task.name)
|
||||
: undefined,
|
||||
color: isVisible ? "white" : undefined,
|
||||
}}>
|
||||
{task.name}
|
||||
<span className="text-xs mt-1">
|
||||
{loss.toFixed(1)}% | {min.toFixed(0)}ms
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pingHistory?.tasks && pingHistory.tasks.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="time"
|
||||
{...(chartData.length > 1 && {
|
||||
domain: ["dataMin", "dataMax"],
|
||||
})}
|
||||
tickFormatter={(time) => {
|
||||
const date = new Date(time);
|
||||
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",
|
||||
});
|
||||
}}
|
||||
scale="time"
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip labelFormatter={lableFormatter} />
|
||||
{pingHistory.tasks.map((task) => (
|
||||
<Line
|
||||
key={task.id}
|
||||
type={cutPeak ? "basis" : "linear"}
|
||||
dataKey={String(task.id)}
|
||||
name={task.name}
|
||||
stroke={stringToColor(task.name)}
|
||||
strokeWidth={2}
|
||||
hide={!visiblePingTasks.includes(task.id)}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
/>
|
||||
))}
|
||||
<Brush
|
||||
dataKey="time"
|
||||
height={30}
|
||||
stroke="#8884d8"
|
||||
tickFormatter={(time) => {
|
||||
const date = new Date(time);
|
||||
if (hours === 0) {
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
return date.toLocaleDateString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}}
|
||||
onChange={(e: any) => {
|
||||
if (
|
||||
e.startIndex !== undefined &&
|
||||
e.endIndex !== undefined &&
|
||||
chartData[e.startIndex] &&
|
||||
chartData[e.endIndex]
|
||||
) {
|
||||
setTimeRange([
|
||||
chartData[e.startIndex].time,
|
||||
chartData[e.endIndex].time,
|
||||
]);
|
||||
} else {
|
||||
setTimeRange(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PingChart;
|
238
src/pages/instance/index.tsx
Normal file
238
src/pages/instance/index.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useState, useEffect, lazy, Suspense, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useNodeData } from "@/contexts/NodeDataContext";
|
||||
import { useLiveData } from "@/contexts/LiveDataContext";
|
||||
import type { NodeData, NodeWithStatus } from "@/types/node";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Instance from "./Instance";
|
||||
const LoadCharts = lazy(() => import("./LoadCharts"));
|
||||
const PingChart = lazy(() => import("./PingChart"));
|
||||
import Loading from "@/components/loading";
|
||||
import Flag from "@/components/sections/Flag";
|
||||
|
||||
const InstancePage = () => {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
nodes: staticNodes,
|
||||
publicSettings,
|
||||
loading: nodesLoading,
|
||||
} = useNodeData();
|
||||
const { liveData } = useLiveData();
|
||||
const { getRecentLoadHistory } = useNodeData();
|
||||
const [staticNode, setStaticNode] = useState<NodeData | null>(null);
|
||||
const [chartType, setChartType] = useState<"load" | "ping">("load");
|
||||
const [loadHours, setLoadHours] = useState<number>(0);
|
||||
const [pingHours, setPingHours] = useState<number>(1); // 默认1小时
|
||||
const [realtimeChartData, setRealtimeChartData] = useState<any[]>([]);
|
||||
|
||||
const maxRecordPreserveTime = publicSettings?.record_preserve_time || 0; // 默认0表示关闭
|
||||
const maxPingRecordPreserveTime =
|
||||
publicSettings?.ping_record_preserve_time || 24; // 默认1天
|
||||
|
||||
const timeRanges = useMemo(() => {
|
||||
return [
|
||||
{ label: "实时", hours: 0 },
|
||||
{ label: "1小时", hours: 1 },
|
||||
{ label: "6小时", hours: 6 },
|
||||
{ label: "1天", hours: 24 },
|
||||
{ label: "7天", hours: 168 },
|
||||
{ label: "30天", hours: 720 },
|
||||
];
|
||||
}, []);
|
||||
|
||||
const pingTimeRanges = useMemo(() => {
|
||||
const filtered = timeRanges.filter(
|
||||
(range) => range.hours !== 0 && range.hours <= maxPingRecordPreserveTime
|
||||
);
|
||||
|
||||
if (maxPingRecordPreserveTime > 720) {
|
||||
filtered.push({
|
||||
label: `${maxPingRecordPreserveTime}小时`,
|
||||
hours: maxPingRecordPreserveTime,
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [timeRanges, maxPingRecordPreserveTime]);
|
||||
|
||||
const loadTimeRanges = useMemo(() => {
|
||||
const filtered = timeRanges.filter(
|
||||
(range) => range.hours <= maxRecordPreserveTime
|
||||
);
|
||||
if (maxRecordPreserveTime > 720) {
|
||||
filtered.push({
|
||||
label: `${maxRecordPreserveTime}小时`,
|
||||
hours: maxRecordPreserveTime,
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [timeRanges, maxRecordPreserveTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const foundNode = staticNodes.find((n) => n.uuid === uuid);
|
||||
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;
|
||||
const stats = isOnline ? liveData?.data[staticNode.uuid] : undefined;
|
||||
return {
|
||||
...staticNode,
|
||||
status: isOnline ? "online" : "offline",
|
||||
stats,
|
||||
};
|
||||
}, [staticNode, liveData]);
|
||||
|
||||
if (!node || !staticNode) {
|
||||
if (nodesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loading text="正在获取节点信息..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
未找到该节点
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[90%] max-w-screen-2xl mx-auto flex-1 flex flex-col pb-15 p-4 space-y-4">
|
||||
<div className="flex items-center justify-between bg-card box-border border rounded-lg p-4 mb-4 text-secondary-foreground">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Button
|
||||
className="bg-card flex-shrink-0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Flag flag={node.region}></Flag>
|
||||
<span className="text-xl md:text-2xl font-bold">{node.name}</span>
|
||||
</div>
|
||||
<span className="text-sm text-secondary-foreground flex-shrink-0">
|
||||
{node.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Instance node={node as NodeWithStatus} />
|
||||
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant={chartType === "load" ? "secondary" : "ghost"}
|
||||
onClick={() => setChartType("load")}>
|
||||
负载
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartType === "ping" ? "secondary" : "ghost"}
|
||||
onClick={() => setChartType("ping")}>
|
||||
延迟
|
||||
</Button>
|
||||
</div>
|
||||
{chartType === "load" ? (
|
||||
<div className="flex justify-center space-x-2">
|
||||
{loadTimeRanges.map((range) => (
|
||||
<Button
|
||||
key={range.label}
|
||||
variant={loadHours === range.hours ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setLoadHours(range.hours)}>
|
||||
{range.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center space-x-2">
|
||||
{pingTimeRanges.map((range) => (
|
||||
<Button
|
||||
key={range.label}
|
||||
variant={pingHours === range.hours ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setPingHours(range.hours)}>
|
||||
{range.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loading text="正在加载图表..." />
|
||||
</div>
|
||||
}>
|
||||
{chartType === "load" && staticNode ? (
|
||||
<LoadCharts
|
||||
node={staticNode}
|
||||
hours={loadHours}
|
||||
data={realtimeChartData}
|
||||
liveData={liveData?.data[staticNode.uuid]}
|
||||
/>
|
||||
) : chartType === "ping" && staticNode ? (
|
||||
<PingChart node={staticNode} hours={pingHours} />
|
||||
) : null}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstancePage;
|
Reference in New Issue
Block a user