Unverified Commit c0d333bb authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 3b4298d7
Loading
Loading
Loading
Loading
+5 −0
Changes for internal/api/cluster.go: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/k8scluster.go: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for panel/src/components/EditServerDialog.tsx: 283 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
Changes for panel/src/i18n/resources/en-US/servers.json: 8 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for panel/src/i18n/resources/zh-CN/servers.json: 8 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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": "正在保存…"
}
Loading