mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-27 05:25:14 +00:00
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 <img width="1345" height="451" alt="image" src="https://github.com/user-attachments/assets/7d52ff76-81b4-44e8-b583-d7d89c9dffcd" /> <img width="1809" height="1134" alt="image" src="https://github.com/user-attachments/assets/4d56c0cb-bae7-4ce2-8f3c-75163b2bc7f4" /> --------- Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
</div>
|
||||
{{template "admin/layout_footer" .}}
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .EnableActions}}
|
||||
<details class="item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows}}open{{end}}>
|
||||
<details class="item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows .PageIsSharedSettingsActionsJobQueue}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/runners">
|
||||
@@ -84,6 +84,9 @@
|
||||
<a class="{{if .PageIsSharedSettingsScopedWorkflows}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/scoped-workflows">
|
||||
{{ctx.Locale.Tr "actions.scoped_workflows"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSharedSettingsActionsJobQueue}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/job_queue">
|
||||
{{ctx.Locale.Tr "actions.job_queue.title"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content repository actions">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
{{template "base/alert" .}}
|
||||
<div class="flex-container">
|
||||
{{template "repo/actions/sidebar" .}}
|
||||
<div class="flex-container-main">
|
||||
{{template "shared/actions/job_queue_list" .}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -10,59 +10,7 @@
|
||||
|
||||
{{if .HasWorkflowsOrRuns}}
|
||||
<div class="flex-container">
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui fluid vertical menu">
|
||||
<a class="item {{if not $.CurWorkflow}}active{{end}}" href="?actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">{{ctx.Locale.Tr "actions.runs.all_workflows"}}</a>
|
||||
{{range .workflows}}
|
||||
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.EntryName}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
|
||||
<span class="gt-ellipsis" data-tooltip-content="{{.DisplayName}}">{{.DisplayName}}</span>
|
||||
|
||||
{{if .ErrMsg}}
|
||||
<span class="flex-text-inline tw-shrink-0" data-tooltip-content="{{.ErrMsg}}">{{svg "octicon-alert" 16 "tw-text-red"}}</span>
|
||||
{{end}}
|
||||
|
||||
{{if $.ActionsConfig.IsWorkflowDisabled .EntryName}}
|
||||
<div class="ui red label tw-shrink-0">{{ctx.Locale.Tr "disabled"}}</div>
|
||||
{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{range .ScopedWorkflowGroups}}
|
||||
<details class="item scoped-workflow-group"{{if .IsActive}} open{{end}}>
|
||||
<summary>
|
||||
<span class="gt-ellipsis tw-min-w-0" data-tooltip-content="{{.SourceRepoName}}">{{if .FromInstance}}{{.SourceRepoName}}{{else}}{{.SourceRepoShortName}}{{end}}</span>
|
||||
<span class="ui label">{{if .FromInstance}}{{ctx.Locale.Tr "actions.workflow.scope_global"}}{{else}}{{ctx.Locale.Tr "actions.workflow.scope_owner"}}{{end}}</span>
|
||||
</summary>
|
||||
{{range .Workflows}}
|
||||
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (eq .SourceRepoID $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.EntryName}}&scoped_workflow_source_repo_id={{.SourceRepoID}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
|
||||
<span class="gt-ellipsis tw-min-w-0" data-tooltip-content="{{.EntryName}}">{{.DisplayName}}</span>
|
||||
{{if .Required}}
|
||||
<span class="ui label">{{ctx.Locale.Tr "actions.workflow.required"}}</span>
|
||||
{{else if .Disabled}}
|
||||
<span class="ui red label">{{ctx.Locale.Tr "disabled"}}</span>
|
||||
{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .OtherWorkflows}}
|
||||
<details class="item"{{if not $.CurWorkflowIsListed}} open{{end}}>
|
||||
<summary data-tooltip-content="{{ctx.Locale.Tr "actions.runs.other_workflows_tooltip"}}">
|
||||
<span class="flex-text-block">
|
||||
{{ctx.Locale.Tr "actions.runs.other_workflows"}}
|
||||
<span class="ui label">{{len .OtherWorkflows}}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="menu items-full-width">
|
||||
{{range .OtherWorkflows}}
|
||||
<a class="item {{if eq . $.CurWorkflow}}active{{end}}" href="?workflow={{.}}&actor={{$.CurActor}}&status={{$.CurStatus}}">
|
||||
<span class="gt-ellipsis" data-tooltip-content="{{.}}">{{.}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "repo/actions/sidebar" .}}
|
||||
<div class="flex-container-main">
|
||||
<div class="ui top attached header flex-left-right">
|
||||
<strong>{{ctx.Locale.TrN .Page.Paginator.Total "actions.runs.workflow_run_count_1" "actions.runs.workflow_run_count_n" .Page.Paginator.Total}}</strong>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
{{range $run := $data.ActionRuns}}
|
||||
<div id="action-run-item-{{$run.ID}}" class="item tw-items-center" data-status="{{$run.Status.String}}">
|
||||
<div id="action-run-item-{{$run.ID}}" class="item tw-items-center" data-status="{{$run.Status.String}}" data-morph-whole>
|
||||
<div class="item-leading">
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr (printf "actions.status.%s" $run.Status.String)}}">
|
||||
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui fluid vertical menu">
|
||||
<a class="item {{if and (not $.CurWorkflow) (not $.PageIsActionsJobQueue)}}active{{end}}" href="{{$.RepoLink}}/actions?actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">{{ctx.Locale.Tr "actions.runs.all_workflows"}}</a>
|
||||
{{range .workflows}}
|
||||
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="{{$.RepoLink}}/actions?workflow={{.EntryName}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
|
||||
<span class="gt-ellipsis" data-tooltip-content="{{.DisplayName}}">{{.DisplayName}}</span>
|
||||
|
||||
{{if .ErrMsg}}
|
||||
<span class="flex-text-inline tw-shrink-0" data-tooltip-content="{{.ErrMsg}}">{{svg "octicon-alert" 16 "tw-text-red"}}</span>
|
||||
{{end}}
|
||||
|
||||
{{if $.ActionsConfig.IsWorkflowDisabled .EntryName}}
|
||||
<div class="ui red label tw-shrink-0">{{ctx.Locale.Tr "disabled"}}</div>
|
||||
{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{range .ScopedWorkflowGroups}}
|
||||
<details class="item scoped-workflow-group"{{if .IsActive}} open{{end}}>
|
||||
<summary>
|
||||
<span class="gt-ellipsis tw-min-w-0" data-tooltip-content="{{.SourceRepoName}}">{{if .FromInstance}}{{.SourceRepoName}}{{else}}{{.SourceRepoShortName}}{{end}}</span>
|
||||
<span class="ui label">{{if .FromInstance}}{{ctx.Locale.Tr "actions.workflow.scope_global"}}{{else}}{{ctx.Locale.Tr "actions.workflow.scope_owner"}}{{end}}</span>
|
||||
</summary>
|
||||
{{range .Workflows}}
|
||||
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (eq .SourceRepoID $.CurWorkflowScopedRepoID)}}active{{end}}" href="{{$.RepoLink}}/actions?workflow={{.EntryName}}&scoped_workflow_source_repo_id={{.SourceRepoID}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
|
||||
<span class="gt-ellipsis tw-min-w-0" data-tooltip-content="{{.EntryName}}">{{.DisplayName}}</span>
|
||||
{{if .Required}}
|
||||
<span class="ui label">{{ctx.Locale.Tr "actions.workflow.required"}}</span>
|
||||
{{else if .Disabled}}
|
||||
<span class="ui red label">{{ctx.Locale.Tr "disabled"}}</span>
|
||||
{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .OtherWorkflows}}
|
||||
<details class="item"{{if not $.CurWorkflowIsListed}} open{{end}}>
|
||||
<summary data-tooltip-content="{{ctx.Locale.Tr "actions.runs.other_workflows_tooltip"}}">
|
||||
<span class="flex-text-block">
|
||||
{{ctx.Locale.Tr "actions.runs.other_workflows"}}
|
||||
<span class="ui label">{{len .OtherWorkflows}}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="menu items-full-width">
|
||||
{{range .OtherWorkflows}}
|
||||
<a class="item {{if eq . $.CurWorkflow}}active{{end}}" href="{{$.RepoLink}}/actions?workflow={{.}}&actor={{$.CurActor}}&status={{$.CurStatus}}">
|
||||
<span class="gt-ellipsis" data-tooltip-content="{{.}}">{{.}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="ui fluid secondary vertical menu flex-items-block">
|
||||
{{/* At the moment, the "job queue" page is view-only, it doesn't really do any management.
|
||||
GitHub has a similar menu section which contains "cache management" and more, so this section is designed for the future */}}
|
||||
<div>{{ctx.Locale.Tr "actions.management"}}</div>
|
||||
<a class="item {{if $.PageIsActionsJobQueue}}active{{end}}" href="{{$.RepoLink}}/actions/job_queue">
|
||||
{{svg "octicon-list-ordered"}}
|
||||
<span class="gt-ellipsis">{{ctx.Locale.Tr "actions.job_queue.title"}}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
<div id="actions-job-queue" class="runner-container" data-global-init="initActionJobQueueList"
|
||||
data-job-queue-refresh-link="{{.JobQueueRefreshLink}}"
|
||||
data-job-queue-refresh-interval="{{.JobQueueRefreshIntervalMs}}"
|
||||
>
|
||||
<div class="ui top attached header flex-left-right">
|
||||
<strong>{{ctx.Locale.Tr "actions.job_queue.title"}} ({{ctx.Locale.Tr "admin.total" .JobQueueTotal}})</strong>
|
||||
<div class="ui secondary filter menu flex-text-block tw-m-0" id="actions-job-queue-filter">
|
||||
{{if .ShowRepoColumn}}
|
||||
<div class="ui{{if not .JobQueueFilterOwners}} disabled{{end}} dropdown jump item">
|
||||
<span class="text">{{if .JobQueueFilterOwnerName}}{{.JobQueueFilterOwnerName}}{{else}}{{ctx.Locale.Tr "repo.owner"}}{{end}}</span>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<div class="ui icon search input">
|
||||
<i class="icon">{{svg "octicon-search"}}</i>
|
||||
<input type="text" placeholder="{{ctx.Locale.Tr "repo.owner"}}">
|
||||
</div>
|
||||
<a class="item{{if not .JobQueueFilterOwnerID}} selected{{end}}" href="?status={{.JobQueueFilterStatus}}">{{ctx.Locale.Tr "actions.job_queue.filter_owner_no_select"}}</a>
|
||||
{{range .JobQueueFilterOwners}}
|
||||
<a class="item{{if eq .ID $.JobQueueFilterOwnerID}} selected{{end}}" href="?owner_id={{.ID}}&status={{$.JobQueueFilterStatus}}">{{.Name}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui{{if not .JobQueueFilterRepos}} disabled{{end}} dropdown jump item">
|
||||
<span class="text">{{if .JobQueueFilterRepoName}}{{.JobQueueFilterRepoName}}{{else}}{{ctx.Locale.Tr "repository"}}{{end}}</span>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<div class="ui icon search input">
|
||||
<i class="icon">{{svg "octicon-search"}}</i>
|
||||
<input type="text" placeholder="{{ctx.Locale.Tr "repository"}}">
|
||||
</div>
|
||||
<a class="item{{if not .JobQueueFilterRepoID}} selected{{end}}" href="?owner_id={{.JobQueueFilterOwnerID}}&status={{.JobQueueFilterStatus}}">{{ctx.Locale.Tr "actions.job_queue.filter_repo_no_select"}}</a>
|
||||
{{range .JobQueueFilterRepos}}
|
||||
<a class="item{{if eq .ID $.JobQueueFilterRepoID}} selected{{end}}" href="?repo_id={{.ID}}&status={{$.JobQueueFilterStatus}}">{{.FullName}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="ui dropdown jump item">
|
||||
<span class="text">{{if .JobQueueFilterStatus}}{{ctx.Locale.Tr (printf "actions.status.%s" .JobQueueFilterStatus)}}{{else}}{{ctx.Locale.Tr "actions.runs.status"}}{{end}}</span>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<a class="item{{if not .JobQueueFilterStatus}} selected{{end}}" href="?owner_id={{.JobQueueFilterOwnerID}}&repo_id={{.JobQueueFilterRepoID}}">{{ctx.Locale.Tr "actions.runs.status_no_select"}}</a>
|
||||
{{range $status := .JobQueueFilterStatuses}}
|
||||
<a class="item{{if eq $status $.JobQueueFilterStatus}} selected{{end}}" href="?owner_id={{$.JobQueueFilterOwnerID}}&repo_id={{$.JobQueueFilterRepoID}}&status={{$status}}">
|
||||
<span class="flex-text-inline tw-gap-2">
|
||||
{{template "repo/icons/action_status" (dict "Status" $status)}}
|
||||
{{ctx.Locale.Tr (printf "actions.status.%s" $status)}}
|
||||
</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached table segment">
|
||||
{{if .JobQueueJobs}}
|
||||
<table class="ui very basic table unstackable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "actions.runs.status"}}</th>
|
||||
{{if .ShowRepoColumn}}<th>{{ctx.Locale.Tr "repository"}}</th>{{end}}
|
||||
<th>{{ctx.Locale.Tr "actions.runners.task_list.job"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.job_queue.runs_on"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runners.runner_title"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.runs.commit"}}</th>
|
||||
<th>{{ctx.Locale.Tr "actions.job_queue.waiting_or_started"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $job := .JobQueueJobs}}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="flex-text-inline" data-tooltip-content="{{$job.Status.LocaleString ctx.Locale}}">
|
||||
{{template "repo/icons/action_status" (dict "Status" $job.Status.String)}}
|
||||
</span>
|
||||
</td>
|
||||
{{if $.ShowRepoColumn}}<td>{{if $job.Repo}}<a href="{{$job.Repo.Link}}">{{$job.Repo.FullName}}</a>{{end}}</td>{{end}}
|
||||
<td>{{if $job.Run}}<a href="{{$job.Run.Link}}">{{$job.Name}} <span class="text grey">#{{$job.Run.Index}}</span></a>{{else}}{{$job.Name}}{{end}}</td>
|
||||
<td><span class="flex-text-inline tw-flex-wrap">{{range $job.RunsOn}}<span class="ui label">{{.}}</span>{{end}}</span></td>
|
||||
<td>{{index $.JobQueueRunners $job.TaskID}}</td>
|
||||
<td>{{if and $job.Run $job.Repo}}<a href="{{$job.Repo.Link}}/commit/{{$job.Run.CommitSHA}}">{{ShortSha $job.Run.CommitSHA}}</a>{{end}}</td>
|
||||
<td>{{if $job.Started}}{{DateUtils.TimeSince $job.Started}}{{else}}{{DateUtils.TimeSince $job.Updated}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="tw-text-center tw-p-4">{{ctx.Locale.Tr "actions.job_queue.no_jobs"}}</div>
|
||||
{{end}}
|
||||
{{template "base/paginate" .}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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, "<html")
|
||||
assert.Contains(t, refresh, queuedJobName)
|
||||
assert.Equal(t, 1, refreshDoc.Find(repoFilterSelector(repo3.ID)).Length())
|
||||
|
||||
running, _ := adminGet(adminJobQueue + "?status=running")
|
||||
assert.NotContains(t, running, queuedJobName)
|
||||
assert.NotContains(t, running, callerJobName)
|
||||
|
||||
byOwner, _ := adminGet(adminJobQueue + "?owner_id=" + strconv.FormatInt(repo1.OwnerID, 10))
|
||||
assert.Contains(t, byOwner, queuedJobName)
|
||||
assert.NotContains(t, byOwner, otherJobName)
|
||||
|
||||
byRepo, _ := adminGet(adminJobQueue + "?repo_id=" + strconv.FormatInt(repo3.ID, 10))
|
||||
assert.Contains(t, byRepo, otherJobName)
|
||||
assert.NotContains(t, byRepo, queuedJobName)
|
||||
|
||||
for _, query := range []string{"?repo_id=987654321", "?owner_id=987654321"} {
|
||||
body, doc := adminGet(adminJobQueue + query)
|
||||
assert.Contains(t, body, queuedJobName)
|
||||
assert.Contains(t, body, otherJobName)
|
||||
assert.NotContains(t, refreshLinkOf(doc), "987654321")
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(ctx).Where("repo_id = ?", repo3.ID).Cols("status").Update(&actions_model.ActionRunJob{Status: actions_model.StatusSuccess})
|
||||
require.NoError(t, err)
|
||||
for _, scope := range []string{"repo_id=" + strconv.FormatInt(repo3.ID, 10), "owner_id=" + strconv.FormatInt(repo3.OwnerID, 10)} {
|
||||
body, doc := adminGet(adminJobQueue + "?" + scope)
|
||||
assert.NotContains(t, body, queuedJobName)
|
||||
assert.NotContains(t, body, otherJobName)
|
||||
assert.Contains(t, refreshLinkOf(doc), scope)
|
||||
}
|
||||
}
|
||||
Vendored
+6
-1
@@ -16,7 +16,12 @@ declare module '*.vue' {
|
||||
|
||||
declare module 'idiomorph' {
|
||||
interface Idiomorph {
|
||||
morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void;
|
||||
morph(existing: Node | string, replacement: Node | string, options?: {
|
||||
morphStyle: 'innerHTML' | 'outerHTML',
|
||||
callbacks?: {
|
||||
beforeNodeMorphed?: (oldNode: Node, newNode: Node) => boolean,
|
||||
},
|
||||
}): Node[];
|
||||
}
|
||||
export const Idiomorph: Idiomorph;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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('<div><span data-morph-protect="">foo</span></div>');
|
||||
});
|
||||
|
||||
describe('morphElementWithProtection', () => {
|
||||
const newElHtml = '<div><div><span>new</span><div class="ui dropdown">changed</div></div></div>';
|
||||
it('morph normal', () => {
|
||||
const el = createElementFromHTML('<div><div><span>old</span><div class="ui dropdown active"></div></div></div>');
|
||||
const newEl = createElementFromHTML(newElHtml);
|
||||
morphElementWithProtection(el, newEl, {morphStyle: 'outerHTML'});
|
||||
// span is changed, but dropdown is skipped because it is active
|
||||
expect(el.outerHTML).toEqual('<div><div><span>new</span><div class="ui dropdown active"></div></div></div>');
|
||||
});
|
||||
it('morph whole', () => {
|
||||
const el = createElementFromHTML('<div><div data-morph-whole><span>old</span><div class="ui dropdown active"></div></div></div>');
|
||||
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('<div><div data-morph-whole=""><span>old</span><div class="ui dropdown active"></div></div></div>');
|
||||
});
|
||||
it('morph protection', () => {
|
||||
const el = createElementFromHTML('<div><span></span><div class="ui dropdown"></div></div>');
|
||||
const newEl = createElementFromHTML('<div><span>new</span><div class="ui dropdown">changed</div></div>');
|
||||
const elSpanOld = el.querySelector('span');
|
||||
const elDropdownOld = el.querySelector('.ui.dropdown');
|
||||
const morphedEl = morphElementWithProtection(el, newEl, {morphStyle: 'outerHTML'});
|
||||
expect(el.outerHTML).toEqual('<div><span>new</span><div class="ui dropdown">changed</div></div>');
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
+32
-1
@@ -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<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
|
||||
type ElementArg = Element | string | ArrayLikeIterable<Element> | ReturnType<typeof $>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user