diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
new file mode 100644
index 0000000000..37e94aa802
--- /dev/null
+++ b/routers/web/devtest/mock_actions.go
@@ -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)
+}
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index f86d4c6177..0f0d7d1ebd 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -66,15 +66,25 @@ func View(ctx *context_module.Context) {
ctx.HTML(http.StatusOK, tplViewActions)
}
+type LogCursor struct {
+ Step int `json:"step"`
+ Cursor int64 `json:"cursor"`
+ Expanded bool `json:"expanded"`
+}
+
type ViewRequest struct {
- LogCursors []struct {
- Step int `json:"step"`
- Cursor int64 `json:"cursor"`
- Expanded bool `json:"expanded"`
- } `json:"logCursors"`
+ LogCursors []LogCursor `json:"logCursors"`
+}
+
+type ArtifactsViewItem struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Status string `json:"status"`
}
type ViewResponse struct {
+ Artifacts []*ArtifactsViewItem `json:"artifacts"`
+
State struct {
Run struct {
Link string `json:"link"`
@@ -146,6 +156,25 @@ type ViewStepLogLine struct {
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) {
req := web.GetForm(ctx).(*ViewRequest)
runIndex := getRunIndex(ctx)
@@ -157,11 +186,19 @@ func ViewPost(ctx *context_module.Context) {
}
run := current.Run
if err := run.LoadAttributes(ctx); err != nil {
- ctx.Error(http.StatusInternalServerError, err.Error())
+ ctx.ServerError("run.LoadAttributes", err)
return
}
+ var err error
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.Link = run.Link()
@@ -205,12 +242,12 @@ func ViewPost(ctx *context_module.Context) {
var err error
task, err = actions_model.GetTaskByID(ctx, current.TaskID)
if err != nil {
- ctx.Error(http.StatusInternalServerError, err.Error())
+ ctx.ServerError("actions_model.GetTaskByID", err)
return
}
task.Job = current
if err := task.LoadAttributes(ctx); err != nil {
- ctx.Error(http.StatusInternalServerError, err.Error())
+ ctx.ServerError("task.LoadAttributes", err)
return
}
}
@@ -278,7 +315,7 @@ func ViewPost(ctx *context_module.Context) {
offset := task.LogIndexes[index]
logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length)
if err != nil {
- ctx.Error(http.StatusInternalServerError, err.Error())
+ ctx.ServerError("actions.ReadLogs", err)
return
}
@@ -555,49 +592,6 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions
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) {
if !ctx.Repo.CanWrite(unit.TypeActions) {
ctx.Error(http.StatusForbidden, "no permission")
diff --git a/routers/web/web.go b/routers/web/web.go
index 6113a6457b..85e0fdc41e 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1424,7 +1424,6 @@ func registerRoutes(m *web.Router) {
})
m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
m.Post("/approve", reqRepoActionsWriter, actions.Approve)
- m.Get("/artifacts", actions.ArtifactsView)
m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
m.Delete("/artifacts/{artifact_name}", actions.ArtifactsDeleteView)
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
@@ -1626,9 +1625,12 @@ func registerRoutes(m *web.Router) {
}
if !setting.IsProd {
- m.Any("/devtest", devtest.List)
- m.Any("/devtest/fetch-action-test", devtest.FetchActionTest)
- m.Any("/devtest/{sub}", devtest.Tmpl)
+ m.Group("/devtest", func() {
+ m.Any("", devtest.List)
+ 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) {
diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl
new file mode 100644
index 0000000000..1fa71c0e5f
--- /dev/null
+++ b/templates/devtest/repo-action-view.tmpl
@@ -0,0 +1,30 @@
+{{template "base/head" .}}
+
+{{template "base/footer" .}}
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 7adc29ad41..909308262e 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -2,10 +2,22 @@
import {SvgIcon} from '../svg.ts';
import ActionRunStatus from './ActionRunStatus.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 {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 = {
name: 'RepoActionView',
@@ -23,7 +35,7 @@ const sfc = {
data() {
return {
// internal state
- loading: false,
+ loadingAbortController: null,
intervalID: null,
currentJobStepsStates: [],
artifacts: [],
@@ -89,9 +101,7 @@ const sfc = {
// 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();
- this.intervalID = setInterval(() => {
- this.loadJob();
- }, 1000);
+ this.intervalID = setInterval(() => this.loadJob(), 1000);
document.body.addEventListener('click', this.closeDropdown);
this.hashChangeListener();
window.addEventListener('hashchange', this.hashChangeListener);
@@ -113,38 +123,44 @@ const sfc = {
methods: {
// get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
- getLogsContainer(idx) {
- const el = this.$refs.logs[idx];
+ getLogsContainer(stepIndex: number) {
+ const el = this.$refs.logs[stepIndex];
return el._stepLogsActiveContainer ?? el;
},
// begin a log group
- beginLogGroup(idx) {
- const el = this.$refs.logs[idx];
-
- const elJobLogGroup = document.createElement('div');
- elJobLogGroup.classList.add('job-log-group');
-
- const elJobLogGroupSummary = document.createElement('div');
- elJobLogGroupSummary.classList.add('job-log-group-summary');
-
- const elJobLogList = document.createElement('div');
- elJobLogList.classList.add('job-log-list');
-
- elJobLogGroup.append(elJobLogGroupSummary);
- elJobLogGroup.append(elJobLogList);
+ beginLogGroup(stepIndex: number, startTime: number, line: LogLine) {
+ const el = this.$refs.logs[stepIndex];
+ const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
+ this.createLogLine(stepIndex, startTime, {
+ index: line.index,
+ timestamp: line.timestamp,
+ message: line.message.substring(LogLinePrefixGroup.length),
+ }),
+ );
+ 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(idx) {
- const el = this.$refs.logs[idx];
+ endLogGroup(stepIndex: number, startTime: number, line: LogLine) {
+ const el = this.$refs.logs[stepIndex];
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
- toggleStepLogs(idx) {
+ toggleStepLogs(idx: number) {
this.currentJobStepsStates[idx].expanded = !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
@@ -156,62 +172,53 @@ const sfc = {
POST(`${this.run.link}/approve`);
},
- createLogLine(line, startTime, stepIndex) {
- const div = document.createElement('div');
- div.classList.add('job-log-line');
- div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`);
- div._jobLogTime = line.timestamp;
+ createLogLine(stepIndex: number, startTime: number, line: LogLine) {
+ const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
+ String(line.index),
+ );
- const lineNumber = document.createElement('a');
- lineNumber.classList.add('line-num', 'muted');
- lineNumber.textContent = line.index;
- lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${line.index}`);
- div.append(lineNumber);
+ const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
+ formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
+ );
+
+ 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']);
- // 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']);
- const logMessage = document.createElement('span');
- logMessage.className = 'log-msg';
- logMessage.innerHTML = renderAnsi(line.message);
- div.append(logTimeStamp);
- div.append(logMessage);
- div.append(logTimeSeconds);
-
- return div;
+ return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
+ lineNum, logTimeStamp, logMsg, logTimeSeconds,
+ );
},
- appendLogs(stepIndex, logLines, startTime) {
+ appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
for (const line of logLines) {
- // TODO: group support: ##[group]GroupTitle , ##[endgroup]
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() {
- const resp = await GET(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);
- return await resp.json();
- },
-
- async deleteArtifact(name) {
+ 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.loadJob();
+ await this.loadJobForce();
},
- async fetchJob() {
+ 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.
@@ -219,30 +226,27 @@ const sfc = {
return {step: idx, cursor: it.cursor, expanded: it.expanded};
});
const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {
+ signal: abortController.signal,
data: {logCursors},
});
return await resp.json();
},
+ async loadJobForce() {
+ this.loadingAbortController?.abort();
+ this.loadingAbortController = null;
+ await this.loadJob();
+ },
+
async loadJob() {
- if (this.loading) return;
+ if (this.loadingAbortController) return;
+ const abortController = new AbortController();
+ this.loadingAbortController = abortController;
try {
- this.loading = true;
+ const job = await this.fetchJobData(abortController);
+ if (this.loadingAbortController !== abortController) return;
- let 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.artifacts = job.artifacts || [];
this.run = job.state.run;
this.currentJob = job.state.currentJob;
@@ -254,26 +258,30 @@ const sfc = {
}
}
// 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
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) {
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 {
- this.loading = false;
+ if (this.loadingAbortController === abortController) this.loadingAbortController = null;
}
},
- isDone(status) {
+ isDone(status: RunStatus) {
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
},
- isExpandable(status) {
+ isExpandable(status: RunStatus) {
return ['success', 'running', 'failure', 'cancelled'].includes(status);
},
@@ -281,7 +289,7 @@ const sfc = {
if (this.menuVisible) this.menuVisible = false;
},
- toggleTimeDisplay(type) {
+ toggleTimeDisplay(type: string) {
this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
for (const el of this.$refs.steps.querySelectorAll(`.log-time-${type}`)) {
toggleElem(el, this.timeVisible[`log-time-${type}`]);
@@ -294,7 +302,7 @@ const sfc = {
const outerEl = document.querySelector('.full.height');
const actionBodyEl = document.querySelector('.action-view-body');
const headerEl = document.querySelector('#navbar');
- const contentEl = document.querySelector('.page-content.repository');
+ const contentEl = document.querySelector('.page-content');
const footerEl = document.querySelector('.page-footer');
toggleElem(headerEl, !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,
// 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('body > div.full.height');
if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
const view = createApp(sfc, {
@@ -858,7 +866,7 @@ export function initRepositoryActionView() {
white-space: nowrap;
}
-.job-step-section .job-step-logs .job-log-line .log-msg {
+.job-step-logs .job-log-line .log-msg {
flex: 1;
word-break: break-all;
white-space: break-spaces;
@@ -884,15 +892,18 @@ export function initRepositoryActionView() {
border-radius: 0;
}
-/* TODO: group support
-
-.job-log-group {
-
+.job-log-group .job-log-list .job-log-line .log-msg {
+ margin-left: 2em;
}
+
.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;
+}