diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f5a6aae..038538b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4755,6 +4755,40 @@ paths: $ref: '#/components/responses/ServiceUnavailable' /api/v1/images/build: + get: + tags: [images] + operationId: listBuilds + summary: List builds (admin), newest first. + description: >- + One page of the build history across every admin. Rows are read as stored + (the reconcile loop advances them; GET /images/build/{id} reconciles one on + demand) and leave out the Dockerfile, which GET /images/build/{id} returns. + x-felis-face: [external] + x-felis-tier: admin + security: [{ accessJWT: [] }] + parameters: + - { name: query, in: query, required: false, schema: { type: string }, description: 'Build id or status (exact), or part of the image ref; case-insensitive' } + - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } } + - { name: offset, in: query, required: false, schema: { type: integer, default: 0 } } + responses: + '200': + description: A page of builds plus how many match the query. + content: + application/json: + schema: + type: object + required: [builds, total] + properties: + builds: + type: array + items: { $ref: '#/components/schemas/Build' } + total: { type: integer } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + $ref: '#/components/responses/ServiceUnavailable' post: tags: [images] operationId: buildImage diff --git a/internal/api/api.go b/internal/api/api.go index ffcae4d..d377a0f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -604,6 +604,7 @@ func (a *API) externalAPIRoutes() []apiRoute { // is build-time RCE against the cluster, so submission requires the admin // Zero-Trust path, not merely an authenticated session. {Method: "POST", Pattern: "/api/v1/images/build", Admin: true, h: a.handleBuildImage}, + {Method: "GET", Pattern: "/api/v1/images/build", Admin: true, h: a.handleListBuilds}, {Method: "GET", Pattern: "/api/v1/images/build/{id}", Admin: true, h: a.handleGetBuild}, {Method: "GET", Pattern: "/api/v1/images/build/{id}/logs", Admin: true, h: a.handleBuildLogs}, {Method: "POST", Pattern: "/api/v1/images/build/{id}/cancel", Admin: true, h: a.handleCancelBuild}, diff --git a/internal/api/images.go b/internal/api/images.go index d20bfbd..c971fcf 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strconv" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/imagepin" @@ -25,6 +26,8 @@ type ImageBuilder interface { // GET doubles as the reconcile tick (idempotent on terminal builds). Sync(ctx context.Context, id string) (*build.Build, error) Cancel(ctx context.Context, id string) (*build.Build, error) + // ListBuilds pages the build history, newest first, with the match total. + ListBuilds(ctx context.Context, opts build.ListOpts) ([]build.Build, int, error) ListImages(ctx context.Context) ([]build.Image, error) AddExternalImage(ctx context.Context, imageRef, addedBy string) (*build.Image, error) RemoveImage(ctx context.Context, imageRef string) error @@ -78,6 +81,30 @@ func (a *API) handleBuildImage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusAccepted, bld) } +// handleListBuilds pages the build history (admin-tier), newest first. The +// panel lists from here, so a build started from another browser, or by another +// admin, is still there to follow and to cancel. +func (a *API) handleListBuilds(w http.ResponseWriter, r *http.Request) { + if a.Builder == nil { + writeError(w, r, errBuildUnavailable) + return + } + q := r.URL.Query() + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + builds, total, err := a.Builder.ListBuilds(r.Context(), build.ListOpts{ + Query: q.Get("query"), Limit: limit, Offset: offset, + }) + if err != nil { + writeBuildError(w, r, err) + return + } + if builds == nil { + builds = []build.Build{} + } + writeJSON(w, http.StatusOK, map[string]any{"builds": builds, "total": total}) +} + // handleGetBuild returns a build, reconciling it against its Job first so // polling drives the scan-gate translation without a separate background loop. func (a *API) handleGetBuild(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/images_test.go b/internal/api/images_test.go index eb71336..d87efe1 100644 --- a/internal/api/images_test.go +++ b/internal/api/images_test.go @@ -30,6 +30,10 @@ type fakeBuilder struct { lastBuildID string admitted map[string]bool admitErr error + builds []build.Build + buildsTotal int + buildsErr error + listOpts build.ListOpts } func (f *fakeBuilder) Submit(_ context.Context, req build.Request) (*build.Build, error) { @@ -69,6 +73,11 @@ func (f *fakeBuilder) Cancel(_ context.Context, id string) (*build.Build, error) return &build.Build{ID: id, ImageRef: "registry.felis.svc:5000/x:1", Status: build.StatusCancelled}, nil } +func (f *fakeBuilder) ListBuilds(_ context.Context, opts build.ListOpts) ([]build.Build, int, error) { + f.listOpts = opts + return f.builds, f.buildsTotal, f.buildsErr +} + func (f *fakeBuilder) ListImages(context.Context) ([]build.Image, error) { return f.images, f.listErr } @@ -110,6 +119,7 @@ func TestImageRoutesAreAdminOnly(t *testing.T) { method, target, body string }{ {"POST", "/api/v1/images/build", `{"image_ref":"registry.felis.svc:5000/x:1","dockerfile":"FROM x","context_ref":"c"}`}, + {"GET", "/api/v1/images/build", ""}, {"GET", "/api/v1/images/build/bld-1", ""}, {"GET", "/api/v1/images/build/bld-1/logs", ""}, {"POST", "/api/v1/images/build/bld-1/cancel", ""}, @@ -207,6 +217,43 @@ func TestCancelTerminalBuildIs409(t *testing.T) { } } +func TestListBuilds(t *testing.T) { + fb := &fakeBuilder{ + builds: []build.Build{{ID: "bld-2", ImageRef: "registry.felis.svc:5000/x:2", Status: build.StatusBuilding}}, + buildsTotal: 41, + } + api := adminAPI(fb) + w := do(api.ExternalHandler(), "GET", "/api/v1/images/build?query=paper&limit=20&offset=40", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if want := (build.ListOpts{Query: "paper", Limit: 20, Offset: 40}); fb.listOpts != want { + t.Errorf("forwarded %+v, want %+v", fb.listOpts, want) + } + var got struct { + Builds []build.Build `json:"builds"` + Total int `json:"total"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("body not JSON: %v", err) + } + if got.Total != 41 || len(got.Builds) != 1 || got.Builds[0].ID != "bld-2" { + t.Fatalf("body = %s", w.Body.String()) + } +} + +// An empty history is an empty list, so the panel never has to handle null. +func TestListBuildsEmptyIsAnArray(t *testing.T) { + api := adminAPI(&fakeBuilder{}) + w := do(api.ExternalHandler(), "GET", "/api/v1/images/build", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if body := w.Body.String(); body != `{"builds":[],"total":0}`+"\n" { + t.Fatalf("body = %q", body) + } +} + func TestListImages(t *testing.T) { fb := &fakeBuilder{images: []build.Image{ {ImageRef: "registry.felis.svc:5000/a:1", Source: build.SourceBuilt, Enabled: true}, diff --git a/internal/build/build.go b/internal/build/build.go index 1ce3772..e537e1b 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -175,6 +175,20 @@ type Image struct { AddedAt time.Time `json:"added_at"` } +// ListOpts selects a page of the build history. Query matches a build id or a +// status exactly, or any part of the image ref, ignoring case; empty matches all. +type ListOpts struct { + Query string + Limit int + Offset int +} + +// DefaultListLimit and MaxListLimit bound one page of ListBuilds. +const ( + DefaultListLimit = 20 + MaxListLimit = 100 +) + // Store is the business-layer persistence the Builder depends on (image_builds // + image_whitelist). It is an interface so the Builder is tested against an // in-memory fake; the Postgres implementation (pgStore) is integration-tested @@ -191,6 +205,10 @@ type Store interface { // ListUnfinishedBuilds returns builds still being reconciled (status pending // or building), oldest first — the work list for SyncAll. ListUnfinishedBuilds(ctx context.Context) ([]Build, error) + // ListBuilds returns one page of the build history, newest first, and how + // many builds match in all. The rows leave out the Dockerfile (up to + // MaxDockerfileBytes each); GetBuild has it. + ListBuilds(ctx context.Context, opts ListOpts) ([]Build, int, error) // AdmitBuiltImage upserts an image_whitelist row with enabled=true and // source=built (the scan-gate success path, spec §16). It records added_by. AdmitBuiltImage(ctx context.Context, img Image) error @@ -501,6 +519,20 @@ func (b *Builder) Get(ctx context.Context, id string) (*Build, error) { return b.Store.GetBuild(ctx, id) } +// ListBuilds pages the build history for the admin panel, so every admin sees +// every build (and can cancel a running one) from any browser. It reads rows as +// stored: reconcileBuilds advances them in the background, and GET +// /images/build/{id} reconciles one on demand. +func (b *Builder) ListBuilds(ctx context.Context, opts ListOpts) ([]Build, int, error) { + opts.Query = strings.TrimSpace(opts.Query) + if opts.Limit <= 0 { + opts.Limit = DefaultListLimit + } + opts.Limit = min(opts.Limit, MaxListLimit) + opts.Offset = max(opts.Offset, 0) + return b.Store.ListBuilds(ctx, opts) +} + // Sync reconciles one non-terminal build against its Job phase — the scan-gate // translation (spec §16). A terminal build is returned unchanged (idempotent). // diff --git a/internal/build/build_test.go b/internal/build/build_test.go index bc75b8c..14ff1b4 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -24,6 +24,7 @@ type fakeStore struct { finished []string // "id:status" removeErr error createErr error + listOpts ListOpts } func newFakeStore() *fakeStore { @@ -88,6 +89,18 @@ func (f *fakeStore) ListUnfinishedBuilds(_ context.Context) ([]Build, error) { return out, nil } +func (f *fakeStore) ListBuilds(_ context.Context, opts ListOpts) ([]Build, int, error) { + f.listOpts = opts + var out []Build + for _, b := range f.builds { + out = append(out, *b) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + total := len(out) + out = out[min(opts.Offset, total):min(opts.Offset+opts.Limit, total)] + return out, total, nil +} + func (f *fakeStore) AdmitBuiltImage(_ context.Context, img Image) error { f.images[img.ImageRef] = img f.admitted = append(f.admitted, img) @@ -740,3 +753,26 @@ func TestRecommendedImageAdmittedLikeAnyOtherSource(t *testing.T) { t.Error("a disabled recommended image must not be admitted; curation is not a disable bypass") } } + +// ListBuilds keeps one page bounded whatever the query string asks for. +func TestListBuildsBoundsThePage(t *testing.T) { + cases := []struct { + in ListOpts + want ListOpts + }{ + {ListOpts{}, ListOpts{Limit: DefaultListLimit}}, + {ListOpts{Limit: 5, Offset: 40}, ListOpts{Limit: 5, Offset: 40}}, + {ListOpts{Limit: 100000}, ListOpts{Limit: MaxListLimit}}, + {ListOpts{Limit: -3, Offset: -7}, ListOpts{Limit: DefaultListLimit}}, + {ListOpts{Query: " paper \t"}, ListOpts{Query: "paper", Limit: DefaultListLimit}}, + } + for _, c := range cases { + b, st, _ := newBuilder() + if _, _, err := b.ListBuilds(context.Background(), c.in); err != nil { + t.Fatalf("ListBuilds(%+v): %v", c.in, err) + } + if st.listOpts != c.want { + t.Errorf("ListBuilds(%+v) asked the store for %+v, want %+v", c.in, st.listOpts, c.want) + } + } +} diff --git a/internal/build/pgstore.go b/internal/build/pgstore.go index a8d689d..0f9fff8 100644 --- a/internal/build/pgstore.go +++ b/internal/build/pgstore.go @@ -99,6 +99,30 @@ func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) { if err != nil { return nil, err } + return scanBuilds(rows) +} + +// ListBuilds pages image_builds newest first. The id breaks created_at ties so +// the pages neither repeat nor skip a row; the Dockerfile column is left empty. +func (s *PGStore) ListBuilds(ctx context.Context, opts ListOpts) ([]Build, int, error) { + const match = ` WHERE $1::text = '' OR id = $1::text OR status::text = lower($1::text) + OR strpos(lower(image_ref), lower($1::text)) > 0` + var total int + if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM image_builds`+match, opts.Query).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.db.QueryContext(ctx, `SELECT id, image_ref, status, '', context_ref, base_image, + requested_by, job_name, log_ref, error, created_at, finished_at, context_digest + FROM image_builds`+match+` ORDER BY created_at DESC, id DESC LIMIT $2 OFFSET $3`, + opts.Query, opts.Limit, opts.Offset) + if err != nil { + return nil, 0, err + } + out, err := scanBuilds(rows) + return out, total, err +} + +func scanBuilds(rows *sql.Rows) ([]Build, error) { defer rows.Close() var out []Build for rows.Next() { diff --git a/internal/pgint/pgint_test.go b/internal/pgint/pgint_test.go index 316e714..8feffff 100644 --- a/internal/pgint/pgint_test.go +++ b/internal/pgint/pgint_test.go @@ -1447,6 +1447,70 @@ func TestBuildStoreContract(t *testing.T) { } } +// ListBuilds pages newest first across every requester, finds a build by part of +// its ref (any case), its id or its status, and leaves the Dockerfile out. +func TestBuildStoreListBuilds(t *testing.T) { + ctx := context.Background() + s := build.NewPGStore(db) + tag := "list-" + suffix(t) + base := mustNow().Add(-time.Hour) + ids := make([]string, 3) + for i := range ids { + ids[i] = fmt.Sprintf("bld-%s-%d", tag, i) + if err := s.CreateBuild(ctx, &build.Build{ID: ids[i], + ImageRef: fmt.Sprintf("registry.felis.svc:5000/%s/img:%d", tag, i), Status: build.StatusPending, + RequestedBy: fmt.Sprintf("admin%d@example.test", i), Dockerfile: "FROM scratch\n", + CreatedAt: base.Add(time.Duration(i) * time.Minute)}); err != nil { + t.Fatalf("CreateBuild(%d): %v", i, err) + } + } + idsOf := func(bs []build.Build) []string { + out := []string{} + for _, b := range bs { + out = append(out, b.ID) + } + return out + } + + page, total, err := s.ListBuilds(ctx, build.ListOpts{Query: strings.ToUpper(tag), Limit: 2}) + if err != nil { + t.Fatalf("ListBuilds: %v", err) + } + if got, want := idsOf(page), []string{ids[2], ids[1]}; total != 3 || fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("first page = %v of %d, want %v of 3", got, total, want) + } + if page[0].Dockerfile != "" || page[0].RequestedBy != "admin2@example.test" { + t.Fatalf("listed row = %+v, want no Dockerfile and the requester", page[0]) + } + page, total, err = s.ListBuilds(ctx, build.ListOpts{Query: tag, Limit: 2, Offset: 2}) + if err != nil || total != 3 || fmt.Sprint(idsOf(page)) != fmt.Sprint([]string{ids[0]}) { + t.Fatalf("second page = %v of %d (%v), want [%s] of 3", idsOf(page), total, err, ids[0]) + } + + page, total, err = s.ListBuilds(ctx, build.ListOpts{Query: ids[1], Limit: 10}) + if err != nil || total != 1 || fmt.Sprint(idsOf(page)) != fmt.Sprint([]string{ids[1]}) { + t.Fatalf("by id = %v of %d (%v), want [%s]", idsOf(page), total, err, ids[1]) + } + + if err := s.FinishBuild(ctx, ids[0], build.StatusFailed, "trivy: CRITICAL", mustNow()); err != nil { + t.Fatalf("FinishBuild: %v", err) + } + page, _, err = s.ListBuilds(ctx, build.ListOpts{Query: "Failed", Limit: build.MaxListLimit}) + if err != nil { + t.Fatalf("ListBuilds(status): %v", err) + } + found := false + for _, b := range page { + if b.Status != build.StatusFailed { + t.Fatalf("status query returned %s in state %s", b.ID, b.Status) + } + found = found || (b.ID == ids[0] && b.Error == "trivy: CRITICAL") + } + if !found { + t.Fatalf("status query missed %s: %v", ids[0], idsOf(page)) + } +} + // ---- helpers ------------------------------------------------------------------- func assertAttempts(t *testing.T, userID, purpose string, want int) { diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 40bc61a..a6ca9ab 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -322,6 +322,23 @@ function initialState(): MockState { created_at: new Date(Date.now() - 1800000).toISOString(), finished_at: new Date(Date.now() - 1700000).toISOString(), }, + // Started by another admin: the list is the server's, so it shows here too. + { + id: "bld-3", + image_ref: "registry.felis.svc:5000/user-uploads/sub-9:latest", + status: "building", + requested_by: "admin2@mock.felis.local", + created_at: new Date(Date.now() - 300000).toISOString(), + }, + // Older history, enough to need a second page. + ...Array.from({ length: 11 }, (_, i): Build => ({ + id: `bld-old-${i + 1}`, + image_ref: `registry.felis.svc:5000/paper-1.21:r${i + 1}`, + status: i % 4 === 3 ? "cancelled" : "succeeded", + requested_by: i % 2 ? "admin2@mock.felis.local" : "owner@mock.felis.local", + created_at: new Date(Date.now() - (i + 2) * 86400000).toISOString(), + finished_at: new Date(Date.now() - (i + 2) * 86400000 + 240000).toISOString(), + })), ], passkeys: { owner: [ @@ -1389,7 +1406,19 @@ async function handleImageRoute(ctx: SessionContext): Promise { sendError(ctx.res, 403, "forbidden", "admin account required"); return true; } - sendJSON(ctx.res, 200, { builds: ctx.state.builds }); + // Same page and match as build.PGStore.ListBuilds: newest first, a query hits + // the id or status exactly or any part of the ref, and rows carry no Dockerfile. + const url = new URL(ctx.req.url ?? "/", "http://localhost"); + const q = (url.searchParams.get("query") ?? "").trim().toLowerCase(); + const limit = Math.min(Number(url.searchParams.get("limit")) || 20, 100); + const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0); + const matched = ctx.state.builds + .filter((b) => !q || b.id.toLowerCase() === q || b.status === q || b.image_ref.toLowerCase().includes(q)) + .sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id)); + sendJSON(ctx.res, 200, { + builds: matched.slice(offset, offset + limit).map(({ dockerfile: _, ...b }) => b), + total: matched.length, + }); return true; } diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index fc0e293..82c3009 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -231,5 +231,7 @@ "search_no_results_hint": "Try a different search term or filter.", "submissions_search_placeholder": "Search modpack name or submitter...", "trigger_build_desc": "Enter the build parameters to launch a Kaniko pipeline job in an isolated namespace.", - "builds_search_placeholder": "Search build ID, image reference, or status..." + "builds_search_placeholder": "Search build ID, image reference, or status...", + "builds_refresh_failed": "Couldn't refresh the build list: {{reason}}", + "build_requested_by_you": "You" } diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 7d99a60..e3fe7fe 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -231,5 +231,7 @@ "search_no_results_hint": "尝试更换搜索词或筛选条件。", "submissions_search_placeholder": "搜索模组包名称或提交人...", "trigger_build_desc": "输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。", - "builds_search_placeholder": "搜索构建 ID、镜像引用或状态..." + "builds_search_placeholder": "搜索构建 ID、镜像引用或状态...", + "builds_refresh_failed": "构建列表刷新失败:{{reason}}", + "build_requested_by_you": "你" } diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index cdf2137..788fcf0 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -438,6 +438,17 @@ describe("image whitelist and builds wire shapes", () => { }); }); + it("listBuilds GETs one page of /images/build with the query", async () => { + const fetchSpy = fakeFetch({ builds: [{ id: "bld-2", image_ref: "x", status: "building" }], total: 41 }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.listBuilds({ query: "paper 1.21", limit: 20, offset: 40 }); + expect(res.total).toBe(41); + expect(res.builds.map((b) => b.id)).toEqual(["bld-2"]); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/images/build?query=paper+1.21&limit=20&offset=40"); + expect((opts as RequestInit).method).toBe("GET"); + }); + it("getBuild GETs build status from /images/build/{id}", async () => { const fetchSpy = fakeFetch({ id: "bld-1", image_ref: "x", status: "building" }); vi.stubGlobal("fetch", fetchSpy); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index b7d8d69..d1109c3 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -413,6 +413,19 @@ export const api = rejectingSync({ buildImage: (req: { image_ref: string; dockerfile: string; context_ref: string; base_image?: string }) => request("POST", "/images/build", req), + /** One page of the build history, newest first; rows leave out the Dockerfile. */ + listBuilds: (params?: { query?: string; limit?: number; offset?: number }) => { + const sp = new URLSearchParams(); + if (params?.query) sp.set("query", params.query); + if (params?.limit) sp.set("limit", String(params.limit)); + if (params?.offset) sp.set("offset", String(params.offset)); + const qs = sp.toString(); + return request<{ builds: Build[]; total: number }>( + "GET", + `/images/build${qs ? `?${qs}` : ""}`, + ).then((r) => ({ builds: r.builds ?? [], total: r.total ?? 0 })); + }, + getBuild: (id: string) => request("GET", urlPath`/images/build/${id}`), diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 5496a5c..de96cc6 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1714,7 +1714,11 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** + * List builds (admin), newest first. + * @description One page of the build history across every admin. Rows are read as stored (the reconcile loop advances them; GET /images/build/{id} reconciles one on demand) and leave out the Dockerfile, which GET /images/build/{id} returns. + */ + get: operations["listBuilds"]; put?: never; /** Submit an image build (admin). A build is build-time RCE against the cluster. */ post: operations["buildImage"]; @@ -6450,6 +6454,37 @@ export interface operations { 503: components["responses"]["ServiceUnavailable"]; }; }; + listBuilds: { + parameters: { + query?: { + /** @description Build id or status (exact), or part of the image ref; case-insensitive */ + query?: string; + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A page of builds plus how many match the query. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + builds: components["schemas"]["Build"][]; + total: number; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; buildImage: { parameters: { query?: never; diff --git a/panel/src/pages/admin/ImageBuildPage.test.tsx b/panel/src/pages/admin/ImageBuildPage.test.tsx index 2c5d052..6d81d02 100644 --- a/panel/src/pages/admin/ImageBuildPage.test.tsx +++ b/panel/src/pages/admin/ImageBuildPage.test.tsx @@ -7,9 +7,19 @@ import i18next from "i18next"; import { ImageBuildPage } from "./ImageBuildPage"; import type { Build } from "@/lib/types"; -const calls = vi.hoisted(() => ({ getBuild: vi.fn(), cancelBuild: vi.fn(), listSubmissions: vi.fn() })); +const calls = vi.hoisted(() => ({ + listBuilds: vi.fn(), + cancelBuild: vi.fn(), + listSubmissions: vi.fn(), + buildImage: vi.fn(), +})); vi.mock("@/lib/tier", () => ({ - useTier: () => ({ loading: false, identity: { user_id: "owner-1", role: "owner" }, isAdmin: true, isOwner: true }), + useTier: () => ({ + loading: false, + identity: { user_id: "owner-1", email: "owner@example.test", role: "owner" }, + isAdmin: true, + isOwner: true, + }), })); vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) })); vi.mock("@/lib/api", async (importOriginal) => { @@ -18,34 +28,30 @@ vi.mock("@/lib/api", async (importOriginal) => { }); const t = (key: string) => i18next.t(key); -const BUILD: Build = { +// Started by another admin in another browser: only the server knows of it. +const RUNNING: Build = { id: "b-1", image_ref: "registry.felis.svc:5000/modpack:7", status: "building", - requested_by: "owner-1", + requested_by: "admin2@example.test", created_at: new Date().toISOString(), }; +const MINE: Build = { + id: "b-0", + image_ref: "registry.felis.svc:5000/paper:3", + status: "succeeded", + requested_by: "owner@example.test", + created_at: new Date(Date.now() - 3600_000).toISOString(), + finished_at: new Date(Date.now() - 3500_000).toISOString(), +}; -// Node's own (unconfigured) localStorage shadows jsdom's, so give the page one. -function memoryStorage(): Storage { - const m = new Map(); - return { - get length() { - return m.size; - }, - key: (i) => [...m.keys()][i] ?? null, - getItem: (k) => m.get(k) ?? null, - setItem: (k, v) => void m.set(k, String(v)), - removeItem: (k) => void m.delete(k), - clear: () => m.clear(), - }; +function page(builds: Build[], total = builds.length) { + return { builds, total }; } beforeEach(() => { for (const fn of Object.values(calls)) fn.mockReset(); - vi.stubGlobal("localStorage", memoryStorage()); - localStorage.setItem("felis_triggered_builds", JSON.stringify([BUILD.id])); - calls.getBuild.mockResolvedValue(BUILD); + calls.listBuilds.mockResolvedValue(page([RUNNING, MINE])); vi.spyOn(window, "confirm").mockImplementation(() => { throw new Error("window.confirm used"); }); @@ -54,36 +60,164 @@ beforeEach(() => { }); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); + return i18next.changeLanguage("en-US"); }); -async function openCancel() { - render( +function renderPage() { + return render( , ); +} + +async function openCancel() { + renderPage(); await userEvent.click(await screen.findByRole("button", { name: t("admin:cancel_build_btn") })); return screen.getByRole("dialog", { name: t("admin:cancel_build_title") }); } +describe("ImageBuildPage list", () => { + it("lists the server's builds, including one another admin started, and marks the caller's own", async () => { + renderPage(); + const running = (await screen.findByText(RUNNING.image_ref)).closest("li")!; + expect(calls.listBuilds).toHaveBeenCalledWith({ query: undefined, limit: 10, offset: 0 }); + expect(within(running).getByRole("button", { name: t("admin:cancel_build_btn") })).toBeTruthy(); + expect(within(running).queryByText("You")).toBeNull(); + + const mine = screen.getByText(MINE.image_ref).closest("li")!; + expect(within(mine).getByText("You")).toBeTruthy(); + }); + + it("asks the server for the page picked", async () => { + calls.listBuilds.mockResolvedValue(page([MINE], 25)); + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: t("common:pagination_next") })); + await vi.waitFor(() => expect(calls.listBuilds).toHaveBeenLastCalledWith({ query: undefined, limit: 10, offset: 10 })); + }); + + it("searches on the server, sending a status typed as the badge shows it by its code, from page one", async () => { + await i18next.changeLanguage("zh-CN"); + calls.listBuilds.mockResolvedValue(page([MINE], 25)); + renderPage(); + await userEvent.click(await screen.findByRole("button", { name: t("common:pagination_next") })); + await vi.waitFor(() => expect(calls.listBuilds).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 10 }))); + + await userEvent.type(screen.getByRole("textbox"), "失败"); + await vi.waitFor(() => + expect(calls.listBuilds).toHaveBeenLastCalledWith({ query: "failed", limit: 10, offset: 0 }), + ); + }); + + it("follows the page while a build on it runs, and stops once none does", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderPage(); + await screen.findByText(RUNNING.image_ref); + const before = calls.listBuilds.mock.calls.length; + + calls.listBuilds.mockResolvedValue(page([{ ...RUNNING, status: "succeeded" }, MINE])); + await vi.advanceTimersByTimeAsync(4000); + expect(calls.listBuilds.mock.calls.length).toBe(before + 1); + await vi.waitFor(() => expect(screen.queryByRole("button", { name: t("admin:cancel_build_btn") })).toBeNull()); + + await vi.advanceTimersByTimeAsync(12000); + expect(calls.listBuilds.mock.calls.length).toBe(before + 1); + }); + + it("goes back to the top of the full list after starting a build, and opens its log", async () => { + // The log console attaches over SSE; jsdom has no EventSource. + vi.stubGlobal( + "EventSource", + class { + readyState = 0; + onopen = null; + onmessage = null; + onerror = null; + addEventListener() {} + close() {} + }, + ); + calls.listSubmissions.mockResolvedValue([]); + calls.listBuilds.mockResolvedValue(page([MINE], 25)); + const fresh: Build = { ...RUNNING, id: "b-new", image_ref: "registry.felis.svc:5000/fresh:1", requested_by: "owner@example.test" }; + calls.buildImage.mockResolvedValue(fresh); + renderPage(); + await userEvent.type(await screen.findByRole("textbox"), "paper"); + await vi.waitFor(() => expect(calls.listBuilds).toHaveBeenLastCalledWith(expect.objectContaining({ query: "paper" }))); + await userEvent.click(await screen.findByRole("button", { name: t("common:pagination_next") })); + await vi.waitFor(() => + expect(calls.listBuilds).toHaveBeenLastCalledWith({ query: "paper", limit: 10, offset: 10 }), + ); + + calls.listBuilds.mockResolvedValue(page([fresh, MINE], 26)); + await userEvent.click(screen.getByRole("button", { name: t("admin:trigger_build_title") })); + const form = screen.getByRole("dialog", { name: t("admin:trigger_build_title") }); + await userEvent.type(within(form).getByLabelText(new RegExp(t("admin:image_ref_label"))), fresh.image_ref); + await userEvent.type(within(form).getByLabelText(new RegExp(t("admin:context_ref_label"))), "http://ctx/x.tgz"); + await userEvent.type(within(form).getByLabelText(new RegExp(t("admin:dockerfile_label"))), "FROM scratch"); + await userEvent.click(within(form).getByRole("button", { name: t("admin:trigger_build_btn") })); + + await vi.waitFor(() => + expect(calls.listBuilds).toHaveBeenLastCalledWith({ query: undefined, limit: 10, offset: 0 }), + ); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe(""); + const row = (await screen.findByText(fresh.image_ref)).closest("li")!; + expect(within(row).getByRole("button", { name: t("admin:view_logs_btn") }).getAttribute("aria-expanded")).toBe("true"); + + // Already at the top: nothing to reset, and the list still has to be read again. + const again: Build = { ...fresh, id: "b-again", image_ref: "registry.felis.svc:5000/again:1" }; + calls.buildImage.mockResolvedValue(again); + calls.listBuilds.mockResolvedValue(page([again, fresh, MINE], 27)); + await userEvent.click(screen.getByRole("button", { name: t("admin:trigger_build_title") })); + const form2 = screen.getByRole("dialog", { name: t("admin:trigger_build_title") }); + await userEvent.type(within(form2).getByLabelText(new RegExp(t("admin:image_ref_label"))), again.image_ref); + await userEvent.type(within(form2).getByLabelText(new RegExp(t("admin:context_ref_label"))), "http://ctx/y.tgz"); + await userEvent.type(within(form2).getByLabelText(new RegExp(t("admin:dockerfile_label"))), "FROM scratch"); + await userEvent.click(within(form2).getByRole("button", { name: t("admin:trigger_build_btn") })); + expect(await screen.findByText(again.image_ref)).toBeTruthy(); + }); + + it("says a first load failed and retries it", async () => { + calls.listBuilds.mockRejectedValueOnce({ status: 409, code: "test", message: "database is away" }); + renderPage(); + expect((await screen.findByRole("alert")).textContent).toBe("database is away"); + + await userEvent.click(screen.getByRole("button", { name: t("common:try_again") })); + expect(await screen.findByText(RUNNING.image_ref)).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); + describe("ImageBuildPage cancel", () => { it("asks in a dialog that names the image, then stops the build", async () => { calls.cancelBuild.mockResolvedValue(undefined); const dialog = await openCancel(); - expect(within(dialog).getByText(BUILD.image_ref)).toBeTruthy(); + expect(within(dialog).getByText(RUNNING.image_ref)).toBeTruthy(); expect(calls.cancelBuild).not.toHaveBeenCalled(); - calls.getBuild.mockResolvedValue({ ...BUILD, status: "cancelled" }); + calls.listBuilds.mockResolvedValue(page([{ ...RUNNING, status: "cancelled" }, MINE])); await userEvent.click(within(dialog).getByRole("button", { name: t("admin:cancel_build_confirm") })); - expect(calls.cancelBuild).toHaveBeenCalledWith(BUILD.id); + expect(calls.cancelBuild).toHaveBeenCalledWith(RUNNING.id); await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); await vi.waitFor(() => expect(screen.queryByRole("button", { name: t("admin:cancel_build_btn") })).toBeNull(), ); }); + it("keeps the rows and says so when the list cannot be refreshed", async () => { + calls.cancelBuild.mockResolvedValue(undefined); + const dialog = await openCancel(); + calls.listBuilds.mockRejectedValue({ status: 409, code: "test", message: "database is away" }); + await userEvent.click(within(dialog).getByRole("button", { name: t("admin:cancel_build_confirm") })); + + expect((await screen.findByRole("alert")).textContent).toBe("Couldn't refresh the build list: database is away"); + expect(screen.getByText(RUNNING.image_ref)).toBeTruthy(); + expect(screen.getByText(MINE.image_ref)).toBeTruthy(); + }); + it("keeps building when the dialog is dismissed", async () => { const dialog = await openCancel(); await userEvent.click(within(dialog).getByRole("button", { name: t("admin:cancel_build_keep") })); diff --git a/panel/src/pages/admin/ImageBuildPage.tsx b/panel/src/pages/admin/ImageBuildPage.tsx index 1980495..90130db 100644 --- a/panel/src/pages/admin/ImageBuildPage.tsx +++ b/panel/src/pages/admin/ImageBuildPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState, useMemo, Fragment } from "react"; +import { useCallback, useEffect, useState, useMemo } from "react"; import { Cpu, Terminal, XCircle, AlertCircle, Plus, ChevronDown } from "lucide-react"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { SearchInput } from "@/components/SearchInput"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -22,16 +23,29 @@ import { LogConsole } from "@/components/LogConsole"; import { cn } from "@/lib/utils"; import { PageHeader } from "@/components/PageHeader"; import { MessageLine } from "@/components/MessageLine"; -import { Loading, EmptyState } from "@/components/States"; +import { Loading, EmptyState, ErrorState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; import { api, buildLogsStreamURL, humanizeError } from "@/lib/api"; import { formatRelative, formatAbsolute } from "@/lib/format"; -import { useConfig } from "@/lib/hooks"; +import { useAsync, useConfig } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import type { Build, BuildStatus, Submission } from "@/lib/types"; -const LOCAL_STORAGE_KEY = "felis_triggered_builds"; const PAGE_SIZE = 10; +const POLL_MS = 4000; +const SEARCH_DEBOUNCE_MS = 300; + +const isActive = (b: Build) => b.status === "pending" || b.status === "building"; + +const BUILD_STATUSES: BuildStatus[] = ["pending", "building", "succeeded", "failed", "cancelled"]; + +// The server matches a status by its code, so a status typed the way the badge +// shows it ("失败") is sent as the code ("failed"). +function serverQuery(search: string, t: TFunction): string { + const q = search.trim(); + const status = BUILD_STATUSES.find((s) => t(`admin:build_status_${s}`).toLowerCase() === q.toLowerCase()); + return status ?? q; +} const STATUS_BADGE_STYLE: Record = { pending: "bg-amber-500/10 text-amber-500 border-amber-500/20 animate-pulse", @@ -66,7 +80,7 @@ export function ImageBuildPage() { // from guessing at the address: the old mock-era email heuristic misread the // real owner (felis-owner@example.com) as non-owner while treating any // "owner@…" address as one. - const { isOwner } = useTier(); + const { isOwner, identity } = useTier(); // Form & Dialog State const [dialogOpen, setDialogOpen] = useState(false); @@ -119,98 +133,45 @@ export function ImageBuildPage() { setBaseImage(""); }; - // Build List State - const [buildIds, setBuildIds] = useState([]); - const [builds, setBuilds] = useState([]); - const [loadingBuilds, setLoadingBuilds] = useState(true); + // Build List State. The list is the server's (GET /images/build), so a build + // started from another browser, or by another admin, shows up and can be + // cancelled here too. const [activeLogBuildId, setActiveLogBuildId] = useState(null); const [cancelBuild, setCancelBuild] = useState(null); - // Search & Pagination State + // Search & Pagination State. The search runs on the server, a moment after + // typing stops, and starts again from the first page. const [search, setSearch] = useState(""); + const [query, setQuery] = useState(""); const [page, setPage] = useState(1); - - // Load build IDs from localStorage on mount useEffect(() => { - try { - const stored = localStorage.getItem(LOCAL_STORAGE_KEY); - if (stored) { - setBuildIds(JSON.parse(stored)); - } - } catch { - setLoadingBuilds(false); - } - }, []); + const timer = setTimeout(() => { + setQuery(serverQuery(search, t)); + setPage(1); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [search, t]); - // Fetch full details of builds - const fetchBuilds = async (ids: string[]) => { - if (ids.length === 0) { - setBuilds([]); - setLoadingBuilds(false); - return; - } - try { - const list = await Promise.all( - ids.map(async (id) => { - try { - return await api.getBuild(id); - } catch { - return null; // ignore individual failures - } - }) - ); - // Filter out nulls and sort by creation time (newest first) - const validBuilds = list.filter((b): b is Build => b !== null); - validBuilds.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - setBuilds(validBuilds); - } catch { - // ignore - } finally { - setLoadingBuilds(false); - } - }; + const listBuilds = useCallback( + () => api.listBuilds({ query: query || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }), + [query, page], + ); + const { + data: buildPage, + error: listError, + loading: loadingBuilds, + reload: reloadBuilds, + } = useAsync(listBuilds, [listBuilds]); + const builds = useMemo(() => buildPage?.builds ?? [], [buildPage]); + const total = buildPage?.total ?? 0; - // Fetch builds when IDs change + // Follow the page while a build on it is still running. The server advances + // builds on its own, so this only re-reads the page. useEffect(() => { - fetchBuilds(buildIds); - }, [buildIds]); - - // Poll active builds - useEffect(() => { - const active = builds.some((b) => b.status === "pending" || b.status === "building"); - if (!active) return; - - const timer = setInterval(() => { - fetchBuilds(buildIds); - }, 4000); - + if (!builds.some(isActive)) return; + const timer = setInterval(reloadBuilds, POLL_MS); return () => clearInterval(timer); - }, [builds, buildIds]); - - // Filtered & Paginated Builds - const filteredBuilds = useMemo(() => { - let list = [...builds]; - if (search.trim()) { - const q = search.toLowerCase(); - list = list.filter( - (b) => - b.id.toLowerCase().includes(q) || - b.image_ref.toLowerCase().includes(q) || - b.status.toLowerCase().includes(q) - ); - } - return list; - }, [builds, search]); - - // Reset page when search changes - useEffect(() => { - setPage(1); - }, [search]); - - const paginatedBuilds = useMemo(() => { - const start = (page - 1) * PAGE_SIZE; - return filteredBuilds.slice(start, start + PAGE_SIZE); - }, [filteredBuilds, page]); + }, [builds, reloadBuilds]); // Form submission handler const handleTrigger = async (e?: React.FormEvent) => { @@ -227,10 +188,11 @@ export function ImageBuildPage() { base_image: baseImage.trim() || undefined, }); - // Save build ID to localStorage and update state - const nextIds = [newBuild.id, ...buildIds]; - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(nextIds)); - setBuildIds(nextIds); + // Back to the top of the unfiltered list, where the new build is. + setSearch(""); + setQuery(""); + setPage(1); + reloadBuilds(); setActiveLogBuildId(newBuild.id); setDialogOpen(false); @@ -249,7 +211,7 @@ export function ImageBuildPage() { // Cancel build handler const handleCancel = async (id: string) => { await api.cancelBuild(id); - fetchBuilds(buildIds); + reloadBuilds(); }; if (!config) { @@ -424,152 +386,157 @@ export function ImageBuildPage() { - {/* List Content */} - {loadingBuilds && builds.length === 0 ? ( + {/* List Content. A failed refresh keeps the rows already shown and + says so above them; with nothing shown yet it takes the card. */} + {buildPage === null && listError ? ( + + ) : buildPage === null && loadingBuilds ? (
- ) : filteredBuilds.length === 0 ? ( + ) : builds.length === 0 ? (
) : (
-
- - - - - - - - - - - - - - {paginatedBuilds.map((b) => ( - - - {/* Column 1: Build ID */} - - - {/* Column 2: Image Reference & Error */} - - - {/* Column 3: Status */} - - - {/* Column 4: Requester */} - - - {/* Column 5: Created At */} - - - {/* Column 6: Duration */} - - - {/* Column 7: Action */} - - - - {/* Inline SSE Log Console */} - {activeLogBuildId === b.id && ( - - - - )} - - ))} - -
{t("table_build_id")}{t("table_ref")}{t("table_status")}{t("table_requester")}{t("table_created_at")}{t("table_duration")}{t("table_action")}
- {b.id} - -
- - {b.image_ref} - - {b.error && ( -
- - {b.error} -
- )} -
-
-
- - {t(`build_status_${b.status}`)} - -
-
- {b.requested_by} - - {formatRelative(b.created_at, now, locale)} - - {formatDuration(b.created_at, b.finished_at, t) || "—"} - -
- {/* Cancel Button */} - {(b.status === "pending" || b.status === "building") && ( - - )} - {/* Log Button */} - -
-
-
-
- -
-
-
+ {listError !== null && ( +
+ + +
+ )} + {/* Build rows. A grid per row, like the image list: at md and up the + columns line up under the header; narrower, each build stacks so + its status and actions stay on screen. */} +
+
{t("table_ref")}
+
{t("table_status")}
+
{t("table_requester")}
+
{t("table_created_at")}
+
{t("table_duration")}
+
{t("table_action")}
+
    + {builds.map((b) => ( +
  • +
    + {/* Image reference, build id, and the failure if any */} +
    + + {b.image_ref} + + {b.id} + {b.error && ( +
    + + {b.error} +
    + )} +
    + + {/* Status */} +
    + + {t(`build_status_${b.status}`)} + +
    + + {/* Requester */} +
    + {identity?.email === b.requested_by && ( + + {t("build_requested_by_you")} + + )} + {b.requested_by} +
    + + {/* Narrow screens: times and actions start their own line. */} + + + {/* Inline SSE Log Console */} + {activeLogBuildId === b.id && ( +
    + +
    + )} +
  • + ))} +
{/* Pagination */} - {filteredBuilds.length > PAGE_SIZE && ( + {total > PAGE_SIZE && (