feat(panel): support full server config edit dialog with status prefilling

This commit is contained in:
Lemon-miaow committed 2026-07-02 04:24:48 +08:00
1 parent 3b4298d777
commit c0d333bb98
8 files changed
+356 -3

No files matched your search

+5
View File
@@ -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).
+13
View File
@@ -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,
}
}
+283
View File
@@ -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<EditServerForm>({
displayName: currentDisplayName,
autostartPolicy: currentPolicy,
image: currentImage,
memory: currentMemory,
cpu: currentCpu,
});
const [error, setError] = useState<string | null>(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 extends keyof EditServerForm>(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<typeof api.patchServer>[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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{/* Unified Sidebar Link/Card style trigger */}
<button
type="button"
className="group flex w-full text-left items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent focus:outline-none"
>
<Settings className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-medium">{t("edit_server_title")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("edit_server_desc")}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("edit_server_title")}</DialogTitle>
<DialogDescription>
{t("edit_server_desc_long")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="es-display">{t("create_server_display_name")}</Label>
<Input
id="es-display"
placeholder={t("create_server_display_name_placeholder")}
value={form.displayName}
onChange={(e) => set("displayName", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>{t("create_server_image")}</Label>
<Select value={form.image} onValueChange={(v) => set("image", v)}>
<SelectTrigger>
<SelectValue
placeholder={
images.loading
? t("create_server_image_loading")
: enabledImages.length
? t("create_server_image_choose")
: t("create_server_image_none")
}
/>
</SelectTrigger>
<SelectContent>
{/* Fallback to display the current image even if not in the whitelist options list */}
{form.image && !enabledImages.some((img) => img.image_ref === form.image) && (
<SelectItem value={form.image}>{form.image}</SelectItem>
)}
{enabledImages.map((img) => (
<SelectItem key={img.image_ref} value={img.image_ref}>
{img.image_ref}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>{t("create_server_memory")}</Label>
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* Prepend current memory if not present in options */}
{form.memory && !MEMORY_OPTIONS.includes(form.memory) && (
<SelectItem value={form.memory}>{form.memory}</SelectItem>
)}
{MEMORY_OPTIONS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
{/* Storage is immutable but displayed to maintain visual consistency */}
<Label>{t("create_server_storage")}</Label>
<Input value={currentStorage} disabled className="opacity-60 cursor-not-allowed select-none bg-muted" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="es-cpu">CPU 限制</Label>
<Input
id="es-cpu"
placeholder='例如 1, 2, 500m'
value={form.cpu}
onChange={(e) => set("cpu", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>{t("create_server_policy")}</Label>
<Select
value={form.autostartPolicy}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{policies.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
{t("create_server_cancel")}
</Button>
<Button onClick={submit} disabled={!canSubmit}>
{submitting ? t("edit_server_updating") : t("edit_server_submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+8 -1
View File
@@ -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…"
}
+8 -1
View File
@@ -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": "正在保存…"
}
+19
View File
@@ -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
+4
View File
@@ -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).
+16 -1
View File
@@ -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 */}
<div className="flex flex-col gap-4 lg:col-span-1 shrink-0">
{isAdmin && (
<EditServerDialog
serverName={name}
currentDisplayName={data.displayName}
currentPolicy={data.autostartPolicy as AutostartPolicy}
currentImage={data.image}
currentMemory={data.javaMemory}
currentStorage={data.storageSize}
currentCpu={data.cpu}
onUpdated={reload}
/>
)}
{/* 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. */}