From 3875db1974d2816c7f8328727cbf2fcf89921129 Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 26 Sep 2026 10:01:41 +0200 Subject: [PATCH] feat(actions): add build queue view (#38585) Adds a read-only Actions job queue: running jobs first, then waiting jobs in the order a runner picks them up. It is shown instance-wide in the admin Actions section with owner, repository and status filters, and per repository in the Actions tab. Both lists refresh in place. Pending work is currently only visible per repository and newest-first, so nothing shows what is queued, in which order, or what occupies the runners. Reordering the queue will be proposed separately. A migration adds indexes for the runner pickup query and repository-scoped status lookups. * Fix #34198 image image --------- Co-authored-by: Zettat123 Co-authored-by: silverwind Co-authored-by: wxiaoguang --- modelmigration/migrations.go | 1 + modelmigration/v28/v354.go | 34 ++++ modelmigration/v28/v354_test.go | 51 +++++ models/actions/run.go | 4 +- models/actions/run_job.go | 8 +- models/actions/run_job_list_test.go | 51 +++++ models/actions/run_job_queue.go | 81 ++++++++ models/actions/task.go | 23 +++ models/actions/task_test.go | 12 ++ options/locale/locale_en-US.json | 7 + routers/web/repo/actions/actions.go | 6 +- routers/web/repo/actions/job_queue.go | 55 ++++++ routers/web/shared/actions/job_queue.go | 187 +++++++++++++++++++ routers/web/web.go | 2 + templates/admin/actions.tmpl | 3 + templates/admin/navbar.tmpl | 5 +- templates/repo/actions/job_queue.tmpl | 14 ++ templates/repo/actions/list.tmpl | 54 +----- templates/repo/actions/runs_list.tmpl | 2 +- templates/repo/actions/sidebar.tmpl | 63 +++++++ templates/shared/actions/job_queue_list.tmpl | 93 +++++++++ tests/e2e/actions.test.ts | 29 +++ tests/integration/actions_job_queue_test.go | 112 +++++++++++ types.d.ts | 7 +- web_src/css/base.css | 4 +- web_src/css/modules/dropdown.css | 12 +- web_src/css/modules/menu.css | 5 +- web_src/js/features/repo-actions.ts | 33 ++-- web_src/js/utils/dom.test.ts | 32 +++- web_src/js/utils/dom.ts | 33 +++- 30 files changed, 928 insertions(+), 95 deletions(-) create mode 100644 modelmigration/v28/v354.go create mode 100644 modelmigration/v28/v354_test.go create mode 100644 models/actions/run_job_queue.go create mode 100644 routers/web/repo/actions/job_queue.go create mode 100644 routers/web/shared/actions/job_queue.go create mode 100644 templates/repo/actions/job_queue.tmpl create mode 100644 templates/repo/actions/sidebar.tmpl create mode 100644 templates/shared/actions/job_queue_list.tmpl create mode 100644 tests/e2e/actions.test.ts create mode 100644 tests/integration/actions_job_queue_test.go diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 6a655ebb98a..8cdcddd43df 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -427,6 +427,7 @@ func prepareMigrationTasks() []*migration { newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer), newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey), newMigration(353, "Add audit event table", v28.AddAuditEventTable), + newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes), } return preparedMigrations } diff --git a/modelmigration/v28/v354.go b/modelmigration/v28/v354.go new file mode 100644 index 00000000000..da919068955 --- /dev/null +++ b/modelmigration/v28/v354.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "context" + + "gitea.dev/modelmigration/base" + "gitea.dev/modules/timeutil" + + "xorm.io/xorm" +) + +// AddActionQueueIndexes indexes the runner pickup query and repository-scoped status lookups. +func AddActionQueueIndexes(_ context.Context, x base.EngineMigration) error { + type ActionRunJob struct { + RepoID int64 `xorm:"index(repo_status)"` + TaskID int64 `xorm:"index(pickup)"` + Status int `xorm:"index(pickup) index(repo_status)"` + Updated timeutil.TimeStamp `xorm:"index(pickup)"` + } + + type ActionRun struct { + RepoID int64 `xorm:"index(repo_status)"` + Status int `xorm:"index(repo_status)"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + IgnoreConstrains: true, + }, new(ActionRunJob), new(ActionRun)) + return err +} diff --git a/modelmigration/v28/v354_test.go b/modelmigration/v28/v354_test.go new file mode 100644 index 00000000000..bab21888d57 --- /dev/null +++ b/modelmigration/v28/v354_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "testing" + + "gitea.dev/modelmigration/migrationtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddActionQueueIndexes(t *testing.T) { + type ActionRunJob struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 + TaskID int64 + Status int + Updated int64 `xorm:"updated"` + } + type ActionRun struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 + Status int + } + + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ActionRunJob), new(ActionRun)) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, AddActionQueueIndexes(t.Context(), x)) + + tables := migrationtest.LoadTableSchemasMap(t, x) + indexCols := func(table string) [][]string { + schema, ok := tables[table] + require.True(t, ok) + var cols [][]string + for _, idx := range schema.Indexes { + cols = append(cols, idx.Cols) + } + return cols + } + + assert.Contains(t, indexCols("action_run_job"), []string{"task_id", "status", "updated"}) + assert.Contains(t, indexCols("action_run_job"), []string{"repo_id", "status"}) + assert.Contains(t, indexCols("action_run"), []string{"repo_id", "status"}) +} diff --git a/models/actions/run.go b/models/actions/run.go index d4cc9665d43..733d21b0cfa 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -30,7 +30,7 @@ import ( type ActionRun struct { ID int64 Title string - RepoID int64 `xorm:"unique(repo_index)"` + RepoID int64 `xorm:"unique(repo_index) index(repo_status)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` WorkflowID string `xorm:"index"` // the name of workflow file @@ -47,7 +47,7 @@ type ActionRun struct { Event webhook_module.HookEventType // the webhook event that causes the workflow to run EventPayload string `xorm:"LONGTEXT"` TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow - Status Status `xorm:"index"` + Status Status `xorm:"index index(repo_status)"` Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed RawConcurrency string // raw concurrency diff --git a/models/actions/run_job.go b/models/actions/run_job.go index c32ce538f8a..13a77ea954f 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -33,7 +33,7 @@ type ActionRunJob struct { ID int64 RunID int64 `xorm:"index"` Run *ActionRun `xorm:"-"` - RepoID int64 `xorm:"index(repo_concurrency)"` + RepoID int64 `xorm:"index(repo_concurrency) index(repo_status)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` CommitSHA string `xorm:"index"` @@ -52,10 +52,10 @@ type ActionRunJob struct { Needs []string `xorm:"JSON TEXT"` RunsOn []string `xorm:"JSON TEXT"` - TaskID int64 // the task created by this job in its own attempt + TaskID int64 `xorm:"index(pickup)"` // the task created by this job in its own attempt SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"` // SourceTaskID points to a historical task when this job reuses an earlier attempt's result. - Status Status `xorm:"index"` + Status Status `xorm:"index index(pickup) index(repo_status)"` RawConcurrency string // raw concurrency from job YAML's "concurrency" section @@ -131,7 +131,7 @@ type ActionRunJob struct { Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` - Updated timeutil.TimeStamp `xorm:"updated index"` + Updated timeutil.TimeStamp `xorm:"updated index index(pickup)"` } // ActionRunAttemptJobIDIndex backs the run-wide AttemptJobID counter, keyed by ActionRun.ID. diff --git a/models/actions/run_job_list_test.go b/models/actions/run_job_list_test.go index 271031b4cdd..053114c3107 100644 --- a/models/actions/run_job_list_test.go +++ b/models/actions/run_job_list_test.go @@ -6,7 +6,12 @@ package actions import ( "testing" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/timeutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestActionJobList_SortMatrixGroupsByName(t *testing.T) { @@ -59,3 +64,49 @@ func TestActionJobList_SortMatrixGroupsByName(t *testing.T) { assert.Equal(t, []string{"only"}, names(jobs)) }) } + +func TestFindJobQueueJobs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + const repoID int64 = 987654 + + insert := func(status Status, taskID int64, reusable bool, updated timeutil.TimeStamp) int64 { + job := &ActionRunJob{RepoID: repoID, Status: status, TaskID: taskID, IsReusableCaller: reusable, Updated: updated} + _, err := db.GetEngine(ctx).NoAutoTime().Insert(job) + require.NoError(t, err) + return job.ID + } + queuedA := insert(StatusWaiting, 0, false, 200) + queuedB := insert(StatusWaiting, 0, false, 300) + queuedC := insert(StatusWaiting, 0, false, 100) + running := insert(StatusRunning, 998, false, 0) + cancelling := insert(StatusCancelling, 999, false, 0) + insert(StatusWaiting, 999, false, 0) + insert(StatusWaiting, 0, true, 0) + + find := func(status Status, page, pageSize int) (ids []int64, total int64) { + jobs, total, err := FindJobQueueJobs(ctx, JobQueueOptions{RepoID: repoID, Status: status}, page, pageSize) + require.NoError(t, err) + for _, job := range jobs { + ids = append(ids, job.ID) + } + return ids, total + } + + ids, total := find(StatusUnknown, 1, 10) + assert.EqualValues(t, 5, total) + assert.Equal(t, []int64{running, cancelling, queuedC, queuedA, queuedB}, ids) + + ids, _ = find(StatusWaiting, 1, 10) + assert.Equal(t, []int64{queuedC, queuedA, queuedB}, ids) + + ids, _ = find(StatusRunning, 1, 10) + assert.Equal(t, []int64{running, cancelling}, ids) + + ids, _ = find(StatusUnknown, 99, 3) + assert.Equal(t, []int64{queuedA, queuedB}, ids) + + repoIDs, err := JobQueueFilterRepoIDs(ctx, 1000) + require.NoError(t, err) + assert.Contains(t, repoIDs, repoID) +} diff --git a/models/actions/run_job_queue.go b/models/actions/run_job_queue.go new file mode 100644 index 00000000000..86a5276b799 --- /dev/null +++ b/models/actions/run_job_queue.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + "gitea.dev/models/db" + + "xorm.io/builder" + "xorm.io/xorm" +) + +// JobQueueOptions scopes the job queue to a repo, an owner or, when both are zero, the instance. +type JobQueueOptions struct { + RepoID int64 + OwnerID int64 + Status Status +} + +func (opts JobQueueOptions) session(ctx context.Context) *xorm.Session { + // a reusable-workflow caller only tracks its children, it never occupies a runner itself + sess := db.GetEngine(ctx).Table("action_run_job").Where(builder.Eq{"`action_run_job`.is_reusable_caller": false}) + if opts.RepoID > 0 { + sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID}) + } + if opts.OwnerID > 0 { + sess = sess.Join("INNER", "repository", "repository.id = `action_run_job`.repo_id AND repository.owner_id = ?", opts.OwnerID) + } + return sess.And(opts.statusCond()) +} + +var ( + // keep in sync with CreateTaskForRunner + queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0} + // a cancelling job still occupies its runner + runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling) +) + +func (opts JobQueueOptions) statusCond() builder.Cond { + switch opts.Status { + case StatusRunning: + return runningJobsCond + case StatusWaiting: + return queuedJobsCond + default: + return builder.Or(runningJobsCond, queuedJobsCond) + } +} + +// active jobs first by start time, then queued jobs in pickup order +var jobQueueOrderBy = fmt.Sprintf( + "CASE WHEN `action_run_job`.status IN (%d, %d) THEN 0 ELSE 1 END ASC, CASE WHEN `action_run_job`.status IN (%d, %d) THEN `action_run_job`.started ELSE `action_run_job`.updated END ASC, `action_run_job`.id ASC", + StatusRunning, StatusCancelling, StatusRunning, StatusCancelling) + +// FindJobQueueJobs returns one page of the job queue and its total count. +func FindJobQueueJobs(ctx context.Context, opts JobQueueOptions, page, pageSize int) ([]*ActionRunJob, int64, error) { + total, err := opts.session(ctx).Count(new(ActionRunJob)) + if err != nil || total == 0 { + return nil, total, err + } + + // Auto-refresh can shrink the queue under a user still on page 2; show the last page instead of empty. + page = min(page, int((total+int64(pageSize)-1)/int64(pageSize))) + + jobs := make([]*ActionRunJob, 0, pageSize) + return jobs, total, opts.session(ctx). + Cols("`action_run_job`.id", "`action_run_job`.repo_id", "`action_run_job`.name", "`action_run_job`.status", // skip the payload columns + "`action_run_job`.run_id", "`action_run_job`.runs_on", "`action_run_job`.updated", "`action_run_job`.started", "`action_run_job`.task_id"). + OrderBy(jobQueueOrderBy). + Limit(pageSize, (page-1)*pageSize). + Find(&jobs) +} + +// JobQueueFilterRepoIDs returns up to limit ids of the repositories with queued or running jobs. +func JobQueueFilterRepoIDs(ctx context.Context, limit int) ([]int64, error) { + var ids []int64 + return ids, JobQueueOptions{}.session(ctx).Distinct("`action_run_job`.repo_id").Limit(limit).Find(&ids) +} diff --git a/models/actions/task.go b/models/actions/task.go index f4b8a4a54fe..0e922be83bf 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -160,6 +160,29 @@ func GetTasksMapByIDs(ctx context.Context, ids []int64) (map[int64]*ActionTask, return tasks, db.GetEngine(ctx).In("id", ids).Find(&tasks) } +// GetTaskRunnerNames returns runner names keyed by task ID without loading task logs. +func GetTaskRunnerNames(ctx context.Context, taskIDs []int64) (map[int64]string, error) { + names := make(map[int64]string, len(taskIDs)) + if len(taskIDs) == 0 { + return names, nil + } + var rows []struct { + ID int64 + Name string + } + err := db.GetEngine(ctx).Table("action_task"). + Join("INNER", "action_runner", "action_runner.id = action_task.runner_id"). + In("action_task.id", taskIDs). + Select("action_task.id, action_runner.name").Find(&rows) + if err != nil { + return nil, err + } + for _, row := range rows { + names[row.ID] = row.Name + } + return names, nil +} + func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) { errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist) if token == "" { diff --git a/models/actions/task_test.go b/models/actions/task_test.go index 0ee7e028657..ca872089c49 100644 --- a/models/actions/task_test.go +++ b/models/actions/task_test.go @@ -38,6 +38,18 @@ func TestActionTask_GetRunJobLink(t *testing.T) { assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink()) } +func TestGetTaskRunnerNames(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + runner := &ActionRunner{Name: "queue-runner"} + require.NoError(t, db.Insert(ctx, runner)) + task := &ActionTask{RunnerID: runner.ID, TokenHash: "queue-test-task"} + require.NoError(t, db.Insert(ctx, task)) + names, err := GetTaskRunnerNames(ctx, []int64{task.ID, 987654321}) + require.NoError(t, err) + assert.Equal(t, map[int64]string{task.ID: runner.Name}, names) +} + func TestMakeTaskStepDisplayName(t *testing.T) { tests := []struct { name string diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 7c376dbbb0e..fbdfe2eb965 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3936,6 +3936,13 @@ "actions.artifacts.back_to_run_attempt": "Back to action run #%d (attempt %d)", "actions.need_approval_desc": "Need approval to run workflows for fork pull request.", "actions.approve_all_success": "All workflow runs are approved successfully.", + "actions.management": "Management", + "actions.job_queue.title": "Job queue", + "actions.job_queue.runs_on": "Runs on", + "actions.job_queue.waiting_or_started": "Waiting / started", + "actions.job_queue.no_jobs": "No jobs are running or waiting to be picked up.", + "actions.job_queue.filter_owner_no_select": "All owners", + "actions.job_queue.filter_repo_no_select": "All repositories", "actions.variables": "Variables", "actions.variables.management": "Variables Management", "actions.variables.creation": "Add Variable", diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index a4336727260..c73f413c953 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -29,6 +29,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/util" + shared_actions "gitea.dev/routers/web/shared/actions" shared_user "gitea.dev/routers/web/shared/user" actions_service "gitea.dev/services/actions" "gitea.dev/services/context" @@ -595,10 +596,7 @@ func (data *actionRunListData) fillRefreshMeta(ctx *context.Context) bool { } actionRunIDs = append(actionRunIDs, run.ID) } - data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 3*1000, 12*1000) - if !setting.IsProd { - data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 1000, 2*1000) // faster in dev mode to make debug easier - } + data.RefreshIntervalMs = shared_actions.RefreshIntervalMs(hasActiveRuns) if len(data.ActionRuns) == 0 { data.RefreshIntervalMs = 0 } diff --git a/routers/web/repo/actions/job_queue.go b/routers/web/repo/actions/job_queue.go new file mode 100644 index 00000000000..44f1ef4369f --- /dev/null +++ b/routers/web/repo/actions/job_queue.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + + "gitea.dev/modules/util" + shared_actions "gitea.dev/routers/web/shared/actions" + "gitea.dev/services/context" +) + +// JobQueue renders this repository's Actions job queue. +func JobQueue(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageIsActions"] = true + ctx.Data["PageIsActionsJobQueue"] = true + if !ctx.FormBool("refresh") { + prepareActionsSidebar(ctx) + if ctx.Written() { + return + } + } + shared_actions.RenderJobQueue(ctx, ctx.Repo.Repository.ID, "repo/actions/job_queue") +} + +// prepareActionsSidebar lists the workflows without binding the runs list filters. +func prepareActionsSidebar(ctx *context.Context) { + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx, ctx.Repo.Repository.DefaultBranch) + if errors.Is(err, util.ErrNotExist) { + ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch) + ctx.NotFound(nil) + return + } else if err != nil { + ctx.ServerError("GetBranchCommit", err) + return + } + + workflows, _ := prepareWorkflowTemplate(ctx, commit) + if ctx.Written() { + return + } + ctx.Data["CurWorkflow"] = "" + ctx.Data["CurWorkflowScopedRepoID"] = int64(0) + ctx.Data["CurActor"] = int64(0) + ctx.Data["CurStatus"] = 0 + ctx.Data["CurBranch"] = "" + + scopedNames := prepareScopedWorkflows(ctx, "", 0) + if ctx.Written() { + return + } + prepareOtherWorkflows(ctx, workflows, scopedNames, "") +} diff --git a/routers/web/shared/actions/job_queue.go b/routers/web/shared/actions/job_queue.go new file mode 100644 index 00000000000..a0710983e46 --- /dev/null +++ b/routers/web/shared/actions/job_queue.go @@ -0,0 +1,187 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "maps" + "net/http" + "net/url" + "slices" + "strconv" + + actions_model "gitea.dev/models/actions" + repo_model "gitea.dev/models/repo" + user_model "gitea.dev/models/user" + "gitea.dev/modules/base" + "gitea.dev/modules/container" + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + "gitea.dev/modules/util" + "gitea.dev/services/context" +) + +// JobQueue renders the instance-wide Actions job queue on the admin settings page. +func JobQueue(ctx *context.Context) { + ctx.Data["PageIsSharedSettingsActionsJobQueue"] = true + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageType"] = "job_queue" + + RenderJobQueue(ctx, 0, "admin/actions") +} + +const jobQueuePageSize = 50 + +// RefreshIntervalMs is how often an auto-refreshing Actions list re-fetches itself. +func RefreshIntervalMs(hasActivity bool) int64 { + if !setting.IsProd { + return util.Iif[int64](hasActivity, 1000, 2*1000) + } + return util.Iif[int64](hasActivity, 3*1000, 12*1000) +} + +// RenderJobQueue renders the job queue of one repository, or of the instance when repoID is 0. +func RenderJobQueue(ctx *context.Context, repoID int64, fullTemplate templates.TplName) { + page := max(ctx.FormInt("page"), 1) + + filterStatus := ctx.FormString("status") + status := actions_model.StatusUnknown + switch filterStatus { + case actions_model.StatusRunning.String(): + status = actions_model.StatusRunning + case actions_model.StatusWaiting.String(): + status = actions_model.StatusWaiting + default: + filterStatus = "" + } + var filterOwnerID, filterRepoID int64 + if repoID == 0 { + var err error + if filterOwnerID, filterRepoID, err = renderJobQueueFilterOptions(ctx); err != nil { + ctx.ServerError("renderJobQueueFilterOptions", err) + return + } + } + ctx.Data["JobQueueFilterOwnerID"], ctx.Data["JobQueueFilterRepoID"] = filterOwnerID, filterRepoID + + jobs, total, err := actions_model.FindJobQueueJobs(ctx, actions_model.JobQueueOptions{ + RepoID: util.Iif(filterRepoID > 0, filterRepoID, repoID), + OwnerID: filterOwnerID, + Status: status, + }, page, jobQueuePageSize) + if err != nil { + ctx.ServerError("FindJobQueueJobs", err) + return + } + if err := actions_model.ActionJobList(jobs).LoadAttributes(ctx, true); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + runners, err := actions_model.GetTaskRunnerNames(ctx, container.FilterSlice(jobs, func(job *actions_model.ActionRunJob) (int64, bool) { + return job.TaskID, job.TaskID > 0 + })) + if err != nil { + ctx.ServerError("GetTaskRunnerNames", err) + return + } + + ctx.Data["JobQueueJobs"] = jobs + ctx.Data["JobQueueRunners"] = runners + ctx.Data["JobQueueTotal"] = total + if !setting.IsProd && !ctx.FormBool("refresh") { + // for dev mode, force the first screen to be blank to debug more edge cases + ctx.Data["JobQueueJobs"], ctx.Data["JobQueueRunners"], ctx.Data["JobQueueTotal"] = nil, nil, 0 + } + ctx.Data["ShowRepoColumn"] = repoID == 0 + ctx.Data["JobQueueFilterStatus"] = filterStatus + ctx.Data["JobQueueFilterStatuses"] = []string{actions_model.StatusRunning.String(), actions_model.StatusWaiting.String()} + + pager := context.NewPagerBuilder(ctx).TotalCount(total).PerPageLimit(jobQueuePageSize).CurPage(page).Build() + query := url.Values{} + if filterOwnerID > 0 { + query.Set("owner_id", strconv.FormatInt(filterOwnerID, 10)) + } + if filterRepoID > 0 { + query.Set("repo_id", strconv.FormatInt(filterRepoID, 10)) + } + if filterStatus != "" { + query.Set("status", filterStatus) + } + pager.RemoveParam(container.SetOf("refresh", "owner_id", "repo_id", "status")) + pager.AddParamFromQuery(query) + ctx.Data["Page"] = pager + + ctx.Data["JobQueueRefreshIntervalMs"] = RefreshIntervalMs(len(jobs) > 0) + query.Set("page", strconv.Itoa(pager.Paginator.Current())) + query.Set("refresh", "1") + ctx.Data["JobQueueRefreshLink"] = setting.AppSubURL + ctx.Req.URL.EscapedPath() + "?" + query.Encode() + + if ctx.FormBool("refresh") { + ctx.HTML(http.StatusOK, "shared/actions/job_queue_list") + return + } + ctx.HTML(http.StatusOK, fullTemplate) +} + +// JobQueueFilterOwner is one entry of the job queue's owner filter. +type JobQueueFilterOwner struct { + ID int64 + Name string +} + +// renderJobQueueFilterOptions includes pending work and the selected scope, even when its queue is empty. +func renderJobQueueFilterOptions(ctx *context.Context) (filterOwnerID, filterRepoID int64, _ error) { + repoIDs, err := actions_model.JobQueueFilterRepoIDs(ctx, 200) + if err != nil { + return 0, 0, err + } + reqRepoID := ctx.FormInt64("repo_id") + if reqRepoID > 0 { + repoIDs = append(repoIDs, reqRepoID) + } + repoMap, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) + if err != nil { + return 0, 0, err + } + + repos := slices.Collect(maps.Values(repoMap)) + slices.SortFunc(repos, func(a, b *repo_model.Repository) int { + return base.NaturalSortCompare(a.FullName(), b.FullName()) + }) + + owners := make([]*JobQueueFilterOwner, 0, len(repos)) + seenOwners := make(container.Set[int64], len(repos)) + for _, repo := range repos { + if seenOwners.Add(repo.OwnerID) { + owners = append(owners, &JobQueueFilterOwner{ID: repo.OwnerID, Name: repo.OwnerName}) + } + } + + if repo := repoMap[reqRepoID]; repo != nil { + filterRepoID = repo.ID + ctx.Data["JobQueueFilterRepoName"] = repo.FullName() + } else if reqOwnerID := ctx.FormInt64("owner_id"); reqOwnerID > 0 { + owner, err := user_model.GetUserByID(ctx, reqOwnerID) + if err != nil && !user_model.IsErrUserNotExist(err) { + return 0, 0, err + } + if owner != nil { + filterOwnerID = owner.ID + ctx.Data["JobQueueFilterOwnerName"] = owner.Name + if seenOwners.Add(owner.ID) { + owners = append(owners, &JobQueueFilterOwner{ID: owner.ID, Name: owner.Name}) + } + } + } + slices.SortFunc(owners, func(a, b *JobQueueFilterOwner) int { + return base.NaturalSortCompare(a.Name, b.Name) + }) + + if filterOwnerID > 0 { + repos = slices.DeleteFunc(repos, func(repo *repo_model.Repository) bool { return repo.OwnerID != filterOwnerID }) + } + ctx.Data["JobQueueFilterOwners"] = owners + ctx.Data["JobQueueFilterRepos"] = repos + return filterOwnerID, filterRepoID, nil +} diff --git a/routers/web/web.go b/routers/web/web.go index 3e2740b1ad6..0aecc64c476 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -907,6 +907,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost) addSettingsVariablesRoutes() addSettingsScopedWorkflowsRoutes() + m.Get("/job_queue", shared_actions.JobQueue) }) }, adminReq, ctxDataSet(reqctx.ContextData{"EnableOAuth2": setting.OAuth2.Enabled, "EnablePackages": setting.Packages.Enabled})) // ***** END: Admin ***** @@ -1563,6 +1564,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/{username}/{reponame}/actions", func() { m.Get("", actions.List) + m.Get("/job_queue", actions.JobQueue) m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) m.Post("/run", reqRepoActionsWriter, actions.Run) diff --git a/templates/admin/actions.tmpl b/templates/admin/actions.tmpl index 6be9bdf73b0..a20ce650100 100644 --- a/templates/admin/actions.tmpl +++ b/templates/admin/actions.tmpl @@ -9,5 +9,8 @@ {{if eq .PageType "scoped-workflows"}} {{template "shared/actions/scoped_workflows" .}} {{end}} + {{if eq .PageType "job_queue"}} + {{template "shared/actions/job_queue_list" .}} + {{end}} {{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index b7c28d17e5a..4f821a9e660 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -72,7 +72,7 @@ {{end}} {{end}} {{if .EnableActions}} -
+
{{ctx.Locale.Tr "actions.actions"}}
{{end}} diff --git a/templates/repo/actions/job_queue.tmpl b/templates/repo/actions/job_queue.tmpl new file mode 100644 index 00000000000..0b16bd0fb41 --- /dev/null +++ b/templates/repo/actions/job_queue.tmpl @@ -0,0 +1,14 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ {{template "base/alert" .}} +
+ {{template "repo/actions/sidebar" .}} +
+ {{template "shared/actions/job_queue_list" .}} +
+
+
+
+{{template "base/footer" .}} diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index ab5aa5f8b3d..981bafcff6c 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -10,59 +10,7 @@ {{if .HasWorkflowsOrRuns}}
-
- -
+ {{template "repo/actions/sidebar" .}}
{{ctx.Locale.TrN .Page.Paginator.Total "actions.runs.workflow_run_count_1" "actions.runs.workflow_run_count_n" .Page.Paginator.Total}} diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index e65143ccf35..a5c403d5781 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -10,7 +10,7 @@
{{end}} {{range $run := $data.ActionRuns}} -
+
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}} diff --git a/templates/repo/actions/sidebar.tmpl b/templates/repo/actions/sidebar.tmpl new file mode 100644 index 00000000000..4a2dfafdc01 --- /dev/null +++ b/templates/repo/actions/sidebar.tmpl @@ -0,0 +1,63 @@ +
+ + + +
diff --git a/templates/shared/actions/job_queue_list.tmpl b/templates/shared/actions/job_queue_list.tmpl new file mode 100644 index 00000000000..af21d454e79 --- /dev/null +++ b/templates/shared/actions/job_queue_list.tmpl @@ -0,0 +1,93 @@ +
+
+ {{ctx.Locale.Tr "actions.job_queue.title"}} ({{ctx.Locale.Tr "admin.total" .JobQueueTotal}}) + +
+
+ {{if .JobQueueJobs}} + + + + + {{if .ShowRepoColumn}}{{end}} + + + + + + + + + {{range $job := .JobQueueJobs}} + + + {{if $.ShowRepoColumn}}{{end}} + + + + + + + {{end}} + +
{{ctx.Locale.Tr "actions.runs.status"}}{{ctx.Locale.Tr "repository"}}{{ctx.Locale.Tr "actions.runners.task_list.job"}}{{ctx.Locale.Tr "actions.job_queue.runs_on"}}{{ctx.Locale.Tr "actions.runners.runner_title"}}{{ctx.Locale.Tr "actions.runs.commit"}}{{ctx.Locale.Tr "actions.job_queue.waiting_or_started"}}
+ + {{template "repo/icons/action_status" (dict "Status" $job.Status.String)}} + + {{if $job.Repo}}{{$job.Repo.FullName}}{{end}}{{if $job.Run}}{{$job.Name}} #{{$job.Run.Index}}{{else}}{{$job.Name}}{{end}}{{range $job.RunsOn}}{{.}}{{end}}{{index $.JobQueueRunners $job.TaskID}}{{if and $job.Run $job.Repo}}{{ShortSha $job.Run.CommitSHA}}{{end}}{{if $job.Started}}{{DateUtils.TimeSince $job.Started}}{{else}}{{DateUtils.TimeSince $job.Updated}}{{end}}
+ {{else}} +
{{ctx.Locale.Tr "actions.job_queue.no_jobs"}}
+ {{end}} + {{template "base/paginate" .}} +
+ +
diff --git a/tests/e2e/actions.test.ts b/tests/e2e/actions.test.ts new file mode 100644 index 00000000000..ce0edf08ab2 --- /dev/null +++ b/tests/e2e/actions.test.ts @@ -0,0 +1,29 @@ +import {env} from 'node:process'; +import {expect, test} from '@playwright/test'; +import {apiCreateFiles, apiCreateRepo, apiHeaders, randomString} from './utils.ts'; + +test('job queue refreshes with filter open', async ({page, request}) => { + const owner = env.GITEA_TEST_E2E_USER; + const repo = `e2e-queue-${randomString(8)}`; + await apiCreateRepo(request, {name: repo, autoInit: false}); + await apiCreateFiles(request, owner, repo, [{ + path: '.gitea/workflows/queue.yml', + content: 'on: workflow_dispatch\njobs:\n queued:\n runs-on: no-runner\n steps:\n - run: exit 0\n', + }]); + const dispatch = async () => { + const response = await request.post(`/api/v1/repos/${owner}/${repo}/actions/workflows/queue.yml/dispatches`, {headers: apiHeaders(), data: {ref: 'main'}}); + expect(response.ok()).toBe(true); + }; + await dispatch(); + + await page.clock.install(); + await page.goto(`/${owner}/${repo}/actions/job_queue`); + await page.getByRole('menu').getByText('Status', {exact: true}).click(); + const waitingFilter = page.getByRole('menuitem', {name: 'Waiting'}); + await expect(waitingFilter).toBeVisible(); + + await dispatch(); + await page.clock.fastForward(3000); + await expect(page.getByRole('link', {name: 'queued #2'})).toBeVisible(); + await expect(waitingFilter).toBeVisible(); +}); diff --git a/tests/integration/actions_job_queue_test.go b/tests/integration/actions_job_queue_test.go new file mode 100644 index 00000000000..2ab1d69971a --- /dev/null +++ b/tests/integration/actions_job_queue_test.go @@ -0,0 +1,112 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "strconv" + "strings" + "testing" + + actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unittest" + "gitea.dev/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionsJobQueue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + ctx := t.Context() + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + + insertQueuedJob := func(repo *repo_model.Repository, index int64, jobName string) *actions_model.ActionRunJob { + run := &actions_model.ActionRun{RepoID: repo.ID, OwnerID: repo.OwnerID, Index: index, Status: actions_model.StatusWaiting} + require.NoError(t, db.Insert(ctx, run)) + job := &actions_model.ActionRunJob{RunID: run.ID, RepoID: repo.ID, Name: jobName, Status: actions_model.StatusWaiting} + require.NoError(t, db.Insert(ctx, job)) + return job + } + const queuedJobName, otherJobName, callerJobName = "queued-job-marker", "queued-job-other-owner", "reusable-caller-marker" + job := insertQueuedJob(repo1, 8801, queuedJobName) + insertQueuedJob(repo3, 8802, otherJobName) + require.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{ + RunID: job.RunID, + RepoID: repo1.ID, + Name: callerJobName, + Status: actions_model.StatusRunning, + IsReusableCaller: true, + })) + + const repoJobQueue = "/user2/repo1/actions/job_queue" + sessionUser2 := loginUser(t, "user2") + repoDoc := NewHTMLParser(t, sessionUser2.MakeRequest(t, NewRequest(t, "GET", repoJobQueue+"?workflow=test.yaml"), http.StatusOK).Body) + assert.Contains(t, repoDoc.Find("#actions-job-queue tbody").Text(), queuedJobName) + assert.NotContains(t, repoDoc.Find("#actions-job-queue tbody").Text(), callerJobName) + assert.Equal(t, 1, repoDoc.Find(`.flex-container-nav a.active[href="`+repoJobQueue+`"]`).Length()) + assert.Zero(t, repoDoc.Find(`.flex-container-nav a.active:not([href="`+repoJobQueue+`"])`).Length()) + + listDoc := NewHTMLParser(t, sessionUser2.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/actions"), http.StatusOK).Body) + assert.Equal(t, 1, listDoc.Find(`.flex-container-nav a:not(.active)[href="`+repoJobQueue+`"]`).Length()) + + assert.Contains(t, MakeRequest(t, NewRequest(t, "GET", repoJobQueue), http.StatusOK).Body.String(), queuedJobName) + + sessionAdmin := loginUser(t, "user1") + adminGet := func(link string) (string, *HTMLDoc) { + body := sessionAdmin.MakeRequest(t, NewRequest(t, "GET", link), http.StatusOK).Body.String() + return body, NewHTMLParser(t, strings.NewReader(body)) + } + refreshLinkOf := func(doc *HTMLDoc) string { + link, ok := doc.Find("#actions-job-queue").Attr("data-job-queue-refresh-link") + require.True(t, ok) + return link + } + repoFilterSelector := func(repoID int64) string { + return `#actions-job-queue-filter a[href^="?repo_id=` + strconv.FormatInt(repoID, 10) + `&"]` + } + + const adminJobQueue = "/-/admin/actions/job_queue" + unfiltered, unfilteredDoc := adminGet(adminJobQueue) + assert.Contains(t, unfiltered, queuedJobName) + assert.Contains(t, unfiltered, otherJobName) + assert.Equal(t, 1, unfilteredDoc.Find(repoFilterSelector(repo1.ID)).Length()) + + refresh, refreshDoc := adminGet(refreshLinkOf(unfilteredDoc)) + assert.NotContains(t, refresh, " boolean, + }, + }): Node[]; } export const Idiomorph: Idiomorph; } diff --git a/web_src/css/base.css b/web_src/css/base.css index 535cac150c6..3df9cea3691 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -844,8 +844,8 @@ table th[data-sortt-desc] .svg { min-width: 0; } -.ui.list.flex-items-block > .item, -.ui.vertical.menu .item.flex-text-block, +.ui.ui.ui.ui .item.flex-text-block, /* override .ui.list and .ui.vertical.menu */ +.ui.ui.ui.ui.flex-items-block > .item, .ui.form .field > label.flex-text-block, /* override fomantic "block" style */ .flex-items-block > .item, .flex-text-block { diff --git a/web_src/css/modules/dropdown.css b/web_src/css/modules/dropdown.css index 28c4c27414c..cbd00aecccb 100644 --- a/web_src/css/modules/dropdown.css +++ b/web_src/css/modules/dropdown.css @@ -817,10 +817,6 @@ select.ui.dropdown { max-height: 2em; } -.ui.dropdown .menu > .item > svg { - margin-right: 0.78rem; -} - /* extend fomantic style '.ui.dropdown > .text > img' to include svg.img */ .ui.dropdown > .text > .img { margin-left: 0; @@ -893,7 +889,8 @@ select.ui.dropdown { } /* to override Fomantic's default display: block for ".menu .item", and use a slightly larger gap for menu item content -the "!important" is necessary to override Fomantic UI menu item styles, meanwhile we should keep the "hidden" items still hidden */ +the "!important" is necessary to override Fomantic UI menu item styles, meanwhile we should keep the "hidden" items still hidden +TODO: rename it to "flex-items-dropdown" in the future since it is only used for dropdown menu items, not for other menu items */ .ui.dropdown .menu.flex-items-menu > .item:not(.hidden, .filtered, .tw-hidden) { display: flex !important; align-items: center; @@ -901,6 +898,11 @@ the "!important" is necessary to override Fomantic UI menu item styles, meanwhil min-width: 0; } +/* TODO: MENU-ITEM-FLEX-MARGIN: need to refactor: flex menu items don't need svg margin, too many patches (see below ...) */ +.ui.dropdown .menu:not(.flex-items-menu) > .item:not(.flex-text-block, .flex-text-inline) > .svg { + margin-right: 10px; +} + .ui.dropdown .menu.flex-items-menu > .item img, .ui.dropdown .menu.flex-items-menu > .item svg { margin: 0; diff --git a/web_src/css/modules/menu.css b/web_src/css/modules/menu.css index 418b4eb5216..cbeb4210f33 100644 --- a/web_src/css/modules/menu.css +++ b/web_src/css/modules/menu.css @@ -55,8 +55,9 @@ background: var(--color-secondary); } -.ui.menu .item > .svg { - margin-right: 0.35em; +/* TODO: MENU-ITEM-FLEX-MARGIN: need to refactor: flex menu items don't need svg margin */ +.ui.menu:not(.flex-items-block) .item:not(.flex-text-block, .flex-text-inline) > .svg { + margin-right: 5px; } .ui.menu .item > a:not(.ui) { diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 3f4f1bc3355..9d3fd0f5445 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -3,8 +3,7 @@ import RepoActionView from '../components/RepoActionView.vue'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {html} from '../utils/html.ts'; import {GET} from '../modules/fetch.ts'; -import {activePageTimerRefresh, createElementFromHTML, protectMorphElements, recoverMorphElements} from '../utils/dom.ts'; -import {Idiomorph} from 'idiomorph'; +import {activePageTimerRefresh, createElementFromHTML, morphElementWithProtection} from '../utils/dom.ts'; export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!); @@ -31,6 +30,7 @@ export function initRepositoryActions() { registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm); initRepositoryActionsView(); registerGlobalInitFunc('initActionRunsList', initActionRunsList); + registerGlobalInitFunc('initActionJobQueueList', initActionJobQueueList); } function initRepositoryActionsView() { @@ -115,22 +115,19 @@ function initActionRunsList(el: HTMLElement) { interval: () => Number(el.getAttribute('data-action-runs-refresh-interval')), async callback() { const resp = await GET(el.getAttribute('data-action-runs-refresh-link')!); - if (!resp.ok || resp.status !== 200) return; - - const newEl = createElementFromHTML(await resp.text()); - for (const attr of newEl.attributes) el.setAttribute(attr.name, attr.value); - for (const newItem of newEl.querySelectorAll(':scope > .item')) { - const oldItem = el.querySelector(`#${newItem.id}`); - if (!oldItem) continue; - - // If the end user is operating the row, then don't refresh its content. - // Otherwise, there will be more edge cases and inconsistencies, e.g.: dropdown still shows old items but the icon has changed. - if (oldItem.querySelector('.ui.dropdown.active')) continue; - - const protectedElems = protectMorphElements(newItem); - Idiomorph.morph(oldItem, newItem, {morphStyle: 'outerHTML'}); - recoverMorphElements(el.querySelector(`#${newItem.id}`)!, protectedElems); - } + if (!resp.ok) return; + morphElementWithProtection(el, createElementFromHTML(await resp.text()), {morphStyle: 'outerHTML'}); + }, + }); +} + +function initActionJobQueueList(el: HTMLElement) { + activePageTimerRefresh({ + interval: () => Number(el.getAttribute('data-job-queue-refresh-interval')), + async callback() { + const resp = await GET(el.getAttribute('data-job-queue-refresh-link')!); + if (!resp.ok) return; + morphElementWithProtection(el, createElementFromHTML(await resp.text()), {morphStyle: 'outerHTML'}); }, }); } diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index d047eac92b2..4a9c92d2564 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -2,7 +2,7 @@ import { createElementFromAttrs, createElementFromHTML, queryElemChildren, querySingleVisibleElem, protectMorphElements, recoverMorphElements, - toggleElem, + toggleElem, morphElementWithProtection, } from './dom.ts'; test('createElementFromHTML', () => { @@ -70,3 +70,33 @@ test('protectMorphElements', () => { recoverMorphElements(el, protectedElems); expect(el.outerHTML).toEqual('
foo
'); }); + +describe('morphElementWithProtection', () => { + const newElHtml = '
new
'; + it('morph normal', () => { + const el = createElementFromHTML('
old
'); + const newEl = createElementFromHTML(newElHtml); + morphElementWithProtection(el, newEl, {morphStyle: 'outerHTML'}); + // span is changed, but dropdown is skipped because it is active + expect(el.outerHTML).toEqual('
new
'); + }); + it('morph whole', () => { + const el = createElementFromHTML('
old
'); + const newEl = createElementFromHTML(newElHtml); + morphElementWithProtection(el, newEl, {morphStyle: 'outerHTML'}); + // span is not changed, because the parent div is marked as data-morph-whole and there is an active dropdown, so it is skipped + expect(el.outerHTML).toEqual('
old
'); + }); + it('morph protection', () => { + const el = createElementFromHTML('
'); + const newEl = createElementFromHTML('
new
'); + const elSpanOld = el.querySelector('span'); + const elDropdownOld = el.querySelector('.ui.dropdown'); + const morphedEl = morphElementWithProtection(el, newEl, {morphStyle: 'outerHTML'}); + expect(el.outerHTML).toEqual('
new
'); + const elSpanNew = morphedEl.querySelector('span'); + const elDropdownNew = morphedEl.querySelector('.ui.dropdown'); + expect(elSpanNew).toBe(elSpanOld); // span is morphed in place, so it is the same element + expect(elDropdownNew).not.toBe(elDropdownOld); // dropdown is protected and fully replaced, so it is a new element + }); +}); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index bceda40acc2..936c2187772 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -1,6 +1,7 @@ import {debounce} from './func.ts'; import type {Promisable} from '../types.ts'; import type $ from 'jquery'; +import {Idiomorph} from 'idiomorph'; type ArrayLikeIterable = ArrayLike & Iterable; // for NodeListOf and Array type ElementArg = Element | string | ArrayLikeIterable | ReturnType; @@ -424,6 +425,36 @@ export function recoverMorphElements(el: Element, protectedElems: ProtectedMorph for (const [id, html] of Object.entries(protectedElems)) { const it = el.querySelector(`[data-morph-protect="${CSS.escape(id)}"]`); if (!it) continue; - it.outerHTML = html; + it.replaceWith(createElementFromHTML(html)); } } + +export type MorphElementOptions = { + morphStyle: 'innerHTML' | 'outerHTML'; +}; + +export function morphElementWithProtection(el: Element, newEl: Element, opts: MorphElementOptions): Element { + const protectedElems = protectMorphElements(newEl); + const selectorSkipElems = '.ui.dropdown.active'; + const nodes = Idiomorph.morph(el, newEl, { + morphStyle: opts.morphStyle, + callbacks: { + beforeNodeMorphed: (oldNode /* , newNode */) => { + if (!(oldNode instanceof Element)) return true; + + // If the end user is operating a row, then don't refresh its content. + // Otherwise, there will be more edge cases and inconsistencies, e.g.: dropdown still shows old items but the icon has changed. + const oldNodeMorphWholeAndSkipChild = oldNode.matches('[data-morph-whole]') && oldNode.querySelector(selectorSkipElems); + + // If the element should be skipped, don't morph it + const oldNodeShouldSkip = oldNode.matches(selectorSkipElems); + + const shouldSkip = oldNodeMorphWholeAndSkipChild || oldNodeShouldSkip; + return !shouldSkip; + }, + }, + }); + const morphedElem = nodes[0] as Element; + recoverMorphElements(morphedElem, protectedElems); + return morphedElem; +}