diff --git a/panel/src/components/ConfirmDialog.test.tsx b/panel/src/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..37e5356 --- /dev/null +++ b/panel/src/components/ConfirmDialog.test.tsx @@ -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 }) { + const [open, setOpen] = useState(true); + return ( + <> + {open ? "open" : "closed"} + + + + ); +} + +describe("ConfirmDialog", () => { + it("closes once the action succeeds", async () => { + const action = vi.fn().mockResolvedValue(undefined); + render(); + + 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(); + + 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(); + 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(); + 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(); + }); +}); diff --git a/panel/src/components/ConfirmDialog.tsx b/panel/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..808831b --- /dev/null +++ b/panel/src/components/ConfirmDialog.tsx @@ -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; +} + +// 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(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 ( + + + + {title} + {description && {description}} + + {error && } + setOpen(false)} + onConfirm={() => void confirm()} + loading={busy} + cancelLabel={cancelLabel ?? t("cancel")} + confirmLabel={confirmLabel} + confirmVariant={confirmVariant} + /> + + + ); +} diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 8ea42a1..fc0e293 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -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.", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index dd880b3..7d99a60 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -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 运行在隔离沙箱中,完成构建后将自动运行安全扫描。", diff --git a/panel/src/pages/admin/ImageAdmin.test.tsx b/panel/src/pages/admin/ImageAdmin.test.tsx new file mode 100644 index 0000000..dd11c29 --- /dev/null +++ b/panel/src/pages/admin/ImageAdmin.test.tsx @@ -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(); + 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( + + + , + ); + 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( + + + , + ); + + 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(); + }); +}); diff --git a/panel/src/pages/admin/ImageAdmin.tsx b/panel/src/pages/admin/ImageAdmin.tsx index 1d3cc9e..488991c 100644 --- a/panel/src/pages/admin/ImageAdmin.tsx +++ b/panel/src/pages/admin/ImageAdmin.tsx @@ -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 = { + 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(null); - const [deletingRef, setDeletingRef] = useState(null); + const [removeRef, setRemoveRef] = useState(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() {
{img.source ? ( - {img.source} + {sourceLabel(img.source, t)} ) : ( — @@ -304,17 +313,12 @@ export function ImageAdmin() {
@@ -336,6 +340,20 @@ export function ImageAdmin() { )} + + !o && setRemoveRef(null)} + title={t("delete_image_title")} + description={ + <> + {t("delete_image_desc")}{" "} + {removeRef} + + } + confirmLabel={t("delete_image_confirm")} + onConfirm={() => handleRemove(removeRef!)} + /> ); } \ No newline at end of file diff --git a/panel/src/pages/admin/ImageBuildPage.test.tsx b/panel/src/pages/admin/ImageBuildPage.test.tsx new file mode 100644 index 0000000..2c5d052 --- /dev/null +++ b/panel/src/pages/admin/ImageBuildPage.test.tsx @@ -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(); + 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(); + 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( + + + , + ); + 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"); + }); +}); diff --git a/panel/src/pages/admin/ImageBuildPage.tsx b/panel/src/pages/admin/ImageBuildPage.tsx index 6cb2f50..1980495 100644 --- a/panel/src/pages/admin/ImageBuildPage.tsx +++ b/panel/src/pages/admin/ImageBuildPage.tsx @@ -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([]); const [loadingBuilds, setLoadingBuilds] = useState(true); const [activeLogBuildId, setActiveLogBuildId] = useState(null); - const [cancellingId, setCancellingId] = useState(null); + const [cancelBuild, setCancelBuild] = useState(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() { )} @@ -590,6 +578,21 @@ export function ImageBuildPage() { )} + + !o && setCancelBuild(null)} + title={t("cancel_build_title")} + description={ + <> + {t("cancel_build_desc")}{" "} + {cancelBuild?.image_ref} + + } + cancelLabel={t("cancel_build_keep")} + confirmLabel={t("cancel_build_confirm")} + onConfirm={() => handleCancel(cancelBuild!.id)} + /> ); }