mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 17:50:13 +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:
+5
-1
@@ -9,6 +9,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:mock": "node scripts/dev-mock.js",
|
||||||
|
"mock:server": "node scripts/mock-nezha-server.js",
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
"build": "pnpm run typecheck && vite build",
|
"build": "pnpm run typecheck && vite build",
|
||||||
"lint": "biome lint",
|
"lint": "biome lint",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"@tanstack/react-query": "5.101.0",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@tanstack/react-query-devtools": "5.101.0",
|
"@tanstack/react-query-devtools": "5.101.0",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.5",
|
||||||
"@types/d3-geo": "3.1.0",
|
"@types/d3-geo": "3.1.0",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -79,7 +82,8 @@
|
|||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^8.1.2",
|
"vite": "^8.1.2",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9",
|
||||||
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485"
|
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+37
@@ -62,6 +62,9 @@ importers:
|
|||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: 8.21.3
|
specifier: 8.21.3
|
||||||
version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
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':
|
'@types/d3-geo':
|
||||||
specifier: 3.1.0
|
specifier: 3.1.0
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
@@ -174,6 +177,9 @@ importers:
|
|||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.9
|
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))
|
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:
|
packages:
|
||||||
|
|
||||||
@@ -1125,10 +1131,19 @@ packages:
|
|||||||
react: '>=16.8'
|
react: '>=16.8'
|
||||||
react-dom: '>=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':
|
'@tanstack/table-core@8.21.3':
|
||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.3':
|
||||||
|
resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2057,6 +2072,18 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
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:
|
xml-name-validator@5.0.0:
|
||||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2902,8 +2929,16 @@ snapshots:
|
|||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(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/table-core@8.21.3': {}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.3': {}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
@@ -3714,6 +3749,8 @@ snapshots:
|
|||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
|
ws@8.21.0: {}
|
||||||
|
|
||||||
xml-name-validator@5.0.0: {}
|
xml-name-validator@5.0.0: {}
|
||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Home, Moon, Sun, SunMoon } from "lucide-react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -42,9 +42,9 @@ export function DashCommand() {
|
|||||||
return () => document.removeEventListener("keydown", down);
|
return () => document.removeEventListener("keydown", down);
|
||||||
}, [toggleCommand]);
|
}, [toggleCommand]);
|
||||||
|
|
||||||
if (!connected || !nezhaWsData) return null;
|
const shortcuts = useMemo(
|
||||||
|
() =>
|
||||||
const shortcuts = [
|
[
|
||||||
{
|
{
|
||||||
keywords: ["home", "homepage"],
|
keywords: ["home", "homepage"],
|
||||||
icon: <Home />,
|
icon: <Home />,
|
||||||
@@ -72,7 +72,21 @@ export function DashCommand() {
|
|||||||
].map((item) => ({
|
].map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
value: `${item.keywords.join(" ")} ${item.label}`,
|
value: `${item.keywords.join(" ")} ${item.label}`,
|
||||||
|
})),
|
||||||
|
[navigate, setTheme, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<CommandDialog open={isOpen} onOpenChange={closeCommand}>
|
<CommandDialog open={isOpen} onOpenChange={closeCommand}>
|
||||||
@@ -83,9 +97,9 @@ export function DashCommand() {
|
|||||||
/>
|
/>
|
||||||
<CommandList className="border-t">
|
<CommandList className="border-t">
|
||||||
<CommandEmpty>{t("NoResults")}</CommandEmpty>
|
<CommandEmpty>{t("NoResults")}</CommandEmpty>
|
||||||
{nezhaWsData.servers && nezhaWsData.servers.length > 0 && (
|
{serverCommands.length > 0 && (
|
||||||
<CommandGroup heading={t("Servers")}>
|
<CommandGroup heading={t("Servers")}>
|
||||||
{nezhaWsData.servers.map((server) => (
|
{serverCommands.map((server) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={server.id}
|
key={server.id}
|
||||||
value={server.name}
|
value={server.name}
|
||||||
@@ -95,7 +109,7 @@ export function DashCommand() {
|
|||||||
closeCommand();
|
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-green-500 self-center" />
|
||||||
) : (
|
) : (
|
||||||
<span className="h-2 w-2 shrink-0 rounded-full bg-red-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 { geoEquirectangular, geoPath } from "d3-geo";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useTooltip from "@/hooks/use-tooltip";
|
import useTooltip from "@/hooks/use-tooltip";
|
||||||
import { geoJsonString } from "@/lib/geo-json-string";
|
import { geoJsonString } from "@/lib/geo-json-string";
|
||||||
@@ -8,6 +9,28 @@ import type { NezhaServer } from "@/types/nezha-api";
|
|||||||
|
|
||||||
import MapTooltip from "./MapTooltip";
|
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({
|
export default function GlobalMap({
|
||||||
serverList,
|
serverList,
|
||||||
now,
|
now,
|
||||||
@@ -16,33 +39,39 @@ export default function GlobalMap({
|
|||||||
now: number;
|
now: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { countryList, countryServers, serverCounts } = useMemo(() => {
|
||||||
const countryList: string[] = [];
|
const countryList: string[] = [];
|
||||||
const serverCounts: { [key: string]: number } = {};
|
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 =
|
const customBackgroundImage =
|
||||||
(window.CustomBackgroundImage as string) !== ""
|
(window.CustomBackgroundImage as string) !== ""
|
||||||
? window.CustomBackgroundImage
|
? window.CustomBackgroundImage
|
||||||
: undefined;
|
: 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 width = 900;
|
||||||
const height = 500;
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn("flex flex-col gap-4 mt-8", {
|
className={cn("flex flex-col gap-4 mt-8", {
|
||||||
@@ -59,8 +88,7 @@ export default function GlobalMap({
|
|||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
filteredFeatures={filteredFeatures}
|
filteredFeatures={filteredFeatures}
|
||||||
nezhaServerList={serverList}
|
countryServers={countryServers}
|
||||||
now={now}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -75,13 +103,13 @@ interface InteractiveMapProps {
|
|||||||
filteredFeatures: {
|
filteredFeatures: {
|
||||||
type: "Feature";
|
type: "Feature";
|
||||||
properties: {
|
properties: {
|
||||||
|
iso_a3_eh: string;
|
||||||
iso_a2_eh: string;
|
iso_a2_eh: string;
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
geometry: never;
|
geometry: never;
|
||||||
}[];
|
}[];
|
||||||
nezhaServerList: NezhaServer[];
|
countryServers: Record<string, TooltipServer[]>;
|
||||||
now: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InteractiveMap({
|
export function InteractiveMap({
|
||||||
@@ -90,17 +118,31 @@ export function InteractiveMap({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
filteredFeatures,
|
filteredFeatures,
|
||||||
nezhaServerList,
|
countryServers,
|
||||||
now,
|
|
||||||
}: InteractiveMapProps) {
|
}: InteractiveMapProps) {
|
||||||
const { setTooltipData } = useTooltip();
|
const { setTooltipData } = useTooltip();
|
||||||
|
|
||||||
|
const { path, projection } = useMemo(() => {
|
||||||
const projection = geoEquirectangular()
|
const projection = geoEquirectangular()
|
||||||
.scale(140)
|
.scale(140)
|
||||||
.translate([width / 2, height / 2])
|
.translate([width / 2, height / 2])
|
||||||
.rotate([-12, 0, 0]);
|
.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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -130,7 +172,7 @@ export function InteractiveMap({
|
|||||||
onMouseEnter={() => setTooltipData(null)}
|
onMouseEnter={() => setTooltipData(null)}
|
||||||
/>
|
/>
|
||||||
{filteredFeatures.map((feature, index) => {
|
{filteredFeatures.map((feature, index) => {
|
||||||
const isHighlighted = countries.includes(
|
const isHighlighted = highlightedCountryCodes.has(
|
||||||
feature.properties.iso_a2_eh,
|
feature.properties.iso_a2_eh,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,21 +194,11 @@ export function InteractiveMap({
|
|||||||
}
|
}
|
||||||
if (path.centroid(feature)) {
|
if (path.centroid(feature)) {
|
||||||
const countryCode = feature.properties.iso_a2_eh;
|
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({
|
setTooltipData({
|
||||||
centroid: path.centroid(feature),
|
centroid: path.centroid(feature),
|
||||||
country: feature.properties.name,
|
country: feature.properties.name,
|
||||||
count: serverCount,
|
count: serverCount,
|
||||||
servers: countryServers,
|
servers: countryServers[countryCode] ?? [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -177,9 +209,7 @@ export function InteractiveMap({
|
|||||||
{/* 渲染不在 filteredFeatures 中的国家标记点 */}
|
{/* 渲染不在 filteredFeatures 中的国家标记点 */}
|
||||||
{countries.map((countryCode) => {
|
{countries.map((countryCode) => {
|
||||||
// 检查该国家是否已经在 filteredFeatures 中
|
// 检查该国家是否已经在 filteredFeatures 中
|
||||||
const isInFilteredFeatures = filteredFeatures.some(
|
const isInFilteredFeatures = featureCountryCodes.has(countryCode);
|
||||||
(feature) => feature.properties.iso_a2_eh === countryCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 如果已经在 filteredFeatures 中,跳过
|
// 如果已经在 filteredFeatures 中,跳过
|
||||||
if (isInFilteredFeatures) return null;
|
if (isInFilteredFeatures) return null;
|
||||||
@@ -196,22 +226,11 @@ export function InteractiveMap({
|
|||||||
<g
|
<g
|
||||||
key={countryCode}
|
key={countryCode}
|
||||||
onMouseEnter={() => {
|
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({
|
setTooltipData({
|
||||||
centroid: [x, y],
|
centroid: [x, y],
|
||||||
country: coords.name,
|
country: coords.name,
|
||||||
count: serverCount,
|
count: serverCount,
|
||||||
servers: countryServers,
|
servers: countryServers[countryCode] ?? [],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import ServerFlag from "@/components/ServerFlag";
|
import ServerFlag from "@/components/ServerFlag";
|
||||||
@@ -16,7 +17,7 @@ import PlanInfo from "./PlanInfo";
|
|||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Card } from "./ui/card";
|
import { Card } from "./ui/card";
|
||||||
|
|
||||||
export default function ServerCard({
|
function ServerCard({
|
||||||
now,
|
now,
|
||||||
serverInfo,
|
serverInfo,
|
||||||
}: {
|
}: {
|
||||||
@@ -279,3 +280,5 @@ export default function ServerCard({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(ServerCard);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import ServerFlag from "@/components/ServerFlag";
|
import ServerFlag from "@/components/ServerFlag";
|
||||||
@@ -16,7 +17,7 @@ import PlanInfo from "./PlanInfo";
|
|||||||
import { Card } from "./ui/card";
|
import { Card } from "./ui/card";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
|
|
||||||
export default function ServerCardInline({
|
function ServerCardInline({
|
||||||
now,
|
now,
|
||||||
serverInfo,
|
serverInfo,
|
||||||
}: {
|
}: {
|
||||||
@@ -239,3 +240,5 @@ export default function ServerCardInline({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(ServerCardInline);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { fetchService } from "@/lib/nezha-api";
|
import { fetchService } from "@/lib/nezha-api";
|
||||||
import type { NezhaServer, ServiceData } from "@/types/nezha-api";
|
import type { NezhaServer, ServiceData } from "@/types/nezha-api";
|
||||||
@@ -8,17 +9,7 @@ import { CycleTransferStatsCard } from "./CycleTransferStats";
|
|||||||
import { Loader } from "./loading/Loader";
|
import { Loader } from "./loading/Loader";
|
||||||
import ServiceTrackerClient from "./ServiceTrackerClient";
|
import ServiceTrackerClient from "./ServiceTrackerClient";
|
||||||
|
|
||||||
export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
function processServiceData(serviceData: ServiceData) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: serviceData, isLoading } = useQuery({
|
|
||||||
queryKey: ["service"],
|
|
||||||
queryFn: () => fetchService(),
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchInterval: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const processServiceData = (serviceData: ServiceData) => {
|
|
||||||
const days = serviceData.up.map((up, index) => {
|
const days = serviceData.up.map((up, index) => {
|
||||||
const totalChecks = up + serviceData.down[index];
|
const totalChecks = up + serviceData.down[index];
|
||||||
const dailyUptime = totalChecks > 0 ? (up / totalChecks) * 100 : 0;
|
const dailyUptime = totalChecks > 0 ? (up / totalChecks) * 100 : 0;
|
||||||
@@ -38,12 +29,31 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
|||||||
|
|
||||||
const avgDelay =
|
const avgDelay =
|
||||||
serviceData.delay.length > 0
|
serviceData.delay.length > 0
|
||||||
? serviceData.delay.reduce((a, b) => a + b, 0) /
|
? serviceData.delay.reduce((a, b) => a + b, 0) / serviceData.delay.length
|
||||||
serviceData.delay.length
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return { days, uptime, avgDelay };
|
return { days, uptime, avgDelay };
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: serviceData, isLoading } = useQuery({
|
||||||
|
queryKey: ["service"],
|
||||||
|
queryFn: () => fetchService(),
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceSummaries = useMemo(() => {
|
||||||
|
return Object.entries(serviceData?.data?.services ?? {}).map(
|
||||||
|
([name, data]) => ({
|
||||||
|
...processServiceData(data),
|
||||||
|
name,
|
||||||
|
title: data.service_name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [serviceData?.data?.services]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -76,21 +86,17 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{serviceData.data.services &&
|
{serviceSummaries.length > 0 && (
|
||||||
Object.keys(serviceData.data.services).length > 0 && (
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 mt-4 gap-2 md:gap-4">
|
<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]) => {
|
{serviceSummaries.map(({ avgDelay, days, name, title, uptime }) => (
|
||||||
const { days, uptime, avgDelay } = processServiceData(data);
|
|
||||||
return (
|
|
||||||
<ServiceTrackerClient
|
<ServiceTrackerClient
|
||||||
key={name}
|
key={name}
|
||||||
days={days}
|
days={days}
|
||||||
title={data.service_name}
|
title={title}
|
||||||
uptime={uptime}
|
uptime={uptime}
|
||||||
avgDelay={avgDelay}
|
avgDelay={avgDelay}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+214
-167
@@ -7,22 +7,41 @@ import {
|
|||||||
ViewColumnsIcon,
|
ViewColumnsIcon,
|
||||||
} from "@heroicons/react/20/solid";
|
} from "@heroicons/react/20/solid";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useTranslation } from "react-i18next";
|
||||||
import GlobalMap from "@/components/GlobalMap";
|
import GlobalMap from "@/components/GlobalMap";
|
||||||
import GroupSwitch from "@/components/GroupSwitch";
|
import GroupSwitch from "@/components/GroupSwitch";
|
||||||
import { Loader } from "@/components/loading/Loader";
|
import { Loader } from "@/components/loading/Loader";
|
||||||
import ServerCard from "@/components/ServerCard";
|
|
||||||
import ServerCardInline from "@/components/ServerCardInline";
|
|
||||||
import ServerOverview from "@/components/ServerOverview";
|
import ServerOverview from "@/components/ServerOverview";
|
||||||
import { ServiceTracker } from "@/components/ServiceTracker";
|
import { ServiceTracker } from "@/components/ServiceTracker";
|
||||||
|
import VirtualServerList from "@/components/VirtualServerList";
|
||||||
import { SORT_TYPES } from "@/context/sort-context";
|
import { SORT_TYPES } from "@/context/sort-context";
|
||||||
import { useSort } from "@/hooks/use-sort";
|
import { useSort } from "@/hooks/use-sort";
|
||||||
import { useStatus } from "@/hooks/use-status";
|
import { useStatus } from "@/hooks/use-status";
|
||||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||||
import { fetchServerGroup, fetchService } from "@/lib/nezha-api";
|
import { fetchServerGroup, fetchService } from "@/lib/nezha-api";
|
||||||
import { cn, formatNezhaInfo } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ServerGroup } from "@/types/nezha-api";
|
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() {
|
export default function Servers() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -140,19 +159,195 @@ export default function Servers() {
|
|||||||
}
|
}
|
||||||
}, [nezhaWsData, restoreScrollPosition]);
|
}, [nezhaWsData, restoreScrollPosition]);
|
||||||
|
|
||||||
const groupTabs = [
|
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",
|
"All",
|
||||||
...(groupData?.data
|
...(groupData?.data?.map((item: ServerGroup) => item.group.name) || []),
|
||||||
?.filter((item: ServerGroup) => {
|
],
|
||||||
return (
|
[groupData?.data],
|
||||||
Array.isArray(item.servers) &&
|
|
||||||
item.servers.some((serverId) =>
|
|
||||||
nezhaWsData?.servers?.some((server) => server.id === serverId),
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
})
|
const {
|
||||||
?.map((item: ServerGroup) => item.group.name) || []),
|
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) {
|
if (!connected && !lastData) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl px-0">
|
<div className="mx-auto w-full max-w-5xl px-0">
|
||||||
<ServerOverview
|
<ServerOverview
|
||||||
@@ -448,28 +512,11 @@ export default function Servers() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showServices === "1" && <ServiceTracker serverList={filteredServers} />}
|
{showServices === "1" && <ServiceTracker serverList={filteredServers} />}
|
||||||
{inline === "1" && (
|
<VirtualServerList
|
||||||
<section className="flex flex-col gap-2 overflow-x-scroll p-px scrollbar-hidden mt-6 server-inline-list">
|
inline={inline === "1"}
|
||||||
{filteredServers.map((serverInfo) => (
|
|
||||||
<ServerCardInline
|
|
||||||
now={nezhaWsData.now}
|
now={nezhaWsData.now}
|
||||||
key={serverInfo.id}
|
servers={filteredServers}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
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 { DashCommand } from "@/components/DashCommand";
|
||||||
import { createServer } from "@/test/fixtures";
|
import { createServer } from "@/test/fixtures";
|
||||||
|
|
||||||
@@ -61,6 +61,16 @@ function seedWebSocketData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("DashCommand", () => {
|
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", () => {
|
it("does not render until websocket data is available", () => {
|
||||||
dashMocks.connected = false;
|
dashMocks.connected = false;
|
||||||
dashMocks.lastData = null;
|
dashMocks.lastData = null;
|
||||||
@@ -70,6 +80,15 @@ describe("DashCommand", () => {
|
|||||||
expect(container).toBeEmptyDOMElement();
|
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 () => {
|
it("renders server and shortcut commands, handles selection, and listens for keyboard toggle", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
seedWebSocketData();
|
seedWebSocketData();
|
||||||
|
|||||||
@@ -87,27 +87,27 @@ describe("InteractiveMap", () => {
|
|||||||
width={900}
|
width={900}
|
||||||
height={500}
|
height={500}
|
||||||
filteredFeatures={filteredFeatures}
|
filteredFeatures={filteredFeatures}
|
||||||
nezhaServerList={[
|
countryServers={{
|
||||||
createServer({
|
US: [
|
||||||
|
{
|
||||||
id: 11,
|
id: 11,
|
||||||
name: "us-online",
|
name: "us-online",
|
||||||
country_code: "us",
|
status: true,
|
||||||
last_active: "2025-01-01T00:00:00.000Z",
|
},
|
||||||
}),
|
{
|
||||||
createServer({
|
|
||||||
id: 12,
|
id: 12,
|
||||||
name: "us-offline",
|
name: "us-offline",
|
||||||
country_code: "us",
|
status: false,
|
||||||
last_active: "2024-12-31T00:00:00.000Z",
|
},
|
||||||
}),
|
],
|
||||||
createServer({
|
SG: [
|
||||||
|
{
|
||||||
id: 13,
|
id: 13,
|
||||||
name: "sg-edge",
|
name: "sg-edge",
|
||||||
country_code: "sg",
|
status: true,
|
||||||
last_active: "2025-01-01T00:00:00.000Z",
|
},
|
||||||
}),
|
],
|
||||||
]}
|
}}
|
||||||
now={now}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -149,14 +149,15 @@ describe("InteractiveMap", () => {
|
|||||||
width={900}
|
width={900}
|
||||||
height={500}
|
height={500}
|
||||||
filteredFeatures={filteredFeatures}
|
filteredFeatures={filteredFeatures}
|
||||||
nezhaServerList={[
|
countryServers={{
|
||||||
createServer({
|
US: [
|
||||||
|
{
|
||||||
id: 11,
|
id: 11,
|
||||||
name: "us-online",
|
name: "us-online",
|
||||||
country_code: "us",
|
status: true,
|
||||||
}),
|
},
|
||||||
]}
|
],
|
||||||
now={now}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -339,7 +339,28 @@ describe("Servers page", () => {
|
|||||||
lastData: websocketPayload([createServer({ id: 1, name: "alpha" })]),
|
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 () => {
|
it("toggles map and service tracker controls when service data exists", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user