From f7f68e4cc02ca57b841e20e0975b8926bf5c3722 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Fri, 6 Dec 2024 12:04:16 +0800
Subject: [PATCH] Refactor RepoActionView.vue, add `::group::` support (#32713)

1. make it able to "force reload", then the previous pending request
won't block the new request
2. make it support `::group::`
3. add some TS types (but there are still many variables untyped, this
PR is large enough, the remaining types could be added in the future)
---
 routers/web/devtest/mock_actions.go      | 108 ++++++++++++
 routers/web/repo/actions/view.go         |  98 +++++------
 routers/web/web.go                       |  10 +-
 templates/devtest/repo-action-view.tmpl  |  30 ++++
 web_src/js/components/RepoActionView.vue | 207 ++++++++++++-----------
 5 files changed, 299 insertions(+), 154 deletions(-)
 create mode 100644 routers/web/devtest/mock_actions.go
 create mode 100644 templates/devtest/repo-action-view.tmpl

diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
new file mode 100644
index 0000000000..37e94aa802
--- /dev/null
+++ b/routers/web/devtest/mock_actions.go
@@ -0,0 +1,108 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package devtest
+
+import (
+	"fmt"
+	mathRand "math/rand/v2"
+	"net/http"
+	"strings"
+	"time"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/web"
+	"code.gitea.io/gitea/routers/web/repo/actions"
+	"code.gitea.io/gitea/services/context"
+)
+
+func generateMockStepsLog(logCur actions.LogCursor) (stepsLog []*actions.ViewStepLog) {
+	mockedLogs := []string{
+		"::group::test group for: step={step}, cursor={cursor}",
+		"in group msg for: step={step}, cursor={cursor}",
+		"in group msg for: step={step}, cursor={cursor}",
+		"in group msg for: step={step}, cursor={cursor}",
+		"::endgroup::",
+		"message for: step={step}, cursor={cursor}",
+		"message for: step={step}, cursor={cursor}",
+		"message for: step={step}, cursor={cursor}",
+		"message for: step={step}, cursor={cursor}",
+		"message for: step={step}, cursor={cursor}",
+	}
+	cur := logCur.Cursor // usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally
+	for i := 0; i < util.Iif(logCur.Step == 0, 3, 1); i++ {
+		logStr := mockedLogs[int(cur)%len(mockedLogs)]
+		cur++
+		logStr = strings.ReplaceAll(logStr, "{step}", fmt.Sprintf("%d", logCur.Step))
+		logStr = strings.ReplaceAll(logStr, "{cursor}", fmt.Sprintf("%d", cur))
+		stepsLog = append(stepsLog, &actions.ViewStepLog{
+			Step:    logCur.Step,
+			Cursor:  cur,
+			Started: time.Now().Unix() - 1,
+			Lines: []*actions.ViewStepLogLine{
+				{Index: cur, Message: logStr, Timestamp: float64(time.Now().UnixNano()) / float64(time.Second)},
+			},
+		})
+	}
+	return stepsLog
+}
+
+func MockActionsRunsJobs(ctx *context.Context) {
+	req := web.GetForm(ctx).(*actions.ViewRequest)
+
+	resp := &actions.ViewResponse{}
+	resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
+		Name:   "artifact-a",
+		Size:   100 * 1024,
+		Status: "expired",
+	})
+	resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
+		Name:   "artifact-b",
+		Size:   1024 * 1024,
+		Status: "completed",
+	})
+	resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
+		Summary:  "step 0 (mock slow)",
+		Duration: time.Hour.String(),
+		Status:   actions_model.StatusRunning.String(),
+	})
+	resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
+		Summary:  "step 1 (mock fast)",
+		Duration: time.Hour.String(),
+		Status:   actions_model.StatusRunning.String(),
+	})
+	resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
+		Summary:  "step 2 (mock error)",
+		Duration: time.Hour.String(),
+		Status:   actions_model.StatusRunning.String(),
+	})
+	if len(req.LogCursors) == 0 {
+		ctx.JSON(http.StatusOK, resp)
+		return
+	}
+
+	resp.Logs.StepsLog = []*actions.ViewStepLog{}
+	doSlowResponse := false
+	doErrorResponse := false
+	for _, logCur := range req.LogCursors {
+		if !logCur.Expanded {
+			continue
+		}
+		doSlowResponse = doSlowResponse || logCur.Step == 0
+		doErrorResponse = doErrorResponse || logCur.Step == 2
+		resp.Logs.StepsLog = append(resp.Logs.StepsLog, generateMockStepsLog(logCur)...)
+	}
+	if doErrorResponse {
+		if mathRand.Float64() > 0.5 {
+			ctx.Error(http.StatusInternalServerError, "devtest mock error response")
+			return
+		}
+	}
+	if doSlowResponse {
+		time.Sleep(time.Duration(3000) * time.Millisecond)
+	} else {
+		time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
+	}
+	ctx.JSON(http.StatusOK, resp)
+}
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index f86d4c6177..0f0d7d1ebd 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -66,15 +66,25 @@ func View(ctx *context_module.Context) {
 	ctx.HTML(http.StatusOK, tplViewActions)
 }
 
+type LogCursor struct {
+	Step     int   `json:"step"`
+	Cursor   int64 `json:"cursor"`
+	Expanded bool  `json:"expanded"`
+}
+
 type ViewRequest struct {
-	LogCursors []struct {
-		Step     int   `json:"step"`
-		Cursor   int64 `json:"cursor"`
-		Expanded bool  `json:"expanded"`
-	} `json:"logCursors"`
+	LogCursors []LogCursor `json:"logCursors"`
+}
+
+type ArtifactsViewItem struct {
+	Name   string `json:"name"`
+	Size   int64  `json:"size"`
+	Status string `json:"status"`
 }
 
 type ViewResponse struct {
+	Artifacts []*ArtifactsViewItem `json:"artifacts"`
+
 	State struct {
 		Run struct {
 			Link              string     `json:"link"`
@@ -146,6 +156,25 @@ type ViewStepLogLine struct {
 	Timestamp float64 `json:"timestamp"`
 }
 
+func getActionsViewArtifacts(ctx context.Context, repoID, runIndex int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
+	run, err := actions_model.GetRunByIndex(ctx, repoID, runIndex)
+	if err != nil {
+		return nil, err
+	}
+	artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID)
+	if err != nil {
+		return nil, err
+	}
+	for _, art := range artifacts {
+		artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{
+			Name:   art.ArtifactName,
+			Size:   art.FileSize,
+			Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
+		})
+	}
+	return artifactsViewItems, nil
+}
+
 func ViewPost(ctx *context_module.Context) {
 	req := web.GetForm(ctx).(*ViewRequest)
 	runIndex := getRunIndex(ctx)
@@ -157,11 +186,19 @@ func ViewPost(ctx *context_module.Context) {
 	}
 	run := current.Run
 	if err := run.LoadAttributes(ctx); err != nil {
-		ctx.Error(http.StatusInternalServerError, err.Error())
+		ctx.ServerError("run.LoadAttributes", err)
 		return
 	}
 
+	var err error
 	resp := &ViewResponse{}
+	resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runIndex)
+	if err != nil {
+		if !errors.Is(err, util.ErrNotExist) {
+			ctx.ServerError("getActionsViewArtifacts", err)
+			return
+		}
+	}
 
 	resp.State.Run.Title = run.Title
 	resp.State.Run.Link = run.Link()
@@ -205,12 +242,12 @@ func ViewPost(ctx *context_module.Context) {
 		var err error
 		task, err = actions_model.GetTaskByID(ctx, current.TaskID)
 		if err != nil {
-			ctx.Error(http.StatusInternalServerError, err.Error())
+			ctx.ServerError("actions_model.GetTaskByID", err)
 			return
 		}
 		task.Job = current
 		if err := task.LoadAttributes(ctx); err != nil {
-			ctx.Error(http.StatusInternalServerError, err.Error())
+			ctx.ServerError("task.LoadAttributes", err)
 			return
 		}
 	}
@@ -278,7 +315,7 @@ func ViewPost(ctx *context_module.Context) {
 				offset := task.LogIndexes[index]
 				logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length)
 				if err != nil {
-					ctx.Error(http.StatusInternalServerError, err.Error())
+					ctx.ServerError("actions.ReadLogs", err)
 					return
 				}
 
@@ -555,49 +592,6 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions
 	return jobs[0], jobs
 }
 
-type ArtifactsViewResponse struct {
-	Artifacts []*ArtifactsViewItem `json:"artifacts"`
-}
-
-type ArtifactsViewItem struct {
-	Name   string `json:"name"`
-	Size   int64  `json:"size"`
-	Status string `json:"status"`
-}
-
-func ArtifactsView(ctx *context_module.Context) {
-	runIndex := getRunIndex(ctx)
-	run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
-	if err != nil {
-		if errors.Is(err, util.ErrNotExist) {
-			ctx.Error(http.StatusNotFound, err.Error())
-			return
-		}
-		ctx.Error(http.StatusInternalServerError, err.Error())
-		return
-	}
-	artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID)
-	if err != nil {
-		ctx.Error(http.StatusInternalServerError, err.Error())
-		return
-	}
-	artifactsResponse := ArtifactsViewResponse{
-		Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)),
-	}
-	for _, art := range artifacts {
-		status := "completed"
-		if art.Status == actions_model.ArtifactStatusExpired {
-			status = "expired"
-		}
-		artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{
-			Name:   art.ArtifactName,
-			Size:   art.FileSize,
-			Status: status,
-		})
-	}
-	ctx.JSON(http.StatusOK, artifactsResponse)
-}
-
 func ArtifactsDeleteView(ctx *context_module.Context) {
 	if !ctx.Repo.CanWrite(unit.TypeActions) {
 		ctx.Error(http.StatusForbidden, "no permission")
diff --git a/routers/web/web.go b/routers/web/web.go
index 6113a6457b..85e0fdc41e 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1424,7 +1424,6 @@ func registerRoutes(m *web.Router) {
 			})
 			m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
 			m.Post("/approve", reqRepoActionsWriter, actions.Approve)
-			m.Get("/artifacts", actions.ArtifactsView)
 			m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
 			m.Delete("/artifacts/{artifact_name}", actions.ArtifactsDeleteView)
 			m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
@@ -1626,9 +1625,12 @@ func registerRoutes(m *web.Router) {
 	}
 
 	if !setting.IsProd {
-		m.Any("/devtest", devtest.List)
-		m.Any("/devtest/fetch-action-test", devtest.FetchActionTest)
-		m.Any("/devtest/{sub}", devtest.Tmpl)
+		m.Group("/devtest", func() {
+			m.Any("", devtest.List)
+			m.Any("/fetch-action-test", devtest.FetchActionTest)
+			m.Any("/{sub}", devtest.Tmpl)
+			m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
+		})
 	}
 
 	m.NotFound(func(w http.ResponseWriter, req *http.Request) {
diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl
new file mode 100644
index 0000000000..1fa71c0e5f
--- /dev/null
+++ b/templates/devtest/repo-action-view.tmpl
@@ -0,0 +1,30 @@
+{{template "base/head" .}}
+<div class="page-content">
+	<div id="repo-action-view"
+			data-run-index="1"
+			data-job-index="2"
+			data-actions-url="{{AppSubUrl}}/devtest/actions-mock"
+			data-locale-approve="approve"
+			data-locale-cancel="cancel"
+			data-locale-rerun="re-run"
+			data-locale-rerun-all="re-run all"
+			data-locale-runs-scheduled="scheduled"
+			data-locale-runs-commit="commit"
+			data-locale-runs-pushed-by="pushed by"
+			data-locale-status-unknown="unknown"
+			data-locale-status-waiting="waiting"
+			data-locale-status-running="running"
+			data-locale-status-success="success"
+			data-locale-status-failure="failure"
+			data-locale-status-cancelled="cancelled"
+			data-locale-status-skipped="skipped"
+			data-locale-status-blocked="blocked"
+			data-locale-artifacts-title="artifacts"
+			data-locale-confirm-delete-artifact="confirm delete artifact"
+			data-locale-show-timestamps="show timestamps"
+			data-locale-show-log-seconds="show log seconds"
+			data-locale-show-full-screen="show full screen"
+			data-locale-download-logs="download logs"
+	></div>
+</div>
+{{template "base/footer" .}}
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 7adc29ad41..909308262e 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -2,10 +2,22 @@
 import {SvgIcon} from '../svg.ts';
 import ActionRunStatus from './ActionRunStatus.vue';
 import {createApp} from 'vue';
-import {toggleElem} from '../utils/dom.ts';
+import {createElementFromAttrs, toggleElem} from '../utils/dom.ts';
 import {formatDatetime} from '../utils/time.ts';
 import {renderAnsi} from '../render/ansi.ts';
-import {GET, POST, DELETE} from '../modules/fetch.ts';
+import {POST, DELETE} from '../modules/fetch.ts';
+
+// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
+type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
+
+type LogLine = {
+  index: number;
+  timestamp: number;
+  message: string;
+};
+
+const LogLinePrefixGroup = '::group::';
+const LogLinePrefixEndGroup = '::endgroup::';
 
 const sfc = {
   name: 'RepoActionView',
@@ -23,7 +35,7 @@ const sfc = {
   data() {
     return {
       // internal state
-      loading: false,
+      loadingAbortController: null,
       intervalID: null,
       currentJobStepsStates: [],
       artifacts: [],
@@ -89,9 +101,7 @@ const sfc = {
     // load job data and then auto-reload periodically
     // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
     await this.loadJob();
-    this.intervalID = setInterval(() => {
-      this.loadJob();
-    }, 1000);
+    this.intervalID = setInterval(() => this.loadJob(), 1000);
     document.body.addEventListener('click', this.closeDropdown);
     this.hashChangeListener();
     window.addEventListener('hashchange', this.hashChangeListener);
@@ -113,38 +123,44 @@ const sfc = {
 
   methods: {
     // get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
-    getLogsContainer(idx) {
-      const el = this.$refs.logs[idx];
+    getLogsContainer(stepIndex: number) {
+      const el = this.$refs.logs[stepIndex];
       return el._stepLogsActiveContainer ?? el;
     },
     // begin a log group
-    beginLogGroup(idx) {
-      const el = this.$refs.logs[idx];
-
-      const elJobLogGroup = document.createElement('div');
-      elJobLogGroup.classList.add('job-log-group');
-
-      const elJobLogGroupSummary = document.createElement('div');
-      elJobLogGroupSummary.classList.add('job-log-group-summary');
-
-      const elJobLogList = document.createElement('div');
-      elJobLogList.classList.add('job-log-list');
-
-      elJobLogGroup.append(elJobLogGroupSummary);
-      elJobLogGroup.append(elJobLogList);
+    beginLogGroup(stepIndex: number, startTime: number, line: LogLine) {
+      const el = this.$refs.logs[stepIndex];
+      const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
+        this.createLogLine(stepIndex, startTime, {
+          index: line.index,
+          timestamp: line.timestamp,
+          message: line.message.substring(LogLinePrefixGroup.length),
+        }),
+      );
+      const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
+      const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
+        elJobLogGroupSummary,
+        elJobLogList,
+      );
+      el.append(elJobLogGroup);
       el._stepLogsActiveContainer = elJobLogList;
     },
     // end a log group
-    endLogGroup(idx) {
-      const el = this.$refs.logs[idx];
+    endLogGroup(stepIndex: number, startTime: number, line: LogLine) {
+      const el = this.$refs.logs[stepIndex];
       el._stepLogsActiveContainer = null;
+      el.append(this.createLogLine(stepIndex, startTime, {
+        index: line.index,
+        timestamp: line.timestamp,
+        message: line.message.substring(LogLinePrefixEndGroup.length),
+      }));
     },
 
     // show/hide the step logs for a step
-    toggleStepLogs(idx) {
+    toggleStepLogs(idx: number) {
       this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
       if (this.currentJobStepsStates[idx].expanded) {
-        this.loadJob(); // try to load the data immediately instead of waiting for next timer interval
+        this.loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
       }
     },
     // cancel a run
@@ -156,62 +172,53 @@ const sfc = {
       POST(`${this.run.link}/approve`);
     },
 
-    createLogLine(line, startTime, stepIndex) {
-      const div = document.createElement('div');
-      div.classList.add('job-log-line');
-      div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`);
-      div._jobLogTime = line.timestamp;
+    createLogLine(stepIndex: number, startTime: number, line: LogLine) {
+      const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
+        String(line.index),
+      );
 
-      const lineNumber = document.createElement('a');
-      lineNumber.classList.add('line-num', 'muted');
-      lineNumber.textContent = line.index;
-      lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${line.index}`);
-      div.append(lineNumber);
+      const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
+        formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
+      );
+
+      const logMsg = createElementFromAttrs('span', {class: 'log-msg'});
+      logMsg.innerHTML = renderAnsi(line.message);
+
+      const seconds = Math.floor(line.timestamp - startTime);
+      const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
+        `${seconds}s`, // for "Show seconds"
+      );
 
-      // for "Show timestamps"
-      const logTimeStamp = document.createElement('span');
-      logTimeStamp.className = 'log-time-stamp';
-      const date = new Date(parseFloat(line.timestamp * 1000));
-      const timeStamp = formatDatetime(date);
-      logTimeStamp.textContent = timeStamp;
       toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
-      // for "Show seconds"
-      const logTimeSeconds = document.createElement('span');
-      logTimeSeconds.className = 'log-time-seconds';
-      const seconds = Math.floor(parseFloat(line.timestamp) - parseFloat(startTime));
-      logTimeSeconds.textContent = `${seconds}s`;
       toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
 
-      const logMessage = document.createElement('span');
-      logMessage.className = 'log-msg';
-      logMessage.innerHTML = renderAnsi(line.message);
-      div.append(logTimeStamp);
-      div.append(logMessage);
-      div.append(logTimeSeconds);
-
-      return div;
+      return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
+        lineNum, logTimeStamp, logMsg, logTimeSeconds,
+      );
     },
 
-    appendLogs(stepIndex, logLines, startTime) {
+    appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
       for (const line of logLines) {
-        // TODO: group support: ##[group]GroupTitle , ##[endgroup]
         const el = this.getLogsContainer(stepIndex);
-        el.append(this.createLogLine(line, startTime, stepIndex));
+        if (line.message.startsWith(LogLinePrefixGroup)) {
+          this.beginLogGroup(stepIndex, startTime, line);
+          continue;
+        } else if (line.message.startsWith(LogLinePrefixEndGroup)) {
+          this.endLogGroup(stepIndex, startTime, line);
+          continue;
+        }
+        el.append(this.createLogLine(stepIndex, startTime, line));
       }
     },
 
-    async fetchArtifacts() {
-      const resp = await GET(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);
-      return await resp.json();
-    },
-
-    async deleteArtifact(name) {
+    async deleteArtifact(name: string) {
       if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;
+      // TODO: should escape the "name"?
       await DELETE(`${this.run.link}/artifacts/${name}`);
-      await this.loadJob();
+      await this.loadJobForce();
     },
 
-    async fetchJob() {
+    async fetchJobData(abortController: AbortController) {
       const logCursors = this.currentJobStepsStates.map((it, idx) => {
         // cursor is used to indicate the last position of the logs
         // it's only used by backend, frontend just reads it and passes it back, it and can be any type.
@@ -219,30 +226,27 @@ const sfc = {
         return {step: idx, cursor: it.cursor, expanded: it.expanded};
       });
       const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {
+        signal: abortController.signal,
         data: {logCursors},
       });
       return await resp.json();
     },
 
+    async loadJobForce() {
+      this.loadingAbortController?.abort();
+      this.loadingAbortController = null;
+      await this.loadJob();
+    },
+
     async loadJob() {
-      if (this.loading) return;
+      if (this.loadingAbortController) return;
+      const abortController = new AbortController();
+      this.loadingAbortController = abortController;
       try {
-        this.loading = true;
+        const job = await this.fetchJobData(abortController);
+        if (this.loadingAbortController !== abortController) return;
 
-        let job, artifacts;
-        try {
-          [job, artifacts] = await Promise.all([
-            this.fetchJob(),
-            this.fetchArtifacts(), // refresh artifacts if upload-artifact step done
-          ]);
-        } catch (err) {
-          if (err instanceof TypeError) return; // avoid network error while unloading page
-          throw err;
-        }
-
-        this.artifacts = artifacts['artifacts'] || [];
-
-        // save the state to Vue data, then the UI will be updated
+        this.artifacts = job.artifacts || [];
         this.run = job.state.run;
         this.currentJob = job.state.currentJob;
 
@@ -254,26 +258,30 @@ const sfc = {
           }
         }
         // append logs to the UI
-        for (const logs of job.logs.stepsLog) {
+        for (const logs of job.logs.stepsLog ?? []) {
           // save the cursor, it will be passed to backend next time
           this.currentJobStepsStates[logs.step].cursor = logs.cursor;
-          this.appendLogs(logs.step, logs.lines, logs.started);
+          this.appendLogs(logs.step, logs.started, logs.lines);
         }
 
         if (this.run.done && this.intervalID) {
           clearInterval(this.intervalID);
           this.intervalID = null;
         }
+      } catch (e) {
+        // avoid network error while unloading page, and ignore "abort" error
+        if (e instanceof TypeError || abortController.signal.aborted) return;
+        throw e;
       } finally {
-        this.loading = false;
+        if (this.loadingAbortController === abortController) this.loadingAbortController = null;
       }
     },
 
-    isDone(status) {
+    isDone(status: RunStatus) {
       return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
     },
 
-    isExpandable(status) {
+    isExpandable(status: RunStatus) {
       return ['success', 'running', 'failure', 'cancelled'].includes(status);
     },
 
@@ -281,7 +289,7 @@ const sfc = {
       if (this.menuVisible) this.menuVisible = false;
     },
 
-    toggleTimeDisplay(type) {
+    toggleTimeDisplay(type: string) {
       this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
       for (const el of this.$refs.steps.querySelectorAll(`.log-time-${type}`)) {
         toggleElem(el, this.timeVisible[`log-time-${type}`]);
@@ -294,7 +302,7 @@ const sfc = {
       const outerEl = document.querySelector('.full.height');
       const actionBodyEl = document.querySelector('.action-view-body');
       const headerEl = document.querySelector('#navbar');
-      const contentEl = document.querySelector('.page-content.repository');
+      const contentEl = document.querySelector('.page-content');
       const footerEl = document.querySelector('.page-footer');
       toggleElem(headerEl, !this.isFullScreen);
       toggleElem(contentEl, !this.isFullScreen);
@@ -332,7 +340,7 @@ export function initRepositoryActionView() {
 
   // TODO: the parent element's full height doesn't work well now,
   // but we can not pollute the global style at the moment, only fix the height problem for pages with this component
-  const parentFullHeight = document.querySelector('body > div.full.height');
+  const parentFullHeight = document.querySelector<HTMLElement>('body > div.full.height');
   if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
 
   const view = createApp(sfc, {
@@ -858,7 +866,7 @@ export function initRepositoryActionView() {
   white-space: nowrap;
 }
 
-.job-step-section .job-step-logs .job-log-line .log-msg {
+.job-step-logs .job-log-line .log-msg {
   flex: 1;
   word-break: break-all;
   white-space: break-spaces;
@@ -884,15 +892,18 @@ export function initRepositoryActionView() {
   border-radius: 0;
 }
 
-/* TODO: group support
-
-.job-log-group {
-
+.job-log-group .job-log-list .job-log-line .log-msg {
+  margin-left: 2em;
 }
+
 .job-log-group-summary {
-
+  position: relative;
 }
-.job-log-list {
 
-} */
+.job-log-group-summary > .job-log-line {
+  position: absolute;
+  inset: 0;
+  z-index: -1; /* to avoid hiding the triangle of the "details" element */
+  overflow: hidden;
+}
 </style>