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> 项&nbsp;&nbsp;
+                服务调用总计 <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>.