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

This commit is contained in:
Lemon-miaow committed 2026-09-25 12:08:29 +08:00
1 parent a5307d44d4
commit 9d03386c83
8 files changed
+452 -45

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();
});
});
+84
View File
@@ -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>
);
}
+10 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});
});
+40 -22
View File
@@ -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);
}
await api.removeImage(imageRef);
reload();
}
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" />
)}
<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");
});
});
+22 -19
View File
@@ -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);
}
await api.cancelBuild(id);
fetchBuilds(buildIds);
};
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" />
)}
<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>
);
}