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

fix(panel): 建服对话框每次打开重读镜像白名单,编辑或重开时清掉旧错误,读不到白名单时说明并可重试

parent c1bf1a81
Loading
Loading
Loading
Loading
+69 −0
Changes for panel/src/components/CreateServerDialog.test.tsx: 69 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -189,3 +189,72 @@ describe("CreateServerDialog names", () => {
    expect(subdomainBox().getAttribute("aria-invalid")).toBe("false");
  });
});

describe("CreateServerDialog on reopening", () => {
  const cancel = () => screen.getByRole("button", { name: "Cancel" });
  const reopen = async (user: ReturnType<typeof userEvent.setup>) => {
    await user.click(screen.getByRole("button", { name: "New server" }));
    await screen.findByRole("dialog");
  };

  it("rereads the image whitelist each time it opens", async () => {
    const NEW = "registry.example.test/fabric:1.21";
    const user = await openDialog();
    await user.click(cancel());
    expect(calls.listImages).toHaveBeenCalledTimes(1);

    calls.listImages.mockResolvedValue([image(PAPER, true), image(NEW, true)]);
    await reopen(user);
    expect(calls.listImages).toHaveBeenCalledTimes(2);
    await user.click(screen.getByRole("combobox", { name: "Image" }));
    const options = await screen.findAllByRole("option");
    expect(options.map((o) => o.textContent)).toEqual([PAPER, NEW]);
  });

  it("drops a chosen image that was disabled while the dialog was closed", async () => {
    const user = await openDialog();
    await fillValid(user);
    expect(create().disabled).toBe(false);
    await user.click(cancel());

    calls.listImages.mockResolvedValue([image(PAPER, false)]);
    await reopen(user);
    expect(await screen.findByText("No whitelisted images")).toBeTruthy();
    expect(nameBox().value).toBe("survival");
    expect(create().disabled).toBe(true);
  });

  it("clears a refusal once the form is edited, and when the dialog opens again", async () => {
    const refusal = { status: 409, code: "conflict", message: "server survival already exists" };
    calls.createServer.mockRejectedValue(refusal);
    const user = await openDialog();
    await fillValid(user);

    await user.click(create());
    expect(screen.getByText(humanizeError(refusal))).toBeTruthy();
    await user.type(nameBox(), "-2");
    expect(screen.queryByText(humanizeError(refusal))).toBeNull();

    await user.click(create());
    expect(screen.getByText(humanizeError(refusal))).toBeTruthy();
    await user.click(cancel());
    await reopen(user);
    expect(screen.queryByText(humanizeError(refusal))).toBeNull();
  });

  it("says when the whitelist could not be read, and reads it again on request", async () => {
    const outage = { status: 503, code: "unavailable", message: "images unavailable" };
    calls.listImages.mockRejectedValueOnce(outage);
    const user = await openDialog();

    const alert = await screen.findByRole("alert");
    expect(alert.textContent).toContain(humanizeError(outage));
    expect(screen.getByText("Image list not loaded")).toBeTruthy();
    expect(screen.queryByText("No whitelisted images")).toBeNull();

    await user.click(within(alert).getByRole("button", { name: "Try again" }));
    expect(calls.listImages).toHaveBeenCalledTimes(2);
    await pick(user, "Image", PAPER);
    expect(screen.queryByRole("alert")).toBeNull();
  });
});
+26 −4
Changes for panel/src/components/CreateServerDialog.tsx: 26 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -52,7 +52,9 @@ interface Props {
export function CreateServerDialog({ cfg, onCreated }: Props) {
  const { t } = useTranslation("servers");
  const [open, setOpen] = useState(false);
  const images = useAsync(() => api.listImages(), []);
  // The whitelist is read each time the dialog opens, so an image added, enabled
  // or retired since the page loaded shows up or drops out without a reload.
  const images = useAsync(() => (open ? api.listImages() : Promise.resolve(null)), [open]);

  const [form, setForm] = useState<CreateServerRequest>({
    name: "",
@@ -66,17 +68,27 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
  const [error, setError] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);

  // A refusal answers the form as it was sent; any edit, or opening the dialog
  // again, starts past it.
  function set<K extends keyof CreateServerRequest>(k: K, v: CreateServerRequest[K]) {
    setForm((f) => ({ ...f, [k]: v }));
    setError(null);
  }

  function openChange(next: boolean) {
    setOpen(next);
    if (next) setError(null);
  }

  const enabledImages = (images.data ?? []).filter((i) => i.enabled);
  // An image chosen before it was disabled or removed no longer counts as chosen.
  const image = enabledImages.some((i) => i.image_ref === form.image) ? form.image : "";
  const nameIssue = serverNameIssue(form.name);
  const subdomainIssue = serverNameIssue(form.subdomain);
  const canSubmit =
    nameIssue === null &&
    subdomainIssue === null &&
    !!form.image &&
    !!image &&
    !!form.memory &&
    !!form.storage &&
    !submitting;
@@ -109,7 +121,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
    value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground";

  return (
    <Dialog open={open} onOpenChange={setOpen}>
    <Dialog open={open} onOpenChange={openChange}>
      <DialogTrigger asChild>
        <Button size="sm" className="gap-1.5">
          <Plus className="h-4 w-4" />
@@ -172,12 +184,14 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {

          <div className="grid gap-2">
            <Label htmlFor="create-server-image">{t("create_server_image")}</Label>
            <Select value={form.image} onValueChange={(v) => set("image", v)}>
            <Select value={image} onValueChange={(v) => set("image", v)}>
              <SelectTrigger id="create-server-image">
                <SelectValue
                  placeholder={
                    images.loading
                      ? t("create_server_image_loading")
                      : images.error
                        ? t("create_server_image_unavailable")
                        : enabledImages.length
                          ? t("create_server_image_choose")
                          : t("create_server_image_none")
@@ -192,6 +206,14 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
                ))}
              </SelectContent>
            </Select>
            {!!images.error && (
              <div role="alert" className="flex items-center justify-between gap-3 text-xs text-destructive">
                <span>{t("create_server_image_failed", { reason: humanizeError(images.error) })}</span>
                <Button variant="outline" size="sm" className="shrink-0" onClick={images.reload}>
                  {t("common:try_again")}
                </Button>
              </div>
            )}
          </div>

          <div className="grid grid-cols-2 gap-4">
+2 −0
Changes for panel/src/i18n/resources/en-US/servers.json: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -144,6 +144,8 @@
  "create_server_image": "Image",
  "create_server_image_loading": "Loading images…",
  "create_server_image_choose": "Choose an image",
  "create_server_image_unavailable": "Image list not loaded",
  "create_server_image_failed": "Couldn't load the image list: {{reason}}",
  "create_server_image_none": "No whitelisted images",
  "create_server_memory": "Memory",
  "create_server_storage": "Storage",
+2 −0
Changes for panel/src/i18n/resources/zh-CN/servers.json: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -144,6 +144,8 @@
  "create_server_image": "镜像",
  "create_server_image_loading": "加载镜像列表中…",
  "create_server_image_choose": "选择一个镜像",
  "create_server_image_unavailable": "镜像列表未读到",
  "create_server_image_failed": "镜像列表读取失败:{{reason}}",
  "create_server_image_none": "暂无白名单镜像",
  "create_server_memory": "内存",
  "create_server_storage": "存储",