265 lines
9.4 KiB
TypeScript
265 lines
9.4 KiB
TypeScript
import { useState } from "react";
|
|
import { Plus } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
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 { hostFor, type RuntimeConfig } from "@/lib/config";
|
|
import { DISPLAY_NAME_MAX, serverNameIssue, type ServerNameIssue } from "@/lib/naming";
|
|
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
|
|
import { InlineError } from "@/components/MessageLine";
|
|
|
|
// CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every
|
|
// dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities)
|
|
// is simply absent from the UI. The image is chosen from the server-side whitelist
|
|
// (GET /images); memory/storage are constrained text the API re-validates. Nothing
|
|
// here can express a privileged pod — the form's value space IS the allowlist.
|
|
|
|
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
|
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];
|
|
|
|
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 Props {
|
|
cfg: RuntimeConfig;
|
|
onCreated: () => void;
|
|
}
|
|
|
|
export function CreateServerDialog({ cfg, onCreated }: Props) {
|
|
const { t } = useTranslation("servers");
|
|
const [open, setOpen] = useState(false);
|
|
const images = useAsync(() => api.listImages(), []);
|
|
|
|
const [form, setForm] = useState<CreateServerRequest>({
|
|
name: "",
|
|
subdomain: "",
|
|
displayName: "",
|
|
image: "",
|
|
memory: "4Gi",
|
|
storage: "10Gi",
|
|
autostartPolicy: "ownerOnly",
|
|
});
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
function set<K extends keyof CreateServerRequest>(k: K, v: CreateServerRequest[K]) {
|
|
setForm((f) => ({ ...f, [k]: v }));
|
|
}
|
|
|
|
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
|
|
const nameIssue = serverNameIssue(form.name);
|
|
const subdomainIssue = serverNameIssue(form.subdomain);
|
|
const canSubmit =
|
|
nameIssue === null &&
|
|
subdomainIssue === null &&
|
|
!!form.image &&
|
|
!!form.memory &&
|
|
!!form.storage &&
|
|
!submitting;
|
|
|
|
async function submit() {
|
|
setSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
const payload: CreateServerRequest = {
|
|
...form,
|
|
displayName: form.displayName?.trim() || undefined,
|
|
};
|
|
await api.createServer(payload);
|
|
setOpen(false);
|
|
onCreated();
|
|
setForm((f) => ({ ...f, name: "", subdomain: "", displayName: "" }));
|
|
} catch (e) {
|
|
setError(humanizeError(e));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const policies = policyOptions(t);
|
|
|
|
// A field's hint turns into the reason it would be refused once something is typed.
|
|
const issueText = (issue: ServerNameIssue, value: string) =>
|
|
issue === "reserved" ? t("create_server_name_reserved", { name: value }) : t("create_server_name_invalid");
|
|
const hintClass = (value: string, issue: ServerNameIssue | null) =>
|
|
value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground";
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" className="gap-1.5">
|
|
<Plus className="h-4 w-4" />
|
|
{t("create_server_btn")}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("create_server_title")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("create_server_desc")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cs-name">{t("create_server_name")}</Label>
|
|
<Input
|
|
id="cs-name"
|
|
placeholder={t("create_server_name_placeholder")}
|
|
value={form.name}
|
|
onChange={(e) => set("name", e.target.value.toLowerCase())}
|
|
aria-invalid={!!form.name && nameIssue !== null}
|
|
aria-describedby="cs-name-hint"
|
|
/>
|
|
<p id="cs-name-hint" className={hintClass(form.name, nameIssue)}>
|
|
{form.name && nameIssue
|
|
? issueText(nameIssue, form.name)
|
|
: t("create_server_name_hint")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cs-sub">{t("create_server_subdomain")}</Label>
|
|
<Input
|
|
id="cs-sub"
|
|
placeholder={t("create_server_subdomain_placeholder")}
|
|
value={form.subdomain}
|
|
onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
|
|
aria-invalid={!!form.subdomain && subdomainIssue !== null}
|
|
aria-describedby="cs-sub-hint"
|
|
/>
|
|
<p id="cs-sub-hint" className={hintClass(form.subdomain, subdomainIssue)}>
|
|
{form.subdomain && subdomainIssue
|
|
? issueText(subdomainIssue, form.subdomain)
|
|
: t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cs-display">{t("create_server_display_name")}</Label>
|
|
<Input
|
|
id="cs-display"
|
|
placeholder={t("create_server_display_name_placeholder")}
|
|
maxLength={DISPLAY_NAME_MAX}
|
|
value={form.displayName ?? ""}
|
|
onChange={(e) => set("displayName", e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="create-server-image">{t("create_server_image")}</Label>
|
|
<Select value={form.image} onValueChange={(v) => set("image", v)}>
|
|
<SelectTrigger id="create-server-image">
|
|
<SelectValue
|
|
placeholder={
|
|
images.loading
|
|
? t("create_server_image_loading")
|
|
: enabledImages.length
|
|
? t("create_server_image_choose")
|
|
: t("create_server_image_none")
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{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 htmlFor="create-server-memory">{t("create_server_memory")}</Label>
|
|
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
|
|
<SelectTrigger id="create-server-memory">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{MEMORY_OPTIONS.map((m) => (
|
|
<SelectItem key={m} value={m}>
|
|
{m}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="create-server-storage">{t("create_server_storage")}</Label>
|
|
<Select value={form.storage} onValueChange={(v) => set("storage", v)}>
|
|
<SelectTrigger id="create-server-storage">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STORAGE_OPTIONS.map((s) => (
|
|
<SelectItem key={s} value={s}>
|
|
{s}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="create-server-policy">{t("create_server_policy")}</Label>
|
|
<Select
|
|
value={form.autostartPolicy}
|
|
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
|
|
>
|
|
<SelectTrigger id="create-server-policy">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{policies.map((p) => (
|
|
<SelectItem key={p.value} value={p.value}>
|
|
{p.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<InlineError message={error} />
|
|
</div>
|
|
|
|
<ConfirmFooter
|
|
onCancel={() => setOpen(false)}
|
|
onConfirm={submit}
|
|
disabled={!canSubmit}
|
|
loading={submitting}
|
|
cancelLabel={t("create_server_cancel")}
|
|
confirmLabel={t("create_server_submit")}
|
|
confirmVariant="default"
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|