mirror of
https://github.com/go-gitea/gitea.git
synced 2025-08-14 04:34:05 +09:00
chore: rename to action
This commit is contained in:
502
web_src/js/components/RepoActionView.vue
Normal file
502
web_src/js/components/RepoActionView.vue
Normal file
@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<div class="action-view-container">
|
||||
<div class="action-view-left">
|
||||
<div class="action-info-summary">
|
||||
{{ runInfo.title }}
|
||||
</div>
|
||||
|
||||
<div class="job-group-section" v-for="(jobGroup, i) in allJobGroups" :key="i">
|
||||
<!-- <div class="job-group-summary">-->
|
||||
<!-- {{ jobGroup.summary }}-->
|
||||
<!-- </div>-->
|
||||
<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.status !== 'waiting' && job.status !== 'blocked' && job.status !== 'running'">
|
||||
<SvgIcon name="octicon-sync" class="ui text black"/>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<button class="ui fluid tiny basic red button" @click="cancelRun()" v-if="runInfo.cancelable">
|
||||
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">
|
||||
<!--
|
||||
possible layouts:
|
||||
<div class="job-log-group">
|
||||
<div class="job-log-group-summary"></div>
|
||||
<div class="job-log-list">
|
||||
<div class="job-log-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
-- or --
|
||||
<div class="job-log-line"></div>
|
||||
-->
|
||||
</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);
|
||||
// const respData = this.fetchMockData(reqData);
|
||||
|
||||
// console.log('loadJobData by request', reqData, ', get response ', respData);
|
||||
|
||||
// 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-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 226px); // fine tune this value to make the main view has full height
|
||||
}
|
||||
|
||||
|
||||
// ================
|
||||
// action view left
|
||||
|
||||
.action-view-left {
|
||||
width: 20%;
|
||||
overflow-y: scroll;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.action-info-summary {
|
||||
font-size: 150%;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.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%;
|
||||
|
||||
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>
|
Reference in New Issue
Block a user