mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-11 13:13:16 +01:00
edf98a2dc3
Currently, Gitea will run actions automatically which are triggered by fork pull request. It's a security risk, people can create a PR and modify the workflow yamls to execute a malicious script. So we should require approval for first-time contributors, which is the default strategy of a public repo on GitHub, see [Approving workflow runs from public forks](https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks). Current strategy: - don't need approval if it's not a fork PR; - always need approval if the user is restricted; - don't need approval if the user can write; - don't need approval if the user has been approved before; - otherwise, need approval. https://user-images.githubusercontent.com/9418365/217207121-badf50a8-826c-4425-bef1-d82d1979bc81.mov GitHub has an option for that, you can see that at `/<owner>/<repo>/settings/actions`, and we can support that later. <img width="835" alt="image" src="https://user-images.githubusercontent.com/9418365/217199990-2967e68b-e693-4e59-8186-ab33a1314a16.png"> --------- Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
480 lines
13 KiB
Vue
480 lines
13 KiB
Vue
<template>
|
|
<div class="action-view-container">
|
|
<div class="action-view-header">
|
|
<div class="action-info-summary">
|
|
{{ run.title }}
|
|
<button class="run_approve" @click="approveRun()" v-if="run.canApprove">
|
|
<i class="play circle outline icon"/>
|
|
</button>
|
|
<button class="run_cancel" @click="cancelRun()" v-else-if="run.canCancel">
|
|
<i class="stop circle outline icon"/>
|
|
</button>
|
|
</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">
|
|
<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/>
|
|
<span class="ui text">{{ job.name }}</span>
|
|
</a>
|
|
<button class="job-brief-rerun" @click="rerunJob(index)" v-if="job.canRerun">
|
|
<SvgIcon name="octicon-sync" class="ui text black"/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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="octicon-chevron-down" class="gt-mr-3" v-show="currentJobStepsStates[i].expanded"/>
|
|
<SvgIcon name="octicon-chevron-right" class="gt-mr-3" v-show="!currentJobStepsStates[i].expanded"/>
|
|
|
|
<SvgIcon name="octicon-check-circle-fill" class="green gt-mr-3" v-if="jobStep.status === 'success'"/>
|
|
<SvgIcon name="octicon-skip" class="ui text grey gt-mr-3" v-else-if="jobStep.status === 'skipped'"/>
|
|
<SvgIcon name="octicon-clock" class="ui text yellow gt-mr-3" v-else-if="jobStep.status === 'waiting'"/>
|
|
<SvgIcon name="octicon-blocked" class="ui text yellow gt-mr-3" v-else-if="jobStep.status === 'blocked'"/>
|
|
<SvgIcon name="octicon-meter" class="ui text yellow gt-mr-3" class-name="job-status-rotate" v-else-if="jobStep.status === 'running'"/>
|
|
<SvgIcon name="octicon-x-circle-fill" class="red gt-mr-3 " v-else/>
|
|
|
|
<span class="step-summary-msg">{{ jobStep.summary }}</span>
|
|
<span class="step-summary-dur">{{ 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 {createApp} from 'vue';
|
|
import AnsiToHTML from 'ansi-to-html';
|
|
|
|
const {csrfToken} = window.config;
|
|
|
|
const sfc = {
|
|
name: 'RepoActionView',
|
|
components: {
|
|
SvgIcon,
|
|
},
|
|
props: {
|
|
runIndex: String,
|
|
jobIndex: String,
|
|
actionsURL: String,
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
ansiToHTML: new AnsiToHTML({escapeXML: true}),
|
|
|
|
// internal state
|
|
loading: false,
|
|
intervalID: null,
|
|
currentJobStepsStates: [],
|
|
|
|
// provided by backend
|
|
run: {
|
|
link: '',
|
|
title: '',
|
|
canCancel: false,
|
|
canApprove: false,
|
|
done: false,
|
|
jobs: [
|
|
// {
|
|
// id: 0,
|
|
// name: '',
|
|
// status: '',
|
|
// canRerun: false,
|
|
// },
|
|
],
|
|
},
|
|
currentJob: {
|
|
title: '',
|
|
detail: '',
|
|
steps: [
|
|
// {
|
|
// summary: '',
|
|
// duration: '',
|
|
// status: '',
|
|
// }
|
|
],
|
|
},
|
|
};
|
|
},
|
|
|
|
mounted() {
|
|
// load job data and then auto-reload periodically
|
|
this.loadJob();
|
|
this.intervalID = setInterval(this.loadJob, 1000);
|
|
},
|
|
|
|
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.appendChild(elJobLogGroupSummary);
|
|
elJobLogGroup.appendChild(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;
|
|
},
|
|
// 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.innerText = line.index;
|
|
div.appendChild(lineNumber);
|
|
|
|
// TODO: Support displaying time optionally
|
|
|
|
const logMessage = document.createElement('div');
|
|
logMessage.className = 'log-msg';
|
|
logMessage.innerHTML = this.ansiToHTML.toHtml(line.message);
|
|
div.appendChild(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;
|
|
|
|
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'),
|
|
});
|
|
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 20px 20px 20px;
|
|
.run_cancel {
|
|
border: none;
|
|
color: var(--color-red);
|
|
background-color: transparent;
|
|
outline: none;
|
|
cursor: pointer;
|
|
transition:transform 0.2s;
|
|
};
|
|
.run_approve {
|
|
border: none;
|
|
color: var(--color-green);
|
|
background-color: transparent;
|
|
outline: none;
|
|
cursor: pointer;
|
|
transition:transform 0.2s;
|
|
};
|
|
.run_cancel:hover, .run_approve:hover {
|
|
transform:scale(130%);
|
|
};
|
|
}
|
|
|
|
.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 {
|
|
.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-brief-rerun {
|
|
float: right;
|
|
border: none;
|
|
background-color: transparent;
|
|
outline: none;
|
|
cursor: pointer;
|
|
transition:transform 0.2s;
|
|
};
|
|
.job-brief-rerun:hover{
|
|
transform:scale(130%);
|
|
};
|
|
.job-brief-link {
|
|
flex-grow: 1;
|
|
display: flex;
|
|
span {
|
|
margin-right: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
}
|
|
}
|
|
.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-detail {
|
|
padding: 0 10px 10px;
|
|
border-bottom: 1px solid var(--color-grey);
|
|
}
|
|
}
|
|
|
|
.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: var(--color-black-light);
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<style lang="less">
|
|
// 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-logs {
|
|
font-family: monospace, monospace;
|
|
.job-log-line {
|
|
display: flex;
|
|
.line-num {
|
|
width: 48px;
|
|
color: var(--color-grey-light);
|
|
text-align: right;
|
|
}
|
|
.log-time {
|
|
color: var(--color-grey-light);
|
|
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>
|