mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2024-12-26 10:20:29 +00:00
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:
parent
0bb62be303
commit
58838f225c
239 changed files with 7765 additions and 13633 deletions
18
.vscode/launch.json
vendored
18
.vscode/launch.json
vendored
|
@ -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>/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
10
Makefile
10
Makefile
|
@ -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/')
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
16
web/.babelrc
16
web/.babelrc
|
@ -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
6
web/.eslintignore
Normal 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
|
155
web/.eslintrc.js
155
web/.eslintrc.js
|
@ -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
5
web/.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
|
@ -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
4
web/.prettierignore
Normal file
|
@ -0,0 +1,4 @@
|
|||
yarn-lock.yaml
|
||||
dist
|
||||
coverage/
|
||||
LICENSE
|
8
web/.prettierrc.js
Normal file
8
web/.prettierrc.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: 'all',
|
||||
singleQuote: true,
|
||||
printWidth: 120,
|
||||
tabWidth: 2,
|
||||
endOfLine: 'lf',
|
||||
};
|
|
@ -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
0
web/dist/.gitkeep
vendored
14
web/index.html
Normal file
14
web/index.html
Normal 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>
|
152
web/package.json
152
web/package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
115
web/src/App.vue
Normal file
115
web/src/App.vue
Normal 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
1
web/src/assets/logo.svg
Normal 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 |
3
web/src/assets/woodpecker.svg
Normal file
3
web/src/assets/woodpecker.svg
Normal 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 |
134
web/src/components/atomic/Button.vue
Normal file
134
web/src/components/atomic/Button.vue
Normal 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>
|
32
web/src/components/atomic/DocsLink.vue
Normal file
32
web/src/components/atomic/DocsLink.vue
Normal 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>
|
83
web/src/components/atomic/Icon.vue
Normal file
83
web/src/components/atomic/Icon.vue
Normal 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>
|
60
web/src/components/atomic/IconButton.vue
Normal file
60
web/src/components/atomic/IconButton.vue
Normal 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>
|
32
web/src/components/atomic/ListItem.vue
Normal file
32
web/src/components/atomic/ListItem.vue
Normal 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>
|
48
web/src/components/build-feed/BuildFeedItem.vue
Normal file
48
web/src/components/build-feed/BuildFeedItem.vue
Normal 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>
|
48
web/src/components/build-feed/BuildFeedSidebar.vue
Normal file
48
web/src/components/build-feed/BuildFeedSidebar.vue
Normal 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>
|
108
web/src/components/form/Checkbox.vue
Normal file
108
web/src/components/form/Checkbox.vue
Normal 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>
|
65
web/src/components/form/CheckboxesField.vue
Normal file
65
web/src/components/form/CheckboxesField.vue
Normal 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>
|
38
web/src/components/form/InputField.vue
Normal file
38
web/src/components/form/InputField.vue
Normal 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>
|
48
web/src/components/form/NumberField.vue
Normal file
48
web/src/components/form/NumberField.vue
Normal 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>
|
105
web/src/components/form/RadioField.vue
Normal file
105
web/src/components/form/RadioField.vue
Normal 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>
|
63
web/src/components/form/SelectField.vue
Normal file
63
web/src/components/form/SelectField.vue
Normal 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>
|
74
web/src/components/form/TextField.vue
Normal file
74
web/src/components/form/TextField.vue
Normal 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>
|
9
web/src/components/form/form.types.ts
Normal file
9
web/src/components/form/form.types.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export type SelectOption = {
|
||||
value: string;
|
||||
text: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type RadioOption = SelectOption;
|
||||
|
||||
export type CheckboxOption = SelectOption;
|
13
web/src/components/layout/FluidContainer.vue
Normal file
13
web/src/components/layout/FluidContainer.vue
Normal 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>
|
17
web/src/components/layout/Panel.vue
Normal file
17
web/src/components/layout/Panel.vue
Normal 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>
|
72
web/src/components/layout/header/ActiveBuilds.vue
Normal file
72
web/src/components/layout/header/ActiveBuilds.vue
Normal 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>
|
69
web/src/components/layout/header/Navbar.vue
Normal file
69
web/src/components/layout/header/Navbar.vue
Normal 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>
|
86
web/src/components/repo/build/BuildItem.vue
Normal file
86
web/src/components/repo/build/BuildItem.vue
Normal 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>
|
41
web/src/components/repo/build/BuildList.vue
Normal file
41
web/src/components/repo/build/BuildList.vue
Normal 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>
|
86
web/src/components/repo/build/BuildLogs.vue
Normal file
86
web/src/components/repo/build/BuildLogs.vue
Normal 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>
|
54
web/src/components/repo/build/BuildProcDuration.vue
Normal file
54
web/src/components/repo/build/BuildProcDuration.vue
Normal 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>
|
67
web/src/components/repo/build/BuildProcs.vue
Normal file
67
web/src/components/repo/build/BuildProcs.vue
Normal 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>
|
43
web/src/components/repo/build/BuildRunningIcon.vue
Normal file
43
web/src/components/repo/build/BuildRunningIcon.vue
Normal 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>
|
43
web/src/components/repo/build/BuildStatusIcon.vue
Normal file
43
web/src/components/repo/build/BuildStatusIcon.vue
Normal 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>
|
27
web/src/components/repo/build/build-status.ts
Normal file
27
web/src/components/repo/build/build-status.ts
Normal 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: '',
|
||||
};
|
85
web/src/components/repo/settings/ActionsTab.vue
Normal file
85
web/src/components/repo/settings/ActionsTab.vue
Normal 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>
|
61
web/src/components/repo/settings/BadgeTab.vue
Normal file
61
web/src/components/repo/settings/BadgeTab.vue
Normal 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"><branch></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>
|
159
web/src/components/repo/settings/GeneralTab.vue
Normal file
159
web/src/components/repo/settings/GeneralTab.vue
Normal 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>
|
130
web/src/components/repo/settings/RegistriesTab.vue
Normal file
130
web/src/components/repo/settings/RegistriesTab.vue
Normal 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>
|
163
web/src/components/repo/settings/SecretsTab.vue
Normal file
163
web/src/components/repo/settings/SecretsTab.vue
Normal 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>
|
51
web/src/components/tabs/Tab.vue
Normal file
51
web/src/components/tabs/Tab.vue
Normal 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>
|
107
web/src/components/tabs/Tabs.vue
Normal file
107
web/src/components/tabs/Tabs.vue
Normal 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>
|
4
web/src/components/tabs/types.ts
Normal file
4
web/src/components/tabs/types.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type Tab = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
18
web/src/compositions/useApiClient.ts
Normal file
18
web/src/compositions/useApiClient.ts
Normal 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;
|
||||
};
|
37
web/src/compositions/useAsyncAction.ts
Normal file
37
web/src/compositions/useAsyncAction.ts
Normal 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),
|
||||
};
|
||||
}
|
13
web/src/compositions/useAuthentication.ts
Normal file
13
web/src/compositions/useAuthentication.ts
Normal 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);
|
84
web/src/compositions/useBuild.ts
Normal file
84
web/src/compositions/useBuild.ts
Normal 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 };
|
||||
};
|
30
web/src/compositions/useBuildFeed.ts
Normal file
30
web/src/compositions/useBuildFeed.ts
Normal 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,
|
||||
};
|
||||
};
|
48
web/src/compositions/useBuildProc.ts
Normal file
48
web/src/compositions/useBuildProc.ts
Normal 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 };
|
||||
};
|
19
web/src/compositions/useConfig.ts
Normal file
19
web/src/compositions/useConfig.ts
Normal 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,
|
||||
});
|
43
web/src/compositions/useDarkMode.ts
Normal file
43
web/src/compositions/useDarkMode.ts
Normal 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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
50
web/src/compositions/useElapsedTime.ts
Normal file
50
web/src/compositions/useElapsedTime.ts
Normal 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,
|
||||
};
|
||||
}
|
42
web/src/compositions/useEvents.ts
Normal file
42
web/src/compositions/useEvents.ts
Normal 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);
|
||||
});
|
||||
};
|
5
web/src/compositions/useNotifications.ts
Normal file
5
web/src/compositions/useNotifications.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Notifications, { notify } from '@kyvg/vue3-notification';
|
||||
|
||||
export const notifications = Notifications;
|
||||
|
||||
export default () => ({ notify });
|
27
web/src/compositions/useRepoSearch.ts
Normal file
27
web/src/compositions/useRepoSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
30
web/src/compositions/useUserConfig.ts
Normal file
30
web/src/compositions/useUserConfig.ts
Normal 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),
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
import DroneClient from "drone-js";
|
||||
|
||||
export default DroneClient.fromWindow();
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
|
@ -1,12 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- drone:version -->
|
||||
<!-- drone:user -->
|
||||
<!-- drone:csrf -->
|
||||
<!-- drone:docs -->
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
|
@ -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
136
web/src/lib/api/client.ts
Normal 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
141
web/src/lib/api/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
124
web/src/lib/api/types/build.ts
Normal file
124
web/src/lib/api/types/build.ts
Normal 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;
|
||||
};
|
6
web/src/lib/api/types/index.ts
Normal file
6
web/src/lib/api/types/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from './build';
|
||||
export * from './registry';
|
||||
export * from './repo';
|
||||
export * from './secret';
|
||||
export * from './user';
|
||||
export * from './webhook';
|
6
web/src/lib/api/types/registry.ts
Normal file
6
web/src/lib/api/types/registry.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type Registry = {
|
||||
id: string;
|
||||
address: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
72
web/src/lib/api/types/repo.ts
Normal file
72
web/src/lib/api/types/repo.ts
Normal 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;
|
||||
};
|
9
web/src/lib/api/types/secret.ts
Normal file
9
web/src/lib/api/types/secret.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { WebhookEvents } from './webhook';
|
||||
|
||||
export type Secret = {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
event: WebhookEvents[];
|
||||
image: string[];
|
||||
};
|
20
web/src/lib/api/types/user.ts
Normal file
20
web/src/lib/api/types/user.ts
Normal 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.
|
||||
};
|
6
web/src/lib/api/types/webhook.ts
Normal file
6
web/src/lib/api/types/webhook.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export enum WebhookEvents {
|
||||
Push = 'push',
|
||||
Tag = 'tag',
|
||||
PullRequest = 'pull-request',
|
||||
Deploy = 'deploy',
|
||||
}
|
18
web/src/main.ts
Normal file
18
web/src/main.ts
Normal 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
120
web/src/router.ts
Normal 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;
|
|
@ -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));
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
import { List, Item } from "./list";
|
||||
|
||||
export { List, Item };
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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);
|
||||
}
|
|
@ -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"]);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
import LoginForm from "./form";
|
||||
import LoginError from "./error";
|
||||
|
||||
export { LoginForm, LoginError };
|
|
@ -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
Loading…
Reference in a new issue