feat(theme): 新增主题可配置项,优化代码逻辑和样式

-  在 `komari-theme.json` 中添加了新的配置选项
- 支持自定义标题栏、内容区、实例页面和通用UI元素
- 优化部分组件调用逻辑
- 优化页面样式
This commit is contained in:
Montia37
2025-08-15 19:27:55 +08:00
parent e74611b947
commit 1c1f739043
21 changed files with 922 additions and 766 deletions

View File

@@ -8,9 +8,10 @@ import Loading from "@/components/loading";
import type { NodeWithStatus } from "@/types/node";
import { useNodeData } from "@/contexts/NodeDataContext";
import { useLiveData } from "@/contexts/LiveDataContext";
import { useConfigItem } from "@/config";
interface HomePageProps {
viewMode: "card" | "list";
viewMode: "grid" | "table";
searchTerm: string;
}
@@ -18,6 +19,8 @@ const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
const { nodes: staticNodes, loading, getGroups } = useNodeData();
const { liveData } = useLiveData();
const [selectedGroup, setSelectedGroup] = useState("所有");
const enableGroupedBar = useConfigItem("enableGroupedBar");
const enableStatsBar = useConfigItem("enableStatsBar");
const [displayOptions, setDisplayOptions] = useState({
time: true,
online: true,
@@ -80,47 +83,51 @@ const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
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}
/>
{enableStatsBar && (
<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>
{enableGroupedBar && (
<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">
<div className="space-y-4 mt-4">
{loading ? (
<Loading text="正在努力获取数据中..." />
) : filteredNodes.length > 0 ? (
<div
className={
viewMode === "card"
viewMode === "grid"
? ""
: "space-y-2 bg-card overflow-auto backdrop-blur-[10px] rounded-lg p-2"
}>
<div
className={
viewMode === "card"
viewMode === "grid"
? "grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4"
: "min-w-[1080px]"
}>
{viewMode === "list" && <NodeListHeader />}
{viewMode === "table" && <NodeListHeader />}
{filteredNodes.map((node: NodeWithStatus) =>
viewMode === "card" ? (
viewMode === "grid" ? (
<NodeCard key={node.uuid} node={node} />
) : (
<NodeListItem key={node.uuid} node={node} />

View File

@@ -1,40 +1,30 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { NodeWithStatus } from "@/types/node";
import { useMemo, memo } from "react";
import { formatBytes, formatUptime } from "@/utils";
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);
const formatTrafficLimit = (
limit?: number,
type?: "sum" | "max" | "min" | "up" | "down"
) => {
if (!limit) return "未设置";
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}`;
const limitText = formatBytes(limit);
return result.trim();
};
const typeText =
{
sum: "总和",
max: "最大值",
min: "最小值",
up: "上传",
down: "下载",
}[type || "max"] || "";
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`;
}
return `${limitText} (${typeText})`;
};
const Instance = memo(({ node }: InstanceProps) => {
@@ -47,33 +37,33 @@ const Instance = memo(({ node }: InstanceProps) => {
return (
<Card>
<CardHeader>
<CardHeader className="pb-2">
<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>
<CardContent className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div className="md:col-span-2">
<p className="text-muted-foreground"></p>
<p>{node.os}</p>
<p className="text-muted-foreground text-sm">CPU</p>
<p className="text-sm">{`${node.cpu_name} (x${node.cpu_cores})`}</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">{node.arch}</p>
</div>
<div>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">{node.virtualization}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">GPU</p>
<p className="text-sm">{node.gpu_name || "N/A"}</p>
</div>
<div>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">{node.os}</p>
</div>
<div>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">
{stats && isOnline
? `${formatBytes(stats.ram.used)} / ${formatBytes(
node.mem_total
@@ -82,18 +72,18 @@ const Instance = memo(({ node }: InstanceProps) => {
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">
{stats && isOnline
? `${formatBytes(stats.swap.used, "MB")} / ${formatBytes(
? `${formatBytes(stats.swap.used)} / ${formatBytes(
node.swap_total
)}`
: `N/A / ${formatBytes(node.swap_total)}`}
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">
{stats && isOnline
? `${formatBytes(stats.disk.used)} / ${formatBytes(
node.disk_total
@@ -101,20 +91,24 @@ const Instance = memo(({ node }: InstanceProps) => {
: `N/A / ${formatBytes(node.disk_total)}`}
</p>
</div>
<div>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">{formatUptime(stats?.uptime || 0)}</p>
</div>
<div className="md:col-span-2">
<p className="text-muted-foreground"></p>
<p>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">
{stats && isOnline
? `${formatBytes(stats.network.up, "KB")}/s${formatBytes(
? `${formatBytes(stats.network.up, true)}${formatBytes(
stats.network.down,
"KB"
)}/s`
true
)}`
: "N/A"}
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
<div className="md:col-span-2">
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">
{stats && isOnline
? `${formatBytes(stats.network.totalUp)}${formatBytes(
stats.network.totalDown
@@ -122,13 +116,15 @@ const Instance = memo(({ node }: InstanceProps) => {
: "N/A"}
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>{formatUptime(stats?.uptime || 0)}</p>
<div className="md:col-span-2">
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">
{formatTrafficLimit(node.traffic_limit, node.traffic_limit_type)}
</p>
</div>
<div>
<p className="text-muted-foreground"></p>
<p>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">
{stats && isOnline
? new Date(stats.updated_at).toLocaleString()
: "N/A"}

View File

@@ -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<RecordFormat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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: (
<Flex gap="0" direction="column" align="end" className="text-sm">
<label>
{liveData?.ram?.used
? `${formatBytes(liveData.ram.used)} / ${formatBytes(
node?.mem_total || 0
)}`
: "-"}
</label>
<label>
{liveData?.swap?.used
? `${formatBytes(liveData.swap.used)} / ${formatBytes(
node?.swap_total || 0
)}`
: "-"}
</label>
</Flex>
),
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: (
<>
<Flex gap="0" align="end" direction="column" className="text-sm">
<span> {formatBytes(liveData?.network.up || 0)}/s</span>
<span> {formatBytes(liveData?.network.down || 0)}/s</span>
</Flex>
</>
),
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: (
<Flex gap="0" direction="column" align="end" className="text-sm">
<label>
{liveData?.ram?.used
? `${formatBytes(liveData.ram.used)} / ${formatBytes(
node?.mem_total || 0
)}`
: "-"}
</label>
<label>
{liveData?.swap?.used
? `${formatBytes(liveData.swap.used)} / ${formatBytes(
node?.swap_total || 0
)}`
: "-"}
</label>
</Flex>
),
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: (
<>
<Flex gap="0" align="end" direction="column" className="text-sm">
<span>TCP: {liveData?.connections.tcp}</span>
<span>UDP: {liveData?.connections.udp}</span>
<span> {formatBytes(liveData?.network.up || 0)}/s</span>
<span> {formatBytes(liveData?.network.down || 0)}/s</span>
</Flex>
),
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: (
<Flex gap="0" align="end" direction="column" className="text-sm">
<span>TCP: {liveData?.connections.tcp}</span>
<span>UDP: {liveData?.connections.udp}</span>
</Flex>
),
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 (
<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">
{labelFormatter(label)}
</p>
<div className="space-y-1">
{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 (
<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">
{series?.tooltipLabel || item.dataKey}:
</span>
</div>
<span className="text-sm font-bold ml-2">{value}</span>
</div>
);
})}
</div>
</div>
);
};
// 根据配置渲染图表
const renderChart = (config: any) => {
const ChartComponent = config.type === "area" ? AreaChart : LineChart;
const DataComponent =
config.type === "area" ? Area : (Line as React.ComponentType<any>);
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 (
<Card className={cn} key={config.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{config.title}
</CardTitle>
<span className="text-sm font-bold">{config.value}</span>
</CardHeader>
<ChartContainer config={chartConfig}>
<ChartComponent data={config.data} margin={chartMargin}>
<CartesianGrid strokeDasharray="2 4" vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tickFormatter={timeFormatter}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
domain={config.yAxisDomain}
tickFormatter={config.yAxisFormatter}
orientation="left"
type="number"
tick={{ dx: -10 }}
mirror={true}
/>
<Tooltip
cursor={false}
content={(props: any) => (
<CustomTooltip {...props} chartConfig={config} />
)}
/>
{config.series ? (
config.series.map((series: any) => (
<DataComponent
key={series.dataKey}
dataKey={series.dataKey}
animationDuration={0}
stroke={series.color}
fill={config.type === "area" ? series.color : undefined}
opacity={0.8}
dot={false}
/>
))
) : (
<DataComponent
dataKey={config.dataKey}
animationDuration={0}
stroke={config.color}
fill={config.type === "area" ? config.color : undefined}
opacity={0.8}
dot={false}
/>
)}
</ChartComponent>
</ChartContainer>
</Card>
);
};
// 通用提示组件
const CustomTooltip = ({ active, payload, label, chartConfig }: any) => {
if (!active || !payload || !payload.length) return null;
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">
{chartConfigs.map(renderChart)}
<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">
{labelFormatter(label)}
</p>
<div className="space-y-1">
{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 (
<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">
{series?.tooltipLabel || item.dataKey}:
</span>
</div>
<span className="text-sm font-bold ml-2">{value}</span>
</div>
);
})}
</div>
</div>
);
}
);
};
// 根据配置渲染图表
const renderChart = (config: any) => {
const ChartComponent = config.type === "area" ? AreaChart : LineChart;
const DataComponent =
config.type === "area" ? Area : (Line as React.ComponentType<any>);
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 (
<Card className={cn} key={config.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{config.title}</CardTitle>
<span className="text-sm font-bold">{config.value}</span>
</CardHeader>
<ChartContainer config={chartConfig}>
<ChartComponent data={config.data} margin={chartMargin}>
<CartesianGrid strokeDasharray="2 4" vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tickFormatter={timeFormatter}
interval={0}
/>
<YAxis
tickLine={false}
axisLine={false}
domain={config.yAxisDomain}
tickFormatter={config.yAxisFormatter}
orientation="left"
type="number"
tick={{ dx: -10 }}
mirror={true}
/>
<Tooltip
cursor={false}
content={(props: any) => (
<CustomTooltip {...props} chartConfig={config} />
)}
/>
{config.series ? (
config.series.map((series: any) => (
<DataComponent
key={series.dataKey}
dataKey={series.dataKey}
animationDuration={0}
stroke={series.color}
fill={config.type === "area" ? series.color : undefined}
opacity={0.8}
dot={false}
/>
))
) : (
<DataComponent
dataKey={config.dataKey}
animationDuration={0}
stroke={config.color}
fill={config.type === "area" ? config.color : undefined}
opacity={0.8}
dot={false}
/>
)}
</ChartComponent>
</ChartContainer>
</Card>
);
};
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">
{chartConfigs.map(renderChart)}
</div>
</div>
);
});
export default LoadCharts;

View File

@@ -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<number[]>([]);
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) =>

View File

@@ -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<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 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 = () => {
</div>
</div>
<Instance node={node as NodeWithStatus} />
{enableInstanceDetail && <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 className="bg-card border rounded-lg py-3 px-4 inline-block mx-auto">
<div className="flex justify-center space-x-2">
<Button
variant={chartType === "load" ? "secondary" : "ghost"}
onClick={() => setChartType("load")}>
</Button>
{enablePingChart && (
<Button
variant={chartType === "ping" ? "secondary" : "ghost"}
onClick={() => setChartType("ping")}>
</Button>
)}
</div>
{chartType === "load" ? (
<div className="flex justify-center space-x-2 mt-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 mt-2">
{pingTimeRanges.map((range) => (
<Button
key={range.label}
variant={pingHours === range.hours ? "secondary" : "ghost"}
size="sm"
onClick={() => setPingHours(range.hours)}>
{range.label}
</Button>
))}
</div>
)}
</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={
@@ -224,7 +179,6 @@ const InstancePage = () => {
<LoadCharts
node={staticNode}
hours={loadHours}
data={realtimeChartData}
liveData={liveData?.data[staticNode.uuid]}
/>
) : chartType === "ping" && staticNode ? (