Loading panel/src/i18n/resources/en-US/ops.json +4 −0 Changes for panel/src/i18n/resources/en-US/ops.json: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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", Loading panel/src/i18n/resources/zh-CN/ops.json +4 −0 Changes for panel/src/i18n/resources/zh-CN/ops.json: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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": "状态", Loading panel/src/pages/servers/ServersPage.test.tsx +99 −1 Changes for panel/src/pages/servers/ServersPage.test.tsx: 99 added lines, 1 removed line. Original line number Diff line number Diff line // @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"; Loading Loading @@ -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( <MemoryRouter> <ServersPage /> </MemoryRouter>, ); return userEvent.setup(); } async function sortBy(user: ReturnType<typeof userEvent.setup>, 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(); Loading Loading @@ -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)); }); }); panel/src/pages/servers/ServersPage.tsx +62 −5 Changes for panel/src/pages/servers/ServersPage.tsx: 62 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -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<SortKey, string> = { 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; Loading Loading @@ -107,6 +143,7 @@ export function ServersPage() { const [query, setQuery] = useState(""); const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all"); const [sort, setSort] = useState<SortKey>("name"); const [page, setPage] = useState(1); usePolling(reload, REFRESH_MS); Loading Loading @@ -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); Loading Loading @@ -315,7 +354,7 @@ export function ServersPage() { setPage(1); }} > <SelectTrigger className="w-44" aria-label={t("fleet_filter_phase")}> <SelectTrigger className="w-[calc(50%-0.375rem)] sm:w-44" aria-label={t("fleet_filter_phase")}> <SelectValue /> </SelectTrigger> <SelectContent> Loading @@ -327,6 +366,24 @@ export function ServersPage() { ))} </SelectContent> </Select> <Select value={sort} onValueChange={(v) => { setSort(v as SortKey); setPage(1); }} > <SelectTrigger className="w-[calc(50%-0.375rem)] sm:w-44" aria-label={t("fleet_sort")}> <SelectValue /> </SelectTrigger> <SelectContent> {SORT_KEYS.map((k) => ( <SelectItem key={k} value={k}> {t(SORT_LABEL[k])} </SelectItem> ))} </SelectContent> </Select> <span className="shrink-0 text-xs text-muted-foreground"> {visible.length === servers.length ? t("fleet_count", { count: servers.length }) Loading Loading
panel/src/i18n/resources/en-US/ops.json +4 −0 Changes for panel/src/i18n/resources/en-US/ops.json: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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", Loading
panel/src/i18n/resources/zh-CN/ops.json +4 −0 Changes for panel/src/i18n/resources/zh-CN/ops.json: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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": "状态", Loading
panel/src/pages/servers/ServersPage.test.tsx +99 −1 Changes for panel/src/pages/servers/ServersPage.test.tsx: 99 added lines, 1 removed line. Original line number Diff line number Diff line // @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"; Loading Loading @@ -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( <MemoryRouter> <ServersPage /> </MemoryRouter>, ); return userEvent.setup(); } async function sortBy(user: ReturnType<typeof userEvent.setup>, 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(); Loading Loading @@ -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)); }); });
panel/src/pages/servers/ServersPage.tsx +62 −5 Changes for panel/src/pages/servers/ServersPage.tsx: 62 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -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<SortKey, string> = { 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; Loading Loading @@ -107,6 +143,7 @@ export function ServersPage() { const [query, setQuery] = useState(""); const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all"); const [sort, setSort] = useState<SortKey>("name"); const [page, setPage] = useState(1); usePolling(reload, REFRESH_MS); Loading Loading @@ -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); Loading Loading @@ -315,7 +354,7 @@ export function ServersPage() { setPage(1); }} > <SelectTrigger className="w-44" aria-label={t("fleet_filter_phase")}> <SelectTrigger className="w-[calc(50%-0.375rem)] sm:w-44" aria-label={t("fleet_filter_phase")}> <SelectValue /> </SelectTrigger> <SelectContent> Loading @@ -327,6 +366,24 @@ export function ServersPage() { ))} </SelectContent> </Select> <Select value={sort} onValueChange={(v) => { setSort(v as SortKey); setPage(1); }} > <SelectTrigger className="w-[calc(50%-0.375rem)] sm:w-44" aria-label={t("fleet_sort")}> <SelectValue /> </SelectTrigger> <SelectContent> {SORT_KEYS.map((k) => ( <SelectItem key={k} value={k}> {t(SORT_LABEL[k])} </SelectItem> ))} </SelectContent> </Select> <span className="shrink-0 text-xs text-muted-foreground"> {visible.length === servers.length ? t("fleet_count", { count: servers.length }) Loading