| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981 |
- <script lang="ts">
- import {SvgIcon} from '../svg.ts';
- import ActionRunStatus from './ActionRunStatus.vue';
- import {defineComponent, type PropType} from 'vue';
- import {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';
- import type {IntervalId} from '../types.ts';
- import {toggleFullScreen} from '../utils.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 LogLinePrefixesGroup = ['::group::', '##[group]'];
- const LogLinePrefixesEndGroup = ['::endgroup::', '##[endgroup]'];
-
- type LogLineCommand = {
- name: 'group' | 'endgroup',
- prefix: string,
- }
-
- type Job = {
- id: number;
- name: string;
- status: RunStatus;
- canRerun: boolean;
- duration: string;
- }
-
- type Step = {
- summary: string,
- duration: string,
- status: RunStatus,
- }
-
- function parseLineCommand(line: LogLine): LogLineCommand | null {
- for (const prefix of LogLinePrefixesGroup) {
- if (line.message.startsWith(prefix)) {
- return {name: 'group', prefix};
- }
- }
- for (const prefix of LogLinePrefixesEndGroup) {
- if (line.message.startsWith(prefix)) {
- return {name: 'endgroup', prefix};
- }
- }
- return null;
- }
-
- function isLogElementInViewport(el: Element): boolean {
- const rect = el.getBoundingClientRect();
- return rect.top >= 0 && rect.bottom <= window.innerHeight; // only check height but not width
- }
-
- type LocaleStorageOptions = {
- autoScroll: boolean;
- expandRunning: boolean;
- };
-
- function getLocaleStorageOptions(): LocaleStorageOptions {
- try {
- const optsJson = localStorage.getItem('actions-view-options');
- if (optsJson) return JSON.parse(optsJson);
- } catch {}
- // if no options in localStorage, or failed to parse, return default options
- return {autoScroll: true, expandRunning: false};
- }
-
- export default defineComponent({
- name: 'RepoActionView',
- components: {
- SvgIcon,
- ActionRunStatus,
- },
- props: {
- runIndex: {
- type: String,
- default: '',
- },
- jobIndex: {
- type: String,
- default: '',
- },
- actionsURL: {
- type: String,
- default: '',
- },
- locale: {
- type: Object as PropType<Record<string, any>>,
- default: null,
- },
- },
-
- data() {
- const {autoScroll, expandRunning} = getLocaleStorageOptions();
- return {
- // internal state
- loadingAbortController: null as AbortController | null,
- intervalID: null as IntervalId | null,
- currentJobStepsStates: [] as Array<Record<string, any>>,
- artifacts: [] as Array<Record<string, any>>,
- menuVisible: false,
- isFullScreen: false,
- timeVisible: {
- 'log-time-stamp': false,
- 'log-time-seconds': false,
- },
- optionAlwaysAutoScroll: autoScroll ?? false,
- optionAlwaysExpandRunning: expandRunning ?? false,
-
- // provided by backend
- run: {
- link: '',
- title: '',
- titleHTML: '',
- status: '' as RunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
- canCancel: false,
- canApprove: false,
- canRerun: false,
- canDeleteArtifact: false,
- done: false,
- workflowID: '',
- workflowLink: '',
- isSchedule: false,
- jobs: [
- // {
- // id: 0,
- // name: '',
- // status: '',
- // canRerun: false,
- // duration: '',
- // },
- ] as Array<Job>,
- commit: {
- localeCommit: '',
- localePushedBy: '',
- shortSHA: '',
- link: '',
- pusher: {
- displayName: '',
- link: '',
- },
- branch: {
- name: '',
- link: '',
- isDeleted: false,
- },
- },
- },
- currentJob: {
- title: '',
- detail: '',
- steps: [
- // {
- // summary: '',
- // duration: '',
- // status: '',
- // }
- ] as Array<Step>,
- },
- };
- },
-
- watch: {
- optionAlwaysAutoScroll() {
- this.saveLocaleStorageOptions();
- },
- optionAlwaysExpandRunning() {
- this.saveLocaleStorageOptions();
- },
- },
-
- async mounted() {
- // 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);
- document.body.addEventListener('click', this.closeDropdown);
- this.hashChangeListener();
- window.addEventListener('hashchange', this.hashChangeListener);
- },
-
- beforeUnmount() {
- document.body.removeEventListener('click', this.closeDropdown);
- window.removeEventListener('hashchange', this.hashChangeListener);
- },
-
- 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: {
- saveLocaleStorageOptions() {
- const opts: LocaleStorageOptions = {autoScroll: this.optionAlwaysAutoScroll, expandRunning: this.optionAlwaysExpandRunning};
- localStorage.setItem('actions-view-options', JSON.stringify(opts));
- },
-
- // get the job step logs container ('.job-step-logs')
- getJobStepLogsContainer(stepIndex: number): HTMLElement {
- return (this.$refs.logs as any)[stepIndex];
- },
-
- // get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
- getActiveLogsContainer(stepIndex: number): HTMLElement {
- const el = this.getJobStepLogsContainer(stepIndex);
- // @ts-expect-error - _stepLogsActiveContainer is a custom property
- return el._stepLogsActiveContainer ?? el;
- },
- // begin a log group
- beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
- const el = (this.$refs.logs as any)[stepIndex];
- const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
- this.createLogLine(stepIndex, startTime, {
- index: line.index,
- timestamp: line.timestamp,
- message: line.message.substring(cmd.prefix.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(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
- const el = (this.$refs.logs as any)[stepIndex];
- el._stepLogsActiveContainer = null;
- el.append(this.createLogLine(stepIndex, startTime, {
- index: line.index,
- timestamp: line.timestamp,
- message: line.message.substring(cmd.prefix.length),
- }));
- },
-
- // show/hide the step logs for a step
- toggleStepLogs(idx: number) {
- 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
- }
- },
- // cancel a run
- cancelRun() {
- POST(`${this.run.link}/cancel`);
- },
- // approve a run
- approveRun() {
- POST(`${this.run.link}/approve`);
- },
-
- createLogLine(stepIndex: number, startTime: number, line: LogLine) {
- 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 = 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"
- );
-
- toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
- toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
-
- return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
- lineNum, logTimeStamp, logMsg, logTimeSeconds,
- );
- },
-
- shouldAutoScroll(stepIndex: number): boolean {
- if (!this.optionAlwaysAutoScroll) return false;
- 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);
- },
-
- appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
- for (const line of logLines) {
- const el = this.getActiveLogsContainer(stepIndex);
- const cmd = parseLineCommand(line);
- if (cmd?.name === 'group') {
- this.beginLogGroup(stepIndex, startTime, line, cmd);
- continue;
- } else if (cmd?.name === 'endgroup') {
- this.endLogGroup(stepIndex, startTime, line, cmd);
- continue;
- }
- el.append(this.createLogLine(stepIndex, startTime, line));
- }
- },
-
- 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.loadJobForce();
- },
-
- 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.
- // 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 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.loadingAbortController) return;
- const abortController = new AbortController();
- this.loadingAbortController = abortController;
- try {
- const isFirstLoad = !this.run.status;
- const job = await this.fetchJobData(abortController);
- if (this.loadingAbortController !== abortController) return;
-
- this.artifacts = job.artifacts || [];
- this.run = job.state.run;
- this.currentJob = job.state.currentJob;
-
- // 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';
- if (!this.currentJobStepsStates[i]) {
- // initial states for job steps
- this.currentJobStepsStates[i] = {cursor: null, expanded};
- }
- }
-
- // find the step indexes that need to auto-scroll
- const autoScrollStepIndexes = new Map<number, boolean>();
- for (const logs of job.logs.stepsLog ?? []) {
- if (autoScrollStepIndexes.has(logs.step)) continue;
- autoScrollStepIndexes.set(logs.step, this.shouldAutoScroll(logs.step));
- }
-
- // append logs to the UI
- 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.started, logs.lines);
- }
-
- // auto-scroll to the last log line of the last step
- let autoScrollJobStepElement: HTMLElement;
- for (let stepIndex = 0; stepIndex < this.currentJob.steps.length; stepIndex++) {
- if (!autoScrollStepIndexes.get(stepIndex)) continue;
- autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
- }
- autoScrollJobStepElement?.lastElementChild.scrollIntoView({behavior: 'smooth', block: 'nearest'});
-
- // clear the interval timer if the job is done
- 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 {
- if (this.loadingAbortController === abortController) this.loadingAbortController = null;
- }
- },
-
- isDone(status: RunStatus) {
- return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
- },
-
- isExpandable(status: RunStatus) {
- return ['success', 'running', 'failure', 'cancelled'].includes(status);
- },
-
- closeDropdown() {
- if (this.menuVisible) this.menuVisible = false;
- },
-
- 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}`)) {
- toggleElem(el, this.timeVisible[`log-time-${type}`]);
- }
- },
-
- toggleFullScreen() {
- this.isFullScreen = !this.isFullScreen;
- toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
- },
- async hashChangeListener() {
- const selectedLogStep = window.location.hash;
- if (!selectedLogStep) return;
- const [_, step, _line] = selectedLogStep.split('-');
- const stepNum = Number(step);
- if (!this.currentJobStepsStates[stepNum]) return;
- if (!this.currentJobStepsStates[stepNum].expanded && this.currentJobStepsStates[stepNum].cursor === null) {
- this.currentJobStepsStates[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 this.loadJob();
- }
- const logLine = (this.$refs.steps as HTMLElement).querySelector(selectedLogStep);
- if (!logLine) return;
- logLine.querySelector<HTMLAnchorElement>('.line-num').click();
- },
- },
- });
- </script>
- <template>
- <!-- make the view container full width to make users easier to read logs -->
- <div class="ui fluid 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"/>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
- </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 link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
- {{ locale.rerun_all }}
- </button>
- </div>
- <div class="action-commit-summary">
- <span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
- <template v-if="run.isSchedule">
- {{ locale.scheduled }}
- </template>
- <template v-else>
- {{ locale.commit }}
- <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>
- {{ locale.pushedBy }}
- <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
- </template>
- <span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">
- <span v-if="run.commit.branch.isDeleted" class="gt-ellipsis tw-line-through" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</span>
- <a v-else class="gt-ellipsis" :href="run.commit.branch.link" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</a>
- </span>
- </div>
- </div>
- <div class="action-view-body">
- <div class="action-view-left">
- <div class="job-group-section">
- <div class="job-brief-list">
- <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id">
- <div class="job-brief-item-left">
- <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
- <span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
- </div>
- <span class="job-brief-item-right">
- <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun"/>
- <span class="step-summary-duration">{{ job.duration }}</span>
- </span>
- </a>
- </div>
- </div>
- <div class="job-artifacts" v-if="artifacts.length > 0">
- <div class="job-artifacts-title">
- {{ locale.artifactsTitle }}
- </div>
- <ul class="job-artifacts-list">
- <template v-for="artifact in artifacts" :key="artifact.name">
- <li class="job-artifacts-item">
- <template v-if="artifact.status !== 'expired'">
- <a class="flex-text-inline" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
- <SvgIcon name="octicon-file" class="text black"/>
- <span class="gt-ellipsis">{{ artifact.name }}</span>
- </a>
- <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
- <SvgIcon name="octicon-trash" class="text black"/>
- </a>
- </template>
- <span v-else class="flex-text-inline text light grey">
- <SvgIcon name="octicon-file"/>
- <span class="gt-ellipsis">{{ artifact.name }}</span>
- <span class="ui label text light grey tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
- </span>
- </li>
- </template>
- </ul>
- </div>
- </div>
-
- <div class="action-view-right">
- <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="btn gt-interact-bg tw-p-2">
- <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="toggleFullScreen()">
- <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/'+jobIndex+'/logs'" target="_blank">
- <i class="icon"><SvgIcon name="octicon-download"/></i>
- {{ locale.downloadLogs }}
- </a>
- </div>
- </div>
- </div>
- </div>
- <div class="job-step-container" ref="steps" 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
- 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="tw-mr-2 circular-spin"/>
- <SvgIcon v-else :name="currentJobStepsStates[i].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="logs" v-show="currentJobStepsStates[i].expanded"/>
- </div>
- </div>
- </div>
- </div>
- </div>
- </template>
- <style scoped>
- .action-view-body {
- padding-top: 12px;
- padding-bottom: 12px;
- display: flex;
- gap: 12px;
- }
-
- /* ================ */
- /* action view header */
-
- .action-view-header {
- margin-top: 8px;
- }
-
- .action-info-summary {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- }
-
- .action-info-summary-title {
- display: flex;
- align-items: center;
- gap: 0.5em;
- }
-
- .action-info-summary-title-text {
- font-size: 20px;
- margin: 0;
- flex: 1;
- overflow-wrap: anywhere;
- }
-
- .action-info-summary .ui.button {
- margin: 0;
- white-space: nowrap;
- }
-
- .action-commit-summary {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
- margin-left: 28px;
- }
-
- @media (max-width: 767.98px) {
- .action-commit-summary {
- margin-left: 0;
- margin-top: 8px;
- }
- }
-
- /* ================ */
- /* action view left */
-
- .action-view-left {
- width: 30%;
- max-width: 400px;
- position: sticky;
- top: 12px;
- max-height: 100vh;
- overflow-y: auto;
- background: var(--color-body);
- z-index: 2; /* above .job-info-header */
- }
-
- @media (max-width: 767.98px) {
- .action-view-left {
- position: static; /* can not sticky because multiple jobs would overlap into right view */
- }
- }
-
- .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;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
-
- .job-artifacts-list {
- padding-left: 12px;
- list-style: none;
- }
-
- .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;
- color: var(--color-text);
- }
-
- .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;
- }
-
- .job-brief-item .job-brief-item-left {
- display: flex;
- width: 100%;
- min-width: 0;
- }
-
- .job-brief-item .job-brief-item-left span {
- display: flex;
- align-items: center;
- }
-
- .job-brief-item .job-brief-item-left .job-brief-name {
- display: block;
- width: 70%;
- }
-
- .job-brief-item .job-brief-item-right {
- display: flex;
- align-items: center;
- }
-
- /* ================ */
- /* action view right */
-
- .action-view-right {
- flex: 1;
- color: var(--color-console-fg-subtle);
- max-height: 100%;
- width: 70%;
- display: flex;
- flex-direction: column;
- border: 1px solid var(--color-console-border);
- border-radius: var(--border-radius);
- background: var(--color-console-bg);
- align-self: flex-start;
- }
-
- /* 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 */
-
- .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;
- }
-
- @media (max-width: 767.98px) {
- .action-view-body {
- flex-direction: column;
- }
- .action-view-left, .action-view-right {
- width: 100%;
- }
- .action-view-left {
- max-width: none;
- }
- }
- </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;
- }
-
- /* 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>
|