fix(panel): 服务器地址改为一键复制并带上非默认游戏端口,玩家列表去掉无意义的连接地址列,管理员列改称内部地址
This commit is contained in:
20 files changed
+379
-59
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -34,5 +34,8 @@
|
||||
"reload_page": "刷新页面",
|
||||
"config_unavailable": "面板配置(/config.json)没有加载成功,这里显示的服务器地址和链接可能不对。刷新页面可重试。",
|
||||
"build_dev": "开发版",
|
||||
"open_menu": "打开菜单"
|
||||
"open_menu": "打开菜单",
|
||||
"copy_address": "复制地址 {{address}}",
|
||||
"address_copied": "已复制",
|
||||
"address_copy_failed": "已选中,请用系统菜单复制"
|
||||
}
|
||||
@@ -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": "你",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user