From c0d333bb98ab595f05452ae83d4e7225aaa96c47 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 2 Jul 2026 03:41:52 +0800 Subject: [PATCH] feat(panel): support full server config edit dialog with status prefilling --- internal/api/cluster.go | 5 + internal/api/k8scluster.go | 13 + panel/src/components/EditServerDialog.tsx | 283 ++++++++++++++++++++ panel/src/i18n/resources/en-US/servers.json | 9 +- panel/src/i18n/resources/zh-CN/servers.json | 9 +- panel/src/lib/api.ts | 19 ++ panel/src/lib/types.ts | 4 + panel/src/pages/ServerConsole.tsx | 17 +- 8 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 panel/src/components/EditServerDialog.tsx diff --git a/internal/api/cluster.go b/internal/api/cluster.go index 8e65deb..8bdbf49 100644 --- a/internal/api/cluster.go +++ b/internal/api/cluster.go @@ -23,6 +23,11 @@ type ServerInfo struct { EndpointAddress string `json:"endpointAddress,omitempty"` PlayersOnline int32 `json:"playersOnline"` PlayersMax int32 `json:"playersMax"` + DisplayName string `json:"displayName,omitempty"` + Image string `json:"image,omitempty"` + JavaMemory string `json:"javaMemory,omitempty"` + StorageSize string `json:"storageSize,omitempty"` + CPU string `json:"cpu,omitempty"` } // CreateServerInput is the validated, structured create-server form (spec §15). diff --git a/internal/api/k8scluster.go b/internal/api/k8scluster.go index c4fa3a3..70189c2 100644 --- a/internal/api/k8scluster.go +++ b/internal/api/k8scluster.go @@ -4,6 +4,7 @@ import ( "context" "felis.lolicon.best/internal/apis/felis/v1alpha1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -142,6 +143,13 @@ func (k *K8sCluster) PatchServerSpec(ctx context.Context, name string, p ServerS // serverInfo projects a MinecraftServer onto the API's lifecycle view. func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo { + var cpuStr string + if ms.Spec.Resources.Limits != nil { + if limit, ok := ms.Spec.Resources.Limits[corev1.ResourceCPU]; ok { + cpuStr = limit.String() + } + } + return &ServerInfo{ Name: ms.Name, Subdomain: ms.Spec.Subdomain, @@ -153,5 +161,10 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo { EndpointAddress: ms.Status.Endpoint.Address, PlayersOnline: ms.Status.Players.Online, PlayersMax: ms.Status.Players.Max, + DisplayName: ms.Spec.DisplayName, + Image: ms.Spec.Image, + JavaMemory: ms.Spec.JavaMemory, + StorageSize: ms.Spec.Storage.Size, + CPU: cpuStr, } } diff --git a/panel/src/components/EditServerDialog.tsx b/panel/src/components/EditServerDialog.tsx new file mode 100644 index 0000000..a004ead --- /dev/null +++ b/panel/src/components/EditServerDialog.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect } from "react"; +import { Settings, ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import type { AutostartPolicy } from "@/lib/types"; + +const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"]; + +function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] { + return [ + { value: "ownerOnly", label: t("create_server_policy_owner") }, + { value: "public", label: t("create_server_policy_public") }, + { value: "allowlist", label: t("create_server_policy_allowlist") }, + ]; +} + +interface EditServerForm { + displayName: string; + autostartPolicy: AutostartPolicy; + image: string; + memory: string; + cpu: string; +} + +interface Props { + serverName: string; + currentDisplayName?: string; + currentPolicy?: AutostartPolicy; + currentImage?: string; + currentMemory?: string; + currentStorage?: string; + currentCpu?: string; + onUpdated: () => void; +} + +export function EditServerDialog({ + serverName, + currentDisplayName = "", + currentPolicy = "ownerOnly", + currentImage = "", + currentMemory = "", + currentStorage = "", + currentCpu = "", + onUpdated, +}: Props) { + const { t } = useTranslation("servers"); + const [open, setOpen] = useState(false); + const images = useAsync(() => api.listImages(), []); + + const [form, setForm] = useState({ + displayName: currentDisplayName, + autostartPolicy: currentPolicy, + image: currentImage, + memory: currentMemory, + cpu: currentCpu, + }); + + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Sync form state when dialog opens or current values change from server status + useEffect(() => { + if (open) { + setForm({ + displayName: currentDisplayName, + autostartPolicy: currentPolicy, + image: currentImage, + memory: currentMemory, + cpu: currentCpu, + }); + setError(null); + } + }, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu]); + + function set(k: K, v: EditServerForm[K]) { + setForm((f) => ({ ...f, [k]: v })); + } + + const enabledImages = (images.data ?? []).filter((i) => i.enabled); + + // Checks if form state has mutated from initial values + const hasChanges = + form.displayName !== currentDisplayName || + form.autostartPolicy !== currentPolicy || + form.image !== currentImage || + form.memory !== currentMemory || + form.cpu !== currentCpu; + + const canSubmit = hasChanges && !submitting; + + async function submit() { + setSubmitting(true); + setError(null); + try { + const payload: Parameters[1] = {}; + + if (form.displayName !== currentDisplayName) { + payload.displayName = form.displayName.trim() || undefined; + } + if (form.autostartPolicy !== currentPolicy) { + payload.autostartPolicy = form.autostartPolicy; + } + if (form.image !== currentImage) { + payload.image = form.image; + } + if (form.memory !== currentMemory) { + payload.memory = form.memory; + } + if (form.cpu !== currentCpu) { + payload.resources = { + cpu: form.cpu.trim(), + }; + } + + await api.patchServer(serverName, payload); + setOpen(false); + onUpdated(); + } catch (e) { + setError(humanizeError(e)); + } finally { + setSubmitting(false); + } + } + + const policies = policyOptions(t); + + return ( + + + {/* Unified Sidebar Link/Card style trigger */} + + + + + {t("edit_server_title")} + + {t("edit_server_desc_long")} + + + +
+
+ + set("displayName", e.target.value)} + /> +
+ +
+ + +
+ +
+
+ + +
+
+ {/* Storage is immutable but displayed to maintain visual consistency */} + + +
+
+ +
+
+ + set("cpu", e.target.value)} + /> +
+
+ + +
+
+ + {error &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 166d9e3..69ce957 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -128,5 +128,12 @@ "create_server_creating": "Creating…", "no_servers_managed": "No servers under your management yet", "no_servers_managed_hint": "Use \"New server\" to provision one from the vetted spec.", - "server_admin_footer": "Server creation goes through the structured form only — the platform maps your choices onto a vetted Kubernetes spec. Raw cluster config (host networking, host paths, arbitrary images, privileged pods) is never expressible here." + "server_admin_footer": "Server creation goes through the structured form only — the platform maps your choices onto a vetted Kubernetes spec. Raw cluster config (host networking, host paths, arbitrary images, privileged pods) is never expressible here.", + "edit_server_title": "Edit Server Config", + "edit_server_desc": "Configure display name, autostart policy, image, memory, and CPU", + "edit_server_desc_long": "Updating server spec. Fields left unchanged will retain their current values.", + "edit_server_unchanged": "Leave unchanged", + "edit_server_immutable": "Unchanged (Immutable)", + "edit_server_submit": "Save Config", + "edit_server_updating": "Saving…" } diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 4d614e7..366fefd 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -128,5 +128,12 @@ "create_server_creating": "创建中…", "no_servers_managed": "你还没有管理的服务器", "no_servers_managed_hint": "使用「新建服务器」在线分配。", - "server_admin_footer": "服务器仅通过此结构化表单创建——平台将选择映射为审核后的 Kubernetes spec。宿主机网络、宿主机路径、任意镜像、特权 Pod 等原始集群配置在此不可表达。" + "server_admin_footer": "服务器仅通过此结构化表单创建——平台将选择映射为审核后的 Kubernetes spec。宿主机网络、宿主机路径、任意镜像、特权 Pod 等原始集群配置在此不可表达。", + "edit_server_title": "编辑服务器配置", + "edit_server_desc": "配置显示名、自启策略、镜像、内存与CPU", + "edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。", + "edit_server_unchanged": "保持不变", + "edit_server_immutable": "保持不变 (不可修改)", + "edit_server_submit": "保存配置", + "edit_server_updating": "正在保存…" } diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 7f2e0e9..120ef05 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -1,6 +1,7 @@ import type { AccessResult, ApiError, + AutostartPolicy, BackupView, BanlistResult, CreateServerRequest, @@ -162,6 +163,24 @@ export const api = { req, ), + patchServer: (name: string, req: { + displayName?: string; + autostartPolicy?: AutostartPolicy; + image?: string; + memory?: string; + resources?: { + cpu?: string; + cpuRequest?: string; + memory?: string; + memoryRequest?: string; + }; + }) => + request<{ name: string; desiredState: string }>( + "PATCH", + `/servers/${name}`, + req, + ), + // World backups (spec §7). listBackups is the app-tier read: an admin sees every // present backup, a user only the backups of worlds they formerly owned — the // scope is decided server-side from the principal, not by any client filter, so a diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 2ff319d..dc89754 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -32,6 +32,10 @@ export interface ServerInfo { claimable?: boolean; /** Whether the caller owns it. */ owned?: boolean; + image?: string; + javaMemory?: string; + storageSize?: string; + cpu?: string; } /** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access). diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index d1ef2cf..5dfd391 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -9,8 +9,10 @@ import { LogConsole } from "@/components/LogConsole"; import { Loading, ErrorState } from "@/components/States"; import { api, consoleStreamURL, humanizeError } from "@/lib/api"; import { useAsync, useConfig } from "@/lib/hooks"; +import { useTier } from "@/lib/tier"; import { hostFor } from "@/lib/config"; -import type { Phase } from "@/lib/types"; +import type { Phase, AutostartPolicy } from "@/lib/types"; +import { EditServerDialog } from "@/components/EditServerDialog"; // notStreamingCopy explains why there is no live feed for a phase that has no // streamable pod. The read path only has something to relay once a pod is up, so @@ -172,6 +174,7 @@ function CommandInput({ name }: { name: string }) { export function ServerConsole() { const { name = "" } = useParams(); const cfg = useConfig(); + const { isAdmin } = useTier(); const { t } = useTranslation("servers"); const { data, error, loading, reload } = useAsync( () => api.status(name), @@ -267,6 +270,18 @@ export function ServerConsole() { {/* Right/Sidebar column: Navigation */}
+ {isAdmin && ( + + )} {/* Player management lives on its own subpage (whitelist / online / bans), not crammed under the console. This is the doorway to it; the page itself owns the ownership + readiness gating. */}