From 25cabd18a7f214f7330b473ca3873ed7bbb8c0ed Mon Sep 17 00:00:00 2001 From: Sudhanshu Date: Sat, 4 Jul 2026 18:53:09 +0530 Subject: [PATCH 1/9] feat(actions): implement adaptive auto-refresh for workflow runs list This commit introduces client-side auto-refreshing for the workflow runs list page using Idiomorph morphing and adaptive polling: - Adds an endpoint parameter `runs-list-only` on the backend to render only the inner runs list template, minimizing bandwidth. - Adds `data-status` attribute on run list items so the frontend can detect active running jobs. - Implements an adaptive polling loop in TypeScript: refreshes every 3s if there are active running/waiting jobs, and drops to 10s if the page is idle. --- routers/web/repo/actions/actions.go | 5 +++ templates/repo/actions/list.tmpl | 2 +- templates/repo/actions/runs_list.tmpl | 2 +- web_src/js/features/repo-actions.ts | 53 +++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 1bdcffab789ef..11ce31eff2332 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -119,6 +119,11 @@ func List(ctx *context.Context) { return } + if ctx.FormBool("runs-list-only") { + ctx.HTML(http.StatusOK, "repo/actions/runs_list") + return + } + ctx.HTML(http.StatusOK, tplListActions) } diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index cb1b1c7fb5e4f..012dac7802b6f 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -152,7 +152,7 @@ {{template "repo/actions/workflow_dispatch" .}} {{end}} -
+
{{template "repo/actions/runs_list" .}}
diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index 58dc222cf5b38..5248117b7c863 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -6,7 +6,7 @@ {{end}} {{range $run := .Runs}} -
+
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}} diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 89ed94e078878..19030cbf0a5d8 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -1,7 +1,9 @@ import {createApp} from 'vue'; +import {Idiomorph} from 'idiomorph'; import RepoActionView from '../components/RepoActionView.vue'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {html} from '../utils/html.ts'; +import {GET} from '../modules/fetch.ts'; export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!); @@ -27,6 +29,7 @@ function initWorkflowBadgeForm(form: HTMLElement): void { export function initRepositoryActions() { registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm); initRepositoryActionsView(); + initRepositoryActionsList(); } function initRepositoryActionsView() { @@ -103,3 +106,53 @@ function initRepositoryActionsView() { }); view.mount(el); } + +function initRepositoryActionsList(): void { + const runsList = document.querySelector('#actions-runs-list'); + if (!runsList) return; + + let timerId: number | null = null; + let pollingInterval = 3000; + + const runPolling = async () => { + if (!document.contains(runsList)) { + if (timerId) { + clearTimeout(timerId); + } + return; + } + + if (document.hidden) { + timerId = window.setTimeout(runPolling, pollingInterval); + return; + } + + const hasActiveRuns = document.querySelector( + '#actions-runs-list .run-list .item[data-status="running"], ' + + '#actions-runs-list .run-list .item[data-status="waiting"], ' + + '#actions-runs-list .run-list .item[data-status="cancelling"]', + ) !== null; + + pollingInterval = hasActiveRuns ? 3000 : 10000; + + try { + const url = new URL(window.location.href); + url.searchParams.set('runs-list-only', 'true'); + const response = await GET(url.href, { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (response.ok) { + const htmlText = await response.text(); + Idiomorph.morph(runsList, htmlText, {morphStyle: 'innerHTML'}); + } + } catch (e) { + console.error('Failed to update actions runs list:', e); + } + + timerId = window.setTimeout(runPolling, pollingInterval); + }; + + timerId = window.setTimeout(runPolling, pollingInterval); +} From 9de32771ca470f5b59ec96ae56fbaeffd7a73f3e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Jul 2026 09:58:59 +0800 Subject: [PATCH 2/9] fix --- models/actions/run.go | 6 ++ routers/web/repo/actions/actions.go | 111 +++++++++++++++----------- templates/base/paginate.tmpl | 7 +- templates/repo/actions/list.tmpl | 2 +- templates/repo/actions/runs_list.tmpl | 23 +++--- 5 files changed, 87 insertions(+), 62 deletions(-) diff --git a/models/actions/run.go b/models/actions/run.go index 79b71459351e2..fee71f1af6c81 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -277,6 +277,12 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er 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) { run, has, err := db.Get[ActionRun](ctx, builder.Eq{"repo_id": repoID, "`index`": runIndex}) if err != nil { diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 11ce31eff2332..9b279962b1fc2 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -114,7 +114,7 @@ func List(ctx *context.Context) { return } - prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0) + prepareWorkflowList(ctx, workflows, curWorkflowID, otherWorkflows, len(scopedNames) > 0) if ctx.Written() { return } @@ -457,66 +457,74 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf ctx.Data["Tags"] = tags } -func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) { +func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWorkflowID string, 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 - } + runIDs := ctx.FormStringInt64s("run_ids") // if status or actor query param is not given to frontend href, (href="//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{ - ListOptions: db.ListOptions{ - Page: page, - PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), - }, - RepoID: ctx.Repo.Repository.ID, - WorkflowID: workflowID, - WorkflowRepoID: scopedWorkflowSourceRepoID, - TriggerUserID: actorID, - } + var actionRuns []*actions_model.ActionRun + if len(runIDs) == 0 { + opts := actions_model.FindRunOptions{ + ListOptions: db.ListOptions{ + Page: max(ctx.FormInt("page"), 1), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + RepoID: ctx.Repo.Repository.ID, + WorkflowID: workflowID, + WorkflowRepoID: scopedWorkflowSourceRepoID, + TriggerUserID: 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) - } + // 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 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 branch != "" { - opts.Ref = string(git.RefNameFromBranch(branch)) - } + // 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 branch != "" { + opts.Ref = string(git.RefNameFromBranch(branch)) + } - runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) - if err != nil { - ctx.ServerError("FindAndCount", err) - return + runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) + if err != nil { + ctx.ServerError("FindAndCount", err) + return + } + pager := context.NewPagination(total, opts.PageSize, opts.Page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + actionRuns = runs + } else { + runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, runIDs) + if err != nil { + ctx.ServerError("GetRunsByRepoAndID", err) + return + } + actionRuns = runs } - for _, run := range runs { + for _, run := range actionRuns { run.Repo = ctx.Repo.Repository } - if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { + if err := actions_model.RunList(actionRuns).LoadTriggerUser(ctx); err != nil { ctx.ServerError("LoadTriggerUser", err) return } - if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil { + if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, actionRuns); err != nil { log.Error("LoadIsRefDeleted", err) } @@ -531,7 +539,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo ctx.ServerError("FindRunners", err) return } - for _, run := range runs { + for _, run := range actionRuns { if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { continue } @@ -569,9 +577,6 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo } } } - ctx.Data["RunErrors"] = runErrors - - ctx.Data["Runs"] = runs workflowNames := make(map[string]string, len(workflows)) workflowDisplayName := workflowID @@ -582,7 +587,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo 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 { @@ -605,12 +610,24 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo } 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["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(actionRuns) > 0 || hasScopedWorkflows - ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions) + type runListData struct { + ActionRuns []*actions_model.ActionRun + IsFiltered bool + WorkflowNames map[string]string + RunErrors map[int64]string + CurWorkflow string + CanWriteRepoUnitActions bool + } + ctx.Data["ActionRunListData"] = runListData{ + ActionRuns: actionRuns, + IsFiltered: actorID > 0 || status != int(actions_model.StatusUnknown) || branch != "", + WorkflowNames: workflowNames, + RunErrors: runErrors, + CurWorkflow: curWorkflowID, + CanWriteRepoUnitActions: ctx.Repo.Permission.CanWrite(unit.TypeActions), + } } func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) { diff --git a/templates/base/paginate.tmpl b/templates/base/paginate.tmpl index f6c1785ccf0b1..49374f96f322a 100644 --- a/templates/base/paginate.tmpl +++ b/templates/base/paginate.tmpl @@ -1,7 +1,8 @@ -{{$paginationParams := .Page.GetParams}} -{{$paginationLink := $.Link}} +{{$page := or $.Page ctx.RootData.Page}} +{{$paginationParams := $page.GetParams}} +{{$paginationLink := ctx.RootData.Link}} {{if eq $paginationLink AppSubUrl}}{{$paginationLink = print $paginationLink "/"}}{{end}} -{{with .Page.Paginater}} +{{with $page.Paginater}} {{if or (eq .TotalPages -1) (gt .TotalPages 1)}} {{$showFirstLast := gt .TotalPages 1}}
diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index 012dac7802b6f..c0d0a7f7e2104 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -153,7 +153,7 @@ {{end}}
- {{template "repo/actions/runs_list" .}} + {{template "repo/actions/runs_list" dict "ActionRunListData" $.ActionRunListData}}
diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index 5248117b7c863..52d5561ca0ec7 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -1,11 +1,12 @@ +{{$data := .ActionRunListData}}
- {{if not .Runs}} + {{if not $data.ActionRuns}}
{{svg "octicon-no-entry" 48}} -

{{if $.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}

+

{{if $data.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}

{{end}} - {{range $run := .Runs}} + {{range $run := $data.ActionRuns}}
@@ -15,22 +16,22 @@
{{$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}}
- {{$workflowName := index $.WorkflowNames $run.WorkflowID}} - {{if not $.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}: + {{$workflowName := index $data.WorkflowNames $run.WorkflowID}} + {{if not $data.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}: {{- if $run.ScheduleID -}} {{ctx.Locale.Tr "actions.runs.scheduled"}} {{- else -}} {{ctx.Locale.Tr "actions.runs.commit"}} - {{ShortSha $run.CommitSHA}} + {{ShortSha $run.CommitSHA}} {{ctx.Locale.Tr "actions.runs.pushed_by"}} {{$run.TriggerUser.GetDisplayName}} {{- end -}} - {{$errMsg := index $.RunErrors $run.ID}} + {{$errMsg := index $data.RunErrors $run.ID}} {{if $errMsg}} {{svg "octicon-alert" 16 "tw-text-red"}} @@ -52,12 +53,12 @@ {{svg "octicon-kebab-horizontal"}} -{{template "base/paginate" .}} +{{template "base/paginate" dict "Page" ctx.RootData.Page}} From 97862a46fa89984c0a8f3f8e5b5eb7191a94ec3a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Jul 2026 10:15:39 +0800 Subject: [PATCH 3/9] fix --- routers/web/repo/actions/actions.go | 94 +++++++++++++++------------ templates/repo/actions/runs_list.tmpl | 5 +- 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 9b279962b1fc2..ac6e71cb9fc27 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -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" @@ -114,16 +116,16 @@ func List(ctx *context.Context) { return } - prepareWorkflowList(ctx, workflows, curWorkflowID, otherWorkflows, len(scopedNames) > 0) + data := prepareWorkflowList(ctx, workflows, curWorkflowID, otherWorkflows, len(scopedNames) > 0) if ctx.Written() { return } - if ctx.FormBool("runs-list-only") { + ctx.Data["ActionRunListData"] = data + if data.PartialRefresh { ctx.HTML(http.StatusOK, "repo/actions/runs_list") return } - ctx.HTML(http.StatusOK, tplListActions) } @@ -457,13 +459,26 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf ctx.Data["Tags"] = tags } -func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWorkflowID string, otherWorkflows []string, hasScopedWorkflows bool) { +type actionRunListData struct { + PartialRefresh bool + RefreshLink template.URL + RefreshIntervalMs int64 + ActionRuns []*actions_model.ActionRun + IsFiltered bool + WorkflowNames map[string]string + RunErrors map[int64]string + CurWorkflow string + CanWriteRepoUnitActions bool +} + +func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWorkflowID string, otherWorkflows []string, hasScopedWorkflows bool) (data *actionRunListData) { + data = &actionRunListData{} actorID := ctx.FormInt64("actor") status := ctx.FormInt("status") workflowID := ctx.FormString("workflow") scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") branch := ctx.FormString("branch") - runIDs := ctx.FormStringInt64s("run_ids") + refreshRunIDs := ctx.FormStringInt64s("run_ids") // if status or actor query param is not given to frontend href, (href="//actions") // they will be 0 by default, which indicates get all status or actors @@ -471,8 +486,16 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork ctx.Data["CurStatus"] = status ctx.Data["CurBranch"] = branch - var actionRuns []*actions_model.ActionRun - if len(runIDs) == 0 { + data.RefreshIntervalMs = 10 * 1000 + data.PartialRefresh = len(refreshRunIDs) != 0 + if data.PartialRefresh { + runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, refreshRunIDs) + if err != nil { + ctx.ServerError("GetRunsByRepoAndID", err) + return data + } + data.ActionRuns = runs + } else { opts := actions_model.FindRunOptions{ ListOptions: db.ListOptions{ Page: max(ctx.FormInt("page"), 1), @@ -500,31 +523,24 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) if err != nil { ctx.ServerError("FindAndCount", err) - return + return data } pager := context.NewPagination(total, opts.PageSize, opts.Page, 5) pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager - actionRuns = runs - } else { - runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, runIDs) - if err != nil { - ctx.ServerError("GetRunsByRepoAndID", err) - return - } - actionRuns = runs + data.ActionRuns = runs } - for _, run := range actionRuns { + for _, run := range data.ActionRuns { run.Repo = ctx.Repo.Repository } - if err := actions_model.RunList(actionRuns).LoadTriggerUser(ctx); err != nil { + if err := actions_model.RunList(data.ActionRuns).LoadTriggerUser(ctx); err != nil { ctx.ServerError("LoadTriggerUser", err) - return + return data } - if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, actionRuns); err != nil { + if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, data.ActionRuns); err != nil { log.Error("LoadIsRefDeleted", err) } @@ -537,16 +553,19 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork }) if err != nil { ctx.ServerError("FindRunners", err) - return + return data } - for _, run := range actionRuns { + + var actionRunIDs []int64 + for _, run := range data.ActionRuns { + actionRunIDs = append(actionRunIDs, run.ID) 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 data } for _, job := range jobs { if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) { @@ -577,6 +596,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork } } } + data.RefreshLink = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "run_ids", base.Int64sToStrings(actionRunIDs)) workflowNames := make(map[string]string, len(workflows)) workflowDisplayName := workflowID @@ -597,7 +617,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("GetActors", err) - return + return data } ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors) @@ -606,28 +626,18 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("GetRunBranches", err) - return + return data } ctx.Data["RunBranches"] = runBranches - ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(actionRuns) > 0 || hasScopedWorkflows + ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(data.ActionRuns) > 0 || hasScopedWorkflows - type runListData struct { - ActionRuns []*actions_model.ActionRun - IsFiltered bool - WorkflowNames map[string]string - RunErrors map[int64]string - CurWorkflow string - CanWriteRepoUnitActions bool - } - ctx.Data["ActionRunListData"] = runListData{ - ActionRuns: actionRuns, - IsFiltered: actorID > 0 || status != int(actions_model.StatusUnknown) || branch != "", - WorkflowNames: workflowNames, - RunErrors: runErrors, - CurWorkflow: curWorkflowID, - CanWriteRepoUnitActions: ctx.Repo.Permission.CanWrite(unit.TypeActions), - } + data.IsFiltered = actorID > 0 || status != int(actions_model.StatusUnknown) || branch != "" + data.WorkflowNames = workflowNames + data.RunErrors = runErrors + data.CurWorkflow = curWorkflowID + data.CanWriteRepoUnitActions = ctx.Repo.Permission.CanWrite(unit.TypeActions) + return data } func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) { diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index 52d5561ca0ec7..362c540b561ad 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -1,5 +1,8 @@ {{$data := .ActionRunListData}} -
+
{{if not $data.ActionRuns}}
{{svg "octicon-no-entry" 48}} From edc593b31be9365f1be879ff75ea95f1d0895f8c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Jul 2026 11:41:29 +0800 Subject: [PATCH 4/9] fix --- routers/web/repo/actions/actions.go | 6 ++- templates/repo/actions/list.tmpl | 2 +- web_src/js/features/repo-actions.ts | 66 ++++++++++------------------- web_src/js/utils/dom.ts | 65 ++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 47 deletions(-) diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index ac6e71cb9fc27..fcc7ccd5cec56 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -486,7 +486,9 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork ctx.Data["CurStatus"] = status ctx.Data["CurBranch"] = branch - data.RefreshIntervalMs = 10 * 1000 + // use shorter interval in dev mode to make debug easier + // FIXME: if no need to refresh (statuses are all "over"), set data.RefreshIntervalMs to 0 + data.RefreshIntervalMs = util.Iif[int64](setting.IsProd, 10*1000, 1000) data.PartialRefresh = len(refreshRunIDs) != 0 if data.PartialRefresh { runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, refreshRunIDs) @@ -596,7 +598,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork } } } - data.RefreshLink = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "run_ids", base.Int64sToStrings(actionRunIDs)) + data.RefreshLink = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "run_ids", strings.Join(base.Int64sToStrings(actionRunIDs), ",")) workflowNames := make(map[string]string, len(workflows)) workflowDisplayName := workflowID diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index c0d0a7f7e2104..fc0263cc1d738 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -152,7 +152,7 @@ {{template "repo/actions/workflow_dispatch" .}} {{end}} -
+
{{template "repo/actions/runs_list" dict "ActionRunListData" $.ActionRunListData}}
diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 19030cbf0a5d8..4c6278858b54f 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -1,9 +1,10 @@ -import {createApp} from 'vue'; +import {createApp, ref} from 'vue'; import {Idiomorph} from 'idiomorph'; 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 {ActivePageTimer, createElementFromHTML} from "../utils/dom.ts"; export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!); @@ -29,7 +30,7 @@ function initWorkflowBadgeForm(form: HTMLElement): void { export function initRepositoryActions() { registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm); initRepositoryActionsView(); - initRepositoryActionsList(); + registerGlobalInitFunc('initActionRunsList', initActionRunsList); } function initRepositoryActionsView() { @@ -107,52 +108,29 @@ function initRepositoryActionsView() { view.mount(el); } -function initRepositoryActionsList(): void { - const runsList = document.querySelector('#actions-runs-list'); - if (!runsList) return; +async function initActionRunsList(el: HTMLElement) { + const refreshLink = el.getAttribute('data-action-runs-refresh-link')!; + const refreshInterval = Number(el.getAttribute('data-action-runs-refresh-interval')); + if (!refreshInterval) return; - let timerId: number | null = null; - let pollingInterval = 3000; - - const runPolling = async () => { - if (!document.contains(runsList)) { - if (timerId) { - clearTimeout(timerId); - } - return; - } - - if (document.hidden) { - timerId = window.setTimeout(runPolling, pollingInterval); + let timer = new ActivePageTimer(refreshInterval, {once: true}); + const refresh = async () => { + const resp = await GET(refreshLink); + if (!resp.ok) { + timer.start(); return; } - const hasActiveRuns = document.querySelector( - '#actions-runs-list .run-list .item[data-status="running"], ' + - '#actions-runs-list .run-list .item[data-status="waiting"], ' + - '#actions-runs-list .run-list .item[data-status="cancelling"]', - ) !== null; + const newEl = createElementFromHTML(await resp.text()); + // FIXME: 1. use HTML id to replace the rows one by one; 2. don't replace the rows which have active elements like opened dropdown + el.innerHTML = newEl.innerHTML; - pollingInterval = hasActiveRuns ? 3000 : 10000; - - try { - const url = new URL(window.location.href); - url.searchParams.set('runs-list-only', 'true'); - const response = await GET(url.href, { - headers: { - 'X-Requested-With': 'XMLHttpRequest', - }, - }); - if (response.ok) { - const htmlText = await response.text(); - Idiomorph.morph(runsList, htmlText, {morphStyle: 'innerHTML'}); - } - } catch (e) { - console.error('Failed to update actions runs list:', e); - } - - timerId = window.setTimeout(runPolling, pollingInterval); + const newInterval = Number(newEl.getAttribute('data-action-runs-refresh-interval')); + if (!newInterval) return; + timer = new ActivePageTimer(newInterval, {once: true}); + timer.callback = refresh; + timer.start(); }; - - timerId = window.setTimeout(runPolling, pollingInterval); + timer.callback = refresh; + timer.start(); } diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 03ab783142731..78e8c43220349 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -339,3 +339,68 @@ let elemIdCounter = 0; export function generateElemId(prefix: string = ''): string { return `${prefix}${elemIdCounter++}`; } + +export type ActivePageTimerOptions = { + once: boolean; +}; + +export class ActivePageTimer { + callback?: () => Promise; + + private interval: number; + private opts: ActivePageTimerOptions; + + private timerId?: number; + private startTime?: number; + private onVisibilityChange: () => void; + + constructor(interval: number, opts: ActivePageTimerOptions = {once: false}) { + this.interval = interval; + this.opts = opts; + this.onVisibilityChange = () => { + if (!this.startTime) return; + if (document.hidden) { + this.clearSysTimer(); + } else { + this.startSysTimer(); + } + }; + } + + private async handler() { + this.clear(); + await this.callback!(); + if (!this.opts.once) this.start(); + } + + start() { + if (this.timerId) return; + if (!this.startTime) { + this.startTime = Date.now(); + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + if (!document.hidden) this.startSysTimer(); + } + + private startSysTimer() { + const remaining = this.interval - (Date.now() - this.startTime!); + if (remaining <= 0) { + this.callback!(); + } else { + this.timerId = window.setTimeout(() => this.handler(), remaining); + } + } + + private clearSysTimer() { + if (this.timerId === undefined) return; + window.clearTimeout(this.timerId); + this.timerId = undefined; + } + + clear() { + if (this.startTime === undefined) return; + this.startTime = undefined; + this.clearSysTimer(); + document.removeEventListener('visibilitychange', this.onVisibilityChange); + } +} From bf02fee0edce14013ffa1130d8e78a892f749b82 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Jul 2026 11:54:21 +0800 Subject: [PATCH 5/9] fix --- templates/repo/actions/runs_list.tmpl | 2 +- web_src/js/features/repo-actions.ts | 36 +++++++++++++-------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index 362c540b561ad..0fd9e3fb3bfad 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -10,7 +10,7 @@
{{end}} {{range $run := $data.ActionRuns}} -
+
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}} diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 4c6278858b54f..9a847c35dda57 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -1,10 +1,9 @@ -import {createApp, ref} from 'vue'; -import {Idiomorph} from 'idiomorph'; +import {createApp} from 'vue'; 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 {ActivePageTimer, createElementFromHTML} from "../utils/dom.ts"; +import {ActivePageTimer, createElementFromHTML} from '../utils/dom.ts'; export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!); @@ -109,28 +108,27 @@ function initRepositoryActionsView() { } async function initActionRunsList(el: HTMLElement) { - const refreshLink = el.getAttribute('data-action-runs-refresh-link')!; - const refreshInterval = Number(el.getAttribute('data-action-runs-refresh-interval')); - if (!refreshInterval) return; - - let timer = new ActivePageTimer(refreshInterval, {once: true}); - const refresh = async () => { - const resp = await GET(refreshLink); + let timer: ActivePageTimer; + let startRefresh: () => void; + const refresh = async (link: string) => { + const resp = await GET(link); if (!resp.ok) { - timer.start(); + timer.start(); // retry on network error return; } - const newEl = createElementFromHTML(await resp.text()); + for (const attr of newEl.attributes) el.setAttribute(attr.name, attr.value); // FIXME: 1. use HTML id to replace the rows one by one; 2. don't replace the rows which have active elements like opened dropdown el.innerHTML = newEl.innerHTML; - - const newInterval = Number(newEl.getAttribute('data-action-runs-refresh-interval')); - if (!newInterval) return; - timer = new ActivePageTimer(newInterval, {once: true}); - timer.callback = refresh; + startRefresh(); + }; + startRefresh = () => { + const link = el.getAttribute('data-action-runs-refresh-link')!; + const interval = Number(el.getAttribute('data-action-runs-refresh-interval')); + if (!interval) return; + timer = new ActivePageTimer(interval, {once: true}); + timer.callback = async () => await refresh(link); timer.start(); }; - timer.callback = refresh; - timer.start(); + startRefresh(); } From 809d8b6788b3c55502589ff33d0129c91e6b094c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Jul 2026 12:00:30 +0800 Subject: [PATCH 6/9] fix --- web_src/js/utils/dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 78e8c43220349..d9362f12d472f 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -385,7 +385,7 @@ export class ActivePageTimer { private startSysTimer() { const remaining = this.interval - (Date.now() - this.startTime!); if (remaining <= 0) { - this.callback!(); + this.handler(); } else { this.timerId = window.setTimeout(() => this.handler(), remaining); } From b3b646b7248c1e5b8d431d0a481c16c071ddd283 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Jul 2026 12:03:39 +0800 Subject: [PATCH 7/9] fine tune --- web_src/js/utils/dom.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index d9362f12d472f..fcb8f1431e786 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -347,12 +347,12 @@ export type ActivePageTimerOptions = { export class ActivePageTimer { callback?: () => Promise; - private interval: number; - private opts: ActivePageTimerOptions; + private readonly interval: number; + private readonly opts: ActivePageTimerOptions; + private readonly onVisibilityChange: () => void; - private timerId?: number; + private sysTimerId?: number; private startTime?: number; - private onVisibilityChange: () => void; constructor(interval: number, opts: ActivePageTimerOptions = {once: false}) { this.interval = interval; @@ -374,7 +374,7 @@ export class ActivePageTimer { } start() { - if (this.timerId) return; + if (this.sysTimerId) return; if (!this.startTime) { this.startTime = Date.now(); document.addEventListener('visibilitychange', this.onVisibilityChange); @@ -387,14 +387,14 @@ export class ActivePageTimer { if (remaining <= 0) { this.handler(); } else { - this.timerId = window.setTimeout(() => this.handler(), remaining); + this.sysTimerId = window.setTimeout(() => this.handler(), remaining); } } private clearSysTimer() { - if (this.timerId === undefined) return; - window.clearTimeout(this.timerId); - this.timerId = undefined; + if (this.sysTimerId === undefined) return; + window.clearTimeout(this.sysTimerId); + this.sysTimerId = undefined; } clear() { From f4887200f02c21dcaa74497f9c96c1cab886c451 Mon Sep 17 00:00:00 2001 From: Sudhanshu Date: Sun, 5 Jul 2026 18:29:15 +0530 Subject: [PATCH 8/9] fix(actions): combine active page timer with idiomorph morphing to prevent dropdown disruption This commit integrates the robust ActivePageTimer from the remote branch to handle tab visibility changes, while keeping the Idiomorph DOM morphing on the frontend. The morphing preserves open or active dropdown menus on the runs list. On the backend, query execution is optimized via ListRunsOnly and findAndPrepareRuns to avoid Git operations and YAML file walking during polling, and list metadata is gathered in actionRunListData. --- routers/web/repo/actions/actions.go | 350 +++++++++++++++++++--------- types.d.ts | 20 +- web_src/js/features/repo-actions.ts | 15 +- 3 files changed, 269 insertions(+), 116 deletions(-) diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index fcc7ccd5cec56..adf1a0b01072c 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -11,6 +11,7 @@ import ( "html/template" "net/http" "net/url" + "path" "slices" "strings" @@ -20,7 +21,6 @@ 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" @@ -87,6 +87,11 @@ func List(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("actions.actions") ctx.Data["PageIsActions"] = true + if ctx.FormBool("runs-list-only") { + ListRunsOnly(ctx) + return + } + commit, err := ctx.Repo.GitRepo.GetBranchCommit(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) @@ -122,11 +127,206 @@ func List(ctx *context.Context) { } ctx.Data["ActionRunListData"] = data - if data.PartialRefresh { - ctx.HTML(http.StatusOK, "repo/actions/runs_list") + ctx.HTML(http.StatusOK, tplListActions) +} + +func findAndPrepareRuns(ctx *context.Context, opts actions_model.FindRunOptions) ([]*actions_model.ActionRun, int64, map[int64]string, error) { + runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) + if err != nil { + return nil, 0, nil, err + } + + for _, run := range runs { + run.Repo = ctx.Repo.Repository + } + + if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { + return nil, 0, nil, err + } + + if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil { + log.Error("LoadIsRefDeleted: %v", err) + } + + runErrors := make(map[int64]string) + var runners []*actions_model.ActionRunner + var runnersLoaded bool + + for _, run := range runs { + 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 { + return nil, 0, nil, err + } + 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()) + 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()) + break + } + } + if job.Status.IsWaiting() { + if !runnersLoaded { + runners, err = db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ + RepoID: ctx.Repo.Repository.ID, + IsOnline: optional.Some(true), + WithAvailable: true, + }) + if err != nil { + return nil, 0, nil, err + } + runnersLoaded = true + } + hasOnlineRunner := false + for _, runner := range runners { + if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) { + hasOnlineRunner = true + break + } + } + if !hasOnlineRunner { + runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ",")) + break + } + } + } + } + + return runs, total, runErrors, nil +} + +func fillRefreshMetadata(ctx *context.Context, data *actionRunListData) { + // Dynamic refresh URL + refreshURL, _ := url.Parse(setting.AppSubURL + ctx.Req.RequestURI) + q := refreshURL.Query() + q.Set("runs-list-only", "true") + refreshURL.RawQuery = q.Encode() + data.RefreshLink = template.URL(refreshURL.String()) + + // Dynamic refresh interval + hasActiveRuns := false + for _, run := range data.ActionRuns { + if run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { + hasActiveRuns = true + break + } + } + interval := 10 * 1000 // 10s default + if hasActiveRuns { + interval = 3000 // 3s for active runs + } + if !setting.IsProd { + interval = 1000 // 1s in dev mode + } + data.RefreshIntervalMs = int64(interval) +} + +func ListRunsOnly(ctx *context.Context) { + 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 + } + + ctx.Data["CurActor"] = actorID + ctx.Data["CurStatus"] = status + ctx.Data["CurBranch"] = branch + + opts := actions_model.FindRunOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + RepoID: ctx.Repo.Repository.ID, + WorkflowID: workflowID, + WorkflowRepoID: scopedWorkflowSourceRepoID, + TriggerUserID: actorID, + } + + if actions_model.Status(status) != actions_model.StatusUnknown { + opts.Status = []actions_model.Status{actions_model.Status(status)} + } + if branch != "" { + opts.Ref = string(git.RefNameFromBranch(branch)) + } + + runs, total, runErrors, err := findAndPrepareRuns(ctx, opts) + if err != nil { + ctx.ServerError("findAndPrepareRuns", err) return } - ctx.HTML(http.StatusOK, tplListActions) + + data := &actionRunListData{ + ActionRuns: runs, + IsFiltered: actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "", + RunErrors: runErrors, + CurWorkflow: workflowID, + CanWriteRepoUnitActions: ctx.Repo.Permission.CanWrite(unit.TypeActions), + } + + fillRefreshMetadata(ctx, data) + + // Lazy load workflow display names from default branch commit + var commit *git.Commit + var commitLoaded bool + workflowNames := make(map[string]string) + for _, run := range runs { + if run.WorkflowRepoID > 0 { + continue + } + if _, ok := workflowNames[run.WorkflowID]; !ok { + if !commitLoaded { + commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + log.Error("GetBranchCommit: %v", err) + break + } + commitLoaded = true + } + if commit == nil { + continue + } + var workflowPath string + for _, dir := range setting.Actions.WorkflowDirs { + pathToCheck := path.Join(dir, run.WorkflowID) + if has, _ := commit.HasFile(pathToCheck); has { + workflowPath = pathToCheck + break + } + } + if workflowPath == "" { + workflowPath = path.Join(".gitea/workflows", run.WorkflowID) + } + var displayName string + if content, err := commit.GetFileContent(workflowPath, 1024*1024); err == nil { + displayName = actions.WorkflowDisplayName(workflowPath, []byte(content)) + } else { + displayName = run.WorkflowID + } + workflowNames[run.WorkflowID] = displayName + } + } + data.WorkflowNames = workflowNames + + pager := context.NewPagination(total, opts.PageSize, opts.Page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.Data["ActionRunListData"] = data + ctx.HTML(http.StatusOK, "repo/actions/runs_list") } // prepareOtherWorkflows surfaces historical runs whose workflow file no longer @@ -478,7 +678,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork workflowID := ctx.FormString("workflow") scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") branch := ctx.FormString("branch") - refreshRunIDs := ctx.FormStringInt64s("run_ids") + page := max(ctx.FormInt("page"), 1) // if status or actor query param is not given to frontend href, (href="//actions") // they will be 0 by default, which indicates get all status or actors @@ -486,119 +686,42 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork ctx.Data["CurStatus"] = status ctx.Data["CurBranch"] = branch - // use shorter interval in dev mode to make debug easier - // FIXME: if no need to refresh (statuses are all "over"), set data.RefreshIntervalMs to 0 - data.RefreshIntervalMs = util.Iif[int64](setting.IsProd, 10*1000, 1000) - data.PartialRefresh = len(refreshRunIDs) != 0 - if data.PartialRefresh { - runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, refreshRunIDs) - if err != nil { - ctx.ServerError("GetRunsByRepoAndID", err) - return data - } - data.ActionRuns = runs - } else { - opts := actions_model.FindRunOptions{ - ListOptions: db.ListOptions{ - Page: max(ctx.FormInt("page"), 1), - PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), - }, - RepoID: ctx.Repo.Repository.ID, - WorkflowID: workflowID, - WorkflowRepoID: scopedWorkflowSourceRepoID, - TriggerUserID: 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 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 branch != "" { - opts.Ref = string(git.RefNameFromBranch(branch)) - } - - runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) - if err != nil { - ctx.ServerError("FindAndCount", err) - return data - } - pager := context.NewPagination(total, opts.PageSize, opts.Page, 5) - pager.AddParamFromRequest(ctx.Req) - ctx.Data["Page"] = pager - data.ActionRuns = runs + opts := actions_model.FindRunOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + RepoID: ctx.Repo.Repository.ID, + WorkflowID: workflowID, + WorkflowRepoID: scopedWorkflowSourceRepoID, + TriggerUserID: actorID, } - for _, run := range data.ActionRuns { - run.Repo = ctx.Repo.Repository + // 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 err := actions_model.RunList(data.ActionRuns).LoadTriggerUser(ctx); err != nil { - ctx.ServerError("LoadTriggerUser", err) - return data + if actions_model.Status(status) != actions_model.StatusUnknown { + opts.Status = []actions_model.Status{actions_model.Status(status)} } - - if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, data.ActionRuns); err != nil { - log.Error("LoadIsRefDeleted", err) + if branch != "" { + opts.Ref = string(git.RefNameFromBranch(branch)) } - // 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), - WithAvailable: true, - }) + runs, total, runErrors, err := findAndPrepareRuns(ctx, opts) if err != nil { - ctx.ServerError("FindRunners", err) + ctx.ServerError("findAndPrepareRuns", err) return data } - var actionRunIDs []int64 - for _, run := range data.ActionRuns { - actionRunIDs = append(actionRunIDs, run.ID) - 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 data - } - 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()) - 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()) - break - } - } - if job.Status.IsWaiting() { - hasOnlineRunner := false - for _, runner := range runners { - if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) { - hasOnlineRunner = true - break - } - } - if !hasOnlineRunner { - runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ",")) - break - } - } - } - } - data.RefreshLink = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "run_ids", strings.Join(base.Int64sToStrings(actionRunIDs), ",")) + data.ActionRuns = runs + data.IsFiltered = actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" + data.RunErrors = runErrors + data.CurWorkflow = workflowID + data.CanWriteRepoUnitActions = ctx.Repo.Permission.CanWrite(unit.TypeActions) + + fillRefreshMetadata(ctx, data) workflowNames := make(map[string]string, len(workflows)) workflowDisplayName := workflowID @@ -609,6 +732,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork workflowDisplayName = displayName } } + 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. @@ -632,13 +756,13 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWork } ctx.Data["RunBranches"] = runBranches - ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(data.ActionRuns) > 0 || hasScopedWorkflows + 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) - data.IsFiltered = actorID > 0 || status != int(actions_model.StatusUnknown) || branch != "" - data.WorkflowNames = workflowNames - data.RunErrors = runErrors - data.CurWorkflow = curWorkflowID - data.CanWriteRepoUnitActions = ctx.Repo.Permission.CanWrite(unit.TypeActions) return data } diff --git a/types.d.ts b/types.d.ts index 68f839924ff6d..e90a21fe00eed 100644 --- a/types.d.ts +++ b/types.d.ts @@ -15,8 +15,26 @@ declare module '*.vue' { } declare module 'idiomorph' { + interface IdiomorphCallbacks { + beforeNodeAdded?(node: Node): boolean | void; + afterNodeAdded?(node: Node): void; + beforeNodeMorphed?(oldNode: Node, newNode: Node): boolean | void; + afterNodeMorphed?(oldNode: Node, newNode: Node): void; + beforeNodeRemoved?(node: Node): boolean | void; + afterNodeRemoved?(node: Node): void; + beforeAttributeUpdated?(attributeName: string, node: Node, mutationType: 'update' | 'remove'): boolean | void; + } + + interface IdiomorphOptions { + morphStyle?: 'innerHTML' | 'outerHTML'; + ignoreActive?: boolean; + ignoreActiveValue?: boolean; + restoreFocus?: boolean; + callbacks?: IdiomorphCallbacks; + } + interface Idiomorph { - morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void; + morph(existing: Node | string, replacement: Node | string, options?: IdiomorphOptions): void; } export const Idiomorph: Idiomorph; } diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 9a847c35dda57..f56424ec6b790 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -1,4 +1,5 @@ import {createApp} from 'vue'; +import {Idiomorph} from 'idiomorph'; import RepoActionView from '../components/RepoActionView.vue'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {html} from '../utils/html.ts'; @@ -118,8 +119,18 @@ async function initActionRunsList(el: HTMLElement) { } const newEl = createElementFromHTML(await resp.text()); for (const attr of newEl.attributes) el.setAttribute(attr.name, attr.value); - // FIXME: 1. use HTML id to replace the rows one by one; 2. don't replace the rows which have active elements like opened dropdown - el.innerHTML = newEl.innerHTML; + + Idiomorph.morph(el, newEl.innerHTML, { + morphStyle: 'innerHTML', + callbacks: { + beforeNodeMorphed: (oldNode: Node) => !( + oldNode instanceof HTMLElement && + oldNode.classList.contains('dropdown') && + (oldNode.classList.contains('active') || oldNode.classList.contains('visible')) + ), + }, + }); + startRefresh(); }; startRefresh = () => { From a515772afb47ce440a6d89181aac15a64fceac29 Mon Sep 17 00:00:00 2001 From: Sudhanshu Date: Sun, 5 Jul 2026 18:59:12 +0530 Subject: [PATCH 9/9] fix(actions): remove unused parameter curWorkflowID to resolve golangci-lint unparam error --- routers/web/repo/actions/actions.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index adf1a0b01072c..0b453b07de19a 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -121,7 +121,7 @@ func List(ctx *context.Context) { return } - data := prepareWorkflowList(ctx, workflows, curWorkflowID, otherWorkflows, len(scopedNames) > 0) + data := prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0) if ctx.Written() { return } @@ -671,7 +671,7 @@ type actionRunListData struct { CanWriteRepoUnitActions bool } -func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, curWorkflowID string, otherWorkflows []string, hasScopedWorkflows bool) (data *actionRunListData) { +func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) (data *actionRunListData) { data = &actionRunListData{} actorID := ctx.FormInt64("actor") status := ctx.FormInt("status")