Unverified Commit f6d586ed authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 服务器列表每页 20 台,可按名称、状态或在线人数排序,轮询时行序不再跳动

parent 9910c787
Loading
Loading
Loading
Loading
+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
@@ -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",
+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
@@ -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": "状态",
+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";
@@ -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();
@@ -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));
  });
});
+62 −5
Changes for panel/src/pages/servers/ServersPage.tsx: 62 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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;
@@ -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);
@@ -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);
              }}
            >
              <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>
@@ -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 })