fix(panel): 服务器地址改为一键复制并带上非默认游戏端口,玩家列表去掉无意义的连接地址列,管理员列改称内部地址

This commit is contained in:
Lemon-miaow committed 2026-09-25 12:51:16 +08:00
1 parent bb9c2168df
commit f156e385f0
20 files changed
+379 -59

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ test("a player sees their own server, can claim an unowned one, and has no admin
await expect(own.getByRole("link", { name: t("servers:console") })).toBeVisible();
await expect(own.getByRole("button", { name: t("servers:stop"), exact: true })).toBeVisible();
const unowned = page.getByRole("row").filter({ hasText: "Claimable Node" });
await expect(unowned.getByRole("button", { name: t("servers:claim") })).toBeVisible();
await expect(unowned.getByRole("button", { name: t("servers:claim"), exact: true })).toBeVisible();
await expect(unowned.getByRole("button", { name: t("servers:stop"), exact: true })).toHaveCount(0);
await expect(page.getByRole("link", { name: t("navigation:admin_users") })).toHaveCount(0);
+62
View File
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { CopyAddress } from "./CopyAddress";
function stubClipboard(writeText: (text: string) => Promise<void>) {
const fn = vi.fn(writeText);
Object.defineProperty(navigator, "clipboard", { value: { writeText: fn }, configurable: true });
return fn;
}
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
window.getSelection()?.removeAllRanges();
});
describe("CopyAddress", () => {
it("shows the address as text, never as a web link", () => {
render(<CopyAddress address="survival.mc.example:25570" />);
expect(screen.getByText("survival.mc.example:25570")).toBeTruthy();
expect(screen.queryByRole("link")).toBeNull();
});
it("copies the address and says so, then settles back", async () => {
vi.useFakeTimers();
const writeText = stubClipboard(async () => {});
render(<CopyAddress address="survival.mc.example:25570" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example:25570" }));
});
expect(writeText).toHaveBeenCalledWith("survival.mc.example:25570");
expect(screen.getByRole("status").textContent).toBe("Copied");
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByRole("status").textContent).toBe("");
});
it("selects the address for the system menu when the clipboard is refused", async () => {
stubClipboard(async () => {
throw new DOMException("denied", "NotAllowedError");
});
render(<CopyAddress address="survival.mc.example" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example" }));
});
expect(window.getSelection()?.toString()).toBe("survival.mc.example");
expect(screen.getByRole("status").textContent).toBe("Selected — copy it from the menu");
});
it("selects the address where there is no clipboard at all", async () => {
render(<CopyAddress address="survival.mc.example" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example" }));
});
expect(window.getSelection()?.toString()).toBe("survival.mc.example");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const FEEDBACK_MS = 2000;
// CopyAddress shows the address a Minecraft client joins and copies it with one
// tap. Minecraft is not a web page, so the address is never a link. When the
// clipboard is refused (an insecure origin, an embedded browser) the text is
// selected instead, ready for the system copy menu.
export function CopyAddress({ address, className }: { address: string; className?: string }) {
const { t } = useTranslation("common");
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
const textRef = useRef<HTMLElement>(null);
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
function selectText() {
const node = textRef.current;
const selection = window.getSelection();
if (!node || !selection) return;
const range = document.createRange();
range.selectNodeContents(node);
selection.removeAllRanges();
selection.addRange(range);
}
async function copy() {
window.clearTimeout(timer.current);
try {
// Without a clipboard (an insecure origin) this throws, and lands below too.
await navigator.clipboard.writeText(address);
setState("copied");
} catch {
selectText();
setState("failed");
}
timer.current = window.setTimeout(() => setState("idle"), FEEDBACK_MS);
}
const feedback =
state === "copied" ? t("address_copied") : state === "failed" ? t("address_copy_failed") : "";
// After a refused copy the whole address shows, selected, with the hint on its
// own line; truncated, the selection would hide what the menu is about to copy.
const failed = state === "failed";
return (
<span
className={cn(
"inline-flex min-w-0 max-w-full flex-wrap items-center gap-x-1 text-xs",
className,
)}
>
<code
ref={textRef}
className={cn(
"min-w-0 select-all font-mono text-muted-foreground",
failed ? "break-all" : "truncate",
)}
title={address}
>
{address}
</code>
<button
type="button"
onClick={() => void copy()}
aria-label={t("copy_address", { address })}
title={t("copy_address", { address })}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{state === "copied" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
<span
role="status"
className={cn(
"shrink-0",
failed ? "basis-full" : "whitespace-nowrap",
state === "copied" ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
>
{feedback}
</span>
</span>
);
}
+4 -1
View File
@@ -34,5 +34,8 @@
"reload_page": "Reload page",
"config_unavailable": "The panel configuration (/config.json) did not load, so server addresses and links shown here may be wrong. Reload to try again.",
"build_dev": "dev",
"open_menu": "Open menu"
"open_menu": "Open menu",
"copy_address": "Copy address {{address}}",
"address_copied": "Copied",
"address_copy_failed": "Selected — copy it from the menu"
}
+1 -1
View File
@@ -20,7 +20,7 @@
"fleet_col_owner": "Owner",
"fleet_col_players": "Players",
"fleet_col_policy": "Autostart",
"fleet_col_endpoint": "Endpoint",
"fleet_col_endpoint": "Internal address",
"fleet_col_actions": "Actions",
"fleet_unclaimed": "Unclaimed",
"fleet_owner_you": "You",
+4 -1
View File
@@ -34,5 +34,8 @@
"reload_page": "刷新页面",
"config_unavailable": "面板配置(/config.json)没有加载成功,这里显示的服务器地址和链接可能不对。刷新页面可重试。",
"build_dev": "开发版",
"open_menu": "打开菜单"
"open_menu": "打开菜单",
"copy_address": "复制地址 {{address}}",
"address_copied": "已复制",
"address_copy_failed": "已选中,请用系统菜单复制"
}
+1 -1
View File
@@ -20,7 +20,7 @@
"fleet_col_owner": "所有者",
"fleet_col_players": "在线",
"fleet_col_policy": "自启策略",
"fleet_col_endpoint": "连接地址",
"fleet_col_endpoint": "内部地址",
"fleet_col_actions": "操作",
"fleet_unclaimed": "未认领",
"fleet_owner_you": "你",
+27
View File
@@ -105,3 +105,30 @@ describe("versionLabel", () => {
expect(versionLabel({ version: "dev", release: "dev", dev: true })).toBe("dev");
});
});
describe("joinAddress", () => {
const base = { apiBase: "/api/v1", rootDomain: "mc.example" };
it("is the bare hostname on the default port", async () => {
const { joinAddress } = await freshConfig();
expect(joinAddress("survival", base)).toBe("survival.mc.example");
expect(joinAddress("survival", { ...base, gamePort: 25565 })).toBe("survival.mc.example");
});
it("adds a port the proxy moved off the default", async () => {
const { joinAddress } = await freshConfig();
expect(joinAddress("survival", { ...base, gamePort: 25570 })).toBe("survival.mc.example:25570");
});
it("reads the port from config.json and drops one no client could dial", async () => {
serve({ apiBase: "/api/v1", rootDomain: "mc.example", gamePort: 25570 });
let mod = await freshConfig();
expect((await mod.loadConfig()).gamePort).toBe(25570);
for (const bad of [0, 70000, "25570", 1.5]) {
serve({ apiBase: "/api/v1", rootDomain: "mc.example", gamePort: bad });
mod = await freshConfig();
expect((await mod.loadConfig()).gamePort).toBeUndefined();
}
});
});
+17
View File
@@ -22,6 +22,8 @@ export interface RuntimeConfig {
panelHostname?: string;
/** Operator-console hostname (op.console.<root>), absent when unconfigured. */
adminHostname?: string;
/** The public Minecraft port, absent when it is the default 25565. */
gamePort?: number;
/** What the server runs, for the version badge. */
build?: BuildInfo;
/** /config.json could not be read, so these are build-time defaults and
@@ -35,6 +37,13 @@ const FALLBACK: RuntimeConfig = {
fallback: true,
};
// A port that could not be dialed is dropped; the bare hostname is the safer guess.
function parsePort(raw: unknown): number | undefined {
return Number.isInteger(raw) && (raw as number) > 0 && (raw as number) <= 65535
? (raw as number)
: undefined;
}
function parseBuild(raw: unknown): BuildInfo | undefined {
if (!raw || typeof raw !== "object") return undefined;
const b = raw as Partial<BuildInfo>;
@@ -73,6 +82,7 @@ export async function loadConfig(): Promise<RuntimeConfig> {
rootDomain: raw.rootDomain,
panelHostname: raw.panelHostname,
adminHostname: raw.adminHostname,
gamePort: parsePort(raw.gamePort),
build: parseBuild(raw.build),
};
} catch (err) {
@@ -87,3 +97,10 @@ export async function loadConfig(): Promise<RuntimeConfig> {
export function hostFor(subdomain: string, cfg: RuntimeConfig): string {
return `${subdomain}.${cfg.rootDomain}`;
}
/** joinAddress is what a player types into Minecraft to reach a server: its
* hostname, with the port only when the proxy listens off the default 25565. */
export function joinAddress(subdomain: string, cfg: RuntimeConfig): string {
const host = hostFor(subdomain, cfg);
return cfg.gamePort && cfg.gamePort !== 25565 ? `${host}:${cfg.gamePort}` : host;
}
+3 -2
View File
@@ -12,7 +12,8 @@ import { api, consoleStreamURL, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { canManage } from "@/lib/ownership";
import { hostFor } from "@/lib/config";
import { joinAddress } from "@/lib/config";
import { CopyAddress } from "@/components/CopyAddress";
import type { Phase, AutostartPolicy } from "@/lib/types";
import { EditServerDialog } from "@/components/EditServerDialog";
import { PowerButton } from "@/components/PowerButton";
@@ -223,7 +224,7 @@ export function ServerConsole() {
<PageHeader
icon={Terminal}
title={data.displayName || data.name}
subtitle={cfg ? hostFor(data.subdomain, cfg) : undefined}
subtitle={cfg ? <CopyAddress address={joinAddress(data.subdomain, cfg)} /> : undefined}
actions={
<div className="flex items-center gap-2">
<PhaseBadge phase={data.phase} />
+49 -2
View File
@@ -2,7 +2,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { FleetServer } from "@/lib/types";
import type { FleetServer, MyServerView } from "@/lib/types";
import { ServersPage } from "./ServersPage";
const tier = vi.hoisted(() => ({ isAdmin: true, identity: { email: "[email protected]" } }));
@@ -13,7 +13,7 @@ vi.mock("@/lib/config", async (importActual) => {
const actual = await importActual<typeof import("@/lib/config")>();
return {
...actual,
loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test" }),
loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gamePort: 25570 }),
};
});
vi.mock("@/lib/api", async (importActual) => {
@@ -45,6 +45,7 @@ async function tableRow(name: string) {
}
beforeEach(() => {
tier.isAdmin = true;
calls.fleet.mockReset();
calls.myServers.mockReset();
});
@@ -92,3 +93,49 @@ describe("ServersPage fleet ownership", () => {
expect(survival.queryByRole("button", { name: /Claim/ })).toBeNull();
});
});
describe("ServersPage addresses", () => {
it("offers a player the join address to copy and no internal address", async () => {
tier.isAdmin = false;
const mine: MyServerView = {
name: "survival",
subdomain: "survival",
owned: true,
claimable: false,
phase: "Running",
playersOnline: 3,
playersMax: 20,
};
calls.myServers.mockResolvedValue([mine]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const table = await screen.findByRole("table");
expect(within(table).queryByText("Internal address")).toBeNull();
// The player's view has no endpoint, so a column for it could only say "Not running".
expect(within(table).queryByText("Not running")).toBeNull();
const survival = await tableRow("survival");
expect(survival.getByRole("button", { name: "Copy address survival.example.test:25570" })).toBeTruthy();
expect(survival.queryByRole("link", { name: /survival\.example\.test/ })).toBeNull();
});
it("keeps the proxy's internal address for admins", async () => {
calls.fleet.mockResolvedValue([
row("survival", { phase: "Running", ready: true, endpointAddress: "10.43.0.10:25565" }),
]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const table = await screen.findByRole("table");
expect(within(table).getByText("Internal address")).toBeTruthy();
const survival = await tableRow("survival");
expect(survival.getByText("10.43.0.10:25565")).toBeTruthy();
expect(survival.getByRole("button", { name: "Copy address survival.example.test:25570" })).toBeTruthy();
});
});
+38 -38
View File
@@ -4,7 +4,6 @@ import {
Network,
Play,
Terminal,
ExternalLink,
RefreshCw,
Server,
Users,
@@ -37,11 +36,12 @@ import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { CreateServerDialog } from "@/components/CreateServerDialog";
import { StatCard } from "@/components/StatCard";
import { CopyAddress } from "@/components/CopyAddress";
import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import { joinAddress, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy";
import type { AutostartPolicy, FleetServer, Phase, MyServerView } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -355,10 +355,11 @@ export function ServersPage() {
/>
) : (
<>
{/* Cards below 2xl (two per row from md); the admin table needs
about 1100px of content width for all its columns, which the
page only has from 2xl beside the sidebar. */}
<ul className="grid gap-3 md:grid-cols-2 2xl:hidden">
{/* Cards until the table fits (two per row from md). The admin
table needs about 1100px of content width for all its columns,
which the page only has from 2xl beside the sidebar; the
player's four columns fit from xl. */}
<ul className={cn("grid gap-3 md:grid-cols-2", isAdmin ? "2xl:hidden" : "xl:hidden")}>
{paged.map((s) => (
<ServerMobileCard
key={s.name}
@@ -369,7 +370,12 @@ export function ServersPage() {
/>
))}
</ul>
<Card className="hidden overflow-hidden border border-border/80 2xl:block">
<Card
className={cn(
"hidden overflow-hidden border border-border/80",
isAdmin ? "2xl:block" : "xl:block",
)}
>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
@@ -378,7 +384,7 @@ export function ServersPage() {
{isAdmin && <th className="px-4 py-2.5 font-medium">{t("fleet_col_owner")}</th>}
<th className="px-4 py-2.5 font-medium">{t("fleet_col_players")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_policy")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th>
{isAdmin && <th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th>}
<th className="px-4 py-2.5 text-right font-medium">
{t("fleet_col_actions")}
</th>
@@ -526,20 +532,6 @@ function ServerActions({
);
}
function HostLink({ host }: { host: string }) {
return (
<a
href={`https://${host}`}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 max-w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground md:max-w-[15rem]"
>
<span className="truncate">{host}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
);
}
function OwnerLabel({ server }: { server: UnifiedServer }) {
const { t } = useTranslation("ops");
const { t: ts } = useTranslation("servers");
@@ -611,7 +603,7 @@ function ServerRow({
onChanged: () => void;
}) {
const { t } = useTranslation("ops");
const host = server.subdomain ? hostFor(server.subdomain, cfg) : "";
const address = server.subdomain ? joinAddress(server.subdomain, cfg) : "";
return (
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40">
@@ -620,7 +612,7 @@ function ServerRow({
<ServerName server={server} />
<PhaseBadge phase={server.phase} />
</div>
{host && <HostLink host={host} />}
{address && <CopyAddress address={address} className="md:max-w-[16rem]" />}
</td>
{isAdmin && (
<td className="px-4 py-3 align-middle text-left">
@@ -639,9 +631,11 @@ function ServerRow({
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-3 align-middle text-left">
<EndpointLabel server={server} />
</td>
{isAdmin && (
<td className="px-4 py-3 align-middle text-left">
<EndpointLabel server={server} />
</td>
)}
<td className="px-4 py-3 align-middle">
<ServerActions server={server} isAdmin={isAdmin} onChanged={onChanged} />
</td>
@@ -664,20 +658,22 @@ function ServerMobileCard({
onChanged: () => void;
}) {
const { t } = useTranslation("ops");
const host = server.subdomain ? hostFor(server.subdomain, cfg) : "";
const address = server.subdomain ? joinAddress(server.subdomain, cfg) : "";
return (
<li className="flex">
<Card className="flex flex-1 flex-col border border-border/80">
<li className="flex min-w-0">
<Card className="flex min-w-0 flex-1 flex-col border border-border/80">
<CardContent className="flex flex-1 flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="space-y-1">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-2">
<ServerName server={server} />
</div>
{host && <HostLink host={host} />}
<PhaseBadge phase={server.phase} />
</div>
<PhaseBadge phase={server.phase} />
{/* Its own line: the join address is what a player came for, so it
gets the card's full width rather than sharing it with the badge. */}
{address && <CopyAddress address={address} />}
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
{isAdmin && (
@@ -698,10 +694,14 @@ function ServerMobileCard({
<dd className="text-xs">{t(POLICY_KEY[server.autostartPolicy])}</dd>
</>
)}
<dt className="text-xs text-muted-foreground">{t("fleet_col_endpoint")}</dt>
<dd className="min-w-0">
<EndpointLabel server={server} />
</dd>
{isAdmin && (
<>
<dt className="text-xs text-muted-foreground">{t("fleet_col_endpoint")}</dt>
<dd className="min-w-0">
<EndpointLabel server={server} />
</dd>
</>
)}
</dl>
<div className="mt-auto border-t border-border/50 pt-3">
<ServerActions server={server} isAdmin={isAdmin} onChanged={onChanged} />