mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-20 02:00:15 +00:00
feat: refactor DashCommand to use useMemo for shortcuts and server commands
feat: optimize GlobalMap component with useMemo for country data processing feat: enhance ServerCard and ServerCardInline components with memoization feat: improve ServiceTracker component by processing service data with useMemo feat: add VirtualServerList component for efficient rendering of server lists refactor: update Servers page to utilize VirtualServerList for better performance test: add tests for DashCommand and GlobalMap components, including virtualization checks
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Home, Moon, Sun, SunMoon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
@@ -42,37 +42,51 @@ export function DashCommand() {
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, [toggleCommand]);
|
||||
|
||||
if (!connected || !nezhaWsData) return null;
|
||||
const shortcuts = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
keywords: ["home", "homepage"],
|
||||
icon: <Home />,
|
||||
label: t("Home"),
|
||||
action: () => navigate("/"),
|
||||
},
|
||||
{
|
||||
keywords: ["light", "theme", "lightmode"],
|
||||
icon: <Sun />,
|
||||
label: t("ToggleLightMode"),
|
||||
action: () => setTheme("light"),
|
||||
},
|
||||
{
|
||||
keywords: ["dark", "theme", "darkmode"],
|
||||
icon: <Moon />,
|
||||
label: t("ToggleDarkMode"),
|
||||
action: () => setTheme("dark"),
|
||||
},
|
||||
{
|
||||
keywords: ["system", "theme", "systemmode"],
|
||||
icon: <SunMoon />,
|
||||
label: t("ToggleSystemMode"),
|
||||
action: () => setTheme("system"),
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
value: `${item.keywords.join(" ")} ${item.label}`,
|
||||
})),
|
||||
[navigate, setTheme, t],
|
||||
);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
keywords: ["home", "homepage"],
|
||||
icon: <Home />,
|
||||
label: t("Home"),
|
||||
action: () => navigate("/"),
|
||||
},
|
||||
{
|
||||
keywords: ["light", "theme", "lightmode"],
|
||||
icon: <Sun />,
|
||||
label: t("ToggleLightMode"),
|
||||
action: () => setTheme("light"),
|
||||
},
|
||||
{
|
||||
keywords: ["dark", "theme", "darkmode"],
|
||||
icon: <Moon />,
|
||||
label: t("ToggleDarkMode"),
|
||||
action: () => setTheme("dark"),
|
||||
},
|
||||
{
|
||||
keywords: ["system", "theme", "systemmode"],
|
||||
icon: <SunMoon />,
|
||||
label: t("ToggleSystemMode"),
|
||||
action: () => setTheme("system"),
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
value: `${item.keywords.join(" ")} ${item.label}`,
|
||||
}));
|
||||
const serverCommands = useMemo(() => {
|
||||
if (!isOpen || !nezhaWsData) return [];
|
||||
|
||||
return (nezhaWsData.servers ?? []).map((server) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
online: formatNezhaInfo(nezhaWsData.now, server).online,
|
||||
}));
|
||||
}, [isOpen, nezhaWsData]);
|
||||
|
||||
if (!connected || !nezhaWsData) return null;
|
||||
|
||||
return (
|
||||
<CommandDialog open={isOpen} onOpenChange={closeCommand}>
|
||||
@@ -83,9 +97,9 @@ export function DashCommand() {
|
||||
/>
|
||||
<CommandList className="border-t">
|
||||
<CommandEmpty>{t("NoResults")}</CommandEmpty>
|
||||
{nezhaWsData.servers && nezhaWsData.servers.length > 0 && (
|
||||
{serverCommands.length > 0 && (
|
||||
<CommandGroup heading={t("Servers")}>
|
||||
{nezhaWsData.servers.map((server) => (
|
||||
{serverCommands.map((server) => (
|
||||
<CommandItem
|
||||
key={server.id}
|
||||
value={server.name}
|
||||
@@ -95,7 +109,7 @@ export function DashCommand() {
|
||||
closeCommand();
|
||||
}}
|
||||
>
|
||||
{formatNezhaInfo(nezhaWsData.now, server).online ? (
|
||||
{server.online ? (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500 self-center" />
|
||||
) : (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-500 self-center" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { geoEquirectangular, geoPath } from "d3-geo";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useTooltip from "@/hooks/use-tooltip";
|
||||
import { geoJsonString } from "@/lib/geo-json-string";
|
||||
@@ -8,6 +9,28 @@ import type { NezhaServer } from "@/types/nezha-api";
|
||||
|
||||
import MapTooltip from "./MapTooltip";
|
||||
|
||||
const geoJson = JSON.parse(geoJsonString) as {
|
||||
features: Array<{
|
||||
type: "Feature";
|
||||
properties: {
|
||||
iso_a2_eh: string;
|
||||
iso_a3_eh: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
geometry: never;
|
||||
}>;
|
||||
};
|
||||
|
||||
const filteredFeatures = geoJson.features.filter(
|
||||
(feature) => feature.properties.iso_a3_eh !== "",
|
||||
);
|
||||
|
||||
type TooltipServer = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: boolean;
|
||||
};
|
||||
|
||||
export default function GlobalMap({
|
||||
serverList,
|
||||
now,
|
||||
@@ -16,33 +39,39 @@ export default function GlobalMap({
|
||||
now: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const countryList: string[] = [];
|
||||
const serverCounts: { [key: string]: number } = {};
|
||||
const { countryList, countryServers, serverCounts } = useMemo(() => {
|
||||
const countryList: string[] = [];
|
||||
const countryServers: Record<string, TooltipServer[]> = {};
|
||||
const serverCounts: Record<string, number> = {};
|
||||
|
||||
serverList.forEach((server) => {
|
||||
if (!server.country_code) return;
|
||||
|
||||
const countryCode = server.country_code.toUpperCase();
|
||||
if (!countryServers[countryCode]) {
|
||||
countryList.push(countryCode);
|
||||
countryServers[countryCode] = [];
|
||||
}
|
||||
|
||||
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1;
|
||||
countryServers[countryCode].push({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
status: formatNezhaInfo(now, server).online,
|
||||
});
|
||||
});
|
||||
|
||||
return { countryList, countryServers, serverCounts };
|
||||
}, [now, serverList]);
|
||||
|
||||
const customBackgroundImage =
|
||||
(window.CustomBackgroundImage as string) !== ""
|
||||
? window.CustomBackgroundImage
|
||||
: undefined;
|
||||
|
||||
serverList.forEach((server) => {
|
||||
if (server.country_code) {
|
||||
const countryCode = server.country_code.toUpperCase();
|
||||
if (!countryList.includes(countryCode)) {
|
||||
countryList.push(countryCode);
|
||||
}
|
||||
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const width = 900;
|
||||
const height = 500;
|
||||
|
||||
const geoJson = JSON.parse(geoJsonString);
|
||||
const filteredFeatures = geoJson.features.filter(
|
||||
(feature: { properties: { iso_a3_eh: string } }) =>
|
||||
feature.properties.iso_a3_eh !== "",
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn("flex flex-col gap-4 mt-8", {
|
||||
@@ -59,8 +88,7 @@ export default function GlobalMap({
|
||||
width={width}
|
||||
height={height}
|
||||
filteredFeatures={filteredFeatures}
|
||||
nezhaServerList={serverList}
|
||||
now={now}
|
||||
countryServers={countryServers}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -75,13 +103,13 @@ interface InteractiveMapProps {
|
||||
filteredFeatures: {
|
||||
type: "Feature";
|
||||
properties: {
|
||||
iso_a3_eh: string;
|
||||
iso_a2_eh: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
geometry: never;
|
||||
}[];
|
||||
nezhaServerList: NezhaServer[];
|
||||
now: number;
|
||||
countryServers: Record<string, TooltipServer[]>;
|
||||
}
|
||||
|
||||
export function InteractiveMap({
|
||||
@@ -90,17 +118,31 @@ export function InteractiveMap({
|
||||
width,
|
||||
height,
|
||||
filteredFeatures,
|
||||
nezhaServerList,
|
||||
now,
|
||||
countryServers,
|
||||
}: InteractiveMapProps) {
|
||||
const { setTooltipData } = useTooltip();
|
||||
|
||||
const projection = geoEquirectangular()
|
||||
.scale(140)
|
||||
.translate([width / 2, height / 2])
|
||||
.rotate([-12, 0, 0]);
|
||||
const { path, projection } = useMemo(() => {
|
||||
const projection = geoEquirectangular()
|
||||
.scale(140)
|
||||
.translate([width / 2, height / 2])
|
||||
.rotate([-12, 0, 0]);
|
||||
|
||||
const path = geoPath().projection(projection);
|
||||
return {
|
||||
path: geoPath().projection(projection),
|
||||
projection,
|
||||
};
|
||||
}, [height, width]);
|
||||
|
||||
const featureCountryCodes = useMemo(
|
||||
() =>
|
||||
new Set(filteredFeatures.map((feature) => feature.properties.iso_a2_eh)),
|
||||
[filteredFeatures],
|
||||
);
|
||||
const highlightedCountryCodes = useMemo(
|
||||
() => new Set(countries),
|
||||
[countries],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -130,7 +172,7 @@ export function InteractiveMap({
|
||||
onMouseEnter={() => setTooltipData(null)}
|
||||
/>
|
||||
{filteredFeatures.map((feature, index) => {
|
||||
const isHighlighted = countries.includes(
|
||||
const isHighlighted = highlightedCountryCodes.has(
|
||||
feature.properties.iso_a2_eh,
|
||||
);
|
||||
|
||||
@@ -152,21 +194,11 @@ export function InteractiveMap({
|
||||
}
|
||||
if (path.centroid(feature)) {
|
||||
const countryCode = feature.properties.iso_a2_eh;
|
||||
const countryServers = nezhaServerList
|
||||
.filter(
|
||||
(server: NezhaServer) =>
|
||||
server.country_code?.toUpperCase() === countryCode,
|
||||
)
|
||||
.map((server: NezhaServer) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
status: formatNezhaInfo(now, server).online,
|
||||
}));
|
||||
setTooltipData({
|
||||
centroid: path.centroid(feature),
|
||||
country: feature.properties.name,
|
||||
count: serverCount,
|
||||
servers: countryServers,
|
||||
servers: countryServers[countryCode] ?? [],
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -177,9 +209,7 @@ export function InteractiveMap({
|
||||
{/* 渲染不在 filteredFeatures 中的国家标记点 */}
|
||||
{countries.map((countryCode) => {
|
||||
// 检查该国家是否已经在 filteredFeatures 中
|
||||
const isInFilteredFeatures = filteredFeatures.some(
|
||||
(feature) => feature.properties.iso_a2_eh === countryCode,
|
||||
);
|
||||
const isInFilteredFeatures = featureCountryCodes.has(countryCode);
|
||||
|
||||
// 如果已经在 filteredFeatures 中,跳过
|
||||
if (isInFilteredFeatures) return null;
|
||||
@@ -196,22 +226,11 @@ export function InteractiveMap({
|
||||
<g
|
||||
key={countryCode}
|
||||
onMouseEnter={() => {
|
||||
const countryServers = nezhaServerList
|
||||
.filter(
|
||||
(server: NezhaServer) =>
|
||||
server.country_code?.toUpperCase() ===
|
||||
countryCode.toUpperCase(),
|
||||
)
|
||||
.map((server: NezhaServer) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
status: formatNezhaInfo(now, server).online,
|
||||
}));
|
||||
setTooltipData({
|
||||
centroid: [x, y],
|
||||
country: coords.name,
|
||||
count: serverCount,
|
||||
servers: countryServers,
|
||||
servers: countryServers[countryCode] ?? [],
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ServerFlag from "@/components/ServerFlag";
|
||||
@@ -16,7 +17,7 @@ import PlanInfo from "./PlanInfo";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Card } from "./ui/card";
|
||||
|
||||
export default function ServerCard({
|
||||
function ServerCard({
|
||||
now,
|
||||
serverInfo,
|
||||
}: {
|
||||
@@ -279,3 +280,5 @@ export default function ServerCard({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServerCard);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ServerFlag from "@/components/ServerFlag";
|
||||
@@ -16,7 +17,7 @@ import PlanInfo from "./PlanInfo";
|
||||
import { Card } from "./ui/card";
|
||||
import { Separator } from "./ui/separator";
|
||||
|
||||
export default function ServerCardInline({
|
||||
function ServerCardInline({
|
||||
now,
|
||||
serverInfo,
|
||||
}: {
|
||||
@@ -239,3 +240,5 @@ export default function ServerCardInline({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ServerCardInline);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchService } from "@/lib/nezha-api";
|
||||
import type { NezhaServer, ServiceData } from "@/types/nezha-api";
|
||||
@@ -8,6 +9,32 @@ import { CycleTransferStatsCard } from "./CycleTransferStats";
|
||||
import { Loader } from "./loading/Loader";
|
||||
import ServiceTrackerClient from "./ServiceTrackerClient";
|
||||
|
||||
function processServiceData(serviceData: ServiceData) {
|
||||
const days = serviceData.up.map((up, index) => {
|
||||
const totalChecks = up + serviceData.down[index];
|
||||
const dailyUptime = totalChecks > 0 ? (up / totalChecks) * 100 : 0;
|
||||
return {
|
||||
completed: up > serviceData.down[index],
|
||||
date: new Date(Date.now() - (29 - index) * 24 * 60 * 60 * 1000),
|
||||
uptime: dailyUptime,
|
||||
delay: serviceData.delay[index] || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const totalUp = serviceData.up.reduce((a, b) => a + b, 0);
|
||||
const totalChecks =
|
||||
serviceData.up.reduce((a, b) => a + b, 0) +
|
||||
serviceData.down.reduce((a, b) => a + b, 0);
|
||||
const uptime = totalChecks > 0 ? (totalUp / totalChecks) * 100 : 0;
|
||||
|
||||
const avgDelay =
|
||||
serviceData.delay.length > 0
|
||||
? serviceData.delay.reduce((a, b) => a + b, 0) / serviceData.delay.length
|
||||
: 0;
|
||||
|
||||
return { days, uptime, avgDelay };
|
||||
}
|
||||
|
||||
export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: serviceData, isLoading } = useQuery({
|
||||
@@ -18,32 +45,15 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const processServiceData = (serviceData: ServiceData) => {
|
||||
const days = serviceData.up.map((up, index) => {
|
||||
const totalChecks = up + serviceData.down[index];
|
||||
const dailyUptime = totalChecks > 0 ? (up / totalChecks) * 100 : 0;
|
||||
return {
|
||||
completed: up > serviceData.down[index],
|
||||
date: new Date(Date.now() - (29 - index) * 24 * 60 * 60 * 1000),
|
||||
uptime: dailyUptime,
|
||||
delay: serviceData.delay[index] || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const totalUp = serviceData.up.reduce((a, b) => a + b, 0);
|
||||
const totalChecks =
|
||||
serviceData.up.reduce((a, b) => a + b, 0) +
|
||||
serviceData.down.reduce((a, b) => a + b, 0);
|
||||
const uptime = totalChecks > 0 ? (totalUp / totalChecks) * 100 : 0;
|
||||
|
||||
const avgDelay =
|
||||
serviceData.delay.length > 0
|
||||
? serviceData.delay.reduce((a, b) => a + b, 0) /
|
||||
serviceData.delay.length
|
||||
: 0;
|
||||
|
||||
return { days, uptime, avgDelay };
|
||||
};
|
||||
const serviceSummaries = useMemo(() => {
|
||||
return Object.entries(serviceData?.data?.services ?? {}).map(
|
||||
([name, data]) => ({
|
||||
...processServiceData(data),
|
||||
name,
|
||||
title: data.service_name,
|
||||
}),
|
||||
);
|
||||
}, [serviceData?.data?.services]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -76,23 +86,19 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{serviceData.data.services &&
|
||||
Object.keys(serviceData.data.services).length > 0 && (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 mt-4 gap-2 md:gap-4">
|
||||
{Object.entries(serviceData.data.services).map(([name, data]) => {
|
||||
const { days, uptime, avgDelay } = processServiceData(data);
|
||||
return (
|
||||
<ServiceTrackerClient
|
||||
key={name}
|
||||
days={days}
|
||||
title={data.service_name}
|
||||
uptime={uptime}
|
||||
avgDelay={avgDelay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
{serviceSummaries.length > 0 && (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 mt-4 gap-2 md:gap-4">
|
||||
{serviceSummaries.map(({ avgDelay, days, name, title, uptime }) => (
|
||||
<ServiceTrackerClient
|
||||
key={name}
|
||||
days={days}
|
||||
title={title}
|
||||
uptime={uptime}
|
||||
avgDelay={avgDelay}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useEffect, useState } from "react";
|
||||
import ServerCard from "@/components/ServerCard";
|
||||
import ServerCardInline from "@/components/ServerCardInline";
|
||||
import type { NezhaServer } from "@/types/nezha-api";
|
||||
|
||||
type VirtualServerListProps = {
|
||||
now: number;
|
||||
servers: NezhaServer[];
|
||||
inline: boolean;
|
||||
};
|
||||
|
||||
const CARD_ROW_GAP = 8;
|
||||
const CARD_COLUMNS_BREAKPOINT = 768;
|
||||
|
||||
function useCardColumnCount(enabled: boolean) {
|
||||
const [columnCount, setColumnCount] = useState(() => {
|
||||
if (!enabled || typeof window === "undefined") return 1;
|
||||
return window.innerWidth >= CARD_COLUMNS_BREAKPOINT ? 2 : 1;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setColumnCount(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateColumnCount = () => {
|
||||
setColumnCount(window.innerWidth >= CARD_COLUMNS_BREAKPOINT ? 2 : 1);
|
||||
};
|
||||
|
||||
updateColumnCount();
|
||||
window.addEventListener("resize", updateColumnCount);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateColumnCount);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return columnCount;
|
||||
}
|
||||
|
||||
export default function VirtualServerList({
|
||||
inline,
|
||||
now,
|
||||
servers,
|
||||
}: VirtualServerListProps) {
|
||||
if (inline) {
|
||||
return <VirtualInlineServerList now={now} servers={servers} />;
|
||||
}
|
||||
|
||||
return <VirtualCardServerList now={now} servers={servers} />;
|
||||
}
|
||||
|
||||
function VirtualCardServerList({
|
||||
now,
|
||||
servers,
|
||||
}: {
|
||||
now: number;
|
||||
servers: NezhaServer[];
|
||||
}) {
|
||||
const columnCount = useCardColumnCount(true);
|
||||
const rowCount = Math.ceil(servers.length / columnCount);
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: rowCount,
|
||||
estimateSize: () => 150,
|
||||
gap: CARD_ROW_GAP,
|
||||
overscan: 6,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative mt-6 server-card-list"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const rowStart = virtualRow.index * columnCount;
|
||||
const row = servers.slice(rowStart, rowStart + columnCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-2"
|
||||
data-index={virtualRow.index}
|
||||
key={virtualRow.key}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
paddingBottom: CARD_ROW_GAP,
|
||||
}}
|
||||
>
|
||||
{row.map((serverInfo) => (
|
||||
<ServerCard
|
||||
key={serverInfo.id}
|
||||
now={now}
|
||||
serverInfo={serverInfo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function VirtualInlineServerList({
|
||||
now,
|
||||
servers,
|
||||
}: {
|
||||
now: number;
|
||||
servers: NezhaServer[];
|
||||
}) {
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: servers.length,
|
||||
estimateSize: () => 86,
|
||||
gap: CARD_ROW_GAP,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative mt-6 overflow-x-scroll scrollbar-hidden server-inline-list"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 px-px py-px flex w-full flex-col"
|
||||
style={{
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const serverInfo = servers[virtualRow.index];
|
||||
if (!serverInfo) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-index={virtualRow.index}
|
||||
key={virtualRow.key}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ paddingBottom: CARD_ROW_GAP }}
|
||||
>
|
||||
<ServerCardInline now={now} serverInfo={serverInfo} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+218
-171
@@ -7,22 +7,41 @@ import {
|
||||
ViewColumnsIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import GlobalMap from "@/components/GlobalMap";
|
||||
import GroupSwitch from "@/components/GroupSwitch";
|
||||
import { Loader } from "@/components/loading/Loader";
|
||||
import ServerCard from "@/components/ServerCard";
|
||||
import ServerCardInline from "@/components/ServerCardInline";
|
||||
import ServerOverview from "@/components/ServerOverview";
|
||||
import { ServiceTracker } from "@/components/ServiceTracker";
|
||||
import VirtualServerList from "@/components/VirtualServerList";
|
||||
import { SORT_TYPES } from "@/context/sort-context";
|
||||
import { useSort } from "@/hooks/use-sort";
|
||||
import { useStatus } from "@/hooks/use-status";
|
||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||
import { fetchServerGroup, fetchService } from "@/lib/nezha-api";
|
||||
import { cn, formatNezhaInfo } from "@/lib/utils";
|
||||
import type { ServerGroup } from "@/types/nezha-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NezhaServer, ServerGroup } from "@/types/nezha-api";
|
||||
|
||||
type PreparedServer = {
|
||||
online: boolean;
|
||||
server: NezhaServer;
|
||||
};
|
||||
|
||||
const EMPTY_SERVER_LIST: NezhaServer[] = [];
|
||||
|
||||
const isServerOnline = (now: number, server: NezhaServer) => {
|
||||
const lastActiveTime = server.last_active.startsWith("000")
|
||||
? 0
|
||||
: Date.parse(server.last_active);
|
||||
|
||||
return now - lastActiveTime <= 30000;
|
||||
};
|
||||
|
||||
const getUsagePercent = (used = 0, total = 0) => {
|
||||
if (!total) return 0;
|
||||
return (used / total) * 100;
|
||||
};
|
||||
|
||||
export default function Servers() {
|
||||
const { t } = useTranslation();
|
||||
@@ -140,19 +159,195 @@ export default function Servers() {
|
||||
}
|
||||
}, [nezhaWsData, restoreScrollPosition]);
|
||||
|
||||
const groupTabs = [
|
||||
"All",
|
||||
...(groupData?.data
|
||||
?.filter((item: ServerGroup) => {
|
||||
return (
|
||||
Array.isArray(item.servers) &&
|
||||
item.servers.some((serverId) =>
|
||||
nezhaWsData?.servers?.some((server) => server.id === serverId),
|
||||
)
|
||||
);
|
||||
})
|
||||
?.map((item: ServerGroup) => item.group.name) || []),
|
||||
];
|
||||
const groupServerIdSets = useMemo(() => {
|
||||
const sets = new Map<string, Set<number>>();
|
||||
|
||||
for (const item of groupData?.data ?? []) {
|
||||
if (Array.isArray(item.servers)) {
|
||||
sets.set(item.group.name, new Set(item.servers));
|
||||
}
|
||||
}
|
||||
|
||||
return sets;
|
||||
}, [groupData?.data]);
|
||||
|
||||
const groupTabs = useMemo(
|
||||
() => [
|
||||
"All",
|
||||
...(groupData?.data?.map((item: ServerGroup) => item.group.name) || []),
|
||||
],
|
||||
[groupData?.data],
|
||||
);
|
||||
const {
|
||||
down,
|
||||
downSpeed,
|
||||
filteredServers,
|
||||
offlineServers,
|
||||
onlineServers,
|
||||
totalServers,
|
||||
up,
|
||||
upSpeed,
|
||||
} = useMemo(() => {
|
||||
if (!nezhaWsData) {
|
||||
return {
|
||||
down: 0,
|
||||
downSpeed: 0,
|
||||
filteredServers: EMPTY_SERVER_LIST,
|
||||
offlineServers: 0,
|
||||
onlineServers: 0,
|
||||
totalServers: 0,
|
||||
up: 0,
|
||||
upSpeed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const currentGroupServerIds = groupServerIdSets.get(currentGroup);
|
||||
const groupFilteredServers: PreparedServer[] = [];
|
||||
const overview = {
|
||||
down: 0,
|
||||
downSpeed: 0,
|
||||
offlineServers: 0,
|
||||
onlineServers: 0,
|
||||
up: 0,
|
||||
upSpeed: 0,
|
||||
};
|
||||
|
||||
for (const server of nezhaWsData.servers) {
|
||||
if (currentGroup !== "All" && !currentGroupServerIds?.has(server.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const online = isServerOnline(nezhaWsData.now, server);
|
||||
|
||||
groupFilteredServers.push({
|
||||
online,
|
||||
server,
|
||||
});
|
||||
|
||||
if (!online) {
|
||||
overview.offlineServers += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
overview.onlineServers += 1;
|
||||
overview.up += server.state?.net_out_transfer ?? 0;
|
||||
overview.down += server.state?.net_in_transfer ?? 0;
|
||||
overview.upSpeed += server.state?.net_out_speed ?? 0;
|
||||
overview.downSpeed += server.state?.net_in_speed ?? 0;
|
||||
}
|
||||
|
||||
const statusFilteredServers =
|
||||
status === "all"
|
||||
? groupFilteredServers
|
||||
: groupFilteredServers.filter((item) =>
|
||||
status === "online" ? item.online : !item.online,
|
||||
);
|
||||
|
||||
if (sortType === "default") {
|
||||
const onlineServers: NezhaServer[] = [];
|
||||
const offlineServers: NezhaServer[] = [];
|
||||
|
||||
for (const item of statusFilteredServers) {
|
||||
if (item.online) {
|
||||
onlineServers.push(item.server);
|
||||
} else {
|
||||
offlineServers.push(item.server);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...overview,
|
||||
filteredServers: [...onlineServers, ...offlineServers],
|
||||
totalServers: groupFilteredServers.length,
|
||||
};
|
||||
}
|
||||
|
||||
const sortedServers = [...statusFilteredServers].sort((a, b) => {
|
||||
if (sortType !== "name") {
|
||||
if (!a.online && b.online) return 1;
|
||||
if (a.online && !b.online) return -1;
|
||||
if (!a.online && !b.online) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortType) {
|
||||
case "name":
|
||||
comparison = a.server.name.localeCompare(b.server.name);
|
||||
break;
|
||||
case "uptime":
|
||||
comparison =
|
||||
(a.server.state?.uptime ?? 0) - (b.server.state?.uptime ?? 0);
|
||||
break;
|
||||
case "system":
|
||||
comparison = (a.server.host?.platform ?? "").localeCompare(
|
||||
b.server.host?.platform ?? "",
|
||||
);
|
||||
break;
|
||||
case "cpu":
|
||||
comparison = (a.server.state?.cpu ?? 0) - (b.server.state?.cpu ?? 0);
|
||||
break;
|
||||
case "mem":
|
||||
comparison =
|
||||
getUsagePercent(
|
||||
a.server.state?.mem_used,
|
||||
a.server.host?.mem_total,
|
||||
) -
|
||||
getUsagePercent(b.server.state?.mem_used, b.server.host?.mem_total);
|
||||
break;
|
||||
case "disk":
|
||||
comparison =
|
||||
getUsagePercent(
|
||||
a.server.state?.disk_used,
|
||||
a.server.host?.disk_total,
|
||||
) -
|
||||
getUsagePercent(
|
||||
b.server.state?.disk_used,
|
||||
b.server.host?.disk_total,
|
||||
);
|
||||
break;
|
||||
case "up":
|
||||
comparison =
|
||||
(a.server.state?.net_out_speed ?? 0) -
|
||||
(b.server.state?.net_out_speed ?? 0);
|
||||
break;
|
||||
case "down":
|
||||
comparison =
|
||||
(a.server.state?.net_in_speed ?? 0) -
|
||||
(b.server.state?.net_in_speed ?? 0);
|
||||
break;
|
||||
case "up total":
|
||||
comparison =
|
||||
(a.server.state?.net_out_transfer ?? 0) -
|
||||
(b.server.state?.net_out_transfer ?? 0);
|
||||
break;
|
||||
case "down total":
|
||||
comparison =
|
||||
(a.server.state?.net_in_transfer ?? 0) -
|
||||
(b.server.state?.net_in_transfer ?? 0);
|
||||
break;
|
||||
default:
|
||||
comparison = 0;
|
||||
}
|
||||
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return {
|
||||
...overview,
|
||||
filteredServers: sortedServers.map((item) => item.server),
|
||||
totalServers: groupFilteredServers.length,
|
||||
};
|
||||
}, [
|
||||
currentGroup,
|
||||
groupServerIdSets,
|
||||
nezhaWsData,
|
||||
sortOrder,
|
||||
sortType,
|
||||
status,
|
||||
]);
|
||||
|
||||
if (!connected && !lastData) {
|
||||
return (
|
||||
@@ -173,137 +368,6 @@ export default function Servers() {
|
||||
);
|
||||
}
|
||||
|
||||
let filteredServers =
|
||||
nezhaWsData?.servers?.filter((server) => {
|
||||
if (currentGroup === "All") return true;
|
||||
const group = groupData?.data?.find(
|
||||
(g: ServerGroup) =>
|
||||
g.group.name === currentGroup &&
|
||||
Array.isArray(g.servers) &&
|
||||
g.servers.includes(server.id),
|
||||
);
|
||||
return !!group;
|
||||
}) || [];
|
||||
|
||||
const totalServers = filteredServers.length || 0;
|
||||
const onlineServers =
|
||||
filteredServers.filter(
|
||||
(server) => formatNezhaInfo(nezhaWsData.now, server).online,
|
||||
)?.length || 0;
|
||||
const offlineServers =
|
||||
filteredServers.filter(
|
||||
(server) => !formatNezhaInfo(nezhaWsData.now, server).online,
|
||||
)?.length || 0;
|
||||
const up =
|
||||
filteredServers.reduce(
|
||||
(total, server) =>
|
||||
formatNezhaInfo(nezhaWsData.now, server).online
|
||||
? total + (server.state?.net_out_transfer ?? 0)
|
||||
: total,
|
||||
0,
|
||||
) || 0;
|
||||
const down =
|
||||
filteredServers.reduce(
|
||||
(total, server) =>
|
||||
formatNezhaInfo(nezhaWsData.now, server).online
|
||||
? total + (server.state?.net_in_transfer ?? 0)
|
||||
: total,
|
||||
0,
|
||||
) || 0;
|
||||
|
||||
const upSpeed =
|
||||
filteredServers.reduce(
|
||||
(total, server) =>
|
||||
formatNezhaInfo(nezhaWsData.now, server).online
|
||||
? total + (server.state?.net_out_speed ?? 0)
|
||||
: total,
|
||||
0,
|
||||
) || 0;
|
||||
const downSpeed =
|
||||
filteredServers.reduce(
|
||||
(total, server) =>
|
||||
formatNezhaInfo(nezhaWsData.now, server).online
|
||||
? total + (server.state?.net_in_speed ?? 0)
|
||||
: total,
|
||||
0,
|
||||
) || 0;
|
||||
|
||||
filteredServers =
|
||||
status === "all"
|
||||
? filteredServers
|
||||
: filteredServers.filter((server) =>
|
||||
[status].includes(
|
||||
formatNezhaInfo(nezhaWsData.now, server).online
|
||||
? "online"
|
||||
: "offline",
|
||||
),
|
||||
);
|
||||
|
||||
filteredServers = filteredServers.sort((a, b) => {
|
||||
const serverAInfo = formatNezhaInfo(nezhaWsData.now, a);
|
||||
const serverBInfo = formatNezhaInfo(nezhaWsData.now, b);
|
||||
|
||||
if (sortType !== "name") {
|
||||
// 仅在非 "name" 排序时,先按在线状态排序
|
||||
if (!serverAInfo.online && serverBInfo.online) return 1;
|
||||
if (serverAInfo.online && !serverBInfo.online) return -1;
|
||||
if (!serverAInfo.online && !serverBInfo.online) {
|
||||
// 如果两者都离线,可以继续按照其他条件排序,或者保持原序
|
||||
// 这里选择保持原序
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortType) {
|
||||
case "name":
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case "uptime":
|
||||
comparison = (a.state?.uptime ?? 0) - (b.state?.uptime ?? 0);
|
||||
break;
|
||||
case "system":
|
||||
comparison = (a.host?.platform ?? "").localeCompare(
|
||||
b.host?.platform ?? "",
|
||||
);
|
||||
break;
|
||||
case "cpu":
|
||||
comparison = (a.state?.cpu ?? 0) - (b.state?.cpu ?? 0);
|
||||
break;
|
||||
case "mem":
|
||||
comparison =
|
||||
(formatNezhaInfo(nezhaWsData.now, a).mem ?? 0) -
|
||||
(formatNezhaInfo(nezhaWsData.now, b).mem ?? 0);
|
||||
break;
|
||||
case "disk":
|
||||
comparison =
|
||||
(formatNezhaInfo(nezhaWsData.now, a).disk ?? 0) -
|
||||
(formatNezhaInfo(nezhaWsData.now, b).disk ?? 0);
|
||||
break;
|
||||
case "up":
|
||||
comparison =
|
||||
(a.state?.net_out_speed ?? 0) - (b.state?.net_out_speed ?? 0);
|
||||
break;
|
||||
case "down":
|
||||
comparison =
|
||||
(a.state?.net_in_speed ?? 0) - (b.state?.net_in_speed ?? 0);
|
||||
break;
|
||||
case "up total":
|
||||
comparison =
|
||||
(a.state?.net_out_transfer ?? 0) - (b.state?.net_out_transfer ?? 0);
|
||||
break;
|
||||
case "down total":
|
||||
comparison =
|
||||
(a.state?.net_in_transfer ?? 0) - (b.state?.net_in_transfer ?? 0);
|
||||
break;
|
||||
default:
|
||||
comparison = 0;
|
||||
}
|
||||
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl px-0">
|
||||
<ServerOverview
|
||||
@@ -448,28 +512,11 @@ export default function Servers() {
|
||||
/>
|
||||
)}
|
||||
{showServices === "1" && <ServiceTracker serverList={filteredServers} />}
|
||||
{inline === "1" && (
|
||||
<section className="flex flex-col gap-2 overflow-x-scroll p-px scrollbar-hidden mt-6 server-inline-list">
|
||||
{filteredServers.map((serverInfo) => (
|
||||
<ServerCardInline
|
||||
now={nezhaWsData.now}
|
||||
key={serverInfo.id}
|
||||
serverInfo={serverInfo}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{inline === "0" && (
|
||||
<section className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6 server-card-list">
|
||||
{filteredServers.map((serverInfo) => (
|
||||
<ServerCard
|
||||
now={nezhaWsData.now}
|
||||
key={serverInfo.id}
|
||||
serverInfo={serverInfo}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<VirtualServerList
|
||||
inline={inline === "1"}
|
||||
now={nezhaWsData.now}
|
||||
servers={filteredServers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DashCommand } from "@/components/DashCommand";
|
||||
import { createServer } from "@/test/fixtures";
|
||||
|
||||
@@ -61,6 +61,16 @@ function seedWebSocketData() {
|
||||
}
|
||||
|
||||
describe("DashCommand", () => {
|
||||
beforeEach(() => {
|
||||
dashMocks.closeCommand.mockClear();
|
||||
dashMocks.navigate.mockClear();
|
||||
dashMocks.setTheme.mockClear();
|
||||
dashMocks.toggleCommand.mockClear();
|
||||
dashMocks.isOpen = true;
|
||||
dashMocks.connected = true;
|
||||
dashMocks.lastData = null;
|
||||
});
|
||||
|
||||
it("does not render until websocket data is available", () => {
|
||||
dashMocks.connected = false;
|
||||
dashMocks.lastData = null;
|
||||
@@ -70,6 +80,15 @@ describe("DashCommand", () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("does not mount server command items while the dialog is closed", () => {
|
||||
seedWebSocketData();
|
||||
dashMocks.isOpen = false;
|
||||
|
||||
render(<DashCommand />);
|
||||
|
||||
expect(screen.queryByText("edge-7")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders server and shortcut commands, handles selection, and listens for keyboard toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
seedWebSocketData();
|
||||
|
||||
@@ -87,27 +87,27 @@ describe("InteractiveMap", () => {
|
||||
width={900}
|
||||
height={500}
|
||||
filteredFeatures={filteredFeatures}
|
||||
nezhaServerList={[
|
||||
createServer({
|
||||
id: 11,
|
||||
name: "us-online",
|
||||
country_code: "us",
|
||||
last_active: "2025-01-01T00:00:00.000Z",
|
||||
}),
|
||||
createServer({
|
||||
id: 12,
|
||||
name: "us-offline",
|
||||
country_code: "us",
|
||||
last_active: "2024-12-31T00:00:00.000Z",
|
||||
}),
|
||||
createServer({
|
||||
id: 13,
|
||||
name: "sg-edge",
|
||||
country_code: "sg",
|
||||
last_active: "2025-01-01T00:00:00.000Z",
|
||||
}),
|
||||
]}
|
||||
now={now}
|
||||
countryServers={{
|
||||
US: [
|
||||
{
|
||||
id: 11,
|
||||
name: "us-online",
|
||||
status: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: "us-offline",
|
||||
status: false,
|
||||
},
|
||||
],
|
||||
SG: [
|
||||
{
|
||||
id: 13,
|
||||
name: "sg-edge",
|
||||
status: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -149,14 +149,15 @@ describe("InteractiveMap", () => {
|
||||
width={900}
|
||||
height={500}
|
||||
filteredFeatures={filteredFeatures}
|
||||
nezhaServerList={[
|
||||
createServer({
|
||||
id: 11,
|
||||
name: "us-online",
|
||||
country_code: "us",
|
||||
}),
|
||||
]}
|
||||
now={now}
|
||||
countryServers={{
|
||||
US: [
|
||||
{
|
||||
id: 11,
|
||||
name: "us-online",
|
||||
status: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -339,7 +339,28 @@ describe("Servers page", () => {
|
||||
lastData: websocketPayload([createServer({ id: 1, name: "alpha" })]),
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(scrollTo).not.toHaveBeenCalledWith({
|
||||
top: 345,
|
||||
left: 0,
|
||||
behavior: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("virtualizes large server lists instead of rendering every card", () => {
|
||||
const servers = Array.from({ length: 2000 }, (_, index) =>
|
||||
createServer({ id: index + 1, name: `server-${index + 1}` }),
|
||||
);
|
||||
|
||||
renderServerPage({
|
||||
connected: true,
|
||||
lastData: websocketPayload(servers),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("server-overview")).toHaveTextContent(
|
||||
"2000:2000:0",
|
||||
);
|
||||
expect(screen.getAllByTestId("server-card").length).toBeLessThan(2000);
|
||||
expect(screen.getAllByTestId("server-card").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("toggles map and service tracker controls when service data exists", async () => {
|
||||
|
||||
Reference in New Issue
Block a user