mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 11:35:03 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			497 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			497 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <div class="action-view-container">
 | |
|     <div class="action-view-header">
 | |
|       <div class="action-info-summary">
 | |
|         {{ runInfo.title }}
 | |
|       </div>
 | |
|     </div>
 | |
|     <div class="action-view-body">
 | |
|       <div class="action-view-left">
 | |
|         <div class="job-group-section" v-for="(jobGroup, i) in allJobGroups" :key="i">
 | |
|           <div class="job-brief-list">
 | |
|             <a class="job-brief-item" v-for="(job, index) in jobGroup.jobs" :key="job.id" :href="runInfo.htmlurl+'/jobs/'+index">
 | |
|               <SvgIcon name="octicon-check-circle-fill" class="green" v-if="job.status === 'success'"/>
 | |
|               <SvgIcon name="octicon-skip" class="ui text grey" v-else-if="job.status === 'skipped'"/>
 | |
|               <SvgIcon name="octicon-clock" class="ui text yellow" v-else-if="job.status === 'waiting'"/>
 | |
|               <SvgIcon name="octicon-blocked" class="ui text yellow" v-else-if="job.status === 'blocked'"/>
 | |
|               <SvgIcon name="octicon-meter" class="ui text yellow" class-name="job-status-rotate" v-else-if="job.status === 'running'"/>
 | |
|               <SvgIcon name="octicon-x-circle-fill" class="red" v-else/>
 | |
|               {{ job.name }}
 | |
|               <button class="job-brief-rerun" @click="rerunJob(index)" v-if="job.can_rerun">
 | |
|                 <SvgIcon name="octicon-sync" class="ui text black"/>
 | |
|               </button>
 | |
|             </a>
 | |
|           </div>
 | |
|           <button class="ui fluid tiny basic red button" @click="cancelRun()" v-if="runInfo.can_cancel">
 | |
|             Cancel
 | |
|           </button>
 | |
|         </div>
 | |
|       </div>
 | |
| 
 | |
|       <div class="action-view-right">
 | |
|         <div class="job-info-header">
 | |
|           <div class="job-info-header-title">
 | |
|             {{ currentJobInfo.title }}
 | |
|           </div>
 | |
|           <div class="job-info-header-detail">
 | |
|             {{ currentJobInfo.detail }}
 | |
|           </div>
 | |
|         </div>
 | |
|         <div class="job-step-container">
 | |
|           <div class="job-step-section" v-for="(jobStep, i) in currentJobSteps" :key="i">
 | |
|             <div class="job-step-summary" @click.stop="toggleStepLogs(i)">
 | |
|               <SvgIcon name="octicon-chevron-down" class="mr-3" v-show="currentJobStepsStates[i].expanded"/>
 | |
|               <SvgIcon name="octicon-chevron-right" class="mr-3" v-show="!currentJobStepsStates[i].expanded"/>
 | |
| 
 | |
|               <SvgIcon name="octicon-check-circle-fill" class="green mr-3" v-if="jobStep.status === 'success'"/>
 | |
|               <SvgIcon name="octicon-skip" class="ui text grey mr-3" v-else-if="jobStep.status === 'skipped'"/>
 | |
|               <SvgIcon name="octicon-clock" class="ui text yellow mr-3" v-else-if="jobStep.status === 'waiting'"/>
 | |
|               <SvgIcon name="octicon-blocked" class="ui text yellow mr-3" v-else-if="jobStep.status === 'blocked'"/>
 | |
|               <SvgIcon name="octicon-meter" class="ui text yellow mr-3" class-name="job-status-rotate" v-else-if="jobStep.status === 'running'"/>
 | |
|               <SvgIcon name="octicon-x-circle-fill" class="red mr-3 " v-else/>
 | |
| 
 | |
|               <span class="step-summary-msg">{{ jobStep.summary }}</span>
 | |
|               <span class="step-summary-dur">{{ formatDuration(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="elJobStepLogs" v-show="currentJobStepsStates[i].expanded"/>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|     </div>
 | |
|   </div>
 | |
| </template>
 | |
| 
 | |
| <script>
 | |
| import {SvgIcon} from '../svg.js';
 | |
| import {createApp} from 'vue';
 | |
| import AnsiToHTML from 'ansi-to-html';
 | |
| 
 | |
| const {csrfToken} = window.config;
 | |
| 
 | |
| const sfc = {
 | |
|   name: 'RepoActionView',
 | |
|   components: {
 | |
|     SvgIcon,
 | |
|   },
 | |
|   props: {
 | |
|     runIndex: Number,
 | |
|     jobIndex: Number,
 | |
|   },
 | |
| 
 | |
|   data() {
 | |
|     return {
 | |
|       // internal state
 | |
|       loading: false,
 | |
|       currentJobStepsStates: [],
 | |
| 
 | |
|       // provided by backend
 | |
|       runInfo: {},
 | |
|       allJobGroups: [],
 | |
|       currentJobInfo: {},
 | |
|       currentJobSteps: [],
 | |
|     };
 | |
|   },
 | |
| 
 | |
|   created() {
 | |
|     this.ansiToHTML = new AnsiToHTML({escapeXML: true});
 | |
|   },
 | |
| 
 | |
|   mounted() {
 | |
|     // load job data and then auto-reload periodically
 | |
|     this.loadJobData();
 | |
|     setInterval(() => this.loadJobData(), 1000);
 | |
|   },
 | |
| 
 | |
|   methods: {
 | |
|     // get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
 | |
|     stepLogsGetActiveContainer(idx) {
 | |
|       const el = this.$refs.elJobStepLogs[idx];
 | |
|       return el._stepLogsActiveContainer ?? el;
 | |
|     },
 | |
|     // begin a log group
 | |
|     stepLogsGroupBegin(idx) {
 | |
|       const el = this.$refs.elJobStepLogs[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.appendChild(elJobLogGroupSummary);
 | |
|       elJobLogGroup.appendChild(elJobLogList);
 | |
|       el._stepLogsActiveContainer = elJobLogList;
 | |
|     },
 | |
|     // end a log group
 | |
|     stepLogsGroupEnd(idx) {
 | |
|       const el = this.$refs.elJobStepLogs[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.loadJobData(); // try to load the data immediately instead of waiting for next timer interval
 | |
|       }
 | |
|     },
 | |
|     // rerun a job
 | |
|     rerunJob(idx) {
 | |
|       fetch(`${this.runInfo.htmlurl}/jobs/${idx}/rerun`, {
 | |
|         method: 'POST',
 | |
|         headers: {
 | |
|           'Content-Type': 'application/json',
 | |
|           'X-Csrf-Token': csrfToken,
 | |
|         },
 | |
|         body: {},
 | |
|       });
 | |
|     },
 | |
|     // cancel a run
 | |
|     cancelRun() {
 | |
|       fetch(`${this.runInfo.htmlurl}/cancel`, {
 | |
|         method: 'POST',
 | |
|         headers: {
 | |
|           'Content-Type': 'application/json',
 | |
|           'X-Csrf-Token': csrfToken,
 | |
|         },
 | |
|         body: {},
 | |
|       });
 | |
|     },
 | |
| 
 | |
|     formatDuration(d) {
 | |
|       d = Math.round(d);
 | |
|       const unitValues = [60, 60, 24];
 | |
|       const unitNames = ['s', 'm', 'h', 'd'];
 | |
|       const parts = [];
 | |
|       for (let i = 0; i < unitValues.length; i++) {
 | |
|         parts[i] = d % unitValues[i];
 | |
|         d = Math.floor(d / unitValues[i]);
 | |
|       }
 | |
|       parts.push(d);
 | |
|       let res = '', resCount = 0;
 | |
|       for (let i = parts.length - 1; i >= 0 && resCount < 2; i--) {
 | |
|         if (parts[i] > 0) {
 | |
|           res += `${parts[i] + unitNames[i]} `;
 | |
|           resCount++;
 | |
|         }
 | |
|       }
 | |
|       if (!res) return '0s';
 | |
|       return res.substring(0, res.length - 1);
 | |
|     },
 | |
| 
 | |
|     createLogLine(line) {
 | |
|       const el = document.createElement('div');
 | |
|       el.classList.add('job-log-line');
 | |
|       el._jobLogTime = line.t;
 | |
| 
 | |
|       const elLineNum = document.createElement('line-num');
 | |
|       elLineNum.innerText = line.ln;
 | |
|       el.appendChild(elLineNum);
 | |
| 
 | |
|       // TODO: Support displaying time optionally
 | |
|       // const elLogTime = document.createElement('log-time');
 | |
|       // elLogTime.innerText = new Date(line.t * 1000).toISOString();
 | |
|       // el.appendChild(elLogTime);
 | |
| 
 | |
|       const elLogMsg = document.createElement('log-msg');
 | |
|       elLogMsg.innerHTML = this.ansiToHTML.toHtml(line.m);
 | |
|       el.appendChild(elLogMsg);
 | |
| 
 | |
|       return el;
 | |
|     },
 | |
| 
 | |
|     appendLogs(stepIndex, logLines) {
 | |
|       for (const line of logLines) {
 | |
|         // TODO: group support: ##[group]GroupTitle , ##[endgroup]
 | |
|         const el = this.stepLogsGetActiveContainer(stepIndex);
 | |
|         el.append(this.createLogLine(line));
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     // the respData has the following fields:
 | |
|     // * stateData: it will be stored into Vue data and used to update the UI state
 | |
|     // * logsData: the logs in it will be appended to the UI manually, no touch to Vue data
 | |
|     fetchMockData(reqData) {
 | |
|       const stateData = {
 | |
|         runInfo: {title: 'The Demo Build'},
 | |
|         allJobGroups: [
 | |
|           {
 | |
|             summary: 'Job Group Foo',
 | |
|             jobs: [
 | |
|               {id: 1, name: 'Job A', status: 'success'},
 | |
|               {id: 2, name: 'Job B', status: 'error'},
 | |
|             ],
 | |
|           },
 | |
|           {
 | |
|             summary: 'Job Group Bar',
 | |
|             jobs: [
 | |
|               {id: 3, name: 'Job X', status: 'skipped'},
 | |
|               {id: 4, name: 'Job Y', status: 'waiting'},
 | |
|               {id: 5, name: 'Job Z', status: 'running'},
 | |
|             ],
 | |
|           },
 | |
|         ],
 | |
|         currentJobInfo: {title: 'the job title', detail: 'succeeded 3 hours ago in 11s'},
 | |
|         currentJobSteps: [
 | |
|           {summary: 'Job Step 1', duration: 0.5, status: 'success'},
 | |
|           {summary: 'Job Step 2', duration: 2, status: 'error'},
 | |
|           {summary: 'Job Step 3', duration: 64, status: 'skipped'},
 | |
|           {summary: 'Job Step 4', duration: 3600 + 60, status: 'waiting'},
 | |
|           {summary: 'Job Step 5', duration: 86400 + 60 + 1, status: 'running'},
 | |
|         ],
 | |
|       };
 | |
|       const logsData = {
 | |
|         streamingLogs: [
 | |
|           // {stepIndex: 0, lines: [{t: timestamp, ln: lineNumber, m: message}, ...]},
 | |
|         ]
 | |
|       };
 | |
| 
 | |
|       // prepare mock data for logs
 | |
|       for (const reqCursor of reqData.stepLogCursors) {
 | |
|         if (!reqCursor.expanded) continue; // backend can decide whether send logs for a step
 | |
|         if (reqCursor.cursor > 100) continue;
 | |
|         let cursor = reqCursor.cursor; // use cursor to send remaining logs
 | |
|         const lines = [];
 | |
|         for (let i = 0; i < 110; i++) {
 | |
|           lines.push({
 | |
|             ln: cursor, // demo only, use cursor for line number
 | |
|             m: `${' '.repeat(i % 4)}\x1B[1;3;31mDemo Log\x1B[0m, tag test <br>, hello world ${Date.now()}, cursor: ${cursor}`,
 | |
|             t: Date.now() / 1000, // use second as unit
 | |
|           });
 | |
|           cursor++;
 | |
|         }
 | |
|         logsData.streamingLogs.push({stepIndex: reqCursor.stepIndex, cursor, lines});
 | |
|       }
 | |
| 
 | |
|       return {stateData, logsData};
 | |
|     },
 | |
| 
 | |
|     async fetchJobData(reqData) {
 | |
|       const resp = await fetch(``, {
 | |
|         method: 'POST',
 | |
|         headers: {
 | |
|           'Content-Type': 'application/json',
 | |
|           'X-Csrf-Token': csrfToken,
 | |
|         },
 | |
|         body: JSON.stringify(reqData),
 | |
|       });
 | |
|       return await resp.json();
 | |
|     },
 | |
| 
 | |
|     async loadJobData() {
 | |
|       if (this.loading) return;
 | |
|       try {
 | |
|         this.loading = true;
 | |
| 
 | |
|         const stepLogCursors = 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 {stepIndex: idx, cursor: it.cursor, expanded: it.expanded};
 | |
|         });
 | |
|         const reqData = {stepLogCursors};
 | |
| 
 | |
|         const respData = await this.fetchJobData(reqData);
 | |
| 
 | |
|         // save the stateData to Vue data, then the UI will be updated
 | |
|         for (const [key, value] of Object.entries(respData.stateData)) {
 | |
|           this[key] = value;
 | |
|         }
 | |
| 
 | |
|         // sync the currentJobStepsStates to store the job step states
 | |
|         for (let i = 0; i < this.currentJobSteps.length; i++) {
 | |
|           if (!this.currentJobStepsStates[i]) {
 | |
|             this.currentJobStepsStates[i] = {cursor: null, expanded: false};
 | |
|           }
 | |
|         }
 | |
|         // append logs to the UI
 | |
|         for (const logs of respData.logsData.streamingLogs) {
 | |
|           // save the cursor, it will be passed to backend next time
 | |
|           this.currentJobStepsStates[logs.stepIndex].cursor = logs.cursor;
 | |
|           this.appendLogs(logs.stepIndex, logs.lines);
 | |
|         }
 | |
|       } finally {
 | |
|         this.loading = false;
 | |
|       }
 | |
|     }
 | |
|   },
 | |
| };
 | |
| 
 | |
| export default sfc;
 | |
| 
 | |
| export function initRepositoryActionView() {
 | |
|   const el = document.getElementById('repo-action-view');
 | |
|   if (!el) return;
 | |
| 
 | |
|   const view = createApp(sfc, {
 | |
|     jobIndex: el.getAttribute('data-job-index'),
 | |
|     runIndex: el.getAttribute('data-run-index'),
 | |
|   });
 | |
|   view.mount(el);
 | |
| }
 | |
| 
 | |
| </script>
 | |
| 
 | |
| <style scoped lang="less">
 | |
| 
 | |
| .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 0 20px 20px;
 | |
| }
 | |
| 
 | |
| .action-info-summary {
 | |
|   font-size: 150%;
 | |
|   height: 20px;
 | |
|   padding: 0 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-brief-list {
 | |
|     a.job-brief-item {
 | |
|       display: block;
 | |
|       margin: 5px 0;
 | |
|       padding: 10px;
 | |
|       background: #f8f8f8;
 | |
|       border-radius: 5px;
 | |
|       text-decoration: none;
 | |
|       button.job-brief-rerun {
 | |
|         float: right;
 | |
|         border: none;
 | |
|         background-color: transparent;
 | |
|         outline: none
 | |
|       };
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| 
 | |
| 
 | |
| // ================
 | |
| // action view right
 | |
| 
 | |
| .action-view-right {
 | |
|   flex: 1;
 | |
|   background-color: #262626;
 | |
|   color: #d6d6d6;
 | |
|   max-height: 100%;
 | |
|   margin-right: 10px;
 | |
| 
 | |
|   display: flex;
 | |
|   flex-direction: column;
 | |
| }
 | |
| 
 | |
| .job-info-header {
 | |
|   .job-info-header-title {
 | |
|     color: #fdfdfd;
 | |
|     font-size: 150%;
 | |
|     padding: 10px;
 | |
|   }
 | |
|   .job-info-header-detail {
 | |
|     padding: 0 10px 10px;
 | |
|     border-bottom: 1px solid #666;
 | |
|   }
 | |
| }
 | |
| 
 | |
| .job-step-container {
 | |
|   max-height: 100%;
 | |
|   overflow: auto;
 | |
| 
 | |
|   .job-step-summary {
 | |
|     cursor: pointer;
 | |
|     padding: 5px 10px;
 | |
|     display: flex;
 | |
| 
 | |
|     .step-summary-msg {
 | |
|       flex: 1;
 | |
|     }
 | |
|     .step-summary-dur {
 | |
|       margin-left: 16px;
 | |
|     }
 | |
|   }
 | |
|   .job-step-summary:hover {
 | |
|     background-color: #333;
 | |
|   }
 | |
| }
 | |
| </style>
 | |
| 
 | |
| <style lang="less">
 | |
| // some elements are not managed by vue, so we need to use global style
 | |
| 
 | |
| // TODO: the parent element's full height doesn't work well now
 | |
| body > div.full.height {
 | |
|   padding-bottom: 0;
 | |
| }
 | |
| 
 | |
| .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-logs {
 | |
|     font-family: monospace, monospace;
 | |
|     .job-log-line {
 | |
|       display: flex;
 | |
|       line-num {
 | |
|         width: 48px;
 | |
|         color: #555;
 | |
|         text-align: right;
 | |
|       }
 | |
|       log-time {
 | |
|         color: #777;
 | |
|         margin-left: 10px;
 | |
|         white-space: nowrap;
 | |
|       }
 | |
|       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>
 |