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

fix(panel): 构建对话框的提交选择器说明读取失败并可重试,服务端搜索找到最新 100 条之外的提交

parent ec861c10
Loading
Loading
Loading
Loading
+6 −1
Changes for panel/src/i18n/resources/en-US/admin.json: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -162,7 +162,12 @@
  "versions_fix_hint": "The versions below may be out of date. Run a check on the host now, then read the timer's log to find out why it did not run:",
  "build_import_submission_label": "Import parameters from submission",
  "build_import_submission_placeholder": "Select a user submission...",
  "build_import_submission_none": "No matching submissions found or not loaded",
  "build_import_submission_none": "No submissions to import yet",
  "build_import_submission_no_match": "No submissions match “{{query}}”",
  "build_import_submission_unavailable": "Submission list not loaded",
  "build_import_submission_failed": "Couldn't load submissions: {{reason}}",
  "build_import_submission_more": "Listing the newest {{shown}} of {{total}}; search above to find older ones.",
  "build_import_search_placeholder": "Search submissions by name, ID or submitter",
  "build_import_submission_hint": "Selecting a submission automatically populates the Image Reference, Context Reference, and the corresponding Dockerfile audit header.",
  "build_import_submission_warning_title": "Warning: This submission is currently \"{{status}}\"",
  "build_import_submission_warning_desc": "Manually triggering a build will not automatically mark this submission as approved, nor will it update its associated build status in the database. Use for emergency debugging or testing only.",
+6 −1
Changes for panel/src/i18n/resources/zh-CN/admin.json: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -161,7 +161,12 @@
  "versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:",
  "build_import_submission_label": "从已有的审核提交导入参数",
  "build_import_submission_placeholder": "选择一个用户提交...",
  "build_import_submission_none": "无匹配的提交或暂未加载",
  "build_import_submission_none": "还没有可导入的提交",
  "build_import_submission_no_match": "没有匹配“{{query}}”的提交",
  "build_import_submission_unavailable": "提交列表未读到",
  "build_import_submission_failed": "提交列表读取失败:{{reason}}",
  "build_import_submission_more": "列出最新的 {{shown}} 条,共 {{total}} 条;更早的提交用上面的搜索找。",
  "build_import_search_placeholder": "按名称、ID 或提交人搜索提交",
  "build_import_submission_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。",
  "build_import_submission_warning_title": "警告:该提交状态为「{{status}}」",
  "build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。",
+121 −2
Changes for panel/src/pages/admin/ImageBuildPage.test.tsx: 121 added lines, 2 removed lines.
Original line number Diff line number Diff line
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeAll, 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";
import { humanizeError } from "@/lib/api";
import type { Build, Submission, SubmissionPage } from "@/lib/types";

const calls = vi.hoisted(() => ({
  listBuilds: vi.fn(),
@@ -232,3 +233,121 @@ describe("ImageBuildPage cancel", () => {
    expect((await within(dialog).findByRole("alert")).textContent).toBe("build already finished");
  });
});

describe("ImageBuildPage submission picker", () => {
  // Radix Select measures and captures the pointer, which jsdom leaves out.
  beforeAll(() => {
    Element.prototype.hasPointerCapture ??= () => false;
    Element.prototype.releasePointerCapture ??= () => {};
    Element.prototype.scrollIntoView ??= () => {};
  });

  const sub = (id: string, display_name: string): Submission => ({
    id,
    submitted_by: "user-1",
    display_name,
    context_ref: `submissions/${id}.tar.gz`,
    status: "approved",
    created_at: "2026-09-01T00:00:00Z",
  });
  const subPage = (subs: Submission[], total = subs.length): SubmissionPage => ({
    submissions: subs,
    total,
    counts: { pending_review: 0, approved: total, rejected: 0 },
  });
  const NEWEST = sub("sub-250", "Newest Pack");
  const ANCIENT = sub("sub-3", "Ancient Pack");

  async function openDialog() {
    const user = userEvent.setup();
    renderPage();
    await user.click(await screen.findByRole("button", { name: t("admin:trigger_build_title") }));
    const dialog = screen.getByRole("dialog", { name: t("admin:trigger_build_title") });
    return { user, dialog };
  }
  const picker = () => screen.getByRole("combobox", { name: t("admin:build_import_submission_label") });
  const search = (dialog: HTMLElement) => within(dialog).getByPlaceholderText(t("admin:build_import_search_placeholder"));

  it("says why the submissions did not load and reads them again", async () => {
    const outage = { status: 503, code: "unavailable", message: "db down" };
    calls.listSubmissions.mockRejectedValueOnce(outage).mockResolvedValue(subPage([NEWEST]));
    const { user, dialog } = await openDialog();

    const alert = await within(dialog).findByRole("alert");
    expect(alert.textContent).toBe(
      i18next.t("admin:build_import_submission_failed", { reason: humanizeError(outage) }) + t("common:try_again"),
    );
    await user.click(picker());
    expect((await screen.findByRole("option")).textContent).toBe(t("admin:build_import_submission_unavailable"));
    await user.keyboard("{Escape}");

    await user.click(within(alert).getByRole("button", { name: t("common:try_again") }));
    await vi.waitFor(() => expect(within(dialog).queryByRole("alert")).toBeNull());
    await user.click(picker());
    expect(await screen.findByRole("option", { name: /Newest Pack/ })).toBeTruthy();
  });

  it("searches the server for a submission older than the newest page", async () => {
    calls.listSubmissions.mockResolvedValue(subPage([NEWEST], 250));
    const { user, dialog } = await openDialog();
    const more = i18next.t("admin:build_import_submission_more", { shown: 1, total: 250 });
    expect(await within(dialog).findByText(more)).toBeTruthy();
    expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: undefined, query: undefined, limit: 100 });

    calls.listSubmissions.mockResolvedValue(subPage([ANCIENT]));
    await user.type(search(dialog), "ancient");
    await vi.waitFor(() =>
      expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: undefined, query: "ancient", limit: 100 }),
    );
    await vi.waitFor(() => expect(within(dialog).queryByText(more)).toBeNull());
    expect(within(dialog).queryByText(/Listing the newest/)).toBeNull();
    await user.click(picker());
    await user.click(await screen.findByRole("option", { name: /Ancient Pack/ }));
    expect((within(dialog).getByLabelText(new RegExp(t("admin:context_ref_label"))) as HTMLInputElement).value).toBe(
      "submissions/sub-3.tar.gz",
    );

    // Closed and opened again, the picker starts over from the newest page.
    calls.listSubmissions.mockResolvedValue(subPage([NEWEST], 250));
    await user.keyboard("{Escape}");
    await user.click(screen.getByRole("button", { name: t("admin:trigger_build_title") }));
    const again = screen.getByRole("dialog", { name: t("admin:trigger_build_title") });
    expect((search(again) as HTMLInputElement).value).toBe("");
    expect(await within(again).findByText(more)).toBeTruthy();
    expect(calls.listSubmissions).toHaveBeenLastCalledWith({ status: undefined, query: undefined, limit: 100 });
  });

  it("keeps naming the picked submission while a search narrows past it", async () => {
    calls.listSubmissions.mockResolvedValue(subPage([NEWEST, ANCIENT]));
    const { user, dialog } = await openDialog();
    await user.click(picker());
    await user.click(await screen.findByRole("option", { name: /Ancient Pack/ }));
    expect(picker().textContent).toContain("Ancient Pack");

    calls.listSubmissions.mockResolvedValue(subPage([NEWEST]));
    await user.type(search(dialog), "newest");
    await vi.waitFor(() => expect(calls.listSubmissions).toHaveBeenLastCalledWith(expect.objectContaining({ query: "newest" })));
    // Open, the list hides the rest of the dialog from the accessibility tree.
    const trigger = picker();
    await user.click(trigger);
    await screen.findByRole("option", { name: /Newest Pack/ });
    expect(trigger.textContent).toContain("Ancient Pack");
    expect(screen.getAllByRole("option").map((o) => o.textContent?.includes("Ancient Pack"))).toEqual([true, false]);
  });

  it("names a search that found nothing", async () => {
    calls.listSubmissions.mockResolvedValue(subPage([NEWEST]));
    const { user, dialog } = await openDialog();
    await within(dialog).findByText(t("admin:build_import_submission_hint"));

    calls.listSubmissions.mockResolvedValue(subPage([]));
    await user.type(search(dialog), "zzz");
    await vi.waitFor(() => expect(calls.listSubmissions).toHaveBeenLastCalledWith(expect.objectContaining({ query: "zzz" })));
    await user.click(picker());
    await vi.waitFor(async () =>
      expect((await screen.findByRole("option")).textContent).toBe(
        i18next.t("admin:build_import_submission_no_match", { query: "zzz" }),
      ),
    );
  });
});
+65 −24
Changes for panel/src/pages/admin/ImageBuildPage.tsx: 65 added lines, 24 removed lines.
Original line number Diff line number Diff line
@@ -35,6 +35,9 @@ import type { Build, BuildStatus, Submission } from "@/lib/types";
const PAGE_SIZE = 10;
const POLL_MS = 4000;
const SEARCH_DEBOUNCE_MS = 300;
// The server caps a page of submissions at 100 (submit.MaxListLimit); the picker
// shows that newest page and searches the server for anything older.
const SUBMISSION_PICK_LIMIT = 100;

const isActive = (b: Build) => b.status === "pending" || b.status === "building";

@@ -92,34 +95,42 @@ export function ImageBuildPage() {
  const [triggering, setTriggering] = useState(false);
  const [triggerError, setTriggerError] = useState<string | null>(null);

  const [submissions, setSubmissions] = useState<Submission[]>([]);
  const [loadingSubmissions, setLoadingSubmissions] = useState(false);
  // The submission picker reads while the dialog is open. Its search runs on
  // the server (id, submitter or name), so a submission older than the newest
  // page is still one query away, and a failed read says why instead of
  // passing for an empty list.
  const [subSearch, setSubSearch] = useState("");
  const [subQuery, setSubQuery] = useState("");
  useEffect(() => {
    const timer = setTimeout(() => setSubQuery(subSearch.trim()), SEARCH_DEBOUNCE_MS);
    return () => clearTimeout(timer);
  }, [subSearch]);
  const subsQ = useAsync(
    () =>
      dialogOpen
        ? // A non-owner only ever sees approved rows, so the server narrows to those.
          api.listSubmissions({
            status: isOwner ? undefined : "approved",
            query: subQuery || undefined,
            limit: SUBMISSION_PICK_LIMIT,
          })
        : Promise.resolve(null),
    [dialogOpen, isOwner, subQuery],
    { keepPrevious: true },
  );
  const submissions = useMemo(() => subsQ.data?.submissions ?? [], [subsQ.data]);
  const [selectedSub, setSelectedSub] = useState<Submission | null>(null);

  // The picked submission stays in the list while a search narrows past it, so
  // the picker keeps naming what filled the form.
  const visibleSubmissions = useMemo(() => {
    if (isOwner) {
      return submissions;
    }
    return submissions.filter((s) => s.status === "approved");
  }, [submissions, isOwner]);

  useEffect(() => {
    if (dialogOpen) {
      setLoadingSubmissions(true);
      // The newest page is what a build is picked from; a non-owner only ever
      // sees approved rows, so the server narrows to those.
      api.listSubmissions({ status: isOwner ? undefined : "approved", limit: 100 })
        .then((p) => setSubmissions(p.submissions))
        .catch(() => {})
        .finally(() => {
          setLoadingSubmissions(false);
        });
    }
  }, [dialogOpen, isOwner]);
    const shown = isOwner ? submissions : submissions.filter((s) => s.status === "approved");
    return selectedSub && !shown.some((s) => s.id === selectedSub.id) ? [selectedSub, ...shown] : shown;
  }, [submissions, isOwner, selectedSub]);

  const handleSelectSubmission = (subId: string) => {
    if (!subId || subId.startsWith("_")) return;
    const sub = submissions.find((s) => s.id === subId);
    const sub = visibleSubmissions.find((s) => s.id === subId);
    if (!sub) return;

    setSelectedSub(sub);
@@ -229,6 +240,8 @@ export function ImageBuildPage() {
          if (!o) {
            setTriggerError(null);
            setSelectedSub(null);
            setSubSearch("");
            setSubQuery("");
          }
        }}>
          <DialogTrigger asChild>
@@ -248,18 +261,30 @@ export function ImageBuildPage() {
                <Label htmlFor="build-import-submission" className="text-xs font-semibold text-muted-foreground">
                  {t("build_import_submission_label")}
                </Label>
                <SearchInput
                  value={subSearch}
                  onChange={setSubSearch}
                  placeholder={t("build_import_search_placeholder")}
                  className="min-w-0"
                />
                <Select onValueChange={handleSelectSubmission} disabled={triggering}>
                  <SelectTrigger id="build-import-submission" className="w-full text-xs h-9 bg-background [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:justify-between [&>span]:gap-2 pr-2">
                    <SelectValue placeholder={t("build_import_submission_placeholder")} />
                  </SelectTrigger>
                  <SelectContent className="max-h-60 overflow-y-auto">
                    {loadingSubmissions ? (
                    {subsQ.loading && !subsQ.data ? (
                      <SelectItem value="_loading" disabled>
                        {t("common:loading_config")}...
                      </SelectItem>
                    ) : subsQ.error && !subsQ.data ? (
                      <SelectItem value="_failed" disabled>
                        {t("build_import_submission_unavailable")}
                      </SelectItem>
                    ) : visibleSubmissions.length === 0 ? (
                      <SelectItem value="_none" disabled>
                        {t("build_import_submission_none")}
                        {subQuery
                          ? t("build_import_submission_no_match", { query: subQuery })
                          : t("build_import_submission_none")}
                      </SelectItem>
                    ) : (
                      visibleSubmissions.map((sub) => (
@@ -287,6 +312,22 @@ export function ImageBuildPage() {
                    )}
                  </SelectContent>
                </Select>
                {!!subsQ.error && (
                  <div role="alert" className="flex flex-wrap items-center justify-between gap-2 text-xs text-destructive">
                    <span>{t("build_import_submission_failed", { reason: humanizeError(subsQ.error) })}</span>
                    <Button type="button" size="sm" variant="outline" className="h-7" onClick={subsQ.reload}>
                      {t("common:try_again")}
                    </Button>
                  </div>
                )}
                {!!subsQ.data && subsQ.data.total > subsQ.data.submissions.length && (
                  <p className="text-[10px] text-muted-foreground leading-normal">
                    {t("build_import_submission_more", {
                      shown: subsQ.data.submissions.length,
                      total: subsQ.data.total,
                    })}
                  </p>
                )}
                <p className="text-[10px] text-muted-foreground/80 leading-normal">
                  {t("build_import_submission_hint")}
                </p>