Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions routers/web/devtest/mock_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package devtest
import (
mathRand "math/rand/v2"
"net/http"
"slices"
"strconv"
"strings"
"time"
Expand All @@ -17,25 +18,29 @@ import (
"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::",
type generateMockStepsLogOptions struct {
mockCountFirst int
mockCountGeneral int
groupRepeat int
}

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}",
"##[group]test group for: step={step}, cursor={cursor}",
"in group msg for: step={step}, cursor={cursor}",
"##[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
mockCount := util.Iif(logCur.Step == 0, 3, 1)
if logCur.Step == 1 && logCur.Cursor == 0 {
mockCount = 30 // for the first batch, return as many as possible to test the auto-expand and auto-scroll
}
for i := 0; i < mockCount; i++ {
)
// usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally
cur := logCur.Cursor
// 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 {
logStr := mockedLogs[int(cur)%len(mockedLogs)]
cur++
logStr = strings.ReplaceAll(logStr, "{step}", strconv.Itoa(logCur.Step))
Expand Down Expand Up @@ -127,21 +132,28 @@ func MockActionsRunsJobs(ctx *context.Context) {
Duration: "3h",
})

var mockLogOptions []generateMockStepsLogOptions
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(),
})
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 1, groupRepeat: 3})

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(),
})
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 20})

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(),
})
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})

if len(req.LogCursors) == 0 {
ctx.JSON(http.StatusOK, resp)
return
Expand All @@ -156,7 +168,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
}
doSlowResponse = doSlowResponse || logCur.Step == 0
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 mathRand.Float64() > 0.5 {
Expand Down
59 changes: 47 additions & 12 deletions web_src/js/components/RepoActionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import {SvgIcon} from '../svg.ts';
import ActionRunStatus from './ActionRunStatus.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 {renderAnsi} from '../render/ansi.ts';
import {POST, DELETE} from '../modules/fetch.ts';
Expand Down Expand Up @@ -40,6 +40,12 @@ type Step = {
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 {
for (const prefix of LogLinePrefixesGroup) {
if (line.message.startsWith(prefix)) {
Expand All @@ -54,9 +60,10 @@ function parseLineCommand(line: LogLine): LogLineCommand | null {
return null;
}
function isLogElementInViewport(el: Element): boolean {
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
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 = {
Expand Down Expand Up @@ -104,7 +111,7 @@ export default defineComponent({
// internal state
loadingAbortController: null as AbortController | null,
intervalID: null as IntervalId | null,
currentJobStepsStates: [] as Array<Record<string, any>>,
currentJobStepsStates: [] as Array<JobStepState>,
artifacts: [] as Array<Record<string, any>>,
menuVisible: false,
isFullScreen: false,
Expand Down Expand Up @@ -181,6 +188,19 @@ export default defineComponent({
// 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();
Expand Down Expand Up @@ -252,6 +272,8 @@ export default defineComponent({
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
Expand Down Expand Up @@ -293,7 +315,8 @@ export default defineComponent({
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;
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[]) {
Expand Down Expand Up @@ -343,7 +366,6 @@ export default defineComponent({
const abortController = new AbortController();
this.loadingAbortController = abortController;
try {
const isFirstLoad = !this.run.status;
const job = await this.fetchJobData(abortController);
if (this.loadingAbortController !== abortController) return;
Expand All @@ -353,10 +375,15 @@ export default defineComponent({
// sync the currentJobStepsStates to store the job step states
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]) {
// 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;
}
}
}
Expand All @@ -380,7 +407,10 @@ export default defineComponent({
if (!autoScrollStepIndexes.get(stepIndex)) continue;
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
if (this.run.done && this.intervalID) {
Expand Down Expand Up @@ -408,9 +438,13 @@ export default defineComponent({
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.$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}`]);
}
},
Expand All @@ -419,6 +453,7 @@ export default defineComponent({
this.isFullScreen = !this.isFullScreen;
toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
},
async hashChangeListener() {
const selectedLogStep = window.location.hash;
if (!selectedLogStep) return;
Expand All @@ -431,7 +466,7 @@ export default defineComponent({
// so logline can be selected by querySelector
await this.loadJob();
}
const logLine = (this.$refs.steps as HTMLElement).querySelector(selectedLogStep);
const logLine = this.elStepsContainer().querySelector(selectedLogStep);
if (!logLine) return;
logLine.querySelector<HTMLAnchorElement>('.line-num').click();
},
Expand Down Expand Up @@ -566,7 +601,7 @@ export default defineComponent({
</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-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
Expand Down