Rewrite of WebUI (#245)

Rewrite of the UI using Typescript, Vue3, Windicss and Vite. The design should  be close to the current one with some changes:
- latest pipeline in a sidebar on the right
- secrets and registry as part of the repo-settings (secrets and registry entries shouldn't be used as much so they can be "hidden" under settings IMO)
- start page shows list of active repositories with button to enable / add new ones (currently you see all repositories and in most cases you only add new repositories once in a while)
This commit is contained in:
Anbraten 2021-11-03 17:40:31 +01:00 committed by GitHub
parent 0bb62be303
commit 58838f225c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
239 changed files with 7765 additions and 13633 deletions

18
.vscode/launch.json vendored
View file

@ -19,6 +19,24 @@
"mode": "debug",
"program": "${workspaceFolder}/cmd/agent/",
"cwd": "${workspaceFolder}"
},
{
"name": "Woodpecker UI",
"type": "node",
"request": "launch",
"runtimeExecutable": "yarn",
"runtimeArgs": [
"start",
],
"cwd": "${workspaceFolder}/web",
"port": 3000,
"resolveSourceMapLocations": [
"${workspaceFolder}/web/**",
"!**/node_modules/**"
],
"skipFiles": [
"<node_internals>/**"
]
}
]
}

View file

@ -3,6 +3,61 @@ clone:
image: plugins/git:next
pipeline:
web-deps:
image: node:16-alpine
commands:
- cd web/
- yarn install --frozen-lockfile
when:
path: "web/**"
# TODO: enable if we have enouth mem (~2g) to lint, cause an oom atm.
# For reviewers, please run localy to verify it passes
# web-lint:
# TODO: disabled group for now to prevent oom
# group: web-test
# image: node:16-alpine
# commands:
# - cd web/
# - yarn lint
# when:
# path: "web/**"
web-formatcheck:
group: web-test
image: node:16-alpine
commands:
- cd web/
- yarn formatcheck
when:
path: "web/**"
web-typecheck:
group: web-test
image: node:16-alpine
commands:
- cd web/
- yarn typecheck
when:
path: "web/**"
web-test:
group: web-test
image: node:16-alpine
commands:
- cd web/
- yarn test
when:
path: "web/**"
web-build:
image: node:16-alpine
commands:
- cd web/
- yarn build
when:
path: "web/**"
test:
image: golang:1.16
group: test
@ -12,15 +67,6 @@ pipeline:
- make lint
- make formatcheck
test-frontend:
image: node:10.17.0-stretch
group: test
commands:
- (cd web/; yarn install)
- (cd web/; yarn run lesshint)
- (cd web/; yarn run lint --quiet)
- make test-frontend
test-postgres:
image: golang:1.16
group: db-test
@ -40,8 +86,9 @@ pipeline:
- go test -timeout 30s github.com/woodpecker-ci/woodpecker/server/store/datastore
build-frontend:
image: node:10.17.0-stretch
image: node:16-alpine
commands:
- apk add make
- make release-frontend
build-server:

View file

@ -48,14 +48,20 @@ lint:
go run vendor/github.com/rs/zerolog/cmd/lint/lint.go github.com/woodpecker-ci/woodpecker/cmd/cli
go run vendor/github.com/rs/zerolog/cmd/lint/lint.go github.com/woodpecker-ci/woodpecker/cmd/server
frontend-dependencies:
(cd web/; yarn install --frozen-lockfile)
test-agent:
$(DOCKER_RUN) go test -race -timeout 30s github.com/woodpecker-ci/woodpecker/cmd/agent $(GO_PACKAGES)
test-server:
$(DOCKER_RUN) go test -race -timeout 30s github.com/woodpecker-ci/woodpecker/cmd/server
test-frontend:
(cd web/; yarn; yarn run test)
test-frontend: frontend-dependencies
(cd web/; yarn run lint)
(cd web/; yarn run formatcheck)
(cd web/; yarn run typecheck)
(cd web/; yarn run test)
test-lib:
$(DOCKER_RUN) go test -race -timeout 30s $(shell go list ./... | grep -v '/cmd/')

View file

@ -276,7 +276,7 @@ func PostHook(c *gin.Context) {
defer func() {
for _, item := range buildItems {
uri := fmt.Sprintf("%s/%s/%d", server.Config.Server.Host, repo.FullName, build.Number)
uri := fmt.Sprintf("%s/%s/build/%d", server.Config.Server.Host, repo.FullName, build.Number)
if len(buildItems) > 1 {
err = remote_.Status(c, user, repo, build, uri, item.Proc)
} else {

View file

@ -30,7 +30,6 @@ import (
// Load loads the router
func Load(serveHTTP func(w http.ResponseWriter, r *http.Request), middleware ...gin.HandlerFunc) http.Handler {
e := gin.New()
e.Use(gin.Recovery())

View file

@ -62,7 +62,7 @@ func (w *website) Register(mux *gin.Engine) {
h := http.FileServer(w.fs)
h = setupCache(h)
mux.GET("/favicon.svg", gin.WrapH(h))
mux.GET("/static/*filepath", gin.WrapH(h))
mux.GET("/assets/*filepath", gin.WrapH(h))
mux.NoRoute(gin.WrapF(w.handleIndex))
}

View file

@ -1,16 +0,0 @@
{
"sourceMaps": false,
"presets": [
["es2015", { "loose":true }],
"stage-0",
"react"
],
"plugins": [
["transform-decorators-legacy"],
["transform-object-rest-spread"],
["transform-react-jsx"],
["transform-es3-property-literals"],
["transform-es3-member-expression-literals"],
["transform-decorators-legacy"]
]
}

6
web/.eslintignore Normal file
View file

@ -0,0 +1,6 @@
# don't lint build output (make sure it's set to your correct build folder name)
dist
coverage/
package.json
tsconfig.eslint.json
tsconfig.json

View file

@ -1,33 +1,136 @@
// @ts-check
/** @type {import('@typescript-eslint/experimental-utils').TSESLint.Linter.Config} */
/* eslint-env node */
module.exports = {
extends: [
"standard",
"plugin:jest/recommended",
"plugin:react/recommended",
"prettier",
"prettier/react"
],
plugins: ["react", "jest", "prettier"],
parser: "babel-eslint",
parserOptions: {
ecmaVersion: 2016,
sourceType: "module",
ecmaFeatures: {
jsx: true
}
},
env: {
es6: true,
browser: true,
node: true,
"jest/globals": true
},
parser: 'vue-eslint-parser',
parserOptions: {
project: ['./tsconfig.eslint.json'],
tsconfigRootDir: __dirname,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore see https://github.com/vuejs/vue-eslint-parser#parseroptionsparser
parser: '@typescript-eslint/parser',
sourceType: 'module',
extraFileExtensions: ['.vue'],
},
plugins: ['@typescript-eslint', 'import', 'simple-import-sort'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'airbnb-base-ts',
'plugin:import/errors',
'plugin:import/warnings',
'plugin:import/typescript',
'plugin:promise/recommended',
'plugin:vue/vue3-recommended',
'plugin:prettier/recommended',
'plugin:vue-scoped-css/recommended',
],
rules: {
"react/prop-types": 1,
"prettier/prettier": [
"error",
// enable scope analysis rules
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'error',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'error',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
// make typescript eslint rules even more strict
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
'import/no-unresolved': 'off', // disable as this is handled by tsc itself
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-cycle': 'error',
'import/no-relative-parent-imports': 'error',
'import/no-duplicates': 'error',
'import/no-extraneous-dependencies': 'error',
'import/extensions': 'off',
'import/prefer-default-export': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'promise/prefer-await-to-then': 'error',
'promise/prefer-await-to-callbacks': 'error',
'no-underscore-dangle': 'off',
'no-else-return': ['error', { allowElseIf: false }],
'no-return-assign': ['error', 'always'],
'no-return-await': 'error',
'no-useless-return': 'error',
'no-restricted-imports': [
'error',
{
trailingComma: "all",
}
]
}
patterns: ['src', 'dist'],
},
],
'no-console': 'warn',
'no-useless-concat': 'error',
'prefer-const': 'error',
'spaced-comment': ['error', 'always'],
'object-shorthand': ['error', 'always'],
'no-useless-rename': 'error',
eqeqeq: 'error',
'vue/attribute-hyphenation': 'error',
// enable in accordance with https://github.com/prettier/eslint-config-prettier#vuehtml-self-closing
'vue/html-self-closing': [
'error',
{
html: {
void: 'any',
},
},
],
'vue/no-static-inline-styles': 'error',
'vue/v-on-function-call': 'error',
'vue/no-useless-v-bind': 'error',
'vue/no-useless-mustaches': 'error',
'vue/no-useless-concat': 'error',
'vue/no-boolean-default': 'error',
'vue/html-button-has-type': 'error',
'vue/component-name-in-template-casing': 'error',
'vue/match-component-file-name': [
'error',
{
extensions: ['vue'],
shouldMatchCase: true,
},
],
'vue/require-name-property': 'error',
'vue/v-for-delimiter-style': 'error',
'vue/no-empty-component-block': 'error',
'vue/no-duplicate-attr-inheritance': 'error',
'vue/no-unused-properties': [
'error',
{
groups: ['props', 'data', 'computed', 'methods', 'setup'],
},
],
'vue/new-line-between-multi-line-property': 'error',
'vue/padding-line-between-blocks': 'error',
// css rules
'vue-scoped-css/no-unused-selector': 'error',
'vue-scoped-css/no-parsing-error': 'error',
'vue-scoped-css/require-scoped': 'error',
// enable in accordance with https://github.com/prettier/eslint-config-prettier#curly
curly: ['error', 'all'],
// risky because of https://github.com/prettier/eslint-plugin-prettier#arrow-body-style-and-prefer-arrow-callback-issue
'arrow-body-style': 'error',
'prefer-arrow-callback': 'error',
},
};

5
web/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local

View file

@ -1,17 +0,0 @@
{
"fileExtensions": [".less", ".css"],
"excludedFiles": ["ansi.less"],
"spaceAfterPropertyColon": {
"enabled": true,
"style": "one_space"
},
"emptyRule": true,
"qualifyingElement": false,
"trailingWhitespace": true,
"zeroUnit": {
"exclude": ["flex"]
}
}

4
web/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
yarn-lock.yaml
dist
coverage/
LICENSE

8
web/.prettierrc.js Normal file
View file

@ -0,0 +1,8 @@
module.exports = {
semi: true,
trailingComma: 'all',
singleQuote: true,
printWidth: 120,
tabWidth: 2,
endOfLine: 'lf',
};

View file

@ -1,57 +0,0 @@
This project contains the source code for the drone user interface. The generated javascript and css assets are embedded into a Go source file which is imported into the main drone application, using go get.
## Building
To compile the source and create minified css and javascript assets:
```text
yarn install # install project dependencies
yarn run format # formats the codebase
yarn run lint # lints the codebase
yarn run test # tests the codebase
yarn run build # builds the production bundle
```
## Running
To run a devserver with watching, hotreloading and proxy to drone server:
```text
export DRONE_SERVER=<drone server>
export DRONE_TOKEN=<drone api token>
yarn run start
```
For example:
```text
export DRONE_SERVER=http://your.drone.server
export DRONE_TOKEN=eyJhbGciOiJIUzI1NiIsIn...
yarn run start
```
Note you will need to retrieve your drone user token from the tokens screen in the drone user interface. When the server is running you can open the following url in your browser:
```text
http://localhost:9999
```
## Releases
To bundle and embed the code in a Go source file install the following command line utility:
```text
go get github.com/bradrydzewski/togo
```
To generate the Go source file run the following command:
```text
go generate ./...
go install ./...
```
__Note__ that for security reasons we will not accept a pull request that updates embedded Go asset file since we are not able to easily review the embedded, minified code. This file is instead automatically generated by our build server to prevent tampering.

0
web/dist/.gitkeep vendored
View file

14
web/index.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Woodpecker</title>
<script type="" src="/web-config.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,107 +1,59 @@
{
"name": "drone-ui-react",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"prebuild": "rm -rf dist/files",
"build": "cross-env NODE_ENV=production webpack",
"lint": "eslint src/",
"lesshint": "lesshint --config .lesshintrc src/",
"test": "jest",
"start": "webpack-dev-server --progress --hot --inline",
"format": "prettier --trailing-comma all --write {src/*.js,src/**/*.js,src/**/*/*.js,src/*/*/*/*.js,src/*/*/*/*/*.js,src/*/*/*/*/*/*.js,src/*/*/*/*/*/*.js,src/*/*/*/*/*/*/*.js}"
},
"jest": {
"moduleFileExtensions": [
"js",
"jsx"
],
"moduleDirectories": [
"src",
"node_modules"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|less)$": "identity-obj-proxy",
"^react$": "preact-compat-enzyme",
"^react-dom/server$": "preact-render-to-string",
"^react-dom$": "preact-compat-enzyme",
"^react-addons-test-utils$": "preact-test-utils"
},
"collectCoverageFrom": [
"src/**/*.{js,jsx}"
]
},
"author": "Brad Rydzewski",
"name": "woodpecker-ci",
"author": "Woodpecker CI",
"version": "0.0.0",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
},
"scripts": {
"start": "vite",
"build": "vite build",
"serve": "vite preview",
"lint": "eslint --max-warnings 0 --ext .js,.ts,.vue,.json .",
"formatcheck": "prettier -c .",
"format:fix": "prettier --write .",
"typecheck": "vue-tsc --noEmit",
"test": "echo 'No tests configured' && exit 0"
},
"dependencies": {
"ansi_up": "^2.0.2",
"babel-polyfill": "^6.23.0",
"baobab": "^2.4.3",
"baobab-react": "^2.1.2",
"classnames": "^2.2.5",
"drone-js": "file:./vendor/drone-js/",
"humanize-duration": "^3.10.1",
"preact": "^8.2.1",
"preact-compat": "^3.16.0",
"query-string": "^5.0.0",
"react-collapsible": "^2.6.0",
"react-router": "^4.1.2",
"react-router-dom": "^4.1.2",
"react-screen-size": "^1.0.1",
"react-timeago": "^3.4.3",
"react-title-component": "^1.0.1",
"react-transition-group": "^1.2.0",
"yarn": "^1.17.3"
"@kyvg/vue3-notification": "2.3.4",
"@meforma/vue-toaster": "1.2.2",
"ansi-to-html": "0.7.2",
"fuse.js": "6.4.6",
"humanize-duration": "3.27.0",
"javascript-time-ago": "2.3.10",
"node-emoji": "1.11.0",
"pinia": "2.0.0",
"vue": "v3.2.20",
"vue-router": "4.0.10"
},
"devDependencies": {
"babel-core": "^6.25.0",
"babel-eslint": "^7.2.3",
"babel-jest": "^21.0.0",
"babel-loader": "^7.1.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-es3-member-expression-literals": "^6.22.0",
"babel-plugin-transform-es3-property-literals": "^6.22.0",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-env": "^1.6.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"cross-env": "^5.0.3",
"css-loader": "^0.28.4",
"dotenv": "^4.0.0",
"enzyme": "^2.9.1",
"eslint": "^4.6.1",
"eslint-config-prettier": "^2.4.0",
"eslint-config-standard": "^10.2.1",
"eslint-plugin-import": "^2.7.0",
"eslint-plugin-jest": "^21.0.2",
"eslint-plugin-node": "^5.1.1",
"eslint-plugin-prettier": "^2.2.0",
"eslint-plugin-promise": "^3.5.0",
"eslint-plugin-react": "^7.3.0",
"eslint-plugin-standard": "^3.0.1",
"file-loader": "^0.11.2",
"html-webpack-plugin": "^2.30.1",
"identity-obj-proxy": "^3.0.0",
"jasmine-expect": "^3.7.1",
"jest": "^21.0.1",
"jsdoc": "^3.5.4",
"less": "^2.7.2",
"less-loader": "^4.0.5",
"lesshint": "^4.1.3",
"preact-compat-enzyme": "^0.2.5",
"preact-render-to-string": "^3.6.3",
"preact-test-utils": "^0.1.3",
"prettier": "^1.6.0",
"sinon": "^3.2.1",
"sinon-chai": "^2.13.0",
"style-loader": "^0.18.2",
"url-loader": "^0.5.9",
"webpack": "^3.4.1",
"webpack-dev-server": "^2.6.1"
},
"resolutions": {
"ua-parser-js": "^0.7.30"
"@iconify/json": "1.1.421",
"@types/humanize-duration": "3.27.0",
"@types/javascript-time-ago": "2.0.3",
"@types/node": "16.11.6",
"@types/node-emoji": "1.8.1",
"@typescript-eslint/eslint-plugin": "4.31.2",
"@typescript-eslint/parser": "4.31.1",
"@vitejs/plugin-vue": "1.9.4",
"@vue/compiler-sfc": "3.2.20",
"eslint": "7.32.0",
"eslint-config-airbnb-base-ts": "14.1.2",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "4.0.0",
"eslint-plugin-promise": "5.1.1",
"eslint-plugin-simple-import-sort": "7.0.0",
"eslint-plugin-vue": "7.18.0",
"eslint-plugin-vue-scoped-css": "1.3.0",
"prettier": "2.4.1",
"typescript": "4.4.4",
"unplugin-icons": "0.12.17",
"unplugin-vue-components": "0.17.0",
"vite": "2.6.13",
"vite-plugin-windicss": "1.4.12",
"vite-svg-loader": "3.0.0",
"vue-tsc": "0.28.10",
"windicss": "3.2.0"
}
}

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

115
web/src/App.vue Normal file
View file

@ -0,0 +1,115 @@
<template>
<div class="app flex flex-col m-auto w-full h-full bg-gray-100 dark:bg-dark-gray-600">
<router-view v-if="blank" />
<template v-else>
<Navbar />
<div class="relative flex min-h-0 h-full">
<div class="flex flex-col overflow-y-auto flex-grow">
<router-view />
</div>
<transition name="slide-right">
<BuildFeedSidebar class="shadow-md border-l w-full absolute top-0 right-0 bottom-0 max-w-80 xl:max-w-96" />
</transition>
</div>
</template>
<notifications position="bottom right" />
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import { useRoute } from 'vue-router';
import BuildFeedSidebar from '~/components/build-feed/BuildFeedSidebar.vue';
import Navbar from '~/components/layout/header/Navbar.vue';
import useApiClient from '~/compositions/useApiClient';
import useNotifications from '~/compositions/useNotifications';
export default defineComponent({
name: 'App',
components: {
Navbar,
BuildFeedSidebar,
},
setup() {
const route = useRoute();
const apiClient = useApiClient();
const notifications = useNotifications();
// eslint-disable-next-line promise/prefer-await-to-callbacks
apiClient.setErrorHandler((err) => {
notifications.notify({ title: err.message || 'An unkown error occurred', type: 'error' });
});
const blank = computed(() => route.meta.blank);
return { blank };
},
});
</script>
<!-- eslint-disable-next-line vue-scoped-css/require-scoped -->
<style>
html,
body,
#app {
width: 100%;
height: 100%;
}
.vue-notification {
@apply rounded-md text-base border-l-6;
}
.vue-notification .notification-title {
@apply font-normal;
}
.vue-notification.success {
@apply bg-lime-600 border-l-lime-700;
}
.vue-notification.error {
@apply bg-red-600 border-l-red-700;
}
*::-webkit-scrollbar {
@apply bg-transparent w-12px h-12px;
}
* {
scrollbar-width: thin;
}
*::-webkit-scrollbar-thumb {
transition: background 0.2s ease-in-out;
border: 3px solid transparent;
@apply bg-cool-gray-200 dark:bg-dark-200 rounded-full bg-clip-content;
}
*::-webkit-scrollbar-thumb:hover {
@apply bg-cool-gray-300 dark:bg-dark-100;
}
*::-webkit-scrollbar-corner {
@apply bg-transparent;
}
</style>
<style scoped>
.app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.3s ease;
}
.slide-right-enter-from,
.slide-right-leave-to {
transform: translate(100%, 0);
}
</style>

1
web/src/assets/logo.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="white"><path d="M1.263 2.744C2.41 3.832 2.845 4.932 4.118 5.08l.036.007c-.588.606-1.09 1.402-1.443 2.423-.38 1.096-.488 2.285-.614 3.659-.19 2.046-.401 4.364-1.556 7.269-2.486 6.258-1.12 11.63.332 17.317.664 2.604 1.348 5.297 1.642 8.107a.857.857 0 00.633.744.86.86 0 00.922-.323c.227-.313.524-.797.86-1.424.84 3.323 1.355 6.13 1.783 8.697a.866.866 0 001.517.41c2.88-3.463 3.763-8.636 2.184-12.674.459-2.433 1.402-4.45 2.398-6.583.536-1.15 1.08-2.318 1.55-3.566.228-.084.569-.314.79-.441l1.707-.981-.256 1.052a.864.864 0 001.678.408l.68-2.858 1.285-2.95a.863.863 0 10-1.581-.687l-1.152 2.669-2.383 1.372a18.97 18.97 0 00.508-2.981c.432-4.86-.718-9.074-3.066-11.266-.163-.157-.208-.281-.247-.26.095-.12.249-.26.358-.374 2.283-1.693 6.047-.147 8.319.75.589.232.876-.337.316-.67-1.95-1.153-5.948-4.196-8.188-6.193-.313-.275-.527-.607-.89-.913C9.825.555 4.072 3.057 1.355 2.569c-.102-.018-.166.103-.092.175m10.98 5.899c-.06 1.242-.603 1.8-1 2.208-.217.224-.426.436-.524.738-.236.714.008 1.51.66 2.143 1.974 1.84 2.925 5.527 2.538 9.86-.291 3.288-1.448 5.763-2.671 8.385-1.031 2.207-2.096 4.489-2.577 7.259a.853.853 0 00.056.48c1.02 2.434 1.135 6.197-.672 9.46a96.586 96.586 0 00-1.97-8.711c1.964-4.488 4.203-11.75 2.919-17.668-.325-1.497-1.304-3.276-2.387-4.207-.208-.18-.402-.237-.495-.167-.084.06-.151.238-.062.444.55 1.266.879 2.599 1.226 4.276 1.125 5.443-.956 12.49-2.835 16.782l-.116.259-.457.982c-.356-2.014-.85-3.95-1.33-5.84-1.38-5.406-2.68-10.515-.401-16.254 1.247-3.137 1.483-5.692 1.672-7.746.116-1.263.216-2.355.526-3.252.905-2.605 3.062-3.178 4.744-2.852 1.632.316 3.24 1.593 3.156 3.42zm-2.868.62a1.177 1.177 0 10.736-2.236 1.178 1.178 0 10-.736 2.237z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 103 228" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M92.5292 2.12453C87.3155 7.06998 85.3383 12.07 79.5519 12.7427L79.3883 12.7745C82.061 15.5291 84.3428 19.1473 85.9473 23.7882C87.6746 28.77 88.1655 34.1745 88.7383 40.42C89.6019 49.72 90.561 60.2563 95.811 73.4609C107.111 101.906 100.902 126.325 94.3019 152.175C91.2837 164.011 88.1746 176.252 86.8383 189.025C86.7602 189.814 86.4428 190.561 85.9286 191.166C85.4144 191.77 84.7279 192.203 83.961 192.406C83.196 192.616 82.3854 192.587 81.6368 192.325C80.8883 192.063 80.2372 191.579 79.7701 190.938C78.7383 189.515 77.3883 187.315 75.861 184.465C72.0428 199.57 69.7019 212.329 67.7564 223.997C67.6294 224.737 67.2933 225.426 66.7879 225.981C66.2824 226.536 65.6287 226.935 64.9039 227.131C64.179 227.327 63.4132 227.312 62.6969 227.087C61.9806 226.861 61.3435 226.436 60.861 225.861C47.7701 210.12 43.7564 186.606 50.9337 168.252C48.8474 157.193 44.561 148.025 40.0337 138.329C37.5974 133.102 35.1246 127.793 32.9883 122.12C31.9519 121.738 30.4019 120.693 29.3973 120.115L21.6383 115.656L22.8019 120.438C23.0066 121.435 22.8168 122.472 22.2723 123.331C21.7279 124.19 20.8713 124.804 19.8829 125.045C18.8945 125.285 17.8514 125.133 16.9732 124.619C16.095 124.106 15.4502 123.272 15.1746 122.293L12.0837 109.302L6.2428 95.8927C6.0066 95.4163 5.86973 94.8968 5.84051 94.3658C5.81128 93.8349 5.89032 93.3035 6.07281 92.8041C6.25531 92.3046 6.53744 91.8474 6.90208 91.4604C7.26672 91.0733 7.70625 90.7645 8.19396 90.5525C8.68167 90.3406 9.20737 90.2301 9.73913 90.2276C10.2709 90.2251 10.7976 90.3308 11.2872 90.5382C11.7769 90.7456 12.2193 91.0504 12.5875 91.4341C12.9557 91.8177 13.242 92.2722 13.4292 92.77L18.6655 104.902L29.4974 111.138C28.3683 106.69 27.5962 102.159 27.1883 97.5882C25.2246 75.4973 30.4519 56.3427 41.1246 46.3791C41.8655 45.6654 42.0701 45.1018 42.2474 45.1973C41.8155 44.6518 41.1155 44.0154 40.6201 43.4973C30.2428 35.8018 13.1337 42.8291 2.80643 46.9063C0.129162 47.9609 -1.17538 45.3745 1.37007 43.8609C10.2337 38.62 28.4064 24.7882 38.5883 15.7109C40.011 14.4609 40.9837 12.9518 42.6337 11.5609C53.611 -7.82547 79.761 3.54726 92.111 1.32908C92.5746 1.24726 92.8655 1.79726 92.5292 2.12453V2.12453ZM42.6201 28.9382C42.8928 34.5836 45.361 37.12 47.1655 38.9745C48.1519 39.9927 49.1019 40.9563 49.5474 42.3291C50.6201 45.5745 49.511 49.1927 46.5474 52.07C37.5746 60.4336 33.2519 77.1927 35.011 96.8882C36.3337 111.834 41.5928 123.084 47.1519 135.002C51.8383 145.034 56.6792 155.406 58.8655 167.997C58.9928 168.734 58.9044 169.492 58.611 170.179C53.9746 181.243 53.4519 198.347 61.6655 213.179C64.0382 199.85 67.0263 186.637 70.6201 173.584C61.6928 153.184 51.5155 120.175 57.3519 93.2745C58.8292 86.47 63.2792 78.3836 68.2019 74.1518C69.1474 73.3336 70.0292 73.0745 70.4519 73.3927C70.8337 73.6654 71.1383 74.4745 70.7337 75.4109C68.2337 81.1654 66.7383 87.2245 65.161 94.8473C60.0473 119.588 69.5064 151.62 78.0473 171.129L78.5746 172.306L80.6519 176.77C82.2701 167.615 84.5155 158.815 86.6973 150.225C92.9701 125.652 98.8792 102.429 88.5201 76.3427C82.8519 62.0836 81.7792 50.47 80.9201 41.1336C80.3928 35.3927 79.9383 30.4291 78.5292 26.3518C74.4155 14.5109 64.611 11.9063 56.9655 13.3882C49.5474 14.8245 42.2383 20.6291 42.6201 28.9336V28.9382ZM55.6564 31.7563C54.9891 31.976 54.285 32.0621 53.5844 32.0096C52.8838 31.9572 52.2003 31.7673 51.5731 31.4507C50.9459 31.1342 50.3872 30.6971 49.9289 30.1647C49.4705 29.6322 49.1216 29.0146 48.9019 28.3473C48.6822 27.6799 48.5962 26.9758 48.6486 26.2752C48.701 25.5746 48.891 24.8912 49.2075 24.2639C49.5241 23.6367 49.9611 23.078 50.4936 22.6197C51.0261 22.1613 51.6436 21.8124 52.311 21.5927C52.9847 21.3487 53.7005 21.2424 54.416 21.2801C55.1316 21.3178 55.8322 21.4987 56.4766 21.8121C57.1209 22.1255 57.6958 22.5651 58.1672 23.1047C58.6385 23.6444 58.9968 24.2731 59.2207 24.9538C59.4446 25.6344 59.5297 26.353 59.4708 27.0671C59.412 27.7812 59.2104 28.4763 58.878 29.111C58.5456 29.7458 58.0892 30.3074 57.5359 30.7626C56.9825 31.2177 56.3434 31.5572 55.6564 31.7609V31.7563Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,134 @@
<template>
<button
type="button"
class="
relative
flex
items-center
py-1
px-4
rounded-md
border
shadow-sm
cursor-pointer
transition-all
duration-150
focus:outline-none
overflow-hidden
disabled:opacity-50 disabled:cursor-not-allowed
"
:class="{
'bg-white hover:bg-gray-200 border-gray-300 text-gray-500 dark:text-gray-500 dark:bg-dark-gray-700 dark:border-dark-400 dark:hover:bg-dark-gray-800':
color === 'gray',
'bg-lime-600 hover:bg-lime-700 border-lime-800 text-white dark:text-gray-400 dark:bg-lime-900 dark:hover:bg-lime-800':
color === 'green',
'bg-cyan-600 hover:bg-cyan-700 border-cyan-800 text-white dark:text-gray-400 dark:bg-cyan-900 dark:hover:bg-cyan-800':
color === 'blue',
'bg-red-500 hover:bg-red-600 border-red-700 text-white dark:text-gray-400 dark:bg-red-900 dark:hover:bg-red-800':
color === 'red',
...passedClasses,
}"
:disabled="disabled"
@click="doClick"
>
<slot>
<Icon v-if="startIcon" :name="startIcon" class="mr-2" :class="{ invisible: isLoading }" />
<span :class="{ invisible: isLoading }">{{ text }}</span>
<Icon v-if="endIcon" :name="endIcon" class="ml-2" :class="{ invisible: isLoading }" />
<div
class="absolute left-0 top-0 right-0 bottom-0 flex items-center justify-center"
:class="{
'opacity-100': isLoading,
'opacity-0': !isLoading,
'bg-white dark:bg-dark-gray-700': color === 'gray',
'bg-lime-700': color === 'green',
'bg-cyan-700': color === 'blue',
'bg-red-600': color === 'red',
}"
>
<Icon name="loading" class="animate-spin" />
</div>
</slot>
</button>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from 'vue';
import { RouteLocationRaw, useRouter } from 'vue-router';
import Icon, { IconNames } from '~/components/atomic/Icon.vue';
export default defineComponent({
name: 'Button',
components: { Icon },
props: {
text: {
type: String,
default: null,
},
disabled: {
type: Boolean,
required: false,
},
to: {
type: [String, Object, null] as PropType<RouteLocationRaw | null>,
default: null,
},
color: {
type: String as PropType<'blue' | 'green' | 'red' | 'gray'>,
default: 'gray',
},
startIcon: {
type: String as PropType<IconNames | null>,
default: null,
},
endIcon: {
type: String as PropType<IconNames | null>,
default: null,
},
isLoading: {
type: Boolean,
},
},
setup(props, { attrs }) {
const router = useRouter();
async function doClick() {
if (props.isLoading) {
return;
}
if (!props.to) {
return;
}
if (typeof props.to === 'string' && props.to.startsWith('http')) {
window.location.href = props.to;
return;
}
await router.push(props.to);
}
const passedClasses = computed(() => {
const classes: Record<string, boolean> = {};
const origClass = (attrs.class as string) || '';
origClass.split(' ').forEach((c) => {
classes[c] = true;
});
return classes;
});
return { doClick, passedClasses };
},
});
</script>

View file

@ -0,0 +1,32 @@
<template>
<a :href="`${docsBaseUrl}${url}`" target="_blank" class="text-blue-500 hover:text-blue-600 cursor-pointer mt-1"
><Icon name="question" class="!w-4 !h-4"
/></a>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import Icon from '~/components/atomic/Icon.vue';
export default defineComponent({
name: 'DocsLink',
components: {
Icon,
},
props: {
url: {
type: String,
required: true,
},
},
setup() {
const docsBaseUrl = window.WOODPECKER_DOCS;
return { docsBaseUrl };
},
});
</script>

View file

@ -0,0 +1,83 @@
<template>
<i-ic-sharp-timelapse v-if="name === 'duration'" class="h-6 w-6" />
<i-mdi-clock-time-eight-outline v-else-if="name === 'since'" class="h-6 w-6" />
<i-mdi-source-branch v-else-if="name === 'push'" class="h-6 w-6" />
<i-mdi-source-pull v-else-if="name === 'pull_request'" class="h-6 w-6" />
<i-mdi-tag-outline v-else-if="name === 'tag'" class="h-6 w-6" />
<i-clarity-deploy-line v-else-if="name === 'deployment'" class="h-6 w-6" />
<i-mdisource-commit v-else-if="name === 'commit'" class="h-6 w-6" />
<i-iconoir-arrow-left v-else-if="name === 'back'" class="w-8 h-8" />
<i-mdi-github v-else-if="name === 'github'" class="h-8 w-8" />
<i-teenyicons-git-solid v-else-if="name === 'repo'" class="h-8 w-8" />
<i-clarity-settings-solid v-else-if="name === 'settings'" class="w-8 h-8" />
<i-gg-trash v-else-if="name === 'trash'" class="h-6 w-6" />
<i-ph-hand v-else-if="name === 'status-blocked'" class="h-6 w-6" />
<i-ph-hand v-else-if="name === 'status-declined'" class="h-6 w-6" />
<i-ph-warning v-else-if="name === 'status-error'" class="h-8 w-8" />
<i-ph-x-circle v-else-if="name === 'status-failure'" class="h-8 w-8" />
<i-octicon-skip-24 v-else-if="name === 'status-killed'" class="h-7 w-7" />
<i-ph-hourglass v-else-if="name === 'status-pending'" class="h-7 w-7" />
<i-entypo-dots-two-vertical v-else-if="name === 'status-running'" class="h-8 w-8" />
<i-ph-prohibit v-else-if="name === 'status-skipped'" class="h-8 w-8" />
<i-entypo-dots-two-vertical v-else-if="name === 'status-started'" class="h-8 w-8" />
<i-ph-check-circle v-else-if="name === 'status-success'" class="h-8 w-8" />
<i-cib-gitea v-else-if="name === 'gitea'" class="h-8 w-8" />
<i-vaadin-question-circle-o v-else-if="name === 'question'" class="h-6 w-6" />
<i-ic-twotone-add v-else-if="name === 'plus'" class="h-6 w-6" />
<i-mdi-format-list-bulleted v-else-if="name === 'list'" class="h-6 w-6" />
<i-mdi-loading v-else-if="name === 'loading'" class="h-6 w-6" />
<i-ic-baseline-dark-mode v-else-if="name === 'dark'" class="h-6 w-6" />
<i-ic-round-light-mode v-else-if="name === 'light'" class="h-6 w-6" />
<i-mdi-sync v-else-if="name === 'sync'" class="h-6 w-6" />
<i-ic-baseline-healing v-else-if="name === 'heal'" class="h-6 w-6" />
<div v-else-if="name === 'blank'" class="h-6 w-6" />
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
export type IconNames =
| 'duration'
| 'since'
| 'push'
| 'pull_request'
| 'tag'
| 'deployment'
| 'commit'
| 'back'
| 'github'
| 'repo'
| 'settings'
| 'trash'
| 'status-blocked'
| 'status-declined'
| 'status-error'
| 'status-failure'
| 'status-killed'
| 'status-pending'
| 'status-running'
| 'status-skipped'
| 'status-started'
| 'status-success'
| 'gitea'
| 'question'
| 'list'
| 'loading'
| 'plus'
| 'blank'
| 'dark'
| 'light'
| 'sync'
| 'heal';
export default defineComponent({
name: 'Icon',
props: {
name: {
type: String as PropType<IconNames>,
required: true,
},
},
});
</script>

View file

@ -0,0 +1,60 @@
<template>
<Button
:disabled="disabled"
:is-loading="isLoading"
:to="to"
class="
flex
items-center
justify-center
text-gray-500
px-1
py-1
rounded-full
!bg-transparent
!hover:bg-gray-200
!dark:hover:bg-gray-600
hover:text-gray-700
dark:text-gray-500 dark:hover:text-gray-700
shadow-none
border-none
"
>
<Icon :name="icon" />
</Button>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { RouteLocationRaw } from 'vue-router';
import Button from '~/components/atomic/Button.vue';
import Icon, { IconNames } from '~/components/atomic/Icon.vue';
export default defineComponent({
name: 'IconButton',
components: { Button, Icon },
props: {
icon: {
type: String as PropType<IconNames>,
required: true,
},
disabled: {
type: Boolean,
required: false,
},
to: {
type: [String, Object, null] as PropType<RouteLocationRaw | null>,
default: null,
},
isLoading: {
type: Boolean,
},
},
});
</script>

View file

@ -0,0 +1,32 @@
<template>
<div
class="
w-full
flex
border
rounded-md
bg-white
overflow-hidden
p-4
border-gray-300
dark:bg-dark-gray-700 dark:border-dark-400
"
:class="{ 'cursor-pointer hover:shadow-md hover:bg-gray-200 dark:hover:bg-dark-gray-800': clickable }"
>
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'ListItem',
props: {
clickable: {
type: Boolean,
},
},
});
</script>

View file

@ -0,0 +1,48 @@
<template>
<div v-if="build" class="flex text-gray-600 dark:text-gray-500">
<BuildStatusIcon :build="build" class="flex items-center" />
<div class="flex flex-col ml-4">
<span class="underline">{{ build.owner }} / {{ build.name }}</span>
<span>{{ message }}</span>
<div class="flex flex-col mt-2">
<div class="flex space-x-2 items-center">
<Icon name="duration" />
<span>{{ duration }}</span>
</div>
<div class="flex space-x-2 items-center">
<Icon name="since" />
<span>{{ since }}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, toRef } from 'vue';
import Icon from '~/components/atomic/Icon.vue';
import BuildStatusIcon from '~/components/repo/build/BuildStatusIcon.vue';
import useBuild from '~/compositions/useBuild';
import { BuildFeed } from '~/lib/api/types';
export default defineComponent({
name: 'BuildFeedItem',
components: { BuildStatusIcon, Icon },
props: {
build: {
type: Object as PropType<BuildFeed>,
required: true,
},
},
setup(props) {
const build = toRef(props, 'build');
const { since, duration, message } = useBuild(build);
return { since, duration, message };
},
});
</script>

View file

@ -0,0 +1,48 @@
<template>
<div
v-if="isBuildFeedOpen"
class="flex flex-col overflow-y-auto items-center bg-white dark:bg-dark-gray-800 dark:border-dark-500"
>
<router-link
v-for="build in sortedBuildFeed"
:key="build.id"
:to="{ name: 'repo-build', params: { repoOwner: build.owner, repoName: build.name, buildId: build.number } }"
class="
flex
border-b
py-4
px-2
w-full
hover:bg-light-300
dark:hover:bg-dark-gray-900 dark:border-dark-gray-600
hover:shadow-sm
"
>
<BuildFeedItem :build="build" />
</router-link>
<span v-if="sortedBuildFeed.length === 0" class="text-gray-500 m-4">No pipelines have been started yet.</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import BuildFeedItem from '~/components/build-feed/BuildFeedItem.vue';
import useBuildFeed from '~/compositions/useBuildFeed';
export default defineComponent({
name: 'BuildFeedSidebar',
components: { BuildFeedItem },
setup() {
const buildFeed = useBuildFeed();
return {
isBuildFeedOpen: buildFeed.isOpen,
sortedBuildFeed: buildFeed.sortedBuilds,
};
},
});
</script>

View file

@ -0,0 +1,108 @@
<template>
<div class="flex items-center mb-2">
<input
:id="`checkbox-${id}`"
type="checkbox"
class="
checkbox
relative
border border-gray-400
dark:border-gray-600
cursor-pointer
rounded-md
transition-colors
duration-150
w-5
h-5
checked:bg-lime-600 checked:border-lime-600
dark:checked:bg-lime-800 dark:checked:border-lime-800
"
:checked="innerValue"
@click="innerValue = !innerValue"
/>
<div class="flex flex-col ml-4">
<label v-if="label" class="cursor-pointer text-gray-600 dark:text-gray-500" :for="`checkbox-${id}`">{{
label
}}</label>
<span v-if="description" class="text-sm text-gray-400 dark:text-gray-600">{{ description }}</span>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, toRef } from 'vue';
export default defineComponent({
name: 'Checkbox',
props: {
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: Boolean,
required: true,
},
label: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:modelValue': (_value: boolean): boolean => true,
},
setup: (props, ctx) => {
const modelValue = toRef(props, 'modelValue');
const innerValue = computed({
get: () => modelValue.value,
set: (value) => {
ctx.emit('update:modelValue', value);
},
});
const id = (Math.random() + 1).toString(36).substring(7);
return {
id,
innerValue,
};
},
});
</script>
<style scoped>
.checkbox {
appearance: none;
outline: 0;
cursor: pointer;
transition: background 175ms cubic-bezier(0.1, 0.1, 0.25, 1);
}
.checkbox::before {
position: absolute;
content: '';
display: block;
top: 50%;
left: 50%;
width: 8px;
height: 14px;
border-style: solid;
border-color: white;
border-width: 0 2px 2px 0;
transform: translate(-50%, -60%) rotate(45deg);
opacity: 0;
@apply dark:border-gray-400;
}
.checkbox:checked::before {
opacity: 1;
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<Checkbox
v-for="option in options"
:key="option.value"
:model-value="innerValue.includes(option.value)"
:label="option.text"
class="mb-2"
@update:model-value="clickOption(option)"
/>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, toRef } from 'vue';
import Checkbox from './Checkbox.vue';
import { CheckboxOption } from './form.types';
export default defineComponent({
name: 'CheckboxesField',
components: { Checkbox },
props: {
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: Array as PropType<CheckboxOption['value'][]>,
default: () => [],
},
options: {
type: Array as PropType<CheckboxOption[]>,
required: true,
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:modelValue': (_value: CheckboxOption['value'][]): boolean => true,
},
setup: (props, ctx) => {
const modelValue = toRef(props, 'modelValue');
const innerValue = computed({
get: () => modelValue.value,
set: (value) => {
ctx.emit('update:modelValue', value);
},
});
function clickOption(option: CheckboxOption) {
if (innerValue.value.includes(option.value)) {
innerValue.value = innerValue.value.filter((o) => o !== option.value);
} else {
innerValue.value.push(option.value);
}
}
return {
innerValue,
clickOption,
};
},
});
</script>

View file

@ -0,0 +1,38 @@
<template>
<div class="flex flex-col mt-2 mb-4">
<div class="flex items-center text-gray-500 font-bold mb-2">
<label v-if="label" v-bind="$attrs">{{ label }}</label>
<DocsLink v-if="docsUrl" :url="docsUrl" class="ml-2" />
</div>
<slot />
<div v-if="$slots['description']" class="ml-1 text-gray-400">
<slot name="description" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import DocsLink from '~/components/atomic/DocsLink.vue';
export default defineComponent({
name: 'InputField',
components: { DocsLink },
inheritAttrs: false,
props: {
label: {
type: String,
default: null,
},
docsUrl: {
type: String,
default: null,
},
},
});
</script>

View file

@ -0,0 +1,48 @@
<template>
<TextField v-model="innerValue" :placeholder="placeholder" type="number" />
</template>
<script lang="ts">
import { computed, defineComponent, toRef } from 'vue';
import TextField from '~/components/form/TextField.vue';
export default defineComponent({
name: 'NumberField',
components: { TextField },
props: {
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: Number,
required: true,
},
placeholder: {
type: String,
default: '',
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:modelValue': (_value: number): boolean => true,
},
setup: (props, ctx) => {
const modelValue = toRef(props, 'modelValue');
const innerValue = computed({
get: () => modelValue.value.toString(),
set: (value) => {
ctx.emit('update:modelValue', parseFloat(value));
},
});
return {
innerValue,
};
},
});
</script>

View file

@ -0,0 +1,105 @@
<template>
<div v-for="option in options" :key="option.value" class="flex items-center mb-2">
<input
:id="`radio-${id}-${option.value}`"
type="radio"
class="
radio
relative
border border-gray-400
dark:border-gray-600
cursor-pointer
rounded-full
w-5
h-5
checked:bg-lime-600 checked:border-lime-600
dark:checked:bg-lime-700 dark:checked:border-lime-700
"
:value="option.value"
:checked="innerValue.includes(option.value)"
@click="innerValue = option.value"
/>
<div class="flex flex-col ml-4">
<label class="cursor-pointer text-gray-600 dark:text-gray-500" :for="`radio-${id}-${option.value}`">{{
option.text
}}</label>
<span v-if="option.description" class="text-sm text-gray-400 dark:text-gray-600">{{ option.description }}</span>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, toRef } from 'vue';
import { RadioOption } from './form.types';
export default defineComponent({
name: 'RadioField',
components: {},
props: {
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: String,
required: true,
},
options: {
type: Array as PropType<RadioOption[]>,
required: true,
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:modelValue': (_value: RadioOption['value']): boolean => true,
},
setup: (props, ctx) => {
const modelValue = toRef(props, 'modelValue');
const innerValue = computed({
get: () => modelValue.value,
set: (value) => {
ctx.emit('update:modelValue', value);
},
});
const id = (Math.random() + 1).toString(36).substring(7);
return {
id,
innerValue,
};
},
});
</script>
<style scoped>
.radio {
appearance: none;
outline: 0;
cursor: pointer;
transition: background 175ms cubic-bezier(0.1, 0.1, 0.25, 1);
}
.radio::before {
position: absolute;
content: '';
display: block;
top: 50%;
left: 50%;
width: 7px;
height: 7px;
border-radius: 50%;
background: white;
transform: translate(-50%, -50%);
opacity: 0;
@apply dark:bg-gray-400;
}
.radio:checked::before {
opacity: 1;
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<select
v-model="innerValue"
class="w-full border border-gray-900 py-1 px-2 rounded-md bg-white focus:outline-none border-gray-900"
:class="{
'text-gray-500': innerValue === '',
'text-gray-900': innerValue !== '',
}"
>
<option v-if="placeholder" value="" class="hidden">{{ placeholder }}</option>
<option v-for="option in options" :key="option.value" :value="option.value" class="text-gray-500">
{{ option.text }}
</option>
</select>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, toRef } from 'vue';
import { SelectOption } from './form.types';
export default defineComponent({
name: 'SelectField',
props: {
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
options: {
type: Array as PropType<SelectOption[]>,
required: true,
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:modelValue': (_value: SelectOption['value'] | null): boolean => true,
},
setup: (props, ctx) => {
const modelValue = toRef(props, 'modelValue');
const innerValue = computed({
get: () => modelValue.value,
set: (selectedValue) => {
ctx.emit('update:modelValue', selectedValue);
},
});
return {
innerValue,
};
},
});
</script>

View file

@ -0,0 +1,74 @@
<template>
<div
class="
w-full
border border-gray-200
py-1
px-2
rounded-md
bg-white
hover:border-gray-300
dark:bg-dark-gray-700 dark:border-dark-400 dark:hover:border-dark-800
"
>
<input
v-model="innerValue"
class="
w-full
bg-transparent
text-gray-600
placeholder-gray-400
focus:outline-none focus:border-blue-400
dark:placeholder-gray-600 dark:text-gray-500
"
:type="type"
:placeholder="placeholder"
/>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, toRef } from 'vue';
export default defineComponent({
name: 'TextField',
props: {
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
type: {
type: String,
default: 'text',
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:modelValue': (_value: string): boolean => true,
},
setup: (props, ctx) => {
const modelValue = toRef(props, 'modelValue');
const innerValue = computed({
get: () => modelValue.value,
set: (value) => {
ctx.emit('update:modelValue', value);
},
});
return {
innerValue,
};
},
});
</script>

View file

@ -0,0 +1,9 @@
export type SelectOption = {
value: string;
text: string;
description?: string;
};
export type RadioOption = SelectOption;
export type CheckboxOption = SelectOption;

View file

@ -0,0 +1,13 @@
<template>
<div class="w-full max-w-5xl p-2 md:p-4 lg:px-0 mx-auto">
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'FluidContainer',
});
</script>

View file

@ -0,0 +1,17 @@
<template>
<div class="rounded-md w-full p-4 shadow border bg-white dark:bg-dark-gray-700 dark:border-dark-200">
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'Panel',
setup() {
return {};
},
});
</script>

View file

@ -0,0 +1,72 @@
<template>
<div
class="
flex
rounded-full
w-8
h-8
bg-opacity-30
hover:bg-opacity-50
bg-white
items-center
justify-center
cursor-pointer
text-white
"
:class="{
spinner: activeBuilds.length !== 0,
}"
@click="toggle"
>
<div class="spinner-ring ring1" />
<div class="spinner-ring ring2" />
<div class="spinner-ring ring3" />
<div class="spinner-ring ring4" />
{{ activeBuilds.length || 0 }}
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted } from 'vue';
import useBuildFeed from '~/compositions/useBuildFeed';
export default defineComponent({
name: 'ActiveBuilds',
setup() {
const buildFeed = useBuildFeed();
onMounted(() => {
buildFeed.load();
});
return buildFeed;
},
});
</script>
<style scoped>
.spinner .spinner-ring {
animation: spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
@apply w-8 h-8 border-2 rounded-full m-4 absolute;
}
.spinner .ring1 {
animation-delay: -0.45s;
}
.spinner .ring2 {
animation-delay: -0.3s;
}
.spinner .ring3 {
animation-delay: -0.15s;
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,69 @@
<template>
<div class="flex shadow-lg bg-lime-600 text-neutral-content px-2 md:px-8 py-2 dark:bg-dark-gray-900">
<div class="flex text-white dark:text-gray-500 items-center">
<router-link :to="{ name: 'home' }" class="relative">
<img class="-mt-3 w-8" src="../../../assets/logo.svg?url" />
<span class="absolute -bottom-4 text-xs">{{ version }}</span>
</router-link>
<router-link
v-if="user"
:to="{ name: 'repos' }"
class="mx-4 hover:bg-lime-700 dark:hover:bg-gray-600 px-4 py-1 rounded-md"
>
<span class="flex md:hidden">Repos</span>
<span class="hidden md:flex">Repositories</span>
</router-link>
</div>
<div class="flex ml-auto items-center space-x-4 text-white dark:text-gray-500">
<a
:href="docsUrl"
target="_blank"
class="hover:bg-lime-700 dark:hover:bg-gray-600 px-4 py-1 rounded-md hidden md:flex"
>Docs</a
>
<IconButton
:icon="darkMode ? 'dark' : 'light'"
class="!text-white !dark:text-gray-500"
@click="darkMode = !darkMode"
/>
<router-link v-if="user" :to="{ name: 'user' }">
<img v-if="user && user.avatar_url" class="w-8" :src="`${user.avatar_url}`" />
</router-link>
<Button v-else text="Login" @click="doLogin" />
<ActiveBuilds v-if="user" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import Button from '~/components/atomic/Button.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import useAuthentication from '~/compositions/useAuthentication';
import useConfig from '~/compositions/useConfig';
import { useDarkMode } from '~/compositions/useDarkMode';
import ActiveBuilds from './ActiveBuilds.vue';
export default defineComponent({
name: 'Navbar',
components: { Button, ActiveBuilds, IconButton },
setup() {
const config = useConfig();
const authentication = useAuthentication();
const { darkMode } = useDarkMode();
const docsUrl = window.WOODPECKER_DOCS;
function doLogin() {
authentication.authenticate();
}
const version = config.version?.startsWith('next') ? 'next' : config.version;
return { darkMode, user: authentication.user, doLogin, docsUrl, version };
},
});
</script>

View file

@ -0,0 +1,86 @@
<template>
<ListItem v-if="build" clickable class="p-0">
<div class="flex items-center mr-4">
<div
class="min-h-full w-3"
:class="{
'bg-yellow-400 dark:bg-dark-200': build.status === 'pending',
'bg-red-400 dark:bg-red-800': buildStatusColors[build.status] === 'red',
'bg-gray-600 dark:bg-gray-500': buildStatusColors[build.status] === 'gray',
'bg-lime-400 dark:bg-lime-900': buildStatusColors[build.status] === 'green',
'bg-blue-400 dark:bg-blue-900': buildStatusColors[build.status] === 'blue',
}"
/>
<div class="w-8 flex">
<BuildRunningIcon v-if="build.status === 'started' || build.status === 'running'" />
<BuildStatusIcon v-else class="mx-3" :build="build" />
</div>
</div>
<div class="flex w-full py-2 px-4">
<div class="flex items-center"><img class="w-8" :src="build.author_avatar" /></div>
<div class="ml-4 flex items-center mx-4">
<span class="text-gray-600 dark:text-gray-500">{{ message }}</span>
</div>
<div class="flex ml-auto text-gray-500 py-2">
<div class="flex flex-col space-y-2 w-42">
<div class="flex space-x-2 items-center">
<Icon v-if="build.event === 'pull_request'" name="pull_request" />
<Icon v-else-if="build.event === 'deployment'" name="deployment" />
<Icon v-else-if="build.event === 'tag'" name="tag" />
<Icon v-else name="push" />
<span class="truncate">{{ build.branch }}</span>
</div>
<div class="flex space-x-2 items-center">
<Icon name="commit" />
<span>{{ build.commit.slice(0, 10) }}</span>
</div>
</div>
<div class="flex flex-col ml-4 space-y-2 w-42">
<div class="flex space-x-2 items-center">
<Icon name="duration" />
<span>{{ duration }}</span>
</div>
<div class="flex space-x-2 items-center">
<Icon name="since" />
<span>{{ since }}</span>
</div>
</div>
</div>
</div>
</ListItem>
</template>
<script lang="ts">
import { defineComponent, PropType, toRef } from 'vue';
import Icon from '~/components/atomic/Icon.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import { buildStatusColors } from '~/components/repo/build/build-status';
import BuildRunningIcon from '~/components/repo/build/BuildRunningIcon.vue';
import BuildStatusIcon from '~/components/repo/build/BuildStatusIcon.vue';
import useBuild from '~/compositions/useBuild';
import { Build } from '~/lib/api/types';
export default defineComponent({
name: 'BuildItem',
components: { Icon, BuildStatusIcon, ListItem, BuildRunningIcon },
props: {
build: {
type: Object as PropType<Build>,
required: true,
},
},
setup(props) {
const build = toRef(props, 'build');
const { since, duration, message } = useBuild(build);
return { since, duration, message, buildStatusColors };
},
});
</script>

View file

@ -0,0 +1,41 @@
<template>
<div v-if="builds" class="space-y-4">
<router-link
v-for="build in builds"
:key="build.id"
:to="{ name: 'repo-build', params: { repoOwner: repo.owner, repoName: repo.name, buildId: build.number } }"
class="flex"
>
<BuildItem :build="build" />
</router-link>
<Panel v-if="builds.length === 0">
<span class="text-gray-500">No pipelines have been started yet.</span>
</Panel>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import Panel from '~/components/layout/Panel.vue';
import BuildItem from '~/components/repo/build/BuildItem.vue';
import { Build, Repo } from '~/lib/api/types';
export default defineComponent({
name: 'BuildList',
components: { Panel, BuildItem },
props: {
repo: {
type: Object as PropType<Repo>,
required: true,
},
builds: {
type: Object as PropType<Build[] | undefined>,
required: true,
},
},
});
</script>

View file

@ -0,0 +1,86 @@
<template>
<div v-if="build" class="bg-gray-700 dark:bg-dark-gray-700 p-4">
<div v-for="logLine in logLines" :key="logLine.pos" class="flex items-center">
<div class="text-gray-500 text-sm w-4">{{ (logLine.pos || 0) + 1 }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="mx-4 text-gray-200 dark:text-gray-400" v-html="logLine.out" />
<div class="ml-auto text-gray-500 text-sm">{{ logLine.time || 0 }}s</div>
</div>
<div v-if="proc?.end_time !== undefined" class="text-gray-500 text-sm mt-4 ml-8">
exit code {{ proc.exit_code }}
</div>
<template v-if="!proc?.start_time" />
<div class="text-gray-300 mx-auto">
<span v-if="proc?.state === 'skipped'" class="text-orange-300 dark:text-orange-800"
>This step has been canceled.</span
>
<span v-else-if="!proc?.start_time" class="dark:text-gray-500">This step hasn't started yet.</span>
</div>
</div>
</template>
<script lang="ts">
import AnsiConvert from 'ansi-to-html';
import { computed, defineComponent, inject, onBeforeUnmount, onMounted, PropType, Ref, toRef, watch } from 'vue';
import useBuildProc from '~/compositions/useBuildProc';
import { Build, Repo } from '~/lib/api/types';
import { findProc } from '~/utils/helpers';
export default defineComponent({
name: 'BuildLogs',
components: {},
props: {
build: {
type: Object as PropType<Build>,
required: true,
},
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
procId: {
type: Number,
required: true,
},
},
setup(props) {
const build = toRef(props, 'build');
const procId = toRef(props, 'procId');
const repo = inject<Ref<Repo>>('repo');
const buildProc = useBuildProc();
const ansiConvert = new AnsiConvert();
const logLines = computed(() => buildProc.logs.value?.map((l) => ({ ...l, out: ansiConvert.toHtml(l.out) })));
const proc = computed(() => build.value && findProc(build.value.procs || [], procId.value));
function loadBuildProc() {
if (!repo) {
throw new Error('Unexpected: "repo" should be provided at this place');
}
if (!repo.value || !build.value || !proc.value) {
return;
}
buildProc.load(repo.value.owner, repo.value.name, build.value.number, proc.value);
}
onMounted(() => {
loadBuildProc();
});
watch([repo, build, procId], () => {
loadBuildProc();
});
onBeforeUnmount(() => {
buildProc.unload();
});
return { logLines, proc };
},
});
</script>

View file

@ -0,0 +1,54 @@
<template>
<span v-if="proc.start_time !== undefined" class="ml-auto text-sm">{{ duration }}</span>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, toRef } from 'vue';
import { useElapsedTime } from '~/compositions/useElapsedTime';
import { BuildProc } from '~/lib/api/types';
import { durationAsNumber } from '~/utils/duration';
export default defineComponent({
name: 'BuildProcDuration',
props: {
proc: {
type: Object as PropType<BuildProc>,
required: true,
},
},
setup(props) {
const proc = toRef(props, 'proc');
const durationRaw = computed(() => {
const start = proc.value.start_time || 0;
const end = proc.value.end_time || 0;
if (end === 0 && start === 0) {
return undefined;
}
if (end === 0) {
return Date.now() - start * 1000;
}
return (end - start) * 1000;
});
const running = computed(() => proc.value.state === 'running');
const { time: durationElapsed } = useElapsedTime(running, durationRaw);
const duration = computed(() => {
if (durationElapsed.value === undefined) {
return '-';
}
return durationAsNumber(durationElapsed.value);
});
return { duration };
},
});
</script>

View file

@ -0,0 +1,67 @@
<template>
<div class="flex mt-4 w-full bg-gray-600 dark:bg-dark-gray-800 min-h-0 flex-grow">
<div v-if="build.error" class="flex flex-col p-4">
<span class="text-red-400 font-bold text-xl mb-2">Execution error</span>
<span class="text-red-400">{{ build.error }}</span>
</div>
<div class="flex flex-col w-3/12 text-gray-200 dark:text-gray-400">
<div v-for="proc in build.procs" :key="proc.id">
<div class="p-4 pb-1">{{ proc.name }}</div>
<div
v-for="job in proc.children"
:key="job.pid"
class="flex p-2 pl-6 cursor-pointer items-center hover:bg-gray-700 hover:dark:bg-dark-gray-900"
:class="{ 'bg-gray-700 !dark:bg-dark-gray-600': selectedProcId && selectedProcId === job.pid }"
@click="$emit('update:selected-proc-id', job.pid)"
>
<div v-if="['success'].includes(job.state)" class="w-2 h-2 bg-lime-400 rounded-full" />
<div v-if="['pending', 'skipped'].includes(job.state)" class="w-2 h-2 bg-gray-400 rounded-full" />
<div
v-if="['killed', 'error', 'failure', 'blocked', 'declined'].includes(job.state)"
class="w-2 h-2 bg-red-400 rounded-full"
/>
<div v-if="['started', 'running'].includes(job.state)" class="w-2 h-2 bg-blue-400 rounded-full" />
<span class="ml-2">{{ job.name }}</span>
<BuildProcDuration :proc="job" />
</div>
</div>
</div>
<BuildLogs v-if="selectedProcId" :build="build" :proc-id="selectedProcId" class="w-9/12 flex-grow" />
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import BuildLogs from '~/components/repo/build/BuildLogs.vue';
import BuildProcDuration from '~/components/repo/build/BuildProcDuration.vue';
import { Build } from '~/lib/api/types';
export default defineComponent({
name: 'BuildProcs',
components: {
BuildLogs,
BuildProcDuration,
},
props: {
build: {
type: Object as PropType<Build>,
required: true,
},
selectedProcId: {
type: Number as PropType<number | null>,
default: null,
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:selected-proc-id': (selectedProcId: number) => true,
},
});
</script>

View file

@ -0,0 +1,43 @@
<template>
<WoodpeckerIcon class="woodpecker h-16" />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
// eslint-disable-next-line import/no-relative-parent-imports
import WoodpeckerIcon from '../../../assets/woodpecker.svg?component';
export default defineComponent({
name: 'BuildRunningIcon',
components: {
WoodpeckerIcon,
},
});
</script>
<style scoped>
@keyframes peck {
0% {
transform: rotate(5deg) translateX(5%);
}
10% {
transform: rotate(-5deg) translateX(-15%);
}
20% {
transform: rotate(5deg) translateX(5%);
}
30% {
transform: rotate(-5deg) translateX(-15%);
}
100% {
transform: rotate(5deg) translateX(5%);
}
}
.woodpecker ::v-deep(path) {
animation: peck 1s ease infinite;
@apply fill-gray-600 dark:fill-gray-500;
}
</style>

View file

@ -0,0 +1,43 @@
<template>
<div v-if="build" class="flex items-center justify-center">
<Icon
:name="`status-${build.status}`"
:class="{
'text-yellow-400': build.status === 'pending',
'text-red-400': buildStatusColors[build.status] === 'red',
'text-gray-400': buildStatusColors[build.status] === 'gray',
'text-lime-400': buildStatusColors[build.status] === 'green',
'text-blue-400': buildStatusColors[build.status] === 'blue',
[buildStatusAnimations[build.status]]: true,
}"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import Icon from '~/components/atomic/Icon.vue';
import { Build } from '~/lib/api/types';
import { buildStatusAnimations, buildStatusColors } from './build-status';
export default defineComponent({
name: 'BuildStatusIcon',
components: {
Icon,
},
props: {
build: {
type: Object as PropType<Build>,
required: true,
},
},
setup() {
return { buildStatusColors, buildStatusAnimations };
},
});
</script>

View file

@ -0,0 +1,27 @@
import { BuildStatus } from '~/lib/api/types';
export const buildStatusColors: Record<BuildStatus, string> = {
blocked: 'gray',
declined: 'red',
error: 'red',
failure: 'red',
killed: 'gray',
pending: 'gray',
skipped: 'gray',
running: 'blue',
started: 'blue',
success: 'green',
};
export const buildStatusAnimations: Record<BuildStatus, string> = {
blocked: '',
declined: '',
error: '',
failure: '',
killed: '',
pending: '',
skipped: '',
running: 'animate-spin animate-slow',
started: 'animate-spin animate-slow',
success: '',
};

View file

@ -0,0 +1,85 @@
<template>
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<h1 class="text-xl ml-2 text-gray-500">Actions</h1>
</div>
<div class="flex flex-col">
<Button
class="mr-auto mt-4"
color="blue"
start-icon="heal"
text="Repair repository"
:is-loading="isRepairingRepo"
@click="repairRepo"
/>
<Button
class="mr-auto mt-4"
color="red"
start-icon="trash"
text="Delete repository"
:is-loading="isDeletingRepo"
@click="deleteRepo"
/>
</div>
</Panel>
</template>
<script lang="ts">
import { defineComponent, inject, Ref } from 'vue';
import { useRouter } from 'vue-router';
import Button from '~/components/atomic/Button.vue';
import Panel from '~/components/layout/Panel.vue';
import useApiClient from '~/compositions/useApiClient';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useNotifications from '~/compositions/useNotifications';
import { Repo } from '~/lib/api/types';
export default defineComponent({
name: 'ActionsTab',
components: { Button, Panel },
setup() {
const apiClient = useApiClient();
const router = useRouter();
const notifications = useNotifications();
const repo = inject<Ref<Repo>>('repo');
const { doSubmit: repairRepo, isLoading: isRepairingRepo } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo should be set');
}
await apiClient.repairRepo(repo.value.owner, repo.value.name);
notifications.notify({ title: 'Repository repaired', type: 'success' });
});
const { doSubmit: deleteRepo, isLoading: isDeletingRepo } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo should be set');
}
// TODO use proper dialog
// eslint-disable-next-line no-alert, no-restricted-globals
if (!confirm('All data will be lost after this action!!!\n\nDo you really want to procceed?')) {
return;
}
await apiClient.deleteRepo(repo.value.owner, repo.value.name);
notifications.notify({ title: 'Repository deleted', type: 'success' });
await router.replace({ name: 'repos' });
});
return {
isRepairingRepo,
isDeletingRepo,
deleteRepo,
repairRepo,
};
},
});
</script>

View file

@ -0,0 +1,61 @@
<template>
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<h1 class="text-xl ml-2 text-gray-500">Badge</h1>
<a v-if="badgeUrl" :href="badgeUrl" target="_blank" class="ml-auto">
<img :src="badgeUrl" />
</a>
</div>
<div class="flex flex-col space-y-4">
<div>
<h2 class="text-lg text-gray-500 ml-2">Url</h2>
<pre class="box">{{ baseUrl }}{{ badgeUrl }}</pre>
</div>
<div>
<h2 class="text-lg text-gray-500 ml-2">Url for specific branch</h2>
<pre class="box">{{ baseUrl }}{{ badgeUrl }}?branch=<span class="font-bold">&lt;branch&gt;</span></pre>
</div>
<div>
<h2 class="text-lg text-gray-500 ml-2">Markdown</h2>
<pre class="box">![status-badge]({{ baseUrl }}{{ badgeUrl }})</pre>
</div>
</div>
</Panel>
</template>
<script lang="ts">
import { computed, defineComponent, inject, Ref } from 'vue';
import Panel from '~/components/layout/Panel.vue';
import { Repo } from '~/lib/api/types';
export default defineComponent({
name: 'BadgeTab',
components: { Panel },
setup() {
const repo = inject<Ref<Repo>>('repo');
const baseUrl = `${window.location.protocol}//${window.location.hostname}`;
const badgeUrl = computed(() => {
if (!repo) {
throw new Error('Unexpected: "repo" should be provided at this place');
}
return `/api/badges/${repo.value.owner}/${repo.value.name}/status.svg`;
});
return { baseUrl, badgeUrl };
},
});
</script>
<style scoped>
.box {
@apply bg-gray-400 p-2 rounded-md text-white break-words dark:bg-dark-300 dark:text-gray-500;
white-space: pre-wrap;
}
</style>

View file

@ -0,0 +1,159 @@
<template>
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<h1 class="text-xl ml-2 text-gray-500">General</h1>
</div>
<div v-if="repoSettings" class="flex flex-col">
<InputField label="Pipeline path" docs-url="docs/usage/project-settings#pipeline-path">
<TextField
v-model="repoSettings.config_file"
class="max-w-124"
placeholder="By default: .woodpecker/*.yml -> .woodpecker.yml -> .drone.yml"
/>
<template #description>
<p class="text-sm text-gray-400 dark:text-gray-600">
Path to your pipeline config (for example
<span class="bg-gray-300 dark:bg-dark-100 rounded-md px-1">my/path/</span>). Folders should end with a
<span class="bg-gray-300 dark:bg-dark-100 rounded-md px-1">/</span>.
</p>
</template>
</InputField>
<InputField label="Project settings" docs-url="docs/usage/project-settings#project-settings-1">
<Checkbox
v-model="repoSettings.allow_pr"
label="Allow Pull Request"
description="Pipelines can run on pull requests."
/>
<Checkbox
v-model="repoSettings.gated"
label="Protected"
description="Every pipeline needs to be approved before being executed."
/>
<Checkbox
v-if="user?.admin"
v-model="repoSettings.trusted"
label="Trusted"
description="Underlying pipeline containers get access to escalated capabilities like mounting volumes."
/>
</InputField>
<InputField label="Project visibility" docs-url="docs/usage/project-settings#project-visibility">
<RadioField v-model="repoSettings.visibility" :options="projectVisibilityOptions" />
</InputField>
<InputField label="Timeout" docs-url="docs/usage/project-settings#timeout">
<div class="flex items-center">
<NumberField v-model="repoSettings.timeout" class="w-24" />
<span class="ml-4 text-gray-600">minutes</span>
</div>
</InputField>
<Button class="mr-auto" color="green" text="Save settings" :is-loading="isSaving" @click="saveRepoSettings" />
</div>
</Panel>
</template>
<script lang="ts">
import { defineComponent, inject, onMounted, Ref, ref } from 'vue';
import Button from '~/components/atomic/Button.vue';
import Checkbox from '~/components/form/Checkbox.vue';
import { RadioOption } from '~/components/form/form.types';
import InputField from '~/components/form/InputField.vue';
import NumberField from '~/components/form/NumberField.vue';
import RadioField from '~/components/form/RadioField.vue';
import TextField from '~/components/form/TextField.vue';
import Panel from '~/components/layout/Panel.vue';
import useApiClient from '~/compositions/useApiClient';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useAuthentication from '~/compositions/useAuthentication';
import useNotifications from '~/compositions/useNotifications';
import { Repo, RepoSettings, RepoVisibility } from '~/lib/api/types';
import RepoStore from '~/store/repos';
const projectVisibilityOptions: RadioOption[] = [
{
value: RepoVisibility.Public,
text: 'Public',
description: 'Every user can see your project without being logged in.',
},
{
value: RepoVisibility.Private,
text: 'Private',
description: 'Only authenticated users of the Woodpecker instance can see this project.',
},
{
value: RepoVisibility.Internal,
text: 'Internal',
description: 'Only you and other owners of the repository can see this project.',
},
];
export default defineComponent({
name: 'GeneralTab',
components: { Button, Panel, InputField, TextField, RadioField, NumberField, Checkbox },
setup() {
const apiClient = useApiClient();
const notifications = useNotifications();
const { user } = useAuthentication();
const repoStore = RepoStore();
const repo = inject<Ref<Repo>>('repo');
const repoSettings = ref<RepoSettings>();
function loadRepoSettings() {
if (!repo) {
throw new Error('Unexpected: Repo should be set');
}
repoSettings.value = {
config_file: repo.value.config_file,
timeout: repo.value.timeout,
visibility: repo.value.visibility,
gated: repo.value.gated,
trusted: repo.value.trusted,
allow_pr: repo.value.allow_pr,
};
}
async function loadRepo() {
if (!repo) {
throw new Error('Unexpected: Repo should be set');
}
await repoStore.loadRepo(repo.value.owner, repo.value.name);
loadRepoSettings();
}
const { doSubmit: saveRepoSettings, isLoading: isSaving } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo should be set');
}
if (!repoSettings.value) {
throw new Error('Unexpected: Repo-Settings should be set');
}
await apiClient.updateRepo(repo.value.owner, repo.value.name, repoSettings.value);
await loadRepo();
notifications.notify({ title: 'Repository settings updated', type: 'success' });
});
onMounted(() => {
loadRepoSettings();
});
return {
user,
repoSettings,
isSaving,
saveRepoSettings,
projectVisibilityOptions,
};
},
});
</script>

View file

@ -0,0 +1,130 @@
<template>
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<div class="ml-2">
<h1 class="text-xl text-gray-500">Registry credentials</h1>
<p class="text-sm text-gray-400 dark:text-gray-600">
Registries credentials can be added to use private images for your pipeline.
<DocsLink url="docs/usage/registry" />
</p>
</div>
<Button
v-if="showAddRegistry"
class="ml-auto"
start-icon="list"
text="Show registries"
@click="showAddRegistry = false"
/>
<Button v-else class="ml-auto" start-icon="plus" text="Add registry" @click="showAddRegistry = true" />
</div>
<div v-if="!showAddRegistry" class="space-y-4 text-gray-500">
<ListItem v-for="registry in registries" :key="registry.id" class="items-center">
<span>{{ registry.address }}</span>
<IconButton
icon="trash"
class="ml-auto w-8 h-8 hover:text-red-400"
:is-loading="isDeleting"
@click="deleteRegistry(registry)"
/>
</ListItem>
<div v-if="registries?.length === 0" class="ml-2">There are no registry credentials yet.</div>
</div>
<div v-else class="space-y-4">
<form @submit.prevent="createRegistry">
<InputField label="Address">
<TextField v-model="selectedRegistry.address" placeholder="Registry Address (e.g. docker.io)" required />
</InputField>
<InputField label="Username">
<TextField v-model="selectedRegistry.username" placeholder="Username" required />
</InputField>
<InputField label="Password">
<TextField v-model="selectedRegistry.password" placeholder="Password" required />
</InputField>
<Button type="submit" :is-loading="isSaving" text="Add registry" />
</form>
</div>
</Panel>
</template>
<script lang="ts">
import { defineComponent, inject, onMounted, Ref, ref } from 'vue';
import Button from '~/components/atomic/Button.vue';
import DocsLink from '~/components/atomic/DocsLink.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import InputField from '~/components/form/InputField.vue';
import TextField from '~/components/form/TextField.vue';
import Panel from '~/components/layout/Panel.vue';
import useApiClient from '~/compositions/useApiClient';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useNotifications from '~/compositions/useNotifications';
import { Repo } from '~/lib/api/types';
import { Registry } from '~/lib/api/types/registry';
export default defineComponent({
name: 'RegistriesTab',
components: {
Button,
Panel,
ListItem,
IconButton,
InputField,
TextField,
DocsLink,
},
setup() {
const apiClient = useApiClient();
const notifications = useNotifications();
const repo = inject<Ref<Repo>>('repo');
const registries = ref<Registry[]>();
const showAddRegistry = ref(false);
const selectedRegistry = ref<Partial<Registry>>({});
async function loadRegistries() {
if (!repo?.value) {
throw new Error("Unexpected: Can't load repo");
}
registries.value = await apiClient.getRegistryList(repo.value.owner, repo.value.name);
}
const { doSubmit: createRegistry, isLoading: isSaving } = useAsyncAction(async () => {
if (!repo?.value) {
throw new Error("Unexpected: Can't load repo");
}
await apiClient.createRegistry(repo.value.owner, repo.value.name, selectedRegistry.value);
notifications.notify({ title: 'Registry credentials created', type: 'success' });
showAddRegistry.value = false;
selectedRegistry.value = {};
await loadRegistries();
});
const { doSubmit: deleteRegistry, isLoading: isDeleting } = useAsyncAction(async (_registry: Registry) => {
if (!repo?.value) {
throw new Error("Unexpected: Can't load repo");
}
await apiClient.deleteRegistry(repo.value.owner, repo.value.name, _registry.address);
notifications.notify({ title: 'Registry credentials deleted', type: 'success' });
await loadRegistries();
});
onMounted(async () => {
await loadRegistries();
});
return { selectedRegistry, registries, showAddRegistry, isSaving, isDeleting, createRegistry, deleteRegistry };
},
});
</script>

View file

@ -0,0 +1,163 @@
<template>
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<div class="ml-2">
<h1 class="text-xl text-gray-500">Secrets</h1>
<p class="text-sm text-gray-400 dark:text-gray-600">
Secrets can be passed to individual pipeline steps at runtime as environmental variables.
<DocsLink url="docs/usage/secrets" />
</p>
</div>
<Button
v-if="showAddSecret"
class="ml-auto"
text="Show secrets"
start-icon="list"
@click="showAddSecret = false"
/>
<Button v-else class="ml-auto" text="Add secret" start-icon="plus" @click="showAddSecret = true" />
</div>
<div v-if="!showAddSecret" class="space-y-4 text-gray-500">
<ListItem v-for="secret in secrets" :key="secret.id" class="items-center">
<span>{{ secret.name }}</span>
<div class="ml-auto">
<span
v-for="event in secret.event"
:key="event"
class="bg-gray-400 dark:bg-dark-200 dark:text-gray-500 text-white rounded-md mx-1 py-1 px-2 text-sm"
>{{ event }}</span
>
</div>
<IconButton
icon="trash"
class="ml-2 w-8 h-8 hover:text-red-400"
:is-loading="isDeleting"
@click="deleteSecret(secret)"
/>
</ListItem>
<div v-if="secrets?.length === 0" class="ml-2">There are no secrets yet.</div>
</div>
<div v-else class="space-y-4">
<form @submit.prevent="createSecret">
<InputField label="Name">
<TextField v-model="selectedSecret.name" placeholder="Name" required />
</InputField>
<InputField label="Value">
<TextField v-model="selectedSecret.value" placeholder="Value" required />
</InputField>
<InputField label="Available at following events">
<CheckboxesField v-model="selectedSecret.event" :options="secretEventsOptions" />
</InputField>
<Button :is-loading="isSaving" type="submit" text="Add secret" />
</form>
</div>
</Panel>
</template>
<script lang="ts">
import { defineComponent, inject, onMounted, Ref, ref } from 'vue';
import Button from '~/components/atomic/Button.vue';
import DocsLink from '~/components/atomic/DocsLink.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import CheckboxesField from '~/components/form/CheckboxesField.vue';
import { CheckboxOption } from '~/components/form/form.types';
import InputField from '~/components/form/InputField.vue';
import TextField from '~/components/form/TextField.vue';
import Panel from '~/components/layout/Panel.vue';
import useApiClient from '~/compositions/useApiClient';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useNotifications from '~/compositions/useNotifications';
import { Repo, Secret, WebhookEvents } from '~/lib/api/types';
const emptySecret = {
name: '',
value: '',
image: [],
event: [WebhookEvents.Push],
};
const secretEventsOptions: CheckboxOption[] = [
{ value: WebhookEvents.Push, text: 'Push' },
{ value: WebhookEvents.Tag, text: 'Tag' },
{ value: WebhookEvents.PullRequest, text: 'Pull Request' },
{ value: WebhookEvents.Deploy, text: 'Deploy' },
];
export default defineComponent({
name: 'SecretsTab',
components: {
Button,
Panel,
ListItem,
IconButton,
InputField,
TextField,
DocsLink,
CheckboxesField,
},
setup() {
const apiClient = useApiClient();
const notifications = useNotifications();
const repo = inject<Ref<Repo>>('repo');
const secrets = ref<Secret[]>();
const showAddSecret = ref(false);
const selectedSecret = ref<Partial<Secret>>({ ...emptySecret });
async function loadSecrets() {
if (!repo?.value) {
throw new Error("Unexpected: Can't load repo");
}
secrets.value = await apiClient.getSecretList(repo.value.owner, repo.value.name);
}
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
if (!repo?.value) {
throw new Error("Unexpected: Can't load repo");
}
await apiClient.createSecret(repo.value.owner, repo.value.name, selectedSecret.value);
notifications.notify({ title: 'Secret created', type: 'success' });
showAddSecret.value = false;
selectedSecret.value = { ...emptySecret };
await loadSecrets();
});
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
if (!repo?.value) {
throw new Error("Unexpected: Can't load repo");
}
await apiClient.deleteSecret(repo.value.owner, repo.value.name, _secret.name);
notifications.notify({ title: 'Secret deleted', type: 'success' });
await loadSecrets();
});
onMounted(async () => {
await loadSecrets();
});
return {
secretEventsOptions,
selectedSecret,
secrets,
showAddSecret,
isSaving,
isDeleting,
createSecret,
deleteSecret,
};
},
});
</script>

View file

@ -0,0 +1,51 @@
<template>
<div v-show="isActive" :aria-hidden="!isActive">
<slot />
</div>
</template>
<script lang="ts">
import { computed, defineComponent, inject, onMounted, Ref, ref } from 'vue';
import { Tab } from './types';
export default defineComponent({
name: 'Tab',
props: {
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
id: {
type: String,
default: undefined,
},
title: {
type: String,
required: true,
},
},
setup(props) {
const activeTab = inject<Ref<string>>('active-tab');
const tabs = inject<Ref<Tab[]>>('tabs');
if (activeTab === undefined || tabs === undefined) {
throw new Error('Please wrap this "Tab"-component inside a "Tabs" list.');
}
const tab = ref<Tab>();
onMounted(() => {
tab.value = {
id: props.title.toLocaleLowerCase() || tabs.value.length.toString(),
title: props.title,
};
tabs.value.push(tab.value);
});
const isActive = computed(() => tab.value && tab.value.id === activeTab.value);
return { isActive };
},
});
</script>

View file

@ -0,0 +1,107 @@
<template>
<div class="flex flex-col">
<div class="flex w-full pt-4 mb-4">
<div
v-for="tab in tabs"
:key="tab.id"
class="
flex
cursor-pointer
pb-2
px-8
border-b-2
text-gray-500
hover:text-gray-700
dark:text-gray-500 dark:hover:text-gray-400
"
:class="{
'border-gray-400 dark:border-gray-600': activeTab === tab.id,
'border-transparent': activeTab !== tab.id,
}"
@click="selectTab(tab)"
>
<span>{{ tab.title }}</span>
</div>
</div>
<div>
<slot />
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, provide, ref, toRef } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Tab } from './types';
export default defineComponent({
name: 'Tabs',
props: {
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
disableHashMode: {
type: Boolean,
},
// used by toRef
// eslint-disable-next-line vue/no-unused-properties
modelValue: {
type: String,
default: '',
},
},
emits: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'update:modelValue': (_value: string): boolean => true,
},
setup(props, { emit }) {
const router = useRouter();
const route = useRoute();
const disableHashMode = toRef(props, 'disableHashMode');
const modelValue = toRef(props, 'modelValue');
const tabs = ref<Tab[]>([]);
const activeTab = ref();
provide('tabs', tabs);
provide(
'active-tab',
computed(() => activeTab.value),
);
async function selectTab(tab: Tab) {
if (tab.id === undefined) {
return;
}
activeTab.value = tab.id;
emit('update:modelValue', activeTab.value);
if (!disableHashMode.value) {
await router.replace({ params: route.params, hash: `#${tab.id}` });
}
}
onMounted(() => {
if (modelValue.value) {
activeTab.value = modelValue.value;
return;
}
const hashTab = route.hash.replace(/^#/, '');
if (hashTab) {
activeTab.value = hashTab;
return;
}
activeTab.value = tabs.value[0].id;
});
return { tabs, activeTab, selectTab };
},
});
</script>

View file

@ -0,0 +1,4 @@
export type Tab = {
id: string;
title: string;
};

View file

@ -0,0 +1,18 @@
import WoodpeckerClient from '~/lib/api';
import useConfig from './useConfig';
let apiClient: WoodpeckerClient | undefined;
export default (): WoodpeckerClient => {
if (!apiClient) {
const config = useConfig();
const server = '';
const token = null;
const csrf = config.csrf || null;
apiClient = new WoodpeckerClient(server, token, csrf);
}
return apiClient;
};

View file

@ -0,0 +1,37 @@
import { computed, ref } from 'vue';
import useNotifications from '~/compositions/useNotifications';
const notifications = useNotifications();
export type UseSubmitOptions = {
showErrorNotification: false;
};
export function useAsyncAction<T extends unknown[]>(
action: (...a: T) => void | Promise<void>,
options?: UseSubmitOptions,
) {
const isLoading = ref(false);
async function doSubmit(...a: T) {
if (isLoading.value) {
return;
}
isLoading.value = true;
try {
await action(...a);
} catch (error) {
if (options?.showErrorNotification) {
notifications.notify({ title: (error as Error).message, type: 'error' });
}
}
isLoading.value = false;
}
return {
doSubmit,
isLoading: computed(() => isLoading.value),
};
}

View file

@ -0,0 +1,13 @@
import useConfig from '~/compositions/useConfig';
export default () =>
({
isAuthenticated: useConfig().user,
user: useConfig().user,
authenticate(origin?: string) {
const url = `/login?url=${origin || ''}`;
window.location.href = url;
},
} as const);

View file

@ -0,0 +1,84 @@
import { computed, Ref } from 'vue';
import { useElapsedTime } from '~/compositions/useElapsedTime';
import { Build } from '~/lib/api/types';
import { prettyDuration } from '~/utils/duration';
import { convertEmojis } from '~/utils/emoji';
import timeAgo from '~/utils/timeAgo';
export default (build: Ref<Build | undefined>) => {
const sinceRaw = computed(() => {
if (!build.value) {
return undefined;
}
const start = build.value.started_at || 0;
if (start === 0) {
return 0;
}
return start * 1000;
});
const sinceUnderOneHour = computed(
() => sinceRaw.value !== undefined && sinceRaw.value > 0 && sinceRaw.value <= 1000 * 60 * 60,
);
const { time: sinceElapsed } = useElapsedTime(sinceUnderOneHour, sinceRaw);
const since = computed(() => {
if (sinceRaw.value === 0) {
return 'not started yet';
}
if (sinceElapsed.value === undefined) {
return null;
}
return timeAgo.format(sinceElapsed.value);
});
const durationRaw = computed(() => {
if (!build.value) {
return undefined;
}
const start = build.value.started_at || 0;
const end = build.value.finished_at || 0;
if (start === 0) {
return 0;
}
if (end === 0) {
return Date.now() - start * 1000;
}
return (end - start) * 1000;
});
const running = computed(() => build.value !== undefined && build.value.status === 'running');
const { time: durationElapsed } = useElapsedTime(running, durationRaw);
const duration = computed(() => {
if (durationElapsed.value === undefined) {
return null;
}
if (durationRaw.value === 0) {
return 'not started yet';
}
return prettyDuration(durationElapsed.value);
});
const message = computed(() => {
if (!build.value) {
return '';
}
return convertEmojis(build.value.message);
});
return { since, duration, message };
};

View file

@ -0,0 +1,30 @@
import { computed, toRef } from 'vue';
import useUserConfig from '~/compositions/useUserConfig';
import BuildStore from '~/store/builds';
import useAuthentication from './useAuthentication';
const { userConfig, setUserConfig } = useUserConfig();
export default () => {
const buildStore = BuildStore();
const { isAuthenticated } = useAuthentication();
const isOpen = computed(() => userConfig.value.isBuildFeedOpen && !!isAuthenticated);
function toggle() {
setUserConfig('isBuildFeedOpen', !userConfig.value.isBuildFeedOpen);
}
const sortedBuilds = toRef(buildStore, 'sortedBuildFeed');
const activeBuilds = toRef(buildStore, 'activeBuilds');
return {
toggle,
isOpen,
sortedBuilds,
activeBuilds,
load: buildStore.loadBuildFeed,
};
};

View file

@ -0,0 +1,48 @@
import { ref } from 'vue';
import { BuildLog, BuildProc } from '~/lib/api/types';
import { isProcFinished, isProcRunning } from '~/utils/helpers';
import useApiClient from './useApiClient';
const apiClient = useApiClient();
export default () => {
const logs = ref<BuildLog[] | undefined>();
const proc = ref<BuildProc>();
let stream: EventSource | undefined;
function onLogsUpdate(data: BuildLog) {
if (data.proc === proc.value?.name) {
logs.value = [...(logs.value || []), data];
}
}
function unload() {
if (stream) {
stream.close();
}
}
async function load(owner: string, repo: string, build: number, _proc: BuildProc) {
unload();
proc.value = _proc;
logs.value = [];
// we do not have logs for skipped jobs
if (_proc.state === 'skipped' || _proc.state === 'killed') {
return;
}
if (isProcFinished(_proc)) {
logs.value = await apiClient.getLogs(owner, repo, build, _proc.pid);
}
if (isProcRunning(_proc)) {
stream = apiClient.streamLogs(owner, repo, build, _proc.pid, onLogsUpdate);
}
}
return { logs, load, unload };
};

View file

@ -0,0 +1,19 @@
import { User } from '~/lib/api/types';
declare global {
interface Window {
WOODPECKER_USER: User | undefined;
WOODPECKER_SYNC: boolean | undefined;
WOODPECKER_DOCS: string | undefined;
WOODPECKER_VERSION: string | undefined;
WOODPECKER_CSRF: string | undefined;
}
}
export default () => ({
user: window.WOODPECKER_USER || null,
syncing: window.WOODPECKER_SYNC || null,
docs: window.WOODPECKER_DOCS || null,
version: window.WOODPECKER_VERSION,
csrf: window.WOODPECKER_CSRF || null,
});

View file

@ -0,0 +1,43 @@
import { computed, ref, watch } from 'vue';
const LS_DARK_MODE = 'woodpecker:dark-mode';
const isDarkModeActive = ref(false);
watch(isDarkModeActive, (isActive) => {
if (isActive) {
document.documentElement.classList.remove('light');
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
}
});
function setDarkMode(isActive: boolean) {
isDarkModeActive.value = isActive;
localStorage.setItem(LS_DARK_MODE, isActive ? 'dark' : 'light');
}
function load() {
const isActive = localStorage.getItem(LS_DARK_MODE) as 'dark' | 'light' | null;
if (isActive === null) {
setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
} else {
setDarkMode(isActive === 'dark');
}
}
load();
export function useDarkMode() {
return {
darkMode: computed({
get() {
return isDarkModeActive.value;
},
set(isActive: boolean) {
setDarkMode(isActive);
},
}),
};
}

View file

@ -0,0 +1,50 @@
import { computed, onBeforeUnmount, onMounted, Ref, ref, watch } from 'vue';
export function useElapsedTime(running: Ref<boolean>, startTime: Ref<number | undefined>) {
const time = ref<number | undefined>(startTime.value);
const timer = ref<NodeJS.Timer>();
function stopTimer() {
if (timer.value !== undefined) {
clearInterval(timer.value);
timer.value = undefined;
}
}
function startTimer() {
stopTimer();
if (time.value === undefined || !running.value) {
return;
}
timer.value = setInterval(() => {
if (time.value !== undefined) {
time.value += 1000;
}
}, 1000);
}
watch([running, startTime], () => {
time.value = startTime.value;
// should run, has a start-time and is not running atm
if (running.value && time.value !== undefined && timer.value === undefined) {
startTimer();
}
// should not run or has no start-time and is running atm
if ((!running.value || time.value === undefined) && timer.value !== undefined) {
stopTimer();
}
});
onMounted(startTimer);
onBeforeUnmount(stopTimer);
return {
time: computed(() => time.value),
running,
};
}

View file

@ -0,0 +1,42 @@
import BuildStore from '~/store/builds';
import RepoStore from '~/store/repos';
import { repoSlug } from '~/utils/helpers';
import useApiClient from './useApiClient';
const apiClient = useApiClient();
let initialized = false;
export default () => {
if (initialized) {
return;
}
const repoStore = RepoStore();
const buildStore = BuildStore();
initialized = true;
apiClient.on((data) => {
// contains repo update
if (!data.repo) {
return;
}
const { repo } = data;
repoStore.setRepo(repo);
// contains build update
if (!data.build) {
return;
}
const { build } = data;
buildStore.setBuild(repo.owner, repo.name, build);
buildStore.setBuildFeedItem({ ...build, name: repo.name, owner: repo.owner, full_name: repoSlug(repo) });
// contains proc update
if (!data.proc) {
return;
}
const { proc } = data;
buildStore.setProc(repo.owner, repo.name, build.number, proc);
});
};

View file

@ -0,0 +1,5 @@
import Notifications, { notify } from '@kyvg/vue3-notification';
export const notifications = Notifications;
export default () => ({ notify });

View file

@ -0,0 +1,27 @@
import Fuse from 'fuse.js';
import { computed, Ref } from 'vue';
import { Repo } from '~/lib/api/types';
export function useRepoSearch(repos: Ref<Repo[] | undefined>, search: Ref<string>) {
const searchIndex = computed(
() =>
new Fuse(repos.value || [], {
includeScore: true,
keys: ['name', 'owner'],
threshold: 0.4,
}),
);
const searchedRepos = computed(() => {
if (search.value === '') {
return repos.value;
}
return searchIndex.value.search(search.value).map((result) => result.item);
});
return {
searchedRepos,
};
}

View file

@ -0,0 +1,30 @@
import { computed, ref } from 'vue';
const USER_CONFIG_KEY = 'woodpecker-user-config';
type UserConfig = {
isBuildFeedOpen: boolean;
};
const defaultUserConfig: UserConfig = {
isBuildFeedOpen: false,
};
function loadUserConfig(): UserConfig {
const lsData = localStorage.getItem(USER_CONFIG_KEY);
if (!lsData) {
return defaultUserConfig;
}
return JSON.parse(lsData);
}
const config = ref<UserConfig>(loadUserConfig());
export default () => ({
setUserConfig<T extends keyof UserConfig>(key: T, value: UserConfig[T]): void {
config.value = { ...config.value, [key]: value };
localStorage.setItem(USER_CONFIG_KEY, JSON.stringify(config.value));
},
userConfig: computed(() => config.value),
});

View file

@ -1,3 +0,0 @@
import DroneClient from "drone-js";
export default DroneClient.fromWindow();

View file

@ -1,36 +0,0 @@
import React from "react";
export const drone = (client, Component) => {
// @see https://github.com/yannickcr/eslint-plugin-react/issues/512
// eslint-disable-next-line react/display-name
const component = class extends React.Component {
getChildContext() {
return {
drone: client,
};
}
render() {
return <Component {...this.state} {...this.props} />;
}
};
component.childContextTypes = {
drone: (props, propName) => {},
};
return component;
};
export const inject = Component => {
// @see https://github.com/yannickcr/eslint-plugin-react/issues/512
// eslint-disable-next-line react/display-name
const component = class extends React.Component {
render() {
this.props.drone = this.context.drone;
return <Component {...this.state} {...this.props} />;
}
};
return component;
};

View file

@ -1,78 +0,0 @@
import Baobab from "baobab";
const user = window.DRONE_USER;
const sync = window.DRONE_SYNC;
const state = {
follow: false,
language: "en-US",
user: {
data: user,
error: undefined,
loaded: true,
syncing: sync,
},
feed: {
loaded: false,
error: undefined,
data: {},
},
repos: {
loaded: false,
error: undefined,
data: {},
},
secrets: {
loaded: false,
error: undefined,
data: {},
},
registry: {
error: undefined,
loaded: false,
data: {},
},
builds: {
loaded: false,
error: undefined,
data: {},
},
logs: {
follow: false,
loading: true,
error: false,
data: {},
},
token: {
value: undefined,
error: undefined,
loading: false,
},
message: {
show: false,
text: undefined,
error: false,
},
location: {
protocol: window.location.protocol,
host: window.location.host,
},
};
const tree = new Baobab(state);
if (window) {
window.tree = tree;
}
export default tree;

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!-- drone:version -->
<!-- drone:user -->
<!-- drone:csrf -->
<!-- drone:docs -->
</head>
<body>
</body>
</html>

View file

@ -1,14 +0,0 @@
import "babel-polyfill";
import React from "react";
import { render } from "react-dom";
let root;
function init() {
let App = require("./screens/drone").default;
root = render(<App />, document.body, root);
}
init();
if (module.hot) module.hot.accept("./screens/drone", init);

136
web/src/lib/api/client.ts Normal file
View file

@ -0,0 +1,136 @@
export type ApiError = {
status: number;
message: string;
};
export function encodeQueryString(_params: Record<string, string | number | boolean | undefined> = {}): string {
const params: Record<string, string | number | boolean> = {};
Object.keys(_params).forEach((key) => {
const val = _params[key];
if (val !== undefined) {
params[key] = val;
}
});
return params
? Object.keys(params)
.sort()
.map((key) => {
const val = params[key];
return `${encodeURIComponent(key)}=${encodeURIComponent(val)}`;
})
.join('&')
: '';
}
export default class ApiClient {
server: string;
token: string | null;
csrf: string | null;
onerror: ((err: ApiError) => void) | undefined;
constructor(server: string, token: string | null, csrf: string | null) {
this.server = server;
this.token = token;
this.csrf = csrf;
}
private _request(method: string, path: string, data: unknown): Promise<unknown> {
const endpoint = `${this.server}${path}`;
const xhr = new XMLHttpRequest();
xhr.open(method, endpoint, true);
if (this.token) {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`);
}
if (method !== 'GET' && this.csrf) {
xhr.setRequestHeader('X-CSRF-TOKEN', this.csrf);
}
return new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.readyState === 4) {
if (xhr.status >= 300) {
const error: ApiError = {
status: xhr.status,
message: xhr.response,
};
if (this.onerror) {
this.onerror(error);
}
reject(error);
return;
}
const contentType = xhr.getResponseHeader('Content-Type');
if (contentType && contentType.startsWith('application/json')) {
resolve(JSON.parse(xhr.response));
} else {
resolve(xhr.response);
}
}
};
xhr.onerror = (e) => {
reject(e);
};
if (data) {
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(data));
} else {
xhr.send();
}
});
}
_get(path: string) {
return this._request('GET', path, null);
}
_post(path: string, data?: unknown) {
return this._request('POST', path, data);
}
_patch(path: string, data?: unknown) {
return this._request('PATCH', path, data);
}
_delete(path: string) {
return this._request('DELETE', path, null);
}
_subscribe<T>(path: string, callback: (data: T) => void, opts = { reconnect: true }) {
const query = encodeQueryString({
access_token: this.token || undefined,
});
// eslint-disable-next-line @typescript-eslint/naming-convention
let _path = this.server ? this.server + path : path;
_path = this.token ? `${path}?${query}` : path;
const events = new EventSource(_path);
events.onmessage = (event) => {
const data = JSON.parse(event.data) as T;
// eslint-disable-next-line promise/prefer-await-to-callbacks
callback(data);
};
if (!opts.reconnect) {
events.onerror = (err) => {
// TODO check if such events really have a data property
if ((err as Event & { data: string }).data === 'eof') {
events.close();
}
};
}
return events;
}
setErrorHandler(onerror: (err: ApiError) => void) {
this.onerror = onerror;
}
}

141
web/src/lib/api/index.ts Normal file
View file

@ -0,0 +1,141 @@
import ApiClient, { encodeQueryString } from './client';
import { Build, BuildFeed, BuildLog, BuildProc, Registry, Repo, RepoPermissions, RepoSettings, Secret } from './types';
type RepoListOptions = {
all?: boolean;
flush?: boolean;
};
export default class WoodpeckerClient extends ApiClient {
getRepoList(opts?: RepoListOptions): Promise<Repo[]> {
const query = encodeQueryString(opts);
return this._get(`/api/user/repos?${query}`) as Promise<Repo[]>;
}
getRepo(owner: string, repo: string): Promise<Repo> {
return this._get(`/api/repos/${owner}/${repo}`) as Promise<Repo>;
}
getRepoPermissions(owner: string, repo: string): Promise<RepoPermissions> {
return this._get(`/api/repos/${owner}/${repo}/permissions`) as Promise<RepoPermissions>;
}
getRepoBranches(owner: string, repo: string): Promise<string[]> {
return this._get(`/api/repos/${owner}/${repo}/branches`) as Promise<string[]>;
}
activateRepo(owner: string, repo: string): Promise<unknown> {
return this._post(`/api/repos/${owner}/${repo}`);
}
updateRepo(owner: string, repo: string, repoSettings: RepoSettings): Promise<unknown> {
return this._patch(`/api/repos/${owner}/${repo}`, repoSettings);
}
deleteRepo(owner: string, repo: string): Promise<unknown> {
return this._delete(`/api/repos/${owner}/${repo}`);
}
repairRepo(owner: string, repo: string): Promise<unknown> {
return this._post(`/api/repos/${owner}/${repo}/repair`);
}
getBuildList(owner: string, repo: string, opts?: Record<string, string | number | boolean>): Promise<Build[]> {
const query = encodeQueryString(opts);
return this._get(`/api/repos/${owner}/${repo}/builds?${query}`) as Promise<Build[]>;
}
getBuild(owner: string, repo: string, number: string | 'latest'): Promise<Build> {
return this._get(`/api/repos/${owner}/${repo}/builds/${number}`) as Promise<Build>;
}
getBuildFeed(opts?: Record<string, string | number | boolean>): Promise<BuildFeed[]> {
const query = encodeQueryString(opts);
return this._get(`/api/user/feed?${query}`) as Promise<BuildFeed[]>;
}
cancelBuild(owner: string, repo: string, number: number, ppid: number): Promise<unknown> {
return this._delete(`/api/repos/${owner}/${repo}/builds/${number}/${ppid}`);
}
approveBuild(owner: string, repo: string, build: string): Promise<unknown> {
return this._post(`/api/repos/${owner}/${repo}/builds/${build}/approve`);
}
declineBuild(owner: string, repo: string, build: string): Promise<unknown> {
return this._post(`/api/repos/${owner}/${repo}/builds/${build}/decline`);
}
restartBuild(
owner: string,
repo: string,
build: string,
opts?: Record<string, string | number | boolean>,
): Promise<unknown> {
const query = encodeQueryString(opts);
return this._post(`/api/repos/${owner}/${repo}/builds/${build}?${query}`);
}
getLogs(owner: string, repo: string, build: number, proc: number): Promise<BuildLog[]> {
return this._get(`/api/repos/${owner}/${repo}/logs/${build}/${proc}`) as Promise<BuildLog[]>;
}
getArtifact(owner: string, repo: string, build: string, proc: string, file: string): Promise<unknown> {
return this._get(`/api/repos/${owner}/${repo}/files/${build}/${proc}/${file}?raw=true`);
}
getArtifactList(owner: string, repo: string, build: string): Promise<unknown> {
return this._get(`/api/repos/${owner}/${repo}/files/${build}`);
}
getSecretList(owner: string, repo: string): Promise<Secret[]> {
return this._get(`/api/repos/${owner}/${repo}/secrets`) as Promise<Secret[]>;
}
createSecret(owner: string, repo: string, secret: Partial<Secret>): Promise<unknown> {
return this._post(`/api/repos/${owner}/${repo}/secrets`, secret);
}
deleteSecret(owner: string, repo: string, secretName: string): Promise<unknown> {
return this._delete(`/api/repos/${owner}/${repo}/secrets/${secretName}`);
}
getRegistryList(owner: string, repo: string): Promise<Registry[]> {
return this._get(`/api/repos/${owner}/${repo}/registry`) as Promise<Registry[]>;
}
createRegistry(owner: string, repo: string, registry: Partial<Registry>): Promise<unknown> {
return this._post(`/api/repos/${owner}/${repo}/registry`, registry);
}
deleteRegistry(owner: string, repo: string, registryAddress: string): Promise<unknown> {
return this._delete(`/api/repos/${owner}/${repo}/registry/${registryAddress}`);
}
getSelf(): Promise<unknown> {
return this._get('/api/user');
}
getToken(): Promise<string> {
return this._post('/api/user/token') as Promise<string>;
}
// eslint-disable-next-line promise/prefer-await-to-callbacks
on(callback: (data: { build?: Build; repo?: Repo; proc?: BuildProc }) => void): EventSource {
return this._subscribe('/stream/events', callback, {
reconnect: true,
});
}
streamLogs(
owner: string,
repo: string,
build: number,
proc: number,
// eslint-disable-next-line promise/prefer-await-to-callbacks
callback: (data: BuildLog) => void,
): EventSource {
return this._subscribe(`/stream/logs/${owner}/${repo}/${build}/${proc}`, callback, {
reconnect: true,
});
}
}

View file

@ -0,0 +1,124 @@
// A build for a repository.
export type Build = {
id: number;
// The build number.
// This number is specified within the context of the repository the build belongs to and is unique within that.
number: number;
parent: number;
event: 'push' | 'tag' | 'pull_request' | 'deployment';
// The current status of the build.
status: BuildStatus;
error: string;
// When the build request was received.
created_at: number;
// When the build was enqueued.
enqueued_at: number;
// When the build began execution.
started_at: number;
// When the build was finished.
finished_at: number;
// Where the deployment should go.
deploy_to: string;
// The commit for the build.
commit: string;
// The branch the commit was pushed to.
branch: string;
// The commit message.
message: string;
// When the commit was created.
timestamp: number;
// The alias for the commit.
ref: string;
// The mapping from the local repository to a branch in the remote.
refspec: string;
// The remote repository.
remote: string;
title: string;
sender: string;
// The login for the author of the commit.
author: string;
// The avatar for the author of the commit.
author_avatar: string;
// email for the author of the commit.
author_email: string;
// The link to view the repository.
// This link will point to the repository state associated with the build's commit.
link_url: string;
signed: boolean;
verified: boolean;
reviewed_by: string;
reviewed_at: number;
// The jobs associated with this build.
// A build will have multiple jobs if a matrix build was used or if a rebuild was requested.
procs?: BuildProc[];
changed_files?: string[];
};
export type BuildStatus =
| 'blocked'
| 'declined'
| 'error'
| 'failure'
| 'killed'
| 'pending'
| 'running'
| 'skipped'
| 'started'
| 'success';
export type BuildProc = {
id: number;
build_id: number;
pid: number;
ppid: number;
pgid: number;
name: string;
state: BuildStatus;
exit_code: number;
start_time: number;
end_time: number;
machine: string;
children?: BuildProc[];
};
export type BuildLog = {
proc: string;
pos: number;
out: string;
time?: number;
};
export type BuildFeed = Build & {
owner: string;
name: string;
full_name: string;
};

View file

@ -0,0 +1,6 @@
export * from './build';
export * from './registry';
export * from './repo';
export * from './secret';
export * from './user';
export * from './webhook';

View file

@ -0,0 +1,6 @@
export type Registry = {
id: string;
address: string;
username: string;
password: string;
};

View file

@ -0,0 +1,72 @@
// A version control repository.
export type Repo = {
active: boolean;
// Is the repo currently active or not
id: number;
// The unique identifier for the repository.
scm: string;
// The source control management being used.
// Currently this is either 'git' or 'hg' (Mercurial).
owner: string;
// The owner of the repository.
name: string;
// The name of the repository.
full_name: string;
// The full name of the repository.
// This is created from the owner and name of the repository.
avatar_url: string;
// The url for the avatar image.
link_url: string;
// The link to view the repository.
clone_url: string;
// The url used to clone the repository.
default_branch: string;
// The default branch of the repository.
private: boolean;
// Whether the repository is publicly visible.
trusted: boolean;
// Whether the repository has trusted access for builds.
// If the repository is trusted then the host network can be used and
// volumes can be created.
timeout: number;
// x-dart-type: Duration
// The amount of time in minutes before the build is killed.
allow_pr: boolean;
// Whether pull requests should trigger a build.
config_file: string;
visibility: RepoVisibility;
last_build: number;
gated: boolean;
};
export enum RepoVisibility {
Public = 'public',
Private = 'private',
Internal = 'internal',
}
export type RepoSettings = Pick<Repo, 'config_file' | 'timeout' | 'visibility' | 'trusted' | 'gated' | 'allow_pr'>;
export type RepoPermissions = {
pull: boolean;
push: boolean;
admin: boolean;
synced: number;
};

View file

@ -0,0 +1,9 @@
import { WebhookEvents } from './webhook';
export type Secret = {
id: string;
name: string;
value: string;
event: WebhookEvents[];
image: string[];
};

View file

@ -0,0 +1,20 @@
// The user account.
export type User = {
id: number;
// The unique identifier for the account.
login: string;
// The login name for the account.
email: string;
// The email address for the account.
avatar_url: string;
// The url for the avatar image.
admin: boolean;
// Whether the account has administrative privileges.
active: boolean;
// Whether the account is currently active.
};

View file

@ -0,0 +1,6 @@
export enum WebhookEvents {
Push = 'push',
Tag = 'tag',
PullRequest = 'pull-request',
Deploy = 'deploy',
}

18
web/src/main.ts Normal file
View file

@ -0,0 +1,18 @@
import 'windi.css';
import { createPinia } from 'pinia';
import { createApp } from 'vue';
import App from '~/App.vue';
import useEvents from '~/compositions/useEvents';
import { notifications } from '~/compositions/useNotifications';
import router from '~/router';
const app = createApp(App);
app.use(router);
app.use(notifications);
app.use(createPinia());
app.mount('#app');
useEvents();

120
web/src/router.ts Normal file
View file

@ -0,0 +1,120 @@
import { Component } from 'vue';
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import useAuthentication from './compositions/useAuthentication';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
redirect: '/repos',
},
{
path: '/repos',
name: 'repos',
component: (): Component => import('~/views/Repos.vue'),
meta: { authentication: 'required' },
},
{
path: '/repo/add',
name: 'repo-add',
component: (): Component => import('~/views/RepoAdd.vue'),
meta: { authentication: 'required' },
},
{
path: '/:repoOwner/:repoName',
name: 'repo-wrapper',
component: (): Component => import('~/views/repo/RepoWrapper.vue'),
props: true,
children: [
{
path: '',
name: 'repo',
component: (): Component => import('~/views/repo/RepoBuilds.vue'),
meta: { repoHeader: true },
},
{
path: 'branches',
name: 'repo-branches',
component: (): Component => import('~/views/repo/RepoBranches.vue'),
meta: { repoHeader: true },
props: (route) => ({ branch: route.params.branch }),
},
{
path: 'branches/:branch',
name: 'repo-branch',
component: (): Component => import('~/views/repo/RepoBranch.vue'),
meta: { repoHeader: true },
props: (route) => ({ branch: route.params.branch }),
},
{
path: 'build/:buildId/:procId?',
name: 'repo-build',
component: (): Component => import('~/views/repo/RepoBuild.vue'),
props: true,
},
{
path: 'settings',
name: 'repo-settings',
component: (): Component => import('~/views/repo/RepoSettings.vue'),
meta: { authentication: 'required' },
props: true,
},
// TODO: redirect to support backwards compatibility => remove after some time
{
path: ':buildId',
redirect: (route) => ({ name: 'repo-build', params: route.params }),
},
],
},
{
path: '/admin',
name: 'admin',
component: (): Component => import('~/views/admin/Admin.vue'),
meta: { authentication: 'required' },
props: true,
},
{
path: '/user',
name: 'user',
component: (): Component => import('~/views/User.vue'),
meta: { authentication: 'required' },
props: true,
},
{
path: '/login/error',
name: 'login-error',
component: (): Component => import('~/views/Login.vue'),
meta: { blank: true },
props: true,
},
{
path: '/do-login',
name: 'login',
component: (): Component => import('~/views/Login.vue'),
meta: { blank: true },
props: true,
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: (): Component => import('~/views/NotFound.vue'),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach(async (to, _, next) => {
const authentication = useAuthentication();
if (to.meta.authentication === 'required' && !authentication.isAuthenticated) {
next({ name: 'login', query: { url: to.fullPath } });
return;
}
next();
});
export default router;

View file

@ -1,52 +0,0 @@
import React, { Component } from "react";
import { root } from "baobab-react/higher-order";
import tree from "config/state";
import client from "config/client";
import { drone } from "config/client/inject";
import { LoginForm, LoginError } from "screens/login/screens";
import Title from "./titles";
import Layout from "./layout";
import RedirectRoot from "./redirect";
import { fetchFeedOnce, subscribeToFeedOnce } from "shared/utils/feed";
import { BrowserRouter, Route, Switch } from "react-router-dom";
// eslint-disable-next-line no-unused-vars
import styles from "./drone.less";
if (module.hot) {
require("preact/devtools");
}
class App extends Component {
render() {
return (
<BrowserRouter>
<div>
<Title />
<Switch>
<Route path="/" exact={true} component={RedirectRoot} />
<Route path="/login/form" exact={true} component={LoginForm} />
<Route path="/login/error" exact={true} component={LoginError} />
<Route path="/" exact={false} component={Layout} />
</Switch>
</div>
</BrowserRouter>
);
}
}
if (tree.exists(["user", "data"])) {
fetchFeedOnce(tree, client);
subscribeToFeedOnce(tree, client);
}
client.onerror = error => {
console.error(error);
if (error.status === 401) {
tree.unset(["user", "data"]);
}
};
export default root(tree, drone(client, App));

View file

@ -1,15 +0,0 @@
:global {
@import url('https://fonts.googleapis.com/css?family=Roboto+Mono|Roboto:300,400,500');
div,
span {
font-family: 'Roboto';
font-size: 16px;
}
html,
body {
margin: 0px;
padding: 0px;
}
}

View file

@ -1,3 +0,0 @@
import { List, Item } from "./list";
export { List, Item };

View file

@ -1,55 +0,0 @@
import React, { Component } from "react";
import Status from "shared/components/status";
import BuildTime from "shared/components/build_time";
import styles from "./list.less";
import { StarIcon } from "shared/components/icons/index";
export const List = ({ children }) => (
<div className={styles.list}>{children}</div>
);
export class Item extends Component {
constructor(props) {
super(props);
this.handleFave = this.handleFave.bind(this);
}
handleFave(e) {
e.preventDefault();
this.props.onFave(this.props.item.full_name);
}
render() {
const { item, faved } = this.props;
return (
<div className={styles.item}>
<div onClick={this.handleFave}>
<StarIcon filled={faved} size={16} className={styles.star} />
</div>
<div className={styles.header}>
<div className={styles.title}>{item.full_name}</div>
<div className={styles.icon}>
{item.status ? <Status status={item.status} /> : <noscript />}
</div>
</div>
<div className={styles.body}>
<BuildTime
start={item.started_at || item.created_at}
finish={item.finished_at}
/>
</div>
</div>
);
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.item !== nextProps.item || this.props.faved !== nextProps.faved
);
}
}

View file

@ -1,67 +0,0 @@
@import '~shared/styles/colors';
@import '~shared/styles/utils';
.list {
a {
border-top: 1px solid @gray-light;
color: @gray-dark;
display: block;
text-decoration: none;
&:first-of-type {
border-top-width: 0px;
}
}
}
.item {
display: flex;
flex-direction: column;
padding: 20px;
text-decoration: none;
position: relative;
.header {
display: flex;
margin-bottom: 10px;
}
.title {
color: @gray-dark;
flex: 1 1 auto;
font-size: 15px;
line-height: 22px;
max-width: 250px;
padding-right: 20px;
.text-ellipsis
}
.body div time {
color: @gray-dark;
font-size: 13px;
}
.body time {
color: @gray-dark;
display: inline-block;
font-size: 13px;
line-height: 22px;
margin: 0px;
padding: 0px;
vertical-align: middle;
}
.body svg {
fill: @gray-dark;
line-height: 22px;
margin-right: 10px;
vertical-align: middle;
}
.star {
position: absolute;
bottom: 20px;
right: 20px;
fill: @gray;
}
}

View file

@ -1,192 +0,0 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import { compareFeedItem } from "shared/utils/feed";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import DroneIcon from "shared/components/logo";
import { List, Item } from "./components";
import style from "./index.less";
import Collapsible from "react-collapsible";
const binding = (props, context) => {
return { feed: ["feed"] };
};
@inject
@branch(binding)
export default class Sidebar extends Component {
constructor(props, context) {
super(props, context);
this.setState({
starred: JSON.parse(localStorage.getItem("starred") || "[]"),
starredOpen: (localStorage.getItem("starredOpen") || "true") === "true",
reposOpen: (localStorage.getItem("reposOpen") || "true") === "true",
});
this.handleFilter = this.handleFilter.bind(this);
this.toggleStarred = this.toggleItem.bind(this, "starredOpen");
this.toggleAll = this.toggleItem.bind(this, "reposOpen");
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.feed !== nextProps.feed ||
this.state.filter !== nextState.filter ||
this.state.starred.length !== nextState.starred.length
);
}
handleFilter(e) {
this.setState({
filter: e.target.value,
});
}
toggleItem = item => {
this.setState(state => {
return { [item]: !state[item] };
});
localStorage.setItem(item, this.state[item]);
};
renderFeed = (list, renderStarred) => {
return (
<div>
<List>{list.map(item => this.renderItem(item, renderStarred))}</List>
</div>
);
};
renderItem = (item, renderStarred) => {
const starred = this.state.starred;
if (renderStarred && !starred.includes(item.full_name)) {
return null;
}
return (
<Link to={`/${item.full_name}`} key={item.full_name}>
<Item
item={item}
onFave={this.onFave}
faved={starred.includes(item.full_name)}
/>
</Link>
);
};
onFave = fullName => {
if (!this.state.starred.includes(fullName)) {
this.setState(state => {
const list = state.starred.concat(fullName);
return { starred: list };
});
} else {
this.setState(state => {
const list = state.starred.filter(v => v !== fullName);
return { starred: list };
});
}
localStorage.setItem("starred", JSON.stringify(this.state.starred));
};
render() {
const { feed } = this.props;
const { filter } = this.state;
const list = feed.data ? Object.values(feed.data) : [];
const filterFunc = item => {
return !filter || item.full_name.indexOf(filter) !== -1;
};
const filtered = list.filter(filterFunc).sort(compareFeedItem);
const starredOpen = this.state.starredOpen;
const reposOpen = this.state.reposOpen;
return (
<div className={style.feed}>
{LOGO}
<Collapsible
trigger="Starred"
triggerTagName="div"
transitionTime={200}
open={starredOpen}
onOpen={this.toggleStarred}
onClose={this.toggleStarred}
triggerOpenedClassName={style.Collapsible__trigger}
triggerClassName={style.Collapsible__trigger}
>
{feed.loaded === false ? (
LOADING
) : feed.error ? (
ERROR
) : list.length === 0 ? (
EMPTY
) : (
this.renderFeed(list, true)
)}
</Collapsible>
<Collapsible
trigger="Repos"
triggerTagName="div"
transitionTime={200}
open={reposOpen}
onOpen={this.toggleAll}
onClose={this.toggleAll}
triggerOpenedClassName={style.Collapsible__trigger}
triggerClassName={style.Collapsible__trigger}
>
<input
type="text"
placeholder="Search …"
onChange={this.handleFilter}
/>
{feed.loaded === false ? (
LOADING
) : feed.error ? (
ERROR
) : list.length === 0 ? (
EMPTY
) : filtered.length > 0 ? (
this.renderFeed(filtered.sort(compareFeedItem), false)
) : (
NO_MATCHES
)}
</Collapsible>
</div>
);
}
}
const LOGO = (
<div className={style.brand}>
<DroneIcon />
<p>
Woodpecker<span style="margin-left: 4px;">{window.DRONE_VERSION}</span>
<br />
<span>
<a href={window.DRONE_DOCS} target="_blank" rel="noopener noreferrer">
Docs
</a>
</span>
</p>
</div>
);
const LOADING = <div className={style.message}>Loading</div>;
const EMPTY = <div className={style.message}>Your build feed is empty</div>;
const NO_MATCHES = <div className={style.message}>No results found</div>;
const ERROR = (
<div className={style.message}>
Oops. It looks like there was a problem loading your feed
</div>
);

View file

@ -1,70 +0,0 @@
@import '~shared/styles/colors';
.feed {
width: 300px;
input {
border: 1px solid @gray-light;
font-size: 15px;
height: 24px;
line-height: 24px;
outline: none;
margin: 20px;
padding: 5px;
width: 250px;
border-radius: 2px;
}
::-moz-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
}
::-webkit-input-placeholder {
color: @gray;
font-size: 15px;
font-weight: 300;
}
}
.message {
color: @gray;
font-size: 15px;
margin-top: 50px;
padding: 20px;
text-align: center;
}
.brand {
align-items: center;
border-bottom: 1px solid @gray-light;
box-sizing: border-box;
display: flex;
height: 60px;
padding: 0px 10px;
svg {
fill: @gray-dark;
height: 50px;
position: relative;
top: 5px;
}
p {
font-size: 18px;
}
span {
font-size: 13px;
color: @gray-dark
}
}
.Collapsible__trigger {
background-color: @gray-light;
border-radius: 2px;
display: flex;
padding: 10px 20px;
text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);
}

View file

@ -1,224 +0,0 @@
import React, { Component } from "react";
import classnames from "classnames";
import { Route, Switch, Link } from "react-router-dom";
import { connectScreenSize } from "react-screen-size";
import { branch } from "baobab-react/higher-order";
import { inject } from "config/client/inject";
import MenuIcon from "shared/components/icons/menu";
import Feed from "screens/feed";
import RepoRegistry from "screens/repo/screens/registry";
import RepoSecrets from "screens/repo/screens/secrets";
import RepoSettings from "screens/repo/screens/settings";
import RepoBuilds from "screens/repo/screens/builds";
import UserRepos, { UserRepoTitle } from "screens/user/screens/repos";
import UserTokens from "screens/user/screens/tokens";
import RedirectRoot from "./redirect";
import RepoHeader from "screens/repo/screens/builds/header";
import UserReposMenu from "screens/user/screens/repos/menu";
import BuildLogs, { BuildLogsTitle } from "screens/repo/screens/build";
import BuildMenu from "screens/repo/screens/build/menu";
import RepoMenu from "screens/repo/screens/builds/menu";
import { Snackbar } from "shared/components/snackbar";
import { Drawer, DOCK_RIGHT } from "shared/components/drawer/drawer";
import styles from "./layout.less";
const binding = (props, context) => {
return {
user: ["user"],
message: ["message"],
sidebar: ["sidebar"],
menu: ["menu"],
};
};
const mapScreenSizeToProps = screenSize => {
return {
isTablet: screenSize["small"],
isMobile: screenSize["mobile"],
isDesktop: screenSize["> small"],
};
};
@inject
@branch(binding)
@connectScreenSize(mapScreenSizeToProps)
export default class Default extends Component {
constructor(props, context) {
super(props, context);
this.state = {
menu: false,
feed: false,
};
this.openMenu = this.openMenu.bind(this);
this.closeMenu = this.closeMenu.bind(this);
this.closeSnackbar = this.closeSnackbar.bind(this);
}
componentWillReceiveProps(nextProps) {
if (nextProps.location !== this.props.location) {
this.closeMenu(true);
}
}
openMenu() {
this.props.dispatch(tree => {
tree.set(["menu"], true);
});
}
closeMenu() {
this.props.dispatch(tree => {
tree.set(["menu"], false);
});
}
render() {
const { user, message, menu } = this.props;
const classes = classnames(!user || !user.data ? styles.guest : null);
return (
<div className={classes}>
<div className={styles.left}>
<Switch>
<Route path={"/"} component={Feed} />
</Switch>
</div>
<div className={styles.center}>
{!user || !user.data ? (
<a
href={"/login?url=" + window.location.href}
target="_self"
className={styles.login}
>
Click to Login
</a>
) : (
<noscript />
)}
<div className={styles.title}>
<Switch>
<Route path="/account/repos" component={UserRepoTitle} />
<Route
path="/:owner/:repo/:build(\d*)/:proc(\d*)"
exact={true}
component={BuildLogsTitle}
/>
<Route
path="/:owner/:repo/:build(\d*)"
component={BuildLogsTitle}
/>
<Route path="/:owner/:repo" component={RepoHeader} />
</Switch>
{user && user.data ? (
<div className={styles.avatar}>
<img src={user.data.avatar_url} />
</div>
) : (
undefined
)}
{user && user.data ? (
<button onClick={this.openMenu}>
<MenuIcon />
</button>
) : (
<noscript />
)}
</div>
<div className={styles.menu}>
<Switch>
<Route
path="/account/repos"
exact={true}
component={UserReposMenu}
/>
<Route path="/account/" exact={false} component={undefined} />
BuildMenu
<Route
path="/:owner/:repo/:build(\d*)/:proc(\d*)"
exact={true}
component={BuildMenu}
/>
<Route
path="/:owner/:repo/:build(\d*)"
exact={true}
component={BuildMenu}
/>
<Route path="/:owner/:repo" exact={false} component={RepoMenu} />
</Switch>
</div>
<Switch>
<Route path="/account/token" exact={true} component={UserTokens} />
<Route path="/account/repos" exact={true} component={UserRepos} />
<Route
path="/:owner/:repo/settings/secrets"
exact={true}
component={RepoSecrets}
/>
<Route
path="/:owner/:repo/settings/registry"
exact={true}
component={RepoRegistry}
/>
<Route
path="/:owner/:repo/settings"
exact={true}
component={RepoSettings}
/>
<Route
path="/:owner/:repo/:build(\d*)"
exact={true}
component={BuildLogs}
/>
<Route
path="/:owner/:repo/:build(\d*)/:proc(\d*)"
exact={true}
component={BuildLogs}
/>
<Route path="/:owner/:repo" exact={true} component={RepoBuilds} />
<Route path="/" exact={true} component={RedirectRoot} />
</Switch>
</div>
<Snackbar message={message.text} onClose={this.closeSnackbar} />
<Drawer onClick={this.closeMenu} position={DOCK_RIGHT} open={menu}>
<section>
<ul>
<li>
<Link to="/account/repos">Repositories</Link>
</li>
<li>
<Link to="/account/token">Token</Link>
</li>
</ul>
</section>
<section>
<ul>
<li>
<a href="/logout" target="_self">
Logout
</a>
</li>
</ul>
</section>
</Drawer>
</div>
);
}
closeSnackbar() {
this.props.dispatch(tree => {
tree.unset(["message", "text"]);
});
}
}

View file

@ -1,85 +0,0 @@
@import '~shared/styles/colors';
.title {
align-items: center;
border-bottom: 1px solid @gray-light;
box-sizing: border-box;
display: flex;
height: 60px;
padding: 0px 20px;
&> :first-child {
flex: 1;
}
.avatar {
align-items: center;
display: flex;
img {
border-radius: 50%;
height: 28px;
width: 28px;
}
}
button {
align-items: stretch;
background: @white;
border: 0px;
cursor: pointer;
display: flex;
margin: 0px;
margin-left: 10px;
outline: none;
padding: 0px;
}
}
.menu {}
.left {
border-right: 1px solid @splitter-border-color;
bottom: 0px;
box-sizing: border-box;
left: 0px;
overflow: hidden;
overflow-y: auto;
position: fixed;
right: 0px;
top: 0px;
width: 300px;
}
.center {
box-sizing: border-box;
padding-left: 300px;
}
.login {
background: @yellow;
box-sizing: border-box;
color: @white;
display: block;
font-size: 15px;
line-height: 50px;
// HACK
margin-top: -1px;
padding: 0px 30px;
text-align: center;
text-decoration: none;
text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);
text-transform: uppercase;
}
.guest {
.left {
display: none;
}
.center {
padding-left: 0px;
}
}

View file

@ -1,34 +0,0 @@
import React, { Component } from "react";
import queryString from "query-string";
import Icon from "shared/components/icons/report";
import styles from "./index.less";
const DEFAULT_ERROR = "The system failed to process your Login request.";
class Error extends Component {
render() {
const parsed = queryString.parse(window.location.search);
let error = DEFAULT_ERROR;
switch (parsed.code || parsed.error) {
case "oauth_error":
break;
case "access_denied":
break;
}
return (
<div className={styles.root}>
<div className={styles.alert}>
<div>
<Icon />
</div>
<div>{error}</div>
</div>
</div>
);
}
}
export default Error;

View file

@ -1,34 +0,0 @@
@import '~shared/styles/colors';
@font: 'Roboto';
.root {
box-sizing: border-box;
margin: 50px auto;
max-width: 400px;
min-width: 400px;
padding: 30px;
.alert {
background: @yellow;
color: @white;
display: flex;
margin-bottom: 20px;
padding: 20px;
text-align: left;
&> :last-child {
font-family: @font;
font-size: 15px;
line-height: 20px;
padding-left: 10px;
padding-top: 2px;
}
}
svg {
fill: @white;
height: 26px;
width: 26px;
}
}

View file

@ -1,21 +0,0 @@
import React from "react";
import styles from "./index.less";
const LoginForm = props => (
<div className={styles.login}>
<form method="post" action="/authorize">
<p>Login with your version control system username and password.</p>
<input
placeholder="Username"
name="username"
type="text"
spellCheck="false"
/>
<input placeholder="Password" name="password" type="password" />
<input value="Login" type="submit" />
</form>
</div>
);
export default LoginForm;

View file

@ -1,69 +0,0 @@
@import '~shared/styles/colors';
@font: 'Roboto';
.login {
margin-top: 50px;
p {
color: @gray-dark;
font-family: @font;
line-height: 22px;
margin: 0px;
margin-bottom: 30px;
padding: 0px;
text-align: center;
user-select: none;
}
input {
box-sizing: border-box;
display: block;
outline: none;
width: 100%;
&[type='password'],
&[type='text'] {
background: @white;
border: 1px solid @gray-light;
font-family: @font;
margin-bottom: 20px;
padding: 10px;
&:focus {
border: 1px solid @gray-dark;
}
}
&[type='submit'] {
background: @gray-dark;
border: 0px;
color: @white;
font-family: @font;
line-height: 36px;
user-select: none;
}
}
form {
box-sizing: border-box;
margin: 0px auto;
max-width: 400px;
min-width: 400px;
padding: 30px;
}
::-moz-input-placeholder {
color: @gray;
font-size: 16px;
font-weight: 300;
user-select: none;
}
::-webkit-input-placeholder {
color: @gray;
font-size: 16px;
font-weight: 300;
user-select: none;
}
}

View file

@ -1,4 +0,0 @@
import LoginForm from "./form";
import LoginError from "./error";
export { LoginForm, LoginError };

View file

@ -1,41 +0,0 @@
import React, { Component } from "react";
import { Redirect } from "react-router-dom";
import { branch } from "baobab-react/higher-order";
import { Message } from "shared/components/sync";
const binding = (props, context) => {
return {
feed: ["feed"],
user: ["user", "data"],
syncing: ["user", "syncing"],
};
};
@branch(binding)
export default class RedirectRoot extends Component {
componentWillReceiveProps(nextProps) {
const { user } = nextProps;
if (!user && window) {
window.location.href = "/login?url=" + window.location.href;
}
}
render() {
const { user, syncing } = this.props;
const { latest, loaded } = this.props.feed;
return !loaded && syncing ? (
<Message />
) : !loaded ? (
undefined
) : !user ? (
undefined
) : !latest ? (
<Redirect to="/account/repos" />
) : !latest.number ? (
<Redirect to={`/${latest.full_name}`} />
) : (
<Redirect to={`/${latest.full_name}/${latest.number}`} />
);
}
}

Some files were not shown because too many files have changed in this diff Show more