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:
34 files changed
+555
-103
No files matched your search
+10
-4
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,请重试。",
|
||||
|
||||
@@ -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": "可以在运行中保存;设置将在下次启动或重启后生效"
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user