fix(panel): 构建对话框的提交选择器说明读取失败并可重试,服务端搜索找到最新 100 条之外的提交
This commit is contained in:
4 files changed
+198
-28
No files matched your search
@@ -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:",
|
"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_label": "Import parameters from submission",
|
||||||
"build_import_submission_placeholder": "Select a user 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_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_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.",
|
"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.",
|
||||||
|
|||||||
@@ -161,7 +161,12 @@
|
|||||||
"versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:",
|
"versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:",
|
||||||
"build_import_submission_label": "从已有的审核提交导入参数",
|
"build_import_submission_label": "从已有的审核提交导入参数",
|
||||||
"build_import_submission_placeholder": "选择一个用户提交...",
|
"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_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。",
|
||||||
"build_import_submission_warning_title": "警告:该提交状态为「{{status}}」",
|
"build_import_submission_warning_title": "警告:该提交状态为「{{status}}」",
|
||||||
"build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。",
|
"build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 { render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import { ImageBuildPage } from "./ImageBuildPage";
|
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(() => ({
|
const calls = vi.hoisted(() => ({
|
||||||
listBuilds: vi.fn(),
|
listBuilds: vi.fn(),
|
||||||
@@ -232,3 +233,121 @@ describe("ImageBuildPage cancel", () => {
|
|||||||
expect((await within(dialog).findByRole("alert")).textContent).toBe("build already finished");
|
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" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,9 @@ import type { Build, BuildStatus, Submission } from "@/lib/types";
|
|||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const POLL_MS = 4000;
|
const POLL_MS = 4000;
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
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";
|
const isActive = (b: Build) => b.status === "pending" || b.status === "building";
|
||||||
|
|
||||||
@@ -92,34 +95,42 @@ export function ImageBuildPage() {
|
|||||||
const [triggering, setTriggering] = useState(false);
|
const [triggering, setTriggering] = useState(false);
|
||||||
const [triggerError, setTriggerError] = useState<string | null>(null);
|
const [triggerError, setTriggerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
// The submission picker reads while the dialog is open. Its search runs on
|
||||||
const [loadingSubmissions, setLoadingSubmissions] = useState(false);
|
// 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);
|
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(() => {
|
const visibleSubmissions = useMemo(() => {
|
||||||
if (isOwner) {
|
const shown = isOwner ? submissions : submissions.filter((s) => s.status === "approved");
|
||||||
return submissions;
|
return selectedSub && !shown.some((s) => s.id === selectedSub.id) ? [selectedSub, ...shown] : shown;
|
||||||
}
|
}, [submissions, isOwner, selectedSub]);
|
||||||
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 handleSelectSubmission = (subId: string) => {
|
const handleSelectSubmission = (subId: string) => {
|
||||||
if (!subId || subId.startsWith("_")) return;
|
if (!subId || subId.startsWith("_")) return;
|
||||||
const sub = submissions.find((s) => s.id === subId);
|
const sub = visibleSubmissions.find((s) => s.id === subId);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
|
|
||||||
setSelectedSub(sub);
|
setSelectedSub(sub);
|
||||||
@@ -229,6 +240,8 @@ export function ImageBuildPage() {
|
|||||||
if (!o) {
|
if (!o) {
|
||||||
setTriggerError(null);
|
setTriggerError(null);
|
||||||
setSelectedSub(null);
|
setSelectedSub(null);
|
||||||
|
setSubSearch("");
|
||||||
|
setSubQuery("");
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -248,18 +261,30 @@ export function ImageBuildPage() {
|
|||||||
<Label htmlFor="build-import-submission" className="text-xs font-semibold text-muted-foreground">
|
<Label htmlFor="build-import-submission" className="text-xs font-semibold text-muted-foreground">
|
||||||
{t("build_import_submission_label")}
|
{t("build_import_submission_label")}
|
||||||
</Label>
|
</Label>
|
||||||
|
<SearchInput
|
||||||
|
value={subSearch}
|
||||||
|
onChange={setSubSearch}
|
||||||
|
placeholder={t("build_import_search_placeholder")}
|
||||||
|
className="min-w-0"
|
||||||
|
/>
|
||||||
<Select onValueChange={handleSelectSubmission} disabled={triggering}>
|
<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">
|
<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")} />
|
<SelectValue placeholder={t("build_import_submission_placeholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-60 overflow-y-auto">
|
<SelectContent className="max-h-60 overflow-y-auto">
|
||||||
{loadingSubmissions ? (
|
{subsQ.loading && !subsQ.data ? (
|
||||||
<SelectItem value="_loading" disabled>
|
<SelectItem value="_loading" disabled>
|
||||||
{t("common:loading_config")}...
|
{t("common:loading_config")}...
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
) : subsQ.error && !subsQ.data ? (
|
||||||
|
<SelectItem value="_failed" disabled>
|
||||||
|
{t("build_import_submission_unavailable")}
|
||||||
|
</SelectItem>
|
||||||
) : visibleSubmissions.length === 0 ? (
|
) : visibleSubmissions.length === 0 ? (
|
||||||
<SelectItem value="_none" disabled>
|
<SelectItem value="_none" disabled>
|
||||||
{t("build_import_submission_none")}
|
{subQuery
|
||||||
|
? t("build_import_submission_no_match", { query: subQuery })
|
||||||
|
: t("build_import_submission_none")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
visibleSubmissions.map((sub) => (
|
visibleSubmissions.map((sub) => (
|
||||||
@@ -287,6 +312,22 @@ export function ImageBuildPage() {
|
|||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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">
|
<p className="text-[10px] text-muted-foreground/80 leading-normal">
|
||||||
{t("build_import_submission_hint")}
|
{t("build_import_submission_hint")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in new issue
Block a user