fix(panel): 删除镜像与取消构建改用面板确认对话框,失败原因显示在对话框内,镜像来源徽标跟随界面语言
This commit is contained in:
8 files changed
+446
-39
No files matched your search
@@ -0,0 +1,90 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 运行在隔离沙箱中,完成构建后将自动运行安全扫描。",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Loader2, Wrench } from "lucide-react";
|
||||
import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Wrench } from "lucide-react";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -27,6 +29,19 @@ import { useAsync } from "@/lib/hooks";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// image_whitelist.source is plain text (build.Source* in Go); the values Felis
|
||||
// writes itself get a name in the UI language, anything else shows as stored.
|
||||
const SOURCE_KEYS: Record<string, string> = {
|
||||
built: "image_source_built",
|
||||
external: "image_source_external",
|
||||
recommended: "image_source_recommended",
|
||||
};
|
||||
|
||||
function sourceLabel(source: string, t: TFunction): string {
|
||||
const key = SOURCE_KEYS[source];
|
||||
return key ? t(key) : source;
|
||||
}
|
||||
|
||||
export function ImageAdmin() {
|
||||
const { t } = useTranslation("admin");
|
||||
const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
|
||||
@@ -37,7 +52,7 @@ export function ImageAdmin() {
|
||||
const [newImageRef, setNewImageRef] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [deletingRef, setDeletingRef] = useState<string | null>(null);
|
||||
const [removeRef, setRemoveRef] = useState<string | null>(null);
|
||||
|
||||
// Search & Filtering State
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -64,7 +79,9 @@ export function ImageAdmin() {
|
||||
list = list.filter(
|
||||
(img) =>
|
||||
img.image_ref.toLowerCase().includes(q) ||
|
||||
(img.source && img.source.toLowerCase().includes(q))
|
||||
(img.source &&
|
||||
(img.source.toLowerCase().includes(q) ||
|
||||
sourceLabel(img.source, t).toLowerCase().includes(q)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +93,7 @@ export function ImageAdmin() {
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [images, search, statusFilter]);
|
||||
}, [images, search, statusFilter, t]);
|
||||
|
||||
// Reset page when filter changes
|
||||
const lastFilterKey = `${search}-${statusFilter}`;
|
||||
@@ -109,16 +126,8 @@ export function ImageAdmin() {
|
||||
}
|
||||
|
||||
async function handleRemove(imageRef: string) {
|
||||
if (!confirm(t("delete_confirm"))) return;
|
||||
setDeletingRef(imageRef);
|
||||
try {
|
||||
await api.removeImage(imageRef);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(humanizeError(err));
|
||||
} finally {
|
||||
setDeletingRef(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -279,7 +288,7 @@ export function ImageAdmin() {
|
||||
<div className="col-span-2 py-1">
|
||||
{img.source ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-medium capitalize">
|
||||
{img.source}
|
||||
{sourceLabel(img.source, t)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60">—</span>
|
||||
@@ -304,17 +313,12 @@ export function ImageAdmin() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(img.image_ref)}
|
||||
disabled={deletingRef === img.image_ref}
|
||||
onClick={() => setRemoveRef(img.image_ref)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
aria-label={t("delete_image_tooltip")}
|
||||
title={t("delete_image_tooltip")}
|
||||
>
|
||||
{deletingRef === img.image_ref ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,6 +340,20 @@ export function ImageAdmin() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={removeRef !== null}
|
||||
onOpenChange={(o) => !o && setRemoveRef(null)}
|
||||
title={t("delete_image_title")}
|
||||
description={
|
||||
<>
|
||||
{t("delete_image_desc")}{" "}
|
||||
<code className="break-all font-mono text-foreground">{removeRef}</code>
|
||||
</>
|
||||
}
|
||||
confirmLabel={t("delete_image_confirm")}
|
||||
onConfirm={() => handleRemove(removeRef!)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @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 { ImageBuildPage } from "./ImageBuildPage";
|
||||
import type { Build } from "@/lib/types";
|
||||
|
||||
const calls = vi.hoisted(() => ({ getBuild: vi.fn(), cancelBuild: vi.fn(), listSubmissions: vi.fn() }));
|
||||
vi.mock("@/lib/tier", () => ({
|
||||
useTier: () => ({ loading: false, identity: { user_id: "owner-1", role: "owner" }, isAdmin: true, isOwner: true }),
|
||||
}));
|
||||
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
|
||||
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 BUILD: Build = {
|
||||
id: "b-1",
|
||||
image_ref: "registry.felis.svc:5000/modpack:7",
|
||||
status: "building",
|
||||
requested_by: "owner-1",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Node's own (unconfigured) localStorage shadows jsdom's, so give the page one.
|
||||
function memoryStorage(): Storage {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return m.size;
|
||||
},
|
||||
key: (i) => [...m.keys()][i] ?? null,
|
||||
getItem: (k) => m.get(k) ?? null,
|
||||
setItem: (k, v) => void m.set(k, String(v)),
|
||||
removeItem: (k) => void m.delete(k),
|
||||
clear: () => m.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(calls)) fn.mockReset();
|
||||
vi.stubGlobal("localStorage", memoryStorage());
|
||||
localStorage.setItem("felis_triggered_builds", JSON.stringify([BUILD.id]));
|
||||
calls.getBuild.mockResolvedValue(BUILD);
|
||||
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();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function openCancel() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ImageBuildPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await userEvent.click(await screen.findByRole("button", { name: t("admin:cancel_build_btn") }));
|
||||
return screen.getByRole("dialog", { name: t("admin:cancel_build_title") });
|
||||
}
|
||||
|
||||
describe("ImageBuildPage cancel", () => {
|
||||
it("asks in a dialog that names the image, then stops the build", async () => {
|
||||
calls.cancelBuild.mockResolvedValue(undefined);
|
||||
const dialog = await openCancel();
|
||||
expect(within(dialog).getByText(BUILD.image_ref)).toBeTruthy();
|
||||
expect(calls.cancelBuild).not.toHaveBeenCalled();
|
||||
|
||||
calls.getBuild.mockResolvedValue({ ...BUILD, status: "cancelled" });
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: t("admin:cancel_build_confirm") }));
|
||||
expect(calls.cancelBuild).toHaveBeenCalledWith(BUILD.id);
|
||||
await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: t("admin:cancel_build_btn") })).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps building when the dialog is dismissed", async () => {
|
||||
const dialog = await openCancel();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: t("admin:cancel_build_keep") }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(calls.cancelBuild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a refused cancel in the dialog", async () => {
|
||||
calls.cancelBuild.mockRejectedValue({ status: 409, code: "test", message: "build already finished" });
|
||||
const dialog = await openCancel();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: t("admin:cancel_build_confirm") }));
|
||||
expect((await within(dialog).findByRole("alert")).textContent).toBe("build already finished");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useMemo, Fragment } from "react";
|
||||
import { Cpu, Terminal, Loader2, XCircle, AlertCircle, Plus, ChevronDown } from "lucide-react";
|
||||
import { Cpu, Terminal, XCircle, AlertCircle, Plus, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -123,7 +124,7 @@ export function ImageBuildPage() {
|
||||
const [builds, setBuilds] = useState<Build[]>([]);
|
||||
const [loadingBuilds, setLoadingBuilds] = useState(true);
|
||||
const [activeLogBuildId, setActiveLogBuildId] = useState<string | null>(null);
|
||||
const [cancellingId, setCancellingId] = useState<string | null>(null);
|
||||
const [cancelBuild, setCancelBuild] = useState<Build | null>(null);
|
||||
|
||||
// Search & Pagination State
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -247,16 +248,8 @@ export function ImageBuildPage() {
|
||||
|
||||
// Cancel build handler
|
||||
const handleCancel = async (id: string) => {
|
||||
if (!confirm(t("cancel_confirm"))) return;
|
||||
setCancellingId(id);
|
||||
try {
|
||||
await api.cancelBuild(id);
|
||||
fetchBuilds(buildIds);
|
||||
} catch (err) {
|
||||
alert(humanizeError(err));
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
@@ -525,15 +518,10 @@ export function ImageBuildPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCancel(b.id)}
|
||||
disabled={cancellingId === b.id}
|
||||
onClick={() => setCancelBuild(b)}
|
||||
className="h-7 px-2 text-[11px] gap-1 font-semibold text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
{cancellingId === b.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{t("cancel_build_btn")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -590,6 +578,21 @@ export function ImageBuildPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={cancelBuild !== null}
|
||||
onOpenChange={(o) => !o && setCancelBuild(null)}
|
||||
title={t("cancel_build_title")}
|
||||
description={
|
||||
<>
|
||||
{t("cancel_build_desc")}{" "}
|
||||
<code className="break-all font-mono text-foreground">{cancelBuild?.image_ref}</code>
|
||||
</>
|
||||
}
|
||||
cancelLabel={t("cancel_build_keep")}
|
||||
confirmLabel={t("cancel_build_confirm")}
|
||||
onConfirm={() => handleCancel(cancelBuild!.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user