;
}
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 (
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({
{
- 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"
diff --git a/src/components/ServerCard.tsx b/src/components/ServerCard.tsx
index 3f95b2f..d320b70 100644
--- a/src/components/ServerCard.tsx
+++ b/src/components/ServerCard.tsx
@@ -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({
);
}
+
+export default memo(ServerCard);
diff --git a/src/components/ServerCardInline.tsx b/src/components/ServerCardInline.tsx
index c6b2a1a..74c6467 100644
--- a/src/components/ServerCardInline.tsx
+++ b/src/components/ServerCardInline.tsx
@@ -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({
);
}
+
+export default memo(ServerCardInline);
diff --git a/src/components/ServiceTracker.tsx b/src/components/ServiceTracker.tsx
index 8500c2d..0bb7743 100644
--- a/src/components/ServiceTracker.tsx
+++ b/src/components/ServiceTracker.tsx
@@ -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[] }) {
/>
)}
- {serviceData.data.services &&
- Object.keys(serviceData.data.services).length > 0 && (
-
- {Object.entries(serviceData.data.services).map(([name, data]) => {
- const { days, uptime, avgDelay } = processServiceData(data);
- return (
-
- );
- })}
-
- )}
+ {serviceSummaries.length > 0 && (
+
+ {serviceSummaries.map(({ avgDelay, days, name, title, uptime }) => (
+
+ ))}
+
+ )}
);
}
diff --git a/src/components/VirtualServerList.tsx b/src/components/VirtualServerList.tsx
new file mode 100644
index 0000000..ef62b17
--- /dev/null
+++ b/src/components/VirtualServerList.tsx
@@ -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 ;
+ }
+
+ return ;
+}
+
+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 (
+
+
+ {virtualItems.map((virtualRow) => {
+ const rowStart = virtualRow.index * columnCount;
+ const row = servers.slice(rowStart, rowStart + columnCount);
+
+ return (
+
+ {row.map((serverInfo) => (
+
+ ))}
+
+ );
+ })}
+
+
+ );
+}
+
+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 (
+
+
+ {virtualItems.map((virtualRow) => {
+ const serverInfo = servers[virtualRow.index];
+ if (!serverInfo) return null;
+
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/pages/Server.tsx b/src/pages/Server.tsx
index 218e8f3..d1cebae 100644
--- a/src/pages/Server.tsx
+++ b/src/pages/Server.tsx
@@ -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>();
+
+ 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 (
)}
{showServices === "1" && }
- {inline === "1" && (
-
- {filteredServers.map((serverInfo) => (
-
- ))}
-
- )}
- {inline === "0" && (
-
- {filteredServers.map((serverInfo) => (
-
- ))}
-
- )}
+
);
}
diff --git a/src/test/components/dash-command.test.tsx b/src/test/components/dash-command.test.tsx
index a909fee..0cf7f6a 100644
--- a/src/test/components/dash-command.test.tsx
+++ b/src/test/components/dash-command.test.tsx
@@ -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();
+
+ 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();
diff --git a/src/test/components/global-map.test.tsx b/src/test/components/global-map.test.tsx
index a5b8fdd..73b039a 100644
--- a/src/test/components/global-map.test.tsx
+++ b/src/test/components/global-map.test.tsx
@@ -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,
+ },
+ ],
+ }}
/>,
);
diff --git a/src/test/pages/Server.test.tsx b/src/test/pages/Server.test.tsx
index 5c0a783..fb2a525 100644
--- a/src/test/pages/Server.test.tsx
+++ b/src/test/pages/Server.test.tsx
@@ -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 () => {