mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 14:03:24 +09:00
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:
co-authored by
wxiaoguang
parent
65c5a5ff7b
commit
b998c3c1aa
@@ -8,6 +8,7 @@ import (
|
||||
stdCtx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
@@ -42,9 +44,9 @@ const (
|
||||
)
|
||||
|
||||
type WorkflowInfo struct {
|
||||
Entry git.TreeEntry
|
||||
ErrMsg string
|
||||
Workflow *act_model.Workflow
|
||||
EntryName string
|
||||
ErrMsg string
|
||||
Workflow *act_model.Workflow
|
||||
}
|
||||
|
||||
type workflowBadge struct {
|
||||
@@ -58,7 +60,7 @@ func (w WorkflowInfo) DisplayName() string {
|
||||
if w.Workflow != nil && w.Workflow.Name != "" {
|
||||
return w.Workflow.Name
|
||||
}
|
||||
return w.Entry.Name()
|
||||
return w.EntryName
|
||||
}
|
||||
|
||||
// MustEnableActions check if actions are enabled in settings
|
||||
@@ -99,9 +101,22 @@ func List(ctx *context.Context) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
curWorkflowRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
|
||||
ctx.Data["CurWorkflowRepoID"] = curWorkflowRepoID
|
||||
scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, curWorkflowRepoID)
|
||||
|
||||
data := prepareWorkflowList(ctx, curWorkflowID)
|
||||
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() {
|
||||
return
|
||||
}
|
||||
@@ -109,16 +124,22 @@ func List(ctx *context.Context) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, curWorkflowRepoID)
|
||||
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, data.scopedWorkflowSourceRepoID)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0)
|
||||
if ctx.Written() {
|
||||
if !data.prepareFullPageRuns(ctx, otherWorkflows) {
|
||||
return
|
||||
}
|
||||
if !data.prepareCommon(ctx, workflows) {
|
||||
return
|
||||
}
|
||||
if !data.prepareFullPageTemplate(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(data.ActionRuns) > 0 || len(scopedNames) > 0
|
||||
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 {
|
||||
listed := make(container.Set[string], len(workflows))
|
||||
for _, w := range workflows {
|
||||
listed.Add(w.Entry.Name())
|
||||
listed.Add(w.EntryName)
|
||||
}
|
||||
|
||||
var other []string
|
||||
@@ -193,7 +214,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
|
||||
|
||||
workflows = make([]WorkflowInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
workflow := WorkflowInfo{Entry: *entry}
|
||||
workflow := WorkflowInfo{EntryName: entry.Name()}
|
||||
content, err := actions.GetContentFromEntry(entry)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetContentFromEntry", err)
|
||||
@@ -404,7 +425,7 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
|
||||
curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID)
|
||||
} else {
|
||||
for _, workflowInfo := range workflowInfos {
|
||||
if workflowInfo.Entry.Name() == curWorkflowID {
|
||||
if workflowInfo.EntryName == curWorkflowID {
|
||||
if workflowInfo.Workflow == nil {
|
||||
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
|
||||
return
|
||||
@@ -452,71 +473,66 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
|
||||
ctx.Data["Tags"] = tags
|
||||
}
|
||||
|
||||
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows 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
|
||||
}
|
||||
|
||||
func (data *actionRunListData) prepareFullPageRuns(ctx *context.Context, otherWorkflows []string) bool {
|
||||
opts := actions_model.FindRunOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
Page: max(ctx.FormInt("page"), 1),
|
||||
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
||||
},
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
WorkflowID: workflowID,
|
||||
WorkflowRepoID: scopedWorkflowSourceRepoID,
|
||||
TriggerUserID: actorID,
|
||||
WorkflowID: data.workflowID,
|
||||
WorkflowRepoID: data.scopedWorkflowSourceRepoID,
|
||||
TriggerUserID: data.actorID,
|
||||
}
|
||||
|
||||
// Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id.
|
||||
if workflowID != "" && !slices.Contains(otherWorkflows, workflowID) {
|
||||
opts.IsScopedRun = optional.Some(scopedWorkflowSourceRepoID > 0)
|
||||
if data.workflowID != "" && !slices.Contains(otherWorkflows, data.workflowID) {
|
||||
opts.IsScopedRun = optional.Some(data.scopedWorkflowSourceRepoID > 0)
|
||||
}
|
||||
|
||||
// if status is not StatusUnknown, it means user has selected a status filter
|
||||
if actions_model.Status(status) != actions_model.StatusUnknown {
|
||||
opts.Status = []actions_model.Status{actions_model.Status(status)}
|
||||
if data.status != actions_model.StatusUnknown {
|
||||
opts.Status = []actions_model.Status{data.status}
|
||||
}
|
||||
if branch != "" {
|
||||
opts.Ref = string(git.RefNameFromBranch(branch))
|
||||
if data.branch != "" {
|
||||
opts.Ref = string(git.RefNameFromBranch(data.branch))
|
||||
}
|
||||
|
||||
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
|
||||
if err != nil {
|
||||
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 {
|
||||
run.Repo = ctx.Repo.Repository
|
||||
}
|
||||
type actionRunListData struct {
|
||||
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 {
|
||||
ctx.ServerError("LoadTriggerUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil {
|
||||
log.Error("LoadIsRefDeleted", err)
|
||||
}
|
||||
// the following fields are used by the template
|
||||
RefreshLink template.URL
|
||||
RefreshIntervalMs int64
|
||||
ActionRuns []*actions_model.ActionRun
|
||||
IsFiltered bool
|
||||
WorkflowNames map[string]string
|
||||
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
|
||||
runErrors := make(map[int64]string)
|
||||
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
IsOnline: optional.Some(true),
|
||||
@@ -524,28 +540,30 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
|
||||
})
|
||||
if err != nil {
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunJobsByRunID", err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
if job.CallUses != "" {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -558,54 +576,132 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
workflowDisplayName := workflowID
|
||||
func (data *actionRunListData) fillWorkflowNames(workflows []WorkflowInfo) bool {
|
||||
data.WorkflowNames = make(map[string]string, len(workflows))
|
||||
data.workflowDisplayName = data.workflowID
|
||||
for _, wf := range workflows {
|
||||
displayName := wf.DisplayName()
|
||||
workflowNames[wf.Entry.Name()] = displayName
|
||||
if wf.Entry.Name() == workflowID {
|
||||
workflowDisplayName = displayName
|
||||
data.WorkflowNames[wf.EntryName] = displayName
|
||||
if wf.EntryName == data.workflowID {
|
||||
data.workflowDisplayName = displayName
|
||||
}
|
||||
}
|
||||
ctx.Data["WorkflowNames"] = workflowNames
|
||||
// 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 {
|
||||
prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName)
|
||||
return true
|
||||
}
|
||||
|
||||
func prepareWorkflowList(ctx *context.Context, curWorkflowID string) *actionRunListData {
|
||||
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)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetActors", err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunBranches", err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
ctx.Data["RunBranches"] = runBranches
|
||||
|
||||
pager := context.NewPagination(total, opts.PageSize, opts.Page, 5)
|
||||
pager.AddParamFromRequest(ctx.Req)
|
||||
ctx.Data["Page"] = pager
|
||||
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 || hasScopedWorkflows
|
||||
|
||||
ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions)
|
||||
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
|
||||
ctx.Data["CurActor"] = data.actorID
|
||||
ctx.Data["CurStatus"] = int(data.status) // don't make template use its "String" method, otherwise the query parameter would be wrong
|
||||
ctx.Data["CurBranch"] = data.branch
|
||||
ctx.Data["CurWorkflowRepoID"] = data.scopedWorkflowSourceRepoID
|
||||
ctx.Data["Page"] = data.pager
|
||||
return true
|
||||
}
|
||||
|
||||
func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) {
|
||||
|
||||
Reference in New Issue
Block a user