From f6db5cbd6460e80bb991c973650d744c660e6b1a Mon Sep 17 00:00:00 2001 From: Montia37 Date: Thu, 14 Aug 2025 19:55:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=94=9F=E6=88=90=E5=92=8C=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yaml | 37 +- README.md | 4 +- komari-theme.json | 2 +- src/pages/instance/LoadCharts.tsx | 742 +++++++++++------------------- src/pages/instance/PingChart.tsx | 74 +-- src/utils/RecordHelper.tsx | 5 +- 6 files changed, 327 insertions(+), 537 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1b53db7..187a864 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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<> $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 diff --git a/README.md b/README.md index 38e2de2..564491a 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ > > **当前版本注意事项** > -> - `Instance` 页面尚在完善中,目前仅基于官方样式进行了微调 -> - 延迟信息图表的有较大问题仍需优化 +> - [ ] `Instance` 页面尚在完善中,目前仅基于官方样式进行了微调 +> - [x] 延迟信息图表的有较大问题仍需优化(优化完成) > > 如果您对以上页面的功能和展示有较高要求,建议暂时选用 [社区中的其他主题](https://komari-document.pages.dev/community/theme)。 diff --git a/komari-theme.json b/komari-theme.json index 50a8a26..672e0ec 100644 --- a/komari-theme.json +++ b/komari-theme.json @@ -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" diff --git a/src/pages/instance/LoadCharts.tsx b/src/pages/instance/LoadCharts.tsx index a6c3231..54c6f66 100644 --- a/src/pages/instance/LoadCharts.tsx +++ b/src/pages/instance/LoadCharts.tsx @@ -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 ( - - {text} - {left} - - ); -}; - const LoadCharts = memo( - ({ node, hours, data = [], liveData: live_data }: LoadChartsProps) => { + ({ node, hours, data = [], liveData }: LoadChartsProps) => { const { getLoadHistory } = useNodeData(); const [historicalData, setHistoricalData] = useState([]); 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: ( + + + + + ), + series: [ + { + dataKey: "ram", + color: colors[0], + tooltipLabel: "内存", + tooltipFormatter: (value: number, raw: any) => + `${formatBytes(raw?.ram_raw || 0)} (${value.toFixed(0)}%)`, + }, + { + dataKey: "swap", + color: colors[1], + tooltipLabel: "交换", + tooltipFormatter: (value: number, raw: any) => + `${formatBytes(raw?.swap_raw || 0)} (${value.toFixed(0)}%)`, + }, + ], + yAxisDomain: [0, 100], + yAxisFormatter: (value: number, index: number) => + index !== 0 ? `${value}%` : "", + data: memoryChartData, + }, + { + id: "disk", + title: "磁盘", + type: "area", + value: liveData?.disk?.used + ? `${formatBytes(liveData.disk.used)} / ${formatBytes( + node?.disk_total || 0 + )}` + : "-", + dataKey: "disk", + yAxisDomain: [0, node?.disk_total || 100], + yAxisFormatter: (value: number, index: number) => + index !== 0 ? formatBytes(value) : "", + color: colors[0], + data: chartData, + tooltipFormatter: (value: number) => formatBytes(value), + tooltipLabel: "磁盘使用", + }, + { + id: "network", + title: "网络", + type: "line", + value: ( + <> + + ↑ {formatBytes(liveData?.network.up || 0)}/s + ↓ {formatBytes(liveData?.network.down || 0)}/s + + + ), + series: [ + { + dataKey: "net_in", + color: colors[0], + tooltipLabel: "下载", + tooltipFormatter: (value: number) => `${formatBytes(value)}/s`, + }, + { + dataKey: "net_out", + color: colors[3], + tooltipLabel: "上传", + tooltipFormatter: (value: number) => `${formatBytes(value)}/s`, + }, + ], + yAxisFormatter: (value: number, index: number) => + index !== 0 ? formatBytes(value) : "", + data: chartData, + }, + { + id: "connections", + title: "连接数", + type: "line", + value: ( + + TCP: {liveData?.connections.tcp} + UDP: {liveData?.connections.udp} + + ), + series: [ + { + dataKey: "connections", + color: colors[0], + tooltipLabel: "TCP 连接", + }, + { + dataKey: "connections_udp", + color: colors[1], + tooltipLabel: "UDP 连接", + }, + ], + data: chartData, + }, + { + id: "process", + title: "进程数", + type: "line", + value: liveData?.process || "-", + dataKey: "process", + color: colors[0], + data: chartData, + tooltipLabel: "进程数", + }, + ]; - // 通用自定义 Tooltip 组件 - const CustomTooltip = ({ active, payload, label, chartType }: any) => { + // 通用提示组件 + const CustomTooltip = ({ active, payload, label, chartConfig }: any) => { if (!active || !payload || !payload.length) return null; return (

- {lableFormatter(label)} + {labelFormatter(label)}

{payload.map((item: any, index: number) => { + const series = chartConfig.series + ? chartConfig.series.find( + (s: any) => s.dataKey === item.dataKey + ) + : { + dataKey: chartConfig.dataKey, + tooltipLabel: chartConfig.tooltipLabel, + tooltipFormatter: chartConfig.tooltipFormatter, + }; + let value = item.value; - 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 }} /> - {displayName}: + {series?.tooltipLabel || item.dataKey}:
{value} @@ -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); + + const chartConfig = config.series + ? config.series.reduce((acc: any, series: any) => { + acc[series.dataKey] = { + label: series.tooltipLabel || series.dataKey, + color: series.color, + }; + return acc; + }, {}) + : { + [config.dataKey]: { + label: config.tooltipLabel || config.dataKey, + color: config.color, + }, + }; + + return ( + + + + {config.title} + + {config.value} + + + + + + + ( + + )} + /> + {config.series ? ( + config.series.map((series: any) => ( + + )) + ) : ( + + )} + + + + ); + }; + return (
{loading && ( @@ -250,381 +412,7 @@ const LoadCharts = memo(
)}
- {/* CPU */} - - {ChartTitle( - "CPU", - live_data?.cpu?.usage ? `${live_data.cpu.usage.toFixed(2)}%` : "-" - )} - - - - - - index !== 0 ? `${value}%` : "" - } - orientation="left" - type="number" - tick={{ dx: -10 }} - mirror={true} - /> - ( - - )} - /> - - - - - - {/* Ram */} - - {ChartTitle( - "内存", - - - - - )} - - - - - - index !== 0 ? `${value}%` : "" - } - orientation="left" - type="number" - tick={{ dx: -10 }} - mirror={true} - /> - ( - - )} - /> - - - - - - - {/* Disk */} - - {ChartTitle( - "磁盘", - live_data?.disk?.used - ? `${formatBytes(live_data.disk.used)} / ${formatBytes( - node?.disk_total || 0 - )}` - : "-" - )} - - - - - - index !== 0 ? `${formatBytes(value)}` : "" - } - orientation="left" - type="number" - tick={{ dx: -10 }} - mirror={true} - /> - ( - - )} - /> - - - - - - {/* Network */} - - {ChartTitle( - "网络", - - ↑ {formatBytes(live_data?.network.up || 0)}/s - ↓ {formatBytes(live_data?.network.down || 0)}/s - - )} - - - - - - index !== 0 ? `${formatBytes(value)}` : "" - } - orientation="left" - type="number" - tick={{ dx: -10 }} - mirror={true} - /> - ( - - )} - /> - - - - - - - {/* Connections */} - - {ChartTitle( - "连接数", - - TCP: {live_data?.connections.tcp} - UDP: {live_data?.connections.udp} - - )} - - - - - - index !== 0 ? `${value}` : "" - } - orientation="left" - type="number" - tick={{ dx: -10 }} - mirror={true} - /> - ( - - )} - /> - - - - - - - {/* Process */} - - {ChartTitle("进程数", live_data?.process || "-")} - - - - - - index !== 0 ? `${value}` : "" - } - orientation="left" - type="number" - tick={{ dx: -10 }} - mirror={true} - /> - ( - - )} - /> - - - - + {chartConfigs.map(renderChart)}
); diff --git a/src/pages/instance/PingChart.tsx b/src/pages/instance/PingChart.tsx index 3bcff9c..a2c8ca9 100644 --- a/src/pages/instance/PingChart.tsx +++ b/src/pages/instance/PingChart.tsx @@ -59,21 +59,12 @@ const PingChart = memo(({ node, hours }: PingChartProps) => { if (!pingHistory || !pingHistory.records || !pingHistory.tasks) return []; const grouped: Record = {}; - 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) => { @@ -195,13 +214,11 @@ const PingChart = memo(({ node, hours }: PingChartProps) => { {pingHistory?.tasks && pingHistory.tasks.length > 0 ? ( - + 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) => { diff --git a/src/utils/RecordHelper.tsx b/src/utils/RecordHelper.tsx index db4bc91..e5c9728 100644 --- a/src/utils/RecordHelper.tsx +++ b/src/utils/RecordHelper.tsx @@ -232,10 +232,11 @@ export function cutPeakValues( 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) {