diff --git a/.autod.conf.js b/.autod.conf.js new file mode 100644 index 0000000..7c5ea4e --- /dev/null +++ b/.autod.conf.js @@ -0,0 +1,30 @@ +'use strict'; + +module.exports = { + write: true, + prefix: '^', + plugin: 'autod-egg', + test: [ + 'test', + 'benchmark', + ], + dep: [ + 'egg', + 'egg-scripts', + ], + devdep: [ + 'egg-ci', + 'egg-bin', + 'egg-mock', + 'autod', + 'autod-egg', + 'eslint', + 'eslint-config-egg', + 'webstorm-disable-index', + ], + exclude: [ + './test/fixtures', + './dist', + ], +}; + diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3be460f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +test +run +docker +logs +.idea +.npmignore +.travis.yml +.gitignore +.git +.eslintrc +.eslintignore +.autod.conf.js +*.md +*.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9d08a1a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4ebc8ae --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +coverage diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..1156d47 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,8 @@ +{ + "extends": "eslint-config-egg", + "parserOptions": { + "ecmaFeatures": { + "experimentalObjectRestSpread": true + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ccc33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +logs/ +npm-debug.log +yarn-error.log +node_modules/ +package-lock.json +yarn.lock +coverage/ +.nyc_output +.idea/ +.vscode/ +run/ +.DS_Store +*.sw* +*.un~ diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 0000000..366a254 --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,9 @@ +'use strict'; + +const path = require('path'); + +module.exports = { + config: path.join(__dirname, 'database/config.js'), + 'migrations-path': path.join(__dirname, 'database/migrations'), + 'seeders-path': path.join(__dirname, 'database/seeders'), +}; diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b20f8dc --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +sudo: false + +language: node_js + +services: + - docker + +node_js: + - '8' + +before_install: + - docker pull macacajs/reliable-mysql + - docker run --rm --name reliable-mysql -p 13306:3306 -d macacajs/reliable-mysql + - docker ps -a + +install: + - npm i npminstall && npminstall + +script: + - MYSQL_PORT=13306 npm run ci + +after_script: + - npminstall codecov && codecov diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7143dbe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +FROM centos:centos7 + +RUN yum -y install curl \ + && mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.backup \ + && curl http://mirrors.163.com/.help/CentOS7-Base-163.repo -o /etc/yum.repos.d/CentOS7-Base-163.repo \ + && yum clean all \ + && yum makecache \ + && yum install -y make \ + bzip2 \ + gcc-c++ \ + ca-certificates \ + xorg-x11-server-Xvfb \ + gtk2 \ + vim \ + git \ + gtk2-devel \ + libXScrnSaver \ + GConf2 \ + libXtst.i686 \ + alsa-lib-devel \ + libXScrnSaver* \ + epel-release \ + libappindicator-gtk3 \ + libnss3.so \ + glibc-common \ + xorg-x11-fonts-Type1 \ + wqy-microhei-fonts \ + wqy-zenhei-fonts \ + thai-scalable-garuda-fonts \ + cjkuni-ukai-fonts \ + cjkuni-uming-fonts + +# Variable Layer: Node.js etc. +ENV NODE_VERSION=8.12.0 \ + NODE_REGISTRY=https://npm.taobao.org/mirrors/node \ + CHROMEDRIVER_CDNURL=http://npm.taobao.org/mirrors/chromedriver/ \ + ELECTRON_MIRROR=https://npm.taobao.org/mirrors/electron/ \ + DISPLAY=':99.0' \ + NODE_IN_DOCKER=1 + +RUN curl -SLO "$NODE_REGISTRY/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.gz" \ + && tar -xzf "node-v$NODE_VERSION-linux-x64.tar.gz" -C /usr/local --strip-components=1 \ + && rm "node-v$NODE_VERSION-linux-x64.tar.gz" + +COPY . /root/reliable-web + +WORKDIR /root/reliable-web + +ENV MYSQL_HOST=mysql-host + +RUN npm install --production --verbose && ln -s /root/logs . + +HEALTHCHECK --interval=10s --retries=6 \ + CMD wget -O /dev/null localhost:9900 || echo 1 + +ENTRYPOINT ["./entrypoint.sh"] + +EXPOSE 9900 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..3b24011 --- /dev/null +++ b/LICENCE @@ -0,0 +1,22 @@ +MIT LICENSE + +Copyright (c) 2018 Alibaba Group Holding Limited and other contributors. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2d99c0 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Reliable + +[中文版](README.zh-CN.md) + +

+ + Macaca + +

+ +--- + +[![build status][travis-image]][travis-url] +[![Test coverage][codecov-image]][codecov-url] +[![node version][node-image]][node-url] +[![docker pull][docker-pull-image]][docker-url] +[![docker layers][docker-layers-image]][docker-url] +[![docker-size][docker-size-image]][docker-url] + +[travis-image]: https://img.shields.io/travis/macacajs/reliable/master.svg?style=flat-square&logo=travis +[travis-url]: https://travis-ci.org/macacajs/reliable +[codecov-image]: https://img.shields.io/codecov/c/github/macacajs/reliable/master.svg?style=flat-square +[codecov-url]: https://codecov.io/gh/macacajs/reliable +[node-image]: https://img.shields.io/badge/node.js-%3E=_8-green.svg?style=flat-square +[node-url]: http://nodejs.org/download/ +[docker-pull-image]: https://img.shields.io/docker/pulls/macacajs/reliable-web.svg?style=flat-square&logo=dockbit +[docker-layers-image]: https://img.shields.io/microbadger/layers/macacajs/reliable-web.svg?style=flat-square&logo=dockbit +[docker-size-image]: https://img.shields.io/microbadger/image-size/macacajs/reliable-web.svg?style=flat-square&logo=dockbit +[docker-url]: https://hub.docker.com/r/macacajs/reliable-web/ + +> Testing management suite with continuous delivery support. + +## Docs + +- [CLI Usage](//github.com/macacajs/reliable-cli) +- [Development](./docker/reliable-web#development) + +## License + +The MIT License (MIT) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..3d3e0f2 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,10 @@ +# Reliable + +--- + +> 持续交付测试套件 + +## 文档 + +- [命令行客户端](//github.com/macacajs/reliable-cli) +- [开发 Reliable](./docker/reliable-web#development) diff --git a/app/common/error/index.js b/app/common/error/index.js new file mode 100644 index 0000000..64e37e6 --- /dev/null +++ b/app/common/error/index.js @@ -0,0 +1,22 @@ +'use strict'; + +// key: error code +// value: error details +// value.message: default error message +module.exports = new Map([ + [ + 'ERR_RELIABLE_INTERNAL_SERVER_ERROR', { + message: 'Internal server error.', + }, + ], + [ + 'ERR_RELIABLE_INVALID_PARAM_ERROR', { + message: 'Invalid parameters.', + }, + ], + [ + 'ERR_RELIABLE_BUILD_RECORD_NOT_FOUND', { + message: 'Build record not found.', + }, + ] +]); diff --git a/app/common/snsAuthorize/dingtalkAuth.js b/app/common/snsAuthorize/dingtalkAuth.js new file mode 100644 index 0000000..1497f59 --- /dev/null +++ b/app/common/snsAuthorize/dingtalkAuth.js @@ -0,0 +1,99 @@ +'use strict'; + +const querystring = require('querystring'); +const debug = require('debug')('reliable:common:dingtalkAuth'); + +module.exports = class DingtalkAuth { + constructor({ ctx, appid, appsecret }) { + this.ctx = ctx; + this.appid = appid; + this.appsecret = appsecret; + } + + async getAuthData({ tmpAuthCode }) { + const accessTokenInfo = await this._getAccessToken(); + debug(accessTokenInfo); + const persistentCodeInfo = await this._getPersistentCode({ + accessToken: accessTokenInfo.access_token, + tmpAuthCode, + }); + debug(persistentCodeInfo); + const snsTokenInfo = await this._getSnsToken({ + accessToken: accessTokenInfo.access_token, + openid: persistentCodeInfo.openid, + persistentCode: persistentCodeInfo.persistent_code, + }); + debug(snsTokenInfo); + const userInfo = await this._getUserInfo({ + snsToken: snsTokenInfo.sns_token, + }); + debug(userInfo); + const { + nick, + unionid, + openid, + } = userInfo.user_info || {}; + return { + nick, + unionid, + openid, + }; + } + + async _getAccessToken() { + const query = querystring.stringify({ + appid: this.appid, + appsecret: this.appsecret, + }); + debug(query); + const requestAccessToken = await this.ctx.curl(`https://oapi.dingtalk.com/sns/gettoken?${query}`, { + dataType: 'json', + }); + return requestAccessToken.data; + } + + async _getPersistentCode({ accessToken, tmpAuthCode }) { + const query = querystring.stringify({ + access_token: accessToken, + }); + debug(query); + const requestPersistentCode = await this.ctx.curl(`https://oapi.dingtalk.com/sns/get_persistent_code?${query}`, { + method: 'POST', + dataType: 'json', + contentType: 'json', + data: { + tmp_auth_code: tmpAuthCode, + }, + }); + return requestPersistentCode.data; + } + + async _getSnsToken({ accessToken, openid, persistentCode }) { + const query = querystring.stringify({ + access_token: accessToken, + }); + debug(query); + const requestSnsToken = await this.ctx.curl(`https://oapi.dingtalk.com/sns/get_sns_token?${query}`, { + method: 'POST', + dataType: 'json', + contentType: 'json', + data: { + openid, + persistent_code: persistentCode, + }, + }); + return requestSnsToken.data; + } + + async _getUserInfo({ snsToken }) { + const query = querystring.stringify({ + sns_token: snsToken, + }); + debug(query); + const requestUserInfo = await this.ctx.curl(`https://oapi.dingtalk.com/sns/getuserinfo?${query}`, { + dataType: 'json', + contentType: 'json', + }); + return requestUserInfo.data; + } +}; diff --git a/app/constants/build.js b/app/constants/build.js new file mode 100644 index 0000000..d13fbce --- /dev/null +++ b/app/constants/build.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = { + BUILD_STATE_INIT: 'INIT', + BUILD_STATE_SUCCESS: 'SUCCESS', +}; diff --git a/app/controller/app.js b/app/controller/app.js new file mode 100644 index 0000000..22c5221 --- /dev/null +++ b/app/controller/app.js @@ -0,0 +1,89 @@ +'use strict'; + +const { + Controller, +} = require('egg'); +const debug = require('debug')('reliable:controller:app'); + +const DEFAULT_BRANCH_QUERY_DAYS_RANGE = 30; + +class AppController extends Controller { + async show() { + const ctx = this.ctx; + ctx.validate({ + bucketTag: { type: 'string' }, + type: { type: 'string' }, + }, ctx.query); + debug(ctx.query); + + const Op = ctx.app.Sequelize.Op; + const branches = await ctx.model.Build.findAll({ + where: { + createdAt: { + [Op.gte]: ctx.moment().subtract(DEFAULT_BRANCH_QUERY_DAYS_RANGE, 'days').toDate(), + }, + }, + attributes: [ 'gitBranch', 'uniqId', 'createdAt' ], + order: [[ 'createdAt', 'DESC' ]], + }); + if (!branches.length) { + return; + } + const uniqBranchMap = branches.reduce((map, branch) => { + if (!(map.has(branch.gitBranch))) map.set(branch.gitBranch, branch.uniqId); + return map; + }, new Map()); + const buildUniqIds = Array.from(uniqBranchMap.values()); + + const buildList = await ctx.model.Build.findAll({ + where: { + uniqId: { + [Op.in]: buildUniqIds, + }, + }, + order: [ + [ + 'createdAt', + 'DESC', + ], + ], + }); + + if (!buildList.length) { + return; + } + + const latestBuild = buildList[0]; + + const builds = []; + + for (const build of buildList) { + const data = build.get({ plain: true }); + let size; + const packages = ctx.safeGet(data, 'data.packages') || []; + if (packages.length) { + const pkg = packages.find(i => i.type === type); + size = pkg && pkg.size; + } + const appBuildData = { + uniqId: data.uniqId, + version: ctx.safeGet(data, 'data.packages[0].version'), + size, + gitBranch: data.gitBranch, + gitCommitInfo: ctx.safeGet(data, 'data.gitCommitInfo'), + testInfo: ctx.safeGet(data, 'data.testInfo'), + extendInfo: data.extendInfo || {}, + state: data.state, + createdAt: data.createdAt, + }; + builds.push(appBuildData); + } + + ctx.success({ + gitRepo: ctx.safeGet(latestBuild, 'data.gitCommitInfo.gitUrl'), + builds, + }); + } +} + +module.exports = AppController; diff --git a/app/controller/build.js b/app/controller/build.js new file mode 100644 index 0000000..4ee0312 --- /dev/null +++ b/app/controller/build.js @@ -0,0 +1,150 @@ +'use strict'; + +const { + Controller, +} = require('egg'); + +const UPDATE_FIELDS = [ + 'extendInfo', +]; + +class BuildController extends Controller { + async show() { + const ctx = this.ctx; + const uniqId = ctx.params.uniqId; + const res = await ctx.service.build.queryBuildByUniqId({ + uniqId, + }); + ctx.success(res); + } + + async query() { + const ctx = this.ctx; + const page = Number(ctx.query.page) || 1; + const num = Number(ctx.query.num) || ctx.app.config.modelQueryConfig.pagination.num; + + const { jobName, buildNumber } = ctx.query; + + let res; + if (jobName) { + if (buildNumber) { + res = await ctx.service.build.queryByJobNameAndBuildNumber({ + jobName, + buildNumber, + }); + } else { + res = await ctx.service.build.queryByJobName({ + jobName, + page, num, + }); + } + } else { + res = await ctx.service.build.queryAllBuilds({ page, num }); + } + + if (res.success === false) { + ctx.fail(res.code); + return; + } + ctx.body = res; + } + + async queryLatestByJobNameAndGitBranch() { + const jobName = this.ctx.params.jobName; + const gitBranch = this.ctx.params.gitBranch; + const result = await this.ctx.model.Build.findAll({ + limit: 5, + where: { + jobName, + gitBranch, + }, + order: [ + [ + 'createdAt', + 'DESC', + ], + ], + }); + this.ctx.success({ + result, + }); + } + + async update() { + const ctx = this.ctx; + const uniqId = ctx.params.uniqId; + + const requestData = ctx.request.body; + const payload = {}; + for (const key of UPDATE_FIELDS) { + if (!(key in requestData)) continue; + payload[key] = requestData[key]; + } + const queryRes = await ctx.service.build.queryBuildByUniqId({ + uniqId, + }); + if (!queryRes) { + ctx.fail('ERR_RELIABLE_BUILD_RECORD_NOT_FOUND'); + return; + } + if (!Object.keys(payload).length) { + ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR'); + return; + } + const res = await ctx.service.build.updateBuild({ + uniqId, + payload, + }); + ctx.success(res); + } + + async patch() { + const ctx = this.ctx; + const uniqId = ctx.params.uniqId; + + const requestData = ctx.request.body; + const currentData = await ctx.service.build.queryBuildByUniqId({ + uniqId, + }); + if (!currentData) { + ctx.fail('ERR_RELIABLE_BUILD_RECORD_NOT_FOUND'); + return; + } + + const payload = {}; + for (const key of UPDATE_FIELDS) { + if (!(key in requestData)) continue; + const currentValue = currentData[key]; + if (!currentValue) { + payload[key] = requestData[key]; + continue; + } + if (Array.isArray(currentValue)) { + payload[key] = [ + ...currentValue, + ...requestData[key], + ]; + continue; + } + if (typeof currentValue === 'object') { + payload[key] = Object.assign({}, + currentValue, + requestData[key] + ); + continue; + } + payload[key] = requestData[key]; + } + if (!Object.keys(payload).length) { + ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR'); + return; + } + const res = await ctx.service.build.updateBuild({ + uniqId, + payload, + }); + ctx.success(res); + } +} + +module.exports = BuildController; diff --git a/app/controller/config.js b/app/controller/config.js new file mode 100644 index 0000000..806fabe --- /dev/null +++ b/app/controller/config.js @@ -0,0 +1,34 @@ +'use strict'; + +const { + Controller, +} = require('egg'); + +class ConfigController extends Controller { + async show() { + const result = await this.ctx.model.Config.findOne(); + this.ctx.success(result ? result.data : {}); + } + + async update() { + const oldResult = await this.ctx.model.Config.findOne(); + + if (!oldResult) { + await this.ctx.model.Config.create({ + data: this.ctx.request.body, + }); + } else { + const uniqId = oldResult.uniqId; + await this.ctx.model.Config.update({ + data: Object.assign(oldResult.data, this.ctx.request.body), + }, { + where: { + uniqId, + }, + }); + } + this.ctx.success(); + } +} + +module.exports = ConfigController; diff --git a/app/controller/delegate.js b/app/controller/delegate.js new file mode 100644 index 0000000..c1b0ff3 --- /dev/null +++ b/app/controller/delegate.js @@ -0,0 +1,28 @@ +'use strict'; + +const { + Controller, +} = require('egg'); + +class DelegateController extends Controller { + + async message() { + const ctx = this.ctx; + const { + webhook, + text, + title, + } = ctx.request.body; + + await ctx.helper.sendMarkdown({ + webhook, + title: title || 'title', + text, + }); + + ctx.success({}); + } + +} + +module.exports = DelegateController; diff --git a/app/controller/gw.js b/app/controller/gw.js new file mode 100644 index 0000000..5159a59 --- /dev/null +++ b/app/controller/gw.js @@ -0,0 +1,81 @@ +'use strict'; + +const { + Controller, +} = require('egg'); +const debug = require('debug')('reliable:controller:gw'); + +const { + BUILD_STATE_INIT, + BUILD_STATE_SUCCESS, +} = require('../constants/build'); + +class GwController extends Controller { + async index() { + const ctx = this.ctx; + const { state = BUILD_STATE_SUCCESS, ...data } = ctx.request.body; + // TODO remove + const jobName = ctx.safeGet(data, 'environment.ci.JOB_NAME') + || ctx.safeGet(data, 'environment.gitlab_ci.JOB_NAME'); + const buildNumber = ctx.safeGet(data, 'environment.ci.BUILD_NUMBER') + || ctx.safeGet(data, 'environment.gitlab_ci.BUILD_NUMBER'); + if (!jobName || !buildNumber) { + ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR', 'environment.ci.JOB_NAME and environment.ci.BUILD_NUMBER are required.'); + return; + } + const gitBranch = ctx.safeGet(data, 'gitCommitInfo.gitBranch'); + if (!gitBranch) { + ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR', 'gitCommitInfo.gitBranch is required.'); + return; + } + debug('jobName %s, buildNumber %s', jobName, buildNumber); + await ctx.model.JobName.findOrCreate({ + where: { + jobName, + }, + defaults: { + jobName, + }, + }); + + let upsertResult = {}; + + const build = await ctx.model.Build.findOne({ + where: { + buildNumber, + jobName, + }, + }); + + if (build) { + debug('update build to finished', build.uniqId); + upsertResult = await ctx.service.build.finishBuild({ + uniqId: build.uniqId, + payload: { + buildNumber, + jobName, + gitBranch, + data, + state, + finishedAt: Date.now(), + }, + }); + } else { + debug('insert new build'); + upsertResult = await ctx.model.Build.create({ + buildNumber, + jobName, + gitBranch, + data, + state, + }); + } + + if (state !== BUILD_STATE_INIT) { + await ctx.service.webhook.pushBuildNotification(data); + } + ctx.success(upsertResult); + } +} + +module.exports = GwController; diff --git a/app/controller/home.js b/app/controller/home.js new file mode 100644 index 0000000..38744a3 --- /dev/null +++ b/app/controller/home.js @@ -0,0 +1,29 @@ +'use strict'; + +const { + Controller, +} = require('egg'); + +class HomeController extends Controller { + async index() { + const ctx = this.ctx; + const user = ctx.session.user; + const { appid, callbackUrl } = ctx.app.config.authorize.dingtalkAuth; + ctx.body = await this.app.render({ + dingtalkAuth: { + appid, + callbackUrl, + }, + user, + }, { + title: 'Reliable Platform', + pageId: 'home', + SERVER_ADDRESS: this.config.reliableView.serverUrl, + STATIC_ADDRESS: this.config.reliableView.staticUrl, + assetsUrl: this.config.reliableView.assetsUrl, + version: this.app.config.pkg.version, + }); + } +} + +module.exports = HomeController; diff --git a/app/controller/insight.js b/app/controller/insight.js new file mode 100644 index 0000000..675c35b --- /dev/null +++ b/app/controller/insight.js @@ -0,0 +1,164 @@ +'use strict'; + +const { + Controller, +} = require('egg'); + +class InsightController extends Controller { + _fixedNumber(x) { + return Number(Number.parseFloat(x).toFixed(2)); + } + + _getCommitUrl(record) { + const ctx = this.ctx; + const gitUrl = ctx.safeGet(record, 'data.gitCommitInfo.gitUrl'); + const shortHash = ctx.safeGet(record, 'data.gitCommitInfo.shortHash'); + return (gitUrl && shortHash) ? `${gitUrl}/commit/${shortHash}` : null; + } + + _normalizeCoverageUrl(record) { + const ctx = this.ctx; + const path = ctx.safeGet(record, 'data.testInfo.coverageHtmlReporterPath'); + return this._normalizeResourceUrl(record.data, path); + } + _normalizeReporterUrl(record) { + const ctx = this.ctx; + const path = ctx.safeGet(record, 'data.testInfo.testHtmlReporterPath'); + return this._normalizeResourceUrl(record.data, path); + } + _normalizeResourceUrl(data, target) { + if (!target) { + return ''; + } + if (target.startsWith('http://') || target.startsWith('https://')) { + return target; + } + return; + } + + async ci() { + const ctx = this.ctx; + const { startDate = '', endDate = '', allBranches = 'Y' } = ctx.query; + const allJobName = await ctx.model.JobName.findAll({ + attributes: [ + 'jobName', + ], + }).map(i => i.jobName); + + const Op = ctx.app.Sequelize.Op; + const result = await Promise.all(allJobName.map(async jobName => { + const findOptions = { + where: { + jobName, + state: 'SUCCESS', + }, + attributes: [ + 'jobName', + 'buildNumber', + 'gitBranch', + 'data', + 'createdAt', + 'finishedAt', + ], + limit: 100, + order: [[ 'createdAt', 'DESC' ]], + }; + if (allBranches === 'N') { + findOptions.where.gitBranch = 'master'; + } + if (startDate && endDate) { + findOptions.where.createdAt = { + [Op.between]: [ + ctx.moment(startDate).toDate(), + ctx.moment(endDate).toDate(), + ], + }; + } + const res = await ctx.model.Build.findAll(findOptions); + if (res.length === 0) return null; + + const lastCommit = { + committer: ctx.safeGet(res, '[0].data.gitCommitInfo.committer.name'), + shortHash: ctx.safeGet(res, '[0].data.gitCommitInfo.shortHash'), + commitUrl: this._getCommitUrl(res[0]), + }; + + let linePercentCount = 0; + let passTimeSumCount = 0; + let durationCount = 0; + + const linePercentList = []; + const passPercentList = []; + const durationList = []; + + const linePercentSum = res.reduce((value, i) => { + if (i.data.testInfo.linePercent && !Number.isNaN(i.data.testInfo.linePercent)) { + linePercentCount++; + linePercentList.push({ + commitUrl: this._getCommitUrl(i), + shortHash: ctx.safeGet(i, 'data.gitCommitInfo.shortHash'), + coverageUrl: this._normalizeCoverageUrl(i), + linePercent: ctx.safeGet(i, 'data.testInfo.linePercent'), + createdAt: ctx.moment(i.createdAt).fromNow(), + }); + return Number.parseFloat(i.data.testInfo.linePercent) + value; + } + return value; + }, 0); + const passTimesSum = res.reduce((value, i) => { + if (i.data.testInfo.passPercent && !Number.isNaN(i.data.testInfo.passPercent)) { + passTimeSumCount++; + passPercentList.push({ + commitUrl: this._getCommitUrl(i), + shortHash: ctx.safeGet(i, 'data.gitCommitInfo.shortHash'), + reporterUrl: this._normalizeReporterUrl(i), + passPercent: ctx.safeGet(i, 'data.testInfo.passPercent'), + createdAt: ctx.moment(i.createdAt).fromNow(), + }); + if (Number.parseFloat(i.data.testInfo.passPercent) === 100) { + return 1 + value; + } + } + return value; + + }, 0); + const durationSum = res.reduce((value, i) => { + if (i.finishedAt - i.createdAt > 0) { + durationCount++; + durationList.push({ + commitUrl: this._getCommitUrl(i), + shortHash: ctx.safeGet(i, 'data.gitCommitInfo.shortHash'), + duration: ctx.moment.duration(i.finishedAt - i.createdAt).humanize(), + createdAt: ctx.moment(i.createdAt).fromNow(), + }); + return i.finishedAt - i.createdAt + value; + } + return value; + }, 0); + + const linePercent = linePercentCount > 0 ? this._fixedNumber(linePercentSum / linePercentCount) : linePercentCount; + const passPercent = passTimeSumCount > 0 ? this._fixedNumber(passTimesSum / passTimeSumCount * 100) : passTimeSumCount; + const humanizeDuration = durationCount > 0 + ? ctx.moment.duration(durationSum / durationCount).humanize() + : durationCount; + + return { + jobName, + linePercent, + passPercent, + lastCommit, + humanizeDuration, + linePercentList, + passPercentList, + durationList, + }; + })); + + this.ctx.body = { + success: true, + data: result.filter(i => i !== null), + }; + } +} + +module.exports = InsightController; diff --git a/app/controller/snsAuthorize.js b/app/controller/snsAuthorize.js new file mode 100644 index 0000000..8b1c363 --- /dev/null +++ b/app/controller/snsAuthorize.js @@ -0,0 +1,38 @@ +'use strict'; + +const { + Controller, +} = require('egg'); + +const debug = require('debug')('reliable:controller:snsAuthorize'); +const DingtalkAuth = require('../common/snsAuthorize/dingtalkAuth'); + +class snsAuthorizeController extends Controller { + async signOut() { + const ctx = this.ctx; + debug(ctx.session); + ctx.session = null; + ctx.redirect('/'); + } + + async dingtalkCallback() { + const ctx = this.ctx; + const { appid, appsecret } = ctx.app.config.authorize.dingtalkAuth; + this.dingtalkAuth = new DingtalkAuth({ + ctx, + appid, + appsecret, + }); + const { code: tmpAuthCode } = ctx.query; + const userInfo = await this.dingtalkAuth.getAuthData({ tmpAuthCode }); + debug(userInfo); + if (!userInfo.openid) { + ctx.redirect('back'); + return; + } + ctx.session.user = userInfo; + ctx.redirect('back'); + } +} + +module.exports = snsAuthorizeController; diff --git a/app/extend/application.js b/app/extend/application.js new file mode 100644 index 0000000..2c89d45 --- /dev/null +++ b/app/extend/application.js @@ -0,0 +1,9 @@ +'use strict'; + +const render = require('../../view/lib/render'); + +module.exports = { + render (context, options) { + return render(context, options); + }, +}; \ No newline at end of file diff --git a/app/extend/helper.js b/app/extend/helper.js new file mode 100644 index 0000000..460b105 --- /dev/null +++ b/app/extend/helper.js @@ -0,0 +1,88 @@ +'use strict'; + +const debug = require('debug')('reliable:reliable-dingtalk'); +const ChatBot = require('dingtalk-robot-sender'); + +const sendMarkdown = async (options) => { + debug(options); + const robot = new ChatBot({ + webhook: options.webhook.url, + }); + if (options.isRawMarkdown) { + await robot.markdown(options.title, options.text); + return; + } + await robot.markdown(options.title, options.text.join('\n\n')); +}; + +module.exports = { + sendMarkdown, + sendDingTalk: async function ({ + webhook, + data, + staticServerUrl, + reliableServerUrl, + }) { + const { + gitCommitInfo = {}, + environment = {}, + testInfo = {}, + packages = [], + } = data; + + const { ci } = environment; + + const title = `[Reliable] **${ci.JOB_NAME}** build passed.`; + + // message body + let text = []; + const gitUrl = `${gitCommitInfo.gitUrl}`; + text.push(`### Repository ${ci.JOB_NAME} build passed`); + text.push(''); + text.push(`#### Platform: ${environment.platform}`); + text.push('#### Commit'); + text.push(`[${gitCommitInfo.shortHash}](${gitUrl}/commit/${gitCommitInfo.hash}): ${gitCommitInfo.subject}`); + text.push(`> committer:[@${gitCommitInfo.committer.name}]() author:[@${gitCommitInfo.author.name}]()`); + + // test report info + + text.push('#### Test report'); + const staticUrl = `${staticServerUrl}/jenkins/${ci.JOB_NAME}/${ci.BUILD_NUMBER}/`; + const buildStaticPath = path => /^https?:\/\//.test(path) ? path : staticUrl + path; + const passLogUrl = buildStaticPath(testInfo.testHtmlReporterPath); + + if (testInfo && testInfo.tests) { + text.push(`> Test Cases: [(${testInfo.passes}/${testInfo.tests}) ${testInfo.passPercent}% passed](${passLogUrl})`); + } + + const covUrl = buildStaticPath(testInfo.coverageHtmlReporterPath); + + if (testInfo && testInfo.linePercent) { + text.push(`> Line Coverage: [${testInfo.linePercent}%](${covUrl})`); + } + + // release + text.push('#### Release'); + const pkgText = packages.map(i => i && `> [${i.type}-${i.version}](${buildStaticPath(i.path)})`); + + if (pkgText.length) { + text = text.concat(pkgText); + } else { + text.push('> none'); + } + + if (ci.BUILD_NUMBER) { + text.push(`[> See details on reliable-web](${reliableServerUrl}/buildinfo?jobName=${ci.JOB_NAME}&buildNumber=${ci.BUILD_NUMBER})`); + } + + try { + await sendMarkdown({ + webhook, + title, + text, + }); + } catch (e) { + console.log(e.stack); + } + } +}; diff --git a/app/middleware/authorize.js b/app/middleware/authorize.js new file mode 100644 index 0000000..f1cec6b --- /dev/null +++ b/app/middleware/authorize.js @@ -0,0 +1,29 @@ +'use strict'; + +const debug = require('debug')('reliable:middleware:authorize'); + +module.exports = () => { + return async function authorize(ctx, next) { + const host = ctx.host; + if (host.startsWith('127.0.0.1')) { + await next(); + return; + } + const user = ctx.session.user; + const path = ctx.path; + debug('path %s user %o', path, user); + const authUrl = '/snsAuthorize/auth'; + if (!user) { + if (ctx.acceptJSON) { + ctx.status = 401; + ctx.body = { + url: authUrl, + }; + return; + } + ctx.redirect(authUrl); + return; + } + await next(); + }; +}; diff --git a/app/middleware/cors.js b/app/middleware/cors.js new file mode 100644 index 0000000..6e22f01 --- /dev/null +++ b/app/middleware/cors.js @@ -0,0 +1,23 @@ +'use strict'; + +module.exports = () => { + return async function cors(ctx, next) { + const origin = ctx.get('origin'); + if (!origin) { + return await next(); + } + + ctx.set('Access-Control-Allow-Origin', origin); + ctx.set('Access-Control-Allow-Credentials', 'true'); + + if (ctx.method !== 'OPTIONS') { + return await next(); + } + + // preflight OPTIONS request + ctx.set('Access-Control-Allow-Headers', ctx.get('Access-Control-Request-Headers')); + ctx.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,POST,DELETE,PATCH'); + ctx.set('Access-Control-Max-Age', 10 * 60 * 1000); + ctx.status = 204; + }; +}; diff --git a/app/middleware/error_handler.js b/app/middleware/error_handler.js new file mode 100644 index 0000000..82a0867 --- /dev/null +++ b/app/middleware/error_handler.js @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = () => { + return async function errorHandler(ctx, next) { + try { + await next(); + } catch (e) { + + ctx.logger.error(e); + + let message = e.status === 500 && ctx.app.config.env === 'prod' + ? 'Internal Server Error' + : e.message; + + if (e.code === 'invalid_param') { + message += `, ${e.errors.map(e => `${e.field}: ${e.message}`).join(', ')}`; + ctx.fail('ERR_RELIABLE_INVALID_PARAM_ERROR', message); + return; + } + + ctx.fail('ERR_RELIABLE_INTERNAL_SERVER_ERROR', message); + } + }; +}; diff --git a/app/middleware/host_redirect.js b/app/middleware/host_redirect.js new file mode 100644 index 0000000..f616e0a --- /dev/null +++ b/app/middleware/host_redirect.js @@ -0,0 +1,23 @@ +'use strict'; + +const debug = require('debug')('reliable:middleware:hostRedirect'); + +module.exports = options => { + return async function hostRedirect(ctx, next) { + const defaultHost = options.defaultHost; + debug(defaultHost); + if (!defaultHost) { + await next(); + return; + } + if (ctx.host !== defaultHost) { + ctx.logger.info('[reliable-middleware-hostRedirect] protocol %s host %s defaultHost %s', + ctx.protocol, ctx.host, defaultHost); + // use http by default, more flexiable with options + // ctx.redirect(`${ctx.protocol}://${defaultHost}`); + ctx.redirect(`http://${defaultHost}`); + return; + } + await next(); + }; +}; diff --git a/app/middleware/inject.js b/app/middleware/inject.js new file mode 100644 index 0000000..99a9e17 --- /dev/null +++ b/app/middleware/inject.js @@ -0,0 +1,38 @@ +'use strict'; + +const moment = require('moment'); +const crypto = require('crypto'); +const get = require('lodash.get'); +const errors = require('../common/error'); +const defaultErrorCode = 'ERR_RELIABLE_INTERNAL_SERVER_ERROR'; + +module.exports = () => { + return async function inject(ctx, next) { + ctx.moment = moment; + ctx.safeGet = get; + ctx.toMd5 = string => { + return crypto.createHash('md5') + .update(string, 'utf8') + .digest('hex'); + }; + ctx.success = (data = {}) => { + ctx.body = { + success: true, + data, + }; + }; + + ctx.fail = (errorCode = defaultErrorCode, message = '') => { + if (!errors.has(errorCode)) { + errorCode = defaultErrorCode; + } + const defaultMessage = errors.get(errorCode).message; + ctx.body = { + success: false, + errorCode, + message: message || defaultMessage, + }; + }; + await next(); + }; +}; diff --git a/app/middleware/open_api_authorize.js b/app/middleware/open_api_authorize.js new file mode 100644 index 0000000..3afca2a --- /dev/null +++ b/app/middleware/open_api_authorize.js @@ -0,0 +1,18 @@ +'use strict'; + +const debug = require('debug')('reliable:middleware:openApiAuthorize'); + +module.exports = () => { + return async function openApiAuthorize(ctx, next) { + const origin = ctx.get('origin'); + const path = ctx.path; + debug('path %s origin %s', path, origin); + ctx.logger.info('[reliable-middleware-openApiAuthorize] path %s origin %s', path, origin); + if (!origin) { + await next(); + return; + } + await next(); + return; + }; +}; diff --git a/app/model/build.js b/app/model/build.js new file mode 100644 index 0000000..af7d066 --- /dev/null +++ b/app/model/build.js @@ -0,0 +1,43 @@ +'use strict'; + +module.exports = app => { + const { + STRING, + UUID, + UUIDV4, + JSON, + ENUM, + DATE, + } = app.Sequelize; + + const Build = app.model.define('build', { + jobName: { + type: STRING, + }, + buildNumber: { + type: STRING, + }, + gitBranch: { + type: STRING, + }, + data: JSON, + uniqId: { + type: UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + extendInfo: { + type: JSON, + defaultValue: {}, + }, + state: { + type: ENUM, + values: [ 'INIT', 'SUCCESS', 'FAIL' ], + }, + finishedAt: { + type: DATE, + }, + }, { }); + + return Build; +}; diff --git a/app/model/config.js b/app/model/config.js new file mode 100644 index 0000000..2ada281 --- /dev/null +++ b/app/model/config.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = app => { + const { + UUID, + UUIDV4, + JSON, + } = app.Sequelize; + + const Config = app.model.define('config', { + data: JSON, + uniqId: { + type: UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + }, { }); + + return Config; +}; diff --git a/app/model/jobName.js b/app/model/jobName.js new file mode 100644 index 0000000..a141e84 --- /dev/null +++ b/app/model/jobName.js @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = app => { + const { + STRING, + UUID, + UUIDV4, + } = app.Sequelize; + + const JobName = app.model.define('jobName', { + jobName: { + type: STRING, + allowNull: false, + unique: true, + }, + uniqId: { + type: UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + }, { }); + + return JobName; +}; diff --git a/app/router.js b/app/router.js new file mode 100644 index 0000000..087390e --- /dev/null +++ b/app/router.js @@ -0,0 +1,42 @@ +'use strict'; + +/** + * @param {Egg.Application} app - egg application + */ +module.exports = app => { + const { + router, + controller, + } = app; + + router.post('/api/gw', controller.gw.index); + + // latestBuild + router.get('/api/latestBuild/:jobName/:gitBranch+', controller.build.queryLatestByJobNameAndGitBranch); + + // insight + router.get('/api/insight/ci', controller.insight.ci); + + // build + router.get('/api/build', controller.build.query); + router.get('/api/build/:uniqId', controller.build.show); + router.put('/api/build/:uniqId', controller.build.update); + router.patch('/api/build/:uniqId', controller.build.patch); + + // config + router.get('/api/config', controller.config.show); + router.post('/api/config', controller.config.update); + + // apps + router.get('/api/app/:appId', controller.app.show); + + // delegate + router.post('/api/delegate/message', controller.delegate.message); + + // oauth login callback + router.get('/snsAuthorize/callback/dingtalk', controller.snsAuthorize.dingtalkCallback); + router.get('/snsAuthorize/signout', controller.snsAuthorize.signOut); + + // home page + router.get('*', controller.home.index); +}; diff --git a/app/service/build.js b/app/service/build.js new file mode 100644 index 0000000..0840784 --- /dev/null +++ b/app/service/build.js @@ -0,0 +1,138 @@ +'use strict'; + +const Service = require('egg').Service; +const debug = require('debug')('reliable:service:build'); + +module.exports = class BuildService extends Service { + async queryAllBuilds({ page, num }) { + const ctx = this.ctx; + const allJobName = await ctx.model.JobName.findAll({ + attributes: [ + 'jobName', + ], + }).map(i => i.jobName); + + debug({ + limit: num, + offset: (page - 1) * num, + order: [ + [ + 'createdAt', + 'DESC', + ], + ], + }); + + const total = await ctx.model.Build.count(); + + const result = await ctx.model.Build.findAll({ + limit: num, + offset: (page - 1) * num, + order: [ + [ + 'createdAt', + 'DESC', + ], + ], + }); + + return { + success: true, + message: '', + data: { + allJobName, + total, + page, + result, + }, + }; + } + + async queryByJobName({ page, num, jobName }) { + const ctx = this.ctx; + const allJobName = await ctx.model.JobName.findAll({ + attributes: [ + 'jobName', + ], + }).map(i => i.jobName); + const total = await ctx.model.Build.count(); + const result = await ctx.model.Build.findAll({ + limit: num, + offset: (page - 1) * num, + order: [ + [ + 'createdAt', + 'DESC', + ], + ], + where: { + jobName, + }, + }); + return { + success: true, + message: '', + data: { + allJobName, + total, + page, + result, + }, + }; + } + + async queryByJobNameAndBuildNumber({ jobName, buildNumber }) { + const ctx = this.ctx; + const build = await ctx.model.Build.findOne({ + where: { + jobName, + buildNumber, + }, + }); + if (!build) { + return { + success: false, + code: 'ERR_RELIABLE_BUILD_RECORD_NOT_FOUND', + }; + } + + const result = build.get({ plain: true }); + return { + success: true, + message: '', + data: result, + }; + } + + async queryBuildByUniqId({ uniqId }) { + return await this.ctx.model.Build.findOne({ + where: { + uniqId, + }, + } + ); + } + + async updateBuild({ uniqId, payload }) { + return await this.ctx.model.Build.update( + payload, + { + where: { + uniqId, + }, + } + ); + } + + async finishBuild({ uniqId, payload }) { + return await this.ctx.model.Build.update( + payload, + { + where: { + uniqId, + }, + } + ); + } +}; + diff --git a/app/service/webhook.js b/app/service/webhook.js new file mode 100644 index 0000000..18fca19 --- /dev/null +++ b/app/service/webhook.js @@ -0,0 +1,34 @@ +'use strict'; + +const { + Service, +} = require('egg'); + +/* istanbul ignore next */ +module.exports = class WebHookService extends Service { + + async pushBuildNotification(data = {}) { + const ctx = this.ctx; + // get all webhooks + const globalConfig = await this.ctx.model.Config.findOne(); + + if (!globalConfig || !globalConfig.data || !globalConfig.data.webhooks) { + return; + } + const { + webhooks, + } = globalConfig.data; + try { + await Promise.all(webhooks + .filter(webhook => webhook.tag === 'build') + .map(webhook => ctx.helper.sendDingTalk({ + webhook, + data, + staticServerUrl: `http:${this.config.reliableView.staticUrl}`, + reliableServerUrl: `http:${this.config.reliableView.reliableHost}`, + }))); + } catch (e) { + ctx.logger.error(e); + } + } +}; diff --git a/config/config.default.js b/config/config.default.js new file mode 100644 index 0000000..c77cafa --- /dev/null +++ b/config/config.default.js @@ -0,0 +1,100 @@ +'use strict'; + +const dbConfig = require('../database/config'); + +module.exports = appInfo => { + const config = exports = {}; + + config.siteFile = { + '/favicon.ico': 'https://macacajs.github.io/assets/favicon.ico', + }; + + // use for cookie sign key, should change to your own and keep security + config.keys = process.env.RELIABLE_SECRET_KEY || appInfo.name + '_1528180445670_8068'; + + // add your config here + config.middleware = [ + 'hostRedirect', + 'openApiAuthorize', + 'authorize', + 'inject', + 'cors', + 'errorHandler', + ]; + + config.hostRedirect = { + enable: !!process.env.RELIABLE_DEFAULT_HOST, + defaultHost: process.env.RELIABLE_DEFAULT_HOST, + ignore: [ + /^\/api\//, + ], + }; + + config.authorize = { + enable: process.env.RELIABLE_ENABLE_AUTHORIZE === 'Y', + ignore: [ + '/snsAuthorize/callback/dingtalk', + '/snsAuthorize/auth', + '/snsAuthorize/signout', + + '/api/gw', + '/api/latestBuild/:id', + '/api/app/:id', + '/api/build/:id', + ], + dingtalkAuth: { + appid: process.env.RELIABLE_AUTH_DINGTALK_APPID, + appsecret: process.env.RELIABLE_AUTH_DINGTALK_APPSECRET, + callbackUrl: '/snsAuthorize/callback/dingtalk', + }, + }; + + config.openApiAuthorize = { + enable: process.env.RELIABLE_ENABLE_OPENAPI_AUTHORIZE === 'Y', + match: [ + '/api/gw', + '/api/latestBuild/:id', + '/api/app/:id', + '/api/build/:id', + ], + }; + + config.session = { + maxAge: 48 * 3600 * 1000, // 48 hours + renew: true, // keep session + }; + + config.modelQueryConfig = { + pagination: { + // default num + num: 10, + }, + }; + + config.errorHandler = { + match: '/api', + }; + + const reliableHost = process.env.RELIABLE_HOST || '127.0.0.1'; + const staticHost = process.env.STATIC_HOST || reliableHost; + + config.reliableView = { + serverUrl: '', + reliableHost, + assetsUrl: `//${reliableHost}:8080`, + staticUrl: `//${staticHost}:9920`, + }; + + config.security = { + csrf: { + enable: false, + }, + methodnoallow: { + enable: false, + }, + }; + + config.sequelize = dbConfig.development; + + return config; +}; diff --git a/config/config.local.js b/config/config.local.js new file mode 100644 index 0000000..c9e8293 --- /dev/null +++ b/config/config.local.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = () => { + const config = exports = {}; + + config.reliableView = { + // assetsUrl: '//127.0.0.1:8080', + }; + config.authorize = { + enable: false, + dingtalkAuth: { + appid: '', + appsecret: '', + }, + }; + config.openApiAuthorize = { + enable: true, + }; + config.hostRedirect = { + enable: false, + defaultHost: '', + }; + return config; +}; + diff --git a/config/config.prod.js b/config/config.prod.js new file mode 100644 index 0000000..b4e0b87 --- /dev/null +++ b/config/config.prod.js @@ -0,0 +1,4 @@ +'use strict'; + +const dbConfig = require('../database/config'); +exports.sequelize = dbConfig.production; diff --git a/config/config.unittest.js b/config/config.unittest.js new file mode 100644 index 0000000..19b97cb --- /dev/null +++ b/config/config.unittest.js @@ -0,0 +1,11 @@ +'use strict'; + +const dbConfig = require('../database/config'); + +module.exports = appInfo => { + const config = exports = {}; + + config.sequelize = dbConfig.test; + config.keys = appInfo.name + '_unittest_key'; + return config; +}; diff --git a/config/plugin.js b/config/plugin.js new file mode 100644 index 0000000..a1d3f9a --- /dev/null +++ b/config/plugin.js @@ -0,0 +1,11 @@ +'use strict'; + +exports.sequelize = { + enable: true, + package: 'egg-sequelize', +}; + +exports.validate = { + enable: true, + package: 'egg-validate', +}; diff --git a/database/config.js b/database/config.js new file mode 100644 index 0000000..2259bc4 --- /dev/null +++ b/database/config.js @@ -0,0 +1,25 @@ +'use strict'; + +const defaultConfig = { + username: 'root', + password: 'reliable', + database: 'reliable_development', + host: process.env.MYSQL_HOST || '127.0.0.1', + port: process.env.MYSQL_PORT || '3306', + operatorsAliases: false, + dialect: 'mysql', + define: { + underscored: false, + }, +}; + +module.exports = { + development: defaultConfig, + test: Object.assign({}, defaultConfig, { + database: 'reliable_unittest', + }), + production: Object.assign({}, defaultConfig, { + defaultConfig, + database: 'reliable', + }), +}; diff --git a/database/migrations/20180801094816-init.js b/database/migrations/20180801094816-init.js new file mode 100644 index 0000000..1b835a0 --- /dev/null +++ b/database/migrations/20180801094816-init.js @@ -0,0 +1,98 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + const { + STRING, + UUID, + UUIDV4, + JSON, + INTEGER, + DATE, + } = Sequelize; + + await queryInterface.createTable('jobNames', { + id: { + type: INTEGER, + autoIncrement: true, + primaryKey: true, + }, + jobName: { + type: STRING, + primaryKey: true, + }, + uniqId: { + type: UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + created_at: { + type: DATE, + allowNull: false, + }, + updated_at: { + type: DATE, + allowNull: false, + }, + }); + + await queryInterface.createTable('builds', { + id: { + type: INTEGER, + autoIncrement: true, + primaryKey: true, + }, + jobName: { + type: STRING, + }, + buildNumber: { + type: STRING, + }, + gitBranch: { + type: STRING, + }, + data: JSON, + uniqId: { + type: UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + created_at: { + type: DATE, + allowNull: false, + }, + updated_at: { + type: DATE, + allowNull: false, + }, + }); + + await queryInterface.createTable('configs', { + id: { + type: INTEGER, + autoIncrement: true, + primaryKey: true, + }, + data: JSON, + uniqId: { + type: UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + created_at: { + type: DATE, + allowNull: false, + }, + updated_at: { + type: DATE, + allowNull: false, + }, + }); + }, + + down: async queryInterface => { + await queryInterface.dropTable('jobNames'); + await queryInterface.dropTable('builds'); + await queryInterface.dropTable('configs'); + }, +}; diff --git a/database/migrations/20180805093448-change-init-table.js b/database/migrations/20180805093448-change-init-table.js new file mode 100644 index 0000000..af3ea48 --- /dev/null +++ b/database/migrations/20180805093448-change-init-table.js @@ -0,0 +1,55 @@ +'use strict'; + +module.exports = { + up: async queryInterface => { + await queryInterface.removeColumn('jobNames', 'id'); + await queryInterface.removeColumn('builds', 'id'); + await queryInterface.removeColumn('configs', 'id'); + + await queryInterface.renameColumn('jobNames', 'created_at', 'createdAt'); + await queryInterface.renameColumn('jobNames', 'updated_at', 'updatedAt'); + await queryInterface.renameColumn('builds', 'created_at', 'createdAt'); + await queryInterface.renameColumn('builds', 'updated_at', 'updatedAt'); + await queryInterface.renameColumn('configs', 'created_at', 'createdAt'); + await queryInterface.renameColumn('configs', 'updated_at', 'updatedAt'); + + const res = await queryInterface.showConstraint('jobNames'); + for (const item of res) { + try { + await queryInterface.removeConstraint('jobNames', item.constraintName); + } catch (e) { /* */ } + } + await queryInterface.addConstraint('jobNames', [ 'uniqId' ], { + type: 'primary key', + }); + await queryInterface.addConstraint('jobNames', [ 'jobName' ], { + type: 'unique', + }); + }, + + down: async (queryInterface, Sequelize) => { + const dataType = { + type: Sequelize.INTEGER, + }; + await queryInterface.addColumn('jobNames', 'id', dataType); + await queryInterface.addColumn('builds', 'id', dataType); + await queryInterface.addColumn('configs', 'id', dataType); + + await queryInterface.renameColumn('jobNames', 'createdAt', 'created_at'); + await queryInterface.renameColumn('jobNames', 'updatedAt', 'updated_at'); + await queryInterface.renameColumn('builds', 'createdAt', 'created_at'); + await queryInterface.renameColumn('builds', 'updatedAt', 'updated_at'); + await queryInterface.renameColumn('configs', 'createdAt', 'created_at'); + await queryInterface.renameColumn('configs', 'updatedAt', 'updated_at'); + + const res = await queryInterface.showConstraint('jobNames'); + for (const item of res) { + try { + await queryInterface.removeConstraint('jobNames', item.constraintName); + } catch (e) { /**/ } + } + await queryInterface.addConstraint('jobNames', [ 'uniqId' ], { + type: 'primary key', + }); + }, +}; diff --git a/database/migrations/20180915131836-build-add-extendInfo.js b/database/migrations/20180915131836-build-add-extendInfo.js new file mode 100644 index 0000000..d4e89dc --- /dev/null +++ b/database/migrations/20180915131836-build-add-extendInfo.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + const { + JSON, + } = Sequelize; + await queryInterface.addColumn('builds', 'extendInfo', { + type: JSON, + }); + }, + + down: async queryInterface => { + await queryInterface.removeColumn('builds', 'extendInfo'); + }, +}; diff --git a/database/migrations/20181114033427-build-add-state.js b/database/migrations/20181114033427-build-add-state.js new file mode 100644 index 0000000..c454d21 --- /dev/null +++ b/database/migrations/20181114033427-build-add-state.js @@ -0,0 +1,18 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + const { + ENUM, + } = Sequelize; + await queryInterface.addColumn('builds', 'state', { + type: ENUM, + values: [ 'INIT', 'SUCCESS', 'FAIL' ], + defaultValue: 'SUCCESS', + }); + }, + + down: async queryInterface => { + await queryInterface.removeColumn('builds', 'state'); + }, +}; diff --git a/database/migrations/20181130024204-build-add-finishedAt.js b/database/migrations/20181130024204-build-add-finishedAt.js new file mode 100644 index 0000000..a4babcc --- /dev/null +++ b/database/migrations/20181130024204-build-add-finishedAt.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + const { + DATE, + } = Sequelize; + await queryInterface.addColumn('builds', 'finishedAt', { + type: DATE, + }); + }, + + down: async queryInterface => { + await queryInterface.removeColumn('builds', 'finishedAt'); + }, +}; diff --git a/database/seeders/20180815163737-add-seed-to-table-jobNames.js b/database/seeders/20180815163737-add-seed-to-table-jobNames.js new file mode 100644 index 0000000..9636ba6 --- /dev/null +++ b/database/seeders/20180815163737-add-seed-to-table-jobNames.js @@ -0,0 +1,29 @@ +'use strict'; + +const uuidv4 = require('uuid/v4'); + +module.exports = { + up: async queryInterface => { + await queryInterface.bulkInsert('jobNames', [ + 'foo', + 'baz', + 'qux', + 'bar', + ].map( + item => { + console.log('item') + return { + jobName: item, + uniqId: uuidv4(), + createdAt: new Date(), + updatedAt: new Date(), + }; + } + )); + console.log('finish up') + }, + + down: async queryInterface => { + await queryInterface.bulkDelete('jobNames'); + }, +}; diff --git a/database/seeders/20180815172240-add-seed-to-table-builds.js b/database/seeders/20180815172240-add-seed-to-table-builds.js new file mode 100644 index 0000000..4528334 --- /dev/null +++ b/database/seeders/20180815172240-add-seed-to-table-builds.js @@ -0,0 +1,41 @@ +'use strict'; + +const build = require('../../test/fixtures/post-gw.json'); +const data = JSON.stringify(build); + +module.exports = { + up: async queryInterface => { + let baseId = 1000; + const uidPrefix = '00000000-0000-0000-0000-00000000'; + const insertData = []; + for (let i = 0; i < 5; i++) { + insertData.push({ + jobName: 'foo', + buildNumber: '1074395', + gitBranch: 'master', + data, + uniqId: uidPrefix + baseId, + createdAt: new Date(), + updatedAt: new Date(), + finishedAt: new Date(Date.now() + 60 * 1000), + }); + baseId++; + insertData.push({ + jobName: 'bar', + buildNumber: '1074395', + gitBranch: 'master', + data, + uniqId: uidPrefix + baseId, + createdAt: new Date(), + updatedAt: new Date(), + finishedAt: new Date(Date.now() + 60 * 1000), + }); + baseId++; + } + await queryInterface.bulkInsert('builds', insertData); + }, + + down: async queryInterface => { + await queryInterface.bulkDelete('builds'); + }, +}; diff --git a/database/seeders/20180816055801-add-seed-to-table-configs.js b/database/seeders/20180816055801-add-seed-to-table-configs.js new file mode 100644 index 0000000..36d1147 --- /dev/null +++ b/database/seeders/20180816055801-add-seed-to-table-configs.js @@ -0,0 +1,20 @@ +'use strict'; + +const uuidv4 = require('uuid/v4'); +const config = require('../../test/fixtures/config-data.json'); +const data = JSON.stringify(config); + +module.exports = { + up: async queryInterface => { + await queryInterface.bulkInsert('configs', [{ + data, + uniqId: uuidv4(), + createdAt: new Date(), + updatedAt: new Date(), + }]); + }, + + down: async queryInterface => { + await queryInterface.bulkDelete('configs'); + }, +}; diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..085f670 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,3 @@ +# Dockerfiles + +--- diff --git a/docker/reliable-mysql/.dockerignore b/docker/reliable-mysql/.dockerignore new file mode 100644 index 0000000..dd44972 --- /dev/null +++ b/docker/reliable-mysql/.dockerignore @@ -0,0 +1 @@ +*.md diff --git a/docker/reliable-mysql/Dockerfile b/docker/reliable-mysql/Dockerfile new file mode 100644 index 0000000..fa0821d --- /dev/null +++ b/docker/reliable-mysql/Dockerfile @@ -0,0 +1,5 @@ +FROM mysql:5 + +ENV MYSQL_ROOT_PASSWORD=reliable + +COPY docker-entrypoint-initdb.d /docker-entrypoint-initdb.d diff --git a/docker/reliable-mysql/README.md b/docker/reliable-mysql/README.md new file mode 100644 index 0000000..0a459aa --- /dev/null +++ b/docker/reliable-mysql/README.md @@ -0,0 +1,45 @@ +# reliable-mysql + +--- + +[![docker pull][docker-pull-image]][docker-url] +[![docker pull][docker-size-image]][docker-url] +[![docker pull][docker-layers-image]][docker-url] + +[docker-pull-image]: https://img.shields.io/docker/pulls/macacajs/reliable-mysql.svg?style=flat-square&logo=dockbit +[docker-size-image]: https://img.shields.io/microbadger/image-size/macacajs/reliable-mysql.svg?style=flat-square&logo=dockbit +[docker-layers-image]: https://img.shields.io/microbadger/layers/macacajs/reliable-mysql.svg?style=flat-square&logo=dockbit +[docker-url]: https://hub.docker.com/r/macacajs/reliable-mysql/ + +## production + +```bash +$ docker run --name reliable-mysql \ + -v $HOME/reliable_home/mysql_data:/var/lib/mysql \ + -d macacajs/reliable-mysql +``` + +--- + +Just for developer + +## build image + +```bash +$ cd docker/reliable-mysql +$ docker build --pull -t macacajs/reliable-mysql . +$ docker push macacajs/reliable-mysql +``` + +## development + +```bash +# start +$ docker run --rm --name reliable-mysql \ + -p 3306:3306 \ + -v $HOME/reliable_home/mysql_data:/var/lib/mysql \ + macacajs/reliable-mysql + +# stop +$ docker stop reliable-mysql +``` diff --git a/docker/reliable-mysql/docker-entrypoint-initdb.d/init.sql b/docker/reliable-mysql/docker-entrypoint-initdb.d/init.sql new file mode 100644 index 0000000..781a2f7 --- /dev/null +++ b/docker/reliable-mysql/docker-entrypoint-initdb.d/init.sql @@ -0,0 +1,8 @@ +CREATE DATABASE IF NOT EXISTS reliable_development + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE DATABASE IF NOT EXISTS reliable_unittest + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE DATABASE IF NOT EXISTS reliable + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/docker/reliable-web/README.md b/docker/reliable-web/README.md new file mode 100644 index 0000000..a65fdc9 --- /dev/null +++ b/docker/reliable-web/README.md @@ -0,0 +1,101 @@ +# reliable-web + +--- + +[![docker pull][docker-pull-image]][docker-url] +[![docker pull][docker-size-image]][docker-url] +[![docker pull][docker-layers-image]][docker-url] + +[docker-pull-image]: https://img.shields.io/docker/pulls/macacajs/reliable-web.svg?style=flat-square&logo=dockbit +[docker-size-image]: https://img.shields.io/microbadger/image-size/macacajs/reliable-web.svg?style=flat-square&logo=dockbit +[docker-layers-image]: https://img.shields.io/microbadger/layers/macacajs/reliable-web.svg?style=flat-square&logo=dockbit +[docker-url]: https://hub.docker.com/r/macacajs/reliable-web/ + +## production + +should launch `reliable-mysql` service first. + +[available environment variable](./#environment-variable) + +```bash +$ docker run --rm --name reliable-web \ + -p 9900:9900 \ + -e RELIABLE_HOST=127.0.0.1 \ + --link reliable-mysql:mysql-host \ + macacajs/reliable-web +``` + +run as a service + +```bash +$ docker run --name reliable-web \ + -p 9900:9900 \ + -e RELIABLE_HOST=127.0.0.1 \ + --link reliable-mysql:mysql-host \ + -d macacajs/reliable-web +``` + +open http://127.0.0.1:9900 + +if you want another hostname, please replace the `127.0.0.1` + +--- + +Just for developer + +## build image + +```bash +$ cd ${PROJECT_ROOT_PATH} +$ docker build --no-cache --pull -t macacajs/reliable-web . +$ docker push macacajs/reliable-web +``` + +## development + +start mysql: + +```bash +# start +$ docker run --rm --name reliable-mysql \ + -p 3306:3306 \ + -v $HOME/reliable_home/mysql_data:/var/lib/mysql \ + -d macacajs/reliable-mysql + +# stop +$ docker stop reliable-mysql +``` + +start server: + +```bash +npm run dev +``` + +insert seed data: + +```bash +npm run db:seed:all +``` + +remove seed data: + +```bash +npm run db:seed:undo:all +``` + +## test + +```bash +npm test # test migration then test web server +npm run test-local # only test web server +npm run cov # test and output test coverage +``` + +## environment-variable + +variable name | description | default value +--- | --- | --- +MYSQL_HOST | mysql server ip | 127.0.0.1 +MYSQL_PORT | mysql port | 3306 +RELIABLE_HOST | used for notification message template | 127.0.0.1 diff --git a/docs/integrate-with-gitlab-ci.md b/docs/integrate-with-gitlab-ci.md new file mode 100644 index 0000000..c8074fb --- /dev/null +++ b/docs/integrate-with-gitlab-ci.md @@ -0,0 +1,3 @@ +# Integrate With Gitlab CI + +--- diff --git a/docs/integrate-with-gitlab-ci.zh-CN.md b/docs/integrate-with-gitlab-ci.zh-CN.md new file mode 100644 index 0000000..9147f58 --- /dev/null +++ b/docs/integrate-with-gitlab-ci.zh-CN.md @@ -0,0 +1,3 @@ +# Gitlab CI 集成文档 + +--- diff --git a/docs/integrate-with-jenkins.md b/docs/integrate-with-jenkins.md new file mode 100644 index 0000000..8e65ddc --- /dev/null +++ b/docs/integrate-with-jenkins.md @@ -0,0 +1,55 @@ +Integrate With Jenkins + +--- + +## Reliable home path + +```bash +$ mkdir $HOME/reliable_home +``` + +reliable_home requires the following sub-directories to be created. + +``` +. +├── static Static HTTP server root folder, containing build artifacts, reports, and archived files. +├── mysql_data Mysql Database. Can be backed up easily. +├── jenkins_home Jenkins root folder, containing configuration files and plugins. +├── jenkins_tmp Jenkins temporary folder. +└── jenkins.war Jenkins war package. Can execute programs. +``` + +## Reliable Jenkins Deployment + +- In `$HOME/reliable_home` directory, create jenkins_home, jenkins_tmp +- Download official [War package](http://mirrors.jenkins.io/) to $HOME/reliable_home directory +- jenkins service launches at port 9910 + +```bash +$ java -Dfile.encoding=UTF-8 \ + -XX:PermSize=256m \ + -XX:MaxPermSize=512m \ + -Xms256m \ + -Xmx512m \ + -DJENKINS_HOME=$HOME/reliable_home/jenkins_home \ + -Djava.io.tmpdir=$HOME/reliable_home/jenkins_tmp \ + -jar $HOME/reliable_home/jenkins.war \ + --httpPort=9910 +``` + +Change `$HOME/reliable_home/jenkins_home/config.xml` useSecurity to false, and restart the Jenkins. + +```xml +false +``` + +0. input the `initialAdminPassword` and next. +0. select `Install suggested plugins` and wait for Jenkins plugins installation ready. + +--- + +## Build Tasks Sample + +- [jenkins-android.md](./jenkins-android.md) +- [jenkins-ios.md](./jenkins-ios.md) +- [jenkins-web.md](./jenkins-web.md) \ No newline at end of file diff --git a/docs/integrate-with-jenkins.zh-CN.md b/docs/integrate-with-jenkins.zh-CN.md new file mode 100644 index 0000000..2c1f272 --- /dev/null +++ b/docs/integrate-with-jenkins.zh-CN.md @@ -0,0 +1,57 @@ +# Jenkins 集成文档 + +--- + +## Reliable home 目录 + +在配置前需要创建 `reliable_home` 目录。 + +```bash +$ mkdir $HOME/reliable_home +``` + +`reliable_home` 包含以下子目录。 + +``` +. +├── static 提供静态资源下载服务 +├── mysql_data Mysql 数据库存档 +├── jenkins_home Jenkins home 目录 +├── jenkins_tmp Jenkins 临时文件目录 +└── jenkins.war Jenkins war 包执行文件 +``` + +## 部署 Jenkins + +- 在 `$HOME/reliable_home` 目录创建 `jenkins_home` 和 `jenkins_tmp` +- 下载 [War 包](http://mirrors.jenkins.io/) 到 `$HOME/reliable_home` +- Jenkins 服务启动在 9910 端口 + +```bash +$ java -Dfile.encoding=UTF-8 \ + -XX:PermSize=256m \ + -XX:MaxPermSize=512m \ + -Xms256m \ + -Xmx512m \ + -DJENKINS_HOME=$HOME/reliable_home/jenkins_home \ + -Djava.io.tmpdir=$HOME/reliable_home/jenkins_tmp \ + -jar $HOME/reliable_home/jenkins.war \ + --httpPort=9910 +``` + +修改 `$HOME/reliable_home/jenkins_home/config.xml` 文件中的配置字段 `useSecurity` 为 false,并且重启 Jenkins 服务。 + +```xml +false +``` + +0. 输入 `initialAdminPassword` 管理员密码 +0. 选择 `Install suggested plugins` 推荐模式安装 Jenkins 插件。 + +--- + +## 构建任务配置 + +- [jenkins-android.zh-CN.md](./jenkins-android.zh-CN.md) +- [jenkins-ios.zh-CN.md](./jenkins-ios.zh-CN.md) +- [jenkins-web.zh-CN.md](./jenkins-web.zh-CN.md) diff --git a/docs/jenkins-android.md b/docs/jenkins-android.md new file mode 100644 index 0000000..9cc6aa0 --- /dev/null +++ b/docs/jenkins-android.md @@ -0,0 +1,123 @@ +# Jenkins Android Task + +--- + +## Dependencies + +### Environment + +Should create `gradle_cache` directory in reliable_home for gradle tool. + +```bash +$ mkdir $HOME/reliable_home/gradle_cache +``` + +### Docker + +Just like reliable-web, we recommend to build Android with Docker. + +## Sample Project + +[android-app-bootstrap](//github.com/app-bootstrap/android-app-bootstrap) + +## Quick Start + +### Step1 - Create New + +Create a new item named `android-app-bootstrap`, and select the `Freestyle project` mode. + +
+ +
+ +### Step2 - SCM Config + +
+ +
+ +Please input the `android-app-bootstrap` git url, and set the clone depth to `1`, branch to `master` is ok. + +``` +https://github.com/app-bootstrap/android-app-bootstrap.git +``` + +### Step3 - Build Scripts Config + +
+ +
+ +**Noted** + +- please confirm jenkins delete the workspace before build to avoid the old middle-file problem. + +
+ +
+ +We provide the Android build docker like `macacajs/macaca-android-build-docker`, so you can set the feild content like this: + +``` +docker stop $JOB_NAME || true && docker rm -f $JOB_NAME || true + +docker run --rm \ + --name $JOB_NAME \ + -e JOB_NAME \ + -e BUILD_NUMBER \ + -e reliable_SERVER_URL=http://192.168.0.102:9900 \ + -v $WORKSPACE:/root/src \ + -v $HOME/reliable_home/static:/static \ + -v $HOME/reliable_home/gradle_cache:/root/.gradle \ + macacajs/macaca-android-build-docker +``` + +**Noted** + +- you can also build android out off the docker container, system shell command is ok. +- please confirm the `reliable_SERVER_URL` has the correct address just like the IPV4 or some domain name which can be visited from the docker container, otherwise you will meet the problem below. + +``` +error: TypeError: Cannot read property 'server' of undefined + at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66) + at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18) + at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35) + at + at process._tickCallback (internal/process/next_tick.js:188:7) +npm ERR! code ELIFECYCLE +npm ERR! errno 1 +npm ERR! android-app-bootstrap@1.0.8 reliable: `reliable report -c ./reliable.config.js` +npm ERR! Exit status 1 +``` + +### Step4 - Build Now + +
+ +
+ +After the building ready, you can get the final result from reliable-web. + +
+ +
+ +We cat get the `debug` and `relese` package of the `android-app-bootstrap`. + +
+ +
+ +Scan the QRCode, you can download and install it with your device. + +
+ +
+ +You can also get other extra build infomation. If you want more, please tweak the [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration) file. + +### Step5 - Test Reporter + +reliable support the Unit and E2E test reporter, coverage based on Macaca is supported. + +coming soon diff --git a/docs/jenkins-android.zh-CN.md b/docs/jenkins-android.zh-CN.md new file mode 100644 index 0000000..9f5a48f --- /dev/null +++ b/docs/jenkins-android.zh-CN.md @@ -0,0 +1,123 @@ +# Jenkins Android 任务配置 + +--- + +## 依赖准备 + +### 环境依赖 + +在 `reliable_home` 创建 `gradle_cache` 目录用于 Gradle 工具的缓存。 + +```bash +$ mkdir $HOME/reliable_home/gradle_cache +``` + +### Docker 部署 + +就像 reliable-web 一样,Reliable 环境配置倾向于容器化,推荐你使用 Android Docker 容器运行任务。 + +## 示例工程 + +[android-app-bootstrap](//github.com/app-bootstrap/android-app-bootstrap) + +## 快速上手 + +### 第1步 - 创建任务 + +创建一个项目名为 `android-app-bootstrap`,并且选择自由风格模式。 + +
+ +
+ +### 第2步 - SCM 配置 + +
+ +
+ +输入项目的 git 地址,并且选择克隆深度为 1,分支为 `master`。 + +``` +https://github.com/app-bootstrap/android-app-bootstrap.git +``` + +### 第3步 - 构建脚本配置 + +
+ +
+ +**注意** + +- 请确保勾选构建前删除运行空间,以排除老的中间文件造成的问题。 + +
+ +
+ +我们提供 Android 构建 Docker 镜像 `macacajs/macaca-android-build-docker`,你可以设置如下: + +``` +docker stop $JOB_NAME || true && docker rm -f $JOB_NAME || true + +docker run --rm \ + --name $JOB_NAME \ + -e JOB_NAME \ + -e BUILD_NUMBER \ + -e RELIABLE_SERVER_URL=http://192.168.0.102:9900 \ + -v $WORKSPACE:/root/src \ + -v $HOME/reliable_home/static:/static \ + -v $HOME/reliable_home/gradle_cache:/root/.gradle \ + macacajs/macaca-android-build-docker +``` + +**注意** + +- 也可以不使用容器而使用系统命令行直接进行构建。 +- 请确认 `RELIABLE_SERVER_URL` 已经正确配置,可以是一个 IPV4 或者某个 url,否则会遇到如下问题: + +``` +error: TypeError: Cannot read property 'server' of undefined + at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66) + at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18) + at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35) + at + at process._tickCallback (internal/process/next_tick.js:188:7) +npm ERR! code ELIFECYCLE +npm ERR! errno 1 +npm ERR! android-app-bootstrap@1.0.8 reliable: `reliable report -c ./reliable.config.js` +npm ERR! Exit status 1 +``` + +### 第4步 - 理解构建 + +
+ +
+ +构建结束后,你可以在 reliable web 平台获得构建结果。 + +
+ +
+ +比如我们能够获得 `android-app-bootstrap` 的 `debug` 类型包和 `release` 类型包。 + +
+ +
+ +Reliable 平台也支持扫码下载安装等实用功能。 + +
+ +
+ +我们也可以获得项目配置,版本等额外信息。如果需要更多上报信息可以参考上报脚本文档 [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration)。 + +### 第5步 - 自动化测试 + +Reliable 无缝集成 Macaca 自动化测试工具,支持通过率报告,端到端链路刻画,覆盖率等质量覆盖方案。 + +配置敬请期待! diff --git a/docs/jenkins-ios.md b/docs/jenkins-ios.md new file mode 100644 index 0000000..f78fc83 --- /dev/null +++ b/docs/jenkins-ios.md @@ -0,0 +1,108 @@ +# Jenkins iOS Task + +--- + +## Dependencies + +### Environment + +Please install reliable-ios automation utils with following command. + +```bash +$ curl -fsSL https://github.com/macacajs/reliable-ios/files/2114440/Makefile.txt -o Makefile && make init +``` + +## Sample Project + +There are two sample projects, one for publish app and the other for publish private cocoapod pod frameworks: + +- [ios-app-bootstrap - publish iOS App](//github.com/app-bootstrap/ios-app-bootstrap) +- [publish cocoapod frameworks](//github.com/macacajs/reliable-ios/tree/master/Example) + +## Quick Start + +### Step1 - Create New + +Create a new item named `ios-app-bootstrap`, and select the `Freestyle project` mode. + +
+ +
+ +### Step2 - SCM Config + +
+ +
+ +Please input the `ios-app-bootstrap` git url, and set the clone depth to `1`, branch to `master` is ok. + +``` +https://github.com/app-bootstrap/ios-app-bootstrap.git +``` + +### Step3 - Build Scripts Config + +
+ +
+ +``` +RELIABLE_SERVER_URL=http://127.0.0.1:9900 RELIABLE_IOS=true ./ci.sh +``` + +**Noted** + +- To release the app and sign, you may need to configure your developer certificate in Jenkins. +- please confirm the RELIABLE_SERVER_URL has the correct address just like the IPV4 or some domain name which can be visited from the docker container, otherwise you will meet the problem below. + +``` +error: TypeError: Cannot read property 'server' of undefined + at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66) + at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18) + at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35) + at + at process._tickCallback (internal/process/next_tick.js:188:7) +npm ERR! code ELIFECYCLE +npm ERR! errno 1 +npm ERR! ios-app-bootstrap@1.0.11 reliable: `reliable report -c ./reliable.config.js` +npm ERR! Exit status 1 +``` + +### Step4 - Build Now + +
+ +
+ +After the building ready, you can get the final result from reliable-web. + +
+ +
+ +We cat get the `debug` package of the `ios-app-bootstrap`. + +
+ +
+ +Scan the QRCode, you can download and install it with your device. + +
+ +
+ +You can also get other extra build infomation. If you want more, please tweak the [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration) file. + +### Step5 - Test Reporter + +Reliable support the Unit and E2E test reporter, coverage based on Macaca is supported. + +
+ +
+ +
+ +
diff --git a/docs/jenkins-ios.zh-CN.md b/docs/jenkins-ios.zh-CN.md new file mode 100644 index 0000000..e70ef22 --- /dev/null +++ b/docs/jenkins-ios.zh-CN.md @@ -0,0 +1,108 @@ +# Jenkins iOS 任务配置 + +--- + +## 依赖准备 + +### 环境依赖 + +请安装 reliable-ios 自动化脚本工具集。 + +```bash +$ curl -fsSL https://github.com/macacajs/reliable-ios/files/2114440/Makefile.txt -o Makefile && make init +``` + +## 示例项目 + +这里有两个示例工程,一个典型的 iOS 工程,另一个是发布私有 cocoapod pod 框架: + +- [ios-app-bootstrap - publish iOS App](//github.com/app-bootstrap/ios-app-bootstrap) +- [publish cocoapod frameworks](//github.com/macacajs/reliable-ios/tree/master/Example) + +## 快速上手 + +### 第1步 - 创建任务 + +创建一个项目名为 `ios-app-bootstrap`,并且选择自由风格模式。 + +
+ +
+ +### 第2步 - SCM 配置 + +
+ +
+ +输入项目的 git 地址,并且选择克隆深度为 1,分支为 `master`。 + +``` +https://github.com/app-bootstrap/ios-app-bootstrap.git +``` + +### 第3步 - 构建脚本配置 + +
+ +
+ +``` +RELIABLE_SERVER_URL=http://127.0.0.1:9900 RELIABLE_IOS=true ./ci.sh +``` + +**注意** + +- 为了能够直接扫码安装应用,请在 Jenkins 配置开发者证书签名。 +- 请确认 `RELIABLE_SERVER_URL` 已经正确配置,可以是一个 IPV4 或者某个 url,否则会遇到如下问题: + +``` +error: TypeError: Cannot read property 'server' of undefined + at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66) + at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18) + at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35) + at + at process._tickCallback (internal/process/next_tick.js:188:7) +npm ERR! code ELIFECYCLE +npm ERR! errno 1 +npm ERR! ios-app-bootstrap@1.0.11 reliable: `reliable report -c ./reliable.config.js` +npm ERR! Exit status 1 +``` + +### 第4步 - 立即构建 + +
+ +
+ +构建结束后,你可以在 reliable web 平台获得构建结果。 + +
+ +
+ +比如我们能够获得 `ios-app-bootstrap` 的 `debug` 类型包。 + +
+ +
+ +Reliable 平台也支持扫码下载安装等实用功能。 + +
+ +
+ +我们也可以获得项目配置,版本等额外信息。如果需要更多上报信息可以参考上报脚本文档 [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration)。 + +### 第5步 - 自动化测试 + +Reliable 无缝集成 Macaca 自动化测试工具,支持通过率报告,端到端链路刻画,覆盖率等质量覆盖方案。 + +
+ +
+ +
+ +
diff --git a/docs/jenkins-web.md b/docs/jenkins-web.md new file mode 100644 index 0000000..6f389d3 --- /dev/null +++ b/docs/jenkins-web.md @@ -0,0 +1,118 @@ +# Jenkins Web Task + +--- + +## Dependencies + +### Docker + +Just like reliable-web, we recommend to build web with Docker. + +## Sample Project + +- [web-app-bootstrap](//github.com/app-bootstrap/web-app-bootstrap) + +## Quick Start + +### Step1 - Create New + +Create a new item named `web-app-bootstrap`, and select the `Freestyle project` mode. + +
+ +
+ +### Step2 - SCM Config + +
+ +
+ +Please input the `web-app-bootstrap` git url, and set the clone depth to `1`, branch to `master` is ok. + +``` +https://github.com/app-bootstrap/web-app-bootstrap.git +``` + +### Step3 - Build Scripts Config + +**Noted** + +- please confirm jenkins delete the workspace before build to avoid the old middle-file problem. + +
+ +
+ +We provide the webpack build docker like `macacajs/macaca-electron-docker`, so you can set the feild content like this: + +``` +docker stop $JOB_NAME || true && docker rm $JOB_NAME || true + +docker run --rm \ + --name $JOB_NAME \ + -e JOB_NAME \ + -e BUILD_NUMBER \ + -e RELIABLE_SERVER_URL=http://129.168.1.102:9900 \ + -v $HOME/reliable_home/static:/static \ + -v $WORKSPACE:/root/src \ + macacajs/macaca-electron-docker \ + bash -c "cd /root/src && npm run ci" +``` + +**Noted** + +- please confirm the `RELIABLE_SERVER_URL` has the correct address just like the IPV4 or some domain name which can be visited from the docker container, otherwise you will meet the problem below. + +``` +error: TypeError: Cannot read property 'server' of undefined + at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66) + at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18) + at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35) + at + at process._tickCallback (internal/process/next_tick.js:188:7) +npm ERR! code ELIFECYCLE +npm ERR! errno 1 +npm ERR! web-app-bootstrap@1.0.8 reliable: `reliable report -c ./reliable.config.js` +npm ERR! Exit status 1 +``` + +### Step4 - Build Now + +After the building ready, you can get the final result from reliable-web. + +
+ +
+ +We cat get the build results of the `web-app-bootstrap`. + +
+ +
+ +You can also get other extra build infomation. If you want more, please tweak the [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration) file. + +### Step5 - Test Reporter + +Reliable support the Unit and E2E test reporter, coverage based on Macaca is supported. + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/docs/jenkins-web.zh-CN.md b/docs/jenkins-web.zh-CN.md new file mode 100644 index 0000000..01cbba6 --- /dev/null +++ b/docs/jenkins-web.zh-CN.md @@ -0,0 +1,118 @@ +# Jenkins Web 任务配置 + +--- + +## 依赖准备 + +### Docker 部署 + +就像 reliable-web 一样,Reliable 环境配置倾向于容器化,推荐你使用 Android Docker 容器运行任务。 + +## 示例工程 + +- [web-app-bootstrap](//github.com/app-bootstrap/web-app-bootstrap) + +## 快速上手 + +### 第1步 - 创建任务 + +Create a new item named `web-app-bootstrap`, and select the `Freestyle project` mode. + +
+ +
+ +### 第2步 - SCM 配置 + +
+ +
+ +Please input the `web-app-bootstrap` git url, and set the clone depth to `1`, branch to `master` is ok. + +``` +https://github.com/app-bootstrap/web-app-bootstrap.git +``` + +### 第3步 - 构建脚本配置 + +**注意** + +- 请确保勾选构建前删除运行空间,以排除老的中间文件造成的问题。 + +
+ +
+ +我们提供 Web 构建 Docker 镜像 `macacajs/macaca-electron-docker`,你可以设置如下: + +``` +docker stop $JOB_NAME || true && docker rm $JOB_NAME || true + +docker run --rm \ + --name $JOB_NAME \ + -e JOB_NAME \ + -e BUILD_NUMBER \ + -e RELIABLE_SERVER_URL=http://129.168.1.102:9900 \ + -v $HOME/reliable_home/static:/static \ + -v $WORKSPACE:/root/src \ + macacajs/macaca-electron-docker \ + bash -c "cd /root/src && npm run ci" +``` + +**注意** + +- 请确认 `RELIABLE_SERVER_URL` 已经正确配置,可以是一个 IPV4 或者某个 url,否则会遇到如下问题: + +``` +error: TypeError: Cannot read property 'server' of undefined + at _.postToGW (/root/src/node_modules/reliable-cli/lib/helper.js:31:66) + at ReportCommand.pushToWebhook (/root/src/node_modules/reliable-cli/lib/report-command.js:130:18) + at ReportCommand._run (/root/src/node_modules/reliable-cli/lib/report-command.js:70:35) + at + at process._tickCallback (internal/process/next_tick.js:188:7) +npm ERR! code ELIFECYCLE +npm ERR! errno 1 +npm ERR! web-app-bootstrap@1.0.8 reliable: `reliable report -c ./reliable.config.js` +npm ERR! Exit status 1 +``` + +### 第4步 - 立即构建 + +构建结束后,你可以在 reliable web 平台获得构建结果。 + +
+ +
+ +We cat get the build results of the `web-app-bootstrap`. + +
+ +
+ +我们也可以获得项目配置,版本等额外信息。如果需要更多上报信息可以参考上报脚本文档 [reliable-cli#configuration](//github.com/macacajs/reliable-cli#configuration)。 + +### 第5步 - 自动化测试 + +Reliable 无缝集成 Macaca 自动化测试工具,支持通过率报告,端到端链路刻画,覆盖率等质量覆盖方案。 + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/docs/marmot-web-deploy.md b/docs/marmot-web-deploy.md new file mode 100644 index 0000000..ce63e60 --- /dev/null +++ b/docs/marmot-web-deploy.md @@ -0,0 +1,59 @@ +# Reliable Web Deploy + +--- + +## Docker Deploy + +### Using [docker-compose](https://docs.docker.com/compose/) (recommended) + +## production + +``` +# start services +$ docker-compose -p reliable -f docker-compose.yml up -d + +# NOTE: if you meet the problem, maybe the issue caused by the existed service, just run the stop command below. + +# stop services +$ docker-compose -p reliable -f docker-compose.yml down +``` + +execute `docker ps`, we can see: + +``` +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +8b2941c9774a macacajs/reliable-web "./entrypoint.sh npm…" 12 minutes ago Up 12 minutes (healthy) 0.0.0.0:9900->9900/tcp reliable_web_1 +b726a3232cdc macacajs/reliable-mysql "docker-entrypoint.s…" 12 minutes ago Up 12 minutes 3306/tcp reliable_mysql_1 +ffb2ab9f12fb macacajs/reliable-nginx "nginx -g 'daemon of…" 12 minutes ago Up 12 minutes 0.0.0.0:9920->80/tcp reliable_nginx_1 +``` + +go into the MySQL + +```bash +$ docker exec -it reliable_mysql_1 mysql -uroot -preliable +mysql> use reliable; +mysql> show tables; +mysql> select * from reliable.jobNames; +``` + +## development + +``` +# start services +$ docker-compose up + +# stop services +$ docker-compose down +``` + +Reliable server is running on `http://127.0.0.1:9900` by default. + +Nginx server is running on `http://127.0.0.1:9920` by default. + +should edit [docker-compose.yml](../docker-compose.yml) on demand. + +### Using [docker](https://docs.docker.com/) + +- [reliable-web](../docker/reliable-web/README.md) +- [reliable-mysql](../docker/reliable-mysql/README.md) +- [reliable-nginx](../docker/reliable-nginx/README.md) diff --git a/docs/marmot-web-deploy.zh-CN.md b/docs/marmot-web-deploy.zh-CN.md new file mode 100644 index 0000000..1617367 --- /dev/null +++ b/docs/marmot-web-deploy.zh-CN.md @@ -0,0 +1,59 @@ +# Reliable Web 部署文档 + +--- + +## Docker 部署 + +### 使用 [docker-compose](https://docs.docker.com/compose/) (推荐) + +## 生产环境 + +```bash +# start services +$ docker-compose -p reliable -f docker-compose.yml up -d + +# NOTE: if you meet the problem, maybe the issue caused by the existed service, just run the stop command below. + +# stop services +$ docker-compose -p reliable -f docker-compose.yml down +``` + +执行 `docker ps` 我们能够看到以下容器: + +``` +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +8b2941c9774a macacajs/reliable-web "./entrypoint.sh npm…" 12 minutes ago Up 12 minutes (healthy) 0.0.0.0:9900->9900/tcp reliable_web_1 +b726a3232cdc macacajs/reliable-mysql "docker-entrypoint.s…" 12 minutes ago Up 12 minutes 3306/tcp reliable_mysql_1 +ffb2ab9f12fb macacajs/reliable-nginx "nginx -g 'daemon of…" 12 minutes ago Up 12 minutes 0.0.0.0:9920->80/tcp reliable_nginx_1 +``` + +进入 MySQL + +```bash +$ docker exec -it reliable_mysql_1 mysql -uroot -preliable +mysql> use reliable; +mysql> show tables; +mysql> select * from reliable.jobNames; +``` + +## 开发环境 + +```bash +# start services +$ docker-compose up + +# stop services +$ docker-compose down +``` + +Reliable 服务默认运行在 `http://127.0.0.1:9900`。 + +Nginx 服务默认运行在 `http://127.0.0.1:9920`。 + +需要按需修改 [docker-compose.yml](../docker-compose.yml) 配置。 + +### 其他 [Docker](https://docs.docker.com/) 服务部署 + +- [reliable-web](../docker/reliable-web/README.md) +- [reliable-mysql](../docker/reliable-mysql/README.md) +- [reliable-nginx](../docker/reliable-nginx/README.md) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..5fc4448 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec "$@" diff --git a/package.json b/package.json new file mode 100644 index 0000000..f3f8799 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "reliable-web", + "version": "1.0.0", + "description": "Testing management suite with continuous delivery support.", + "private": true, + "dependencies": { + "dingtalk-robot-sender": "^1.1.1", + "egg": "^2.2.1", + "egg-scripts": "^2.5.0", + "egg-sequelize": "^4.2.0", + "egg-validate": "^1.0.0", + "lodash.get": "^4.4.2", + "moment": "^2.22.2", + "sequelize-cli": "^4.1.1" + }, + "devDependencies": { + "autod": "^3.0.1", + "autod-egg": "^1.0.0", + "concurrently": "4.0.1", + "cross-env": "^5.2.0", + "debug": "^3.1.0", + "egg-bin": "^4.3.5", + "egg-mock": "^3.14.0", + "eslint": "^4.11.0", + "eslint-config-egg": "^6.0.0", + "git-contributor": "^1.0.8", + "sinon": "^6.1.4", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=8.9.0" + }, + "scripts": { + "start": "cross-env NODE_ENV=production npm run db:migrate && eggctl start --port=9900 --title=egg-server-reliable-web", + "stop": "eggctl stop --title=egg-server-reliable-web", + "dev:server": "npm run db:migrate && cross-env DEBUG=reliable* egg-bin dev", + "dev:view": "cd view && npm run dev", + "dev": "concurrently \"npm run dev:view\" \"npm run dev:server\"", + "test": "npm run db:prepare:test && npm run lint && npm run test-local", + "test-local": "egg-bin test", + "test:debug": "cross-env DEBUG=reliable* egg-bin test", + "cov": "npm run db:prepare:test && egg-bin cov", + "lint": "eslint . --fix", + "ci": "npm run lint && npm run cov", + "autod": "autod", + "build:docker": "docker build --no-cache --pull -t macacajs/reliable-web .", + "db:prepare:test": "cross-env NODE_ENV=test npm run db:migrate:undo:all && cross-env NODE_ENV=test npm run db:migrate", + "db:status": "sequelize db:migrate:status", + "db:migrate": "sequelize db:migrate", + "db:migrate:undo": "sequelize db:migrate:undo", + "db:migrate:undo:all": "sequelize db:migrate:undo:all", + "db:seed": "sequelize db:seed", + "db:seed:all": "sequelize db:seed:all", + "db:seed:undo:all": "sequelize db:seed:undo:all", + "migration:generate": "sequelize migration:generate --name", + "seed:generate": "sequelize seed:generate --name" + }, + "repository": { + "type": "git", + "url": "https://github.com/macacajs/reliable.git" + }, + "license": "MIT" +} diff --git a/test/app/controller/app.test.js b/test/app/controller/app.test.js new file mode 100644 index 0000000..29d5c9e --- /dev/null +++ b/test/app/controller/app.test.js @@ -0,0 +1,232 @@ +'use strict'; + +const { + app, + assert, +} = require('egg-mock/bootstrap'); + +const { delay } = require('../../util'); +const postData = require('../../fixtures/post-gw.json'); + +async function insertData(customData = {}) { + return await app.httpRequest() + .post('/api/gw') + .send(Object.assign({}, postData, customData)); +} + +describe('test/app/controller/app.test.js', function() { + + let ctx; + beforeEach(() => { + ctx = app.mockContext(); + app.mockService('webhook', 'pushBuildNotification', {}); + }); + + it('GET /api/app/:appId with empty deploy result and change build extraInfo', async () => { + const appId = 'APP_ONE'; + await app.model.Config.create({ + data: {}, + }); + // await app.model.Credential.create({ + // provider: 'ALIYUN_OSS', + // bucketTag: 'dev', + // region: 'region', + // bucket: 'bucket', + // namespace: 'namespace', + // accessKeyId: 'accessKeyId', + // accessKeySecret: 'accessKeySecret', + // }); + await insertData({ + gitCommitInfo: { + gitBranch: 'feat/one', + gitUrl: 'http://domain/url/one', + }, + extraInfo: { + appId, + }, + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'jobName', + BUILD_NUMBER: '11', + }, + platform: 'web', + }, + }); + + await delay(1000); + + const { body: { data: { uniqId: buildUniqId } } } = await insertData({ + gitCommitInfo: { + gitBranch: 'feat/two', + gitUrl: 'http://domain/url/two', + }, + extraInfo: { + appId, + }, + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'jobName', + BUILD_NUMBER: '12', + }, + platform: 'web', + }, + }); + await ctx.model.JobName.bulkCreate([{ + jobName: 'android', + }, { + jobName: 'ios', + }]); + const { body: updateRes } = await app.httpRequest() + .put(`/api/build/${buildUniqId}`) + .send({ + extendInfo: { + key: 'value', + }, + }); + assert.deepStrictEqual(updateRes, { + success: true, + data: [ 1 ], + }); + const { body: queryResult } = await app.httpRequest() + .get(`/api/app/${appId}?bucketTag=dev&type=type1`); + assert(queryResult.success); + assert(queryResult.data.appId === appId); + assert(queryResult.data.gitRepo === 'http://domain/url/two'); + assert(queryResult.data.builds.length === 2); + assert(queryResult.data.builds[0].uniqId.length === 36); + assert(queryResult.data.builds[0].version === '1.0.0'); + assert(queryResult.data.builds[0].gitBranch === 'feat/two'); + assert(queryResult.data.builds[0].deploy === null); + assert(queryResult.data.builds[0].reliableDeployUrl === 'http://127.0.0.1/buildinfo?jobName=jobName&buildNumber=12'); + assert(queryResult.data.builds[0].state === 'SUCCESS'); + assert.deepStrictEqual(queryResult.data.builds[0].extendInfo, { + key: 'value', + }); + assert.deepStrictEqual(queryResult.data.builds[0].gitCommitInfo, { + gitUrl: 'http://domain/url/two', + gitBranch: 'feat/two', + }); + assert.deepStrictEqual(queryResult.data.builds[0].testInfo, { + tests: 16, + passes: 16, + linePercent: 95.24, + passPercent: 100, + testHtmlReporterPath: 'http://host/index.html', + coverageHtmlReporterPath: 'http://host/index.html', + }); + assert.deepStrictEqual(queryResult.data.builds[1].extendInfo, {}); + }); + + it('GET /api/app/:appId contains deploy result', async () => { + const appId = 'APP_ONE'; + app.mockService('deployAliyunOss', 'deploy', { + success: true, + message: '', + uploadResult: { + other: [{ + url: 'https://github.com/a.zip', + }], + }, + }); + await app.model.Config.create({ + data: {}, + }); + const { uniqId: credentialUniqId } = await app.model.Credential.create({ + provider: 'ALIYUN_OSS', + bucketTag: 'dev', + region: 'region', + bucket: 'bucket', + namespace: 'namespace', + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + }); + const { body: { data: { uniqId: buildUniqId } } } = await insertData({ + gitCommitInfo: { + gitBranch: 'feat/two', + gitUrl: 'http://domain/url/two', + }, + extraInfo: { + appId, + }, + }); + await app.httpRequest() + .post('/api/deploy') + .send({ + type: 'type1', + buildUniqId, + credentialSecret: 'accessKeySecret', + credentialUniqId, + }); + const { body: queryResult } = await app.httpRequest() + .get(`/api/app/${appId}?bucketTag=dev&type=type1`); + assert(queryResult.success); + assert(queryResult.data.appId === appId); + assert(queryResult.data.gitRepo === 'http://domain/url/two'); + assert(queryResult.data.builds.length === 1); + assert(queryResult.data.builds[0].state === 'SUCCESS'); + assert.deepStrictEqual(queryResult.data.builds[0].extendInfo, {}); + assert.deepStrictEqual(queryResult.data.builds[0].deploy, { + package: { + url: 'https://github.com/a.zip', + }, + }); + }); + + it('GET /api/app/:appId contains customDomain deploy result', async () => { + const appId = 'APP_ONE'; + app.mockService('deployAliyunOss', 'deploy', { + success: true, + message: '', + uploadResult: { + other: [{ + url: 'https://github.com/a.zip', + }], + }, + }); + await app.model.Config.create({ + data: {}, + }); + const { uniqId: credentialUniqId } = await app.model.Credential.create({ + provider: 'ALIYUN_OSS', + bucketTag: 'dev', + region: 'region', + bucket: 'bucket', + namespace: 'namespace', + accessKeyId: 'accessKeyId', + accessKeySecret: 'accessKeySecret', + customDomainProtocal: 'https://', + customDomain: 'github.io', + }); + const { body: { data: { uniqId: buildUniqId } } } = await insertData({ + gitCommitInfo: { + gitBranch: 'feat/two', + gitUrl: 'http://domain/url/two', + }, + extraInfo: { + appId, + }, + }); + await app.httpRequest() + .post('/api/deploy') + .send({ + type: 'type1', + buildUniqId, + credentialSecret: 'accessKeySecret', + credentialUniqId, + }); + const { body: queryResult } = await app.httpRequest() + .get(`/api/app/${appId}?bucketTag=dev&type=type1`); + assert(queryResult.success); + assert(queryResult.data.appId === appId); + assert(queryResult.data.gitRepo === 'http://domain/url/two'); + assert(queryResult.data.builds.length === 1); + assert.deepStrictEqual(queryResult.data.builds[0].extendInfo, {}); + assert.deepStrictEqual(queryResult.data.builds[0].deploy, { + package: { + url: 'https://github.io/a.zip', + }, + }); + }); +}); diff --git a/test/app/controller/build.test.js b/test/app/controller/build.test.js new file mode 100644 index 0000000..12ae499 --- /dev/null +++ b/test/app/controller/build.test.js @@ -0,0 +1,270 @@ +'use strict'; + +const { + app, + assert, +} = require('egg-mock/bootstrap'); + +const { delay } = require('../../util'); +const postData = require('../../fixtures/post-gw.json'); + +async function insertData(customData = {}) { + return await app.httpRequest() + .post('/api/gw') + .send(Object.assign({}, postData, customData)); +} + +describe('test/app/controller/build.test.js', function() { + + let ctx; + beforeEach(() => { + ctx = app.mockContext(); + app.mockService('webhook', 'pushBuildNotification', {}); + }); + + it('GET /api/build query all builds', async () => { + await ctx.model.Build.bulkCreate([{ + jobName: 'android', + buildNumber: '10', + data: {}, + }, { + jobName: 'ios', + buildNumber: '20', + data: {}, + }]); + await ctx.model.JobName.bulkCreate([{ + jobName: 'android', + }, { + jobName: 'ios', + }]); + const { body } = await app.httpRequest() + .get('/api/build'); + assert(body.success === true); + assert.deepStrictEqual(body.data.allJobName, [ 'android', 'ios' ]); + assert(body.data.total); + assert(body.data.page); + assert(body.data.result.length === 2); + assert(body.data.result[0].jobName); + assert(body.data.result[0].buildNumber); + assert(body.data.result[0].data); + assert(body.data.result[0].uniqId); + assert(body.data.result[0].createdAt); + assert(body.data.result[1].jobName); + assert(body.data.result[1].buildNumber); + assert(body.data.result[1].data); + assert(body.data.result[1].uniqId); + assert(body.data.result[1].createdAt); + }); + + it('GET /api/build/:uniqId query one build', async () => { + const [{ uniqId }] = await ctx.model.Build.bulkCreate([{ + jobName: 'android', + buildNumber: '10', + data: {}, + }, { + jobName: 'ios', + buildNumber: '20', + data: {}, + }]); + await ctx.model.JobName.bulkCreate([{ + jobName: 'android', + }, { + jobName: 'ios', + }]); + const { body } = await app.httpRequest() + .get(`/api/build/${uniqId}`); + assert(body.success === true); + assert(body.data.jobName === 'android'); + assert(body.data.buildNumber === '10'); + assert(body.data.data); + assert(body.data.uniqId === uniqId); + assert(body.data.createdAt); + }); + + it('GET /api/build/:jobName query by jobName', async () => { + await insertData({ + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'android_app', + BUILD_NUMBER: '1', + }, + platform: 'android', + os: { + nodeVersion: 'v1.1.2', + platform: 'linux', + }, + }, + }); + + await insertData({ + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'ios_app', + BUILD_NUMBER: '1', + }, + platform: 'ios', + os: { + nodeVersion: 'v1.1.2', + platform: 'linux', + }, + }, + }); + + const { body } = await app.httpRequest() + .get('/api/build?jobName=ios_app'); + assert.deepStrictEqual(body.data.allJobName, [ 'android_app', 'ios_app' ]); + assert(body.data.total); + assert(body.data.page); + assert(body.data.result.length === 1); + assert(body.data.result[0].jobName === 'ios_app'); + assert(body.data.result[0].buildNumber === '1'); + assert(body.data.result[0].data); + assert(body.data.result[0].uniqId); + assert(body.data.result[0].createdAt); + }); + + it('GET /api/build/:jobName/:buildNumber query by jobName and buildNumber', async () => { + await insertData({ + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'android_app', + BUILD_NUMBER: '1', + }, + platform: 'android', + os: { + nodeVersion: 'v1.1.2', + platform: 'linux', + }, + }, + }); + + await insertData({ + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'ios_app', + BUILD_NUMBER: '1', + }, + platform: 'ios', + os: { + nodeVersion: 'v1.1.2', + platform: 'linux', + }, + }, + }); + + const { body } = await app.httpRequest() + .get('/api/build?jobName=ios_app&buildNumber=1'); + assert(body.success === true); + assert(body.data.jobName === 'ios_app'); + assert(body.data.buildNumber === '1'); + assert(typeof body.data.data === 'object'); + assert(body.data.uniqId); + assert(body.data.createdAt); + }); + + it('GET /api/latestBuild/:jobName/:gitBranch+ query latest build', async () => { + await ctx.model.Build.bulkCreate([{ + jobName: 'ios_app', + gitBranch: 'master', + buildNumber: '10', + data: { + environment: { + os: { + nodeVersion: 'v1.1.2', + platform: 'linux', + }, + }, + }, + }, { + jobName: 'web_app', + gitBranch: 'master', + buildNumber: '20', + data: { + environment: { + os: { + nodeVersion: 'v1.1.2', + platform: 'linux', + }, + }, + }, + }]); + const { body } = await app.httpRequest() + .get('/api/latestBuild/web_app/master'); + assert(body.success === true); + assert(body.data.result[0].jobName === 'web_app'); + assert(body.data.result[0].buildNumber === '20'); + }); + + it('POST /api/build/:uniqId update build extendInfo', async () => { + const appId = 'APP_ONE'; + await app.model.Config.create({ + data: {}, + }); + // await app.model.Credential.create({ + // provider: 'ALIYUN_OSS', + // bucketTag: 'dev', + // region: 'region', + // bucket: 'bucket', + // namespace: 'namespace', + // accessKeyId: 'accessKeyId', + // accessKeySecret: 'accessKeySecret', + // }); + await insertData({ + gitCommitInfo: { + gitBranch: 'feat/one', + gitUrl: 'http://domain/url/one', + }, + extraInfo: { + // appId, + }, + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'jobName', + BUILD_NUMBER: '11', + }, + platform: 'web', + }, + }); + + await delay(1000); + + const { body: { data: { uniqId: buildUniqId } } } = await insertData({ + gitCommitInfo: { + gitBranch: 'feat/two', + gitUrl: 'http://domain/url/two', + }, + extraInfo: { + appId, + }, + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'jobName', + BUILD_NUMBER: '12', + }, + platform: 'web', + }, + }); + await ctx.model.JobName.bulkCreate([{ + jobName: 'android', + }, { + jobName: 'ios', + }]); + const { body: updateRes } = await app.httpRequest() + .put(`/api/build/${buildUniqId}`) + .send({ + extendInfo: { + key: 'value', + }, + }); + assert.deepStrictEqual(updateRes, { + success: true, + data: [ 1 ], + }); + }); +}); diff --git a/test/app/controller/config.test.js b/test/app/controller/config.test.js new file mode 100644 index 0000000..1373256 --- /dev/null +++ b/test/app/controller/config.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const { + app, + assert, +} = require('egg-mock/bootstrap'); + +describe('test/app/controller/config.test.js', () => { + + beforeEach(() => { + app.mockService('webhook', 'pushBuildNotification', {}); + }); + + async function assertConfig(app, data) { + const res = await app.httpRequest() + .get('/api/config'); + assert.deepStrictEqual(res.body, data); + } + + it('POST /api/config create and update', async () => { + await assertConfig(app, { + success: true, + data: {}, + }); + + let res = await app.httpRequest() + .post('/api/config') + .send({ + type: 'webhooks', + webhooks: [ 'url-1', 'url-2' ], + }); + assert.deepStrictEqual(res.body, { + success: true, + data: {}, + }); + await assertConfig(app, { + success: true, + data: { + type: 'webhooks', + webhooks: [ 'url-1', 'url-2' ], + }, + }); + + res = await app.httpRequest() + .post('/api/config') + .send({ + type: 'webhooks', + webhooks: [ 'url-3', 'url-4' ], + }); + assert.deepStrictEqual(res.body, { + success: true, + data: {}, + }); + await assertConfig(app, { + success: true, + data: { + type: 'webhooks', + webhooks: [ 'url-3', 'url-4' ], + }, + }); + }); +}); diff --git a/test/app/controller/gw.test.js b/test/app/controller/gw.test.js new file mode 100644 index 0000000..1d931ac --- /dev/null +++ b/test/app/controller/gw.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const { + app, + assert, +} = require('egg-mock/bootstrap'); + +const postData = require('../../fixtures/post-gw.json'); + +describe('test/app/controller/gw.test.js', () => { + + beforeEach(async () => { + app.mockService('webhook', 'pushBuildNotification', {}); + await app.model.Build.destroy({ + where: {}, + }); + await app.model.JobName.destroy({ + where: {}, + }); + }); + + it('POST /api/gw success', async () => { + const { header, body } = await app.httpRequest() + .post('/api/gw') + .send(postData); + assert(header['content-type'] === 'application/json; charset=utf-8'); + assert(body.success); + const data = body.data; + assert(data.uniqId); + assert.deepStrictEqual(data.data, postData); + assert(data.buildNumber === '11'); + assert(data.jobName === 'jobName'); + }); + + it('POST /api/gw with INIT state', async () => { + const { header, body } = await app.httpRequest() + .post('/api/gw') + .send(Object.assign({}, postData, { + testInfo: {}, + extraInfo: {}, + packages: [], + files: [], + })); + assert(header['content-type'] === 'application/json; charset=utf-8'); + assert(body.success); + const data = body.data; + assert(data.uniqId); + assert(data.buildNumber === '11'); + assert.deepStrictEqual(data.data.files, []); + assert(data.jobName === 'jobName'); + + const { header: updateHeader, body: updateBody } = await app.httpRequest() + .post('/api/gw') + .send(postData); + assert(updateHeader['content-type'] === 'application/json; charset=utf-8'); + assert.deepStrictEqual(updateBody, { + success: true, + data: [ 1 ], + }); + }); + + it('POST /api/gw error', async () => { + const { header, body } = await app.httpRequest() + .post('/api/gw') + .send(Object.assign({}, postData, { environment: {} })); + assert(header['content-type'] === 'application/json; charset=utf-8'); + assert(body.success === false); + assert(body.message === 'environment.ci.JOB_NAME and environment.ci.BUILD_NUMBER are required.'); + }); +}); diff --git a/test/app/controller/home.test.js b/test/app/controller/home.test.js new file mode 100644 index 0000000..184cc2d --- /dev/null +++ b/test/app/controller/home.test.js @@ -0,0 +1,18 @@ +'use strict'; + +const { app, assert } = require('egg-mock/bootstrap'); + +describe('test/app/controller/home.test.js', () => { + + it('assert keys', () => { + const pkg = require('../../../package.json'); + assert(app.config.keys.startsWith(pkg.name)); + }); + + it('GET /', () => { + return app.httpRequest() + .get('/') + .expect(/Reliable Platform/) + .expect(200); + }); +}); diff --git a/test/app/controller/insight.test.js b/test/app/controller/insight.test.js new file mode 100644 index 0000000..771bb3a --- /dev/null +++ b/test/app/controller/insight.test.js @@ -0,0 +1,98 @@ +'use strict'; + +const { + app, + assert, +} = require('egg-mock/bootstrap'); + +const postData = require('../../fixtures/post-gw.json'); + +async function insertData(customData = {}) { + return await app.httpRequest() + .post('/api/gw') + .send(Object.assign({}, postData, customData)); +} + +describe('test/app/controller/insight.test.js', function() { + + beforeEach(() => { + app.mockService('webhook', 'pushBuildNotification', {}); + }); + + it('GET /api/insight/ci query ci data', async () => { + await insertData({ + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'android_app', + BUILD_NUMBER: '1', + }, + platform: 'android', + os: { + nodeVersion: 'v1.1.2', + platform: 'linux', + }, + }, + }); + + await insertData({ + environment: { + ci: { + RUNNER_TYPE: 'GITLAB_CI', + JOB_NAME: 'ios_app', + BUILD_NUMBER: '1', + }, + platform: 'ios', + os: { + nodeVersion: 'v1.1.2', + platform: 'linux', + }, + }, + }); + + const { body } = await app.httpRequest() + .get('/api/insight/ci'); + assert.deepStrictEqual(body, { + success: true, + data: [{ + jobName: 'android_app', + linePercent: 95.24, + passPercent: 100, + lastCommit: { committer: 'user', shortHash: 'ecb4bac', commitUrl: 'http://host/group/repo/commit/ecb4bac' }, + humanizeDuration: 0, + linePercentList: [{ + commitUrl: 'http://host/group/repo/commit/ecb4bac', + shortHash: 'ecb4bac', + coverageUrl: 'http://host/index.html', + linePercent: 95.24, + createdAt: 'a few seconds ago', + }], + passPercentList: [{ + commitUrl: 'http://host/group/repo/commit/ecb4bac', + shortHash: 'ecb4bac', + reporterUrl: 'http://host/index.html', + passPercent: 100, + createdAt: 'a few seconds ago', + }], + durationList: [], + }, { + jobName: 'ios_app', + linePercent: 95.24, + passPercent: 100, + lastCommit: { + committer: 'user', + shortHash: 'ecb4bac', + commitUrl: 'http://host/group/repo/commit/ecb4bac', + }, + humanizeDuration: 0, + linePercentList: [{ + commitUrl: 'http://host/group/repo/commit/ecb4bac', shortHash: 'ecb4bac', coverageUrl: 'http://host/index.html', linePercent: 95.24, createdAt: 'a few seconds ago' }, + ], + passPercentList: [{ + commitUrl: 'http://host/group/repo/commit/ecb4bac', shortHash: 'ecb4bac', reporterUrl: 'http://host/index.html', passPercent: 100, createdAt: 'a few seconds ago', + }], + durationList: [], + }], + }); + }); +}); diff --git a/test/app/middleware/cors.test.js b/test/app/middleware/cors.test.js new file mode 100644 index 0000000..c7c4c4a --- /dev/null +++ b/test/app/middleware/cors.test.js @@ -0,0 +1,28 @@ +'use strict'; + +const { + app, + assert, +} = require('egg-mock/bootstrap'); + +describe('test/app/middleware/cors.test.js', function() { + + it('cors with origin', async () => { + const { header, body } = await app.httpRequest() + .get('/api/config') + .set('origin', 'http://example.com'); + assert(header['access-control-allow-origin'] === 'http://example.com'); + assert(header['access-control-allow-credentials'] === 'true'); + assert(body.success); + }); + + it('cors ignore OPTIONS request', async () => { + const { header } = await app.httpRequest() + .options('/api/config') + .set('origin', 'http://example.com') + .set('Access-Control-Request-Headers', 'x-custom-header'); + assert(header['access-control-allow-origin'] === 'http://example.com'); + assert(header['access-control-allow-credentials'] === 'true'); + assert(header['access-control-allow-headers'] === 'x-custom-header'); + }); +}); diff --git a/test/fixtures/config-data.json b/test/fixtures/config-data.json new file mode 100644 index 0000000..9fff414 --- /dev/null +++ b/test/fixtures/config-data.json @@ -0,0 +1,9 @@ +{ + "type": "webhooks", + "webhooks": [ + { + "tag": "build", + "url": "https://localhost" + } + ] +} diff --git a/test/fixtures/post-gw.json b/test/fixtures/post-gw.json new file mode 100644 index 0000000..60f0323 --- /dev/null +++ b/test/fixtures/post-gw.json @@ -0,0 +1,67 @@ +{ + "files": [ + "http://local/readmd.md" + ], + "packages": [ + { + "path": "http://host/1.zip", + "type": "type1", + "version": "1.0.0" + }, + { + "path": "http://host/2.zip", + "type": "type2", + "version": "1.0.0" + }, + { + "path": "http://host/3.zip", + "type": "type3", + "version": "1.0.0" + } + ], + "testInfo": { + "tests": 16, + "passes": 16, + "linePercent": 95.24, + "passPercent": 100, + "testHtmlReporterPath": "http://host/index.html", + "coverageHtmlReporterPath": "http://host/index.html" + }, + "extraInfo": { + }, + "environment": { + "os": { + "platform": "linux", + "nodeVersion": "v8.11.4" + }, + "ci": { + "RUNNER_TYPE": "GITLAB_CI", + "JOB_NAME": "jobName", + "BUILD_NUMBER": "11" + }, + "platform": "web" + }, + "gitCommitInfo": { + "body": "", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "author": { + "date": "1535946242", + "name": "user", + "email": "user@domain", + "relativeDate": "32 hours ago" + }, + "gitTag": "", + "gitUrl": "http://host/group/repo", + "subject": "git subject", + "committer": { + "date": "1536061959", + "name": "user", + "email": "user@domain", + "relativeDate": "5 minutes ago" + }, + "gitBranch": "feat/dev", + "gitRemote": "http://host/group/repo.git", + "shortHash": "ecb4bac", + "sanitizedSubject": "clean-up" + } +} diff --git a/test/util.js b/test/util.js new file mode 100644 index 0000000..0d77e89 --- /dev/null +++ b/test/util.js @@ -0,0 +1,5 @@ +'use strict'; + +exports.delay = ms => { + return new Promise(resolve => setTimeout(resolve, ms)); +}; diff --git a/view/.babelrc b/view/.babelrc new file mode 100644 index 0000000..79f0ada --- /dev/null +++ b/view/.babelrc @@ -0,0 +1,26 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-react" + ], + "plugins": [ + [ + "import", + { + "libraryName": "antd", + "libraryDirectory": "lib", + "style": "css" + } + ], + "@babel/plugin-transform-runtime", + "@babel/plugin-proposal-class-properties", + "@babel/plugin-proposal-export-default-from" + ], + "env": { + "test": { + "plugins": [ + "istanbul" + ] + } + } +} diff --git a/view/.browserslistrc b/view/.browserslistrc new file mode 100644 index 0000000..0079123 --- /dev/null +++ b/view/.browserslistrc @@ -0,0 +1,8 @@ +> 1% +last 2 versions +Firefox ESR +not ie 10 +not ie_mob 10 + +[development] +last 1 chrome version diff --git a/view/.eslintignore b/view/.eslintignore new file mode 100644 index 0000000..cb32de6 --- /dev/null +++ b/view/.eslintignore @@ -0,0 +1,7 @@ +**/.* +**/node_modules +**/dist +**/assets +**/coverage +**/reports + diff --git a/view/.eslintrc b/view/.eslintrc new file mode 100644 index 0000000..3a6c019 --- /dev/null +++ b/view/.eslintrc @@ -0,0 +1,17 @@ +{ + "extends": [ + "eslint-config-antife" + ], + "plugins": [ + "react" + ], + "rules": { + "max-len": ["error", { + "code": 300 + }], + "semi": [ "error", "always" ], + "no-unused-vars": "error", + "react/jsx-uses-vars": "error", + "react/jsx-uses-react": "error" + } +} diff --git a/view/.gitignore b/view/.gitignore new file mode 100644 index 0000000..c6218cd --- /dev/null +++ b/view/.gitignore @@ -0,0 +1,4 @@ +screenshots +reports +logs +coverage diff --git a/view/.postcssrc.js b/view/.postcssrc.js new file mode 100644 index 0000000..90d9fff --- /dev/null +++ b/view/.postcssrc.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + autoprefixer: {}, + }, +} diff --git a/view/.stylelintrc.js b/view/.stylelintrc.js new file mode 100644 index 0000000..1aed7b4 --- /dev/null +++ b/view/.stylelintrc.js @@ -0,0 +1,65 @@ +'use strict'; + +module.exports = { + rules: { + 'at-rule-semicolon-newline-after': 'always', + 'block-no-empty': true, + 'block-opening-brace-newline-after': 'always', + 'block-closing-brace-newline-before': 'always', + 'block-opening-brace-space-before': 'always', + 'color-hex-length': 'short', + 'color-no-invalid-hex': true, + 'declaration-block-no-duplicate-properties': [true, { ignore: ['consecutive-duplicates'] }], + 'declaration-block-no-shorthand-property-overrides': true, + 'declaration-block-trailing-semicolon': 'always', + 'function-url-quotes': 'never', + indentation: 2, + 'max-empty-lines': 2, + 'max-line-length': 200, + 'max-nesting-depth': 5, + 'no-duplicate-selectors': true, + 'no-eol-whitespace': true, + 'no-missing-end-of-source-newline': true, + 'no-unknown-animations': true, + 'length-zero-no-unit': true, + 'rule-empty-line-before': ['always-multi-line', { ignore: ['after-comment', 'inside-block'] }], + 'string-no-newline': true, + 'time-min-milliseconds': 100, + 'unit-no-unknown': true, + + 'function-linear-gradient-no-nonstandard-direction': true, + 'property-no-vendor-prefix': null, + 'selector-no-vendor-prefix': null, + 'value-no-vendor-prefix': true, + + 'comment-whitespace-inside': 'always', + 'declaration-bang-space-after': 'never', + 'declaration-bang-space-before': 'always', + 'declaration-colon-space-after': 'always', + 'declaration-colon-space-before': 'never', + 'function-comma-space-after': 'always', + 'function-comma-space-before': 'never', + 'function-calc-no-unspaced-operator': true, + 'function-whitespace-after': 'always', + 'media-feature-colon-space-after': 'always', + 'media-feature-colon-space-before': 'never', + 'media-feature-parentheses-space-inside': 'never', + 'media-feature-range-operator-space-after': 'always', + 'media-feature-range-operator-space-before': 'always', + 'selector-combinator-space-after': 'always', + 'selector-combinator-space-before': 'always', + 'selector-list-comma-space-after': 'always-single-line', + 'selector-list-comma-space-before': 'never', + 'value-list-comma-space-after': 'always', + 'value-list-comma-space-before': 'never', + + 'at-rule-name-case': 'lower', + 'function-name-case': 'lower', + 'property-case': 'lower', + 'selector-pseudo-class-case': 'lower', + 'selector-pseudo-element-case': 'lower', + 'selector-type-case': 'lower', + 'unit-case': 'lower', + }, +} + diff --git a/view/index.html b/view/index.html new file mode 100644 index 0000000..8df44e0 --- /dev/null +++ b/view/index.html @@ -0,0 +1,36 @@ + + + + + <!-- title --> + + + +
+ + + +
+ + diff --git a/view/lib/render.js b/view/lib/render.js new file mode 100644 index 0000000..35e9e00 --- /dev/null +++ b/view/lib/render.js @@ -0,0 +1,22 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const templatePath = path.join(__dirname, '..', 'index.html'); +const template = fs.readFileSync(templatePath, 'utf8'); + +module.exports = async (context, options = {}) => { + const content = template.replace(//, () => { + return ` + + `; + }).replace(//, () => { + return `${options.title}`; + }); + return content; +}; + diff --git a/view/package.json b/view/package.json new file mode 100644 index 0000000..f7728ff --- /dev/null +++ b/view/package.json @@ -0,0 +1,87 @@ +{ + "name": "reliable-view", + "version": "", + "description": "view layer for Reliable", + "files": [ + "dist/*.js", + "dist/*.css", + "lib/*.js", + "index.html" + ], + "links": { + "issues": "https://github.com/macacajs/reliable/issues?utf8=%E2%9C%93&q=", + "document": "https://github.com/macacajs/reliable/tree/master/docs" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development webpack-dev-server --mode development", + "dev:test": "cross-env NODE_ENV=test webpack-dev-server --mode development", + "serve": "npm run dev:test &", + "test": "macaca run -d ./test", + "lint": "eslint --fix . --ext jsx,js && stylelint --fix assets/**/*.less -s less", + "build": "cross-env NODE_ENV=production webpack -p --mode production", + "build:report": "npm run build --report", + "prepublishOnly": "npm run build", + "ci": "npm run lint && npm run build && npm run serve && npm test", + "contributor": "git-contributor" + }, + "precommit": [ + "lint" + ], + "devDependencies": { + "@babel/core": "^7.0.0", + "@babel/plugin-proposal-class-properties": "^7.0.0", + "@babel/plugin-proposal-export-default-from": "^7.0.0", + "@babel/plugin-syntax-dynamic-import": "^7.0.0", + "@babel/plugin-transform-runtime": "^7.0.0", + "@babel/preset-env": "^7.0.0", + "@babel/preset-react": "^7.0.0", + "@babel/register": "^7.0.0", + "@babel/runtime": "^7.0.0", + "antd": "^3.6.1", + "autoprefixer": "^9.1.0", + "awesome-clipboard": "^2.0.2", + "babel-loader": "^8.0.0", + "babel-plugin-import": "^1.2.1", + "babel-plugin-istanbul": "^5.0.1", + "cross-env": "^5.1.1", + "css-loader": "^0.28.11", + "eslint": "^4.5.0", + "eslint-config-antife": "^1.0.2", + "eslint-plugin-mocha": "^5.0.0", + "eslint-plugin-react": "^7.2.1", + "git-contributor": "^1.0.8", + "less": "^2.7.2", + "less-loader": "^4.1.0", + "lodash.get": "^4.4.2", + "lodash.uniqby": "^4.7.0", + "macaca-cli": "2", + "macaca-electron": "2", + "macaca-wd": "2", + "mini-css-extract-plugin": "^0.4.0", + "postcss-loader": "^2.1.6", + "pre-commit": "^1.2.2", + "qrcode-react": "^0.1.16", + "query-string": "^6.2.0", + "react": "^16.6.0", + "react-code-input": "^3.8.0", + "react-dom": "^16.6.0", + "react-github-button": "0.1.11", + "react-intl": "^2.4.0", + "react-load-script": "^0.0.6", + "react-router-dom": "^4.2.2", + "style-loader": "^0.21.0", + "stylelint": "^9.3.0", + "url-loader": "^1.1.2", + "url-parse": "^1.4.3", + "webpack": "^4.10.0", + "webpack-bundle-analyzer": "^2.13.1", + "webpack-cli": "^3.0.1", + "webpack-dev-server": "^3.1.4", + "whatwg-fetch": "^2.0.3", + "xutil": "1" + }, + "dependencies": { + "dayjs": "^1.7.7" + }, + "licenses": "MIT" +} diff --git a/view/postcss.config.js b/view/postcss.config.js new file mode 100644 index 0000000..ccd5722 --- /dev/null +++ b/view/postcss.config.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = { + plugins: [ + require('autoprefixer'), + ], +}; + diff --git a/view/src/components/BuildsTable.js b/view/src/components/BuildsTable.js new file mode 100644 index 0000000..cf8133a --- /dev/null +++ b/view/src/components/BuildsTable.js @@ -0,0 +1,157 @@ +'use strict'; + +import React from 'react'; +import dayjs from 'dayjs'; +import { Link } from 'react-router-dom'; +import Clipboard from 'awesome-clipboard'; +import { + Icon, + Table, + Popover, + message, +} from 'antd'; +import { FormattedMessage } from 'react-intl'; + +import { + getUuid, + mapBuildDataToColor, +} from '../util/index'; + +import './buildsTable.less'; + +export default class BuildsTable extends React.Component { + handleTableChange = (pagination, filters, sorter) => { + const pager = { ...this.props.pagination }; + pager.current = pagination.current; + this.props.updatePagination(pager); + } + columns = [{ + title: , + dataIndex: 'jobName', + render: (text, record) => + + {text} + , + }, { + title: , + width: 100, + render: (text, record) => + record.buildLogUrl + ? + + + : + + , + }, { + title: , + dataIndex: 'buildNumber', + width: 160, + render: (value, record) => ( + + + + + + {value} + + + ), + }, { + title: , + dataIndex: 'platform', + }, { + title: , + dataIndex: 'buildEndTime', + render: (text, record) => ( + + {dayjs(text).format('YYYY-MM-DD HH:mm:ss')} + +

uniqId: {record.buildUniqId}

+ + } trigger="hover" placement="top"> + { + Clipboard.write(record.buildUniqId).then(res => { + res && message.success('UniqId copied to clipboard.'); + }); + }} className="builds-table-uniqId-tip" + type="copy" theme="filled" style={{ color: '#2593fc' }} + /> +
+
+ ), + }, { + title: , + dataIndex: 'gitBranch', + width: 240, + render: (text, record) => + + {record.gitCommitInfo.gitBranch} + , + }, { + title: , + dataIndex: 'committer', + width: 120, + render: (text, record) => + + {record.gitCommitInfo.committer.name} + , + }, { + title: , + dataIndex: 'state', + width: 90, + render: (text, record) => { + return record.state === 'INIT' ? 'init' : 'done'; + }, + }, { + title: , + align: 'center', + width: 80, + render: (value, record) => { + if (record.state === 'INIT') return; + return ( + record.buildNumber + ? + + + : '' + ); + }, + } +]; + + render() { + return ( +
+ record.buildNumber + getUuid()} + rowClassName={record => mapBuildDataToColor(record)} + dataSource={this.props.data} + loading={this.props.loading} + pagination={this.props.pagination} + onChange={this.handleTableChange} + /> + + ); + } +} diff --git a/view/src/components/BuildsTabs.js b/view/src/components/BuildsTabs.js new file mode 100644 index 0000000..e816d5a --- /dev/null +++ b/view/src/components/BuildsTabs.js @@ -0,0 +1,164 @@ +'use strict'; + +import React from 'react'; +import { Tabs } from 'antd'; +import { withRouter } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; +import safeGet from 'lodash.get'; + +import request from '../util/request'; +import BuildsTable from './BuildsTable'; +import { + PAGE_SIZE, +} from '../constants/index'; +import { logger, queryParse } from '../util/index'; + +const TabPane = Tabs.TabPane; + +class BuildsTabs extends React.Component { + state = { + data: [], + loading: false, + jobName: queryParse(location.search).jobName || '', + allJobName: [], + pagination: { + pageSize: PAGE_SIZE, + }, + }; + + getBuildsInfo (res) { + const result = []; + res.forEach(item => { + // common fields + const jobName = item.jobName; + const buildNumber = item.buildNumber; + const platform = item.data.environment.platform; + const buildEndTime = item.createdAt; + const testInfo = item.data.testInfo; + const gitCommitInfo = item.data.gitCommitInfo; + const buildUniqId = item.uniqId; + const hasDeployed = Array.isArray(item.deploys) && + item.deploys.some(deploy => deploy.state === 'SUCCESS'); + const state = item.state; + + const formattedItem = { + buildNumber, + jobName, + platform, + testInfo, + buildEndTime, + gitCommitInfo, + buildUniqId, + hasDeployed, + state, + }; + + const buildData = item.data; + const runnerType = safeGet(buildData, 'environment.ci.RUNNER_TYPE'); + // TODO remove + const isGitlab = buildData.environment && buildData.environment.gitlab_ci; + if (runnerType === 'GITLAB_CI' || isGitlab) { + Object.assign(formattedItem, { + buildUrl: `${buildData.gitCommitInfo.gitUrl}/builds/${buildNumber}`, + configureUrl: buildData.gitCommitInfo.gitUrl, + buildLogUrl: `${buildData.gitCommitInfo.gitUrl}/builds/${buildNumber}`, + }); + } + result.push(formattedItem); + }); + return result; + } + + updatePagination = (pagination, tab) => { + const pager = { ...this.state.pagination }; + Object.assign(pager, pagination); + this.setState({ + pagination: pager, + }); + this.fetch(pagination, tab); + } + + fetch = (pager, tab) => { + const param = { + num: this.state.pagination.pageSize, + page: pager && pager.current || 1, + jobName: tab, + }; + logger('request getBuildsTable', param); + + this.setState({ loading: true }); + + request('getBuildsTable', 'GET', param).then((res) => { + if (res.success && res.data && res.data.result) { + logger('getBuildsTable res', res); + const data = this.getBuildsInfo(res.data.result); + + // Read total count from server + const pagination = { ...this.state.pagination }; + pagination.total = res.data.total; + + this.setState({ + loading: false, + data, + pagination, + allJobName: res.data && res.data.allJobName || [], + }); + } else { + console.error(res); + } + }); + } + + handleTabClick = tab => { + const pagination = { + pageSize: PAGE_SIZE, + current: 1, + }; + const path = tab ? `./?jobName=${tab}` : './'; + + this.setState({ + jobName: tab, + pagination, + }); + this.props.history.push(path); + this.updatePagination(pagination, tab); + } + + componentDidMount () { + const tab = queryParse(location.search).jobName; + this.setState({ + jobName: tab, + }); + this.fetch({ ...this.state.pagination }, tab); + } + + render () { + const allJobName = this.state.allJobName; + const listItems = allJobName && allJobName.map(item => + + ); + return ( +
+ { listItems } + + } key=""> + + { listItems } + + +
+ ); + } +} + +export default withRouter(BuildsTabs); diff --git a/view/src/components/ChartCard.js b/view/src/components/ChartCard.js new file mode 100644 index 0000000..d55e562 --- /dev/null +++ b/view/src/components/ChartCard.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { Card, Spin } from 'antd'; + +import './chartCard.less'; + +class ChartCard extends React.Component { + render () { + return ( + + { + +

{this.props.title}

+
{this.props.content}
+
+ } +
+ ); + } +} + +export default ChartCard; diff --git a/view/src/components/DingdingSetting.js b/view/src/components/DingdingSetting.js new file mode 100644 index 0000000..a098b4e --- /dev/null +++ b/view/src/components/DingdingSetting.js @@ -0,0 +1,180 @@ +'use strict'; + +import React from 'react'; +import safeGet from 'lodash.get'; +import uniqBy from 'lodash.uniqby'; +import { FormattedMessage } from 'react-intl'; +import { + Form, + Icon, + Spin, + Input, + Button, + Select, + message, +} from 'antd'; + +import request from '../util/request'; + +const FormItem = Form.Item; +const Option = Select.Option; + +const webhookFormItemLayout = { + wrapperCol: { + xs: { span: 24 }, + sm: { offset: 4, span: 16 }, + }, +}; +const buttonFormItemLayout = { + wrapperCol: { + xs: { span: 24 }, + sm: { offset: 10, span: 4 }, + }, +}; + +class DingdingSetting extends React.Component { + state = { + webhooks: [], + loading: false, + } + + componentDidMount () { + this.fetchWebhooks(); + } + + fetchWebhooks = async () => { + this.setState({ loading: true }); + const res = await request('getWebhooks', 'GET'); + this.setState({ loading: false }); + if (!res.success) return; + + const webhooks = safeGet(res, 'data.webhooks'); + if (!Array.isArray(webhooks) || !webhooks.length) { + this.setState({ + webhooks: [{ + url: '', + }], + }); + return; + } + + this.setState({ + webhooks, + }); + } + + postWebhooks = async (webhooks) => { + const res = await request('postWebhooks', 'POST', { + type: 'webhooks', + webhooks, + }); + if (res.success) { + await this.fetchWebhooks(); + message.success('Update webhooks successfully!'); + } else { + message.error('Update webhooks failed.'); + console.error('postWebhooks', res); + } + } + + removeOneNotification = index => { + const webhooks = [ + ...this.state.webhooks, + ]; + webhooks.splice(index, 1); + this.setState({ webhooks }); + } + + addMoreNotification = () => { + this.setState({ + webhooks: [ + ...this.state.webhooks, + { + url: '', + }, + ], + }); + } + + updateWebhooks = e => { + e.preventDefault(); + this.props.form.validateFields((err, values) => { + if (err) return; + const uniqWebhooks = uniqBy(values.webhooks, value => `${value.tag}${value.url}`); + this.postWebhooks(uniqWebhooks); + }); + } + + renderWebhookList = () => { + const { getFieldDecorator } = this.props.form; + return this.state.webhooks.map((webhook, index) => { + const notifyTypeSelector = getFieldDecorator(`webhooks[${index}].tag`, { + initialValue: webhook.tag || 'build', + })( + + ); + return ( + {getFieldDecorator(`webhooks[${index}].url`, { + validateTrigger: ['onBlur'], + initialValue: webhook.url, + rules: [{ + required: true, + type: 'url', + whitespace: true, + message: 'Please input DingTalk webhook url.', + }], + })( + this.removeOneNotification(index)} + /> + ) : null + } + /> + )} + ); + }); + } + + render () { + return ( + + + {this.renderWebhookList()} + + + + + + + + + ); + } +} + +export default Form.create()(DingdingSetting); + diff --git a/view/src/components/ExtraTable.js b/view/src/components/ExtraTable.js new file mode 100644 index 0000000..5999c54 --- /dev/null +++ b/view/src/components/ExtraTable.js @@ -0,0 +1,37 @@ +'use strict'; + +import React from 'react'; +import { Table } from 'antd'; +import { FormattedMessage } from 'react-intl'; + +import { getUuid } from '../util/index'; + +const columns = [{ + title: , + dataIndex: 'extraName', + width: 200, + render: value => {value}, +}, { + title: , + dataIndex: 'extraContent', + render: value =>
{JSON.stringify(value, null, 2)}
, +}]; + +export default class ExtraTable extends React.Component { + state = { + loading: false, + }; + + render () { + return ( +
getUuid()} + dataSource={this.props.data} + loading={this.state.loading} + onChange={this.handleTableChange} + size="small" + bordered + /> + ); + } +} diff --git a/view/src/components/FileTable.js b/view/src/components/FileTable.js new file mode 100644 index 0000000..4b9eece --- /dev/null +++ b/view/src/components/FileTable.js @@ -0,0 +1,25 @@ +'use strict'; + +import React from 'react'; +import { List } from 'antd'; + +export default class DepTable extends React.Component { + state = { + loading: false, + }; + + render () { + return ( + ( + + {item.fileName} + + )} + /> + ); + } +} diff --git a/view/src/components/Header.js b/view/src/components/Header.js new file mode 100644 index 0000000..3a7b206 --- /dev/null +++ b/view/src/components/Header.js @@ -0,0 +1,130 @@ +'use strict'; + +import React from 'react'; +import { + Icon, + Menu, + Avatar, + Layout, + Tooltip, + Dropdown, +} from 'antd'; +import safeGet from 'lodash.get'; +import { FormattedMessage } from 'react-intl'; +import GitHubButton from 'react-github-button'; + +import 'react-github-button/assets/style.css'; + +import './header.less'; + +import { LANG_LIST as langList } from '../constants/index'; + +const nickName = safeGet(window, 'context.user.nick'); +const Header = Layout.Header; + +const pkg = require('../../package.json'); + +function ContentHeader (props) { + const toggle = () => { + props.toggleCollapsed(!props.collapsed); + }; + + const changeLang = key => { + localStorage.RELIABLE_LANGUAGE = key; + location.href = `/?locale=${key}`; + }; + + const currentLocale = localStorage.RELIABLE_LANGUAGE || ''; + const menu = ( + + { + langList + .filter(lang => !currentLocale || lang !== currentLocale) + .map(lang => { + return ( + { + changeLang(key); + }}> + {lang} + + ); + }) + } + + ); + return ( +
+ +
+ + + + }> + + + + + }> + + + + + + + + + + { + nickName && + + + + { nickName } + + + + { + location.href = '/snsAuthorize/signout'; + }}> + + + {'Sign out'} + + + + } placement="topCenter"> + + + { nickName.substr(0, 1) } + + + + } +
+
+ ); +} + +export default ContentHeader; diff --git a/view/src/components/OneBuildTabs.js b/view/src/components/OneBuildTabs.js new file mode 100644 index 0000000..5427238 --- /dev/null +++ b/view/src/components/OneBuildTabs.js @@ -0,0 +1,133 @@ +'use strict'; + +import React from 'react'; +import dayjs from 'dayjs'; +import safeGet from 'lodash.get'; +import { Spin, Tabs } from 'antd'; +import { FormattedMessage } from 'react-intl'; + +import PkgTable from './PkgTable'; +import TestTable from './TestTable'; +import ExtraTable from './ExtraTable'; +import FileTable from './FileTable'; + +import { getBuildLink } from '../util'; + +const TabPane = Tabs.TabPane; + +export default class OneBuildTabs extends React.Component { + getExtraInfo () { + const data = this.props.data; + const result = []; + if (data && data.extraInfo) { + Object.keys(data.extraInfo).forEach(item => { + result.push({ + extraName: item, + extraContent: data.extraInfo[item], + }); + }); + } + return result; + } + + getPkgInfo () { + const data = this.props.data; + const result = { + packages: [], + platform: safeGet(data, 'environment.platform'), + }; + if (data && data.packages && data.packages.length && data.gitCommitInfo) { + data.packages.forEach(item => { + const commitTime = data.gitCommitInfo.committer.date * 1000; + data.gitCommitInfo.commitTime = dayjs(commitTime).format('YYYY-MM-DD HH:mm:ss'); + data.gitCommitInfo.gitHref = `${data.gitCommitInfo.gitUrl}/commit/${data.gitCommitInfo.hash}`; + result.packages.push({ + ...item, + download: getBuildLink(this.props.data, item.path), + gitCommitInfo: data.gitCommitInfo, + buildUniqId: data.buildUniqId, + }); + }); + } + return result; + } + + getTestInfo () { + const data = this.props.data; + const result = []; + if (data && data.testInfo && data.testInfo.tests) { + const report = data.testInfo.testHtmlReporterPath; + const coverage = data.testInfo.coverageHtmlReporterPath; + const commitTime = data.gitCommitInfo.committer.date * 1000; + result.push({ + lineCoverage: data.testInfo.linePercent, + passingRate: data.testInfo.passPercent, + testInfo: data.testInfo, + testReporter: getBuildLink(this.props.data, report), + coverageReporter: getBuildLink(this.props.data, coverage), + gitBranch: data.gitCommitInfo.gitBranch, + gitCommit: data.gitCommitInfo.shortHash, + gitHref: `${data.gitCommitInfo.gitUrl}/commit/${data.gitCommitInfo.hash}`, + committer: data.gitCommitInfo.committer.name, + committerEmail: data.gitCommitInfo.committer.email, + commitTime: dayjs(commitTime).format('YYYY-MM-DD HH:mm:ss'), + }); + } + return result; + } + + getFileInfo () { + const data = this.props.data; + const result = []; + if (data && data.files && data.files.length) { + data.files.forEach(item => { + result.push({ + fileName: item, + fileAddress: getBuildLink(this.props.data, item), + }); + }); + } + return result; + } + + onTabChange (type) { + location.hash = `type=${type}`; + } + + render () { + if (this.props.loading) { + return ; + } + const getPkgInfo = this.getPkgInfo(); + const activeTabKey = location.hash.replace('#type=', ''); + const fallBackActiveTabKey = getPkgInfo.packages.length ? 'pkginfo' : 'test'; + return ( +
+ + } key="pkginfo"> + + + } key="test"> + + + } key="extrainfo"> + + + } key="fileinfo"> + + + +
+ ); + } +} + diff --git a/view/src/components/PkgTable.js b/view/src/components/PkgTable.js new file mode 100644 index 0000000..795f861 --- /dev/null +++ b/view/src/components/PkgTable.js @@ -0,0 +1,182 @@ +'use strict'; + +import React from 'react'; +import QRCode from 'qrcode-react'; +import { FormattedMessage } from 'react-intl'; + +import { + Table, + Icon, + Modal, + Popover, +} from 'antd'; + +import safeGet from 'lodash.get'; + +import { + getUuid, +} from '../util/index'; +import logos from './logos'; + +import './pkgTable.less'; + +const getLogo = type => { + return logos[type] || logos['web']; +}; + +export default class PkgTable extends React.Component { + state = { + loading: false, + record: {}, + visible: false, + currentPackage: {}, + }; + + getColumns = () => { + let columns = [{ + title: , + dataIndex: 'version', + width: 100, + }, { + title: , + dataIndex: 'type', + width: 160, + }]; + columns = columns.concat([{ + title: , + dataIndex: 'download', + width: 160, + render: (value, record) => ( + + + + + + + ), + }]); + columns = columns.concat([{ + title: , + dataIndex: 'gitBranch', + width: 300, + render: (value, record) => { + return ( + { record.gitCommitInfo.gitBranch } + ); + }, + }, { + title: , + width: 240, + dataIndex: 'gitCommit', + render: (value, record) => { + return ( + +
  • + committer name: { safeGet(record, 'gitCommitInfo.committer.name') } +
  • +
  • + committer email: { safeGet(record, 'gitCommitInfo.committer.email') } +
  • +
  • + author name: { safeGet(record, 'gitCommitInfo.author.name') } +
  • +
  • + author email: { safeGet(record, 'gitCommitInfo.author.email') } +
  • +
  • + gitTag: { safeGet(record, 'gitCommitInfo.gitTag') || 'null' } +
  • +
  • + subject: { safeGet(record, 'gitCommitInfo.subject') } +
  • + + } trigger="hover" placement="left"> + + {record.gitCommitInfo.shortHash} + {record.gitCommitInfo.committer.name} +
    + ); + }, + }] + ); + return columns; + } + showQrCodeModal = (record) => { + this.setState({ + record, + visible: true, + }); + } + + handleQrCodeOk = (e) => { + this.setState({ + visible: false, + }); + } + + handleQrCodeCancel = (e) => { + this.setState({ + visible: false, + }); + } + + getDownloadUrl = () => { + if (this.state.record && + this.state.record.download && + this.state.record.download.startsWith('http')) { + return this.state.record.download; + } + return `${location.protocol}${this.state.record.download}`; + } + + render () { + return ( +
    +
    getUuid()} + dataSource={this.props.data} + loading={this.state.loading} + onChange={this.handleTableChange} + /> + + +
    + { + `${this.state.record.version} | + ${this.state.record.type} | + ${safeGet(this.state.record, 'gitCommitInfo.commitTime')} | + ${safeGet(this.state.record, 'gitCommitInfo.shortHash')} | + ${safeGet(this.state.record, 'gitCommitInfo.gitBranch')} | + ${safeGet(this.state.record, 'gitCommitInfo.committer.name')}` + } +
    +
    + + ); + } +} diff --git a/view/src/components/ReliableLayout.js b/view/src/components/ReliableLayout.js new file mode 100644 index 0000000..7592665 --- /dev/null +++ b/view/src/components/ReliableLayout.js @@ -0,0 +1,40 @@ +'use strict'; + +import React from 'react'; +import { Layout } from 'antd'; + +import Header from './Header'; +import SiderBar from './SiderBar'; + +const Content = Layout.Content; + +export default class ReliableLayout extends React.Component { + state = { + collapsed: localStorage.RELIABLE_SIDERBAR_COLLAPSED === 'true', + }; + + toggleCollapsed = (value) => { + localStorage.RELIABLE_SIDERBAR_COLLAPSED = value; + this.setState({ + collapsed: value, + }); + }; + + render () { + return ( + + { !this.props.hideMenu && } + +
    + + { this.props.children } + + + + ); + } +} + diff --git a/view/src/components/SiderBar.js b/view/src/components/SiderBar.js new file mode 100644 index 0000000..08cbe0d --- /dev/null +++ b/view/src/components/SiderBar.js @@ -0,0 +1,68 @@ +'use strict'; + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Layout, Menu, Icon } from 'antd'; +import { FormattedMessage } from 'react-intl'; + +const Sider = Layout.Sider; + +export default class SiderBar extends React.Component { + handleMenuClick (e) { + this.setState({ + currentPath: e.key, + }); + } + + render () { + return ( + +
    + + logo +

    Reliable

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + ); + } +} + diff --git a/view/src/components/TestTable.js b/view/src/components/TestTable.js new file mode 100644 index 0000000..9d50c7d --- /dev/null +++ b/view/src/components/TestTable.js @@ -0,0 +1,75 @@ +'use strict'; + +import React from 'react'; +import { Table } from 'antd'; +import { FormattedMessage } from 'react-intl'; + +import { getUuid } from '../util/index'; + +const columns = [{ + title: , + dataIndex: 'lineCoverage', + render: value => {value ? `${value}%` : ''}, + width: 100, +}, { + title: , + dataIndex: 'passingRate', + render: (text, record) => + + { + record.testInfo.passPercent + ? + + {record.testInfo.passPercent}% +   + {record.testInfo.passes}/{record.testInfo.tests} + + : null + } + , + width: 140, +}, { + title: , + dataIndex: 'testReporter', + render: value => value ? : '', +}, { + title: , + dataIndex: 'coverageReporter', + render: value => value ? : '', +}, { + title: , + dataIndex: 'gitBranch', + width: 240, +}, { + title: , + dataIndex: 'gitCommit', + render: (value, record) => ( + + {value} + + ), +}, { + title: , + dataIndex: 'committer', +}, { + title: , + dataIndex: 'commitTime', +}]; + +export default class TesTable extends React.Component { + state = { + data: [], + loading: false, + }; + + render () { + return ( +
    record.testId + getUuid()} + dataSource={this.props.data} + loading={this.state.loading} + onChange={this.handleTableChange} + /> + ); + } +} diff --git a/view/src/components/buildsTable.less b/view/src/components/buildsTable.less new file mode 100644 index 0000000..25ebc67 --- /dev/null +++ b/view/src/components/buildsTable.less @@ -0,0 +1,13 @@ +.builds-table { + td:last-child { + position: relative; + } + .builds-table-uniqId-tip { + padding: 0 4px; + opacity: 0; + } + tr:hover .builds-table-uniqId-tip { + cursor: pointer; + opacity: 1; + } +} \ No newline at end of file diff --git a/view/src/components/chartCard.less b/view/src/components/chartCard.less new file mode 100644 index 0000000..3452c00 --- /dev/null +++ b/view/src/components/chartCard.less @@ -0,0 +1,36 @@ +.chartcard { + .title { + color: rgba(0, 0, 0, 0.45); + font-size: 14px; + line-height: 22px; + height: 22px; + } + .content { + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + white-space: nowrap; + color: rgba(0, 0, 0, 0.85); + margin-bottom: 0; + font-size: 30px; + } + .topcard { + font-size: 14px; + .anticon { + margin-right: 10px; + } + .up { + .anticon { + color: green; + } + } + .down { + .anticon { + color: red; + } + } + .committer { + margin-left: 10px; + } + } +} diff --git a/view/src/components/header.less b/view/src/components/header.less new file mode 100644 index 0000000..f45bb33 --- /dev/null +++ b/view/src/components/header.less @@ -0,0 +1,70 @@ +.logo { + height: 64px; + width: 100%; + position: relative; + line-height: 64px; + padding-left: 24px; + transition: all .3s; + background: #002140; + overflow: hidden; + + a { + color: #1890ff; + background-color: transparent; + text-decoration: none; + outline: none; + cursor: pointer; + transition: color .3s; + + * { + display: inline-block; + vertical-align: middle; + } + } + + img { + width: 32px; + } + + h1 { + color: #fff; + font-size: 20px; + margin: 0 0 0 12px; + font-family: "Myriad Pro", "Helvetica Neue", Arial, Helvetica, sans-serif; + font-weight: 600; + } + +} + +.ant-layout-header { + z-index: 0; + .right { + height: 100%; + float: right; + a { + cursor: pointer; + padding: 0 12px; + display: inline-block; + transition: all .3s; + height: 100%; + .anticon { + font-size: 14px !important; + transform: none; + } + } + } +} + +.github-btn-container { + a { + padding: 2px 5px 2px 4px !important; + } + float: right; + height: 20px; + padding-left: 12px; + margin-top: 21px; +} + +.nickname-menu li .anticon { + padding-right: 6px; +} diff --git a/view/src/components/logos.js b/view/src/components/logos.js new file mode 100644 index 0000000..d8dbd2b --- /dev/null +++ b/view/src/components/logos.js @@ -0,0 +1,6 @@ +'use strict'; + +/* eslint max-len: 0 */ +exports.android = 'data:image/jpeg;base64,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'; +exports.ios = 'data:image/jpeg;base64,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'; +exports.web = 'data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMfaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkMwM0I3NkYwNjc0RjExRThBNzQ0REQyRUYzNzBCMjBFIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkMwM0I3NkVGNjc0RjExRThBNzQ0REQyRUYzNzBCMjBFIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IE1hY2ludG9zaCI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSIxRDk1N0JERDQxMUIyREU3Qjk5QUU1Rjc4NTg1MzJCOSIgc3RSZWY6ZG9jdW1lbnRJRD0iMUQ5NTdCREQ0MTFCMkRFN0I5OUFFNUY3ODU4NTMyQjkiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAGBAQEBQQGBQUGCQYFBgkLCAYGCAsMCgoLCgoMEAwMDAwMDBAMDg8QDw4MExMUFBMTHBsbGxwfHx8fHx8fHx8fAQcHBw0MDRgQEBgaFREVGh8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx//wAARCACAAIADAREAAhEBAxEB/8QBogAAAAcBAQEBAQAAAAAAAAAABAUDAgYBAAcICQoLAQACAgMBAQEBAQAAAAAAAAABAAIDBAUGBwgJCgsQAAIBAwMCBAIGBwMEAgYCcwECAxEEAAUhEjFBUQYTYSJxgRQykaEHFbFCI8FS0eEzFmLwJHKC8SVDNFOSorJjc8I1RCeTo7M2F1RkdMPS4ggmgwkKGBmElEVGpLRW01UoGvLj88TU5PRldYWVpbXF1eX1ZnaGlqa2xtbm9jdHV2d3h5ent8fX5/c4SFhoeIiYqLjI2Oj4KTlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+hEAAgIBAgMFBQQFBgQIAwNtAQACEQMEIRIxQQVRE2EiBnGBkTKhsfAUwdHhI0IVUmJy8TMkNEOCFpJTJaJjssIHc9I14kSDF1STCAkKGBkmNkUaJ2R0VTfyo7PDKCnT4/OElKS0xNTk9GV1hZWltcXV5fVGVmZ2hpamtsbW5vZHV2d3h5ent8fX5/c4SFhoeIiYqLjI2Oj4OUlZaXmJmam5ydnp+So6SlpqeoqaqrrK2ur6/9oADAMBAAIRAxEAPwD1TirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdXFULcalbQ1WvN/wCVf4nFUE+szkngiqPfc4qonU70/t0HgAP6Yq0NTvR/uz7wP6YqrJrFwPtKrD8cVRkGqW0hox9Nj0B6ffiqMBBFR0xV2KuxV2KuxV2KuxV2Kqc9xHAheQ0HbxOKpNd6hNOaCqR/yjr9JxVCYq7FXYq7FXYq7FURa3s1ufhPJO6Hp9GKp1bXcVwnJDv+0p6jFVbFXYq7FXYq7FVk80cMZkc0A/E+GKsfubmS4kLv/sV8BiqlirsVdirsVdirsVdirsVXxSvFIJENGHfx+eKp/a3KXEYZeo+0vgcVVsVdirsVdiqSandGWYop+CM0Hz8cVQWKvPfPXnPzBouu/VLJ41tjEjqHTkakfFvUd80+u1mTHkqNVT1XY3ZODUYOKYPFZHNj3/Kz/Nn+/Yf+Rf8AzdmH/Kebydr/AKHtL3S+bv8AlZ/mz/fsP/Iv/m7H+U83kv8Aoe0vdL5u/wCVn+bP9+w/8i/+bsf5TzeS/wCh7S90vm7/AJWf5s/37D/yL/5ux/lPN5L/AKHtL3S+bv8AlZ/mz/fsP/Iv/m7H+U83kv8Aoe0vdL5u/wCVn+bP9+w/8i/+bsf5TzeS/wCh7S90vmi9I/MXzTearaWryRFJ5URgI96E/PLcPaGaUxHbctGq7C02PFKYEriO96yaVPhm/eHV7K5ME4b9ltn+X9mKsgBBAI6Yq7FXYqoX03o2zuPtdF+ZxVj2KuxV5X+bsXHWrGT/AH5btX/YvTND2uPWPc9t7MSvDMd0v0MFzUvSNbYpcSvjhVNIkiMakgVpmMS1lfxi/lGC0bu4RnooxsqivKUQl856ctNknV6f6ubHs8Xlj73B7Wlw6TJ/Ve3Z1j5u7FU80uYyWwU/ajND8u2KozFXYqlmtSf3cYPixH6sVSrFXYq83/OCL4tMm70eP8eWaXtcfSXrvZaX94PcXn1oVW7gLKGX1E5KehHIVrmnjzD1WS+E13Poc+UvKpJI0m2IPQ+mOmdX+VxfzQ+ZjtDUfz5fN3+EPK//AFaLb/kXj+Vx/wA0L/KWo/1SXzX/AOF/Lo2Gl29O3wYPyeL+YF/lHUf6pL5u/wAL+Xv+rZb/APAY/k8P8wL/ACjqP9Ul83l/5jW1la+Yvq1pAkEccMZMcYoKuK1zm+1YRjl4YihQex7CyTnp+KZ4jxFKvy8j9XzrCevBZn/4EZb2ZG8o9zZ29KtJLzID2TOnfPnYqmGjycbhk7MvT3GKpxirsVSXWDW6A8EH6ziqBxV2KsD/ADeh5aRYSjrHcMD8mTNT2sPRE+b03svKssx/R/S8uQ0dD4MD9xzRB7QjZ9IN/pfl1jWvr2fIU8WirtnXc8f+b+h8uHoz+6f6XzottqQQAxzVpQ/a7ZyYjLzfTjkx94TaKC+9NPgl6D+bMYxl5tRnDyX+hf8A8kv/AA2Dhl5o44eSi4cMRJUMOvKtfxyJB6tgI6J5+VMfPzLNN/vuB/8Ah9s3nZUf3hPk6P2klWnA75PWc6F4ZvFUTpppex+9f1HFU+xV2KpLrApdA+KD9ZxVA4q7FWIfmnFz8rGSn91PGf8AgjxzXdqD91fm772clWprviXj56Gmc496948vec/KyaFp8dxq1rFPHbRJJG8gDBkQKQR9GdPg1eLgAMhdB871nZmoOaZjjkQZGtvNMP8AGnk7/q9WX/I1ct/N4f50XH/kzVf6nP5Lx5t8qHcataEH/ixcj+dwfz4/NH8man/U5/JMbW6tbuBLi1lSeB90ljNVP05kQmJC4mwXEyY5QkYyFSHR4l5xm9TzLqrjoJ3C/JTnHa03nl/WfRey48Omxj+iEy/J+Kt/qUx/ZijUfMuSc2/ZA9Uj5B1HtRL0QHmfuenZvHjnYqidNFb2L2r+o4qn+KuxVK9aj/u5Pmp/WMVSvFXYqxz8w4vU8o3o/k4P/wAA1cwu0BeEu27Dnw6uHxHzeJ5zD6I9Y8l/l75W1fy5Y6hdQyNPOG9UrI6j4ZGXoDt0zeaTQ4smMSPMvH9p9s6jDnlCJFCunk801qzWy1a9tFBCQzSJGD/KGPHc+2afLDhmR3F6rS5PExRl3gImI/uk+QzCPNkXuvk6Aw+V9LjIo3oKW7bknOy0EawQ9z5x2rPi1OQ/0nieuT+re301a85JG+85yWWXFkJ830HTQ4ccR3AMv/KCL/RNSmp1lSOvyUHOg7IHpkfN5n2on64R8noObh5V2Ko/R4+Vwz9lX8Tiqc4q7FUPfQ+tbOg6gVX5jFWP4q7FUp82xer5Y1RBufq0hHzC7Zj6sXil7nO7Mlw6nGf6QeDZyb6Y9c/LXzh5ftPL8GmXt6ltdRu/FJKqCGYsKH6c33Z+qxjHwyNF4ztzs3PPMckI8UT3e5mV5onlvWkLT21tecx/erxL0/11+L8cz5YseTmAXR49VnwHaUo/juOylYeUPLOnLWGxiAHR5v3lPpkrTK8ehww3ER8f2tmXtPUZdjM/Db7nan5r8vaajJLexCVR8EKHk1abUA2xza3Fj5yF9y6fs3PlO0TXeXhV637qVv5iT95zj47yfR4vRvyni4eXrh/9+3BP/Arx/hnUdlCsZPm8X7SyvPEd0Wa5s3nXYqnmlQmO2DEUaT4j8u2KozFXYq7FUj1K19Gcuo+CTcfPwxVB4qhtUi9bTbuLrzidafMZDKLiR5N2nlw5InuIfPOcc+ql3z2xVEWl/fWTcrO4kt2G/wC6dk+8AjJwnKPI01ZMMMm0gJe9PLjXdZv40N3ezTAjcFyFPzAoMpy6jJPnIlx8WjxY/oiB8EA0kSA8mC+2UiJcncoa5uo5IyiAmvfp0y2ECDukRet/lpFw8o2zd5JJWP8AyMIGdT2aKwj4vA+0Er1UvcPuZRme6VEWVsbicLT4V3c+39uKsgAAAA2A6DFXYq7FXYqpzwpNGY3Gx/A+OKpDc20lvJwfp+y3YjFVHr7juMVYtqX5beV7urRQtZyfzQNQV9w3LMDJ2bilyFe53WDt/U4+Z4h5sW1H8ptTiq2n3Udwv8kgMbfRTlX8MwMnZMh9Jt3WD2mxHbJEx92/6mLaj5Y8waeSLuwlRR+2q81+9OWYGTTZIc4l3WDtDBl+mY+771lnpeu6gwjtLWebsOKkL/wRov45DHp5S+mJLPNq8OIXOQHxZJp35Va9cUa8kis0PUE+pIP9iKD/AIbM/H2XkP1el0+f2kwR2gDP7B+PgyjTfyt8vW1Gu2kvX7hjwjP+xXf8cz8fZeOPPd0uf2j1E/oqH3/j4MttLS2s7ZLa1jEMEYoka9B9+bCEBEUOTpMuWWSRlI3IoiGF5pAiCrH7h88k1p/aWyW8QRdz+03icVVsVdirsVdirsVU5oI5kKSCqnFUmu9OmgNVq8fiOo+eKoTFXYq3Xah+yeo6jFWlVUXigCL/ACqAB9wxSTfN2KHYqiLWxnuDsOKd3P8ADFU6trWK3Tig+bdziqtirsVdirsVdirsVdirsVQtxpttMSSvFz+0uKoKXRpR/duGHgdjiqgdMvR/uuvuCP64q4aben/df3kf1xVWj0advtsFHtucVRkGl20ZBYeow7t0+7FUYAAKDpirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdir//2Q=='; diff --git a/view/src/components/pkgTable.less b/view/src/components/pkgTable.less new file mode 100644 index 0000000..46fb4be --- /dev/null +++ b/view/src/components/pkgTable.less @@ -0,0 +1,43 @@ +.pkg { + .ant-modal-body { + padding: 10px; + } + + .tips { + margin-top: 10px; + word-break: break-all; + word-wrap: break-word; + white-space: initial; + } + + .ant-modal-footer { + display: none; + } + +} + +.vertical-center-modal { + text-align: center; + white-space: nowrap; +} + +.vertical-center-modal:before { + content: ''; + display: inline-block; + height: 100%; + vertical-align: middle; + width: 0; +} + +.vertical-center-modal .ant-modal { + display: inline-block; + vertical-align: middle; + top: 0; + text-align: left; +} + +.package-table { + td:last-child { + position: relative; + } +} diff --git a/view/src/constants/index.js b/view/src/constants/index.js new file mode 100644 index 0000000..086f0e9 --- /dev/null +++ b/view/src/constants/index.js @@ -0,0 +1,7 @@ +'use strict'; + +export const SERVER_ADDRESS = window.pageConfig.SERVER_ADDRESS; // 9900 +export const STATIC_ADDRESS = window.pageConfig.STATIC_ADDRESS; // 9920 +export const PAGE_SIZE = 20; + +export LANG_LIST from './lang_list'; diff --git a/view/src/constants/lang_list.js b/view/src/constants/lang_list.js new file mode 100644 index 0000000..0ca31dd --- /dev/null +++ b/view/src/constants/lang_list.js @@ -0,0 +1,4 @@ +export default [ + 'zh-CN', + 'en-US', +]; diff --git a/view/src/i18n/en_US.js b/view/src/i18n/en_US.js new file mode 100644 index 0000000..b8dd79a --- /dev/null +++ b/view/src/i18n/en_US.js @@ -0,0 +1,71 @@ +export default { + 'common.comfirm': 'Confirm', + 'common.cancel': 'Cancel', + 'common.comfirmDelete': 'Confirm Delete?', + 'common.input.invalid': 'Please correct the input', + + 'header.issues': 'issues', + 'header.document': 'document', + + 'sidebar.homepage': 'Home Page', + 'sidebar.allbuilds': 'All Builds', + 'sidebar.buildinfo': 'Build Info', + 'sidebar.insight': 'Insight', + 'sidebar.setting': 'Setting', + + 'setting.dingMessage': 'DingTalk Setting', + 'setting.addDingMessage': 'Add DingTalk Webhook', + 'setting.submit': 'Update', + 'setting.versioning': 'Version Info', + 'setting.notification.build': 'Build', + + 'builds.buildNumber': 'Build Number', + 'builds.buildLog': 'Build Log', + 'builds.jobName': 'Job Name', + 'builds.platform': 'Platform', + 'builds.buildEndTime': 'Build End Time', + 'builds.detailInfo': 'Detail', + 'builds.rank': 'Rank', + + 'buildinfo.pkgTab': 'Package', + 'buildinfo.testTab': 'Test Result', + 'buildinfo.extraTab': 'Extra Info', + 'buildinfo.filesTab': 'Build Product', + + 'buildinfo.pkg.version': 'Version', + 'buildinfo.pkg.type': 'Type', + 'buildinfo.pkg.download': 'Download', + 'buildinfo.pkg.gitBranch': 'Current Branch', + 'buildinfo.pkg.gitCommit': 'Commit Url', + 'buildinfo.pkg.gitInfo': 'Commit Info', + 'buildinfo.pkg.committer': 'Commiter', + 'buildinfo.pkg.commitTime': 'Commit Time', + 'buildinfo.state': 'Build State', + + 'buildinfo.test.lineCoverage': 'Coverage Percent', + 'buildinfo.test.passPercent': 'Pass Percent', + 'buildinfo.test.testReporter': 'Tests Reporter', + 'buildinfo.test.coverageReporter': 'Coverage Reporter', + 'buildinfo.test.reporter': 'reporter', + + 'buildinfo.extra.extraName': 'Name', + 'buildinfo.extra.extraContent': 'Content', + + 'buildinfo.files.fileName': 'File Name', + 'buildinfo.files.fileAddress': 'Download', + + 'insight.builds.number': 'Total Job Number', + 'insight.builds.trend': 'Total Builds Number', + 'insight.builds.top': 'Top', + 'insight.test.lineCoverage': 'Coverage Percent avg', + 'insight.test.lineCoverage.tip': 'Average test coverage over this period of time', + 'insight.test.lineCoverage.history': 'Coverage History', + 'insight.test.lineCoverage.latest': 'Coverage', + 'insight.test.passPercent': 'Pass Percent', + 'insight.test.passPercent.tip': 'CI 100% pass rate', + 'insight.test.passPercent.history': 'PassPercent History', + 'insight.test.duration': 'Duration avg', + 'insight.test.duration.history': 'Duration History', + 'insight.committer': 'Last Commit', + 'insight.dateRange.tip': 'Please select range or query entire data' +}; diff --git a/view/src/i18n/zh_CN.js b/view/src/i18n/zh_CN.js new file mode 100644 index 0000000..174f979 --- /dev/null +++ b/view/src/i18n/zh_CN.js @@ -0,0 +1,72 @@ +export default { + 'common.comfirm': '确认', + 'common.cancel': '取消', + 'common.comfirmDelete': '确定删除?', + 'common.input.invalid': '请修改输入的内容', + + 'header.issues': '问题反馈', + 'header.document': '文档', + + 'sidebar.homepage': '主页', + 'sidebar.allbuilds': '所有构建', + 'sidebar.buildinfo': '构建信息', + 'sidebar.insight': '洞察', + 'sidebar.setting': '设置', + + 'setting.dingMessage': '钉钉消息设置', + 'setting.addDingMessage': '添加通知', + 'setting.submit': '更新设置', + 'setting.versioning': '版本信息', + 'setting.notification.build': '构建', + + 'builds.buildNumber': '构建号', + 'builds.buildLog': '构建日志', + 'builds.jobName': '构建名', + 'builds.platform': '平台', + 'builds.buildEndTime': '完成时间', + 'builds.detailInfo': '详情', + 'builds.rank': '排行', + + 'buildinfo.pkgTab': '包信息', + 'buildinfo.testTab': '测试结果', + 'buildinfo.extraTab': '扩展信息', + 'buildinfo.filesTab': '构建产物', + + 'buildinfo.pkg.version': '版本号', + 'buildinfo.pkg.type': '类型', + 'buildinfo.pkg.download': '下载', + 'buildinfo.pkg.gitBranch': '代码分支', + 'buildinfo.pkg.gitCommit': '提交链接', + 'buildinfo.pkg.gitInfo': '提交信息', + 'buildinfo.pkg.committer': '提交人', + 'buildinfo.pkg.commitTime': '提交时间', + 'buildinfo.state': '构建状态', + + 'buildinfo.test.lineCoverage': '行覆盖率', + 'buildinfo.test.passPercent': '通过率', + 'buildinfo.test.testReporter': '测试报告', + 'buildinfo.test.coverageReporter': '覆盖率报告', + 'buildinfo.test.reporter': '查看', + + 'buildinfo.extra.extraName': '项', + 'buildinfo.extra.extraContent': '内容', + + 'buildinfo.files.fileName': '文件名称', + 'buildinfo.files.fileAddress': '下载地址', + + 'insight.builds.number': '应用总数', + 'insight.builds.trend': '构建趋势', + 'insight.builds.top': '榜单', + 'insight.test.lineCoverage': '平均行覆盖率', + 'insight.test.lineCoverage.tip': '测试覆盖率的平均值', + 'insight.test.lineCoverage.history': '测试覆盖率历史记录', + 'insight.test.lineCoverage.latest': '行覆盖率', + 'insight.test.passPercent': '通过率', + 'insight.test.passPercent.tip': 'CI 100% 成功次数 / CI 执行次数', + 'insight.test.passPercent.history': '通过率历史记录', + 'insight.test.duration': '平均构建时长', + 'insight.test.duration.history': '构建时长历史记录', + 'insight.committer': '最后提交', + 'insight.dateRange.tip': '请选择时间范围,不选择将会统计全部数据', + +}; diff --git a/view/src/index.js b/view/src/index.js new file mode 100644 index 0000000..e87dfd5 --- /dev/null +++ b/view/src/index.js @@ -0,0 +1,64 @@ +'use strict'; + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Route, BrowserRouter } from 'react-router-dom'; +import { addLocaleData, IntlProvider } from 'react-intl'; + +import zhCN from './i18n/zh_CN'; +import enUS from './i18n/en_US'; +import zh from 'react-intl/locale-data/zh'; +import en from 'react-intl/locale-data/en'; + +import Builds from './page/Builds'; +import Setting from './page/Setting'; +import Insight from './page/Insight'; +import OneBuild from './page/OneBuild'; +import BuildLog from './page/BuildLog'; +import SnsAuthorize from './page/SnsAuthorize'; + +import './index.less'; + +addLocaleData([ + ...en, + ...zh, +]); + +const chooseLocale = () => { + const language = window.localStorage.RELIABLE_LANGUAGE || window.navigator.language; + switch (language) { + case 'zh-CN': + case 'zh-HK': + case 'zh-TW': + case 'zh': + return { + locale: 'zh-CN', + messages: zhCN, + }; + default: + return { + locale: 'en-US', + messages: enUS, + }; + } +}; +const lang = chooseLocale(); + +ReactDOM.render( + + +
    + + + + + + +
    +
    +
    , + document.querySelector('#app') +); diff --git a/view/src/index.less b/view/src/index.less new file mode 100644 index 0000000..c07790c --- /dev/null +++ b/view/src/index.less @@ -0,0 +1,67 @@ +#app { + height: 100%; + > div { + height: 100%; + > div { + height: 100%; + } + } +} + +.ant-layout-content { + min-height: auto !important; +} + +.ant-menu-dark { + margin-top: 20px !important; +} + +.color { + &-egt-90 { + background: #f6ffed; + } + + &-egt-80 { + background: #fcffe6; + } + + &-egt-70 { + background: #fffbe6; + } + + &-egt-50 { + background: #fff7e6; + } + + &-egt-30 { + background: #fff2e8; + } + + &-egt-0 { + background: #fff1f0; + } +} + +.ant-table pre { + white-space: pre; + white-space: pre-wrap; +} + +.ant-table span.itemName { + display: inline-block; + white-space: pre-wrap; + width: 100px; +} + +body, div, +dl, dt, dd, +ul, ol, li, +h1, h2, h3, h4, h5, h6, +pre, code, +form, fieldset, legend, input, +textarea, p, blockquote, +th, td, hr, button, article, aside, details, +figcaption, figure, footer, header, hgroup, menu, nav, section { + margin: 0; + padding: 0; +} diff --git a/view/src/page/Blank.js b/view/src/page/Blank.js new file mode 100644 index 0000000..16bc86b --- /dev/null +++ b/view/src/page/Blank.js @@ -0,0 +1,23 @@ +'use strict'; + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; +import { Alert, Breadcrumb } from 'antd'; + +import ReliableLayout from '../components/ReliableLayout'; + +export default class Setting extends React.Component { + render () { + return ( + + + + + + + + + ); + } +} diff --git a/view/src/page/BuildLog.js b/view/src/page/BuildLog.js new file mode 100644 index 0000000..6c8bf5d --- /dev/null +++ b/view/src/page/BuildLog.js @@ -0,0 +1,32 @@ +'use strict'; + +import React from 'react'; +import { + Breadcrumb, +} from 'antd'; +import { Link } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; + +import { queryParse } from '../util/index'; +import ReliableLayout from '../components/ReliableLayout'; + +export default class BuildLog extends React.Component { + render () { + const { buildNumber, jobName } = queryParse(location.search); + + return ( + + + + + + + + + {`${jobName} / ${buildNumber}`} + + + ); + } +} + diff --git a/view/src/page/Builds.js b/view/src/page/Builds.js new file mode 100644 index 0000000..633e8ce --- /dev/null +++ b/view/src/page/Builds.js @@ -0,0 +1,32 @@ +'use strict'; + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Breadcrumb } from 'antd'; +import { FormattedMessage } from 'react-intl'; + +import { queryParse } from '../util/index'; +import BuildsTabs from '../components/BuildsTabs'; +import ReliableLayout from '../components/ReliableLayout'; + +export default class Builds extends React.Component { + render () { + const jobName = queryParse(location.search).jobName; + return ( + + + + + + + + + + { jobName ? {jobName} : '' } + + + + + ); + } +} diff --git a/view/src/page/Insight.js b/view/src/page/Insight.js new file mode 100644 index 0000000..14d0d32 --- /dev/null +++ b/view/src/page/Insight.js @@ -0,0 +1,388 @@ +'use strict'; + +import { + Row, + Col, + Spin, + Icon, + Table, + Popover, + Breadcrumb, + DatePicker, +} from 'antd'; +import React from 'react'; +import safeGet from 'lodash.get'; + +import { Link } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; + +import request from '../util/request'; +import ChartCard from '../components/ChartCard'; +import { + logger, + mapNumberToColor, +} from '../util/index'; +import ReliableLayout from '../components/ReliableLayout'; + +const RangePicker = DatePicker.RangePicker; + +const topColResponsiveProps = { + xs: 24, + sm: 10, + md: 10, + lg: 10, + xl: 8, + style: { marginBottom: 24 }, +}; + +export default class Builds extends React.Component { + state = { + loading: false, + total: 0, + data: [], + loading1: true, + loading2: true, + loading3: true, + loading4: true, + }; + + componentDidMount () { + this.fetch(); + } + + fetch = async ([ startDate, endDate ] = []) => { + this.setState({ + loading: true, + }); + const insightRes = await request('insight/ci', 'GET', { + startDate, + endDate, + allBranches: 'Y', + }); + if (insightRes.success) { + try { + const result = insightRes.data; + logger('buildData res', result); + this.setState({ + loading1: false, + loading3: false, + loading4: false, + data: result, + }); + } catch (e) { + console.error(insightRes, e); + } + } else { + console.error(insightRes); + } + + const buildsRes = await request('getBuildsTable', 'GET', {}); + if (buildsRes.success) { + try { + const { + total, + } = buildsRes.data; + this.setState({ + loading2: false, + total, + }); + } catch (e) { + console.error('getBuildsTable error', buildsRes, e); + } + } else { + console.error('getBuildsTable error', buildsRes); + } + } + + changeDateRange = async (date, dateString) => { + console.log(date, dateString); + await this.fetch(dateString); + } + + + getColumns = () => { + return [{ + title: , + key: 'rank', + render: (text, record, index) => {++index}, + align: 'center', + width: 80, + }, { + title: , + dataIndex: 'jobName', + render: (text, record) => + + {text} + , + }, { + title: , + dataIndex: 'linePercentList[0].linePercent', + render: (text, record) => { + const coverageUrl = safeGet(record, 'linePercentList[0].coverageUrl'); + if (!text) { + return '-'; + } + return + {text}% + ; + }, + }, { + title: + + }> + + + , + dataIndex: 'linePercent', + render: (text, record) => { + return record.linePercent + ? } + content={ +
    { + return {record.shortHash}; + }, + }, + { + title: 'Coverage', + dataIndex: 'coverageUrl', + key: 'commitUrl', + align: 'center', + render: (text, record) => { + return record.linePercent + ? {record.linePercent}% + : '-'; + }, + }, + { + title: 'CreatedAt', + dataIndex: 'createdAt', + key: 'createdAt', + align: 'right', + }, + ]} + /> + } + > + {record.linePercent}% + + : '-'; + }, + }, { + title: + + }> + + + , + dataIndex: 'passPercent', + render: (text, record) => { + return record.passPercent + ? } + content={ +
    { + return {record.shortHash}; + }, + }, + { + title: 'Pass Percentage', + dataIndex: 'reporterUrl', + key: 'reporterUrl', + align: 'right', + render: (text, record) => { + return record.passPercent + ? {record.passPercent}% + : '-'; + }, + }, + { + title: 'CreatedAt', + dataIndex: 'createdAt', + key: 'createdAt', + align: 'right', + }, + ]} + /> + } + > + {record.passPercent}% + + : '-'; + }, + }, { + title: , + dataIndex: 'humanizeDuration', + render: (text, record) => { + return record.humanizeDuration + ? } + content={ +
    { + return {record.shortHash}; + }, + }, + { + title: 'Duration', + dataIndex: 'duration', + key: 'duration', + align: 'right', + }, + { + title: 'CreatedAt', + dataIndex: 'createdAt', + key: 'createdAt', + align: 'right', + }, + ]} + /> + } + > + {record.humanizeDuration} + + : '-'; + }, + }, { + title: , + dataIndex: 'committer', + render: (text, record) => + { record.lastCommit.committer }, + }]; + } + + getData = () => { + const data = this.state.data.sort((a, b) => { + return parseFloat(b.linePercent || 0) - parseFloat(a.linePercent || 0); + }); + console.log('data =>', data); + return data; + } + + getTopCard () { + const list = this.getData(); + if (!list.length) { + return ( +
    No Rank
    + ); + } + const first = list[0]; + const last = list.length === 1 ? { jobName: '', committer: '' } : list[list.length - 1]; + + return ( +
    +
    + + + {first.jobName} + + + {first.committer} + +
    +
    + + + {last.jobName} + + + {last.committer} + +
    +
    + ); + } + + render () { + return ( + + + + + + + + + + +
    + } + loading={this.state.loading1} + content={this.state.data.length} + /> + + + } + loading={this.state.loading2} + content={this.state.total} + /> + + + } + loading={this.state.loading3} + content={this.getTopCard()} + /> + + + + + + + + + + + } + onChange={this.changeDateRange} + /> + + +
    record.jobName} + rowClassName={record => mapNumberToColor(record.linePercent)} + dataSource={this.getData()} + columns={this.getColumns()} + pagination={false} + /> + + + ); + } +} diff --git a/view/src/page/OneBuild.js b/view/src/page/OneBuild.js new file mode 100644 index 0000000..7dba9a8 --- /dev/null +++ b/view/src/page/OneBuild.js @@ -0,0 +1,75 @@ +'use strict'; + +import React from 'react'; +import { Breadcrumb } from 'antd'; +import { Link } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; + +import request from '../util/request'; +import { logger, queryParse } from '../util/index'; +import OneBuildTabs from '../components/OneBuildTabs'; +import ReliableLayout from '../components/ReliableLayout'; + +export default class Builds extends React.Component { + state = { + data: {}, + loading: true, + }; + + fetchOneBuild = () => { + this.setState({ + loading: true, + }); + request('getOneBuildTable', 'GET', queryParse(location.href)).then(res => { + logger('getOneBuildTable res', res); + if (res.success) { + try { + const buildData = res.data.data; + buildData.buildUniqId = res.data.uniqId; + logger('buildData res', buildData); + this.setState({ + data: buildData, + }); + } catch (e) { + console.error('getOneBuildTable error', res, e); + } + } else { + console.error('getOneBuildTable failed', res); + } + this.setState({ loading: false }); + }).catch(e => { + console.error('getOneBuildTable error', e); + this.setState({ loading: false }); + }); + } + + componentDidMount () { + this.fetchOneBuild(); + } + + render () { + const { buildNumber, jobName } = queryParse(location.search); + return ( + + + + + + + + + + {jobName} + + {buildNumber} + + + + + ); + } +} diff --git a/view/src/page/Setting.js b/view/src/page/Setting.js new file mode 100644 index 0000000..96457f2 --- /dev/null +++ b/view/src/page/Setting.js @@ -0,0 +1,53 @@ +'use strict'; + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { FormattedMessage } from 'react-intl'; +import { + Row, + Col, + Card, + Breadcrumb, +} from 'antd'; + +import ReliableLayout from '../components/ReliableLayout'; +import DingdingSetting from '../components/DingdingSetting'; + +import pkg from '../../package.json'; + +import './Setting.less'; + +export default class Setting extends React.Component { + + render () { + return ( + + + + + + + + + + + + }> + + + + + + + }> + + + reliable-web: { window.pageConfig.version } + + + + + ); + } +} + diff --git a/view/src/page/Setting.less b/view/src/page/Setting.less new file mode 100644 index 0000000..be91d60 --- /dev/null +++ b/view/src/page/Setting.less @@ -0,0 +1,5 @@ +.confirm-delete-credential { + .ant-confirm-content { + margin-left: 0; + } +} \ No newline at end of file diff --git a/view/src/page/SnsAuthorize.js b/view/src/page/SnsAuthorize.js new file mode 100644 index 0000000..09030ba --- /dev/null +++ b/view/src/page/SnsAuthorize.js @@ -0,0 +1,86 @@ +import React from 'react'; +import Script from 'react-load-script'; +import queryString from 'query-string'; +import safeGet from 'lodash.get'; +import { + Icon, +} from 'antd'; + +import ReliableLayout from '../components/ReliableLayout'; + +const appid = safeGet(window, 'context.dingtalkAuth.appid'); +const callbackUrl = `${location.protocol}//${location.hostname}${safeGet(window, 'context.dingtalkAuth.callbackUrl')}`; + +export default class Builds extends React.Component { + handleScriptError = () => { + console.log('handleScriptError'); + } + handleScriptLoad = () => { + this.addHandler(); + this.initAuth(); + } + addHandler = () => { + const hanndleMessage = event => { + const origin = event.origin; + if (origin === 'https://login.dingtalk.com') { + const loginTmpCode = event.data; + this.redirectToCallbackUrl(loginTmpCode); + } + }; + + if (typeof window.addEventListener !== 'undefined') { + window.addEventListener('message', hanndleMessage, false); + } else if (typeof window.attachEvent !== 'undefined') { + window.attachEvent('onmessage', hanndleMessage); + } + } + redirectToCallbackUrl = loginTmpCode => { + const query = queryString.stringify({ + appid, + response_type: 'code', + scope: 'snsapi_login', + state: 'STATE', + redirect_uri: callbackUrl, + loginTmpCode, + }); + location.href = `https://oapi.dingtalk.com/connect/oauth2/sns_authorize?${query}`; + } + initAuth = () => { + const query = queryString.stringify({ + appid, + response_type: 'code', + scope: 'snsapi_login', + state: 'STATE', + redirect_uri: callbackUrl, + }); + const goto = encodeURIComponent(`https://oapi.dingtalk.com/connect/oauth2/sns_authorize?${query}`); + window.DDLogin({ + id: 'login_container', + goto, + style: 'border:none;background-color:#FFFFFF;', + width: '365', + height: '400', + }); + } + render () { + return ( + +