Unverified Commit ec861c10 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 5d95dc6b
Loading
Loading
Loading
Loading
+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
@@ -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 } };
@@ -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");
@@ -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")}`));
  });
});
+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";
@@ -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(() => {
@@ -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 */}