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

fix(panel): 删除镜像与取消构建改用面板确认对话框,失败原因显示在对话框内,镜像来源徽标跟随界面语言

parent a5307d44
Loading
Loading
Loading
Loading
+90 −0
Changes for panel/src/components/ConfirmDialog.test.tsx: 90 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { ConfirmDialog } from "./ConfirmDialog";

function Harness({ onConfirm }: { onConfirm: () => Promise<void> }) {
  const [open, setOpen] = useState(true);
  return (
    <>
      <span>{open ? "open" : "closed"}</span>
      <button onClick={() => setOpen(true)}>reopen</button>
      <ConfirmDialog
        open={open}
        onOpenChange={setOpen}
        title="Remove it?"
        description="It cannot come back."
        confirmLabel="Remove"
        onConfirm={onConfirm}
      />
    </>
  );
}

describe("ConfirmDialog", () => {
  it("closes once the action succeeds", async () => {
    const action = vi.fn().mockResolvedValue(undefined);
    render(<Harness onConfirm={action} />);

    await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }));
    expect(action).toHaveBeenCalledTimes(1);
    expect(await screen.findByText("closed")).toBeTruthy();
    expect(screen.queryByRole("dialog")).toBeNull();
  });

  it("keeps the dialog open with the reason when the action fails, and lets it be retried", async () => {
    const action = vi
      .fn()
      .mockRejectedValueOnce({ status: 409, code: "test", message: "still in use" })
      .mockResolvedValueOnce(undefined);
    render(<Harness onConfirm={action} />);

    const dialog = screen.getByRole("dialog");
    await userEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
    expect((await within(dialog).findByRole("alert")).textContent).toBe("still in use");
    expect(screen.getByText("open")).toBeTruthy();

    await userEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
    expect(await screen.findByText("closed")).toBeTruthy();
    expect(action).toHaveBeenCalledTimes(2);
  });

  it("cannot be dismissed while the action runs, and clears the old failure on retry", async () => {
    let finish!: (v?: unknown) => void;
    const action = vi
      .fn()
      .mockRejectedValueOnce({ status: 409, code: "test", message: "still in use" })
      .mockImplementationOnce(() => new Promise((r) => (finish = r)));
    render(<Harness onConfirm={action} />);
    const dialog = screen.getByRole("dialog");
    const cancel = within(dialog).getByRole("button", { name: i18next.t("common:cancel") });

    await userEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
    await within(dialog).findByRole("alert");
    await userEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
    expect(within(dialog).queryByRole("alert")).toBeNull();
    await userEvent.keyboard("{Escape}");
    await userEvent.click(cancel, { pointerEventsCheck: 0 });
    expect(screen.getByText("open")).toBeTruthy();

    finish();
    expect(await screen.findByText("closed")).toBeTruthy();
  });

  it("forgets an old failure when reopened", async () => {
    const action = vi.fn().mockRejectedValue({ status: 409, code: "test", message: "still in use" });
    render(<Harness onConfirm={action} />);
    const dialog = screen.getByRole("dialog");

    await userEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
    await within(dialog).findByRole("alert");
    await userEvent.click(within(dialog).getByRole("button", { name: i18next.t("common:cancel") }));
    expect(await screen.findByText("closed")).toBeTruthy();

    await userEvent.click(screen.getByRole("button", { name: "reopen" }));
    expect(within(await screen.findByRole("dialog")).queryByRole("alert")).toBeNull();
  });
});
+84 −0
Changes for panel/src/components/ConfirmDialog.tsx: 84 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine } from "@/components/MessageLine";
import { humanizeError } from "@/lib/api";

interface Props {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  description?: ReactNode;
  confirmLabel: string;
  /** Defaults to "Cancel"; name what dismissing keeps when the action is itself a cancel. */
  cancelLabel?: string;
  confirmVariant?: "destructive" | "default";
  /** Runs the action. The dialog closes when it resolves; a rejection is shown in
   *  the dialog, which stays open so the action can be retried or dismissed. */
  onConfirm: () => Promise<void>;
}

// ConfirmDialog asks before an action that cannot be taken back, in the panel's
// own dialog rather than window.confirm (which some embedded browsers block, so
// the action could never be confirmed there). The failure stays next to the
// buttons that caused it.
export function ConfirmDialog({
  open,
  onOpenChange,
  title,
  description,
  confirmLabel,
  cancelLabel,
  confirmVariant = "destructive",
  onConfirm,
}: Props) {
  const { t } = useTranslation("common");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  function setOpen(next: boolean) {
    if (busy) return;
    if (!next) setError(null);
    onOpenChange(next);
  }

  async function confirm() {
    setBusy(true);
    setError(null);
    try {
      await onConfirm();
      setBusy(false);
      onOpenChange(false);
    } catch (err) {
      setError(humanizeError(err));
      setBusy(false);
    }
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          {description && <DialogDescription>{description}</DialogDescription>}
        </DialogHeader>
        {error && <MessageLine kind="error" message={error} compact />}
        <ConfirmFooter
          onCancel={() => setOpen(false)}
          onConfirm={() => void confirm()}
          loading={busy}
          cancelLabel={cancelLabel ?? t("cancel")}
          confirmLabel={confirmLabel}
          confirmVariant={confirmVariant}
        />
      </DialogContent>
    </Dialog>
  );
}
+10 −2
Changes for panel/src/i18n/resources/en-US/admin.json: 10 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -9,8 +9,16 @@
  "add_image_btn": "Add Image",
  "image_ref_placeholder": "e.g. registry.felis.svc:5000/image:tag",
  "delete_image_tooltip": "Delete Whitelist Image",
  "delete_confirm": "Are you sure you want to delete this image?",
  "cancel_confirm": "Are you sure you want to cancel this build job?",
  "image_source_built": "Built here",
  "image_source_external": "External",
  "image_source_recommended": "Recommended",
  "delete_image_title": "Remove this image from the whitelist?",
  "delete_image_desc": "New servers can no longer be created from",
  "delete_image_confirm": "Remove image",
  "cancel_build_title": "Stop this build?",
  "cancel_build_desc": "The build stops now and pushes nothing:",
  "cancel_build_keep": "Keep building",
  "cancel_build_confirm": "Stop build",
  "delete_btn": "Delete",
  "builds_title": "Build Pipeline",
  "builds_subtitle": "History of image build jobs and trigger controls. The build pod runs inside an isolated namespace with automated Trivy scans.",
+10 −2
Changes for panel/src/i18n/resources/zh-CN/admin.json: 10 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -9,8 +9,16 @@
  "add_image_btn": "添加镜像",
  "image_ref_placeholder": "例如: registry.felis.svc:5000/image:tag",
  "delete_image_tooltip": "删除白名单镜像",
  "delete_confirm": "确定删除此镜像吗?",
  "cancel_confirm": "确定要取消该构建任务吗?",
  "image_source_built": "平台构建",
  "image_source_external": "外部",
  "image_source_recommended": "推荐",
  "delete_image_title": "从白名单移除这个镜像?",
  "delete_image_desc": "移除后,新服务器将不能再选用",
  "delete_image_confirm": "移除镜像",
  "cancel_build_title": "停止这次构建?",
  "cancel_build_desc": "构建会立即停止,不会推送任何镜像:",
  "cancel_build_keep": "继续构建",
  "cancel_build_confirm": "停止构建",
  "delete_btn": "删除",
  "builds_title": "构建流水线",
  "builds_subtitle": "平台镜像构建任务历史及触发终端。构建 Pod 运行在隔离沙箱中,完成构建后将自动运行安全扫描。",
+96 −0
Changes for panel/src/pages/admin/ImageAdmin.test.tsx: 96 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import i18next from "i18next";
import { ImageAdmin } from "./ImageAdmin";

const calls = vi.hoisted(() => ({ listImages: vi.fn(), removeImage: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/api")>();
  return { ...actual, api: { ...actual.api, ...calls } };
});

const t = (key: string) => i18next.t(key);
const REF = "registry.felis.svc:5000/paper:1.21";

beforeEach(() => {
  for (const fn of Object.values(calls)) fn.mockReset();
  calls.listImages.mockResolvedValue([{ image_ref: REF, source: "external", enabled: true }]);
  // A native dialog here would block (or be blocked in an embedded browser).
  vi.spyOn(window, "confirm").mockImplementation(() => {
    throw new Error("window.confirm used");
  });
  vi.spyOn(window, "alert").mockImplementation(() => {
    throw new Error("window.alert used");
  });
});
afterEach(() => vi.restoreAllMocks());

async function openRemove() {
  render(
    <MemoryRouter>
      <ImageAdmin />
    </MemoryRouter>,
  );
  await userEvent.click(await screen.findByRole("button", { name: t("admin:delete_image_tooltip") }));
  return screen.getByRole("dialog", { name: t("admin:delete_image_title") });
}

describe("ImageAdmin removal", () => {
  it("asks in a dialog that names the image, then removes it", async () => {
    calls.removeImage.mockResolvedValue(undefined);
    const dialog = await openRemove();
    expect(within(dialog).getByText(REF)).toBeTruthy();
    expect(calls.removeImage).not.toHaveBeenCalled();

    await userEvent.click(within(dialog).getByRole("button", { name: t("admin:delete_image_confirm") }));
    expect(calls.removeImage).toHaveBeenCalledWith(REF);
    await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
    expect(calls.listImages).toHaveBeenCalledTimes(2);
  });

  it("shows a failed removal in the dialog", async () => {
    calls.removeImage.mockRejectedValue({ status: 409, code: "test", message: "a server still uses it" });
    const dialog = await openRemove();

    await userEvent.click(within(dialog).getByRole("button", { name: t("admin:delete_image_confirm") }));
    expect((await within(dialog).findByRole("alert")).textContent).toBe("a server still uses it");
    expect(screen.getByRole("dialog")).toBeTruthy();
  });

  it("leaves the image alone when the dialog is dismissed", async () => {
    const dialog = await openRemove();
    await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
    expect(screen.queryByRole("dialog")).toBeNull();
    expect(calls.removeImage).not.toHaveBeenCalled();
  });
});

describe("ImageAdmin source", () => {
  afterEach(() => i18next.changeLanguage("en-US"));

  it("names the sources Felis writes in the UI language, finds them by that name, and shows others as stored", async () => {
    calls.listImages.mockResolvedValue([
      { image_ref: "registry.felis.svc:5000/paper:demo", source: "recommended", enabled: true },
      { image_ref: "docker.io/itzg/minecraft-server:java21", source: "external", enabled: true },
      { image_ref: "mirror.example.test/forge:1.20", source: "community-mirror", enabled: true },
    ]);
    await i18next.changeLanguage("zh-CN");
    render(
      <MemoryRouter>
        <ImageAdmin />
      </MemoryRouter>,
    );

    expect(await screen.findByText("推荐")).toBeTruthy();
    expect(screen.getByText("外部")).toBeTruthy();
    expect(screen.getByText("community-mirror")).toBeTruthy();
    expect(screen.queryByText("recommended")).toBeNull();

    await userEvent.type(screen.getByRole("textbox"), "推荐");
    await vi.waitFor(() => expect(screen.queryByText("docker.io/itzg/minecraft-server:java21")).toBeNull());
    expect(screen.getByText("registry.felis.svc:5000/paper:demo")).toBeTruthy();
  });
});
Loading