diff options
author | Jason Song <i@wolfogre.com> | 2023-01-31 09:45:19 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-31 09:45:19 +0800 |
commit | 4011821c946e8db032be86266dd9364ccb204118 (patch) | |
tree | a8a1cf1b8f088df583f316c8233bc18a89881099 /web_src | |
parent | b5b3e0714e624cea3ce4d5368aa1266f7639d0eb (diff) | |
download | gitea-4011821c946e8db032be86266dd9364ccb204118.tar.gz gitea-4011821c946e8db032be86266dd9364ccb204118.zip |
Implement actions (#21937)
Close #13539.
Co-authored by: @lunny @appleboy @fuxiaohei and others.
Related projects:
- https://gitea.com/gitea/actions-proto-def
- https://gitea.com/gitea/actions-proto-go
- https://gitea.com/gitea/act
- https://gitea.com/gitea/act_runner
### Summary
The target of this PR is to bring a basic implementation of "Actions",
an internal CI/CD system of Gitea. That means even though it has been
merged, the state of the feature is **EXPERIMENTAL**, and please note
that:
- It is disabled by default;
- It shouldn't be used in a production environment currently;
- It shouldn't be used in a public Gitea instance currently;
- Breaking changes may be made before it's stable.
**Please comment on #13539 if you have any different product design
ideas**, all decisions reached there will be adopted here. But in this
PR, we don't talk about **naming, feature-creep or alternatives**.
### ⚠️ Breaking
`gitea-actions` will become a reserved user name. If a user with the
name already exists in the database, it is recommended to rename it.
### Some important reviews
- What is `DEFAULT_ACTIONS_URL` in `app.ini` for?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1055954954
- Why the api for runners is not under the normal `/api/v1` prefix?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1061173592
- Why DBFS?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1061301178
- Why ignore events triggered by `gitea-actions` bot?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1063254103
- Why there's no permission control for actions?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1090229868
### What it looks like
<details>
#### Manage runners
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205870657-c72f590e-2e08-4cd4-be7f-2e0abb299bbf.png">
#### List runs
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205872794-50fde990-2b45-48c1-a178-908e4ec5b627.png">
#### View logs
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205872501-9b7b9000-9542-4991-8f55-18ccdada77c3.png">
</details>
### How to try it
<details>
#### 1. Start Gitea
Clone this branch and [install from
source](https://docs.gitea.io/en-us/install-from-source).
Add additional configurations in `app.ini` to enable Actions:
```ini
[actions]
ENABLED = true
```
Start it.
If all is well, you'll see the management page of runners:
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205877365-8e30a780-9b10-4154-b3e8-ee6c3cb35a59.png">
#### 2. Start runner
Clone the [act_runner](https://gitea.com/gitea/act_runner), and follow
the
[README](https://gitea.com/gitea/act_runner/src/branch/main/README.md)
to start it.
If all is well, you'll see a new runner has been added:
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205878000-216f5937-e696-470d-b66c-8473987d91c3.png">
#### 3. Enable actions for a repo
Create a new repo or open an existing one, check the `Actions` checkbox
in settings and submit.
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205879705-53e09208-73c0-4b3e-a123-2dcf9aba4b9c.png">
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205879383-23f3d08f-1a85-41dd-a8b3-54e2ee6453e8.png">
If all is well, you'll see a new tab "Actions":
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205881648-a8072d8c-5803-4d76-b8a8-9b2fb49516c1.png">
#### 4. Upload workflow files
Upload some workflow files to `.gitea/workflows/xxx.yaml`, you can
follow the [quickstart](https://docs.github.com/en/actions/quickstart)
of GitHub Actions. Yes, Gitea Actions is compatible with GitHub Actions
in most cases, you can use the same demo:
```yaml
name: GitHub Actions Demo
run-name: ${{ github.actor }} is testing out GitHub Actions 🚀
on: [push]
jobs:
Explore-GitHub-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!"
- run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}."
- name: Check out repository code
uses: actions/checkout@v3
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ github.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."
```
If all is well, you'll see a new run in `Actions` tab:
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205884473-79a874bc-171b-4aaf-acd5-0241a45c3b53.png">
#### 5. Check the logs of jobs
Click a run and you'll see the logs:
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205884800-994b0374-67f7-48ff-be9a-4c53f3141547.png">
#### 6. Go on
You can try more examples in [the
documents](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions)
of GitHub Actions, then you might find a lot of bugs.
Come on, PRs are welcome.
</details>
See also: [Feature Preview: Gitea
Actions](https://blog.gitea.io/2022/12/feature-preview-gitea-actions/)
---------
Co-authored-by: a1012112796 <1012112796@qq.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
Co-authored-by: John Olheiser <john.olheiser@gmail.com>
Diffstat (limited to 'web_src')
-rw-r--r-- | web_src/js/components/RepoActionView.vue | 398 | ||||
-rw-r--r-- | web_src/js/index.js | 2 | ||||
-rw-r--r-- | web_src/js/svg.js | 12 | ||||
-rw-r--r-- | web_src/less/_actions.less | 43 | ||||
-rw-r--r-- | web_src/less/_base.less | 2 | ||||
-rw-r--r-- | web_src/less/_repository.less | 3 | ||||
-rw-r--r-- | web_src/less/_runner.less | 45 | ||||
-rw-r--r-- | web_src/less/index.less | 2 |
8 files changed, 505 insertions, 2 deletions
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue new file mode 100644 index 0000000000..673e5b0d8c --- /dev/null +++ b/web_src/js/components/RepoActionView.vue @@ -0,0 +1,398 @@ +<template> + <div class="action-view-container"> + <div class="action-view-header"> + <div class="action-info-summary"> + {{ run.title }} + <button class="run_cancel" @click="cancelRun()" v-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"> + <a class="job-brief-item" v-for="(job, index) in run.jobs" :key="job.id" :href="run.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.canRerun"> + <SvgIcon name="octicon-sync" class="ui text black"/> + </button> + </a> + </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="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">{{ 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: { + htmlurl: '', + title: '', + canCancel: 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 + rerunJob(idx) { + this.fetch(`${this.run.htmlurl}/jobs/${idx}/rerun`); + }, + // cancel a run + cancelRun() { + this.fetch(`${this.run.htmlurl}/cancel`); + }, + + 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.fetch( + `${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; + } + }, + + fetch(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; + + 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"> + +// some elements are not managed by vue, so we need to use _actions.less in addition. + +.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; + button.run_cancel { + border: none; + color: var(--color-red); + background-color: transparent; + outline: none; + cursor: pointer; + transition:transform 0.2s; + }; + button.run_cancel: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 { + a.job-brief-item { + display: block; + margin: 5px 0; + padding: 10px; + background: var(--color-info-bg); + border-radius: 5px; + text-decoration: none; + button.job-brief-rerun { + float: right; + border: none; + background-color: transparent; + outline: none; + cursor: pointer; + transition:transform 0.2s; + }; + button.job-brief-rerun:hover{ + transform:scale(130%); + }; + } + a.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> + diff --git a/web_src/js/index.js b/web_src/js/index.js index a866184203..14483f3fa2 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -90,6 +90,7 @@ import {initRepoCommentForm, initRepository} from './features/repo-legacy.js'; import {initFormattingReplacements} from './features/formatting.js'; import {initMcaptcha} from './features/mcaptcha.js'; import {initCopyContent} from './features/copycontent.js'; +import {initRepositoryActionView} from './components/RepoActionView.vue'; // Run time-critical code as soon as possible. This is safe to do because this // script appears at the end of <body> and rendered HTML is accessible at that point. @@ -187,6 +188,7 @@ $(document).ready(() => { initRepoTopicBar(); initRepoWikiForm(); initRepository(); + initRepositoryActionView(); initCommitStatuses(); initMcaptcha(); diff --git a/web_src/js/svg.js b/web_src/js/svg.js index 0f08f64e80..2132ad3120 100644 --- a/web_src/js/svg.js +++ b/web_src/js/svg.js @@ -25,8 +25,16 @@ import octiconSidebarCollapse from '../../public/img/svg/octicon-sidebar-collaps import octiconSidebarExpand from '../../public/img/svg/octicon-sidebar-expand.svg'; import octiconTriangleDown from '../../public/img/svg/octicon-triangle-down.svg'; import octiconX from '../../public/img/svg/octicon-x.svg'; +import octiconCheckCircleFill from '../../public/img/svg/octicon-check-circle-fill.svg'; +import octiconXCircleFill from '../../public/img/svg/octicon-x-circle-fill.svg'; +import octiconSkip from '../../public/img/svg/octicon-skip.svg'; +import octiconMeter from '../../public/img/svg/octicon-meter.svg'; +import octiconBlocked from '../../public/img/svg/octicon-blocked.svg'; +import octiconSync from '../../public/img/svg/octicon-sync.svg'; export const svgs = { + 'octicon-blocked': octiconBlocked, + 'octicon-check-circle-fill': octiconCheckCircleFill, 'octicon-chevron-down': octiconChevronDown, 'octicon-chevron-right': octiconChevronRight, 'octicon-clock': octiconClock, @@ -44,6 +52,7 @@ export const svgs = { 'octicon-kebab-horizontal': octiconKebabHorizontal, 'octicon-link': octiconLink, 'octicon-lock': octiconLock, + 'octicon-meter': octiconMeter, 'octicon-milestone': octiconMilestone, 'octicon-mirror': octiconMirror, 'octicon-project': octiconProject, @@ -52,8 +61,11 @@ export const svgs = { 'octicon-repo-template': octiconRepoTemplate, 'octicon-sidebar-collapse': octiconSidebarCollapse, 'octicon-sidebar-expand': octiconSidebarExpand, + 'octicon-skip': octiconSkip, + 'octicon-sync': octiconSync, 'octicon-triangle-down': octiconTriangleDown, 'octicon-x': octiconX, + 'octicon-x-circle-fill': octiconXCircleFill, }; const parser = new DOMParser(); diff --git a/web_src/less/_actions.less b/web_src/less/_actions.less new file mode 100644 index 0000000000..1acad06a65 --- /dev/null +++ b/web_src/less/_actions.less @@ -0,0 +1,43 @@ +@import "variables.less"; + +// 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; + .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 + } +} diff --git a/web_src/less/_base.less b/web_src/less/_base.less index 697bc0ee74..26fc83785b 100644 --- a/web_src/less/_base.less +++ b/web_src/less/_base.less @@ -1797,7 +1797,7 @@ footer { .ui { &.left, &.right { - line-height: 40px; + line-height: 39px; // there is a border-top on the footer, so make the line-height 1px less } } } diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index 7aa42b1f07..5d30d0d81a 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -2619,7 +2619,8 @@ } &.webhooks .list > .item:not(:first-child), - &.githooks .list > .item:not(:first-child) { + &.githooks .list > .item:not(:first-child), + &.runners .list > .item:not(:first-child) { padding: .25rem 1rem; margin: 12px -1rem -1rem; } diff --git a/web_src/less/_runner.less b/web_src/less/_runner.less new file mode 100644 index 0000000000..74d917fc87 --- /dev/null +++ b/web_src/less/_runner.less @@ -0,0 +1,45 @@ +@import "variables.less"; + +.runner-container { + padding-bottom: 30px; + .runner-ops > a { + margin-left: .5em; + } + .runner-ops-delete { + color: var(--color-red-light); + } + .runner-basic-info .dib { + margin-right: 1em; + } + .runner-status-online { + .ui.label; + background-color: var(--color-green); + color: var(--color-white); + } + .runner-new-text { + color: var(--color-white); + } + #runner-new:hover .runner-new-text { + color: var(--color-white) !important; + } + .runner-new-menu { + width: 300px; + } + .task-status-success { + background-color: var(--color-green); + color: var(--color-white); + } + .task-status-failure { + background-color: var(--color-red-light); + color: var(--color-white); + } + .task-status-running { + background-color: var(--color-blue); + color: var(--color-white); + } + .task-status-cancelled, + .task-status-blocked { + background-color: var(--color-yellow); + color: var(--color-white); + } +} diff --git a/web_src/less/index.less b/web_src/less/index.less index 185bf7ca31..29cff15c54 100644 --- a/web_src/less/index.less +++ b/web_src/less/index.less @@ -37,5 +37,7 @@ @import "_explore"; @import "_review"; @import "_package"; +@import "_runner"; +@import "_actions"; @import "./helpers.less"; |