fix(panel): 镜像页构建卡显示真实构建总数并链到构建页,读不到时显示横线

This commit is contained in:
Lemon-miaow committed 2026-09-27 15:44:10 +08:00
1 parent 5d95dc6b6b
commit ec861c10e0
2 files changed
+53 -5

No files matched your search

+36 -1
View File
@@ -6,7 +6,7 @@ import { MemoryRouter } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
import { ImageAdmin } from "./ImageAdmin"; 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) => { vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>(); const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } }; return { ...actual, api: { ...actual.api, ...calls } };
@@ -18,6 +18,7 @@ const REF = "registry.felis.svc:5000/paper:1.21";
beforeEach(() => { beforeEach(() => {
for (const fn of Object.values(calls)) fn.mockReset(); for (const fn of Object.values(calls)) fn.mockReset();
calls.listImages.mockResolvedValue([{ image_ref: REF, source: "external", enabled: true }]); 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). // A native dialog here would block (or be blocked in an embedded browser).
vi.spyOn(window, "confirm").mockImplementation(() => { vi.spyOn(window, "confirm").mockImplementation(() => {
throw new Error("window.confirm used"); throw new Error("window.confirm used");
@@ -94,3 +95,37 @@ describe("ImageAdmin source", () => {
expect(screen.getByText("registry.felis.svc:5000/paper:demo")).toBeTruthy(); 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")}`));
});
});
+17 -4
View File
@@ -1,4 +1,5 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Link } from "react-router-dom";
import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Wrench } from "lucide-react"; import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Wrench } from "lucide-react";
import { SearchInput } from "@/components/SearchInput"; import { SearchInput } from "@/components/SearchInput";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
@@ -64,10 +65,12 @@ export function ImageAdmin() {
const total = images.length; const total = images.length;
const enabled = images.filter((img) => img.enabled).length; const enabled = images.filter((img) => img.enabled).length;
const disabled = total - enabled; const disabled = total - enabled;
const buildCount = total; return { total, enabled, disabled };
return { total, enabled, disabled, buildCount };
}, [images]); }, [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 // Filtered & Paginated Images
const filteredImages = useMemo(() => { const filteredImages = useMemo(() => {
@@ -185,7 +188,17 @@ export function ImageAdmin() {
<StatCard icon={Boxes} label={t("filter_all")} value={total} accentClass="text-primary bg-primary/10" /> <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={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={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> </div>
{/* Whitelist Table Card */} {/* Whitelist Table Card */}