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

+32
View File
@@ -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).
//
+36
View File
@@ -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)
}
}
}
+24
View File
@@ -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() {