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