<script>import {SvgIcon} from '../svg.js';import ActionRunStatus from './ActionRunStatus.vue';import {createApp} from 'vue';import {toggleElem} from '../utils/dom.js';import {formatDatetime} from '../utils/time.js';import {renderAnsi} from '../render/ansi.js';import {GET, POST, DELETE} from '../modules/fetch.js'; const sfc = { name: 'RepoActionView', components: { SvgIcon, ActionRunStatus, }, props: { runIndex: String, jobIndex: String, actionsURL: String, workflowName: String, workflowURL: String, locale: Object, }, data() { return { // internal state loading: false, needLoadingWithLogCursors: null, intervalID: null, currentJobStepsStates: [], artifacts: [], menuVisible: false, isFullScreen: false, timeVisible: { 'log-time-stamp': false, 'log-time-seconds': false, }, // provided by backend run: { link: '', title: '', titleHTML: '', status: '', canCancel: false, canApprove: false, canRerun: false, done: false, jobs: [ // { // id: 0, // name: '', // status: '', // canRerun: false, // duration: '', // }, ], commit: { localeCommit: '', localePushedBy: '', localeWorkflow: '', shortSHA: '', link: '', pusher: { displayName: '', link: '', }, branch: { name: '', link: '', }, }, }, currentJob: { title: '', detail: '', steps: [ // { // summary: '', // duration: '', // status: '', // } ], }, }; }, 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: { // show/hide the step logs for a step toggleStepLogs(idx) { this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded; if (this.currentJobStepsStates[idx].expanded) { // request data load immediately instead of waiting for next timer interval (which, if the job is done, will // never happen because the interval will have been disabled) this.loadJob(); } }, // cancel a run cancelRun() { POST(`${this.run.link}/cancel`); }, // approve a run approveRun() { POST(`${this.run.link}/approve`); }, // show/hide the step logs for a group toggleGroupLogs(event) { const line = event.target.parentElement; const list = line.nextSibling; if (event.newState === 'open') { list.classList.remove('hidden'); } else { list.classList.add('hidden'); } }, createLogLine(line, startTime, stepIndex, group) { const div = document.createElement('div'); div.classList.add('job-log-line'); div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`); div._jobLogTime = line.timestamp; 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); // 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']); let logMessage = document.createElement('span'); logMessage.innerHTML = renderAnsi(line.message); if (group.isHeader) { const details = document.createElement('details'); details.addEventListener('toggle', this.toggleGroupLogs); const summary = document.createElement('summary'); summary.append(logMessage); details.append(summary); logMessage = details; } logMessage.className = 'log-msg'; logMessage.style.paddingLeft = `${group.depth}em`; div.append(logTimeStamp); div.append(logMessage); div.append(logTimeSeconds); return div; }, appendLogs(stepIndex, logLines, startTime) { const groupStack = []; const container = this.$refs.logs[stepIndex]; for (const line of logLines) { const el = groupStack.length > 0 ? groupStack[groupStack.length - 1] : container; const group = { depth: groupStack.length, isHeader: false, }; if (line.message.startsWith('##[group]')) { group.isHeader = true; const logLine = this.createLogLine( { ...line, message: line.message.substring(9), }, startTime, stepIndex, group, ); logLine.setAttribute('data-group', group.index); el.append(logLine); const list = document.createElement('div'); list.classList.add('job-log-list', 'hidden'); list.setAttribute('data-group', group.index); groupStack.push(list); el.append(list); } else if (line.message.startsWith('##[endgroup]')) { groupStack.pop(); } else { el.append(this.createLogLine(line, startTime, stepIndex, group)); } } }, async fetchArtifacts() { const resp = await GET(`${this.actionsURL}/runs/${this.runIndex}/artifacts`); return await resp.json(); }, async deleteArtifact(name) { if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return; await DELETE(`${this.run.link}/artifacts/${name}`); await this.loadJob(); }, getLogCursors() { return 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}; }); }, async fetchJob(logCursors) { const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, { data: {logCursors}, }); return await resp.json(); }, async loadJob() { let myLoadingLogCursors = this.getLogCursors(); if (this.loading) { // loadJob is already executing; but it's possible that our log cursor request has changed since it started. If // the interval load is active, that problem would solve itself, but if it isn't (say we're viewing a "done" // job), then the change to the requested cursors may never be loaded. To address this we set our newest // requested log cursors into a state property and rely on loadJob to retry at the end of its execution if it // notices these have changed. this.needLoadingWithLogCursors = myLoadingLogCursors; return; } try { this.loading = true; // Since no async operations occurred since fetching myLoadingLogCursors, we can be sure that we have the most // recent needed log cursors, so we can reset needLoadingWithLogCursors -- it could be stale if exceptions // occurred in previous load attempts. this.needLoadingWithLogCursors = null; let job, artifacts; while (true) { try { [job, artifacts] = await Promise.all([ this.fetchJob(myLoadingLogCursors), this.fetchArtifacts(), // refresh artifacts if upload-artifact step done ]); } catch (err) { if (err instanceof TypeError) return; // avoid network error while unloading page throw err; } // We can be done as long as needLoadingWithLogCursors is null, or the same as what we just loaded. if (this.needLoadingWithLogCursors === null || JSON.stringify(this.needLoadingWithLogCursors) === JSON.stringify(myLoadingLogCursors)) { this.needLoadingWithLogCursors = null; break; } // Otherwise we need to retry that. myLoadingLogCursors = this.needLoadingWithLogCursors; } this.artifacts = artifacts['artifacts'] || []; // save the state to Vue data, then the UI will be updated 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++) { 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 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); } if (this.run.done && this.intervalID) { clearInterval(this.intervalID); this.intervalID = null; } } finally { this.loading = false; } }, isDone(status) { return ['success', 'skipped', 'failure', 'cancelled'].includes(status); }, isExpandable(status) { return ['success', 'running', 'failure', 'cancelled'].includes(status); }, closeDropdown() { if (this.menuVisible) this.menuVisible = false; }, toggleTimeDisplay(type) { 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}`]); } }, 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); } }, async hashChangeListener() { const selectedLogStep = window.location.hash; if (!selectedLogStep) return; const [_, step, _line] = selectedLogStep.split('-'); if (!this.currentJobStepsStates[step]) return; if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) { this.currentJobStepsStates[step].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.querySelector(selectedLogStep); if (!logLine) return; logLine.querySelector('.line-num').click(); }, },}; 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'), workflowName: el.getAttribute('data-workflow-name'), workflowURL: el.getAttribute('data-workflow-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'), areYouSure: el.getAttribute('data-locale-are-you-sure'), confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'), 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'), downloadLogs: el.getAttribute('data-locale-download-logs'), 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);}</script><template> <div class="ui container fluid padded 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"/> <!-- 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 tw-mr-0 tw-whitespace-nowrap link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun"> {{ locale.rerun_all }} </button> </div> <div class="action-summary"> {{ run.commit.localeCommit }} <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a> {{ run.commit.localePushedBy }} <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a> <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 class="action-summary"> {{ run.commit.localeWorkflow }} <a class="muted" :href="workflowURL">{{ workflowName }}</a> </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-3 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"> <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name"> <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }} </a> <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete"> <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/> </a> </li> </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"> <button class="btn gt-interact-bg tw-p-2"> <SvgIcon name="octicon-gear" :size="18"/>Lines 1–500 of 938. Load more
<script> import {SvgIcon} from '../svg.js'; import ActionRunStatus from './ActionRunStatus.vue'; import {createApp} from 'vue'; import {toggleElem} from '../utils/dom.js'; import {formatDatetime} from '../utils/time.js'; import {renderAnsi} from '../render/ansi.js'; import {GET, POST, DELETE} from '../modules/fetch.js'; const sfc = { name: 'RepoActionView', components: { SvgIcon, ActionRunStatus, }, props: { runIndex: String, jobIndex: String, actionsURL: String, workflowName: String, workflowURL: String, locale: Object, }, data() { return { // internal state loading: false, needLoadingWithLogCursors: null, intervalID: null, currentJobStepsStates: [], artifacts: [], menuVisible: false, isFullScreen: false, timeVisible: { 'log-time-stamp': false, 'log-time-seconds': false, }, // provided by backend run: { link: '', title: '', titleHTML: '', status: '', canCancel: false, canApprove: false, canRerun: false, done: false, jobs: [ // { // id: 0, // name: '', // status: '', // canRerun: false, // duration: '', // }, ], commit: { localeCommit: '', localePushedBy: '', localeWorkflow: '', shortSHA: '', link: '', pusher: { displayName: '', link: '', }, branch: { name: '', link: '', }, }, }, currentJob: { title: '', detail: '', steps: [ // { // summary: '', // duration: '', // status: '', // } ], }, }; }, 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: { // show/hide the step logs for a step toggleStepLogs(idx) { this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded; if (this.currentJobStepsStates[idx].expanded) { // request data load immediately instead of waiting for next timer interval (which, if the job is done, will // never happen because the interval will have been disabled) this.loadJob(); } }, // cancel a run cancelRun() { POST(`${this.run.link}/cancel`); }, // approve a run approveRun() { POST(`${this.run.link}/approve`); }, // show/hide the step logs for a group toggleGroupLogs(event) { const line = event.target.parentElement; const list = line.nextSibling; if (event.newState === 'open') { list.classList.remove('hidden'); } else { list.classList.add('hidden'); } }, createLogLine(line, startTime, stepIndex, group) { const div = document.createElement('div'); div.classList.add('job-log-line'); div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`); div._jobLogTime = line.timestamp; 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); // 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']); let logMessage = document.createElement('span'); logMessage.innerHTML = renderAnsi(line.message); if (group.isHeader) { const details = document.createElement('details'); details.addEventListener('toggle', this.toggleGroupLogs); const summary = document.createElement('summary'); summary.append(logMessage); details.append(summary); logMessage = details; } logMessage.className = 'log-msg'; logMessage.style.paddingLeft = `${group.depth}em`; div.append(logTimeStamp); div.append(logMessage); div.append(logTimeSeconds); return div; }, appendLogs(stepIndex, logLines, startTime) { const groupStack = []; const container = this.$refs.logs[stepIndex]; for (const line of logLines) { const el = groupStack.length > 0 ? groupStack[groupStack.length - 1] : container; const group = { depth: groupStack.length, isHeader: false, }; if (line.message.startsWith('##[group]')) { group.isHeader = true; const logLine = this.createLogLine( { ...line, message: line.message.substring(9), }, startTime, stepIndex, group, ); logLine.setAttribute('data-group', group.index); el.append(logLine); const list = document.createElement('div'); list.classList.add('job-log-list', 'hidden'); list.setAttribute('data-group', group.index); groupStack.push(list); el.append(list); } else if (line.message.startsWith('##[endgroup]')) { groupStack.pop(); } else { el.append(this.createLogLine(line, startTime, stepIndex, group)); } } }, async fetchArtifacts() { const resp = await GET(`${this.actionsURL}/runs/${this.runIndex}/artifacts`); return await resp.json(); }, async deleteArtifact(name) { if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return; await DELETE(`${this.run.link}/artifacts/${name}`); await this.loadJob(); }, getLogCursors() { return 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}; }); }, async fetchJob(logCursors) { const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, { data: {logCursors}, }); return await resp.json(); }, async loadJob() { let myLoadingLogCursors = this.getLogCursors(); if (this.loading) { // loadJob is already executing; but it's possible that our log cursor request has changed since it started. If // the interval load is active, that problem would solve itself, but if it isn't (say we're viewing a "done" // job), then the change to the requested cursors may never be loaded. To address this we set our newest // requested log cursors into a state property and rely on loadJob to retry at the end of its execution if it // notices these have changed. this.needLoadingWithLogCursors = myLoadingLogCursors; return; } try { this.loading = true; // Since no async operations occurred since fetching myLoadingLogCursors, we can be sure that we have the most // recent needed log cursors, so we can reset needLoadingWithLogCursors -- it could be stale if exceptions // occurred in previous load attempts. this.needLoadingWithLogCursors = null; let job, artifacts; while (true) { try { [job, artifacts] = await Promise.all([ this.fetchJob(myLoadingLogCursors), this.fetchArtifacts(), // refresh artifacts if upload-artifact step done ]); } catch (err) { if (err instanceof TypeError) return; // avoid network error while unloading page throw err; } // We can be done as long as needLoadingWithLogCursors is null, or the same as what we just loaded. if (this.needLoadingWithLogCursors === null || JSON.stringify(this.needLoadingWithLogCursors) === JSON.stringify(myLoadingLogCursors)) { this.needLoadingWithLogCursors = null; break; } // Otherwise we need to retry that. myLoadingLogCursors = this.needLoadingWithLogCursors; } this.artifacts = artifacts['artifacts'] || []; // save the state to Vue data, then the UI will be updated 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++) { 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 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); } if (this.run.done && this.intervalID) { clearInterval(this.intervalID); this.intervalID = null; } } finally { this.loading = false; } }, isDone(status) { return ['success', 'skipped', 'failure', 'cancelled'].includes(status); }, isExpandable(status) { return ['success', 'running', 'failure', 'cancelled'].includes(status); }, closeDropdown() { if (this.menuVisible) this.menuVisible = false; }, toggleTimeDisplay(type) { 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}`]); } }, 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); } }, async hashChangeListener() { const selectedLogStep = window.location.hash; if (!selectedLogStep) return; const [_, step, _line] = selectedLogStep.split('-'); if (!this.currentJobStepsStates[step]) return; if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) { this.currentJobStepsStates[step].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.querySelector(selectedLogStep); if (!logLine) return; logLine.querySelector('.line-num').click(); }, }, }; 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'), workflowName: el.getAttribute('data-workflow-name'), workflowURL: el.getAttribute('data-workflow-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'), areYouSure: el.getAttribute('data-locale-are-you-sure'), confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'), 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'), downloadLogs: el.getAttribute('data-locale-download-logs'), 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); } </script> <template> <div class="ui container fluid padded 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"/> <!-- 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 tw-mr-0 tw-whitespace-nowrap link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun"> {{ locale.rerun_all }} </button> </div> <div class="action-summary"> {{ run.commit.localeCommit }} <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a> {{ run.commit.localePushedBy }} <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a> <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 class="action-summary"> {{ run.commit.localeWorkflow }} <a class="muted" :href="workflowURL">{{ workflowName }}</a> </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-3 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"> <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name"> <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }} </a> <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete"> <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/> </a> </li> </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"> <button class="btn gt-interact-bg tw-p-2"> <SvgIcon name="octicon-gear" :size="18"/>