From f6d586ed0ca2c4e260e709864ff264e6884a0a9e Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 15:19:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=AF=8F=E9=A1=B5=2020=20=E5=8F=B0=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E6=8C=89=E5=90=8D=E7=A7=B0=E3=80=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=88=96=E5=9C=A8=E7=BA=BF=E4=BA=BA=E6=95=B0=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=EF=BC=8C=E8=BD=AE=E8=AF=A2=E6=97=B6=E8=A1=8C=E5=BA=8F=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=B7=B3=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- panel/src/i18n/resources/en-US/ops.json | 4 + panel/src/i18n/resources/zh-CN/ops.json | 4 + panel/src/pages/servers/ServersPage.test.tsx | 100 ++++++++++++++++++- panel/src/pages/servers/ServersPage.tsx | 67 ++++++++++++- 4 files changed, 169 insertions(+), 6 deletions(-) diff --git a/panel/src/i18n/resources/en-US/ops.json b/panel/src/i18n/resources/en-US/ops.json index 74a2a5e..b8e30b9 100644 --- a/panel/src/i18n/resources/en-US/ops.json +++ b/panel/src/i18n/resources/en-US/ops.json @@ -13,6 +13,10 @@ "fleet_search_placeholder": "Search name, subdomain or owner…", "fleet_filter_all": "All phases", "fleet_filter_phase": "Filter by phase", + "fleet_sort": "Sort order", + "fleet_sort_name": "Sort by name", + "fleet_sort_status": "Sort by status", + "fleet_sort_players": "Most players first", "fleet_count": "{{count}} servers", "fleet_count_filtered": "{{shown}} / {{total}} servers", "fleet_col_status": "Status", diff --git a/panel/src/i18n/resources/zh-CN/ops.json b/panel/src/i18n/resources/zh-CN/ops.json index 223d5b2..6688f78 100644 --- a/panel/src/i18n/resources/zh-CN/ops.json +++ b/panel/src/i18n/resources/zh-CN/ops.json @@ -13,6 +13,10 @@ "fleet_search_placeholder": "搜索名称、域名或所有者…", "fleet_filter_all": "全部状态", "fleet_filter_phase": "按状态筛选", + "fleet_sort": "排序方式", + "fleet_sort_name": "按名称排序", + "fleet_sort_status": "按状态排序", + "fleet_sort_players": "在线人数多的在前", "fleet_count": "共 {{count}} 台", "fleet_count_filtered": "{{shown}} / {{total}} 台", "fleet_col_status": "状态", diff --git a/panel/src/pages/servers/ServersPage.test.tsx b/panel/src/pages/servers/ServersPage.test.tsx index d344b84..33f4a62 100644 --- a/panel/src/pages/servers/ServersPage.test.tsx +++ b/panel/src/pages/servers/ServersPage.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import type { FleetServer, MyServerView } from "@/lib/types"; import { ServersPage } from "./ServersPage"; @@ -44,6 +45,37 @@ async function tableRow(name: string) { return within(tr); } +// Radix Select opens with pointer capture and scrolls the picked item into view, +// neither of which jsdom implements. +beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.releasePointerCapture ??= () => {}; + Element.prototype.scrollIntoView ??= () => {}; +}); + +// The labels of the desktop table's rows, top to bottom. +async function tableOrder() { + const table = await screen.findByRole("table"); + return within(table) + .getAllByRole("row") + .slice(1) + .map((tr) => tr.querySelector("td span.font-medium")?.textContent); +} + +function renderPage() { + render( + + + , + ); + return userEvent.setup(); +} + +async function sortBy(user: ReturnType, option: string) { + await user.click(screen.getByRole("combobox", { name: "Sort order" })); + await user.click(await screen.findByRole("option", { name: option })); +} + beforeEach(() => { tier.isAdmin = true; calls.fleet.mockReset(); @@ -243,3 +275,69 @@ describe("ServersPage retiring servers", () => { expect(survival.queryByRole("button", { name: /Wake/ })).toBeNull(); }); }); + +describe("ServersPage order and paging", () => { + it("lists by name, and on request by status or by players online", async () => { + calls.fleet.mockResolvedValue([ + row("survival", { phase: "Running", ready: true, playersOnline: 3 }), + row("creative", { phase: "Stopped" }), + row("skyblock", { phase: "Running", ready: true, playersOnline: 7 }), + // Ordered by the label it shows, and counted as starting once asked to wake. + row("lobby", { displayName: "Arcade", phase: "Stopped", desiredState: "Running" }), + row("node-10", { phase: "Failed" }), + row("node-2", { phase: "Failed" }), + ]); + const user = renderPage(); + + expect(await tableOrder()).toEqual(["Arcade", "creative", "node-2", "node-10", "skyblock", "survival"]); + + await sortBy(user, "Sort by status"); + expect(await tableOrder()).toEqual(["skyblock", "survival", "Arcade", "node-2", "node-10", "creative"]); + + await sortBy(user, "Most players first"); + expect(await tableOrder()).toEqual(["skyblock", "survival", "Arcade", "creative", "node-2", "node-10"]); + }); + + it("leads a search with the best match and ranks equal matches by the picked order", async () => { + calls.fleet.mockResolvedValue([ + row("my-surv", { phase: "Running", ready: true, playersOnline: 9 }), + row("survival-b", { phase: "Running", ready: true, playersOnline: 5 }), + row("survival-a", { phase: "Running", ready: true, playersOnline: 2 }), + ]); + const user = renderPage(); + await tableOrder(); + await user.type(screen.getByPlaceholderText(/Search/), "surv"); + + // "surv" opens both survival names and sits inside my-surv, which ranks it below. + expect(await tableOrder()).toEqual(["survival-a", "survival-b", "my-surv"]); + await sortBy(user, "Most players first"); + expect(await tableOrder()).toEqual(["survival-b", "survival-a", "my-surv"]); + await user.clear(screen.getByPlaceholderText(/Search/)); + expect(await tableOrder()).toEqual(["my-surv", "survival-b", "survival-a"]); + }); + + it("keeps servers that show the same label in name order", async () => { + // The fleet comes off an informer cache, which lists in no fixed order. + calls.fleet.mockResolvedValue([ + row("zeta", { displayName: "Survival" }), + row("alpha", { displayName: "Survival" }), + ]); + renderPage(); + const table = await screen.findByRole("table"); + expect(within(table).getAllByText(/^(alpha|zeta)$/).map((e) => e.textContent)).toEqual(["alpha", "zeta"]); + }); + + it("shows twenty servers to a page", async () => { + const names = Array.from({ length: 21 }, (_, i) => `srv-${String(i + 1).padStart(2, "0")}`); + calls.fleet.mockResolvedValue(names.map((n) => row(n, {}))); + const user = renderPage(); + + expect(await tableOrder()).toEqual(names.slice(0, 20)); + expect(screen.getByText("Page 1 of 2")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: /Next/ })); + expect(await tableOrder()).toEqual(["srv-21"]); + // A new order starts again from its first page. + await sortBy(user, "Sort by status"); + expect(await tableOrder()).toEqual(names.slice(0, 20)); + }); +}); diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx index 27add7c..ff7e05b 100644 --- a/panel/src/pages/servers/ServersPage.tsx +++ b/panel/src/pages/servers/ServersPage.tsx @@ -67,7 +67,43 @@ function isLive(phase: Phase): boolean { return phase === "Running" || phase === "Starting" || phase === "Stopping"; } -const PAGE_SIZE = 6; +// Twenty to a page: an even count fills the two-column cards, and a fleet of a +// dozen or so needs no paging at all. +const PAGE_SIZE = 20; + +type SortKey = "name" | "status" | "players"; + +const SORT_KEYS: SortKey[] = ["name", "status", "players"]; + +const SORT_LABEL: Record = { + name: "fleet_sort_name", + status: "fleet_sort_status", + players: "fleet_sort_players", +}; + +const labelOrder = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +function byName(a: UnifiedServer, b: UnifiedServer): number { + return ( + labelOrder.compare(a.displayName || a.name, b.displayName || b.name) || + (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) + ); +} + +/** compareBy orders servers by the picked key, then by name. The name settles + * every tie, so a list that rereads every few seconds keeps each row in place + * until the key itself changes. Status runs live to idle, the order of the + * phase filter. */ +function compareBy(key: SortKey): (a: UnifiedServer, b: UnifiedServer) => number { + switch (key) { + case "status": + return (a, b) => PHASES.indexOf(shownPhase(a)) - PHASES.indexOf(shownPhase(b)) || byName(a, b); + case "players": + return (a, b) => b.playersOnline - a.playersOnline || byName(a, b); + default: + return byName; + } +} interface UnifiedServer { name: string; @@ -107,6 +143,7 @@ export function ServersPage() { const [query, setQuery] = useState(""); const [phaseFilter, setPhaseFilter] = useState("all"); + const [sort, setSort] = useState("name"); const [page, setPage] = useState(1); usePolling(reload, REFRESH_MS); @@ -181,16 +218,18 @@ export function ServersPage() { const visible = useMemo(() => { const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const phaseOk = (s: UnifiedServer) => phaseFilter === "all" || shownPhase(s) === phaseFilter; - if (terms.length === 0) return servers.filter(phaseOk); + const order = compareBy(sort); + if (terms.length === 0) return servers.filter(phaseOk).sort(order); const scored: { s: UnifiedServer; score: number }[] = []; for (const s of servers) { if (!phaseOk(s)) continue; const score = matchScore([s.name, s.displayName ?? "", s.subdomain ?? "", s.owner ?? ""], terms); if (score >= 0) scored.push({ s, score }); } - scored.sort((a, b) => b.score - a.score); + // A search leads with the best match; the picked order ranks equal matches. + scored.sort((a, b) => b.score - a.score || order(a.s, b.s)); return scored.map((x) => x.s); - }, [servers, query, phaseFilter]); + }, [servers, query, phaseFilter, sort]); const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages); @@ -315,7 +354,7 @@ export function ServersPage() { setPage(1); }} > - + @@ -327,6 +366,24 @@ export function ServersPage() { ))} + {visible.length === servers.length ? t("fleet_count", { count: servers.length })