mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-29 10:57:44 +09:00 
			
		
		
		
	Implement action artifacts server api. This change is used for supporting https://github.com/actions/upload-artifact and https://github.com/actions/download-artifact in gitea actions. It can run sample workflow from doc https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts. The api design is inspired by https://github.com/nektos/act/blob/master/pkg/artifacts/server.go and includes some changes from gitea internal structs and methods. Actions artifacts contains two parts: - Gitea server api and storage (this pr implement basic design without some complex cases supports) - Runner communicate with gitea server api (in comming) Old pr https://github.com/go-gitea/gitea/pull/22345 is outdated after actions merged. I create new pr from main branch.  Add artifacts list in actions workflow page.
		
			
				
	
	
		
			617 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			617 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <div class="action-view-container">
 | |
|     <div class="action-view-header">
 | |
|       <div class="action-info-summary">
 | |
|         <ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
 | |
|         <div class="action-title">
 | |
|           {{ run.title }}
 | |
|         </div>
 | |
|         <button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
 | |
|           <SvgIcon class="gt-mr-2" name="octicon-play" :size="20"/> {{ locale.approve }}
 | |
|         </button>
 | |
|         <button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
 | |
|           <SvgIcon class="gt-mr-2" name="octicon-x-circle-fill" :size="20"/> {{ locale.cancel }}
 | |
|         </button>
 | |
|         <button class="ui basic small compact button secondary" @click="rerun()" v-else-if="run.canRerun">
 | |
|           <SvgIcon class="gt-mr-2" name="octicon-sync" :size="20"/> {{ locale.rerun }}
 | |
|         </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" v-for="(job, index) in run.jobs" :key="job.id">
 | |
|               <a class="job-brief-link" :href="run.link+'/jobs/'+index">
 | |
|                 <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
 | |
|                 <span class="ui text gt-mx-3">{{ job.name }}</span>
 | |
|               </a>
 | |
|               <span class="step-summary-duration">{{ job.duration }}</span>
 | |
|               <button :data-tooltip-content="locale.rerun" class="job-brief-rerun" @click="rerunJob(index)" v-if="job.canRerun">
 | |
|                 <SvgIcon name="octicon-sync" class="ui text black"/>
 | |
|               </button>
 | |
|             </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-title">
 | |
|             {{ currentJob.title }}
 | |
|           </div>
 | |
|           <div class="job-info-header-detail">
 | |
|             {{ currentJob.detail }}
 | |
|           </div>
 | |
|         </div>
 | |
|         <div class="job-step-container">
 | |
|           <div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
 | |
|             <div class="job-step-summary" @click.stop="toggleStepLogs(i)">
 | |
|               <SvgIcon :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">{{ 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 -->
 | |
|             <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';
 | |
| 
 | |
| 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: [],
 | |
| 
 | |
|       // 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);
 | |
|   },
 | |
| 
 | |
|   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) {
 | |
|       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);
 | |
| 
 | |
|       // TODO: Support displaying time optionally
 | |
| 
 | |
|       const logMessage = document.createElement('div');
 | |
|       logMessage.className = 'log-msg';
 | |
|       logMessage.innerHTML = ansiLogToHTML(line.message);
 | |
|       div.append(logMessage);
 | |
| 
 | |
|       return div;
 | |
|     },
 | |
| 
 | |
|     appendLogs(stepIndex, logLines) {
 | |
|       for (const line of logLines) {
 | |
|         // TODO: group support: ##[group]GroupTitle , ##[endgroup]
 | |
|         const el = this.getLogsContainer(stepIndex);
 | |
|         el.append(this.createLogLine(line));
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     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]) {
 | |
|             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);
 | |
|         }
 | |
| 
 | |
|         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,
 | |
|       });
 | |
|     },
 | |
|   },
 | |
| };
 | |
| 
 | |
| 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'),
 | |
|       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;
 | |
|   height: calc(100vh - 266px); /* fine tune this value to make the main view has full height */
 | |
| }
 | |
| 
 | |
| /* ================ */
 | |
| /* action view header */
 | |
| 
 | |
| .action-view-header {
 | |
|   margin: 0 20px 20px 20px;
 | |
| }
 | |
| 
 | |
| .action-info-summary {
 | |
|   font-size: 150%;
 | |
|   height: 20px;
 | |
|   display: flex;
 | |
|   align-items: center;
 | |
|   margin-top: 1rem;
 | |
| }
 | |
| 
 | |
| .action-info-summary .action-title {
 | |
|   padding: 0 5px;
 | |
|   flex: 1;
 | |
| }
 | |
| 
 | |
| .action-commit-summary {
 | |
|   padding: 10px 10px;
 | |
| }
 | |
| 
 | |
| /* ================ */
 | |
| /* action view left */
 | |
| 
 | |
| .action-view-left {
 | |
|   width: 30%;
 | |
|   max-width: 400px;
 | |
|   overflow-y: scroll;
 | |
|   margin-left: 10px;
 | |
| }
 | |
| 
 | |
| .job-group-section .job-group-summary {
 | |
|   margin: 5px 0;
 | |
|   padding: 10px;
 | |
| }
 | |
| 
 | |
| .job-artifacts-title {
 | |
|   font-size: 18px;
 | |
|   margin-top: 16px;
 | |
|   padding: 16px 10px 0px 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-group-section .job-brief-list .job-brief-item {
 | |
|   margin: 5px 0;
 | |
|   padding: 10px;
 | |
|   background: var(--color-info-bg);
 | |
|   border-radius: 5px;
 | |
|   text-decoration: none;
 | |
|   display: flex;
 | |
|   justify-items: center;
 | |
|   flex-wrap: nowrap;
 | |
| }
 | |
| 
 | |
| .job-group-section .job-brief-list .job-brief-item .job-brief-rerun {
 | |
|   float: right;
 | |
|   border: none;
 | |
|   background-color: transparent;
 | |
|   outline: none;
 | |
|   cursor: pointer;
 | |
|   transition: transform 0.2s;
 | |
| }
 | |
| 
 | |
| .job-group-section .job-brief-list .job-brief-item .job-brief-rerun:hover {
 | |
|   transform: scale(130%);
 | |
| }
 | |
| 
 | |
| .job-group-section .job-brief-list .job-brief-item .job-brief-link {
 | |
|   flex-grow: 1;
 | |
|   display: flex;
 | |
| }
 | |
| 
 | |
| .job-group-section .job-brief-list .job-brief-item .job-brief-link span {
 | |
|   display: flex;
 | |
|   align-items: center;
 | |
| }
 | |
| 
 | |
| .job-group-section .job-brief-list .job-brief-item:hover {
 | |
|   background-color: var(--color-secondary);
 | |
| }
 | |
| 
 | |
| /* ================ */
 | |
| /* action view right */
 | |
| 
 | |
| .action-view-right {
 | |
|   flex: 1;
 | |
|   background-color: var(--color-console-bg);
 | |
|   color: var(--color-console-fg);
 | |
|   max-height: 100%;
 | |
|   margin-right: 10px;
 | |
|   display: flex;
 | |
|   flex-direction: column;
 | |
| }
 | |
| 
 | |
| .job-info-header .job-info-header-title {
 | |
|   font-size: 150%;
 | |
|   padding: 10px;
 | |
| }
 | |
| 
 | |
| .job-info-header .job-info-header-detail {
 | |
|   padding: 0 10px 10px;
 | |
|   border-bottom: 1px solid var(--color-grey);
 | |
| }
 | |
| 
 | |
| .job-step-container {
 | |
|   max-height: 100%;
 | |
|   overflow: auto;
 | |
| }
 | |
| 
 | |
| .job-step-container .job-step-summary {
 | |
|   cursor: pointer;
 | |
|   padding: 5px 10px;
 | |
|   display: flex;
 | |
| }
 | |
| 
 | |
| .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 {
 | |
|   background-color: var(--color-black-light);
 | |
| }
 | |
| </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: monospace, monospace;
 | |
| }
 | |
| 
 | |
| .job-step-section .job-step-logs .job-log-line {
 | |
|   display: flex;
 | |
| }
 | |
| 
 | |
| .job-step-section .job-step-logs .job-log-line .line-num {
 | |
|   width: 48px;
 | |
|   color: var(--color-grey-light);
 | |
|   text-align: right;
 | |
|   user-select: none;
 | |
| }
 | |
| 
 | |
| .job-step-section .job-step-logs .job-log-line .log-time {
 | |
|   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>
 |