mirror of
https://github.com/fankes/komari-theme-purcarte.git
synced 2025-10-18 19:39:22 +08:00
feat: 更新构建流程,优化变更日志生成和图表渲染
This commit is contained in:
37
.github/workflows/build.yaml
vendored
37
.github/workflows/build.yaml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-and-package:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -67,41 +67,20 @@ jobs:
|
||||
echo "Created package: ${ZIP_NAME}"
|
||||
ls -la ${ZIP_NAME}
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: theme-package
|
||||
path: ${{ env.ZIP_NAME }}
|
||||
retention-days: 1
|
||||
|
||||
create-release-on-tag-push:
|
||||
needs: build-and-package
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: theme-package
|
||||
path: .
|
||||
- name: Get Asset Name
|
||||
id: get_asset_name
|
||||
run: |
|
||||
ASSET_NAME=$(ls *.zip)
|
||||
echo "ASSET_NAME=${ASSET_NAME}" >> $GITHUB_ENV
|
||||
- name: Generate Changelog
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
id: changelog
|
||||
run: |
|
||||
git fetch --prune --unshallow
|
||||
git fetch --prune
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 `git rev-list --tags --skip=1 --max-count=1` 2>/dev/null || git rev-list --max-parents=0 HEAD)
|
||||
echo "Previous tag: $PREVIOUS_TAG"
|
||||
CHANGELOG=$(git log $PREVIOUS_TAG..${{ github.ref_name }} --pretty=format:"* %s (%h)")
|
||||
echo "CHANGELOG<<EOF" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Release
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
@@ -112,12 +91,14 @@ jobs:
|
||||
body: ${{ env.CHANGELOG }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload Release Asset
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./${{ env.ASSET_NAME }}
|
||||
asset_name: ${{ env.ASSET_NAME }}
|
||||
asset_path: ./${{ env.ZIP_NAME }}
|
||||
asset_name: ${{ env.ZIP_NAME }}
|
||||
asset_content_type: application/zip
|
||||
|
@@ -17,8 +17,8 @@
|
||||
>
|
||||
> **当前版本注意事项**
|
||||
>
|
||||
> - `Instance` 页面尚在完善中,目前仅基于官方样式进行了微调
|
||||
> - 延迟信息图表的有较大问题仍需优化
|
||||
> - [ ] `Instance` 页面尚在完善中,目前仅基于官方样式进行了微调
|
||||
> - [x] 延迟信息图表的有较大问题仍需优化(优化完成)
|
||||
>
|
||||
> 如果您对以上页面的功能和展示有较高要求,建议暂时选用 [社区中的其他主题](https://komari-document.pages.dev/community/theme)。
|
||||
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"name": "Komari Theme PurCarte",
|
||||
"short": "PurCarte",
|
||||
"description": "A frosted glass theme for Komari",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"author": "Montia & Gemini",
|
||||
"url": "https://github.com/Montia37/Komari-theme-purcarte",
|
||||
"preview": "preview.png"
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useState, useEffect, useRef } from "react";
|
||||
import { memo, useCallback, useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
@@ -25,17 +25,8 @@ interface LoadChartsProps {
|
||||
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) => {
|
||||
({ node, hours, data = [], liveData }: LoadChartsProps) => {
|
||||
const { getLoadHistory } = useNodeData();
|
||||
const [historicalData, setHistoricalData] = useState<RecordFormat[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -43,6 +34,7 @@ const LoadCharts = memo(
|
||||
|
||||
const isRealtime = hours === 0;
|
||||
|
||||
// 获取历史数据
|
||||
useEffect(() => {
|
||||
if (!isRealtime) {
|
||||
const fetchHistoricalData = async () => {
|
||||
@@ -52,19 +44,18 @@ const LoadCharts = memo(
|
||||
const data = await getLoadHistory(node.uuid, hours);
|
||||
setHistoricalData(data?.records || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch historical data");
|
||||
setError(err.message || "获取历史数据失败");
|
||||
} 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
|
||||
@@ -81,13 +72,22 @@ const LoadCharts = memo(
|
||||
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 (chartDataLengthRef.current === 0) return "";
|
||||
if (index === 0 || index === chartDataLengthRef.current - 1) {
|
||||
return new Date(value).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
@@ -97,7 +97,7 @@ const LoadCharts = memo(
|
||||
return "";
|
||||
}, []);
|
||||
|
||||
const lableFormatter = useCallback(
|
||||
const labelFormatter = useCallback(
|
||||
(value: any) => {
|
||||
const date = new Date(value);
|
||||
if (hours === 0) {
|
||||
@@ -117,102 +117,180 @@ const LoadCharts = memo(
|
||||
[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 chartMargin = { top: 10, right: 16, bottom: 10, left: 16 };
|
||||
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]);
|
||||
// 图表配置
|
||||
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: (
|
||||
<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: "进程数",
|
||||
},
|
||||
];
|
||||
|
||||
// 通用自定义 Tooltip 组件
|
||||
const CustomTooltip = ({ active, payload, label, chartType }: any) => {
|
||||
// 通用提示组件
|
||||
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">
|
||||
{lableFormatter(label)}
|
||||
{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;
|
||||
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();
|
||||
if (series?.tooltipFormatter) {
|
||||
value = series.tooltipFormatter(value, item.payload);
|
||||
} else {
|
||||
value = value.toString();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -225,7 +303,7 @@ const LoadCharts = memo(
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{displayName}:
|
||||
{series?.tooltipLabel || item.dataKey}:
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold ml-2">{value}</span>
|
||||
@@ -237,6 +315,90 @@ const LoadCharts = memo(
|
||||
);
|
||||
};
|
||||
|
||||
// 根据配置渲染图表
|
||||
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 && (
|
||||
@@ -250,381 +412,7 @@ const LoadCharts = memo(
|
||||
</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>
|
||||
{chartConfigs.map(renderChart)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -59,21 +59,12 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
if (!pingHistory || !pingHistory.records || !pingHistory.tasks) return [];
|
||||
|
||||
const grouped: Record<string, any> = {};
|
||||
const timeKeys: number[] = [];
|
||||
|
||||
// 优化:将2秒窗口内的点分组,以合并几乎同时的记录
|
||||
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;
|
||||
const useKey = Math.round(t / 2000) * 2000;
|
||||
if (!grouped[useKey]) {
|
||||
grouped[useKey] = { time: useKey };
|
||||
if (foundKey === null) timeKeys.push(useKey);
|
||||
}
|
||||
grouped[useKey][rec.task_id] = rec.value === -1 ? null : rec.value;
|
||||
}
|
||||
@@ -82,17 +73,31 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
|
||||
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;
|
||||
let interval = task[0]?.interval || 60; // base interval in seconds
|
||||
const maxGap = interval * 1.2;
|
||||
|
||||
if (selectedHours > 24) {
|
||||
interval *= 60;
|
||||
// 使用固定的 hours 值进行降采样计算,不依赖 timeRange
|
||||
const selectedDurationHours = hours;
|
||||
const totalDurationSeconds = hours * 60 * 60;
|
||||
|
||||
// 根据所选视图调整间隔,进行更积极的降采样
|
||||
if (selectedDurationHours > 30 * 24) {
|
||||
// > 30 天
|
||||
interval = 60 * 60; // 1 hour
|
||||
} else if (selectedDurationHours > 7 * 24) {
|
||||
// > 7 天
|
||||
interval = 15 * 60; // 15 minutes
|
||||
} else if (selectedDurationHours > 24) {
|
||||
// > 1 天
|
||||
interval = 5 * 60; // 5 minutes
|
||||
}
|
||||
|
||||
full = fillMissingTimePoints(full, interval, hours * 60 * 60, maxGap);
|
||||
full = fillMissingTimePoints(
|
||||
full,
|
||||
interval,
|
||||
totalDurationSeconds,
|
||||
maxGap
|
||||
);
|
||||
|
||||
full = full.map((d: any) => ({
|
||||
...d,
|
||||
@@ -100,13 +105,27 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
}));
|
||||
}
|
||||
|
||||
// 添加渲染硬限制以防止崩溃,即使在间隔调整后也是如此
|
||||
const MAX_POINTS_TO_RENDER = 0;
|
||||
if (full.length > MAX_POINTS_TO_RENDER && MAX_POINTS_TO_RENDER > 0) {
|
||||
console.log(
|
||||
`数据量过大 (${full.length}), 降采样至 ${MAX_POINTS_TO_RENDER} 个点。`
|
||||
);
|
||||
const samplingFactor = Math.ceil(full.length / MAX_POINTS_TO_RENDER);
|
||||
const sampledData = [];
|
||||
for (let i = 0; i < full.length; i += samplingFactor) {
|
||||
sampledData.push(full[i]);
|
||||
}
|
||||
full = sampledData;
|
||||
}
|
||||
|
||||
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]);
|
||||
}, [pingHistory, hours, cutPeak]);
|
||||
|
||||
const handleTaskVisibilityToggle = (taskId: number) => {
|
||||
setVisiblePingTasks((prev) =>
|
||||
@@ -172,7 +191,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
<Button
|
||||
variant={isVisible ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
className="h-auto px-2 py-1 flex flex-col"
|
||||
onClick={() => handleTaskVisibilityToggle(task.id)}
|
||||
style={{
|
||||
backgroundColor: isVisible
|
||||
@@ -180,8 +199,8 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
: undefined,
|
||||
color: isVisible ? "white" : undefined,
|
||||
}}>
|
||||
{task.name}
|
||||
<span className="text-xs mt-1">
|
||||
<div>{task.name}</div>
|
||||
<span className="text-xs">
|
||||
{loss.toFixed(1)}% | {min.toFixed(0)}ms
|
||||
</span>
|
||||
</Button>
|
||||
@@ -195,13 +214,11 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
{pingHistory?.tasks && pingHistory.tasks.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<CartesianGrid strokeDasharray="2 4" vertical={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="time"
|
||||
{...(chartData.length > 1 && {
|
||||
domain: ["dataMin", "dataMax"],
|
||||
})}
|
||||
domain={timeRange || ["dataMin", "dataMax"]}
|
||||
tickFormatter={(time) => {
|
||||
const date = new Date(time);
|
||||
if (hours === 0) {
|
||||
@@ -239,6 +256,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
dataKey="time"
|
||||
height={30}
|
||||
stroke="#8884d8"
|
||||
alwaysShowText
|
||||
tickFormatter={(time) => {
|
||||
const date = new Date(time);
|
||||
if (hours === 0) {
|
||||
@@ -250,6 +268,8 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
return date.toLocaleDateString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}}
|
||||
onChange={(e: any) => {
|
||||
|
@@ -232,10 +232,11 @@ export function cutPeakValues<T extends { [key: string]: any }>(
|
||||
if (currentValue != null && typeof currentValue === "number") {
|
||||
if (ewma === null) {
|
||||
// 第一个有效值作为初始EWMA值
|
||||
ewma = currentValue;
|
||||
ewma = Math.round(currentValue * 100) / 100;
|
||||
} else {
|
||||
// EWMA = α * 当前值 + (1-α) * 前一个EWMA值
|
||||
ewma = alpha * currentValue + (1 - alpha) * ewma;
|
||||
ewma =
|
||||
Math.round((alpha * currentValue + (1 - alpha) * ewma) * 100) / 100;
|
||||
}
|
||||
result[i] = { ...result[i], [key]: ewma };
|
||||
} else if (ewma !== null) {
|
||||
|
Reference in New Issue
Block a user