Add summary to action runs view (#36883)
When opening a Actions run without a job in the path (`/actions/runs/{run}`),
show a run summary.
---------
Signed-off-by: Nicolas <bircni@icloud.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -170,10 +170,10 @@ type ActionArtifactMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
|
// 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)
|
arts := make([]*ActionArtifactMeta, 0, 10)
|
||||||
return arts, db.GetEngine(ctx).Table("action_artifact").
|
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").
|
GroupBy("artifact_name").
|
||||||
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
|
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
|
||||||
Find(&arts)
|
Find(&arts)
|
||||||
|
|||||||
@@ -3709,6 +3709,10 @@
|
|||||||
"actions.runs.not_done": "This workflow run is not done.",
|
"actions.runs.not_done": "This workflow run is not done.",
|
||||||
"actions.runs.view_workflow_file": "View workflow file",
|
"actions.runs.view_workflow_file": "View workflow file",
|
||||||
"actions.runs.workflow_graph": "Workflow Graph",
|
"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": "Disable Workflow",
|
||||||
"actions.workflow.disable_success": "Workflow '%s' disabled successfully.",
|
"actions.workflow.disable_success": "Workflow '%s' disabled successfully.",
|
||||||
"actions.workflow.enable": "Enable Workflow",
|
"actions.workflow.enable": "Enable Workflow",
|
||||||
|
|||||||
@@ -59,15 +59,14 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func MockActionsView(ctx *context.Context) {
|
func MockActionsView(ctx *context.Context) {
|
||||||
ctx.Data["RunID"] = ctx.PathParam("run")
|
ctx.Data["RunID"] = ctx.PathParamInt64("run")
|
||||||
ctx.Data["JobID"] = ctx.PathParam("job")
|
ctx.Data["JobID"] = ctx.PathParamInt64("job")
|
||||||
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
|
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
|
||||||
}
|
}
|
||||||
|
|
||||||
func MockActionsRunsJobs(ctx *context.Context) {
|
func MockActionsRunsJobs(ctx *context.Context) {
|
||||||
runID := ctx.PathParamInt64("run")
|
runID := ctx.PathParamInt64("run")
|
||||||
|
|
||||||
req := web.GetForm(ctx).(*actions.ViewRequest)
|
|
||||||
resp := &actions.ViewResponse{}
|
resp := &actions.ViewResponse{}
|
||||||
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
||||||
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
|
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.CanDeleteArtifact = true
|
||||||
resp.State.Run.WorkflowID = "workflow-id"
|
resp.State.Run.WorkflowID = "workflow-id"
|
||||||
resp.State.Run.WorkflowLink = "./workflow-link"
|
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{
|
resp.State.Run.Commit = actions.ViewCommit{
|
||||||
ShortSha: "ccccdddd",
|
ShortSha: "ccccdddd",
|
||||||
Link: "./commit-link",
|
Link: "./commit-link",
|
||||||
@@ -140,6 +142,17 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
Needs: []string{"job-100", "job-101"},
|
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
|
var mockLogOptions []generateMockStepsLogOptions
|
||||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
||||||
Summary: "step 0 (mock slow)",
|
Summary: "step 0 (mock slow)",
|
||||||
@@ -163,7 +176,6 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})
|
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})
|
||||||
|
|
||||||
if len(req.LogCursors) == 0 {
|
if len(req.LogCursors) == 0 {
|
||||||
ctx.JSON(http.StatusOK, resp)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,5 +201,4 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
} else {
|
} else {
|
||||||
time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
|
time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, resp)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-113
@@ -38,41 +38,54 @@ import (
|
|||||||
"github.com/nektos/act/pkg/model"
|
"github.com/nektos/act/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getRunID(ctx *context_module.Context) int64 {
|
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 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 run param is "latest", get the latest run id
|
||||||
if ctx.PathParam("run") == "latest" {
|
if ctx.PathParam("run") == "latest" {
|
||||||
if run, _ := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID); run != nil {
|
run, err = actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID)
|
||||||
return run.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 ctx.PathParamInt64("run")
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
func View(ctx *context_module.Context) {
|
func View(ctx *context_module.Context) {
|
||||||
ctx.Data["PageIsActions"] = true
|
ctx.Data["PageIsActions"] = true
|
||||||
runID := getRunID(ctx)
|
run := getCurrentRunByPathParam(ctx)
|
||||||
|
|
||||||
_, _, current := getRunJobsAndCurrentJob(ctx, runID)
|
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ctx.Data["RunID"] = run.ID
|
||||||
ctx.Data["RunID"] = runID
|
ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view)
|
||||||
ctx.Data["JobID"] = current.ID
|
|
||||||
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
|
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, tplViewActions)
|
ctx.HTML(http.StatusOK, tplViewActions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ViewWorkflowFile(ctx *context_module.Context) {
|
func ViewWorkflowFile(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run := getCurrentRunByPathParam(ctx)
|
||||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
if ctx.Written() {
|
||||||
if err != nil {
|
|
||||||
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
|
|
||||||
return errors.Is(err, util.ErrNotExist)
|
|
||||||
}, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA)
|
commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.NotFoundOrServerError("GetCommit", func(err error) bool {
|
ctx.NotFoundOrServerError("GetCommit", func(err error) bool {
|
||||||
@@ -130,6 +143,10 @@ type ViewResponse struct {
|
|||||||
IsSchedule bool `json:"isSchedule"`
|
IsSchedule bool `json:"isSchedule"`
|
||||||
Jobs []*ViewJob `json:"jobs"`
|
Jobs []*ViewJob `json:"jobs"`
|
||||||
Commit ViewCommit `json:"commit"`
|
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"`
|
} `json:"run"`
|
||||||
CurrentJob struct {
|
CurrentJob struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -190,11 +207,7 @@ type ViewStepLogLine struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
|
func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
|
||||||
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -209,10 +222,7 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifact
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ViewPost(ctx *context_module.Context) {
|
func ViewPost(ctx *context_module.Context) {
|
||||||
req := web.GetForm(ctx).(*ViewRequest)
|
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||||
runID := getRunID(ctx)
|
|
||||||
|
|
||||||
run, jobs, current := getRunJobsAndCurrentJob(ctx, runID)
|
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -221,14 +231,24 @@ func ViewPost(ctx *context_module.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
|
||||||
resp := &ViewResponse{}
|
resp := &ViewResponse{}
|
||||||
resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runID)
|
fillViewRunResponseSummary(ctx, resp, run, jobs)
|
||||||
if err != nil {
|
if ctx.Written() {
|
||||||
if !errors.Is(err, util.ErrNotExist) {
|
|
||||||
ctx.ServerError("getActionsViewArtifacts", err)
|
|
||||||
return
|
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 {
|
||||||
|
ctx.ServerError("getActionsViewArtifacts", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// the title for the "run" is from the commit message
|
// the title for the "run" is from the commit message
|
||||||
@@ -289,6 +309,20 @@ func ViewPost(ctx *context_module.Context) {
|
|||||||
Pusher: pusher,
|
Pusher: pusher,
|
||||||
Branch: branch,
|
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
|
var task *actions_model.ActionTask
|
||||||
if current.TaskID > 0 {
|
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.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...)
|
||||||
resp.Logs.StepsLog = append(resp.Logs.StepsLog, logs...)
|
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) {
|
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
|
// Rerun will rerun jobs in the given run
|
||||||
// If jobIDStr is a blank string, it means rerun all jobs
|
// If jobIDStr is a blank string, it means rerun all jobs
|
||||||
func Rerun(ctx *context_module.Context) {
|
func Rerun(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||||
|
|
||||||
run, jobs, currentJob := getRunJobsAndCurrentJob(ctx, runID)
|
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !checkRunRerunAllowed(ctx, run) {
|
if !checkRunRerunAllowed(ctx, run) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentJob, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
|
||||||
|
if hasPathParam && currentJob == nil {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var jobsToRerun []*actions_model.ActionRunJob
|
var jobsToRerun []*actions_model.ActionRunJob
|
||||||
if ctx.PathParam("job") != "" {
|
if currentJob != nil {
|
||||||
jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
|
jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
|
||||||
} else {
|
} else {
|
||||||
jobsToRerun = jobs
|
jobsToRerun = jobs
|
||||||
@@ -454,13 +489,10 @@ func Rerun(ctx *context_module.Context) {
|
|||||||
|
|
||||||
// RerunFailed reruns all failed jobs in the given run
|
// RerunFailed reruns all failed jobs in the given run
|
||||||
func RerunFailed(ctx *context_module.Context) {
|
func RerunFailed(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||||
|
|
||||||
run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
|
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !checkRunRerunAllowed(ctx, run) {
|
if !checkRunRerunAllowed(ctx, run) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -474,18 +506,13 @@ func RerunFailed(ctx *context_module.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Logs(ctx *context_module.Context) {
|
func Logs(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run := getCurrentRunByPathParam(ctx)
|
||||||
jobID := ctx.PathParamInt64("job")
|
if ctx.Written() {
|
||||||
|
|
||||||
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
|
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 {
|
ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool {
|
||||||
return errors.Is(err, util.ErrNotExist)
|
return errors.Is(err, util.ErrNotExist)
|
||||||
}, err)
|
}, err)
|
||||||
@@ -493,9 +520,7 @@ func Logs(ctx *context_module.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Cancel(ctx *context_module.Context) {
|
func Cancel(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||||
|
|
||||||
run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
|
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -529,9 +554,11 @@ func Cancel(ctx *context_module.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Approve(ctx *context_module.Context) {
|
func Approve(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run := getCurrentRunByPathParam(ctx)
|
||||||
|
if ctx.Written() {
|
||||||
approveRuns(ctx, []int64{runID})
|
return
|
||||||
|
}
|
||||||
|
approveRuns(ctx, []int64{run.ID})
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -606,16 +633,8 @@ func approveRuns(ctx *context_module.Context, runIDs []int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Delete(ctx *context_module.Context) {
|
func Delete(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run := getCurrentRunByPathParam(ctx)
|
||||||
repoID := ctx.Repo.Repository.ID
|
if ctx.Written() {
|
||||||
|
|
||||||
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, util.ErrNotExist) {
|
|
||||||
ctx.JSONErrorNotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.ServerError("GetRunByRepoAndID", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,59 +651,37 @@ func Delete(ctx *context_module.Context) {
|
|||||||
ctx.JSONOK()
|
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).
|
// getRunJobs loads the run and its jobs for runID
|
||||||
// Any error will be written to the ctx, and nils are returned in that case.
|
// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil.
|
||||||
func getRunJobsAndCurrentJob(ctx *context_module.Context, runID int64) (*actions_model.ActionRun, []*actions_model.ActionRunJob, *actions_model.ActionRunJob) {
|
func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) {
|
||||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
run := getCurrentRunByPathParam(ctx)
|
||||||
if err != nil {
|
if ctx.Written() {
|
||||||
ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
|
return nil, nil
|
||||||
return errors.Is(err, util.ErrNotExist)
|
|
||||||
}, err)
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
}
|
||||||
run.Repo = ctx.Repo.Repository
|
run.Repo = ctx.Repo.Repository
|
||||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetRunJobsByRunID", err)
|
ctx.ServerError("GetRunJobsByRunID", err)
|
||||||
return nil, nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if len(jobs) == 0 {
|
if len(jobs) == 0 {
|
||||||
ctx.NotFound(nil)
|
ctx.NotFound(nil)
|
||||||
return nil, nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
job.Run = run
|
job.Run = run
|
||||||
}
|
}
|
||||||
|
return run, jobs
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ArtifactsDeleteView(ctx *context_module.Context) {
|
func ArtifactsDeleteView(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run := getCurrentRunByPathParam(ctx)
|
||||||
artifactName := ctx.PathParam("artifact_name")
|
if ctx.Written() {
|
||||||
|
|
||||||
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
|
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)
|
ctx.ServerError("SetArtifactNeedDelete", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -692,19 +689,12 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ArtifactsDownloadView(ctx *context_module.Context) {
|
func ArtifactsDownloadView(ctx *context_module.Context) {
|
||||||
runID := getRunID(ctx)
|
run := getCurrentRunByPathParam(ctx)
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
artifactName := ctx.PathParam("artifact_name")
|
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)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
||||||
RunID: run.ID,
|
RunID: run.ID,
|
||||||
ArtifactName: artifactName,
|
ArtifactName: artifactName,
|
||||||
|
|||||||
+3
-1
@@ -1737,8 +1737,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
m.Any("/mail-preview", devtest.MailPreview)
|
m.Any("/mail-preview", devtest.MailPreview)
|
||||||
m.Any("/mail-preview/*", devtest.MailPreviewRender)
|
m.Any("/mail-preview/*", devtest.MailPreviewRender)
|
||||||
m.Any("/{sub}", devtest.TmplCommon)
|
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.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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{{template "base/head" .}}
|
{{template "base/head" .}}
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="tw-flex tw-justify-center tw-items-center tw-gap-5">
|
<div class="flex-text-block tw-justify-center tw-gap-5">
|
||||||
<a href="/devtest/repo-action-view/runs/10/jobs/0">Run:CanCancel</a>
|
<a href="/devtest/repo-action-view/runs/10">Run:CanCancel</a>
|
||||||
<a href="/devtest/repo-action-view/runs/20/jobs/1">Run:CanApprove</a>
|
<a href="/devtest/repo-action-view/runs/20">Run:CanApprove</a>
|
||||||
<a href="/devtest/repo-action-view/runs/30/jobs/2">Run:CanRerun</a>
|
<a href="/devtest/repo-action-view/runs/30">Run:CanRerun</a>
|
||||||
</div>
|
</div>
|
||||||
{{template "repo/actions/view_component" (dict
|
{{template "repo/actions/view_component" (dict
|
||||||
"RunID" (or .RunID 10)
|
"RunID" (or .RunID 10)
|
||||||
"JobID" (or .JobID 0)
|
"JobID" (or .JobID 0)
|
||||||
"ActionsURL" (print AppSubUrl "/devtest/actions-mock")
|
"ActionsURL" (print AppSubUrl "/devtest/repo-action-view")
|
||||||
)}}
|
)}}
|
||||||
</div>
|
</div>
|
||||||
{{template "base/footer" .}}
|
{{template "base/footer" .}}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
data-locale-runs-commit="{{ctx.Locale.Tr "actions.runs.commit"}}"
|
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-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
|
||||||
data-locale-runs-workflow-graph="{{ctx.Locale.Tr "actions.runs.workflow_graph"}}"
|
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-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}"
|
||||||
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
|
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
|
||||||
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"
|
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"
|
||||||
|
|||||||
@@ -0,0 +1,684 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||||
|
import {SvgIcon} from '../svg.ts';
|
||||||
|
import ActionRunStatus from './ActionRunStatus.vue';
|
||||||
|
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||||
|
import {formatDatetime} from '../utils/time.ts';
|
||||||
|
import {POST} from '../modules/fetch.ts';
|
||||||
|
import type {IntervalId} from '../types.ts';
|
||||||
|
import {toggleFullScreen} from '../utils.ts';
|
||||||
|
import {localUserSettings} from '../modules/user-settings.ts';
|
||||||
|
import type {ActionsArtifact, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||||
|
import {
|
||||||
|
type ActionRunViewStore,
|
||||||
|
createLogLineMessage,
|
||||||
|
type LogLine,
|
||||||
|
type LogLineCommand,
|
||||||
|
parseLogLineCommand
|
||||||
|
} from './ActionRunView.ts';
|
||||||
|
|
||||||
|
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
// only check whether bottom is in viewport, because the log element can be a log group which is usually tall
|
||||||
|
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = {
|
||||||
|
summary: string,
|
||||||
|
duration: string,
|
||||||
|
status: ActionsRunStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobStepState = {
|
||||||
|
cursor: string|null,
|
||||||
|
expanded: boolean,
|
||||||
|
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepContainerElement = HTMLElement & {
|
||||||
|
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
||||||
|
// then the following batches of logs should still use the same group (active logs container).
|
||||||
|
// maybe it can be refactored to decouple from the HTML element in the future.
|
||||||
|
_stepLogsActiveContainer?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocaleStorageOptions = {
|
||||||
|
autoScroll: boolean;
|
||||||
|
expandRunning: boolean;
|
||||||
|
actionsLogShowSeconds: boolean;
|
||||||
|
actionsLogShowTimestamps: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CurrentJob = {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
steps: Array<Step>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JobData = {
|
||||||
|
artifacts: Array<ActionsArtifact>;
|
||||||
|
state: {
|
||||||
|
run: ActionsRun;
|
||||||
|
currentJob: CurrentJob;
|
||||||
|
},
|
||||||
|
logs: {
|
||||||
|
stepsLog?: Array<{
|
||||||
|
step: number;
|
||||||
|
cursor: string | null;
|
||||||
|
started: number;
|
||||||
|
lines: LogLine[];
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ActionRunJobView',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
store: ActionRunViewStore,
|
||||||
|
runId: number;
|
||||||
|
jobId: number;
|
||||||
|
actionsUrl: string;
|
||||||
|
locale: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
const store = props.store;
|
||||||
|
const {currentRun: run} = toRefs(store.viewData);
|
||||||
|
|
||||||
|
const defaultViewOptions: LocaleStorageOptions = {
|
||||||
|
autoScroll: true,
|
||||||
|
expandRunning: false,
|
||||||
|
actionsLogShowSeconds: false,
|
||||||
|
actionsLogShowTimestamps: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedViewOptions = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
|
||||||
|
const {autoScroll, expandRunning, actionsLogShowSeconds, actionsLogShowTimestamps} = savedViewOptions;
|
||||||
|
|
||||||
|
// internal state
|
||||||
|
let loadingAbortController: AbortController | null = null;
|
||||||
|
let intervalID: IntervalId | null = null;
|
||||||
|
|
||||||
|
const currentJobStepsStates = ref<Array<JobStepState>>([]);
|
||||||
|
const menuVisible = ref(false);
|
||||||
|
const isFullScreen = ref(false);
|
||||||
|
const timeVisible = ref<Record<string, boolean>>({
|
||||||
|
'log-time-stamp': actionsLogShowTimestamps,
|
||||||
|
'log-time-seconds': actionsLogShowSeconds,
|
||||||
|
});
|
||||||
|
const optionAlwaysAutoScroll = ref(autoScroll);
|
||||||
|
const optionAlwaysExpandRunning = ref(expandRunning);
|
||||||
|
const currentJob = ref<CurrentJob>({
|
||||||
|
title: '',
|
||||||
|
detail: '',
|
||||||
|
steps: [] as Array<Step>,
|
||||||
|
});
|
||||||
|
const stepsContainer = ref<HTMLElement | null>(null);
|
||||||
|
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
||||||
|
|
||||||
|
watch(optionAlwaysAutoScroll, () => {
|
||||||
|
saveLocaleStorageOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(optionAlwaysExpandRunning, () => {
|
||||||
|
saveLocaleStorageOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 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 loadJob();
|
||||||
|
|
||||||
|
// auto-scroll to the bottom of the log group when it is opened
|
||||||
|
// "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
|
||||||
|
addDelegatedEventListener(elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
|
||||||
|
if (!optionAlwaysAutoScroll.value) return;
|
||||||
|
const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
|
||||||
|
elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
intervalID = setInterval(() => void loadJob(), 1000);
|
||||||
|
document.body.addEventListener('click', closeDropdown);
|
||||||
|
void hashChangeListener();
|
||||||
|
window.addEventListener('hashchange', hashChangeListener);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.body.removeEventListener('click', closeDropdown);
|
||||||
|
window.removeEventListener('hashchange', hashChangeListener);
|
||||||
|
// clear the interval timer when the component is unmounted
|
||||||
|
// even our page is rendered once, not spa style
|
||||||
|
if (intervalID) {
|
||||||
|
clearInterval(intervalID);
|
||||||
|
intervalID = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveLocaleStorageOptions() {
|
||||||
|
const opts: LocaleStorageOptions = {
|
||||||
|
autoScroll: optionAlwaysAutoScroll.value,
|
||||||
|
expandRunning: optionAlwaysExpandRunning.value,
|
||||||
|
actionsLogShowSeconds: timeVisible.value['log-time-seconds'],
|
||||||
|
actionsLogShowTimestamps: timeVisible.value['log-time-stamp'],
|
||||||
|
};
|
||||||
|
localUserSettings.setJsonObject('actions-view-options', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the job step logs container ('.job-step-logs')
|
||||||
|
function getJobStepLogsContainer(stepIndex: number): StepContainerElement {
|
||||||
|
return jobStepLogs.value[stepIndex] as StepContainerElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
|
||||||
|
function getActiveLogsContainer(stepIndex: number): StepContainerElement {
|
||||||
|
const el = getJobStepLogsContainer(stepIndex);
|
||||||
|
return el._stepLogsActiveContainer ?? el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// begin a log group
|
||||||
|
function beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||||
|
const el = getJobStepLogsContainer(stepIndex);
|
||||||
|
// Using "summary + details" is the best way to create a log group because it has built-in support for "toggle" and "accessibility".
|
||||||
|
// And it makes users can use "Ctrl+F" to search the logs without opening all log groups.
|
||||||
|
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
|
||||||
|
createLogLine(stepIndex, startTime, line, cmd),
|
||||||
|
);
|
||||||
|
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
|
||||||
|
function endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||||
|
const el = getJobStepLogsContainer(stepIndex);
|
||||||
|
el._stepLogsActiveContainer = undefined;
|
||||||
|
el.append(createLogLine(stepIndex, startTime, line, cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// show/hide the step logs for a step
|
||||||
|
function toggleStepLogs(idx: number) {
|
||||||
|
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
|
||||||
|
if (currentJobStepsStates.value[idx].expanded) {
|
||||||
|
void loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
|
||||||
|
} else if (currentJob.value.steps[idx].status === 'running') {
|
||||||
|
currentJobStepsStates.value[idx].manuallyCollapsed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
|
||||||
|
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
|
||||||
|
String(line.index),
|
||||||
|
);
|
||||||
|
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
||||||
|
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
|
||||||
|
);
|
||||||
|
const logMsg = createLogLineMessage(line, cmd);
|
||||||
|
const seconds = Math.floor(line.timestamp - startTime);
|
||||||
|
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
|
||||||
|
`${seconds}s`, // for "Show seconds"
|
||||||
|
);
|
||||||
|
|
||||||
|
toggleElem(logTimeStamp, timeVisible.value['log-time-stamp']);
|
||||||
|
toggleElem(logTimeSeconds, timeVisible.value['log-time-seconds']);
|
||||||
|
|
||||||
|
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
|
||||||
|
lineNum, logTimeStamp, logMsg, logTimeSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAutoScroll(stepIndex: number): boolean {
|
||||||
|
if (!optionAlwaysAutoScroll.value) return false;
|
||||||
|
const el = getJobStepLogsContainer(stepIndex);
|
||||||
|
// if the logs container is empty, then auto-scroll if the step is expanded
|
||||||
|
if (!el.lastChild) return currentJobStepsStates.value[stepIndex].expanded;
|
||||||
|
// use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
|
||||||
|
return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
||||||
|
for (const line of logLines) {
|
||||||
|
const cmd = parseLogLineCommand(line);
|
||||||
|
switch (cmd?.name) {
|
||||||
|
case 'hidden':
|
||||||
|
continue;
|
||||||
|
case 'group':
|
||||||
|
beginLogGroup(stepIndex, startTime, line, cmd);
|
||||||
|
continue;
|
||||||
|
case 'endgroup':
|
||||||
|
endLogGroup(stepIndex, startTime, line, cmd);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// the active logs container may change during the loop, for example: entering and leaving a group
|
||||||
|
const el = getActiveLogsContainer(stepIndex);
|
||||||
|
el.append(createLogLine(stepIndex, startTime, line, cmd));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJobData(abortController: AbortController): Promise<JobData> {
|
||||||
|
const logCursors = currentJobStepsStates.value.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 can be any type.
|
||||||
|
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
|
||||||
|
return {step: idx, cursor: it.cursor, expanded: it.expanded};
|
||||||
|
});
|
||||||
|
const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`;
|
||||||
|
const resp = await POST(url, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
data: {logCursors},
|
||||||
|
});
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJobForce() {
|
||||||
|
loadingAbortController?.abort();
|
||||||
|
loadingAbortController = null;
|
||||||
|
await loadJob();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJob() {
|
||||||
|
if (loadingAbortController) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
loadingAbortController = abortController;
|
||||||
|
try {
|
||||||
|
const runJobResp = await fetchJobData(abortController);
|
||||||
|
if (loadingAbortController !== abortController) return;
|
||||||
|
|
||||||
|
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
|
||||||
|
// Use consistent "store" operations to load/update the view data
|
||||||
|
store.viewData.runArtifacts = runJobResp.artifacts || [];
|
||||||
|
store.viewData.currentRun = runJobResp.state.run;
|
||||||
|
|
||||||
|
currentJob.value = runJobResp.state.currentJob;
|
||||||
|
const jobLogs = runJobResp.logs.stepsLog ?? [];
|
||||||
|
|
||||||
|
// sync the currentJobStepsStates to store the job step states
|
||||||
|
for (let i = 0; i < currentJob.value.steps.length; i++) {
|
||||||
|
const autoExpand = optionAlwaysExpandRunning.value && currentJob.value.steps[i].status === 'running';
|
||||||
|
if (!currentJobStepsStates.value[i]) {
|
||||||
|
// initial states for job steps
|
||||||
|
currentJobStepsStates.value[i] = {cursor: null, expanded: autoExpand, manuallyCollapsed: false};
|
||||||
|
} else {
|
||||||
|
// if the step is not manually collapsed by user, then auto-expand it if option is enabled
|
||||||
|
if (autoExpand && !currentJobStepsStates.value[i].manuallyCollapsed) {
|
||||||
|
currentJobStepsStates.value[i].expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// find the step indexes that need to auto-scroll
|
||||||
|
const autoScrollStepIndexes = new Map<number, boolean>();
|
||||||
|
for (const stepLogs of jobLogs) {
|
||||||
|
if (autoScrollStepIndexes.has(stepLogs.step)) continue;
|
||||||
|
autoScrollStepIndexes.set(stepLogs.step, shouldAutoScroll(stepLogs.step));
|
||||||
|
}
|
||||||
|
|
||||||
|
// append logs to the UI
|
||||||
|
for (const stepLogs of jobLogs) {
|
||||||
|
// save the cursor, it will be passed to backend next time
|
||||||
|
currentJobStepsStates.value[stepLogs.step].cursor = stepLogs.cursor;
|
||||||
|
appendLogs(stepLogs.step, stepLogs.started, stepLogs.lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto-scroll to the last log line of the last step
|
||||||
|
let autoScrollJobStepElement: StepContainerElement | undefined;
|
||||||
|
for (let stepIndex = 0; stepIndex < currentJob.value.steps.length; stepIndex++) {
|
||||||
|
if (!autoScrollStepIndexes.get(stepIndex)) continue;
|
||||||
|
autoScrollJobStepElement = getJobStepLogsContainer(stepIndex);
|
||||||
|
}
|
||||||
|
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
|
||||||
|
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
|
||||||
|
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear the interval timer if the job is done
|
||||||
|
if (run.value.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDone(status: ActionsRunStatus) {
|
||||||
|
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpandable(status: ActionsRunStatus) {
|
||||||
|
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
if (menuVisible.value) menuVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elStepsContainer(): HTMLElement {
|
||||||
|
return stepsContainer.value as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
||||||
|
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
|
||||||
|
for (const el of elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
|
||||||
|
toggleElem(el, timeVisible.value[`log-time-${type}`]);
|
||||||
|
}
|
||||||
|
saveLocaleStorageOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullScreenMode() {
|
||||||
|
isFullScreen.value = !isFullScreen.value;
|
||||||
|
toggleFullScreen('.action-view-right', isFullScreen.value, '.action-view-body');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashChangeListener() {
|
||||||
|
const selectedLogStep = window.location.hash;
|
||||||
|
if (!selectedLogStep) return;
|
||||||
|
const [_, step, _line] = selectedLogStep.split('-');
|
||||||
|
const stepNum = Number(step);
|
||||||
|
if (!currentJobStepsStates.value[stepNum]) return;
|
||||||
|
if (!currentJobStepsStates.value[stepNum].expanded && currentJobStepsStates.value[stepNum].cursor === null) {
|
||||||
|
currentJobStepsStates.value[stepNum].expanded = true;
|
||||||
|
// need to await for load job if the step log is loaded for the first time
|
||||||
|
// so logline can be selected by querySelector
|
||||||
|
await loadJob();
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
const logLine = elStepsContainer().querySelector(selectedLogStep);
|
||||||
|
if (!logLine) return;
|
||||||
|
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="job-info-header">
|
||||||
|
<div class="job-info-header-left gt-ellipsis">
|
||||||
|
<h3 class="job-info-header-title gt-ellipsis">
|
||||||
|
{{ currentJob.title }}
|
||||||
|
</h3>
|
||||||
|
<p class="job-info-header-detail">
|
||||||
|
{{ currentJob.detail }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="job-info-header-right">
|
||||||
|
<div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
|
||||||
|
<button class="ui button tw-px-3">
|
||||||
|
<SvgIcon name="octicon-gear" :size="18"/>
|
||||||
|
</button>
|
||||||
|
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
|
||||||
|
<a class="item" @click="toggleTimeDisplay('seconds')">
|
||||||
|
<i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||||
|
{{ locale.showLogSeconds }}
|
||||||
|
</a>
|
||||||
|
<a class="item" @click="toggleTimeDisplay('stamp')">
|
||||||
|
<i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||||
|
{{ locale.showTimeStamps }}
|
||||||
|
</a>
|
||||||
|
<a class="item" @click="toggleFullScreenMode()">
|
||||||
|
<i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||||
|
{{ locale.showFullScreen }}
|
||||||
|
</a>
|
||||||
|
<div class="divider"/>
|
||||||
|
<a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
|
||||||
|
<i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||||
|
{{ locale.logsAlwaysAutoScroll }}
|
||||||
|
</a>
|
||||||
|
<a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
|
||||||
|
<i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||||
|
{{ locale.logsAlwaysExpandRunning }}
|
||||||
|
</a>
|
||||||
|
<div class="divider"/>
|
||||||
|
<a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link + '/jobs/' + jobId + '/logs'" download>
|
||||||
|
<i class="icon"><SvgIcon name="octicon-download"/></i>
|
||||||
|
{{ locale.downloadLogs }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
||||||
|
<div class="job-step-container" ref="stepsContainer" v-show="currentJob.steps.length">
|
||||||
|
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||||
|
<div
|
||||||
|
class="job-step-summary"
|
||||||
|
@click.stop="isExpandable(jobStep.status) && toggleStepLogs(stepIdx)"
|
||||||
|
:class="[currentJobStepsStates[stepIdx].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']"
|
||||||
|
>
|
||||||
|
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
|
||||||
|
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
|
||||||
|
-->
|
||||||
|
<SvgIcon
|
||||||
|
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
|
||||||
|
name="gitea-running"
|
||||||
|
class="tw-mr-2 rotate-clockwise"
|
||||||
|
/>
|
||||||
|
<SvgIcon
|
||||||
|
v-else
|
||||||
|
:name="currentJobStepsStates[stepIdx].expanded ? 'octicon-chevron-down' : 'octicon-chevron-right'"
|
||||||
|
:class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"
|
||||||
|
/>
|
||||||
|
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
|
||||||
|
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
||||||
|
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
|
||||||
|
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
|
||||||
|
<div class="job-step-logs" :ref="(el) => jobStepLogs[stepIdx] = el as StepContainerElement" v-show="currentJobStepsStates[stepIdx].expanded"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
|
||||||
|
/* some elements are not managed by vue, so we need to use global style */
|
||||||
|
.job-step-section {
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-section .job-step-logs {
|
||||||
|
font-family: var(--fonts-monospace);
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-section .job-step-logs .job-log-line {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-log-line:hover,
|
||||||
|
.job-log-line:target {
|
||||||
|
background-color: var(--color-console-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-log-line:target {
|
||||||
|
scroll-margin-top: 95px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
||||||
|
.job-log-line .line-num, .log-time-seconds {
|
||||||
|
width: 48px;
|
||||||
|
color: var(--color-text-light-3);
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-log-line:target > .line-num {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time-seconds {
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-log-line .log-time,
|
||||||
|
.log-time-stamp {
|
||||||
|
color: var(--color-text-light-3);
|
||||||
|
margin-left: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-logs .job-log-line .log-msg {
|
||||||
|
flex: 1;
|
||||||
|
white-space: break-spaces;
|
||||||
|
margin-left: 10px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-logs .job-log-line .log-cmd-command {
|
||||||
|
color: var(--color-ansi-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-logs .job-log-line .log-cmd-error {
|
||||||
|
color: var(--color-ansi-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* selectors here are intentionally exact to only match fullscreen */
|
||||||
|
|
||||||
|
.full.height > .action-view-right {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full.height > .action-view-right > .job-info-header {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full.height > .action-view-right > .job-step-container {
|
||||||
|
height: calc(100% - 60px);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-log-group .job-log-list .job-log-line .log-msg {
|
||||||
|
margin-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-log-group-summary {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ActionRunStatus from './ActionRunStatus.vue';
|
||||||
|
import WorkflowGraph from './WorkflowGraph.vue';
|
||||||
|
import type {ActionRunViewStore} from "./ActionRunView.ts";
|
||||||
|
import {computed, onBeforeUnmount, onMounted, toRefs} from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ActionRunSummaryView',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
store: ActionRunViewStore;
|
||||||
|
locale: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const {currentRun: run} = toRefs(props.store.viewData);
|
||||||
|
|
||||||
|
const runTriggeredAtIso = computed(() => {
|
||||||
|
const t = props.store.viewData.currentRun.triggeredAt;
|
||||||
|
return t ? new Date(t * 1000).toISOString() : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await props.store.startPollingCurrentRun();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
props.store.stopPollingCurrentRun();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="action-run-summary-block">
|
||||||
|
<p class="action-run-summary-trigger">
|
||||||
|
{{ locale.triggeredVia.replace('%s', run.triggerEvent) }}
|
||||||
|
• <relative-time :datetime="runTriggeredAtIso" prefix=" "/>
|
||||||
|
</p>
|
||||||
|
<p class="tw-mb-0">
|
||||||
|
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>
|
||||||
|
<span class="tw-ml-2">{{ locale.status[run.status] }}</span>
|
||||||
|
<span class="tw-ml-3">{{ locale.totalDuration }} {{ run.duration || '–' }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<WorkflowGraph
|
||||||
|
v-if="run.jobs.length > 0"
|
||||||
|
:jobs="run.jobs"
|
||||||
|
:run-link="run.link"
|
||||||
|
:workflow-id="run.workflowID"
|
||||||
|
class="workflow-graph-container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.action-run-summary-block {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-run-summary-trigger {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--color-text-light-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import {createLogLineMessage, parseLogLineCommand} from './RepoActionView.vue';
|
import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts';
|
||||||
|
|
||||||
test('LogLineMessage', () => {
|
test('LogLineMessage', () => {
|
||||||
const cases = {
|
const cases = {
|
||||||
@@ -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<string, LogLineCommandName> = {
|
||||||
|
'::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<ActionsJob>,
|
||||||
|
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<ActionsArtifact>,
|
||||||
|
});
|
||||||
|
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<typeof createActionRunViewStore>;
|
||||||
@@ -1,497 +1,40 @@
|
|||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import ActionRunStatus from './ActionRunStatus.vue';
|
import ActionRunStatus from './ActionRunStatus.vue';
|
||||||
import {defineComponent, type PropType} from 'vue';
|
import {toRefs} from 'vue';
|
||||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
|
||||||
import {formatDatetime} from '../utils/time.ts';
|
|
||||||
import {renderAnsi} from '../render/ansi.ts';
|
|
||||||
import {POST, DELETE} from '../modules/fetch.ts';
|
import {POST, DELETE} from '../modules/fetch.ts';
|
||||||
import type {IntervalId} from '../types.ts';
|
import ActionRunSummaryView from './ActionRunSummaryView.vue';
|
||||||
import {toggleFullScreen} from '../utils.ts';
|
import ActionRunJobView from './ActionRunJobView.vue';
|
||||||
import WorkflowGraph from './WorkflowGraph.vue'
|
import {createActionRunViewStore} from "./ActionRunView.ts";
|
||||||
import {localUserSettings} from '../modules/user-settings.ts';
|
|
||||||
import type {ActionsRunStatus, ActionsJob} from '../modules/gitea-actions.ts';
|
|
||||||
|
|
||||||
type StepContainerElement = HTMLElement & {
|
defineOptions({
|
||||||
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
|
||||||
// then the following batches of logs should still use the same group (active logs container).
|
|
||||||
// maybe it can be refactored to decouple from the HTML element in the future.
|
|
||||||
_stepLogsActiveContainer?: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LogLine = {
|
|
||||||
index: number;
|
|
||||||
timestamp: number;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'hidden';
|
|
||||||
type LogLineCommand = {
|
|
||||||
name: LogLineCommandName,
|
|
||||||
prefix: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<string, LogLineCommandName> = {
|
|
||||||
'::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
|
|
||||||
};
|
|
||||||
|
|
||||||
type Step = {
|
|
||||||
summary: string,
|
|
||||||
duration: string,
|
|
||||||
status: ActionsRunStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
type JobStepState = {
|
|
||||||
cursor: string|null,
|
|
||||||
expanded: boolean,
|
|
||||||
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
|
||||||
}
|
|
||||||
|
|
||||||
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 in 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
// only check whether bottom is in viewport, because the log element can be a log group which is usually tall
|
|
||||||
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocaleStorageOptions = {
|
|
||||||
autoScroll: boolean;
|
|
||||||
expandRunning: boolean;
|
|
||||||
showWorkflowGraph: boolean;
|
|
||||||
actionsLogShowSeconds: boolean;
|
|
||||||
actionsLogShowTimestamps: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'RepoActionView',
|
name: 'RepoActionView',
|
||||||
components: {
|
|
||||||
SvgIcon,
|
|
||||||
ActionRunStatus,
|
|
||||||
WorkflowGraph,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
runId: {type: Number, required: true},
|
|
||||||
jobId: {type: Number, required: true},
|
|
||||||
actionsURL: {type: String, required: true},
|
|
||||||
locale: {
|
|
||||||
type: Object as PropType<Record<string, any>>,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
const defaultViewOptions: LocaleStorageOptions = {autoScroll: true, expandRunning: false, showWorkflowGraph: false, actionsLogShowSeconds: false, actionsLogShowTimestamps: false};
|
|
||||||
const {autoScroll, expandRunning, showWorkflowGraph, actionsLogShowSeconds, actionsLogShowTimestamps} = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
|
|
||||||
return {
|
|
||||||
// internal state
|
|
||||||
loadingAbortController: null as AbortController | null,
|
|
||||||
intervalID: null as IntervalId | null,
|
|
||||||
currentJobStepsStates: [] as Array<JobStepState>,
|
|
||||||
artifacts: [] as Array<Record<string, any>>,
|
|
||||||
menuVisible: false,
|
|
||||||
isFullScreen: false,
|
|
||||||
showWorkflowGraph: showWorkflowGraph,
|
|
||||||
timeVisible: {
|
|
||||||
'log-time-stamp': actionsLogShowTimestamps,
|
|
||||||
'log-time-seconds': actionsLogShowSeconds,
|
|
||||||
},
|
|
||||||
optionAlwaysAutoScroll: autoScroll,
|
|
||||||
optionAlwaysExpandRunning: expandRunning,
|
|
||||||
|
|
||||||
// provided by backend
|
|
||||||
run: {
|
|
||||||
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,
|
|
||||||
jobs: [
|
|
||||||
// {
|
|
||||||
// id: 0,
|
|
||||||
// name: '',
|
|
||||||
// status: '',
|
|
||||||
// canRerun: false,
|
|
||||||
// duration: '',
|
|
||||||
// },
|
|
||||||
] as Array<ActionsJob>,
|
|
||||||
commit: {
|
|
||||||
localeCommit: '',
|
|
||||||
localePushedBy: '',
|
|
||||||
shortSHA: '',
|
|
||||||
link: '',
|
|
||||||
pusher: {
|
|
||||||
displayName: '',
|
|
||||||
link: '',
|
|
||||||
},
|
|
||||||
branch: {
|
|
||||||
name: '',
|
|
||||||
link: '',
|
|
||||||
isDeleted: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
currentJob: {
|
|
||||||
title: '',
|
|
||||||
detail: '',
|
|
||||||
steps: [
|
|
||||||
// {
|
|
||||||
// summary: '',
|
|
||||||
// duration: '',
|
|
||||||
// status: '',
|
|
||||||
// }
|
|
||||||
] as Array<Step>,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
optionAlwaysAutoScroll() {
|
|
||||||
this.saveLocaleStorageOptions();
|
|
||||||
},
|
|
||||||
optionAlwaysExpandRunning() {
|
|
||||||
this.saveLocaleStorageOptions();
|
|
||||||
},
|
|
||||||
showWorkflowGraph() {
|
|
||||||
this.saveLocaleStorageOptions();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
async mounted() {
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
// auto-scroll to the bottom of the log group when it is opened
|
|
||||||
// "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
|
|
||||||
addDelegatedEventListener(this.elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
|
|
||||||
if (!this.optionAlwaysAutoScroll) return;
|
|
||||||
const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
|
|
||||||
elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.intervalID = setInterval(() => this.loadJob(), 1000);
|
|
||||||
document.body.addEventListener('click', this.closeDropdown);
|
|
||||||
this.hashChangeListener();
|
|
||||||
window.addEventListener('hashchange', this.hashChangeListener);
|
|
||||||
},
|
|
||||||
|
|
||||||
beforeUnmount() {
|
|
||||||
document.body.removeEventListener('click', this.closeDropdown);
|
|
||||||
window.removeEventListener('hashchange', this.hashChangeListener);
|
|
||||||
},
|
|
||||||
|
|
||||||
unmounted() {
|
|
||||||
// clear the interval timer when the component is unmounted
|
|
||||||
// even our page is rendered once, not spa style
|
|
||||||
if (this.intervalID) {
|
|
||||||
clearInterval(this.intervalID);
|
|
||||||
this.intervalID = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
saveLocaleStorageOptions() {
|
|
||||||
const opts: LocaleStorageOptions = {
|
|
||||||
autoScroll: this.optionAlwaysAutoScroll,
|
|
||||||
expandRunning: this.optionAlwaysExpandRunning,
|
|
||||||
showWorkflowGraph: this.showWorkflowGraph,
|
|
||||||
actionsLogShowSeconds: this.timeVisible['log-time-seconds'],
|
|
||||||
actionsLogShowTimestamps: this.timeVisible['log-time-stamp'],
|
|
||||||
};
|
|
||||||
localUserSettings.setJsonObject('actions-view-options', opts);
|
|
||||||
},
|
|
||||||
|
|
||||||
// get the job step logs container ('.job-step-logs')
|
|
||||||
getJobStepLogsContainer(stepIndex: number): StepContainerElement {
|
|
||||||
return (this.$refs.logs as any)[stepIndex];
|
|
||||||
},
|
|
||||||
|
|
||||||
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
|
|
||||||
getActiveLogsContainer(stepIndex: number): StepContainerElement {
|
|
||||||
const el = this.getJobStepLogsContainer(stepIndex);
|
|
||||||
return el._stepLogsActiveContainer ?? el;
|
|
||||||
},
|
|
||||||
// begin a log group
|
|
||||||
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
|
||||||
const el = (this.$refs.logs as any)[stepIndex] as StepContainerElement;
|
|
||||||
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
|
|
||||||
this.createLogLine(stepIndex, startTime, line, cmd),
|
|
||||||
);
|
|
||||||
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(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
|
||||||
const el = (this.$refs.logs as any)[stepIndex];
|
|
||||||
el._stepLogsActiveContainer = null;
|
|
||||||
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
|
|
||||||
},
|
|
||||||
|
|
||||||
// show/hide the step logs for a step
|
|
||||||
toggleStepLogs(idx: number) {
|
|
||||||
this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
|
|
||||||
if (this.currentJobStepsStates[idx].expanded) {
|
|
||||||
this.loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
|
|
||||||
} else if (this.currentJob.steps[idx].status === 'running') {
|
|
||||||
this.currentJobStepsStates[idx].manuallyCollapsed = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// cancel a run
|
|
||||||
cancelRun() {
|
|
||||||
POST(`${this.run.link}/cancel`);
|
|
||||||
},
|
|
||||||
// approve a run
|
|
||||||
approveRun() {
|
|
||||||
POST(`${this.run.link}/approve`);
|
|
||||||
},
|
|
||||||
|
|
||||||
createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
|
|
||||||
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
|
|
||||||
String(line.index),
|
|
||||||
);
|
|
||||||
|
|
||||||
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
|
||||||
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
|
|
||||||
);
|
|
||||||
|
|
||||||
const logMsg = createLogLineMessage(line, cmd);
|
|
||||||
const seconds = Math.floor(line.timestamp - startTime);
|
|
||||||
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
|
|
||||||
`${seconds}s`, // for "Show seconds"
|
|
||||||
);
|
|
||||||
|
|
||||||
toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
|
|
||||||
toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
|
|
||||||
|
|
||||||
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
|
|
||||||
lineNum, logTimeStamp, logMsg, logTimeSeconds,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
shouldAutoScroll(stepIndex: number): boolean {
|
|
||||||
if (!this.optionAlwaysAutoScroll) return false;
|
|
||||||
const el = this.getJobStepLogsContainer(stepIndex);
|
|
||||||
// if the logs container is empty, then auto-scroll if the step is expanded
|
|
||||||
if (!el.lastChild) return this.currentJobStepsStates[stepIndex].expanded;
|
|
||||||
// use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
|
|
||||||
return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
|
|
||||||
},
|
|
||||||
|
|
||||||
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
|
||||||
for (const line of logLines) {
|
|
||||||
const cmd = parseLogLineCommand(line);
|
|
||||||
switch (cmd?.name) {
|
|
||||||
case 'hidden':
|
|
||||||
continue;
|
|
||||||
case 'group':
|
|
||||||
this.beginLogGroup(stepIndex, startTime, line, cmd);
|
|
||||||
continue;
|
|
||||||
case 'endgroup':
|
|
||||||
this.endLogGroup(stepIndex, startTime, line, cmd);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// the active logs container may change during the loop, for example: entering and leaving a group
|
|
||||||
const el = this.getActiveLogsContainer(stepIndex);
|
|
||||||
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
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.loadJobForce();
|
|
||||||
},
|
|
||||||
|
|
||||||
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.
|
|
||||||
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
|
|
||||||
return {step: idx, cursor: it.cursor, expanded: it.expanded};
|
|
||||||
});
|
|
||||||
const resp = await POST(`${this.actionsURL}/runs/${this.runId}/jobs/${this.jobId}`, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
data: {logCursors},
|
|
||||||
});
|
|
||||||
return await resp.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadJobForce() {
|
|
||||||
this.loadingAbortController?.abort();
|
|
||||||
this.loadingAbortController = null;
|
|
||||||
await this.loadJob();
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadJob() {
|
|
||||||
if (this.loadingAbortController) return;
|
|
||||||
const abortController = new AbortController();
|
|
||||||
this.loadingAbortController = abortController;
|
|
||||||
try {
|
|
||||||
const job = await this.fetchJobData(abortController);
|
|
||||||
if (this.loadingAbortController !== abortController) return;
|
|
||||||
|
|
||||||
this.artifacts = job.artifacts || [];
|
|
||||||
this.run = job.state.run;
|
|
||||||
this.currentJob = job.state.currentJob;
|
|
||||||
|
|
||||||
// sync the currentJobStepsStates to store the job step states
|
|
||||||
for (let i = 0; i < this.currentJob.steps.length; i++) {
|
|
||||||
const autoExpand = this.optionAlwaysExpandRunning && this.currentJob.steps[i].status === 'running';
|
|
||||||
if (!this.currentJobStepsStates[i]) {
|
|
||||||
// initial states for job steps
|
|
||||||
this.currentJobStepsStates[i] = {cursor: null, expanded: autoExpand, manuallyCollapsed: false};
|
|
||||||
} else {
|
|
||||||
// if the step is not manually collapsed by user, then auto-expand it if option is enabled
|
|
||||||
if (autoExpand && !this.currentJobStepsStates[i].manuallyCollapsed) {
|
|
||||||
this.currentJobStepsStates[i].expanded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// find the step indexes that need to auto-scroll
|
|
||||||
const autoScrollStepIndexes = new Map<number, boolean>();
|
|
||||||
for (const logs of job.logs.stepsLog ?? []) {
|
|
||||||
if (autoScrollStepIndexes.has(logs.step)) continue;
|
|
||||||
autoScrollStepIndexes.set(logs.step, this.shouldAutoScroll(logs.step));
|
|
||||||
}
|
|
||||||
|
|
||||||
// append logs to the UI
|
|
||||||
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.started, logs.lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
// auto-scroll to the last log line of the last step
|
|
||||||
let autoScrollJobStepElement: StepContainerElement | undefined;
|
|
||||||
for (let stepIndex = 0; stepIndex < this.currentJob.steps.length; stepIndex++) {
|
|
||||||
if (!autoScrollStepIndexes.get(stepIndex)) continue;
|
|
||||||
autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
|
|
||||||
}
|
|
||||||
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
|
|
||||||
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
|
|
||||||
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
|
|
||||||
}
|
|
||||||
|
|
||||||
// clear the interval timer if the job is done
|
|
||||||
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 {
|
|
||||||
if (this.loadingAbortController === abortController) this.loadingAbortController = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
isDone(status: ActionsRunStatus) {
|
|
||||||
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
|
||||||
},
|
|
||||||
|
|
||||||
isExpandable(status: ActionsRunStatus) {
|
|
||||||
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
|
||||||
},
|
|
||||||
|
|
||||||
closeDropdown() {
|
|
||||||
if (this.menuVisible) this.menuVisible = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
elStepsContainer(): HTMLElement {
|
|
||||||
return this.$refs.stepsContainer as HTMLElement;
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
|
||||||
this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
|
|
||||||
for (const el of this.elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
|
|
||||||
toggleElem(el, this.timeVisible[`log-time-${type}`]);
|
|
||||||
}
|
|
||||||
this.saveLocaleStorageOptions();
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleFullScreen() {
|
|
||||||
this.isFullScreen = !this.isFullScreen;
|
|
||||||
toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
|
|
||||||
},
|
|
||||||
|
|
||||||
async hashChangeListener() {
|
|
||||||
const selectedLogStep = window.location.hash;
|
|
||||||
if (!selectedLogStep) return;
|
|
||||||
const [_, step, _line] = selectedLogStep.split('-');
|
|
||||||
const stepNum = Number(step);
|
|
||||||
if (!this.currentJobStepsStates[stepNum]) return;
|
|
||||||
if (!this.currentJobStepsStates[stepNum].expanded && this.currentJobStepsStates[stepNum].cursor === null) {
|
|
||||||
this.currentJobStepsStates[stepNum].expanded = true;
|
|
||||||
// need to await for load job if the step log is loaded for the first time
|
|
||||||
// so logline can be selected by querySelector
|
|
||||||
await this.loadJob();
|
|
||||||
}
|
|
||||||
const logLine = this.elStepsContainer().querySelector(selectedLogStep);
|
|
||||||
if (!logLine) return;
|
|
||||||
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
runId: number;
|
||||||
|
jobId: number;
|
||||||
|
actionsUrl: string;
|
||||||
|
locale: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const locale = props.locale;
|
||||||
|
const store = createActionRunViewStore(props.actionsUrl, props.runId);
|
||||||
|
const {currentRun: run , runArtifacts: artifacts} = toRefs(store.viewData);
|
||||||
|
|
||||||
|
function cancelRun() {
|
||||||
|
POST(`${run.value.link}/cancel`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function approveRun() {
|
||||||
|
POST(`${run.value.link}/approve`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteArtifact(name: string) {
|
||||||
|
if (!window.confirm(locale.confirmDeleteArtifact.replace('%s', name))) return;
|
||||||
|
await DELETE(`${run.value.link}/artifacts/${encodeURIComponent(name)}`);
|
||||||
|
await store.forceReloadCurrentRun();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<!-- make the view container full width to make users easier to read logs -->
|
<!-- make the view container full width to make users easier to read logs -->
|
||||||
@@ -504,9 +47,6 @@ export default defineComponent({
|
|||||||
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
|
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-text-block tw-shrink-0 tw-flex-wrap">
|
<div class="flex-text-block tw-shrink-0 tw-flex-wrap">
|
||||||
<button class="ui basic small compact button primary" @click="showWorkflowGraph = !showWorkflowGraph" :class="{ active: showWorkflowGraph }" v-if="run.jobs.length > 1">
|
|
||||||
{{ locale.workflowGraph }}
|
|
||||||
</button>
|
|
||||||
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
|
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
|
||||||
{{ locale.approve }}
|
{{ locale.approve }}
|
||||||
</button>
|
</button>
|
||||||
@@ -553,8 +93,16 @@ export default defineComponent({
|
|||||||
<div class="action-view-body">
|
<div class="action-view-body">
|
||||||
<div class="action-view-left">
|
<div class="action-view-left">
|
||||||
<div class="job-group-section">
|
<div class="job-group-section">
|
||||||
|
<a class="job-brief-item" :href="run.link" :class="!props.jobId ? 'selected' : ''">
|
||||||
|
<div class="job-brief-item-left">
|
||||||
|
<SvgIcon name="octicon-list-unordered" class="tw-mr-2"/>
|
||||||
|
<span class="job-brief-name tw-mx-2 gt-ellipsis">{{ locale.summary }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="ui divider"/>
|
||||||
<div class="job-brief-list">
|
<div class="job-brief-list">
|
||||||
<a class="job-brief-item" :href="run.link+'/jobs/'+job.id" :class="jobId === job.id ? 'selected' : ''" v-for="job in run.jobs" :key="job.id">
|
<div class="left-list-header">{{ locale.allJobs }}</div>
|
||||||
|
<a class="job-brief-item" :href="run.link+'/jobs/'+job.id" :class="props.jobId === job.id ? 'selected' : ''" v-for="job in run.jobs" :key="job.id">
|
||||||
<div class="job-brief-item-left">
|
<div class="job-brief-item-left">
|
||||||
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
|
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
|
||||||
<span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
|
<span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
|
||||||
@@ -567,9 +115,8 @@ export default defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-artifacts" v-if="artifacts.length > 0">
|
<div class="job-artifacts" v-if="artifacts.length > 0">
|
||||||
<div class="job-artifacts-title">
|
<div class="ui divider"/>
|
||||||
{{ locale.artifactsTitle }}
|
<div class="left-list-header">{{ locale.artifactsTitle }} ({{ artifacts.length }})</div>
|
||||||
</div>
|
|
||||||
<ul class="job-artifacts-list">
|
<ul class="job-artifacts-list">
|
||||||
<template v-for="artifact in artifacts" :key="artifact.name">
|
<template v-for="artifact in artifacts" :key="artifact.name">
|
||||||
<li class="job-artifacts-item">
|
<li class="job-artifacts-item">
|
||||||
@@ -594,82 +141,19 @@ export default defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-view-right">
|
<div class="action-view-right">
|
||||||
<WorkflowGraph
|
<ActionRunSummaryView
|
||||||
v-if="showWorkflowGraph && run.jobs.length > 1"
|
v-if="!props.jobId"
|
||||||
:jobs="run.jobs"
|
:store="store"
|
||||||
:current-job-id="jobId"
|
:locale="locale"
|
||||||
:run-link="run.link"
|
/>
|
||||||
:workflow-id="run.workflowID"
|
<ActionRunJobView
|
||||||
class="workflow-graph-container"
|
v-else
|
||||||
|
:store="store"
|
||||||
|
:locale="locale"
|
||||||
|
:run-id="props.runId"
|
||||||
|
:job-id="props.jobId"
|
||||||
|
:actions-url="props.actionsUrl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="job-info-header">
|
|
||||||
<div class="job-info-header-left gt-ellipsis">
|
|
||||||
<h3 class="job-info-header-title gt-ellipsis">
|
|
||||||
{{ currentJob.title }}
|
|
||||||
</h3>
|
|
||||||
<p class="job-info-header-detail">
|
|
||||||
{{ currentJob.detail }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="job-info-header-right">
|
|
||||||
<div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
|
|
||||||
<button class="ui button tw-px-3">
|
|
||||||
<SvgIcon name="octicon-gear" :size="18"/>
|
|
||||||
</button>
|
|
||||||
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
|
|
||||||
<a class="item" @click="toggleTimeDisplay('seconds')">
|
|
||||||
<i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
|
||||||
{{ locale.showLogSeconds }}
|
|
||||||
</a>
|
|
||||||
<a class="item" @click="toggleTimeDisplay('stamp')">
|
|
||||||
<i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
|
||||||
{{ locale.showTimeStamps }}
|
|
||||||
</a>
|
|
||||||
<a class="item" @click="toggleFullScreen()">
|
|
||||||
<i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
|
||||||
{{ locale.showFullScreen }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="divider"/>
|
|
||||||
<a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
|
|
||||||
<i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
|
||||||
{{ locale.logsAlwaysAutoScroll }}
|
|
||||||
</a>
|
|
||||||
<a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
|
|
||||||
<i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
|
||||||
{{ locale.logsAlwaysExpandRunning }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="divider"/>
|
|
||||||
<a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link+'/jobs/'+jobId+'/logs'" download>
|
|
||||||
<i class="icon"><SvgIcon name="octicon-download"/></i>
|
|
||||||
{{ locale.downloadLogs }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
|
||||||
<div class="job-step-container" ref="stepsContainer" v-show="currentJob.steps.length">
|
|
||||||
<div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
|
|
||||||
<div class="job-step-summary" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
|
|
||||||
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
|
|
||||||
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
|
|
||||||
-->
|
|
||||||
<SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
|
||||||
<SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
|
|
||||||
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
|
|
||||||
|
|
||||||
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
|
||||||
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
|
|
||||||
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
|
|
||||||
<div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -750,11 +234,9 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-artifacts-title {
|
.left-list-header {
|
||||||
font-size: 18px;
|
font-size: 12px;
|
||||||
margin-top: 16px;
|
color: var(--color-grey);
|
||||||
padding: 16px 10px 0 20px;
|
|
||||||
border-top: 1px solid var(--color-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-artifacts-item {
|
.job-artifacts-item {
|
||||||
@@ -766,7 +248,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.job-artifacts-list {
|
.job-artifacts-list {
|
||||||
padding-left: 12px;
|
padding-left: 4px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,7 +259,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.job-brief-item {
|
.job-brief-item {
|
||||||
padding: 10px;
|
padding: 6px 10px;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -861,111 +343,6 @@ export default defineComponent({
|
|||||||
|
|
||||||
/* end fomantic button overrides */
|
/* 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) {
|
@media (max-width: 767.98px) {
|
||||||
.action-view-body {
|
.action-view-body {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -978,101 +355,3 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
|
|
||||||
/* some elements are not managed by vue, so we need to use global style */
|
|
||||||
.job-step-section {
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-step-section .job-step-logs {
|
|
||||||
font-family: var(--fonts-monospace);
|
|
||||||
margin: 8px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-step-section .job-step-logs .job-log-line {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-log-line:hover,
|
|
||||||
.job-log-line:target {
|
|
||||||
background-color: var(--color-console-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-log-line:target {
|
|
||||||
scroll-margin-top: 95px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
|
||||||
.job-log-line .line-num, .log-time-seconds {
|
|
||||||
width: 48px;
|
|
||||||
color: var(--color-text-light-3);
|
|
||||||
text-align: right;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-log-line:target > .line-num {
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-time-seconds {
|
|
||||||
padding-right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-log-line .log-time,
|
|
||||||
.log-time-stamp {
|
|
||||||
color: var(--color-text-light-3);
|
|
||||||
margin-left: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-step-logs .job-log-line .log-msg {
|
|
||||||
flex: 1;
|
|
||||||
white-space: break-spaces;
|
|
||||||
margin-left: 10px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-step-logs .job-log-line .log-cmd-command {
|
|
||||||
color: var(--color-ansi-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-step-logs .job-log-line .log-cmd-error {
|
|
||||||
color: var(--color-ansi-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* selectors here are intentionally exact to only match fullscreen */
|
|
||||||
|
|
||||||
.full.height > .action-view-right {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full.height > .action-view-right > .job-info-header {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full.height > .action-view-right > .job-step-container {
|
|
||||||
height: calc(100% - 60px);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-log-group .job-log-list .job-log-line .log-msg {
|
|
||||||
margin-left: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-log-group-summary {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ interface StoredState {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
jobs: ActionsJob[];
|
jobs: ActionsJob[];
|
||||||
currentJobId: number;
|
|
||||||
runLink: string;
|
runLink: string;
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
}>()
|
}>()
|
||||||
@@ -86,9 +85,7 @@ const saveState = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadSavedState();
|
loadSavedState();
|
||||||
watch([translateX, translateY, scale], () => {
|
watch([translateX, translateY, scale], debounce(500, saveState))
|
||||||
debounce(500, saveState);
|
|
||||||
})
|
|
||||||
|
|
||||||
const nodeWidth = computed(() => {
|
const nodeWidth = computed(() => {
|
||||||
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
|
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
|
||||||
@@ -588,8 +585,6 @@ function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onNodeClick(job: JobNode, event: MouseEvent) {
|
function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||||
if (job.id === props.currentJobId) return;
|
|
||||||
|
|
||||||
const link = `${props.runLink}/jobs/${job.id}`;
|
const link = `${props.runLink}/jobs/${job.id}`;
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
window.open(link, '_blank');
|
window.open(link, '_blank');
|
||||||
@@ -652,7 +647,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
|||||||
<g
|
<g
|
||||||
v-for="job in jobsWithLayout"
|
v-for="job in jobsWithLayout"
|
||||||
:key="job.id"
|
:key="job.id"
|
||||||
:class="{'current-job': job.id === currentJobId}"
|
|
||||||
class="job-node-group"
|
class="job-node-group"
|
||||||
@click="onNodeClick(job, $event)"
|
@click="onNodeClick(job, $event)"
|
||||||
@mouseenter="handleNodeMouseEnter(job)"
|
@mouseenter="handleNodeMouseEnter(job)"
|
||||||
@@ -665,8 +659,8 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
|||||||
:height="nodeHeight"
|
:height="nodeHeight"
|
||||||
rx="8"
|
rx="8"
|
||||||
:fill="getNodeColor(job.status)"
|
:fill="getNodeColor(job.status)"
|
||||||
:stroke="job.id === currentJobId ? 'var(--color-primary)' : 'var(--color-card-border)'"
|
stroke="var(--color-card-border)"
|
||||||
:stroke-width="job.id === currentJobId ? '3' : '2'"
|
stroke-width="2"
|
||||||
class="job-rect"
|
class="job-rect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -734,18 +728,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
|||||||
keySplines="0.4, 0, 0.2, 1"
|
keySplines="0.4, 0, 0.2, 1"
|
||||||
/>
|
/>
|
||||||
</rect>
|
</rect>
|
||||||
|
|
||||||
<text
|
|
||||||
v-if="job.needs?.length"
|
|
||||||
:x="job.x + nodeWidth / 2"
|
|
||||||
:y="job.y - 8"
|
|
||||||
fill="var(--color-text-light-2)"
|
|
||||||
font-size="10"
|
|
||||||
text-anchor="middle"
|
|
||||||
class="job-deps-label"
|
|
||||||
>
|
|
||||||
← {{ job.needs.length }} deps
|
|
||||||
</text>
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<defs>
|
<defs>
|
||||||
@@ -769,10 +751,9 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
padding: 8px 14px;
|
||||||
padding: 6px 12px;
|
background: var(--color-box-header);
|
||||||
border-bottom: 1px solid var(--color-secondary-alpha-20);
|
gap: 20px;
|
||||||
gap: 15px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,7 +767,10 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.graph-stats {
|
.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;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -805,7 +789,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
|||||||
.graph-container {
|
.graph-container {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
@@ -844,14 +827,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
|||||||
z-index: 10;
|
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 {
|
.job-name {
|
||||||
max-width: calc(var(--node-width, 150px) - 50px);
|
max-width: calc(var(--node-width, 150px) - 50px);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -862,8 +837,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.job-status,
|
.job-status,
|
||||||
.job-duration,
|
.job-duration {
|
||||||
.job-deps-label {
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function initRepositoryActionView() {
|
|||||||
const view = createApp(RepoActionView, {
|
const view = createApp(RepoActionView, {
|
||||||
runId: parseInt(el.getAttribute('data-run-id')!),
|
runId: parseInt(el.getAttribute('data-run-id')!),
|
||||||
jobId: parseInt(el.getAttribute('data-job-id')!),
|
jobId: parseInt(el.getAttribute('data-job-id')!),
|
||||||
actionsURL: el.getAttribute('data-actions-url'),
|
actionsUrl: el.getAttribute('data-actions-url'),
|
||||||
locale: {
|
locale: {
|
||||||
approve: el.getAttribute('data-locale-approve'),
|
approve: el.getAttribute('data-locale-approve'),
|
||||||
cancel: el.getAttribute('data-locale-cancel'),
|
cancel: el.getAttribute('data-locale-cancel'),
|
||||||
@@ -24,6 +24,10 @@ export function initRepositoryActionView() {
|
|||||||
commit: el.getAttribute('data-locale-runs-commit'),
|
commit: el.getAttribute('data-locale-runs-commit'),
|
||||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||||
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
|
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'),
|
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||||
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||||
|
|||||||
@@ -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"
|
// 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 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<ActionsJob>,
|
||||||
|
commit: {
|
||||||
|
localeCommit: string,
|
||||||
|
localePushedBy: string,
|
||||||
|
shortSHA: string,
|
||||||
|
link: string,
|
||||||
|
pusher: {
|
||||||
|
displayName: string,
|
||||||
|
link: string,
|
||||||
|
},
|
||||||
|
branch: {
|
||||||
|
name: string,
|
||||||
|
link: string,
|
||||||
|
isDeleted: boolean,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type ActionsJob = {
|
export type ActionsJob = {
|
||||||
id: number;
|
id: number;
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@@ -10,3 +45,8 @@ export type ActionsJob = {
|
|||||||
needs?: string[];
|
needs?: string[];
|
||||||
duration: string;
|
duration: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ActionsArtifact = {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -322,6 +322,10 @@ class RelativeTime extends HTMLElement {
|
|||||||
return this.getAttribute('prefix') ?? (this.format === 'datetime' ? '' : 'on');
|
return this.getAttribute('prefix') ?? (this.format === 'datetime' ? '' : 'on');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set prefix(v: string) {
|
||||||
|
this.setAttribute('prefix', v);
|
||||||
|
}
|
||||||
|
|
||||||
get #thresholdMs(): number {
|
get #thresholdMs(): number {
|
||||||
const ms = parseDurationMs(this.getAttribute('threshold') ?? '');
|
const ms = parseDurationMs(this.getAttribute('threshold') ?? '');
|
||||||
return ms >= 0 ? ms : 30 * 86400000;
|
return ms >= 0 ? ms : 30 * 86400000;
|
||||||
@@ -355,6 +359,10 @@ class RelativeTime extends HTMLElement {
|
|||||||
return this.getAttribute('datetime') || '';
|
return this.getAttribute('datetime') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set datetime(v: string) {
|
||||||
|
this.setAttribute('datetime', v);
|
||||||
|
}
|
||||||
|
|
||||||
get date(): Date | null {
|
get date(): Date | null {
|
||||||
const parsed = Date.parse(this.datetime);
|
const parsed = Date.parse(this.datetime);
|
||||||
return Number.isNaN(parsed) ? null : new Date(parsed);
|
return Number.isNaN(parsed) ? null : new Date(parsed);
|
||||||
|
|||||||
Reference in New Issue
Block a user