fix(panel): streamline system settings and server creation

Use shared action buttons and default to the login space. Allow staff to save startup-only experience settings while running and request a durable restart through the existing operator flow.

Grant the API PVC list permission needed to detect retained worlds before server creation, and show internal failures with a request ID. Cover permission, maintenance, restart and UI behavior with regression checks.
This commit is contained in:
Lemon-miaow committed 2026-10-04 16:26:03 +08:00
1 parent e67896979e
commit cb3ed94026
34 files changed
+555 -103

No files matched your search

+10 -4
View File
@@ -2267,7 +2267,7 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
streamConsole(ctx.req, ctx.res, serverInfo);
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "wake") {
if (is("POST", ctx) && (ctx.parts[4] === "wake" || ctx.parts[4] === "restart")) {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
@@ -2276,8 +2276,12 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 409, "server_retiring", "this server is being given up or deleted; cancel that first");
return true;
}
if (ctx.parts[4] === "restart" && serverInfo.phase !== "Running") {
sendError(ctx.res, 409, "not_running", "start the server before restarting it");
return true;
}
setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" });
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Running" });
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "stop") {
@@ -2454,11 +2458,13 @@ async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Pro
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
if (serverInfo.phase !== "Stopped") {
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const liveConfig = ["login", "lobby"].includes(serverInfo.name) &&
["GET file", "PUT file"].includes(key) && url.searchParams.get("path") === "felis-experience.json";
if (!liveConfig && serverInfo.phase !== "Stopped") {
sendError(ctx.res, 409, "not_stopped", "stop the server before editing its files");
return true;
}
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const tree = filesOf(ctx.state, serverInfo.name);
const p = cleanFilePath(ctx, url.searchParams.get("path"), key === "GET files");
if (p === null) return true;
+19
View File
@@ -1,5 +1,24 @@
import { test, expect, t, expectFitsScreen } from "./fixtures";
test("login is the default space; live lobby settings save before a confirmed restart", async ({ page, signIn }) => {
await signIn("owner");
await page.goto("/admin/lobby");
await expect(page.getByRole("button", { name: t("lobby:login"), exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByLabel(t("lobby:bookTitle"))).toBeVisible();
await page.getByRole("button", { name: t("lobby:lobby"), exact: true }).click();
await page.getByLabel(t("lobby:menuTitleEn")).fill("Our Network");
await expect(page.getByRole("button", { name: t("lobby:restart"), exact: true })).toBeDisabled();
await page.getByRole("button", { name: t("lobby:save"), exact: true }).click();
await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible();
await expectFitsScreen(page);
await page.getByRole("button", { name: t("lobby:restart"), exact: true }).click();
const dialog = page.getByRole("dialog", { name: t("lobby:restart_title") });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: t("lobby:restart"), exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText(t("lobby:restart_requested"), { exact: true })).toBeVisible();
});
test("a signed-out visit signs in by email code and returns to the page it asked for", async ({ page }) => {
await page.goto("/servers");
await expect(page).toHaveURL(/\/login\?next=%2Fservers$/);
@@ -104,7 +104,7 @@ describe("WakeListSection", () => {
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
expect((await screen.findByRole("alert")).textContent).toBe(
"Couldn't load the wake list. The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
"Couldn't load the wake list. The operation failed because of an internal server error. Ask an admin to check the logs.",
);
});
+3 -1
View File
@@ -121,7 +121,9 @@
"auth_unavailable": "The sign-in service isn't available right now.",
"setup_token_invalid": "That setup token is invalid or expired.",
"network_error": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. Reload the page to sign in again.",
"upstream_unavailable": "The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
"upstream_unavailable": "The service is unavailable right now. Try again shortly.",
"internal_error": "The operation failed because of an internal server error. Ask an admin to check the logs.",
"request_id": "Request ID: {{id}}",
"bad_path_param": "The name in this link is not valid. Open it again from the list.",
"payload_too_large": "That is larger than the entry proxy accepts, so it was not sent.",
"passkey_no_credential": "The browser returned no passkey. Try again.",
+8 -2
View File
@@ -13,7 +13,13 @@
"stop_login": "Stop the login space to read and save settings. New players cannot join during maintenance; start it again when finished.",
"stop_lobby": "Stop the lobby to read and save settings. Back up its map before maintenance, then start it again when finished.",
"read_failed": "Could not read settings: {{reason}}. Check felis-experience.json in the file manager.",
"saved": "Saved. Start this space to apply the settings.",
"saved": "Saved. Settings apply on the next start or restart.",
"restart": "Restart & apply",
"restarting": "Restarting…",
"restart_requested": "Restart requested. Follow the startup status above.",
"restart_title": "Restart this space?",
"restart_hint": "Players here will be disconnected briefly. Saved settings take effect when this space starts again.",
"restart_required": "Settings saved; restart to apply",
"save": "Save settings",
"saving": "Saving…",
"invalid_values": "Check numeric ranges and selected options. Text is limited to 512 characters.",
@@ -88,5 +94,5 @@
"loginBook_description": "Edit the guidance players see in the login book.",
"content_tools": "Content & maintenance",
"unsaved": "You have unsaved changes",
"apply_on_start": "Saved settings apply when this space starts"
"apply_on_start": "Save while running; settings take effect on the next start or restart"
}
+3 -1
View File
@@ -121,7 +121,9 @@
"auth_unavailable": "登录服务当前不可用。",
"setup_token_invalid": "初始化令牌无效或已过期。",
"network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。",
"upstream_unavailable": "服务暂时不可用(可能正在重启或升级),请稍后重试。",
"upstream_unavailable": "服务暂时不可用,请稍后重试。",
"internal_error": "操作失败,服务器出现内部错误。请联系管理员查看日志。",
"request_id": "请求编号:{{id}}",
"bad_path_param": "链接里的名称无效,请从列表重新打开。",
"payload_too_large": "内容超过了入口允许的大小,没有发送成功。",
"passkey_no_credential": "浏览器没有返回 Passkey,请重试。",
+8 -2
View File
@@ -13,7 +13,13 @@
"stop_login": "停止登录空间后可读取和保存配置。维护期间新玩家无法进入;完成后请启动登录空间。",
"stop_lobby": "停止大厅后可读取和保存配置。维护前可先备份地图,完成后请启动大厅。",
"read_failed": "读取设置失败:{{reason}}。可在文件管理中检查 felis-experience.json。",
"saved": "已保存。启动该空间后生效。",
"saved": "已保存,下次启动或重启后生效。",
"restart": "重启并应用",
"restarting": "正在重启…",
"restart_requested": "已发送重启请求,可在上方查看启动状态。",
"restart_title": "重启该空间?",
"restart_hint": "重启会暂时断开这里的玩家,重新启动后应用已保存的设置。",
"restart_required": "设置已保存,等待重启生效",
"save": "保存设置",
"saving": "正在保存…",
"invalid_values": "请检查数字范围和选项,文案最多 512 个字符。",
@@ -88,5 +94,5 @@
"loginBook_description": "编辑玩家打开登录书时看到的引导内容。",
"content_tools": "内容与维护",
"unsaved": "有未保存的更改",
"apply_on_start": "保存的设置将在启动后生效"
"apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效"
}
+13 -1
View File
@@ -1102,9 +1102,21 @@ describe("responses that are not the API's JSON", () => {
});
it("maps an unknown 5xx code to the unavailable line", () => {
expect(humanizeError({ status: 500, code: "internal", message: "internal error" })).toMatch(/unavailable/i);
expect(humanizeError({ status: 500, code: "unknown", message: "internal error" })).toMatch(/unavailable/i);
expect(humanizeError({ status: 413, code: "error", message: "Payload Too Large" })).toMatch(/larger/i);
});
it("keeps an internal error's request ID without exposing backend details", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: false, status: 500,
text: async () => JSON.stringify({ error: { code: "internal", message: "database credentials rejected", request_id: "req-123" } }),
})));
const err = await api.status("lobby").catch((e) => e);
expect(err.request_id).toBe("req-123");
expect(humanizeError(err)).toMatch(/internal server error/);
expect(humanizeError(err)).toContain("req-123");
expect(humanizeError(err)).not.toContain("credentials");
});
});
describe("session and connection signals", () => {
+7 -2
View File
@@ -56,7 +56,7 @@ import i18next from "i18next";
// the upstream Zero-Trust / Access cookie rides along; the panel never holds a
// service token, and the RCON password is never requested (spec §8).
function isApiError(x: unknown): x is { error: { code: string; message: string } } {
function isApiError(x: unknown): x is { error: { code: string; message: string; request_id?: string } } {
if (typeof x !== "object" || x === null || !("error" in x)) return false;
const e = (x as { error: unknown }).error;
return typeof e === "object" && e !== null && typeof (e as { code?: unknown }).code === "string";
@@ -193,7 +193,7 @@ function failed(path: string, status: number, statusText: string, text: string):
/* no body, or not JSON: an ingress or tunnel answered */
}
const err: ApiError = isApiError(parsed)
? { status, code: parsed.error.code, message: parsed.error.message }
? { status, code: parsed.error.code, message: parsed.error.message, ...(typeof parsed.error.request_id === "string" && { request_id: parsed.error.request_id }) }
: {
status,
code: status >= 500 ? "upstream_unavailable" : "error",
@@ -447,6 +447,9 @@ export const api = rejectingSync({
stop: (name: string) =>
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/stop`),
restart: (name: string) =>
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/restart`),
claim: (name: string) =>
request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`),
@@ -1567,6 +1570,8 @@ export function humanizeError(e: unknown): string {
return err.message ? t("bad_request", { detail: err.message }) : t("generic");
case "bad_schedule":
return err.message ? t("bad_schedule", { detail: err.message }) : t("generic");
case "internal":
return t("internal_error") + (err.request_id ? " " + t("request_id", { id: err.request_id }) : "");
default:
if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden");
+64 -5
View File
@@ -580,6 +580,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/restart": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Restart your own running server, or a system service as staff.
* @description Records a durable request for the operator to gracefully recreate the game pod. Desired state remains Running. A concurrent stop supersedes the request; maintenance blocks admission.
*/
post: operations["restart"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/claim": {
parameters: {
query?: never;
@@ -1495,13 +1515,13 @@ export interface paths {
cookie?: never;
};
/**
* Read a file from a server's world volume (owner-or-admin; server must be stopped).
* @description Returns one file's bytes, base64-encoded, from inside the server's world volume. Same stopped-gate and os.Root containment as the directory listing. Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read, because a config editor that silently returned half a file would let a subsequent save destroy the other half.
* Read a file from a server's world volume (owner-or-admin).
* @description Returns one file's bytes, base64-encoded, from inside the server's world volume. Same stopped-gate and os.Root containment as the directory listing, except staff may read login/lobby's felis-experience.json while running. Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read, because a config editor that silently returned half a file would let a subsequent save destroy the other half.
*/
get: operations["readServerFile"];
/**
* Write a file in a server's world volume (owner-or-admin; server must be stopped).
* @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received the same way before writing. Audited as file.write.
* Write a file in a server's world volume (owner-or-admin).
* @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received the same way before writing. Staff may save login/lobby's startup-only felis-experience.json while running; restart to apply. That config cannot be a symlink. Audited as file.write.
*/
put: operations["writeServerFile"];
post?: never;
@@ -4927,6 +4947,45 @@ export interface operations {
404: components["responses"]["NotFound"];
};
};
restart: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Restart accepted. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
name: string;
/** @constant */
desiredState: "Running";
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description Server is not running, is retiring, or maintenance is in progress. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
claim: {
parameters: {
query?: never;
@@ -7286,7 +7345,7 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description Server is not stopped (its world PVC is still mounted). */
/** @description Server is not stopped (except startup-only system experience config). */
409: {
headers: {
[name: string]: unknown;
+1
View File
@@ -391,6 +391,7 @@ export interface ApiError {
status: number;
code: string;
message: string;
request_id?: string;
}
/** Identity mirrors GET /api/v1/me (app-tier — every authenticated principal may
@@ -156,7 +156,7 @@ describe("BuildScanPanel", () => {
calls.getBuildScan.mockResolvedValueOnce(BLOCKED);
render(<BuildScanPanel build={FAILED} />);
expect((await screen.findByRole("alert")).textContent).toBe(
"Couldn't load the security scan: The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
"Couldn't load the security scan: The operation failed because of an internal server error. Ask an admin to check the logs.",
);
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("region", { name: "Security scan" })).toBeTruthy();
+35 -9
View File
@@ -1,12 +1,12 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { LobbyPage } from "./LobbyPage";
const calls = vi.hoisted(() => ({
status: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(),
status: vi.fn(), restart: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(),
}));
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
vi.mock("@/lib/config", async (original) => ({ ...await original<typeof import("@/lib/config")>(), loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gameVersion: "26.3" }) }));
@@ -18,10 +18,11 @@ beforeEach(() => {
calls.readServerFile.mockResolvedValue({ content: btoa(JSON.stringify({ menuTitleEn: "Old title", customPlugin: { enabled: true } })), sha256: "read-hash" });
calls.writeServerFile.mockResolvedValue({ sha256: "saved-hash" });
calls.createServerFile.mockResolvedValue({ sha256: "saved-hash" });
calls.restart.mockResolvedValue({ name: "lobby", desiredState: "Running" });
});
function page(space = "lobby") {
render(<MemoryRouter initialEntries={[`/admin/lobby?space=${space}`]}><LobbyPage /></MemoryRouter>);
render(<MemoryRouter initialEntries={[`/admin/lobby${space ? `?space=${space}` : ""}`]}><LobbyPage /></MemoryRouter>);
}
describe("LobbyPage", () => {
@@ -29,7 +30,7 @@ describe("LobbyPage", () => {
page();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
await screen.findByText("Saved. Start this space to apply the settings.");
await screen.findByText("Saved. Settings apply on the next start or restart.");
const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
expect([name, path, hash]).toEqual(["lobby", "felis-experience.json", "read-hash"]);
expect(JSON.parse(atob(content))).toEqual({ menuTitleEn: "My Network", customPlugin: { enabled: true } });
@@ -45,12 +46,37 @@ describe("LobbyPage", () => {
expect(calls.writeServerFile).not.toHaveBeenCalled();
});
it("requires a stop before reading or writing a running space", async () => {
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true });
it("opens the leftmost login space by default", async () => {
page("");
await screen.findByLabelText("Login book title");
expect(calls.status).toHaveBeenCalledWith("login");
expect(screen.getByRole("button", { name: "Login space" }).getAttribute("aria-pressed")).toBe("true");
});
it("saves while running and restarts only when requested", async () => {
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 0 });
page();
await screen.findByText(/Stop the lobby to read and save settings/);
expect(calls.readServerFile).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
await screen.findByText("Saved. Settings apply on the next start or restart.");
expect(calls.restart).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Restart & apply" }));
await waitFor(() => expect(calls.restart).toHaveBeenCalledWith("lobby"));
});
it("confirms a restart when players are online and keeps failures visible", async () => {
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 2 });
calls.restart.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "busy" });
page();
await screen.findByLabelText("English menu title");
await userEvent.click(screen.getByRole("button", { name: "Restart & apply" }));
const dialog = await screen.findByRole("dialog", { name: "Restart this space?" });
expect(calls.restart).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Restart & apply" }));
await waitFor(() => expect(calls.restart).toHaveBeenCalledWith("lobby"));
await screen.findByRole("alert");
expect(screen.getByRole("dialog", { name: "Restart this space?" })).toBeTruthy();
});
it("keeps the draft when another editor changed the file", async () => {
+48 -16
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, RotateCw, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader";
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
import { PowerButton, SUBMITTED_HOLD_MS } from "@/components/PowerButton";
import { CopyAddress } from "@/components/CopyAddress";
import { EditServerDialog } from "@/components/EditServerDialog";
import { ErrorState, Loading } from "@/components/States";
@@ -17,6 +17,7 @@ import { InlineError, MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { joinAddress } from "@/lib/config";
import { cn } from "@/lib/utils";
import type { ServerStatus } from "@/lib/types";
import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks";
import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience";
@@ -27,12 +28,22 @@ const GROUP_ICONS: Record<string, LucideIcon> = {
loginBook: BookOpen,
};
function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) {
function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name: string; server: ServerStatus; onDirtyChange: (dirty: boolean) => void; onChanged: () => void }) {
const { t } = useTranslation("lobby");
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
const query = useAsync(() => stopped ? readExperience(name, groups) : Promise.resolve(null), [name, stopped]);
const query = useAsync(() => readExperience(name, groups), [name]);
const [draft, setDraft] = useState<{ values: Experience; original: string; sha256: string } | null>(null);
const [saving, setSaving] = useState(false);
const [needsRestart, setNeedsRestart] = useState(false);
const [restarting, setRestarting] = useState(false);
const [confirmRestart, setConfirmRestart] = useState(false);
const running = server.phase === "Running" && server.desiredState === "Running";
useEffect(() => {
if (!restarting) return;
if (!running) { setRestarting(false); return; }
const hold = window.setTimeout(() => setRestarting(false), SUBMITTED_HOLD_MS);
return () => window.clearTimeout(hold);
}, [running, restarting]);
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
useEffect(() => {
if (query.data && !draft) {
@@ -44,12 +55,13 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
async function save() {
if (!draft || !stopped || saving || !experienceValid(draft.values, groups)) return;
if (!draft || saving || !experienceValid(draft.values, groups)) return;
setSaving(true);
setMessage(null);
try {
const sha256 = await writeExperience(name, draft.values, draft.sha256);
setDraft({ ...draft, original: JSON.stringify(draft.values), sha256 });
setNeedsRestart(true);
setMessage({ kind: "success", text: t("saved") });
} catch (error) {
setMessage({ kind: "error", text: humanizeError(error) });
@@ -58,10 +70,23 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
}
}
async function restart() {
setRestarting(true);
setMessage(null);
try {
await api.restart(name);
setNeedsRestart(false);
setMessage({ kind: "success", text: t("restart_requested") });
onChanged();
} catch (error) {
setRestarting(false);
throw error;
}
}
return (
<div className="space-y-4">
{!stopped && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm">{t(name === "login" ? "stop_login" : "stop_lobby")}</p>}
{stopped && query.loading && !draft && <Loading />}
{query.loading && !draft && <Loading />}
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
{draft && groups.map((group) => {
const Icon = GROUP_ICONS[group.key];
@@ -86,13 +111,13 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
<div key={id} className="min-w-0 space-y-2">
<Label htmlFor={id} className="block text-xs font-medium text-muted-foreground">{t(field.key)}</Label>
{field.choices ? (
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={saving || restarting} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
</select>
) : field.multiline ? (
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="block h-32 w-full resize-y rounded-lg border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={saving || restarting} className="block h-32 w-full resize-y rounded-lg border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
) : (
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!stopped || saving} className="h-10 rounded-lg bg-background shadow-none" />
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={saving || restarting} className="h-10 rounded-lg bg-background shadow-none" />
)}
</div>
);
@@ -103,7 +128,7 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
<label key={field.key} htmlFor={`experience-${field.key}`} className="flex min-h-12 cursor-pointer items-center justify-between gap-4 py-3 text-sm">
<span>{t(field.key)}</span>
<span className="relative shrink-0">
<input id={`experience-${field.key}`} type="checkbox" role="switch" checked={(draft.values[field.key] ?? field.value) as boolean} onChange={(e) => setDraft({ ...draft, values: { ...draft.values, [field.key]: e.target.checked } })} disabled={!stopped || saving} className="peer sr-only" />
<input id={`experience-${field.key}`} type="checkbox" role="switch" checked={(draft.values[field.key] ?? field.value) as boolean} onChange={(e) => setDraft({ ...draft, values: { ...draft.values, [field.key]: e.target.checked } })} disabled={saving || restarting} className="peer sr-only" />
<span aria-hidden="true" className="block h-5 w-9 rounded-full bg-muted-foreground/25 p-0.5 transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 peer-checked:[&>span]:translate-x-4">
<span className="block h-4 w-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none" />
</span>
@@ -119,11 +144,18 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
{message && <MessageLine kind={message.kind} message={message.text} />}
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-3">
<p className="text-xs text-muted-foreground">{t(dirty ? "unsaved" : "apply_on_start")}</p>
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
<div className={cn("flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-card p-3", needsRestart ? "border-amber-500/30 bg-amber-500/5" : "border-border")}>
<p className={cn("text-xs", needsRestart ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground")}>{t(dirty ? "unsaved" : needsRestart ? "restart_required" : "apply_on_start")}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={!running || dirty || saving || restarting} onClick={() => {
if ((server.playersOnline ?? 0) > 0 || server.playerCountUnknown) setConfirmRestart(true);
else void restart().catch((error) => setMessage({ kind: "error", text: humanizeError(error) }));
}}><RotateCw className={cn(restarting && "animate-spin")} />{t(restarting ? "restarting" : "restart")}</Button>
<Button onClick={() => void save()} disabled={!dirty || saving || restarting || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
</div>
</div>
</>}
<ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} />
</div>
);
}
@@ -164,7 +196,7 @@ function BuilderAccess({ ready }: { ready: boolean }) {
export function LobbyPage() {
const { t } = useTranslation("lobby");
const [params, setParams] = useSearchParams();
const name = params.get("space") === "login" ? "login" : "lobby";
const name = params.get("space") === "lobby" ? "lobby" : "login";
const [dirty, setDirty] = useState(false);
const [nextSpace, setNextSpace] = useState<string | null>(null);
const cfg = useConfig();
@@ -212,7 +244,7 @@ export function LobbyPage() {
<PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />
</div>
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<ExperienceSettings key={name} name={name} onDirtyChange={setDirty} stopped={data.phase === "Stopped" && data.desiredState === "Stopped"} />
<ExperienceSettings key={name} name={name} server={data} onDirtyChange={setDirty} onChanged={status.reload} />
<div className="space-y-4">
<Card className="overflow-hidden rounded-xl shadow-none">
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader>
+1 -1
View File
@@ -533,7 +533,7 @@ function ServerActions({
if (server.system) {
// Staff customize system services in the dedicated lobby view.
if (isAdmin) return <Link className="text-sm text-primary hover:underline" to={`/admin/lobby?space=${server.name}`}>{ts("system_customize")}</Link>;
if (isAdmin) return <Button asChild variant="outline" size="sm"><Link to={`/admin/lobby?space=${server.name}`}>{ts("system_customize")}</Link></Button>;
return (
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
{ts("system_service")}