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 })