diff --git a/package.json b/package.json index 196002f..3502da6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ }, "scripts": { "dev": "vite", + "dev:mock": "node scripts/dev-mock.js", + "mock:server": "node scripts/mock-nezha-server.js", "typecheck": "tsc -b", "build": "pnpm run typecheck && vite build", "lint": "biome lint", @@ -41,6 +43,7 @@ "@tanstack/react-query": "5.101.0", "@tanstack/react-query-devtools": "5.101.0", "@tanstack/react-table": "8.21.3", + "@tanstack/react-virtual": "^3.14.5", "@types/d3-geo": "3.1.0", "@types/luxon": "3.7.1", "class-variance-authority": "0.7.1", @@ -79,7 +82,8 @@ "tailwindcss": "^4.3.2", "typescript": "~6.0.3", "vite": "^8.1.2", - "vitest": "^4.1.9" + "vitest": "^4.1.9", + "ws": "^8.21.0" }, "packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba09c20..9e8b3fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@tanstack/react-table': specifier: 8.21.3 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.5 + version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/d3-geo': specifier: 3.1.0 version: 3.1.0 @@ -174,6 +177,9 @@ importers: vitest: specifier: ^4.1.9 version: 4.1.9(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.1.2(@types/node@25.9.2)(jiti@2.7.0)) + ws: + specifier: ^8.21.0 + version: 8.21.0 packages: @@ -1125,10 +1131,19 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2057,6 +2072,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -2902,8 +2929,16 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.17.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -3714,6 +3749,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.21.0: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/scripts/dev-mock.js b/scripts/dev-mock.js new file mode 100644 index 0000000..b9382d8 --- /dev/null +++ b/scripts/dev-mock.js @@ -0,0 +1,40 @@ +import { spawn } from "node:child_process"; + +const isWindows = process.platform === "win32"; +const pnpmCommand = isWindows ? "pnpm.cmd" : "pnpm"; + +const children = [ + spawn(process.execPath, ["scripts/mock-nezha-server.js"], { + stdio: "inherit", + }), + spawn(pnpmCommand, ["dev"], { + stdio: "inherit", + }), +]; + +let shuttingDown = false; + +function shutdown(exitCode = 0) { + if (shuttingDown) return; + shuttingDown = true; + + for (const child of children) { + if (!child.killed) { + child.kill("SIGTERM"); + } + } + + process.exit(exitCode); +} + +for (const child of children) { + child.on("exit", (code, signal) => { + if (shuttingDown) return; + if (code === 0 || signal === "SIGTERM") return; + + shutdown(code ?? 1); + }); +} + +process.on("SIGINT", () => shutdown(0)); +process.on("SIGTERM", () => shutdown(0)); diff --git a/scripts/mock-nezha-server.js b/scripts/mock-nezha-server.js new file mode 100644 index 0000000..44f0b33 --- /dev/null +++ b/scripts/mock-nezha-server.js @@ -0,0 +1,287 @@ +import http from "node:http"; +import { URL } from "node:url"; +import { WebSocket, WebSocketServer } from "ws"; + +const port = Number(process.env.MOCK_NEZHA_PORT || 8008); +const serverCount = Number(process.env.MOCK_NEZHA_COUNT || 3000); +const updateIntervalMs = Number( + process.env.MOCK_NEZHA_INTERVAL_MS || (serverCount > 10_000 ? 0 : 2000), +); + +const countries = [ + "US", + "SG", + "JP", + "DE", + "GB", + "FR", + "NL", + "CA", + "AU", + "BR", + "IN", + "KR", + "HK", + "TW", + "FI", + "PL", +]; + +const platforms = ["linux", "debian", "ubuntu", "centos", "alpine", "Windows"]; + +const nowIso = () => new Date().toISOString(); + +function createServer(index) { + const id = index + 1; + const platform = platforms[index % platforms.length]; + const memTotal = 2 ** 30 * (2 + (index % 15)); + const diskTotal = 2 ** 30 * (40 + (index % 200)); + const swapTotal = 2 ** 30 * (index % 8); + const memUsed = memTotal * ((index % 90) / 100); + const diskUsed = diskTotal * ((index % 84) / 100); + const swapUsed = swapTotal ? swapTotal * ((index % 60) / 100) : 0; + const isOffline = id % 17 === 0; + + return { + id, + name: `mock-server-${String(id).padStart(4, "0")}`, + public_note: id % 19 === 0 ? "[[Plan]] bandwidth=1Gbps" : "", + last_active: isOffline + ? new Date(Date.now() - 10 * 60 * 1000).toISOString() + : nowIso(), + country_code: countries[index % countries.length], + host: { + platform, + platform_version: platform === "Windows" ? "Server 2022" : "6.8", + cpu: [`Mock CPU ${1 + (index % 8)} Core`], + gpu: index % 40 === 0 ? ["Mock GPU"] : [], + mem_total: memTotal, + disk_total: diskTotal, + swap_total: swapTotal, + arch: index % 5 === 0 ? "arm64" : "amd64", + boot_time: Math.floor(Date.now() / 1000) - 86_400 * (1 + (index % 365)), + version: "mock-1.0.0", + }, + state: { + cpu: (index * 7) % 100, + mem_used: memUsed, + swap_used: swapUsed, + disk_used: diskUsed, + net_in_transfer: 2 ** 30 * (index % 500), + net_out_transfer: 2 ** 30 * ((index * 2) % 500), + net_in_speed: 1024 * (20 + ((index * 13) % 80_000)), + net_out_speed: 1024 * (20 + ((index * 17) % 80_000)), + uptime: 3600 * (1 + (index % 20_000)), + load_1: Number(((index % 100) / 20).toFixed(2)), + load_5: Number(((index % 80) / 20).toFixed(2)), + load_15: Number(((index % 60) / 20).toFixed(2)), + tcp_conn_count: index % 300, + udp_conn_count: index % 80, + process_count: 40 + (index % 240), + temperatures: [], + gpu: index % 40 === 0 ? [(index * 3) % 100] : [], + }, + }; +} + +const servers = Array.from({ length: serverCount }, (_, index) => + createServer(index), +); +let onlineCount = servers.filter((server) => server.id % 17 !== 0).length; + +function updateServerMetrics() { + const now = Date.now(); + let nextOnlineCount = 0; + + for (const server of servers) { + const isOffline = server.id % 17 === 0; + if (!isOffline) { + server.last_active = new Date(now).toISOString(); + nextOnlineCount += 1; + } + + const wave = (now / 1000 + server.id) % 100; + server.state.cpu = Number(wave.toFixed(2)); + server.state.net_in_speed = + 1024 * (20 + ((server.id * 13 + now / 1000) % 80_000)); + server.state.net_out_speed = + 1024 * (20 + ((server.id * 17 + now / 1000) % 80_000)); + server.state.net_in_transfer += server.state.net_in_speed; + server.state.net_out_transfer += server.state.net_out_speed; + } + + onlineCount = nextOnlineCount; +} + +function websocketPayload() { + return JSON.stringify({ + now: Date.now(), + online: onlineCount, + servers, + }); +} + +function jsonResponse(response, data) { + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + }); + response.end(JSON.stringify(data)); +} + +const serverGroups = [ + { + group: { + id: 1, + created_at: nowIso(), + updated_at: nowIso(), + name: "Region US/EU", + }, + servers: + serverCount > 10_000 + ? [] + : servers + .filter((server) => + ["US", "DE", "GB", "FR", "NL"].includes(server.country_code), + ) + .map((server) => server.id), + }, + { + group: { + id: 2, + created_at: nowIso(), + updated_at: nowIso(), + name: "Region APAC", + }, + servers: + serverCount > 10_000 + ? [] + : servers + .filter((server) => + ["SG", "JP", "KR", "HK", "TW", "AU"].includes( + server.country_code, + ), + ) + .map((server) => server.id), + }, +]; + +const serviceHistory = Array.from({ length: 30 }, (_, index) => ({ + up: 95 + (index % 6), + down: index % 13 === 0 ? 1 : 0, + delay: 20 + index * 3, +})); + +const httpServer = http.createServer((request, response) => { + const requestUrl = new URL( + request.url || "/", + `http://${request.headers.host}`, + ); + + if (request.method === "OPTIONS") { + response.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type, X-CSRF-Token", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + }); + response.end(); + return; + } + + switch (requestUrl.pathname) { + case "/api/v1/setting": + jsonResponse(response, { + success: true, + data: { + config: { + debug: true, + language: "en-US", + site_name: `Nezha Mock ${serverCount}`, + user_template: "", + admin_template: "", + custom_code: "", + }, + version: "mock-1.0.0", + }, + }); + return; + case "/api/v1/server-group": + jsonResponse(response, { success: true, data: serverGroups }); + return; + case "/api/v1/service": + jsonResponse(response, { + success: true, + data: { + services: { + mock_http: { + service_name: "Mock HTTP", + current_up: 1, + current_down: 0, + total_up: 2999, + total_down: 1, + delay: serviceHistory.map((item) => item.delay), + up: serviceHistory.map((item) => item.up), + down: serviceHistory.map((item) => item.down), + }, + }, + cycle_transfer_stats: {}, + }, + }); + return; + case "/api/v1/profile": + jsonResponse(response, { + success: true, + data: { + id: 1, + username: "mock-user", + password: "", + created_at: nowIso(), + updated_at: nowIso(), + }, + }); + return; + default: + jsonResponse(response, { success: true, data: [] }); + } +}); + +const websocketServer = new WebSocketServer({ noServer: true }); + +httpServer.on("upgrade", (request, socket, head) => { + if (!request.url?.startsWith("/api/v1/ws/server")) { + socket.destroy(); + return; + } + + websocketServer.handleUpgrade(request, socket, head, (websocket) => { + websocketServer.emit("connection", websocket, request); + }); +}); + +websocketServer.on("connection", (websocket) => { + websocket.send(websocketPayload()); +}); + +if (updateIntervalMs > 0) { + setInterval(() => { + updateServerMetrics(); + const payload = websocketPayload(); + for (const client of websocketServer.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(payload); + } + } + }, updateIntervalMs); +} + +httpServer.listen(port, () => { + console.log( + `Mock Nezha API listening on http://localhost:${port} with ${serverCount} servers`, + ); + if (updateIntervalMs === 0) { + console.log( + "Mock Nezha websocket is in static mode. Set MOCK_NEZHA_INTERVAL_MS to enable repeated full-payload updates.", + ); + } +}); diff --git a/src/components/DashCommand.tsx b/src/components/DashCommand.tsx index 6668365..14ce314 100644 --- a/src/components/DashCommand.tsx +++ b/src/components/DashCommand.tsx @@ -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: , + label: t("Home"), + action: () => navigate("/"), + }, + { + keywords: ["light", "theme", "lightmode"], + icon: , + label: t("ToggleLightMode"), + action: () => setTheme("light"), + }, + { + keywords: ["dark", "theme", "darkmode"], + icon: , + label: t("ToggleDarkMode"), + action: () => setTheme("dark"), + }, + { + keywords: ["system", "theme", "systemmode"], + icon: , + label: t("ToggleSystemMode"), + action: () => setTheme("system"), + }, + ].map((item) => ({ + ...item, + value: `${item.keywords.join(" ")} ${item.label}`, + })), + [navigate, setTheme, t], + ); - const shortcuts = [ - { - keywords: ["home", "homepage"], - icon: , - label: t("Home"), - action: () => navigate("/"), - }, - { - keywords: ["light", "theme", "lightmode"], - icon: , - label: t("ToggleLightMode"), - action: () => setTheme("light"), - }, - { - keywords: ["dark", "theme", "darkmode"], - icon: , - label: t("ToggleDarkMode"), - action: () => setTheme("dark"), - }, - { - keywords: ["system", "theme", "systemmode"], - icon: , - 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 ( @@ -83,9 +97,9 @@ export function DashCommand() { /> {t("NoResults")} - {nezhaWsData.servers && nezhaWsData.servers.length > 0 && ( + {serverCommands.length > 0 && ( - {nezhaWsData.servers.map((server) => ( + {serverCommands.map((server) => ( - {formatNezhaInfo(nezhaWsData.now, server).online ? ( + {server.online ? ( ) : ( diff --git a/src/components/GlobalMap.tsx b/src/components/GlobalMap.tsx index d71c012..5cd2c4d 100644 --- a/src/components/GlobalMap.tsx +++ b/src/components/GlobalMap.tsx @@ -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 = {}; + const serverCounts: Record = {}; + + 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 (
@@ -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; } 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 () => {