mirror of https://github.com/go-gitea/gitea.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
684 lines
23 KiB
684 lines
23 KiB
<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>
|
|
|