Skip to content

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

Draft
SudhanshuMatrix wants to merge 9 commits into
go-gitea:mainfrom
SudhanshuMatrix:feat/actions-auto-refresh
Draft

feat(actions): implement adaptive auto-refresh for workflow runs list#38329
SudhanshuMatrix wants to merge 9 commits into
go-gitea:mainfrom
SudhanshuMatrix:feat/actions-auto-refresh

Conversation

@SudhanshuMatrix

@SudhanshuMatrix SudhanshuMatrix commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

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.

It solves #37457

Technical Details

  1. Backend Integration (routers/web/repo/actions/actions.go):

    • Performance Optimization: Intercepts requests with the runs-list-only query parameter and routes them directly to ListRunsOnly, skipping default branch commit fetching, workflow tree walking, and parsing YAML files from disk.
    • Unified Logic: Extracted querying and online runner matching into a unified findAndPrepareRuns helper function.
    • Scoping & Scoped URLs: Scopes all runs data inside actionRunListData. Automatically computes the refresh link (retaining active pagination and search filters) and sets the refresh interval dynamically.
  2. Template Enhancements:

    • Moves the initialization attributes directly onto the .run-list container in runs_list.tmpl (data-global-init="initActionRunsList").
    • Scopes all variables inside runs_list.tmpl through ActionRunListData to keep template rendering isolated and clean.
  3. Robust Client-Side Polling (web_src/js/features/repo-actions.ts & web_src/js/utils/dom.ts):

    • Active Page Timer: Uses a custom tab-visibility-aware scheduler (ActivePageTimer) to automatically pause background polling when the tab is hidden and resume it immediately upon visibility.
    • Dropdown Protection via Morphing: Uses Idiomorph.morph to perform in-place DOM updates. Features a callback to prevent mutating or closing active Fomantic UI dropdown menus (resolving dropdown closure issues).
    • Dynamic Interval: Polls every 3 seconds (or 1 second in dev mode) if active runs (running, waiting, cancelling, blocked) exist on the page, relaxing to 10 seconds when all runs are completed.

How to Test

  1. Go to the Actions tab of any repository.
  2. Trigger/run a workflow.
  3. Open a kebab/action dropdown on the runs list.
  4. Verify that the runs list updates its status dynamically without losing focus or closing the opened dropdown.
  5. Switch to another tab and verify that polling pauses, resuming once you navigate back.

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.
@GiteaBot GiteaBot added the lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. label Jul 4, 2026
@github-actions github-actions Bot added the type/feature Completely new functionality. Can only be merged if feature freeze is not active. label Jul 4, 2026
@bircni

bircni commented Jul 4, 2026

Copy link
Copy Markdown
Member

What is the performance impact?

@SudhanshuMatrix

Copy link
Copy Markdown
Contributor Author

The performance impact is negligible. By returning only the runs list HTML fragment (reducing payload size by 95% and saving server CPU) and morphing only the modified elements on the client, it runs smoothly without page reloads. It also automatically pauses polling when the tab is hidden or inactive.

@bircni

bircni commented Jul 4, 2026

Copy link
Copy Markdown
Member

The performance impact is negligible. By returning only the runs list HTML fragment (reducing payload size by 95% and saving server CPU) and morphing only the modified elements on the client, it runs smoothly without page reloads. It also automatically pauses polling when the tab is hidden or inactive.

What if like 40 persons have such a tab open at the same time?

@bircni bircni left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about other pages? e.g. the pullrequest view? how is github doing that?

Comment thread web_src/js/features/repo-actions.ts Outdated
Comment thread routers/web/repo/actions/actions.go Outdated
return
}

if ctx.FormBool("runs-list-only") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The runs-list-only short-circuit is placed after every prepare* call. So every 3s (per open tab, per client, forever) the server re-runs prepareWorkflowTemplate → actions.ListWorkflows (git tree read) → reads and YAML-parses every workflow file → ResolveUses for reusable workflows (actions.go:188-227), plus the scoped/other/dispatch prep — all of it discarded, only runs_list returned. On a repo with many/large workflow files this is a large, continuous CPU + git-I/O cost for a "lightweight" refresh.

Comment thread web_src/js/features/repo-actions.ts Outdated
pollingInterval = hasActiveRuns ? 3000 : 10000;

try {
const url = new URL(window.location.href);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open run action-menu (kebab dropdown) is reset on every morph.

Comment thread web_src/js/features/repo-actions.ts Outdated
Comment thread web_src/js/features/repo-actions.ts Outdated
@GiteaBot GiteaBot added lgtm/blocked A maintainer has reservations with the PR and thus it cannot be merged and removed lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. labels Jul 4, 2026
@wxiaoguang

Copy link
Copy Markdown
Contributor

Actually, there is a more fundamental design problem, I don't think the current approach is right.

The "list" can change when new Actions tasks are created.

So, the end users will be surprising to see that the list items keep changing (different titles) when they are just doing nothing.

@wxiaoguang

wxiaoguang commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

TODOs:

  1. Fix the FIXMEs
    • // FIXME: if no need to refresh (statuses are all "over"), set data.RefreshIntervalMs to 0
    • // 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
  2. Add some necessary tests to cover new backend logic

@wxiaoguang wxiaoguang marked this pull request as draft July 5, 2026 04:03
@SudhanshuMatrix

Copy link
Copy Markdown
Contributor Author

This implementation introduces a lightweight ListRunsOnly handler that fetches only the current page of runs using db.FindAndCount. Additional data, such as workflow names and runners, is loaded only when required. During periodic polling, this avoids unnecessary Git tree traversal, workflow parsing, and extra database queries, reducing overhead when no workflows are actively waiting or running.

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
	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,
	}

	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
	}

	for _, run := range runs {
		run.Repo = ctx.Repo.Repository
	}

	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)
	}

	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 {
			ctx.ServerError("GetRunJobsByRunID", err)
			return
		}
		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 {
						ctx.ServerError("FindRunners", err)
						return
					}
					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
				}
			}
		}
	}
	ctx.Data["RunErrors"] = runErrors
	ctx.Data["Runs"] = runs
	ctx.Data["CurWorkflow"] = workflowID

	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
		}
	}
	ctx.Data["WorkflowNames"] = workflowNames
	ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions)

	pager := context.NewPagination(total, opts.PageSize, opts.Page, 5)
	pager.AddParamFromRequest(ctx.Req)
	ctx.Data["Page"] = pager

	ctx.HTML(http.StatusOK, "repo/actions/runs_list")
}

@wxiaoguang

Copy link
Copy Markdown
Contributor

This implementation introduces a lightweight ListRunsOnly handler that fetches only the current page of runs using db.FindAndCount. Additional data, such as workflow names and runners, is loaded only when required. During periodic polling, this avoids unnecessary Git tree traversal, workflow parsing, and extra database queries, reducing overhead when no workflows are actively waiting or running.

It should avoid copying&pasting code.

Also, see my commits. You can continue to improve.

…event 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.
@wxiaoguang

Copy link
Copy Markdown
Contributor

Actually, there is a more fundamental design problem, I don't think the current approach is right.

The "list" can change when new Actions tasks are created.

So, the end users will be surprising to see that the list items keep changing (different titles) when they are just doing nothing.

You broke many designs including the fix for this one.

Please take a look at my commits, especially how "run_ids" works.

@wxiaoguang

wxiaoguang commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

f488720
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.

That's just AI hallucination. Your ListRunsOnly still calls commit.GetFileContent(workflowPath, 1024*1024)

Please make sure you fully understand what you are doing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm/blocked A maintainer has reservations with the PR and thus it cannot be merged type/feature Completely new functionality. Can only be merged if feature freeze is not active.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants