From 112607740a927dfe78b3ad437a314bf398cb1760 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Wed, 25 Dec 2024 11:03:11 +0800 Subject: [PATCH] feat: chart history on browser --- src/components/ServerDetailChart.tsx | 493 ++++++++++++++++++++------- src/context/websocket-context.ts | 2 + src/context/websocket-context.tsx | 0 src/context/websocket-provider.tsx | 10 +- src/hooks/use-chart-history.ts | 26 ++ 5 files changed, 399 insertions(+), 132 deletions(-) create mode 100644 src/context/websocket-context.tsx create mode 100644 src/hooks/use-chart-history.ts diff --git a/src/components/ServerDetailChart.tsx b/src/components/ServerDetailChart.tsx index 5135c9b..8cfb115 100644 --- a/src/components/ServerDetailChart.tsx +++ b/src/components/ServerDetailChart.tsx @@ -4,7 +4,7 @@ import { useWebSocketContext } from "@/hooks/use-websocket-context" import { formatBytes } from "@/lib/format" import { cn, formatNezhaInfo, formatRelativeTime } from "@/lib/utils" import { NezhaServer, NezhaWebsocketResponse } from "@/types/nezha-api" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts" @@ -50,7 +50,7 @@ type connectChartData = { } export default function ServerDetailChart({ server_id }: { server_id: string }) { - const { lastMessage, connected } = useWebSocketContext() + const { lastMessage, connected, messageHistory } = useWebSocketContext() if (!connected && !lastMessage) { return @@ -73,48 +73,108 @@ export default function ServerDetailChart({ server_id }: { server_id: string }) return (
- + {gpuStats.length >= 1 && gpuList.length === gpuStats.length ? ( - gpuList.map((gpu, index) => ) + gpuList.map((gpu, index) => ( + + )) ) : gpuStats.length > 0 ? ( - gpuStats.map((gpu, index) => ) + gpuStats.map((gpu, index) => ( + + )) ) : ( <> )} - - - - - + + + + +
) } -function GpuChart({ now, gpuStat, gpuName }: { now: number; gpuStat: number; gpuName?: string }) { - const [gpuChartData, setGpuChartData] = useState([] as gpuChartData[]) +function GpuChart({ + id, + index, + gpuStat, + gpuName, + messageHistory, +}: { + now: number + id: number + index: number + gpuStat: number + gpuName?: string + messageHistory: { data: string }[] +}) { + const [gpuChartData, setGpuChartData] = useState([]) + const hasInitialized = useRef(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const customBackgroundImage = // @ts-expect-error CustomBackgroundImage is a global variable (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined + // 初始化历史数据 useEffect(() => { - if (gpuStat) { - const timestamp = Date.now().toString() - let newData = [] as gpuChartData[] - if (gpuChartData.length === 0) { - newData = [ - { timeStamp: timestamp, gpu: gpuStat }, - { timeStamp: timestamp, gpu: gpuStat }, - ] - } else { - newData = [...gpuChartData, { timeStamp: timestamp, gpu: gpuStat }] - } - if (newData.length > 30) { - newData.shift() - } - setGpuChartData(newData) + if (!hasInitialized.current && messageHistory.length > 0) { + const historyData = messageHistory + .map((msg) => { + const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse + const server = wsData.servers.find((s) => s.id === id) + if (!server) return null + const { gpu } = formatNezhaInfo(wsData.now, server) + return { + timeStamp: wsData.now.toString(), + gpu: gpu[index], + } + }) + .filter((item): item is gpuChartData => item !== null) + .reverse() + + setGpuChartData(historyData) + hasInitialized.current = true + setHistoryLoaded(true) } - }, [now, gpuStat]) + }, [messageHistory]) + + useEffect(() => { + if (gpuStat && historyLoaded) { + const timestamp = Date.now().toString() + setGpuChartData((prevData) => { + let newData = [] as gpuChartData[] + if (prevData.length === 0) { + newData = [ + { timeStamp: timestamp, gpu: gpuStat }, + { timeStamp: timestamp, gpu: gpuStat }, + ] + } else { + newData = [...prevData, { timeStamp: timestamp, gpu: gpuStat }] + if (newData.length > 30) { + newData.shift() + } + } + return newData + }) + } + }, [gpuStat, historyLoaded]) const chartConfig = { gpu: { @@ -170,8 +230,10 @@ function GpuChart({ now, gpuStat, gpuName }: { now: number; gpuStat: number; gpu ) } -function CpuChart({ now, data }: { now: number; data: NezhaServer }) { - const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[]) +function CpuChart({ now, data, messageHistory }: { now: number; data: NezhaServer; messageHistory: { data: string }[] }) { + const [cpuChartData, setCpuChartData] = useState([]) + const hasInitialized = useRef(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const { cpu } = formatNezhaInfo(now, data) @@ -179,24 +241,50 @@ function CpuChart({ now, data }: { now: number; data: NezhaServer }) { // @ts-expect-error CustomBackgroundImage is a global variable (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined + // 初始化历史数据 useEffect(() => { - if (data) { - const timestamp = Date.now().toString() - let newData = [] as cpuChartData[] - if (cpuChartData.length === 0) { - newData = [ - { timeStamp: timestamp, cpu: cpu }, - { timeStamp: timestamp, cpu: cpu }, - ] - } else { - newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }] - } - if (newData.length > 30) { - newData.shift() - } - setCpuChartData(newData) + if (!hasInitialized.current && messageHistory.length > 0) { + const historyData = messageHistory + .map((msg) => { + const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse + const server = wsData.servers.find((s) => s.id === data.id) + if (!server) return null + const { cpu } = formatNezhaInfo(wsData.now, server) + return { + timeStamp: wsData.now.toString(), + cpu: cpu, + } + }) + .filter((item): item is cpuChartData => item !== null) + .reverse() // 保持时间顺序 + + setCpuChartData(historyData) + hasInitialized.current = true + setHistoryLoaded(true) } - }, [data]) + }, [messageHistory]) + + // 更新实时数据 + useEffect(() => { + if (data && historyLoaded) { + const timestamp = Date.now().toString() + setCpuChartData((prevData) => { + let newData = [] as cpuChartData[] + if (prevData.length === 0) { + newData = [ + { timeStamp: timestamp, cpu: cpu }, + { timeStamp: timestamp, cpu: cpu }, + ] + } else { + newData = [...prevData, { timeStamp: timestamp, cpu: cpu }] + if (newData.length > 30) { + newData.shift() + } + } + return newData + }) + } + }, [data, historyLoaded]) const chartConfig = { cpu: { @@ -249,9 +337,11 @@ function CpuChart({ now, data }: { now: number; data: NezhaServer }) { ) } -function ProcessChart({ now, data }: { now: number; data: NezhaServer }) { +function ProcessChart({ now, data, messageHistory }: { now: number; data: NezhaServer; messageHistory: { data: string }[] }) { const { t } = useTranslation() const [processChartData, setProcessChartData] = useState([] as processChartData[]) + const hasInitialized = useRef(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const customBackgroundImage = // @ts-expect-error CustomBackgroundImage is a global variable @@ -259,24 +349,50 @@ function ProcessChart({ now, data }: { now: number; data: NezhaServer }) { const { process } = formatNezhaInfo(now, data) + // 初始化历史数据 useEffect(() => { - if (data) { - const timestamp = Date.now().toString() - let newData = [] as processChartData[] - if (processChartData.length === 0) { - newData = [ - { timeStamp: timestamp, process: process }, - { timeStamp: timestamp, process: process }, - ] - } else { - newData = [...processChartData, { timeStamp: timestamp, process: process }] - } - if (newData.length > 30) { - newData.shift() - } - setProcessChartData(newData) + if (!hasInitialized.current && messageHistory.length > 0) { + const historyData = messageHistory + .map((msg) => { + const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse + const server = wsData.servers.find((s) => s.id === data.id) + if (!server) return null + const { process } = formatNezhaInfo(wsData.now, server) + return { + timeStamp: wsData.now.toString(), + process, + } + }) + .filter((item): item is processChartData => item !== null) + .reverse() + + setProcessChartData(historyData) + hasInitialized.current = true + setHistoryLoaded(true) } - }, [data]) + }, [messageHistory]) + + // 修改实时数据更新逻辑 + useEffect(() => { + if (data && historyLoaded) { + const timestamp = Date.now().toString() + setProcessChartData((prevData) => { + let newData = [] as processChartData[] + if (prevData.length === 0) { + newData = [ + { timeStamp: timestamp, process }, + { timeStamp: timestamp, process }, + ] + } else { + newData = [...prevData, { timeStamp: timestamp, process }] + if (newData.length > 30) { + newData.shift() + } + } + return newData + }) + } + }, [data, historyLoaded]) const chartConfig = { process: { @@ -335,9 +451,11 @@ function ProcessChart({ now, data }: { now: number; data: NezhaServer }) { ) } -function MemChart({ now, data }: { now: number; data: NezhaServer }) { +function MemChart({ now, data, messageHistory }: { now: number; data: NezhaServer; messageHistory: { data: string }[] }) { const { t } = useTranslation() const [memChartData, setMemChartData] = useState([] as memChartData[]) + const hasInitialized = useRef(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const customBackgroundImage = // @ts-expect-error CustomBackgroundImage is a global variable @@ -345,24 +463,51 @@ function MemChart({ now, data }: { now: number; data: NezhaServer }) { const { mem, swap } = formatNezhaInfo(now, data) + // 初始化历史数据 useEffect(() => { - if (data) { - const timestamp = Date.now().toString() - let newData = [] as memChartData[] - if (memChartData.length === 0) { - newData = [ - { timeStamp: timestamp, mem: mem, swap: swap }, - { timeStamp: timestamp, mem: mem, swap: swap }, - ] - } else { - newData = [...memChartData, { timeStamp: timestamp, mem: mem, swap: swap }] - } - if (newData.length > 30) { - newData.shift() - } - setMemChartData(newData) + if (!hasInitialized.current && messageHistory.length > 0) { + const historyData = messageHistory + .map((msg) => { + const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse + const server = wsData.servers.find((s) => s.id === data.id) + if (!server) return null + const { mem, swap } = formatNezhaInfo(wsData.now, server) + return { + timeStamp: wsData.now.toString(), + mem, + swap, + } + }) + .filter((item): item is memChartData => item !== null) + .reverse() + + setMemChartData(historyData) + hasInitialized.current = true + setHistoryLoaded(true) } - }, [data]) + }, [messageHistory]) + + // 修改实时数据更新逻辑 + useEffect(() => { + if (data && historyLoaded) { + const timestamp = Date.now().toString() + setMemChartData((prevData) => { + let newData = [] as memChartData[] + if (prevData.length === 0) { + newData = [ + { timeStamp: timestamp, mem, swap }, + { timeStamp: timestamp, mem, swap }, + ] + } else { + newData = [...prevData, { timeStamp: timestamp, mem, swap }] + if (newData.length > 30) { + newData.shift() + } + } + return newData + }) + } + }, [data, historyLoaded]) const chartConfig = { mem: { @@ -451,9 +596,11 @@ function MemChart({ now, data }: { now: number; data: NezhaServer }) { ) } -function DiskChart({ now, data }: { now: number; data: NezhaServer }) { +function DiskChart({ now, data, messageHistory }: { now: number; data: NezhaServer; messageHistory: { data: string }[] }) { const { t } = useTranslation() const [diskChartData, setDiskChartData] = useState([] as diskChartData[]) + const hasInitialized = useRef(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const customBackgroundImage = // @ts-expect-error CustomBackgroundImage is a global variable @@ -461,24 +608,50 @@ function DiskChart({ now, data }: { now: number; data: NezhaServer }) { const { disk } = formatNezhaInfo(now, data) + // 初始化历史数据 useEffect(() => { - if (data) { - const timestamp = Date.now().toString() - let newData = [] as diskChartData[] - if (diskChartData.length === 0) { - newData = [ - { timeStamp: timestamp, disk: disk }, - { timeStamp: timestamp, disk: disk }, - ] - } else { - newData = [...diskChartData, { timeStamp: timestamp, disk: disk }] - } - if (newData.length > 30) { - newData.shift() - } - setDiskChartData(newData) + if (!hasInitialized.current && messageHistory.length > 0) { + const historyData = messageHistory + .map((msg) => { + const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse + const server = wsData.servers.find((s) => s.id === data.id) + if (!server) return null + const { disk } = formatNezhaInfo(wsData.now, server) + return { + timeStamp: wsData.now.toString(), + disk, + } + }) + .filter((item): item is diskChartData => item !== null) + .reverse() + + setDiskChartData(historyData) + hasInitialized.current = true + setHistoryLoaded(true) } - }, [data]) + }, [messageHistory]) + + // 修改实时数据更新逻辑 + useEffect(() => { + if (data && historyLoaded) { + const timestamp = Date.now().toString() + setDiskChartData((prevData) => { + let newData = [] as diskChartData[] + if (prevData.length === 0) { + newData = [ + { timeStamp: timestamp, disk }, + { timeStamp: timestamp, disk }, + ] + } else { + newData = [...prevData, { timeStamp: timestamp, disk }] + if (newData.length > 30) { + newData.shift() + } + } + return newData + }) + } + }, [data, historyLoaded]) const chartConfig = { disk: { @@ -536,9 +709,11 @@ function DiskChart({ now, data }: { now: number; data: NezhaServer }) { ) } -function NetworkChart({ now, data }: { now: number; data: NezhaServer }) { +function NetworkChart({ now, data, messageHistory }: { now: number; data: NezhaServer; messageHistory: { data: string }[] }) { const { t } = useTranslation() const [networkChartData, setNetworkChartData] = useState([] as networkChartData[]) + const hasInitialized = useRef(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const customBackgroundImage = // @ts-expect-error CustomBackgroundImage is a global variable @@ -546,24 +721,51 @@ function NetworkChart({ now, data }: { now: number; data: NezhaServer }) { const { up, down } = formatNezhaInfo(now, data) + // 初始化历史数据 useEffect(() => { - if (data) { - const timestamp = Date.now().toString() - let newData = [] as networkChartData[] - if (networkChartData.length === 0) { - newData = [ - { timeStamp: timestamp, upload: up, download: down }, - { timeStamp: timestamp, upload: up, download: down }, - ] - } else { - newData = [...networkChartData, { timeStamp: timestamp, upload: up, download: down }] - } - if (newData.length > 30) { - newData.shift() - } - setNetworkChartData(newData) + if (!hasInitialized.current && messageHistory.length > 0) { + const historyData = messageHistory + .map((msg) => { + const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse + const server = wsData.servers.find((s) => s.id === data.id) + if (!server) return null + const { up, down } = formatNezhaInfo(wsData.now, server) + return { + timeStamp: wsData.now.toString(), + upload: up, + download: down, + } + }) + .filter((item): item is networkChartData => item !== null) + .reverse() + + setNetworkChartData(historyData) + hasInitialized.current = true + setHistoryLoaded(true) } - }, [data]) + }, [messageHistory]) + + // 修改实时数据更新逻辑 + useEffect(() => { + if (data && historyLoaded) { + const timestamp = Date.now().toString() + setNetworkChartData((prevData) => { + let newData = [] as networkChartData[] + if (prevData.length === 0) { + newData = [ + { timeStamp: timestamp, upload: up, download: down }, + { timeStamp: timestamp, upload: up, download: down }, + ] + } else { + newData = [...prevData, { timeStamp: timestamp, upload: up, download: down }] + if (newData.length > 30) { + newData.shift() + } + } + return newData + }) + } + }, [data, historyLoaded]) let maxDownload = Math.max(...networkChartData.map((item) => item.download)) maxDownload = Math.ceil(maxDownload) @@ -651,8 +853,10 @@ function NetworkChart({ now, data }: { now: number; data: NezhaServer }) { ) } -function ConnectChart({ now, data }: { now: number; data: NezhaServer }) { +function ConnectChart({ now, data, messageHistory }: { now: number; data: NezhaServer; messageHistory: { data: string }[] }) { const [connectChartData, setConnectChartData] = useState([] as connectChartData[]) + const hasInitialized = useRef(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const customBackgroundImage = // @ts-expect-error CustomBackgroundImage is a global variable @@ -660,24 +864,51 @@ function ConnectChart({ now, data }: { now: number; data: NezhaServer }) { const { tcp, udp } = formatNezhaInfo(now, data) + // 初始化历史数据 useEffect(() => { - if (data) { - const timestamp = Date.now().toString() - let newData = [] as connectChartData[] - if (connectChartData.length === 0) { - newData = [ - { timeStamp: timestamp, tcp: tcp, udp: udp }, - { timeStamp: timestamp, tcp: tcp, udp: udp }, - ] - } else { - newData = [...connectChartData, { timeStamp: timestamp, tcp: tcp, udp: udp }] - } - if (newData.length > 30) { - newData.shift() - } - setConnectChartData(newData) + if (!hasInitialized.current && messageHistory.length > 0) { + const historyData = messageHistory + .map((msg) => { + const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse + const server = wsData.servers.find((s) => s.id === data.id) + if (!server) return null + const { tcp, udp } = formatNezhaInfo(wsData.now, server) + return { + timeStamp: wsData.now.toString(), + tcp, + udp, + } + }) + .filter((item): item is connectChartData => item !== null) + .reverse() + + setConnectChartData(historyData) + hasInitialized.current = true + setHistoryLoaded(true) } - }, [data]) + }, [messageHistory]) + + // 修改实时数据更新逻辑 + useEffect(() => { + if (data && historyLoaded) { + const timestamp = Date.now().toString() + setConnectChartData((prevData) => { + let newData = [] as connectChartData[] + if (prevData.length === 0) { + newData = [ + { timeStamp: timestamp, tcp, udp }, + { timeStamp: timestamp, tcp, udp }, + ] + } else { + newData = [...prevData, { timeStamp: timestamp, tcp, udp }] + if (newData.length > 30) { + newData.shift() + } + } + return newData + }) + } + }, [data, historyLoaded]) const chartConfig = { tcp: { diff --git a/src/context/websocket-context.ts b/src/context/websocket-context.ts index 792ecdd..2bf955c 100644 --- a/src/context/websocket-context.ts +++ b/src/context/websocket-context.ts @@ -3,9 +3,11 @@ import { createContext } from "react" export interface WebSocketContextType { lastMessage: { data: string } | null connected: boolean + messageHistory: { data: string }[] } export const WebSocketContext = createContext({ lastMessage: null, connected: false, + messageHistory: [], }) diff --git a/src/context/websocket-context.tsx b/src/context/websocket-context.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/context/websocket-provider.tsx b/src/context/websocket-provider.tsx index 6966c34..b41aed7 100644 --- a/src/context/websocket-provider.tsx +++ b/src/context/websocket-provider.tsx @@ -9,6 +9,7 @@ interface WebSocketProviderProps { export const WebSocketProvider: React.FC = ({ url, children }) => { const [lastMessage, setLastMessage] = useState<{ data: string } | null>(null) + const [messageHistory, setMessageHistory] = useState<{ data: string }[]>([]) // 新增历史消息状态 const [connected, setConnected] = useState(false) const ws = useRef(null) const reconnectTimeout = useRef(null) @@ -42,7 +43,13 @@ export const WebSocketProvider: React.FC = ({ url, child } ws.current.onmessage = (event) => { - setLastMessage({ data: event.data }) + const newMessage = { data: event.data } + setLastMessage(newMessage) + // 更新历史消息,保持最新的30条记录 + setMessageHistory((prev) => { + const updated = [newMessage, ...prev] + return updated.slice(0, 30) + }) } ws.current.onerror = (error) => { @@ -69,6 +76,7 @@ export const WebSocketProvider: React.FC = ({ url, child const contextValue: WebSocketContextType = { lastMessage, connected, + messageHistory, // 添加到 context value } return {children} diff --git a/src/hooks/use-chart-history.ts b/src/hooks/use-chart-history.ts new file mode 100644 index 0000000..40793f7 --- /dev/null +++ b/src/hooks/use-chart-history.ts @@ -0,0 +1,26 @@ +import { NezhaWebsocketResponse } from "@/types/nezha-api" +import { useEffect, useState } from "react" + +export function useChartHistory( + messageHistory: { data: string }[], + serverId: number, + formatFn: (wsData: NezhaWebsocketResponse, serverId: number) => T | null, +) { + const [data, setData] = useState([]) + + useEffect(() => { + if (messageHistory.length > 0 && data.length === 0) { + const historyData = messageHistory + .map((msg) => { + const wsData = JSON.parse(msg.data) as NezhaWebsocketResponse + return formatFn(wsData, serverId) + }) + .filter((item): item is T => item !== null) + .reverse() + + setData(historyData) + } + }, [messageHistory]) + + return data +}