feat(actions): implement adaptive auto-refresh for workflow runs list (#38329)

### Description
This PR implements an optimized, adaptive client-side auto-refresh
mechanism for the Gitea Actions workflow runs list page. It allows users
to see workflow progress updates dynamically without having to reload
the page.

Fixes https://github.com/go-gitea/gitea/issues/37457
---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Shudhanshu Singh
2026-07-12 17:44:37 +05:30
committed by GitHub
parent 65c5a5ff7b
commit b998c3c1aa
9 changed files with 282 additions and 107 deletions
+6
View File
@@ -277,6 +277,12 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
return &run, nil return &run, nil
} }
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) {
var runs []*ActionRun
err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs)
return runs, err
}
func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionRun, error) { func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionRun, error) {
run, has, err := db.Get[ActionRun](ctx, builder.Eq{"repo_id": repoID, "`index`": runIndex}) run, has, err := db.Get[ActionRun](ctx, builder.Eq{"repo_id": repoID, "`index`": runIndex})
if err != nil { if err != nil {
+180 -84
View File
@@ -8,6 +8,7 @@ import (
stdCtx "context" stdCtx "context"
"errors" "errors"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
@@ -19,6 +20,7 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit" "gitea.dev/models/unit"
"gitea.dev/modules/actions" "gitea.dev/modules/actions"
"gitea.dev/modules/base"
"gitea.dev/modules/container" "gitea.dev/modules/container"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/log" "gitea.dev/modules/log"
@@ -42,9 +44,9 @@ const (
) )
type WorkflowInfo struct { type WorkflowInfo struct {
Entry git.TreeEntry EntryName string
ErrMsg string ErrMsg string
Workflow *act_model.Workflow Workflow *act_model.Workflow
} }
type workflowBadge struct { type workflowBadge struct {
@@ -58,7 +60,7 @@ func (w WorkflowInfo) DisplayName() string {
if w.Workflow != nil && w.Workflow.Name != "" { if w.Workflow != nil && w.Workflow.Name != "" {
return w.Workflow.Name return w.Workflow.Name
} }
return w.Entry.Name() return w.EntryName
} }
// MustEnableActions check if actions are enabled in settings // MustEnableActions check if actions are enabled in settings
@@ -99,9 +101,22 @@ func List(ctx *context.Context) {
if ctx.Written() { if ctx.Written() {
return return
} }
curWorkflowRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
ctx.Data["CurWorkflowRepoID"] = curWorkflowRepoID data := prepareWorkflowList(ctx, curWorkflowID)
scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, curWorkflowRepoID) if data.partialRefresh {
// TODO: at the moment, the "partial refresh" depends on some heavy git&db operations (especially finding & parsing the workflow files)
// If it becomes a real problem in the future, maybe some "caches" can be used to optimize.
if !data.preparePartialRefreshRuns(ctx) {
return
}
if !data.prepareCommon(ctx, workflows) {
return
}
ctx.HTML(http.StatusOK, "repo/actions/runs_list")
return
}
scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, data.scopedWorkflowSourceRepoID)
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -109,16 +124,22 @@ func List(ctx *context.Context) {
if ctx.Written() { if ctx.Written() {
return return
} }
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, curWorkflowRepoID) prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, data.scopedWorkflowSourceRepoID)
if ctx.Written() { if ctx.Written() {
return return
} }
prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0) if !data.prepareFullPageRuns(ctx, otherWorkflows) {
if ctx.Written() { return
}
if !data.prepareCommon(ctx, workflows) {
return
}
if !data.prepareFullPageTemplate(ctx) {
return return
} }
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(data.ActionRuns) > 0 || len(scopedNames) > 0
ctx.HTML(http.StatusOK, tplListActions) ctx.HTML(http.StatusOK, tplListActions)
} }
@@ -127,7 +148,7 @@ func List(ctx *context.Context) {
func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string { func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string {
listed := make(container.Set[string], len(workflows)) listed := make(container.Set[string], len(workflows))
for _, w := range workflows { for _, w := range workflows {
listed.Add(w.Entry.Name()) listed.Add(w.EntryName)
} }
var other []string var other []string
@@ -193,7 +214,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
workflows = make([]WorkflowInfo, 0, len(entries)) workflows = make([]WorkflowInfo, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
workflow := WorkflowInfo{Entry: *entry} workflow := WorkflowInfo{EntryName: entry.Name()}
content, err := actions.GetContentFromEntry(entry) content, err := actions.GetContentFromEntry(entry)
if err != nil { if err != nil {
ctx.ServerError("GetContentFromEntry", err) ctx.ServerError("GetContentFromEntry", err)
@@ -404,7 +425,7 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID) curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID)
} else { } else {
for _, workflowInfo := range workflowInfos { for _, workflowInfo := range workflowInfos {
if workflowInfo.Entry.Name() == curWorkflowID { if workflowInfo.EntryName == curWorkflowID {
if workflowInfo.Workflow == nil { if workflowInfo.Workflow == nil {
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID) log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
return return
@@ -452,71 +473,66 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
ctx.Data["Tags"] = tags ctx.Data["Tags"] = tags
} }
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) { func (data *actionRunListData) prepareFullPageRuns(ctx *context.Context, otherWorkflows []string) bool {
actorID := ctx.FormInt64("actor")
status := ctx.FormInt("status")
workflowID := ctx.FormString("workflow")
scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
branch := ctx.FormString("branch")
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
// if status or actor query param is not given to frontend href, (href="/<repoLink>/actions")
// they will be 0 by default, which indicates get all status or actors
ctx.Data["CurActor"] = actorID
ctx.Data["CurStatus"] = status
ctx.Data["CurBranch"] = branch
if actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" {
ctx.Data["IsFiltered"] = true
}
opts := actions_model.FindRunOptions{ opts := actions_model.FindRunOptions{
ListOptions: db.ListOptions{ ListOptions: db.ListOptions{
Page: page, Page: max(ctx.FormInt("page"), 1),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
}, },
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID, WorkflowID: data.workflowID,
WorkflowRepoID: scopedWorkflowSourceRepoID, WorkflowRepoID: data.scopedWorkflowSourceRepoID,
TriggerUserID: actorID, TriggerUserID: data.actorID,
} }
// Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id. // Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id.
if workflowID != "" && !slices.Contains(otherWorkflows, workflowID) { if data.workflowID != "" && !slices.Contains(otherWorkflows, data.workflowID) {
opts.IsScopedRun = optional.Some(scopedWorkflowSourceRepoID > 0) opts.IsScopedRun = optional.Some(data.scopedWorkflowSourceRepoID > 0)
} }
// if status is not StatusUnknown, it means user has selected a status filter // if status is not StatusUnknown, it means user has selected a status filter
if actions_model.Status(status) != actions_model.StatusUnknown { if data.status != actions_model.StatusUnknown {
opts.Status = []actions_model.Status{actions_model.Status(status)} opts.Status = []actions_model.Status{data.status}
} }
if branch != "" { if data.branch != "" {
opts.Ref = string(git.RefNameFromBranch(branch)) opts.Ref = string(git.RefNameFromBranch(data.branch))
} }
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil { if err != nil {
ctx.ServerError("FindAndCount", err) ctx.ServerError("FindAndCount", err)
return return false
} }
data.pager = context.NewPagination(total, opts.PageSize, opts.Page, 5)
data.pager.AddParamFromRequest(ctx.Req)
data.ActionRuns = runs
return true
}
for _, run := range runs { type actionRunListData struct {
run.Repo = ctx.Repo.Repository actorID int64
} status actions_model.Status
workflowID string
workflowDisplayName string
scopedWorkflowSourceRepoID int64
branch string
refreshRunIDs []int64
partialRefresh bool
pager *context.Pagination
if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { // the following fields are used by the template
ctx.ServerError("LoadTriggerUser", err) RefreshLink template.URL
return RefreshIntervalMs int64
} ActionRuns []*actions_model.ActionRun
IsFiltered bool
if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil { WorkflowNames map[string]string
log.Error("LoadIsRefDeleted", err) RunErrors map[int64]string
} CurWorkflow string
CanWriteRepoUnitActions bool
}
func (data *actionRunListData) processActionRuns(ctx *context.Context) bool {
// Check for each run if there is at least one online runner that can run its jobs // Check for each run if there is at least one online runner that can run its jobs
runErrors := make(map[int64]string)
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
IsOnline: optional.Some(true), IsOnline: optional.Some(true),
@@ -524,28 +540,30 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
}) })
if err != nil { if err != nil {
ctx.ServerError("FindRunners", err) ctx.ServerError("FindRunners", err)
return return false
} }
for _, run := range runs {
data.RunErrors = make(map[int64]string)
for _, run := range data.ActionRuns {
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
continue continue
} }
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil { if err != nil {
ctx.ServerError("GetRunJobsByRunID", err) ctx.ServerError("GetRunJobsByRunID", err)
return return false
} }
for _, job := range jobs { for _, job := range jobs {
if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) { if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) {
continue continue
} }
if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil { if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
break break
} }
if job.CallUses != "" { if job.CallUses != "" {
if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil { if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error()) data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
break break
} }
} }
@@ -558,54 +576,132 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
} }
} }
if !hasOnlineRunner { if !hasOnlineRunner {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ",")) data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
break break
} }
} }
} }
} }
ctx.Data["RunErrors"] = runErrors return true
}
ctx.Data["Runs"] = runs func (data *actionRunListData) fillRefreshMeta(ctx *context.Context) bool {
hasActiveRuns := false
var actionRunIDs []int64
for _, run := range data.ActionRuns {
if !hasActiveRuns && run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
hasActiveRuns = true
}
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
}
if len(data.ActionRuns) == 0 {
data.RefreshIntervalMs = 0
}
// Only refresh the items on the current page, do not keep loading "new" items.
// Otherwise, when end users are browsing the list page, they will just keep seeing "new" items coming in
// and "old" items disappearing, they won't be able to focus on an item that they are interested in, which is a bad user experience.
data.RefreshLink = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "run_ids", strings.Join(base.Int64sToStrings(actionRunIDs), ","))
return true
}
workflowNames := make(map[string]string, len(workflows)) func (data *actionRunListData) fillWorkflowNames(workflows []WorkflowInfo) bool {
workflowDisplayName := workflowID data.WorkflowNames = make(map[string]string, len(workflows))
data.workflowDisplayName = data.workflowID
for _, wf := range workflows { for _, wf := range workflows {
displayName := wf.DisplayName() displayName := wf.DisplayName()
workflowNames[wf.Entry.Name()] = displayName data.WorkflowNames[wf.EntryName] = displayName
if wf.Entry.Name() == workflowID { if wf.EntryName == data.workflowID {
workflowDisplayName = displayName data.workflowDisplayName = displayName
} }
} }
ctx.Data["WorkflowNames"] = workflowNames return true
// A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs), }
// so don't offer the "create status badge" entry for it.
if scopedWorkflowSourceRepoID == 0 { func prepareWorkflowList(ctx *context.Context, curWorkflowID string) *actionRunListData {
prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName) data := &actionRunListData{}
data.actorID = ctx.FormInt64("actor")
data.status = actions_model.Status(ctx.FormInt("status"))
data.workflowID = ctx.FormString("workflow")
data.scopedWorkflowSourceRepoID = ctx.FormInt64("scoped_workflow_source_repo_id")
data.branch = ctx.FormString("branch")
data.refreshRunIDs = ctx.FormStringInt64s("run_ids")
data.partialRefresh = ctx.Req.Form.Has("run_ids")
data.IsFiltered = data.actorID > 0 || data.status != actions_model.StatusUnknown || data.branch != ""
data.CurWorkflow = curWorkflowID
data.CanWriteRepoUnitActions = ctx.Repo.Permission.CanWrite(unit.TypeActions)
return data
}
func (data *actionRunListData) preparePartialRefreshRuns(ctx *context.Context) bool {
runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, data.refreshRunIDs)
if err != nil {
ctx.ServerError("GetRunsByRepoAndID", err)
return false
}
data.ActionRuns = runs
return true
}
func (data *actionRunListData) prepareCommon(ctx *context.Context, workflows []WorkflowInfo) bool {
for _, run := range data.ActionRuns {
run.Repo = ctx.Repo.Repository
} }
if err := actions_model.RunList(data.ActionRuns).LoadTriggerUser(ctx); err != nil {
ctx.ServerError("LoadTriggerUser", err)
return false
}
if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, data.ActionRuns); err != nil {
log.Error("LoadIsRefDeleted", err)
}
if !data.processActionRuns(ctx) {
return false
}
if !data.fillRefreshMeta(ctx) {
return false
}
if !data.fillWorkflowNames(workflows) {
return false
}
ctx.Data["ActionRunListData"] = data
return true
}
func (data *actionRunListData) prepareFullPageTemplate(ctx *context.Context) bool {
// A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs),
// so don't offer the "create status badge" entry for it.
if data.scopedWorkflowSourceRepoID == 0 {
prepareWorkflowBadgeTemplate(ctx, data.workflowID, data.workflowDisplayName)
}
// fill template data
actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
ctx.ServerError("GetActors", err) ctx.ServerError("GetActors", err)
return return false
} }
ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors) ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors)
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID) runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
ctx.ServerError("GetRunBranches", err) ctx.ServerError("GetRunBranches", err)
return return false
} }
ctx.Data["RunBranches"] = runBranches ctx.Data["RunBranches"] = runBranches
pager := context.NewPagination(total, opts.PageSize, opts.Page, 5) ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
pager.AddParamFromRequest(ctx.Req) ctx.Data["CurActor"] = data.actorID
ctx.Data["Page"] = pager ctx.Data["CurStatus"] = int(data.status) // don't make template use its "String" method, otherwise the query parameter would be wrong
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 || hasScopedWorkflows ctx.Data["CurBranch"] = data.branch
ctx.Data["CurWorkflowRepoID"] = data.scopedWorkflowSourceRepoID
ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions) ctx.Data["Page"] = data.pager
return true
} }
func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) { func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) {
+4 -2
View File
@@ -1,7 +1,9 @@
{{$paginationParams := .Page.GetParams}} {{$page := or $.Page ctx.RootData.Page}}
{{$paginationLink := $.Link}} {{$paginationLink := $.Link}}
{{if eq $paginationLink nil}}{{$paginationLink = ctx.RootData.Link}}{{end}}
{{if eq $paginationLink AppSubUrl}}{{$paginationLink = print $paginationLink "/"}}{{end}} {{if eq $paginationLink AppSubUrl}}{{$paginationLink = print $paginationLink "/"}}{{end}}
{{with .Page.Paginater}} {{$paginationParams := $page.GetParams}}
{{with $page.Paginater}}
{{if or (eq .TotalPages -1) (gt .TotalPages 1)}} {{if or (eq .TotalPages -1) (gt .TotalPages 1)}}
{{$showFirstLast := gt .TotalPages 1}} {{$showFirstLast := gt .TotalPages 1}}
<div class="center page buttons"> <div class="center page buttons">
+4 -3
View File
@@ -14,14 +14,14 @@
<div class="ui fluid vertical menu"> <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> <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}} {{range .workflows}}
<a class="item flex-text-block {{if and (eq .Entry.Name $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.Entry.Name}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}"> <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> <span class="gt-ellipsis" data-tooltip-content="{{.DisplayName}}">{{.DisplayName}}</span>
{{if .ErrMsg}} {{if .ErrMsg}}
<span class="flex-text-inline tw-shrink-0" data-tooltip-content="{{.ErrMsg}}">{{svg "octicon-alert" 16 "tw-text-red"}}</span> <span class="flex-text-inline tw-shrink-0" data-tooltip-content="{{.ErrMsg}}">{{svg "octicon-alert" 16 "tw-text-red"}}</span>
{{end}} {{end}}
{{if $.ActionsConfig.IsWorkflowDisabled .Entry.Name}} {{if $.ActionsConfig.IsWorkflowDisabled .EntryName}}
<div class="ui red label tw-shrink-0">{{ctx.Locale.Tr "disabled"}}</div> <div class="ui red label tw-shrink-0">{{ctx.Locale.Tr "disabled"}}</div>
{{end}} {{end}}
</a> </a>
@@ -153,7 +153,8 @@
{{end}} {{end}}
<div class="ui attached segment"> <div class="ui attached segment">
{{template "repo/actions/runs_list" .}} {{template "repo/actions/runs_list" dict "ActionRunListData" $.ActionRunListData}}
{{template "base/paginate" dict "Page" $.Page}}
</div> </div>
</div> </div>
</div> </div>
+16 -13
View File
@@ -1,12 +1,16 @@
<div class="flex-divided-list items-with-main run-list"> {{$data := .ActionRunListData}}
{{if not .Runs}} <div class="flex-divided-list items-with-main run-list" data-global-init="initActionRunsList"
data-action-runs-refresh-link="{{$data.RefreshLink}}"
data-action-runs-refresh-interval="{{$data.RefreshIntervalMs}}"
>
{{if not $data.ActionRuns}}
<div class="empty-placeholder"> <div class="empty-placeholder">
{{svg "octicon-no-entry" 48}} {{svg "octicon-no-entry" 48}}
<h2>{{if $.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2> <h2>{{if $data.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2>
</div> </div>
{{end}} {{end}}
{{range $run := .Runs}} {{range $run := $data.ActionRuns}}
<div class="item tw-items-center"> <div id="action-run-item-{{$run.ID}}" class="item tw-items-center" data-status="{{$run.Status.String}}">
<div class="item-leading"> <div class="item-leading">
<span data-tooltip-content="{{ctx.Locale.Tr (printf "actions.status.%s" $run.Status.String)}}"> <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")}} {{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}}
@@ -15,22 +19,22 @@
<div class="item-main"> <div class="item-main">
<div class="item-title"> <div class="item-title">
{{$title := or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}} {{$title := or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $.Repository}} {{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $run.Repo}}
</div> </div>
<div class="item-body"> <div class="item-body">
{{$workflowName := index $.WorkflowNames $run.WorkflowID}} {{$workflowName := index $data.WorkflowNames $run.WorkflowID}}
<span><b>{{if not $.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}</b>:</span> <span><b>{{if not $data.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}</b>:</span>
{{- if $run.ScheduleID -}} {{- if $run.ScheduleID -}}
{{ctx.Locale.Tr "actions.runs.scheduled"}} {{ctx.Locale.Tr "actions.runs.scheduled"}}
{{- else -}} {{- else -}}
{{ctx.Locale.Tr "actions.runs.commit"}} {{ctx.Locale.Tr "actions.runs.commit"}}
<a href="{{$.RepoLink}}/commit/{{$run.CommitSHA}}">{{ShortSha $run.CommitSHA}}</a> <a href="{{$run.Repo.Link}}/commit/{{$run.CommitSHA}}">{{ShortSha $run.CommitSHA}}</a>
{{ctx.Locale.Tr "actions.runs.pushed_by"}} {{ctx.Locale.Tr "actions.runs.pushed_by"}}
<a href="{{$run.TriggerUser.HomeLink}}">{{$run.TriggerUser.GetDisplayName}}</a> <a href="{{$run.TriggerUser.HomeLink}}">{{$run.TriggerUser.GetDisplayName}}</a>
{{- end -}} {{- end -}}
{{$errMsg := index $.RunErrors $run.ID}} {{$errMsg := index $data.RunErrors $run.ID}}
{{if $errMsg}} {{if $errMsg}}
<span class="flex-text-inline" data-tooltip-content="{{$errMsg}}"> <span class="flex-text-inline" data-tooltip-content="{{$errMsg}}">
{{svg "octicon-alert" 16 "tw-text-red"}} {{svg "octicon-alert" 16 "tw-text-red"}}
@@ -52,12 +56,12 @@
{{svg "octicon-kebab-horizontal"}} {{svg "octicon-kebab-horizontal"}}
<div class="menu flex-items-menu"> <div class="menu flex-items-menu">
<a class="item" href="{{$run.Link}}/workflow">{{svg "octicon-play"}}{{ctx.Locale.Tr "actions.runs.view_workflow_file"}}</a> <a class="item" href="{{$run.Link}}/workflow">{{svg "octicon-play"}}{{ctx.Locale.Tr "actions.runs.view_workflow_file"}}</a>
{{if and $.CanWriteRepoUnitActions (not $run.Status.IsDone)}} {{if and $data.CanWriteRepoUnitActions (not $run.Status.IsDone)}}
<a class="item link-action" data-url="{{$run.Link}}/cancel"> <a class="item link-action" data-url="{{$run.Link}}/cancel">
{{svg "octicon-x"}}{{ctx.Locale.Tr "actions.runs.cancel"}} {{svg "octicon-x"}}{{ctx.Locale.Tr "actions.runs.cancel"}}
</a> </a>
{{end}} {{end}}
{{if and $.CanWriteRepoUnitActions $run.Status.IsDone}} {{if and $data.CanWriteRepoUnitActions $run.Status.IsDone}}
<a class="item link-action" <a class="item link-action"
data-url="{{$run.Link}}/delete" data-url="{{$run.Link}}/delete"
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.delete.description"}}" data-modal-confirm="{{ctx.Locale.Tr "actions.runs.delete.description"}}"
@@ -71,4 +75,3 @@
</div> </div>
{{end}} {{end}}
</div> </div>
{{template "base/paginate" .}}
@@ -37,7 +37,7 @@
</div> </div>
{{end}} {{end}}
{{range .workflows}} {{range .workflows}}
{{if and .ErrMsg (eq .Entry.Name $.CurWorkflow)}} {{if and .ErrMsg (eq .EntryName $.CurWorkflow)}}
<div class="ui field"> <div class="ui field">
<div>{{svg "octicon-alert" 16 "tw-text-red"}} {{.ErrMsg}}</div> <div>{{svg "octicon-alert" 16 "tw-text-red"}} {{.ErrMsg}}</div>
</div> </div>
+29
View File
@@ -2,6 +2,9 @@ import {createApp} from 'vue';
import RepoActionView from '../components/RepoActionView.vue'; import RepoActionView from '../components/RepoActionView.vue';
import {registerGlobalInitFunc} from '../modules/observer.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts';
import {html} from '../utils/html.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';
export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void {
const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!); const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!);
@@ -27,6 +30,7 @@ function initWorkflowBadgeForm(form: HTMLElement): void {
export function initRepositoryActions() { export function initRepositoryActions() {
registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm); registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm);
initRepositoryActionsView(); initRepositoryActionsView();
registerGlobalInitFunc('initActionRunsList', initActionRunsList);
} }
function initRepositoryActionsView() { function initRepositoryActionsView() {
@@ -103,3 +107,28 @@ function initRepositoryActionsView() {
}); });
view.mount(el); view.mount(el);
} }
function initActionRunsList(el: HTMLElement) {
activePageTimerRefresh({
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);
}
},
});
}
+19 -4
View File
@@ -1,8 +1,7 @@
import { import {
createElementFromAttrs, createElementFromAttrs, createElementFromHTML,
createElementFromHTML, queryElemChildren, querySingleVisibleElem,
queryElemChildren, protectMorphElements, recoverMorphElements,
querySingleVisibleElem,
toggleElem, toggleElem,
} from './dom.ts'; } from './dom.ts';
@@ -54,3 +53,19 @@ test('toggleElem', () => {
toggleElem(el.children, true); toggleElem(el.children, true);
expect(el.outerHTML).toEqual('<div><div class="">a</div><div class="">b</div></div>'); expect(el.outerHTML).toEqual('<div><div class="">a</div><div class="">b</div></div>');
}); });
test('protectMorphElements', () => {
const el = createElementFromHTML('<div><span data-morph-protect="">foo</span></div>');
const protectedElems = protectMorphElements(el);
const span = el.querySelector('span')!;
const spanMorphProtectId = span.getAttribute('data-morph-protect');
expect(spanMorphProtectId).toBeTruthy();
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}">foo</span></div>`);
span.textContent = 'bar';
span.classList.add('new-class');
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}" class="new-class">bar</span></div>`);
recoverMorphElements(el, protectedElems);
expect(el.outerHTML).toEqual('<div><span data-morph-protect="">foo</span></div>');
});
+23
View File
@@ -411,3 +411,26 @@ export function activePageTimerRefresh(opts: ActivePageTimerOptions): ActivePage
timer.start(); timer.start();
return timer; return timer;
} }
type ProtectedMorphElements = Record<string, string>;
export function protectMorphElements(el: Element): ProtectedMorphElements {
// Some components like Dropdown manage their internal states and styles.
// If morph changes any style or layout, then the Dropdown will stop working.
// So, protect such fragile components ahead, then morph, then recover.
const ret: ProtectedMorphElements = {};
for (const it of el.querySelectorAll('.ui.dropdown, [data-morph-protect]')) {
const id = generateElemId();
ret[id] = it.outerHTML;
it.setAttribute('data-morph-protect', id);
}
return ret;
}
export function recoverMorphElements(el: Element, protectedElems: ProtectedMorphElements) {
for (const [id, html] of Object.entries(protectedElems)) {
const it = el.querySelector(`[data-morph-protect="${CSS.escape(id)}"]`);
if (!it) continue;
it.outerHTML = html;
}
}