mirror of
https://github.com/go-gitea/gitea
synced 2024-12-23 18:07:56 +01:00
8e798ebbdf
Backport #25200 by @HesterG An error occurs when clicking on `show full screen` on action page. <img width="1440" alt="Screen Shot 2023-06-12 at 13 06 52" src="https://github.com/go-gitea/gitea/assets/17645053/1d4ded3c-fb77-4dd8-9201-24d0696f96eb"> class name has changed in #25134, so the selector is not working. Enhance the selectors to fix this. Co-authored-by: HesterG <hestergong@gmail.com>
886 lines
26 KiB
Vue
886 lines
26 KiB
Vue
<template>
|
|
<div class="ui container action-view-container">
|
|
<div class="action-view-header">
|
|
<div class="action-info-summary">
|
|
<div class="action-info-summary-title">
|
|
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
|
|
<h2 class="action-info-summary-title-text">
|
|
{{ run.title }}
|
|
</h2>
|
|
</div>
|
|
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
|
|
{{ locale.approve }}
|
|
</button>
|
|
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
|
|
{{ locale.cancel }}
|
|
</button>
|
|
<button class="ui basic small compact button gt-mr-0" @click="rerun()" v-else-if="run.canRerun">
|
|
{{ locale.rerun_all }}
|
|
</button>
|
|
</div>
|
|
<div class="action-commit-summary">
|
|
{{ run.commit.localeCommit }}
|
|
<a :href="run.commit.link">{{ run.commit.shortSHA }}</a>
|
|
<span class="ui label" v-if="run.commit.shortSHA">
|
|
<a :href="run.commit.branch.link">{{ run.commit.branch.name }}</a>
|
|
</span>
|
|
{{ run.commit.localePushedBy }}
|
|
<a :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
|
|
</div>
|
|
</div>
|
|
<div class="action-view-body">
|
|
<div class="action-view-left">
|
|
<div class="job-group-section">
|
|
<div class="job-brief-list">
|
|
<div class="job-brief-item" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id" @mouseenter="onHoverRerunIndex = job.id" @mouseleave="onHoverRerunIndex = -1">
|
|
<a class="job-brief-link" :href="run.link+'/jobs/'+index">
|
|
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
|
|
<span class="job-brief-name gt-mx-3 gt-ellipsis">{{ job.name }}</span>
|
|
</a>
|
|
<span class="job-brief-info">
|
|
<span class="step-summary-duration">{{ job.duration }}</span>
|
|
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun gt-mx-3" @click="rerunJob(index)" v-if="job.canRerun && onHoverRerunIndex === job.id"/>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="job-artifacts" v-if="artifacts.length > 0">
|
|
<div class="job-artifacts-title">
|
|
{{ locale.artifactsTitle }}
|
|
</div>
|
|
<ul class="job-artifacts-list">
|
|
<li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.id">
|
|
<a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.id">
|
|
<SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-view-right">
|
|
<div class="job-info-header">
|
|
<div class="job-info-header-left">
|
|
<h3 class="job-info-header-title">
|
|
{{ 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="btn gt-interact-bg gt-p-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 v-show="timeVisible['log-time-seconds']" name="octicon-check"/></i>
|
|
{{ locale.showLogSeconds }}
|
|
</a>
|
|
<a class="item" @click="toggleTimeDisplay('stamp')">
|
|
<i class="icon"><SvgIcon v-show="timeVisible['log-time-stamp']" name="octicon-check"/></i>
|
|
{{ locale.showTimeStamps }}
|
|
</a>
|
|
<div class="divider"/>
|
|
<a class="item" @click="toggleFullScreen()">
|
|
<i class="icon"><SvgIcon v-show="isFullScreen" name="octicon-check"/></i>
|
|
{{ locale.showFullScreen }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="job-step-container" ref="steps">
|
|
<div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
|
|
<div class="job-step-summary" @click.stop="toggleStepLogs(i)" :class="currentJobStepsStates[i].expanded ? 'selected' : ''">
|
|
<!-- 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[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="gt-mr-3 job-status-rotate"/>
|
|
<SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" class="gt-mr-3"/>
|
|
<ActionRunStatus :status="jobStep.status" class="gt-mr-3"/>
|
|
|
|
<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="logs" v-show="currentJobStepsStates[i].expanded"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import {SvgIcon} from '../svg.js';
|
|
import ActionRunStatus from './ActionRunStatus.vue';
|
|
import {createApp} from 'vue';
|
|
import AnsiToHTML from 'ansi-to-html';
|
|
import {toggleElem} from '../utils/dom.js';
|
|
import {getCurrentLocale} from '../utils.js';
|
|
|
|
const {csrfToken} = window.config;
|
|
|
|
const ansiLogRender = new AnsiToHTML({escapeXML: true});
|
|
|
|
const sfc = {
|
|
name: 'RepoActionView',
|
|
components: {
|
|
SvgIcon,
|
|
ActionRunStatus,
|
|
},
|
|
props: {
|
|
runIndex: String,
|
|
jobIndex: String,
|
|
actionsURL: String,
|
|
locale: Object,
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
// internal state
|
|
loading: false,
|
|
intervalID: null,
|
|
currentJobStepsStates: [],
|
|
artifacts: [],
|
|
onHoverRerunIndex: -1,
|
|
menuVisible: false,
|
|
isFullScreen: false,
|
|
timeVisible: {
|
|
'log-time-stamp': false,
|
|
'log-time-seconds': false,
|
|
},
|
|
|
|
// provided by backend
|
|
run: {
|
|
link: '',
|
|
title: '',
|
|
status: '',
|
|
canCancel: false,
|
|
canApprove: false,
|
|
canRerun: false,
|
|
done: false,
|
|
jobs: [
|
|
// {
|
|
// id: 0,
|
|
// name: '',
|
|
// status: '',
|
|
// canRerun: false,
|
|
// duration: '',
|
|
// },
|
|
],
|
|
commit: {
|
|
localeCommit: '',
|
|
localePushedBy: '',
|
|
shortSHA: '',
|
|
link: '',
|
|
pusher: {
|
|
displayName: '',
|
|
link: '',
|
|
},
|
|
branch: {
|
|
name: '',
|
|
link: '',
|
|
},
|
|
}
|
|
},
|
|
currentJob: {
|
|
title: '',
|
|
detail: '',
|
|
steps: [
|
|
// {
|
|
// summary: '',
|
|
// duration: '',
|
|
// status: '',
|
|
// }
|
|
],
|
|
},
|
|
};
|
|
},
|
|
|
|
mounted() {
|
|
// load job data and then auto-reload periodically
|
|
this.loadJob();
|
|
this.intervalID = setInterval(this.loadJob, 1000);
|
|
document.body.addEventListener('click', this.closeDropdown);
|
|
},
|
|
|
|
beforeUnmount() {
|
|
document.body.removeEventListener('click', this.closeDropdown);
|
|
},
|
|
|
|
unmounted() {
|
|
// clear the interval timer when the component is unmounted
|
|
// even our page is rendered once, not spa style
|
|
if (this.intervalID) {
|
|
clearInterval(this.intervalID);
|
|
this.intervalID = null;
|
|
}
|
|
},
|
|
|
|
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];
|
|
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);
|
|
el._stepLogsActiveContainer = elJobLogList;
|
|
},
|
|
// end a log group
|
|
endLogGroup(idx) {
|
|
const el = this.$refs.logs[idx];
|
|
el._stepLogsActiveContainer = null;
|
|
},
|
|
|
|
// show/hide the step logs for a step
|
|
toggleStepLogs(idx) {
|
|
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
|
|
}
|
|
},
|
|
// rerun a job
|
|
async rerunJob(idx) {
|
|
const jobLink = `${this.run.link}/jobs/${idx}`;
|
|
await this.fetchPost(`${jobLink}/rerun`);
|
|
window.location.href = jobLink;
|
|
},
|
|
// rerun workflow
|
|
async rerun() {
|
|
await this.fetchPost(`${this.run.link}/rerun`);
|
|
window.location.href = this.run.link;
|
|
},
|
|
// cancel a run
|
|
cancelRun() {
|
|
this.fetchPost(`${this.run.link}/cancel`);
|
|
},
|
|
// approve a run
|
|
approveRun() {
|
|
this.fetchPost(`${this.run.link}/approve`);
|
|
},
|
|
|
|
createLogLine(line, startTime) {
|
|
const div = document.createElement('div');
|
|
div.classList.add('job-log-line');
|
|
div._jobLogTime = line.timestamp;
|
|
|
|
const lineNumber = document.createElement('div');
|
|
lineNumber.className = 'line-num';
|
|
lineNumber.textContent = line.index;
|
|
div.append(lineNumber);
|
|
|
|
// for "Show timestamps"
|
|
const logTimeStamp = document.createElement('span');
|
|
logTimeStamp.className = 'log-time-stamp';
|
|
const date = new Date(parseFloat(line.timestamp * 1000));
|
|
const timeStamp = date.toLocaleString(getCurrentLocale(), {timeZoneName: 'short'});
|
|
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 = ansiLogToHTML(line.message);
|
|
div.append(logTimeStamp);
|
|
div.append(logMessage);
|
|
div.append(logTimeSeconds);
|
|
|
|
return div;
|
|
},
|
|
|
|
appendLogs(stepIndex, logLines, startTime) {
|
|
for (const line of logLines) {
|
|
// TODO: group support: ##[group]GroupTitle , ##[endgroup]
|
|
const el = this.getLogsContainer(stepIndex);
|
|
el.append(this.createLogLine(line, startTime));
|
|
}
|
|
},
|
|
|
|
async fetchJob() {
|
|
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.
|
|
// 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 resp = await this.fetchPost(
|
|
`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`,
|
|
JSON.stringify({logCursors}),
|
|
);
|
|
return await resp.json();
|
|
},
|
|
|
|
async loadJob() {
|
|
if (this.loading) return;
|
|
try {
|
|
this.loading = true;
|
|
|
|
// refresh artifacts if upload-artifact step done
|
|
const resp = await this.fetchPost(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);
|
|
const artifacts = await resp.json();
|
|
this.artifacts = artifacts['artifacts'] || [];
|
|
|
|
const response = await this.fetchJob();
|
|
|
|
// save the state to Vue data, then the UI will be updated
|
|
this.run = response.state.run;
|
|
this.currentJob = response.state.currentJob;
|
|
|
|
// sync the currentJobStepsStates to store the job step states
|
|
for (let i = 0; i < this.currentJob.steps.length; i++) {
|
|
if (!this.currentJobStepsStates[i]) {
|
|
// initial states for job steps
|
|
this.currentJobStepsStates[i] = {cursor: null, expanded: false};
|
|
}
|
|
}
|
|
// append logs to the UI
|
|
for (const logs of response.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);
|
|
}
|
|
|
|
if (this.run.done && this.intervalID) {
|
|
clearInterval(this.intervalID);
|
|
this.intervalID = null;
|
|
}
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
|
|
fetchPost(url, body) {
|
|
return fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Csrf-Token': csrfToken,
|
|
},
|
|
body,
|
|
});
|
|
},
|
|
|
|
isDone(status) {
|
|
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
|
},
|
|
|
|
closeDropdown() {
|
|
if (this.menuVisible) this.menuVisible = false;
|
|
},
|
|
|
|
// show at most one of log seconds and timestamp (can be both invisible)
|
|
toggleTimeDisplay(type) {
|
|
const toToggleTypes = [];
|
|
const other = type === 'seconds' ? 'stamp' : 'seconds';
|
|
this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
|
|
toToggleTypes.push(type);
|
|
if (this.timeVisible[`log-time-${type}`] && this.timeVisible[`log-time-${other}`]) {
|
|
this.timeVisible[`log-time-${other}`] = false;
|
|
toToggleTypes.push(other);
|
|
}
|
|
for (const toToggle of toToggleTypes) {
|
|
for (const el of this.$refs.steps.querySelectorAll(`.log-time-${toToggle}`)) {
|
|
toggleElem(el, this.timeVisible[`log-time-${toToggle}`]);
|
|
}
|
|
}
|
|
},
|
|
|
|
toggleFullScreen() {
|
|
this.isFullScreen = !this.isFullScreen;
|
|
const fullScreenEl = document.querySelector('.action-view-right');
|
|
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 footerEl = document.querySelector('.page-footer');
|
|
toggleElem(headerEl, !this.isFullScreen);
|
|
toggleElem(contentEl, !this.isFullScreen);
|
|
toggleElem(footerEl, !this.isFullScreen);
|
|
// move .action-view-right to new parent
|
|
if (this.isFullScreen) {
|
|
outerEl.append(fullScreenEl);
|
|
} else {
|
|
actionBodyEl.append(fullScreenEl);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
export default sfc;
|
|
|
|
export function initRepositoryActionView() {
|
|
const el = document.getElementById('repo-action-view');
|
|
if (!el) return;
|
|
|
|
// 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');
|
|
if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
|
|
|
|
const view = createApp(sfc, {
|
|
runIndex: el.getAttribute('data-run-index'),
|
|
jobIndex: el.getAttribute('data-job-index'),
|
|
actionsURL: el.getAttribute('data-actions-url'),
|
|
locale: {
|
|
approve: el.getAttribute('data-locale-approve'),
|
|
cancel: el.getAttribute('data-locale-cancel'),
|
|
rerun: el.getAttribute('data-locale-rerun'),
|
|
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
|
rerun_all: el.getAttribute('data-locale-rerun-all'),
|
|
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
|
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
|
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
|
|
status: {
|
|
unknown: el.getAttribute('data-locale-status-unknown'),
|
|
waiting: el.getAttribute('data-locale-status-waiting'),
|
|
running: el.getAttribute('data-locale-status-running'),
|
|
success: el.getAttribute('data-locale-status-success'),
|
|
failure: el.getAttribute('data-locale-status-failure'),
|
|
cancelled: el.getAttribute('data-locale-status-cancelled'),
|
|
skipped: el.getAttribute('data-locale-status-skipped'),
|
|
blocked: el.getAttribute('data-locale-status-blocked'),
|
|
},
|
|
}
|
|
});
|
|
view.mount(el);
|
|
}
|
|
|
|
// some unhandled control sequences by AnsiToHTML
|
|
// https://man7.org/linux/man-pages/man4/console_codes.4.html
|
|
const ansiRegexpRemove = /\x1b\[\d+[A-H]/g; // Move cursor, treat them as no-op.
|
|
const ansiRegexpNewLine = /\x1b\[\d?[JK]/g; // Erase display/line, treat them as a Carriage Return
|
|
|
|
function ansiCleanControlSequences(line) {
|
|
if (line.includes('\x1b')) {
|
|
line = line.replace(ansiRegexpRemove, '');
|
|
line = line.replace(ansiRegexpNewLine, '\r');
|
|
}
|
|
return line;
|
|
}
|
|
|
|
export function ansiLogToHTML(line) {
|
|
if (line.endsWith('\r\n')) {
|
|
line = line.substring(0, line.length - 2);
|
|
} else if (line.endsWith('\n')) {
|
|
line = line.substring(0, line.length - 1);
|
|
}
|
|
|
|
// usually we do not need to process control chars like "\033[", let AnsiToHTML do it
|
|
// but AnsiToHTML has bugs, so we need to clean some control sequences first
|
|
line = ansiCleanControlSequences(line);
|
|
|
|
if (!line.includes('\r')) {
|
|
return ansiLogRender.toHtml(line);
|
|
}
|
|
|
|
// handle "\rReading...1%\rReading...5%\rReading...100%",
|
|
// convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%"
|
|
const lines = [];
|
|
for (const part of line.split('\r')) {
|
|
if (part === '') continue;
|
|
const partHtml = ansiLogRender.toHtml(part);
|
|
if (partHtml !== '') {
|
|
lines.push(partHtml);
|
|
}
|
|
}
|
|
// the log message element is with "white-space: break-spaces;", so use "\n" to break lines
|
|
return lines.join('\n');
|
|
}
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.action-view-body {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
/* ================ */
|
|
/* action view header */
|
|
|
|
.action-view-header {
|
|
margin-top: 8px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.action-info-summary {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.action-info-summary-title {
|
|
display: flex;
|
|
}
|
|
|
|
.action-info-summary-title-text {
|
|
font-size: 20px;
|
|
margin: 0 0 0 5px;
|
|
flex: 1;
|
|
}
|
|
|
|
.action-commit-summary {
|
|
display: flex;
|
|
gap: 5px;
|
|
margin: 5px 0 0 25px;
|
|
}
|
|
|
|
.action-view-left, .action-view-right {
|
|
padding-top: 12px;
|
|
padding-bottom: 12px;
|
|
}
|
|
|
|
/* ================ */
|
|
/* action view left */
|
|
|
|
.action-view-left {
|
|
width: 30%;
|
|
max-width: 400px;
|
|
position: sticky;
|
|
top: 0;
|
|
max-height: 100vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.job-group-section .job-group-summary {
|
|
margin: 5px 0;
|
|
padding: 10px;
|
|
}
|
|
|
|
.job-artifacts-title {
|
|
font-size: 18px;
|
|
margin-top: 16px;
|
|
padding: 16px 10px 0 20px;
|
|
border-top: 1px solid var(--color-secondary);
|
|
}
|
|
|
|
.job-artifacts-item {
|
|
margin: 5px 0;
|
|
padding: 6px;
|
|
}
|
|
|
|
.job-artifacts-list {
|
|
padding-left: 12px;
|
|
list-style: none;
|
|
}
|
|
|
|
.job-artifacts-icon {
|
|
padding-right: 3px;
|
|
}
|
|
|
|
.job-brief-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.job-brief-item {
|
|
padding: 10px;
|
|
border-radius: var(--border-radius);
|
|
text-decoration: none;
|
|
display: flex;
|
|
flex-wrap: nowrap;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.job-brief-item:hover {
|
|
background-color: var(--color-hover);
|
|
}
|
|
|
|
.job-brief-item.selected {
|
|
font-weight: var(--font-weight-bold);
|
|
background-color: var(--color-active);
|
|
}
|
|
|
|
.job-brief-item:first-of-type {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.job-brief-item .job-brief-rerun {
|
|
cursor: pointer;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.job-brief-item .job-brief-rerun:hover {
|
|
transform: scale(130%);
|
|
}
|
|
|
|
.job-brief-item .job-brief-link {
|
|
display: flex;
|
|
width: 100%;
|
|
}
|
|
|
|
.job-brief-item .job-brief-link span {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.job-brief-item .job-brief-link .job-brief-name {
|
|
display: block;
|
|
width: 70%;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.job-brief-item .job-brief-link:hover {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.job-brief-item .job-brief-info {
|
|
display: flex;
|
|
align-items: center;
|
|
width: 55px;
|
|
}
|
|
|
|
/* ================ */
|
|
/* action view right */
|
|
|
|
.action-view-right {
|
|
flex: 1;
|
|
color: var(--color-console-fg-subtle);
|
|
max-height: 100%;
|
|
width: 70%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* begin fomantic button overrides */
|
|
|
|
.action-view-right .ui.button,
|
|
.action-view-right .ui.button:focus {
|
|
background: transparent;
|
|
color: var(--color-console-fg-subtle);
|
|
}
|
|
|
|
.action-view-right .ui.button:hover {
|
|
background: var(--color-console-hover-bg);
|
|
color: var(--color-console-fg);
|
|
}
|
|
|
|
.action-view-right .ui.button:active {
|
|
background: var(--color-console-active-bg);
|
|
color: var(--color-console-fg);
|
|
}
|
|
|
|
/* end fomantic button overrides */
|
|
|
|
/* 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 */
|
|
|
|
/* 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-info-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0 12px;
|
|
border-bottom: 1px solid var(--color-console-border);
|
|
background-color: var(--color-console-bg);
|
|
position: sticky;
|
|
top: 0;
|
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
|
height: 60px;
|
|
z-index: 1;
|
|
}
|
|
|
|
.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-step-container {
|
|
background-color: var(--color-console-bg);
|
|
max-height: 100%;
|
|
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
|
}
|
|
|
|
.job-step-container .job-step-summary {
|
|
cursor: pointer;
|
|
padding: 5px 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
user-select: none;
|
|
border-radius: var(--border-radius);
|
|
}
|
|
|
|
.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:hover {
|
|
color: var(--color-console-fg);
|
|
background-color: var(--color-console-hover-bg);
|
|
|
|
}
|
|
|
|
.job-step-container .job-step-summary.selected {
|
|
color: var(--color-console-fg);
|
|
background-color: var(--color-console-active-bg);
|
|
position: sticky;
|
|
top: 60px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.action-view-body {
|
|
flex-direction: column;
|
|
}
|
|
.action-view-left, .action-view-right {
|
|
width: 100%;
|
|
}
|
|
|
|
.action-view-left {
|
|
max-width: none;
|
|
overflow-y: hidden;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* some elements are not managed by vue, so we need to use global style */
|
|
.job-status-rotate {
|
|
animation: job-status-rotate-keyframes 1s linear infinite;
|
|
}
|
|
|
|
@keyframes job-status-rotate-keyframes {
|
|
100% {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.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-step-section .job-step-logs .job-log-line:hover {
|
|
background-color: var(--color-console-hover-bg);
|
|
}
|
|
|
|
/* 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-grey-light);
|
|
text-align: right;
|
|
user-select: none;
|
|
}
|
|
|
|
.log-time-seconds {
|
|
padding-right: 2px;
|
|
}
|
|
|
|
.job-log-line .log-time,
|
|
.log-time-stamp {
|
|
color: var(--color-grey-light);
|
|
margin-left: 10px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.job-step-section .job-step-logs .job-log-line .log-msg {
|
|
flex: 1;
|
|
word-break: break-all;
|
|
white-space: break-spaces;
|
|
margin-left: 10px;
|
|
}
|
|
|
|
/* TODO: group support
|
|
|
|
.job-log-group {
|
|
|
|
}
|
|
.job-log-group-summary {
|
|
|
|
}
|
|
.job-log-list {
|
|
|
|
} */
|
|
</style>
|