Loading panel/src/pages/admin/ImageAdmin.test.tsx +36 −1 Changes for panel/src/pages/admin/ImageAdmin.test.tsx: 36 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -6,7 +6,7 @@ import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; import { ImageAdmin } from "./ImageAdmin"; const calls = vi.hoisted(() => ({ listImages: vi.fn(), removeImage: vi.fn() })); const calls = vi.hoisted(() => ({ listImages: vi.fn(), removeImage: vi.fn(), listBuilds: vi.fn() })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal<typeof import("@/lib/api")>(); return { ...actual, api: { ...actual.api, ...calls } }; Loading @@ -18,6 +18,7 @@ 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 }]); calls.listBuilds.mockResolvedValue({ builds: [], total: 0 }); // A native dialog here would block (or be blocked in an embedded browser). vi.spyOn(window, "confirm").mockImplementation(() => { throw new Error("window.confirm used"); Loading Loading @@ -94,3 +95,37 @@ describe("ImageAdmin source", () => { expect(screen.getByText("registry.felis.svc:5000/paper:demo")).toBeTruthy(); }); }); describe("ImageAdmin build pipeline card", () => { const renderPage = () => render( <MemoryRouter> <ImageAdmin /> </MemoryRouter>, ); const card = () => screen.getByRole("link", { name: new RegExp(t("admin:builds_title")) }); it("counts builds, whatever the number of images, and leads to the pipeline", async () => { calls.listImages.mockResolvedValue([ { image_ref: REF, source: "external", enabled: true }, { image_ref: `${REF}-b`, source: "external", enabled: false }, ]); calls.listBuilds.mockResolvedValue({ builds: [{ id: "b-7", image_ref: REF, status: "succeeded", requested_by: "admin", created_at: "2026-09-01T00:00:00Z" }], total: 7, }); renderPage(); await vi.waitFor(() => expect(card().textContent).toBe(`7${t("admin:builds_title")}`)); expect(card().getAttribute("href")).toBe("/admin/builds"); expect(calls.listBuilds.mock.calls).toEqual([[{ limit: 1 }]]); }); it("shows a dash when the build history can't be read", async () => { calls.listBuilds.mockRejectedValue({ status: 503, code: "build_unavailable", message: "builder off" }); renderPage(); await screen.findByText(REF); await vi.waitFor(() => expect(card().textContent).toBe(`—${t("admin:builds_title")}`)); }); }); panel/src/pages/admin/ImageAdmin.tsx +17 −4 Changes for panel/src/pages/admin/ImageAdmin.tsx: 17 added lines, 4 removed lines. Original line number Diff line number Diff line import { useState, useMemo } from "react"; import { Link } from "react-router-dom"; import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Wrench } from "lucide-react"; import { SearchInput } from "@/components/SearchInput"; import { StatCard } from "@/components/StatCard"; Loading Loading @@ -64,10 +65,12 @@ export function ImageAdmin() { const total = images.length; const enabled = images.filter((img) => img.enabled).length; const disabled = total - enabled; const buildCount = total; return { total, enabled, disabled, buildCount }; return { total, enabled, disabled }; }, [images]); const { total, enabled, disabled, buildCount } = stats; const { total, enabled, disabled } = stats; // The pipeline card counts builds, which live in their own history: one row is // enough, the answer carries the total. A read that failed shows a dash. const buildsQ = useAsync(() => api.listBuilds({ limit: 1 }), []); // Filtered & Paginated Images const filteredImages = useMemo(() => { Loading Loading @@ -185,7 +188,17 @@ export function ImageAdmin() { <StatCard icon={Boxes} label={t("filter_all")} value={total} accentClass="text-primary bg-primary/10" /> <StatCard icon={CheckCircle2} label={t("enabled")} value={enabled} accentClass="text-emerald-500 bg-emerald-500/10" /> <StatCard icon={CircleSlash} label={t("disabled")} value={disabled} accentClass="text-zinc-500 bg-zinc-500/10" /> <StatCard icon={Wrench} label={t("builds_title")} value={buildCount} accentClass="text-primary bg-primary/10" /> <Link to="/admin/builds" className="block rounded-lg transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <StatCard icon={Wrench} label={t("builds_title")} value={buildsQ.data ? buildsQ.data.total : "—"} accentClass="text-primary bg-primary/10" /> </Link> </div> {/* Whitelist Table Card */} Loading Loading
panel/src/pages/admin/ImageAdmin.test.tsx +36 −1 Changes for panel/src/pages/admin/ImageAdmin.test.tsx: 36 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -6,7 +6,7 @@ import { MemoryRouter } from "react-router-dom"; import i18next from "i18next"; import { ImageAdmin } from "./ImageAdmin"; const calls = vi.hoisted(() => ({ listImages: vi.fn(), removeImage: vi.fn() })); const calls = vi.hoisted(() => ({ listImages: vi.fn(), removeImage: vi.fn(), listBuilds: vi.fn() })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal<typeof import("@/lib/api")>(); return { ...actual, api: { ...actual.api, ...calls } }; Loading @@ -18,6 +18,7 @@ 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 }]); calls.listBuilds.mockResolvedValue({ builds: [], total: 0 }); // A native dialog here would block (or be blocked in an embedded browser). vi.spyOn(window, "confirm").mockImplementation(() => { throw new Error("window.confirm used"); Loading Loading @@ -94,3 +95,37 @@ describe("ImageAdmin source", () => { expect(screen.getByText("registry.felis.svc:5000/paper:demo")).toBeTruthy(); }); }); describe("ImageAdmin build pipeline card", () => { const renderPage = () => render( <MemoryRouter> <ImageAdmin /> </MemoryRouter>, ); const card = () => screen.getByRole("link", { name: new RegExp(t("admin:builds_title")) }); it("counts builds, whatever the number of images, and leads to the pipeline", async () => { calls.listImages.mockResolvedValue([ { image_ref: REF, source: "external", enabled: true }, { image_ref: `${REF}-b`, source: "external", enabled: false }, ]); calls.listBuilds.mockResolvedValue({ builds: [{ id: "b-7", image_ref: REF, status: "succeeded", requested_by: "admin", created_at: "2026-09-01T00:00:00Z" }], total: 7, }); renderPage(); await vi.waitFor(() => expect(card().textContent).toBe(`7${t("admin:builds_title")}`)); expect(card().getAttribute("href")).toBe("/admin/builds"); expect(calls.listBuilds.mock.calls).toEqual([[{ limit: 1 }]]); }); it("shows a dash when the build history can't be read", async () => { calls.listBuilds.mockRejectedValue({ status: 503, code: "build_unavailable", message: "builder off" }); renderPage(); await screen.findByText(REF); await vi.waitFor(() => expect(card().textContent).toBe(`—${t("admin:builds_title")}`)); }); });
panel/src/pages/admin/ImageAdmin.tsx +17 −4 Changes for panel/src/pages/admin/ImageAdmin.tsx: 17 added lines, 4 removed lines. Original line number Diff line number Diff line import { useState, useMemo } from "react"; import { Link } from "react-router-dom"; import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Wrench } from "lucide-react"; import { SearchInput } from "@/components/SearchInput"; import { StatCard } from "@/components/StatCard"; Loading Loading @@ -64,10 +65,12 @@ export function ImageAdmin() { const total = images.length; const enabled = images.filter((img) => img.enabled).length; const disabled = total - enabled; const buildCount = total; return { total, enabled, disabled, buildCount }; return { total, enabled, disabled }; }, [images]); const { total, enabled, disabled, buildCount } = stats; const { total, enabled, disabled } = stats; // The pipeline card counts builds, which live in their own history: one row is // enough, the answer carries the total. A read that failed shows a dash. const buildsQ = useAsync(() => api.listBuilds({ limit: 1 }), []); // Filtered & Paginated Images const filteredImages = useMemo(() => { Loading Loading @@ -185,7 +188,17 @@ export function ImageAdmin() { <StatCard icon={Boxes} label={t("filter_all")} value={total} accentClass="text-primary bg-primary/10" /> <StatCard icon={CheckCircle2} label={t("enabled")} value={enabled} accentClass="text-emerald-500 bg-emerald-500/10" /> <StatCard icon={CircleSlash} label={t("disabled")} value={disabled} accentClass="text-zinc-500 bg-zinc-500/10" /> <StatCard icon={Wrench} label={t("builds_title")} value={buildCount} accentClass="text-primary bg-primary/10" /> <Link to="/admin/builds" className="block rounded-lg transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <StatCard icon={Wrench} label={t("builds_title")} value={buildsQ.data ? buildsQ.data.total : "—"} accentClass="text-primary bg-primary/10" /> </Link> </div> {/* Whitelist Table Card */} Loading