diff --git a/models/actions/artifact.go b/models/actions/artifact.go
index 757bd13acd..ec5cc0e32f 100644
--- a/models/actions/artifact.go
+++ b/models/actions/artifact.go
@@ -170,10 +170,10 @@ type ActionArtifactMeta struct {
}
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
-func ListUploadedArtifactsMeta(ctx context.Context, runID int64) ([]*ActionArtifactMeta, error) {
+func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*ActionArtifactMeta, error) {
arts := make([]*ActionArtifactMeta, 0, 10)
return arts, db.GetEngine(ctx).Table("action_artifact").
- Where("run_id=? AND (status=? OR status=?)", runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
+ Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
GroupBy("artifact_name").
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
Find(&arts)
diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index c9a13f8e21..80744253d8 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -3709,6 +3709,10 @@
"actions.runs.not_done": "This workflow run is not done.",
"actions.runs.view_workflow_file": "View workflow file",
"actions.runs.workflow_graph": "Workflow Graph",
+ "actions.runs.summary": "Summary",
+ "actions.runs.all_jobs": "All jobs",
+ "actions.runs.triggered_via": "Triggered via %s",
+ "actions.runs.total_duration": "Total duration:",
"actions.workflow.disable": "Disable Workflow",
"actions.workflow.disable_success": "Workflow '%s' disabled successfully.",
"actions.workflow.enable": "Enable Workflow",
diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
index ac3483239a..00ca095e71 100644
--- a/routers/web/devtest/mock_actions.go
+++ b/routers/web/devtest/mock_actions.go
@@ -59,15 +59,14 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
}
func MockActionsView(ctx *context.Context) {
- ctx.Data["RunID"] = ctx.PathParam("run")
- ctx.Data["JobID"] = ctx.PathParam("job")
+ ctx.Data["RunID"] = ctx.PathParamInt64("run")
+ ctx.Data["JobID"] = ctx.PathParamInt64("job")
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
}
func MockActionsRunsJobs(ctx *context.Context) {
runID := ctx.PathParamInt64("run")
- req := web.GetForm(ctx).(*actions.ViewRequest)
resp := &actions.ViewResponse{}
resp.State.Run.TitleHTML = `mock run title link`
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
@@ -79,6 +78,9 @@ func MockActionsRunsJobs(ctx *context.Context) {
resp.State.Run.CanDeleteArtifact = true
resp.State.Run.WorkflowID = "workflow-id"
resp.State.Run.WorkflowLink = "./workflow-link"
+ resp.State.Run.Duration = "1h 23m 45s"
+ resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix()
+ resp.State.Run.TriggerEvent = "push"
resp.State.Run.Commit = actions.ViewCommit{
ShortSha: "ccccdddd",
Link: "./commit-link",
@@ -140,6 +142,17 @@ func MockActionsRunsJobs(ctx *context.Context) {
Needs: []string{"job-100", "job-101"},
})
+ fillViewRunResponseCurrentJob(ctx, resp)
+ ctx.JSON(http.StatusOK, resp)
+}
+
+func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewResponse) {
+ jobID := ctx.PathParamInt64("job")
+ if jobID == 0 {
+ return
+ }
+
+ req := web.GetForm(ctx).(*actions.ViewRequest)
var mockLogOptions []generateMockStepsLogOptions
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
Summary: "step 0 (mock slow)",
@@ -163,7 +176,6 @@ func MockActionsRunsJobs(ctx *context.Context) {
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})
if len(req.LogCursors) == 0 {
- ctx.JSON(http.StatusOK, resp)
return
}
@@ -189,5 +201,4 @@ func MockActionsRunsJobs(ctx *context.Context) {
} 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 5c010dfbed..98d86f0bb3 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -38,41 +38,54 @@ import (
"github.com/nektos/act/pkg/model"
)
-func getRunID(ctx *context_module.Context) int64 {
- // if run param is "latest", get the latest run id
- if ctx.PathParam("run") == "latest" {
- if run, _ := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID); run != nil {
- return run.ID
+func findCurrentJobByPathParam(ctx *context_module.Context, jobs []*actions_model.ActionRunJob) (job *actions_model.ActionRunJob, hasPathParam bool) {
+ selectedJobID := ctx.PathParamInt64("job")
+ if selectedJobID <= 0 {
+ return nil, false
+ }
+ for _, job = range jobs {
+ if job.ID == selectedJobID {
+ return job, true
}
}
- return ctx.PathParamInt64("run")
+ return nil, true
+}
+
+func getCurrentRunByPathParam(ctx *context_module.Context) (run *actions_model.ActionRun) {
+ var err error
+ // if run param is "latest", get the latest run id
+ if ctx.PathParam("run") == "latest" {
+ run, err = actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID)
+ } else {
+ run, err = actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("run"))
+ }
+ if errors.Is(err, util.ErrNotExist) {
+ ctx.NotFound(nil)
+ } else if err != nil {
+ ctx.ServerError("GetRun:"+ctx.PathParam("run"), err)
+ }
+ return run
}
func View(ctx *context_module.Context) {
ctx.Data["PageIsActions"] = true
- runID := getRunID(ctx)
-
- _, _, current := getRunJobsAndCurrentJob(ctx, runID)
+ run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
return
}
-
- ctx.Data["RunID"] = runID
- ctx.Data["JobID"] = current.ID
+ ctx.Data["RunID"] = run.ID
+ ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view)
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
ctx.HTML(http.StatusOK, tplViewActions)
}
func ViewWorkflowFile(ctx *context_module.Context) {
- runID := getRunID(ctx)
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
+
commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA)
if err != nil {
ctx.NotFoundOrServerError("GetCommit", func(err error) bool {
@@ -130,6 +143,10 @@ type ViewResponse struct {
IsSchedule bool `json:"isSchedule"`
Jobs []*ViewJob `json:"jobs"`
Commit ViewCommit `json:"commit"`
+ // Summary view: run duration and trigger time/event
+ Duration string `json:"duration"`
+ TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
+ TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
} `json:"run"`
CurrentJob struct {
Title string `json:"title"`
@@ -190,11 +207,7 @@ type ViewStepLogLine struct {
}
func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
- run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
- if err != nil {
- return nil, err
- }
- artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID)
+ artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID)
if err != nil {
return nil, err
}
@@ -209,10 +222,7 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifact
}
func ViewPost(ctx *context_module.Context) {
- req := web.GetForm(ctx).(*ViewRequest)
- runID := getRunID(ctx)
-
- run, jobs, current := getRunJobsAndCurrentJob(ctx, runID)
+ run, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
@@ -221,14 +231,24 @@ func ViewPost(ctx *context_module.Context) {
return
}
- var err error
resp := &ViewResponse{}
- resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runID)
+ fillViewRunResponseSummary(ctx, resp, run, jobs)
+ if ctx.Written() {
+ return
+ }
+ fillViewRunResponseCurrentJob(ctx, resp, run, jobs)
+ if ctx.Written() {
+ return
+ }
+ ctx.JSON(http.StatusOK, resp)
+}
+
+func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
+ var err error
+ resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID)
if err != nil {
- if !errors.Is(err, util.ErrNotExist) {
- ctx.ServerError("getActionsViewArtifacts", err)
- return
- }
+ ctx.ServerError("getActionsViewArtifacts", err)
+ return
}
// the title for the "run" is from the commit message
@@ -289,6 +309,20 @@ func ViewPost(ctx *context_module.Context) {
Pusher: pusher,
Branch: branch,
}
+ resp.State.Run.Duration = run.Duration().String()
+ resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
+ resp.State.Run.TriggerEvent = run.TriggerEvent
+}
+
+func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
+ req := web.GetForm(ctx).(*ViewRequest)
+ current, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
+ if current == nil {
+ if hasPathParam {
+ ctx.NotFound(nil)
+ }
+ return
+ }
var task *actions_model.ActionTask
if current.TaskID > 0 {
@@ -321,8 +355,6 @@ func ViewPost(ctx *context_module.Context) {
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...)
resp.Logs.StepsLog = append(resp.Logs.StepsLog, logs...)
}
-
- ctx.JSON(http.StatusOK, resp)
}
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
@@ -426,19 +458,22 @@ func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.Action
// Rerun will rerun jobs in the given run
// If jobIDStr is a blank string, it means rerun all jobs
func Rerun(ctx *context_module.Context) {
- runID := getRunID(ctx)
-
- run, jobs, currentJob := getRunJobsAndCurrentJob(ctx, runID)
+ run, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
-
if !checkRunRerunAllowed(ctx, run) {
return
}
+ currentJob, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
+ if hasPathParam && currentJob == nil {
+ ctx.NotFound(nil)
+ return
+ }
+
var jobsToRerun []*actions_model.ActionRunJob
- if ctx.PathParam("job") != "" {
+ if currentJob != nil {
jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
} else {
jobsToRerun = jobs
@@ -454,13 +489,10 @@ func Rerun(ctx *context_module.Context) {
// RerunFailed reruns all failed jobs in the given run
func RerunFailed(ctx *context_module.Context) {
- runID := getRunID(ctx)
-
- run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
+ run, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
-
if !checkRunRerunAllowed(ctx, run) {
return
}
@@ -474,18 +506,13 @@ func RerunFailed(ctx *context_module.Context) {
}
func Logs(ctx *context_module.Context) {
- runID := getRunID(ctx)
- jobID := ctx.PathParamInt64("job")
-
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
+ jobID := ctx.PathParamInt64("job")
- if err = common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
+ if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
@@ -493,9 +520,7 @@ func Logs(ctx *context_module.Context) {
}
func Cancel(ctx *context_module.Context) {
- runID := getRunID(ctx)
-
- run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
+ run, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
@@ -529,9 +554,11 @@ func Cancel(ctx *context_module.Context) {
}
func Approve(ctx *context_module.Context) {
- runID := getRunID(ctx)
-
- approveRuns(ctx, []int64{runID})
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
+ return
+ }
+ approveRuns(ctx, []int64{run.ID})
if ctx.Written() {
return
}
@@ -606,16 +633,8 @@ func approveRuns(ctx *context_module.Context, runIDs []int64) {
}
func Delete(ctx *context_module.Context) {
- runID := getRunID(ctx)
- repoID := ctx.Repo.Repository.ID
-
- run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
- if err != nil {
- if errors.Is(err, util.ErrNotExist) {
- ctx.JSONErrorNotFound()
- return
- }
- ctx.ServerError("GetRunByRepoAndID", err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
@@ -632,59 +651,37 @@ func Delete(ctx *context_module.Context) {
ctx.JSONOK()
}
-// getRunJobsAndCurrentJob loads the run and its jobs for runID, and returns the selected job based on the optional "job" path param (or the first job by default).
-// Any error will be written to the ctx, and nils are returned in that case.
-func getRunJobsAndCurrentJob(ctx *context_module.Context, runID int64) (*actions_model.ActionRun, []*actions_model.ActionRunJob, *actions_model.ActionRunJob) {
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
- return nil, nil, nil
+// getRunJobs loads the run and its jobs for runID
+// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil.
+func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) {
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
+ return nil, nil
}
run.Repo = ctx.Repo.Repository
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
- return nil, nil, nil
+ return nil, nil
}
if len(jobs) == 0 {
ctx.NotFound(nil)
- return nil, nil, nil
+ return nil, nil
}
for _, job := range jobs {
job.Run = run
}
-
- current := jobs[0]
- if ctx.PathParam("job") != "" {
- jobID := ctx.PathParamInt64("job")
- current, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunJobByRunAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
- return nil, nil, nil
- }
- current.Run = run
- }
-
- return run, jobs, current
+ return run, jobs
}
func ArtifactsDeleteView(ctx *context_module.Context) {
- runID := getRunID(ctx)
- artifactName := ctx.PathParam("artifact_name")
-
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
- if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
+ artifactName := ctx.PathParam("artifact_name")
+ if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
ctx.ServerError("SetArtifactNeedDelete", err)
return
}
@@ -692,19 +689,12 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
}
func ArtifactsDownloadView(ctx *context_module.Context) {
- runID := getRunID(ctx)
- artifactName := ctx.PathParam("artifact_name")
-
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- if errors.Is(err, util.ErrNotExist) {
- ctx.HTTPError(http.StatusNotFound, err.Error())
- return
- }
- ctx.ServerError("GetRunByRepoAndID", err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
+ artifactName := ctx.PathParam("artifact_name")
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: run.ID,
ArtifactName: artifactName,
diff --git a/routers/web/web.go b/routers/web/web.go
index f09f1bde8f..6893e380df 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1737,8 +1737,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Any("/mail-preview", devtest.MailPreview)
m.Any("/mail-preview/*", devtest.MailPreviewRender)
m.Any("/{sub}", devtest.TmplCommon)
+ m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
- m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
+ m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
+ m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
})
}
diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl
index b3a52db1e2..46f040d8a6 100644
--- a/templates/devtest/repo-action-view.tmpl
+++ b/templates/devtest/repo-action-view.tmpl
@@ -1,14 +1,14 @@
{{template "base/head" .}}
-
-
Run:CanCancel
-
Run:CanApprove
-
Run:CanRerun
+
{{template "repo/actions/view_component" (dict
"RunID" (or .RunID 10)
"JobID" (or .JobID 0)
- "ActionsURL" (print AppSubUrl "/devtest/actions-mock")
+ "ActionsURL" (print AppSubUrl "/devtest/repo-action-view")
)}}
{{template "base/footer" .}}
diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl
index 9fa77d8a3f..59b5c9cbf9 100644
--- a/templates/repo/actions/view_component.tmpl
+++ b/templates/repo/actions/view_component.tmpl
@@ -12,6 +12,10 @@
data-locale-runs-commit="{{ctx.Locale.Tr "actions.runs.commit"}}"
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
data-locale-runs-workflow-graph="{{ctx.Locale.Tr "actions.runs.workflow_graph"}}"
+ data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}"
+ data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}"
+ data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"
+ data-locale-total-duration="{{ctx.Locale.Tr "actions.runs.total_duration"}}"
data-locale-status-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}"
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"
diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue
new file mode 100644
index 0000000000..9d8ee0dbde
--- /dev/null
+++ b/web_src/js/components/ActionRunJobView.vue
@@ -0,0 +1,684 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ jobStep.summary }}
+
{{ jobStep.duration }}
+
+
+
+
+
+
+
+
+
diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue
new file mode 100644
index 0000000000..2d79a82288
--- /dev/null
+++ b/web_src/js/components/ActionRunSummaryView.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+ {{ locale.triggeredVia.replace('%s', run.triggerEvent) }}
+ •
+
+
+
+ {{ locale.status[run.status] }}
+ {{ locale.totalDuration }} {{ run.duration || '–' }}
+
+
+
+
+
+
diff --git a/web_src/js/components/RepoActionView.test.ts b/web_src/js/components/ActionRunView.test.ts
similarity index 93%
rename from web_src/js/components/RepoActionView.test.ts
rename to web_src/js/components/ActionRunView.test.ts
index a0855ecf24..f0e3fa090a 100644
--- a/web_src/js/components/RepoActionView.test.ts
+++ b/web_src/js/components/ActionRunView.test.ts
@@ -1,4 +1,4 @@
-import {createLogLineMessage, parseLogLineCommand} from './RepoActionView.vue';
+import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts';
test('LogLineMessage', () => {
const cases = {
diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts
new file mode 100644
index 0000000000..6ae09a46fe
--- /dev/null
+++ b/web_src/js/components/ActionRunView.ts
@@ -0,0 +1,154 @@
+import {createElementFromAttrs} from '../utils/dom.ts';
+import {renderAnsi} from '../render/ansi.ts';
+import {reactive} from 'vue';
+import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
+import type {IntervalId} from '../types.ts';
+import {POST} from '../modules/fetch.ts';
+
+// How GitHub Actions logs work:
+// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
+// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
+// * The reported logs are the processed logs.
+// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
+const LogLinePrefixCommandMap: Record
= {
+ '::group::': 'group',
+ '##[group]': 'group',
+ '::endgroup::': 'endgroup',
+ '##[endgroup]': 'endgroup',
+
+ '##[error]': 'error',
+ '[command]': 'command',
+
+ // https://github.com/actions/toolkit/blob/master/docs/commands.md
+ // https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
+ '::add-matcher::': 'hidden',
+ '##[add-matcher]': 'hidden',
+ '::remove-matcher': 'hidden', // it has arguments
+};
+
+export type LogLine = {
+ index: number;
+ timestamp: number;
+ message: string;
+};
+
+export type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'hidden';
+export type LogLineCommand = {
+ name: LogLineCommandName,
+ prefix: string,
+};
+
+export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
+ // TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
+ for (const prefix of Object.keys(LogLinePrefixCommandMap)) {
+ if (line.message.startsWith(prefix)) {
+ return {name: LogLinePrefixCommandMap[prefix], prefix};
+ }
+ }
+ return null;
+}
+
+export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
+ const logMsgAttrs = {class: 'log-msg'};
+ if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd?.name}`; // make it easier to add styles to some commands like "error"
+
+ // TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
+ // it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
+ const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
+
+ const logMsg = createElementFromAttrs('span', logMsgAttrs);
+ logMsg.innerHTML = renderAnsi(msgContent);
+ return logMsg;
+}
+
+export function createEmptyActionsRun(): ActionsRun {
+ return {
+ link: '',
+ title: '',
+ titleHTML: '',
+ status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
+ canCancel: false,
+ canApprove: false,
+ canRerun: false,
+ canRerunFailed: false,
+ canDeleteArtifact: false,
+ done: false,
+ workflowID: '',
+ workflowLink: '',
+ isSchedule: false,
+ duration: '',
+ triggeredAt: 0,
+ triggerEvent: '',
+ jobs: [] as Array,
+ commit: {
+ localeCommit: '',
+ localePushedBy: '',
+ shortSHA: '',
+ link: '',
+ pusher: {
+ displayName: '',
+ link: '',
+ },
+ branch: {
+ name: '',
+ link: '',
+ isDeleted: false,
+ },
+ },
+ };
+}
+
+export function createActionRunViewStore(actionsUrl: string, runId: number) {
+ let loadingAbortController: AbortController | null = null;
+ let intervalID: IntervalId | null = null;
+ const viewData = reactive({
+ currentRun: createEmptyActionsRun(),
+ runArtifacts: [] as Array,
+ });
+ const loadCurrentRun = async () => {
+ if (loadingAbortController) return;
+ const abortController = new AbortController();
+ loadingAbortController = abortController;
+ try {
+ const url = `${actionsUrl}/runs/${runId}`;
+ const resp = await POST(url, {signal: abortController.signal, data: {}});
+ const runResp = await resp.json();
+ if (loadingAbortController !== abortController) return;
+
+ viewData.runArtifacts = runResp.artifacts || [];
+ viewData.currentRun = runResp.state.run;
+ // clear the interval timer if the job is done
+ if (viewData.currentRun.done && intervalID) {
+ clearInterval(intervalID);
+ 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 {
+ if (loadingAbortController === abortController) loadingAbortController = null;
+ }
+ };
+
+ return reactive({
+ viewData,
+
+ async startPollingCurrentRun() {
+ await loadCurrentRun();
+ intervalID = setInterval(() => loadCurrentRun(), 1000);
+ },
+ async forceReloadCurrentRun() {
+ loadingAbortController?.abort();
+ loadingAbortController = null;
+ await loadCurrentRun();
+ },
+ stopPollingCurrentRun() {
+ if (!intervalID) return;
+ clearInterval(intervalID);
+ intervalID = null;
+ },
+ });
+}
+
+export type ActionRunViewStore = ReturnType;
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 3c4f6d5273..4ced86b523 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -1,497 +1,40 @@
-
@@ -504,9 +47,6 @@ export default defineComponent({
-
@@ -553,8 +93,16 @@ export default defineComponent({
+
+
+
+ {{ locale.summary }}
+
+
+
-
- {{ locale.artifactsTitle }}
-
+
+
-
@@ -594,82 +141,19 @@ export default defineComponent({
-
+
-
-
-
-
-
-
-
-
-
-
-
-
{{ jobStep.summary }}
-
{{ jobStep.duration }}
-
-
-
-
-
-
@@ -750,11 +234,9 @@ export default defineComponent({
}
}
-.job-artifacts-title {
- font-size: 18px;
- margin-top: 16px;
- padding: 16px 10px 0 20px;
- border-top: 1px solid var(--color-secondary);
+.left-list-header {
+ font-size: 12px;
+ color: var(--color-grey);
}
.job-artifacts-item {
@@ -766,7 +248,7 @@ export default defineComponent({
}
.job-artifacts-list {
- padding-left: 12px;
+ padding-left: 4px;
list-style: none;
}
@@ -777,7 +259,7 @@ export default defineComponent({
}
.job-brief-item {
- padding: 10px;
+ padding: 6px 10px;
border-radius: var(--border-radius);
text-decoration: none;
display: flex;
@@ -861,111 +343,6 @@ export default defineComponent({
/* end fomantic button overrides */
-/* begin fomantic dropdown menu overrides */
-
-.action-view-right .ui.dropdown .menu {
- background: var(--color-console-menu-bg);
- border-color: var(--color-console-menu-border);
-}
-
-.action-view-right .ui.dropdown .menu > .item {
- color: var(--color-console-fg);
-}
-
-.action-view-right .ui.dropdown .menu > .item:hover {
- color: var(--color-console-fg);
- background: var(--color-console-hover-bg);
-}
-
-.action-view-right .ui.dropdown .menu > .item:active {
- color: var(--color-console-fg);
- background: var(--color-console-active-bg);
-}
-
-.action-view-right .ui.dropdown .menu > .divider {
- border-top-color: var(--color-console-menu-border);
-}
-
-.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
- background: var(--color-console-menu-bg);
- box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
-}
-
-/* end fomantic dropdown menu overrides */
-
-.job-info-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0 12px;
- position: sticky;
- top: 0;
- height: 60px;
- z-index: 1; /* above .job-step-container */
- background: var(--color-console-bg);
- border-radius: 3px;
-}
-
-.job-info-header:has(+ .job-step-container) {
- border-radius: var(--border-radius) var(--border-radius) 0 0;
-}
-
-.job-info-header .job-info-header-title {
- color: var(--color-console-fg);
- font-size: 16px;
- margin: 0;
-}
-
-.job-info-header .job-info-header-detail {
- color: var(--color-console-fg-subtle);
- font-size: 12px;
-}
-
-.job-info-header-left {
- flex: 1;
-}
-
-.job-step-container {
- max-height: 100%;
- border-radius: 0 0 var(--border-radius) var(--border-radius);
- border-top: 1px solid var(--color-console-border);
- z-index: 0;
-}
-
-.job-step-container .job-step-summary {
- padding: 5px 10px;
- display: flex;
- align-items: center;
- border-radius: var(--border-radius);
-}
-
-.job-step-container .job-step-summary.step-expandable {
- cursor: pointer;
-}
-
-.job-step-container .job-step-summary.step-expandable:hover {
- color: var(--color-console-fg);
- background: var(--color-console-hover-bg);
-}
-
-.job-step-container .job-step-summary .step-summary-msg {
- flex: 1;
-}
-
-.job-step-container .job-step-summary .step-summary-duration {
- margin-left: 16px;
-}
-
-.job-step-container .job-step-summary.selected {
- color: var(--color-console-fg);
- background-color: var(--color-console-active-bg);
- position: sticky;
- top: 60px;
- /* workaround ansi_up issue related to faintStyle generating a CSS stacking context via `opacity`
- inline style which caused such elements to render above the .job-step-summary header. */
- z-index: 1;
-}
-
@media (max-width: 767.98px) {
.action-view-body {
flex-direction: column;
@@ -978,101 +355,3 @@ export default defineComponent({
}
}
-
-
diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue
index 571db2cd03..c311b87d98 100644
--- a/web_src/js/components/WorkflowGraph.vue
+++ b/web_src/js/components/WorkflowGraph.vue
@@ -40,7 +40,6 @@ interface StoredState {
const props = defineProps<{
jobs: ActionsJob[];
- currentJobId: number;
runLink: string;
workflowId: string;
}>()
@@ -86,9 +85,7 @@ const saveState = () => {
};
loadSavedState();
-watch([translateX, translateY, scale], () => {
- debounce(500, saveState);
-})
+watch([translateX, translateY, scale], debounce(500, saveState))
const nodeWidth = computed(() => {
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
@@ -588,8 +585,6 @@ function computeJobLevels(jobs: ActionsJob[]): Map
{
}
function onNodeClick(job: JobNode, event: MouseEvent) {
- if (job.id === props.currentJobId) return;
-
const link = `${props.runLink}/jobs/${job.id}`;
if (event.ctrlKey || event.metaKey) {
window.open(link, '_blank');
@@ -652,7 +647,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
@@ -734,18 +728,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
keySplines="0.4, 0, 0.2, 1"
/>
-
-
- ← {{ job.needs.length }} deps
-
@@ -769,10 +751,9 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 20px;
- padding: 6px 12px;
- border-bottom: 1px solid var(--color-secondary-alpha-20);
- gap: 15px;
+ padding: 8px 14px;
+ background: var(--color-box-header);
+ gap: 20px;
flex-wrap: wrap;
}
@@ -786,7 +767,10 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
}
.graph-stats {
- color: var(--color-text-light-2);
+ display: flex;
+ align-items: baseline;
+ column-gap: 8px;
+ color: var(--color-text-light-1);
font-size: 13px;
white-space: nowrap;
}
@@ -805,7 +789,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
.graph-container {
overflow: auto;
padding: 12px;
- border-radius: 8px;
cursor: grab;
min-height: 300px;
max-height: 600px;
@@ -844,14 +827,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
z-index: 10;
}
-.job-node-group.current-job {
- cursor: default;
-}
-
-.job-node-group.current-job .job-rect {
- filter: drop-shadow(0 0 8px color-mix(in srgb, var(--color-primary) 30%, transparent));
-}
-
.job-name {
max-width: calc(var(--node-width, 150px) - 50px);
text-overflow: ellipsis;
@@ -862,8 +837,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
}
.job-status,
-.job-duration,
-.job-deps-label {
+.job-duration {
user-select: none;
pointer-events: none;
}
diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts
index 7fa0461786..1467250c1a 100644
--- a/web_src/js/features/repo-actions.ts
+++ b/web_src/js/features/repo-actions.ts
@@ -13,7 +13,7 @@ export function initRepositoryActionView() {
const view = createApp(RepoActionView, {
runId: parseInt(el.getAttribute('data-run-id')!),
jobId: parseInt(el.getAttribute('data-job-id')!),
- actionsURL: el.getAttribute('data-actions-url'),
+ actionsUrl: el.getAttribute('data-actions-url'),
locale: {
approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'),
@@ -24,6 +24,10 @@ export function initRepositoryActionView() {
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
+ summary: el.getAttribute('data-locale-summary'),
+ allJobs: el.getAttribute('data-locale-all-jobs'),
+ triggeredVia: el.getAttribute('data-locale-triggered-via'),
+ totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
diff --git a/web_src/js/modules/gitea-actions.ts b/web_src/js/modules/gitea-actions.ts
index 5cc3e096ec..fb8639458e 100644
--- a/web_src/js/modules/gitea-actions.ts
+++ b/web_src/js/modules/gitea-actions.ts
@@ -1,6 +1,41 @@
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
export type ActionsRunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
+export type ActionsRun = {
+ link: string,
+ title: string,
+ titleHTML: string,
+ status: ActionsRunStatus,
+ canCancel: boolean,
+ canApprove: boolean,
+ canRerun: boolean,
+ canRerunFailed: boolean,
+ canDeleteArtifact: boolean,
+ done: boolean,
+ workflowID: string,
+ workflowLink: string,
+ isSchedule: boolean,
+ duration: string,
+ triggeredAt: number,
+ triggerEvent: string,
+ jobs: Array,
+ commit: {
+ localeCommit: string,
+ localePushedBy: string,
+ shortSHA: string,
+ link: string,
+ pusher: {
+ displayName: string,
+ link: string,
+ },
+ branch: {
+ name: string,
+ link: string,
+ isDeleted: boolean,
+ },
+ },
+};
+
export type ActionsJob = {
id: number;
jobId: string;
@@ -10,3 +45,8 @@ export type ActionsJob = {
needs?: string[];
duration: string;
};
+
+export type ActionsArtifact = {
+ name: string;
+ status: string;
+};
diff --git a/web_src/js/webcomponents/relative-time.ts b/web_src/js/webcomponents/relative-time.ts
index 80d96652d7..fdff909c2f 100644
--- a/web_src/js/webcomponents/relative-time.ts
+++ b/web_src/js/webcomponents/relative-time.ts
@@ -322,6 +322,10 @@ class RelativeTime extends HTMLElement {
return this.getAttribute('prefix') ?? (this.format === 'datetime' ? '' : 'on');
}
+ set prefix(v: string) {
+ this.setAttribute('prefix', v);
+ }
+
get #thresholdMs(): number {
const ms = parseDurationMs(this.getAttribute('threshold') ?? '');
return ms >= 0 ? ms : 30 * 86400000;
@@ -355,6 +359,10 @@ class RelativeTime extends HTMLElement {
return this.getAttribute('datetime') || '';
}
+ set datetime(v: string) {
+ this.setAttribute('datetime', v);
+ }
+
get date(): Date | null {
const parsed = Date.parse(this.datetime);
return Number.isNaN(parsed) ? null : new Date(parsed);