This is an automated email from the ASF dual-hosted git repository.

zakwu pushed a commit to branch TUBEMQ-336
in repository https://gitbox.apache.org/repos/asf/incubator-tubemq.git


The following commit(s) were added to refs/heads/TUBEMQ-336 by this push:
     new 9dfa8ad  feat(webclient): rebuild web client
9dfa8ad is described below

commit 9dfa8ad2fc05be302813180c8d0b7837e48e46f1
Author: zakwu <[email protected]>
AuthorDate: Fri Nov 6 14:49:05 2020 +0800

    feat(webclient): rebuild web client
---
 web/.env                                       |   2 +
 web/.eslintignore                              |   5 +
 web/.eslintrc                                  |  28 ++
 web/.gitignore                                 |  26 ++
 web/.prettierrc                                |   4 +
 web/.stylelintrc                               |   3 +
 web/README.md                                  |  33 ++
 web/config-overrides.js                        |  52 +++
 web/mock/_constant.js                          |   3 +
 web/mock/app.js                                |   6 +
 web/package.json                               | 110 ++++++
 web/public/favicon.ico                         | Bin 0 -> 1226 bytes
 web/public/index.html                          |  43 +++
 web/public/logo192.png                         | Bin 0 -> 33077 bytes
 web/public/logo512.png                         | Bin 0 -> 9664 bytes
 web/public/manifest.json                       |  24 ++
 web/public/robots.txt                          |   3 +
 web/src/components/Breadcrumb/index.less       |   8 +
 web/src/components/Breadcrumb/index.tsx        |  47 +++
 web/src/components/Layout/index.less           |  31 ++
 web/src/components/Layout/index.tsx            |  76 ++++
 web/src/components/Modalx/index.less           |  17 +
 web/src/components/Modalx/index.tsx            |  49 +++
 web/src/components/Tablex/index.less           |  16 +
 web/src/components/Tablex/index.tsx            | 114 ++++++
 web/src/components/Tablex/tableFilterHelper.ts |  32 ++
 web/src/components/TitleWrap/index.less        |  11 +
 web/src/components/TitleWrap/index.tsx         |  22 ++
 web/src/components/index.tsx                   |   3 +
 web/src/configs/index.ts                       |   3 +
 web/src/configs/menus/index.tsx                |  50 +++
 web/src/constants/broker.ts                    |  22 ++
 web/src/constants/person.ts                    |   4 +
 web/src/constants/topic.ts                     |  10 +
 web/src/context/globalContext.ts               |  12 +
 web/src/defaultSettings.js                     |   6 +
 web/src/hooks/index.ts                         |  52 +++
 web/src/index.tsx                              |  11 +
 web/src/pages/Broker/commonModal.tsx           | 274 +++++++++++++
 web/src/pages/Broker/detail.tsx                | 378 ++++++++++++++++++
 web/src/pages/Broker/index.less                |   9 +
 web/src/pages/Broker/index.tsx                 | 280 ++++++++++++++
 web/src/pages/Broker/query.tsx                 | 128 +++++++
 web/src/pages/Cluster/index.less               |   0
 web/src/pages/Cluster/index.tsx                | 143 +++++++
 web/src/pages/Issue/consumeGroupDetail.tsx     |  95 +++++
 web/src/pages/Issue/index.less                 |   0
 web/src/pages/Issue/index.tsx                  |  98 +++++
 web/src/pages/NotFound/index.tsx               |   5 +
 web/src/pages/Topic/commonModal.tsx            | 349 +++++++++++++++++
 web/src/pages/Topic/detail.tsx                 | 510 +++++++++++++++++++++++++
 web/src/pages/Topic/index.less                 |   9 +
 web/src/pages/Topic/index.tsx                  | 279 ++++++++++++++
 web/src/pages/Topic/query.tsx                  | 180 +++++++++
 web/src/react-app-env.d.ts                     |   1 +
 web/src/router.tsx                             |  55 +++
 web/src/routes/index.tsx                       |  37 ++
 web/src/serviceWorker.ts                       | 146 +++++++
 web/src/setupProxy.js                          |  12 +
 web/src/store/global.ts                        |  30 ++
 web/src/typings/index.ts                       |   1 +
 web/src/typings/router.ts                      |  14 +
 web/src/utils/index.ts                         |  45 +++
 web/tsconfig.json                              |  33 ++
 web/tsconfig.paths.json                        |   8 +
 65 files changed, 4057 insertions(+)

diff --git a/web/.env b/web/.env
new file mode 100644
index 0000000..936a9dc
--- /dev/null
+++ b/web/.env
@@ -0,0 +1,2 @@
+PORT=3000
+SKIP_PREFLIGHT_CHECK=true
diff --git a/web/.eslintignore b/web/.eslintignore
new file mode 100644
index 0000000..6bba8e4
--- /dev/null
+++ b/web/.eslintignore
@@ -0,0 +1,5 @@
+node_modules/**
+dist/**
+public/**
+src/serviceWorker.ts
+mock
diff --git a/web/.eslintrc b/web/.eslintrc
new file mode 100644
index 0000000..472dd8d
--- /dev/null
+++ b/web/.eslintrc
@@ -0,0 +1,28 @@
+{
+  "extends": [
+    "plugin:@typescript-eslint/recommended",
+    "react-app",
+    "plugin:prettier/recommended"
+  ],
+  "plugins": ["@typescript-eslint", "react", "prettier"],
+  "env": {
+    "browser": true,
+    "node": true,
+    "mocha": true
+  },
+  "globals": {
+    "Babel": true,
+    "React": true
+  },
+  "settings": {
+    "react": {
+      "version": "detect"
+    }
+  },
+  "rules": {
+    "@typescript-eslint/no-explicit-any": 0,
+    "@typescript-eslint/explicit-function-return-type": 0,
+    "jsx-a11y/anchor-is-valid": 0,
+    "react-hooks/exhaustive-deps": 0
+  }
+}
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..448f7e5
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,26 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring 
files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+package-lock.json
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+yarn.lock
+.history
diff --git a/web/.prettierrc b/web/.prettierrc
new file mode 100644
index 0000000..c1a6f66
--- /dev/null
+++ b/web/.prettierrc
@@ -0,0 +1,4 @@
+{
+  "singleQuote": true,
+  "trailingComma": "es5"
+}
diff --git a/web/.stylelintrc b/web/.stylelintrc
new file mode 100644
index 0000000..2e8ff58
--- /dev/null
+++ b/web/.stylelintrc
@@ -0,0 +1,3 @@
+{
+  "extends": ["stylelint-config-standard", "stylelint-config-prettier"]
+}
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..d96c9e5
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,33 @@
+# web
+This project was bootstrapped with [React 
Seed](https://github.com/reactseed/reactseed).
+
+## Available Scripts
+Inside the newly created project, you can run some built-in commands:
+
+### `npm start` or `yarn start`
+
+Runs the app in development mode. 
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+
+The page will reload if you make edits.
+You will also see any lint errors in the console.
+
+### `npm test` or `yarn test`
+
+Launches the test runner in the interactive watch mode.
+See the section about [running 
tests](https://create-react-app.dev/docs/running-tests/) for more information.
+
+### `npm run build` or `yarn build`
+
+Builds the app for production to the build folder.
+It correctly bundles React in production mode and optimizes the build for the 
best performance.
+
+The build is minified and the filenames include the hashes.
+Your app is ready to be deployed!
+
+See the section about 
[deployment](https://create-react-app.dev/docs/deployment/) for more 
information.
+
+### `npm run analyze` or `yarn analyze`
+
+Analyzes JavaScript bundles using the source maps.
+> You need to run `npm run build` or `yarn build` before analysis.
diff --git a/web/config-overrides.js b/web/config-overrides.js
new file mode 100644
index 0000000..bb8515b
--- /dev/null
+++ b/web/config-overrides.js
@@ -0,0 +1,52 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+const path = require('path');
+const webpack = require('webpack');
+const devServer = require('@reactseed/devserver');
+const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin');
+const {
+  override,
+  addWebpackAlias,
+  addLessLoader,
+  overrideDevServer,
+  addWebpackPlugin,
+  fixBabelImports,
+  addBabelPlugin,
+} = require('customize-cra');
+const nodeModulesPath = path.resolve(__dirname, 'node_modules');
+const nodeModules = pkg => path.resolve(nodeModulesPath, pkg);
+
+module.exports = {
+  webpack: override(
+    addBabelPlugin('react-hot-loader/babel'),
+    addLessLoader({
+      javascriptEnabled: true,
+    }),
+    addWebpackAlias({
+      '@': path.resolve(__dirname, 'src'),
+    }),
+    fixBabelImports('antd', {
+      libraryDirectory: 'lib',
+      style: 'css',
+    }),
+    addWebpackPlugin(
+      new AntdDayjsWebpackPlugin(),
+      new webpack.HotModuleReplacementPlugin()
+    ),
+    config => {
+      if (config.mode === 'development') {
+        config.resolve.alias['react-dom'] = path.resolve(
+          __dirname,
+          'node_modules/@hot-loader/react-dom'
+        );
+        config.entry.unshift(nodeModules('react-hot-loader/patch'));
+      }
+      return config;
+    }
+  ),
+  devServer: overrideDevServer(devServer, config => {
+    config.inline = true;
+    // eslint-disable-next-line no-undef
+    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000);
+    return config;
+  }),
+};
diff --git a/web/mock/_constant.js b/web/mock/_constant.js
new file mode 100644
index 0000000..286afb4
--- /dev/null
+++ b/web/mock/_constant.js
@@ -0,0 +1,3 @@
+module.exports = {
+  apiPrefix: '/api',
+};
diff --git a/web/mock/app.js b/web/mock/app.js
new file mode 100644
index 0000000..6a109a8
--- /dev/null
+++ b/web/mock/app.js
@@ -0,0 +1,6 @@
+const { apiPrefix } = require('./_constant');
+const packageJSON = require('../package.json');
+
+module.exports = {
+  [`GET ${apiPrefix}/app`]: packageJSON,
+};
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..19ecc93
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,110 @@
+{
+  "name": "web",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "@ant-design/icons": "^4.2.1",
+    "@ant-design/pro-layout": "^5.0.12",
+    "@reactseed/use-redux": "^0.0.3",
+    "@reactseed/use-request": "^0.0.2",
+    "@types/lodash": "^4.14.155",
+    "@umijs/use-request": "^1.4.3",
+    "antd": "^4.2.2",
+    "immer": "^6.0.9",
+    "lodash": "^4.17.15",
+    "react": "16.13.0",
+    "react-dom": "16.13.0",
+    "react-redux": "^7.2.0",
+    "react-router-dom": "^5.2.0",
+    "redux": "^4.0.5"
+  },
+  "scripts": {
+    "analyze": "source-map-explorer 'build/static/js/*.js'",
+    "start": "EXTEND_ESLINT=true react-app-rewired start",
+    "build": "react-app-rewired build",
+    "test": "react-app-rewired test",
+    "commit": "git cz",
+    "prettier": "prettier --write 'src/**/*.{ts,tsx}'",
+    "eslint": "eslint --fix --no-error-on-unmatched-pattern 
'src/**/*.{ts,tsx}'",
+    "stylelint": "stylelint 'src/**/*.less' --syntax less --allow-empty-input"
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  },
+  "lint-staged": {
+    "*.{ts,tsx}": [
+      "npm run eslint",
+      "npm run prettier"
+    ],
+    "*.{less}": [
+      "npm run stylelint"
+    ]
+  },
+  "pre-commit": "lint-staged",
+  "husky": {
+    "hooks": {
+      "pre-commit": "lint-staged",
+      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS --verbose --no-verify"
+    }
+  },
+  "commitlint": {
+    "extends": [
+      "@commitlint/config-conventional"
+    ]
+  },
+  "config": {
+    "commitizen": {
+      "path": "./node_modules/cz-conventional-changelog"
+    }
+  },
+  "devDependencies": {
+    "@commitlint/cli": "^8.3.5",
+    "@commitlint/config-conventional": "^8.3.4",
+    "@hot-loader/react-dom": "=16.13.0",
+    "@reactseed/devserver": "^0.0.2",
+    "@testing-library/jest-dom": "^4.2.4",
+    "@testing-library/react": "^9.3.2",
+    "@testing-library/user-event": "^7.1.2",
+    "@types/jest": "^25.2.1",
+    "@types/node": "^13.13.5",
+    "@types/react": "^16.9.35",
+    "@types/react-dom": "^16.9.8",
+    "@types/react-redux": "^7.1.9",
+    "@types/react-router-dom": "^5.1.5",
+    "antd-dayjs-webpack-plugin": "^1.0.0",
+    "babel-plugin-import": "^1.13.0",
+    "customize-cra": "^0.9.1",
+    "cz-conventional-changelog": "^3.2.0",
+    "eslint": "^6.8.0",
+    "eslint-config-prettier": "^6.10.0",
+    "eslint-config-react-app": "^5.2.0",
+    "eslint-plugin-prettier": "^3.1.2",
+    "eslint-plugin-react": "^7.19.0",
+    "eslint-plugin-react-hooks": "^4.0.2",
+    "http-proxy-middleware": "^1.0.1",
+    "husky": "^4.2.5",
+    "less": "^3.11.1",
+    "less-loader": "^5.0.0",
+    "lint-staged": "^10.2.2",
+    "prettier": "^1.19.1",
+    "react-app-rewire-hot-loader": "^2.0.1",
+    "react-app-rewired": "^2.1.5",
+    "react-hot-loader": "^4.12.21",
+    "react-scripts": "3.4.0",
+    "source-map-explorer": "^2.3.1",
+    "stylelint": "^13.3.3",
+    "stylelint-config-prettier": "^8.0.1",
+    "stylelint-config-standard": "^20.0.0",
+    "typescript": "~3.8.3",
+    "use-immer": "^0.4.0"
+  }
+}
diff --git a/web/public/favicon.ico b/web/public/favicon.ico
new file mode 100644
index 0000000..094e7d6
Binary files /dev/null and b/web/public/favicon.ico differ
diff --git a/web/public/index.html b/web/public/index.html
new file mode 100644
index 0000000..b833b69
--- /dev/null
+++ b/web/public/index.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <meta
+      name="description"
+      content="Web site created using create-react-app"
+    />
+    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+    <!--
+      manifest.json provides metadata used when your web app is installed on a
+      user's mobile device or desktop. See 
https://developers.google.com/web/fundamentals/web-app-manifest/
+    -->
+    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+    <!--
+      Notice the use of %PUBLIC_URL% in the tags above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>web</title>
+  </head>
+  <body>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start` or `yarn start`.
+      To create a production bundle, use `npm run build` or `yarn build`.
+    -->
+  </body>
+</html>
diff --git a/web/public/logo192.png b/web/public/logo192.png
new file mode 100644
index 0000000..904a235
Binary files /dev/null and b/web/public/logo192.png differ
diff --git a/web/public/logo512.png b/web/public/logo512.png
new file mode 100644
index 0000000..a4e47a6
Binary files /dev/null and b/web/public/logo512.png differ
diff --git a/web/public/manifest.json b/web/public/manifest.json
new file mode 100644
index 0000000..4e45728
--- /dev/null
+++ b/web/public/manifest.json
@@ -0,0 +1,24 @@
+{
+  "short_name": "web",
+  "name": "web",
+  "icons": [{
+      "src": "favicon.ico",
+      "sizes": "64x64 32x32 24x24 16x16",
+      "type": "image/x-icon"
+    },
+    {
+      "src": "logo192.png",
+      "type": "image/png",
+      "sizes": "192x192"
+    },
+    {
+      "src": "logo512.png",
+      "type": "image/png",
+      "sizes": "512x512"
+    }
+  ],
+  "start_url": ".",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#ffffff"
+}
diff --git a/web/public/robots.txt b/web/public/robots.txt
new file mode 100644
index 0000000..e9e57dc
--- /dev/null
+++ b/web/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/web/src/components/Breadcrumb/index.less 
b/web/src/components/Breadcrumb/index.less
new file mode 100644
index 0000000..f02c2b7
--- /dev/null
+++ b/web/src/components/Breadcrumb/index.less
@@ -0,0 +1,8 @@
+.breadcrumb-wrapper {
+  background: #fff;
+  padding: 10px 20px;
+}
+
+.ant-pro-basicLayout-content {
+  margin: 1px 0 0 0;
+}
\ No newline at end of file
diff --git a/web/src/components/Breadcrumb/index.tsx 
b/web/src/components/Breadcrumb/index.tsx
new file mode 100644
index 0000000..c4113bf
--- /dev/null
+++ b/web/src/components/Breadcrumb/index.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { MenuDataItem } from '@ant-design/pro-layout';
+import { Link } from 'react-router-dom';
+import { useLocation } from '@/hooks';
+import './index.less';
+import { Breadcrumb } from 'antd';
+
+export interface BreadcrumbProps {
+  breadcrumbMap?: Map<string, import('@umijs/route-utils').MenuDataItem>;
+  appendParams?: string;
+}
+
+const BasicLayout: React.FC<BreadcrumbProps> = props => {
+  const location = useLocation();
+  const { breadcrumbMap, appendParams } = props;
+
+  const pathSnippets = location.pathname.split('/').filter(i => i);
+  const breadcrumbItems = pathSnippets.map((_, index) => {
+    const breadcrumbNameMap = {} as any;
+    breadcrumbMap &&
+      breadcrumbMap.forEach((t: MenuDataItem) => {
+        breadcrumbNameMap[t.key as string] = t.name;
+      });
+    const url = `/${pathSnippets.slice(0, index + 1).join('/')}`;
+    if (appendParams && index === pathSnippets.length - 1) {
+      return (
+        <Breadcrumb.Item key={url}>
+          <Link to={url}>{appendParams}</Link>
+        </Breadcrumb.Item>
+      );
+    }
+
+    return (
+      <Breadcrumb.Item key={url}>
+        <Link to={url}>{breadcrumbNameMap[url]}</Link>
+      </Breadcrumb.Item>
+    );
+  });
+
+  return (
+    <>
+      <Breadcrumb className="breadcrumb-wrapper">{breadcrumbItems}</Breadcrumb>
+    </>
+  );
+};
+
+export default BasicLayout;
diff --git a/web/src/components/Layout/index.less 
b/web/src/components/Layout/index.less
new file mode 100644
index 0000000..6e0ed86
--- /dev/null
+++ b/web/src/components/Layout/index.less
@@ -0,0 +1,31 @@
+#root {
+  height: 100%;
+}
+
+.header-wrapper {
+  width: 400px;
+}
+
+.header-span {
+  float: left;
+}
+
+// global css
+.main-container {
+  background: #fff;
+  padding: 10px 20px 20px 20px;
+}
+
+.search-wrapper {
+  margin-bottom: 20px;
+}
+
+.options-wrapper {
+  a {
+    margin-right: 8px;
+  }
+}
+
+.ant-layout-sider-collapsed .ant-pro-sider-menu-logo {
+  margin-left: -15px;
+}
\ No newline at end of file
diff --git a/web/src/components/Layout/index.tsx 
b/web/src/components/Layout/index.tsx
new file mode 100644
index 0000000..a4a4ddd
--- /dev/null
+++ b/web/src/components/Layout/index.tsx
@@ -0,0 +1,76 @@
+import React, { useState, useEffect, useMemo, useContext } from 'react';
+import ProBasicLayout, {
+  SettingDrawer,
+  getMenuData,
+  MenuDataItem,
+  SettingDrawerProps,
+} from '@ant-design/pro-layout';
+import { Link } from 'react-router-dom';
+import { useLocation } from '@/hooks';
+import { isDevelopEnv } from '@/utils';
+import initSetting from '@/defaultSettings';
+import { menus } from '@/configs';
+import './index.less';
+import GlobalContext from '@/context/globalContext';
+
+const BasicLayout: React.FC = props => {
+  const { cluster, setBreadMap } = useContext(GlobalContext);
+  const location = useLocation();
+  const [settings, setSetting] = useState<SettingDrawerProps['settings']>(
+    initSetting as SettingDrawerProps['settings']
+  );
+  const [openKeys, setOpenKeys] = useState<string[]>([]);
+  const [selectedKeys, setSelectedKeys] = useState<string[]>(['/']);
+  const isDev = isDevelopEnv();
+  const { pathname } = location;
+  const { breadcrumbMap, menuData } = useMemo(() => getMenuData(menus), []);
+  // set breadmap 4 children page 2 use
+  setBreadMap && setBreadMap(breadcrumbMap);
+  useEffect(() => {
+    const select = breadcrumbMap.get(pathname);
+    if (select) {
+      setOpenKeys((select as MenuDataItem)['pro_layout_parentKeys']);
+      setSelectedKeys([(select as MenuDataItem)['key'] as string]);
+    }
+  }, [breadcrumbMap, pathname]);
+
+  return (
+    <>
+      <ProBasicLayout
+        title="TubeMQ"
+        logo="/logo192.png"
+        menuDataRender={() => menuData}
+        menuItemRender={(menuItemProps, defaultDom) => {
+          if (menuItemProps.isUrl || !menuItemProps.path) {
+            return defaultDom;
+          }
+          return <Link to={menuItemProps.path}>{defaultDom}</Link>;
+        }}
+        headerRender={(menuItemProps, defaultDom) => (
+          <div className="header-wrapper">
+            <span className="header-span">{defaultDom}</span>
+            <span>{cluster}</span>
+          </div>
+        )}
+        menuProps={{
+          selectedKeys,
+          openKeys,
+          onOpenChange: setOpenKeys,
+        }}
+        {...settings}
+      >
+        {props.children}
+      </ProBasicLayout>
+
+      {isDev && (
+        <SettingDrawer
+          getContainer={() => document.getElementById('root')}
+          settings={settings}
+          onSettingChange={setSetting}
+        />
+      )}
+    </>
+  );
+};
+
+export default BasicLayout;
diff --git a/web/src/components/Modalx/index.less 
b/web/src/components/Modalx/index.less
new file mode 100644
index 0000000..6b03518
--- /dev/null
+++ b/web/src/components/Modalx/index.less
@@ -0,0 +1,17 @@
+.psw-set {
+  border-top: 1px solid #ccc;
+  padding-top: 20px;
+
+  .pws-label {
+    float: left;
+    line-height: 32px;
+  }
+
+  .psw-input {
+    width: 300px;
+  }
+}
+
+.enhance {
+  color: red;
+}
\ No newline at end of file
diff --git a/web/src/components/Modalx/index.tsx 
b/web/src/components/Modalx/index.tsx
new file mode 100644
index 0000000..6d42e82
--- /dev/null
+++ b/web/src/components/Modalx/index.tsx
@@ -0,0 +1,49 @@
+/**
+ * TABLE COMPONENT WITH SEARCH
+ */
+import { Modal, Input } from 'antd';
+import * as React from 'react';
+import { ModalProps } from 'antd/lib/modal';
+import { ReactElement } from 'react';
+import './index.less';
+
+const { useState } = React;
+
+export interface OKProps {
+  e: React.MouseEvent<HTMLElement>;
+  psw: string;
+  params?: any;
+}
+
+type ComProps = {
+  context?: number;
+  children?: ReactElement;
+  onOk?: (p: OKProps) => {};
+  params?: any;
+};
+
+const Comp = (props: ComProps & Omit<ModalProps, 'onOk'>) => {
+  const { params } = props;
+  const [psw, setPsw] = useState('');
+  const onOk = (e: React.MouseEvent<HTMLElement>) => {
+    props.onOk && props.onOk({ e, psw, params });
+  };
+
+  return (
+    <>
+      <Modal {...props} className="textWrap" width="60%" onOk={onOk}>
+        {props.children}
+        <div className="psw-set">
+          <label className="pws-label">机器授权:</label>
+          <Input
+            className="psw-input"
+            placeholder="请输入机器授权字段,验证操作权限"
+            onChange={e => setPsw(e.target.value)}
+          />
+        </div>
+      </Modal>
+    </>
+  );
+};
+
+export default Comp;
diff --git a/web/src/components/Tablex/index.less 
b/web/src/components/Tablex/index.less
new file mode 100644
index 0000000..f49a245
--- /dev/null
+++ b/web/src/components/Tablex/index.less
@@ -0,0 +1,16 @@
+.textWrap {
+  white-space: pre-line;
+  word-break: break-word;
+}
+.pb10{
+  padding-bottom: 10px;
+}
+.pd10{
+  padding: 10px;
+}
+.mt10{
+  margin-top: 10px;
+}
+.mb10{
+  margin-bottom: 10px;
+}
diff --git a/web/src/components/Tablex/index.tsx 
b/web/src/components/Tablex/index.tsx
new file mode 100644
index 0000000..ec41277
--- /dev/null
+++ b/web/src/components/Tablex/index.tsx
@@ -0,0 +1,114 @@
+/**
+ * TABLE COMPONENT WITH SEARCH
+ */
+import { Table, Input, Row, Button, Col, Tooltip } from 'antd';
+import * as React from 'react';
+import { TableProps } from 'antd/lib/table';
+import { CaretDownFilled, CaretUpFilled } from '@ant-design/icons';
+import { isEmptyParam } from '@/utils';
+import { useEffect } from 'react';
+import './index.less';
+
+const { useState } = React;
+
+const { Search } = Input;
+
+interface ComProps extends TableProps<any> {
+  filterFnX?: (value: any) => void;
+  columns?: any;
+  dataSourceX?: any;
+  searchPlaceholder?: string;
+  defaultSearchKey?: string;
+  isTruePagination?: boolean;
+  showSearch?: boolean;
+  searchWidth?: number;
+  searchStyle?: any;
+}
+
+const Comp = (props: ComProps) => {
+  const {
+    columns,
+    filterFnX,
+    searchPlaceholder,
+    expandable,
+    defaultSearchKey,
+    isTruePagination,
+    showSearch = true,
+    searchWidth = 8,
+    searchStyle = {},
+  } = props;
+  const [filterKey, setFilterKey] = useState(defaultSearchKey);
+  // 自动增加排序
+  if (columns) {
+    columns.forEach((t: any) => {
+      t.sorter = (a: any, b: any) =>
+        a[(t as any).dataIndex] - b[(t as any).dataIndex] >= 0 ? 1 : -1;
+    });
+  }
+  if (expandable && !expandable.expandIcon) {
+    expandable.expandIcon = ({ expanded, onExpand, record }) =>
+      expanded ? (
+        <CaretUpFilled onClick={e => onExpand(record, e)} />
+      ) : (
+        <CaretDownFilled onClick={e => onExpand(record, e)} />
+      );
+  }
+  const opts = { ...props };
+  const dataSource =
+    (!filterKey && isEmptyParam(opts.dataSourceX)) || isTruePagination
+      ? opts.dataSource
+      : opts.dataSourceX;
+  // 如有默认,先处理一次
+  useEffect(() => {
+    if (defaultSearchKey === undefined) return;
+
+    setFilterKey(defaultSearchKey || '');
+    filterFnX && filterFnX(defaultSearchKey || '');
+  }, [defaultSearchKey, filterFnX]);
+  // 分页如果只有一页,自动隐藏
+  opts.pagination = Object.assign(
+    {
+      hideOnSinglePage: true,
+    },
+    {
+      ...opts.pagination,
+    }
+  );
+
+  const onChange = (e: any) => {
+    if (!isTruePagination) {
+      filterFnX && filterFnX(e.target.value);
+    }
+
+    setFilterKey(e.target.value);
+  };
+
+  return (
+    <>
+      {showSearch && filterFnX && (
+        <Row gutter={20} className="mb10" style={{ position: 'relative' }}>
+          <Col span={searchWidth} style={{ padding: 0, ...searchStyle }}>
+            <Tooltip title={filterKey}>
+              <Search
+                value={filterKey}
+                onChange={onChange}
+                onSearch={v => filterFnX(v)}
+                allowClear
+                placeholder={searchPlaceholder || '字符串大小写敏感'}
+                enterButton={
+                  <Button type="primary" onClick={filterFnX}>
+                    搜索
+                  </Button>
+                }
+              />
+            </Tooltip>
+          </Col>
+        </Row>
+      )}
+
+      <Table {...opts} dataSource={dataSource} className="textWrap" />
+    </>
+  );
+};
+
+export default Comp;
diff --git a/web/src/components/Tablex/tableFilterHelper.ts 
b/web/src/components/Tablex/tableFilterHelper.ts
new file mode 100644
index 0000000..0032e8f
--- /dev/null
+++ b/web/src/components/Tablex/tableFilterHelper.ts
@@ -0,0 +1,32 @@
+interface TableFilterHelperProp {
+  key: string;
+  targetArray: Array<any>;
+  srcArray: Array<any>;
+  filterList: Array<any>;
+  updateFunction?: (p: Array<any>) => void;
+}
+const tableFilterHelper = (p: TableFilterHelperProp): any[] => {
+  const { key, srcArray = [], filterList, updateFunction } = p;
+  const res: any[] = [];
+
+  if (key) {
+    srcArray.forEach(it => {
+      const tar = filterList.map(t => {
+        return it[t];
+      });
+      let isFilterRight = false;
+      tar.forEach(t => {
+        if ((t + '').indexOf(key) > -1) isFilterRight = true;
+      });
+      if (isFilterRight) {
+        res.push(it);
+      }
+    });
+  }
+
+  if (updateFunction) updateFunction(res);
+
+  return res;
+};
+
+export default tableFilterHelper;
diff --git a/web/src/components/TitleWrap/index.less 
b/web/src/components/TitleWrap/index.less
new file mode 100644
index 0000000..b64cf1b
--- /dev/null
+++ b/web/src/components/TitleWrap/index.less
@@ -0,0 +1,11 @@
+.title-wrap-title {
+  font-size: 16px;
+  font-weight: bold;
+  margin-top: 15px;
+  margin-bottom: 15px;
+  position: relative;
+}
+
+.split-border {
+  border-top: 1px solid #eee;
+}
diff --git a/web/src/components/TitleWrap/index.tsx 
b/web/src/components/TitleWrap/index.tsx
new file mode 100644
index 0000000..47624f4
--- /dev/null
+++ b/web/src/components/TitleWrap/index.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+import './index.less';
+
+interface ComProps {
+  title: any;
+  children?: any;
+  wrapperStyle?: any;
+  hasSplit?: boolean;
+}
+
+const Comp = (props: ComProps) => {
+  const { hasSplit = true } = props;
+
+  return (
+    <div style={props.wrapperStyle} className={hasSplit ? 'split-border' : ''}>
+      <div className="title-wrap-title">{props.title}</div>
+      {props.children}
+    </div>
+  );
+};
+
+export default Comp;
diff --git a/web/src/components/index.tsx b/web/src/components/index.tsx
new file mode 100644
index 0000000..d7314f6
--- /dev/null
+++ b/web/src/components/index.tsx
@@ -0,0 +1,3 @@
+import Layout from './Layout';
+
+export { Layout };
diff --git a/web/src/configs/index.ts b/web/src/configs/index.ts
new file mode 100644
index 0000000..1cb36a6
--- /dev/null
+++ b/web/src/configs/index.ts
@@ -0,0 +1,3 @@
+import menus from './menus';
+
+export { menus };
diff --git a/web/src/configs/menus/index.tsx b/web/src/configs/menus/index.tsx
new file mode 100644
index 0000000..e689571
--- /dev/null
+++ b/web/src/configs/menus/index.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { Route } from '@/typings/router';
+import {
+  NodeExpandOutlined,
+  ClusterOutlined,
+  SettingOutlined,
+} from '@ant-design/icons';
+/*
+ * Note:
+ * Menu items with children need to set a key starting with "/"
+ * @see 
https://github.com/umijs/route-utils/blob/master/src/transformRoute/transformRoute.ts#L219
+ */
+
+const menus: Route[] = [
+  {
+    path: '/issue',
+    name: '分发查询',
+    icon: <NodeExpandOutlined />,
+    hideChildrenInMenu: true,
+    children: [
+      {
+        path: '/:id',
+        name: '消费组详情',
+      },
+    ],
+  },
+  {
+    name: '配置管理',
+    key: '/other',
+    icon: <SettingOutlined />,
+    path: '/other',
+    children: [
+      {
+        path: '/broker',
+        name: 'Broker列表',
+      },
+      {
+        path: '/topic',
+        name: 'topic列表',
+      },
+    ],
+  },
+  {
+    path: '/cluster',
+    name: '集群管理',
+    icon: <ClusterOutlined />,
+  },
+];
+
+export default menus;
diff --git a/web/src/constants/broker.ts b/web/src/constants/broker.ts
new file mode 100644
index 0000000..bc6ac45
--- /dev/null
+++ b/web/src/constants/broker.ts
@@ -0,0 +1,22 @@
+export const BROKER_INFO_ZH_MAP = {
+  acceptPublish: '可发布',
+  acceptSubscribe: '可订阅',
+  brokerId: 'BrokerId',
+  brokerIp: 'BrokerIP',
+  brokerPort: 'BrokerPort',
+  brokerTLSPort: 'TLS端口',
+  brokerVersion: '版本',
+  enableTLS: '启用TLS',
+  isAutoForbidden: '自动屏蔽',
+  isBrokerOnline: 'broker注册',
+  isConfChanged: '配置变更',
+  isConfLoaded: '变更加载',
+  isRepAbnormal: '上报异常',
+  manageStatus: '管理状态',
+  runStatus: '运行状态',
+  subStatus: '运行子状态',
+  'runInfo.acceptPublish': 'broker可发布状态',
+  'runInfo.acceptSubscribe': 'broker可订阅状态',
+  'runInfo.numPartitions': 'broker分区数',
+  'runInfo.brokerManageStatus': 'broker运行状态',
+};
diff --git a/web/src/constants/person.ts b/web/src/constants/person.ts
new file mode 100644
index 0000000..eadf34f
--- /dev/null
+++ b/web/src/constants/person.ts
@@ -0,0 +1,4 @@
+export const PERSON_INFO_ZH_MAP = {
+  createDate: '创建时间',
+  createUser: '创建人',
+};
diff --git a/web/src/constants/topic.ts b/web/src/constants/topic.ts
new file mode 100644
index 0000000..d4c8abf
--- /dev/null
+++ b/web/src/constants/topic.ts
@@ -0,0 +1,10 @@
+export const TOPIC_INFO_ZH_MAP = {
+  topicName: 'TopicName',
+  infoCount: '配置Broker数',
+  totalCfgNumPart: '配置分区数',
+  totalRunNumPartCount: '运行分区数',
+  isSrvAcceptPublish: '可发布',
+  isSrvAcceptSubscribe: '可订阅',
+  enableAuthControl: '权限受控',
+  groupCount: '授权消费组',
+};
diff --git a/web/src/context/globalContext.ts b/web/src/context/globalContext.ts
new file mode 100644
index 0000000..0540e02
--- /dev/null
+++ b/web/src/context/globalContext.ts
@@ -0,0 +1,12 @@
+// global context
+import React from 'react';
+import { BreadcrumbProps } from '@/components/Breadcrumb';
+export interface GlobalContextProps {
+  cluster?: string;
+  setCluster?: Function;
+  breadMap?: BreadcrumbProps['breadcrumbMap'];
+  setBreadMap?: Function;
+  userInfo?: any;
+}
+
+export default React.createContext<GlobalContextProps>({});
diff --git a/web/src/defaultSettings.js b/web/src/defaultSettings.js
new file mode 100644
index 0000000..684a9a3
--- /dev/null
+++ b/web/src/defaultSettings.js
@@ -0,0 +1,6 @@
+export default {
+  layout: 'sidemenu',
+  contentWidth: 'Fluid',
+  navTheme: 'dark',
+  primaryColor: '#1890ff',
+};
diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts
new file mode 100644
index 0000000..8340c9e
--- /dev/null
+++ b/web/src/hooks/index.ts
@@ -0,0 +1,52 @@
+import { useHistory, useLocation } from 'react-router-dom';
+import useRequest, { axios } from '@reactseed/use-request';
+import useRedux from '@reactseed/use-redux';
+import { message } from 'antd';
+
+interface DataProps {
+  data: any;
+  errorCode: number;
+  errMsg: number;
+  result: boolean;
+}
+// handler for old type interface
+axios.interceptors.request.use(
+  config => {
+    const urlArr = (config.url as any).split('/');
+    config.url = '/webapi.htm';
+    config.params = config.params || {};
+    config.params['type'] = urlArr[2];
+    config.params['method'] = urlArr[3];
+
+    return config;
+  },
+  function(error) {
+    return Promise.reject(error);
+  }
+);
+
+axios.interceptors.response.use(
+  ({ data }) => {
+    if (data.errCode !== 0) {
+      message.error(data.errMsg);
+      return Promise.reject(data);
+    }
+
+    // admin_query_master_group_info interface design no good need handle
+    if (
+      Object.keys(data).includes('groupName') &&
+      Object.keys(data).includes('groupStatus')
+    ) {
+      data.data = {
+        data: data.data,
+        groupName: data.groupName,
+        groupStatus: data.groupStatus,
+      };
+    }
+    return data || [];
+  },
+  function(error) {
+    return Promise.reject(error);
+  }
+);
+export { useHistory, useLocation, useRequest, useRedux };
diff --git a/web/src/index.tsx b/web/src/index.tsx
new file mode 100644
index 0000000..416097f
--- /dev/null
+++ b/web/src/index.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import App from '@/router';
+import * as serviceWorker from './serviceWorker';
+
+ReactDOM.render(<App />, document.getElementById('root'));
+
+// If you want your app to work offline and load faster, you can change
+// unregister() to register() below. Note this comes with some pitfalls.
+// Learn more about service workers: https://bit.ly/CRA-PWA
+serviceWorker.unregister();
diff --git a/web/src/pages/Broker/commonModal.tsx 
b/web/src/pages/Broker/commonModal.tsx
new file mode 100644
index 0000000..8dc8654
--- /dev/null
+++ b/web/src/pages/Broker/commonModal.tsx
@@ -0,0 +1,274 @@
+import { boolean2Chinese } from '@/utils';
+import Table from '@/components/Tablex';
+import { Col, Form, Input, Row } from 'antd';
+import Modal, { OKProps } from '@/components/Modalx';
+import React from 'react';
+import Query from '@/pages/Broker/query';
+import { FormProps } from 'antd/lib/form';
+
+export const OPTIONS = [
+  {
+    value: 'online',
+    name: '上线',
+  },
+  {
+    value: 'offline',
+    name: '下线',
+  },
+  {
+    value: 'reload',
+    name: '重载',
+  },
+  {
+    value: 'delete',
+    name: '删除',
+  },
+];
+export const OPTIONS_VALUES = OPTIONS.map(t => t.value);
+
+// interface
+export declare type BrokerData = any[];
+export interface BrokerResultData {
+  acceptPublish: string;
+  acceptSubscribe: string;
+  brokerId: number;
+  brokerIp: string;
+  brokerPort: number;
+  brokerTLSPort: number;
+  brokerVersion: string;
+  enableTLS: boolean;
+  isAutoForbidden: boolean;
+  isBrokerOnline: string;
+  isConfChanged: string;
+  isConfLoaded: string;
+  isRepAbnormal: boolean;
+  manageStatus: string;
+  runStatus: string;
+  subStatus: string;
+  [key: string]: any;
+}
+export interface BrokerModalProps {
+  type: string;
+  title: string;
+  updateFunction: (draft: any) => any;
+  params?: any;
+}
+// exports broker modal
+// render funcs
+const renderBrokerOptions = (modalParams: any, dataSource: any[]) => {
+  const columns = [
+    {
+      title: 'Broker',
+      render: (t: string, r: BrokerResultData) => {
+        return `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`;
+      },
+    },
+    {
+      title: 'BrokerIP',
+      dataIndex: 'brokerIp',
+    },
+    {
+      title: '管理状态',
+      dataIndex: 'manageStatus',
+    },
+    {
+      title: '运行状态',
+      dataIndex: 'runStatus',
+    },
+    {
+      title: '运行子状态',
+      dataIndex: 'subStatus',
+    },
+    {
+      title: '可发布',
+      render: (t: string) => boolean2Chinese(t),
+    },
+    {
+      title: '可订阅',
+      render: (t: string) => boolean2Chinese(t),
+    },
+  ];
+  return <Table columns={columns} dataSource={dataSource} rowKey="brokerId" />;
+};
+const renderNewBroker = (form: any) => {
+  const brokerFormArr = [
+    {
+      name: 'brokerId',
+      defaultValue: '0',
+    },
+    {
+      name: 'numPartitions',
+      defaultValue: '3',
+    },
+    {
+      name: 'brokerIp',
+      defaultValue: '',
+    },
+    {
+      name: 'brokerPort',
+      defaultValue: '8123',
+    },
+    {
+      name: 'deleteWhen',
+      defaultValue: '0 0 6,18 * * ?',
+    },
+    {
+      name: 'deletePolicy',
+      defaultValue: 'delete,168h',
+    },
+    {
+      name: 'unflushThreshold',
+      defaultValue: '1000',
+    },
+    {
+      name: 'unflushInterval',
+      defaultValue: '10000',
+    },
+    {
+      name: 'acceptPublish',
+      defaultValue: 'true',
+    },
+    {
+      name: 'acceptSubscribe',
+      defaultValue: 'true',
+    },
+  ];
+
+  return (
+    <Form form={form}>
+      <Row gutter={24}>
+        {brokerFormArr.map((t, index) => (
+          <Col span={12} key={'brokerFormArr' + index}>
+            <Form.Item
+              labelCol={{ span: 12 }}
+              label={t.name}
+              name={t.name}
+              initialValue={t.defaultValue}
+            >
+              <Input />
+            </Form.Item>
+          </Col>
+        ))}
+      </Row>
+    </Form>
+  );
+};
+const renderEditBroker = (modalParams: any, form: FormProps['form']) => {
+  const { params: p } = modalParams;
+  const pickArr = [
+    'numPartitions',
+    'unflushThreshold',
+    'unflushInterval',
+    'deleteWhen',
+    'deletePolicy',
+    'acceptPublish',
+    'acceptSubscribe',
+  ];
+  const brokerFormArr: Array<{
+    name: string;
+    defaultValue: string;
+  }> = [];
+  pickArr.forEach(t => {
+    brokerFormArr.push({
+      name: t,
+      defaultValue: p[t],
+    });
+  });
+
+  return (
+    <Form form={form}>
+      <Row gutter={24}>
+        {brokerFormArr.map((t, index) => (
+          <Col span={12} key={'brokerFormArr' + index}>
+            <Form.Item
+              labelCol={{ span: 12 }}
+              label={t.name}
+              name={t.name}
+              initialValue={t.defaultValue}
+            >
+              <Input />
+            </Form.Item>
+          </Col>
+        ))}
+      </Row>
+    </Form>
+  );
+};
+const renderBrokerStateChange = (modalParams: any) => {
+  const { params } = modalParams;
+
+  return (
+    <div>
+      请确认<span className="enhance">{params.option}</span> ID:{' '}
+      <span className="enhance">{params.id}</span> 的 Broker?
+    </div>
+  );
+};
+export const onOpenModal = (p: BrokerModalProps) => {
+  const { type, title, updateFunction, params } = p;
+  if (typeof params === 'function') {
+    p.params = params();
+  }
+  updateFunction((m: any) => {
+    m.type = type;
+    m.params = params;
+    Object.assign(m, {
+      params,
+      visible: type,
+      title,
+      onOk: (p: OKProps) => {
+        updateFunction((m: any) => {
+          if (type === 'newBroker' || type === 'editBroker') {
+            p.params = f && f.getFieldsValue();
+          }
+          m.okParams = p;
+          m.isOk = Date.now();
+        });
+      },
+      onCancel: () => {
+        updateFunction((m: any) => {
+          m.visible = false;
+          m.isOk = null;
+        });
+      },
+    });
+  });
+};
+
+interface ComProps {
+  modalParams: any;
+  data: any[];
+}
+let f: FormProps['form'];
+const Comp = (props: ComProps) => {
+  const { modalParams, data } = props;
+  const [form] = Form.useForm();
+  f = form;
+
+  return (
+    <Modal {...modalParams}>
+      <div>
+        {modalParams.type &&
+          OPTIONS_VALUES.includes(modalParams.type) &&
+          renderBrokerOptions(
+            modalParams,
+            data.filter((t: BrokerResultData) =>
+              modalParams.params.includes(t.brokerId)
+            )
+          )}
+        {modalParams.type === 'newBroker' && renderNewBroker(form)}
+        {modalParams.type === 'editBroker' &&
+          renderEditBroker(modalParams, form)}
+        {modalParams.type === 'brokerStateChange' &&
+          renderBrokerStateChange(modalParams)}
+      </div>
+      <Query
+        fire={modalParams.isOk}
+        params={modalParams.okParams}
+        type={modalParams.query || modalParams.type}
+      />
+    </Modal>
+  );
+};
+
+export default Comp;
diff --git a/web/src/pages/Broker/detail.tsx b/web/src/pages/Broker/detail.tsx
new file mode 100644
index 0000000..28a7d06
--- /dev/null
+++ b/web/src/pages/Broker/detail.tsx
@@ -0,0 +1,378 @@
+import React, { ReactNode, useContext, useState } from 'react';
+import GlobalContext from '@/context/globalContext';
+import Breadcrumb from '@/components/Breadcrumb';
+import Table from '@/components/Tablex';
+import TitleWrap from '@/components/TitleWrap';
+import { Form, Button, Spin, Col, Row, Switch, Tabs } from 'antd';
+import { useImmer } from 'use-immer';
+import './index.less';
+import { useRequest } from '@/hooks';
+import { useParams } from 'react-router-dom';
+import { boolean2Chinese, transParamsWithConstantsMap } from '@/utils';
+import { BROKER_INFO_ZH_MAP } from '@/constants/broker';
+import tableFilterHelper from '@/components/Tablex/tableFilterHelper';
+import CommonModal, { OPTIONS, onOpenModal, BrokerData } from './commonModal';
+
+declare type BrokerQueryData = {
+  withDetail: boolean;
+  brokerId: string;
+};
+
+declare type TopicQueryData = {
+  withTopic: boolean;
+  brokerId: string;
+};
+
+const { TabPane } = Tabs;
+
+const Detail: React.FC = () => {
+  const { id } = useParams();
+  const { breadMap } = useContext(GlobalContext);
+  const [form] = Form.useForm();
+  const [modalParams, updateModelParams] = useImmer<any>({});
+  const [acceptPublish, setAcceptPublish] = useState<any>(false);
+  const [acceptSubscribe, setAcceptSubscribe] = useState<any>(false);
+  const [filterData, updateFilterData] = useImmer<any>({});
+  const queryBrokerConf = useRequest<any>(
+    (
+      data: BrokerQueryData = {
+        withDetail: true,
+        brokerId: id,
+      }
+    ) => ({
+      url: '/api/op_query/admin_query_broker_run_status',
+      data: {
+        ...data,
+      },
+    }),
+    {
+      onSuccess: data => {
+        setAcceptPublish(data[0]['acceptPublish'] === 'true');
+        setAcceptSubscribe(data[0]['acceptSubscribe'] === 'true');
+      },
+    }
+  );
+  const queryTopicInfo = useRequest<any>(
+    (
+      data: TopicQueryData = {
+        withTopic: true,
+        brokerId: id,
+      }
+    ) => ({
+      url: '/api/op_query/admin_query_broker_configure',
+      data: {
+        ...data,
+      },
+    })
+  );
+
+  // render
+  const renderConf = () => {
+    const columns = [
+      {
+        title: '类别',
+        dataIndex: `type`,
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 
'acceptPublish'),
+        dataIndex: 'acceptPublish',
+        render: (t: string) => boolean2Chinese(t),
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'acceptSubscribe'
+        ),
+        dataIndex: 'acceptSubscribe',
+        render: (t: string) => boolean2Chinese(t),
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'unflushThreshold'
+        ),
+        dataIndex: 'unflushThreshold',
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'unflushInterval'
+        ),
+        dataIndex: 'unflushInterval',
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deleteWhen'),
+        dataIndex: 'deleteWhen',
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deletePolicy'),
+        dataIndex: 'deletePolicy',
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 
'numPartitions'),
+        dataIndex: 'numPartitions',
+      },
+      {
+        title: '操作',
+        render: (t: string, r: BrokerData) => {
+          return <a onClick={() => onEditConf(r)}>编辑</a>;
+        },
+      },
+    ];
+    const { data } = queryBrokerConf;
+    if (!data || !data[0]) return null;
+    const { BrokerSyncStatusInfo } = data[0];
+    const dataSource = [];
+    dataSource.push({
+      type: '缺省配置',
+      ...BrokerSyncStatusInfo.curBrokerDefaultConfInfo,
+    });
+    dataSource.push({
+      type: '最近上报',
+      ...BrokerSyncStatusInfo.reportedBrokerDefaultConfInfo,
+    });
+    dataSource.push({
+      type: '最近下发',
+      ...BrokerSyncStatusInfo.lastPushBrokerDefaultConfInfo,
+    });
+
+    return <Table columns={columns} dataSource={dataSource} rowKey="type" />;
+  };
+  const renderTopics = (type: string): ReactNode => {
+    const columns = [
+      {
+        title: 'topicName',
+        dataIndex: `topicName`,
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 
'numPartitions'),
+        dataIndex: 'numPartitions',
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 
'acceptPublish'),
+        dataIndex: 'acceptPublish',
+        render: (t: string) => boolean2Chinese(t),
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'acceptSubscribe'
+        ),
+        dataIndex: 'acceptSubscribe',
+        render: (t: string) => boolean2Chinese(t),
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'unflushThreshold'
+        ),
+        dataIndex: 'unflushThreshold',
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'unflushInterval'
+        ),
+        dataIndex: 'unflushInterval',
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deleteWhen'),
+        dataIndex: 'deleteWhen',
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deletePolicy'),
+        dataIndex: 'deletePolicy',
+      },
+    ];
+    const { data } = queryBrokerConf;
+    if (!data || !data[0]) return null;
+    const { BrokerSyncStatusInfo } = data[0];
+    let dataSource: any[] = [];
+    if (type === 'cur') {
+      dataSource = BrokerSyncStatusInfo.curBrokerTopicSetConfInfo;
+    } else if (type === 'lastPush') {
+      dataSource = BrokerSyncStatusInfo.lastPushBrokerTopicSetConfInfo;
+    } else if (type === 'lastReported') {
+      dataSource = BrokerSyncStatusInfo.reportedBrokerTopicSetConfInfo;
+    }
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={dataSource}
+        rowKey={r => type + r.topicName}
+        dataSourceX={filterData.list}
+        searchPlaceholder="请输入TopicName搜索"
+        searchStyle={{
+          position: 'absolute',
+          top: '-55px',
+          right: '10px',
+          zIndex: 1,
+        }}
+        filterFnX={value =>
+          tableFilterHelper({
+            key: value,
+            srcArray: dataSource,
+            targetArray: filterData.list,
+            updateFunction: res =>
+              updateFilterData(filterData => {
+                filterData.list = res;
+              }),
+            filterList: ['topicName'],
+          })
+        }
+      />
+    );
+  };
+
+  // event
+  // acceptPublish && acceptSubscribe event
+  const onSwitchChange = (e: boolean, type: string) => {
+    let option = '';
+    if (type === 'acceptPublish') {
+      option = e ? '发布' : '禁止可发布';
+    } else if (type === 'acceptSubscribe') {
+      option = e ? '订阅' : '禁止可订阅';
+    }
+
+    onOpenModal({
+      type: 'brokerStateChange',
+      title: `请确认操作`,
+      updateFunction: updateModelParams,
+      params: {
+        option,
+        id: queryBrokerConf.data[0].brokerId,
+        callback: () => {
+          if (type === 'acceptPublish') {
+            setAcceptPublish(e);
+          } else if (type === 'acceptSubscribe') {
+            setAcceptSubscribe(e);
+          }
+        },
+      },
+    });
+  };
+
+  const onOptions = (type: string) => {
+    onOpenModal({
+      type,
+      title: `确认进行【${OPTIONS.find(t => t.value === type)?.name}】操作?`,
+      updateFunction: updateModelParams,
+      params: [queryBrokerConf.data[0].brokerId],
+    });
+  };
+
+  // new broker
+  const onEditConf = (r: BrokerData) => {
+    onOpenModal({
+      type: 'editBroker',
+      title: '编辑Broker',
+      updateFunction: updateModelParams,
+      params: r,
+    });
+  };
+
+  return (
+    <Spin spinning={queryBrokerConf.loading && queryTopicInfo.loading}>
+      <Breadcrumb
+        breadcrumbMap={breadMap}
+        appendParams={`Broker(${id})详情`}
+      />
+      <div className="main-container">
+        <TitleWrap title="运行状态" wrapperStyle={{ position: 'relative' }}>
+          <div className="broker-detail-options-wrapper">
+            <Switch
+              className="mr10"
+              checked={acceptPublish}
+              checkedChildren="订阅"
+              unCheckedChildren="订阅"
+              onChange={e => onSwitchChange(e, 'acceptPublish')}
+            />
+            <Switch
+              className="mr10"
+              checked={acceptSubscribe}
+              checkedChildren="发布"
+              unCheckedChildren="发布"
+              onChange={e => onSwitchChange(e, 'acceptSubscribe')}
+            />
+            <Button
+              className="mr10"
+              type="primary"
+              size="small"
+              onClick={() => onOptions('online')}
+            >
+              上线
+            </Button>
+            <Button
+              className="mr10"
+              type="primary"
+              size="small"
+              onClick={() => onOptions('offline')}
+            >
+              下线
+            </Button>
+            <Button
+              className="mr10"
+              type="primary"
+              size="small"
+              onClick={() => onOptions('reload')}
+            >
+              重载
+            </Button>
+          </div>
+          <Form form={form}>
+            <Row gutter={24}>
+              {queryBrokerConf.data &&
+                Object.keys(queryBrokerConf.data[0]).map(
+                  (t: string, index: number) => {
+                    const label = transParamsWithConstantsMap(
+                      BROKER_INFO_ZH_MAP,
+                      t
+                    );
+                    const ignoreList = [
+                      'acceptPublish',
+                      'brokerVersion',
+                      'acceptSubscribe',
+                    ];
+                    if (
+                      queryBrokerConf.data[0][t] instanceof Object ||
+                      !label ||
+                      ignoreList.includes(t)
+                    )
+                      return null;
+                    return (
+                      <Col span={12} key={'queryBrokerConf' + index}>
+                        <Form.Item labelCol={{ span: 12 }} label={label}>
+                          {queryBrokerConf.data[0][t] + ''}
+                        </Form.Item>
+                      </Col>
+                    );
+                  }
+                )}
+            </Row>
+          </Form>
+        </TitleWrap>
+        <TitleWrap title="缺省配置">{renderConf()}</TitleWrap>
+        <TitleWrap title="Topic集合配置">
+          <Tabs>
+            <TabPane tab="当前配置" key="cur">
+              {renderTopics('cur')}
+            </TabPane>
+            <TabPane tab="最后下发" key="lastPush">
+              {renderTopics('lastPush')}
+            </TabPane>
+            <TabPane tab="最后上报" key="lastReported">
+              {renderTopics('lastReported')}
+            </TabPane>
+          </Tabs>
+        </TitleWrap>
+      </div>
+      <CommonModal
+        modalParams={modalParams}
+        data={[queryBrokerConf.data && queryBrokerConf.data[0]]}
+      />
+    </Spin>
+  );
+};
+
+export default Detail;
diff --git a/web/src/pages/Broker/index.less b/web/src/pages/Broker/index.less
new file mode 100644
index 0000000..c95e98d
--- /dev/null
+++ b/web/src/pages/Broker/index.less
@@ -0,0 +1,9 @@
+.broker-detail-options-wrapper {
+  position: absolute;
+  top: 15px;
+  right: 0;
+
+  .mr10 {
+    margin-right: 10px;
+  }
+}
\ No newline at end of file
diff --git a/web/src/pages/Broker/index.tsx b/web/src/pages/Broker/index.tsx
new file mode 100644
index 0000000..7cc91dc
--- /dev/null
+++ b/web/src/pages/Broker/index.tsx
@@ -0,0 +1,280 @@
+import React, { useContext, useState } from 'react';
+import GlobalContext from '@/context/globalContext';
+import Breadcrumb from '@/components/Breadcrumb';
+import Table from '@/components/Tablex';
+import { Form, Select, Button, Spin, Switch, message } from 'antd';
+import { useImmer } from 'use-immer';
+import { useRequest } from '@/hooks';
+import tableFilterHelper from '@/components/Tablex/tableFilterHelper';
+import { boolean2Chinese, transParamsWithConstantsMap } from '@/utils';
+import { BROKER_INFO_ZH_MAP } from '@/constants/broker';
+import './index.less';
+import { Link } from 'react-router-dom';
+import CommonModal, {
+  OPTIONS,
+  onOpenModal,
+  BrokerResultData,
+  BrokerData,
+} from './commonModal';
+
+const { Option } = Select;
+const Broker: React.FC = () => {
+  // column config
+  const columns = [
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'brokerId'),
+      dataIndex: 'brokerId',
+      fixed: 'left',
+      render: (t: Array<any>) => <Link to={'/broker/' + t}>{t}</Link>,
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'brokerIp'),
+      dataIndex: 'brokerIp',
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'brokerPort'),
+      dataIndex: 'brokerPort',
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'manageStatus'),
+      dataIndex: 'manageStatus',
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'runStatus'),
+      dataIndex: 'runStatus',
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'subStatus'),
+      dataIndex: 'subStatus',
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'acceptPublish'),
+      dataIndex: 'acceptPublish',
+      render: (t: string, r: BrokerResultData) => {
+        return (
+          <Switch
+            checked={t === 'true'}
+            onChange={e => onSwitchChange(e, r, 'acceptPublish')}
+          />
+        );
+      },
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 
'acceptSubscribe'),
+      dataIndex: 'acceptSubscribe',
+      render: (t: string, r: BrokerResultData) => {
+        return (
+          <Switch
+            checked={t === 'true'}
+            onChange={e => onSwitchChange(e, r, 'acceptSubscribe')}
+          />
+        );
+      },
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isConfChanged'),
+      dataIndex: 'isConfChanged',
+      render: (t: string) => boolean2Chinese(t),
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isConfLoaded'),
+      dataIndex: 'isConfLoaded',
+      render: (t: string) => boolean2Chinese(t),
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isBrokerOnline'),
+      dataIndex: 'isBrokerOnline',
+      render: (t: string) => boolean2Chinese(t),
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'acceptPublish'),
+      dataIndex: 'isBrokerOnline',
+      render: (t: string) => boolean2Chinese(t),
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'brokerTLSPort'),
+      dataIndex: 'brokerTLSPort',
+      render: (t: string) => boolean2Chinese(t),
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'enableTLS'),
+      dataIndex: 'enableTLS',
+      render: (t: boolean) => boolean2Chinese(t),
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isRepAbnormal'),
+      dataIndex: 'isRepAbnormal',
+      render: (t: boolean) => boolean2Chinese(t),
+    },
+    {
+      title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 
'isAutoForbidden'),
+      dataIndex: 'isAutoForbidden',
+      render: (t: boolean) => boolean2Chinese(t),
+    },
+    {
+      title: '操作',
+      dataIndex: 'brokerIp',
+      fixed: 'right',
+      width: 180,
+      render: (t: string, r: any) => {
+        return (
+          <span className="options-wrapper">
+            {OPTIONS.map(t => (
+              <a key={t.value} onClick={() => onOptionsChange(t.value, r)}>
+                {t.name}
+              </a>
+            ))}
+          </span>
+        );
+      },
+    },
+  ];
+  const { breadMap } = useContext(GlobalContext);
+  const [modalParams, updateModelParams] = useImmer<any>({});
+  const [filterData, updateFilterData] = useImmer<any>({});
+  const [selectBroker, setSelectBroker] = useState<any>([]);
+  const [brokerList, updateBrokerList] = useImmer<BrokerData>([]);
+  const [form] = Form.useForm();
+  // init query
+  const { data, loading, run } = useRequest<any, BrokerData>(
+    (data: BrokerResultData) => ({
+      url: '/api/op_query/admin_query_broker_run_status',
+      data: data,
+    }),
+    {
+      onSuccess: data => {
+        updateBrokerList(d => {
+          Object.assign(d, data);
+        });
+      },
+    }
+  );
+
+  // table event
+  // acceptSubscribe && acceptPublish options
+  const onSwitchChange = (e: boolean, r: BrokerResultData, type: string) => {
+    let option = '';
+    if (type === 'acceptPublish') {
+      option = e ? '发布' : '禁止可发布';
+    } else if (type === 'acceptSubscribe') {
+      option = e ? '订阅' : '禁止可订阅';
+    }
+
+    onOpenModal({
+      type: 'brokerStateChange',
+      title: `请确认操作`,
+      updateFunction: updateModelParams,
+      params: {
+        option,
+        id: r.brokerId,
+        type,
+        callback: () => {
+          const index = data.findIndex(
+            (t: BrokerResultData) => t.brokerId === r.brokerId
+          );
+          updateBrokerList(d => {
+            d[index][type] = e + '';
+          });
+        },
+      },
+    });
+  };
+  // new broker
+  const onNewBroker = () => {
+    onOpenModal({
+      type: 'newBroker',
+      title: '新建Broker',
+      updateFunction: updateModelParams,
+    });
+  };
+  // online, offline, etc.
+  const onOptionsChange = (type: string, r?: BrokerResultData) => {
+    if (!r && !selectBroker.length) {
+      form.resetFields();
+      return message.error('批量操作至少选择一列!');
+    }
+
+    onOpenModal({
+      type,
+      title: `确认进行【${OPTIONS.find(t => t.value === type)?.name}】操作?`,
+      updateFunction: updateModelParams,
+      params: r ? [r.brokerId] : selectBroker,
+    });
+  };
+  // table select
+  const onBrokerTableSelectChange = (p: any[]) => {
+    setSelectBroker(p);
+  };
+
+  return (
+    <Spin spinning={loading}>
+      <Breadcrumb breadcrumbMap={breadMap} />
+      <div className="main-container">
+        <div
+          className="search-wrapper"
+          style={{ float: 'right', marginRight: '-16px' }}
+        >
+          <Form form={form} layout={'inline'}>
+            <Form.Item label="批量操作" name="optionType">
+              <Select
+                style={{ width: 120 }}
+                onChange={(v: string) => onOptionsChange(v)}
+                placeholder="请选择操作"
+              >
+                {OPTIONS.map(t => (
+                  <Option value={t.value} key={t.value}>
+                    {t.name}
+                  </Option>
+                ))}
+              </Select>
+            </Form.Item>
+            <Form.Item>
+              <Button
+                type="primary"
+                onClick={() => onNewBroker()}
+                style={{ margin: '0 10px 0 10px' }}
+              >
+                新增
+              </Button>
+              <Button type="primary" onClick={() => run()}>
+                刷新
+              </Button>
+            </Form.Item>
+          </Form>
+        </div>
+        <Table
+          rowSelection={{ onChange: onBrokerTableSelectChange }}
+          columns={columns}
+          dataSource={brokerList}
+          rowKey="brokerId"
+          searchPlaceholder="请输入关键字搜索"
+          searchWidth={12}
+          dataSourceX={filterData.list}
+          scroll={{ x: 2500 }}
+          filterFnX={value =>
+            tableFilterHelper({
+              key: value,
+              srcArray: data,
+              targetArray: filterData.list,
+              updateFunction: res =>
+                updateFilterData(filterData => {
+                  filterData.list = res;
+                }),
+              filterList: [
+                'brokerId',
+                'brokerIp',
+                'brokerPort',
+                'runStatus',
+                'subStatus',
+                'manageStatus',
+              ],
+            })
+          }
+        />
+      </div>
+      <CommonModal modalParams={modalParams} data={data} />
+    </Spin>
+  );
+};
+
+export default Broker;
diff --git a/web/src/pages/Broker/query.tsx b/web/src/pages/Broker/query.tsx
new file mode 100644
index 0000000..9e89789
--- /dev/null
+++ b/web/src/pages/Broker/query.tsx
@@ -0,0 +1,128 @@
+import * as React from 'react';
+import './index.less';
+import { OKProps } from '@/components/Modalx';
+import { useRequest } from '@/hooks';
+import { useContext, useEffect } from 'react';
+import GlobalContext from '@/context/globalContext';
+
+interface ComProps {
+  fire: string;
+  params: any;
+  type: string;
+}
+
+const Comp = (props: ComProps) => {
+  const { fire } = props;
+  const { userInfo } = useContext(GlobalContext);
+  // eslint-disable-next-line
+  useEffect(() => {
+    const { params, type } = props;
+    dispatchAction(type, params);
+  }, [fire, props]);
+
+  const dispatchAction = (type: string, p: OKProps) => {
+    if (!fire) return null;
+    let promise;
+    switch (type) {
+      case 'newBroker':
+        promise = newBroker(p);
+        break;
+      case 'editBroker':
+        promise = editBroker(p);
+        break;
+      case 'brokerStateChange':
+        promise = brokerAcceptPublish(type, p);
+        break;
+      case 'online':
+      case 'offline':
+      case 'reload':
+      case 'delete':
+        promise = brokerOptions(type, p);
+        break;
+    }
+
+    promise &&
+      promise.then(t => {
+        const { callback } = p.params;
+        if (t.statusCode !== 0 && callback) callback(t);
+      });
+  };
+
+  const newBrokerQuery = useRequest<any, any>(
+    data => ({ url: '/api/op_modify/admin_add_broker_configure', ...data }),
+    { manual: true }
+  );
+  const newBroker = (p: OKProps) => {
+    const { params } = p;
+    return newBrokerQuery.run({
+      data: {
+        ...params,
+        confModAuthToken: p.psw,
+        createUser: userInfo.userName,
+      },
+    });
+  };
+
+  const updateBrokerQuery = useRequest<any, any>(
+    data => ({ url: '/api/op_modify/admin_update_broker_configure', ...data }),
+    { manual: true }
+  );
+  const editBroker = (p: OKProps) => {
+    const { params } = p;
+    return updateBrokerQuery.run({
+      data: {
+        ...params,
+        confModAuthToken: p.psw,
+        createUser: userInfo.userName,
+      },
+    });
+  };
+
+  const brokerOptionsQuery = useRequest<any, any>(
+    (url, data) => ({ url, ...data }),
+    { manual: true }
+  );
+  const brokerOptions = (type: string, p: OKProps) => {
+    const { params } = p;
+    return brokerOptionsQuery.run(
+      `/api/op_modify/admin_${type}_broker_configure`,
+      {
+        data: {
+          brokerId: params ? params?.join(',') : 
params?.selectBroker.join(','),
+          confModAuthToken: p.psw,
+          createUser: userInfo.userName,
+        },
+      }
+    );
+  };
+
+  const brokerAcceptPublishQuery = useRequest<any, any>(
+    (url, data) => ({ url, ...data }),
+    { manual: true }
+  );
+  const brokerAcceptPublish = (type: string, p: OKProps) => {
+    const { params } = p;
+    const data: any = {
+      brokerId: params.id,
+      confModAuthToken: p.psw,
+      createUser: userInfo.userName,
+    };
+    if (params.type === 'acceptPublish') {
+      data.isAcceptPublish = params.option;
+    }
+    if (params.type === 'acceptSubscribe') {
+      data.isAcceptSubscribe = params.option;
+    }
+
+    return brokerAcceptPublishQuery.run(
+      `/api/op_modify/admin_set_broker_read_or_write`,
+      {
+        data,
+      }
+    );
+  };
+
+  return <></>;
+};
+
+export default Comp;
diff --git a/web/src/pages/Cluster/index.less b/web/src/pages/Cluster/index.less
new file mode 100644
index 0000000..e69de29
diff --git a/web/src/pages/Cluster/index.tsx b/web/src/pages/Cluster/index.tsx
new file mode 100644
index 0000000..6b82403
--- /dev/null
+++ b/web/src/pages/Cluster/index.tsx
@@ -0,0 +1,143 @@
+import React, { useContext } from 'react';
+import GlobalContext from '@/context/globalContext';
+import Breadcrumb from '@/components/Breadcrumb';
+import Table from '@/components/Tablex';
+import { Spin } from 'antd';
+import './index.less';
+import { useRequest } from '@/hooks';
+import Modal, { OKProps } from '@/components/Modalx';
+import { useImmer } from 'use-immer';
+
+interface ClusterResultData {
+  groupName: string;
+  groupStatus: string;
+  hostName: string;
+  index: number;
+  port: string;
+  nodeStatus: string;
+  length: number;
+}
+
+const queryClusterList = (data: ClusterResultData) => ({
+  url: '/api/op_query/admin_query_master_group_info',
+  data: data,
+});
+
+const Cluster: React.FC = () => {
+  const { breadMap } = useContext(GlobalContext);
+  const [modalParams, updateModelParams] = useImmer<any>({
+    title: '请确认操作',
+  });
+  const { data, loading } = useRequest<any, any>(queryClusterList, {
+    formatResult: d => {
+      return {
+        list: d.data.map((t: any) => ({
+          groupName: d.groupName,
+          groupStatus: d.groupStatus,
+          hostName: t.hostName,
+          index: t.index,
+          port: t.port,
+          nodeStatus: t.statusInfo.nodeStatus,
+          length: d.data.length,
+        })),
+      };
+    },
+  });
+  const columns = [
+    {
+      title: '集群名',
+      dataIndex: 'groupName',
+      render: (t: string, r: ClusterResultData, index: number) => {
+        return {
+          children: t,
+          props: {
+            rowSpan: index === 0 ? r.length : 0,
+          },
+        };
+      },
+    },
+    {
+      title: '集群状态',
+      dataIndex: 'groupStatus',
+      render: (t: string, r: ClusterResultData, index: number) => {
+        return {
+          children: t,
+          props: {
+            rowSpan: index === 0 ? r.length : 0,
+          },
+        };
+      },
+    },
+    {
+      title: '节点名',
+      render: (t: string, r: ClusterResultData) => {
+        return `${r.groupName}-${r.hostName}`;
+      },
+    },
+    {
+      title: 'IP地址',
+      render: (t: string, r: ClusterResultData) => {
+        return `${r.hostName}-${r.port}`;
+      },
+    },
+    {
+      title: '节点名',
+      dataIndex: 'nodeStatus',
+    },
+    {
+      title: '操作',
+      render: (t: string, r: ClusterResultData, index: number) => {
+        return {
+          children: (
+            <span className="options-wrapper">
+              <a onClick={() => onSwitchCluster(t, r)}>切换</a>
+            </span>
+          ),
+          props: {
+            rowSpan: index === 0 ? r.length : 0,
+          },
+        };
+      },
+    },
+  ];
+
+  const switchClusterQuery = useRequest<any, any>(
+    (data?: ClusterResultData) => ({
+      url: '/api/op_modify/admin_transfer_current_master',
+      data,
+    }),
+    { manual: true }
+  );
+  const onSwitchCluster = (t: string, r: ClusterResultData) => {
+    updateModelParams(d => {
+      d = Object.assign(d, {
+        visible: true,
+        onOk: (p: OKProps) => {
+          switchClusterQuery.run({
+            confModAuthToken: p.psw,
+          });
+        },
+        onCancel: () => {
+          updateModelParams((m: any) => {
+            m.visible = false;
+          });
+        },
+      });
+    });
+  };
+  return (
+    <Spin spinning={loading}>
+      <Breadcrumb breadcrumbMap={breadMap}></Breadcrumb>
+      <div className="main-container">
+        <Table columns={columns} dataSource={data?.list} 
rowKey="index"></Table>
+      </div>
+      <Modal {...modalParams}>
+        <div>
+          确认<span className="enhance">切换</span>集群?
+        </div>
+      </Modal>
+    </Spin>
+  );
+};
+
+export default Cluster;
diff --git a/web/src/pages/Issue/consumeGroupDetail.tsx 
b/web/src/pages/Issue/consumeGroupDetail.tsx
new file mode 100644
index 0000000..3c5090f
--- /dev/null
+++ b/web/src/pages/Issue/consumeGroupDetail.tsx
@@ -0,0 +1,95 @@
+import React, { useContext } from 'react';
+import GlobalContext from '@/context/globalContext';
+import Breadcrumb from '@/components/Breadcrumb';
+import Table from '@/components/Tablex';
+import tableFilterHelper from '@/components/Tablex/tableFilterHelper';
+import { Spin } from 'antd';
+import { useImmer } from 'use-immer';
+import './index.less';
+import { useRequest } from '@/hooks';
+import { useParams } from 'react-router-dom';
+
+declare type ConsumeGroupData = any[];
+interface ConsumeGroupQueryData {
+  consumeGroup: string;
+}
+
+// column config
+const columns = [
+  {
+    title: '消费者ID',
+    dataIndex: 'consumerId',
+  },
+  {
+    title: '消费Topic',
+    dataIndex: 'topicName',
+  },
+  {
+    title: 'broker地址',
+    dataIndex: 'brokerAddr',
+  },
+  {
+    title: '分区ID',
+    dataIndex: 'partId',
+  },
+];
+
+const queryUser = (data: ConsumeGroupQueryData) => ({
+  url: '/api/op_query/admin_query_consume_group_detail',
+  data: data,
+});
+
+const ConsumeGroupDetail: React.FC = () => {
+  const { id } = useParams();
+  const { breadMap } = useContext(GlobalContext);
+  const [filterData, updateFilterData] = useImmer<any>({});
+  const { data, loading } = useRequest<any, ConsumeGroupData>(
+    () =>
+      queryUser({
+        consumeGroup: id,
+      }),
+    {
+      formatResult: data => {
+        const d = data[0];
+        return {
+          list: d.parInfo.map((t: any) => ({
+            consumerId: d.consumerId,
+            ...t,
+          })),
+        };
+      },
+    }
+  );
+
+  return (
+    <Spin spinning={loading}>
+      <Breadcrumb
+        breadcrumbMap={breadMap}
+        appendParams={`消费组详情(${id})`}
+      ></Breadcrumb>
+      <div className="main-container">
+        <Table
+          columns={columns}
+          dataSource={data?.list}
+          rowKey="brokerAddr"
+          searchPlaceholder="请输入 broker地址/分区ID 搜索"
+          dataSourceX={filterData.list}
+          filterFnX={value =>
+            tableFilterHelper({
+              key: value,
+              srcArray: data?.list,
+              targetArray: filterData.list,
+              updateFunction: res =>
+                updateFilterData(filterData => {
+                  filterData.list = res;
+                }),
+              filterList: ['brokerAddr', 'partId'],
+            })
+          }
+        ></Table>
+      </div>
+    </Spin>
+  );
+};
+
+export default ConsumeGroupDetail;
diff --git a/web/src/pages/Issue/index.less b/web/src/pages/Issue/index.less
new file mode 100644
index 0000000..e69de29
diff --git a/web/src/pages/Issue/index.tsx b/web/src/pages/Issue/index.tsx
new file mode 100644
index 0000000..54f5ad3
--- /dev/null
+++ b/web/src/pages/Issue/index.tsx
@@ -0,0 +1,98 @@
+import React, { useContext } from 'react';
+import GlobalContext from '@/context/globalContext';
+import Breadcrumb from '@/components/Breadcrumb';
+import Table from '@/components/Tablex';
+import { Form, Input, Button, Spin } from 'antd';
+import { useImmer } from 'use-immer';
+import './index.less';
+import { useRequest } from '@/hooks';
+import { Link } from 'react-router-dom';
+
+declare type IssueData = any[];
+interface IssueQueryData {
+  topicName?: string;
+  consumeGroup?: string;
+}
+
+// column config
+const columns = [
+  {
+    title: '消费组',
+    dataIndex: 'consumeGroup',
+    render: (t: Array<any>) => <Link to={'/issue/' + t}>{t}</Link>,
+  },
+  {
+    title: '消费Topic',
+    dataIndex: 'topicSet',
+    render: (t: Array<any>) => {
+      return t.join(',');
+    },
+  },
+  {
+    title: '消费分区',
+    dataIndex: 'consumerNum',
+  },
+];
+
+const queryIssueList = (data: IssueQueryData) => ({
+  url: '/api/op_query/admin_query_sub_info',
+  data: data,
+});
+
+const Issue: React.FC = () => {
+  const { breadMap } = useContext(GlobalContext);
+  const [form] = Form.useForm();
+  const [formValues, updateFormValues] = useImmer<any>({});
+  const { data, loading, run } = useRequest<any, IssueData>(queryIssueList, 
{});
+
+  const onValuesChange = (p: any) => {
+    updateFormValues(d => {
+      Object.assign(d, p);
+    });
+  };
+  const onSearch = () => {
+    run(formValues);
+  };
+
+  const onReset = () => {
+    form.resetFields();
+    run({});
+  };
+
+  return (
+    <Spin spinning={loading}>
+      <Breadcrumb breadcrumbMap={breadMap}></Breadcrumb>
+      <div className="main-container">
+        <div className="search-wrapper">
+          <Form form={form} layout={'inline'} onValuesChange={onValuesChange}>
+            <Form.Item label="Topic 名称" name="topicName">
+              <Input placeholder="" />
+            </Form.Item>
+            <Form.Item label="消费组" name="consumeGroup">
+              <Input placeholder="" />
+            </Form.Item>
+            <Form.Item>
+              <Button
+                type="primary"
+                onClick={onSearch}
+                style={{ margin: '0 20px' }}
+              >
+                查询
+              </Button>
+              <Button type="default" onClick={onReset}>
+                重置
+              </Button>
+            </Form.Item>
+          </Form>
+        </div>
+        <Table
+          columns={columns}
+          dataSource={data}
+          rowKey="consumeGroup"
+        ></Table>
+      </div>
+    </Spin>
+  );
+};
+
+export default Issue;
diff --git a/web/src/pages/NotFound/index.tsx b/web/src/pages/NotFound/index.tsx
new file mode 100644
index 0000000..8a0e971
--- /dev/null
+++ b/web/src/pages/NotFound/index.tsx
@@ -0,0 +1,5 @@
+import React from 'react';
+
+const NotFound: React.FC = () => <div>404</div>;
+
+export default NotFound;
diff --git a/web/src/pages/Topic/commonModal.tsx 
b/web/src/pages/Topic/commonModal.tsx
new file mode 100644
index 0000000..9d7783c
--- /dev/null
+++ b/web/src/pages/Topic/commonModal.tsx
@@ -0,0 +1,349 @@
+import { boolean2Chinese } from '@/utils';
+import Table from '@/components/Tablex';
+import { Col, Form, Input, message, Row } from 'antd';
+import Modal, { OKProps } from '@/components/Modalx';
+import React from 'react';
+import Query from '@/pages/Topic/query';
+import { FormProps } from 'antd/lib/form';
+
+export const OPTIONS = [
+  {
+    value: 'delete',
+    name: '删除',
+  },
+];
+export const OPTIONS_VALUES = OPTIONS.map(t => t.value);
+
+// interface
+export declare type TopicData = any[];
+export interface TopicResultData {
+  topicName: string;
+  infoCount: string;
+  totalCfgNumPart: string;
+  totalRunNumPartCount: string;
+  isSrvAcceptPublish: string | number;
+  isSrvAcceptSubscribe: string | number;
+  enableAuthControl: string | number;
+  [key: string]: any;
+}
+export interface TopicModalProps {
+  type: string;
+  title?: string;
+  updateFunction: (draft: any) => any;
+  params?: any;
+}
+interface ComProps {
+  modalParams: any;
+  data: any[];
+}
+// exports broker modal
+// render funcs
+const renderTopicOptions = (modalParams: any, dataSource: any[]) => {
+  const columns = [
+    {
+      title: 'Topic',
+      render: (t: string, r: TopicResultData) => {
+        return `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`;
+      },
+    },
+    {
+      title: 'TopicIP',
+      dataIndex: 'brokerIp',
+    },
+    {
+      title: '管理状态',
+      dataIndex: 'manageStatus',
+    },
+    {
+      title: '运行状态',
+      dataIndex: 'runStatus',
+    },
+    {
+      title: '运行子状态',
+      dataIndex: 'subStatus',
+    },
+    {
+      title: '可发布',
+      render: (t: string) => boolean2Chinese(t),
+    },
+    {
+      title: '可订阅',
+      render: (t: string) => boolean2Chinese(t),
+    },
+  ];
+  return <Table columns={columns} dataSource={dataSource} rowKey="brokerId" />;
+};
+const renderNewTopic = (form: any) => {
+  const brokerFormArr = [
+    {
+      name: 'topicName',
+      defaultValue: '',
+    },
+    {
+      name: 'numPartitions',
+      defaultValue: '3',
+    },
+    {
+      name: 'deleteWhen',
+      defaultValue: '0 0 6,18 * * ?',
+    },
+    {
+      name: 'deletePolicy',
+      defaultValue: 'delete,168h',
+    },
+    {
+      name: 'unflushThreshold',
+      defaultValue: '1000',
+    },
+    {
+      name: 'unflushInterval',
+      defaultValue: '10000',
+    },
+    {
+      name: 'acceptPublish',
+      defaultValue: 'true',
+    },
+    {
+      name: 'acceptSubscribe',
+      defaultValue: 'true',
+    },
+  ];
+
+  return (
+    <Form form={form}>
+      <Row gutter={24}>
+        {brokerFormArr.map((t, index) => (
+          <Col span={12} key={'brokerFormArr' + index}>
+            <Form.Item
+              labelCol={{ span: 12 }}
+              label={t.name}
+              name={t.name}
+              initialValue={t.defaultValue}
+            >
+              <Input />
+            </Form.Item>
+          </Col>
+        ))}
+      </Row>
+    </Form>
+  );
+};
+const renderChooseBroker = (modalParams: any) => {
+  const { params } = modalParams;
+  const columns = [
+    {
+      title: 'Broker',
+      render: (t: string, r: TopicResultData) => {
+        return `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`;
+      },
+    },
+    {
+      title: '实例数',
+      dataIndex: ['runInfo', 'numTopicStores'],
+    },
+    {
+      title: '当前运行状态',
+      dataIndex: ['runInfo', 'brokerManageStatus'],
+    },
+    {
+      title: '可发布',
+      dataIndex: ['runInfo', 'acceptPublish'],
+      render: (t: string) => boolean2Chinese(t),
+    },
+    {
+      title: '可订阅',
+      dataIndex: ['runInfo', 'acceptSubscribe'],
+      render: (t: string) => boolean2Chinese(t),
+    },
+  ];
+  const onChangeSelect = (p: any) => {
+    selectBroker = p;
+  };
+  return (
+    <Table
+      rowSelection={{ onChange: onChangeSelect }}
+      columns={columns}
+      dataSource={params.data}
+      rowKey="brokerId"
+    />
+  );
+};
+const renderEditTopic = (modalParams: any, form: FormProps['form']) => {
+  const { params: p } = modalParams;
+  const pickArr = [
+    'topicName',
+    'numPartitions',
+    'unflushThreshold',
+    'unflushInterval',
+    'deleteWhen',
+    'deletePolicy',
+    'acceptPublish',
+    'acceptSubscribe',
+  ];
+  const brokerFormArr: Array<{
+    name: string;
+    defaultValue: string;
+  }> = [];
+  pickArr.forEach(t => {
+    brokerFormArr.push({
+      name: t,
+      defaultValue: p[t],
+    });
+  });
+
+  return (
+    <Form form={form}>
+      <Row gutter={24}>
+        {brokerFormArr.map((t, index) => (
+          <Col span={12} key={'brokerFormArr' + index}>
+            <Form.Item
+              labelCol={{ span: 12 }}
+              label={t.name}
+              name={t.name}
+              initialValue={t.defaultValue}
+            >
+              <Input />
+            </Form.Item>
+          </Col>
+        ))}
+      </Row>
+    </Form>
+  );
+};
+const renderTopicStateChange = (modalParams: any) => {
+  const { params } = modalParams;
+
+  return (
+    <div>
+      请确认<span className="enhance">{params.option}</span> 以下broker列表的
+      topic :<span className="enhance">({params.topicName})</span> 的 Topic?
+      {renderChooseBroker(modalParams)}
+    </div>
+  );
+};
+const renderDeleteTopic = (modalParams: any) => {
+  const { params } = modalParams;
+
+  return (
+    <div>
+      请确认<span className="enhance">删除</span> 以下broker列表的 topic :
+      <span className="enhance">({params.topicName})</span> 吗?
+      {renderChooseBroker(modalParams)}
+    </div>
+  );
+};
+const renderDeleteConsumeGroup = (modalParams: any) => {
+  const { params } = modalParams;
+
+  return (
+    <div>
+      确认<span className="enhance">删除</span> 以下 :
+      <span className="enhance">({params.groupName})</span> 吗?
+    </div>
+  );
+};
+const renderAuthorizeControlChange = (modalParams: any) => {
+  const { params } = modalParams;
+
+  return (
+    <div>
+      请确认
+      <span className="enhance">
+        {params.value ? '启动' : '关闭'}topic
+        <span className="enhance">({params.topicName})</span>的消费组授权控制
+      </span>
+      吗?
+    </div>
+  );
+};
+export const onOpenModal = (p: TopicModalProps) => {
+  const { type, title, updateFunction, params } = p;
+  updateFunction((m: any) => {
+    m.type = type;
+    m.params = params;
+    Object.assign(m, {
+      params,
+      visible: type,
+      title,
+      onOk: (p: OKProps) => {
+        updateFunction((m: any) => {
+          if (type === 'newTopic' || type === 'editTopic') {
+            p.params = Object.assign(f && f.getFieldsValue(), {
+              callback: p.params.callback,
+            });
+          }
+
+          if (
+            type === 'chooseBroker' ||
+            type === 'topicStateChange' ||
+            type === 'deleteTopic'
+          ) {
+            if (!selectBroker.length) {
+              message.error('至少选择一列!');
+              return;
+            }
+
+            // end
+            if (type === 'chooseBroker') {
+              m.query =
+                p.params.subType === 'edit'
+                  ? 'endEditChooseBroker'
+                  : 'endChooseBroker';
+            }
+            p.params = Object.assign({}, p.params, {
+              selectBroker,
+            });
+          }
+
+          m.okParams = p;
+          m.isOk = Date.now();
+        });
+      },
+      onCancel: () =>
+        updateFunction((m: any) => {
+          m.visible = false;
+          m.isOk = null;
+        }),
+    });
+  });
+};
+
+let selectBroker: any[] = [];
+let f: FormProps['form'];
+const Comp = (props: ComProps) => {
+  const { modalParams, data } = props;
+  const [form] = Form.useForm();
+  f = form;
+
+  return (
+    <Modal {...modalParams}>
+      <div>
+        {modalParams.type &&
+          OPTIONS_VALUES.includes(modalParams.type) &&
+          renderTopicOptions(
+            modalParams,
+            data.filter((t: TopicResultData) =>
+              modalParams.params.includes(t.brokerId)
+            )
+          )}
+        {modalParams.type === 'newTopic' && renderNewTopic(form)}
+        {modalParams.type === 'chooseBroker' && 
renderChooseBroker(modalParams)}
+        {modalParams.type === 'editTopic' && renderEditTopic(modalParams, 
form)}
+        {modalParams.type === 'topicStateChange' &&
+          renderTopicStateChange(modalParams)}
+        {modalParams.type === 'deleteTopic' && renderDeleteTopic(modalParams)}
+        {modalParams.type === 'deleteConsumeGroup' &&
+          renderDeleteConsumeGroup(modalParams)}
+        {modalParams.type === 'authorizeControl' &&
+          renderAuthorizeControlChange(modalParams)}
+      </div>
+      <Query
+        fire={modalParams.isOk}
+        params={modalParams.okParams}
+        type={modalParams.visible && (modalParams.query || modalParams.type)}
+      />
+    </Modal>
+  );
+};
+
+export default Comp;
diff --git a/web/src/pages/Topic/detail.tsx b/web/src/pages/Topic/detail.tsx
new file mode 100644
index 0000000..0c9965c
--- /dev/null
+++ b/web/src/pages/Topic/detail.tsx
@@ -0,0 +1,510 @@
+import React, { ReactNode, useContext, useState } from 'react';
+import GlobalContext from '@/context/globalContext';
+import Breadcrumb from '@/components/Breadcrumb';
+import Table from '@/components/Tablex';
+import TitleWrap from '@/components/TitleWrap';
+import { Form, Button, Spin, Col, Row, Switch } from 'antd';
+import { useImmer } from 'use-immer';
+import './index.less';
+import { useRequest } from '@/hooks';
+import { useParams } from 'react-router-dom';
+import { boolean2Chinese, transParamsWithConstantsMap } from '@/utils';
+import tableFilterHelper from '@/components/Tablex/tableFilterHelper';
+import CommonModal, { onOpenModal, TopicResultData } from './commonModal';
+import BrokerModal, {
+  onOpenModal as onOpenBrokerModal,
+} from '@/pages/Broker/commonModal';
+import { BROKER_INFO_ZH_MAP } from '@/constants/broker';
+import { PERSON_INFO_ZH_MAP } from '@/constants/person';
+import { TOPIC_INFO_ZH_MAP } from '@/constants/topic';
+
+declare type TopicQueryData = {
+  topicName: string;
+};
+
+const Detail: React.FC = () => {
+  const { name } = useParams();
+  const { breadMap } = useContext(GlobalContext);
+  const [form] = Form.useForm();
+  const [modalParams, updateModelParams] = useImmer<any>({});
+  const [brokerModalParams, updateBrokerModalParams] = useImmer<any>({});
+  const [isSrvAcceptPublish, setIsSrvAcceptPublish] = useState<any>(false);
+  const [isSrvAcceptSubscribe, setIsSrvAcceptSubscribe] = useState<any>(false);
+  const [enableAuthControl, setEnableAuthControl] = useState<any>(false);
+  const [filterData, updateFilterData] = useImmer<any>({});
+  const queryTopicInfo = useRequest<any>(
+    (
+      data: TopicQueryData = {
+        topicName: name,
+      }
+    ) => ({
+      url: '/api/op_query/admin_query_topic_authorize_control',
+      data: {
+        ...data,
+      },
+    })
+  );
+  const queryTopicConf = useRequest<any>(
+    (
+      data: TopicQueryData = {
+        topicName: name,
+      }
+    ) => ({
+      url: '/api/op_query/admin_query_topic_info',
+      data: {
+        ...data,
+      },
+    }),
+    {
+      onSuccess: data => {
+        setIsSrvAcceptPublish(data[0]['isSrvAcceptPublish']);
+        setIsSrvAcceptSubscribe(data[0]['isSrvAcceptSubscribe']);
+        setEnableAuthControl(data[0]['authData']['enableAuthControl']);
+      },
+    }
+  );
+
+  // render
+  const searchStyle = {
+    position: 'absolute',
+    top: '-40px',
+    right: '10px',
+    zIndex: 1,
+    width: '300px',
+  };
+  const renderBrokerList = (): ReactNode => {
+    const columns = [
+      {
+        title: 'Broker',
+        render: (t: string, r: TopicResultData) => {
+          return `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`;
+        },
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'runInfo.acceptPublish'
+        ),
+        dataIndex: ['runInfo', 'acceptPublish'],
+        render: (t: string) => boolean2Chinese(t),
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'runInfo.acceptSubscribe'
+        ),
+        dataIndex: ['runInfo', 'acceptSubscribe'],
+        render: (t: string) => boolean2Chinese(t),
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'runInfo.numPartitions'
+        ),
+        dataIndex: ['runInfo', 'numPartitions'],
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'runInfo.brokerManageStatus'
+        ),
+        dataIndex: ['runInfo', 'brokerManageStatus'],
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 
'acceptPublish'),
+        dataIndex: 'acceptPublish',
+        render: (t: string) => boolean2Chinese(t),
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'acceptSubscribe'
+        ),
+        dataIndex: 'acceptSubscribe',
+        render: (t: string) => boolean2Chinese(t),
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 
'numPartitions'),
+        dataIndex: 'numPartitions',
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'unflushThreshold'
+        ),
+        dataIndex: 'unflushThreshold',
+      },
+      {
+        title: transParamsWithConstantsMap(
+          BROKER_INFO_ZH_MAP,
+          'unflushInterval'
+        ),
+        dataIndex: 'unflushInterval',
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deleteWhen'),
+        dataIndex: 'deleteWhen',
+      },
+      {
+        title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deletePolicy'),
+        dataIndex: 'deletePolicy',
+      },
+      {
+        title: '操作',
+        render: (t: string, r: TopicResultData) => {
+          return (
+            <span>
+              <a onClick={() => onEdit(r)}>编辑</a>
+              <a onClick={() => onReload(r)}>重载</a>
+              <a onClick={() => onDeleteBroker(r)}>删除</a>
+            </span>
+          );
+        },
+      },
+    ];
+    const { data } = queryTopicConf;
+    if (!data || !data[0]) return null;
+    const { topicInfo } = data[0];
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={topicInfo}
+        rowKey={r => `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`}
+        dataSourceX={filterData.topicInfoList}
+        searchPlaceholder="请输入brokerId,Ip,Port搜索"
+        searchStyle={searchStyle}
+        filterFnX={value =>
+          tableFilterHelper({
+            key: value,
+            srcArray: topicInfo,
+            targetArray: filterData.topicInfoList,
+            updateFunction: res =>
+              updateFilterData(filterData => {
+                filterData.topicInfoList = res;
+              }),
+            filterList: ['brokerId', 'brokerIp', 'brokerPort'],
+          })
+        }
+      />
+    );
+  };
+  const renderConsumeGroupList = (): ReactNode => {
+    const columns = [
+      {
+        title: '消费组',
+        dataIndex: 'groupName',
+      },
+      {
+        title: transParamsWithConstantsMap(PERSON_INFO_ZH_MAP, 'createUser'),
+        dataIndex: 'createUser',
+      },
+      {
+        title: transParamsWithConstantsMap(PERSON_INFO_ZH_MAP, 'createDate'),
+        dataIndex: 'createDate',
+      },
+      {
+        title: '操作',
+        render: (t: string, r: TopicResultData) => {
+          return (
+            <span>
+              <a onClick={() => onDeleteConsumeGroup(r)}>删除</a>
+            </span>
+          );
+        },
+      },
+    ];
+    const { data } = queryTopicInfo;
+    if (!data || !data[0]) return null;
+    const { authConsumeGroup } = data[0];
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={authConsumeGroup}
+        rowKey={r => `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`}
+        dataSourceX={filterData.list}
+        searchPlaceholder="请输入消费组名称搜索"
+        searchStyle={searchStyle}
+        filterFnX={value =>
+          tableFilterHelper({
+            key: value,
+            srcArray: authConsumeGroup,
+            targetArray: filterData.list,
+            updateFunction: res =>
+              updateFilterData(filterData => {
+                filterData.list = res;
+              }),
+            filterList: ['groupName'],
+          })
+        }
+      />
+    );
+  };
+
+  // event
+  // isSrvAcceptPublish && isSrvAcceptSubscribe event
+  const queryBrokerListByTopicNameQuery = useRequest<any, any>(
+    data => ({ url: '/api/op_query/admin_query_topic_info', ...data }),
+    { manual: true }
+  );
+  const onSwitchChange = (e: boolean, type: string) => {
+    let option = '';
+    const topicName = queryTopicConf.data[0].topicInfo[0].topicName;
+    if (type === 'isSrvAcceptPublish') {
+      option = e ? '发布' : '禁止可发布';
+    } else if (type === 'isSrvAcceptSubscribe') {
+      option = e ? '订阅' : '禁止可订阅';
+    }
+
+    queryBrokerListByTopicNameQuery
+      .run({
+        data: {
+          topicName,
+          brokerId: '',
+        },
+      })
+      .then((d: TopicResultData) => {
+        onOpenModal({
+          type: 'topicStateChange',
+          title: `请确认操作`,
+          updateFunction: updateModelParams,
+          params: {
+            option,
+            value: e,
+            topicName,
+            data: d[0].topicInfo,
+            type,
+            callback: () => {
+              if (type === 'isSrvAcceptPublish') {
+                setIsSrvAcceptPublish(e);
+              } else if (type === 'isSrvAcceptSubscribe') {
+                setIsSrvAcceptSubscribe(e);
+              }
+            },
+          },
+        });
+      });
+  };
+  // author
+  const onAuthorizeControl = (e: boolean) => {
+    const option = e ? '发布' : '禁止可发布';
+    const topicName = queryTopicConf.data[0].topicInfo[0].topicName;
+    onOpenModal({
+      type: 'authorizeControl',
+      title: `请确认操作`,
+      updateFunction: updateModelParams,
+      params: {
+        option,
+        value: e,
+        topicName,
+        callback: () => {
+          setEnableAuthControl(e);
+        },
+      },
+    });
+  };
+  // edit topic
+  const onEdit = (r?: TopicResultData) => {
+    const p = r || queryTopicConf.data[0].topicInfo[0];
+    onOpenModal({
+      type: 'editTopic',
+      title: '编辑Topic',
+      updateFunction: updateModelParams,
+      params: {
+        ...p,
+        callback: (d: any) => {
+          onOpenModal({
+            type: 'chooseBroker',
+            title: '选择【修改】broker列表',
+            updateFunction: updateModelParams,
+            params: {
+              data: d,
+              subType: 'edit',
+              callback: () => {
+                onOpenModal({
+                  type: 'close',
+                  updateFunction: updateModelParams,
+                });
+              },
+            },
+          });
+        },
+      },
+    });
+  };
+  // reload topic
+  const queryBrokerInfo = useRequest<any, any>(
+    data => ({ url: '/api/op_query/admin_query_broker_run_status', ...data }),
+    { manual: true }
+  );
+  const onReload = (r: TopicResultData) => {
+    queryBrokerInfo
+      .run({
+        data: {
+          brokerId: r.brokerId,
+        },
+      })
+      .then(data => {
+        onOpenBrokerModal({
+          type: 'reload',
+          title: `确认进行【重载】操作?`,
+          updateFunction: updateBrokerModalParams,
+          params: [data[0].brokerId],
+        });
+      });
+  };
+  // on delete broker
+  const onDeleteBroker = (r: TopicResultData) => {
+    queryBrokerListByTopicNameQuery
+      .run({
+        data: {
+          topicName: r.topicName,
+          brokerId: r.brokerId,
+        },
+      })
+      .then((d: TopicResultData) => {
+        onOpenModal({
+          type: 'deleteTopic',
+          title: `请确认操作`,
+          updateFunction: updateModelParams,
+          params: {
+            topicName: r.topicName,
+            data: d[0].topicInfo,
+          },
+        });
+      });
+  };
+  const onDeleteConsumeGroup = (r: TopicResultData) => {
+    onOpenModal({
+      type: 'deleteConsumeGroup',
+      title: `请确认消费组`,
+      updateFunction: updateModelParams,
+      params: {
+        topicName: r.topicName,
+        groupName: r.groupName,
+      },
+    });
+  };
+
+  return (
+    <Spin spinning={queryTopicConf.loading && queryTopicInfo.loading}>
+      <Breadcrumb
+        breadcrumbMap={breadMap}
+        appendParams={`Topic(${name})详情`}
+      />
+      <div className="main-container">
+        <TitleWrap
+          title="基本信息"
+          wrapperStyle={{ position: 'relative' }}
+          hasSplit={false}
+        >
+          <div className="topic-detail-options-wrapper">
+            <Switch
+              className="mr10"
+              checked={isSrvAcceptPublish}
+              checkedChildren="订阅"
+              unCheckedChildren="订阅"
+              onChange={e => onSwitchChange(e, 'isSrvAcceptPublish')}
+            />
+            <Switch
+              className="mr10"
+              checked={isSrvAcceptSubscribe}
+              checkedChildren="发布"
+              unCheckedChildren="发布"
+              onChange={e => onSwitchChange(e, 'isSrvAcceptSubscribe')}
+            />
+            <Switch
+              className="mr10"
+              checked={enableAuthControl}
+              checkedChildren="权限可控"
+              unCheckedChildren="权限可控"
+              onChange={e => onAuthorizeControl(e)}
+            />
+          </div>
+          <Form form={form}>
+            <Row gutter={24}>
+              {queryTopicConf.data &&
+                Object.keys(queryTopicConf.data[0]).map(
+                  (t: string, index: number) => {
+                    const label = transParamsWithConstantsMap(
+                      TOPIC_INFO_ZH_MAP,
+                      t
+                    );
+                    const ignoreList = [
+                      'isSrvAcceptPublish',
+                      'isSrvAcceptSubscribe',
+                    ];
+                    if (
+                      queryTopicConf.data[0][t] instanceof Object ||
+                      !label ||
+                      ignoreList.includes(t)
+                    )
+                      return null;
+                    return (
+                      <Col span={12} key={'queryTopicConf' + index}>
+                        <Form.Item labelCol={{ span: 12 }} label={label}>
+                          {queryTopicConf.data[0][t] + ''}
+                        </Form.Item>
+                      </Col>
+                    );
+                  }
+                )}
+            </Row>
+          </Form>
+        </TitleWrap>
+        <TitleWrap title="缺省配置" wrapperStyle={{ position: 'relative' }}>
+          <div className="topic-detail-options-wrapper">
+            <Button
+              className="mr10"
+              type="primary"
+              size="small"
+              onClick={() => onEdit()}
+            >
+              编辑
+            </Button>
+          </div>
+          <Form form={form}>
+            <Row gutter={24}>
+              {[
+                'acceptPublish',
+                'acceptSubscribe',
+                'unflushThreshold',
+                'unflushInterval',
+                'deleteWhen',
+                'deletePolicy',
+                'numPartitions',
+              ].map((t: string, index: number) => {
+                if (
+                  !queryTopicConf.data ||
+                  !queryTopicConf.data[0].topicInfo[0]
+                )
+                  return null;
+                const value = queryTopicConf.data[0].topicInfo[0][t];
+                return (
+                  <Col span={12} key={'queryTopicConf' + index}>
+                    <Form.Item labelCol={{ span: 12 }} label={t}>
+                      {value + ''}
+                    </Form.Item>
+                  </Col>
+                );
+              })}
+            </Row>
+          </Form>
+        </TitleWrap>
+        <TitleWrap title="部署Broker列表">{renderBrokerList()}</TitleWrap>
+        <TitleWrap title="消费组列表">{renderConsumeGroupList()}</TitleWrap>
+      </div>
+      <CommonModal
+        modalParams={modalParams}
+        data={[queryTopicConf.data && queryTopicConf.data[0]]}
+      />
+      <BrokerModal
+        modalParams={brokerModalParams}
+        data={[queryBrokerInfo.data && queryBrokerInfo.data[0]]}
+      />
+    </Spin>
+  );
+};
+
+export default Detail;
diff --git a/web/src/pages/Topic/index.less b/web/src/pages/Topic/index.less
new file mode 100644
index 0000000..5efa767
--- /dev/null
+++ b/web/src/pages/Topic/index.less
@@ -0,0 +1,9 @@
+.topic-detail-options-wrapper {
+  position: absolute;
+  top: 15px;
+  right: 0;
+
+  .mr10 {
+    margin-right: 10px;
+  }
+}
\ No newline at end of file
diff --git a/web/src/pages/Topic/index.tsx b/web/src/pages/Topic/index.tsx
new file mode 100644
index 0000000..cd112f9
--- /dev/null
+++ b/web/src/pages/Topic/index.tsx
@@ -0,0 +1,279 @@
+import React, { useContext } from 'react';
+import GlobalContext from '@/context/globalContext';
+import Breadcrumb from '@/components/Breadcrumb';
+import Table from '@/components/Tablex';
+import { Form, Button, Spin, Switch } from 'antd';
+import { useImmer } from 'use-immer';
+import { useRequest } from '@/hooks';
+import tableFilterHelper from '@/components/Tablex/tableFilterHelper';
+import { transParamsWithConstantsMap } from '@/utils';
+import { TOPIC_INFO_ZH_MAP } from '@/constants/topic';
+import './index.less';
+import { Link } from 'react-router-dom';
+import CommonModal, {
+  onOpenModal,
+  TopicResultData,
+  TopicData,
+} from './commonModal';
+
+const Topic: React.FC = () => {
+  // column config
+  const columns = [
+    {
+      title: transParamsWithConstantsMap(TOPIC_INFO_ZH_MAP, 'topicName'),
+      dataIndex: 'topicName',
+      render: (t: Array<any>) => <Link to={'/topic/' + t}>{t}</Link>,
+    },
+    {
+      title: transParamsWithConstantsMap(TOPIC_INFO_ZH_MAP, 'infoCount'),
+      dataIndex: 'infoCount',
+    },
+    {
+      title: transParamsWithConstantsMap(TOPIC_INFO_ZH_MAP, 'totalCfgNumPart'),
+      dataIndex: 'totalCfgNumPart',
+    },
+    {
+      title: transParamsWithConstantsMap(
+        TOPIC_INFO_ZH_MAP,
+        'totalRunNumPartCount'
+      ),
+      dataIndex: 'totalRunNumPartCount',
+    },
+    {
+      title: transParamsWithConstantsMap(
+        TOPIC_INFO_ZH_MAP,
+        'isSrvAcceptPublish'
+      ),
+      dataIndex: 'isSrvAcceptPublish',
+      render: (t: boolean, r: TopicResultData) => {
+        return (
+          <Switch
+            checked={t}
+            onChange={e => onSwitchChange(e, r, 'isSrvAcceptPublish')}
+          />
+        );
+      },
+    },
+    {
+      title: transParamsWithConstantsMap(
+        TOPIC_INFO_ZH_MAP,
+        'isSrvAcceptSubscribe'
+      ),
+      dataIndex: 'isSrvAcceptSubscribe',
+      render: (t: boolean, r: TopicResultData) => {
+        return (
+          <Switch
+            checked={t}
+            onChange={e => onSwitchChange(e, r, 'isSrvAcceptSubscribe')}
+          />
+        );
+      },
+    },
+    {
+      title: transParamsWithConstantsMap(
+        TOPIC_INFO_ZH_MAP,
+        'enableAuthControl'
+      ),
+      dataIndex: 'authData.enableAuthControl',
+      render: (t: boolean, r: TopicResultData) => {
+        return (
+          <Switch
+            checked={r.authData.enableAuthControl}
+            onChange={e => onAuthorizeControl(e, r)}
+          />
+        );
+      },
+    },
+    {
+      title: '操作',
+      dataIndex: 'topicIp',
+      render: (t: string, r: any) => {
+        return <a onClick={() => onDelete(r)}>删除</a>;
+      },
+    },
+  ];
+  const { breadMap } = useContext(GlobalContext);
+  const [modalParams, updateModelParams] = useImmer<any>({});
+  const [filterData, updateFilterData] = useImmer<any>({});
+  const [topicList, updateTopicList] = useImmer<TopicData>([]);
+  const [form] = Form.useForm();
+  // init query
+  const { data, loading, run } = useRequest<any, TopicData>(
+    (data: TopicResultData) => ({
+      url: '/api/op_query/admin_query_topic_info',
+      data: data,
+    }),
+    {
+      cacheKey: 'topicList',
+      onSuccess: data => {
+        updateTopicList(d => {
+          Object.assign(d, data);
+        });
+      },
+    }
+  );
+
+  // table event
+  // acceptSubscribe && acceptPublish options
+  const queryBrokerListByTopicNameQuery = useRequest<any, any>(
+    data => ({ url: '/api/op_query/admin_query_topic_info', ...data }),
+    { manual: true }
+  );
+  const onSwitchChange = (e: boolean, r: TopicResultData, type: string) => {
+    let option = '';
+    if (type === 'isSrvAcceptPublish') {
+      option = e ? '发布' : '禁止可发布';
+    } else if (type === 'isSrvAcceptSubscribe') {
+      option = e ? '订阅' : '禁止可订阅';
+    }
+
+    queryBrokerListByTopicNameQuery
+      .run({
+        data: {
+          topicName: r.topicName,
+          brokerId: '',
+        },
+      })
+      .then((d: TopicResultData) => {
+        onOpenModal({
+          type: 'topicStateChange',
+          title: `请确认操作`,
+          updateFunction: updateModelParams,
+          params: {
+            option,
+            value: e,
+            topicName: r.topicName,
+            data: d[0].topicInfo,
+            type,
+            callback: () => {
+              const index = data.findIndex(
+                (t: TopicResultData) => t.topicName === r.topicName
+              );
+              updateTopicList(d => {
+                d[index][type] = e + '';
+              });
+            },
+          },
+        });
+      });
+  };
+  // author
+  const onAuthorizeControl = (e: boolean, r: TopicResultData) => {
+    const option = e ? '发布' : '禁止可发布';
+    onOpenModal({
+      type: 'authorizeControl',
+      title: `请确认操作`,
+      updateFunction: updateModelParams,
+      params: {
+        option,
+        value: e,
+        topicName: r.topicName,
+        callback: () => {
+          const index = data.findIndex(
+            (t: TopicResultData) => t.topicName === r.topicName
+          );
+          updateTopicList(d => {
+            d[index]['authData']['enableAuthControl'] = e + '';
+          });
+        },
+      },
+    });
+  };
+  // new topic
+  const onNewTopic = () => {
+    onOpenModal({
+      type: 'newTopic',
+      title: '新建Topic',
+      updateFunction: updateModelParams,
+      params: {
+        callback: (d: any) => {
+          onOpenModal({
+            type: 'chooseBroker',
+            title: '选择【新增】broker列表',
+            updateFunction: updateModelParams,
+            params: {
+              data: d,
+              callback: () => {
+                onOpenModal({
+                  type: 'close',
+                  updateFunction: updateModelParams,
+                });
+              },
+            },
+          });
+        },
+      },
+    });
+  };
+  // delete
+  const onDelete = (r: TopicResultData) => {
+    queryBrokerListByTopicNameQuery
+      .run({
+        data: {
+          topicName: r.topicName,
+          brokerId: '',
+        },
+      })
+      .then((d: TopicResultData) => {
+        onOpenModal({
+          type: 'deleteTopic',
+          title: `请确认操作`,
+          updateFunction: updateModelParams,
+          params: {
+            topicName: r.topicName,
+            data: d[0].topicInfo,
+          },
+        });
+      });
+  };
+
+  return (
+    <Spin spinning={loading}>
+      <Breadcrumb breadcrumbMap={breadMap} />
+      <div className="main-container">
+        <div
+          className="search-wrapper"
+          style={{ float: 'right', marginRight: '-16px' }}
+        >
+          <Form form={form} layout={'inline'}>
+            <Form.Item>
+              <Button
+                type="primary"
+                onClick={() => onNewTopic()}
+                style={{ margin: '0 10px 0 10px' }}
+              >
+                新增
+              </Button>
+              <Button type="primary" onClick={() => run()}>
+                刷新
+              </Button>
+            </Form.Item>
+          </Form>
+        </div>
+        <Table
+          columns={columns}
+          dataSource={topicList}
+          rowKey="topicName"
+          searchPlaceholder="请输入topicName查询"
+          searchWidth={12}
+          dataSourceX={filterData.list}
+          filterFnX={value =>
+            tableFilterHelper({
+              key: value,
+              srcArray: data,
+              targetArray: filterData.list,
+              updateFunction: res =>
+                updateFilterData(filterData => {
+                  filterData.list = res;
+                }),
+              filterList: ['topicName'],
+            })
+          }
+        />
+      </div>
+      <CommonModal modalParams={modalParams} data={data} />
+    </Spin>
+  );
+};
+
+export default Topic;
diff --git a/web/src/pages/Topic/query.tsx b/web/src/pages/Topic/query.tsx
new file mode 100644
index 0000000..bf17545
--- /dev/null
+++ b/web/src/pages/Topic/query.tsx
@@ -0,0 +1,180 @@
+import * as React from 'react';
+import './index.less';
+import { OKProps } from '@/components/Modalx';
+import { useRequest } from '@/hooks';
+import { useContext, useEffect } from 'react';
+import GlobalContext from '@/context/globalContext';
+
+interface ComProps {
+  fire: string;
+  params: any;
+  type: string;
+}
+
+let newObjectTemp = '';
+let editObjectTemp = '';
+const Comp = (props: ComProps) => {
+  const { fire } = props;
+  const { userInfo } = useContext(GlobalContext);
+  // eslint-disable-next-line
+  useEffect(() => {
+    const { params, type } = props;
+    dispatchAction(type, params);
+  }, [fire]);
+
+  const dispatchAction = (type: string, p: OKProps) => {
+    if (!fire) return null;
+    let promise;
+    switch (type) {
+      case 'newTopic':
+        promise = newTopic(p);
+        break;
+      case 'endChooseBroker':
+        promise = endChooseBroker(p);
+        break;
+      case 'editTopic':
+        promise = editTopic(p);
+        break;
+      case 'endEditChooseBroker':
+        promise = endEditChooseBroker(p);
+        break;
+      case 'topicStateChange':
+        promise = topicStateChange(type, p);
+        break;
+      case 'authorizeControl':
+        promise = authorizeControl(type, p);
+        break;
+      case 'deleteTopic':
+        promise = deleteTopic(type, p);
+        break;
+      case 'deleteConsumeGroup':
+        promise = deleteConsumeGroup(type, p);
+        break;
+    }
+
+    promise &&
+      promise.then(t => {
+        const { callback } = p.params;
+        if (t.statusCode !== 0 && callback) callback(t);
+      });
+  };
+  const commonQuery = useRequest<any, any>((url, data) => ({ url, ...data }), {
+    manual: true,
+  });
+  const newTopicQuery = useRequest<any, any>(
+    data => ({
+      url: '/api/op_query/admin_query_broker_topic_config_info',
+      ...data,
+    }),
+    { manual: true }
+  );
+  const newTopic = (p: OKProps) => {
+    newObjectTemp = JSON.stringify(p.params);
+    return newTopicQuery.run({
+      data: {
+        topicName: '',
+        brokerId: '',
+      },
+    });
+  };
+
+  const endChooseBrokerQuery = useRequest<any, any>(
+    data => ({ url: '/api/op_modify/admin_add_new_topic_record', ...data }),
+    { manual: true }
+  );
+  const endChooseBroker = (p: OKProps) => {
+    const topicParams = JSON.parse(newObjectTemp);
+    const { params } = p;
+    return endChooseBrokerQuery.run({
+      data: {
+        borkerId: params.selectBroker.join(','),
+        confModAuthToken: p.psw,
+        ...topicParams,
+      },
+    });
+  };
+
+  const editTopic = (p: OKProps) => {
+    const { params } = p;
+    editObjectTemp = JSON.stringify(p.params);
+    return newTopicQuery.run({
+      data: {
+        topicName: params.topicName,
+        brokerId: '',
+      },
+    });
+  };
+  const endEditChooseBroker = (p: OKProps) => {
+    const topicParams = JSON.parse(editObjectTemp);
+    const { params } = p;
+    return commonQuery.run(`/api/op_modify/admin_modify_topic_info`, {
+      data: {
+        borkerId: params.selectBroker.join(','),
+        confModAuthToken: p.psw,
+        ...topicParams,
+      },
+    });
+  };
+
+  const deleteTopic = (type: string, p: OKProps) => {
+    const { params } = p;
+    return commonQuery.run(`/api/op_modify/admin_delete_topic_info`, {
+      data: {
+        brokerId: params.selectBroker.join(','),
+        confModAuthToken: p.psw,
+        modifyUser: userInfo.userName,
+        topicName: params.topicName,
+      },
+    });
+  };
+  const deleteConsumeGroup = (type: string, p: OKProps) => {
+    const { params } = p;
+    return commonQuery.run(
+      `/api/op_modify/admin_delete_allowed_consumer_group_info`,
+      {
+        data: {
+          groupName: params.groupName,
+          confModAuthToken: p.psw,
+          topicName: params.topicName,
+        },
+      }
+    );
+  };
+  const topicStateChange = (type: string, p: OKProps) => {
+    const { params } = p;
+    const data: any = {
+      brokerId: params.selectBroker.join([',']),
+      confModAuthToken: p.psw,
+      modifyUser: userInfo.userName,
+      topicName: params.topicName,
+    };
+    if (params.type === 'isSrvAcceptPublish') {
+      data.acceptPublish = params.value;
+    }
+    if (params.type === 'isSrvAcceptSubscribe') {
+      data.acceptSubscribe = params.value;
+    }
+
+    return commonQuery.run(`/api/op_modify/admin_modify_topic_info`, {
+      data,
+    });
+  };
+
+  const authorizeControl = (type: string, p: OKProps) => {
+    const { params } = p;
+    const data: any = {
+      confModAuthToken: p.psw,
+      topicName: params.topicName,
+      isEnable: params.value,
+      modifyUser: userInfo.userName,
+    };
+
+    return commonQuery.run(`/api/op_modify/admin_set_topic_authorize_control`, 
{
+      data,
+    });
+  };
+
+  return <></>;
+};
+
+export default Comp;
diff --git a/web/src/react-app-env.d.ts b/web/src/react-app-env.d.ts
new file mode 100644
index 0000000..6431bc5
--- /dev/null
+++ b/web/src/react-app-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="react-scripts" />
diff --git a/web/src/router.tsx b/web/src/router.tsx
new file mode 100644
index 0000000..46df97d
--- /dev/null
+++ b/web/src/router.tsx
@@ -0,0 +1,55 @@
+import React, { Suspense, lazy, useState } from 'react';
+import {
+  BrowserRouter as Router,
+  Switch,
+  Route,
+  Redirect,
+} from 'react-router-dom';
+import { PageLoading } from '@ant-design/pro-layout';
+import { hot } from 'react-hot-loader/root';
+import { Layout } from '@/components';
+import routes from '@/routes';
+import GlobalContext from '@/context/globalContext';
+
+const App = () => {
+  const [cluster, setCluster] = useState();
+  const [breadMap, setBreadMap] = useState();
+  // eslint-disable-next-line
+  const [userInfo, setUserInfo] = useState({
+    userName: 'webapi',
+  });
+
+  return (
+    <GlobalContext.Provider
+      value={{ cluster, setCluster, breadMap, setBreadMap, userInfo }}
+    >
+      <Router>
+        <Layout>
+          <Suspense fallback={<PageLoading />}>
+            <Switch>
+              {routes.map((route, index: number) => (
+                <Route
+                  key={index}
+                  path={route.path}
+                  exact={route.exact}
+                  strict={route.strict}
+                  render={props => {
+                    const LazyComponent = lazy(route.component);
+                    return <LazyComponent {...props} />;
+                  }}
+                />
+              ))}
+            </Switch>
+            <Route
+              exact
+              path="/"
+              render={() => <Redirect to="/issue" push />}
+            />
+          </Suspense>
+        </Layout>
+      </Router>
+    </GlobalContext.Provider>
+  );
+};
+
+export default process.env.NODE_ENV === 'development' ? hot(App) : App;
diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx
new file mode 100644
index 0000000..ee36fa2
--- /dev/null
+++ b/web/src/routes/index.tsx
@@ -0,0 +1,37 @@
+import { RouteProps } from '@/typings';
+
+const routes: RouteProps[] = [
+  {
+    path: '/issue/:id',
+    component: () => import('@/pages/Issue/consumeGroupDetail'),
+  },
+  {
+    path: '/issue',
+    component: () => import('@/pages/Issue'),
+  },
+  {
+    path: '/broker/:id',
+    component: () => import('@/pages/Broker/detail'),
+  },
+  {
+    path: '/broker',
+    component: () => import('@/pages/Broker'),
+  },
+  {
+    path: '/topic/:name',
+    component: () => import('@/pages/Topic/detail'),
+  },
+  {
+    path: '/topic',
+    component: () => import('@/pages/Topic'),
+  },
+  {
+    path: '/cluster',
+    component: () => import('@/pages/Cluster'),
+  },
+  {
+    component: () => import('@/pages/NotFound'),
+  },
+];
+
+export default routes;
diff --git a/web/src/serviceWorker.ts b/web/src/serviceWorker.ts
new file mode 100644
index 0000000..109ab0e
--- /dev/null
+++ b/web/src/serviceWorker.ts
@@ -0,0 +1,146 @@
+// This optional code is used to register a service worker.
+// register() is not called by default.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on subsequent visits to a page, after all the
+// existing tabs open on the page have been closed, since previously cached
+// resources are updated in the background.
+
+// To learn more about the benefits of this model and instructions on how to
+// opt-in, read https://bit.ly/CRA-PWA
+
+const isLocalhost = Boolean(
+  window.location.hostname === 'localhost' ||
+    // [::1] is the IPv6 localhost address.
+    window.location.hostname === '[::1]' ||
+    // 127.0.0.0/8 are considered localhost for IPv4.
+    window.location.hostname.match(
+      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+    )
+);
+
+type Config = {
+  onSuccess?: (registration: ServiceWorkerRegistration) => void;
+  onUpdate?: (registration: ServiceWorkerRegistration) => void;
+};
+
+export function register(config?: Config) {
+  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+    // The URL constructor is available in all browsers that support SW.
+    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
+    if (publicUrl.origin !== window.location.origin) {
+      // Our service worker won't work if PUBLIC_URL is on a different origin
+      // from what our page is served on. This might happen if a CDN is used to
+      // serve assets; see 
https://github.com/facebook/create-react-app/issues/2374
+      return;
+    }
+
+    window.addEventListener('load', () => {
+      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+      if (isLocalhost) {
+        // This is running on localhost. Let's check if a service worker still 
exists or not.
+        checkValidServiceWorker(swUrl, config);
+
+        // Add some additional logging to localhost, pointing developers to the
+        // service worker/PWA documentation.
+        navigator.serviceWorker.ready.then(() => {
+          console.log(
+            'This web app is being served cache-first by a service ' +
+              'worker. To learn more, visit https://bit.ly/CRA-PWA'
+          );
+        });
+      } else {
+        // Is not localhost. Just register service worker
+        registerValidSW(swUrl, config);
+      }
+    });
+  }
+}
+
+function registerValidSW(swUrl: string, config?: Config) {
+  navigator.serviceWorker
+    .register(swUrl)
+    .then(registration => {
+      registration.onupdatefound = () => {
+        const installingWorker = registration.installing;
+        if (installingWorker == null) {
+          return;
+        }
+        installingWorker.onstatechange = () => {
+          if (installingWorker.state === 'installed') {
+            if (navigator.serviceWorker.controller) {
+              // At this point, the updated precached content has been fetched,
+              // but the previous service worker will still serve the older
+              // content until all client tabs are closed.
+              console.log(
+                'New content is available and will be used when all ' +
+                  'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
+              );
+
+              // Execute callback
+              if (config && config.onUpdate) {
+                config.onUpdate(registration);
+              }
+            } else {
+              // At this point, everything has been precached.
+              // It's the perfect time to display a
+              // "Content is cached for offline use." message.
+              console.log('Content is cached for offline use.');
+
+              // Execute callback
+              if (config && config.onSuccess) {
+                config.onSuccess(registration);
+              }
+            }
+          }
+        };
+      };
+    })
+    .catch(error => {
+      console.error('Error during service worker registration:', error);
+    });
+}
+
+function checkValidServiceWorker(swUrl: string, config?: Config) {
+  // Check if the service worker can be found. If it can't reload the page.
+  fetch(swUrl, {
+    headers: { 'Service-Worker': 'script' },
+  })
+    .then(response => {
+      // Ensure service worker exists, and that we really are getting a JS 
file.
+      const contentType = response.headers.get('content-type');
+      if (
+        response.status === 404 ||
+        (contentType != null && contentType.indexOf('javascript') === -1)
+      ) {
+        // No service worker found. Probably a different app. Reload the page.
+        navigator.serviceWorker.ready.then(registration => {
+          registration.unregister().then(() => {
+            window.location.reload();
+          });
+        });
+      } else {
+        // Service worker found. Proceed as normal.
+        registerValidSW(swUrl, config);
+      }
+    })
+    .catch(() => {
+      console.log(
+        'No internet connection found. App is running in offline mode.'
+      );
+    });
+}
+
+export function unregister() {
+  if ('serviceWorker' in navigator) {
+    navigator.serviceWorker.ready
+      .then(registration => {
+        registration.unregister();
+      })
+      .catch(error => {
+        console.error(error.message);
+      });
+  }
+}
diff --git a/web/src/setupProxy.js b/web/src/setupProxy.js
new file mode 100644
index 0000000..1340128
--- /dev/null
+++ b/web/src/setupProxy.js
@@ -0,0 +1,12 @@
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const { createProxyMiddleware } = require('http-proxy-middleware');
+
+module.exports = function(app) {
+  app.use(
+    createProxyMiddleware('/webapi.htm', {
+      target: 'http://127.0.0.1:8080', // should set api address
+      changeOrigin: true,
+      ws: true,
+    })
+  );
+};
diff --git a/web/src/store/global.ts b/web/src/store/global.ts
new file mode 100644
index 0000000..18d8693
--- /dev/null
+++ b/web/src/store/global.ts
@@ -0,0 +1,30 @@
+import { createStore } from '@reactseed/use-redux';
+
+export interface TState {
+  name: string;
+  age: number;
+}
+
+export interface TMethod {
+  updateName: (name: string) => void;
+  becomeOlder: () => void;
+}
+
+const store = createStore(() => ({
+  age: 20,
+  name: 'reactseed',
+}));
+
+const methods = (state: TState): TMethod => {
+  const { age } = state;
+  return {
+    updateName: (name: string) => {
+      state.name = name;
+    },
+    becomeOlder: () => {
+      state.age = age + 1;
+    },
+  };
+};
+
+export { store, methods };
diff --git a/web/src/typings/index.ts b/web/src/typings/index.ts
new file mode 100644
index 0000000..164ab50
--- /dev/null
+++ b/web/src/typings/index.ts
@@ -0,0 +1 @@
+export * from './router';
diff --git a/web/src/typings/router.ts b/web/src/typings/router.ts
new file mode 100644
index 0000000..38e855d
--- /dev/null
+++ b/web/src/typings/router.ts
@@ -0,0 +1,14 @@
+import { Route as LayoutRoute } from '@ant-design/pro-layout/lib/typings';
+import { RouteProps as ReactRouteProps } from 'react-router-dom';
+
+export interface Route extends LayoutRoute {
+  paths?: string[];
+}
+
+export type OmitRouteProps = Omit<ReactRouteProps, 'component'> & {
+  component: () => Promise<{ default: any }>;
+};
+
+export interface RouteProps extends OmitRouteProps {
+  component: () => Promise<{ default: any }>;
+}
diff --git a/web/src/utils/index.ts b/web/src/utils/index.ts
new file mode 100644
index 0000000..e410d91
--- /dev/null
+++ b/web/src/utils/index.ts
@@ -0,0 +1,45 @@
+import { isObject, isEmpty } from 'lodash';
+
+export const isDevelopEnv = () => {
+  return process.env.NODE_ENV === 'development';
+};
+
+export const isEmptyParam = (value: any): boolean => {
+  if (Array.isArray(value)) {
+    // value为数组
+    return !value.length;
+  }
+  if (isObject(value)) {
+    // value为对象
+    return isEmpty(value);
+  }
+  if (typeof value === 'undefined') {
+    // value为undefinded
+    return true;
+  }
+  if (Number.isFinite(value)) {
+    // value为数值
+    return false;
+  }
+  // value为默认值
+  return !value;
+};
+
+export const boolean2Chinese = (value: boolean | string): string => {
+  let v: boolean;
+  if (value === 'false') {
+    v = false;
+  } else if (value === 'true') {
+    v = true;
+  } else {
+    v = value as boolean;
+  }
+  return !v ? '否' : '是';
+};
+
+export const transParamsWithConstantsMap = (
+  map: any,
+  paramsName: string
+): string => {
+  return map[paramsName] || paramsName;
+};
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..0124036
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,33 @@
+{
+  "extends": "./tsconfig.paths.json",
+  "compilerOptions": {
+    "typeRoots": [
+      "./typings"
+    ],
+    "target": "es5",
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "esnext"
+    ],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "module": "esnext",
+    "moduleResolution": "node",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react"
+  },
+  "exclude": [
+    "build",
+    "node_modules"
+  ],
+  "include": [
+    "src"
+  ]
+}
diff --git a/web/tsconfig.paths.json b/web/tsconfig.paths.json
new file mode 100644
index 0000000..5879100
--- /dev/null
+++ b/web/tsconfig.paths.json
@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "baseUrl": "src",
+    "paths": {
+      "@/*": ["*"]
+    }
+  }
+}

Reply via email to