mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-05-06 13:58:43 +00:00
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
This commit is contained in:
+15
-3
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import React, { useEffect, useState } from "react"
|
import React, { useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { DashCommand } from "./components/DashCommand"
|
||||||
import ErrorBoundary from "./components/ErrorBoundary"
|
import ErrorBoundary from "./components/ErrorBoundary"
|
||||||
@@ -17,7 +17,12 @@ import NotFound from "./pages/NotFound"
|
|||||||
import Server from "./pages/Server"
|
import Server from "./pages/Server"
|
||||||
import ServerDetail from "./pages/ServerDetail"
|
import ServerDetail from "./pages/ServerDetail"
|
||||||
|
|
||||||
const App: React.FC = () => {
|
// Route checker component
|
||||||
|
const RouteChecker: React.FC = () => {
|
||||||
|
return <MainApp />
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainApp: React.FC = () => {
|
||||||
const { data: settingData, error } = useQuery({
|
const { data: settingData, error } = useQuery({
|
||||||
queryKey: ["setting"],
|
queryKey: ["setting"],
|
||||||
queryFn: () => fetchSetting(),
|
queryFn: () => fetchSetting(),
|
||||||
@@ -66,7 +71,6 @@ const App: React.FC = () => {
|
|||||||
const customMobileBackgroundImage = window.CustomMobileBackgroundImage !== "" ? window.CustomMobileBackgroundImage : undefined
|
const customMobileBackgroundImage = window.CustomMobileBackgroundImage !== "" ? window.CustomMobileBackgroundImage : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router basename={import.meta.env.BASE_URL}>
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{/* 固定定位的背景层 */}
|
{/* 固定定位的背景层 */}
|
||||||
{customBackgroundImage && (
|
{customBackgroundImage && (
|
||||||
@@ -102,6 +106,14 @@ const App: React.FC = () => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main App wrapper with router
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Router basename={import.meta.env.BASE_URL}>
|
||||||
|
<RouteChecker />
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command"
|
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 { useTheme } from "@/hooks/use-theme"
|
||||||
import { useWebSocketContext } from "@/hooks/use-websocket-context"
|
import { useWebSocketContext } from "@/hooks/use-websocket-context"
|
||||||
import { formatNezhaInfo } from "@/lib/utils"
|
import { formatNezhaInfo } from "@/lib/utils"
|
||||||
@@ -11,7 +12,7 @@ import { useTranslation } from "react-i18next"
|
|||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
export function DashCommand() {
|
export function DashCommand() {
|
||||||
const [open, setOpen] = useState(false)
|
const { isOpen, closeCommand, toggleCommand } = useCommand()
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -25,13 +26,13 @@ export function DashCommand() {
|
|||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setOpen((open) => !open)
|
toggleCommand()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", down)
|
document.addEventListener("keydown", down)
|
||||||
return () => document.removeEventListener("keydown", down)
|
return () => document.removeEventListener("keydown", down)
|
||||||
}, [])
|
}, [toggleCommand])
|
||||||
|
|
||||||
if (!connected || !nezhaWsData) return null
|
if (!connected || !nezhaWsData) return null
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ export function DashCommand() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<CommandDialog open={isOpen} onOpenChange={closeCommand}>
|
||||||
<CommandInput placeholder={t("TypeCommand")} value={search} onValueChange={setSearch} />
|
<CommandInput placeholder={t("TypeCommand")} value={search} onValueChange={setSearch} />
|
||||||
<CommandList className="border-t">
|
<CommandList className="border-t">
|
||||||
<CommandEmpty>{t("NoResults")}</CommandEmpty>
|
<CommandEmpty>{t("NoResults")}</CommandEmpty>
|
||||||
@@ -80,7 +81,7 @@ export function DashCommand() {
|
|||||||
value={server.name}
|
value={server.name}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(`/server/${server.id}`)
|
navigate(`/server/${server.id}`)
|
||||||
setOpen(false)
|
closeCommand()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatNezhaInfo(nezhaWsData.now, server).online ? (
|
{formatNezhaInfo(nezhaWsData.now, server).online ? (
|
||||||
@@ -103,7 +104,7 @@ export function DashCommand() {
|
|||||||
value={item.value}
|
value={item.value}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
item.action()
|
item.action()
|
||||||
setOpen(false)
|
closeCommand()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"
|
|||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
import { LanguageSwitcher } from "./LanguageSwitcher"
|
import { LanguageSwitcher } from "./LanguageSwitcher"
|
||||||
|
import { SearchButton } from "./SearchButton"
|
||||||
import { Loader, LoadingSpinner } from "./loading/Loader"
|
import { Loader, LoadingSpinner } from "./loading/Loader"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ function Header() {
|
|||||||
<Links />
|
<Links />
|
||||||
<DashboardLink />
|
<DashboardLink />
|
||||||
</div>
|
</div>
|
||||||
|
<SearchButton />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
{(customBackgroundImage || sessionStorage.getItem("savedBackgroundImage")) && (
|
{(customBackgroundImage || sessionStorage.getItem("savedBackgroundImage")) && (
|
||||||
|
|||||||
+204
-25
@@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query"
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useCallback, useMemo } from "react"
|
import { useCallback, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 NetworkChartLoading from "./NetworkChartLoading"
|
||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
@@ -20,6 +20,63 @@ interface ResultItem {
|
|||||||
[key: string]: number
|
[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 }) {
|
export function NetworkChart({ server_id, show }: { server_id: number; show: boolean }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -125,7 +182,15 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
|
|
||||||
const chartButtons = useMemo(
|
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 (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
data-active={activeCharts.includes(key)}
|
data-active={activeCharts.includes(key)}
|
||||||
@@ -133,31 +198,66 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
onClick={() => handleButtonClick(key)}
|
onClick={() => handleButtonClick(key)}
|
||||||
>
|
>
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">{key}</span>
|
<span className="whitespace-nowrap text-xs text-muted-foreground">{key}</span>
|
||||||
<span className="text-md font-bold leading-none sm:text-lg">{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms</span>
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-md font-bold leading-none sm:text-lg">{lastDelay.toFixed(2)}ms</span>
|
||||||
|
{avgPacketLoss !== null && <span className="text-xs text-muted-foreground">{avgPacketLoss.toFixed(2)}% avg loss</span>}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)),
|
)
|
||||||
|
}),
|
||||||
[chartDataKey, activeCharts, chartData, handleButtonClick],
|
[chartDataKey, activeCharts, chartData, handleButtonClick],
|
||||||
)
|
)
|
||||||
|
|
||||||
const chartLines = useMemo(() => {
|
const chartElements = useMemo(() => {
|
||||||
// If we have active charts selected, render only those
|
const elements = []
|
||||||
if (activeCharts.length > 0) {
|
|
||||||
return activeCharts.map((chart) => (
|
// If exactly one chart is selected, show delay line and packet loss area
|
||||||
|
if (activeCharts.length === 1) {
|
||||||
|
const chart = activeCharts[0]
|
||||||
|
elements.push(
|
||||||
|
<Area
|
||||||
|
key="packet-loss-area"
|
||||||
|
isAnimationActive={false}
|
||||||
|
dataKey="packet_loss"
|
||||||
|
stroke="none"
|
||||||
|
fill="hsl(45, 100%, 60%)"
|
||||||
|
fillOpacity={0.3}
|
||||||
|
yAxisId="packet-loss"
|
||||||
|
/>,
|
||||||
|
<Line
|
||||||
|
key="delay-line"
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={1}
|
||||||
|
type="linear"
|
||||||
|
dot={false}
|
||||||
|
dataKey="avg_delay"
|
||||||
|
stroke={getColorByIndex(chart)}
|
||||||
|
yAxisId="delay"
|
||||||
|
connectNulls={true}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
} else if (activeCharts.length > 1) {
|
||||||
|
// Multiple charts selected - show only delay lines for selected monitors
|
||||||
|
elements.push(
|
||||||
|
...activeCharts.map((chart) => (
|
||||||
<Line
|
<Line
|
||||||
key={chart}
|
key={chart}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
type="linear"
|
type="linear"
|
||||||
dot={false}
|
dot={false}
|
||||||
dataKey={chart} // Change from "avg_delay" to the actual chart key name
|
dataKey={chart}
|
||||||
stroke={getColorByIndex(chart)}
|
stroke={getColorByIndex(chart)}
|
||||||
name={chart}
|
name={chart}
|
||||||
connectNulls={true}
|
connectNulls={true}
|
||||||
|
yAxisId="delay"
|
||||||
/>
|
/>
|
||||||
))
|
)),
|
||||||
}
|
)
|
||||||
// Otherwise show all charts (default view)
|
} else {
|
||||||
return chartDataKey.map((key) => (
|
// No selection - show all charts (default view)
|
||||||
|
elements.push(
|
||||||
|
...chartDataKey.map((key) => (
|
||||||
<Line
|
<Line
|
||||||
key={key}
|
key={key}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
@@ -167,18 +267,33 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
dataKey={key}
|
dataKey={key}
|
||||||
stroke={getColorByIndex(key)}
|
stroke={getColorByIndex(key)}
|
||||||
connectNulls={true}
|
connectNulls={true}
|
||||||
|
yAxisId="delay"
|
||||||
/>
|
/>
|
||||||
))
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements
|
||||||
}, [activeCharts, chartDataKey, getColorByIndex])
|
}, [activeCharts, chartDataKey, getColorByIndex])
|
||||||
|
|
||||||
const processedData = useMemo(() => {
|
const processedData = useMemo(() => {
|
||||||
if (!isPeakEnabled) {
|
// Special handling for single chart selection
|
||||||
// Always use formattedData when multiple charts are selected or none selected
|
let baseData = formattedData
|
||||||
return 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
|
if (!isPeakEnabled) {
|
||||||
const data = formattedData
|
return baseData
|
||||||
|
}
|
||||||
|
|
||||||
|
// For peak cutting, use the base data
|
||||||
|
const data = baseData
|
||||||
|
|
||||||
const windowSize = 11 // 增加窗口大小以获取更好的统计效果
|
const windowSize = 11 // 增加窗口大小以获取更好的统计效果
|
||||||
const alpha = 0.3 // EWMA平滑因子
|
const alpha = 0.3 // EWMA平滑因子
|
||||||
@@ -225,6 +340,23 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
const window = data.slice(index - windowSize + 1, index + 1)
|
const window = data.slice(index - windowSize + 1, index + 1)
|
||||||
const smoothed = { ...point } as ResultItem
|
const smoothed = { ...point } as ResultItem
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (ewmaHistory.avg_delay === undefined) {
|
||||||
|
ewmaHistory.avg_delay = processed
|
||||||
|
} else {
|
||||||
|
ewmaHistory.avg_delay = alpha * processed + (1 - alpha) * ewmaHistory.avg_delay
|
||||||
|
}
|
||||||
|
smoothed.avg_delay = ewmaHistory.avg_delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Process all chart keys or just the selected ones
|
// Process all chart keys or just the selected ones
|
||||||
const keysToProcess = activeCharts.length > 0 ? activeCharts : chartDataKey
|
const keysToProcess = activeCharts.length > 0 ? activeCharts : chartDataKey
|
||||||
|
|
||||||
@@ -244,10 +376,11 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return smoothed
|
return smoothed
|
||||||
})
|
})
|
||||||
}, [isPeakEnabled, activeCharts, formattedData, chartDataKey])
|
}, [isPeakEnabled, activeCharts, formattedData, chartData, chartDataKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -281,7 +414,7 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||||||
<LineChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}>
|
<ComposedChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="created_at"
|
dataKey="created_at"
|
||||||
@@ -316,7 +449,18 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
return minutes === 0 ? `${date.getHours()}:00` : `${date.getHours()}:${minutes}`
|
return minutes === 0 ? `${date.getHours()}:00` : `${date.getHours()}:${minutes}`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<YAxis tickLine={false} axisLine={false} tickMargin={15} minTickGap={20} tickFormatter={(value) => `${value}ms`} />
|
<YAxis yAxisId="delay" tickLine={false} axisLine={false} tickMargin={15} minTickGap={20} tickFormatter={(value) => `${value}ms`} />
|
||||||
|
{activeCharts.length === 1 && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="packet-loss"
|
||||||
|
orientation="right"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={15}
|
||||||
|
minTickGap={20}
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
content={
|
content={
|
||||||
@@ -326,12 +470,35 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
labelFormatter={(_, payload) => {
|
labelFormatter={(_, payload) => {
|
||||||
return formatTime(payload[0].payload.created_at)
|
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 (
|
||||||
|
<div className="flex flex-1 items-center justify-between leading-none">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="ml-2 font-medium text-foreground tabular-nums">{formattedValue}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
{activeCharts.length !== 1 && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
{chartLines}
|
{chartElements}
|
||||||
</LineChart>
|
</ComposedChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -349,10 +516,14 @@ const transformData = (data: NezhaMonitor[]) => {
|
|||||||
monitorData[monitorName] = []
|
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++) {
|
for (let i = 0; i < item.created_at.length; i++) {
|
||||||
monitorData[monitorName].push({
|
monitorData[monitorName].push({
|
||||||
created_at: item.created_at[i],
|
created_at: item.created_at[i],
|
||||||
avg_delay: item.avg_delay[i],
|
avg_delay: item.avg_delay[i],
|
||||||
|
packet_loss: packetLoss[i],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -373,6 +544,9 @@ const formatData = (rawData: NezhaMonitor[]) => {
|
|||||||
rawData.forEach((item) => {
|
rawData.forEach((item) => {
|
||||||
const { monitor_name, created_at, avg_delay } = 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) => {
|
allTimeArray.forEach((time) => {
|
||||||
if (!result[time]) {
|
if (!result[time]) {
|
||||||
result[time] = { created_at: time }
|
result[time] = { created_at: time }
|
||||||
@@ -381,6 +555,11 @@ const formatData = (rawData: NezhaMonitor[]) => {
|
|||||||
const timeIndex = created_at.indexOf(time)
|
const timeIndex = created_at.indexOf(time)
|
||||||
// @ts-expect-error - avg_delay is an array
|
// @ts-expect-error - avg_delay is an array
|
||||||
result[time][monitor_name] = timeIndex !== -1 ? avg_delay[timeIndex] : null
|
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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
|
||||||
|
onClick={openCommand}
|
||||||
|
title="Search"
|
||||||
|
>
|
||||||
|
<MagnifyingGlassIcon className="size-4" />
|
||||||
|
<span className="sr-only">Search</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createContext } from "react"
|
||||||
|
|
||||||
|
export interface CommandContextType {
|
||||||
|
isOpen: boolean
|
||||||
|
openCommand: () => void
|
||||||
|
closeCommand: () => void
|
||||||
|
toggleCommand: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandContext = createContext<CommandContextType | undefined>(undefined)
|
||||||
@@ -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 (
|
||||||
|
<CommandContext.Provider
|
||||||
|
value={{
|
||||||
|
isOpen,
|
||||||
|
openCommand,
|
||||||
|
closeCommand,
|
||||||
|
toggleCommand,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CommandContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -45,7 +45,9 @@
|
|||||||
"monitor": {
|
"monitor": {
|
||||||
"monitorCount": "Services",
|
"monitorCount": "Services",
|
||||||
"noData": "Kein Server Monitoring Daten, bitte fügen sie zuerst einen Monitor hinzu",
|
"noData": "Kein Server Monitoring Daten, bitte fügen sie zuerst einen Monitor hinzu",
|
||||||
"avgDelay": "Latenz"
|
"avgDelay": "Latenz",
|
||||||
|
"packetLoss": "Paketverlust",
|
||||||
|
"clearSelections": "Löschen"
|
||||||
},
|
},
|
||||||
"billingInfo": {
|
"billingInfo": {
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
|
|||||||
@@ -107,7 +107,9 @@
|
|||||||
"monitor": {
|
"monitor": {
|
||||||
"noData": "No server monitor data, please add a service monitor first",
|
"noData": "No server monitor data, please add a service monitor first",
|
||||||
"avgDelay": "Latency",
|
"avgDelay": "Latency",
|
||||||
"monitorCount": "Services"
|
"monitorCount": "Services",
|
||||||
|
"packetLoss": "Packet Loss",
|
||||||
|
"clearSelections": "Clear"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"offlineReady": "App ready to work offline",
|
"offlineReady": "App ready to work offline",
|
||||||
|
|||||||
@@ -91,7 +91,9 @@
|
|||||||
"monitor": {
|
"monitor": {
|
||||||
"avgDelay": "Latencia",
|
"avgDelay": "Latencia",
|
||||||
"noData": "No hay datos de servidores, primero agregue un monitor de servicio",
|
"noData": "No hay datos de servidores, primero agregue un monitor de servicio",
|
||||||
"monitorCount": "Servicios"
|
"monitorCount": "Servicios",
|
||||||
|
"packetLoss": "Pérdida de paquetes",
|
||||||
|
"clearSelections": "Limpiar"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"pageNotFound": "Página no encontrada",
|
"pageNotFound": "Página no encontrada",
|
||||||
|
|||||||
@@ -98,7 +98,9 @@
|
|||||||
"monitor": {
|
"monitor": {
|
||||||
"noData": "Нет данных мониторинга сервера, пожалуйста, сначала добавьте монитор службы",
|
"noData": "Нет данных мониторинга сервера, пожалуйста, сначала добавьте монитор службы",
|
||||||
"avgDelay": "Задержка",
|
"avgDelay": "Задержка",
|
||||||
"monitorCount": "Сервисы"
|
"monitorCount": "Сервисы",
|
||||||
|
"packetLoss": "Потеря пакетов",
|
||||||
|
"clearSelections": "Очистить"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"newContent": "Доступен новый контент",
|
"newContent": "Доступен новый контент",
|
||||||
|
|||||||
@@ -93,7 +93,9 @@
|
|||||||
"monitor": {
|
"monitor": {
|
||||||
"noData": "சேவையக மானிட்டர் தரவு இல்லை, முதலில் ஒரு பணி மானிட்டரைச் சேர்க்கவும்",
|
"noData": "சேவையக மானிட்டர் தரவு இல்லை, முதலில் ஒரு பணி மானிட்டரைச் சேர்க்கவும்",
|
||||||
"avgDelay": "சுணக்கம்",
|
"avgDelay": "சுணக்கம்",
|
||||||
"monitorCount": "சேவைகள்"
|
"monitorCount": "சேவைகள்",
|
||||||
|
"packetLoss": "தொகுப்பு இழப்பு",
|
||||||
|
"clearSelections": "அழி"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"offlineReady": "ஆஃப்லைனில் வேலை செய்ய பயன்பாடு தயாராக உள்ளது",
|
"offlineReady": "ஆஃப்லைனில் வேலை செய்ய பயன்பாடு தயாராக உள்ளது",
|
||||||
|
|||||||
@@ -108,7 +108,9 @@
|
|||||||
"monitor": {
|
"monitor": {
|
||||||
"noData": "没有服务监控数据,请在管理后台服务页添加监控任务",
|
"noData": "没有服务监控数据,请在管理后台服务页添加监控任务",
|
||||||
"avgDelay": "延迟",
|
"avgDelay": "延迟",
|
||||||
"monitorCount": "个监控服务"
|
"monitorCount": "个监控服务",
|
||||||
|
"packetLoss": "丢包率",
|
||||||
|
"clearSelections": "清除"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"offlineReady": "应用可以离线使用了",
|
"offlineReady": "应用可以离线使用了",
|
||||||
|
|||||||
@@ -110,7 +110,9 @@
|
|||||||
"noData": "沒有服務監控數據,請在管理後台服務新增監控任務",
|
"noData": "沒有服務監控數據,請在管理後台服務新增監控任務",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"avgDelay": "延遲",
|
"avgDelay": "延遲",
|
||||||
"monitorCount": "個監控"
|
"monitorCount": "個監控",
|
||||||
|
"packetLoss": "丟包率",
|
||||||
|
"clearSelections": "清除"
|
||||||
},
|
},
|
||||||
"billingInfo": {
|
"billingInfo": {
|
||||||
"remaining": "剩餘天數",
|
"remaining": "剩餘天數",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import App from "./App"
|
|||||||
import { ThemeColorManager } from "./components/ThemeColorManager"
|
import { ThemeColorManager } from "./components/ThemeColorManager"
|
||||||
import { ThemeProvider } from "./components/ThemeProvider"
|
import { ThemeProvider } from "./components/ThemeProvider"
|
||||||
import { MotionProvider } from "./components/motion/motion-provider"
|
import { MotionProvider } from "./components/motion/motion-provider"
|
||||||
|
import { CommandProvider } from "./context/command-provider"
|
||||||
import { SortProvider } from "./context/sort-provider"
|
import { SortProvider } from "./context/sort-provider"
|
||||||
import { StatusProvider } from "./context/status-provider"
|
import { StatusProvider } from "./context/status-provider"
|
||||||
import { TooltipProvider } from "./context/tooltip-provider"
|
import { TooltipProvider } from "./context/tooltip-provider"
|
||||||
@@ -22,6 +23,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<ThemeColorManager />
|
<ThemeColorManager />
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WebSocketProvider url="/api/v1/ws/server">
|
<WebSocketProvider url="/api/v1/ws/server">
|
||||||
|
<CommandProvider>
|
||||||
<StatusProvider>
|
<StatusProvider>
|
||||||
<SortProvider>
|
<SortProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -40,6 +42,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SortProvider>
|
</SortProvider>
|
||||||
</StatusProvider>
|
</StatusProvider>
|
||||||
|
</CommandProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export type ServerMonitorChart = {
|
|||||||
[key: string]: {
|
[key: string]: {
|
||||||
created_at: number
|
created_at: number
|
||||||
avg_delay: number
|
avg_delay: number
|
||||||
|
packet_loss?: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ export interface NezhaMonitor {
|
|||||||
server_name: string
|
server_name: string
|
||||||
created_at: number[]
|
created_at: number[]
|
||||||
avg_delay: number[]
|
avg_delay: number[]
|
||||||
|
packet_loss?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceResponse {
|
export interface ServiceResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user