You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@skywalking.apache.org by ha...@apache.org on 2017/12/18 08:04:10 UTC
[incubator-skywalking-ui] 01/02: Init frontend
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch feature/5.0.0
in repository https://gitbox.apache.org/repos/asf/incubator-skywalking-ui.git
commit b22b60dcb931ad01ee702a4abee19a30636995bd
Author: gaohongtao <ha...@gmail.com>
AuthorDate: Thu Dec 14 09:49:57 2017 +0800
Init frontend
---
src/main/frontend/.editorconfig | 16 +
src/main/frontend/.eslintrc | 53 +++
src/main/frontend/.ga | 3 +
src/main/frontend/.gitignore | 15 +
src/main/frontend/.roadhogrc | 23 ++
src/main/frontend/.roadhogrc.mock.js | 58 ++++
src/main/frontend/.stylelintrc | 25 ++
src/main/frontend/.travis.yml | 33 ++
src/main/frontend/LICENSE | 201 +++++++++++
src/main/frontend/README.md | 1 +
src/main/frontend/appveyor.yml | 22 ++
src/main/frontend/mock/.gitkeep | 0
src/main/frontend/mock/notices.js | 85 +++++
src/main/frontend/mock/rule.js | 127 +++++++
src/main/frontend/mock/utils.js | 45 +++
src/main/frontend/package.json | 107 ++++++
src/main/frontend/public/index.html | 15 +
src/main/frontend/src/common/nav.js | 26 ++
.../src/components/StandardFormRow/index.js | 26 ++
.../src/components/StandardFormRow/index.less | 71 ++++
.../frontend/src/components/StandardTable/index.js | 154 +++++++++
.../src/components/StandardTable/index.less | 13 +
src/main/frontend/src/e2e/home.e2e.js | 9 +
src/main/frontend/src/index.js | 22 ++
src/main/frontend/src/index.less | 16 +
src/main/frontend/src/layouts/BasicLayout.js | 375 +++++++++++++++++++++
src/main/frontend/src/layouts/BasicLayout.less | 113 +++++++
src/main/frontend/src/layouts/BlankLayout.js | 3 +
src/main/frontend/src/layouts/PageHeaderLayout.js | 12 +
.../frontend/src/layouts/PageHeaderLayout.less | 11 +
src/main/frontend/src/models/dashboard.js | 16 +
src/main/frontend/src/models/global.js | 76 +++++
src/main/frontend/src/models/index.js | 11 +
src/main/frontend/src/models/rule.js | 80 +++++
src/main/frontend/src/models/user.js | 66 ++++
src/main/frontend/src/polyfill.js | 7 +
src/main/frontend/src/router.js | 60 ++++
.../frontend/src/routes/Dashboard/Dashboard.js | 13 +
.../frontend/src/routes/Dashboard/Dashboard.less | 23 ++
src/main/frontend/src/routes/List/TableList.js | 326 ++++++++++++++++++
src/main/frontend/src/routes/List/TableList.less | 45 +++
src/main/frontend/src/services/api.js | 30 ++
src/main/frontend/src/services/user.js | 9 +
src/main/frontend/src/theme.js | 5 +
src/main/frontend/src/utils/request.js | 56 +++
src/main/frontend/src/utils/utils.js | 94 ++++++
src/main/frontend/src/utils/utils.less | 50 +++
src/main/frontend/tests/jasmine.js | 1 +
src/main/frontend/tests/run-tests.js | 35 ++
src/main/frontend/tests/setupTests.js | 13 +
src/main/frontend/tests/styleMock.js | 1 +
51 files changed, 2697 insertions(+)
diff --git a/src/main/frontend/.editorconfig b/src/main/frontend/.editorconfig
new file mode 100755
index 0000000..7e3649a
--- /dev/null
+++ b/src/main/frontend/.editorconfig
@@ -0,0 +1,16 @@
+# http://editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab
diff --git a/src/main/frontend/.eslintrc b/src/main/frontend/.eslintrc
new file mode 100755
index 0000000..5209bd9
--- /dev/null
+++ b/src/main/frontend/.eslintrc
@@ -0,0 +1,53 @@
+{
+ "parser": "babel-eslint",
+ "extends": "airbnb",
+ "env": {
+ "browser": true,
+ "node": true,
+ "es6": true,
+ "mocha": true,
+ "jest": true,
+ "jasmine": true
+ },
+ "rules": {
+ "generator-star-spacing": [0],
+ "consistent-return": [0],
+ "react/forbid-prop-types": [0],
+ "react/jsx-filename-extension": [1, { "extensions": [".js"] }],
+ "global-require": [1],
+ "import/prefer-default-export": [0],
+ "react/jsx-no-bind": [0],
+ "react/prop-types": [0],
+ "react/prefer-stateless-function": [0],
+ "no-else-return": [0],
+ "no-restricted-syntax": [0],
+ "import/no-extraneous-dependencies": [0],
+ "no-use-before-define": [0],
+ "jsx-a11y/no-static-element-interactions": [0],
+ "jsx-a11y/no-noninteractive-element-interactions": [0],
+ "jsx-a11y/click-events-have-key-events": [0],
+ "jsx-a11y/anchor-is-valid": [0],
+ "no-nested-ternary": [0],
+ "arrow-body-style": [0],
+ "import/extensions": [0],
+ "no-bitwise": [0],
+ "no-cond-assign": [0],
+ "import/no-unresolved": [0],
+ "comma-dangle": ["error", {
+ "arrays": "always-multiline",
+ "objects": "always-multiline",
+ "imports": "always-multiline",
+ "exports": "always-multiline",
+ "functions": "ignore"
+ }],
+ "object-curly-newline": [0],
+ "function-paren-newline": [0],
+ "no-restricted-globals": [0],
+ "require-yield": [1]
+ },
+ "parserOptions": {
+ "ecmaFeatures": {
+ "experimentalObjectRestSpread": true
+ }
+ }
+}
diff --git a/src/main/frontend/.ga b/src/main/frontend/.ga
new file mode 100644
index 0000000..1932bb5
--- /dev/null
+++ b/src/main/frontend/.ga
@@ -0,0 +1,3 @@
+{
+ "code":"UA-72788897-6"
+}
diff --git a/src/main/frontend/.gitignore b/src/main/frontend/.gitignore
new file mode 100755
index 0000000..c03ea9a
--- /dev/null
+++ b/src/main/frontend/.gitignore
@@ -0,0 +1,15 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+# roadhog-api-doc ignore
+/src/utils/request-temp.js
+
+# production
+/dist
+
+# misc
+.DS_Store
+npm-debug.log*
+
+/coverage
diff --git a/src/main/frontend/.roadhogrc b/src/main/frontend/.roadhogrc
new file mode 100755
index 0000000..e64dacf
--- /dev/null
+++ b/src/main/frontend/.roadhogrc
@@ -0,0 +1,23 @@
+{
+ "entry": "src/index.js",
+ "extraBabelPlugins": [
+ "transform-runtime",
+ "transform-decorators-legacy",
+ "transform-class-properties",
+ ["import", { "libraryName": "antd", "libraryDirectory": "es", "style": true }]
+ ],
+ "env": {
+ "development": {
+ "extraBabelPlugins": [
+ "dva-hmr"
+ ]
+ }
+ },
+ "externals": {
+ "g2": "G2",
+ "g-cloud": "Cloud",
+ "g2-plugin-slider": "G2.Plugin.slider"
+ },
+ "ignoreMomentLocale": true,
+ "theme": "./src/theme.js"
+}
diff --git a/src/main/frontend/.roadhogrc.mock.js b/src/main/frontend/.roadhogrc.mock.js
new file mode 100644
index 0000000..11cfdbc
--- /dev/null
+++ b/src/main/frontend/.roadhogrc.mock.js
@@ -0,0 +1,58 @@
+import mockjs from 'mockjs';
+import { getRule, postRule } from './mock/rule';
+import { imgMap } from './mock/utils';
+import { getNotices } from './mock/notices';
+import { delay } from 'roadhog-api-doc';
+
+// 是否禁用代理
+const noProxy = process.env.NO_PROXY === 'true';
+
+// 代码中会兼容本地 service mock 以及部署站点的静态数据
+const proxy = {
+ // 支持值为 Object 和 Array
+ 'GET /api/currentUser': {
+ $desc: "获取当前用户接口",
+ $params: {
+ pageSize: {
+ desc: '分页',
+ exp: 2,
+ },
+ },
+ $body: {
+ name: 'Serati Ma',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/dRFVcIqZOYPcSNrlJsqQ.png',
+ userid: '00000001',
+ notifyCount: 12,
+ },
+ },
+ // GET POST 可省略
+ 'GET /api/users': [{
+ key: '1',
+ name: 'John Brown',
+ age: 32,
+ address: 'New York No. 1 Lake Park',
+ }, {
+ key: '2',
+ name: 'Jim Green',
+ age: 42,
+ address: 'London No. 1 Lake Park',
+ }, {
+ key: '3',
+ name: 'Joe Black',
+ age: 32,
+ address: 'Sidney No. 1 Lake Park',
+ }],
+ 'GET /api/rule': getRule,
+ 'POST /api/rule': {
+ $params: {
+ pageSize: {
+ desc: '分页',
+ exp: 2,
+ },
+ },
+ $body: postRule,
+ },
+ 'GET /api/notices': getNotices,
+};
+
+export default noProxy ? {} : delay(proxy, 1000);
diff --git a/src/main/frontend/.stylelintrc b/src/main/frontend/.stylelintrc
new file mode 100644
index 0000000..b4331fb
--- /dev/null
+++ b/src/main/frontend/.stylelintrc
@@ -0,0 +1,25 @@
+{
+ "extends": "stylelint-config-standard",
+ "rules": {
+ "selector-pseudo-class-no-unknown": null,
+ "shorthand-property-no-redundant-values": null,
+ "at-rule-empty-line-before": null,
+ "at-rule-name-space-after": null,
+ "comment-empty-line-before": null,
+ "declaration-bang-space-before": null,
+ "declaration-empty-line-before": null,
+ "function-comma-newline-after": null,
+ "function-name-case": null,
+ "function-parentheses-newline-inside": null,
+ "function-max-empty-lines": null,
+ "function-whitespace-after": null,
+ "number-leading-zero": null,
+ "number-no-trailing-zeros": null,
+ "rule-empty-line-before": null,
+ "selector-combinator-space-after": null,
+ "selector-list-comma-newline-after": null,
+ "selector-pseudo-element-colon-notation": null,
+ "unit-no-unknown": null,
+ "value-list-max-empty-lines": null
+ }
+}
diff --git a/src/main/frontend/.travis.yml b/src/main/frontend/.travis.yml
new file mode 100644
index 0000000..7115c31
--- /dev/null
+++ b/src/main/frontend/.travis.yml
@@ -0,0 +1,33 @@
+language: node_js
+
+node_js:
+ - "8"
+
+env:
+ matrix:
+ - TEST_TYPE=lint
+ - TEST_TYPE=test-all
+ - TEST_TYPE=test-dist
+
+addons:
+ apt:
+ packages:
+ - xvfb
+
+install:
+ - export DISPLAY=':99.0'
+ - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
+ - npm install
+
+script:
+ - |
+ if [ "$TEST_TYPE" = lint ]; then
+ npm run lint
+ elif [ "$TEST_TYPE" = test-all ]; then
+ npm run test:all
+ elif [ "$TEST_TYPE" = test-dist ]; then
+ npm run site
+ mv dist/* ./
+ php -S localhost:8000 &
+ npm test .e2e.js
+ fi
diff --git a/src/main/frontend/LICENSE b/src/main/frontend/LICENSE
new file mode 100644
index 0000000..8dada3e
--- /dev/null
+++ b/src/main/frontend/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/src/main/frontend/README.md b/src/main/frontend/README.md
new file mode 100644
index 0000000..7a6a4c1
--- /dev/null
+++ b/src/main/frontend/README.md
@@ -0,0 +1 @@
+# SkyWalking UI
diff --git a/src/main/frontend/appveyor.yml b/src/main/frontend/appveyor.yml
new file mode 100644
index 0000000..ee06d65
--- /dev/null
+++ b/src/main/frontend/appveyor.yml
@@ -0,0 +1,22 @@
+# Test against the latest version of this Node.js version
+environment:
+ nodejs_version: "8"
+
+# Install scripts. (runs after repo cloning)
+install:
+ # Get the latest stable version of Node.js or io.js
+ - ps: Install-Product node $env:nodejs_version
+ # install modules
+ - npm install
+ # Output useful info for debugging.
+ - node --version
+ - npm --version
+
+# Post-install test scripts.
+test_script:
+ - npm run lint
+ - npm run test:all
+ - npm run build
+
+# Don't actually build.
+build: off
diff --git a/src/main/frontend/mock/.gitkeep b/src/main/frontend/mock/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/frontend/mock/notices.js b/src/main/frontend/mock/notices.js
new file mode 100644
index 0000000..2b69a25
--- /dev/null
+++ b/src/main/frontend/mock/notices.js
@@ -0,0 +1,85 @@
+export default {
+ getNotices(req, res) {
+ res.json([{
+ id: '000000001',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
+ title: '你收到了 14 份新周报',
+ datetime: '2017-08-09',
+ type: '告警',
+ }, {
+ id: '000000002',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png',
+ title: '你推荐的 曲妮妮 已通过第三轮面试',
+ datetime: '2017-08-08',
+ type: '告警',
+ }, {
+ id: '000000003',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png',
+ title: '这种模板可以区分多种通知类型',
+ datetime: '2017-08-07',
+ read: true,
+ type: '告警',
+ }, {
+ id: '000000004',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png',
+ title: '左侧图标用于区分不同的类型',
+ datetime: '2017-08-07',
+ type: '通知',
+ }, {
+ id: '000000005',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
+ title: '内容不要超过两行字,超出时自动截断',
+ datetime: '2017-08-07',
+ type: '通知',
+ }, {
+ id: '000000006',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+ title: '曲丽丽 评论了你',
+ description: '描述信息描述信息描述信息',
+ datetime: '2017-08-07',
+ type: '消息',
+ }, {
+ id: '000000007',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+ title: '朱偏右 回复了你',
+ description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
+ datetime: '2017-08-07',
+ type: '消息',
+ }, {
+ id: '000000008',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+ title: '标题',
+ description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
+ datetime: '2017-08-07',
+ type: '消息',
+ }, {
+ id: '000000009',
+ title: '任务名称',
+ description: '任务需要在 2017-01-12 20:00 前启动',
+ extra: '未开始',
+ status: 'todo',
+ type: '待办',
+ }, {
+ id: '000000010',
+ title: '第三方紧急代码变更',
+ description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
+ extra: '马上到期',
+ status: 'urgent',
+ type: '待办',
+ }, {
+ id: '000000011',
+ title: '信息安全考试',
+ description: '指派竹尔于 2017-01-09 前完成更新并发布',
+ extra: '已耗时 8 天',
+ status: 'doing',
+ type: '待办',
+ }, {
+ id: '000000012',
+ title: 'ABCD 版本发布',
+ description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
+ extra: '进行中',
+ status: 'processing',
+ type: '待办',
+ }]);
+ },
+};
diff --git a/src/main/frontend/mock/rule.js b/src/main/frontend/mock/rule.js
new file mode 100644
index 0000000..4a1a4da
--- /dev/null
+++ b/src/main/frontend/mock/rule.js
@@ -0,0 +1,127 @@
+import { getUrlParams } from './utils';
+
+// mock tableListDataSource
+let tableListDataSource = [];
+for (let i = 0; i < 46; i += 1) {
+ tableListDataSource.push({
+ key: i,
+ disabled: ((i % 6) === 0),
+ href: 'https://ant.design',
+ avatar: ['https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png', 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png'][i % 2],
+ no: `TradeCode ${i}`,
+ title: `一个任务名称 ${i}`,
+ owner: '曲丽丽',
+ description: '这是一段描述',
+ callNo: Math.floor(Math.random() * 1000),
+ status: Math.floor(Math.random() * 10) % 4,
+ updatedAt: new Date(`2017-07-${Math.floor(i / 2) + 1}`),
+ createdAt: new Date(`2017-07-${Math.floor(i / 2) + 1}`),
+ progress: Math.ceil(Math.random() * 100),
+ });
+}
+
+export function getRule(req, res, u) {
+ let url = u;
+ if (!url || Object.prototype.toString.call(url) !== '[object String]') {
+ url = req.url; // eslint-disable-line
+ }
+
+ const params = getUrlParams(url);
+
+ let dataSource = [...tableListDataSource];
+
+ if (params.sorter) {
+ const s = params.sorter.split('_');
+ dataSource = dataSource.sort((prev, next) => {
+ if (s[1] === 'descend') {
+ return next[s[0]] - prev[s[0]];
+ }
+ return prev[s[0]] - next[s[0]];
+ });
+ }
+
+ if (params.status) {
+ const s = params.status.split(',');
+ if (s.length === 1) {
+ dataSource = dataSource.filter(data => parseInt(data.status, 10) === parseInt(s[0], 10));
+ }
+ }
+
+ if (params.no) {
+ dataSource = dataSource.filter(data => data.no.indexOf(params.no) > -1);
+ }
+
+ let pageSize = 10;
+ if (params.pageSize) {
+ pageSize = params.pageSize * 1;
+ }
+
+ const result = {
+ list: dataSource,
+ pagination: {
+ total: dataSource.length,
+ pageSize,
+ current: parseInt(params.currentPage, 10) || 1,
+ },
+ };
+
+ if (res && res.json) {
+ res.json(result);
+ } else {
+ return result;
+ }
+}
+
+export function postRule(req, res, u, b) {
+ let url = u;
+ if (!url || Object.prototype.toString.call(url) !== '[object String]') {
+ url = req.url; // eslint-disable-line
+ }
+
+ const body = (b && b.body) || req.body;
+ const { method, no, description } = body;
+
+ switch (method) {
+ /* eslint no-case-declarations:0 */
+ case 'delete':
+ tableListDataSource = tableListDataSource.filter(item => no.indexOf(item.no) === -1);
+ break;
+ case 'post':
+ const i = Math.ceil(Math.random() * 10000);
+ tableListDataSource.unshift({
+ key: i,
+ href: 'https://ant.design',
+ avatar: ['https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png', 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png'][i % 2],
+ no: `TradeCode ${i}`,
+ title: `一个任务名称 ${i}`,
+ owner: '曲丽丽',
+ description,
+ callNo: Math.floor(Math.random() * 1000),
+ status: Math.floor(Math.random() * 10) % 2,
+ updatedAt: new Date(),
+ createdAt: new Date(),
+ progress: Math.ceil(Math.random() * 100),
+ });
+ break;
+ default:
+ break;
+ }
+
+ const result = {
+ list: tableListDataSource,
+ pagination: {
+ total: tableListDataSource.length,
+ },
+ };
+
+ if (res && res.json) {
+ res.json(result);
+ } else {
+ return result;
+ }
+}
+
+export default {
+ getRule,
+ postRule,
+};
diff --git a/src/main/frontend/mock/utils.js b/src/main/frontend/mock/utils.js
new file mode 100644
index 0000000..8438a26
--- /dev/null
+++ b/src/main/frontend/mock/utils.js
@@ -0,0 +1,45 @@
+export const imgMap = {
+ user: 'https://gw.alipayobjects.com/zos/rmsportal/UjusLxePxWGkttaqqmUI.png',
+ a: 'https://gw.alipayobjects.com/zos/rmsportal/ZrkcSjizAKNWwJTwcadT.png',
+ b: 'https://gw.alipayobjects.com/zos/rmsportal/KYlwHMeomKQbhJDRUVvt.png',
+ c: 'https://gw.alipayobjects.com/zos/rmsportal/gabvleTstEvzkbQRfjxu.png',
+ d: 'https://gw.alipayobjects.com/zos/rmsportal/jvpNzacxUYLlNsHTtrAD.png',
+};
+
+// refers: https://www.sitepoint.com/get-url-parameters-with-javascript/
+export function getUrlParams(url) {
+ const d = decodeURIComponent;
+ let queryString = url ? url.split('?')[1] : window.location.search.slice(1);
+ const obj = {};
+ if (queryString) {
+ queryString = queryString.split('#')[0]; // eslint-disable-line
+ const arr = queryString.split('&');
+ for (let i = 0; i < arr.length; i += 1) {
+ const a = arr[i].split('=');
+ let paramNum;
+ const paramName = a[0].replace(/\[\d*\]/, (v) => {
+ paramNum = v.slice(1, -1);
+ return '';
+ });
+ const paramValue = typeof (a[1]) === 'undefined' ? true : a[1];
+ if (obj[paramName]) {
+ if (typeof obj[paramName] === 'string') {
+ obj[paramName] = d([obj[paramName]]);
+ }
+ if (typeof paramNum === 'undefined') {
+ obj[paramName].push(d(paramValue));
+ } else {
+ obj[paramName][paramNum] = d(paramValue);
+ }
+ } else {
+ obj[paramName] = d(paramValue);
+ }
+ }
+ }
+ return obj;
+}
+
+export default {
+ getUrlParams,
+ imgMap,
+};
diff --git a/src/main/frontend/package.json b/src/main/frontend/package.json
new file mode 100755
index 0000000..2deb001
--- /dev/null
+++ b/src/main/frontend/package.json
@@ -0,0 +1,107 @@
+{
+ "name": "skywalking-ui",
+ "version": "5.0.0-alpha",
+ "description": "A web interface of SkyWalking",
+ "private": true,
+ "scripts": {
+ "start": "roadhog server",
+ "start:no-proxy": "cross-env NO_PROXY=true roadhog server",
+ "build": "roadhog build",
+ "site": "roadhog-api-doc static && gh-pages -d dist",
+ "analyze": "roadhog build --analyze",
+ "lint:style": "stylelint \"src/**/*.less\" --syntax less",
+ "lint": "eslint --ext .js src mock tests && npm run lint:style",
+ "lint:fix": "eslint --fix --ext .js src mock tests && npm run lint:style",
+ "lint-staged": "lint-staged",
+ "lint-staged:js": "eslint --ext .js",
+ "test": "jest",
+ "test:all": "node ./tests/run-tests.js"
+ },
+ "dependencies": {
+ "ant-design-pro": "^0.2.3-rc.1",
+ "antd": "^3.0.0-beta.1",
+ "babel-runtime": "^6.9.2",
+ "classnames": "^2.2.5",
+ "core-js": "^2.5.1",
+ "dva": "^2.0.3",
+ "lodash": "^4.17.4",
+ "lodash-decorators": "^4.4.1",
+ "lodash.clonedeep": "^4.5.0",
+ "moment": "^2.19.1",
+ "numeral": "^2.0.6",
+ "prop-types": "^15.5.10",
+ "qs": "^6.5.0",
+ "react": "^16.0.0",
+ "react-container-query": "^0.9.1",
+ "react-document-title": "^2.0.3",
+ "react-dom": "^16.0.0",
+ "react-fittext": "^1.0.0"
+ },
+ "devDependencies": {
+ "babel-eslint": "^8.0.1",
+ "babel-jest": "^21.0.0",
+ "babel-plugin-dva-hmr": "^0.3.2",
+ "babel-plugin-import": "^1.2.1",
+ "babel-plugin-transform-class-properties": "^6.24.1",
+ "babel-plugin-transform-decorators-legacy": "^1.3.4",
+ "babel-plugin-transform-runtime": "^6.9.0",
+ "babel-preset-env": "^1.6.1",
+ "babel-preset-react": "^6.24.1",
+ "cross-env": "^5.1.1",
+ "cross-port-killer": "^1.0.1",
+ "enzyme": "^3.1.0",
+ "enzyme-adapter-react-16": "^1.0.2",
+ "eslint": "^4.8.0",
+ "eslint-config-airbnb": "^16.0.0",
+ "eslint-plugin-babel": "^4.0.0",
+ "eslint-plugin-import": "^2.2.0",
+ "eslint-plugin-jsx-a11y": "^6.0.0",
+ "eslint-plugin-markdown": "^1.0.0-beta.6",
+ "eslint-plugin-react": "^7.0.1",
+ "gh-pages": "^1.0.0",
+ "husky": "^0.14.3",
+ "jest": "^21.0.1",
+ "lint-staged": "^4.3.0",
+ "mockjs": "^1.0.1-beta3",
+ "pro-download": "^1.0.0",
+ "react-test-renderer": "^16.0.0",
+ "redbox-react": "^1.3.2",
+ "roadhog": "^1.3.1",
+ "roadhog-api-doc": "^0.2.5",
+ "stylelint": "^8.1.0",
+ "stylelint-config-standard": "^17.0.0"
+ },
+ "optionalDependencies": {
+ "nightmare": "^2.10.0"
+ },
+ "babel": {
+ "presets": [
+ "env",
+ "react"
+ ],
+ "plugins": [
+ "transform-decorators-legacy",
+ "transform-class-properties"
+ ]
+ },
+ "jest": {
+ "setupFiles": [
+ "<rootDir>/tests/setupTests.js"
+ ],
+ "testMatch": [
+ "**/?(*.)(spec|test|e2e).js?(x)"
+ ],
+ "setupTestFrameworkScriptFile": "<rootDir>/tests/jasmine.js",
+ "moduleFileExtensions": [
+ "js",
+ "jsx"
+ ],
+ "moduleNameMapper": {
+ "\\.(css|less)$": "<rootDir>/tests/styleMock.js"
+ }
+ },
+ "lint-staged": {
+ "**/*.{js,jsx}": "lint-staged:js",
+ "**/*.less": "stylelint --syntax less"
+ }
+}
diff --git a/src/main/frontend/public/index.html b/src/main/frontend/public/index.html
new file mode 100755
index 0000000..5d5ec75
--- /dev/null
+++ b/src/main/frontend/public/index.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Apache SkyWalking</title>
+ <link rel="stylesheet" href="index.css" />
+</head>
+<body>
+ <div id="root"></div>
+ <script src="https://gw.alipayobjects.com/as/g/??datavis/g2/2.3.12/index.js,datavis/g-cloud/1.0.2/index.js,datavis/g2-plugin-slider/1.2.1/slider.js"></script>
+ <script src="index.js"></script>
+</body>
+</html>
diff --git a/src/main/frontend/src/common/nav.js b/src/main/frontend/src/common/nav.js
new file mode 100644
index 0000000..2d7e9dd
--- /dev/null
+++ b/src/main/frontend/src/common/nav.js
@@ -0,0 +1,26 @@
+import dynamic from 'dva/dynamic';
+
+// wrapper of dynamic
+const dynamicWrapper = (app, models, component) => dynamic({
+ app,
+ models: () => models.map(m => import(`../models/${m}.js`)),
+ component,
+});
+
+// nav data
+export const getNavData = app => [
+ {
+ component: dynamicWrapper(app, ['user'], () => import('../layouts/BasicLayout')),
+ layout: 'BasicLayout',
+ name: 'Main', // for breadcrumb
+ path: '/',
+ children: [
+ {
+ name: 'Dashboard',
+ icon: 'dashboard',
+ path: 'dashboard',
+ component: dynamicWrapper(app, ['dashboard'], () => import('../routes/Dashboard/Dashboard')),
+ },
+ ],
+ },
+];
diff --git a/src/main/frontend/src/components/StandardFormRow/index.js b/src/main/frontend/src/components/StandardFormRow/index.js
new file mode 100644
index 0000000..4ed6d9d
--- /dev/null
+++ b/src/main/frontend/src/components/StandardFormRow/index.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import classNames from 'classnames';
+import styles from './index.less';
+
+export default ({ title, children, last, block, grid, ...rest }) => {
+ const cls = classNames(styles.standardFormRow, {
+ [styles.standardFormRowBlock]: block,
+ [styles.standardFormRowLast]: last,
+ [styles.standardFormRowGrid]: grid,
+ });
+
+ return (
+ <div className={cls} {...rest}>
+ {
+ title && (
+ <div className={styles.label}>
+ <span>{title}</span>
+ </div>
+ )
+ }
+ <div className={styles.content}>
+ {children}
+ </div>
+ </div>
+ );
+};
diff --git a/src/main/frontend/src/components/StandardFormRow/index.less b/src/main/frontend/src/components/StandardFormRow/index.less
new file mode 100644
index 0000000..291d127
--- /dev/null
+++ b/src/main/frontend/src/components/StandardFormRow/index.less
@@ -0,0 +1,71 @@
+@import "~antd/lib/style/themes/default.less";
+
+.standardFormRow {
+ border-bottom: 1px dashed @border-color-split;
+ padding-bottom: 16px;
+ margin-bottom: 16px;
+ display: flex;
+ :global {
+ .ant-form-item {
+ margin-right: 24px;
+ }
+ .ant-form-item-label label {
+ color: @text-color;
+ margin-right: 0;
+ }
+ .ant-form-item-label {
+ padding: 0;
+ line-height: 32px;
+ }
+ }
+ .label {
+ color: @heading-color;
+ font-size: @font-size-base;
+ margin-right: 24px;
+ flex: 0 0 auto;
+ text-align: right;
+ & > span {
+ display: inline-block;
+ height: 32px;
+ line-height: 32px;
+ &:after {
+ content: ':';
+ }
+ }
+ }
+ .content {
+ flex: 1 1 0;
+ :global {
+ .ant-form-item:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+}
+
+.standardFormRowLast {
+ border: none;
+ padding-bottom: 0;
+ margin-bottom: 0;
+}
+
+.standardFormRowBlock {
+ :global {
+ .ant-form-item,
+ div.ant-form-item-control-wrapper {
+ display: block;
+ }
+ }
+}
+
+.standardFormRowGrid {
+ :global {
+ .ant-form-item,
+ div.ant-form-item-control-wrapper {
+ display: block;
+ }
+ .ant-form-item-label {
+ float: left;
+ }
+ }
+}
diff --git a/src/main/frontend/src/components/StandardTable/index.js b/src/main/frontend/src/components/StandardTable/index.js
new file mode 100644
index 0000000..3a4ec8a
--- /dev/null
+++ b/src/main/frontend/src/components/StandardTable/index.js
@@ -0,0 +1,154 @@
+import React, { PureComponent } from 'react';
+import moment from 'moment';
+import { Table, Alert, Badge, Divider } from 'antd';
+import styles from './index.less';
+
+const statusMap = ['default', 'processing', 'success', 'error'];
+class StandardTable extends PureComponent {
+ state = {
+ selectedRowKeys: [],
+ totalCallNo: 0,
+ };
+
+ componentWillReceiveProps(nextProps) {
+ // clean state
+ if (nextProps.selectedRows.length === 0) {
+ this.setState({
+ selectedRowKeys: [],
+ totalCallNo: 0,
+ });
+ }
+ }
+
+ handleRowSelectChange = (selectedRowKeys, selectedRows) => {
+ const totalCallNo = selectedRows.reduce((sum, val) => {
+ return sum + parseFloat(val.callNo, 10);
+ }, 0);
+
+ if (this.props.onSelectRow) {
+ this.props.onSelectRow(selectedRows);
+ }
+
+ this.setState({ selectedRowKeys, totalCallNo });
+ }
+
+ handleTableChange = (pagination, filters, sorter) => {
+ this.props.onChange(pagination, filters, sorter);
+ }
+
+ cleanSelectedKeys = () => {
+ this.handleRowSelectChange([], []);
+ }
+
+ render() {
+ const { selectedRowKeys, totalCallNo } = this.state;
+ const { data: { list, pagination }, loading } = this.props;
+
+ const status = ['关闭', '运行中', '已上线', '异常'];
+
+ const columns = [
+ {
+ title: '规则编号',
+ dataIndex: 'no',
+ },
+ {
+ title: '描述',
+ dataIndex: 'description',
+ },
+ {
+ title: '服务调用次数',
+ dataIndex: 'callNo',
+ sorter: true,
+ render: val => (
+ <div style={{ textAlign: 'center' }}>
+ {val} 万
+ </div>
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ filters: [
+ {
+ text: status[0],
+ value: 0,
+ },
+ {
+ text: status[1],
+ value: 1,
+ },
+ {
+ text: status[2],
+ value: 2,
+ },
+ {
+ text: status[3],
+ value: 3,
+ },
+ ],
+ render(val) {
+ return <Badge status={statusMap[val]} text={status[val]} />;
+ },
+ },
+ {
+ title: '更新时间',
+ dataIndex: 'updatedAt',
+ sorter: true,
+ render: val => <span>{moment(val).format('YYYY-MM-DD HH:mm:ss')}</span>,
+ },
+ {
+ title: '操作',
+ render: () => (
+ <div>
+ <a href="">配置</a>
+ <Divider type="vertical" />
+ <a href="">订阅警报</a>
+ </div>
+ ),
+ },
+ ];
+
+ const paginationProps = {
+ showSizeChanger: true,
+ showQuickJumper: true,
+ ...pagination,
+ };
+
+ const rowSelection = {
+ selectedRowKeys,
+ onChange: this.handleRowSelectChange,
+ getCheckboxProps: record => ({
+ disabled: record.disabled,
+ }),
+ };
+
+ return (
+ <div className={styles.standardTable}>
+ <div className={styles.tableAlert}>
+ <Alert
+ message={(
+ <div>
+ 已选择 <a style={{ fontWeight: 600 }}>{selectedRowKeys.length}</a> 项
+ 服务调用总计 <span style={{ fontWeight: 600 }}>{totalCallNo}</span> 万
+ <a onClick={this.cleanSelectedKeys} style={{ marginLeft: 24 }}>清空</a>
+ </div>
+ )}
+ type="info"
+ showIcon
+ />
+ </div>
+ <Table
+ loading={loading}
+ rowKey={record => record.key}
+ rowSelection={rowSelection}
+ dataSource={list}
+ columns={columns}
+ pagination={paginationProps}
+ onChange={this.handleTableChange}
+ />
+ </div>
+ );
+ }
+}
+
+export default StandardTable;
diff --git a/src/main/frontend/src/components/StandardTable/index.less b/src/main/frontend/src/components/StandardTable/index.less
new file mode 100644
index 0000000..4ced9e7
--- /dev/null
+++ b/src/main/frontend/src/components/StandardTable/index.less
@@ -0,0 +1,13 @@
+@import "~antd/lib/style/themes/default.less";
+
+.standardTable {
+ :global {
+ .ant-table-pagination {
+ margin-top: 24px;
+ }
+ }
+
+ .tableAlert {
+ margin-bottom: 16px;
+ }
+}
diff --git a/src/main/frontend/src/e2e/home.e2e.js b/src/main/frontend/src/e2e/home.e2e.js
new file mode 100644
index 0000000..6de577b
--- /dev/null
+++ b/src/main/frontend/src/e2e/home.e2e.js
@@ -0,0 +1,9 @@
+import Nightmare from 'nightmare';
+
+describe('Homepage', () => {
+ it('it should have logo text', async () => {
+ const page = Nightmare().goto('http://localhost:8000');
+ const text = await page.evaluate(() => document.body.innerHTML).end();
+ expect(text).toContain('<h1>Ant Design Pro</h1>');
+ });
+});
diff --git a/src/main/frontend/src/index.js b/src/main/frontend/src/index.js
new file mode 100644
index 0000000..aad2aaa
--- /dev/null
+++ b/src/main/frontend/src/index.js
@@ -0,0 +1,22 @@
+import dva from 'dva';
+import 'ant-design-pro/dist/ant-design-pro.css';
+// import browserHistory from 'history/createBrowserHistory';
+import './polyfill';
+import './index.less';
+
+// 1. Initialize
+const app = dva({
+ // history: browserHistory(),
+});
+
+// 2. Plugins
+// app.use({});
+
+// 3. Register global model
+app.model(require('./models/global'));
+
+// 4. Router
+app.router(require('./router'));
+
+// 5. Start
+app.start('#root');
diff --git a/src/main/frontend/src/index.less b/src/main/frontend/src/index.less
new file mode 100644
index 0000000..e1a890a
--- /dev/null
+++ b/src/main/frontend/src/index.less
@@ -0,0 +1,16 @@
+
+
+html, body, :global(#root) {
+ height: 100%;
+}
+
+body {
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.globalSpin {
+ width: 100%;
+ margin: 40px 0 !important;
+}
diff --git a/src/main/frontend/src/layouts/BasicLayout.js b/src/main/frontend/src/layouts/BasicLayout.js
new file mode 100644
index 0000000..6eab185
--- /dev/null
+++ b/src/main/frontend/src/layouts/BasicLayout.js
@@ -0,0 +1,375 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import DocumentTitle from 'react-document-title';
+
+import { connect } from 'dva';
+import { Link, Route, Redirect, Switch } from 'dva/router';
+import { Layout, Menu, Icon, Dropdown, Tag, message } from 'antd';
+
+import NoticeIcon from 'ant-design-pro/lib/NoticeIcon';
+import GlobalFooter from 'ant-design-pro/lib/GlobalFooter';
+
+import moment from 'moment';
+import groupBy from 'lodash/groupBy';
+import classNames from 'classnames';
+import { ContainerQuery } from 'react-container-query';
+
+import styles from './BasicLayout.less';
+
+const { Header, Sider, Content } = Layout;
+const { SubMenu } = Menu;
+
+const query = {
+ 'screen-xs': {
+ maxWidth: 575,
+ },
+ 'screen-sm': {
+ minWidth: 576,
+ maxWidth: 767,
+ },
+ 'screen-md': {
+ minWidth: 768,
+ maxWidth: 991,
+ },
+ 'screen-lg': {
+ minWidth: 992,
+ maxWidth: 1199,
+ },
+ 'screen-xl': {
+ minWidth: 1200,
+ },
+};
+
+class BasicLayout extends React.PureComponent {
+ static childContextTypes = {
+ location: PropTypes.object,
+ breadcrumbNameMap: PropTypes.object,
+ }
+ constructor(props) {
+ super(props);
+ // 把一级 Layout 的 children 作为菜单项
+ this.menus = props.navData.reduce((arr, current) => arr.concat(current.children), []);
+ this.state = {
+ openKeys: this.getDefaultCollapsedSubMenus(props),
+ };
+ }
+ getChildContext() {
+ const { location, navData, getRouteData } = this.props;
+ const routeData = getRouteData('BasicLayout');
+ const firstMenuData = navData.reduce((arr, current) => arr.concat(current.children), []);
+ const menuData = this.getMenuData(firstMenuData, '');
+ const breadcrumbNameMap = {};
+
+ routeData.concat(menuData).forEach((item) => {
+ breadcrumbNameMap[item.path] = item.name;
+ });
+ return { location, breadcrumbNameMap };
+ }
+ componentDidMount() {
+ this.props.dispatch({
+ type: 'user/fetchCurrent',
+ });
+ }
+ componentWillUnmount() {
+ clearTimeout(this.resizeTimeout);
+ }
+ onCollapse = (collapsed) => {
+ this.props.dispatch({
+ type: 'global/changeLayoutCollapsed',
+ payload: collapsed,
+ });
+ }
+ onMenuClick = ({ key }) => {
+ if (key === 'logout') {
+ this.props.dispatch({
+ type: 'login/logout',
+ });
+ }
+ }
+ getMenuData = (data, parentPath) => {
+ let arr = [];
+ data.forEach((item) => {
+ if (item.children) {
+ arr.push({ path: `${parentPath}/${item.path}`, name: item.name });
+ arr = arr.concat(this.getMenuData(item.children, `${parentPath}/${item.path}`));
+ }
+ });
+ return arr;
+ }
+ getDefaultCollapsedSubMenus(props) {
+ const currentMenuSelectedKeys = [...this.getCurrentMenuSelectedKeys(props)];
+ currentMenuSelectedKeys.splice(-1, 1);
+ if (currentMenuSelectedKeys.length === 0) {
+ return ['dashboard'];
+ }
+ return currentMenuSelectedKeys;
+ }
+ getCurrentMenuSelectedKeys(props) {
+ const { location: { pathname } } = props || this.props;
+ const keys = pathname.split('/').slice(1);
+ if (keys.length === 1 && keys[0] === '') {
+ return [this.menus[0].key];
+ }
+ return keys;
+ }
+ getNavMenuItems(menusData, parentPath = '') {
+ if (!menusData) {
+ return [];
+ }
+ return menusData.map((item) => {
+ if (!item.name) {
+ return null;
+ }
+ let itemPath;
+ if (item.path.indexOf('http') === 0) {
+ itemPath = item.path;
+ } else {
+ itemPath = `${parentPath}/${item.path || ''}`.replace(/\/+/g, '/');
+ }
+ if (item.children && item.children.some(child => child.name)) {
+ return (
+ <SubMenu
+ title={
+ item.icon ? (
+ <span>
+ <Icon type={item.icon} />
+ <span>{item.name}</span>
+ </span>
+ ) : item.name
+ }
+ key={item.key || item.path}
+ >
+ {this.getNavMenuItems(item.children, itemPath)}
+ </SubMenu>
+ );
+ }
+ const icon = item.icon && <Icon type={item.icon} />;
+ return (
+ <Menu.Item key={item.key || item.path}>
+ {
+ /^https?:\/\//.test(itemPath) ? (
+ <a href={itemPath} target={item.target}>
+ {icon}<span>{item.name}</span>
+ </a>
+ ) : (
+ <Link
+ to={itemPath}
+ target={item.target}
+ replace={itemPath === this.props.location.pathname}
+ >
+ {icon}<span>{item.name}</span>
+ </Link>
+ )
+ }
+ </Menu.Item>
+ );
+ });
+ }
+ getPageTitle() {
+ const { location, getRouteData } = this.props;
+ const { pathname } = location;
+ let title = 'SkyWalking';
+ getRouteData('BasicLayout').forEach((item) => {
+ if (item.path === pathname) {
+ title = `${item.name} - SkyWalking`;
+ }
+ });
+ return title;
+ }
+ getNoticeData() {
+ const { notices = [] } = this.props;
+ if (notices.length === 0) {
+ return {};
+ }
+ const newNotices = notices.map((notice) => {
+ const newNotice = { ...notice };
+ if (newNotice.datetime) {
+ newNotice.datetime = moment(notice.datetime).fromNow();
+ }
+ // transform id to item key
+ if (newNotice.id) {
+ newNotice.key = newNotice.id;
+ }
+ if (newNotice.extra && newNotice.status) {
+ const color = ({
+ todo: '',
+ processing: 'blue',
+ urgent: 'red',
+ doing: 'gold',
+ })[newNotice.status];
+ newNotice.extra = <Tag color={color} style={{ marginRight: 0 }}>{newNotice.extra}</Tag>;
+ }
+ return newNotice;
+ });
+ return groupBy(newNotices, 'type');
+ }
+ handleOpenChange = (openKeys) => {
+ const lastOpenKey = openKeys[openKeys.length - 1];
+ const isMainMenu = this.menus.some(
+ item => lastOpenKey && (item.key === lastOpenKey || item.path === lastOpenKey)
+ );
+ this.setState({
+ openKeys: isMainMenu ? [lastOpenKey] : [...openKeys],
+ });
+ }
+ toggle = () => {
+ const { collapsed } = this.props;
+ this.props.dispatch({
+ type: 'global/changeLayoutCollapsed',
+ payload: !collapsed,
+ });
+ this.resizeTimeout = setTimeout(() => {
+ const event = document.createEvent('HTMLEvents');
+ event.initEvent('resize', true, false);
+ window.dispatchEvent(event);
+ }, 600);
+ }
+ handleNoticeClear = (type) => {
+ message.success(`清空了${type}`);
+ this.props.dispatch({
+ type: 'global/clearNotices',
+ payload: type,
+ });
+ }
+ handleNoticeVisibleChange = (visible) => {
+ if (visible) {
+ this.props.dispatch({
+ type: 'global/fetchNotices',
+ });
+ }
+ }
+ render() {
+ const { currentUser, collapsed, fetchingNotices, getRouteData } = this.props;
+
+ const menu = (
+ <Menu selectedKeys={['1']} onClick={this.onMenuClick}>
+ <Menu.Item key="1">Last 15 minutes</Menu.Item>
+ <Menu.Item key="2">Last 1 hour</Menu.Item>
+ </Menu>
+ );
+ const noticeData = this.getNoticeData();
+
+ // Don't show popup menu when it is been collapsed
+ const menuProps = collapsed ? {} : {
+ openKeys: this.state.openKeys,
+ };
+
+ const layout = (
+ <Layout>
+ <Sider
+ trigger={null}
+ collapsible
+ collapsed={collapsed}
+ breakpoint="md"
+ onCollapse={this.onCollapse}
+ width={256}
+ className={styles.sider}
+ >
+ <div className={styles.logo}>
+ <Link to="/">
+ <img src="https://camo.githubusercontent.com/4ac940361b7345156ff71aa21efdb42a449e67d7/68747470733a2f2f736b7977616c6b696e67746573742e6769746875622e696f2f706167652d7265736f75726365732f332e302f736b7977616c6b696e672e706e67" alt="logo" />
+ </Link>
+ </div>
+ <Menu
+ theme="dark"
+ mode="inline"
+ {...menuProps}
+ onOpenChange={this.handleOpenChange}
+ selectedKeys={this.getCurrentMenuSelectedKeys()}
+ style={{ margin: '16px 0', width: '100%' }}
+ >
+ {this.getNavMenuItems(this.menus)}
+ </Menu>
+ </Sider>
+ <Layout>
+ <Header className={styles.header}>
+ <Icon
+ className={styles.trigger}
+ type={collapsed ? 'menu-unfold' : 'menu-fold'}
+ onClick={this.toggle}
+ />
+ <div className={styles.right}>
+ <Dropdown overlay={menu}>
+ <span className={`${styles.action}`}>
+ Last 15 minutes
+ </span>
+ </Dropdown>
+ <NoticeIcon
+ className={styles.action}
+ count={currentUser.notifyCount}
+ onItemClick={(item, tabProps) => {
+ console.log(item, tabProps); // eslint-disable-line
+ }}
+ onClear={this.handleNoticeClear}
+ onPopupVisibleChange={this.handleNoticeVisibleChange}
+ loading={fetchingNotices}
+ popupAlign={{ offset: [20, -16] }}
+ >
+ <NoticeIcon.Tab
+ list={noticeData['告警']}
+ title="告警"
+ emptyText="无告警信息"
+ emptyImage="https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg"
+ />
+ <NoticeIcon.Tab
+ list={noticeData['消息']}
+ title="消息"
+ emptyText="您已读完所有消息"
+ emptyImage="https://gw.alipayobjects.com/zos/rmsportal/sAuJeJzSKbUmHfBQRzmZ.svg"
+ />
+ </NoticeIcon>
+ </div>
+ </Header>
+ <Content style={{ margin: '24px 24px 0', height: '100%' }}>
+ <Switch>
+ {
+ getRouteData('BasicLayout').map(item =>
+ (
+ <Route
+ exact={item.exact}
+ key={item.path}
+ path={item.path}
+ component={item.component}
+ />
+ )
+ )
+ }
+ <Redirect exact from="/" to="/dashboard" />
+ </Switch>
+ <GlobalFooter
+ links={[{
+ title: 'SkyWalking',
+ href: 'http://skywalking.io',
+ blankTarget: true,
+ }, {
+ title: 'GitHub',
+ href: 'https://github.com/apache/incubator-skywalking',
+ blankTarget: true,
+ }]}
+ copyright={
+ <div>
+ Copyright <Icon type="copyright" /> 2018 SkyWalking
+ </div>
+ }
+ />
+ </Content>
+ </Layout>
+ </Layout>
+ );
+
+ return (
+ <DocumentTitle title={this.getPageTitle()}>
+ <ContainerQuery query={query}>
+ {params => <div className={classNames(params)}>{layout}</div>}
+ </ContainerQuery>
+ </DocumentTitle>
+ );
+ }
+}
+
+export default connect(state => ({
+ currentUser: state.user.currentUser,
+ collapsed: state.global.collapsed,
+ fetchingNotices: state.global.fetchingNotices,
+ notices: state.global.notices,
+}))(BasicLayout);
diff --git a/src/main/frontend/src/layouts/BasicLayout.less b/src/main/frontend/src/layouts/BasicLayout.less
new file mode 100644
index 0000000..6ab937a
--- /dev/null
+++ b/src/main/frontend/src/layouts/BasicLayout.less
@@ -0,0 +1,113 @@
+@import "~antd/lib/style/themes/default.less";
+
+.header {
+ padding: 0 12px 0 0;
+ background: #fff;
+ box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
+ position: relative;
+}
+
+.logo {
+ height: 64px;
+ position: relative;
+ line-height: 64px;
+ padding-left: 24px;
+ transition: all .3s;
+ background: #002140;
+ overflow: hidden;
+ img {
+ display: inline-block;
+ vertical-align: middle;
+ height: 32px;
+ }
+ h1 {
+ color: #fff;
+ display: inline-block;
+ vertical-align: middle;
+ font-size: 20px;
+ margin: 0 0 0 12px;
+ font-family: 'Myriad Pro', 'Helvetica Neue', Arial, Helvetica, sans-serif;
+ font-weight: 600;
+ }
+}
+
+:global(.ant-layout-sider-collapsed) .logo {
+ padding-left: 24px;
+ > a {
+ width: 32px;
+ }
+}
+
+.trigger {
+ font-size: 20px;
+ line-height: 64px;
+ cursor: pointer;
+ transition: all .3s;
+ padding: 0 24px;
+ &:hover {
+ background: @primary-1;
+ }
+}
+
+@media screen and (max-width: @screen-xs) {
+ .trigger {
+ display: none;
+ }
+}
+
+.right {
+ float: right;
+ height: 100%;
+ .action {
+ cursor: pointer;
+ padding: 0 12px;
+ display: inline-block;
+ transition: all .3s;
+ height: 100%;
+ > i {
+ font-size: 16px;
+ vertical-align: middle;
+ }
+ &:global(.ant-popover-open),
+ &:hover {
+ background: @primary-1;
+ }
+ }
+ .search {
+ padding: 0;
+ margin: 0 12px;
+ &:hover {
+ background: transparent;
+ }
+ }
+ .account {
+ .avatar {
+ margin: 20px 8px 20px 0;
+ color: @primary-color;
+ background: rgba(255, 255, 255, .85);
+ vertical-align: middle;
+ }
+ }
+}
+
+.menu {
+ :global(.anticon) {
+ margin-right: 8px;
+ }
+ :global(.ant-dropdown-menu-item) {
+ width: 160px;
+ }
+}
+
+:global {
+ .ant-layout {
+ overflow-x: hidden;
+ }
+}
+
+.sider {
+ min-height: 100vh;
+ box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
+ position: relative;
+ z-index: 10;
+}
diff --git a/src/main/frontend/src/layouts/BlankLayout.js b/src/main/frontend/src/layouts/BlankLayout.js
new file mode 100644
index 0000000..505270f
--- /dev/null
+++ b/src/main/frontend/src/layouts/BlankLayout.js
@@ -0,0 +1,3 @@
+import React from 'react';
+
+export default props => <div {...props} />;
diff --git a/src/main/frontend/src/layouts/PageHeaderLayout.js b/src/main/frontend/src/layouts/PageHeaderLayout.js
new file mode 100644
index 0000000..bc4a05c
--- /dev/null
+++ b/src/main/frontend/src/layouts/PageHeaderLayout.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Link } from 'dva/router';
+import PageHeader from 'ant-design-pro/lib/PageHeader';
+import styles from './PageHeaderLayout.less';
+
+export default ({ children, wrapperClassName, top, ...restProps }) => (
+ <div style={{ margin: '-24px -24px 0' }} className={wrapperClassName}>
+ {top}
+ <PageHeader {...restProps} linkElement={Link} />
+ {children ? <div className={styles.content}>{children}</div> : null}
+ </div>
+);
diff --git a/src/main/frontend/src/layouts/PageHeaderLayout.less b/src/main/frontend/src/layouts/PageHeaderLayout.less
new file mode 100644
index 0000000..a0c0a6e
--- /dev/null
+++ b/src/main/frontend/src/layouts/PageHeaderLayout.less
@@ -0,0 +1,11 @@
+@import "~antd/lib/style/themes/default.less";
+
+.content {
+ margin: 24px 24px 0;
+}
+
+@media screen and (max-width: @screen-sm) {
+ .content {
+ margin: 24px 0 0;
+ }
+}
diff --git a/src/main/frontend/src/models/dashboard.js b/src/main/frontend/src/models/dashboard.js
new file mode 100644
index 0000000..8370d25
--- /dev/null
+++ b/src/main/frontend/src/models/dashboard.js
@@ -0,0 +1,16 @@
+// import { xxx } from '../services/xxx';
+export default {
+ namespace: 'dashboar',
+ state: {},
+ effects: {
+ *fetch({ payload }, { call, put }) {
+ },
+ },
+ reducers: {
+ save(state, action) {
+ return {
+ ...state,
+ };
+ },
+ },
+};
diff --git a/src/main/frontend/src/models/global.js b/src/main/frontend/src/models/global.js
new file mode 100644
index 0000000..91127ed
--- /dev/null
+++ b/src/main/frontend/src/models/global.js
@@ -0,0 +1,76 @@
+import { queryNotices } from '../services/api';
+
+export default {
+ namespace: 'global',
+
+ state: {
+ collapsed: false,
+ notices: [],
+ fetchingNotices: false,
+ },
+
+ effects: {
+ *fetchNotices(_, { call, put }) {
+ yield put({
+ type: 'changeNoticeLoading',
+ payload: true,
+ });
+ const data = yield call(queryNotices);
+ yield put({
+ type: 'saveNotices',
+ payload: data,
+ });
+ },
+ *clearNotices({ payload }, { put, select }) {
+ const count = yield select(state => state.global.notices.length);
+ yield put({
+ type: 'user/changeNotifyCount',
+ payload: count,
+ });
+
+ yield put({
+ type: 'saveClearedNotices',
+ payload,
+ });
+ },
+ },
+
+ reducers: {
+ changeLayoutCollapsed(state, { payload }) {
+ return {
+ ...state,
+ collapsed: payload,
+ };
+ },
+ saveNotices(state, { payload }) {
+ return {
+ ...state,
+ notices: payload,
+ fetchingNotices: false,
+ };
+ },
+ saveClearedNotices(state, { payload }) {
+ return {
+ ...state,
+ notices: state.notices.filter(item => item.type !== payload),
+ };
+ },
+ changeNoticeLoading(state, { payload }) {
+ return {
+ ...state,
+ fetchingNotices: payload,
+ };
+ },
+ },
+
+ subscriptions: {
+ setup({ history }) {
+ // Subscribe history(url) change, trigger `load` action if pathname is `/`
+ return history.listen(({ pathname, search }) => {
+ if (typeof window.ga !== 'undefined') {
+ window.ga('send', 'pageview', pathname + search);
+ }
+ });
+ },
+ },
+};
diff --git a/src/main/frontend/src/models/index.js b/src/main/frontend/src/models/index.js
new file mode 100644
index 0000000..3666614
--- /dev/null
+++ b/src/main/frontend/src/models/index.js
@@ -0,0 +1,11 @@
+// Use require.context to require reducers automatically
+// Ref: https://webpack.github.io/docs/context.html
+const context = require.context('./', false, /\.js$/);
+const keys = context.keys().filter(item => item !== './index.js');
+
+const models = [];
+for (let i = 0; i < keys.length; i += 1) {
+ models.push(context(keys[i]));
+}
+
+export default models;
diff --git a/src/main/frontend/src/models/rule.js b/src/main/frontend/src/models/rule.js
new file mode 100644
index 0000000..8b36ba3
--- /dev/null
+++ b/src/main/frontend/src/models/rule.js
@@ -0,0 +1,80 @@
+import { queryRule, removeRule, addRule } from '../services/api';
+
+export default {
+ namespace: 'rule',
+
+ state: {
+ data: {
+ list: [],
+ pagination: {},
+ },
+ loading: true,
+ },
+
+ effects: {
+ *fetch({ payload }, { call, put }) {
+ yield put({
+ type: 'changeLoading',
+ payload: true,
+ });
+ const response = yield call(queryRule, payload);
+ yield put({
+ type: 'save',
+ payload: response,
+ });
+ yield put({
+ type: 'changeLoading',
+ payload: false,
+ });
+ },
+ *add({ payload, callback }, { call, put }) {
+ yield put({
+ type: 'changeLoading',
+ payload: true,
+ });
+ const response = yield call(addRule, payload);
+ yield put({
+ type: 'save',
+ payload: response,
+ });
+ yield put({
+ type: 'changeLoading',
+ payload: false,
+ });
+
+ if (callback) callback();
+ },
+ *remove({ payload, callback }, { call, put }) {
+ yield put({
+ type: 'changeLoading',
+ payload: true,
+ });
+ const response = yield call(removeRule, payload);
+ yield put({
+ type: 'save',
+ payload: response,
+ });
+ yield put({
+ type: 'changeLoading',
+ payload: false,
+ });
+
+ if (callback) callback();
+ },
+ },
+
+ reducers: {
+ save(state, action) {
+ return {
+ ...state,
+ data: action.payload,
+ };
+ },
+ changeLoading(state, action) {
+ return {
+ ...state,
+ loading: action.payload,
+ };
+ },
+ },
+};
diff --git a/src/main/frontend/src/models/user.js b/src/main/frontend/src/models/user.js
new file mode 100644
index 0000000..197e362
--- /dev/null
+++ b/src/main/frontend/src/models/user.js
@@ -0,0 +1,66 @@
+import { query as queryUsers, queryCurrent } from '../services/user';
+
+export default {
+ namespace: 'user',
+
+ state: {
+ list: [],
+ loading: false,
+ currentUser: {},
+ },
+
+ effects: {
+ *fetch(_, { call, put }) {
+ yield put({
+ type: 'changeLoading',
+ payload: true,
+ });
+ const response = yield call(queryUsers);
+ yield put({
+ type: 'save',
+ payload: response,
+ });
+ yield put({
+ type: 'changeLoading',
+ payload: false,
+ });
+ },
+ *fetchCurrent(_, { call, put }) {
+ const response = yield call(queryCurrent);
+ yield put({
+ type: 'saveCurrentUser',
+ payload: response,
+ });
+ },
+ },
+
+ reducers: {
+ save(state, action) {
+ return {
+ ...state,
+ list: action.payload,
+ };
+ },
+ changeLoading(state, action) {
+ return {
+ ...state,
+ loading: action.payload,
+ };
+ },
+ saveCurrentUser(state, action) {
+ return {
+ ...state,
+ currentUser: action.payload,
+ };
+ },
+ changeNotifyCount(state, action) {
+ return {
+ ...state,
+ currentUser: {
+ ...state.currentUser,
+ notifyCount: action.payload,
+ },
+ };
+ },
+ },
+};
diff --git a/src/main/frontend/src/polyfill.js b/src/main/frontend/src/polyfill.js
new file mode 100644
index 0000000..a358959
--- /dev/null
+++ b/src/main/frontend/src/polyfill.js
@@ -0,0 +1,7 @@
+import 'core-js/es6/map';
+import 'core-js/es6/set';
+
+global.requestAnimationFrame =
+ global.requestAnimationFrame || function requestAnimationFrame(callback) {
+ setTimeout(callback, 0);
+ };
diff --git a/src/main/frontend/src/router.js b/src/main/frontend/src/router.js
new file mode 100644
index 0000000..1c51d96
--- /dev/null
+++ b/src/main/frontend/src/router.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { Router, Route, Switch } from 'dva/router';
+import { Spin } from 'antd';
+import dynamic from 'dva/dynamic';
+import cloneDeep from 'lodash/cloneDeep';
+import { getNavData } from './common/nav';
+import { getPlainNode } from './utils/utils';
+
+import styles from './index.less';
+
+dynamic.setDefaultLoadingComponent(() => {
+ return <Spin size="large" className={styles.globalSpin} />;
+});
+
+function getRouteData(navData, path) {
+ if (!navData.some(item => item.layout === path) ||
+ !(navData.filter(item => item.layout === path)[0].children)) {
+ return null;
+ }
+ const route = cloneDeep(navData.filter(item => item.layout === path)[0]);
+ const nodeList = getPlainNode(route.children);
+ return nodeList;
+}
+
+function getLayout(navData, path) {
+ if (!navData.some(item => item.layout === path) ||
+ !(navData.filter(item => item.layout === path)[0].children)) {
+ return null;
+ }
+ const route = navData.filter(item => item.layout === path)[0];
+ return {
+ component: route.component,
+ layout: route.layout,
+ name: route.name,
+ path: route.path,
+ };
+}
+
+function RouterConfig({ history, app }) {
+ const navData = getNavData(app);
+ const BasicLayout = getLayout(navData, 'BasicLayout').component;
+
+ const passProps = {
+ app,
+ navData,
+ getRouteData: (path) => {
+ return getRouteData(navData, path);
+ },
+ };
+
+ return (
+ <Router history={history}>
+ <Switch>
+ <Route path="/" render={props => <BasicLayout {...props} {...passProps} />} />
+ </Switch>
+ </Router>
+ );
+}
+
+export default RouterConfig;
diff --git a/src/main/frontend/src/routes/Dashboard/Dashboard.js b/src/main/frontend/src/routes/Dashboard/Dashboard.js
new file mode 100644
index 0000000..353eca8
--- /dev/null
+++ b/src/main/frontend/src/routes/Dashboard/Dashboard.js
@@ -0,0 +1,13 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'dva';
+
+@connect(state => ({
+ dashboard: state.dashboard,
+}))
+export default class Dashboard extends PureComponent {
+ render() {
+ return (
+ <div>test</div>
+ );
+ }
+}
diff --git a/src/main/frontend/src/routes/Dashboard/Dashboard.less b/src/main/frontend/src/routes/Dashboard/Dashboard.less
new file mode 100644
index 0000000..e52dd30
--- /dev/null
+++ b/src/main/frontend/src/routes/Dashboard/Dashboard.less
@@ -0,0 +1,23 @@
+@import "~antd/lib/style/themes/default.less";
+@import "../../utils/utils.less";
+
+.mapChart {
+ padding-top: 24px;
+ height: 457px;
+ text-align: center;
+ img {
+ display: inline-block;
+ max-width: 100%;
+ max-height: 437px;
+ }
+}
+
+.pieCard :global(.pie-stat) {
+ font-size: 24px!important;
+}
+
+@media screen and (max-width: @screen-lg) {
+ .mapChart {
+ height: auto;
+ }
+}
diff --git a/src/main/frontend/src/routes/List/TableList.js b/src/main/frontend/src/routes/List/TableList.js
new file mode 100644
index 0000000..332cd14
--- /dev/null
+++ b/src/main/frontend/src/routes/List/TableList.js
@@ -0,0 +1,326 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'dva';
+import { Row, Col, Card, Form, Input, Select, Icon, Button, Dropdown, Menu, InputNumber, DatePicker, Modal, message } from 'antd';
+import StandardTable from '../../components/StandardTable';
+import PageHeaderLayout from '../../layouts/PageHeaderLayout';
+
+import styles from './TableList.less';
+
+const FormItem = Form.Item;
+const { Option } = Select;
+const getValue = obj => Object.keys(obj).map(key => obj[key]).join(',');
+
+@connect(state => ({
+ rule: state.rule,
+}))
+@Form.create()
+export default class TableList extends PureComponent {
+ state = {
+ addInputValue: '',
+ modalVisible: false,
+ expandForm: false,
+ selectedRows: [],
+ formValues: {},
+ };
+
+ componentDidMount() {
+ const { dispatch } = this.props;
+ dispatch({
+ type: 'rule/fetch',
+ });
+ }
+
+ handleStandardTableChange = (pagination, filtersArg, sorter) => {
+ const { dispatch } = this.props;
+ const { formValues } = this.state;
+
+ const filters = Object.keys(filtersArg).reduce((obj, key) => {
+ const newObj = { ...obj };
+ newObj[key] = getValue(filtersArg[key]);
+ return newObj;
+ }, {});
+
+ const params = {
+ currentPage: pagination.current,
+ pageSize: pagination.pageSize,
+ ...formValues,
+ ...filters,
+ };
+ if (sorter.field) {
+ params.sorter = `${sorter.field}_${sorter.order}`;
+ }
+
+ dispatch({
+ type: 'rule/fetch',
+ payload: params,
+ });
+ }
+
+ handleFormReset = () => {
+ const { form, dispatch } = this.props;
+ form.resetFields();
+ dispatch({
+ type: 'rule/fetch',
+ payload: {},
+ });
+ }
+
+ toggleForm = () => {
+ this.setState({
+ expandForm: !this.state.expandForm,
+ });
+ }
+
+ handleMenuClick = (e) => {
+ const { dispatch } = this.props;
+ const { selectedRows } = this.state;
+
+ if (!selectedRows) return;
+
+ switch (e.key) {
+ case 'remove':
+ dispatch({
+ type: 'rule/remove',
+ payload: {
+ no: selectedRows.map(row => row.no).join(','),
+ },
+ callback: () => {
+ this.setState({
+ selectedRows: [],
+ });
+ },
+ });
+ break;
+ default:
+ break;
+ }
+ }
+
+ handleSelectRows = (rows) => {
+ this.setState({
+ selectedRows: rows,
+ });
+ }
+
+ handleSearch = (e) => {
+ e.preventDefault();
+
+ const { dispatch, form } = this.props;
+
+ form.validateFields((err, fieldsValue) => {
+ if (err) return;
+
+ const values = {
+ ...fieldsValue,
+ updatedAt: fieldsValue.updatedAt && fieldsValue.updatedAt.valueOf(),
+ };
+
+ this.setState({
+ formValues: values,
+ });
+
+ dispatch({
+ type: 'rule/fetch',
+ payload: values,
+ });
+ });
+ }
+
+ handleModalVisible = (flag) => {
+ this.setState({
+ modalVisible: !!flag,
+ });
+ }
+
+ handleAddInput = (e) => {
+ this.setState({
+ addInputValue: e.target.value,
+ });
+ }
+
+ handleAdd = () => {
+ this.props.dispatch({
+ type: 'rule/add',
+ payload: {
+ description: this.state.addInputValue,
+ },
+ });
+
+ message.success('添加成功');
+ this.setState({
+ modalVisible: false,
+ });
+ }
+
+ renderSimpleForm() {
+ const { getFieldDecorator } = this.props.form;
+ return (
+ <Form onSubmit={this.handleSearch} layout="inline">
+ <Row gutter={{ md: 8, lg: 24, xl: 48 }}>
+ <Col md={8} sm={24}>
+ <FormItem label="规则编号">
+ {getFieldDecorator('no')(
+ <Input placeholder="请输入" />
+ )}
+ </FormItem>
+ </Col>
+ <Col md={8} sm={24}>
+ <FormItem label="使用状态">
+ {getFieldDecorator('status')(
+ <Select placeholder="请选择" style={{ width: '100%' }}>
+ <Option value="0">关闭</Option>
+ <Option value="1">运行中</Option>
+ </Select>
+ )}
+ </FormItem>
+ </Col>
+ <Col md={8} sm={24}>
+ <span className={styles.submitButtons}>
+ <Button type="primary" htmlType="submit">查询</Button>
+ <Button style={{ marginLeft: 8 }} onClick={this.handleFormReset}>重置</Button>
+ <a style={{ marginLeft: 8 }} onClick={this.toggleForm}>
+ 展开 <Icon type="down" />
+ </a>
+ </span>
+ </Col>
+ </Row>
+ </Form>
+ );
+ }
+
+ renderAdvancedForm() {
+ const { getFieldDecorator } = this.props.form;
+ return (
+ <Form onSubmit={this.handleSearch} layout="inline">
+ <Row gutter={{ md: 8, lg: 24, xl: 48 }}>
+ <Col md={8} sm={24}>
+ <FormItem label="规则编号">
+ {getFieldDecorator('no')(
+ <Input placeholder="请输入" />
+ )}
+ </FormItem>
+ </Col>
+ <Col md={8} sm={24}>
+ <FormItem label="使用状态">
+ {getFieldDecorator('status')(
+ <Select placeholder="请选择" style={{ width: '100%' }}>
+ <Option value="0">关闭</Option>
+ <Option value="1">运行中</Option>
+ </Select>
+ )}
+ </FormItem>
+ </Col>
+ <Col md={8} sm={24}>
+ <FormItem label="调用次数">
+ {getFieldDecorator('number')(
+ <InputNumber style={{ width: '100%' }} />
+ )}
+ </FormItem>
+ </Col>
+ </Row>
+ <Row gutter={{ md: 8, lg: 24, xl: 48 }}>
+ <Col md={8} sm={24}>
+ <FormItem label="更新日期">
+ {getFieldDecorator('date')(
+ <DatePicker style={{ width: '100%' }} placeholder="请输入更新日期" />
+ )}
+ </FormItem>
+ </Col>
+ <Col md={8} sm={24}>
+ <FormItem label="使用状态">
+ {getFieldDecorator('status3')(
+ <Select placeholder="请选择" style={{ width: '100%' }}>
+ <Option value="0">关闭</Option>
+ <Option value="1">运行中</Option>
+ </Select>
+ )}
+ </FormItem>
+ </Col>
+ <Col md={8} sm={24}>
+ <FormItem label="使用状态">
+ {getFieldDecorator('status4')(
+ <Select placeholder="请选择" style={{ width: '100%' }}>
+ <Option value="0">关闭</Option>
+ <Option value="1">运行中</Option>
+ </Select>
+ )}
+ </FormItem>
+ </Col>
+ </Row>
+ <div style={{ overflow: 'hidden' }}>
+ <span style={{ float: 'right', marginBottom: 24 }}>
+ <Button type="primary" htmlType="submit">查询</Button>
+ <Button style={{ marginLeft: 8 }} onClick={this.handleFormReset}>重置</Button>
+ <a style={{ marginLeft: 8 }} onClick={this.toggleForm}>
+ 收起 <Icon type="up" />
+ </a>
+ </span>
+ </div>
+ </Form>
+ );
+ }
+
+ renderForm() {
+ return this.state.expandForm ? this.renderAdvancedForm() : this.renderSimpleForm();
+ }
+
+ render() {
+ const { rule: { loading: ruleLoading, data } } = this.props;
+ const { selectedRows, modalVisible, addInputValue } = this.state;
+
+ const menu = (
+ <Menu onClick={this.handleMenuClick} selectedKeys={[]}>
+ <Menu.Item key="remove">删除</Menu.Item>
+ <Menu.Item key="approval">批量审批</Menu.Item>
+ </Menu>
+ );
+
+ return (
+ <PageHeaderLayout title="查询表格">
+ <Card bordered={false}>
+ <div className={styles.tableList}>
+ <div className={styles.tableListForm}>
+ {this.renderForm()}
+ </div>
+ <div className={styles.tableListOperator}>
+ <Button icon="plus" type="primary" onClick={() => this.handleModalVisible(true)}>新建</Button>
+ {
+ selectedRows.length > 0 && (
+ <span>
+ <Button>批量操作</Button>
+ <Dropdown overlay={menu}>
+ <Button>
+ 更多操作 <Icon type="down" />
+ </Button>
+ </Dropdown>
+ </span>
+ )
+ }
+ </div>
+ <StandardTable
+ selectedRows={selectedRows}
+ loading={ruleLoading}
+ data={data}
+ onSelectRow={this.handleSelectRows}
+ onChange={this.handleStandardTableChange}
+ />
+ </div>
+ </Card>
+ <Modal
+ title="新建规则"
+ visible={modalVisible}
+ onOk={this.handleAdd}
+ onCancel={() => this.handleModalVisible()}
+ >
+ <FormItem
+ labelCol={{ span: 5 }}
+ wrapperCol={{ span: 15 }}
+ label="描述"
+ >
+ <Input placeholder="请输入" onChange={this.handleAddInput} value={addInputValue} />
+ </FormItem>
+ </Modal>
+ </PageHeaderLayout>
+ );
+ }
+}
diff --git a/src/main/frontend/src/routes/List/TableList.less b/src/main/frontend/src/routes/List/TableList.less
new file mode 100644
index 0000000..6c9efa1
--- /dev/null
+++ b/src/main/frontend/src/routes/List/TableList.less
@@ -0,0 +1,45 @@
+@import "~antd/lib/style/themes/default.less";
+@import "../../utils/utils.less";
+
+.tableList {
+ .tableListOperator {
+ margin-bottom: 16px;
+ button {
+ margin-right: 8px;
+ }
+ }
+}
+
+.tableListForm {
+ :global {
+ .ant-form-item {
+ margin-bottom: 24px;
+ margin-right: 0;
+ display: flex;
+ > .ant-form-item-label {
+ width: auto;
+ line-height: 32px;
+ padding-right: 8px;
+ }
+ }
+ .ant-form-item-control-wrapper {
+ flex: 1;
+ }
+ }
+ .submitButtons {
+ white-space: nowrap;
+ margin-bottom: 24px;
+ }
+}
+
+@media screen and (max-width: @screen-lg) {
+ .tableListForm :global(.ant-form-item) {
+ margin-right: 24px;
+ }
+}
+
+@media screen and (max-width: @screen-md) {
+ .tableListForm :global(.ant-form-item) {
+ margin-right: 8px;
+ }
+}
diff --git a/src/main/frontend/src/services/api.js b/src/main/frontend/src/services/api.js
new file mode 100644
index 0000000..aef7f34
--- /dev/null
+++ b/src/main/frontend/src/services/api.js
@@ -0,0 +1,30 @@
+import { stringify } from 'qs';
+import request from '../utils/request';
+
+export async function queryRule(params) {
+ return request(`/api/rule?${stringify(params)}`);
+}
+
+export async function removeRule(params) {
+ return request('/api/rule', {
+ method: 'POST',
+ body: {
+ ...params,
+ method: 'delete',
+ },
+ });
+}
+
+export async function addRule(params) {
+ return request('/api/rule', {
+ method: 'POST',
+ body: {
+ ...params,
+ method: 'post',
+ },
+ });
+}
+
+export async function queryNotices() {
+ return request('/api/notices');
+}
diff --git a/src/main/frontend/src/services/user.js b/src/main/frontend/src/services/user.js
new file mode 100644
index 0000000..c4defb4
--- /dev/null
+++ b/src/main/frontend/src/services/user.js
@@ -0,0 +1,9 @@
+import request from '../utils/request';
+
+export async function query() {
+ return request('/api/users');
+}
+
+export async function queryCurrent() {
+ return request('/api/currentUser');
+}
diff --git a/src/main/frontend/src/theme.js b/src/main/frontend/src/theme.js
new file mode 100644
index 0000000..9e12511
--- /dev/null
+++ b/src/main/frontend/src/theme.js
@@ -0,0 +1,5 @@
+// https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less
+module.exports = {
+ // 'primary-color': '#10e99b',
+ 'card-actions-background': '#f5f8fa',
+};
diff --git a/src/main/frontend/src/utils/request.js b/src/main/frontend/src/utils/request.js
new file mode 100644
index 0000000..094f7fc
--- /dev/null
+++ b/src/main/frontend/src/utils/request.js
@@ -0,0 +1,56 @@
+import fetch from 'dva/fetch';
+import { notification } from 'antd';
+
+function checkStatus(response) {
+ if (response.status >= 200 && response.status < 300) {
+ return response;
+ }
+ notification.error({
+ message: `请求错误 ${response.status}: ${response.url}`,
+ description: response.statusText,
+ });
+ const error = new Error(response.statusText);
+ error.response = response;
+ throw error;
+}
+
+/**
+ * Requests a URL, returning a promise.
+ *
+ * @param {string} url The URL we want to request
+ * @param {object} [options] The options we want to pass to "fetch"
+ * @return {object} An object containing either "data" or "err"
+ */
+export default function request(url, options) {
+ const defaultOptions = {
+ credentials: 'include',
+ };
+ const newOptions = { ...defaultOptions, ...options };
+ if (newOptions.method === 'POST' || newOptions.method === 'PUT') {
+ newOptions.headers = {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json; charset=utf-8',
+ ...newOptions.headers,
+ };
+ newOptions.body = JSON.stringify(newOptions.body);
+ }
+
+ return fetch(url, newOptions)
+ .then(checkStatus)
+ .then(response => response.json())
+ .catch((error) => {
+ if (error.code) {
+ notification.error({
+ message: error.name,
+ description: error.message,
+ });
+ }
+ if ('stack' in error && 'message' in error) {
+ notification.error({
+ message: `请求错误: ${url}`,
+ description: error.message,
+ });
+ }
+ return error;
+ });
+}
diff --git a/src/main/frontend/src/utils/utils.js b/src/main/frontend/src/utils/utils.js
new file mode 100644
index 0000000..e0dfd71
--- /dev/null
+++ b/src/main/frontend/src/utils/utils.js
@@ -0,0 +1,94 @@
+import moment from 'moment';
+
+export function fixedZero(val) {
+ return val * 1 < 10 ? `0${val}` : val;
+}
+
+export function getTimeDistance(type) {
+ const now = new Date();
+ const oneDay = 1000 * 60 * 60 * 24;
+
+ if (type === 'today') {
+ now.setHours(0);
+ now.setMinutes(0);
+ now.setSeconds(0);
+ return [moment(now), moment(now.getTime() + (oneDay - 1000))];
+ }
+
+ if (type === 'week') {
+ let day = now.getDay();
+ now.setHours(0);
+ now.setMinutes(0);
+ now.setSeconds(0);
+
+ if (day === 0) {
+ day = 6;
+ } else {
+ day -= 1;
+ }
+
+ const beginTime = now.getTime() - (day * oneDay);
+
+ return [moment(beginTime), moment(beginTime + ((7 * oneDay) - 1000))];
+ }
+
+ if (type === 'month') {
+ const year = now.getFullYear();
+ const month = now.getMonth();
+ const nextDate = moment(now).add(1, 'months');
+ const nextYear = nextDate.year();
+ const nextMonth = nextDate.month();
+
+ return [moment(`${year}-${fixedZero(month + 1)}-01 00:00:00`), moment(moment(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() - 1000)];
+ }
+
+ if (type === 'year') {
+ const year = now.getFullYear();
+
+ return [moment(`${year}-01-01 00:00:00`), moment(`${year}-12-31 23:59:59`)];
+ }
+}
+
+export function getPlainNode(nodeList, parentPath = '') {
+ const arr = [];
+ nodeList.forEach((node) => {
+ const item = node;
+ item.path = `${parentPath}/${item.path || ''}`.replace(/\/+/g, '/');
+ item.exact = true;
+ if (item.children && !item.component) {
+ arr.push(...getPlainNode(item.children, item.path));
+ } else {
+ if (item.children && item.component) {
+ item.exact = false;
+ }
+ arr.push(item);
+ }
+ });
+ return arr;
+}
+
+export function digitUppercase(n) {
+ const fraction = ['角', '分'];
+ const digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
+ const unit = [
+ ['元', '万', '亿'],
+ ['', '拾', '佰', '仟'],
+ ];
+ let num = Math.abs(n);
+ let s = '';
+ fraction.forEach((item, index) => {
+ s += (digit[Math.floor(num * 10 * (10 ** index)) % 10] + item).replace(/零./, '');
+ });
+ s = s || '整';
+ num = Math.floor(num);
+ for (let i = 0; i < unit[0].length && num > 0; i += 1) {
+ let p = '';
+ for (let j = 0; j < unit[1].length && num > 0; j += 1) {
+ p = digit[num % 10] + unit[1][j] + p;
+ num = Math.floor(num / 10);
+ }
+ s = p.replace(/(零.)*零$/, '').replace(/^$/, '零') + unit[0][i] + s;
+ }
+
+ return s.replace(/(零.)*零元/, '元').replace(/(零.)+/g, '零').replace(/^整$/, '零元整');
+}
diff --git a/src/main/frontend/src/utils/utils.less b/src/main/frontend/src/utils/utils.less
new file mode 100644
index 0000000..1ec1efb
--- /dev/null
+++ b/src/main/frontend/src/utils/utils.less
@@ -0,0 +1,50 @@
+.textOverflow() {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ word-break: break-all;
+ white-space: nowrap;
+}
+
+.textOverflowMulti(@line: 3, @bg: #fff) {
+ overflow: hidden;
+ position: relative;
+ line-height: 1.5em;
+ max-height: @line * 1.5em;
+ text-align: justify;
+ margin-right: -1em;
+ padding-right: 1em;
+ &:before {
+ background: @bg;
+ content: '...';
+ padding: 0 1px;
+ position: absolute;
+ right: 14px;
+ bottom: 0;
+ }
+ &:after {
+ background: white;
+ content: '';
+ margin-top: 0.2em;
+ position: absolute;
+ right: 14px;
+ width: 1em;
+ height: 1em;
+ }
+}
+
+// mixins for clearfix
+// ------------------------
+.clearfix() {
+ zoom: 1;
+ &:before,
+ &:after {
+ content: " ";
+ display: table;
+ }
+ &:after {
+ clear: both;
+ visibility: hidden;
+ font-size: 0;
+ height: 0;
+ }
+}
diff --git a/src/main/frontend/tests/jasmine.js b/src/main/frontend/tests/jasmine.js
new file mode 100644
index 0000000..5ff26bf
--- /dev/null
+++ b/src/main/frontend/tests/jasmine.js
@@ -0,0 +1 @@
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
diff --git a/src/main/frontend/tests/run-tests.js b/src/main/frontend/tests/run-tests.js
new file mode 100644
index 0000000..46ef9bb
--- /dev/null
+++ b/src/main/frontend/tests/run-tests.js
@@ -0,0 +1,35 @@
+const { spawn } = require('child_process');
+const { kill } = require('cross-port-killer');
+
+const env = Object.create(process.env);
+env.BROWSER = 'none';
+const startServer = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['start'], {
+ env,
+});
+
+startServer.stderr.on('data', (data) => {
+ // eslint-disable-next-line
+ console.log(data);
+});
+
+startServer.on('exit', () => {
+ kill(process.env.PORT || 8000);
+});
+
+// eslint-disable-next-line
+console.log('Starting development server for e2e tests...');
+startServer.stdout.on('data', (data) => {
+ // eslint-disable-next-line
+ console.log(data.toString());
+ if (data.toString().indexOf('The app is running at') >= 0 ||
+ data.toString().indexOf('Compiled with warnings') >= 0) {
+ // eslint-disable-next-line
+ console.log('Development server is started, ready to run tests.');
+ const testCmd = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['test'], {
+ stdio: 'inherit',
+ });
+ testCmd.on('exit', () => {
+ startServer.kill();
+ });
+ }
+});
diff --git a/src/main/frontend/tests/setupTests.js b/src/main/frontend/tests/setupTests.js
new file mode 100644
index 0000000..bb003dd
--- /dev/null
+++ b/src/main/frontend/tests/setupTests.js
@@ -0,0 +1,13 @@
+/* eslint-disable import/first */
+import '../src/polyfill';
+import { jsdom } from 'jsdom';
+import Enzyme from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+
+Enzyme.configure({ adapter: new Adapter() });
+
+// fixed jsdom miss
+const documentHTML = '<!doctype html><html><body><div id="root"></div></body></html>';
+global.document = jsdom(documentHTML);
+global.window = document.defaultView;
+global.navigator = global.window.navigator;
diff --git a/src/main/frontend/tests/styleMock.js b/src/main/frontend/tests/styleMock.js
new file mode 100644
index 0000000..f053ebf
--- /dev/null
+++ b/src/main/frontend/tests/styleMock.js
@@ -0,0 +1 @@
+module.exports = {};
--
To stop receiving notification emails like this one, please contact
"commits@skywalking.apache.org" <co...@skywalking.apache.org>.