fix(panel): 镜像页构建卡显示真实构建总数并链到构建页,读不到时显示横线
This commit is contained in:
2 files changed
+53
-5
No files matched your search
@@ -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")}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in new issue
Block a user