From 1fda5ada9f125a0e948a3d990cb3294ebe86dd70 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Thu, 9 Oct 2025 11:26:45 +0800 Subject: [PATCH] feat: implement command context and provider for command handling; add search button component; enhance network chart with packet loss calculation and display; update translations for new features --- src/App.tsx | 84 +++++---- src/components/DashCommand.tsx | 13 +- src/components/Header.tsx | 2 + src/components/NetworkChart.tsx | 291 +++++++++++++++++++++++------ src/components/SearchButton.tsx | 23 +++ src/context/command-context.ts | 10 + src/context/command-provider.tsx | 24 +++ src/hooks/use-command.tsx | 10 + src/locales/de/translation.json | 4 +- src/locales/en/translation.json | 4 +- src/locales/es/translation.json | 4 +- src/locales/ru/translation.json | 4 +- src/locales/ta/translation.json | 4 +- src/locales/zh-CN/translation.json | 4 +- src/locales/zh-TW/translation.json | 4 +- src/main.tsx | 39 ++-- src/types/nezha-api.ts | 2 + 17 files changed, 403 insertions(+), 123 deletions(-) create mode 100644 src/components/SearchButton.tsx create mode 100644 src/context/command-context.ts create mode 100644 src/context/command-provider.tsx create mode 100644 src/hooks/use-command.tsx diff --git a/src/App.tsx b/src/App.tsx index 2ca7e94..931dadf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query" import React, { useEffect, useState } from "react" import { useTranslation } from "react-i18next" -import { Route, BrowserRouter as Router, Routes } from "react-router-dom" +import { Route, BrowserRouter as Router, Routes, useLocation } from "react-router-dom" import { DashCommand } from "./components/DashCommand" import ErrorBoundary from "./components/ErrorBoundary" @@ -17,7 +17,12 @@ import NotFound from "./pages/NotFound" import Server from "./pages/Server" import ServerDetail from "./pages/ServerDetail" -const App: React.FC = () => { +// Route checker component +const RouteChecker: React.FC = () => { + return +} + +const MainApp: React.FC = () => { const { data: settingData, error } = useQuery({ queryKey: ["setting"], queryFn: () => fetchSetting(), @@ -66,42 +71,49 @@ const App: React.FC = () => { const customMobileBackgroundImage = window.CustomMobileBackgroundImage !== "" ? window.CustomMobileBackgroundImage : undefined return ( - - - {/* 固定定位的背景层 */} - {customBackgroundImage && ( -
- )} - {customMobileBackgroundImage && ( -
- )} + + {/* 固定定位的背景层 */} + {customBackgroundImage && (
-
- -
- - - } /> - } /> - } /> - } /> - -
-
-
-
+ style={{ backgroundImage: `url(${customBackgroundImage})` }} + /> + )} + {customMobileBackgroundImage && ( +
+ )} +
+
+ +
+ + + } /> + } /> + } /> + } /> + +
+
+
+ + ) +} + +// Main App wrapper with router +const App: React.FC = () => { + return ( + + ) } diff --git a/src/components/DashCommand.tsx b/src/components/DashCommand.tsx index e56c304..35a72df 100644 --- a/src/components/DashCommand.tsx +++ b/src/components/DashCommand.tsx @@ -1,6 +1,7 @@ "use client" import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command" +import { useCommand } from "@/hooks/use-command" import { useTheme } from "@/hooks/use-theme" import { useWebSocketContext } from "@/hooks/use-websocket-context" import { formatNezhaInfo } from "@/lib/utils" @@ -11,7 +12,7 @@ import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" export function DashCommand() { - const [open, setOpen] = useState(false) + const { isOpen, closeCommand, toggleCommand } = useCommand() const [search, setSearch] = useState("") const navigate = useNavigate() const { t } = useTranslation() @@ -25,13 +26,13 @@ export function DashCommand() { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault() - setOpen((open) => !open) + toggleCommand() } } document.addEventListener("keydown", down) return () => document.removeEventListener("keydown", down) - }, []) + }, [toggleCommand]) if (!connected || !nezhaWsData) return null @@ -67,7 +68,7 @@ export function DashCommand() { return ( <> - + {t("NoResults")} @@ -80,7 +81,7 @@ export function DashCommand() { value={server.name} onSelect={() => { navigate(`/server/${server.id}`) - setOpen(false) + closeCommand() }} > {formatNezhaInfo(nezhaWsData.now, server).online ? ( @@ -103,7 +104,7 @@ export function DashCommand() { value={item.value} onSelect={() => { item.action() - setOpen(false) + closeCommand() }} > {item.icon} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 37eddd8..c9e671b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" import { LanguageSwitcher } from "./LanguageSwitcher" +import { SearchButton } from "./SearchButton" import { Loader, LoadingSpinner } from "./loading/Loader" import { Button } from "./ui/button" @@ -103,6 +104,7 @@ function Header() {
+ {(customBackgroundImage || sessionStorage.getItem("savedBackgroundImage")) && ( diff --git a/src/components/NetworkChart.tsx b/src/components/NetworkChart.tsx index 3c75c71..01dfcc6 100644 --- a/src/components/NetworkChart.tsx +++ b/src/components/NetworkChart.tsx @@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query" import * as React from "react" import { useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" -import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts" +import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from "recharts" import NetworkChartLoading from "./NetworkChartLoading" import { Label } from "./ui/label" @@ -20,6 +20,63 @@ interface ResultItem { [key: string]: number } +/** + * Helper method to calculate packet loss from delay data + */ +const calculatePacketLoss = (delays: number[]): number[] => { + if (!delays || delays.length === 0) return [] + + const packetLossRates: number[] = [] + const windowSize = Math.min(10, Math.max(3, Math.floor(delays.length / 10))) + const timeoutThreshold = 3000 + const extremeDelayThreshold = 10000 + + for (let i = 0; i < delays.length; i++) { + const currentDelay = delays[i] + let lossRate = 0 + + if (currentDelay === 0 || currentDelay === null || currentDelay === undefined) { + lossRate = 100 + } else if (currentDelay >= extremeDelayThreshold) { + lossRate = Math.min(95, 60 + (currentDelay - extremeDelayThreshold) / 1000) + } else if (currentDelay >= timeoutThreshold) { + lossRate = Math.min(50, (currentDelay - timeoutThreshold) / 200) + } else { + const start = Math.max(0, i - Math.floor(windowSize / 2)) + const end = Math.min(delays.length, i + Math.ceil(windowSize / 2)) + const windowDelays = delays.slice(start, end).filter((d) => d > 0) + + if (windowDelays.length > 2) { + const mean = windowDelays.reduce((sum, d) => sum + d, 0) / windowDelays.length + const variance = windowDelays.reduce((sum, d) => sum + (d - mean) ** 2, 0) / windowDelays.length + const standardDeviation = Math.sqrt(variance) + const coefficientOfVariation = standardDeviation / mean + + if (coefficientOfVariation > 0.8) { + lossRate = Math.min(25, coefficientOfVariation * 15) + } else if (coefficientOfVariation > 0.5) { + lossRate = Math.min(10, coefficientOfVariation * 8) + } else if (coefficientOfVariation > 0.3) { + lossRate = Math.min(5, coefficientOfVariation * 5) + } + + if (currentDelay > mean * 2.5) { + lossRate += Math.min(15, (currentDelay / mean - 2.5) * 10) + } + } + } + + if (i > 0) { + const alpha = 0.3 + lossRate = alpha * lossRate + (1 - alpha) * packetLossRates[i - 1] + } + + packetLossRates.push(Math.max(0, Math.min(100, lossRate))) + } + + return packetLossRates.map((rate) => Number(rate.toFixed(2))) +} + export function NetworkChart({ server_id, show }: { server_id: number; show: boolean }) { const { t } = useTranslation() @@ -125,60 +182,118 @@ export const NetworkChartClient = React.memo(function NetworkChart({ const chartButtons = useMemo( () => - chartDataKey.map((key) => ( - - )), + chartDataKey.map((key) => { + const monitorData = chartData[key] + const lastDelay = monitorData[monitorData.length - 1].avg_delay + + // Calculate average packet loss if available + const packetLossData = monitorData.filter((item) => item.packet_loss !== undefined).map((item) => item.packet_loss!) + const avgPacketLoss = packetLossData.length > 0 ? packetLossData.reduce((sum, loss) => sum + loss, 0) / packetLossData.length : null + + return ( + + ) + }), [chartDataKey, activeCharts, chartData, handleButtonClick], ) - const chartLines = useMemo(() => { - // If we have active charts selected, render only those - if (activeCharts.length > 0) { - return activeCharts.map((chart) => ( + const chartElements = useMemo(() => { + const elements = [] + + // If exactly one chart is selected, show delay line and packet loss area + if (activeCharts.length === 1) { + const chart = activeCharts[0] + elements.push( + , - )) + />, + ) + } else if (activeCharts.length > 1) { + // Multiple charts selected - show only delay lines for selected monitors + elements.push( + ...activeCharts.map((chart) => ( + + )), + ) + } else { + // No selection - show all charts (default view) + elements.push( + ...chartDataKey.map((key) => ( + + )), + ) } - // Otherwise show all charts (default view) - return chartDataKey.map((key) => ( - - )) + + return elements }, [activeCharts, chartDataKey, getColorByIndex]) const processedData = useMemo(() => { - if (!isPeakEnabled) { - // Always use formattedData when multiple charts are selected or none selected - return formattedData + // Special handling for single chart selection + let baseData = formattedData + if (activeCharts.length === 1) { + const selectedChart = activeCharts[0] + baseData = chartData[selectedChart].map((item) => ({ + created_at: item.created_at, + avg_delay: item.avg_delay, + packet_loss: item.packet_loss ?? 0, + })) } - // For peak cutting, always use the formatted data which contains all series - const data = formattedData + if (!isPeakEnabled) { + return baseData + } + + // For peak cutting, use the base data + const data = baseData const windowSize = 11 // 增加窗口大小以获取更好的统计效果 const alpha = 0.3 // EWMA平滑因子 @@ -225,29 +340,47 @@ export const NetworkChartClient = React.memo(function NetworkChart({ const window = data.slice(index - windowSize + 1, index + 1) const smoothed = { ...point } as ResultItem - // Process all chart keys or just the selected ones - const keysToProcess = activeCharts.length > 0 ? activeCharts : chartDataKey - - keysToProcess.forEach((key) => { - const values = window.map((w) => w[key]).filter((v) => v !== undefined && v !== null) as number[] + // Special handling for single chart selection + if (activeCharts.length === 1) { + // Process avg_delay for single chart + const values = window.map((w) => w.avg_delay as number).filter((v) => v !== undefined && v !== null) if (values.length > 0) { const processed = processValues(values) if (processed !== null) { - // Apply EWMA smoothing - if (ewmaHistory[key] === undefined) { - ewmaHistory[key] = processed + if (ewmaHistory.avg_delay === undefined) { + ewmaHistory.avg_delay = processed } else { - ewmaHistory[key] = alpha * processed + (1 - alpha) * ewmaHistory[key] + ewmaHistory.avg_delay = alpha * processed + (1 - alpha) * ewmaHistory.avg_delay } - smoothed[key] = ewmaHistory[key] + smoothed.avg_delay = ewmaHistory.avg_delay } } - }) + } else { + // Process all chart keys or just the selected ones + const keysToProcess = activeCharts.length > 0 ? activeCharts : chartDataKey + + keysToProcess.forEach((key) => { + const values = window.map((w) => w[key]).filter((v) => v !== undefined && v !== null) as number[] + + if (values.length > 0) { + const processed = processValues(values) + if (processed !== null) { + // Apply EWMA smoothing + if (ewmaHistory[key] === undefined) { + ewmaHistory[key] = processed + } else { + ewmaHistory[key] = alpha * processed + (1 - alpha) * ewmaHistory[key] + } + smoothed[key] = ewmaHistory[key] + } + } + }) + } return smoothed }) - }, [isPeakEnabled, activeCharts, formattedData, chartDataKey]) + }, [isPeakEnabled, activeCharts, formattedData, chartData, chartDataKey]) return ( )} - + - `${value}ms`} /> + `${value}ms`} /> + {activeCharts.length === 1 && ( + `${value}%`} + /> + )} { return formatTime(payload[0].payload.created_at) }} + formatter={(value, name) => { + let formattedValue: string + let label: string + + if (name === "packet_loss") { + formattedValue = `${Number(value).toFixed(2)}%` + label = t("monitor.packetLoss", "Packet Loss") + } else if (name === "avg_delay") { + formattedValue = `${Number(value).toFixed(2)}ms` + label = t("monitor.avgDelay", "Avg Delay") + } else { + // For monitor names (in multi-chart view) - delay data + formattedValue = `${Number(value).toFixed(2)}ms` + label = name as string + } + + return ( +
+ {label} + {formattedValue} +
+ ) + }} /> } /> - } /> - {chartLines} -
+ {activeCharts.length !== 1 && } />} + {chartElements} +
@@ -349,10 +516,14 @@ const transformData = (data: NezhaMonitor[]) => { monitorData[monitorName] = [] } + // Calculate packet loss from delay data if not provided + const packetLoss = item.packet_loss || calculatePacketLoss(item.avg_delay) + for (let i = 0; i < item.created_at.length; i++) { monitorData[monitorName].push({ created_at: item.created_at[i], avg_delay: item.avg_delay[i], + packet_loss: packetLoss[i], }) } }) @@ -373,6 +544,9 @@ const formatData = (rawData: NezhaMonitor[]) => { rawData.forEach((item) => { const { monitor_name, created_at, avg_delay } = item + // Calculate packet loss if not provided + const packetLoss = item.packet_loss || calculatePacketLoss(avg_delay) + allTimeArray.forEach((time) => { if (!result[time]) { result[time] = { created_at: time } @@ -381,6 +555,11 @@ const formatData = (rawData: NezhaMonitor[]) => { const timeIndex = created_at.indexOf(time) // @ts-expect-error - avg_delay is an array result[time][monitor_name] = timeIndex !== -1 ? avg_delay[timeIndex] : null + // Add packet loss data if available + if (packetLoss) { + // @ts-expect-error - packet_loss is calculated + result[time][`${monitor_name}_packet_loss`] = timeIndex !== -1 ? packetLoss[timeIndex] : null + } }) }) diff --git a/src/components/SearchButton.tsx b/src/components/SearchButton.tsx new file mode 100644 index 0000000..be68643 --- /dev/null +++ b/src/components/SearchButton.tsx @@ -0,0 +1,23 @@ +"use client" + +import { useCommand } from "@/hooks/use-command" +import { MagnifyingGlassIcon } from "@heroicons/react/20/solid" + +import { Button } from "./ui/button" + +export function SearchButton() { + const { openCommand } = useCommand() + + return ( + + ) +} diff --git a/src/context/command-context.ts b/src/context/command-context.ts new file mode 100644 index 0000000..e6ec045 --- /dev/null +++ b/src/context/command-context.ts @@ -0,0 +1,10 @@ +import { createContext } from "react" + +export interface CommandContextType { + isOpen: boolean + openCommand: () => void + closeCommand: () => void + toggleCommand: () => void +} + +export const CommandContext = createContext(undefined) diff --git a/src/context/command-provider.tsx b/src/context/command-provider.tsx new file mode 100644 index 0000000..644cf84 --- /dev/null +++ b/src/context/command-provider.tsx @@ -0,0 +1,24 @@ +import { ReactNode, useCallback, useState } from "react" + +import { CommandContext } from "./command-context" + +export function CommandProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false) + + const openCommand = useCallback(() => setIsOpen(true), []) + const closeCommand = useCallback(() => setIsOpen(false), []) + const toggleCommand = useCallback(() => setIsOpen((prev) => !prev), []) + + return ( + + {children} + + ) +} diff --git a/src/hooks/use-command.tsx b/src/hooks/use-command.tsx new file mode 100644 index 0000000..753e82c --- /dev/null +++ b/src/hooks/use-command.tsx @@ -0,0 +1,10 @@ +import { CommandContext } from "@/context/command-context" +import { useContext } from "react" + +export function useCommand() { + const context = useContext(CommandContext) + if (context === undefined) { + throw new Error("useCommand must be used within a CommandProvider") + } + return context +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 4cf9c43..6a96414 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -45,7 +45,9 @@ "monitor": { "monitorCount": "Services", "noData": "Kein Server Monitoring Daten, bitte fügen sie zuerst einen Monitor hinzu", - "avgDelay": "Latenz" + "avgDelay": "Latenz", + "packetLoss": "Paketverlust", + "clearSelections": "Löschen" }, "billingInfo": { "error": "Fehler", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0627231..7bc483e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -107,7 +107,9 @@ "monitor": { "noData": "No server monitor data, please add a service monitor first", "avgDelay": "Latency", - "monitorCount": "Services" + "monitorCount": "Services", + "packetLoss": "Packet Loss", + "clearSelections": "Clear" }, "pwa": { "offlineReady": "App ready to work offline", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index b760cb6..74eedfd 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -91,7 +91,9 @@ "monitor": { "avgDelay": "Latencia", "noData": "No hay datos de servidores, primero agregue un monitor de servicio", - "monitorCount": "Servicios" + "monitorCount": "Servicios", + "packetLoss": "Pérdida de paquetes", + "clearSelections": "Limpiar" }, "error": { "pageNotFound": "Página no encontrada", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index e5bb6c7..6e2edde 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -98,7 +98,9 @@ "monitor": { "noData": "Нет данных мониторинга сервера, пожалуйста, сначала добавьте монитор службы", "avgDelay": "Задержка", - "monitorCount": "Сервисы" + "monitorCount": "Сервисы", + "packetLoss": "Потеря пакетов", + "clearSelections": "Очистить" }, "pwa": { "newContent": "Доступен новый контент", diff --git a/src/locales/ta/translation.json b/src/locales/ta/translation.json index e99e0f9..6cc350e 100644 --- a/src/locales/ta/translation.json +++ b/src/locales/ta/translation.json @@ -93,7 +93,9 @@ "monitor": { "noData": "சேவையக மானிட்டர் தரவு இல்லை, முதலில் ஒரு பணி மானிட்டரைச் சேர்க்கவும்", "avgDelay": "சுணக்கம்", - "monitorCount": "சேவைகள்" + "monitorCount": "சேவைகள்", + "packetLoss": "தொகுப்பு இழப்பு", + "clearSelections": "அழி" }, "pwa": { "offlineReady": "ஆஃப்லைனில் வேலை செய்ய பயன்பாடு தயாராக உள்ளது", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index c9acd66..9e56131 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -108,7 +108,9 @@ "monitor": { "noData": "没有服务监控数据,请在管理后台服务页添加监控任务", "avgDelay": "延迟", - "monitorCount": "个监控服务" + "monitorCount": "个监控服务", + "packetLoss": "丢包率", + "clearSelections": "清除" }, "pwa": { "offlineReady": "应用可以离线使用了", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 5ba3800..7989371 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -110,7 +110,9 @@ "noData": "沒有服務監控數據,請在管理後台服務新增監控任務", "status": "狀態", "avgDelay": "延遲", - "monitorCount": "個監控" + "monitorCount": "個監控", + "packetLoss": "丟包率", + "clearSelections": "清除" }, "billingInfo": { "remaining": "剩餘天數", diff --git a/src/main.tsx b/src/main.tsx index a8048c8..1b84891 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import App from "./App" import { ThemeColorManager } from "./components/ThemeColorManager" import { ThemeProvider } from "./components/ThemeProvider" import { MotionProvider } from "./components/motion/motion-provider" +import { CommandProvider } from "./context/command-provider" import { SortProvider } from "./context/sort-provider" import { StatusProvider } from "./context/status-provider" import { TooltipProvider } from "./context/tooltip-provider" @@ -22,24 +23,26 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - - - - + + + + + + + + + + + diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts index 75deb94..6abd474 100644 --- a/src/types/nezha-api.ts +++ b/src/types/nezha-api.ts @@ -86,6 +86,7 @@ export type ServerMonitorChart = { [key: string]: { created_at: number avg_delay: number + packet_loss?: number }[] } @@ -96,6 +97,7 @@ export interface NezhaMonitor { server_name: string created_at: number[] avg_delay: number[] + packet_loss?: number[] } export interface ServiceResponse {