fix(panel): 构建记录改由服务端分页列表提供,任何浏览器与管理员都能看到并取消进行中的构建,列表刷新失败保留原行并提示

This commit is contained in:
Lemon-miaow committed 2026-09-25 12:28:22 +08:00
1 parent 9d03386c83
commit 06d5e652c6
16 files changed
+711 -253

No files matched your search

+1
View File
@@ -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},
+27
View File
@@ -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) {
+47
View File
@@ -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},