fix(felis): 编辑服务器时内存和 CPU 可各自单改互不清除,显示名可清空,内存显示真实上限

This commit is contained in:
Lemon-miaow committed 2026-09-26 22:22:02 +08:00
1 parent 22d1e834ad
commit d65cf3af30
18 files changed
+600 -60

No files matched your search

+2 -1
View File
@@ -256,7 +256,8 @@ function initialState(): MockState {
playersOnline: 12,
playersMax: 20,
autostartPolicy: "public",
javaMemory: "4Gi",
memory: "4Gi",
javaMemory: "3072M",
storageSize: "20Gi",
// Pinned the way felis-api stores it: the tag it was created from plus the
// digest that tag named then.
+4 -2
View File
@@ -29,10 +29,12 @@ export function ConfirmFooter({
return (
<DialogFooter className={className}>
{children}
<Button variant="outline" size="sm" onClick={onCancel} disabled={disabled ?? loading}>
{/* Backing out stays open whenever nothing is in flight: a form with nothing to
save yet, or a confirm that is not allowed, is still one to cancel. */}
<Button variant="outline" size="sm" onClick={onCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button variant={confirmVariant} size="sm" onClick={onConfirm} disabled={disabled ?? loading}>
<Button variant={confirmVariant} size="sm" onClick={onConfirm} disabled={disabled || loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditServerDialog } from "./EditServerDialog";
const calls = vi.hoisted(() => ({ listImages: vi.fn(), patchServer: 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 onUpdated = vi.fn();
beforeEach(() => {
calls.listImages.mockReset();
calls.patchServer.mockReset();
onUpdated.mockReset();
calls.listImages.mockResolvedValue([]);
calls.patchServer.mockResolvedValue({ name: "survival", patched: [] });
});
async function openDialog() {
const user = userEvent.setup();
render(
<EditServerDialog
serverName="survival"
currentDisplayName="Survival Realm"
currentPolicy="ownerOnly"
currentImage="registry.example.test/paper:1.21"
currentMemory="4Gi"
currentStorage="10Gi"
currentCpu="1"
currentIdleStopSeconds={600}
onUpdated={onUpdated}
/>,
);
await user.click(screen.getByRole("button", { name: /Edit Server Config/ }));
await screen.findByRole("dialog");
return user;
}
const save = () => screen.getByRole("button", { name: "Save Config" }) as HTMLButtonElement;
const cpuInput = () => screen.getByLabelText("CPU Limit") as HTMLInputElement;
describe("EditServerDialog request body", () => {
it("sends the CPU alone when only the CPU changed", async () => {
const user = await openDialog();
await user.clear(cpuInput());
await user.type(cpuInput(), "2");
await user.click(save());
expect(calls.patchServer).toHaveBeenCalledWith("survival", { resources: { cpu: "2" } });
expect(onUpdated).toHaveBeenCalledTimes(1);
});
it("sends the memory alone when only the memory changed", async () => {
const user = await openDialog();
await user.click(screen.getByRole("combobox", { name: "Memory" }));
await user.click(await screen.findByRole("option", { name: "8Gi" }));
await user.click(save());
expect(calls.patchServer).toHaveBeenCalledWith("survival", { memory: "8Gi" });
});
it("clears the display name with an empty one", async () => {
const user = await openDialog();
await user.clear(screen.getByLabelText("Display name (optional)"));
await user.click(save());
expect(calls.patchServer).toHaveBeenCalledWith("survival", { displayName: "" });
});
it("lifts the CPU limit when the field is emptied", async () => {
const user = await openDialog();
await user.clear(cpuInput());
await user.click(save());
expect(calls.patchServer).toHaveBeenCalledWith("survival", { resources: { cpu: "" } });
});
it("treats surrounding space as no change", async () => {
const user = await openDialog();
await user.type(cpuInput(), " ");
await user.type(screen.getByLabelText("Display name (optional)"), " ");
expect(save().disabled).toBe(true);
});
it("refuses a CPU value the server cannot take", async () => {
const user = await openDialog();
for (const bad of ["two", "0", "1.5m", "-1"]) {
await user.clear(cpuInput());
await user.type(cpuInput(), bad);
expect(screen.getByText("Enter cores (1, 1.5) or millicores (500m), above zero."), bad).toBeTruthy();
expect(cpuInput().getAttribute("aria-invalid"), bad).toBe("true");
expect(save().disabled, bad).toBe(true);
}
for (const good of ["1.5", "500m", "4"]) {
await user.clear(cpuInput());
await user.type(cpuInput(), good);
expect(screen.queryByText(/millicores/), good).toBeNull();
expect(save().disabled, good).toBe(false);
}
expect(calls.patchServer).not.toHaveBeenCalled();
});
it("can be cancelled with nothing changed", async () => {
const user = await openDialog();
expect(save().disabled).toBe(true);
const cancel = screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement;
expect(cancel.disabled).toBe(false);
await user.click(cancel);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
+28 -10
View File
@@ -27,6 +27,14 @@ import { InlineError } from "@/components/MessageLine";
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
/** cpuValid accepts what the API takes as a CPU limit: a positive number of
* cores ("2", "1.5") or millicores ("500m"). Empty is valid too: no limit. */
function cpuValid(cpu: string): boolean {
if (cpu === "") return true;
const m = /^(\d+(?:\.\d+)?)(m?)$/.exec(cpu);
return m !== null && Number(m[1]) > 0 && (m[2] === "" || !m[1].includes("."));
}
/** Idle auto-stop presets in seconds; "0" is Never. The server default is 600. */
const IDLE_OPTIONS = ["0", "300", "600", "900", "1800", "3600", "7200"];
@@ -141,18 +149,22 @@ export function EditServerDialog({
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
// Checks if form state has mutated from initial values
// Text fields count as what they send: surrounding space is no change, and an
// emptied field is one (it clears the name, or lifts the CPU limit).
const displayName = form.displayName.trim();
const cpu = form.cpu.trim();
const cpuOk = cpuValid(cpu);
const hasChanges =
form.displayName !== currentDisplayName ||
displayName !== currentDisplayName ||
form.autostartPolicy !== currentPolicy ||
form.image !== currentImage ||
form.memory !== currentMemory ||
form.cpu !== currentCpu ||
cpu !== currentCpu ||
form.idleStop !== currentIdleStop;
const imageChanged = form.image !== currentImage;
const pinnedBuild = splitImageRef(currentImage).short;
const canSubmit = hasChanges && !submitting && (!imageChanged || imageConfirmed);
const canSubmit = hasChanges && cpuOk && !submitting && (!imageChanged || imageConfirmed);
async function submit() {
setSubmitting(true);
@@ -160,8 +172,8 @@ export function EditServerDialog({
try {
const payload: Parameters<typeof api.patchServer>[1] = {};
if (form.displayName !== currentDisplayName) {
payload.displayName = form.displayName.trim() || undefined;
if (displayName !== currentDisplayName) {
payload.displayName = displayName;
}
if (form.autostartPolicy !== currentPolicy) {
payload.autostartPolicy = form.autostartPolicy;
@@ -170,13 +182,12 @@ export function EditServerDialog({
payload.image = form.image;
payload.confirmImageChange = imageConfirmed;
}
// Memory and CPU each go alone; the API keeps whatever is not sent.
if (form.memory !== currentMemory) {
payload.memory = form.memory;
}
if (form.cpu !== currentCpu) {
payload.resources = {
cpu: form.cpu.trim(),
};
if (cpu !== currentCpu) {
payload.resources = { cpu };
}
if (form.idleStop !== currentIdleStop) {
payload.idleStopSeconds = Number(form.idleStop);
@@ -324,7 +335,14 @@ export function EditServerDialog({
placeholder={t("edit_server_cpu_placeholder")}
value={form.cpu}
onChange={(e) => set("cpu", e.target.value)}
aria-invalid={!cpuOk}
aria-describedby={cpuOk ? undefined : "es-cpu-error"}
/>
{!cpuOk && (
<p id="es-cpu-error" className="text-xs text-destructive">
{t("edit_server_cpu_invalid")}
</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="edit-server-policy">{t("create_server_policy")}</Label>
+2 -1
View File
@@ -178,7 +178,8 @@
"luckperms_rcon_output": "RCON Console Output",
"luckperms_clear_history": "Clear history",
"edit_server_cpu": "CPU Limit",
"edit_server_cpu_placeholder": "e.g. 1, 2, 500m",
"edit_server_cpu_placeholder": "e.g. 1, 1.5, 500m — empty for no limit",
"edit_server_cpu_invalid": "Enter cores (1, 1.5) or millicores (500m), above zero.",
"owned_filter_mine": "me",
"edit_server_idle": "Idle auto-stop",
"edit_server_idle_hint": "Stops the server after this long with nobody online, freeing memory and CPU. The next player to join wakes it.",
+2 -1
View File
@@ -178,7 +178,8 @@
"luckperms_rcon_output": "RCON 控制台输出",
"luckperms_clear_history": "清除历史记录",
"edit_server_cpu": "CPU 限制",
"edit_server_cpu_placeholder": "例如 1, 2, 500m",
"edit_server_cpu_placeholder": "例如 1、1.5、500m,留空不限制",
"edit_server_cpu_invalid": "填核数(1、1.5)或毫核(500m),要大于 0。",
"owned_filter_mine": "我",
"edit_server_idle": "空闲自动停服",
"edit_server_idle_hint": "没人在线达到这个时长就自动停服,省下内存和 CPU;玩家下次进服时自动唤醒。",
+3
View File
@@ -553,6 +553,7 @@ export const api = rejectingSync({
),
patchServer: (name: string, req: {
/** Trimmed server-side; an empty one clears it. */
displayName?: string;
autostartPolicy?: AutostartPolicy;
image?: string;
@@ -562,6 +563,8 @@ export const api = rejectingSync({
memory?: string;
/** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */
idleStopSeconds?: number;
/** Single fields laid over the server's pod block: a field left out keeps its
* value, and an empty cpu or cpuRequest removes that limit or request. */
resources?: {
cpu?: string;
cpuRequest?: string;
+6
View File
@@ -2339,7 +2339,10 @@ export interface components {
playersMax: number;
displayName?: string;
image?: string;
/** @description The JVM heap (-Xmx) derived from the memory limit. */
javaMemory?: string;
/** @description The pod memory limit (the server's memory as an admin picks it), a Kubernetes quantity such as 4Gi. */
memory?: string;
storageSize?: string;
cpu?: string;
/**
@@ -7685,15 +7688,18 @@ export interface operations {
requestBody: {
content: {
"application/json": {
/** @description Trimmed. An empty name clears it, and the server goes by its name again. */
displayName?: string;
autostartPolicy?: string;
/** @description Re-admitted against the whitelist (a pinned name:tag@sha256:… ref is admitted by its name:tag) and pinned like create does. A pin equal to the current image is no change; any other needs confirmImageChange. */
image?: string;
/** @description Acknowledges that the new image opens the world with its Minecraft version, whose chunk upgrades the old one cannot read. Without it an image that would move the server is refused with 409 image_change_unconfirmed. The audit row records image_from/image_to. */
confirmImageChange?: boolean;
/** @description Sets the memory limit and request together, as create does, and re-derives the JVM heap. The CPU limit and request are kept. */
memory?: string;
/** @description Rejected with 400 storage_immutable — present for a clear error, not mutation. */
storage?: string;
/** @description Single fields of the pod block, laid over what the server has: a field left out keeps its value, so each may be sent alone. An empty cpu or cpuRequest removes that limit or request; the memory ceiling cannot be emptied. A request above its limit is a 400. */
resources?: {
cpu?: string;
cpuRequest?: string;
+3
View File
@@ -59,7 +59,10 @@ export interface ServerStatus {
playersMax: number;
displayName?: string;
image?: string;
/** The JVM heap derived from `memory`. */
javaMemory?: string;
/** The pod memory limit — the server's memory as an admin picks it (e.g. "4Gi"). */
memory?: string;
storageSize?: string;
cpu?: string;
/** Seconds empty before idle auto-stop; 0 when the server never idles out. */
+19 -1
View File
@@ -1,10 +1,11 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServerConsole } from "./ServerConsole";
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn() }));
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() }));
vi.mock("@/lib/tier", () => ({
useTier: () => ({
loading: false,
@@ -31,6 +32,8 @@ beforeEach(() => {
calls.status.mockReset();
calls.myServers.mockReset();
calls.myServers.mockResolvedValue([]);
calls.listImages.mockReset();
calls.listImages.mockResolvedValue([]);
});
function renderConsole() {
@@ -76,3 +79,18 @@ describe("ServerConsole failed start", () => {
expect(screen.queryByRole("button", { name: /Retry start/ })).toBeNull();
});
});
describe("ServerConsole edit dialog", () => {
it("offers the server's memory limit as the current memory, not the JVM heap", async () => {
calls.status.mockResolvedValue(
status({ phase: "Stopped", desiredState: "Stopped", memory: "4Gi", javaMemory: "3072M", cpu: "2" }),
);
renderConsole();
await userEvent.setup().click(await screen.findByRole("button", { name: /Edit Server Config/ }));
const memory = await screen.findByRole("combobox", { name: "Memory" });
expect(within(memory).getByText("4Gi")).toBeTruthy();
expect(screen.queryByText("3072M")).toBeNull();
expect((screen.getByLabelText("CPU Limit") as HTMLInputElement).value).toBe("2");
});
});
+1 -1
View File
@@ -308,7 +308,7 @@ export function ServerConsole() {
currentDisplayName={data.displayName}
currentPolicy={data.autostartPolicy as AutostartPolicy}
currentImage={data.image}
currentMemory={data.javaMemory}
currentMemory={data.memory}
currentStorage={data.storageSize}
currentCpu={data.cpu}
currentIdleStopSeconds={data.idleStopSeconds}