diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 411ae3e..1c14ecb 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -392,7 +392,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname), defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname), - resolvedVersion()) + cfg.Velocity.GamePort, resolvedVersion()) internalSrv := newAPIServer(*internalAddr, a.InternalHandler()) externalSrv := newAPIServer(cfg.Server.Listen, externalHandler) diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index a2c803b..c29e869 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -3091,6 +3091,8 @@ egress_mode = "${FELIS_EGRESS_MODE}" # into k3s by build_game_stack below, so setup never has to be told "build these first". login_image = "${FELIS_LIMBO_IMAGE}" lobby_image = "${FELIS_LOBBY_IMAGE}" +# The public port players connect on; the panel shows it in server addresses. +game_port = ${FELIS_GAME_PORT} [registry] url = "${REGISTRY_URL}" diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index 0c7a226..b9110c7 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -1412,7 +1412,7 @@ run_write() { # out-file persisted_auth_source_blocks() { :; } . "$FNFILE" FELIS_ROOT_DOMAIN=r.example.com DB_USER=u DB_PASSWORD=p DB_NAME=d MINECRAFT_NS=minecraft \ - FELIS_EGRESS_MODE=nodeport FELIS_LIMBO_IMAGE=li FELIS_LOBBY_IMAGE=lo \ + FELIS_EGRESS_MODE=nodeport FELIS_LIMBO_IMAGE=li FELIS_LOBBY_IMAGE=lo FELIS_GAME_PORT=25570 \ REGISTRY_URL=registry.felis.svc:5000 BUILD_NS=felis-build FELIS_ARCHIVE_LOCAL_PATH=/a \ FELIS_OFFSITE_BUCKET= write_felis_toml "$OUT_TOML" 127.0.0.1' } @@ -1435,6 +1435,7 @@ expect "a re-run carries the archive retention window" 'retention = "30d"' "$out expect "a re-run carries the on-demand backup count" 'manual_keep = 3' "$out" expect "a re-run carries the on-demand backup cooldown" 'manual_cooldown = "1h"' "$out" expect "the archive mount stays installer-owned" 'local_path = "/a"' "$out" +expect "the panel learns the public game port" 'game_port = 25570' "$out" expect "a re-run keeps the off-site bucket, set apart from the next section" '[offsite] endpoint = "https://objects.example" bucket = "felis-offsite" diff --git a/internal/config/config.go b/internal/config/config.go index d9a57d5..0edf8d7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -108,6 +108,10 @@ type VelocityConfig struct { // LobbyImage is the container image for the always-on "lobby" hub (Paper plus // the felis-paper /menu plugin). Same skip-when-empty contract as LoginImage. LobbyImage string `toml:"lobby_image"` + // GamePort is the public TCP port the proxy accepts players on (bootstrap's + // FELIS_GAME_PORT). The panel adds it to the server addresses players copy + // when it is not Minecraft's default; 0 means that default, 25565. + GamePort int `toml:"game_port"` } // AuthConfig is the [auth] table: the two privileged faces and the access-JWT @@ -459,6 +463,9 @@ func (c *Config) Validate() error { default: return fmt.Errorf("config: [registry] build_user_namespaces %q must be auto, on or off", c.Registry.BuildUserNamespaces) } + if p := c.Velocity.GamePort; p < 0 || p > 65535 { + return fmt.Errorf("config: [velocity] game_port %d must be 1-65535 (0 keeps 25565)", p) + } if n := c.Registry.MaxConcurrentBuilds; n < 0 || n > 6 { return fmt.Errorf("config: [registry] max_concurrent_builds %d must be 1-6 (0 keeps 2)", n) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 82db3dd..a7c66e1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -117,6 +117,25 @@ build_user_namespaces = "yes" } } +func TestLoadGamePort(t *testing.T) { + base := ` +[server] +root_domain = "mc.example.net" +[database] +url = "postgres://felis@db/felis" +[velocity] +` + cfg, err := config.Load(writeTOML(t, base+"game_port = 25570\n")) + if err != nil || cfg.Velocity.GamePort != 25570 { + t.Fatalf("game_port 25570: cfg=%+v err=%v", cfg, err) + } + for _, bad := range []string{"-1", "65536"} { + if _, err := config.Load(writeTOML(t, base+"game_port = "+bad+"\n")); err == nil || !strings.Contains(err.Error(), "game_port") { + t.Errorf("game_port %s: err = %v, want it rejected", bad, err) + } + } +} + func TestLoadAppliesDefaults(t *testing.T) { cfg, err := config.Load(writeTOML(t, ` [server] diff --git a/internal/panel/panel.go b/internal/panel/panel.go index 2c29ad4..820b223 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -20,11 +20,13 @@ import ( var static embed.FS type runtimeConfig struct { - APIBase string `json:"apiBase"` - RootDomain string `json:"rootDomain"` - PanelHostname string `json:"panelHostname,omitempty"` - AdminHostname string `json:"adminHostname,omitempty"` - Build buildInfo `json:"build"` + APIBase string `json:"apiBase"` + RootDomain string `json:"rootDomain"` + PanelHostname string `json:"panelHostname,omitempty"` + AdminHostname string `json:"adminHostname,omitempty"` + // GamePort is the public Minecraft port, absent when it is the default 25565. + GamePort int `json:"gamePort,omitempty"` + Build buildInfo `json:"build"` } // buildInfo is the resolved build stamp the panel renders in its version badge. @@ -107,8 +109,10 @@ func parseBuildVersion(raw string) buildInfo { // configured console. and op.console. hostnames (either // may be empty when that face is not deployed); they let the SPA detect which home // it is being served from by comparing location.host, so one bundle can render the -// right surface (player console vs SysAdmin console) without a rebuild. -func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string) http.Handler { +// right surface (player console vs SysAdmin console) without a rebuild. gamePort +// is the public Minecraft port ([velocity] game_port), which the SPA appends to +// the server addresses players copy; 0 or 25565 leaves them bare. +func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, version string) http.Handler { files, err := fs.Sub(static, "static") if err != nil { panic(err) @@ -118,6 +122,7 @@ func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string) rootDomain: rootDomain, panelHostname: panelHost, adminHostname: adminHost, + gamePort: publicGamePort(gamePort), build: parseBuildVersion(version), files: files, fileServer: http.FileServer(http.FS(files)), @@ -125,11 +130,24 @@ func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string) } } +// defaultGamePort is the port a Minecraft client dials when the address has none. +const defaultGamePort = 25565 + +// publicGamePort is the port the SPA should print, or 0 when a bare hostname +// already reaches the proxy. +func publicGamePort(p int) int { + if p == defaultGamePort { + return 0 + } + return p +} + type handler struct { api http.Handler rootDomain string panelHostname string adminHostname string + gamePort int build buildInfo files fs.FS fileServer http.Handler @@ -208,6 +226,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { RootDomain: h.rootDomain, PanelHostname: h.panelHostname, AdminHostname: h.adminHostname, + GamePort: h.gamePort, Build: h.build, }) case h.hasStaticFile(r.URL.Path): diff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go index fbe91b1..e805c3e 100644 --- a/internal/panel/panel_test.go +++ b/internal/panel/panel_test.go @@ -18,7 +18,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) { } w.WriteHeader(http.StatusTeapot) }) - h := Handler(api, "example.test", "console.example.test", "op.console.example.test", "v1.2.3") + h := Handler(api, "example.test", "console.example.test", "op.console.example.test", 0, "v1.2.3") w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) @@ -65,6 +65,27 @@ func TestHandlerServesPanelAndConfig(t *testing.T) { // carry. The "+g" form is what deploy/bootstrap.sh's dev channel links in, and // it is the one that regressed: before it was parsed, a dev build fell through to the // default case and the badge rendered the whole stamp as the release with no commit. +// The SPA appends the public game port to the addresses players copy, so +// /config.json carries it, except when a bare hostname already reaches the proxy. +func TestHandlerPublishesNonDefaultGamePort(t *testing.T) { + for _, tc := range []struct{ in, want int }{{0, 0}, {25565, 0}, {25570, 25570}} { + h := Handler(http.NotFoundHandler(), "example.test", "", "", tc.in, "v1.2.3") + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config.json", nil)) + var cfg map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil { + t.Fatalf("decode config: %v", err) + } + got, present := cfg["gamePort"] + switch { + case tc.want == 0 && present: + t.Errorf("game port %d: gamePort = %v, want absent", tc.in, got) + case tc.want != 0 && got != float64(tc.want): + t.Errorf("game port %d: gamePort = %v, want %d", tc.in, got, tc.want) + } + } +} + func TestParseBuildVersionSplitsBothStampForms(t *testing.T) { for _, tc := range []struct { raw string @@ -94,7 +115,7 @@ func TestParseBuildVersionSplitsBothStampForms(t *testing.T) { // its hash (and nothing else inline), cannot be framed, and cache by name: // hashed assets forever, the page itself never without revalidation. func TestHandlerSetsPageSecurityAndCacheHeaders(t *testing.T) { - h := Handler(http.NotFoundHandler(), "example.test", "", "", "v1.2.3") + h := Handler(http.NotFoundHandler(), "example.test", "", "", 0, "v1.2.3") w := httptest.NewRecorder() // Through the tunnel: TLS to the origin as well, the edge's scheme in XFP. diff --git a/internal/panel/webview_test.go b/internal/panel/webview_test.go index 83f0352..ef8f0fc 100644 --- a/internal/panel/webview_test.go +++ b/internal/panel/webview_test.go @@ -40,7 +40,7 @@ func newPanelHandler(t *testing.T) http.Handler { api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }) - return Handler(api, "example.test", "", "", "") + return Handler(api, "example.test", "", "", 0, "") } func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) { diff --git a/panel/e2e/smoke.spec.ts b/panel/e2e/smoke.spec.ts index 33e3f45..e832743 100644 --- a/panel/e2e/smoke.spec.ts +++ b/panel/e2e/smoke.spec.ts @@ -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); diff --git a/panel/src/components/CopyAddress.test.tsx b/panel/src/components/CopyAddress.test.tsx new file mode 100644 index 0000000..5ef8738 --- /dev/null +++ b/panel/src/components/CopyAddress.test.tsx @@ -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) { + 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(); + 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(); + + 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(); + + 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(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example" })); + }); + expect(window.getSelection()?.toString()).toBe("survival.mc.example"); + }); +}); diff --git a/panel/src/components/CopyAddress.tsx b/panel/src/components/CopyAddress.tsx new file mode 100644 index 0000000..a78865b --- /dev/null +++ b/panel/src/components/CopyAddress.tsx @@ -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(null); + const timer = useRef(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 ( + + + {address} + + + + {feedback} + + + ); +} diff --git a/panel/src/i18n/resources/en-US/common.json b/panel/src/i18n/resources/en-US/common.json index d1e292c..0e54dc9 100644 --- a/panel/src/i18n/resources/en-US/common.json +++ b/panel/src/i18n/resources/en-US/common.json @@ -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" } diff --git a/panel/src/i18n/resources/en-US/ops.json b/panel/src/i18n/resources/en-US/ops.json index 9fb0f5a..c7ce513 100644 --- a/panel/src/i18n/resources/en-US/ops.json +++ b/panel/src/i18n/resources/en-US/ops.json @@ -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", diff --git a/panel/src/i18n/resources/zh-CN/common.json b/panel/src/i18n/resources/zh-CN/common.json index 2078224..5b7dd44 100644 --- a/panel/src/i18n/resources/zh-CN/common.json +++ b/panel/src/i18n/resources/zh-CN/common.json @@ -34,5 +34,8 @@ "reload_page": "刷新页面", "config_unavailable": "面板配置(/config.json)没有加载成功,这里显示的服务器地址和链接可能不对。刷新页面可重试。", "build_dev": "开发版", - "open_menu": "打开菜单" + "open_menu": "打开菜单", + "copy_address": "复制地址 {{address}}", + "address_copied": "已复制", + "address_copy_failed": "已选中,请用系统菜单复制" } diff --git a/panel/src/i18n/resources/zh-CN/ops.json b/panel/src/i18n/resources/zh-CN/ops.json index db90f09..2ded303 100644 --- a/panel/src/i18n/resources/zh-CN/ops.json +++ b/panel/src/i18n/resources/zh-CN/ops.json @@ -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": "你", diff --git a/panel/src/lib/config.test.ts b/panel/src/lib/config.test.ts index 25503f9..d6e5f88 100644 --- a/panel/src/lib/config.test.ts +++ b/panel/src/lib/config.test.ts @@ -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(); + } + }); +}); diff --git a/panel/src/lib/config.ts b/panel/src/lib/config.ts index 70fa4a3..d1e15ec 100644 --- a/panel/src/lib/config.ts +++ b/panel/src/lib/config.ts @@ -22,6 +22,8 @@ export interface RuntimeConfig { panelHostname?: string; /** Operator-console hostname (op.console.), 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; @@ -73,6 +82,7 @@ export async function loadConfig(): Promise { 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 { 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; +} diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index 9069c00..2c835a8 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -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() { : undefined} actions={
diff --git a/panel/src/pages/servers/ServersPage.test.tsx b/panel/src/pages/servers/ServersPage.test.tsx index 72864e0..c01dd19 100644 --- a/panel/src/pages/servers/ServersPage.test.tsx +++ b/panel/src/pages/servers/ServersPage.test.tsx @@ -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: "admin@example.test" } })); @@ -13,7 +13,7 @@ vi.mock("@/lib/config", async (importActual) => { const actual = await importActual(); 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( + + + , + ); + + 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( + + + , + ); + + 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(); + }); +}); diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx index 7be3321..f74a78d 100644 --- a/panel/src/pages/servers/ServersPage.tsx +++ b/panel/src/pages/servers/ServersPage.tsx @@ -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. */} -
    + {/* 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. */} +
      {paged.map((s) => ( ))}
    - +
    @@ -378,7 +384,7 @@ export function ServersPage() { {isAdmin && } - + {isAdmin && } @@ -526,20 +532,6 @@ function ServerActions({ ); } -function HostLink({ host }: { host: string }) { - return ( - - {host} - - - ); -} - 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 ( @@ -620,7 +612,7 @@ function ServerRow({ - {host && } + {address && } {isAdmin && ( - + {isAdmin && ( + + )} @@ -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 ( -
  • - +
  • + -
    -
    +
    +
    - {host && } +
    - + {/* 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 && }
    {isAdmin && ( @@ -698,10 +694,14 @@ function ServerMobileCard({
    {t(POLICY_KEY[server.autostartPolicy])}
    )} -
    {t("fleet_col_endpoint")}
    -
    - -
    + {isAdmin && ( + <> +
    {t("fleet_col_endpoint")}
    +
    + +
    + + )}
  • {t("fleet_col_owner")}{t("fleet_col_players")} {t("fleet_col_policy")}{t("fleet_col_endpoint")}{t("fleet_col_endpoint")} {t("fleet_col_actions")}
    @@ -639,9 +631,11 @@ function ServerRow({ — )} - - + +