mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-05-06 13:58:43 +00:00
feat: add chart loading skeletons and enhance translation for time periods
This commit is contained in:
+194
-175
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { m } from "framer-motion";
|
||||||
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";
|
||||||
@@ -44,12 +45,6 @@ interface ResultItem {
|
|||||||
[key: string]: number;
|
[key: string]: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIME_RANGE_OPTIONS: { value: MonitorPeriod; label: string }[] = [
|
|
||||||
{ value: "1d", label: "1D" },
|
|
||||||
{ value: "7d", label: "7D" },
|
|
||||||
{ value: "30d", label: "30D" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to calculate packet loss from delay data
|
* Helper method to calculate packet loss from delay data
|
||||||
*/
|
*/
|
||||||
@@ -237,6 +232,12 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const TIME_RANGE_OPTIONS: { value: MonitorPeriod; label: string }[] = [
|
||||||
|
{ value: "1d", label: t("monitor.period1d") },
|
||||||
|
{ value: "7d", label: t("monitor.period7d") },
|
||||||
|
{ value: "30d", label: t("monitor.period30d") },
|
||||||
|
];
|
||||||
|
|
||||||
const customBackgroundImage =
|
const customBackgroundImage =
|
||||||
(window.CustomBackgroundImage as string) !== ""
|
(window.CustomBackgroundImage as string) !== ""
|
||||||
? window.CustomBackgroundImage
|
? window.CustomBackgroundImage
|
||||||
@@ -529,183 +530,201 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
|||||||
}, [isPeakEnabled, activeCharts, formattedData, chartData, chartDataKey]);
|
}, [isPeakEnabled, activeCharts, formattedData, chartData, chartDataKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className="flex flex-col gap-3">
|
||||||
className={cn({
|
<div className="flex items-center gap-3 -mt-5 flex-wrap">
|
||||||
"bg-card/70": customBackgroundImage,
|
<div className="flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border">
|
||||||
})}
|
{TIME_RANGE_OPTIONS.map((option) => {
|
||||||
>
|
const isLocked = !isLogin && option.value !== "1d";
|
||||||
<CardHeader className="flex flex-col items-stretch space-y-0 overflow-hidden rounded-t-lg p-0 sm:flex-row">
|
return (
|
||||||
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
|
<div
|
||||||
<CardTitle className="flex flex-none items-center gap-0.5 text-md">
|
key={option.value}
|
||||||
{serverName}
|
onClick={() => {
|
||||||
</CardTitle>
|
if (!isLocked) {
|
||||||
<CardDescription className="text-xs">
|
onPeriodChange(option.value);
|
||||||
{chartDataKey.length} {t("monitor.monitorCount")}
|
}
|
||||||
</CardDescription>
|
|
||||||
<div className="mt-0.5 flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-1">
|
|
||||||
{TIME_RANGE_OPTIONS.map((option) => {
|
|
||||||
const isLocked = !isLogin && option.value !== "1d";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
disabled={isLocked}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isLocked) {
|
|
||||||
onPeriodChange(option.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-200 ${
|
|
||||||
period === option.value
|
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
} ${isLocked ? "cursor-not-allowed opacity-50" : ""}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="Peak"
|
|
||||||
checked={isPeakEnabled}
|
|
||||||
onCheckedChange={setIsPeakEnabled}
|
|
||||||
/>
|
|
||||||
<Label className="text-xs" htmlFor="Peak">
|
|
||||||
Peak cut
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap w-full">{chartButtons}</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pr-2 pl-0 py-4 sm:pt-6 sm:pb-6 sm:pr-6 sm:pl-2">
|
|
||||||
<div className="relative">
|
|
||||||
{activeCharts.length > 0 && (
|
|
||||||
<button
|
|
||||||
className="absolute -top-2 right-1 z-10 text-xs px-2 py-1 bg-stone-100/80 dark:bg-stone-800/80 backdrop-blur-xs rounded-[5px] text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={clearAllSelections}
|
|
||||||
>
|
|
||||||
{t("monitor.clearSelections", "Clear")} ({activeCharts.length})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<ChartContainer
|
|
||||||
config={chartConfig}
|
|
||||||
className="aspect-auto h-[250px] w-full"
|
|
||||||
>
|
|
||||||
<ComposedChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={processedData}
|
|
||||||
margin={{ left: 12, right: 12 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="created_at"
|
|
||||||
tickLine={true}
|
|
||||||
tickSize={3}
|
|
||||||
axisLine={false}
|
|
||||||
tickMargin={8}
|
|
||||||
minTickGap={80}
|
|
||||||
ticks={processedData
|
|
||||||
.filter((item, index, array) => {
|
|
||||||
if (array.length < 6) {
|
|
||||||
return index === 0 || index === array.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算数据的总时间跨度(毫秒)
|
|
||||||
const timeSpan =
|
|
||||||
array[array.length - 1].created_at - array[0].created_at;
|
|
||||||
const hours = timeSpan / (1000 * 60 * 60);
|
|
||||||
|
|
||||||
// 根据时间跨度调整显示间隔
|
|
||||||
if (hours <= 12) {
|
|
||||||
// 12小时内,每60分钟显示一个刻度
|
|
||||||
return (
|
|
||||||
index === 0 ||
|
|
||||||
index === array.length - 1 ||
|
|
||||||
new Date(item.created_at).getMinutes() % 60 === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 超过12小时,每2小时显示一个刻度
|
|
||||||
const date = new Date(item.created_at);
|
|
||||||
return date.getMinutes() === 0 && date.getHours() % 2 === 0;
|
|
||||||
})
|
|
||||||
.map((item) => item.created_at)}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
const minutes = date.getMinutes();
|
|
||||||
return minutes === 0
|
|
||||||
? `${date.getHours()}:00`
|
|
||||||
: `${date.getHours()}:${minutes}`;
|
|
||||||
}}
|
}}
|
||||||
/>
|
className={cn(
|
||||||
<YAxis
|
"relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
|
||||||
yAxisId="delay"
|
period === option.value
|
||||||
tickLine={false}
|
? "text-foreground"
|
||||||
axisLine={false}
|
: "text-muted-foreground hover:text-foreground",
|
||||||
tickMargin={15}
|
isLocked && "cursor-not-allowed opacity-40 grayscale",
|
||||||
minTickGap={20}
|
)}
|
||||||
tickFormatter={(value) => `${value}ms`}
|
>
|
||||||
/>
|
{period === option.value && (
|
||||||
{activeCharts.length === 1 && (
|
<m.div
|
||||||
|
layoutId="network-period-selector-active"
|
||||||
|
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 400,
|
||||||
|
damping: 30,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-20">{option.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="Peak"
|
||||||
|
checked={isPeakEnabled}
|
||||||
|
onCheckedChange={setIsPeakEnabled}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs" htmlFor="Peak">
|
||||||
|
{t("monitor.peakCut")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card
|
||||||
|
className={cn({
|
||||||
|
"bg-card/70": customBackgroundImage,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-col items-stretch space-y-0 overflow-hidden rounded-t-lg p-0 sm:flex-row">
|
||||||
|
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
|
||||||
|
<CardTitle className="flex flex-none items-center gap-0.5 text-md">
|
||||||
|
{serverName}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{chartDataKey.length} {t("monitor.monitorCount")}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap w-full">{chartButtons}</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pr-2 pl-0 py-4 sm:pt-6 sm:pb-6 sm:pr-6 sm:pl-2">
|
||||||
|
<div className="relative">
|
||||||
|
{activeCharts.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="absolute -top-2 right-1 z-10 text-xs px-2 py-1 bg-stone-100/80 dark:bg-stone-800/80 backdrop-blur-xs rounded-[5px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={clearAllSelections}
|
||||||
|
>
|
||||||
|
{t("monitor.clearSelections", "Clear")} ({activeCharts.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="aspect-auto h-[250px] w-full"
|
||||||
|
>
|
||||||
|
<ComposedChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={processedData}
|
||||||
|
margin={{ left: 12, right: 12 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="created_at"
|
||||||
|
tickLine={true}
|
||||||
|
tickSize={3}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={80}
|
||||||
|
ticks={processedData
|
||||||
|
.filter((item, index, array) => {
|
||||||
|
if (array.length < 6) {
|
||||||
|
return index === 0 || index === array.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算数据的总时间跨度(毫秒)
|
||||||
|
const timeSpan =
|
||||||
|
array[array.length - 1].created_at -
|
||||||
|
array[0].created_at;
|
||||||
|
const hours = timeSpan / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
// 根据时间跨度调整显示间隔
|
||||||
|
if (hours <= 12) {
|
||||||
|
// 12小时内,每60分钟显示一个刻度
|
||||||
|
return (
|
||||||
|
index === 0 ||
|
||||||
|
index === array.length - 1 ||
|
||||||
|
new Date(item.created_at).getMinutes() % 60 === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 超过12小时,每2小时显示一个刻度
|
||||||
|
const date = new Date(item.created_at);
|
||||||
|
return (
|
||||||
|
date.getMinutes() === 0 && date.getHours() % 2 === 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((item) => item.created_at)}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
return minutes === 0
|
||||||
|
? `${date.getHours()}:00`
|
||||||
|
: `${date.getHours()}:${minutes}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="packet-loss"
|
yAxisId="delay"
|
||||||
orientation="right"
|
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={15}
|
tickMargin={15}
|
||||||
minTickGap={20}
|
minTickGap={20}
|
||||||
tickFormatter={(value) => `${value}%`}
|
tickFormatter={(value) => `${value}ms`}
|
||||||
/>
|
/>
|
||||||
)}
|
{activeCharts.length === 1 && (
|
||||||
<ChartTooltip
|
<YAxis
|
||||||
isAnimationActive={false}
|
yAxisId="packet-loss"
|
||||||
content={
|
orientation="right"
|
||||||
<ChartTooltipContent
|
tickLine={false}
|
||||||
indicator={"line"}
|
axisLine={false}
|
||||||
labelKey="created_at"
|
tickMargin={15}
|
||||||
labelFormatter={(_, payload) => {
|
minTickGap={20}
|
||||||
return formatTime(payload[0].payload.created_at);
|
tickFormatter={(value) => `${value}%`}
|
||||||
}}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
/>
|
<ChartTooltip
|
||||||
{activeCharts.length !== 1 && (
|
isAnimationActive={false}
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
content={
|
||||||
)}
|
<ChartTooltipContent
|
||||||
{chartElements}
|
indicator={"line"}
|
||||||
</ComposedChart>
|
labelKey="created_at"
|
||||||
</ChartContainer>
|
labelFormatter={(_, payload) => {
|
||||||
</div>
|
return formatTime(payload[0].payload.created_at);
|
||||||
</CardContent>
|
}}
|
||||||
</Card>
|
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>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{activeCharts.length !== 1 && (
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
)}
|
||||||
|
{chartElements}
|
||||||
|
</ComposedChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { m } from "framer-motion";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,7 @@ import type {
|
|||||||
NezhaWebsocketResponse,
|
NezhaWebsocketResponse,
|
||||||
} from "@/types/nezha-api";
|
} from "@/types/nezha-api";
|
||||||
|
|
||||||
|
import ChartSkeleton from "./loading/ChartSkeleton";
|
||||||
import { ServerDetailChartLoading } from "./loading/ServerDetailLoading";
|
import { ServerDetailChartLoading } from "./loading/ServerDetailLoading";
|
||||||
import AnimatedCircularProgressBar from "./ui/animated-circular-progress-bar";
|
import AnimatedCircularProgressBar from "./ui/animated-circular-progress-bar";
|
||||||
|
|
||||||
@@ -94,31 +96,41 @@ function PeriodSelector({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 mb-3 flex-wrap -mt-5">
|
<div className="flex gap-0.5 mb-3 flex-wrap -mt-5 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border">
|
||||||
{periods.map((period) => {
|
{periods.map((period) => {
|
||||||
// Only realtime and 1d are available for non-logged-in users
|
// Only realtime and 1d are available for non-logged-in users
|
||||||
const isLocked =
|
const isLocked =
|
||||||
!isLogin && period.value !== "realtime" && period.value !== "1d";
|
!isLogin && period.value !== "realtime" && period.value !== "1d";
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={period.value}
|
key={period.value}
|
||||||
type="button"
|
|
||||||
disabled={isLocked}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isLocked) {
|
if (!isLocked) {
|
||||||
onPeriodChange(period.value);
|
onPeriodChange(period.value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1 text-xs rounded-md transition-all",
|
"relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
|
||||||
selectedPeriod === period.value
|
selectedPeriod === period.value
|
||||||
? "bg-primary text-primary-foreground font-medium"
|
? "text-foreground"
|
||||||
: "bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
isLocked && "cursor-not-allowed opacity-50",
|
isLocked && "cursor-not-allowed opacity-40 grayscale",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{period.label}
|
{selectedPeriod === period.value && (
|
||||||
</button>
|
<m.div
|
||||||
|
layoutId="period-selector-active"
|
||||||
|
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
||||||
|
transition={{ type: "spring", stiffness: 250, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="relative z-20 flex items-center gap-1.5">
|
||||||
|
{period.value === "realtime" && (
|
||||||
|
<span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span>
|
||||||
|
)}
|
||||||
|
{period.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -261,15 +273,24 @@ function useHistoricalData<T>(
|
|||||||
) {
|
) {
|
||||||
const [historicalData, setHistoricalData] = useState<T[]>([]);
|
const [historicalData, setHistoricalData] = useState<T[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [displayData, setDisplayData] = useState<T[]>([]);
|
||||||
|
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (period === "realtime") {
|
if (period === "realtime") {
|
||||||
setHistoricalData([]);
|
setHistoricalData([]);
|
||||||
|
setDisplayData([]);
|
||||||
|
if (loadingTimerRef.current) {
|
||||||
|
clearTimeout(loadingTimerRef.current);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
loadingTimerRef.current = setTimeout(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchServerMetrics(
|
const response = await fetchServerMetrics(
|
||||||
serverId,
|
serverId,
|
||||||
@@ -281,10 +302,15 @@ function useHistoricalData<T>(
|
|||||||
transformData(point.ts, point.value),
|
transformData(point.ts, point.value),
|
||||||
);
|
);
|
||||||
setHistoricalData(transformedData);
|
setHistoricalData(transformedData);
|
||||||
|
setDisplayData(transformedData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch ${metricName} metrics:`, error);
|
console.error(`Failed to fetch ${metricName} metrics:`, error);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (loadingTimerRef.current) {
|
||||||
|
clearTimeout(loadingTimerRef.current);
|
||||||
|
loadingTimerRef.current = null;
|
||||||
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -292,7 +318,7 @@ function useHistoricalData<T>(
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [serverId, metricName, period, transformData]);
|
}, [serverId, metricName, period, transformData]);
|
||||||
|
|
||||||
return { historicalData, isLoading };
|
return { historicalData, displayData, isLoading };
|
||||||
}
|
}
|
||||||
|
|
||||||
function GpuChart({
|
function GpuChart({
|
||||||
@@ -328,12 +354,8 @@ function GpuChart({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { historicalData, isLoading } = useHistoricalData<gpuChartData>(
|
const { displayData: gpuHistoricalData, isLoading } =
|
||||||
id,
|
useHistoricalData<gpuChartData>(id, "gpu", period, transformGpuData);
|
||||||
"gpu",
|
|
||||||
period,
|
|
||||||
transformGpuData,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化历史数据
|
// 初始化历史数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -397,7 +419,7 @@ function GpuChart({
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const displayData = period === "realtime" ? gpuChartData : historicalData;
|
const displayData = period === "realtime" ? gpuChartData : gpuHistoricalData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -430,11 +452,7 @@ function GpuChart({
|
|||||||
className="aspect-auto h-[130px] w-full"
|
className="aspect-auto h-[130px] w-full"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<ChartSkeleton />
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
syncId="serverDetailCharts"
|
syncId="serverDetailCharts"
|
||||||
@@ -532,12 +550,8 @@ function CpuChart({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { historicalData, isLoading } = useHistoricalData<cpuChartData>(
|
const { displayData: cpuHistoricalData, isLoading } =
|
||||||
data.id,
|
useHistoricalData<cpuChartData>(data.id, "cpu", period, transformCpuData);
|
||||||
"cpu",
|
|
||||||
period,
|
|
||||||
transformCpuData,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化历史数据
|
// 初始化历史数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -602,7 +616,7 @@ function CpuChart({
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const displayData = period === "realtime" ? cpuChartData : historicalData;
|
const displayData = period === "realtime" ? cpuChartData : cpuHistoricalData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -632,11 +646,7 @@ function CpuChart({
|
|||||||
className="aspect-auto h-[130px] w-full"
|
className="aspect-auto h-[130px] w-full"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<ChartSkeleton />
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
syncId="serverDetailCharts"
|
syncId="serverDetailCharts"
|
||||||
@@ -737,12 +747,13 @@ function ProcessChart({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { historicalData, isLoading } = useHistoricalData<processChartData>(
|
const { displayData: processHistoricalData, isLoading } =
|
||||||
data.id,
|
useHistoricalData<processChartData>(
|
||||||
"process_count",
|
data.id,
|
||||||
period,
|
"process_count",
|
||||||
transformProcessData,
|
period,
|
||||||
);
|
transformProcessData,
|
||||||
|
);
|
||||||
|
|
||||||
// 初始化历史数据
|
// 初始化历史数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -807,7 +818,8 @@ function ProcessChart({
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const displayData = period === "realtime" ? processChartData : historicalData;
|
const displayData =
|
||||||
|
period === "realtime" ? processChartData : processHistoricalData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -830,11 +842,7 @@ function ProcessChart({
|
|||||||
className="aspect-auto h-[130px] w-full"
|
className="aspect-auto h-[130px] w-full"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<ChartSkeleton />
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
syncId="serverDetailCharts"
|
syncId="serverDetailCharts"
|
||||||
@@ -1114,11 +1122,7 @@ function MemChart({
|
|||||||
className="aspect-auto h-[130px] w-full"
|
className="aspect-auto h-[130px] w-full"
|
||||||
>
|
>
|
||||||
{isLoadingMem ? (
|
{isLoadingMem ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<ChartSkeleton />
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
syncId="serverDetailCharts"
|
syncId="serverDetailCharts"
|
||||||
@@ -1238,12 +1242,13 @@ function DiskChart({
|
|||||||
[data.host.disk_total],
|
[data.host.disk_total],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { historicalData, isLoading } = useHistoricalData<diskChartData>(
|
const { displayData: diskHistoricalData, isLoading } =
|
||||||
data.id,
|
useHistoricalData<diskChartData>(
|
||||||
"disk",
|
data.id,
|
||||||
period,
|
"disk",
|
||||||
transformDiskData,
|
period,
|
||||||
);
|
transformDiskData,
|
||||||
|
);
|
||||||
|
|
||||||
// 初始化历史数据
|
// 初始化历史数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1308,7 +1313,8 @@ function DiskChart({
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const displayData = period === "realtime" ? diskChartData : historicalData;
|
const displayData =
|
||||||
|
period === "realtime" ? diskChartData : diskHistoricalData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -1344,11 +1350,7 @@ function DiskChart({
|
|||||||
className="aspect-auto h-[130px] w-full"
|
className="aspect-auto h-[130px] w-full"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<ChartSkeleton />
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
syncId="serverDetailCharts"
|
syncId="serverDetailCharts"
|
||||||
@@ -1615,11 +1617,7 @@ function NetworkChart({
|
|||||||
className="aspect-auto h-[130px] w-full"
|
className="aspect-auto h-[130px] w-full"
|
||||||
>
|
>
|
||||||
{isLoadingNetwork ? (
|
{isLoadingNetwork ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<ChartSkeleton />
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<LineChart
|
<LineChart
|
||||||
syncId="serverDetailCharts"
|
syncId="serverDetailCharts"
|
||||||
@@ -1876,11 +1874,7 @@ function ConnectChart({
|
|||||||
className="aspect-auto h-[130px] w-full"
|
className="aspect-auto h-[130px] w-full"
|
||||||
>
|
>
|
||||||
{isLoadingConnect ? (
|
{isLoadingConnect ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<ChartSkeleton />
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<LineChart
|
<LineChart
|
||||||
syncId="serverDetailCharts"
|
syncId="serverDetailCharts"
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export default function ChartSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="h-[130px] w-full px-6 py-3">
|
||||||
|
<div className="relative h-full w-full animate-pulse">
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-px bg-muted-foreground/20" />
|
||||||
|
<div className="absolute bottom-1 left-0 right-0 flex items-end justify-between gap-1 px-2">
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/20 rounded-t"
|
||||||
|
style={{ height: "40%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/30 rounded-t"
|
||||||
|
style={{ height: "65%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/25 rounded-t"
|
||||||
|
style={{ height: "45%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/35 rounded-t"
|
||||||
|
style={{ height: "80%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/20 rounded-t"
|
||||||
|
style={{ height: "55%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/30 rounded-t"
|
||||||
|
style={{ height: "70%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/25 rounded-t"
|
||||||
|
style={{ height: "50%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/20 rounded-t"
|
||||||
|
style={{ height: "35%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/30 rounded-t"
|
||||||
|
style={{ height: "60%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/25 rounded-t"
|
||||||
|
style={{ height: "45%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/35 rounded-t"
|
||||||
|
style={{ height: "75%" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 bg-muted-foreground/20 rounded-t"
|
||||||
|
style={{ height: "55%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-background/20 via-transparent to-background/10 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,11 @@
|
|||||||
"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",
|
"packetLoss": "Paketverlust",
|
||||||
"clearSelections": "Löschen"
|
"clearSelections": "Löschen",
|
||||||
|
"peakCut": "Peak cut",
|
||||||
|
"period1d": "1 Tag",
|
||||||
|
"period7d": "7 Tage",
|
||||||
|
"period30d": "30 Tage"
|
||||||
},
|
},
|
||||||
"billingInfo": {
|
"billingInfo": {
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
|
|||||||
@@ -113,7 +113,11 @@
|
|||||||
"avgDelay": "Latency",
|
"avgDelay": "Latency",
|
||||||
"monitorCount": "Services",
|
"monitorCount": "Services",
|
||||||
"packetLoss": "Packet Loss",
|
"packetLoss": "Packet Loss",
|
||||||
"clearSelections": "Clear"
|
"clearSelections": "Clear",
|
||||||
|
"peakCut": "Peak cut",
|
||||||
|
"period1d": "1 Day",
|
||||||
|
"period7d": "7 Days",
|
||||||
|
"period30d": "30 Days"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"offlineReady": "App ready to work offline",
|
"offlineReady": "App ready to work offline",
|
||||||
|
|||||||
@@ -93,7 +93,11 @@
|
|||||||
"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",
|
"packetLoss": "Pérdida de paquetes",
|
||||||
"clearSelections": "Limpiar"
|
"clearSelections": "Limpiar",
|
||||||
|
"peakCut": "Peak cut",
|
||||||
|
"period1d": "1 Día",
|
||||||
|
"period7d": "7 Días",
|
||||||
|
"period30d": "30 Días"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"pageNotFound": "Página no encontrada",
|
"pageNotFound": "Página no encontrada",
|
||||||
|
|||||||
@@ -100,7 +100,11 @@
|
|||||||
"avgDelay": "Задержка",
|
"avgDelay": "Задержка",
|
||||||
"monitorCount": "Сервисы",
|
"monitorCount": "Сервисы",
|
||||||
"packetLoss": "Потеря пакетов",
|
"packetLoss": "Потеря пакетов",
|
||||||
"clearSelections": "Очистить"
|
"clearSelections": "Очистить",
|
||||||
|
"peakCut": "Peak cut",
|
||||||
|
"period1d": "1 день",
|
||||||
|
"period7d": "7 дней",
|
||||||
|
"period30d": "30 дней"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"newContent": "Доступен новый контент",
|
"newContent": "Доступен новый контент",
|
||||||
|
|||||||
@@ -95,7 +95,11 @@
|
|||||||
"avgDelay": "சுணக்கம்",
|
"avgDelay": "சுணக்கம்",
|
||||||
"monitorCount": "சேவைகள்",
|
"monitorCount": "சேவைகள்",
|
||||||
"packetLoss": "தொகுப்பு இழப்பு",
|
"packetLoss": "தொகுப்பு இழப்பு",
|
||||||
"clearSelections": "அழி"
|
"clearSelections": "அழி",
|
||||||
|
"peakCut": "Peak cut",
|
||||||
|
"period1d": "1 நாள்",
|
||||||
|
"period7d": "7 நாட்கள்",
|
||||||
|
"period30d": "30 நாட்கள்"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"offlineReady": "ஆஃப்லைனில் வேலை செய்ய பயன்பாடு தயாராக உள்ளது",
|
"offlineReady": "ஆஃப்லைனில் வேலை செய்ய பயன்பாடு தயாராக உள்ளது",
|
||||||
|
|||||||
@@ -114,7 +114,11 @@
|
|||||||
"avgDelay": "延迟",
|
"avgDelay": "延迟",
|
||||||
"monitorCount": "个监控服务",
|
"monitorCount": "个监控服务",
|
||||||
"packetLoss": "丢包率",
|
"packetLoss": "丢包率",
|
||||||
"clearSelections": "清除"
|
"clearSelections": "清除",
|
||||||
|
"peakCut": "削峰",
|
||||||
|
"period1d": "1 天",
|
||||||
|
"period7d": "7 天",
|
||||||
|
"period30d": "30 天"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"offlineReady": "应用可以离线使用了",
|
"offlineReady": "应用可以离线使用了",
|
||||||
|
|||||||
@@ -112,7 +112,11 @@
|
|||||||
"avgDelay": "延遲",
|
"avgDelay": "延遲",
|
||||||
"monitorCount": "個監控",
|
"monitorCount": "個監控",
|
||||||
"packetLoss": "丟包率",
|
"packetLoss": "丟包率",
|
||||||
"clearSelections": "清除"
|
"clearSelections": "清除",
|
||||||
|
"peakCut": "削峰",
|
||||||
|
"period1d": "1 天",
|
||||||
|
"period7d": "7 天",
|
||||||
|
"period30d": "30 天"
|
||||||
},
|
},
|
||||||
"billingInfo": {
|
"billingInfo": {
|
||||||
"remaining": "剩餘天數",
|
"remaining": "剩餘天數",
|
||||||
|
|||||||
Reference in New Issue
Block a user