mirror of
https://github.com/go-gitea/gitea.git
synced 2025-10-06 22:17:28 +02:00
Backport #35583 by ita004 Co-authored-by: Shafi Ahmed <98274448+ita004@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
006fe2a907
commit
aa57531aac
@ -6,6 +6,7 @@ package devtest
|
|||||||
import (
|
import (
|
||||||
mathRand "math/rand/v2"
|
mathRand "math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -17,25 +18,29 @@ import (
|
|||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
func generateMockStepsLog(logCur actions.LogCursor) (stepsLog []*actions.ViewStepLog) {
|
type generateMockStepsLogOptions struct {
|
||||||
mockedLogs := []string{
|
mockCountFirst int
|
||||||
"::group::test group for: step={step}, cursor={cursor}",
|
mockCountGeneral int
|
||||||
"in group msg for: step={step}, cursor={cursor}",
|
groupRepeat int
|
||||||
"in group msg for: step={step}, cursor={cursor}",
|
}
|
||||||
"in group msg for: step={step}, cursor={cursor}",
|
|
||||||
"::endgroup::",
|
func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOptions) (stepsLog []*actions.ViewStepLog) {
|
||||||
|
var mockedLogs []string
|
||||||
|
mockedLogs = append(mockedLogs, "::group::test group for: step={step}, cursor={cursor}")
|
||||||
|
mockedLogs = append(mockedLogs, slices.Repeat([]string{"in group msg for: step={step}, cursor={cursor}"}, opts.groupRepeat)...)
|
||||||
|
mockedLogs = append(mockedLogs, "::endgroup::")
|
||||||
|
mockedLogs = append(mockedLogs,
|
||||||
"message for: step={step}, cursor={cursor}",
|
"message for: step={step}, cursor={cursor}",
|
||||||
"message for: step={step}, cursor={cursor}",
|
"message for: step={step}, cursor={cursor}",
|
||||||
"##[group]test group for: step={step}, cursor={cursor}",
|
"##[group]test group for: step={step}, cursor={cursor}",
|
||||||
"in group msg for: step={step}, cursor={cursor}",
|
"in group msg for: step={step}, cursor={cursor}",
|
||||||
"##[endgroup]",
|
"##[endgroup]",
|
||||||
}
|
)
|
||||||
cur := logCur.Cursor // usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally
|
// usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally
|
||||||
mockCount := util.Iif(logCur.Step == 0, 3, 1)
|
cur := logCur.Cursor
|
||||||
if logCur.Step == 1 && logCur.Cursor == 0 {
|
// for the first batch, return as many as possible to test the auto-expand and auto-scroll
|
||||||
mockCount = 30 // for the first batch, return as many as possible to test the auto-expand and auto-scroll
|
mockCount := util.Iif(logCur.Cursor == 0, opts.mockCountFirst, opts.mockCountGeneral)
|
||||||
}
|
for range mockCount {
|
||||||
for i := 0; i < mockCount; i++ {
|
|
||||||
logStr := mockedLogs[int(cur)%len(mockedLogs)]
|
logStr := mockedLogs[int(cur)%len(mockedLogs)]
|
||||||
cur++
|
cur++
|
||||||
logStr = strings.ReplaceAll(logStr, "{step}", strconv.Itoa(logCur.Step))
|
logStr = strings.ReplaceAll(logStr, "{step}", strconv.Itoa(logCur.Step))
|
||||||
@ -127,21 +132,28 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
Duration: "3h",
|
Duration: "3h",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)",
|
||||||
Duration: time.Hour.String(),
|
Duration: time.Hour.String(),
|
||||||
Status: actions_model.StatusRunning.String(),
|
Status: actions_model.StatusRunning.String(),
|
||||||
})
|
})
|
||||||
|
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 1, groupRepeat: 3})
|
||||||
|
|
||||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
||||||
Summary: "step 1 (mock fast)",
|
Summary: "step 1 (mock fast)",
|
||||||
Duration: time.Hour.String(),
|
Duration: time.Hour.String(),
|
||||||
Status: actions_model.StatusRunning.String(),
|
Status: actions_model.StatusRunning.String(),
|
||||||
})
|
})
|
||||||
|
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 20})
|
||||||
|
|
||||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
||||||
Summary: "step 2 (mock error)",
|
Summary: "step 2 (mock error)",
|
||||||
Duration: time.Hour.String(),
|
Duration: time.Hour.String(),
|
||||||
Status: actions_model.StatusRunning.String(),
|
Status: actions_model.StatusRunning.String(),
|
||||||
})
|
})
|
||||||
|
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)
|
ctx.JSON(http.StatusOK, resp)
|
||||||
return
|
return
|
||||||
@ -156,7 +168,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
doSlowResponse = doSlowResponse || logCur.Step == 0
|
doSlowResponse = doSlowResponse || logCur.Step == 0
|
||||||
doErrorResponse = doErrorResponse || logCur.Step == 2
|
doErrorResponse = doErrorResponse || logCur.Step == 2
|
||||||
resp.Logs.StepsLog = append(resp.Logs.StepsLog, generateMockStepsLog(logCur)...)
|
resp.Logs.StepsLog = append(resp.Logs.StepsLog, generateMockStepsLog(logCur, mockLogOptions[logCur.Step])...)
|
||||||
}
|
}
|
||||||
if doErrorResponse {
|
if doErrorResponse {
|
||||||
if mathRand.Float64() > 0.5 {
|
if mathRand.Float64() > 0.5 {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
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 {defineComponent, type PropType} from 'vue';
|
||||||
import {createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
import {addDelegatedEventListener, 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 {POST, DELETE} from '../modules/fetch.ts';
|
import {POST, DELETE} from '../modules/fetch.ts';
|
||||||
@ -40,6 +40,12 @@ type Step = {
|
|||||||
status: RunStatus,
|
status: RunStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JobStepState = {
|
||||||
|
cursor: string|null,
|
||||||
|
expanded: boolean,
|
||||||
|
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
||||||
|
}
|
||||||
|
|
||||||
function parseLineCommand(line: LogLine): LogLineCommand | null {
|
function parseLineCommand(line: LogLine): LogLineCommand | null {
|
||||||
for (const prefix of LogLinePrefixesGroup) {
|
for (const prefix of LogLinePrefixesGroup) {
|
||||||
if (line.message.startsWith(prefix)) {
|
if (line.message.startsWith(prefix)) {
|
||||||
@ -54,9 +60,10 @@ function parseLineCommand(line: LogLine): LogLineCommand | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLogElementInViewport(el: Element): boolean {
|
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
return rect.top >= 0 && rect.bottom <= window.innerHeight; // only check height but not width
|
// 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 = {
|
type LocaleStorageOptions = {
|
||||||
@ -104,7 +111,7 @@ export default defineComponent({
|
|||||||
// internal state
|
// internal state
|
||||||
loadingAbortController: null as AbortController | null,
|
loadingAbortController: null as AbortController | null,
|
||||||
intervalID: null as IntervalId | null,
|
intervalID: null as IntervalId | null,
|
||||||
currentJobStepsStates: [] as Array<Record<string, any>>,
|
currentJobStepsStates: [] as Array<JobStepState>,
|
||||||
artifacts: [] as Array<Record<string, any>>,
|
artifacts: [] as Array<Record<string, any>>,
|
||||||
menuVisible: false,
|
menuVisible: false,
|
||||||
isFullScreen: false,
|
isFullScreen: false,
|
||||||
@ -181,6 +188,19 @@ export default defineComponent({
|
|||||||
// 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();
|
||||||
|
|
||||||
|
// 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);
|
this.intervalID = setInterval(() => this.loadJob(), 1000);
|
||||||
document.body.addEventListener('click', this.closeDropdown);
|
document.body.addEventListener('click', this.closeDropdown);
|
||||||
this.hashChangeListener();
|
this.hashChangeListener();
|
||||||
@ -252,6 +272,8 @@ export default defineComponent({
|
|||||||
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.loadJobForce(); // 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
|
||||||
|
} else if (this.currentJob.steps[idx].status === 'running') {
|
||||||
|
this.currentJobStepsStates[idx].manuallyCollapsed = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// cancel a run
|
// cancel a run
|
||||||
@ -293,7 +315,8 @@ export default defineComponent({
|
|||||||
const el = this.getJobStepLogsContainer(stepIndex);
|
const el = this.getJobStepLogsContainer(stepIndex);
|
||||||
// if the logs container is empty, then auto-scroll if the step is expanded
|
// if the logs container is empty, then auto-scroll if the step is expanded
|
||||||
if (!el.lastChild) return this.currentJobStepsStates[stepIndex].expanded;
|
if (!el.lastChild) return this.currentJobStepsStates[stepIndex].expanded;
|
||||||
return isLogElementInViewport(el.lastChild as Element);
|
// 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[]) {
|
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
||||||
@ -343,7 +366,6 @@ export default defineComponent({
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
this.loadingAbortController = abortController;
|
this.loadingAbortController = abortController;
|
||||||
try {
|
try {
|
||||||
const isFirstLoad = !this.run.status;
|
|
||||||
const job = await this.fetchJobData(abortController);
|
const job = await this.fetchJobData(abortController);
|
||||||
if (this.loadingAbortController !== abortController) return;
|
if (this.loadingAbortController !== abortController) return;
|
||||||
|
|
||||||
@ -353,10 +375,15 @@ export default defineComponent({
|
|||||||
|
|
||||||
// sync the currentJobStepsStates to store the job step states
|
// sync the currentJobStepsStates to store the job step states
|
||||||
for (let i = 0; i < this.currentJob.steps.length; i++) {
|
for (let i = 0; i < this.currentJob.steps.length; i++) {
|
||||||
const expanded = isFirstLoad && this.optionAlwaysExpandRunning && this.currentJob.steps[i].status === 'running';
|
const autoExpand = this.optionAlwaysExpandRunning && this.currentJob.steps[i].status === 'running';
|
||||||
if (!this.currentJobStepsStates[i]) {
|
if (!this.currentJobStepsStates[i]) {
|
||||||
// initial states for job steps
|
// initial states for job steps
|
||||||
this.currentJobStepsStates[i] = {cursor: null, expanded};
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,7 +407,10 @@ export default defineComponent({
|
|||||||
if (!autoScrollStepIndexes.get(stepIndex)) continue;
|
if (!autoScrollStepIndexes.get(stepIndex)) continue;
|
||||||
autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
|
autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
|
||||||
}
|
}
|
||||||
autoScrollJobStepElement?.lastElementChild.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
|
||||||
|
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
|
||||||
|
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||||
|
}
|
||||||
|
|
||||||
// clear the interval timer if the job is done
|
// clear the interval timer if the job is done
|
||||||
if (this.run.done && this.intervalID) {
|
if (this.run.done && this.intervalID) {
|
||||||
@ -408,9 +438,13 @@ export default defineComponent({
|
|||||||
if (this.menuVisible) this.menuVisible = false;
|
if (this.menuVisible) this.menuVisible = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
elStepsContainer(): HTMLElement {
|
||||||
|
return this.$refs.stepsContainer as HTMLElement;
|
||||||
|
},
|
||||||
|
|
||||||
toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
||||||
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 as HTMLElement).querySelectorAll(`.log-time-${type}`)) {
|
for (const el of this.elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
|
||||||
toggleElem(el, this.timeVisible[`log-time-${type}`]);
|
toggleElem(el, this.timeVisible[`log-time-${type}`]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -419,6 +453,7 @@ export default defineComponent({
|
|||||||
this.isFullScreen = !this.isFullScreen;
|
this.isFullScreen = !this.isFullScreen;
|
||||||
toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
|
toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
|
||||||
},
|
},
|
||||||
|
|
||||||
async hashChangeListener() {
|
async hashChangeListener() {
|
||||||
const selectedLogStep = window.location.hash;
|
const selectedLogStep = window.location.hash;
|
||||||
if (!selectedLogStep) return;
|
if (!selectedLogStep) return;
|
||||||
@ -431,7 +466,7 @@ export default defineComponent({
|
|||||||
// so logline can be selected by querySelector
|
// so logline can be selected by querySelector
|
||||||
await this.loadJob();
|
await this.loadJob();
|
||||||
}
|
}
|
||||||
const logLine = (this.$refs.steps as HTMLElement).querySelector(selectedLogStep);
|
const logLine = this.elStepsContainer().querySelector(selectedLogStep);
|
||||||
if (!logLine) return;
|
if (!logLine) return;
|
||||||
logLine.querySelector<HTMLAnchorElement>('.line-num').click();
|
logLine.querySelector<HTMLAnchorElement>('.line-num').click();
|
||||||
},
|
},
|
||||||
@ -566,7 +601,7 @@ export default defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-step-container" ref="steps" v-if="currentJob.steps.length">
|
<div class="job-step-container" ref="stepsContainer" v-if="currentJob.steps.length">
|
||||||
<div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
|
<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']">
|
<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
|
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
|
||||||
|
Loading…
x
Reference in New Issue
Block a user