Loading internal/naming/panel_mirror_test.go 0 → 100644 +43 −0 Changes for internal/naming/panel_mirror_test.go: 43 added lines, 0 removed lines. Original line number Diff line number Diff line package naming import ( "os" "regexp" "slices" "strings" "testing" ) // The panel's create form checks names as they are typed with its own copy of // this rule (panel/src/lib/naming.ts). A name reserved or a length changed here // without the panel would let the form send what the API refuses, or refuse what // it takes, so the copy is compared to the source. func TestPanelMirrorsServerNameRule(t *testing.T) { raw, err := os.ReadFile("../../panel/src/lib/naming.ts") if err != nil { t.Fatal(err) } src := string(raw) list := regexp.MustCompile(`(?s)RESERVED_SERVER_NAMES: readonly string\[\] = \[(.*?)\];`).FindStringSubmatch(src) if list == nil { t.Fatal("panel/src/lib/naming.ts: RESERVED_SERVER_NAMES not found") } var panel []string for _, m := range regexp.MustCompile(`"([^"]*)"`).FindAllStringSubmatch(list[1], -1) { panel = append(panel, m[1]) } var api []string for name := range reserved { api = append(api, name) } slices.Sort(panel) slices.Sort(api) if !slices.Equal(panel, api) { t.Errorf("panel reserves %v, API reserves %v", panel, api) } if want := "const SERVER_NAME_RE = /" + serverNameRE.String() + "/;"; !strings.Contains(src, want) { t.Errorf("panel/src/lib/naming.ts lacks %q", want) } } panel/src/components/CreateServerDialog.test.tsx 0 → 100644 +181 −0 Changes for panel/src/components/CreateServerDialog.test.tsx: 181 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom 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 { CreateServerDialog } from "./CreateServerDialog"; import { humanizeError } from "@/lib/api"; import type { CreateServerRequest, WhitelistImage } from "@/lib/types"; const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn() })); vi.mock("@/lib/api", async (importActual) => { const actual = await importActual<typeof import("@/lib/api")>(); return { ...actual, api: { ...actual.api, ...calls } }; }); // 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 ??= () => {}; }); const PAPER = "registry.example.test/paper:1.21"; const RETIRED = "registry.example.test/paper:1.20"; const cfg = { apiBase: "/api/v1", rootDomain: "example.test", gamePort: 25570 }; const onCreated = vi.fn(); function image(image_ref: string, enabled: boolean): WhitelistImage { return { image_ref, enabled } as WhitelistImage; } beforeEach(() => { calls.listImages.mockReset(); calls.createServer.mockReset(); onCreated.mockReset(); calls.listImages.mockResolvedValue([image(PAPER, true), image(RETIRED, false)]); calls.createServer.mockResolvedValue({}); }); async function openDialog() { const user = userEvent.setup(); render(<CreateServerDialog cfg={cfg} onCreated={onCreated} />); await user.click(screen.getByRole("button", { name: "New server" })); await screen.findByRole("dialog"); return user; } const nameBox = () => screen.getByLabelText("Name") as HTMLInputElement; const subdomainBox = () => screen.getByLabelText("Subdomain") as HTMLInputElement; const create = () => screen.getByRole("button", { name: "Create" }) as HTMLButtonElement; async function pick(user: ReturnType<typeof userEvent.setup>, field: string, option: string) { await user.click(screen.getByRole("combobox", { name: field })); await user.click(await screen.findByRole("option", { name: option })); } /** fillValid fills every required field with what the API takes. */ async function fillValid(user: ReturnType<typeof userEvent.setup>) { await user.type(nameBox(), "survival"); await user.type(subdomainBox(), "survival"); await pick(user, "Image", PAPER); } function sent(): CreateServerRequest { expect(calls.createServer).toHaveBeenCalledTimes(1); return calls.createServer.mock.calls[0][0]; } describe("CreateServerDialog request body", () => { it("sends every field as chosen, lowercased and trimmed", async () => { const user = await openDialog(); await user.type(nameBox(), "Survival-2"); await user.type(subdomainBox(), "SMP"); await user.type(screen.getByLabelText("Display name (optional)"), " Survival World "); await pick(user, "Image", PAPER); await pick(user, "Memory", "8Gi"); await pick(user, "Storage", "20Gi"); await pick(user, "Autostart policy", "Public — any player join wakes it"); await user.click(create()); expect(sent()).toStrictEqual({ name: "survival-2", subdomain: "smp", displayName: "Survival World", image: PAPER, memory: "8Gi", storage: "20Gi", autostartPolicy: "public", }); expect(onCreated).toHaveBeenCalledTimes(1); expect(screen.queryByRole("dialog")).toBeNull(); }); it("leaves a blank display name out, and sends the default sizes and policy", async () => { const user = await openDialog(); await fillValid(user); await user.type(screen.getByLabelText("Display name (optional)"), " "); await user.click(create()); expect(sent()).toStrictEqual({ name: "survival", subdomain: "survival", displayName: undefined, image: PAPER, memory: "4Gi", storage: "10Gi", autostartPolicy: "ownerOnly", }); }); it("offers only the enabled images", async () => { const user = await openDialog(); await user.click(screen.getByRole("combobox", { name: "Image" })); const options = await screen.findAllByRole("option"); expect(options.map((o) => o.textContent)).toEqual([PAPER]); }); it("keeps the form and says why when the API refuses, then creates on the next try", async () => { const refusal = { status: 409, code: "conflict", message: "server survival already exists" }; calls.createServer.mockRejectedValueOnce(refusal); const user = await openDialog(); await fillValid(user); await user.click(create()); const dialog = screen.getByRole("dialog"); expect(within(dialog).getByText(humanizeError(refusal))).toBeTruthy(); expect(nameBox().value).toBe("survival"); expect(onCreated).not.toHaveBeenCalled(); await user.click(create()); expect(calls.createServer).toHaveBeenCalledTimes(2); expect(onCreated).toHaveBeenCalledTimes(1); }); }); describe("CreateServerDialog names", () => { it.each([ ["ab", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["-survival", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["survival-", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["my_world", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["a".repeat(33), "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["lobby", "“lobby” is kept for the platform. Pick another."], ])("refuses the name %j before sending, and says why", async (name, why) => { const user = await openDialog(); await fillValid(user); expect(create().disabled).toBe(false); await user.clear(nameBox()); await user.type(nameBox(), name); expect(create().disabled).toBe(true); expect(nameBox().getAttribute("aria-invalid")).toBe("true"); expect(screen.getByText(why)).toBeTruthy(); }); it("holds the subdomain to the same rule", async () => { const user = await openDialog(); await fillValid(user); await user.clear(subdomainBox()); await user.type(subdomainBox(), "api"); expect(create().disabled).toBe(true); expect(screen.getByText("“api” is kept for the platform. Pick another.")).toBeTruthy(); expect(screen.queryByText(/Will be reachable at/)).toBeNull(); }); it("shows where a good subdomain will be reachable", async () => { const user = await openDialog(); await user.type(subdomainBox(), "smp"); expect(screen.getByText(/Will be reachable at smp\.example\.test/)).toBeTruthy(); expect(subdomainBox().getAttribute("aria-invalid")).toBe("false"); }); }); panel/src/components/CreateServerDialog.tsx +24 −9 Changes for panel/src/components/CreateServerDialog.tsx: 24 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { hostFor, type RuntimeConfig } from "@/lib/config"; import { serverNameIssue, type ServerNameIssue } from "@/lib/naming"; import type { AutostartPolicy, CreateServerRequest } from "@/lib/types"; import { InlineError } from "@/components/MessageLine"; Loading @@ -35,8 +36,6 @@ import { InlineError } from "@/components/MessageLine"; const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"]; const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"]; const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/; function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] { return [ { value: "ownerOnly", label: t("create_server_policy_owner") }, Loading Loading @@ -72,10 +71,11 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { } const enabledImages = (images.data ?? []).filter((i) => i.enabled); const subdomainValid = SUBDOMAIN_RE.test(form.subdomain); const nameIssue = serverNameIssue(form.name); const subdomainIssue = serverNameIssue(form.subdomain); const canSubmit = !!form.name && subdomainValid && nameIssue === null && subdomainIssue === null && !!form.image && !!form.memory && !!form.storage && Loading @@ -102,6 +102,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { const policies = policyOptions(t); // A field's hint turns into the reason it would be refused once something is typed. const issueText = (issue: ServerNameIssue, value: string) => issue === "reserved" ? t("create_server_name_reserved", { name: value }) : t("create_server_name_invalid"); const hintClass = (value: string, issue: ServerNameIssue | null) => value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground"; return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> Loading @@ -125,8 +131,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { id="cs-name" placeholder={t("create_server_name_placeholder")} value={form.name} onChange={(e) => set("name", e.target.value)} onChange={(e) => set("name", e.target.value.toLowerCase())} aria-invalid={!!form.name && nameIssue !== null} aria-describedby="cs-name-hint" /> <p id="cs-name-hint" className={hintClass(form.name, nameIssue)}> {form.name && nameIssue ? issueText(nameIssue, form.name) : t("create_server_name_hint")} </p> </div> <div className="grid gap-2"> Loading @@ -136,10 +149,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { placeholder={t("create_server_subdomain_placeholder")} value={form.subdomain} onChange={(e) => set("subdomain", e.target.value.toLowerCase())} aria-invalid={!!form.subdomain && subdomainIssue !== null} aria-describedby="cs-sub-hint" /> <p className="text-xs text-muted-foreground"> {form.subdomain && !subdomainValid ? t("create_server_subdomain_invalid") <p id="cs-sub-hint" className={hintClass(form.subdomain, subdomainIssue)}> {form.subdomain && subdomainIssue ? issueText(subdomainIssue, form.subdomain) : t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })} </p> </div> Loading panel/src/i18n/resources/en-US/servers.json +4 −1 Changes for panel/src/i18n/resources/en-US/servers.json: 4 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -41,6 +41,7 @@ "console_offline_title": "Console is offline", "console_offline_body": "The live console attaches automatically as soon as the server is running.", "my_servers_breadcrumb": "My servers", "command_label": "Server command", "command_placeholder": "Type a command… e.g. list", "log_connecting": "Connecting…", "log_live": "Live", Loading Loading @@ -132,9 +133,11 @@ "create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.", "create_server_name": "Name", "create_server_name_placeholder": "survival", "create_server_name_hint": "3–32 lowercase letters, digits or hyphens.", "create_server_name_invalid": "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end.", "create_server_name_reserved": "“{{name}}” is kept for the platform. Pick another.", "create_server_subdomain": "Subdomain", "create_server_subdomain_placeholder": "survival", "create_server_subdomain_invalid": "Lowercase letters, digits and hyphens only.", "create_server_subdomain_hint": "Will be reachable at {{host}}", "create_server_display_name": "Display name (optional)", "create_server_display_name_placeholder": "Survival World", Loading panel/src/i18n/resources/zh-CN/servers.json +4 −1 Changes for panel/src/i18n/resources/zh-CN/servers.json: 4 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -41,6 +41,7 @@ "console_offline_title": "控制台未连接", "console_offline_body": "服务器运行后,实时控制台将自动连接。", "my_servers_breadcrumb": "我的服务器", "command_label": "服务器命令", "command_placeholder": "输入命令…如 list", "log_connecting": "连接中…", "log_live": "实时", Loading Loading @@ -132,9 +133,11 @@ "create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。", "create_server_name": "名称", "create_server_name_placeholder": "survival", "create_server_name_hint": "3–32 位小写字母、数字或连字符。", "create_server_name_invalid": "请用 3–32 位小写字母、数字或连字符,首尾不能是连字符。", "create_server_name_reserved": "“{{name}}” 是平台保留的名字,请换一个。", "create_server_subdomain": "子域名", "create_server_subdomain_placeholder": "survival", "create_server_subdomain_invalid": "仅支持小写字母、数字和连字符。", "create_server_subdomain_hint": "访问地址:{{host}}", "create_server_display_name": "显示名称(选填)", "create_server_display_name_placeholder": "生存世界", Loading Loading
internal/naming/panel_mirror_test.go 0 → 100644 +43 −0 Changes for internal/naming/panel_mirror_test.go: 43 added lines, 0 removed lines. Original line number Diff line number Diff line package naming import ( "os" "regexp" "slices" "strings" "testing" ) // The panel's create form checks names as they are typed with its own copy of // this rule (panel/src/lib/naming.ts). A name reserved or a length changed here // without the panel would let the form send what the API refuses, or refuse what // it takes, so the copy is compared to the source. func TestPanelMirrorsServerNameRule(t *testing.T) { raw, err := os.ReadFile("../../panel/src/lib/naming.ts") if err != nil { t.Fatal(err) } src := string(raw) list := regexp.MustCompile(`(?s)RESERVED_SERVER_NAMES: readonly string\[\] = \[(.*?)\];`).FindStringSubmatch(src) if list == nil { t.Fatal("panel/src/lib/naming.ts: RESERVED_SERVER_NAMES not found") } var panel []string for _, m := range regexp.MustCompile(`"([^"]*)"`).FindAllStringSubmatch(list[1], -1) { panel = append(panel, m[1]) } var api []string for name := range reserved { api = append(api, name) } slices.Sort(panel) slices.Sort(api) if !slices.Equal(panel, api) { t.Errorf("panel reserves %v, API reserves %v", panel, api) } if want := "const SERVER_NAME_RE = /" + serverNameRE.String() + "/;"; !strings.Contains(src, want) { t.Errorf("panel/src/lib/naming.ts lacks %q", want) } }
panel/src/components/CreateServerDialog.test.tsx 0 → 100644 +181 −0 Changes for panel/src/components/CreateServerDialog.test.tsx: 181 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom 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 { CreateServerDialog } from "./CreateServerDialog"; import { humanizeError } from "@/lib/api"; import type { CreateServerRequest, WhitelistImage } from "@/lib/types"; const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn() })); vi.mock("@/lib/api", async (importActual) => { const actual = await importActual<typeof import("@/lib/api")>(); return { ...actual, api: { ...actual.api, ...calls } }; }); // 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 ??= () => {}; }); const PAPER = "registry.example.test/paper:1.21"; const RETIRED = "registry.example.test/paper:1.20"; const cfg = { apiBase: "/api/v1", rootDomain: "example.test", gamePort: 25570 }; const onCreated = vi.fn(); function image(image_ref: string, enabled: boolean): WhitelistImage { return { image_ref, enabled } as WhitelistImage; } beforeEach(() => { calls.listImages.mockReset(); calls.createServer.mockReset(); onCreated.mockReset(); calls.listImages.mockResolvedValue([image(PAPER, true), image(RETIRED, false)]); calls.createServer.mockResolvedValue({}); }); async function openDialog() { const user = userEvent.setup(); render(<CreateServerDialog cfg={cfg} onCreated={onCreated} />); await user.click(screen.getByRole("button", { name: "New server" })); await screen.findByRole("dialog"); return user; } const nameBox = () => screen.getByLabelText("Name") as HTMLInputElement; const subdomainBox = () => screen.getByLabelText("Subdomain") as HTMLInputElement; const create = () => screen.getByRole("button", { name: "Create" }) as HTMLButtonElement; async function pick(user: ReturnType<typeof userEvent.setup>, field: string, option: string) { await user.click(screen.getByRole("combobox", { name: field })); await user.click(await screen.findByRole("option", { name: option })); } /** fillValid fills every required field with what the API takes. */ async function fillValid(user: ReturnType<typeof userEvent.setup>) { await user.type(nameBox(), "survival"); await user.type(subdomainBox(), "survival"); await pick(user, "Image", PAPER); } function sent(): CreateServerRequest { expect(calls.createServer).toHaveBeenCalledTimes(1); return calls.createServer.mock.calls[0][0]; } describe("CreateServerDialog request body", () => { it("sends every field as chosen, lowercased and trimmed", async () => { const user = await openDialog(); await user.type(nameBox(), "Survival-2"); await user.type(subdomainBox(), "SMP"); await user.type(screen.getByLabelText("Display name (optional)"), " Survival World "); await pick(user, "Image", PAPER); await pick(user, "Memory", "8Gi"); await pick(user, "Storage", "20Gi"); await pick(user, "Autostart policy", "Public — any player join wakes it"); await user.click(create()); expect(sent()).toStrictEqual({ name: "survival-2", subdomain: "smp", displayName: "Survival World", image: PAPER, memory: "8Gi", storage: "20Gi", autostartPolicy: "public", }); expect(onCreated).toHaveBeenCalledTimes(1); expect(screen.queryByRole("dialog")).toBeNull(); }); it("leaves a blank display name out, and sends the default sizes and policy", async () => { const user = await openDialog(); await fillValid(user); await user.type(screen.getByLabelText("Display name (optional)"), " "); await user.click(create()); expect(sent()).toStrictEqual({ name: "survival", subdomain: "survival", displayName: undefined, image: PAPER, memory: "4Gi", storage: "10Gi", autostartPolicy: "ownerOnly", }); }); it("offers only the enabled images", async () => { const user = await openDialog(); await user.click(screen.getByRole("combobox", { name: "Image" })); const options = await screen.findAllByRole("option"); expect(options.map((o) => o.textContent)).toEqual([PAPER]); }); it("keeps the form and says why when the API refuses, then creates on the next try", async () => { const refusal = { status: 409, code: "conflict", message: "server survival already exists" }; calls.createServer.mockRejectedValueOnce(refusal); const user = await openDialog(); await fillValid(user); await user.click(create()); const dialog = screen.getByRole("dialog"); expect(within(dialog).getByText(humanizeError(refusal))).toBeTruthy(); expect(nameBox().value).toBe("survival"); expect(onCreated).not.toHaveBeenCalled(); await user.click(create()); expect(calls.createServer).toHaveBeenCalledTimes(2); expect(onCreated).toHaveBeenCalledTimes(1); }); }); describe("CreateServerDialog names", () => { it.each([ ["ab", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["-survival", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["survival-", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["my_world", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["a".repeat(33), "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."], ["lobby", "“lobby” is kept for the platform. Pick another."], ])("refuses the name %j before sending, and says why", async (name, why) => { const user = await openDialog(); await fillValid(user); expect(create().disabled).toBe(false); await user.clear(nameBox()); await user.type(nameBox(), name); expect(create().disabled).toBe(true); expect(nameBox().getAttribute("aria-invalid")).toBe("true"); expect(screen.getByText(why)).toBeTruthy(); }); it("holds the subdomain to the same rule", async () => { const user = await openDialog(); await fillValid(user); await user.clear(subdomainBox()); await user.type(subdomainBox(), "api"); expect(create().disabled).toBe(true); expect(screen.getByText("“api” is kept for the platform. Pick another.")).toBeTruthy(); expect(screen.queryByText(/Will be reachable at/)).toBeNull(); }); it("shows where a good subdomain will be reachable", async () => { const user = await openDialog(); await user.type(subdomainBox(), "smp"); expect(screen.getByText(/Will be reachable at smp\.example\.test/)).toBeTruthy(); expect(subdomainBox().getAttribute("aria-invalid")).toBe("false"); }); });
panel/src/components/CreateServerDialog.tsx +24 −9 Changes for panel/src/components/CreateServerDialog.tsx: 24 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { hostFor, type RuntimeConfig } from "@/lib/config"; import { serverNameIssue, type ServerNameIssue } from "@/lib/naming"; import type { AutostartPolicy, CreateServerRequest } from "@/lib/types"; import { InlineError } from "@/components/MessageLine"; Loading @@ -35,8 +36,6 @@ import { InlineError } from "@/components/MessageLine"; const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"]; const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"]; const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/; function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] { return [ { value: "ownerOnly", label: t("create_server_policy_owner") }, Loading Loading @@ -72,10 +71,11 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { } const enabledImages = (images.data ?? []).filter((i) => i.enabled); const subdomainValid = SUBDOMAIN_RE.test(form.subdomain); const nameIssue = serverNameIssue(form.name); const subdomainIssue = serverNameIssue(form.subdomain); const canSubmit = !!form.name && subdomainValid && nameIssue === null && subdomainIssue === null && !!form.image && !!form.memory && !!form.storage && Loading @@ -102,6 +102,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { const policies = policyOptions(t); // A field's hint turns into the reason it would be refused once something is typed. const issueText = (issue: ServerNameIssue, value: string) => issue === "reserved" ? t("create_server_name_reserved", { name: value }) : t("create_server_name_invalid"); const hintClass = (value: string, issue: ServerNameIssue | null) => value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground"; return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> Loading @@ -125,8 +131,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { id="cs-name" placeholder={t("create_server_name_placeholder")} value={form.name} onChange={(e) => set("name", e.target.value)} onChange={(e) => set("name", e.target.value.toLowerCase())} aria-invalid={!!form.name && nameIssue !== null} aria-describedby="cs-name-hint" /> <p id="cs-name-hint" className={hintClass(form.name, nameIssue)}> {form.name && nameIssue ? issueText(nameIssue, form.name) : t("create_server_name_hint")} </p> </div> <div className="grid gap-2"> Loading @@ -136,10 +149,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { placeholder={t("create_server_subdomain_placeholder")} value={form.subdomain} onChange={(e) => set("subdomain", e.target.value.toLowerCase())} aria-invalid={!!form.subdomain && subdomainIssue !== null} aria-describedby="cs-sub-hint" /> <p className="text-xs text-muted-foreground"> {form.subdomain && !subdomainValid ? t("create_server_subdomain_invalid") <p id="cs-sub-hint" className={hintClass(form.subdomain, subdomainIssue)}> {form.subdomain && subdomainIssue ? issueText(subdomainIssue, form.subdomain) : t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })} </p> </div> Loading
panel/src/i18n/resources/en-US/servers.json +4 −1 Changes for panel/src/i18n/resources/en-US/servers.json: 4 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -41,6 +41,7 @@ "console_offline_title": "Console is offline", "console_offline_body": "The live console attaches automatically as soon as the server is running.", "my_servers_breadcrumb": "My servers", "command_label": "Server command", "command_placeholder": "Type a command… e.g. list", "log_connecting": "Connecting…", "log_live": "Live", Loading Loading @@ -132,9 +133,11 @@ "create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.", "create_server_name": "Name", "create_server_name_placeholder": "survival", "create_server_name_hint": "3–32 lowercase letters, digits or hyphens.", "create_server_name_invalid": "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end.", "create_server_name_reserved": "“{{name}}” is kept for the platform. Pick another.", "create_server_subdomain": "Subdomain", "create_server_subdomain_placeholder": "survival", "create_server_subdomain_invalid": "Lowercase letters, digits and hyphens only.", "create_server_subdomain_hint": "Will be reachable at {{host}}", "create_server_display_name": "Display name (optional)", "create_server_display_name_placeholder": "Survival World", Loading
panel/src/i18n/resources/zh-CN/servers.json +4 −1 Changes for panel/src/i18n/resources/zh-CN/servers.json: 4 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -41,6 +41,7 @@ "console_offline_title": "控制台未连接", "console_offline_body": "服务器运行后,实时控制台将自动连接。", "my_servers_breadcrumb": "我的服务器", "command_label": "服务器命令", "command_placeholder": "输入命令…如 list", "log_connecting": "连接中…", "log_live": "实时", Loading Loading @@ -132,9 +133,11 @@ "create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。", "create_server_name": "名称", "create_server_name_placeholder": "survival", "create_server_name_hint": "3–32 位小写字母、数字或连字符。", "create_server_name_invalid": "请用 3–32 位小写字母、数字或连字符,首尾不能是连字符。", "create_server_name_reserved": "“{{name}}” 是平台保留的名字,请换一个。", "create_server_subdomain": "子域名", "create_server_subdomain_placeholder": "survival", "create_server_subdomain_invalid": "仅支持小写字母、数字和连字符。", "create_server_subdomain_hint": "访问地址:{{host}}", "create_server_display_name": "显示名称(选填)", "create_server_display_name_placeholder": "生存世界", Loading