fix(panel): 构建记录改由服务端分页列表提供,任何浏览器与管理员都能看到并取消进行中的构建,列表刷新失败保留原行并提示
This commit is contained in:
16 files changed
+711
-253
No files matched your search
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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%[email protected]", 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 != "[email protected]" {
|
||||
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) {
|
||||
|
||||
+30
-1
@@ -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: "[email protected]",
|
||||
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 ? "[email protected]" : "[email protected]",
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "你"
|
||||
}
|
||||
@@ -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<typeof vi.fn>).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);
|
||||
|
||||
@@ -413,6 +413,19 @@ export const api = rejectingSync({
|
||||
buildImage: (req: { image_ref: string; dockerfile: string; context_ref: string; base_image?: string }) =>
|
||||
request<Build>("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<Build>("GET", urlPath`/images/build/${id}`),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "[email protected]", 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: "[email protected]",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const MINE: Build = {
|
||||
id: "b-0",
|
||||
image_ref: "registry.felis.svc:5000/paper:3",
|
||||
status: "succeeded",
|
||||
requested_by: "[email protected]",
|
||||
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<string, string>();
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<ImageBuildPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
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: "[email protected]" };
|
||||
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") }));
|
||||
|
||||
@@ -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<BuildStatus, string> = {
|
||||
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 ([email protected]) 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<string[]>([]);
|
||||
const [builds, setBuilds] = useState<Build[]>([]);
|
||||
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<string | null>(null);
|
||||
const [cancelBuild, setCancelBuild] = useState<Build | null>(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() {
|
||||
<SearchInput value={search} onChange={setSearch} placeholder={t("builds_search_placeholder")} />
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<ErrorState error={listError} onRetry={reloadBuilds} />
|
||||
) : buildPage === null && loadingBuilds ? (
|
||||
<div className="py-12"><Loading /></div>
|
||||
) : filteredBuilds.length === 0 ? (
|
||||
) : builds.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<EmptyState
|
||||
title={search.trim() ? t("search_no_results") : t("no_builds_title")}
|
||||
hint={search.trim() ? t("search_no_results_hint") : t("no_builds_hint")}
|
||||
title={query ? t("search_no_results") : t("no_builds_title")}
|
||||
hint={query ? t("search_no_results_hint") : t("no_builds_hint")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground select-none">
|
||||
<th className="px-4 py-2.5 font-medium text-left">{t("table_build_id")}</th>
|
||||
<th className="px-4 py-2.5 font-medium text-left">{t("table_ref")}</th>
|
||||
<th className="px-4 py-2.5 font-medium text-center">{t("table_status")}</th>
|
||||
<th className="px-4 py-2.5 font-medium text-left">{t("table_requester")}</th>
|
||||
<th className="px-4 py-2.5 font-medium text-center">{t("table_created_at")}</th>
|
||||
<th className="px-4 py-2.5 font-medium text-center">{t("table_duration")}</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">{t("table_action")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{paginatedBuilds.map((b) => (
|
||||
<Fragment key={b.id}>
|
||||
<tr className={cn(
|
||||
"hover:bg-accent/40 transition-colors",
|
||||
activeLogBuildId === b.id && "bg-primary/5 hover:bg-primary/5"
|
||||
)}>
|
||||
{/* Column 1: Build ID */}
|
||||
<td className="px-4 py-3 align-middle font-mono font-bold text-muted-foreground select-all text-left">
|
||||
{b.id}
|
||||
</td>
|
||||
|
||||
{/* Column 2: Image Reference & Error */}
|
||||
<td className="px-4 py-3 align-middle text-left">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span
|
||||
className="font-mono font-medium text-foreground select-all block max-w-xl truncate"
|
||||
title={`${t("image_ref_label")}: ${b.image_ref}${b.base_image ? `\n${t("base_image_label")}: ${b.base_image}` : ""}${b.context_ref ? `\n${t("context_ref_label")}: ${b.context_ref}` : ""}`}
|
||||
>
|
||||
{b.image_ref}
|
||||
</span>
|
||||
{b.error && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-destructive font-medium mt-1 select-text" title={b.error}>
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive/80" />
|
||||
<span className="break-all whitespace-pre-wrap">{b.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Column 3: Status */}
|
||||
<td className="px-4 py-3 align-middle text-center">
|
||||
<div className="flex justify-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
title={b.status === "pending" ? t("build_queued_hint") : undefined}
|
||||
className={cn(
|
||||
"text-[10px] py-0.5 font-semibold uppercase tracking-wider w-[80px] justify-center px-0 shrink-0",
|
||||
STATUS_BADGE_STYLE[b.status]
|
||||
)}
|
||||
>
|
||||
{t(`build_status_${b.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Column 4: Requester */}
|
||||
<td className="px-4 py-3 align-middle text-left text-muted-foreground truncate max-w-[12rem]" title={b.requested_by}>
|
||||
{b.requested_by}
|
||||
</td>
|
||||
|
||||
{/* Column 5: Created At */}
|
||||
<td className="px-4 py-3 align-middle text-center text-muted-foreground whitespace-nowrap" title={formatAbsolute(b.created_at, locale)}>
|
||||
{formatRelative(b.created_at, now, locale)}
|
||||
</td>
|
||||
|
||||
{/* Column 6: Duration */}
|
||||
<td className="px-4 py-3 align-middle text-center font-mono text-muted-foreground whitespace-nowrap">
|
||||
{formatDuration(b.created_at, b.finished_at, t) || "—"}
|
||||
</td>
|
||||
|
||||
{/* Column 7: Action */}
|
||||
<td className="px-4 py-3 align-middle text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-1.5 py-0.5">
|
||||
{/* Cancel Button */}
|
||||
{(b.status === "pending" || b.status === "building") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCancelBuild(b)}
|
||||
className="h-7 px-2 text-[11px] gap-1 font-semibold text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
{t("cancel_build_btn")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Log Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setActiveLogBuildId(activeLogBuildId === b.id ? null : b.id)}
|
||||
className={cn(
|
||||
"h-7 px-2 text-[11px] gap-1 font-semibold transition-colors",
|
||||
activeLogBuildId === b.id
|
||||
? "bg-primary/10 text-primary hover:bg-primary/15"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
<span>{t("view_logs_btn")}</span>
|
||||
<ChevronDown className={cn("h-3 w-3 transition-transform duration-200", activeLogBuildId === b.id && "rotate-180")} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Inline SSE Log Console */}
|
||||
{activeLogBuildId === b.id && (
|
||||
<tr className="bg-muted/5 hover:bg-muted/5">
|
||||
<td colSpan={7} className="p-0 border-t border-border">
|
||||
<div className="px-4 pb-4 pt-1 bg-muted/10">
|
||||
<div className="h-[300px] flex flex-col mt-2">
|
||||
<LogConsole url={buildLogsStreamURL(config.apiBase, b.id)} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{listError !== null && (
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 pt-3">
|
||||
<MessageLine
|
||||
kind="error"
|
||||
message={t("builds_refresh_failed", { reason: humanizeError(listError) })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={reloadBuilds}>
|
||||
{t("common:try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<div className="hidden md:grid grid-cols-[minmax(0,1fr)_6rem_minmax(0,11rem)_5.5rem_5.5rem_10.5rem] gap-4 px-4 py-2.5 text-[11px] font-semibold text-muted-foreground border-b select-none">
|
||||
<div>{t("table_ref")}</div>
|
||||
<div className="text-center">{t("table_status")}</div>
|
||||
<div>{t("table_requester")}</div>
|
||||
<div className="text-center">{t("table_created_at")}</div>
|
||||
<div className="text-center">{t("table_duration")}</div>
|
||||
<div className="text-right">{t("table_action")}</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{builds.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className={cn(
|
||||
"px-4 py-3 text-xs transition-colors",
|
||||
activeLogBuildId === b.id ? "bg-primary/5" : "hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 md:grid md:grid-cols-[minmax(0,1fr)_6rem_minmax(0,11rem)_5.5rem_5.5rem_10.5rem] md:gap-4">
|
||||
{/* Image reference, build id, and the failure if any */}
|
||||
<div className="flex w-full min-w-0 flex-col gap-0.5 md:w-auto">
|
||||
<span
|
||||
className="font-mono font-medium text-foreground select-all truncate"
|
||||
title={`${t("image_ref_label")}: ${b.image_ref}${b.base_image ? `\n${t("base_image_label")}: ${b.base_image}` : ""}${b.context_ref ? `\n${t("context_ref_label")}: ${b.context_ref}` : ""}`}
|
||||
>
|
||||
{b.image_ref}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground select-all">{b.id}</span>
|
||||
{b.error && (
|
||||
<div className="flex items-start gap-1.5 text-[11px] text-destructive font-medium mt-0.5 select-text">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0 text-destructive/80" />
|
||||
<span className="break-all whitespace-pre-wrap">{b.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="md:flex md:justify-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
title={b.status === "pending" ? t("build_queued_hint") : undefined}
|
||||
className={cn(
|
||||
"text-[10px] py-0.5 font-semibold uppercase tracking-wider w-[80px] justify-center px-0 shrink-0",
|
||||
STATUS_BADGE_STYLE[b.status]
|
||||
)}
|
||||
>
|
||||
{t(`build_status_${b.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Requester */}
|
||||
<div className="flex min-w-0 flex-1 items-center text-muted-foreground" title={b.requested_by}>
|
||||
{identity?.email === b.requested_by && (
|
||||
<Badge variant="outline" className="mr-1.5 shrink-0 px-1.5 py-0 text-[10px] font-medium">
|
||||
{t("build_requested_by_you")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="truncate">{b.requested_by}</span>
|
||||
</div>
|
||||
|
||||
{/* Narrow screens: times and actions start their own line. */}
|
||||
<div className="h-0 basis-full md:hidden" aria-hidden="true" />
|
||||
|
||||
{/* Created at */}
|
||||
<div className="text-muted-foreground whitespace-nowrap md:text-center" title={formatAbsolute(b.created_at, locale)}>
|
||||
{formatRelative(b.created_at, now, locale)}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="font-mono text-muted-foreground whitespace-nowrap md:text-center">
|
||||
{formatDuration(b.created_at, b.finished_at, t) || "—"}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="ml-auto flex items-center justify-end gap-1.5 md:ml-0">
|
||||
{isActive(b) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCancelBuild(b)}
|
||||
className="h-7 px-2 text-[11px] gap-1 font-semibold text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
{t("cancel_build_btn")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-expanded={activeLogBuildId === b.id}
|
||||
onClick={() => setActiveLogBuildId(activeLogBuildId === b.id ? null : b.id)}
|
||||
className={cn(
|
||||
"h-7 px-2 text-[11px] gap-1 font-semibold transition-colors",
|
||||
activeLogBuildId === b.id
|
||||
? "bg-primary/10 text-primary hover:bg-primary/15"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
<span>{t("view_logs_btn")}</span>
|
||||
<ChevronDown className={cn("h-3 w-3 transition-transform duration-200", activeLogBuildId === b.id && "rotate-180")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline SSE Log Console */}
|
||||
{activeLogBuildId === b.id && (
|
||||
<div className="mt-3 h-[300px] flex flex-col">
|
||||
<LogConsole url={buildLogsStreamURL(config.apiBase, b.id)} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredBuilds.length > PAGE_SIZE && (
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="p-4 border-t">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={filteredBuilds.length}
|
||||
total={total}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user