feat(panel): support full server config edit dialog with status prefilling
This commit is contained in:
8 files changed
+356
-3
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
@@ -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": "正在保存…"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
Reference in new issue
Block a user