From 16f1c84ed9ac838e475a1f08c2056f136a12da33 Mon Sep 17 00:00:00 2001 From: hujian05 Date: Tue, 15 Mar 2022 11:29:10 +0800 Subject: [PATCH 1/4] [init] init doris manager frontend --- frontend/.babelrc | 43 ++ frontend/.editorconfig | 13 + frontend/.eslintrc.js | 33 ++ frontend/.gitignore | 16 + frontend/.lintstagedrc | 7 + frontend/.prettierrc.js | 30 + frontend/README.md | 95 ++++ frontend/babel.config.js | 24 + frontend/config/webpack.base.config.js | 164 ++++++ frontend/config/webpack.dev.config.js | 54 ++ frontend/config/webpack.prod.config.js | 93 +++ frontend/favicon.ico | Bin 0 -> 16958 bytes frontend/package.json | 109 ++++ frontend/postcss.config.js | 41 ++ frontend/public/index.html | 29 + frontend/public/locales/en-us.json | 318 +++++++++++ frontend/public/locales/zh-cn.json | 332 +++++++++++ frontend/src/app.tsx | 42 ++ frontend/src/assets/404.png | Bin 0 -> 9247 bytes frontend/src/assets/background.jpg | Bin 0 -> 118647 bytes frontend/src/assets/doris.png | Bin 0 -> 3957 bytes frontend/src/assets/logo_manager.png | Bin 0 -> 13091 bytes frontend/src/assets/space/u288.png | Bin 0 -> 17706 bytes frontend/src/assets/space/u296.png | Bin 0 -> 2387 bytes frontend/src/assets/space/u919.png | Bin 0 -> 5850 bytes frontend/src/common/common.api.ts | 30 + frontend/src/common/common.context.ts | 5 + frontend/src/common/common.data.ts | 102 ++++ frontend/src/common/common.interface.ts | 34 ++ .../components/clipped-text/clipped-text.tsx | 72 +++ .../components/common-header/header.api.ts | 28 + .../common-header/header.interface.ts | 23 + .../common-header/header.module.less | 48 ++ .../src/components/common-header/header.tsx | 61 ++ frontend/src/components/copy-text/index.less | 34 ++ frontend/src/components/copy-text/index.tsx | 46 ++ .../components/doris-modal/doris-modal.jsx | 41 ++ frontend/src/components/dot/dot.less | 6 + frontend/src/components/dot/dot.tsx | 8 + .../components/flatbtn/flat-btn-group.less | 42 ++ .../src/components/flatbtn/flat-btn-group.tsx | 75 +++ frontend/src/components/flatbtn/flat-btn.tsx | 58 ++ frontend/src/components/flatbtn/index.tsx | 5 + frontend/src/components/flatbtn/style.less | 47 ++ frontend/src/components/header/header.api.ts | 37 ++ frontend/src/components/header/header.tsx | 124 ++++ .../src/components/header/index.module.less | 60 ++ frontend/src/components/helper/helper.less | 23 + frontend/src/components/helper/helper.tsx | 30 + .../initialized-route/initialized-route.tsx | 16 + .../src/components/loading-layout/index.tsx | 23 + frontend/src/components/loading/index.tsx | 30 + frontend/src/components/loading/loading.less | 30 + .../loadingwrapper/loadingwrapper.tsx | 41 ++ frontend/src/components/metadata/index.tsx | 128 +++++ frontend/src/components/not-found/index.tsx | 27 + frontend/src/components/sidebar/sidebar.less | 47 ++ frontend/src/components/sidebar/sidebar.tsx | 169 ++++++ .../src/components/sidebar/siderbar.data.ts | 0 .../components/status-mark/index.module.less | 25 + frontend/src/components/status-mark/index.tsx | 22 + .../components/studio-header/header.api.ts | 37 ++ .../src/components/studio-header/header.tsx | 131 +++++ .../studio-header/index.module.less | 68 +++ frontend/src/components/tabs-header/index.tsx | 34 ++ frontend/src/config.ts | 28 + frontend/src/hooks/use-async.ts | 90 +++ frontend/src/hooks/use-auth.ts | 37 ++ frontend/src/hooks/use-pagination.ts | 47 ++ frontend/src/hooks/use-roles.hooks.ts | 64 +++ frontend/src/hooks/use-userinfo.hooks.ts | 18 + frontend/src/hooks/use-users.hooks.ts | 41 ++ frontend/src/i18n.tsx | 50 ++ frontend/src/index.css | 62 ++ frontend/src/index.tsx | 25 + frontend/src/interfaces/http.interface.ts | 22 + frontend/src/layout/page-side/index.tsx | 51 ++ .../layout/page-side/page-side.module.less | 27 + frontend/src/routes.tsx | 59 ++ frontend/src/routes/admin/admin.module.less | 9 + frontend/src/routes/admin/admin.tsx | 58 ++ frontend/src/routes/admin/people/people.less | 14 + frontend/src/routes/admin/people/people.tsx | 18 + .../people/role/list/create-or-edit-modal.tsx | 86 +++ .../routes/admin/people/role/list/list.tsx | 136 +++++ .../admin/people/role/member/member.tsx | 187 ++++++ .../src/routes/admin/people/role/role.api.ts | 30 + .../src/routes/admin/people/role/role.tsx | 17 + .../routes/admin/people/user/create-modal.tsx | 80 +++ .../src/routes/admin/people/user/user.api.tsx | 22 + .../routes/admin/people/user/user.hooks.ts | 47 ++ .../src/routes/admin/people/user/user.tsx | 114 ++++ frontend/src/routes/cluster/cluster.api.ts | 56 ++ .../src/routes/cluster/cluster.module.less | 3 + frontend/src/routes/cluster/cluster.tsx | 57 ++ .../data-overview-item/index.module.less | 9 + .../components/data-overview-item/index.tsx | 22 + .../liquid-fill-chart/index.module.less | 8 + .../components/liquid-fill-chart/index.tsx | 59 ++ .../cluster/configuration/check-modal.tsx | 41 ++ .../cluster/configuration/edit-modal.tsx | 113 ++++ .../routes/cluster/configuration/index.tsx | 167 ++++++ frontend/src/routes/cluster/list/list.tsx | 48 ++ .../src/routes/cluster/monitor/monitor.api.ts | 70 +++ .../routes/cluster/monitor/monitor.data.ts | 111 ++++ .../src/routes/cluster/monitor/monitor.less | 24 + .../src/routes/cluster/monitor/monitor.tsx | 438 ++++++++++++++ frontend/src/routes/cluster/nodes/index.tsx | 93 +++ .../routes/cluster/overview/index.module.less | 7 + .../src/routes/cluster/overview/index.tsx | 173 ++++++ frontend/src/routes/container.less | 28 + frontend/src/routes/container.tsx | 74 +++ .../components/dashboard-item/index.tsx | 37 ++ .../dashboard-item/message-item.interface.ts | 23 + .../dashboard-item/message-item.less | 57 ++ .../dashboard/connect-info/connect-info.less | 23 + .../dashboard/connect-info/connect-info.tsx | 68 +++ .../src/routes/dashboard/dashboard.api.ts | 31 + .../src/routes/dashboard/dashboard.data.ts | 21 + .../routes/dashboard/dashboard.interface.ts | 33 ++ frontend/src/routes/dashboard/dashboard.less | 27 + frontend/src/routes/dashboard/dashboard.tsx | 75 +++ .../routes/dashboard/overview/overview.less | 23 + .../routes/dashboard/overview/overview.tsx | 69 +++ frontend/src/routes/database/database.api.ts | 29 + .../src/routes/database/database.interface.ts | 28 + .../src/routes/database/database.module.less | 42 ++ frontend/src/routes/database/index.tsx | 91 +++ frontend/src/routes/initialize/auths/auth.tsx | 15 + .../components/admin-user/admin-user.less | 2 + .../components/admin-user/admin-user.tsx | 106 ++++ .../auths/components/finish/finish.tsx | 22 + .../ldap/ldap-admin-user/ldap-admin-user.tsx | 60 ++ .../ldap-admin-user/use-ldap-user.hooks.ts | 23 + .../auths/ldap/ldap-config/ldap-config.tsx | 135 +++++ .../src/routes/initialize/auths/ldap/ldap.tsx | 38 ++ .../routes/initialize/auths/studio/studio.tsx | 38 ++ .../src/routes/initialize/initialize.api.ts | 35 ++ .../src/routes/initialize/initialize.data.ts | 10 + .../src/routes/initialize/initialize.less | 15 + .../routes/initialize/initialize.route.tsx | 57 ++ frontend/src/routes/initialize/initialize.tsx | 42 ++ frontend/src/routes/meta/meta.less | 40 ++ frontend/src/routes/meta/meta.tsx | 52 ++ .../routes/node/dashboard/index.module.less | 20 + frontend/src/routes/node/dashboard/index.tsx | 336 +++++++++++ .../src/routes/node/dashboard/monitor.api.ts | 61 ++ .../src/routes/node/dashboard/monitor.data.ts | 96 ++++ .../src/routes/node/dashboard/monitor.less | 24 + .../list/be-configuration/be-config.api.ts | 37 ++ .../node/list/be-configuration/index.tsx | 534 +++++++++++++++++ frontend/src/routes/node/list/config.data.ts | 26 + .../routes/node/list/configuration/index.tsx | 106 ++++ .../list/fe-configuration/fe-config.api.ts | 37 ++ .../node/list/fe-configuration/index.tsx | 535 ++++++++++++++++++ frontend/src/routes/node/list/index.tsx | 190 +++++++ frontend/src/routes/node/list/node.api.ts | 33 ++ frontend/src/routes/passport/forgot.login.tsx | 37 ++ .../src/routes/passport/index.module.less | 44 ++ frontend/src/routes/passport/login.tsx | 83 +++ frontend/src/routes/passport/passport.api.ts | 30 + frontend/src/routes/query/index.tsx | 147 +++++ .../src/routes/query/query-details/code.css | 126 +++++ .../src/routes/query/query-details/index.tsx | 122 ++++ .../routes/query/query-details/profile.tsx | 93 +++ .../query/query-details/query.module.less | 56 ++ frontend/src/routes/query/query.api.ts | 49 ++ frontend/src/routes/query/query.data.ts | 28 + .../settings-header/settings-header.less | 36 ++ .../settings-header/settings-header.tsx | 55 ++ .../settings/components/tabs-header/index.tsx | 23 + .../components/loading-layout/index.tsx | 23 + .../components/setting-item-layout/index.tsx | 23 + .../global/components/sidebar/index.tsx | 35 ++ .../src/routes/settings/global/constants.ts | 9 + .../context/global-settings-context.tsx | 55 ++ .../routes/settings/global/context/index.tsx | 8 + .../src/routes/settings/global/global.api.ts | 48 ++ .../routes/settings/global/global.hooks.ts | 65 +++ .../routes/settings/global/global.routes.ts | 33 ++ .../src/routes/settings/global/global.tsx | 49 ++ .../routes/settings/global/global.utils.ts | 31 + .../global/routes/certificate/index.tsx | 82 +++ .../global/routes/email/email-modal.tsx | 32 ++ .../settings/global/routes/email/hooks.ts | 68 +++ .../settings/global/routes/email/index.tsx | 152 +++++ .../settings/global/routes/general/index.tsx | 74 +++ .../global/routes/localization/constants.ts | 26 + .../routes/localization/form-item-layout.tsx | 18 + .../routes/localization/form-layout.tsx | 18 + .../global/routes/localization/index.tsx | 52 ++ .../routes/localization/number-form.tsx | 39 ++ .../routes/localization/temporal-form.tsx | 92 +++ .../global/routes/public-sharing/index.tsx | 32 ++ .../routes/settings/global/style.module.less | 6 + .../src/routes/settings/global/types/index.ts | 6 + .../src/routes/settings/settings.module.less | 9 + frontend/src/routes/settings/settings.tsx | 45 ++ .../user/list/create-or-edit-modal.tsx | 128 +++++ .../settings/user/list/list.module.less | 7 + .../src/routes/settings/user/list/list.tsx | 214 +++++++ frontend/src/routes/settings/user/user.api.ts | 45 ++ .../src/routes/settings/user/user.hooks.ts | 40 ++ frontend/src/routes/settings/user/user.less | 0 frontend/src/routes/settings/user/user.tsx | 15 + .../src/routes/settings/user/user.utils.ts | 25 + .../access-cluster/access-cluster.data.ts | 14 + .../access-cluster/access-cluster.recoil.ts | 43 ++ .../space/access-cluster/access-cluster.tsx | 192 +++++++ .../steps/cluster-verify/cluster-verify.tsx | 133 +++++ .../steps/connect-cluster/connect-cluster.tsx | 113 ++++ .../access-cluster/steps/finish/finish.tsx | 24 + .../steps/managed-options/managed-options.tsx | 61 ++ .../cluster-verify/cluster-verify.tsx | 115 ++++ .../routes/space/components/finish/finish.tsx | 52 ++ .../node-verify/node-verify.data.ts | 30 + .../components/node-verify/node-verify.tsx | 96 ++++ .../routes/space/components/result-modal.tsx | 157 +++++ .../space-register/space-register.tsx | 102 ++++ .../src/routes/space/detail/space-detail.tsx | 208 +++++++ frontend/src/routes/space/list/list.less | 29 + frontend/src/routes/space/list/list.tsx | 239 ++++++++ .../routes/space/new-cluster/logs/logs.tsx | 31 + .../space/new-cluster/new-cluster.api.ts | 74 +++ .../space/new-cluster/new-cluster.data.ts | 10 + .../routes/space/new-cluster/new-cluster.tsx | 220 +++++++ .../routes/space/new-cluster/recoils/index.ts | 3 + .../space/new-cluster/recoils/node.recoil.ts | 16 + .../new-cluster/recoils/result.recoil.ts | 56 ++ .../space/new-cluster/recoils/step.recoil.ts | 22 + .../new-cluster/steps/add-node/add-node.less | 9 + .../new-cluster/steps/add-node/add-node.tsx | 49 ++ .../steps/add-node/node-list/node-list.tsx | 170 ++++++ .../steps/cluster-deploy/cluster-deploy.tsx | 166 ++++++ .../steps/cluster-plan/cluster-plan.tsx | 237 ++++++++ .../steps/install-options/install-options.tsx | 56 ++ .../components/custom-config.interface.ts | 10 + .../node-config/components/custom-config.tsx | 36 ++ .../new-cluster/steps/node-config/data.ts | 90 +++ .../steps/node-config/node-config.tsx | 276 +++++++++ .../steps/node-register/node-register.tsx | 45 ++ .../steps/run-cluster/run-cluster.tsx | 141 +++++ .../space/new-cluster/types/index.type.ts | 8 + .../space/new-cluster/types/params.type.ts | 49 ++ .../space/new-cluster/types/result.types.ts | 62 ++ frontend/src/routes/space/space.api.ts | 99 ++++ frontend/src/routes/space/space.data.ts | 26 + frontend/src/routes/space/space.interface.ts | 91 +++ frontend/src/routes/space/space.less | 133 +++++ frontend/src/routes/space/space.recoil.ts | 15 + frontend/src/routes/space/space.tsx | 24 + frontend/src/routes/super-admin-container.tsx | 29 + frontend/src/routes/table-content/index.tsx | 114 ++++ .../routes/table-content/schema/schema.api.ts | 26 + .../table-content/schema/schema.data.ts | 80 +++ .../routes/table-content/schema/schema.tsx | 97 ++++ .../table-content/table-content.data.ts | 23 + .../table-content/table-content.module.less | 33 ++ .../src/routes/table-content/table.api.ts | 32 ++ .../routes/table-content/table.interface.ts | 36 ++ .../routes/table-content/tabs/baseInfo.tsx | 83 +++ .../routes/table-content/tabs/data-import.tsx | 250 ++++++++ .../routes/table-content/tabs/data.pre.tsx | 106 ++++ .../tabs/dataImport-menu/index.tsx | 79 +++ .../src/routes/table-content/tabs/tabs.api.ts | 29 + .../table-content/tabs/tabs.interface.ts | 35 ++ .../table-content/tabs/tabs.module.less | 74 +++ .../tree/create-menu/create.module.less | 27 + .../routes/tree/create-menu/databaseModal.tsx | 107 ++++ .../src/routes/tree/create-menu/index.tsx | 79 +++ frontend/src/routes/tree/index.tsx | 143 +++++ frontend/src/routes/tree/tree.api.ts | 47 ++ frontend/src/routes/tree/tree.data.ts | 24 + frontend/src/routes/tree/tree.interface.ts | 45 ++ frontend/src/routes/tree/tree.module.less | 78 +++ frontend/src/routes/tree/tree.service.ts | 31 + .../src/routes/user-setting/index.module.less | 42 ++ frontend/src/routes/user-setting/index.tsx | 237 ++++++++ frontend/src/routes/user-setting/user.api.ts | 35 ++ .../workspace/components/editor/edito.less | 0 .../components/editor/editor.interface.less | 0 .../workspace/components/editor/editor.tsx | 24 + .../routes/workspace/workspace.interface.ts | 22 + frontend/src/routes/workspace/workspace.tsx | 35 ++ frontend/src/types/global.d.ts | 21 + frontend/src/utils/api.ts | 3 + frontend/src/utils/auth.ts | 37 ++ frontend/src/utils/event-emitter.ts | 60 ++ frontend/src/utils/http.ts | 110 ++++ frontend/src/utils/utils.ts | 78 +++ frontend/theme.js | 52 ++ frontend/tsconfig.json | 54 ++ frontend/version.json | 3 + frontend/webpack.config.js | 31 + 294 files changed, 18668 insertions(+) create mode 100644 frontend/.babelrc create mode 100644 frontend/.editorconfig create mode 100644 frontend/.eslintrc.js create mode 100644 frontend/.gitignore create mode 100644 frontend/.lintstagedrc create mode 100644 frontend/.prettierrc.js create mode 100644 frontend/README.md create mode 100644 frontend/babel.config.js create mode 100644 frontend/config/webpack.base.config.js create mode 100644 frontend/config/webpack.dev.config.js create mode 100644 frontend/config/webpack.prod.config.js create mode 100644 frontend/favicon.ico create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/index.html create mode 100644 frontend/public/locales/en-us.json create mode 100644 frontend/public/locales/zh-cn.json create mode 100644 frontend/src/app.tsx create mode 100644 frontend/src/assets/404.png create mode 100644 frontend/src/assets/background.jpg create mode 100644 frontend/src/assets/doris.png create mode 100644 frontend/src/assets/logo_manager.png create mode 100644 frontend/src/assets/space/u288.png create mode 100644 frontend/src/assets/space/u296.png create mode 100644 frontend/src/assets/space/u919.png create mode 100644 frontend/src/common/common.api.ts create mode 100644 frontend/src/common/common.context.ts create mode 100644 frontend/src/common/common.data.ts create mode 100644 frontend/src/common/common.interface.ts create mode 100644 frontend/src/components/clipped-text/clipped-text.tsx create mode 100644 frontend/src/components/common-header/header.api.ts create mode 100644 frontend/src/components/common-header/header.interface.ts create mode 100644 frontend/src/components/common-header/header.module.less create mode 100644 frontend/src/components/common-header/header.tsx create mode 100644 frontend/src/components/copy-text/index.less create mode 100644 frontend/src/components/copy-text/index.tsx create mode 100644 frontend/src/components/doris-modal/doris-modal.jsx create mode 100644 frontend/src/components/dot/dot.less create mode 100644 frontend/src/components/dot/dot.tsx create mode 100644 frontend/src/components/flatbtn/flat-btn-group.less create mode 100644 frontend/src/components/flatbtn/flat-btn-group.tsx create mode 100644 frontend/src/components/flatbtn/flat-btn.tsx create mode 100644 frontend/src/components/flatbtn/index.tsx create mode 100644 frontend/src/components/flatbtn/style.less create mode 100644 frontend/src/components/header/header.api.ts create mode 100644 frontend/src/components/header/header.tsx create mode 100644 frontend/src/components/header/index.module.less create mode 100644 frontend/src/components/helper/helper.less create mode 100644 frontend/src/components/helper/helper.tsx create mode 100644 frontend/src/components/initialized-route/initialized-route.tsx create mode 100644 frontend/src/components/loading-layout/index.tsx create mode 100644 frontend/src/components/loading/index.tsx create mode 100644 frontend/src/components/loading/loading.less create mode 100644 frontend/src/components/loadingwrapper/loadingwrapper.tsx create mode 100644 frontend/src/components/metadata/index.tsx create mode 100644 frontend/src/components/not-found/index.tsx create mode 100644 frontend/src/components/sidebar/sidebar.less create mode 100644 frontend/src/components/sidebar/sidebar.tsx create mode 100644 frontend/src/components/sidebar/siderbar.data.ts create mode 100644 frontend/src/components/status-mark/index.module.less create mode 100644 frontend/src/components/status-mark/index.tsx create mode 100644 frontend/src/components/studio-header/header.api.ts create mode 100644 frontend/src/components/studio-header/header.tsx create mode 100644 frontend/src/components/studio-header/index.module.less create mode 100644 frontend/src/components/tabs-header/index.tsx create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/hooks/use-async.ts create mode 100644 frontend/src/hooks/use-auth.ts create mode 100644 frontend/src/hooks/use-pagination.ts create mode 100644 frontend/src/hooks/use-roles.hooks.ts create mode 100644 frontend/src/hooks/use-userinfo.hooks.ts create mode 100644 frontend/src/hooks/use-users.hooks.ts create mode 100644 frontend/src/i18n.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/index.tsx create mode 100644 frontend/src/interfaces/http.interface.ts create mode 100644 frontend/src/layout/page-side/index.tsx create mode 100644 frontend/src/layout/page-side/page-side.module.less create mode 100644 frontend/src/routes.tsx create mode 100644 frontend/src/routes/admin/admin.module.less create mode 100644 frontend/src/routes/admin/admin.tsx create mode 100644 frontend/src/routes/admin/people/people.less create mode 100644 frontend/src/routes/admin/people/people.tsx create mode 100644 frontend/src/routes/admin/people/role/list/create-or-edit-modal.tsx create mode 100644 frontend/src/routes/admin/people/role/list/list.tsx create mode 100644 frontend/src/routes/admin/people/role/member/member.tsx create mode 100644 frontend/src/routes/admin/people/role/role.api.ts create mode 100644 frontend/src/routes/admin/people/role/role.tsx create mode 100644 frontend/src/routes/admin/people/user/create-modal.tsx create mode 100644 frontend/src/routes/admin/people/user/user.api.tsx create mode 100644 frontend/src/routes/admin/people/user/user.hooks.ts create mode 100644 frontend/src/routes/admin/people/user/user.tsx create mode 100644 frontend/src/routes/cluster/cluster.api.ts create mode 100644 frontend/src/routes/cluster/cluster.module.less create mode 100644 frontend/src/routes/cluster/cluster.tsx create mode 100644 frontend/src/routes/cluster/components/data-overview-item/index.module.less create mode 100644 frontend/src/routes/cluster/components/data-overview-item/index.tsx create mode 100644 frontend/src/routes/cluster/components/liquid-fill-chart/index.module.less create mode 100644 frontend/src/routes/cluster/components/liquid-fill-chart/index.tsx create mode 100644 frontend/src/routes/cluster/configuration/check-modal.tsx create mode 100644 frontend/src/routes/cluster/configuration/edit-modal.tsx create mode 100644 frontend/src/routes/cluster/configuration/index.tsx create mode 100644 frontend/src/routes/cluster/list/list.tsx create mode 100644 frontend/src/routes/cluster/monitor/monitor.api.ts create mode 100644 frontend/src/routes/cluster/monitor/monitor.data.ts create mode 100644 frontend/src/routes/cluster/monitor/monitor.less create mode 100644 frontend/src/routes/cluster/monitor/monitor.tsx create mode 100644 frontend/src/routes/cluster/nodes/index.tsx create mode 100644 frontend/src/routes/cluster/overview/index.module.less create mode 100644 frontend/src/routes/cluster/overview/index.tsx create mode 100644 frontend/src/routes/container.less create mode 100644 frontend/src/routes/container.tsx create mode 100644 frontend/src/routes/dashboard/components/dashboard-item/index.tsx create mode 100644 frontend/src/routes/dashboard/components/dashboard-item/message-item.interface.ts create mode 100644 frontend/src/routes/dashboard/components/dashboard-item/message-item.less create mode 100644 frontend/src/routes/dashboard/connect-info/connect-info.less create mode 100644 frontend/src/routes/dashboard/connect-info/connect-info.tsx create mode 100644 frontend/src/routes/dashboard/dashboard.api.ts create mode 100644 frontend/src/routes/dashboard/dashboard.data.ts create mode 100644 frontend/src/routes/dashboard/dashboard.interface.ts create mode 100644 frontend/src/routes/dashboard/dashboard.less create mode 100644 frontend/src/routes/dashboard/dashboard.tsx create mode 100644 frontend/src/routes/dashboard/overview/overview.less create mode 100644 frontend/src/routes/dashboard/overview/overview.tsx create mode 100644 frontend/src/routes/database/database.api.ts create mode 100644 frontend/src/routes/database/database.interface.ts create mode 100644 frontend/src/routes/database/database.module.less create mode 100644 frontend/src/routes/database/index.tsx create mode 100644 frontend/src/routes/initialize/auths/auth.tsx create mode 100644 frontend/src/routes/initialize/auths/components/admin-user/admin-user.less create mode 100644 frontend/src/routes/initialize/auths/components/admin-user/admin-user.tsx create mode 100644 frontend/src/routes/initialize/auths/components/finish/finish.tsx create mode 100644 frontend/src/routes/initialize/auths/ldap/ldap-admin-user/ldap-admin-user.tsx create mode 100644 frontend/src/routes/initialize/auths/ldap/ldap-admin-user/use-ldap-user.hooks.ts create mode 100644 frontend/src/routes/initialize/auths/ldap/ldap-config/ldap-config.tsx create mode 100644 frontend/src/routes/initialize/auths/ldap/ldap.tsx create mode 100644 frontend/src/routes/initialize/auths/studio/studio.tsx create mode 100644 frontend/src/routes/initialize/initialize.api.ts create mode 100644 frontend/src/routes/initialize/initialize.data.ts create mode 100644 frontend/src/routes/initialize/initialize.less create mode 100644 frontend/src/routes/initialize/initialize.route.tsx create mode 100644 frontend/src/routes/initialize/initialize.tsx create mode 100644 frontend/src/routes/meta/meta.less create mode 100644 frontend/src/routes/meta/meta.tsx create mode 100644 frontend/src/routes/node/dashboard/index.module.less create mode 100644 frontend/src/routes/node/dashboard/index.tsx create mode 100644 frontend/src/routes/node/dashboard/monitor.api.ts create mode 100644 frontend/src/routes/node/dashboard/monitor.data.ts create mode 100644 frontend/src/routes/node/dashboard/monitor.less create mode 100644 frontend/src/routes/node/list/be-configuration/be-config.api.ts create mode 100644 frontend/src/routes/node/list/be-configuration/index.tsx create mode 100644 frontend/src/routes/node/list/config.data.ts create mode 100644 frontend/src/routes/node/list/configuration/index.tsx create mode 100644 frontend/src/routes/node/list/fe-configuration/fe-config.api.ts create mode 100644 frontend/src/routes/node/list/fe-configuration/index.tsx create mode 100644 frontend/src/routes/node/list/index.tsx create mode 100644 frontend/src/routes/node/list/node.api.ts create mode 100644 frontend/src/routes/passport/forgot.login.tsx create mode 100644 frontend/src/routes/passport/index.module.less create mode 100644 frontend/src/routes/passport/login.tsx create mode 100644 frontend/src/routes/passport/passport.api.ts create mode 100644 frontend/src/routes/query/index.tsx create mode 100644 frontend/src/routes/query/query-details/code.css create mode 100644 frontend/src/routes/query/query-details/index.tsx create mode 100644 frontend/src/routes/query/query-details/profile.tsx create mode 100644 frontend/src/routes/query/query-details/query.module.less create mode 100644 frontend/src/routes/query/query.api.ts create mode 100644 frontend/src/routes/query/query.data.ts create mode 100644 frontend/src/routes/settings/components/settings-header/settings-header.less create mode 100644 frontend/src/routes/settings/components/settings-header/settings-header.tsx create mode 100644 frontend/src/routes/settings/components/tabs-header/index.tsx create mode 100644 frontend/src/routes/settings/global/components/loading-layout/index.tsx create mode 100644 frontend/src/routes/settings/global/components/setting-item-layout/index.tsx create mode 100644 frontend/src/routes/settings/global/components/sidebar/index.tsx create mode 100644 frontend/src/routes/settings/global/constants.ts create mode 100644 frontend/src/routes/settings/global/context/global-settings-context.tsx create mode 100644 frontend/src/routes/settings/global/context/index.tsx create mode 100644 frontend/src/routes/settings/global/global.api.ts create mode 100644 frontend/src/routes/settings/global/global.hooks.ts create mode 100644 frontend/src/routes/settings/global/global.routes.ts create mode 100644 frontend/src/routes/settings/global/global.tsx create mode 100644 frontend/src/routes/settings/global/global.utils.ts create mode 100644 frontend/src/routes/settings/global/routes/certificate/index.tsx create mode 100644 frontend/src/routes/settings/global/routes/email/email-modal.tsx create mode 100644 frontend/src/routes/settings/global/routes/email/hooks.ts create mode 100644 frontend/src/routes/settings/global/routes/email/index.tsx create mode 100644 frontend/src/routes/settings/global/routes/general/index.tsx create mode 100644 frontend/src/routes/settings/global/routes/localization/constants.ts create mode 100644 frontend/src/routes/settings/global/routes/localization/form-item-layout.tsx create mode 100644 frontend/src/routes/settings/global/routes/localization/form-layout.tsx create mode 100644 frontend/src/routes/settings/global/routes/localization/index.tsx create mode 100644 frontend/src/routes/settings/global/routes/localization/number-form.tsx create mode 100644 frontend/src/routes/settings/global/routes/localization/temporal-form.tsx create mode 100644 frontend/src/routes/settings/global/routes/public-sharing/index.tsx create mode 100644 frontend/src/routes/settings/global/style.module.less create mode 100644 frontend/src/routes/settings/global/types/index.ts create mode 100644 frontend/src/routes/settings/settings.module.less create mode 100644 frontend/src/routes/settings/settings.tsx create mode 100644 frontend/src/routes/settings/user/list/create-or-edit-modal.tsx create mode 100644 frontend/src/routes/settings/user/list/list.module.less create mode 100644 frontend/src/routes/settings/user/list/list.tsx create mode 100644 frontend/src/routes/settings/user/user.api.ts create mode 100644 frontend/src/routes/settings/user/user.hooks.ts create mode 100644 frontend/src/routes/settings/user/user.less create mode 100644 frontend/src/routes/settings/user/user.tsx create mode 100644 frontend/src/routes/settings/user/user.utils.ts create mode 100644 frontend/src/routes/space/access-cluster/access-cluster.data.ts create mode 100644 frontend/src/routes/space/access-cluster/access-cluster.recoil.ts create mode 100644 frontend/src/routes/space/access-cluster/access-cluster.tsx create mode 100644 frontend/src/routes/space/access-cluster/steps/cluster-verify/cluster-verify.tsx create mode 100644 frontend/src/routes/space/access-cluster/steps/connect-cluster/connect-cluster.tsx create mode 100644 frontend/src/routes/space/access-cluster/steps/finish/finish.tsx create mode 100644 frontend/src/routes/space/access-cluster/steps/managed-options/managed-options.tsx create mode 100644 frontend/src/routes/space/components/cluster-verify/cluster-verify.tsx create mode 100644 frontend/src/routes/space/components/finish/finish.tsx create mode 100644 frontend/src/routes/space/components/node-verify/node-verify.data.ts create mode 100644 frontend/src/routes/space/components/node-verify/node-verify.tsx create mode 100644 frontend/src/routes/space/components/result-modal.tsx create mode 100644 frontend/src/routes/space/components/space-register/space-register.tsx create mode 100644 frontend/src/routes/space/detail/space-detail.tsx create mode 100644 frontend/src/routes/space/list/list.less create mode 100644 frontend/src/routes/space/list/list.tsx create mode 100644 frontend/src/routes/space/new-cluster/logs/logs.tsx create mode 100644 frontend/src/routes/space/new-cluster/new-cluster.api.ts create mode 100644 frontend/src/routes/space/new-cluster/new-cluster.data.ts create mode 100644 frontend/src/routes/space/new-cluster/new-cluster.tsx create mode 100644 frontend/src/routes/space/new-cluster/recoils/index.ts create mode 100644 frontend/src/routes/space/new-cluster/recoils/node.recoil.ts create mode 100644 frontend/src/routes/space/new-cluster/recoils/result.recoil.ts create mode 100644 frontend/src/routes/space/new-cluster/recoils/step.recoil.ts create mode 100644 frontend/src/routes/space/new-cluster/steps/add-node/add-node.less create mode 100644 frontend/src/routes/space/new-cluster/steps/add-node/add-node.tsx create mode 100644 frontend/src/routes/space/new-cluster/steps/add-node/node-list/node-list.tsx create mode 100644 frontend/src/routes/space/new-cluster/steps/cluster-deploy/cluster-deploy.tsx create mode 100644 frontend/src/routes/space/new-cluster/steps/cluster-plan/cluster-plan.tsx create mode 100644 frontend/src/routes/space/new-cluster/steps/install-options/install-options.tsx create mode 100644 frontend/src/routes/space/new-cluster/steps/node-config/components/custom-config.interface.ts create mode 100644 frontend/src/routes/space/new-cluster/steps/node-config/components/custom-config.tsx create mode 100644 frontend/src/routes/space/new-cluster/steps/node-config/data.ts create mode 100644 frontend/src/routes/space/new-cluster/steps/node-config/node-config.tsx create mode 100644 frontend/src/routes/space/new-cluster/steps/node-register/node-register.tsx create mode 100644 frontend/src/routes/space/new-cluster/steps/run-cluster/run-cluster.tsx create mode 100644 frontend/src/routes/space/new-cluster/types/index.type.ts create mode 100644 frontend/src/routes/space/new-cluster/types/params.type.ts create mode 100644 frontend/src/routes/space/new-cluster/types/result.types.ts create mode 100644 frontend/src/routes/space/space.api.ts create mode 100644 frontend/src/routes/space/space.data.ts create mode 100644 frontend/src/routes/space/space.interface.ts create mode 100644 frontend/src/routes/space/space.less create mode 100644 frontend/src/routes/space/space.recoil.ts create mode 100644 frontend/src/routes/space/space.tsx create mode 100644 frontend/src/routes/super-admin-container.tsx create mode 100644 frontend/src/routes/table-content/index.tsx create mode 100644 frontend/src/routes/table-content/schema/schema.api.ts create mode 100644 frontend/src/routes/table-content/schema/schema.data.ts create mode 100644 frontend/src/routes/table-content/schema/schema.tsx create mode 100644 frontend/src/routes/table-content/table-content.data.ts create mode 100644 frontend/src/routes/table-content/table-content.module.less create mode 100644 frontend/src/routes/table-content/table.api.ts create mode 100644 frontend/src/routes/table-content/table.interface.ts create mode 100644 frontend/src/routes/table-content/tabs/baseInfo.tsx create mode 100644 frontend/src/routes/table-content/tabs/data-import.tsx create mode 100644 frontend/src/routes/table-content/tabs/data.pre.tsx create mode 100644 frontend/src/routes/table-content/tabs/dataImport-menu/index.tsx create mode 100644 frontend/src/routes/table-content/tabs/tabs.api.ts create mode 100644 frontend/src/routes/table-content/tabs/tabs.interface.ts create mode 100644 frontend/src/routes/table-content/tabs/tabs.module.less create mode 100644 frontend/src/routes/tree/create-menu/create.module.less create mode 100644 frontend/src/routes/tree/create-menu/databaseModal.tsx create mode 100644 frontend/src/routes/tree/create-menu/index.tsx create mode 100644 frontend/src/routes/tree/index.tsx create mode 100644 frontend/src/routes/tree/tree.api.ts create mode 100644 frontend/src/routes/tree/tree.data.ts create mode 100644 frontend/src/routes/tree/tree.interface.ts create mode 100644 frontend/src/routes/tree/tree.module.less create mode 100644 frontend/src/routes/tree/tree.service.ts create mode 100644 frontend/src/routes/user-setting/index.module.less create mode 100644 frontend/src/routes/user-setting/index.tsx create mode 100644 frontend/src/routes/user-setting/user.api.ts create mode 100644 frontend/src/routes/workspace/components/editor/edito.less create mode 100644 frontend/src/routes/workspace/components/editor/editor.interface.less create mode 100644 frontend/src/routes/workspace/components/editor/editor.tsx create mode 100644 frontend/src/routes/workspace/workspace.interface.ts create mode 100644 frontend/src/routes/workspace/workspace.tsx create mode 100644 frontend/src/types/global.d.ts create mode 100644 frontend/src/utils/api.ts create mode 100644 frontend/src/utils/auth.ts create mode 100644 frontend/src/utils/event-emitter.ts create mode 100644 frontend/src/utils/http.ts create mode 100644 frontend/src/utils/utils.ts create mode 100755 frontend/theme.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/version.json create mode 100644 frontend/webpack.config.js diff --git a/frontend/.babelrc b/frontend/.babelrc new file mode 100644 index 0000000..2d33edd --- /dev/null +++ b/frontend/.babelrc @@ -0,0 +1,43 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "esmodules": true + } + } + ], + "@babel/preset-react", + "@babel/preset-typescript" + + ], + "plugins": [ + [ + "import", + { + "libraryName": "antd", + "style": "css" + } + ], + ["@babel/plugin-proposal-decorators", { "legacy": true }] + ], + "plugins": [["import", { "libraryName": "antd", "libraryDirectory": "lib", "style": true}, "antd"],] +} diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..ed72c26 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,13 @@ +# http://editorconfig.org + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 80 +trim_trailing_whitespace = true + +[*.md] +max_line_length = 0 \ No newline at end of file diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js new file mode 100644 index 0000000..0b0af8c --- /dev/null +++ b/frontend/.eslintrc.js @@ -0,0 +1,33 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +module.exports = { + parser: '@typescript-eslint/parser', + extends: ['plugin:prettier/recommended', 'plugin:@typescript-eslint/recommended', "plugin:react/recommended", "plugin:react/jsx-runtime"], + parserOptions: { + ecmaVersion: 2019, + sourceType: 'module', + }, + env: { + browser: true, + node: true, + }, + rules: { + }, +}; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..f30fe51 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,16 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +package-lock.json + +# production +/dist + +#mac +.DS_Store + +#npm && yarn +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/frontend/.lintstagedrc b/frontend/.lintstagedrc new file mode 100644 index 0000000..cdf9678 --- /dev/null +++ b/frontend/.lintstagedrc @@ -0,0 +1,7 @@ +{ + "src/**/*.js": [ + "eslint --fix", + "prettier --write", + "git add" + ] +} \ No newline at end of file diff --git a/frontend/.prettierrc.js b/frontend/.prettierrc.js new file mode 100644 index 0000000..85e517c --- /dev/null +++ b/frontend/.prettierrc.js @@ -0,0 +1,30 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +module.exports = { + printWidth: 120, + semi: true, + singleQuote: true, + trailingComma: 'all', + bracketSpacing: true, + arrowParens: 'avoid', + insertPragma: false, + tabWidth: 4, + useTabs: false +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a60ee0a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,95 @@ + +# Doris Manager Frontend Style Guide + +## 整体风格 +### 单一职责原则SRP(https://wikipedia.org/wiki/Single_responsibility_principle) ++ 每个文件只定义一个内容(减少心智负担) ++ 单文件要求不超过400行,超出进行拆分(易阅读) ++ 尽量定义内容小的函数,函数体不要过长 + +### 文件/文件夹夹命名: +- 字母全部小写 +- 不带空格 +- 用中划线(-)和 (.) 连接单词 +- 规范为[feature].[type].ts(如ab-cd.hooks.ts) +- 建议使用英文单词全拼(易于理解,避免英文不好缩写错误),不好命名的可使用 +https://unbug.github.io/codelf/ 进行搜索看看大家怎么命名 + +### Interface / Class 命名: +- Upper Camel Case 大驼峰命名,强类型语言惯例 +- 文件名称为: [feature].interface.ts +- 单文件原则,尽量为 Interface 独立一个文件 +- TypeScript Type 和 Interface 在同一个文件里 + +### 常量、配置、枚举命名 +- 单文件原则,尽量为常量,枚举独立一个文件 +- 文件名称为:[feature].data.ts +- 常量和配置都用 const 关键字,使用大写下划线命名 +``` +const PAGINATIONS = [10, 20, 50, 100, 200]; +const SERVER_URL = 'http://127.0.0.1:8001'; +``` +- 枚举使用 Upper Camel Case 大驼峰命名,且后面加上Enum与 Interface / Class 区分 +``` +enum ImportTaskStatusEnum { + Created: 0, + Running: 1, + Failed: 2, +} +``` + +### 行尾逗号 +- 全部打开,便于在修改后在Git上看到清晰的比对(修改时追加 “,” 会让Git比对从一行变成2行) + +### 组件 +- 尽量不要在.tsx的组件里写太多逻辑,尽可能用hooks或工具类/服务(service)拆出去,保证视图层的干净清爽 + +### 圈复杂度(Cyclomatic complexity, CC) + +参考:http://kaelzhang81.github.io/2017/06/18/%E8%AF%A6%E8%A7%A3%E5%9C%88%E5%A4%8D%E6%9D%82%E5%BA%A6/ + +VS Code插件:CodeMetrics + +使用圈复杂度插件检查代码的负责度,可做参考,因为React本身负责度过高,心智负担重,圈复杂度插件检查主要还是在独立函数中可以作为参考 + +### 单词拼写检查 + +VS Code插件:Code Spell Checker + +使用Code Spell Checker检查单词拼写,错误单词会以绿色波浪线提示,遇到特殊名词可以右键点击添加到文件夹目录,到将其添加cspell.json中。 + +### Import Sorter + +VS Code插件:Typescript Imports Sort +设置 typescript.extension.sortImports.sortOnSave 为 true,在保存时自动sort import内容 + +# HOW TO START +require NodeJS > 10.0 + +INSTALL DEPENDENCE + +```npm install``` + +START SERVER + +```npm run start``` + + +# HOW TO BUILD + +```npm run build``` \ No newline at end of file diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 0000000..419fe02 --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,24 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +"@babel/preset-react"; +module.exports = function (api) { + api.cache(true); + return { + presets: ["@babel/preset-react"], + }; +}; diff --git a/frontend/config/webpack.base.config.js b/frontend/config/webpack.base.config.js new file mode 100644 index 0000000..312ae5a --- /dev/null +++ b/frontend/config/webpack.base.config.js @@ -0,0 +1,164 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const devMode = process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'development'; +const themes = require('../theme')(); +const ESLintPlugin = require('eslint-webpack-plugin'); + +const postCssLoader = () => { + return { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [require('autoprefixer'), require('cssnano')], + }, + }, + }; +}; + +module.exports = { + entry: { + index: ['react-hot-loader/patch'].concat([path.resolve(__dirname, '../src/index.tsx')]), + }, + output: { + path: path.join(__dirname, '../dist/'), + publicPath: '/', + filename: 'assets/js/[name].[contenthash:8].js', + chunkFilename: 'assets/js/[name].[contenthash:8].js', + sourceMapFilename: 'assets/js/[name].[contenthash:8].js.map', + }, + resolve: { + alias: { + '@src': path.resolve(__dirname, '../src'), + '@assets': path.resolve(__dirname, '../src/assets'), + '@components': path.resolve(__dirname, '../src/components'), + '@models': path.resolve(__dirname, '../src/models'), + '@router': path.resolve(__dirname, '../src/router'), + '@pages': path.resolve(__dirname, '../src/pages'), + '@utils': path.resolve(__dirname, '../src/utils'), + '@tools': path.resolve(__dirname, '../src/tools'), + 'bn.js': path.resolve(process.cwd(), 'node_modules', 'bn.js') + }, + extensions: ['.ts', '.tsx', '.js', '.jsx'], + fallback: { + crypto: require.resolve('crypto-browserify'), + buffer: require.resolve('buffer'), + stream: require.resolve('stream-browserify'), + }, + }, + module: { + rules: [ + { + test: /\.ts[x]?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: [['@babel/preset-env', { targets: 'defaults' }]], + }, + }, + }, + { + test: /\.css$/, + use: [ + devMode ? { loader: 'style-loader' } : MiniCssExtractPlugin.loader, + { + loader: 'css-loader', + options: { + sourceMap: true, + }, + }, + { + loader: 'postcss-loader', + options: { + sourceMap: true, + }, + }, + ], + }, + // For pure CSS (without CSS modules) + { + test: /\.less?$/, + include: /node_modules/, + use: [ + devMode ? { loader: 'style-loader' } : MiniCssExtractPlugin.loader, + { + loader: 'css-loader', + }, + postCssLoader(), + { + loader: 'less-loader', + options: { + lessOptions: { + modifyVars: {...themes}, + javascriptEnabled: true, + }, + implementation: require('less'), + }, + }, + ], + }, + // For CSS modules + { + test: /\.less?$/, + exclude: /node_modules/, + use: [ + devMode ? { loader: 'style-loader' } : MiniCssExtractPlugin.loader, + { + loader: 'css-loader', + options: { + modules: true, + }, + }, + postCssLoader(), + { + loader: 'less-loader', + options: { + lessOptions: { + modifyVars: {...themes}, + javascriptEnabled: true, + }, + implementation: require('less'), + }, + }, + ], + }, + { + test: /\.(svg|png|jpg|gif|ttf|eot|otf|svg|woff(2)?)(\?[a-z0-9]+)?$/, + loader: 'url-loader', + options: { + limit: 2 * 1024, + name: '[path][name].[ext]', + }, + }, + ], + }, + plugins: [ + new HtmlWebpackPlugin({ + filename: 'index.html', + template: 'public/index.html', + inject: 'body', + minify: false, + }), + + new ESLintPlugin(), + + ], +}; diff --git a/frontend/config/webpack.dev.config.js b/frontend/config/webpack.dev.config.js new file mode 100644 index 0000000..f158d52 --- /dev/null +++ b/frontend/config/webpack.dev.config.js @@ -0,0 +1,54 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const webpackBaseConfig = require("./webpack.base.config"); +const { merge } = require("webpack-merge"); +const SERVER_RD = 'http://127.0.0.1:8880'; + +module.exports = merge(webpackBaseConfig, { + mode: "development", + devtool: "eval-source-map", + // 开发服务配置 + devServer: { + host: "localhost", + port: 8084, + publicPath: "/", + disableHostCheck: true, + useLocalIp: false, + open: true, + overlay: true, + hot: true, + stats: { + assets: false, + colors: true, + modules: false, + children: false, + chunks: false, + chunkModules: false, + entrypoints: false, + }, + proxy: { + '/api': { + target: SERVER_RD, + changeOrigin: true, + secure: false, + // pathRewrite: {'^/api': '/api'} + }, + }, + historyApiFallback: true, + }, +}); diff --git a/frontend/config/webpack.prod.config.js b/frontend/config/webpack.prod.config.js new file mode 100644 index 0000000..a992579 --- /dev/null +++ b/frontend/config/webpack.prod.config.js @@ -0,0 +1,93 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const webpackBaseConfig = require("./webpack.base.config"); +const { merge } = require("webpack-merge"); +const path = require("path"); +const TerserWebpackPlugin = require('terser-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); +// const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer') +const CopyPlugin = require('copy-webpack-plugin'); + +module.exports = merge(webpackBaseConfig, { + mode: 'production', + output: { + publicPath: '/', + }, + optimization: { + minimizer: [ + new TerserWebpackPlugin({ + terserOptions: { + compress: { + drop_console: true, + }, + sourceMap: true, + }, + parallel: true, + }), + ], + chunkIds: 'named', + moduleIds: 'deterministic', + splitChunks: { + name: false, + chunks: 'all', + minChunks: 1, + // maxAsyncRequests: 5, + // maxInitialRequests: 5, + cacheGroups: { + vendor: { + test: /node_modules/, + name: 'vendor', + chunks: 'all', + enforce: true, + }, + antd: { + test: /antd?/, + name: 'antd', + priority: 10, + chunks: 'initial', + enforce: true, + }, + react: { + test: /react|react-dom|mobx|prop-type/, + name: 'react', + priority: 10, + chunks: 'initial', + enforce: true, + }, + }, + }, + runtimeChunk: { + name: entrypoint => `runtime-${entrypoint.name}`, + }, + }, + plugins: [ + new CleanWebpackPlugin(), + new MiniCssExtractPlugin({ + filename: 'assets/css/[name].[contenthash].css', + chunkFilename: 'assets/css/[name].[contenthash].css', + }), + new CopyPlugin({ + patterns: [ + { from: path.join(__dirname, '../src/assets/'), to: path.join(__dirname, '../dist/src/assets/') }, + { from: path.join(__dirname, '../favicon.ico'), to: path.join(__dirname, '../dist/') } + ], + }), + // new BundleAnalyzerPlugin() + ], +}); diff --git a/frontend/favicon.ico b/frontend/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..48dd93bd646bad0f54fb2d3d80e8a7b4397c0c12 GIT binary patch literal 16958 zcmeHO$x7@%6iqvevtp~e=T2~=f1qyMiC-Y1?%cQ$1Vx?*A}(Ag2u|&c6XJja4j>{T z&Y-CH1EPX%#DxRim)n@4l1?S*blX(&swCY>)vd}sr%zJ1ZlWZ4meSCZzqNAf-!2#;|>+4H}&&t8v+#JNj#5kS<#BaCY z{W2~t&hZ?ao}SvJ0M^#lAR!^a(Hzv&)Y!(49k;f&ATcq~F&yOQ=R;v(p~~Os>FJO- z*xlWQq@*O(n0Dvm-|qMJ_U6ah*VjkpbNv0bx3@#$V1IufQc_a5=YR&;-``I-!0UK( zbCYfkdjIh75Yp1pxaL3$3S$~2u0swsHa4^-K^>o*oIplK2DcpOf${l#B(95(kB8OO zRXsDybr@6On&P*wWjo`uK#Y!#5)N=)Us+jUnOds9yu5_$>}>8hFmirkVuHlB$idRm zl99P-+x7J|c)eb(I52a1YHEsbfNR5rg$1)NhV2+1DEAjTdJT6ULuY>)XJ%$de2;Pd z{QSI?ZcLl%>S}H@z;sg6K(BF-gW1_xjfM3?jFZFtJK7!3y4L`mot?yQT=UwE6Q7=* z$T)WW4p+Zm@Z(yaJ}+`GIXM~35PsYH`+LgoVvBqg>*4lqY-wNzGClCfBRMXKJ+XKno2*w{$AV4gHR zJ>8E5=d_`rA=1X$(OyH5!pS-h%TKI7IyxfwuzyoiQ-C?m4{~5MFPxvBbC(+k0Q6I# zUAr|mH?vHEzCMzZli~LEHo*4g<_2Ts>B zjEVUaI**Fjb8>P>yJE+~w+e)Opv_z0vmUF3zOO0y6Vw8)*pYv_?CtFV>R0()+S}Xd zX4C!k^>xy>wzd}T?(QHjFOSqIkgo8n{{R8&+r z9zU*4?3A9K9>-(HT<7=qchK;04d|9nkI0|S=fEDKI(Gt{{9XvEiGK-a23D9`Hi`r?(S~D_W@pCU*YQN3h + + + + + + + + Doris Manager + + +
+ + diff --git a/frontend/public/locales/en-us.json b/frontend/public/locales/en-us.json new file mode 100644 index 0000000..418dd52 --- /dev/null +++ b/frontend/public/locales/en-us.json @@ -0,0 +1,318 @@ +{ + "username": "Username", + "password": "Password", + "OldPassword":"Old Password", + "PleaseEnterTheOldPassword":"Please enter the old password", + "NewPassword":"New Password", + "ContainAtLeast2Types":"Contain at least 2 types of English uppercase, lowercase English, numbers and special symbols", + "PleaseEnterTheNewPassword":"Please enter the new password", + "NewPasswordCannotBeEmpty":"New password cannot be empty", + "OldPasswordCannotBeEmpty":"Old password cannot be empty", + "ConfirmPassword":"Confirm Password", + "ConfirmPasswordCanNotBeBlank":"Confirm password can not be blank!", + "TwoPasswordEntriesAreInconsistent":"Two password entries are inconsistent", + "PleaseConfirmYourNewPassword":"Please confirm your new password", + "Least6":"The password must be at least 6 characters in length", + "Save":"save", + "signOut": "Sign out", + "loginWarning":"Incorrect username or password", + + "exitSuccessfully":"Exit successfully", + "editor": "Editor", + "format": "Format", + "clear": "Clear the editor", + "execute": "Execute", + "tableStructure": "Table Schema", + "dataPreview": "Data Preview", + "queryForm": "Query", + "runSuccessfully": "Run successfully", + "results": "Results", + "selectTheFile": "Select File", + "loadConfig": "Configurations", + "previous": "Previous", + "next": "Next", + "cancel": "Cancel", + "dataImport": "Data Import", + "table": "Table", + "delimiter": "Column Separator", + "importResults": "Import Results", + "errMsg":"Error request, please try again later", + "display10":"Display up to 10 lines", + "refresh":"Refresh", + "startingTime":"Starting Time", + "endTime":"End Time", + "currentDatabase":"Current Database", + "executionFailed":"Execution failed", + "uploadWarning":"Please select file", + "upload": "Upload", + "delimiterWarning":"Please select a separator", + "uploadedFiles":"Uploaded Files", + "filePreview":"File Preview", + "database":"Database", + "notice":"Notice", + "login":"Login", + "Mail":"Mail", + "loginExpirtMsg":"The login information has expired. Click OK to jump to the login page.", + "nextStep":"Next", + "previousStep":"Previous", + "pleaseEnter":"Please enter", + "cancelButton":"Cancel", + "loadButton":"Import", + "successfulOperation":"The operation was successful", + "tips":"Tips", + "fileSizeWarning": "File size cannot exceed 100M", + "selectWarning": "Please select a table", + "executionTime": "Execution Time", + "search":"Search", + + "noData":"No data", + "data": "Data", + "queryProfileFirst":"please open Query Profile,then you can see the result list", + "namespace": "Current space name", + "backTo": "Back to", + + "viewTime": "View time", + "clusterInformation": "Cluster Information", + "dataMonitoring": "Data Monitoring", + "QPS": "QPS (queries per second)", + "99th": "99th percentile query delay (ms)", + "queryErrorRate": "Query error rate", + "numberOfConnections": "Number of connections", + "importRate": "Import transaction commit / success / failure frequency", + "clusterScheduling":"Cluster scheduling", + "unhealthyFragmentation":"Unhealthy fragmentation", + + "numberOfFeNodes":"Number of Fe nodes", + "FeNumberOfSurvivingNodes":"Fe number of surviving nodes", + "NumberOfBENodes":"Number of be nodes", + "BeNumberOfSurvivingNodes":"Fe number of surviving nodes", + "totalSpace":"Total space", + "usedSpace":"used space", + "CPUidleRate":"CPU idle rate (%)", + "nodeMemoryUsage":"Node memory usage (MB)", + "PleaseSelectNode":"Please select node", + "IOutilization":"IO utilization (%)", + "nodeSelection":"Node selection", + + "required":"The field is required", + "confirmPassword": "Confirm password", + "inconsistentPasswords":"Inconsistent passwords", + + "spaceInfo":"Space Information", + "clusterInfo":"cluster Information", + "spaceName":"Space Name", + "spaceIntroduction":"Space Introduction", + "adminName":"Admin Name", + "adminEmail":"Admin Email", + "adminpsw":"Admin Password", + "clusterAddr":"Cluster Address", + "httpPort":"HTTP Port", + "JDBCPort":"JDBC Port", + "userName":"User Name", + "userPwd":"User Password", + "linkTest":"Link Test", + "submit":"Submit", + "BENodeStatusMonitoring":"BE Node status monitoring", + + "accountSettings":"Account Settings", + "Logout":"Logout", + "Help":"Help", + "IncrementalDataVersionConsolidation":"Incremental data version consolidation", + + "SelectOnlyTheCurrentPage":"Select only the current page", + "SuccessfullyModified":"Successfully modified!", + "PleaseCheckTheConfigurationYouWantToModify":"Please check the configuration you want to modify!", + "operate":"Operate", + "edit":"Edit", + "ConfigurationItem":"Configuration item", + "SearchConfigurationItems":"Search configuration items", + "Node":"Node", + "SearchNode":"Search node", + "CurrentlySelected":"Currently selected", + "StripData":"data", + "BatchEditing":"Batch editing", + "EditConfigurationItems":"Edit configuration items", + "total":"A total of ", + "strip":"", + "ConfigurationValue":"Configuration value", + "PleaseEnterTheConfigurationValue":"Please enter the configuration value", + "EffectiveState":"Effective state", + "PleaseSelectAnEffectiveState":"Please select an effective state", + "TemporarilyEffective":"Temporarily effective", + "TemporarilyEffectiveTooltip":"Temporarily effective means that it only takes effect at the current time, and no longer takes effect after the node is restarted, and the previous configuration value is restored.", + "Permanent":"Permanent", + "PermanentTooltip":"Permanently effective means permanent effective after modification", + "Error":"Error", + "ConfigurationError":"The following configuration value modification failed, please check the editing content and corresponding node status", + "FailToEdit":"fail to edit", + + "PleaseCheckTheConfiguration":"Please check the configuration you want to modify!", + "ForgetThePassword":"I seem to have forgotten my password", + "SignIn":"Sign in", + "Successfully":"The account information has been modified successfully!", + "PasswordResetComplete":"Password reset complete!", + "AccountSetting":"Account setting", + "AccountInformation":"Account information", + "Name":"Name", + "PleaseTypeInYourName":"Please type in your name", + "pleaseInputYourEmail":"Please input your email", + "update":"Update", + "Details":"Details", + + "updateDoris": "Please Update Doris Cluster", + "httpInfo": "HTTP Connected Info", + "JDBCInfo": "JDBC Connected Info", + + "DatabaseNum": "Database Number", + "DataTableNum": "Data Table Number", + "DiskUsage": "Disk Usage", + "RemainingDiskSpace": "Remaining Disk Space", + "dataWarehouse": "Data Warehouse", + "ClusterIinformationOverview": "Cluster information overview", + "ConnectionInformation": "Connection information", + + "BasicInformationOfDatabase": "Basic information of database", + "DatabaseName": "Database name", + "DatabaseDescriptionInformation": "Database description information", + "CreationTime": "Creation time", + "TableType": "Table type (data model)", + "TableStructure": "Table structure", + "loading":"loading", + + "clusterOverview": "Cluster Overview", + "nodeList": "Node List", + "parameterConf": "Parameter Conf", + + "clusterId": "Cluster ID", + "normal": "normal", + "abnormal": "abnormal", + "clusterVersion": "Cluster Version", + "cpuUsage": "CPU Usage", + "ramUsage": "RAM Usage", + "diskUsage": "Disk Usage", + "start": "start", + "stop": "stop", + "restart": "restart", + "sourceUsage": "Source Usage", + "dataOverview": "Data Overview", + + "nodeId": "Node ID", + "nodeType": "Node Type", + "hostIp": "Host IP", + "nodeStatus": "Node Status", + "nodeConf": "Node Conf", + "nodeVersion": "Node Version", + + "paramSearch": "Param Search", + "paramName": "Param Name", + "paramType": "Param Type", + "paramImplication": "Param Implication", + "paramValueType": "Param Value Type", + "hot": "Hot", + "operation": "Operation", + "yes": "yes", + "no": "no", + "viewCurrentValue": "View Current Value", + "currentValue": "Current Value", + "confValue": "Conf Value", + "confValuePlaceholder": "Please fill in the configuration value", + "effectiveWay": "Effective Way", + "permanentEffective": "Permanent", + "onceEffective": "Effective This Time", + "effectiveWayRequiredMessage": "Please select the effective way", + "effectiveRange": "Effective Range", + "effectiveRangeRequiredMessage": "Please select the effective range", + "allNodes": "All Nodes", + "certainNodes": "Certain Nodes", + "effectiveNodes": "Effective Nodes", + "effectiveNodesPlaceholder": "Please select the effective nodes", + + "userManagement": "User Management", + "addUser": "Add User", + "editUser": "Edit User", + "status": "Status", + "Status": "Status", + "superAdministrator": "Super Administrator", + "lastLogin": "Last Login", + "activated": "Activated", + "deactivated": "Deactivated", + "neverLoggedIn": "Never Logged in", + "resetPassword": "Reset Password", + "activateUser": "Activate User", + "deactivateUser": "Deactivate User", + "resetPasswordOrNot": "Whether to reset the account password?", + "pleaseSaveYourPassword": "Please save your password.", + "copySuccess": "Copy successfully.", + "copyError": "Copy failed.", + "resetPasswordFailed": "Reset password failed.", + "setupFailed": "Setup failed.", + "setupSuccess": "Setup successfully.", + "whetherToActivate": "Whether to activate", + "whetherToDeactivate": "Whether to deactivate", + "afterActivate": "After activating,", + "afterDeactivate": "After deactivating,", + "canNotLogin": "can not log in.", + "canLoginAgain": "can log in again.", + "createSuccess": "Create successfully.", + "editSuccess": "Edit successfully.", + "pleaseInputUsername": "Please input the username.", + "usernameLengthMessage": "Username length should be less than 20.", + "usernamePatternMessage": "Username can only contain uppercase and lowercase letters and numbers.", + "pleaseInputEmail": "Please input the user email.", + "emailTypeMessage": "Please input the email address in correct format.", + + "pleaseSelectUsers": "Please select users.", + "members": "Members", + "roles": "Roles", + "removeMember": "Remove Member", + "removeMemberModalTitle": "Are you sure you want to remove this member from the space?", + "removeSuccess": "Remove successfully.", + "removeFailed": "Remove failed.", + "addMembers": "Add Members", + "users": "Users", + "addSuccess": "Add successfully", + "addFailed": "Add failed", + "fetchUserListFailed": "Fetch user list failed.", + "fetchSpaceMemberListFailed": "Fetch space member list failed.", + + "roleName": "Role Name", + "deleteThisRole": "Delete this role", + "deleteThisRoleMessage": "Sure? All members of the role will lose the permission settings under the role. This operation is irreversible.", + "roleTopMessage": "Member permissions are managed through roles. Space Admin and Space Member are default roles and cannot be deleted.", + "create": "Create", + "createRole": "Create Role", + "editRole": "Edit Role", + "pleaseInputRoleName": "Please input the role name.", + "roleNameLengthMessage": "The role name length is 1-20.", + "roleNamePatternMessage": "Role names can only contain letters, numbers, Chinese, and underscores.", + "remove": "Remove", + "removeFromRoleMembers": "Remove from role members.", + "removeFromRoleMembersMessage": "Sure? The user will lose the permission settings under the role. This operation is irreversible.", + "Copy Successfully": "Copy Successfully", + "Platform Settings": "Platform Settings", + "Space List": "Space List", + "Finished": "Finished", + "Not Finished": "Not Finished", + "Space Name": "Space Name", + "Cluster": "Cluster", + "Query": "Query", + "Space Manager": "Space Manager", + "About": "About", + "Born for data analysis": "Born for data analysis", + "Contact": "Contact", + "Current Version": "Current Version", + "Notice": "Notice", + "New Cluster": "New Cluster", + "New Space": "New Space", + "Creator": "Creator", + "Actions": "Actions", + "Enter Space": "Enter Space", + "Delete": "Delete", + "Edit": "Edit", + "Cluster hosting": "Cluster hosting", + "Recover": "Recover", + "Space Register": "Space Register", + "Please Select Users...": "Please Select Users...", + "NewPasswordLengthRuleMessage": "Password length is 6-12", + "NewPasswordPatternRuleMessage": "Passwords only support uppercase letters, lowercase letters, numbers and underscores, including at least three types." +} diff --git a/frontend/public/locales/zh-cn.json b/frontend/public/locales/zh-cn.json new file mode 100644 index 0000000..a4b3a83 --- /dev/null +++ b/frontend/public/locales/zh-cn.json @@ -0,0 +1,332 @@ +{ + "username": "用户名", + "password": "密码", + "OldPassword":"旧密码", + "PleaseEnterTheOldPassword":"请输入旧密码", + "NewPassword":"新密码", + "ContainAtLeast2Types":"至少包含英文大写、英文小写、数字和特殊符号中的2种", + "PleaseEnterTheNewPassword":"请输入新密码", + "NewPasswordCannotBeEmpty":"新密码不能为空", + "OldPasswordCannotBeEmpty":"旧密码不能为空", + "Least6":"密码长度至少6个字符", + "ConfirmPassword":"确认密码", + "ConfirmPasswordCanNotBeBlank":"确认密码不能为空!", + "TwoPasswordEntriesAreInconsistent":"两次密码输入不一致", + "PleaseConfirmYourNewPassword":"请确认您新的密码", + "Save":"保存", + "exitSuccessfully": "退出成功", + "loginWarning":"账号或密码错误", + + "editor": "编辑器", + "format": "格式化", + "clear": "清空编辑器", + "execute": "执行", + "tableStructure": "表结构", + "dataPreview": "数据预览", + "queryForm": "查询", + "runSuccessfully": "运行成功", + "results": "执行结果", + "selectTheFile": "文件选择", + "loadConfig": "导入配置", + "previous": "上一步", + "next": "下一步", + "cancel": "取消", + "dataImport": "数据导入", + "table": "表", + "delimiter": "列分隔符", + "importResults": "导入结果", + "errMsg": "请求出错,请稍后重试", + "display10": "最多显示10行", + "refresh": "刷新", + "startingTime": "开始时间", + "endTime":"结束时间", + "currentDatabase": "当前数据库", + "executionFailed": "上传失败", + "uploadWarning": "请选择文件", + "upload": "上传", + "delimiterWarning": "请选择分隔符", + "uploadedFiles": "已上传文件列表", + "filePreview": "文件预览", + "database": "数据库", + "notice": "注意", + "login": "登录", + "Mail":"邮箱", + "loginExpirtMsg": "登录信息已过期。点击确定跳转到登陆页面。", + "nextStep": "下一步", + "previousStep": "上一步", + "pleaseEnter": "请输入", + "cancelButton": "取消", + "loadButton": "导入", + "successfulOperation": "操作成功", + "tips": "提示", + "fileSizeWarning": "文件大小不能超过100m", + "selectWarning": "请选择表", + "executionTime": "执行时间", + "search":"查询", + + "noData":"暂无数据", + "data": "数据", + "queryProfileFirst":"请先开启集群Query Profile,方可查看查询列表", + "namespace": "当前空间名称", + "backTo": "返回", + + + "viewTime": "查看时间", + "clusterInformation": "集群信息", + "dataMonitoring": "数据监控", + "QPS": "QPS(每秒查询次数)", + "99th": "99分位查询延迟(ms)", + "queryErrorRate": "查询错误率", + "numberOfConnections": "连接数", + "importRate": "导入事务提交/成功/失败频率", + "clusterScheduling":"集群调度情况", + "unhealthyFragmentation":"不健康分片", + + "numberOfFeNodes":"FE节点数", + "FeNumberOfSurvivingNodes":"FE存活节点数", + "NumberOfBENodes":"BE节点数", + "BeNumberOfSurvivingNodes":"BE存活节点数", + "totalSpace":"总空间", + "usedSpace":"已用空间", + + "CPUidleRate":"CPU空闲率(%)", + "nodeMemoryUsage":"节点内存使用量(MB)", + "IOutilization":"节点内存使用量(MB)", + "BaselineDataVersionConsolidation":"基线数据版本合并情况", + "IncrementalDataVersionConsolidation":"增量数据版本合并情况", + "PleaseSelectNode":"请选择节点", + "nodeSelection":"节点选择", + "BENodeStatusMonitoring":"BE节点监控", + + "accountSettings":"账户设置", + "Logout":"注销", + "Help":"帮助", + + "SelectOnlyTheCurrentPage":"只选当前页", + "SuccessfullyModified":"修改成功!", + "PleaseCheckTheConfigurationYouWantToModify":"请勾选要修改的配置!", + "operate":"操作", + "edit":"编辑", + "ConfigurationItem":"配置项", + "SearchConfigurationItems":"搜索配置项", + "Node":"节点", + "SearchNode":"搜索节点", + "CurrentlySelected":"当前选中", + "StripData":"数据", + "BatchEditing":"批量编辑", + "EditConfigurationItems":"编辑配置项", + "total":"共", + "strip":"条", + "ConfigurationValue":"配置值", + "PleaseEnterTheConfigurationValue":"请输入配置值", + "EffectiveState":"生效状态", + "PleaseSelectAnEffectiveState":"请选择生效状态", + "TemporarilyEffective":"暂时生效", + "TemporarilyEffectiveTooltip":"暂时生效,指只在当前生效,节点重启后不再生效,恢复修改之前的配置值", + "Permanent":"永久生效", + "PermanentTooltip":"永久生效,指修改后永久生效", + "Error":"错误", + "ConfigurationError":"以下配置值修改失败,请检查编辑内容和对应节点状态", + "FailToEdit":"配置出错", + + "PleaseCheckTheConfiguration":"请勾选要修改的配置!", + "ForgetThePassword":"我好像忘记密码了", + "SignIn":"登录", + "Successfully":"The account information has been modified successfully!", + "PasswordResetComplete":"密码修改成功!", + "AccountSetting":"账户设置", + "AccountInformation":"帐户信息", + "Name":"姓名", + "PleaseTypeInYourName":"请输入姓名", + "pleaseInputYourEmail":"请输入邮箱", + "update":"更新", + "Details":"详情", + + + + "required":"此项必填", + "confirmPassword": "确认密码", + "inconsistentPasswords":"密码不一致", + + + + "spaceInfo":"空间信息", + "clusterInfo":"集群信息", + "spaceName":"空间名称", + "spaceIntroduction":"空间简介", + "adminName":"管理员姓名", + "adminEmail":"管理员邮箱", + "adminpsw":"管理员密码", + "clusterAddr":"集群地址", + "httpPort":"HTTP端口", + "JDBCPort":"JDBC端口", + "userName":"用户名", + "userPwd":"密码", + "linkTest":"链接测试", + "submit":"提交", + "SpaceDeleteTips": "连接空间的集群信息、空间内的文件夹(其中包括查询、仪表盘、定时任务等)、用户及权限信息等将被一并删除。确认删除空间?", + "DeleteSuccessTips": "删除成功", + "Delete Success": "删除成功", + "Failed": "失败", + "Delete": "删除", + "GoBack": "返回", + "NewSpace": "新建空间", + + "updateDoris": "请升级Doris集群!", + "httpInfo": "HTTP连接信息", + "JDBCInfo": "JDBC连接信息", + + "DatabaseNum": "数据库数量", + "DataTableNum": "数据表数量", + "DiskUsage": "磁盘占用量", + "RemainingDiskSpace": "剩余磁盘空间", + "dataWarehouse": "数据仓库", + "ClusterIinformationOverview": "集群信息概览", + "ConnectionInformation": "连接信息", + + "BasicInformationOfDatabase": "数据库基本信息", + "DatabaseName": "数据库名称", + "DatabaseDescriptionInformation": "数据库描述信息", + "CreationTime": "创建时间", + + "TableType": "表类型(数据模型)", + "TableStructure": "表结构", + "loading":"加载中", + + "clusterOverview": "集群概览", + "nodeList": "节点列表", + "parameterConf": "参数配置", + + "clusterId": "集群ID", + "normal": "正常", + "abnormal": "异常", + "clusterVersion": "集群版本", + "cpuUsage": "CPU 使用率", + "ramUsage": "内存使用率", + "diskUsage": "磁盘使用率", + "start": "启动", + "stop": "停止", + "restart": "重启", + "sourceUsage": "资源使用量", + "dataOverview": "数据概览", + + "nodeId": "节点ID", + "nodeType": "节点类型", + "hostIp": "主机IP", + "nodeStatus": "节点状态", + "nodeConf": "节点配置", + "nodeVersion": "节点版本", + + "paramSearch": "参数搜索", + "paramName": "参数名称", + "paramType": "参数类型", + "paramImplication": "参数含义", + "paramValueType": "参数值类型", + "hot": "热生效", + "operation": "操作", + "yes": "是", + "no": "否", + "viewCurrentValue": "查看当前值", + "currentValue": "当前值", + "confValue": "配置值", + "confValuePlaceholder": "请填写配置值", + "effectiveWay": "生效方式", + "permanentEffective": "永久生效", + "onceEffective": "本次生效", + "effectiveWayRequiredMessage": "请选择生效方式", + "effectiveRange": "生效范围", + "effectiveRangeRequiredMessage": "请选择生效范围", + "allNodes": "全部节点", + "certainNodes": "部分节点", + "effectiveNodes": "生效节点", + "effectiveNodesPlaceholder": "请选择生效节点", + + "userManagement": "用户管理", + "addUser": "添加用户", + "editUser": "编辑用户", + "status": "状态", + "Status": "状态", + "superAdministrator": "超级管理员", + "lastLogin": "上次登录", + "activated": "启用", + "deactivated": "停用", + "neverLoggedIn": "尚未登录", + "resetPassword": "重置密码", + "activateUser": "激活用户", + "deactivateUser": "停用用户", + "resetPasswordOrNot": "是否重置该账号密码?", + "pleaseSaveYourPassword": "请保存您的密码。", + "copySuccess": "复制成功", + "copyError": "复制失败", + "resetPasswordFailed": "重置密码失败", + "setupFailed": "设置失败", + "setupSuccess": "设置成功", + "whetherToActivate": "是否激活", + "whetherToDeactivate": "是否停用", + "afterActivate": "激活后", + "afterDeactivate": "停用后", + "canNotLogin": "无法登录。", + "canLoginAgain": "可以重新登录。", + "createSuccess": "创建成功", + "editSuccess": "修改成功", + "pleaseInputUsername": "请输入用户名称。", + "usernameLengthMessage": "用户名长度应小于20。", + "usernamePatternMessage": "用户名只能包含大小写字母以及数字", + "pleaseInputEmail": "请输入用户邮箱。", + "emailTypeMessage": "请输入正确的邮箱地址", + + "pleaseSelectUsers": "请选择用户。", + "members": "成员", + "roles": "角色", + "removeMember": "移除成员", + "removeMemberModalTitle": "确定移除此成员至空间外?", + "removeSuccess": "移除成功", + "removeFailed": "移除失败", + "addMembers": "添加成员", + "users": "用户", + "addSuccess": "添加成功", + "addFailed": "添加失败", + "fetchUserListFailed": "获取用户列表失败。", + "fetchSpaceMemberListFailed": "获取空间成员列表失败。", + + "roleName": "角色名称", + "deleteThisRole": "删除这个角色", + "deleteThisRoleMessage": "确定吗?该角色所有成员都将丢失该角色下的权限设置。此操作不可逆。", + "roleTopMessage": "通过角色对成员权限进行管理。空间管理员和空间成员是默认角色,无法删除。", + "create": "新建", + "createRole": "创建角色", + "editRole": "编辑角色", + "pleaseInputRoleName": "请输入角色名称。", + "roleNameLengthMessage": "角色名称长度为1-20。", + "roleNamePatternMessage": "角色名称只能包含字母、数字、中文、下划线。", + "remove": "移除", + "removeFromRoleMembers": "从角色成员中移除。", + "removeFromRoleMembersMessage": "确定吗?该用户都将丢失该角色下的权限设置。此操作不可逆。", + "Copy Successfully": "复制成功", + "Platform Settings": "平台设置", + "Space List": "空间列表", + "Finished": "已完成", + "Not Finished": "未完成", + "Space Name": "空间名称", + "Cluster": "集群", + "Query": "查询", + "Space Manager": "空间管理", + "About": "关于", + "Born for data analysis": "为数据分析而生", + "Contact": "联系方式", + "Current Version": "当前版本", + "Notice": "注意", + "New Cluster": "新建集群", + "New Space": "新建空间", + "Creator": "创建人", + "Actions": "操作", + "Enter Space": "进入空间", + "Edit": "编辑", + "Cluster hosting": "集群托管", + "Recover": "恢复", + "Space Register": "注册空间", + "Please Select Users...": "请选择用户...", + "NewPasswordLengthRuleMessage": "密码长度为6-12", + "NewPasswordPatternRuleMessage": "密码仅支持大写字母、小写字母、数字、下划线,至少包含三种" +} + diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx new file mode 100644 index 0000000..b603f7b --- /dev/null +++ b/frontend/src/app.tsx @@ -0,0 +1,42 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React from 'react'; +import { hot } from 'react-hot-loader/root'; +import { BrowserRouter as Router, Switch } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ConfigProvider } from 'antd'; +import zh from 'antd/lib/locale/zh_CN'; +import en from 'antd/lib/locale/en_US'; +import { RecoilRoot } from 'recoil'; +import routes from './routes'; + +const languageMap = { + zh, + en, +}; + +const App = () => { + const { i18n } = useTranslation(); + return ( + + {routes} + + ); +}; + +export default hot(App); diff --git a/frontend/src/assets/404.png b/frontend/src/assets/404.png new file mode 100644 index 0000000000000000000000000000000000000000..c89f60ffb3a5a3ba77c5aeebfa1ff4670fe9d977 GIT binary patch literal 9247 zcmbVyc{tSl`|p@B$o^CaF`_6k_AvH{>gyrwFs^Xh6B@X$s~f-#0x7Eay4l*fps;-RQI1Y%CI02gT7Et!dnJA|8GUhm zH+9qlrU$67ur$Co&`@MF9#? zPd_x))>jnmDe$iZ4V0&yhm#xD34`W4k!X7#Sb{pl1@$#?(^5G!xFEbFk|5?!qAz%%{ z(8CExitR%UjGdP&3XRp)P~r!^f!jOTBd*KIsLR}tm6y4pu5m+LLPGwAgpAw`*&A|F z@>1fmQtJQ8_;0)tYVvB=uFGqvYh06(khmc(r!K9gt|>01AuX+bLq_^PyxM3_tS#CO z^{-whK<_`i>i;7zLfr#pi^X`DU@#B=MS#%*3>M@00OQ7|ZY;xR=7hG#_;{Wq=Wky% zP##X+D0@v0j4R)t=pvl{i}wGw*MIZc|Nlfx3@}FQ1RDPbRQ`Pg;P2$}KfniW{!=K&&^Z%B?pSoiBtEzlDLbAOYZ1%bAL&X?uZ|uV`gB32 z243{^99wVNc{%OnWv%TlbPTN=ZKqvcbuBSLW-d)}%Y0#bpR##(t#?QZDO_0B*(RS_TiADW!(mG)nNs^LQ8Pnz0d~!-Fv2- zb@TiaSJTZ#D)btPR8;MnM5F4La-+Fq$cT;V$z?4ShS2~Sj*@I^{3nq}GV&WjcDNo}QkltW7mFl*C<0Ny)(dcBw9Ge0;ptulWn|=^DnZFES-` z+0xd_R&&e!)R9dxsu4lP>om2^(~6px%#zfTqx%7Ma|*T!VSCkgE<%TVOkZ9u7(Y=F8puaj zW(q;%|!5BLZ)n>vi3yYBlY(5z8O=+5d{1nXR$jVZBQa}KD zy~9Cjqx{*8f-d8!hDCt_D!f8vaC>|EY0 z?kQy`wz#yEqBeaXSrYUKqeMj#C{PcC(sQIeXGjArT^11;>5#EzX0#fs9)0jTv6y-{ zTAIcnzKyLhIpy*;^*kPa5tR3k_4uw^J9vD zsouY(`k9d3Bm`F_IK2hq$jcqnyPOpJ@?Qf3EbOWH(=#3baN9Ym z40d|0__(4Rl)XAn|A!$za;;xLRYkmN#una(P+i|`g4a*t-1b^r55>WsM}1U%Qtrqm<6Pz6cgJ+mJWw<9B7N8dNSEEmwZ-Yh6}GT)DhUBW?C~^KYyqZrAjOB@fJitcCf?IF;B>a;1*e zo5^2pmr?cxt!*TuwQm*{a180DLbe)|Bax}~c6v@@dI3C`=D-_B$WB-VsGMRv2DP`h ze=xrkUGrRsRY_!>a_b8V&&Db2!Rxgz-iitQ0G4#iK!`!9S%sYn07;pW!C3{y;+v9+ z-I;IUQ)>yyd*}R^CM=quGcz*>&#Z!<%Q|NV!E8XbWYm+`zPH6!Tv&Tt*i=x?@im%& z*5OJJGgxO_ih%<@nwefXcbl2#*x!8oZiIfiS47QKA=lU5gCp<1hE>t5h)#m#=!T%5 zElTr^2M4?B$8@hmyuwIquFBOZS53sCK3-{J@KdhP4~_wdH+`tA_lF5v^VB$F>&#oQ zc=Ad{`oM&ch^t4sFVW_AjTSP(pr?X1yy)}88ox`Laob<*ISRcOhYff_x^#QjDz+Sk zrt5Q7w37lc7)3E~hkcEU$ZgJ5MfiZLN`K*}wP@M-wMn-Be z(9@6O=0C=N9m%~jJKiTj%ulb(v|GBVtE(XCw>HDh6}G-%nA{Xp9$f}QRaK1p%_X$64gV+Ri_5GR2pVr-1WHwJc0YC|r=gF~mHWn2c!yu7xtzx>6v zzZgV$NszvL`LZpFtlE*?Kq-{y5)Wo&D|cRv;D3qYn%5k0laD!+-zxYl9e*n+8QWS< zJ+9grE%%hdf@=HMw0|~*xx1cste`rHti^ZeSvEGFU&Sch*J$m_;_m}fKQgS8cNrTS zv&6lAE&VYw^LUq=xT9G~k-I2!=hwCFDqFdrYc0xH+U0Dvkx*q*cdxv>B5aL_j=0ajmcemTq z_xhcRWb#1nEp(}Z&Y&RBItU&&+5I3A#>RNO7KckNADP!!2JOvfs-&H1W{G|E>UmC5 z|J18_Dg8m$@(H{iewKs7CLgwNr*l%XIaxG*NO((j+ZByoN(9EYAod~J^xEH_ z;4!JBdRo}QKm;uCE_QXQk>9lbN3-5adpd!bi*PvES8A<-$)5G{;$`D)aMjWdef*`1 zn}?#pl{iGr^GmvKs!E&(gbfQ*z0a0*MRwNWI^M}x`HXoH5_6tsXKTF52x~a0e`Uzq zZNoUOR5Va4qeaRuUNJ+rsY7a?E**6$EI&k6dM{qeCeT(Eba(|Eaer~>*bB>%b-QFe ziuMU_l|{cKfND^dsyaHY7evZHXJ)2NytYSccXxL?!23p*-gE9HtpLSWd&~WyKFiCo z4`gkw6GS&bUyQELbrI1}4jYhwe$of$!_I)VFAhu9+vA4e{$bvih0YyW#0u)~<+avj z+UbT@Y9U?bR=hl@&Qs5TnO8d0fi~mL?c2SJIfxrenD{oYq`48CSUhXakGb0VX@p(V zv-KQ<9_iqq+*am)YN-94jEfMYg&tn_HVl@|FGpQ-7AEvv-uSwua*RadbY@OR_lqJ) z*%_I&;rQ1U_d>v7+lHbtw2yOmI#^E@i5IDF zOxDi3$sH9`R_?>6 zY+%lCTnu^j5wHOTR0Nlmm2r81Z+RHg!a-}$12UOBc=OVQ##w9e(||w~I(RU0F1zN% zD;~>Wa<_GfbDY9yc;YeA%*;&eyfUyL!@_qYW2}O&W<278%`~6Wm?47;wU7&+pNOS6 z=>3|C&Y06S(h$x*{EJu*QwhYUGKgc`9oXnv=8XPAOX>>UZk}Zdh}&!0_vH_G2%z!c z2)_H_zMWkOmg@4HDl4FXg;HEl(3H7{6?K0*4hMzuowSx}9Cf;D`Q~3f{k6m!`T{^t zqm`q!I;S?B}z&>szaR&sN4ye-^!?dYx}M??w)s1BU4SKWT({&`+&j{RBcyFuzPFt<>T;DS|x%mrn-HPXImkLRwH-_+k2e#93M3dPTq z$gKO{t#kBQ#v%$TK?B=g&vLB9rXF0h;oy^ECwUtDHe*xH)l=8Kd2?q6NRXS1(alk% zwPA#x($$G5hVFUhBR5MBZP3GqpCuWzRvjn0*f=;iI-1k(S#kD`0G{T>6Vz#H%5Xe` zjlB5`;`X0^xpk8@zQ-=FvL`&-P_4&ue~ zMB5-5-}JldvyP3M6m6qhv+8fxgZ5^&NZ&6W#G80PK?32!h2)xeQHxJ;3^##uL}}02 zc7b9&vYXWlXQt3laM(+Mmjw2%g$vPH1~SU8aP^_kkzY_7S0=hOCI!1w(3z3}L-HN( zZTujx;2eaEj*hCdU4l`o`Tw25f>?$$JI}5JDMv?B(>SfAi3X_Zi}591KOVt}1=135 zTCjBH1f-%qF;%?pz!jHx&YbyT5U;S4q0C`qZa#lXGup6maS`9qO4oFi$7(RDo;{*L z;aXq&`I(Z&b%afX;gWK+WnBX>)}5lK3q{i?|CU}GHZb!%3bX@GKZugqh|hWTGBxF% zb4VYmtW~w{jzY(>KLzm>zq=+)rueOqipbpOPV5Q->|&ZAW(*AtaWT^mKp+504}7+P zBCfUe5fhoK2WY%b@`vQYzG23=3v%<3+<+ocy>a8Q7H=u@U{OxlbDhf$=d!AhdKU>j zMYoCPMD7FU8m|d~^7xd5^e+r`Ky1uUYC@eFpS}7MD;np5$3EjRRN6EV0OlS>YAE&Y zO<&{16GL1UmV^wFh7M+84ZnT=M>RsPj_?y|#Oh;YM48oxOn~`Xxc-Ue%+l`X)VR2T zDxin+rrc#w8+6-lS?F$=yUIiV#IbsAHpidV+OqEGa6dgXfR?zA$*!2y%yJ}WNsqte zh4Aca&H`pFCKC?9siAgHEw{2M;IcD7%W&V-b&*-%=5-$jX?TdKh0CMnIiM$FnBQS< z9efZ-({J2A9^q1KC{OF>x>yAl6Bpvbb&N!pzUu)(XYTWkqf7QB~ixkhGK)Q>BGn@;Tni5e+>)?G0mu-qeg+ zt~bNL5N%d~)Q{{ZE-xC*sufSb6FL81EdZhY>`CwR3$_-Oy1=ro3b^I^|@r{Bu2EzT@n&X zrJ=kJdDPhVkbdLn)BGyVd&k|~UFdpA%!5Kptb zQ!>Xdu$Yig=I@F6uzR9UXdep#`}O-^un>Sg0n??>M=+_c=_d_;Ft)SF+_#wRcISMeG~BUR__^ zsI1j}QIMuwR#TdjQ#iN!VOsI=D<(jldiYJC=Uz4~nC6zn?502}SLng(x*I|2@2c%a z(*hm^HZN=_IMPv24L+tJU7{)~$E+B4pwl=>hw3@r(lPImWfPhwWq5e0coOFxHRML?vmw7X97g>pt%~`Ta30+utL+|CV$G zh}<vBAW3nM)M3L9N2ErGvm2$x2B!mtTwr@ zA157ro_BB~5`-I&O~Uf>yD=hnKD(tSL8x}RUsx#NJd3+KA7IkdmE!{29U>^EtlscY_lEi~W@-5r9( zuQD(&5Qo1wcIgdF&M^f7BcG9F4d)Xuj;b>0YL!}n2lL9@d%LBfLfeJ{k~3uwe^B4V zd$R6?H$;S=Rb-3-2Cp3!-1gF<$O5TwK0?l$YDbmje1cU?`Mk7&ru^J#^7aesPjptG z%{ks*oSx=F0s_$tzx{9|`x6!vV36u0q+*^u*P$(3a@en!zL|^(UNlI_2d@iM$ zTD`PWwddE*ZCX4qzl0amWnllIp#3R+wIJ7#93m8GQ|g%RM>vy&rdr`E<{5nXkT zW$h_S`z=6zS(21+-uXFg)D~8X?0`#^cRt_k>9m_NJmg)N1R7_cYHYPz5tKEUKk0q4 zr3#HSzZCPSQ>R3ARV+^kYg+VXS>O{95fSBP{q)f{ z=t`7JHi*jG51Vj2ySqLoyFa~?A=rfurs`aV%tHbZaq&!nKx#i7leAR!IQDim#$fk* zskVS&)M>ZVr)GVuxVYF8IIIdVvp511tDb!oE{dvR4n)dcyEeXpsfrJ?W)8}#@ZZcq zLDawX$2M=8QiaH2f-cyk_?;nznXGx@i(5K7mG1x@*&eX-E`ScYpG)A1*4EbIMTCVr ze?}r+3@Zf{956Q1HMPC-TZ{>%$1`i@>U19Eba=4Rc^b7yD+NIWy1Q(TEDpp6T?(oP zu|P2^Jcp?q8M;Nk=O#Au;wUP+)Cy;Z5}Ol(?tCnUt|6rTc&<%Y?DpWY6LPMJd{-Pm z7yfn+<9xyh^GHrEs9I}v=~6j9*lCLw3xe{U3T)R;kVI`K$$JLY3k^-Sw{vzzj*|l( zW>$rZhJ~MfMq%gNvvk-QEUnp8N47BVokGJL?d>VK`T65&LWBA5D{yr52@Rzl;yF7j zPYdkmo85mBU!F#yP`{hIl(t8M4tAPV0xK#j6_^Zyb`BGhlJH;OzqjR9v1yFGvSK0% zOcb!E4~%r8UxrA#PyI;u^71-n=jI-vxy%IH!VhSe8Ri8moz(+Yn!<~l_>|PtmoHa& z(_rU^)=gvUcn$lngOoGcKME1va2hy}gaie@fo_LvULsJSTZ%k}%-CgrB8-I{ zzxz9$dE<2q^|R+oz8gJS+;i;-o`oMid|0N*tK${1IMEK>*cB9@qV#`r9r31&lrB@o zt8wuwjJzBDDtjVVu4I3#sMxDT*B)*O0IMeH)w}AA3nv@N3-)dfpVmHlZf@=%HRi32 zPx^^Eok{Xi%}agPfGvFfh-_9{;R&1}($2TVw6(S>GUC>UHGu(dLVZ{*HCdS|Pef2~ z_E6N~n`Pkkn2*J~ckkGL^(;o^7G!7dE}uFhcuy zlwT@K!zH<8B_&?rru8cR8>Ht|RaF5qv)g^~ne!!mmbqG|Xu&ING;g2A*yJ)YE<@AK zxiMA%Sl=>3`u$!DXW};aYiE0Tw7l>3gk!g1ieiG=G520^9CCjvO=el$=655_OM>&P zOfbu1eXzXw0&aKhH!s-E#m;WJJUpCcymgVoq7+@dyt6uegOG34SKQRalBA!id}ySt zeQFu@>z7+iO>)%Z)+fV$hx3)j)tqiJVL#U|#q*4o5UKVe3KC__c3IyyQYbBH06 z^wRW9cBO#nv1H7>nMaE^=wMY~etv$Al-l_hNddYxMJmz&1A+VYS}wqSzj#zK-v2{- z`pD1uQoUrvruyEX1rb=zpQUTX%vzPX*%k|TraF#fWMm}4g@vc46?Kn)Bx;qK-MQmG zv;J0B_A(LZtEg8J>$((JiUek@o}>Van{0K;)HhVyn>XfP91fpP1}%-2&sZPt5fbw9 z@)Cj$7p(iTQIGb{X+?AW_C_Sn#m2@OqEM)0_?0V@xi8xK5!qHbIXNC}15agVsQhnU zf_k0t)S+(kKD+GmGZDNzRAd((8nONJ!;PqWkY7Xheg&VG_`q^d+??ejFxMq`ss4dM zpb{`DoK6OvKFNl**{LEt{{+zsIP_Shwys5kn}7VkcJFwv;Aj-8ms7-TzwgfXWu_V$ z8XhMdl}8ObGVTJPGbopQc{^I#)W<0Fb1DJ1MJb literal 0 HcmV?d00001 diff --git a/frontend/src/assets/background.jpg b/frontend/src/assets/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..501e90046c455020fc4da6c1c1bb682fd7d4f259 GIT binary patch literal 118647 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%0D>qsi?a>OwY6EEmN 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 !~Whto2MVh58`TF1aQlrV? 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 P00000NkvXXu0mjfLUe79 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/logo_manager.png b/frontend/src/assets/logo_manager.png new file mode 100644 index 0000000000000000000000000000000000000000..f25d9e996e528820f9a2525957c19d192ae3690b GIT binary patch literal 13091 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 Thx^|@`jP->OM45N87}4D9->F^ literal 0 HcmV?d00001 diff --git a/frontend/src/assets/space/u919.png b/frontend/src/assets/space/u919.png new file mode 100644 index 0000000000000000000000000000000000000000..7a5413bf5fe05f1a3020fd7ae24864aefad0b740 GIT binary patch literal 5850 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/api/permissions/group/${data.roleId}`); +} + +function getRoles() { + return http.get('/api/permissions/group'); +} + +function getUsers(params?: { include_deactivated: boolean }) { + return http.get('/api/v2/user/', params); +} + +function getSpaceUsers() { + return http.get('/api/v2/user/space'); +} + +export const CommonAPI = { + getUserInfo, + getRoleMembersById, + getRoles, + getUsers, + getSpaceUsers, +}; diff --git a/frontend/src/common/common.context.ts b/frontend/src/common/common.context.ts new file mode 100644 index 0000000..895ac1a --- /dev/null +++ b/frontend/src/common/common.context.ts @@ -0,0 +1,5 @@ +import React from 'react'; +import { UserInfo } from './common.interface'; + +export const UserInfoContext = React.createContext(null); +export const NewSpaceInfoContext = React.createContext(null); diff --git a/frontend/src/common/common.data.ts b/frontend/src/common/common.data.ts new file mode 100644 index 0000000..ac9f96f --- /dev/null +++ b/frontend/src/common/common.data.ts @@ -0,0 +1,102 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export enum FieldTypeEnum { + TINYINT = 'TINYINT', + SMALLINT = 'SMALLINT', + INT = 'INT', + BIGINT = 'BIGINT', + LARGEINT = 'LARGEINT', + BOOLEAN = 'BOOLEAN', + FLOAT = 'FLOAT', + DOUBLE = 'DOUBLE', + DECIMAL = 'DECIMAL', + DATE = 'DATE', + DATETIME = 'DATETIME', + CHAR = 'CHAR', + VARCHAR = 'VARCHAR', + // HLL = 'HLL', + BITMAP = 'BITMAP', +} +export enum ConfigurationTypeEnum { + FE = 'fe', + BE = 'be', +} +export enum TableTypeEnum { + PRIMARY_KEYS = 'PRIMARY_KEYS', + UNIQUE_KEYS = 'UNIQUE_KEYS', + DUP_KEYS = 'DUP_KEYS', + AGG_KEYS = 'AGG_KEYS', +} + + +export const TABLE_TYPE_KEYS = [ + { + value: TableTypeEnum.PRIMARY_KEYS, + text: '', + }, + { + value: TableTypeEnum.UNIQUE_KEYS, + text: '主键唯一表', + }, + { + value: TableTypeEnum.DUP_KEYS, + text: '明细表', + }, + { + value: TableTypeEnum.AGG_KEYS, + text: '聚合表', + }, +]; + +export const FIELD_TYPES: string[] = []; +for (const fieldType in FieldTypeEnum) { + if (typeof fieldType !== 'number') { + FIELD_TYPES.push(fieldType); + } +} + +// 首列不能是以下类型 +const FIRST_COLUMN_FIELD_TYPE_CANNOT_BE = [ + FieldTypeEnum.BITMAP, + // FieldTypeEnum.HLL, + FieldTypeEnum.FLOAT, + FieldTypeEnum.DOUBLE, +]; +// 分桶列必须是以下类型 +export const BUCKET_MUST_BE = [ + FieldTypeEnum.VARCHAR, + FieldTypeEnum.BIGINT, + FieldTypeEnum.INT, + FieldTypeEnum.LARGEINT, + FieldTypeEnum.SMALLINT, + FieldTypeEnum.TINYINT, +]; + +export const FIRST_COLUMN_FIELD_TYPES = FIELD_TYPES.filter( + (field: any) => !FIRST_COLUMN_FIELD_TYPE_CANNOT_BE.includes(field), +); + +export const ANALYTICS_URL = '/login'; +export const STUDIO_INDEX_URL = `/meta/index`; //待确认 +export const MANAGE_INDEX_URL = `/super-admin/space/list`; + + +export enum AuthTypeEnum { + LDAP = 'ldap', + STUDIO = 'studio', +} \ No newline at end of file diff --git a/frontend/src/common/common.interface.ts b/frontend/src/common/common.interface.ts new file mode 100644 index 0000000..64d7ed0 --- /dev/null +++ b/frontend/src/common/common.interface.ts @@ -0,0 +1,34 @@ +export interface UserInfo { + email: string; + name: string; + authType: string; + id: number; + collectionId: number; + ldap_auth: boolean; + last_login: Date; + updated_at: Date; + group_ids: number[]; + date_joined: Date; + common_name: string; + google_auth: boolean; + space_id: number; + space_complete: boolean; + deploy_type: string; + manager_enable: boolean; + is_active: boolean; + is_admin: boolean; + is_qbnewb: boolean; + is_super_admin: boolean; +} + +export interface IRole { + id: number; + member_count: number; + name: string; +} + +export interface IMember { + name: string; + members: any[]; + id: number; +} \ No newline at end of file diff --git a/frontend/src/components/clipped-text/clipped-text.tsx b/frontend/src/components/clipped-text/clipped-text.tsx new file mode 100644 index 0000000..d0ed37d --- /dev/null +++ b/frontend/src/components/clipped-text/clipped-text.tsx @@ -0,0 +1,72 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { Tooltip } from 'antd'; +import React, { useRef, useEffect, useState } from 'react'; +import CopyText from '../copy-text'; + +export function ClippedText(props: any) { + const refClip = useRef(null); + const [isShowTip, setShowTip] = useState(false); + let tempWidth = props.width; + if (props.inTable && typeof props.width === 'number') { + tempWidth = props.width - 40; + } + let width; + if (props.width) { + width = typeof props.width === 'number' ? `${tempWidth}px` : props.width; + } else { + width = 'calc(100% - 10px)'; + } + const title = props.text ? props.text : props.children; + + useEffect(() => { + const elem = refClip.current; + if (elem) { + if (elem.scrollWidth > elem.clientWidth) { + setShowTip(true); + } + } + }, []); + + const content = ( +
+ {props.children} +
+ ); + + return isShowTip ? ( + + + {content} + + + ) : ( + content + ); +} diff --git a/frontend/src/components/common-header/header.api.ts b/frontend/src/components/common-header/header.api.ts new file mode 100644 index 0000000..446e897 --- /dev/null +++ b/frontend/src/components/common-header/header.api.ts @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ +import { http } from '@src/utils/http'; +// import { GetDatabaseInfoByDbIdRequestParams, GetDatabaseRequestParams } from './header.interface'; + +function refreshData() { + return http.post(`/api/meta/sync`, {}); +} + +export const HeaderAPI = { + refreshData, +}; diff --git a/frontend/src/components/common-header/header.interface.ts b/frontend/src/components/common-header/header.interface.ts new file mode 100644 index 0000000..cdc11f4 --- /dev/null +++ b/frontend/src/components/common-header/header.interface.ts @@ -0,0 +1,23 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ +export interface HeaderProps { + title: string; + icon: any; + callback: any; +} diff --git a/frontend/src/components/common-header/header.module.less b/frontend/src/components/common-header/header.module.less new file mode 100644 index 0000000..9f2e764 --- /dev/null +++ b/frontend/src/components/common-header/header.module.less @@ -0,0 +1,48 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +.common-header { + position: relative; + // height: 64px; + padding: 15px 38px; + // border-bottom: 1px solid #f0f0f0; + .common-header-title { + // font-size: 30px; + color: #555; + + .common-header-icon { + font-size: 40px; + } + + .common-header-name { + padding-left: 10px; + font-size: 30px; + font-weight: 600; + vertical-align: super; + } + } + + .common-header-refresh { + position: absolute; + top: 25px; + right: 25px; + font-size: 20px; + color: @primary-color; + } +} diff --git a/frontend/src/components/common-header/header.tsx b/frontend/src/components/common-header/header.tsx new file mode 100644 index 0000000..2fc9f81 --- /dev/null +++ b/frontend/src/components/common-header/header.tsx @@ -0,0 +1,61 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +import React, { useState, useCallback, useEffect } from 'react'; +import styles from './header.module.less'; +import { HeaderProps } from './header.interface'; +import { SyncOutlined } from '@ant-design/icons'; +import { HeaderAPI } from './header.api'; +import CSSModules from 'react-css-modules'; +const EventEmitter = require('events').EventEmitter; +const event = new EventEmitter(); + +export function Header(props: HeaderProps) { + const [loading, setLoading] = useState(false); + // useEffect(() => { + // HeaderAPI.refreshData(); + // }, []); + function refresh() { + HeaderAPI.refreshData(); + event.emit('refreshData'); + props.callback(); + setTimeout(() => { + setLoading(false); + }, 300); + } + return ( +
+
+ {props.icon} + {props.title} +
+
+ { + refresh(); + setLoading(true); + }} + /> +
+
+ ); +} + +export const CommonHeader = CSSModules(styles)(Header); \ No newline at end of file diff --git a/frontend/src/components/copy-text/index.less b/frontend/src/components/copy-text/index.less new file mode 100644 index 0000000..f80c7fd --- /dev/null +++ b/frontend/src/components/copy-text/index.less @@ -0,0 +1,34 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +@import '~antd/lib/style/themes/default.less'; + +.copy-wrap { + display: flex; + align-items: center; + width: 100%; +} + +.copy-icon { + margin-left: 10px; + font-size: 16px; + cursor: pointer; + + &:hover { + color: @primary-color; + } +} diff --git a/frontend/src/components/copy-text/index.tsx b/frontend/src/components/copy-text/index.tsx new file mode 100644 index 0000000..18b55d6 --- /dev/null +++ b/frontend/src/components/copy-text/index.tsx @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React from 'react'; +import { CopyOutlined } from '@ant-design/icons'; +import { message } from 'antd'; +import './index.less'; +import { useTranslation } from 'react-i18next'; + +export default function CopyText(props: any) { + const { t } = useTranslation(); + function handleCopy() { + const input = document.createElement('input'); + input.style.opacity = '0'; + input.setAttribute('readonly', 'readonly'); + input.setAttribute('value', props.text); + document.body.appendChild(input); + input.setSelectionRange(0, 9999); + input.select(); + if (document.execCommand('copy')) { + document.execCommand('copy'); + message.success(t`Copy Successfully`); + } + document.body.removeChild(input); + } + return ( +
+ {props.children} + +
+ ); +} diff --git a/frontend/src/components/doris-modal/doris-modal.jsx b/frontend/src/components/doris-modal/doris-modal.jsx new file mode 100644 index 0000000..5a237e9 --- /dev/null +++ b/frontend/src/components/doris-modal/doris-modal.jsx @@ -0,0 +1,41 @@ +import Swal from 'sweetalert2'; +import withReactContent from 'sweetalert2-react-content'; + +const DorisSwal = withReactContent(Swal); + +class DorisModal { + message(message) { + DorisSwal.fire(message); + } + + success(title, message = '') { + return Swal.fire(title, message, 'success'); + } + + error(title = '', message = '') { + return Swal.fire(title, message, 'error'); + } + + confirm(title, message = '', callback) { + Swal.fire({ + title: title, + text: message, + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: '确定', + cancelButtonText: '取消', + }).then(async result => { + if (result.isConfirmed && typeof callback === 'function') { + callback(); + } else if (!result.isConfirmed) { + Swal.close(); + } else { + this.success('操作成功'); + } + }); + } +} + +export const modal = new DorisModal(); diff --git a/frontend/src/components/dot/dot.less b/frontend/src/components/dot/dot.less new file mode 100644 index 0000000..f36e7d9 --- /dev/null +++ b/frontend/src/components/dot/dot.less @@ -0,0 +1,6 @@ +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: @primary-color; +} \ No newline at end of file diff --git a/frontend/src/components/dot/dot.tsx b/frontend/src/components/dot/dot.tsx new file mode 100644 index 0000000..85c0a8b --- /dev/null +++ b/frontend/src/components/dot/dot.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import styles from "./dot.less"; + +export function Dot() { + return ( +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/flatbtn/flat-btn-group.less b/frontend/src/components/flatbtn/flat-btn-group.less new file mode 100644 index 0000000..9136fda --- /dev/null +++ b/frontend/src/components/flatbtn/flat-btn-group.less @@ -0,0 +1,42 @@ +@import '~antd/lib/style/themes/default.less'; + +.flat-btn-group { + display: inline-block; + + .anticon-down { + margin-left: 5px; + vertical-align: middle; + transition: 0.5s; + } + + &:hover { + .anticon-down { + transition: 0.5s; + transform: rotate(180deg); + } + } + + .flat-btn-more { + font-size: 14px; + color: @primary-color; + cursor: pointer; + } +} + +.flat-menu { + &.ant-dropdown-menu { + min-width: 100px; + + .ant-dropdown-menu-item > a, + .ant-dropdown-menu-submenu-title > a { + color: @primary-color; + } + + .ant-dropdown-menu-item-disabled { + // background-color: #ddd; + .flat-btn--disabled { + color: rgba(0, 0, 0, 0.25); + } + } + } +} diff --git a/frontend/src/components/flatbtn/flat-btn-group.tsx b/frontend/src/components/flatbtn/flat-btn-group.tsx new file mode 100644 index 0000000..ac73f23 --- /dev/null +++ b/frontend/src/components/flatbtn/flat-btn-group.tsx @@ -0,0 +1,75 @@ +import React, { FunctionComponent, useRef } from 'react'; +import { DownOutlined } from '@ant-design/icons'; +import { Menu, Dropdown, Divider } from 'antd'; +import './flat-btn-group.less'; + +interface FlatItemProps { + children?: React.ReactNode[]; + showNum?: number; +} + +const FlatBtnGroup: FunctionComponent = ({ showNum = 3, children = [] }) => { + let childList: React.ReactNode[] = []; + if (showNum <= 1) { + showNum = 3; + } + if (!Array.isArray(children)) { + childList.push(children); + } else { + childList = children; + } + const validChildren = childList.filter(child => !!child).flat(Infinity); + const newList = validChildren.slice(0, showNum - 1); + const dropList = validChildren.slice(showNum - 1); + + const menu = ( + + {dropList.map((item: any, index) => { + return ( + + {item} + + ); + })} + + ); + + const wrap = useRef(null); + + return ( +
+ ); +}; + +export default FlatBtnGroup; diff --git a/frontend/src/components/flatbtn/flat-btn.tsx b/frontend/src/components/flatbtn/flat-btn.tsx new file mode 100644 index 0000000..9a1aef4 --- /dev/null +++ b/frontend/src/components/flatbtn/flat-btn.tsx @@ -0,0 +1,58 @@ +import React, { HTMLAttributes } from 'react'; +import classNames from 'classnames'; +import './style.less'; +import { Link } from 'react-router-dom'; + +interface FlatBtnProps extends HTMLAttributes { + to?: string; + type?: '' | 'danger' | 'warn'; + disabled?: boolean; + children?: string | JSX.Element; + className?: string; + default?: string; + key?: string | number; + href?: string; + [attr: string]: any; +} + +const FlatBtn = (props: FlatBtnProps) => { + if (props.to) { + return ( + + {props.children} + + ); + } + return ( + { + if (props.disabled) { + e.preventDefault(); + e.stopPropagation(); + } else { + props.onClick && props.onClick(e); + } + }} + className={classNames( + props.className && props.className, + { [`btn-${props.type}`]: props.type }, + { 'flat-btn-disabled': props.disabled }, + { 'flat-btn-default': props.default }, + )} + > + {props.children} + + ); +}; + +export default FlatBtn; diff --git a/frontend/src/components/flatbtn/index.tsx b/frontend/src/components/flatbtn/index.tsx new file mode 100644 index 0000000..e7689b0 --- /dev/null +++ b/frontend/src/components/flatbtn/index.tsx @@ -0,0 +1,5 @@ +import FlatBtn from './flat-btn'; +import FlatBtnGroup from './flat-btn-group'; + +export { FlatBtn }; +export { FlatBtnGroup }; diff --git a/frontend/src/components/flatbtn/style.less b/frontend/src/components/flatbtn/style.less new file mode 100644 index 0000000..cadf887 --- /dev/null +++ b/frontend/src/components/flatbtn/style.less @@ -0,0 +1,47 @@ +.flat-btn { + display: inline-block; + padding-right: 7px; + padding-left: 7px; + text-decoration: none !important; + text-shadow: none; + cursor: pointer; + background: none; + border: none; + border-right: 1px dashed #c4c4c4; + outline: none; + box-shadow: none; + // &.flat-btn--disabled { + // cursor: inherit !important; + // color: gray !important; + // } + &.flat-btn--default { + color: #414d5f; + cursor: not-allowed; + } + + &:first-child { + padding-left: 0; + } + + &:last-child { + border-right: 0; + } + + &.btn-danger { + color: red; + + &:hover { + background-color: inherit !important; + } + } + + &.btn-warning { + color: #f0b64b; + } + + .glyphicon-triangle-bottom { + margin-left: 5px; + font-size: 12px; + color: #666; + } +} diff --git a/frontend/src/components/header/header.api.ts b/frontend/src/components/header/header.api.ts new file mode 100644 index 0000000..5333ee5 --- /dev/null +++ b/frontend/src/components/header/header.api.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/* eslint-disable prettier/prettier */ +/** @format */ + +import { http } from '@src/utils/http'; +function getCurrentUser() { + return http.get(`/api/user/current`); +} + +function getSpaceName(data: any) { + return http.get(`/api/space/${data}`); +} + +function signOut() { + return http.delete(`/api/session/`); +} +export const LayoutAPI = { + getCurrentUser, + getSpaceName, + signOut, +}; diff --git a/frontend/src/components/header/header.tsx b/frontend/src/components/header/header.tsx new file mode 100644 index 0000000..a2c78d3 --- /dev/null +++ b/frontend/src/components/header/header.tsx @@ -0,0 +1,124 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { Menu, Col, Row, Dropdown, Button } from 'antd'; +import {SettingOutlined } from '@ant-design/icons'; +import React, { useContext, useState } from 'react'; +import { LayoutAPI } from './header.api'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import styles from './index.module.less'; +import { UserInfoContext } from '@src/common/common.context'; +import Swal from 'sweetalert2'; +const VERSION = require('../../../package.json').version; + +type HeaderMode = 'normal' | 'initialize' | 'super-admin'; +interface HeaderProps { + mode: HeaderMode; +} + +export function Header(props: HeaderProps) { + const { t, i18n } = useTranslation(); + const history = useHistory(); + const [statisticInfo, setStatisticInfo] = useState({}); + const user = JSON.parse(window.localStorage.getItem('user') as string); + const userInfo = useContext(UserInfoContext); + function getCurrentUser() { + LayoutAPI.getCurrentUser() + .then(res => { + window.localStorage.setItem('user', JSON.stringify(res.data)) + LayoutAPI.getSpaceName(res.data.space_id).then(res1 => { + setStatisticInfo(res1.data || {}); + }) + }) + .catch(err => { + console.log(err); + }); + } + function onAccountSettings() { + history.push( `/user-setting`); + } + function onLogout() { + LayoutAPI.signOut() + .then(res => { + console.log(res) + if (res.code === 0) { + localStorage.removeItem('login'); + history.push(`/login`); + } + }) + + } + const menu = ( + + {t`accountSettings`} + { + Swal.fire({ + width: '480px', + title: `${t`Thanks for using`} Doris Manager!`, + html: `

${t`Current Version`}: ${VERSION}

${t`Contact`}:palo-support@baidu.com

`, + footer: `Doris,${t`Born for data analysis`}`, + imageUrl: '/src/assets/doris.png', + imageHeight: 68, + showConfirmButton: false, + imageAlt: 'Doris Manager', + }); + }} + style={{ padding: '10px 20px' }} + > + {t`About`} Doris Manager +
+ {t`Logout`} +
+ ); + return ( +
+ + {/* { + user && user.is_super_admin ? ( +
+ ) :( + + {t`namespace`}:{(userInfo as UserInfo)?.space_name} + + ) + } */} + + + + e.preventDefault()}> + + + + + +
+ ); +} diff --git a/frontend/src/components/header/index.module.less b/frontend/src/components/header/index.module.less new file mode 100644 index 0000000..538e4eb --- /dev/null +++ b/frontend/src/components/header/index.module.less @@ -0,0 +1,60 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.logo { + width: 10em; + height: 4em; + + background-image: url(../../assets/logo_manager.png); + background-size: 100% 100%; +} + +#components-layout-demo-custom-trigger .trigger { + padding: 0 8px; + font-size: 18px; + line-height: 64px; + cursor: pointer; + transition: color 0.3s; +} + +#components-layout-demo-custom-trigger .trigger:hover { + color: #1890ff; +} + +.site-layout .site-layout-background { + background: #fff; + border-color: #a1a2a2; + border-width: 1px; + border-bottom-style: solid; +} + +.data-builder-header-items { + padding: 8px; + margin-right: 16px; + border-radius: 8px; +} +.userStyle{ + padding: 0; + background: white; + border-bottom: '1px solid #d9d9d9'; +} + +.adminStyle{ + padding: 0; + background: white; + border-bottom: '1px solid #d9d9d9'; +} \ No newline at end of file diff --git a/frontend/src/components/helper/helper.less b/frontend/src/components/helper/helper.less new file mode 100644 index 0000000..b5922eb --- /dev/null +++ b/frontend/src/components/helper/helper.less @@ -0,0 +1,23 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.palo-studio-helper { + font-size: 16px; + color: @primary-color; + text-align: left; + cursor: pointer; +} diff --git a/frontend/src/components/helper/helper.tsx b/frontend/src/components/helper/helper.tsx new file mode 100644 index 0000000..8c4afa7 --- /dev/null +++ b/frontend/src/components/helper/helper.tsx @@ -0,0 +1,30 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { QuestionCircleFilled } from '@ant-design/icons'; +import { Tooltip } from 'antd'; +import React from 'react'; +import { TooltipProps } from 'antd/lib/tooltip'; +import './helper.less'; + +export function Helper(props: TooltipProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/initialized-route/initialized-route.tsx b/frontend/src/components/initialized-route/initialized-route.tsx new file mode 100644 index 0000000..5424c59 --- /dev/null +++ b/frontend/src/components/initialized-route/initialized-route.tsx @@ -0,0 +1,16 @@ +import { auth } from "@src/utils/auth"; +import React from "react"; +import { Redirect, Route } from "react-router"; + +export function InitializedRoute({ children, ...rest }) { + const isPassportLogin = location.pathname.includes("/passport/login"); + return ( + { + if (!auth.checkInitialized()) { + return ; + } else { + return auth.checkLogin() || isPassportLogin ? children : ; + } + }} /> + ) +} diff --git a/frontend/src/components/loading-layout/index.tsx b/frontend/src/components/loading-layout/index.tsx new file mode 100644 index 0000000..4c97957 --- /dev/null +++ b/frontend/src/components/loading-layout/index.tsx @@ -0,0 +1,23 @@ +import React, { CSSProperties, PropsWithChildren } from 'react'; +import { Spin } from 'antd'; + +interface LoadingLayoutProps { + loading?: boolean; + wrapperStyle?: CSSProperties; + tip?: string; +} + +export default function LoadingLayout(props: PropsWithChildren) { + const { loading = false, wrapperStyle = {}, children, tip } = props; + return ( +
+ {loading ? ( +
+ +
+ ) : ( + children + )} +
+ ); +} diff --git a/frontend/src/components/loading/index.tsx b/frontend/src/components/loading/index.tsx new file mode 100644 index 0000000..8b4b73d --- /dev/null +++ b/frontend/src/components/loading/index.tsx @@ -0,0 +1,30 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import './loading.less'; +export const Loading = () => { + const { t } = useTranslation(); + return ( + <> +
+ {t`loading`}... +
+ + ); +}; diff --git a/frontend/src/components/loading/loading.less b/frontend/src/components/loading/loading.less new file mode 100644 index 0000000..a21d47b --- /dev/null +++ b/frontend/src/components/loading/loading.less @@ -0,0 +1,30 @@ +/* Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. */ + +.components-loading { + width: 100vw; + height: 100vh; + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + & span { + font-size: 30px; + color: yellowgreen; + } +} diff --git a/frontend/src/components/loadingwrapper/loadingwrapper.tsx b/frontend/src/components/loadingwrapper/loadingwrapper.tsx new file mode 100644 index 0000000..42f2f5d --- /dev/null +++ b/frontend/src/components/loadingwrapper/loadingwrapper.tsx @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { PropsWithChildren } from 'react'; +import { SpinProps } from 'antd/lib/spin'; +import { Spin } from 'antd'; +import { TABLE_DELAY } from '@src/config'; + +type SpinWithoutSpinningProp = Omit; + +interface LoadingWrapperProps extends SpinWithoutSpinningProp { + loading?: boolean; +} + +export function LoadingWrapper(props: PropsWithChildren) { + let loading = false; + if (props.loading) { + loading = true; + } + const spinProps = { ...props }; + delete spinProps.loading; + return ( + + {props.children} + + ); +} diff --git a/frontend/src/components/metadata/index.tsx b/frontend/src/components/metadata/index.tsx new file mode 100644 index 0000000..8091089 --- /dev/null +++ b/frontend/src/components/metadata/index.tsx @@ -0,0 +1,128 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { useState } from 'react'; +import { Tree } from 'antd'; +const TREE_DATA = [ + { + title: '0-0', + key: '0-0', + children: [ + { + title: '0-0-0', + key: '0-0-0', + children: [ + { + title: '0-0-0-0', + key: '0-0-0-0', + }, + { + title: '0-0-0-1', + key: '0-0-0-1', + }, + { + title: '0-0-0-2', + key: '0-0-0-2', + }, + ], + }, + { + title: '0-0-1', + key: '0-0-1', + children: [ + { + title: '0-0-1-0', + key: '0-0-1-0', + }, + { + title: '0-0-1-1', + key: '0-0-1-1', + }, + { + title: '0-0-1-2', + key: '0-0-1-2', + }, + ], + }, + { + title: '0-0-2', + key: '0-0-2', + }, + ], + }, + { + title: '0-1', + key: '0-1', + children: [ + { + title: '0-1-0-0', + key: '0-1-0-0', + }, + { + title: '0-1-0-1', + key: '0-1-0-1', + }, + { + title: '0-1-0-2', + key: '0-1-0-2', + }, + ], + }, + { + title: '0-2', + key: '0-2', + }, +]; + +export function MetaDataTree() { + const [expandedKeys, setExpandedKeys] = useState(['0-0-0', '0-0-1']); + const [checkedKeys, setCheckedKeys] = useState(['0-0-0']); + const [selectedKeys, setSelectedKeys] = useState([]); + const [autoExpandParent, setAutoExpandParent] = useState(true); + + const onExpand = expandedKeysValue => { + console.log('onExpand', expandedKeysValue); // if not set autoExpandParent to false, if children expanded, parent can not collapse. + // or, you can remove all expanded children keys. + + setExpandedKeys(expandedKeysValue); + setAutoExpandParent(false); + }; + + const onCheck = checkedKeysValue => { + console.log('onCheck', checkedKeysValue); + setCheckedKeys(checkedKeysValue); + }; + + const onSelect = (selectedKeysValue, info) => { + console.log('onSelect', info); + setSelectedKeys(selectedKeysValue); + }; + + return ( + + ); +} diff --git a/frontend/src/components/not-found/index.tsx b/frontend/src/components/not-found/index.tsx new file mode 100644 index 0000000..27b88ef --- /dev/null +++ b/frontend/src/components/not-found/index.tsx @@ -0,0 +1,27 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import * as React from 'react'; +export const NotFound = () => { + return ( + <> +
+ 404 +
+ + ); +}; diff --git a/frontend/src/components/sidebar/sidebar.less b/frontend/src/components/sidebar/sidebar.less new file mode 100644 index 0000000..755c47c --- /dev/null +++ b/frontend/src/components/sidebar/sidebar.less @@ -0,0 +1,47 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.logo { + width: 10em; + height: 4em; + + background-image: url(../../assets/logo_manager.png); + background-size: 100% 100%; +} + +.logo-collapsed { + width: 34px; + height: 34px; + background-image: url(../../assets/doris.png); + background-size: 100% 100%; +} + +.doris-manager-side { + position: fixed; + left: 0; + top: 0; + height: 100%; + z-index: 9999; + background-color: #002140 !important; +} + +.line { + height: 1px; + margin: 14px 24px; + background-color: #fff; + +} diff --git a/frontend/src/components/sidebar/sidebar.tsx b/frontend/src/components/sidebar/sidebar.tsx new file mode 100644 index 0000000..2cebec4 --- /dev/null +++ b/frontend/src/components/sidebar/sidebar.tsx @@ -0,0 +1,169 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { Menu } from 'antd'; +import Sider from 'antd/lib/layout/Sider'; +import { + ClusterOutlined, + ConsoleSqlOutlined, + DatabaseOutlined, + SettingOutlined, + TableOutlined, + AppstoreOutlined, +} from '@ant-design/icons'; +import { Link, useHistory } from 'react-router-dom'; +import React, { useState, useEffect, useContext } from 'react'; + +import { useTranslation } from 'react-i18next'; +import styles from './sidebar.less'; +import { UserInfoContext } from '@src/common/common.context'; + +const GLOBAL_PATHS = ['/settings', '/space']; + +export function Sidebar(props: any) { + const { t } = useTranslation(); + const [selectedKeys, setSelectedKeys] = useState('/dashboard/overview'); + const [collapsed, setCollapsed] = useState(true); + const { mode } = props; + const user = useContext(UserInfoContext); + + const history = useHistory(); + const isSuperAdmin = user?.is_super_admin; + const isSpaceAdmin = user?.is_admin; + const isInSpace = !GLOBAL_PATHS.includes(selectedKeys); + useEffect(() => { + if (history.location.pathname.includes('configuration')) { + setSelectedKeys('/configuration'); + } else if (history.location.pathname.startsWith('/cluster')) { + setSelectedKeys('/cluster'); + } else if (history.location.pathname.startsWith('/space')) { + setSelectedKeys('/space'); + } else if (history.location.pathname.startsWith('/settings')) { + setSelectedKeys('/settings'); + } else if (history.location.pathname.startsWith('/admin')) { + setSelectedKeys('/admin'); + } else { + setSelectedKeys(history.location.pathname); + } + }, [history.location.pathname]); + + function onCollapse() { + setCollapsed(!collapsed); + } + + const handleMenuChange = (e: any) => { + setSelectedKeys(e.key); + }; + + if (mode === 'initialize') { + return ( + + + +
history.push(`/meta/index`)} + /> + +
+
+ ); + } else { + return ( + + + +
+ + {isInSpace && ( + <> + }> + {t`Cluster`} + + }> + {t`data`} + + }> + {t`Query`} + + {(isSuperAdmin || isSpaceAdmin) && ( + }> + {t`Space Manager`} + + )} +
+ + )} + }> + {t`Space List`} + + {isSuperAdmin && ( + }> + {t`Platform Settings`} + + )} +
+
+ ); + } +} diff --git a/frontend/src/components/sidebar/siderbar.data.ts b/frontend/src/components/sidebar/siderbar.data.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/status-mark/index.module.less b/frontend/src/components/status-mark/index.module.less new file mode 100644 index 0000000..444a46b --- /dev/null +++ b/frontend/src/components/status-mark/index.module.less @@ -0,0 +1,25 @@ +.wrapper { + display: inline-flex; + align-items: center; + .mark { + width: 7px; + height: 7px; + border-radius: 50%; + margin-right: 5px; + &.mark-success { + background-color: #0cce0c; + } + &.mark-error { + background-color: red; + } + &.mark-info { + background-color: #91d5ff; + } + &.mark-warning { + background-color: #f7e568; + } + &.mark-deactivated { + background-color: #b7b3b3; + } + } +} diff --git a/frontend/src/components/status-mark/index.tsx b/frontend/src/components/status-mark/index.tsx new file mode 100644 index 0000000..8dd0780 --- /dev/null +++ b/frontend/src/components/status-mark/index.tsx @@ -0,0 +1,22 @@ +import React, { PropsWithChildren } from 'react'; +import classnames from 'classnames'; +import styles from './index.module.less'; + +type Status = 'success' | 'warning' | 'error' | 'info' | 'deactivated'; + +export interface StatusMarkProps { + status: Status; +} + +export default function StatusMark(props: PropsWithChildren) { + const { status, children } = props; + + const markClassName = classnames(styles.mark, styles[`mark-${status}`]); + + return ( +
+
+ {children} +
+ ); +} diff --git a/frontend/src/components/studio-header/header.api.ts b/frontend/src/components/studio-header/header.api.ts new file mode 100644 index 0000000..5333ee5 --- /dev/null +++ b/frontend/src/components/studio-header/header.api.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/* eslint-disable prettier/prettier */ +/** @format */ + +import { http } from '@src/utils/http'; +function getCurrentUser() { + return http.get(`/api/user/current`); +} + +function getSpaceName(data: any) { + return http.get(`/api/space/${data}`); +} + +function signOut() { + return http.delete(`/api/session/`); +} +export const LayoutAPI = { + getCurrentUser, + getSpaceName, + signOut, +}; diff --git a/frontend/src/components/studio-header/header.tsx b/frontend/src/components/studio-header/header.tsx new file mode 100644 index 0000000..48d4bba --- /dev/null +++ b/frontend/src/components/studio-header/header.tsx @@ -0,0 +1,131 @@ +import logo from '@assets/logo_nav.png'; +import React, { useEffect, useMemo, useState } from 'react'; +import styles from './index.module.less'; +// import queryString from 'query-string'; +import { Anchor, Col, Dropdown, Input, Layout, Menu, message, Row, Tooltip } from 'antd'; +import { + AppstoreAddOutlined, + LeftOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { Link, RouteComponentProps, useHistory, useRouteMatch } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { SettingsIcon } from '../settings-icon/settings-icon'; +import { auth } from '@src/utils/auth'; + +type HeaderMode = 'normal' | 'initialize' | 'super-admin'; +interface HeaderProps { + mode: HeaderMode; +} + +export function Header(props: HeaderProps) { + const { t } = useTranslation(); + const history = useHistory(); + // const { q } = queryString.parse(history.location.search) as { q: string }; + const { mode = 'normal' } = props; + const showHeaderFuncs = mode !== 'initialize' && mode !== 'super-admin'; + const isHistoryQueryPage = history.location.pathname.includes('history-query'); + + const initialized = auth.checkInitialized(); + + // const searchBar = useMemo( + // () => ( + // } + // defaultValue={q} + // onPressEnter={val => { + // history.push({ pathname: '/search', search: `?q=${val.target.value}` }); + // }} + // /> + // ), + // [q], + // ); + return ( +
+
{ + history.push(`/space`); + }}> + +
+ {/* {showHeaderFuncs &&
{searchBar}
} */} +
+ {/* {showHeaderFuncs && ( +
+ + { + history.push(`/collection`); + }} + /> + +
+ )} */} + {/*
+ (window.location.href = `${window.location.origin}`)} + /> + 创建查询 +
+
+ + (window.location.href = `${window.location.origin}/new-studio/browse`)} + /> + +
+
+ + (window.location.href = `${window.location.origin}/new-studio/`)} + /> + +
+ {statisticInfo.manager_enable && ( +
+ + (window.location.href = `${window.location.origin}/d-stack`)} + /> + +
+ ) + } +
+ + { + const analyticsUrl = `${window.location.origin}/docs/pages/产品概述/产品介绍.html`; + window.open(analyticsUrl); + }} + /> + +
*/} + {showHeaderFuncs && isHistoryQueryPage && ( +
+ + + (window.location.href = `${window.location.origin}/question#eyJkYXRhc2V0X3F1ZXJ5Ijp7ImRhdGFiYXNlIjpudWxsLCJuYXRpdmUiOnsicXVlcnkiOiIiLCJ0ZW1wbGF0ZS10YWdzIjp7fX0sInR5cGUiOiJOQVRJVkUifSwiZGlzcGxheSI6InRhYmxlIiwidmlzdWFsaXphdGlvbl9zZXR0aW5ncyI6e319`) + } + /> + +
+ )} + {initialized && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/studio-header/index.module.less b/frontend/src/components/studio-header/index.module.less new file mode 100644 index 0000000..a81ef90 --- /dev/null +++ b/frontend/src/components/studio-header/index.module.less @@ -0,0 +1,68 @@ +.palo-header { + width: 100%; + height: 65px; + background: #000; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + padding: 8px 16px; + justify-content: space-between; + + .palo-logo{ + width: 146px; + height: 60px; + margin-right: 8px; + background-image: url('../../assets/logo_nav.png'); + img{ + height: 100%; + } + } + + .palo-search{ + flex: 1; + .search-icon{ + font-size: 16px; + color: #FFF; + } + &>span{ + width: 480px; + height: 50px; + background: rgb(59, 59, 59); + border-radius: 6px; + border: none; + input{ + background: transparent; + color: #fff; + font-size: 1em; + } + } + } + + .palo-opt-box{ + display: flex; + height: 50px; + align-items: center; + div{ + margin-right: 16px; + padding: 10px; + cursor: pointer; + .icon{ + font-size: 20px; + } + .icon-tip{ + margin-left: 0.5rem; + vertical-align: text-bottom; + } + } + div:hover{ + border-radius: 8px; + background: rgb(32,78,171); + } + div:last-child{ + margin-right: 0; + } + } +} + + diff --git a/frontend/src/components/tabs-header/index.tsx b/frontend/src/components/tabs-header/index.tsx new file mode 100644 index 0000000..27000ac --- /dev/null +++ b/frontend/src/components/tabs-header/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { Tabs } from 'antd'; + +const { TabPane } = Tabs; + +interface TabsHeaderProps { + routes: { + label: string; + path: string; + }[]; +} + +export default function TabsHeader(props: TabsHeaderProps) { + const { routes } = props; + const { pathname } = useLocation(); + const history = useHistory(); + + const handleTabChange = (key: string) => { + history.replace(key); + }; + + const findActiveKey = (pathname: string) => { + return routes.find(route => pathname.startsWith(route.path))?.path; + }; + + return ( + + {routes.map(route => ( + + ))} + + ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..442dc5a --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { getDefaultPageSize } from './utils/utils'; +import version from '../version.json'; + +export const TABLE_DELAY = 150; +export const PAGESIZE_OPTIONS = ['10', '20', '50', '100', '200']; +export const PAGINATION = { + current: 1, + pageSize: getDefaultPageSize(), + // total: 0 +}; +export const VERSION = `v${version.version}`; diff --git a/frontend/src/hooks/use-async.ts b/frontend/src/hooks/use-async.ts new file mode 100644 index 0000000..3295e10 --- /dev/null +++ b/frontend/src/hooks/use-async.ts @@ -0,0 +1,90 @@ +import { useReducer, useCallback, useRef, useEffect } from 'react'; +import * as _ from 'lodash-es'; + +export interface State { + loading: boolean; + data: T | undefined; + error: Error | null; +} + +const defaultState = { + loading: false, + data: undefined, + error: null, +}; + +const defaultConfig = { + setStartLoading: true, + setEndLoading: true, +}; + +export type RunFunction = (promise: Promise, config?: Partial) => Promise; + +function useSafeDispatch(dispatch: (...args: T[]) => void) { + const isMountedRef = useRef(false); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + const safeDispatch = useCallback( + (...args: T[]) => { + if (isMountedRef.current) { + dispatch(...args); + } + }, + [dispatch], + ); + return { + safeDispatch, + isMountedRef, + }; +} + +export function useAsync(initialState?: Partial>) { + const finalState = { + ...defaultState, + ...initialState, + }; + const [state, dispatch] = useReducer((state: State, action: Partial>) => ({ ...state, ...action }), { + ...finalState, + }); + + const { safeDispatch, isMountedRef } = useSafeDispatch(dispatch); + + const run = useCallback>( + (promise, config) => { + const finalConfig = { + ...defaultConfig, + ...config, + }; + const { setStartLoading, setEndLoading } = finalConfig; + if (setStartLoading) { + safeDispatch({ loading: true }); + } + return promise + .then(data => { + safeDispatch({ data, error: _.cloneDeep(finalState.error) }); + return data; + }) + .catch(error => { + safeDispatch({ data: _.cloneDeep(finalState.data), error }); + return Promise.reject(error); + }) + .finally(() => { + if (setEndLoading) { + safeDispatch({ loading: false }); + } + }); + }, + [safeDispatch], + ); + + return { + run, + dispatch: safeDispatch, + isMountedRef, + ...state, + }; +} diff --git a/frontend/src/hooks/use-auth.ts b/frontend/src/hooks/use-auth.ts new file mode 100644 index 0000000..69f0ddb --- /dev/null +++ b/frontend/src/hooks/use-auth.ts @@ -0,0 +1,37 @@ +import { AuthTypeEnum } from '@src/common/common.data'; +import { InitializeAPI } from '@src/routes/initialize/initialize.api'; +import { isSuccess } from '@src/utils/http'; +import { useEffect, useState } from 'react'; +import { useHistory } from 'react-router'; + +export function useAuth() { + const [initialized, setInitialized] = useState(false); + const history = useHistory(); + const [initStep, setInitStep] = useState(0); + const [authType, setAuthType] = useState(); + useEffect(() => { + getInitProperties(); + }, []); + + async function getInitProperties() { + const res = await InitializeAPI.getInitProperties(); + if (isSuccess(res)) { + setInitStep(res.data.initStep); + setAuthType(res.data.auth_type); + if (res.data.completed) { + localStorage.setItem('initialized', 'true'); + setInitialized(true); + } else { + localStorage.setItem('initialized', 'false'); + setInitialized(false); + history.push('/initialize'); + } + } + } + return { + initialized, + initStep, + authType, + getInitProperties, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/use-pagination.ts b/frontend/src/hooks/use-pagination.ts new file mode 100644 index 0000000..be6217a --- /dev/null +++ b/frontend/src/hooks/use-pagination.ts @@ -0,0 +1,47 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useState } from 'react'; +import { PAGESIZE_OPTIONS, PAGINATION } from '@src/config'; + +function usePagination(config?: any) { + const defaultConfig = config || PAGINATION; + const [Pagination, setPagination] = useState(defaultConfig); + const PaginationForView = { + ...Pagination, + showQuickJumper: false, + size: 'small', + showSizeChanger: true, + pageSizeOptions: PAGESIZE_OPTIONS, + total: 0, + }; + const PaginationForViewWithoutCurrent = {}; + for (const [key, value] of Object.entries(PaginationForView)) { + if (key !== 'current') { + PaginationForViewWithoutCurrent[key] = value; + } + } + + return { + Pagination, + PaginationForView, + PaginationForViewWithoutCurrent, + setPagination, + }; +} + +export { usePagination }; diff --git a/frontend/src/hooks/use-roles.hooks.ts b/frontend/src/hooks/use-roles.hooks.ts new file mode 100644 index 0000000..515ebc9 --- /dev/null +++ b/frontend/src/hooks/use-roles.hooks.ts @@ -0,0 +1,64 @@ +import { CommonAPI } from "@src/common/common.api"; +import { IRole, IMember } from "@src/common/common.interface"; +import { isSuccess } from "@src/utils/http"; +import { Dispatch, SetStateAction, useState, useEffect } from "react"; + +export function useRoles(): { + roles: IRole[]; + setRoles: Dispatch>; + getRoles: () => void; + loading: boolean; +} { + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(false); + useEffect(() => { + getRoles(); + }, []); + + async function getRoles() { + setLoading(true); + const res = await CommonAPI.getRoles(); + setLoading(false); + if (isSuccess(res)) { + setRoles(res.data); + } + } + return { + roles, + setRoles, + getRoles, + loading + }; +} + + +export function useRoleMember(roleId: string): { + members: IMember; + setMembers: Dispatch>; + getRoleMembers: () => void; + loading: boolean; +} { + const [members, setMembers] = useState({name: '', id: 0, members: []}); + const [loading, setLoading] = useState(false); + useEffect(() => { + getRoleMembers(); + }, []); + + async function getRoleMembers() { + setLoading(true); + const res = await CommonAPI.getRoleMembersById({ + roleId: roleId + }); + console.log(res); + setLoading(false); + if (isSuccess(res)) { + setMembers(res.data); + } + } + return { + members, + setMembers, + getRoleMembers, + loading + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/use-userinfo.hooks.ts b/frontend/src/hooks/use-userinfo.hooks.ts new file mode 100644 index 0000000..a3f2ad9 --- /dev/null +++ b/frontend/src/hooks/use-userinfo.hooks.ts @@ -0,0 +1,18 @@ +import { CommonAPI } from "@src/common/common.api"; +import { isSuccess } from "@src/utils/http"; +import { Dispatch, SetStateAction, useState, useEffect } from "react"; + +export function useUserInfo(): [any, Dispatch>] { + const [userInfo, setUserInfo] = useState({}); + useEffect(() => { + getUserInfo(); + }, []); + + async function getUserInfo() { + const res = await CommonAPI.getUserInfo(); + if (isSuccess(res)) { + setUserInfo(res.data); + } + } + return [userInfo, setUserInfo]; +} \ No newline at end of file diff --git a/frontend/src/hooks/use-users.hooks.ts b/frontend/src/hooks/use-users.hooks.ts new file mode 100644 index 0000000..2759227 --- /dev/null +++ b/frontend/src/hooks/use-users.hooks.ts @@ -0,0 +1,41 @@ +import { CommonAPI } from '@src/common/common.api'; +import { isSuccess } from '@src/utils/http'; +import { Dispatch, SetStateAction, useState, useEffect } from 'react'; + +export function useUsers(): { + users: any[]; + setUsers: Dispatch>; + getUsers: () => void; +} { + const [users, setUsers] = useState(); + useEffect(() => { + getUsers(); + }, []); + + async function getUsers() { + const res = await CommonAPI.getUsers(); + if (isSuccess(res)) { + setUsers(res.data); + } + } + return { users, getUsers, setUsers }; +} + +export function useSpaceUsers(): { + users: any[]; + setUsers: Dispatch>; + getUsers: () => void; +} { + const [users, setUsers] = useState(); + useEffect(() => { + getUsers(); + }, []); + + async function getUsers() { + const res = await CommonAPI.getSpaceUsers() + if (isSuccess(res)) { + setUsers(res.data); + } + } + return { users, getUsers, setUsers }; +} diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx new file mode 100644 index 0000000..0af2a13 --- /dev/null +++ b/frontend/src/i18n.tsx @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import LanguageDetector from 'i18next-browser-languagedetector'; +import i18n from 'i18next'; +import enUsTrans from '../public/locales/en-us.json'; +import zhCnTrans from '../public/locales/zh-cn.json'; +import { + initReactI18next +} from 'react-i18next'; +i18n.use(LanguageDetector) + .use(initReactI18next) + .init({ + detection: { + caches: ['localStorage', 'cookie'] + }, + resources: { + en: { + translation: enUsTrans + }, + zh: { + translation: zhCnTrans + } + }, + lng: localStorage.getItem('i18nextLng') || 'zh', + fallbackLng: 'zh', + debug: false, + interpolation: { + escapeValue: false + } + }); + +export default i18n; + diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..ac4a59a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,62 @@ +/* // Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. */ + +#root { + min-width: 100vw; + height: 100%; + min-height: 100vh; + background-color: white; +} +.ant-layout { + background-color: white !important; +} +ul,ol,li { + list-style: none; + padding: 0; + margin: 0; +} +h1,h2,h3,h4,h5,h6 { + margin: 0; + padding: 0; +} +.ant-pro-page-container-children-content { + margin: 16px 20px 0; +} +.editable-cell { + position: relative; +} + +.editable-cell-value-wrap { + padding: 5px 12px; + cursor: pointer; +} + +.editable-row:hover .editable-cell-value-wrap { + padding: 4px 11px; + border: 1px solid #d9d9d9; + border-radius: 2px; +} + +[data-theme='dark'] .editable-row:hover .editable-cell-value-wrap { + border: 1px solid #434343; +} +[data-theme='dark'] .ant-menu-dark .ant-menu-sub, .ant-menu.ant-menu-dark, .ant-menu.ant-menu-dark .ant-menu-sub { + background: #001528 !important; +} +.ant-layout-sider { + position: fixed !important; +} \ No newline at end of file diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..936ef70 --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,25 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import App from './app'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import './i18n'; + + +ReactDOM.render(, document.querySelector("#root")); diff --git a/frontend/src/interfaces/http.interface.ts b/frontend/src/interfaces/http.interface.ts new file mode 100644 index 0000000..6f6f077 --- /dev/null +++ b/frontend/src/interfaces/http.interface.ts @@ -0,0 +1,22 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +export interface IResult { + msg: string; + code: number; + data: T; + count: number; +} diff --git a/frontend/src/layout/page-side/index.tsx b/frontend/src/layout/page-side/index.tsx new file mode 100644 index 0000000..dbd6323 --- /dev/null +++ b/frontend/src/layout/page-side/index.tsx @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * @format + */ + +import React, { useState, useEffect, SyntheticEvent } from 'react'; +import { Resizable } from 're-resizable'; +import styles from './page-side.module.less'; + +export function PageSide(props: any) { + const { children } = props; + const [sideBoxWidth, setSideBoxWidth] = useState(300); + const directionEnable = { + top: false, + right: true, + bottom: false, + left: false, + topRight: false, + bottomRight: false, + bottomLeft: false, + topLeft: false, + }; + return ( + { + setSideBoxWidth(sideBoxWidth + d.width); + }} + className={styles['build-page-side']} + > + {props.children} + + ); +} diff --git a/frontend/src/layout/page-side/page-side.module.less b/frontend/src/layout/page-side/page-side.module.less new file mode 100644 index 0000000..bbc67a8 --- /dev/null +++ b/frontend/src/layout/page-side/page-side.module.less @@ -0,0 +1,27 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +.build-page-side { + min-width: 300px; + max-width: 600px; + padding: 20px; + background-color: #edf2f5; + border-radius: 16px; + // box-shadow: 3px 3px 3px #bfbdbd; +} diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx new file mode 100644 index 0000000..df577e4 --- /dev/null +++ b/frontend/src/routes.tsx @@ -0,0 +1,59 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React from 'react'; +import { Loading } from './components/loading'; +import { NotFound } from './components/not-found'; +import { Suspense } from 'react'; + + +import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'; +import { InitializedRoute } from './components/initialized-route/initialized-route'; +import { Settings } from './routes/settings/settings'; +import { Initialize } from './routes/initialize/initialize.route'; +import { SuperAdminContainer } from './routes/super-admin-container'; +import { Admin } from './routes/admin/admin'; +import { Login } from './routes/passport/login'; +import { Container } from './routes/container'; + +const routes = ( + }> + + + + + + + + + + + + + + + + + + + + + + +); + +export default routes; diff --git a/frontend/src/routes/admin/admin.module.less b/frontend/src/routes/admin/admin.module.less new file mode 100644 index 0000000..b14ed56 --- /dev/null +++ b/frontend/src/routes/admin/admin.module.less @@ -0,0 +1,9 @@ +.container { + margin-left: 80px; + overflow-y: scroll; + height: calc(100% - 44px); +} + +.card { + min-height: 100%; +} diff --git a/frontend/src/routes/admin/admin.tsx b/frontend/src/routes/admin/admin.tsx new file mode 100644 index 0000000..569ad5d --- /dev/null +++ b/frontend/src/routes/admin/admin.tsx @@ -0,0 +1,58 @@ +import React, { Suspense, useEffect, useMemo, useState } from 'react'; +import { Redirect, Switch, Route, useHistory } from 'react-router-dom'; +import { Card } from 'antd'; +import { useTranslation } from 'react-i18next'; +import styles from './admin.module.less'; +import { UserInfoContext } from '@src/common/common.context'; +import { Sidebar } from '@src/components/sidebar/sidebar'; +import { Header } from '@src/components/header/header'; +import TabsHeader from '@src/components/tabs-header'; +import { SpaceDetail } from '../space/detail/space-detail'; +import { People } from './people/people'; +import { useUserInfo } from '@src/hooks/use-userinfo.hooks'; +import LoadingLayout from '@src/components/loading-layout'; + +export function Admin() { + const {t} = useTranslation() + const history = useHistory(); + const [loading, setLoading] = useState(true); + const [userInfo] = useUserInfo(); + useEffect(() => { + if (userInfo.id == null) return; + if (userInfo.id != null && !userInfo.is_super_admin && !userInfo.is_admin) { + history.push('/'); + return; + } + setLoading(false); + }, [userInfo.id, userInfo.is_super_admin, userInfo.is_admin]); + const tabRoutes = useMemo( + () => [ + { label: t`spaceInfo`, path: `/admin/space/${userInfo.space_id}` }, + { label: t`members`, path: '/admin/people/user' }, + { label: t`roles`, path: '/admin/people/role' }, + ], + [userInfo.space_id, t], + ); + return ( + + +
+
+ + + } + > + + + + + + + + + +
+ + ); +} diff --git a/frontend/src/routes/admin/people/people.less b/frontend/src/routes/admin/people/people.less new file mode 100644 index 0000000..6ac639d --- /dev/null +++ b/frontend/src/routes/admin/people/people.less @@ -0,0 +1,14 @@ +.optionContent { + width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + span { + margin-left: 5px; + } +} + +.optionTag { + position: absolute; + right: 30px; +} diff --git a/frontend/src/routes/admin/people/people.tsx b/frontend/src/routes/admin/people/people.tsx new file mode 100644 index 0000000..67edd63 --- /dev/null +++ b/frontend/src/routes/admin/people/people.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Role } from './role/role'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { User } from './user/user'; + +export function People(props: any) { + const { match } = props; + console.log(match) + return ( + <> + + + + + + + ); +} diff --git a/frontend/src/routes/admin/people/role/list/create-or-edit-modal.tsx b/frontend/src/routes/admin/people/role/list/create-or-edit-modal.tsx new file mode 100644 index 0000000..f883337 --- /dev/null +++ b/frontend/src/routes/admin/people/role/list/create-or-edit-modal.tsx @@ -0,0 +1,86 @@ +import { isSuccess } from '@src/utils/http'; +import { Form, Input, message, Modal } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RoleAPI } from '../role.api'; + +export function CreateOrEditRoleModal(props: any) { + const { t } = useTranslation(); + const { onCancel, role } = props; + const [loading, setLoading] = useState(false); + const title = role ? t`editRole` : t`createRole`; + + useEffect(() => { + props.form.setFieldsValue({ name: props.role ? props.role.name : '' }); + }, [props.role]); + + async function handleCreate(values: any) { + setLoading(true); + try { + const res = await RoleAPI.createRole(values); + setLoading(false); + if (isSuccess(res)) { + message.success(t`createSuccess`); + props.onSuccess && props.onSuccess(); + } else { + message.error(res.msg); + } + } catch (err) { + setLoading(false); + } + } + async function handleEdit(values: any) { + setLoading(true); + const res = await RoleAPI.updateRole({ + id: (role as any).id, + name: values.name, + }); + setLoading(false); + if (isSuccess(res)) { + message.success(t`SuccessfullyModified`); + props.onSuccess && props.onSuccess(); + } else { + message.error(res.msg); + } + } + return ( + { + props.form + .validateFields() + .then((values: any) => { + if (!role) { + handleCreate(values); + } else { + console.log(values); + handleEdit(values); + } + }) + .catch((info: any) => { + console.log('Validate Failed:', info); + }); + }} + > +
+ + + +
+
+ ); +} diff --git a/frontend/src/routes/admin/people/role/list/list.tsx b/frontend/src/routes/admin/people/role/list/list.tsx new file mode 100644 index 0000000..4042543 --- /dev/null +++ b/frontend/src/routes/admin/people/role/list/list.tsx @@ -0,0 +1,136 @@ +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import { IRole } from '@src/common/common.interface'; +import { FlatBtnGroup, FlatBtn } from '@src/components/flatbtn'; +import { useRoles } from '@src/hooks/use-roles.hooks'; +import { isSuccess } from '@src/utils/http'; +import { showName } from '@src/utils/utils'; +import { Button, Table, Modal, message, Row } from 'antd'; +import { useForm } from 'antd/lib/form/Form'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useRouteMatch } from 'react-router'; +import { RoleAPI } from '../role.api'; +import { CreateOrEditRoleModal } from './create-or-edit-modal'; + +export function RoleList(props: any) { + const { t } = useTranslation(); + const { roles, getRoles, loading } = useRoles(); + const [visible, setVisible] = useState(false); + const [currentRole, setCurrentRole] = useState(); + const [modalLoading, setModalLoading] = useState(false); + const match = useRouteMatch(); + const [form] = useForm(); + const { confirm } = Modal; + const columns = [ + { + title: t`roleName`, + key: 'name', + render: (record: IRole) => {showName(record.name)}, + }, + { + title: t`members`, + dataIndex: 'member_count', + key: 'member_count', + }, + { + title: t`operation`, + key: 'actions', + render: (record: IRole) => { + const forbiddenEditRole = record.name.includes('Administrators') || record.name.includes('All Users'); + return ( + + { + console.log(record); + setCurrentRole(record); + setVisible(true); + }} + > + {t`edit`} + + handleDelete(record)}> + {t`Delete`} + + + ); + }, + }, + ]; + function handleDelete(record: IRole) { + confirm({ + title: t`deleteThisRole`, + icon: , + content: t`deleteThisRoleMessage`, + onOk() { + return RoleAPI.deleteRole({ roleId: record.id }).then(res => { + if (isSuccess(res)) { + message.success(t`DeleteSuccessTips`); + getRoles(); + } else { + message.error(res.msg); + } + }); + }, + okType: 'danger', + onCancel() { + console.log('Cancel'); + }, + }); + } + function onCancel() { + setVisible(false); + } + // async function handleCreate(values: any) { + // setCurrentRole(undefined); + // setModalLoading(true); + // const res = await RoleAPI.createRole(values); + // setModalLoading(false); + // if (isSuccess(res)) { + // message.success('创建成功'); + // setVisible(false); + // getRoles(); + // } + // } + async function handleEdit(values: any) {} + return ( + <> + + + {t`roleTopMessage`} + + + + + + {visible && ( + { + setVisible(false); + form.resetFields(); + getRoles(); + }} + form={form} + role={currentRole} + onCancel={() => setVisible(false)} + /> + )} + + ); +} diff --git a/frontend/src/routes/admin/people/role/member/member.tsx b/frontend/src/routes/admin/people/role/member/member.tsx new file mode 100644 index 0000000..160d288 --- /dev/null +++ b/frontend/src/routes/admin/people/role/member/member.tsx @@ -0,0 +1,187 @@ +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import { UserInfoContext } from '@src/common/common.context'; +import { FlatBtn } from '@src/components/flatbtn'; +import { useRoleMember } from '@src/hooks/use-roles.hooks'; +import { useSpaceUsers } from '@src/hooks/use-users.hooks'; +import { isSuccess } from '@src/utils/http'; +import { showName } from '@src/utils/utils'; +import { Button, Table, Modal, Form, Select, message, Row } from 'antd'; +import { useForm } from 'antd/lib/form/Form'; +import { ColumnsType } from 'antd/lib/table'; +import React, { useContext, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useRouteMatch } from 'react-router'; +import { RoleAPI } from '../role.api'; +import commonStyles from '../../people.less'; + +export function RoleMembers(props: any) { + const { t } = useTranslation(); + const match = useRouteMatch<{ roleId: string }>(); + const { users } = useSpaceUsers(); + const { members, getRoleMembers, loading } = useRoleMember(match.params.roleId); + const [visible, setVisible] = useState(false); + const [confirmLoading, setConfirmLoading] = useState(false); + const userInfo = useContext(UserInfoContext)!; + const [form] = useForm(); + const isAllUser = members?.name.includes('All Users'); + const { confirm } = Modal; + + const filteredUsers = users?.filter(user => { + const data = members.members.map(item => item.email); + if (data.includes(user.email)) { + return false; + } + return true; + }); + + const columns: ColumnsType = [ + { + title: t`members`, + key: 'name', + dataIndex: 'name', + }, + { + title: t`Mail`, + dataIndex: 'email', + key: 'email', + }, + ]; + + if (!isAllUser) { + columns.push({ + title: t`operation`, + key: 'actions', + render: (record: any) => { + const forbiddenEditRole = + members.name === 'All Users_1' || + (members.name === 'Administrators_1' && members?.members.length <= 1) || + userInfo.name === record.name; + return ( + handleDelete(record)}> + {t`remove`} + + ); + }, + }); + } + + async function handleCreate(values: any) { + setConfirmLoading(true); + const res = await RoleAPI.addMember({ + user_ids: values.user, + group_id: +match.params.roleId, + }); + setConfirmLoading(false); + if (isSuccess(res)) { + message.success(t`addSuccess`); + setVisible(false); + form.resetFields(); + getRoleMembers(); + } else { + message.error(res.msg); + } + } + + function handleDelete(record: any) { + confirm({ + title: t`removeFromRoleMembers`, + icon: , + content: t`removeFromRoleMembersMessage`, + onOk() { + return RoleAPI.deleteMember({ membership_id: record.membership_id }).then(res => { + if (isSuccess(res)) { + message.success(t`DeleteSuccessTips`); + getRoleMembers(); + } else { + message.error(res.msg); + } + }); + }, + okType: 'danger', + onCancel() { + console.log('Cancel'); + }, + }); + } + + return ( + <> + + {showName(members?.name)} + + +
+ {visible && ( + setVisible(false)} + confirmLoading={confirmLoading} + onOk={() => { + form.validateFields() + .then(values => { + handleCreate(values); + }) + .catch(info => { + console.log('Validate Failed:', info); + }); + }} + > +
+ + + + +
+ )} + + ); +} diff --git a/frontend/src/routes/admin/people/role/role.api.ts b/frontend/src/routes/admin/people/role/role.api.ts new file mode 100644 index 0000000..fc901c2 --- /dev/null +++ b/frontend/src/routes/admin/people/role/role.api.ts @@ -0,0 +1,30 @@ +import { IRole } from "@src/common/common.interface"; +import { http } from "@src/utils/http"; + +function createRole(data: {name: string}) { + return http.post('/api/permissions/group', data) +} + +function updateRole(data: {id: number, name: string}) { + return http.put(`/api/permissions/group/${data.id}`, {name: data.name}) +} + +function deleteRole(data: {roleId: number}) { + return http.delete(`/api/permissions/group/${data.roleId}`) +} + +function deleteMember(data: {membership_id: number}) { + return http.delete(`/api/permissions/membership/${data.membership_id}`) +} + +function addMember(data: {group_id: number, user_ids: number[]}) { + return http.post('/api/permissions/memberships', data) +} + +export const RoleAPI = { + createRole, + updateRole, + deleteRole, + addMember, + deleteMember +} \ No newline at end of file diff --git a/frontend/src/routes/admin/people/role/role.tsx b/frontend/src/routes/admin/people/role/role.tsx new file mode 100644 index 0000000..f8386fe --- /dev/null +++ b/frontend/src/routes/admin/people/role/role.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { RoleList } from './list/list'; +import { RoleMembers } from './member/member'; + +export function Role(props: any) { + const { match } = props; + return ( + <> + + + + + + + ); +} diff --git a/frontend/src/routes/admin/people/user/create-modal.tsx b/frontend/src/routes/admin/people/user/create-modal.tsx new file mode 100644 index 0000000..6d780e9 --- /dev/null +++ b/frontend/src/routes/admin/people/user/create-modal.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Modal, Form, Select, message, Row } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { IUser } from './user'; +import { useAsync } from '@src/hooks/use-async'; +import { addMemberToSpaceAPI } from './user.api'; +import commonStyles from '../people.less'; + +const { Option } = Select; + +interface CreateModalProps { + visible: boolean; + users: IUser[]; + onCancel: () => void; + getSpaceMembers: () => void; +} + +interface FormInstanceProps { + users: number[]; +} + +export default function CreateModal(props: CreateModalProps) { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const { loading: confirmLoading, run: runAddMembers } = useAsync(); + const { visible, users, onCancel, getSpaceMembers } = props; + + const handleOk = () => { + form.validateFields() + .then(values => { + runAddMembers(Promise.all(values.users.map(userId => addMemberToSpaceAPI(userId)))) + .then(() => { + message.success(t`addSuccess`); + form.resetFields(); + onCancel(); + }) + .catch(() => { + message.error(t`addFailed`); + }) + .finally(() => { + getSpaceMembers(); + }); + }) + .catch(console.log); + }; + + return ( + +
+ + + + +
+ ); +} diff --git a/frontend/src/routes/admin/people/user/user.api.tsx b/frontend/src/routes/admin/people/user/user.api.tsx new file mode 100644 index 0000000..0e3ddfc --- /dev/null +++ b/frontend/src/routes/admin/people/user/user.api.tsx @@ -0,0 +1,22 @@ +import { http, isSuccess } from '@src/utils/http'; + +export function getSpaceMembersAPI() { + return http.get(`/api/v2/user/space`).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +export function removeMemberFromSpaceAPI(userId: number) { + return http.delete(`/api/v2/user/move/${userId}`).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +export function addMemberToSpaceAPI(userId: number) { + return http.post(`/api/v2/user/add/${userId}`).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} \ No newline at end of file diff --git a/frontend/src/routes/admin/people/user/user.hooks.ts b/frontend/src/routes/admin/people/user/user.hooks.ts new file mode 100644 index 0000000..c62363b --- /dev/null +++ b/frontend/src/routes/admin/people/user/user.hooks.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect } from 'react'; +import { message } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { useAsync } from '@src/hooks/use-async'; +import { CommonAPI } from '@src/common/common.api'; +import { isSuccess } from '@src/utils/http'; +import { IUser } from './user'; +import { getSpaceMembersAPI } from './user.api'; + +export function useSpaceMembers(userInfo: any) { + const { t } = useTranslation(); + const { data: users, run: runGetUsers } = useAsync({ loading: true, data: [] }); + + const { + data: spaceMembers, + loading: loading, + run: runGetSpaceMembers, + } = useAsync({ loading: true, data: [] }); + + const getUsers = useCallback(() => { + runGetUsers( + CommonAPI.getUsers({ include_deactivated: false }).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }), + ).catch(() => message.error(t`fetchUserListFailed`)); + }, [runGetUsers]); + + const getSpaceMembers = useCallback(() => { + runGetSpaceMembers(getSpaceMembersAPI()).catch(() => { + message.error(t`fetchSpaceMemberListFailed`); + }); + }, [runGetSpaceMembers]); + + useEffect(() => { + if (userInfo.space_id == null) return; + getSpaceMembers(); + getUsers(); + }, [getSpaceMembers, userInfo.space_id]); + + return { + users, + spaceMembers, + getSpaceMembers, + loading, + }; +} diff --git a/frontend/src/routes/admin/people/user/user.tsx b/frontend/src/routes/admin/people/user/user.tsx new file mode 100644 index 0000000..91496f7 --- /dev/null +++ b/frontend/src/routes/admin/people/user/user.tsx @@ -0,0 +1,114 @@ +import React, { useContext, useState } from 'react'; +import { Button, Table, message, Modal, Row } from 'antd'; +import moment from 'moment'; +import { useTranslation } from 'react-i18next'; +import StatusMark from '@src/components/status-mark'; +import { UserInfoContext } from '@src/common/common.context'; +import CreateModal from './create-modal'; +import { removeMemberFromSpaceAPI } from './user.api'; +import { useSpaceMembers } from './user.hooks'; +import { FlatBtn } from '@src/components/flatbtn'; + +export interface IUser { + id: number; + name: string; + email: string; + last_login: string; + is_active: boolean; +} + +export function User() { + const {t} = useTranslation() + const userInfo = useContext(UserInfoContext)!; + const { users = [], spaceMembers = [], getSpaceMembers, loading } = useSpaceMembers(userInfo); + const [modalVisible, setModalVisible] = useState(false); + + const filteredUsers = users.filter(user => !spaceMembers.find(member => member.id === user.id)); + + const columns = [ + { + title: t`username`, + key: 'name', + dataIndex: 'name', + }, + { + title: t`Mail`, + dataIndex: 'email', + key: 'email', + }, + { + title: t`status`, + dataIndex: 'is_active', + filters: [ + { text: t`activated`, value: true }, + { text: t`deactivated`, value: false }, + ], + render: (is_active: boolean) => ( + {is_active ? t`activated` : t`deactivated`} + ), + onFilter: (value: any, record: any) => record.is_active === value, + }, + { + title: t`lastLogin`, + dataIndex: 'last_login', + key: 'last_login', + render: (last_login: string) => { + return ( + {last_login == null ? t`neverLoggedIn` : moment(last_login).format('YYYY-MM-DD HH:mm:ss')} + ); + }, + }, + { + title: t`operation`, + key: 'actions', + render: (record: IUser) => { + const disabled = userInfo.id === record.id; + return ( + + {t`removeMember`} + + ); + }, + }, + ]; + + const handleRemove = (userId: number) => () => { + Modal.confirm({ + title: t`removeMemberModalTitle`, + onOk: () => { + return removeMemberFromSpaceAPI(userId) + .then(() => { + message.success(t`removeSuccess`); + getSpaceMembers(); + }) + .catch(() => { + message.error('removeFailed'); + }); + }, + }); + }; + + return ( + <> + + + +
+ setModalVisible(false)} + getSpaceMembers={getSpaceMembers} + /> + + ); +} diff --git a/frontend/src/routes/cluster/cluster.api.ts b/frontend/src/routes/cluster/cluster.api.ts new file mode 100644 index 0000000..0a8a029 --- /dev/null +++ b/frontend/src/routes/cluster/cluster.api.ts @@ -0,0 +1,56 @@ +import { http, isSuccess } from '@src/utils/http'; + +export function getClusterOverview() { + return http.get('/api/cluster/overview').then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +export function startCluster(cluster_id: number) { + return http.post('/api/control/cluster/start', { cluster_id }).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +export function stopCluster(cluster_id: number) { + return http.post('/api/control/cluster/stop', { cluster_id }).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +export function restartCluster(cluster_id: number) { + return http.post('/api/control/cluster/restart', { cluster_id }).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +export function getNodeList(clusterId: number) { + return http.get(`/api/control/cluster/${clusterId}/instances`).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +export function getConfigurationList(type: 'be' | 'fe') { + return http.post(`/api/rest/v2/manager/node/configuration_info?type=${type}`, { type }).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +interface ChangeConfigurationParams { + node: string[]; + persist: 'true' | 'false'; + value: string; +} + +export function changeConfiguration(type: 'be' | 'fe', data: Record) { + return http.post(`/api/rest/v2/manager/node/set_config/${type}`, data).then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} diff --git a/frontend/src/routes/cluster/cluster.module.less b/frontend/src/routes/cluster/cluster.module.less new file mode 100644 index 0000000..e0765eb --- /dev/null +++ b/frontend/src/routes/cluster/cluster.module.less @@ -0,0 +1,3 @@ +.container { + min-height: calc(100vh - 84px); +} diff --git a/frontend/src/routes/cluster/cluster.tsx b/frontend/src/routes/cluster/cluster.tsx new file mode 100644 index 0000000..5503ef9 --- /dev/null +++ b/frontend/src/routes/cluster/cluster.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import ProCard from '@ant-design/pro-card'; +import { Card, Steps } from 'antd'; +import { Switch, Route, Redirect, useRouteMatch } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import styles from './cluster.module.less'; +import { UserInfoContext } from '@src/common/common.context'; +import ClusterOverview from './overview'; +import Nodes from './nodes'; +import Configuration from './configuration'; +import { ClusterList } from './list/list'; +import { ClusterMonitor } from './monitor/monitor'; +import TabsHeader from '@src/components/tabs-header'; +import { useUserInfo } from '@src/hooks/use-userinfo.hooks'; +import LoadingLayout from '@src/components/loading-layout'; +const { Step } = Steps; + +export function Cluster(props: any) { + // const match = useRouteMatch(); + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [userInfo] = useUserInfo(); + useEffect(() => { + if (userInfo.id == null) return; + setLoading(false); + }, [userInfo.id]); + const tabRoutes = useMemo( + () => [ + { label: t`clusterOverview`, path: '/cluster/overview' }, + { label: t`nodeList`, path: '/cluster/nodes' }, + { label: t`parameterConf`, path: '/cluster/configuration' }, + ], + [t], + ); + return ( + // <> + // + // + // + // + // + // + +
+ + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/routes/cluster/components/data-overview-item/index.module.less b/frontend/src/routes/cluster/components/data-overview-item/index.module.less new file mode 100644 index 0000000..e9c6844 --- /dev/null +++ b/frontend/src/routes/cluster/components/data-overview-item/index.module.less @@ -0,0 +1,9 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + .label { + font-weight: 700; + margin-top: 40px; + } +} \ No newline at end of file diff --git a/frontend/src/routes/cluster/components/data-overview-item/index.tsx b/frontend/src/routes/cluster/components/data-overview-item/index.tsx new file mode 100644 index 0000000..7e440b4 --- /dev/null +++ b/frontend/src/routes/cluster/components/data-overview-item/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Row, Col } from 'antd'; +import styles from './index.module.less'; + +interface DataOverviewItemProps { + label: string; + value: number; + icon: React.ReactNode; +} + +export default function DataOverviewItem(props: DataOverviewItemProps) { + const { icon, label, value } = props; + return ( +
+ +
{icon} + {value} + +
{label}
+ + ); +} diff --git a/frontend/src/routes/cluster/components/liquid-fill-chart/index.module.less b/frontend/src/routes/cluster/components/liquid-fill-chart/index.module.less new file mode 100644 index 0000000..ba35a2e --- /dev/null +++ b/frontend/src/routes/cluster/components/liquid-fill-chart/index.module.less @@ -0,0 +1,8 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + .label { + font-weight: 700; + } +} \ No newline at end of file diff --git a/frontend/src/routes/cluster/components/liquid-fill-chart/index.tsx b/frontend/src/routes/cluster/components/liquid-fill-chart/index.tsx new file mode 100644 index 0000000..2afac17 --- /dev/null +++ b/frontend/src/routes/cluster/components/liquid-fill-chart/index.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import 'echarts-liquidfill'; +import styles from './index.module.less' + +interface LiquidFillChartProps { + label?: string; + value: number; +} + +export default function LiquidFillChart(props: LiquidFillChartProps) { + const { label = '', value } = props; + return ( +
+
{label}
+ { + return (param.value * 100).toFixed(2) + '%'; + }, + fontSize: 25, + }, + }, + ], + }} + /> +
+ ); +} diff --git a/frontend/src/routes/cluster/configuration/check-modal.tsx b/frontend/src/routes/cluster/configuration/check-modal.tsx new file mode 100644 index 0000000..4e875f1 --- /dev/null +++ b/frontend/src/routes/cluster/configuration/check-modal.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { Button, Modal, Table } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { ConfigurationItem } from '.'; + +interface CheckModalProps { + visible: boolean; + currentParameter: ConfigurationItem; + onCancel: () => void; +} + +export default function CheckModal(props: CheckModalProps) { + const { t } = useTranslation(); + const { visible, currentParameter, onCancel } = props; + const nodeList = currentParameter?.nodes?.map((node, index) => ({ + host: node, + value: currentParameter.values[index], + })); + + const columns = [ + { + title: t`hostIp`, + dataIndex: 'host', + }, + { + title: t`currentValue`, + dataIndex: 'value', + }, + ]; + return ( + {t`cancel`}} + width={700} + onCancel={onCancel} + > +
+ + ); +} diff --git a/frontend/src/routes/cluster/configuration/edit-modal.tsx b/frontend/src/routes/cluster/configuration/edit-modal.tsx new file mode 100644 index 0000000..918362e --- /dev/null +++ b/frontend/src/routes/cluster/configuration/edit-modal.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { Modal, Form, Input, Radio, Select, message } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { ConfigurationItem } from '.'; +import { useAsync } from '@src/hooks/use-async'; +import * as ClusterAPI from '../cluster.api'; + +interface EditModalProps { + visible: boolean; + currentParameter: ConfigurationItem; + onOk: () => void; + onCancel: () => void; +} + +interface FormInstanceProps { + value: string; + persist: boolean; + range: 'all' | 'part'; + nodes: number[]; +} + +export default function EditModal(props: EditModalProps) { + const { t } = useTranslation(); + const { visible, currentParameter, onOk, onCancel } = props; + const { loading: confirmLoading, run: runChangeConfiguration } = useAsync(); + const [form] = Form.useForm(); + + const handleOk = () => { + form.validateFields() + .then(values => { + runChangeConfiguration( + ClusterAPI.changeConfiguration(currentParameter.type === 'Frontend' ? 'fe' : 'be', { + [currentParameter.name]: { + node: [...currentParameter.nodes], + value: values.value, + persist: values.persist ? 'true' : 'false', + }, + }), + ) + .then(() => { + message.success('编辑成功'); + onOk(); + handleCancel(); + }) + .catch(res => { + message.error(res.msg); + }); + }) + .catch(console.log); + }; + + const handleCancel = () => { + form.resetFields(); + // setRange('all'); + onCancel(); + }; + + return ( + +
+ + + + + + {t`permanentEffective`} + {t`onceEffective`} + + + {/* + { + setRange(e.target.value); + }} + > + {t`allNodes`} + {t`certainNodes`} + + */} + {/* {range === 'part' && ( + + + + )} */} + +
+ ); +} diff --git a/frontend/src/routes/cluster/configuration/index.tsx b/frontend/src/routes/cluster/configuration/index.tsx new file mode 100644 index 0000000..094a977 --- /dev/null +++ b/frontend/src/routes/cluster/configuration/index.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Input, Row, Col, Table, message } from 'antd'; +import { useTranslation } from 'react-i18next'; +import CheckModal from './check-modal'; +import EditModal from './edit-modal'; +import { useAsync } from '@src/hooks/use-async'; +import * as ClusterAPI from '../cluster.api'; +import { FlatBtn, FlatBtnGroup } from '@src/components/flatbtn'; + +export interface ConfigurationItem { + name: string; + nodes: string[]; + type: 'Frontend' | 'Backend'; + valueType: string; + values: string[]; + hot: boolean; +} + +const resolveConfigurationList = (configurationList: { rows: string[][] }) => { + return configurationList?.rows?.reduce((memo, current) => { + const index = memo.findIndex(item => item.name === current[0]) + if(index < 0) { + memo.push({ + name: current[0], + nodes: [current[1]], + type: current[2] === 'FE' ? 'Frontend' : 'Backend', + valueType: current[3], + values: [current[current.length - 2]], + hot: current[current.length - 1] === 'true' + }) + }else { + memo[index].nodes.push(current[1]) + memo[index].values.push(current[current.length - 2]) + } + return memo + }, [] as ConfigurationItem[]) || [] +}; + +export default function Configuration() { + const { t } = useTranslation(); + + const { + data: configurationList, + loading: configurationListLoading, + run: runGetConfigurationList, + } = useAsync({ loading: true, data: [] }); + + const [filteredConfigurationList, setFilteredConfigurationList] = useState(); + + const getConfigurationList = useCallback( + (setStartLoading: boolean = false) => { + return runGetConfigurationList( + Promise.all([ClusterAPI.getConfigurationList('fe'), ClusterAPI.getConfigurationList('be')]).then( + res => { + return [...resolveConfigurationList(res[0]), ...resolveConfigurationList(res[1])]; + }, + ), + { setStartLoading }, + ).catch(res => { + message.error(res.msg); + }); + }, + [runGetConfigurationList], + ); + + useEffect(() => { + getConfigurationList(); + }, [getConfigurationList]); + + const [currentParameter, setCurrentParameter] = useState({} as ConfigurationItem); + const [editModalVisible, setEditModalVisible] = useState(false); + const [checkModalVisible, setCheckModalVisible] = useState(false); + const [searchString, setSearchString] = useState(''); + + const columns = [ + { + title: t`paramName`, + dataIndex: 'name', + }, + { + title: t`paramType`, + dataIndex: 'type', + filters: [ + { + text: 'Frontend', + value: 'Frontend', + }, + { + text: 'Backend', + value: 'Backend', + }, + ], + onFilter: (value: any, record: ConfigurationItem) => record.type === value, + }, + { + title: t`paramValueType`, + dataIndex: 'valueType', + }, + { + title: t`hot`, + dataIndex: 'hot', + render: (hot: boolean) => (hot ? t`yes` : t`no`), + }, + { + title: t`operation`, + key: 'operation', + render: (record: ConfigurationItem) => ( + + {t`viewCurrentValue`} + + {t`edit`} + + + ), + }, + ]; + + const handleCheck = (record: ConfigurationItem) => () => { + setCurrentParameter(record); + setCheckModalVisible(true); + }; + + const handleEdit = (record: ConfigurationItem) => () => { + setCurrentParameter(record); + setEditModalVisible(true); + }; + + const handleSearch = () => { + setFilteredConfigurationList( + configurationList?.filter(item => item.name.toLowerCase().includes(searchString.toLowerCase())), + ); + }; + + return ( + <> + +
{t`paramSearch`} + + setSearchString(e.target.value)} onSearch={handleSearch} /> + + +
+ setCheckModalVisible(false)} + /> + { + getConfigurationList(true).then(res => { + setFilteredConfigurationList( + res?.filter(item => item.name.toLowerCase().includes(searchString.toLowerCase())), + ); + }); + }} + onCancel={() => setEditModalVisible(false)} + /> + + ); +} diff --git a/frontend/src/routes/cluster/list/list.tsx b/frontend/src/routes/cluster/list/list.tsx new file mode 100644 index 0000000..c3b04b8 --- /dev/null +++ b/frontend/src/routes/cluster/list/list.tsx @@ -0,0 +1,48 @@ +import { Button, PageHeader, Row, Space, Table } from 'antd'; +import React from 'react'; +import { useHistory, useRouteMatch } from 'react-router'; + +export function ClusterList(props: any) { + const history = useHistory(); + const match = useRouteMatch(); + const columns = [ + { + title: '集群', + dataIndex: 'cluster', + key: 'cluster', + }, + { + title: '创建时间', + dataIndex: 'createTime', + key: 'createTime', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + }, + { + title: '链接信息', + dataIndex: 'connect_info', + key: 'connect_info', + }, + ]; + const [clusterList, setClusterList] = React.useState([]); + return ( +
+ + + + + + + +
record[Object.keys(record).length - 1]} + size="middle" + /> + + ); +} diff --git a/frontend/src/routes/cluster/monitor/monitor.api.ts b/frontend/src/routes/cluster/monitor/monitor.api.ts new file mode 100644 index 0000000..0850b1a --- /dev/null +++ b/frontend/src/routes/cluster/monitor/monitor.api.ts @@ -0,0 +1,70 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +import { http } from '@src/utils/http'; +import { IResult } from 'src/interfaces/http.interface'; + +function getStatisticInfo(data?: any): Promise> { + return http.get(`/api/rest/v2/manager/monitor/value/node_num`); +} +function getDisksCapacity(data?: any): Promise> { + return http.get(`/api/rest/v2/manager/monitor/value/disks_capacity`); +} + +function getQPS(data?: any): Promise> { + return http.post(`/api/rest/v2/manager/monitor/timeserial/${data.type}?start=${data.start}&end=${data.end}`, data); +} +function getQueryLatency(data?: any): Promise> { + return http.post(`/api/rest/v2/manager/monitor/timeserial/query_latency?start=${data.start}&end=${data.end}`, data); +} + +function getErrorRate(data?: any): Promise> { + return http.post( + `/api/rest/v2/manager/monitor/timeserial/query_err_rate?start=${data.start}&end=${data.end}`, + data, + ); +} +function getConnectionTotal(data?: any): Promise> { + return http.post(`/api/rest/v2/manager/monitor/timeserial/conn_total?start=${data.start}&end=${data.end}`, data); +} +function getScheduled(data?: any): Promise> { + return http.post( + `/api/rest/v2/manager/monitor/timeserial/scheduled_tablet_num?start=${data.start}&end=${data.end}`, + data, + ); +} + +function getStatistic(): Promise> { + return http.get(`/api/rest/v2/manager/monitor/value/statistic`); +} +function getTxnStatus(data?: any): Promise> { + return http.post(`/api/rest/v2/manager/monitor/timeserial/txn_status?start=${data.start}&end=${data.end}`, data); +} + +export const MonitorAPI = { + getStatisticInfo, + getDisksCapacity, + getQPS, + getQueryLatency, + getErrorRate, + getConnectionTotal, + getTxnStatus, + getScheduled, + getStatistic, +}; diff --git a/frontend/src/routes/cluster/monitor/monitor.data.ts b/frontend/src/routes/cluster/monitor/monitor.data.ts new file mode 100644 index 0000000..44bd283 --- /dev/null +++ b/frontend/src/routes/cluster/monitor/monitor.data.ts @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import dayjs from 'dayjs'; +export function getTimes(now: dayjs.Dayjs) { + return [ + { + value: '1', + text: '最近半小时', + end: new Date().getTime(), + start: now.subtract(30, 'minute').valueOf(), + format: 'HH:mm', + }, + { + value: '2', + text: '最近1小时', + end: new Date().getTime(), + start: now.subtract(1, 'hour').valueOf(), + format: 'HH:mm', + }, + { + value: '3', + text: '最近6小时', + end: new Date().getTime(), + start: now.subtract(6, 'hour').valueOf(), + format: 'HH:mm', + }, + { + value: '4', + text: '最近1天', + end: new Date().getTime(), + start: now.subtract(1, 'day').valueOf(), + format: 'HH:mm', + }, + { + value: '5', + text: '最近三天', + end: new Date().getTime(), + start: now.subtract(3, 'day').valueOf(), + format: 'MM/DD HH:mm', + }, + { + value: '6', + text: '最近一周', + end: new Date().getTime(), + start: now.subtract(1, 'week').valueOf(), + format: 'MM/DD HH:mm', + }, + { + value: '7', + text: '最近半个月', + end: new Date().getTime(), + start: now.subtract(15, 'day').valueOf(), + format: 'MM/DD HH:mm', + }, + { + value: '8', + text: '最近一个月', + end: new Date().getTime(), + start: now.subtract(1, 'month').valueOf(), + format: 'MM/DD HH:mm', + }, + ]; +} + +export const CHARTS_OPTIONS = { + title: { text: '' }, + grid: { + bottom: 100, + }, + legend: { + data: [], + left: 0, + bottom: '0', + height: 80, + width: '100%', + type: 'scroll', + itemHeight: 10, + orient: 'vertical', + pageIconSize: 7, + textStyle: { + overflow: 'breakAll', + }, + }, + tooltip: { + trigger: 'axis', + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: [], + }, + yAxis: { + type: 'value', + }, + series: [], +}; diff --git a/frontend/src/routes/cluster/monitor/monitor.less b/frontend/src/routes/cluster/monitor/monitor.less new file mode 100644 index 0000000..8383000 --- /dev/null +++ b/frontend/src/routes/cluster/monitor/monitor.less @@ -0,0 +1,24 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.monitor { + min-height: 100%; + + h4 { + margin-left: 10px; + } +} diff --git a/frontend/src/routes/cluster/monitor/monitor.tsx b/frontend/src/routes/cluster/monitor/monitor.tsx new file mode 100644 index 0000000..afb1321 --- /dev/null +++ b/frontend/src/routes/cluster/monitor/monitor.tsx @@ -0,0 +1,438 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/* eslint-disable prettier/prettier */ +import { Row, Col, Select, Collapse, Card, Button, Divider } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import dayjs from 'dayjs'; +import './monitor.less'; + +import { LineChart } from 'echarts/charts'; +import { + GridComponent, + PolarComponent, + RadarComponent, + GeoComponent, + SingleAxisComponent, + ParallelComponent, + CalendarComponent, + GraphicComponent, + ToolboxComponent, + TooltipComponent, + TitleComponent, + LegendComponent, +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; +import { MonitorAPI } from './monitor.api'; +import { CHARTS_OPTIONS, getTimes } from './monitor.data'; +import { deepClone, formatBytes } from '@src/utils/utils'; +import classNames from 'classnames'; + +echarts.use([ + LineChart, + CanvasRenderer, + TitleComponent, + GridComponent, + PolarComponent, + RadarComponent, + GeoComponent, + SingleAxisComponent, + ParallelComponent, + CalendarComponent, + GraphicComponent, + ToolboxComponent, + TooltipComponent, + LegendComponent, +]); + +const { Panel } = Collapse; + +const gridStyle: React.CSSProperties = { + width: 'calc(100% / 6)', + textAlign: 'center', +}; +export function ClusterMonitor() { + const { t } = useTranslation(); + const [TIMES, setTIMES] = useState(() => getTimes(dayjs())); + const [statisticInfo, setStatisticInfo] = useState({}); + const [disksCapacity, setDisksCapacity] = useState({}); + // const [feNodes, setFENodes] = useState([]); + const [statistic, setStatistic] = useState([]); + + const [currentTime, setCurrentTime] = useState(TIMES[1]); + const [qps, setQPS] = useState({}); + const [errorRate, setErrorRate] = useState({}); + const [queryLatency, setQueryLatency] = useState({}); + const [connectionTotal, setConnectionTotal] = useState({}); + const [txnStatus, setTxnStatus] = useState({}); + const [pointNum] = useState('100'); + const [scheduled, setScheduled] = useState({}); + + useEffect(() => { + init(); + }, [currentTime.value, currentTime.start]); + + function init() { + getStatisticInfo(); + getDisksCapacity(); + getStatistic(); + + getQPS(); + getQueryLatency(); + getErrorRate(); + getConnectionTotal(); + getTxnStatus(); + getScheduled(); + } + + function getStatistic() { + MonitorAPI.getStatistic() + .then(res => { + setStatistic(res.data); + }) + .catch(err => { + console.log(err); + }); + } + + function getScheduled() { + MonitorAPI.getScheduled({ + start: currentTime.start, + end: currentTime.end, + point_num: pointNum, + }) + .then(res => { + const option = formatData(res.data, '{t`clusterScheduling`}'); + option.legend.left = 20 + setScheduled(option); + }) + .catch(err => { + console.log(err); + }); + } + + function getStatisticInfo() { + MonitorAPI.getStatisticInfo() + .then(res => { + setStatisticInfo(res.data || {}); + }) + .catch(err => { + console.log(err); + }); + } + function getDisksCapacity() { + MonitorAPI.getDisksCapacity() + .then(res => { + setDisksCapacity(res.data); + }) + .catch(err => { + console.log(err); + }); + } + + function formatData(response: any, title: string) { + const option = deepClone(CHARTS_OPTIONS); + // option.title.text = title; + const xAxisData = response?.x_value.map((item: string) => dayjs(item).format(currentTime.format)); + option.xAxis.data = xAxisData; + const series: any[] = []; + const legendData: any[] = []; + function transformChartsData(y_value: string, formatter?: string) { + for (const [key, value] of Object.entries(y_value)) { + if (Array.isArray(value)) { + legendData.push(key); + series.push({ + name: key, + data: value.map(item => { + if (formatter === 'MB') { + return typeof item === 'number' ? formatBytes(item, 2, false) : item; + } + return typeof item === 'number' ? item.toFixed(3) : item; + }), + type: 'line', + }); + } else { + transformChartsData(value); + } + } + return series; + } + if (title === '节点内存(MB)') { + transformChartsData(response?.y_value || {}, 'MB'); + } else { + transformChartsData(response?.y_value || {}); + } + + option.legend.data = legendData; + option.grid = { + bottom: 110, + }; + option.series = series; + return option; + } + function getQPS() { + MonitorAPI.getQPS({ + type: 'qps', + start: currentTime.start, + end: currentTime.end, + }) + .then(res => { + const option = formatData(res.data, t`QPS`); + setQPS(option); + }) + .catch(err => { + console.log(err); + }); + } + function getErrorRate() { + MonitorAPI.getErrorRate({ + start: currentTime.start, + end: currentTime.end, + }) + .then(res => { + const option = formatData(res.data, t`queryErrorRate`); + setErrorRate(option); + }) + .catch(err => { + console.log(err); + }); + } + function getQueryLatency() { + MonitorAPI.getQueryLatency({ + start: currentTime.start, + end: currentTime.end, + quantile: '0.99', + }) + .then(res => { + const option = formatData(res.data, t`99th`); + option.grid.left = 60 + option.legend.left = 40 + setQueryLatency(option); + }) + .catch(err => { + console.log(err); + }); + } + function getConnectionTotal() { + MonitorAPI.getConnectionTotal({ + start: currentTime.start, + end: currentTime.end, + }) + .then(res => { + const option = formatData(res.data, t`numberOfConnections`); + option.legend.left = 20 + option.grid = { + bottom: 120, + }; + setConnectionTotal(option); + }) + .catch(err => { + console.log(err); + }); + } + function getTxnStatus() { + MonitorAPI.getTxnStatus({ + start: currentTime.start, + end: currentTime.end, + }) + .then(res => { + const option = formatData(res.data, t`importRate`); + function transformToZh_CN(text: string) { + switch (text) { + case 'success': + return '导入成功'; + case 'failed': + return '导入失败'; + case 'begin': + return '提交导入'; + } + } + const series = option.series.map((item: any) => { + item.name = transformToZh_CN(item.name); + return item; + }); + const legendData = option.legend.data.map((item: any) => { + return transformToZh_CN(item); + }); + option.series = series; + option.legend.data = legendData; + option.legend.left = 20 + option.grid = { + bottom: 120, + }; + option.legend.bottom = 25 + option.color = ['#91cc75','#d9363e','#fac858'] + const txnStatus = option; + setTxnStatus(txnStatus); + }) + .catch(err => { + console.log(err); + }); + } + + return ( +
+ +
+ + {t`viewTime`}: + + + + + + + + +
+ + + + +

{t`numberOfFeNodes`}

+ {statisticInfo?.fe_node_num_total} +
+ +

{t`FeNumberOfSurvivingNodes`}

+ {statisticInfo?.fe_node_num_alive} +
+ +

{t`NumberOfBENodes`}

+ {statisticInfo?.be_node_num_total} +
+ +

{t`BeNumberOfSurvivingNodes`}

+ {statisticInfo?.be_node_num_alive} +
+ +

{t`totalSpace`}

+ {formatBytes(disksCapacity?.be_disks_total) || t`noData`} +
+ +

{t`usedSpace`}

+ {formatBytes(disksCapacity?.be_disks_used) || t`noData`} +
+
+ + +

{t`QPS`}

+ +
+ +

{t`99th`}

+ +
+ +

{t`queryErrorRate`}

+ +
+
+ + + +

{t`numberOfConnections`}

+ +
+ +

{t`importRate`}

+ +
+
+
+ + + +

{t`clusterScheduling`}

+ +
+ + +

{t`unhealthyFragmentation`}

+ {statistic?.unhealthy_tablet_num} +
+
+
+
+
+
+ + ); +} diff --git a/frontend/src/routes/cluster/nodes/index.tsx b/frontend/src/routes/cluster/nodes/index.tsx new file mode 100644 index 0000000..920b6dd --- /dev/null +++ b/frontend/src/routes/cluster/nodes/index.tsx @@ -0,0 +1,93 @@ +import React, { useContext, useEffect } from 'react'; +import { message, Table } from 'antd'; +import { useTranslation } from 'react-i18next'; +import StatusMark from '@src/components/status-mark'; +import { useAsync } from '@src/hooks/use-async'; +import * as ClusterAPI from '../cluster.api'; +import { UserInfoContext } from '@src/common/common.context'; + +const enum ModuleNameEnum { + FRONTEND = 'fe', + BACKEND = 'be', + BROKER = 'broker', +} + +const enum OperateStatusEnum { + SUCCESS = 'SUCCESS', + INIT = 'INIT', + PROCESSING = 'PROCESSING', + FAIL = 'FAIL', + CANCEL = 'CANCEL', +} + +interface NodeListItem { + instanceId: number; + moduleName: ModuleNameEnum; + nodeHost: string; + operateStatus: OperateStatusEnum; +} + +export default function Nodes() { + const { t } = useTranslation(); + const userInfo = useContext(UserInfoContext)!; + const { data: nodeList, loading, run: runGetNodeList } = useAsync({ loading: true, data: [] }); + useEffect(() => { + runGetNodeList(ClusterAPI.getNodeList(userInfo.space_id), { setStartLoading: false }).catch(res => { + message.error(res.msg); + }); + }, [runGetNodeList, userInfo.space_id]); + const columns = [ + { + title: t`nodeId`, + dataIndex: 'instanceId', + }, + { + title: t`nodeType`, + dataIndex: 'moduleName', + filters: [ + { + text: 'Frontend', + value: ModuleNameEnum.FRONTEND, + }, + { + text: 'Backend', + value: ModuleNameEnum.BACKEND, + }, + { + text: 'Broker', + value: ModuleNameEnum.BROKER, + }, + ], + onFilter: (value: any, record: NodeListItem) => record.moduleName === value, + render: (moduleName: ModuleNameEnum) => {resolveModuleName(moduleName)}, + }, + { + title: t`hostIp`, + dataIndex: 'nodeHost', + }, + { + title: t`nodeStatus`, + dataIndex: 'operateStatus', + render: (status: OperateStatusEnum) => ( + + {status === OperateStatusEnum.SUCCESS ? t`normal` : t`abnormal`} + + ), + }, + ]; + + const resolveModuleName = (moduleName: ModuleNameEnum) => { + switch (moduleName) { + case ModuleNameEnum.FRONTEND: + return 'Frontend'; + case ModuleNameEnum.BACKEND: + return 'Backend'; + case ModuleNameEnum.BROKER: + return 'Broker'; + default: + return ''; + } + }; + + return
; +} diff --git a/frontend/src/routes/cluster/overview/index.module.less b/frontend/src/routes/cluster/overview/index.module.less new file mode 100644 index 0000000..67ad0c6 --- /dev/null +++ b/frontend/src/routes/cluster/overview/index.module.less @@ -0,0 +1,7 @@ +.infoRow { + width: 80%; + margin-bottom: 1.9vh; + .infoRowContent { + word-wrap: break-word; + } +} diff --git a/frontend/src/routes/cluster/overview/index.tsx b/frontend/src/routes/cluster/overview/index.tsx new file mode 100644 index 0000000..eb38c63 --- /dev/null +++ b/frontend/src/routes/cluster/overview/index.tsx @@ -0,0 +1,173 @@ +import React, { useCallback, useContext, useEffect } from 'react'; +import { Button, Col, Divider, message, PageHeader, Row, Typography } from 'antd'; +import { DatabaseOutlined, TableOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import moment from 'moment'; +import styles from './index.module.less'; +import StatusMark from '@src/components/status-mark'; +import LiquidFillChart from '../components/liquid-fill-chart'; +import DataOverviewItem from '../components/data-overview-item'; +import { UserInfoContext } from '@src/common/common.context'; +import { useAsync } from '@src/hooks/use-async'; +import * as ClusterAPI from '../cluster.api'; +import { SpaceAPI } from '../../space/space.api'; +import LoadingLayout from '@src/components/loading-layout'; +import { isSuccess } from '@src/utils/http'; + +export default function ClusterOverview() { + const { t } = useTranslation(); + const userInfo = useContext(UserInfoContext)!; + const { loading: startLoading, run: runClusterStart } = useAsync(); + const { loading: stopLoading, run: runClusterStop } = useAsync(); + const { loading: restartLoading, run: runClusterRestart } = useAsync(); + const { + data: clusterInfo, + loading: clusterInfoLoading, + run: runGetClusterInfo, + } = useAsync<{ + overview: Record; + space: Record; + }>({ + loading: true, + data: { + overview: {}, + space: {}, + }, + }); + const getClusterInfo = useCallback( + (setStartLoading: boolean = false) => { + return runGetClusterInfo( + Promise.all([ + SpaceAPI.spaceGet(userInfo.space_id + '').then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }), + ClusterAPI.getClusterOverview(), + ]).then(res => { + return { + space: res[0], + overview: res[1], + }; + }), + { setStartLoading }, + ).catch(res => { + message.error(res.msg); + }); + }, + [runGetClusterInfo, userInfo.space_id], + ); + + useEffect(() => { + getClusterInfo(); + }, [getClusterInfo]); + + const handleStart = () => { + runClusterStart(ClusterAPI.startCluster(userInfo.space_id)) + .then(() => { + message.success('启动成功'); + getClusterInfo(true); + }) + .catch(res => message.error(res.msg)); + }; + + const handleStop = () => { + runClusterStop(ClusterAPI.stopCluster(userInfo.space_id)) + .then(() => { + message.success('停止成功'); + getClusterInfo(true); + }) + .catch(res => message.error(res.msg)); + }; + + const handleRestart = () => { + runClusterRestart(ClusterAPI.restartCluster(userInfo.space_id)) + .then(() => { + message.success('重启成功'); + getClusterInfo(true); + }) + .catch(res => message.error(res.msg)); + }; + + return ( + + + {clusterInfo?.space.status === 'NORMAL' ? t`normal` : t`abnormal`} + + } + extra={ + <> + + + + + } + > + + {t`clusterId`}: + + {clusterInfo?.space.id} + + + + {t`CreationTime`}: + + {moment(clusterInfo?.space.createTime).format('YYYY-MM-DD')} + + + + JDBC URL: + + jdbc:mysql://{clusterInfo?.space.address}:{clusterInfo?.space.queryPort} + /DB_NAME?user=USER_NAME&password=PASSWORD + + + + + {t`sourceUsage`} + + + + + + + + + {t`dataOverview`} + + + + } + /> + + + } + /> + + + + + + + ); +} diff --git a/frontend/src/routes/container.less b/frontend/src/routes/container.less new file mode 100644 index 0000000..3d355ba --- /dev/null +++ b/frontend/src/routes/container.less @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.container { + width: calc(100% - 80px); + background: white; + height: 100%; + overflow-y: scroll; + margin-left: 80px; +} +.container-content { + padding: 20px; + min-height: calc(100% - 44px); +} diff --git a/frontend/src/routes/container.tsx b/frontend/src/routes/container.tsx new file mode 100644 index 0000000..4849d8a --- /dev/null +++ b/frontend/src/routes/container.tsx @@ -0,0 +1,74 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React from 'react'; +import styles from './container.less'; + +import { Layout } from 'antd'; +import { Redirect, Route, Router, Switch, useHistory } from 'react-router-dom'; +import { Sidebar } from '@src/components/sidebar/sidebar'; +import { Header } from '@src/components/header/header'; +import { CommonAPI } from '@src/common/common.api'; +import { UserInfoContext } from '@src/common/common.context'; +import { UserInfo } from '@src/common/common.interface'; +import { Dashboard } from './dashboard/dashboard'; +import { Meta } from './meta/meta'; +import { NodeDashboard } from './node/dashboard'; +import {NodeList} from './node/list'; +import { Configuration } from './node/list/configuration'; +import { FEConfiguration } from './node/list/fe-configuration'; +import { BEConfiguration } from './node/list/be-configuration'; +import { Query } from './query'; +import { QueryDetails } from './query/query-details'; +import { Cluster } from './cluster/cluster'; +import { UserSetting } from './user-setting'; +import { useUserInfo } from '@src/hooks/use-userinfo.hooks'; +export function Container(props: any) { + const [userInfo] = useUserInfo(); + const history = useHistory(); + + return ( + + + + + +
+
+
+ + + + + + + + + + + + + + +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/routes/dashboard/components/dashboard-item/index.tsx b/frontend/src/routes/dashboard/components/dashboard-item/index.tsx new file mode 100644 index 0000000..f6af493 --- /dev/null +++ b/frontend/src/routes/dashboard/components/dashboard-item/index.tsx @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import CSSModules from 'react-css-modules'; +import React, { useCallback, useState } from 'react'; +import styles from './message-item.less'; +import { messItemProps } from './message-item.interface'; + +function MessageItem(props: messItemProps) { + return ( +
+
+

{props.icon}

+

{props.title}

+
+
+ {props.des} +
+
+ ); +} + +export const DashboardItem = CSSModules(styles)(MessageItem); diff --git a/frontend/src/routes/dashboard/components/dashboard-item/message-item.interface.ts b/frontend/src/routes/dashboard/components/dashboard-item/message-item.interface.ts new file mode 100644 index 0000000..89bff2b --- /dev/null +++ b/frontend/src/routes/dashboard/components/dashboard-item/message-item.interface.ts @@ -0,0 +1,23 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ +export interface messItemProps { + title: string; + icon: any; + des: number | string; +} diff --git a/frontend/src/routes/dashboard/components/dashboard-item/message-item.less b/frontend/src/routes/dashboard/components/dashboard-item/message-item.less new file mode 100644 index 0000000..ed4f212 --- /dev/null +++ b/frontend/src/routes/dashboard/components/dashboard-item/message-item.less @@ -0,0 +1,57 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +.mess-item { + position: relative; + display: flex; + // height: 64px; + width: 49%; + // height: 28vh; + padding: 5vh; + margin-top: 20px; + background: #edf2f5; + border-radius: 20px; + // box-shadow: 3px 3px 3px #e2dede; + + .mess-item-title { + width: 35%; + text-align: center; + + .mess-item-icon { + margin: 0; + font-size: 5vw; + color: #6536cc; + } + + .mess-item-name { + margin: 0; + font-size: 1vw; + color: #7f7f7f; + } + } + + .mess-item-content { + display: flex; + align-items: center; + justify-content: center; + width: 65%; + font-size: 4vw; + // vertical-align: middle; + } +} diff --git a/frontend/src/routes/dashboard/connect-info/connect-info.less b/frontend/src/routes/dashboard/connect-info/connect-info.less new file mode 100644 index 0000000..1b25c82 --- /dev/null +++ b/frontend/src/routes/dashboard/connect-info/connect-info.less @@ -0,0 +1,23 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.connect-info-container { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: 0 3px 3px 0; +} \ No newline at end of file diff --git a/frontend/src/routes/dashboard/connect-info/connect-info.tsx b/frontend/src/routes/dashboard/connect-info/connect-info.tsx new file mode 100644 index 0000000..6d6aef9 --- /dev/null +++ b/frontend/src/routes/dashboard/connect-info/connect-info.tsx @@ -0,0 +1,68 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import CSSModules from 'react-css-modules'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './connect-info.less'; +import { Card, message } from 'antd'; +import { DashboardAPI } from '../dashboard.api'; + +export function Component(props: any) { + const { t } = useTranslation(); + const [spaceList, setSpaceList] = useState({}); + const [https, setHttps] = useState([]); + const [mysqls, setMysqls] = useState([]); + useEffect(() => { + getOverviewInfo(); + }, []); + async function getOverviewInfo() { + DashboardAPI.getSpaceList().then(res1 => { + const { msg, data, code } = res1; + let http = ''; + let mysql = ''; + if (code === 0) { + if (res1.data) { + setSpaceList(res1.data); + for (let i = 0; i < res1.data.http.length; i++) { + http += res1.data.http[i] + '; '; + } + for (let i = 0; i < res1.data.mysql.length; i++) { + mysql += res1.data.mysql[i] + '; '; + } + setHttps(http); + setMysqls(mysql); + } + } else if (code === 404) { + message.error(t`updateDoris`); + window.location.href = `${window.location.origin}`; + } else { + message.error(msg); + } + }); + } + return ( +
+ +

{t`httpInfo`} {https}

+

{t`JDBCInfo`} {mysqls}

+
+
+ ); +} + +export const ConnectInfo = CSSModules(styles)(Component); diff --git a/frontend/src/routes/dashboard/dashboard.api.ts b/frontend/src/routes/dashboard/dashboard.api.ts new file mode 100644 index 0000000..6750f5b --- /dev/null +++ b/frontend/src/routes/dashboard/dashboard.api.ts @@ -0,0 +1,31 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { http } from '@src/utils/http'; +import { IResult } from '@src/interfaces/http.interface'; +import { MetaInfoResponse } from './dashboard.interface'; + +function getMetaInfo(): Promise> { + return http.get(`/api/cluster/overview`); +} +function getSpaceList() { + return http.get(`/api/rest/v2/manager/cluster/cluster_info/conn_info`); +} +export const DashboardAPI = { + getMetaInfo, + getSpaceList, +}; diff --git a/frontend/src/routes/dashboard/dashboard.data.ts b/frontend/src/routes/dashboard/dashboard.data.ts new file mode 100644 index 0000000..d225780 --- /dev/null +++ b/frontend/src/routes/dashboard/dashboard.data.ts @@ -0,0 +1,21 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export enum DashboardTabEnum { + Overview = 'overview', + ConnectInfo = 'connect-info', +} \ No newline at end of file diff --git a/frontend/src/routes/dashboard/dashboard.interface.ts b/frontend/src/routes/dashboard/dashboard.interface.ts new file mode 100644 index 0000000..8522a6f --- /dev/null +++ b/frontend/src/routes/dashboard/dashboard.interface.ts @@ -0,0 +1,33 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { RouteComponentProps } from 'react-router'; + +export interface DashboardProps extends RouteComponentProps { + +} +export interface GetMetaInfoRequestParams { + nsId: string; +} +export interface MetaInfoResponse { + beCount: number; + dbCount: number; + diskOccupancy: number; + feCount: number; + remainDisk: number; + tblCount: number; +} diff --git a/frontend/src/routes/dashboard/dashboard.less b/frontend/src/routes/dashboard/dashboard.less new file mode 100644 index 0000000..27bd9ed --- /dev/null +++ b/frontend/src/routes/dashboard/dashboard.less @@ -0,0 +1,27 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +.home-main { + background: #f9fbfc; + border-radius: 16px; + + .home-content { + padding: 0 38px 15px; + } +} diff --git a/frontend/src/routes/dashboard/dashboard.tsx b/frontend/src/routes/dashboard/dashboard.tsx new file mode 100644 index 0000000..fd1c014 --- /dev/null +++ b/frontend/src/routes/dashboard/dashboard.tsx @@ -0,0 +1,75 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import CSSModules from 'react-css-modules'; +import React, { useState } from 'react'; +import styles from './dashboard.less'; +import { CommonHeader } from '@src/components/common-header/header'; +import { ConnectInfo } from './connect-info/connect-info'; +import { DashboardTabEnum } from './dashboard.data'; +import { HomeOutlined } from '@ant-design/icons'; +import { Link, match, Redirect, Route, Switch, useHistory, useLocation } from 'react-router-dom'; +import { Overview } from './overview/overview'; +import { Tabs } from 'antd'; +import { useTranslation } from 'react-i18next'; + +const ICON_HOME = ; +const { TabPane } = Tabs; + +function DashboardComponent(props: any) { + const { match } = props; + const { t } = useTranslation(); + const [refreshToken, setRefreshToken] = useState(new Date().getTime()); + const history = useHistory(); + const location = useLocation(); + const [activeKey, setTabsActiveKey] = useState(DashboardTabEnum.Overview); + React.useEffect( ()=>{ + if(location.pathname.includes(DashboardTabEnum.ConnectInfo)) { + setTabsActiveKey(DashboardTabEnum.ConnectInfo); + } else { + setTabsActiveKey(DashboardTabEnum.Overview); + } + }, []) + return ( +
+ setRefreshToken(new Date().getTime())} + > +
+ { + setTabsActiveKey(key); + if (key === DashboardTabEnum.Overview) { + history.push(`${match.path}/overview`); + } else { + history.push(`${match.path}/connect-info`); + } + }}> + + + +
+ + + + + +
+ ); +} +export const Dashboard = CSSModules(styles)(DashboardComponent); diff --git a/frontend/src/routes/dashboard/overview/overview.less b/frontend/src/routes/dashboard/overview/overview.less new file mode 100644 index 0000000..93c5539 --- /dev/null +++ b/frontend/src/routes/dashboard/overview/overview.less @@ -0,0 +1,23 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.overview-container { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin: 0 40px; +} \ No newline at end of file diff --git a/frontend/src/routes/dashboard/overview/overview.tsx b/frontend/src/routes/dashboard/overview/overview.tsx new file mode 100644 index 0000000..d8c0559 --- /dev/null +++ b/frontend/src/routes/dashboard/overview/overview.tsx @@ -0,0 +1,69 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import CSSModules from 'react-css-modules'; +import React, { useEffect, useState } from 'react'; +import styles from './overview.less'; +import { useTranslation } from 'react-i18next'; +import { DashboardAPI } from '../dashboard.api'; +import { DashboardItem } from '../components/dashboard-item'; +import { HddOutlined, LaptopOutlined, PieChartOutlined, TableOutlined } from '@ant-design/icons'; +import { message } from 'antd'; +import { MetaInfoResponse } from '../dashboard.interface'; + +export function Component(props: any) { + const { t } = useTranslation(); + const [metaInfo, setMetaInfo] = useState({ + beCount: 0, + dbCount: 0, + diskOccupancy: 0, + feCount: 0, + remainDisk: 0, + tblCount: 0, + }); + useEffect(() => { + getOverviewInfo(); + }, []); + async function getOverviewInfo() { + const res = await DashboardAPI.getMetaInfo(); + const { msg, data, code } = res; + if (code === 0) { + if (res.data) { + res.data.remainDisk = res.data.remainDisk / 1024; + setMetaInfo(res.data); + } + } else { + message.error(msg); + } + } + return ( +
+ } des={metaInfo.dbCount} /> + } des={metaInfo.tblCount} /> + } + des={ + metaInfo.diskOccupancy == 0 ? metaInfo.diskOccupancy + '%' : metaInfo.diskOccupancy.toFixed(2) + '%' + } + /> + } des={metaInfo.remainDisk.toFixed(2) + 'TB'} /> +
+ ); +} + +export const Overview = CSSModules(styles)(Component); diff --git a/frontend/src/routes/database/database.api.ts b/frontend/src/routes/database/database.api.ts new file mode 100644 index 0000000..d3767b7 --- /dev/null +++ b/frontend/src/routes/database/database.api.ts @@ -0,0 +1,29 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +import { http } from '@src/utils/http'; +import { GetDatabaseInfoRequestParams, DatabaseInfoResponse } from './database.interface'; +import { IResult } from '@src/interfaces/http.interface'; +function getDatabaseInfo(params: GetDatabaseInfoRequestParams): Promise> { + return http.get(`/api/meta/dbId/${params.dbId}/info`); +} + +export const DatabaseAPI = { + getDatabaseInfo, +}; diff --git a/frontend/src/routes/database/database.interface.ts b/frontend/src/routes/database/database.interface.ts new file mode 100644 index 0000000..fcea927 --- /dev/null +++ b/frontend/src/routes/database/database.interface.ts @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +export interface GetDatabaseInfoRequestParams { + dbId: string; +} +export interface DatabaseInfoResponse { + createTime: string; + creator: string; + describe: string; + name: string; +} diff --git a/frontend/src/routes/database/database.module.less b/frontend/src/routes/database/database.module.less new file mode 100644 index 0000000..12720fe --- /dev/null +++ b/frontend/src/routes/database/database.module.less @@ -0,0 +1,42 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +.database-main { + background: #fff; + border-radius: 16px; + + .database-content { + padding: 0 38px 15px; + + .items-container { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + } + + .database-content-des { + padding: 50px; + + :global .ant-form-item-control-input-content { + padding-left: 15px; + color: #948f8f; + } + } + } +} diff --git a/frontend/src/routes/database/index.tsx b/frontend/src/routes/database/index.tsx new file mode 100644 index 0000000..2edf4a8 --- /dev/null +++ b/frontend/src/routes/database/index.tsx @@ -0,0 +1,91 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +import React, { useState, useEffect } from 'react'; +import styles from './database.module.less'; +import CSSModules from 'react-css-modules'; +import { useHistory } from 'react-router-dom'; +import { Layout, Form, Tabs, Button, message } from 'antd'; +import { HddOutlined } from '@ant-design/icons'; +import { CommonHeader } from '@src/components/common-header/header'; +import { DatabaseAPI } from './database.api'; +import { DatabaseInfoResponse } from './database.interface'; +import { useTranslation } from 'react-i18next'; +import { getShowTime } from '@src/utils/utils'; + +const { Content, Sider } = Layout; +const iconDatabase = ; +const { TabPane } = Tabs; +const layout = { + labelCol: { span: 6 }, + wrapperCol: { span: 18 }, +}; + +function Database(props: any) { + const history = useHistory(); + const { t } = useTranslation(); + const [dbName, setDbName] = useState(''); + const [databaseInfo, setDatabaseInfo] = useState({ + createTime: '', + creator: '', + describe: '', + name: '', + }); + useEffect(() => { + refresh(); + }, [window.location.href]); + function refresh() { + const id = localStorage.getItem('database_id'); + const name = localStorage.getItem('database_name'); + if (!id) { + return; + } + setDbName(name); + DatabaseAPI.getDatabaseInfo({ dbId: id }).then(res => { + const { msg, code, data } = res; + if (code === 0) { + setDatabaseInfo(res.data); + } else { + message.error(msg); + } + }); + } + return ( + + +
+ + +
+
+ {databaseInfo.name} + + {databaseInfo.describe ? databaseInfo.describe : '-'} + + {getShowTime(databaseInfo.createTime) ? getShowTime(databaseInfo.createTime) : '-'} + +
+
+
+
+
+ ); +} + +export default CSSModules(styles)(Database); diff --git a/frontend/src/routes/initialize/auths/auth.tsx b/frontend/src/routes/initialize/auths/auth.tsx new file mode 100644 index 0000000..4b06d18 --- /dev/null +++ b/frontend/src/routes/initialize/auths/auth.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { Redirect, Route, Switch, useRouteMatch } from "react-router"; +import { AuthLDAP } from "./ldap/ldap"; +import { AuthStudio } from "./studio/studio"; + +export function InitializeAuth(props: any) { + const match = useRouteMatch(); + return ( + + + + + + ) +} diff --git a/frontend/src/routes/initialize/auths/components/admin-user/admin-user.less b/frontend/src/routes/initialize/auths/components/admin-user/admin-user.less new file mode 100644 index 0000000..f1ef841 --- /dev/null +++ b/frontend/src/routes/initialize/auths/components/admin-user/admin-user.less @@ -0,0 +1,2 @@ +.admin-user { +} \ No newline at end of file diff --git a/frontend/src/routes/initialize/auths/components/admin-user/admin-user.tsx b/frontend/src/routes/initialize/auths/components/admin-user/admin-user.tsx new file mode 100644 index 0000000..03e0500 --- /dev/null +++ b/frontend/src/routes/initialize/auths/components/admin-user/admin-user.tsx @@ -0,0 +1,106 @@ +import { InitializeAPI } from '@src/routes/initialize/initialize.api'; +import { isSuccess } from '@src/utils/http'; +import { Form, Radio, Input, Button, message } from 'antd'; +import { useForm } from 'antd/lib/form/Form'; +import React from 'react'; +import { RouteProps, useHistory, useRouteMatch } from 'react-router'; +import styles from './admin-user.less'; +interface AdminUserProps extends RouteProps {} + +export function AdminUser(props: AdminUserProps) { + const [form] = useForm(); + const history = useHistory(); + const match = useRouteMatch(); + async function onFinish(values: any) { + const { password_confirm, username, ...params } = values; + const res = await InitializeAPI.setAdmin({ ...params, name: username }); + if (isSuccess(res)) { + history.push('/initialize/auth/studio/finish'); + } else { + message.error(res.msg); + } + } + + return ( +
+
+ + + + + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入密码不一致')); + }, + }), + ]} + label="确认密码" + > + + + + + + +
+ ); +} diff --git a/frontend/src/routes/initialize/auths/components/finish/finish.tsx b/frontend/src/routes/initialize/auths/components/finish/finish.tsx new file mode 100644 index 0000000..ced7bcf --- /dev/null +++ b/frontend/src/routes/initialize/auths/components/finish/finish.tsx @@ -0,0 +1,22 @@ +import { Result, Button } from 'antd'; +import React from 'react'; +import { useHistory } from 'react-router'; + +export function AuthFinish(props: any) { + const history = useHistory(); + const authType = props.mode === 'ldap' ? 'LDAP' : '本地'; + return ( + { + localStorage.setItem('initialized', 'true'); + history.push('/passport/login'); + }}> + 跳转至登录页面 + , + ]} + /> + ); +} diff --git a/frontend/src/routes/initialize/auths/ldap/ldap-admin-user/ldap-admin-user.tsx b/frontend/src/routes/initialize/auths/ldap/ldap-admin-user/ldap-admin-user.tsx new file mode 100644 index 0000000..6cd5fc7 --- /dev/null +++ b/frontend/src/routes/initialize/auths/ldap/ldap-admin-user/ldap-admin-user.tsx @@ -0,0 +1,60 @@ +import { useUsers } from '@src/hooks/use-users.hooks'; +import { InitializeAPI } from '@src/routes/initialize/initialize.api'; +import { isSuccess } from '@src/utils/http'; +import { Form, Radio, Input, Button, message, Select, Space } from 'antd'; +import { useForm } from 'antd/lib/form/Form'; +import React from 'react'; +import { RouteProps, useHistory, useRouteMatch } from 'react-router'; +import { useLDAPUsers } from './use-ldap-user.hooks'; +const { Option } = Select; + +interface AdminUserProps extends RouteProps {} + +export function LDAPAdminUser(props: AdminUserProps) { + const history = useHistory(); + const { ldapUsers } = useLDAPUsers(); + async function onFinish(values: any) { + const { username } = values; + const res = await InitializeAPI.setAdmin({ name: username }); + if (isSuccess(res)) { + history.push('/initialize/auth/ldap/finish'); + } else { + message.error(res.msg); + } + } + + return ( +
+
+ + + + + + + + +
+ ); +} diff --git a/frontend/src/routes/initialize/auths/ldap/ldap-admin-user/use-ldap-user.hooks.ts b/frontend/src/routes/initialize/auths/ldap/ldap-admin-user/use-ldap-user.hooks.ts new file mode 100644 index 0000000..95deace --- /dev/null +++ b/frontend/src/routes/initialize/auths/ldap/ldap-admin-user/use-ldap-user.hooks.ts @@ -0,0 +1,23 @@ +import { CommonAPI } from '@src/common/common.api'; +import { InitializeAPI } from '@src/routes/initialize/initialize.api'; +import { isSuccess } from '@src/utils/http'; +import { Dispatch, SetStateAction, useState, useEffect } from 'react'; + +export function useLDAPUsers(): { + ldapUsers: any[]; + setLDAPUsers: Dispatch>; + getLDAPUsers: () => void; +} { + const [ldapUsers, setLDAPUsers] = useState(); + useEffect(() => { + getLDAPUsers(); + }, []); + + async function getLDAPUsers() { + const res = await InitializeAPI.getLDAPUser(); + if (isSuccess(res)) { + setLDAPUsers(res.data); + } + } + return { ldapUsers, getLDAPUsers, setLDAPUsers }; +} diff --git a/frontend/src/routes/initialize/auths/ldap/ldap-config/ldap-config.tsx b/frontend/src/routes/initialize/auths/ldap/ldap-config/ldap-config.tsx new file mode 100644 index 0000000..717a264 --- /dev/null +++ b/frontend/src/routes/initialize/auths/ldap/ldap-config/ldap-config.tsx @@ -0,0 +1,135 @@ +import { InitializeAPI } from '@src/routes/initialize/initialize.api'; +import { isSuccess } from '@src/utils/http'; +import { Form, Input, Button, message, Radio, InputNumber } from 'antd'; +import React, { useState } from 'react'; +import { useHistory } from 'react-router'; + +export function LDAPConfig(props: any) { + const history = useHistory(); + const [loading, setLoading] = useState(false); + + async function onFinish(values: any) { + const { password_confirm, username, ...params } = values; + setLoading(true); + const res = await InitializeAPI.setLDAP({ + authType: 'ldap', + ldapSetting: { + ...values, + ['ldap-user-base']: [values['ldap-user-base']], + }, + }); + setLoading(false); + if (isSuccess(res)) { + console.log(res); + history.push('/initialize/auth/ldap/ldap-info'); + } else { + message.error(res.msg); + history.push('/initialize/auth/ldap/admin-user'); + } + } + return ( +
+

服务器

+
+ + + + + + + + + + None + SSL + StartTLS + + + + + + + + + {/* */} +

用户结构

+ {/*
*/} + + + + + + + {/* */} +

属性

+ {/*
*/} + + + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/routes/initialize/auths/ldap/ldap.tsx b/frontend/src/routes/initialize/auths/ldap/ldap.tsx new file mode 100644 index 0000000..1d61856 --- /dev/null +++ b/frontend/src/routes/initialize/auths/ldap/ldap.tsx @@ -0,0 +1,38 @@ +import { Steps } from "antd"; +import { pathToRegexp } from "path-to-regexp"; +import React, { useEffect, useState } from "react"; +import { Redirect, Route, Switch, useHistory, useRouteMatch } from "react-router"; +import { LDAPStepsEnum } from "../../initialize.data"; +import { AuthFinish } from "../components/finish/finish"; +import { LDAPAdminUser } from "./ldap-admin-user/ldap-admin-user"; +import { LDAPConfig } from "./ldap-config/ldap-config"; +const { Step } = Steps; + +export function AuthLDAP(props: any) { + const match = useRouteMatch(); + const [step, setStep] = useState(LDAPStepsEnum['ldap-info']); + const history = useHistory(); + useEffect(() => { + const regexp = pathToRegexp(`${match.path}/:step`); + const paths = regexp.exec(history.location.pathname); + const step = (paths as string[])[1]; + setStep(LDAPStepsEnum[step]); + }, [history.location.pathname]); + return ( +
+ + + + + +
+ + + + } /> + + +
+
+ ) +} diff --git a/frontend/src/routes/initialize/auths/studio/studio.tsx b/frontend/src/routes/initialize/auths/studio/studio.tsx new file mode 100644 index 0000000..0b8e844 --- /dev/null +++ b/frontend/src/routes/initialize/auths/studio/studio.tsx @@ -0,0 +1,38 @@ +import { Steps } from "antd"; +import React, { useEffect, useState } from "react"; +import { Redirect, Route, Switch, useHistory, useRouteMatch } from "react-router"; +import { pathToRegexp } from 'path-to-regexp'; +import { StudioStepsEnum } from "../../initialize.data"; +import { AdminUser } from "../components/admin-user/admin-user"; +import { AuthFinish } from "../components/finish/finish"; + +const { Step } = Steps; + +export function AuthStudio(props: any) { + const match = useRouteMatch(); + const [step, setStep] = useState(StudioStepsEnum['admin-user']); + const history = useHistory(); + useEffect(() => { + const regexp = pathToRegexp(`${match.path}/:step`); + const paths = regexp.exec(history.location.pathname); + const step = (paths as string[])[1]; + setStep(StudioStepsEnum[step]); + }, [history.location.pathname]); + return ( +
+ + + + +
+ + + } /> + + +
+
+ ) +} + + diff --git a/frontend/src/routes/initialize/initialize.api.ts b/frontend/src/routes/initialize/initialize.api.ts new file mode 100644 index 0000000..923fbdd --- /dev/null +++ b/frontend/src/routes/initialize/initialize.api.ts @@ -0,0 +1,35 @@ +import { IResult } from "@src/interfaces/http.interface"; +import { http } from "@src/utils/http"; + +function setAuthType(data?: any): Promise> { + return http.post(`/api/setting/init/authType`, data); +} + +function resetAuthType(data?: any): Promise> { + return http.get(`/api/setting/reset`, data); +} + +function setAdmin(data?: any): Promise> { + return http.post(`/api/setting/admin`, data); +} + +function setLDAP(data?: any): Promise> { + return http.post(`/api/setting/init/ldapStudio`, data); +} + +function getInitProperties(): Promise> { + return http.get(`/api/session/initProperties`); +} + +function getLDAPUser(): Promise> { + return http.get(`/api/setting/ldapUsers`); +} + +export const InitializeAPI = { + setAuthType, + resetAuthType, + setAdmin, + setLDAP, + getInitProperties, + getLDAPUser +} diff --git a/frontend/src/routes/initialize/initialize.data.ts b/frontend/src/routes/initialize/initialize.data.ts new file mode 100644 index 0000000..890d24f --- /dev/null +++ b/frontend/src/routes/initialize/initialize.data.ts @@ -0,0 +1,10 @@ +export enum StudioStepsEnum { + 'admin-user', + 'finish' +} + +export enum LDAPStepsEnum { + 'ldap-info', + 'admin-user', + 'finish' +} \ No newline at end of file diff --git a/frontend/src/routes/initialize/initialize.less b/frontend/src/routes/initialize/initialize.less new file mode 100644 index 0000000..bb83192 --- /dev/null +++ b/frontend/src/routes/initialize/initialize.less @@ -0,0 +1,15 @@ +.initialize { + height: calc(100vh - 150px); + display: flex; + width: 600px; + margin: 40px auto; + .initialize-steps-content { + width: calc(100% - 32px); + } +} +.initialize-container { + padding: 40px; + width: 600px; + margin: 0 auto; + height: calc(100vh - 65px); +} \ No newline at end of file diff --git a/frontend/src/routes/initialize/initialize.route.tsx b/frontend/src/routes/initialize/initialize.route.tsx new file mode 100644 index 0000000..fd2a0c9 --- /dev/null +++ b/frontend/src/routes/initialize/initialize.route.tsx @@ -0,0 +1,57 @@ +import { AuthTypeEnum } from '@src/common/common.data'; +import { Sidebar } from '@src/components/sidebar/sidebar'; +import { Header } from '@src/components/studio-header/header'; +import { useAuth } from '@src/hooks/use-auth'; +import React, { useEffect } from 'react'; +import { Route, Redirect, useRouteMatch, Switch, useHistory } from 'react-router'; +import { InitializeAuth } from './auths/auth'; +import { InitializePage } from './initialize'; +import { StudioStepsEnum, LDAPStepsEnum } from './initialize.data'; +import styles from './initialize.less'; + +export function Initialize(props: any) { + const match = useRouteMatch(); + const history = useHistory(); + const {initStep, authType: currentAuthType, initialized} = useAuth(); + + useEffect(() => { + if (currentAuthType && initStep) { + const feStep = initStep ? initStep - 1 : 1; + let stepPage = ''; + if (currentAuthType === AuthTypeEnum.STUDIO) { + stepPage = StudioStepsEnum[feStep]; + } else if (currentAuthType === AuthTypeEnum.LDAP){ + stepPage = LDAPStepsEnum[feStep]; + } + if (initialized) { + if (currentAuthType === AuthTypeEnum.STUDIO) { + stepPage = StudioStepsEnum[feStep]; + if (feStep === 1) { + history.push('/space'); + } + } else if (currentAuthType === AuthTypeEnum.LDAP){ + stepPage = LDAPStepsEnum[feStep]; + if (feStep === 2) { + history.push('/space'); + } + } + } else { + history.push(`${match.path}/auth/${currentAuthType}/${stepPage}`); + } + } + }, [currentAuthType, initialized]); + return ( +
+ +
+
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/routes/initialize/initialize.tsx b/frontend/src/routes/initialize/initialize.tsx new file mode 100644 index 0000000..8f80913 --- /dev/null +++ b/frontend/src/routes/initialize/initialize.tsx @@ -0,0 +1,42 @@ +import { AuthTypeEnum } from '@src/common/common.data'; +import { isSuccess } from '@src/utils/http'; +import { Button, Card, Input, message, Radio, Row, Space, Steps } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useHistory, useRouteMatch } from 'react-router'; +import { InitializeAPI } from './initialize.api'; +import { LDAPStepsEnum, StudioStepsEnum } from './initialize.data'; +import styles from './initialize.less'; + +const { Step } = Steps; +export function InitializePage(props: any) { + const [authType, setAuthType] = useState(AuthTypeEnum.STUDIO); + const match = useRouteMatch(); + const history = useHistory(); + async function handleSetAuthType() { + const res = await InitializeAPI.setAuthType({authType}); + if (isSuccess(res)) { + history.push(`${match.path}auth/${authType}`); + } else { + message.error(res.msg); + } + } + + return ( +
+
+ + setAuthType(e.target.value)} value={authType}> + + 本地认证 + {/* LDAP认证 */} + + +

注意,初始化选择好认证方式后不可再改变。

+ + + +
+
+
+ ); +} diff --git a/frontend/src/routes/meta/meta.less b/frontend/src/routes/meta/meta.less new file mode 100644 index 0000000..f7985a5 --- /dev/null +++ b/frontend/src/routes/meta/meta.less @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ +.palo-new-main { + display: flex; + flex-direction: row; + min-height: 380px; + + .new-main-sider { + flex: 0 0 300px; + height: calc(100vh - 95px); + margin-top: 30px; + border-radius: 16px; + } + + .new-main-content { + flex: auto; + height: calc(100vh - 95px); + border-radius: 16px; + } +} + +.site-layout-background { + background: none; +} diff --git a/frontend/src/routes/meta/meta.tsx b/frontend/src/routes/meta/meta.tsx new file mode 100644 index 0000000..4d06a88 --- /dev/null +++ b/frontend/src/routes/meta/meta.tsx @@ -0,0 +1,52 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @format + */ +import React, { useEffect, useState } from 'react'; +import styles from './meta.less'; +import { PageSide } from '@src/layout/page-side/index'; +import { MetaBaseTree } from '../tree/index'; +import { Redirect, Route, Router, Switch } from 'react-router-dom'; +import TableContent from '../table-content'; +import Database from '../database'; + +export function Meta(props: any) { + return ( +
+
+ + + +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/routes/node/dashboard/index.module.less b/frontend/src/routes/node/dashboard/index.module.less new file mode 100644 index 0000000..08e1fc5 --- /dev/null +++ b/frontend/src/routes/node/dashboard/index.module.less @@ -0,0 +1,20 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.dashboard { + // +} diff --git a/frontend/src/routes/node/dashboard/index.tsx b/frontend/src/routes/node/dashboard/index.tsx new file mode 100644 index 0000000..bd6f1b3 --- /dev/null +++ b/frontend/src/routes/node/dashboard/index.tsx @@ -0,0 +1,336 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { Row, Col, Select, Collapse, Button, Divider, AutoComplete } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import dayjs from 'dayjs'; +import './monitor.less'; + +import { LineChart } from 'echarts/charts'; +import { + GridComponent, + PolarComponent, + RadarComponent, + GeoComponent, + SingleAxisComponent, + ParallelComponent, + CalendarComponent, + GraphicComponent, + ToolboxComponent, + TooltipComponent, + TitleComponent, + LegendComponent, +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; +import { MonitorAPI } from './monitor.api'; +import { CHARTS_OPTIONS, getTimes } from './monitor.data'; +import { deepClone, formatBytes } from '@src/utils/utils'; + +echarts.use([ + LineChart, + CanvasRenderer, + TitleComponent, + GridComponent, + PolarComponent, + RadarComponent, + GeoComponent, + SingleAxisComponent, + ParallelComponent, + CalendarComponent, + GraphicComponent, + ToolboxComponent, + TooltipComponent, + LegendComponent, +]); + +const { Panel } = Collapse; +const { Option } = Select; + +export function NodeDashboard() { + const { t } = useTranslation(); + const [TIMES, setTIMES] = useState(() => getTimes(dayjs())); + const [beNodes, setBENodes] = useState([]); + const [selectedBENodes, setSelectedBENodes] = useState([]); + const [currentTime, setCurrentTime] = useState(TIMES[1]); + const [be_cpu_idle, setBE_CPU_IDLE] = useState({}); + const [be_mem, setBE_Mem] = useState({}); + const [be_disk_io, setBE_DISK_IO] = useState({}); + const [be_base_compaction_score, setBE_base_compaction_score] = useState({}); + const [be_cumu_compaction_score, setBE_cumu_compaction_score] = useState({}); + + useEffect(() => { + init(); + }, [currentTime.value, currentTime.start]); + + useEffect(() => { + updateBECharts(); + }, [selectedBENodes, currentTime.value, currentTime.start]); + + function handleBENodeChange(value: any) { + setSelectedBENodes(value); + } + function init() { + getBENodes(); + } + + function formatData(response: any, title: string) { + console.log(response); + const option = deepClone(CHARTS_OPTIONS); + // option.title.text = title; + const xAxisData = response?.x_value.map((item: string) => dayjs(item).format(currentTime.format)); + option.xAxis.data = xAxisData; + const series: any[] = []; + const legendData: any[] = []; + + function transformChartsData(y_value: string, formatter?: string) { + for (const [key, value] of Object.entries(y_value).sort()) { + if (Array.isArray(value)) { + legendData.push(key); + series.push({ + name: key, + data: value.map(item => { + if (formatter === 'MB') { + return typeof item === 'number' ? formatBytes(item, 2, false) : item; + } + return typeof item === 'number' ? item.toFixed(3) : item; + }), + type: 'line', + }); + } else { + transformChartsData(value); + } + } + return series; + } + if (title === '节点内存使用量') { + transformChartsData(response?.y_value, 'MB'); + } else { + transformChartsData(response?.y_value); + } + option.legend = { + bottom: '0', + left: 0, + height: 80, + width: 'auto', + type: 'scroll', + itemHeight: 10, + data: legendData, + orient: 'vertical', + pageIconSize: 7, + textStyle: { + overflow: 'breakAll', + }, + }; + (option.grid = { + bottom: 110, + }), + (option.series = series); + return option; + } + + // BE NODES REQUEST + function updateBECharts() { + getBE_CPU_IDLE(); + getBE_Mem(); + getBE_DiskIO(); + getBE_base_compaction_score(); + getBE_cumu_compaction_score(); + } + function getBENodes() { + MonitorAPI.getBENodes().then(res => { + console.log(res.data); + setBENodes(res.data); + setSelectedBENodes(res.data); + }); + } + function getBE_CPU_IDLE() { + MonitorAPI.getBE_CPU_IDLE(currentTime.start, currentTime.end, { + nodes: selectedBENodes, + }).then(res => { + const option = formatData(res.data, 'CPU空闲率(%)'); + setBE_CPU_IDLE(option); + }); + } + function getBE_Mem() { + MonitorAPI.getBE_Mem(currentTime.start, currentTime.end, { + nodes: selectedBENodes, + }).then(res => { + const option = formatData(res.data, '节点内存使用量'); + setBE_Mem(option); + }); + } + function getBE_DiskIO() { + MonitorAPI.getBE_DiskIO(currentTime.start, currentTime.end, { + nodes: selectedBENodes, + }).then(res => { + const option = formatData(res.data, 'IO利用率'); + setBE_DISK_IO(option); + }); + } + function getBE_base_compaction_score() { + MonitorAPI.getBE_base_compaction_score(currentTime.start, currentTime.end, { + nodes: selectedBENodes, + }).then(res => { + const option = formatData(res.data, '基线数据版本合并情况'); + option.legend.left = 20; + option.yAxis = { + type: 'value', + minInterval: 1, + + boundaryGap: [0, 0.1], + }; + setBE_base_compaction_score(option); + }); + } + function getBE_cumu_compaction_score() { + MonitorAPI.getBE_cumu_compaction_score(currentTime.start, currentTime.end, { + nodes: selectedBENodes, + }).then(res => { + const option = formatData(res.data, '增量数据版本合并情况'); + option.legend.left = 20; + option.yAxis = { + type: 'value', + minInterval: 1, + + boundaryGap: [0, 0.1], + }; + setBE_cumu_compaction_score(option); + }); + } + return ( +
+ +
+ + {t`viewTime`}: + + + + + + + + +
+ + + + {t`nodeSelection`}: + + + + +

{t`CPUidleRate`}

+ +
+ +

{t`nodeMemoryUsage`}

+ +
+ +

{t`IOutilization`}

+ +
+
+ + + +

{t`BaselineDataVersionConsolidation`}

+ +
+ +

{t`IncrementalDataVersionConsolidation`}

+ +
+
+
+
+
+ + ); +} diff --git a/frontend/src/routes/node/dashboard/monitor.api.ts b/frontend/src/routes/node/dashboard/monitor.api.ts new file mode 100644 index 0000000..c1a4e01 --- /dev/null +++ b/frontend/src/routes/node/dashboard/monitor.api.ts @@ -0,0 +1,61 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +import { http } from '@src/utils/http'; +import { IResult } from 'src/interfaces/http.interface'; + +function getBENodes(): Promise> { + return http.get(`/api/rest/v2/manager/monitor/value/be_list`); +} + +function getBE_CPU_IDLE(start?: any, end?: any, data?: any): Promise> { + return http.post(`/api/rest/v2/manager/monitor/timeserial/be_cpu_idle?start=${start}&end=${end}`, data); +} + +function getBE_Mem(start?: any, end?: any, data?: any): Promise> { + return http.post(`/api/rest/v2/manager/monitor/timeserial/be_mem?start=${start}&end=${end}`, data); +} + +function getBE_DiskIO(start?: any, end?: any, data?: any): Promise> { + return http.post(`/api/rest/v2/manager/monitor/timeserial/be_disk_io?start=${start}&end=${end}`, data); +} + +function getBE_base_compaction_score(start?: any, end?: any, data?: any): Promise> { + console.log(data); + return http.post( + `/api/rest/v2/manager/monitor/timeserial/be_base_compaction_score?start=${start}&end=${end}`, + data, + ); +} + +function getBE_cumu_compaction_score(start?: any, end?: any, data?: any): Promise> { + return http.post( + `/api/rest/v2/manager/monitor/timeserial/be_cumu_compaction_score?start=${start}&end=${end}`, + data, + ); +} + +export const MonitorAPI = { + getBENodes, + getBE_CPU_IDLE, + getBE_Mem, + getBE_DiskIO, + getBE_base_compaction_score, + getBE_cumu_compaction_score, +}; diff --git a/frontend/src/routes/node/dashboard/monitor.data.ts b/frontend/src/routes/node/dashboard/monitor.data.ts new file mode 100644 index 0000000..e633fae --- /dev/null +++ b/frontend/src/routes/node/dashboard/monitor.data.ts @@ -0,0 +1,96 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import dayjs from 'dayjs'; +export function getTimes(now: dayjs.Dayjs) { + return [ + { + value: '1', + text: '最近半小时', + end: new Date().getTime(), + start: now.subtract(30, 'minute').valueOf(), + format: 'HH:mm', + }, + { + value: '2', + text: '最近1小时', + end: new Date().getTime(), + start: now.subtract(1, 'hour').valueOf(), + format: 'HH:mm', + }, + { + value: '3', + text: '最近6小时', + end: new Date().getTime(), + start: now.subtract(6, 'hour').valueOf(), + format: 'HH:mm', + }, + { + value: '4', + text: '最近1天', + end: new Date().getTime(), + start: now.subtract(1, 'day').valueOf(), + format: 'HH:mm', + }, + { + value: '5', + text: '最近三天', + end: new Date().getTime(), + start: now.subtract(3, 'day').valueOf(), + format: 'MM/DD HH:mm', + }, + { + value: '6', + text: '最近一周', + end: new Date().getTime(), + start: now.subtract(1, 'week').valueOf(), + format: 'MM/DD HH:mm', + }, + { + value: '7', + text: '最近半个月', + end: new Date().getTime(), + start: now.subtract(15, 'day').valueOf(), + format: 'MM/DD HH:mm', + }, + { + value: '8', + text: '最近一个月', + end: new Date().getTime(), + start: now.subtract(1, 'month').valueOf(), + format: 'MM/DD HH:mm', + }, + ]; +} + +export const CHARTS_OPTIONS = { + title: { text: '' }, + legend: { + data: [], + left: 'center', + }, + tooltip: { + trigger: 'axis', + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: [], + }, + yAxis: { type: 'value' }, + series: [], +}; diff --git a/frontend/src/routes/node/dashboard/monitor.less b/frontend/src/routes/node/dashboard/monitor.less new file mode 100644 index 0000000..8383000 --- /dev/null +++ b/frontend/src/routes/node/dashboard/monitor.less @@ -0,0 +1,24 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.monitor { + min-height: 100%; + + h4 { + margin-left: 10px; + } +} diff --git a/frontend/src/routes/node/list/be-configuration/be-config.api.ts b/frontend/src/routes/node/list/be-configuration/be-config.api.ts new file mode 100644 index 0000000..9873cf6 --- /dev/null +++ b/frontend/src/routes/node/list/be-configuration/be-config.api.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { http } from '@src/utils/http'; + +function getConfigSelect() { + return http.get(`/api/rest/v2/manager/node/configuration_name`); +} +function getNodeSelect() { + return http.get(`/api/rest/v2/manager/node/node_list`); +} +function getConfigurationsInfo(data: any, type: string) { + return http.post(`/api/rest/v2/manager/node/configuration_info?type=${type}`, data); +} +function setConfig(data: any) { + return http.post(`/api/rest/v2/manager/node/set_config/be`, data); +} +export const BeConfigAPI = { + getConfigSelect, + getNodeSelect, + getConfigurationsInfo, + setConfig, +}; diff --git a/frontend/src/routes/node/list/be-configuration/index.tsx b/frontend/src/routes/node/list/be-configuration/index.tsx new file mode 100644 index 0000000..ddca4db --- /dev/null +++ b/frontend/src/routes/node/list/be-configuration/index.tsx @@ -0,0 +1,534 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/* eslint-disable prettier/prettier */ +import React, { useEffect, useState } from 'react'; +import { Descriptions, Radio, Select, Table, Space, Modal, Form, Input, Tooltip, message, Divider } from 'antd'; +import { InfoCircleOutlined } from '@ant-design/icons'; +const { Option } = Select; +import { ConfigurationTypeEnum } from '@src/common/common.data'; +import { BeConfigAPI } from './be-config.api'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { FILTERS } from '../config.data' + +export function BEConfiguration() { + const { t } = useTranslation(); + const history = useHistory(); + const [beColumn, setBeColumn] = useState([]); + const [tableData, setTableData] = useState([]); + const [configSelect, setConfigSelect] = useState([]); + const [nodeSelect, setNodeSelect] = useState([]); + const [confName, setConfName] = useState([]); + const [nodeName, setNodeName] = useState([]); + const [isModalVisible, setIsModalVisible] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [isBatchEdit, setIsBatchEdit] = useState(true); + const [rowData, setRowsData] = useState({}); + const [changeCurrent, setChangeCurrent] = useState(); + const [nodevalue, setNodevalue] = useState([]); + function changeConfig(record: any) { + setRowsData(record); + setIsBatchEdit(true); + setIsModalVisible(true); + } + + const [form] = Form.useForm(); + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[], selectedRows: any) => { + console.log(selectedRows) + let newSelectedRowKeys = []; + let newSelectedRows: any[] = []; + newSelectedRowKeys = selectedRowKeys.filter((key, index) => { + if (selectedRows[index][Object.keys(selectedRows[index]).length - 2] === 'true') { + newSelectedRows.push(selectedRows[index]); + return true; + } + return false; + }); + setSelectedRowKeys(newSelectedRowKeys); + setSelectedRows(newSelectedRows); + }, + getCheckboxProps: (record: any) => { + const arr = Object.keys(record); + return { + disabled: record[arr.length - 2] === 'false', + key: record[arr.length - 1], + }; + }, + selections: [ + Table.SELECTION_ALL, + Table.SELECTION_NONE, + { + text: t`SelectOnlyTheCurrentPage`, + onSelect(selectedRowKeys: React.Key[], selectedRows: any) { + setSelectedRowKeys(selectedRowKeys) + let newSelectedRows: any[] = []; + for (let i = 0; i < selectedRowKeys.length; i++) { + newSelectedRows.push(tableData[selectedRowKeys[i]]) + } + setSelectedRows(newSelectedRows) + }, + }, + ], + }; + const [typeValue, setTypeValue] = React.useState(ConfigurationTypeEnum.BE); + function onChange(e: any) { + setTypeValue(e.target.value); + if (e.target.value === ConfigurationTypeEnum.BE) { + localStorage.setItem("nodeText",""); + history.push(`/configuration/be`); + } else { + localStorage.setItem("nodeText",""); + history.push(`/configuration/fe`); + } + } + const handleOk = (values: any) => { + if (isBatchEdit) { + let name = rowData[0]; + let data = {}; + data[name] = { + node: [rowData[1]], + value: values.value, + persist: values.persist, + }; + + BeConfigAPI.setConfig(data).then(res => { + if (res.code === 0) { + setIsModalVisible(false); + getTable() + if (res.data.failed && res.data.failed.length !== 0) { + warning(res.data.failed) + } else { + message.success(t`SuccessfullyModified`); + } + } else { + + message.error(res.msg); + } + }); + } else { + let name = ""; + let data = {}; + for (let i = 0; i < selectedRows.length; i++) { + let nodes: any[] = []; + name = selectedRows[i][0] + selectedRows.map((item: any[], index: any) => { + if (item[0] === selectedRows[i][0]) { + nodes.push(item[1]) + } + }); + data[name] = { + node: nodes, + value: values.value, + persist: values.persist, + }; + } + BeConfigAPI.setConfig(data).then(res => { + if (res.code === 0) { + setIsModalVisible(false); + getTable() + if (res.data.failed && res.data.failed.length !== 0) { + warning(res.data.failed) + } else { + message.success(t`SuccessfullyModified`); + } + } else { + message.error(res.msg); + } + }); + } + console.log(values, isBatchEdit, rowData); + //setIsModalVisible(false); + }; + + const handleCancel = () => { + setIsModalVisible(false); + }; + + function getTable() { + let data = {}; + if (confName.length === 0 && nodeName.length !== 0) { + data = { type: typeValue, node: nodeName }; + } + if (confName.length !== 0 && nodeName.length === 0) { + data = { type: typeValue, conf_name: confName }; + } + if (confName.length === 0 && nodeName.length === 0) { + data = { type: typeValue }; + } + if (confName.length !== 0 && nodeName.length !== 0) { + data = { type: typeValue, conf_name: confName, node: nodeName }; + } + setTable(data,typeValue) + } + function refresh() { + let data = {}; + const nodeText = localStorage.getItem("nodeText") + if (nodeText !== "") { + setNodevalue(nodeText) + nodeName.push(nodeText) + const node = []; + node.push(nodeText) + if (confName.length === 0 && node.length !== 0) { + data = { type: typeValue, node: node }; + } + if (confName.length !== 0 && node.length === 0) { + data = { type: typeValue, conf_name: confName }; + } + if (confName.length === 0 && node.length === 0) { + data = { type: typeValue }; + } + if (confName.length !== 0 && node.length !== 0) { + data = { type: typeValue, conf_name: confName, node: node }; + } + } else { + if (confName.length === 0 && nodeName.length !== 0) { + data = { type: typeValue, node: nodeName }; + } + if (confName.length !== 0 && nodeName.length === 0) { + data = { type: typeValue, conf_name: confName }; + } + if (confName.length === 0 && nodeName.length === 0) { + data = { type: typeValue }; + } + if (confName.length !== 0 && nodeName.length !== 0) { + data = { type: typeValue, conf_name: confName, node: nodeName }; + } + } + + setTable(data,typeValue) + + BeConfigAPI.getConfigSelect().then((res: { data: any; code: any; msg: any }) => { + if (res.code === 0) { + const { data, code, msg } = res; + console.log(data); + const { backend, frontend } = data; + const configSelect = backend.map((item: string, index: number) => { + return ( + + ); + }); + setConfigSelect(configSelect); + } else { + message.warning(res.msg); + } + }); + BeConfigAPI.getNodeSelect().then((res: { data: any; code: any; msg: any }) => { + if (res.code === 0) { + const { data, code, msg } = res; + const { backend, frontend } = data; + const nodeSelect = backend.map((item: string, index: number) => { + return ( + + ); + }); + setNodeSelect(nodeSelect); + } else { + message.warning(res.msg); + } + }); + } + + function batchEditing() { + if (selectedRowKeys.length === 0) { + message.warning(t`PleaseCheckTheConfigurationYouWantToModify`); + } else { + setIsModalVisible(true); + setIsBatchEdit(false); + } + } + useEffect(() => { + refresh(); + }, []); + function configSelectChange(_value: any) { + setSelectedRowKeys([]) + setSelectedRows([]) + const newArray = _value.map(x => x.trim()) + setConfName(newArray); + let data = {}; + if (newArray.length === 0 && nodeName.length !== 0) { + data = { type: typeValue, node: nodeName }; + } + if (newArray.length !== 0 && nodeName.length === 0) { + data = { type: typeValue, conf_name: newArray }; + } + if (newArray.length === 0 && nodeName.length === 0) { + data = { type: typeValue }; + } + if (newArray.length !== 0 && nodeName.length !== 0) { + data = { conf_name: newArray, node: nodeName }; + } + setTable(data,typeValue) + } + + function setTable(data: any,typeValue: any) { + BeConfigAPI.getConfigurationsInfo(data, typeValue).then(res => { + if (res.code == 0) { + const { column_names, rows } = res.data; + const columns = column_names.map((item: string, index: number) => { + if (item === '可修改') { + return { + title: t`operate`, + dataIndex: item, + render: (text: any, record: any, index: any) => ( + + {record[Object.keys(record).length - 2] === 'true' ? ( + changeConfig(record)}>{ t`edit`} + ) : ( + <> + )} + + ), + fixed: 'right', + width: 100, + }; + } + if (item === 'MasterOnly') { + return { + title: item, + dataIndex: `${index}`, + key: item, + columnWidth: 10, + filters: FILTERS, + onFilter: (value: any, record: any) => onFilterCols(value,record), + }; + } + else { + return { + title: item, + dataIndex: index, + columnWidth: 10, + }; + } + }); + setBeColumn(columns); + + for (let i = 0; i < rows.length; i++) { + rows[i].push('' + i + ''); + } + setTableData(rows.map((item: string[]) => ({ ...item }))); + if (data.conf_name) { + setChangeCurrent(1) + } + } else { + message.warning(res.msg); + } + }); + } + + function onFilterCols(value: any, record: any) { + return record[6].indexOf(value) === 0 + } + + function nodeSelectChange(value: any) { + setSelectedRowKeys([]) + setSelectedRows([]) + if (value.length === 0) { + setNodevalue([]) + + } else { + setNodevalue(value) + } + localStorage.setItem("nodeText", ""); + const newArray = value.map(x => x.trim()) + setNodeName(newArray); + let data = {}; + if (confName.length === 0 && newArray.length !== 0) { + data = { type: typeValue, node: newArray }; + } + if (confName.length !== 0 && newArray.length === 0) { + data = { type: typeValue, conf_name: confName }; + } + if (confName.length === 0 && newArray.length === 0) { + data = { type: typeValue }; + } + if (confName.length !== 0 && newArray.length !== 0) { + data = { type: typeValue, conf_name: confName, node: newArray }; + } + setTable(data,typeValue) + } + + function pageSizeChange(current: React.SetStateAction, pageSize: number | undefined) { + setChangeCurrent(current) + } + const [configvalue, setConfigvalue] = React.useState([]); + const configSelectProps = { + mode: 'multiple' as const, + style: { width: '100%' }, + configvalue, + onChange: (newValue: string[]) => { + setConfigvalue(newValue); + }, + maxTagCount: 'responsive' as const, + }; + const nodeSelectProps = { + mode: 'multiple' as const, + style: { width: '100%' }, + nodevalue, + onChange: (newValue: string[]) => { + setNodevalue(newValue); + }, + maxTagCount: 'responsive' as const, + }; + return ( +
+ + + {t`nodeSelection`}: + + FE节点 + BE节点 + + + + { t`ConfigurationItem`}: + + + + { t`Node`}: + + + + + + + { t`CurrentlySelected`} {selectedRowKeys.length} {t`StripData`} + + + + + + {t`BatchEditing`} + + + +
+
record[Object.keys(record).length - 1]} + size="middle" + scroll={{ x: 1300 }} + pagination={{ + position: ['bottomCenter'], + total: beColumn, + current:changeCurrent, + showSizeChanger: true, + showQuickJumper: true, + onChange: (current, pageSize) => pageSizeChange(current, pageSize), + showTotal: (total: any) => {t`total`+`${total}` +t`strip`}, + }} + >
+
+ { + form.validateFields() + .then((values: any) => { + form.resetFields(); + handleOk(values); + }) + .catch((info: any) => { + console.log('Validate Failed:', info); + }); + }} + > +
+ + + + + + + {t`TemporarilyEffective`} + + + + + + { t`Permanent`} + + + + + + +
+
+ + ); + function warning(failed: []) { + const arr = failed.map((failedMsg:any) => { + return <>{failedMsg.node}{ t`Node`}{failedMsg.config_name}{t`Error`+":"} {failedMsg.err_info}

+ }) + Modal.error({ + content: <> + +

{ t`ConfigurationError`}

+
+ {arr} +
+ + , + title: { t`FailToEdit`}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onOk() { }, + bodyStyle: { height: '250px' }, + width: '520px', + closable:true + + }); + } +} diff --git a/frontend/src/routes/node/list/config.data.ts b/frontend/src/routes/node/list/config.data.ts new file mode 100644 index 0000000..7227a88 --- /dev/null +++ b/frontend/src/routes/node/list/config.data.ts @@ -0,0 +1,26 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export enum MASTER_MAP { + TRUE = 'true', + FALSE = 'false', +} + +export const FILTERS = [ + { text: MASTER_MAP.TRUE, value: MASTER_MAP.TRUE }, + { text: MASTER_MAP.FALSE, value: MASTER_MAP.FALSE }, +]; \ No newline at end of file diff --git a/frontend/src/routes/node/list/configuration/index.tsx b/frontend/src/routes/node/list/configuration/index.tsx new file mode 100644 index 0000000..8f7f881 --- /dev/null +++ b/frontend/src/routes/node/list/configuration/index.tsx @@ -0,0 +1,106 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { useState } from 'react'; +import { Descriptions, Radio, Select, Table, message } from 'antd'; +import { ConfigurationTypeEnum } from '@src/common/common.data'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +export function Configuration() { + const history = useHistory(); + const { t } = useTranslation(); + const [feColumn, setFeColumn] = useState([]); + + const [tableData, setTableData] = useState([]); + + const [configSelect, setConfigSelect] = useState([]); + + const [nodeSelect, setNodeSelect] = useState([]); + + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + const rowSelection = {}; + + function batchEditing() { + if (selectedRowKeys.length === 0) { + message.warning( t`PleaseCheckTheConfiguration`); + } + } + function onChange(e: any) { + if (e.target.value === ConfigurationTypeEnum.BE) { + localStorage.setItem("nodeText",""); + history.push(`/configuration/be`); + } else { + localStorage.setItem("nodeText",""); + history.push(`/configuration/fe`); + } + } + + return ( + <> + + + {t`nodeSelection`+":"} + + FE{t`Node`} + BE{t`Node`} + + + + { t`ConfigurationItem`}: + + + + { t`Node`}: + + + + {' '} + + { t`BatchEditing`} + + + +
+ record[Object.keys(record).length - 1]} + size="middle" + scroll={{ x: 1300 }} + pagination={{ + position: ['bottomCenter'], + total: tableData.length, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (total: any) => {t`total`+`${total}` +t`strip`}, + }} + >
+
+ + ); +} + diff --git a/frontend/src/routes/node/list/fe-configuration/fe-config.api.ts b/frontend/src/routes/node/list/fe-configuration/fe-config.api.ts new file mode 100644 index 0000000..28419f8 --- /dev/null +++ b/frontend/src/routes/node/list/fe-configuration/fe-config.api.ts @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { http } from '@src/utils/http'; + +function getConfigSelect() { + return http.get(`/api/rest/v2/manager/node/configuration_name`); +} +function getNodeSelect() { + return http.get(`/api/rest/v2/manager/node/node_list`); +} +function getConfigurationsInfo(data: any, type: string) { + return http.post(`/api/rest/v2/manager/node/configuration_info?type=${type}`, data); +} +function setConfig(data: any) { + return http.post(`/api/rest/v2/manager/node/set_config/fe`, data); +} +export const FeConfigAPI = { + getConfigSelect, + getNodeSelect, + getConfigurationsInfo, + setConfig, +}; diff --git a/frontend/src/routes/node/list/fe-configuration/index.tsx b/frontend/src/routes/node/list/fe-configuration/index.tsx new file mode 100644 index 0000000..b5fc695 --- /dev/null +++ b/frontend/src/routes/node/list/fe-configuration/index.tsx @@ -0,0 +1,535 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +/* eslint-disable prettier/prettier */ +import React, { useEffect, useState } from 'react'; +import { Descriptions, Radio, Select, Table, Space, Modal, Form, Input, Tooltip, message, Divider } from 'antd'; +import { FallOutlined, InfoCircleOutlined } from '@ant-design/icons'; +const { Option } = Select; +import { ConfigurationTypeEnum } from '@src/common/common.data'; +import { FeConfigAPI } from './fe-config.api'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { FILTERS } from '../config.data' + +export function FEConfiguration() { + const { t } = useTranslation(); + const [modal, contextHolder] = Modal.useModal(); + const history = useHistory(); + const [feColumn, setFeColumn] = useState([]); + const [tableData, setTableData] = useState([]); + const [configSelect, setConfigSelect] = useState([]); + const [nodeSelect, setNodeSelect] = useState([]); + const [confName, setConfName] = useState([]); + const [nodeName, setNodeName] = useState([]); + const [isModalVisible, setIsModalVisible] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [isBatchEdit, setIsBatchEdit] = useState(true); + const [rowData, setRowsData] = useState({}); + const [changeCurrent, setChangeCurrent] = useState(); + const [nodevalue, setNodevalue] = useState([]); + function changeConfig(record: any) { + setRowsData(record); + setIsBatchEdit(true); + setIsModalVisible(true); + } + + const [form] = Form.useForm(); + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[], selectedRows: any) => { + console.log(selectedRows) + let newSelectedRowKeys = []; + const newSelectedRows: any[] = []; + newSelectedRowKeys = selectedRowKeys.filter((key, index) => { + if (selectedRows[index][Object.keys(selectedRows[index]).length - 2] === 'true') { + newSelectedRows.push(selectedRows[index]); + return true; + } + return false; + }); + setSelectedRowKeys(newSelectedRowKeys); + setSelectedRows(newSelectedRows); + }, + getCheckboxProps: (record: any) => { + const arr = Object.keys(record); + return { + disabled: record[arr.length - 2] === 'false', + key: record[arr.length - 1], + }; + }, + selections: [ + Table.SELECTION_ALL, + Table.SELECTION_NONE, + { + text: t`SelectOnlyTheCurrentPage`, + onSelect(selectedRowKeys: React.Key[], selectedRows: any) { + setSelectedRowKeys(selectedRowKeys) + const newSelectedRows: any[] = []; + for (let i = 0; i < selectedRowKeys.length; i++) { + newSelectedRows.push(tableData[selectedRowKeys[i]]) + } + setSelectedRows(newSelectedRows) + }, + }, + ], + }; + const [typeValue, setTypeValue] = React.useState(ConfigurationTypeEnum.FE); + function onChange(e: any) { + setTypeValue(e.target.value); + if (e.target.value === ConfigurationTypeEnum.BE) { + localStorage.setItem("nodeText",""); + history.push(`/configuration/be`); + } else { + localStorage.setItem("nodeText",""); + history.push(`/configuration/fe`); + } + } + const handleOk = (values: any) => { + if (isBatchEdit) { + const name = rowData[0]; + const data = {}; + data[name] = { + node: [rowData[1]], + value: values.value, + persist: values.persist, + }; + + FeConfigAPI.setConfig(data).then(res => { + if (res.code === 0) { + setIsModalVisible(false); + getTable() + if (res.data.failed && res.data.failed.length !== 0) { + warning(res.data.failed) + } else { + message.success(t`SuccessfullyModified`); + } + } else { + message.error(res.msg); + } + }); + } else { + let name = ""; + const data = {}; + for (let i = 0; i < selectedRows.length; i++) { + const nodes: any[] = []; + name = selectedRows[i][0] + selectedRows.map((item: any[], index: any) => { + if (item[0] === selectedRows[i][0]) { + nodes.push(item[1]) + } + }); + data[name] = { + node: nodes, + value: values.value, + persist: values.persist, + }; + } + FeConfigAPI.setConfig(data).then(res => { + if (res.code === 0) { + setIsModalVisible(false); + getTable() + if (res.data.failed && res.data.failed.length !== 0) { + warning(res.data.failed) + } else { + message.success(t`SuccessfullyModified`); + } + } else { + message.error(res.msg); + } + }); + } + console.log(values, isBatchEdit, rowData); + //setIsModalVisible(false); + }; + + const handleCancel = () => { + setIsModalVisible(false); + }; + + function getTable() { + let data = {}; + if (confName.length === 0 && nodeName.length !== 0) { + data = { type: typeValue, node: nodeName }; + } + if (confName.length !== 0 && nodeName.length === 0) { + data = { type: typeValue, conf_name: confName }; + } + if (confName.length === 0 && nodeName.length === 0) { + data = { type: typeValue }; + } + if (confName.length !== 0 && nodeName.length !== 0) { + data = { type: typeValue, conf_name: confName, node: nodeName }; + } + setTable(data,typeValue) + } + function refresh() { + let data = {}; + const nodeText = localStorage.getItem("nodeText") + if (nodeText !== "") { + setNodevalue(nodeText) + nodeName.push(nodeText) + const node = []; + node.push(nodeText) + if (confName.length === 0 && node.length !== 0) { + data = { type: typeValue, node: node }; + } + if (confName.length !== 0 && node.length === 0) { + data = { type: typeValue, conf_name: confName }; + } + if (confName.length === 0 && node.length === 0) { + data = { type: typeValue }; + } + if (confName.length !== 0 && node.length !== 0) { + data = { type: typeValue, conf_name: confName, node: node }; + } + } else { + if (confName.length === 0 && nodeName.length !== 0) { + data = { type: typeValue, node: nodeName }; + } + if (confName.length !== 0 && nodeName.length === 0) { + data = { type: typeValue, conf_name: confName }; + } + if (confName.length === 0 && nodeName.length === 0) { + data = { type: typeValue }; + } + if (confName.length !== 0 && nodeName.length !== 0) { + data = { type: typeValue, conf_name: confName, node: nodeName }; + } + } + + setTable(data,typeValue) + + FeConfigAPI.getConfigSelect().then((res: { data: any; code: any; msg: any }) => { + if (res.code === 0) { + const { data, code, msg } = res; + console.log(data); + const { backend, frontend } = data; + const configSelect = frontend.map((item: string, index: number) => { + return ( + + ); + }); + setConfigSelect(configSelect); + } else { + message.warning(res.msg); + } + }); + FeConfigAPI.getNodeSelect().then((res: { data: any; code: any; msg: any }) => { + if (res.code === 0) { + const { data, code, msg } = res; + const { backend, frontend } = data; + const nodeSelect = frontend.map((item: string, index: number) => { + return ( + + ); + }); + setNodeSelect(nodeSelect); + } else { + message.warning(res.msg); + } + }); + } + + function batchEditing() { + if (selectedRowKeys.length === 0) { + message.warning(t`PleaseCheckTheConfigurationYouWantToModify`); + } else { + setIsModalVisible(true); + setIsBatchEdit(false); + } + } + useEffect(() => { + refresh(); + }, []); + + function configSelectChange(_value: any) { + setSelectedRowKeys([]) + setSelectedRows([]) + const newArray = _value.map(x => x.trim()) + setConfName(newArray); + let data = {}; + if (newArray.length === 0 && nodeName.length !== 0) { + data = { type: typeValue, node: nodeName }; + } + if (newArray.length !== 0 && nodeName.length === 0) { + data = { type: typeValue, conf_name: newArray }; + } + if (newArray.length === 0 && nodeName.length === 0) { + data = { type: typeValue }; + } + if (newArray.length !== 0 && nodeName.length !== 0) { + data = { conf_name: newArray, node: nodeName }; + } + setTable(data,typeValue) + } + + function setTable(data: any,typeValue: any) { + FeConfigAPI.getConfigurationsInfo(data, typeValue).then(res => { + if (res.code == 0) { + const { column_names, rows } = res.data; + const columns = column_names.map((item: string, index: number) => { + if (item === '可修改') { + return { + title: t`operate`, + dataIndex: item, + render: (text: any, record: any, index: any) => ( + + {record[Object.keys(record).length - 2] === 'true' ? ( + changeConfig(record)}>{ t`edit`} + ) : ( + <> + )} + + ), + fixed: 'right', + width: 100, + }; + } + if (item === 'MasterOnly') { + return { + title: item, + dataIndex: `${index}`, + key: item, + columnWidth: 10, + filters: FILTERS, + onFilter: (value: any, record: any) => onFilterCols(value,record), + }; + } + else { + return { + title: item, + dataIndex: index, + columnWidth: 10, + }; + } + }); + setFeColumn(columns); + + for (let i = 0; i < rows.length; i++) { + rows[i].push('' + i + ''); + } + setTableData(rows.map((item: string[]) => ({ ...item }))); + if (data.conf_name) { + setChangeCurrent(1) + } + } else { + message.warning(res.msg); + } + }); + } + + function onFilterCols(value: any, record: any) { + return record[4].indexOf(value) === 0 + } + + function nodeSelectChange(value: any) { + setSelectedRowKeys([]) + setSelectedRows([]) + if (value.length === 0) { + setNodevalue([]) + } else { + setNodevalue(value) + } + + localStorage.setItem("nodeText", ""); + const newArray = value.map(x => x.trim()) + setNodeName(newArray); + let data = {}; + if (confName.length === 0 && newArray.length !== 0) { + data = { type: typeValue, node: newArray }; + } + if (confName.length !== 0 && newArray.length === 0) { + data = { type: typeValue, conf_name: confName }; + } + if (confName.length === 0 && newArray.length === 0) { + data = { type: typeValue }; + } + if (confName.length !== 0 && newArray.length !== 0) { + data = { type: typeValue, conf_name: confName, node: newArray }; + } + setTable(data,typeValue) + } + + function pageSizeChange(current: React.SetStateAction, pageSize: number | undefined) { + setChangeCurrent(current) + } + const [configvalue, setConfigvalue] = React.useState([]); + const configSelectProps = { + mode: 'multiple' as const, + style: { width: '100%' }, + configvalue, + onChange: (newValue: string[]) => { + setConfigvalue(newValue); + }, + maxTagCount: 'responsive' as const, + }; + const nodeSelectProps = { + mode: 'multiple' as const, + style: { width: '100%' }, + nodevalue, + onChange: (newValue: string[]) => { + setNodevalue(newValue); + }, + maxTagCount: 'responsive' as const, + }; + return ( +
+ + + {t`nodeSelection`}: + + FE节点 + BE节点 + + + + { t`ConfigurationItem`}: + + + + { t`Node`}: + + + + + + + { t`CurrentlySelected`} {selectedRowKeys.length} {t`StripData`} + + + + + {t`BatchEditing`} + + + +
+ record[Object.keys(record).length - 1]} + size="middle" + scroll={{ x: 1300 }} + pagination={{ + position: ['bottomCenter'], + total: feColumn, + current:changeCurrent, + showSizeChanger: true, + showQuickJumper: true, + onChange: (current, pageSize) => pageSizeChange(current, pageSize), + showTotal: (total: any) => {t`total`+`${total}` +t`strip`}, + }} + >
+
+ { + form.validateFields() + .then((values: any) => { + form.resetFields(); + handleOk(values); + }) + .catch((info: any) => { + console.log('Validate Failed:', info); + }); + }} + > +
+ + + + + + + {t`TemporarilyEffective`} + + + + + + { t`Permanent`} + + + + + + +
+
+
+ ); + function warning(failed: []) { + const arr = failed.map((failedMsg:any) => { + return <>{failedMsg.node}{ t`Node`}{failedMsg.config_name}{t`Error`+":"} {failedMsg.err_info}

+ }) + Modal.error({ + content: <> + +

{ t`ConfigurationError`}

+
+ {arr} +
+ + , + title: { t`FailToEdit`}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onOk() { }, + bodyStyle: { height: '250px' }, + width: '520px', + closable:true + + }); + } +} + + diff --git a/frontend/src/routes/node/list/index.tsx b/frontend/src/routes/node/list/index.tsx new file mode 100644 index 0000000..25b9eea --- /dev/null +++ b/frontend/src/routes/node/list/index.tsx @@ -0,0 +1,190 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/* eslint-disable prettier/prettier */ +import React, { useEffect, useState } from 'react'; +import { Table, Space, message } from 'antd'; +import { NodeAPI } from './node.api'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +export function NodeList() { + const history = useHistory(); + const { t } = useTranslation(); + const [feColumns, setFeColumns] = useState([]); + const [beColumns, setBeColumns] = useState([]); + const [brokersColumns, setBrokersColumns] = useState([]); + const [beTableData, setBeTableData] = useState([]); + const [feTableData, setFeTableData] = useState([]); + const [brokersTableData, setBrokersTableData] = useState([]); + function refresh() { + NodeAPI.getFrontends().then(res => { + const { msg, data, code } = res; + const { column_names, rows } = data; + if (code === 0) { + const column = column_names.map((item: string, index: number) => { + if (index === 0) { + return { + title: 'FE '+t`Node`, + dataIndex: index, + key: item, + width: 200, + }; + } else { + return { + title: item, + dataIndex: index, + key: item, + width:200, + }; + } + }); + column.push({ + title: 'CONF', + key: 'action', + render: (text: any, record: any, index: any) => ( + + toFeDetailsPage(record)}>{t`Details`} + + ), + fixed: 'right', + width:100, + }); + setFeColumns(column); + setFeTableData(rows.map((item: string[]) => ({ ...item }))); + } else { + message.error(msg); + } + }); + NodeAPI.getBrokers().then(res => { + const { msg, data, code } = res; + const { column_names, rows } = data; + if (code === 0) { + const column = column_names.map((item: string, index: number) => { + if (index === 0) { + return { + title: 'Brokers', + dataIndex: index, + key: item, + width: 200, + }; + } else { + return { + title: item, + dataIndex: index, + key: item, + width:200, + }; + } + }); + setBrokersColumns(column); + setBrokersTableData(rows.map((item: string[]) => ({ ...item }))); + } else { + message.error(msg); + } + }); + NodeAPI.getBackends().then(res => { + const { msg, data, code } = res; + const { column_names, rows } = data; + if (code === 0) { + const column = column_names.map((item: string, index: number) => { + if (index === 0) { + return { + title: 'BE '+t`Node`, + dataIndex: index, + key: item, + width: 200, + }; + } else { + return { + title: item, + dataIndex: index, + key: item, + width:200, + }; + } + } + ); + column.push({ + title: 'CONF', + key: 'CONF', + render: (text: any, record: any, index: any) => ( + + toBeDetailsPage(record)} >{ t`Details`} + + ), + fixed: 'right', + width:100, + }); + setBeColumns(column); + setBeTableData(rows.map((item: string[]) => ({ ...item }))); + } else { + message.error(msg); + } + }); + } + useEffect(() => { + refresh(); + }, []); + + function toBeDetailsPage(record: any) { + let node = ""; + node += record[2]+":"+record[6] + localStorage.setItem("nodeText", node); + history.push(`/configuration/be`) + } + + function toFeDetailsPage(record: any) { + let node = ""; + node += record[1]+":"+record[4] + localStorage.setItem("nodeText", node); + history.push(`/configuration/fe`) ; + } + return ( + <> +
+
+
+ + ); +} diff --git a/frontend/src/routes/node/list/node.api.ts b/frontend/src/routes/node/list/node.api.ts new file mode 100644 index 0000000..4241e6b --- /dev/null +++ b/frontend/src/routes/node/list/node.api.ts @@ -0,0 +1,33 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { http } from '@src/utils/http'; +function getFrontends() { + return http.get(`/api/rest/v2/manager/node/frontends`); +} +function getBrokers() { + return http.get(`/api/rest/v2/manager/node/brokers`); +} +function getBackends() { + return http.get(`/api/rest/v2/manager/node/backends`); +} + +export const NodeAPI = { + getFrontends, + getBrokers, + getBackends, +}; diff --git a/frontend/src/routes/passport/forgot.login.tsx b/frontend/src/routes/passport/forgot.login.tsx new file mode 100644 index 0000000..4393c46 --- /dev/null +++ b/frontend/src/routes/passport/forgot.login.tsx @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ +import Link from 'antd/lib/typography/Link'; +import React, { useState } from 'react'; +import styles from './index.module.less'; + +function ForgotLogin(props: any) { + return ( +
+
+

Please contact an administrator to have them reset your password

+
+ props.history.push(`/login`)}> + Back to login + +
+
+ ); +} + +export default ForgotLogin; diff --git a/frontend/src/routes/passport/index.module.less b/frontend/src/routes/passport/index.module.less new file mode 100644 index 0000000..7f591fa --- /dev/null +++ b/frontend/src/routes/passport/index.module.less @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +.login-container { + width: 420px; + padding: 32px; + margin-top: 32px; + line-height: 24px; + background-color: #fff; + border: 1px solid rgb(215 211 212); + border-radius: 6px; + box-shadow: rgb(0 0 0 / 8%) 0 7px 20px; + transition: all 0.2s linear 0s; + + input { + width: 100%; + padding: 0.75em; + border-radius: 4px; + } +} + +.not-found { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + overflow: hidden; + background: url('../../assets/background.jpg'); + background-size: cover; +} diff --git a/frontend/src/routes/passport/login.tsx b/frontend/src/routes/passport/login.tsx new file mode 100644 index 0000000..c3aabc6 --- /dev/null +++ b/frontend/src/routes/passport/login.tsx @@ -0,0 +1,83 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { useEffect, useState } from 'react'; +import { Form, Input, Button, Radio, Checkbox, message } from 'antd'; +import styles from './index.module.less'; +import { PassportAPI } from './passport.api'; +import { useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; +import { useAuth } from '@src/hooks/use-auth'; +export function Login(props: any) { + const [form] = Form.useForm(); + const { t } = useTranslation(); + const history = useHistory(); + const {initialized} = useAuth(); + function handleLogin(_value: any) { + PassportAPI.SessionLogin(_value).then(res => { + if (res.code === 0) { + PassportAPI.getCurrentUser().then(user => { + window.localStorage.setItem('login', 'true') + window.localStorage.setItem('user', JSON.stringify(user.data)); + history.push('/space/list'); + }) + } else { + message.warn(res.msg); + } + }); + } + + return ( +
+
+
+

{ t`login`}

+ + + + + + + + 记住我 + + + + + {/* + props.history.push(`/forgot`)} + > + { t`ForgetThePassword`} + + */} +
+
+
+ ); +}; diff --git a/frontend/src/routes/passport/passport.api.ts b/frontend/src/routes/passport/passport.api.ts new file mode 100644 index 0000000..4537343 --- /dev/null +++ b/frontend/src/routes/passport/passport.api.ts @@ -0,0 +1,30 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +import { http } from '@src/utils/http'; +function SessionLogin(_value: any) { + return http.post(`/api/session/`, _value); +} +function getCurrentUser() { + return http.get(`/api/user/current`); +} +export const PassportAPI = { + SessionLogin, + getCurrentUser, +}; diff --git a/frontend/src/routes/query/index.tsx b/frontend/src/routes/query/index.tsx new file mode 100644 index 0000000..0c36147 --- /dev/null +++ b/frontend/src/routes/query/index.tsx @@ -0,0 +1,147 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/* eslint-disable prettier/prettier */ +import React, { useState, useEffect } from 'react'; +import { Table, Card, Space, Input } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { getQueryInfo } from './query.api'; +import { ColumnsType } from 'antd/es/table'; +import { FILTERS } from './query.data'; +import { useHistory } from 'react-router-dom'; +import { ClippedText } from '@src/components/clipped-text/clipped-text'; +const { Search } = Input; + +interface ICol { + [propName: string]: any; +} + +export function Query() { + const { t } = useTranslation(); + const history = useHistory(); + const [tableData, setTableData] = useState([]); + const [tableColumn, setTableColumn] = useState>([]); + + function queryDetails(record: any) { + console.log(record) + history.push(`/details/${record[0]}`) + } + useEffect(() => { + getQueryInfo().then(res => { + const { column_names, rows } = res.data; + handleResult(column_names, rows); + }); + }, []); + const onSearch = (value: string) => { + let newValue = encodeURI(value) + getQueryInfo({ + search: newValue, + }).then(res => { + const { column_names, rows } = res.data; + handleResult(column_names, rows); + }); + }; + + const handleResult = (column_names: string[], rows: [][]) => { + const columns = column_names.map((item: string, index: number) => { + if (item === 'Query ID') { + return { + title: item, + dataIndex: `${index}`, + key: item, + columnWidth: 10, + render: (text: any, record: any, index: any) => ( + + + queryDetails(record)}>{text} + + + + ), + }; + } + if (item === '状态') { + return { + title: item, + dataIndex: `${index}`, + key: item, + columnWidth: 10, + filters: FILTERS, + onFilter: (value: any, record: any) => record[index].indexOf(value) === 0, + }; + } + if (item === 'FE节点') { + return { + title: item, + dataIndex: index, + key: item, + width: 150, + ellipsis: true, + }; + } + if (item === '开始时间') { + return { + title: item, + dataIndex: index, + key: item, + width: 150, + ellipsis: true, + }; + } + if (item === '结束时间') { + return { + title: item, + dataIndex: index, + key: item, + width: 150, + ellipsis: true, + }; + } + return { + title: item, + dataIndex: index, + key: item, + columnWidth: 10, + ellipsis: true, + }; + }); + setTableColumn(columns); + setTableData(rows.map((item: string[]) => ({ ...item }))); + }; + + return ( + <> + } + > + + columns={tableColumn} + dataSource={tableData} + bordered + scroll={{ x: 1300 }} + size="middle" + pagination={{ + pageSizeOptions: ['10', '20', '50'], + }} + locale={{emptyText: t`noDate` }} + > + + + ); +} diff --git a/frontend/src/routes/query/query-details/code.css b/frontend/src/routes/query/query-details/code.css new file mode 100644 index 0000000..25d7e4e --- /dev/null +++ b/frontend/src/routes/query/query-details/code.css @@ -0,0 +1,126 @@ +/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/ + +.hljs { + color: #24292e; + background: #ffffff; + } + + .hljs-doctag, + .hljs-keyword, + .hljs-meta .hljs-keyword, + .hljs-template-tag, + .hljs-template-variable, + .hljs-type, + .hljs-variable.language_ { + /* prettylights-syntax-keyword */ + color: #d73a49; + } + + .hljs-title, + .hljs-title.class_, + .hljs-title.class_.inherited__, + .hljs-title.function_ { + /* prettylights-syntax-entity */ + color: #6f42c1; + } + + .hljs-attr, + .hljs-attribute, + .hljs-literal, + .hljs-meta, + .hljs-number, + .hljs-operator, + .hljs-variable, + .hljs-selector-attr, + .hljs-selector-class, + .hljs-selector-id { + /* prettylights-syntax-constant */ + color: #005cc5; + } + + .hljs-regexp, + .hljs-string, + .hljs-meta .hljs-string { + /* prettylights-syntax-string */ + color: #032f62; + } + + .hljs-built_in, + .hljs-symbol { + /* prettylights-syntax-variable */ + color: #e36209; + } + + .hljs-comment, + .hljs-code, + .hljs-formula { + /* prettylights-syntax-comment */ + color: #6a737d; + } + + .hljs-name, + .hljs-quote, + .hljs-selector-tag, + .hljs-selector-pseudo { + /* prettylights-syntax-entity-tag */ + color: #22863a; + } + + .hljs-subst { + /* prettylights-syntax-storage-modifier-import */ + color: #24292e; + } + + .hljs-section { + /* prettylights-syntax-markup-heading */ + color: #005cc5; + font-weight: bold; + } + + .hljs-bullet { + /* prettylights-syntax-markup-list */ + color: #735c0f; + } + + .hljs-emphasis { + /* prettylights-syntax-markup-italic */ + color: #24292e; + font-style: italic; + } + + .hljs-strong { + /* prettylights-syntax-markup-bold */ + color: #24292e; + font-weight: bold; + } + + .hljs-addition { + /* prettylights-syntax-markup-inserted */ + color: #22863a; + background-color: #f0fff4; + } + + .hljs-deletion { + /* prettylights-syntax-markup-deleted */ + color: #b31d28; + background-color: #ffeef0; + } + + .hljs-char.escape_, + .hljs-link, + .hljs-params, + .hljs-property, + .hljs-punctuation, + .hljs-tag { + /* purposely ignored */ + } + \ No newline at end of file diff --git a/frontend/src/routes/query/query-details/index.tsx b/frontend/src/routes/query/query-details/index.tsx new file mode 100644 index 0000000..56d45dc --- /dev/null +++ b/frontend/src/routes/query/query-details/index.tsx @@ -0,0 +1,122 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { Table, Card } from 'antd'; +import { getSQLText, getProfileText, getQueryInfo } from './../query.api'; +import Profile from './profile'; +import styles from './query.module.less'; +import { ColumnsType } from 'antd/es/table'; +import { ClippedText } from '@src/components/clipped-text/clipped-text'; +import hljs from 'highlight.js' +import './code.css'; +type tabType = 'sql' | 'text' | 'profile'; +interface ICol { + [propName: string]: any; +} +export function QueryDetails() { + const [currentTab, setCurrentTab] = useState('sql'); + const [currentSQL, setCurrentSQL] = useState('sql'); + const [currentText, setCurrentText] = useState('sql'); + const [tableData, setTableData] = useState([]); + const [tableColumn, setTableColumn] = useState>([]); + const params = useParams<{ queryId: string }>(); + const queryId = params.queryId; + const tabListNoTitle = [ + { + key: 'sql', + tab: 'SQL语句', + }, + { + key: 'text', + tab: 'Text', + }, + { + key: 'profile', + tab: 'Visualization', + }, + ]; + + const contentListNoTitle = { + sql: ( +
+
+
+ ), + text: ( +
+
{currentText}
+
+ ), + profile: , + }; + + useEffect(() => { + getQueryInfo({ query_id: queryId }).then(res => { + const { column_names, rows } = res.data; + const columns = column_names.map((item: string, index: number) => { + if (item === 'Query ID') { + return { + title: item, + dataIndex: `${index}`, + key: item, + columnWidth: 10, + render: (text: any, record: any, index: any) => ( + {text} + ), + }; + } + return { + title: item, + dataIndex: index, + key: item, + columnWidth: '10px', + ellipsis: true, + }; + }); + setTableColumn(columns); + setTableData(rows.map((item: string[]) => ({ ...item }))); + }); + }, []); + useEffect(() => { + getSQLText({ queryId }).then(res => { + setCurrentSQL(res.data?.sql || '--'); + }); + getProfileText({ queryId }).then(res => { + setCurrentText(res.data?.profile || '--'); + }); + }, []); + + return ( + <> + +
+
+ { + setCurrentTab(key as tabType); + }} + > + {contentListNoTitle[currentTab]} + + + ); +} diff --git a/frontend/src/routes/query/query-details/profile.tsx b/frontend/src/routes/query/query-details/profile.tsx new file mode 100644 index 0000000..de32091 --- /dev/null +++ b/frontend/src/routes/query/query-details/profile.tsx @@ -0,0 +1,93 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/* eslint-disable prettier/prettier */ +import React, { FC, useState, useEffect, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { Tree, Tag, Space, Card, Tabs } from 'antd'; +import { getProfileFragments, getProfileGraph } from '../query.api'; +import styles from './query.module.less' + +interface IFragment { + fragment_id: string; + instance_id: string[]; + time: string; +} +const Profile: FC = () => { + const [value, setValue] = useState(''); + const [treeData, setTreeData] = useState(); + const params = useParams<{ queryId: string }>(); + const queryId = params.queryId; + + useEffect(() => { + getProfileFragments({ queryId }).then(res => { + const temp = res.data.map((item: IFragment) => ({ + + title: `Fragment${item.fragment_id} -- ${item.time}`, + key: item.fragment_id, + disabled: true, + children: Object.keys(item.instance_id).map(instance => ({ + title: `${instance} -- ${item.instance_id[instance]}` , + key: instance, + isLeaf: true, + parent: item.fragment_id, + })), + })); + setTreeData(temp); + }); + getProfileGraph({ + queryId, + }).then(res => { + setValue(res.data.graph); + }); + }, []); + + const treeChange = (keys: React.Key[], info: any) => { + setValue(''); + const { key, parent } = info.node; + getProfileGraph({ + queryId, + fragmentId: parent, + instanceId: key, + }).then(res => { + setValue(res.data.graph); + }); + }; + function overview() { + getProfileGraph({ + queryId, + }).then(res => { + setValue(res.data.graph); + }); + } + + return ( + + ); +}; + +export default Profile; diff --git a/frontend/src/routes/query/query-details/query.module.less b/frontend/src/routes/query/query-details/query.module.less new file mode 100644 index 0000000..61eb866 --- /dev/null +++ b/frontend/src/routes/query/query-details/query.module.less @@ -0,0 +1,56 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +.textBox { + width: 100%; + min-height: 500px; + max-height: 70vh; + overflow-y: scroll; + border: 1px solid #ddd; + } + +.profileBox { + display: flex; + :global { + .ant-tree .ant-tree-treenode-disabled .ant-tree-node-content-wrapper{ + color: #666 !important; + } + } + + .fragment { + flex-shrink: 0; + width: 270px; + height: 70vh; + overflow: scroll; + border: 1px solid #ddd; + } + + .graph { + flex: 1; + max-height: 70vh; + overflow-y: scroll; + font-size: 14px; + color: #fff; + background-color: #000; + } +} + +:global { + .ant-tree .ant-tree-treenode-disabled .ant-tree-node-content-wrapper{ + color: #666 !important; + } +} \ No newline at end of file diff --git a/frontend/src/routes/query/query.api.ts b/frontend/src/routes/query/query.api.ts new file mode 100644 index 0000000..49ba9f3 --- /dev/null +++ b/frontend/src/routes/query/query.api.ts @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** @format */ + +import { http } from '@src/utils/http'; +import { IResult } from '@src/interfaces/http.interface'; + +// 查询主页 +export function getQueryInfo(data?: any): Promise> { + return http.get(`/api/rest/v2/manager/query/query_info`, data); +} + +// sql语句 +export function getSQLText(data?: any): Promise> { + return http.get(`/api/rest/v2/manager/query/sql/${data.queryId}`, {}, { responseType: 'json' }); +} + +// profile text +export function getProfileText(data?: any): Promise> { + return http.get(`/api/rest/v2/manager/query/profile/text/${data.queryId}`); +} + +// profile fragments +export function getProfileFragments(data?: any): Promise> { + return http.get(`/api/rest/v2/manager/query/profile/fragments/${data.queryId}`); +} + +// profile graph +export function getProfileGraph(data?: any): Promise> { + return http.get(`/api/rest/v2/manager/query/profile/graph/${data.queryId}`, { + fragment_id: data.fragmentId, + instance_id: data.instanceId, + }); +} diff --git a/frontend/src/routes/query/query.data.ts b/frontend/src/routes/query/query.data.ts new file mode 100644 index 0000000..d613380 --- /dev/null +++ b/frontend/src/routes/query/query.data.ts @@ -0,0 +1,28 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export enum STATUS_MAP { + RUNNING = 'RUNNING', + EOF = 'EOF', + ERR = 'ERR', +} + +export const FILTERS = [ + { text: STATUS_MAP.RUNNING, value: STATUS_MAP.RUNNING }, + { text: STATUS_MAP.EOF, value: STATUS_MAP.EOF }, + { text: STATUS_MAP.ERR, value: STATUS_MAP.ERR }, +]; diff --git a/frontend/src/routes/settings/components/settings-header/settings-header.less b/frontend/src/routes/settings/components/settings-header/settings-header.less new file mode 100644 index 0000000..85391e1 --- /dev/null +++ b/frontend/src/routes/settings/components/settings-header/settings-header.less @@ -0,0 +1,36 @@ +.admin-header { + display: flex; + width: 100%; + background-color: #001529; +} +.admin-header-logo { + color: white; + width: 240px; + padding-left: 40px; + line-height: 48px; + cursor: pointer; +} +.palo-opt-box{ + display: flex; + height: 50px; + align-items: center; + div{ + margin-right: 16px; + padding: 10px; + cursor: pointer; + .icon{ + font-size: 20px; + } + .icon-tip{ + margin-left: 0.5rem; + vertical-align: text-bottom; + } + } + div:hover{ + border-radius: 8px; + background: rgb(32,78,171); + } + div:last-child{ + margin-right: 0; + } +} \ No newline at end of file diff --git a/frontend/src/routes/settings/components/settings-header/settings-header.tsx b/frontend/src/routes/settings/components/settings-header/settings-header.tsx new file mode 100644 index 0000000..26a0b55 --- /dev/null +++ b/frontend/src/routes/settings/components/settings-header/settings-header.tsx @@ -0,0 +1,55 @@ +import { SettingOutlined } from '@ant-design/icons'; +import { UserInfoContext } from '@src/common/common.context'; +import { Menu, Space } from 'antd'; +import React, { useContext, useState } from 'react'; +import { useHistory, useRouteMatch } from 'react-router'; +import { SettingsIcon } from '../../../../components/settings-icon/settings-icon'; +import styles from './settings-header.less'; + +export function SettingsHeader(props: any) { + const history = useHistory(); + const match = useRouteMatch(); + const [current, setCurrent] = useState(() => { + let tab = 'user'; + ['global', 'user'].map(key => { + if (history.location.pathname.includes(key)) { + tab = key; + } + }); + return tab; + }); + const userInfo = useContext(UserInfoContext); + function handleClick(e) { + setCurrent(e.key); + if (e.key === 'space') { + history.push(`${match.path}/space/${userInfo.space_id}`); + return; + } + history.push(`${match.path}/${e.key}`); + } + return ( +
+
{ + history.push(`/space`); + }}> + + + Palo Studio平台管理 + +
+ + + 用户 + + + 平台设置 + + +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/routes/settings/components/tabs-header/index.tsx b/frontend/src/routes/settings/components/tabs-header/index.tsx new file mode 100644 index 0000000..d713ae8 --- /dev/null +++ b/frontend/src/routes/settings/components/tabs-header/index.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { Tabs } from 'antd'; +import { useTranslation } from 'react-i18next'; + +const { TabPane } = Tabs; + +export default function TabsHeader() { + const { pathname } = useLocation(); + const history = useHistory(); + + const {t} = useTranslation() + + const handleTabChange = (key: string) => { + history.replace(key); + }; + + return ( + + + + ); +} diff --git a/frontend/src/routes/settings/global/components/loading-layout/index.tsx b/frontend/src/routes/settings/global/components/loading-layout/index.tsx new file mode 100644 index 0000000..4c97957 --- /dev/null +++ b/frontend/src/routes/settings/global/components/loading-layout/index.tsx @@ -0,0 +1,23 @@ +import React, { CSSProperties, PropsWithChildren } from 'react'; +import { Spin } from 'antd'; + +interface LoadingLayoutProps { + loading?: boolean; + wrapperStyle?: CSSProperties; + tip?: string; +} + +export default function LoadingLayout(props: PropsWithChildren) { + const { loading = false, wrapperStyle = {}, children, tip } = props; + return ( +
+ {loading ? ( +
+ +
+ ) : ( + children + )} +
+ ); +} diff --git a/frontend/src/routes/settings/global/components/setting-item-layout/index.tsx b/frontend/src/routes/settings/global/components/setting-item-layout/index.tsx new file mode 100644 index 0000000..47fc6b3 --- /dev/null +++ b/frontend/src/routes/settings/global/components/setting-item-layout/index.tsx @@ -0,0 +1,23 @@ +import { Typography, Row } from 'antd'; +import React, { PropsWithChildren } from 'react'; + +interface SettingItemLayoutProps { + title?: string; + description?: string; +} + +export default function SettingItemLayout(props: PropsWithChildren) { + const { title = '', description = '', children } = props; + + return ( +
+ {title && ( + + {title} + + )} + {description && {description}} + {children} +
+ ); +} diff --git a/frontend/src/routes/settings/global/components/sidebar/index.tsx b/frontend/src/routes/settings/global/components/sidebar/index.tsx new file mode 100644 index 0000000..ad43f64 --- /dev/null +++ b/frontend/src/routes/settings/global/components/sidebar/index.tsx @@ -0,0 +1,35 @@ +import React, { useContext } from 'react'; +import { Menu } from 'antd'; +import { useHistory, useLocation, useRouteMatch } from 'react-router'; +import { UserInfoContext } from '@src/common/common.context'; +import { getGlobalRoutes } from '../../global.utils'; + +interface MenuInfo { + key: string; +} + +export default function Sidebar() { + const userInfo = useContext(UserInfoContext); + const location = useLocation(); + const match = useRouteMatch(); + const history = useHistory(); + + const handleClick = (menuInfo: MenuInfo) => { + history.replace(menuInfo.key); + }; + + const globalRoutes = getGlobalRoutes(userInfo.authType === 'ldap'); + + return ( + + {globalRoutes.map(route => ( + {route.label} + ))} + + ); +} diff --git a/frontend/src/routes/settings/global/constants.ts b/frontend/src/routes/settings/global/constants.ts new file mode 100644 index 0000000..8483a4d --- /dev/null +++ b/frontend/src/routes/settings/global/constants.ts @@ -0,0 +1,9 @@ +import { CSSProperties } from 'react'; + +export const LOADING_WRAPPER_STYLE: CSSProperties = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: 600, + height: 600, +}; diff --git a/frontend/src/routes/settings/global/context/global-settings-context.tsx b/frontend/src/routes/settings/global/context/global-settings-context.tsx new file mode 100644 index 0000000..656ff37 --- /dev/null +++ b/frontend/src/routes/settings/global/context/global-settings-context.tsx @@ -0,0 +1,55 @@ +import React, { PropsWithChildren, useCallback, useEffect } from 'react'; +import { message } from 'antd'; +import { GlobalSettingItem } from '../types'; +import { fetchGlobalSettingsApi } from '../global.api'; +import { useAsync } from '@src/hooks/use-async'; + +interface GlobalSettingsContextProps { + globalSettings: GlobalSettingItem[] | undefined; + loading: boolean; + error: Error | null; + fetchGlobalSettings: () => Promise; +} + +export const GlobalSettingsContext = React.createContext({ + globalSettings: [], + loading: true, + error: null, + fetchGlobalSettings: () => Promise.resolve(), +}); + +const ERROR_MESSAGE = '获取平台设置失败'; + +export default function GlobalSettingsContextProvider(props: PropsWithChildren<{}>) { + const { + data: globalSettings, + loading, + error, + run, + } = useAsync({ + loading: true, + data: [], + }); + const fetchGlobalSettings = useCallback(() => { + return run(fetchGlobalSettingsApi(), { setStartLoading: false }).catch(() => { + message.error(ERROR_MESSAGE); + }); + }, [run]); + + useEffect(() => { + fetchGlobalSettings(); + }, [run, fetchGlobalSettings]); + + return ( + + {props.children} + + ); +} diff --git a/frontend/src/routes/settings/global/context/index.tsx b/frontend/src/routes/settings/global/context/index.tsx new file mode 100644 index 0000000..6b78eba --- /dev/null +++ b/frontend/src/routes/settings/global/context/index.tsx @@ -0,0 +1,8 @@ +import React, { PropsWithChildren } from 'react'; +import GlobalSettingsContextProvider from './global-settings-context'; + +export default function GlobalSettingProvider(props: PropsWithChildren<{}>) { + return {props.children}; +} + +export * from './global-settings-context'; diff --git a/frontend/src/routes/settings/global/global.api.ts b/frontend/src/routes/settings/global/global.api.ts new file mode 100644 index 0000000..f409667 --- /dev/null +++ b/frontend/src/routes/settings/global/global.api.ts @@ -0,0 +1,48 @@ +import { http } from '@src/utils/http'; +import { GlobalSettingItem } from './types'; + +export function fetchGlobalSettingsApi() { + return http.get('/api/setting/global').then(res => { + if (res.code === 0) return res.data; + return Promise.reject(res); + }) as Promise; +} + +export interface RemoteSettingParams extends GlobalSettingItem { + type?: string; +} + +export function changeSettingApi(key: string, params: RemoteSettingParams) { + return http.put(`/api/setting/${key}`, params).then(res => { + if (res.code === 0) return res.data; + return Promise.reject(res); + }); +} + +export function changeEmailSettingApi(params: Record) { + return http.put('/api/email/', params).then(res => { + if (res.code === 0) return res.data; + return Promise.reject(res); + }); +} + +export function deleteEmailSettingApi() { + return http.delete('/api/email/').then(res => { + if (res.code === 0) return res.data; + return Promise.reject(res); + }); +} + +export function sendTestEmailApi(params: { email: string }) { + return http.post('/api/email/test/', params).then(res => { + if (res.code === 0) return res.data; + return Promise.reject(res); + }); +} + +export function getLdapSettingsApi() { + return http.get('/api/ldap/setting').then(res => { + if (res.code === 0) return res.data; + return Promise.reject(res); + }); +} diff --git a/frontend/src/routes/settings/global/global.hooks.ts b/frontend/src/routes/settings/global/global.hooks.ts new file mode 100644 index 0000000..b27b158 --- /dev/null +++ b/frontend/src/routes/settings/global/global.hooks.ts @@ -0,0 +1,65 @@ +import { useState, useContext, useEffect, useCallback } from 'react'; +import * as _ from 'lodash-es'; +import { message } from 'antd'; +import { GlobalSettingItem } from './types'; +import { GlobalSettingsContext } from './context'; +import { changeSettingApi, RemoteSettingParams } from './global.api'; + +function getSettings(settings: GlobalSettingItem[] | null, settingKeys: T[]) { + return (settings + ?.filter(item => settingKeys.includes(item.key as T)) + .reduce((memo, current) => { + memo[current.key] = current; + return memo; + }, {}) || {}) as Record; +} + +export function useSettings(settingKeys: T[]) { + const { globalSettings, fetchGlobalSettings } = useContext(GlobalSettingsContext); + const initialSettings = getSettings(globalSettings, settingKeys); + const [settings, setSettings] = useState({ ...initialSettings }); + + useEffect(() => { + const settings = getSettings(globalSettings, settingKeys); + setSettings({ ...settings }); + }, [globalSettings, settingKeys]); + + const remoteSetting = useCallback( + _.debounce((key: T, params: RemoteSettingParams) => { + changeSettingApi(key, params) + .then(() => { + message.success('设置成功'); + }) + .catch(() => { + message.error('设置失败'); + }) + .finally(() => fetchGlobalSettings()); + }, 200), + [fetchGlobalSettings], + ); + + const changeSettingItem = + (key: T, type?: string, changeRemote: boolean = true) => + (e: any) => { + const value = e && e.target ? e.target.value : e; + const targetSetting = settings[key]!; + const newTargetSetting = { + ...targetSetting, + value, + }; + setSettings({ + ...settings, + [key]: newTargetSetting, + }); + if (!changeRemote) return; + remoteSetting(key, { + ...newTargetSetting, + type, + }); + }; + + return { + settings, + changeSettingItem, + }; +} diff --git a/frontend/src/routes/settings/global/global.routes.ts b/frontend/src/routes/settings/global/global.routes.ts new file mode 100644 index 0000000..fbb28c1 --- /dev/null +++ b/frontend/src/routes/settings/global/global.routes.ts @@ -0,0 +1,33 @@ +import PublicSharing from './routes/public-sharing'; +import Localization from './routes/localization'; +import Email from './routes/email'; +import General from './routes/general'; + +export interface Route { + path: string; + label: string; + component: () => JSX.Element; +} + +export const DEFAULT_GLOBAL_ROUTES: Route[] = [ + { + path: 'public_sharing', + label: '公开分享', + component: PublicSharing, + }, + { + path: 'localization', + label: '本土化', + component: Localization, + }, + { + path: 'email', + label: '邮箱', + component: Email, + }, + { + path: 'general', + label: '访问与帮助', + component: General, + }, +]; diff --git a/frontend/src/routes/settings/global/global.tsx b/frontend/src/routes/settings/global/global.tsx new file mode 100644 index 0000000..2daa85b --- /dev/null +++ b/frontend/src/routes/settings/global/global.tsx @@ -0,0 +1,49 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Typography, Row } from 'antd'; +import { Switch, Route, Redirect, useRouteMatch } from 'react-router-dom'; +import styles from './style.module.less'; +import Sidebar from './components/sidebar'; +import GlobalSettingProvider from './context'; +import { UserInfoContext } from '@src/common/common.context'; +import LoadingLayout from './components/loading-layout'; +import { getGlobalRoutes } from './global.utils'; + +export function Global() { + const [loading, setLoading] = useState(true); + const match = useRouteMatch(); + const userInfo = useContext(UserInfoContext); + const isLdap = userInfo.authType === 'ldap'; + + useEffect(() => { + if (userInfo.id != null) { + setLoading(false); + } + }, [userInfo.id]); + + const globalRoutes = getGlobalRoutes(isLdap); + + return ( + +
+ + + 设置 + +
+ + + {globalRoutes.map(route => ( + + ))} + + +
+
+
+
+ ); +} diff --git a/frontend/src/routes/settings/global/global.utils.ts b/frontend/src/routes/settings/global/global.utils.ts new file mode 100644 index 0000000..4c094e7 --- /dev/null +++ b/frontend/src/routes/settings/global/global.utils.ts @@ -0,0 +1,31 @@ +import { DEFAULT_GLOBAL_ROUTES, Route } from './global.routes'; +import Certificate from './routes/certificate'; + +export function getValueFromJson(key: string, defaultValue?: any) { + let res = defaultValue; + try { + res = JSON.parse(key); + } catch (e) {} + return res; +} + +export function getProtocol(url: string) { + const match = /^https?:\/\//.exec(url); + return match ? match[0] : ''; +} + +export function getAddress(url: string) { + const match = /^https?:\/\//.exec(url); + return match ? url.slice(match[0].length) : url || ''; +} + +export function getGlobalRoutes(isLdap: boolean) { + return [ + isLdap && { + path: 'certificate', + label: '认证', + component: Certificate, + }, + ...DEFAULT_GLOBAL_ROUTES, + ].filter(Boolean) as Route[]; +} diff --git a/frontend/src/routes/settings/global/routes/certificate/index.tsx b/frontend/src/routes/settings/global/routes/certificate/index.tsx new file mode 100644 index 0000000..5695919 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/certificate/index.tsx @@ -0,0 +1,82 @@ +import React, { useEffect } from 'react'; +import { Form, Input, message, Radio } from 'antd'; +import { useAsync } from '@src/hooks/use-async'; +import LoadingLayout from '../../components/loading-layout'; +import { LOADING_WRAPPER_STYLE } from '../../constants'; +import { getLdapSettingsApi } from '../../global.api'; + +export default function Certificate() { + const [form] = Form.useForm(); + const { data, loading, run } = useAsync({ loading: true, data: {} }); + useEffect(() => { + run(getLdapSettingsApi()).catch(() => { + message.error('获取ldap设置失败'); + }); + }, [run]); + useEffect(() => { + form.setFieldsValue(data); + }, [data]); + return ( + +
+

服务器

+ + + + + + + + + None + SSL + StartTLS + + + + + + + + +

用户结构

+ + + + + + +

属性

+ + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/routes/settings/global/routes/email/email-modal.tsx b/frontend/src/routes/settings/global/routes/email/email-modal.tsx new file mode 100644 index 0000000..69ff923 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/email/email-modal.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import { Input, Modal } from 'antd'; + +interface EmailModalProps { + visible: boolean; + confirmLoading: boolean; + onOk: (email: string) => () => void; + onCancel: () => void; +} + +export default function EmailModal(props: EmailModalProps) { + const { visible, onOk, onCancel, confirmLoading } = props; + const [emailContent, setEmailContent] = useState(''); + + return ( + + setEmailContent(e.target.value)} + placeholder="请输入目标邮箱地址" + /> + + ); +} diff --git a/frontend/src/routes/settings/global/routes/email/hooks.ts b/frontend/src/routes/settings/global/routes/email/hooks.ts new file mode 100644 index 0000000..aaf2b0f --- /dev/null +++ b/frontend/src/routes/settings/global/routes/email/hooks.ts @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { message } from 'antd'; +import * as _ from 'lodash-es'; +import { useAsync } from '@src/hooks/use-async'; +import { changeEmailSettingApi, deleteEmailSettingApi, sendTestEmailApi } from '../../global.api'; +import { SETTING_KEYS, SettingKeysTypes } from '.'; +import { GlobalSettingItem } from '../../types'; + +export function useEmailSettings( + settings: Record, + fetchGlobalSettings: () => Promise, +) { + const { loading: buttonLoading, run: runRemoteApi } = useAsync(); + const [modalVisible, setModalVisible] = useState(false); + const [isChanged, setIsChanged] = useState(false); + + const handleSaveEmailSettings = _.debounce(() => { + const params = Object.keys(settings).reduce((memo, current) => { + if (SETTING_KEYS.includes(current as SettingKeysTypes)) { + memo[current] = settings[current]?.value || null; + } + return memo; + }, {} as Record); + runRemoteApi(changeEmailSettingApi(params)) + .then(() => { + message.success('保存成功'); + }) + .catch(() => { + message.error('保存失败'); + }) + .finally(() => fetchGlobalSettings()) + .then(() => setIsChanged(false)); + }, 200); + + const handleDeleteEmailSettings = _.debounce(() => { + runRemoteApi(deleteEmailSettingApi()) + .then(() => { + message.success('清除成功'); + }) + .catch(() => { + message.error('清除失败'); + }) + .finally(() => fetchGlobalSettings()) + .then(() => setIsChanged(false)); + }); + + const handleSendTestEmail = (email: string) => () => { + runRemoteApi(sendTestEmailApi({ email })) + .then(() => { + message.success('发送成功'); + }) + .catch(() => { + message.error('发送失败'); + }) + .finally(() => setModalVisible(false)); + }; + + return { + isChanged, + modalVisible, + buttonLoading, + setModalVisible, + setIsChanged, + handleSaveEmailSettings, + handleDeleteEmailSettings, + handleSendTestEmail, + }; +} diff --git a/frontend/src/routes/settings/global/routes/email/index.tsx b/frontend/src/routes/settings/global/routes/email/index.tsx new file mode 100644 index 0000000..fa7875d --- /dev/null +++ b/frontend/src/routes/settings/global/routes/email/index.tsx @@ -0,0 +1,152 @@ +import React, { useContext } from 'react'; +import { Input, InputNumber, Radio, Row, Button, Col } from 'antd'; +import EmailModal from './email-modal'; +import LoadingLayout from '../../components/loading-layout'; +import SettingItemLayout from '../../components/setting-item-layout'; +import { GlobalSettingsContext } from '../../context'; +import { useSettings } from '../../global.hooks'; +import { getValueFromJson } from '../../global.utils'; +import { useEmailSettings } from './hooks'; +import { LOADING_WRAPPER_STYLE } from '../../constants'; + +export const SETTING_KEYS = [ + 'email-smtp-host', + 'email-smtp-port', + 'email-smtp-security', + 'email-smtp-username', + 'email-smtp-password', + 'email-from-address', +] as const; + +export type SettingKeysTypes = typeof SETTING_KEYS[number]; + +const SMTP_SECURITY_OPTIONS = [ + { value: 'none', label: 'None' }, + { value: 'ssl', label: 'SSL' }, + { value: 'tls', label: 'TLS' }, + { value: 'starttls', label: 'STARTTLS' }, +]; + +export default function Email() { + const { loading, fetchGlobalSettings } = useContext(GlobalSettingsContext); + const { settings, changeSettingItem } = useSettings(SETTING_KEYS as any); + const { + buttonLoading, + modalVisible, + setModalVisible, + isChanged, + setIsChanged, + handleSaveEmailSettings, + handleDeleteEmailSettings, + handleSendTestEmail, + } = useEmailSettings(settings, fetchGlobalSettings); + + const buttonDisabled = + !settings['email-smtp-host']?.value || + !settings['email-smtp-port']?.value || + !settings['email-from-address']?.value || + settings['email-smtp-security']?.value == null; + + const sendEmailButtonVisible = !isChanged && settings['email-from-address']?.value; + + const changeEmailSettingItem = (key: SettingKeysTypes) => (e: any) => { + setIsChanged(true); + changeSettingItem(key, undefined, false)(e); + }; + + return ( + + + + + + changeEmailSettingItem('email-smtp-port')(v + '')} + /> + + + + {SMTP_SECURITY_OPTIONS.map(options => ( + + {options.label} + + ))} + + + + + + + + + + + + { + setModalVisible(false); + }} + /> + + + + + {sendEmailButtonVisible && ( + + + + )} + + + + + + ); +} diff --git a/frontend/src/routes/settings/global/routes/general/index.tsx b/frontend/src/routes/settings/global/routes/general/index.tsx new file mode 100644 index 0000000..8216856 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/general/index.tsx @@ -0,0 +1,74 @@ +import React, { useContext } from 'react'; +import { Input, Select } from 'antd'; +import LoadingLayout from '../../components/loading-layout'; +import SettingItemLayout from '../../components/setting-item-layout'; +import { GlobalSettingsContext } from '../../context'; +import { useSettings } from '../../global.hooks'; +import { LOADING_WRAPPER_STYLE } from '../../constants'; +import { getProtocol, getAddress } from '../../global.utils'; + +const { Option } = Select; + +const SETTING_KEYS = ['site-name', 'site-url', 'admin-email'] as const; + +type SettingsKeysTypes = typeof SETTING_KEYS[number]; + +const PROTOCOL_OPTIONS = [{ value: 'http://' }, { value: 'https://' }]; + +export default function General() { + const { loading } = useContext(GlobalSettingsContext); + const { settings, changeSettingItem } = useSettings(SETTING_KEYS as any); + + return ( + + + + + + { + const address = getAddress(settings['site-url']?.value); + changeSettingItem('site-url', 'string')(v + address); + }} + > + {PROTOCOL_OPTIONS.map(options => ( + + ))} + + } + value={getAddress(settings['site-url']?.value)} + placeholder="请输入网站地址" + defaultValue={getAddress(settings['site-url']?.default)} + onChange={e => { + const protocol = getProtocol(settings['site-url']?.value); + changeSettingItem('site-url', 'string')(protocol + e.target.value); + }} + /> + + + + + + ); +} diff --git a/frontend/src/routes/settings/global/routes/localization/constants.ts b/frontend/src/routes/settings/global/routes/localization/constants.ts new file mode 100644 index 0000000..046c4e4 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/localization/constants.ts @@ -0,0 +1,26 @@ +export const DEFAULT_DATE_STYLE_OPTIONS = [ + { value: 'MMMM D, YYYY', completeLabel: '一月 7, 2018', compressedLabel: '1月, 7, 2018' }, + { value: 'D MMMM, YYYY', completeLabel: '7 一月, 2018', compressedLabel: '7 1月, 2018' }, + { value: 'dddd, MMMM D, YYYY', completeLabel: '星期日, 一月 7, 2018', compressedLabel: '周日, 1月 7, 2018' }, + { value: 'M/D/YYYY', label: [1, 7, 2018] }, + { value: 'D/M/YYYY', label: [7, 1, 2018] }, + { value: 'YYYY/M/D', label: [2018, 1, 7] }, +]; + +export const DATE_SEPARATOR_OPTIONS = [ + { value: '/', label: 'M/D/YYYY' }, + { value: '-', label: 'M-D-YYYY' }, + { value: '.', label: 'M.D.YYYY' }, +]; + +export const TIME_STYLE_OPTIONS = [ + { value: 'h:mm A', label: '5:24 下午 (12小时制)' }, + { value: 'k:mm', label: '17:24 (24小时制)' }, +]; + +export const NUMBER_SEPARATORS_OPTIONS = [ + { value: '.,', label: '100,000.00' }, + { value: ', ', label: '100 000,00' }, + { value: ',.', label: '100.000,00' }, + { value: '.', label: '100000.00' }, +]; diff --git a/frontend/src/routes/settings/global/routes/localization/form-item-layout.tsx b/frontend/src/routes/settings/global/routes/localization/form-item-layout.tsx new file mode 100644 index 0000000..87d4382 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/localization/form-item-layout.tsx @@ -0,0 +1,18 @@ +import { Typography } from 'antd'; +import React, { PropsWithChildren } from 'react'; + +interface FormItemLayoutProps { + title: string; +} + +export default function FormItemLayout(props: PropsWithChildren) { + const { title, children } = props; + return ( +
+ + {title} + + {children} +
+ ); +} diff --git a/frontend/src/routes/settings/global/routes/localization/form-layout.tsx b/frontend/src/routes/settings/global/routes/localization/form-layout.tsx new file mode 100644 index 0000000..b1d70b1 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/localization/form-layout.tsx @@ -0,0 +1,18 @@ +import { Typography } from 'antd'; +import React, { PropsWithChildren } from 'react'; + +interface FormLayoutProps { + title: string; +} + +export default function FormLayout(props: PropsWithChildren) { + const { title, children } = props; + return ( +
+ + {title} + + {children} +
+ ); +} diff --git a/frontend/src/routes/settings/global/routes/localization/index.tsx b/frontend/src/routes/settings/global/routes/localization/index.tsx new file mode 100644 index 0000000..6b00d3d --- /dev/null +++ b/frontend/src/routes/settings/global/routes/localization/index.tsx @@ -0,0 +1,52 @@ +import React, { useContext } from 'react'; +import { useSettings } from '../../global.hooks'; +import LoadingLayout from '../../components/loading-layout'; +import TemporalForm from './temporal-form'; +import NumberForm from './number-form'; +import { GlobalSettingsContext } from '../../context'; +import { LOADING_WRAPPER_STYLE } from '../../constants'; +import { Divider } from 'antd'; +import { getValueFromJson } from '../../global.utils'; +import { GlobalSettingItem } from '../../types'; + +const SETTING_KEYS = ['custom-formatting'] as const; + +type SettingKeysTypes = typeof SETTING_KEYS[number]; + +type LocalizationNamspace = 'type/Temporal' | 'type/Number'; + +export default function Localization() { + const { loading } = useContext(GlobalSettingsContext); + const { settings, changeSettingItem } = useSettings(SETTING_KEYS as any); + const targetSettings = settings['custom-formatting'] || ({} as GlobalSettingItem); + const defaultSettings = getValueFromJson(targetSettings?.default, {}); + + const changeLocalizationSetting = (namespace: LocalizationNamspace) => (key: string) => (e: any) => { + const value = e && e.target ? e.target.value : e; + changeSettingItem('custom-formatting')({ + ...targetSettings.value, + [namespace]: { + ...targetSettings.value?.[namespace], + [key]: value, + }, + }); + }; + + return ( + +
+ + + +
+
+ ); +} diff --git a/frontend/src/routes/settings/global/routes/localization/number-form.tsx b/frontend/src/routes/settings/global/routes/localization/number-form.tsx new file mode 100644 index 0000000..9337da2 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/localization/number-form.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Select } from 'antd'; +import FormItemLayout from './form-item-layout'; +import FormLayout from './form-layout'; +import { NUMBER_SEPARATORS_OPTIONS } from './constants'; + +const { Option } = Select; + +interface NumberSettings { + number_separators: string; +} + +interface NumberFormProps { + settings: NumberSettings; + defaultSettings: NumberSettings; + changeLocalizationSettingItem: (key: string) => (e: any) => void; +} + +export default function NumberForm(props: NumberFormProps) { + const { settings, defaultSettings, changeLocalizationSettingItem } = props; + return ( + + + + + + ); +} diff --git a/frontend/src/routes/settings/global/routes/localization/temporal-form.tsx b/frontend/src/routes/settings/global/routes/localization/temporal-form.tsx new file mode 100644 index 0000000..63abea3 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/localization/temporal-form.tsx @@ -0,0 +1,92 @@ +import React, { useMemo } from 'react'; +import { Select, Radio, Space, Switch } from 'antd'; +import FormLayout from './form-layout'; +import FormItemLayout from './form-item-layout'; +import { DEFAULT_DATE_STYLE_OPTIONS, DATE_SEPARATOR_OPTIONS, TIME_STYLE_OPTIONS } from './constants'; + +const { Option } = Select; + +interface TemporalSettings { + date_abbreviate: boolean; + date_separator: string; + date_style: string; + time_style: string; +} + +interface TemporalFormProps { + settings: TemporalSettings; + defaultSettings: TemporalSettings; + changeLocalizationSettingItem: (key: string) => (e: any) => void; +} + +export default function TemporalForm(props: TemporalFormProps) { + const { settings, defaultSettings, changeLocalizationSettingItem } = props; + + const DATE_STYLE_OPTIONS = useMemo(() => { + return DEFAULT_DATE_STYLE_OPTIONS.map(options => ({ + value: options.value, + label: options.label + ? options.label.join(settings.date_separator) + : settings.date_abbreviate + ? options.compressedLabel + : options.completeLabel, + })); + }, [settings.date_separator, settings.date_abbreviate]); + + return ( + + + + + + + + {DATE_SEPARATOR_OPTIONS.map(item => ( + + {item.label} + + ))} + + + + + + + + + + {TIME_STYLE_OPTIONS.map(item => ( + + {item.label} + + ))} + + + + + ); +} diff --git a/frontend/src/routes/settings/global/routes/public-sharing/index.tsx b/frontend/src/routes/settings/global/routes/public-sharing/index.tsx new file mode 100644 index 0000000..f4634b4 --- /dev/null +++ b/frontend/src/routes/settings/global/routes/public-sharing/index.tsx @@ -0,0 +1,32 @@ +import React, { useContext } from 'react'; +import { Switch } from 'antd'; +import LoadingLayout from '../../components/loading-layout'; +import SettingItemLayout from '../../components/setting-item-layout'; +import { GlobalSettingsContext } from '../../context'; +import { useSettings } from '../../global.hooks'; +import { LOADING_WRAPPER_STYLE } from '../../constants'; +import { getValueFromJson } from '../../global.utils'; + +const SETTING_KEYS = ['enable-public-sharing'] as const; + +type SettingKeysTypes = typeof SETTING_KEYS[number]; + +export default function PublicSharing() { + const { loading } = useContext(GlobalSettingsContext); + const { settings, changeSettingItem } = useSettings(SETTING_KEYS as any); + + return ( + + + + + {getValueFromJson(settings['enable-public-sharing']?.value) ? '启用' : '取消'} + + + + ); +} diff --git a/frontend/src/routes/settings/global/style.module.less b/frontend/src/routes/settings/global/style.module.less new file mode 100644 index 0000000..e44217b --- /dev/null +++ b/frontend/src/routes/settings/global/style.module.less @@ -0,0 +1,6 @@ +.container { + padding: 32px; + .main { + display: flex; + } +} diff --git a/frontend/src/routes/settings/global/types/index.ts b/frontend/src/routes/settings/global/types/index.ts new file mode 100644 index 0000000..096487a --- /dev/null +++ b/frontend/src/routes/settings/global/types/index.ts @@ -0,0 +1,6 @@ +export interface GlobalSettingItem { + key: string; + default: any; + value: any; + description: string; +} diff --git a/frontend/src/routes/settings/settings.module.less b/frontend/src/routes/settings/settings.module.less new file mode 100644 index 0000000..b14ed56 --- /dev/null +++ b/frontend/src/routes/settings/settings.module.less @@ -0,0 +1,9 @@ +.container { + margin-left: 80px; + overflow-y: scroll; + height: calc(100% - 44px); +} + +.card { + min-height: 100%; +} diff --git a/frontend/src/routes/settings/settings.tsx b/frontend/src/routes/settings/settings.tsx new file mode 100644 index 0000000..d47666b --- /dev/null +++ b/frontend/src/routes/settings/settings.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from 'react'; +import { Card } from 'antd'; +import { Redirect, Route, Switch, useRouteMatch, useHistory } from 'react-router-dom'; +import styles from './settings.module.less'; +import { UserInfoContext } from '@src/common/common.context'; +import { Sidebar } from '@src/components/sidebar/sidebar'; +import { Header } from '@src/components/header/header'; +import TabsHeader from './components/tabs-header'; +import { User } from './user/user'; +import { useUserInfo } from '@src/hooks/use-userinfo.hooks'; +import LoadingLayout from './global/components/loading-layout'; + +export function Settings(props: any) { + const match = useRouteMatch(); + const history = useHistory(); + const [loading, setLoading] = useState(true); + const [userInfo] = useUserInfo(); + useEffect(() => { + if (userInfo.id == null) return; + if (userInfo.id != null && !userInfo.is_super_admin) { + history.push('/space'); + return; + } + setLoading(false); + }, [userInfo.id]); + return ( + <> + + +
+
+ + + + + + + + + +
+ + + ); +} diff --git a/frontend/src/routes/settings/user/list/create-or-edit-modal.tsx b/frontend/src/routes/settings/user/list/create-or-edit-modal.tsx new file mode 100644 index 0000000..27f7c74 --- /dev/null +++ b/frontend/src/routes/settings/user/list/create-or-edit-modal.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { Form, Input, message, Modal } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { isSuccess } from '@src/utils/http'; +import { UserAPI } from '../user.api'; +import { copyText, generatePassword } from '../user.utils'; +import styles from './list.module.less'; + +export function CreateOrEditRoleModal(props: any) { + const { t, i18n } = useTranslation(); + const { onCancel, user } = props; + const [loading, setLoading] = useState(false); + const title = user ? t`editUser` : t`addUser`; + + useEffect(() => { + props.form.setFieldsValue(user ? user : { name: '', email: '' }); + }, [user]); + + async function handleCreate(values: any) { + setLoading(true); + try { + const password = generatePassword(); + const res = await UserAPI.createUser({ ...values, password }); + setLoading(false); + if (isSuccess(res)) { + message.success(t`createSuccess`); + props.onSuccess && props.onSuccess(); + Modal.confirm({ + title: t`pleaseSaveYourPassword`, + content: ( +
+ { + copyText(password); + message.success(t`copySuccess`); + }} + /> + } + /> +
+ ), + }); + } else { + message.error(res.msg); + } + } catch (err) { + setLoading(false); + } + } + async function handleEdit(values: any) { + setLoading(true); + const res = await UserAPI.updateUser({ + user_id: user.id, + name: values.name, + email: values.email, + }); + setLoading(false); + if (isSuccess(res)) { + message.success(t`editSuccess`); + props.onSuccess && props.onSuccess(); + } else { + message.error(res.msg); + } + } + return ( + { + props.form + .validateFields() + .then((values: any) => { + if (!user) { + handleCreate(values); + } else { + handleEdit(values); + } + }) + .catch((info: any) => { + console.log('Validate Failed:', info); + }); + }} + > +
+ + + + + + +
+
+ ); +} diff --git a/frontend/src/routes/settings/user/list/list.module.less b/frontend/src/routes/settings/user/list/list.module.less new file mode 100644 index 0000000..86d44cb --- /dev/null +++ b/frontend/src/routes/settings/user/list/list.module.less @@ -0,0 +1,7 @@ +.clipInput { + :global { + .copy-icon { + color: #1890ff!important; + } + } +} diff --git a/frontend/src/routes/settings/user/list/list.tsx b/frontend/src/routes/settings/user/list/list.tsx new file mode 100644 index 0000000..db0a04d --- /dev/null +++ b/frontend/src/routes/settings/user/list/list.tsx @@ -0,0 +1,214 @@ +import React, { useContext, useState } from 'react'; +import moment from 'moment'; +import { Button, Table, Input, Modal, message, Switch, Row } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; +import { useForm } from 'antd/lib/form/Form'; +import { useTranslation } from 'react-i18next'; +import StatusMark from '@src/components/status-mark'; +import { FlatBtnGroup, FlatBtn } from '@src/components/flatbtn'; +import { UserInfoContext } from '@src/common/common.context'; +import { isSuccess } from '@src/utils/http'; +import { UserAPI } from '../user.api'; +import { useGlobalUsers } from '../user.hooks'; +import { generatePassword, copyText } from '../user.utils'; +import { CreateOrEditRoleModal } from './create-or-edit-modal'; +import styles from './list.module.less'; +import { UserInfo } from '@src/common/common.interface'; + +export function UserList() { + const { t } = useTranslation(); + const userInfo = useContext(UserInfoContext) as UserInfo; + const { users, getUsers, loading, setUsers } = useGlobalUsers({ + include_deactivated: true, + }); + const [visible, setVisible] = useState(false); + const [currentUser, setCurrentUser] = useState(); + const [form] = useForm(); + const columns = [ + { + title: t`username`, + key: 'name', + dataIndex: 'name', + }, + { + title: t`Mail`, + dataIndex: 'email', + key: 'email', + }, + { + title: t`status`, + dataIndex: 'is_active', + filters: [ + { text: t`enabled`, value: true }, + { text: t`disabled`, value: false }, + ], + render: (is_active: boolean) => ( + + {is_active ? t`activated` : t`deactivated`} + + ), + onFilter: (value: any, record: any) => record.is_active === value, + }, + { + title: t`superAdministrator`, + dataIndex: 'is_super_admin', + key: 'is_super_admin', + render: (is_super_admin: boolean, record: any, index: number) => ( + + ), + }, + { + title: t`lastLogin`, + dataIndex: 'last_login', + key: 'last_login', + render: (last_login: string) => { + return ( + + {last_login == null ? t`neverLoggedIn` : moment(last_login).format('YYYY-MM-DD HH:mm:ss')} + + ); + }, + }, + { + title: t`operation`, + key: 'actions', + render: (record: any) => { + const disabled = userInfo.id === record.id; + return ( + + { + setCurrentUser(record); + setVisible(true); + }} + > + {t`edit`} + + + handleResetPassword(record)}>{t`resetPassword`} + + toggleActivate(record)} disabled={disabled}> + {record.is_active ? t`deactivateUser` : t`activateUser`} + + + ); + }, + }, + ]; + + const handleResetPassword = (record: any) => { + Modal.confirm({ + title: t`resetPasswordOrNot`, + onOk: () => { + const password = generatePassword(); + UserAPI.resetPassword({ user_id: record.id, password }) + .then(res => { + if (isSuccess(res)) { + Modal.confirm({ + title: t`pleaseSaveYourPassword`, + content: ( +
+ { + try { + copyText(password); + message.success(t`copySuccess`); + } catch (e) { + message.error(t`copyError`); + } + }} + /> + } + /> +
+ ), + }); + return; + } + message.error(res.msg); + }) + .catch(() => message.error(t`resetPasswordFailed`)); + }, + }); + }; + + const changeSuperAdmin = (user_id: number, index: number) => async (checked: boolean) => { + users.splice(index, 1, { + ...users[index], + is_super_admin: !users[index].is_super_admin, + }); + setUsers([...users]); + UserAPI.updateUserAdmin({ admin: checked, user_id }) + .then(res => { + if (!isSuccess(res)) { + message.error(res.msg); + getUsers(); + } + }) + .catch(() => { + message.error(t`setupFailed`); + getUsers(); + }); + }; + const toggleActivate = (record: any) => { + const { is_active } = record; + Modal.confirm({ + title: `${is_active ? t`whetherToDeactivate` : t`whetherToActivate`} ${record.name} ?`, + content: `${is_active ? t`afterDeactivate` : t`afterActivate`} ${record.name} ${ + is_active ? t`canNotLogin` : t`canLoginAgain` + }`, + onOk() { + const operator = is_active ? UserAPI.deactivateUser : UserAPI.activateUser; + operator({ user_id: record.id }) + .then(res => { + if (isSuccess(res)) { + message.success(t`setupSuccess`); + getUsers(); + } else { + message.error(t`setupFailed`); + } + }) + .catch(() => { + message.error(t`setupFailed`); + }); + }, + }); + }; + + return ( + <> + + + + + {visible && ( + { + setVisible(false); + form.resetFields(); + getUsers(); + }} + form={form} + user={currentUser} + onCancel={() => setVisible(false)} + /> + )} + + ); +} diff --git a/frontend/src/routes/settings/user/user.api.ts b/frontend/src/routes/settings/user/user.api.ts new file mode 100644 index 0000000..3f295db --- /dev/null +++ b/frontend/src/routes/settings/user/user.api.ts @@ -0,0 +1,45 @@ +import { http, isSuccess } from '@src/utils/http'; + +function getUsers(data: { include_deactivated: boolean; cluster_id?: number }) { + return http.get('/api/v2/user/', data); +} +function createUser(data: { name: string; email: string; password: string }) { + return http.post('/api/v2/user/', data); +} +function updateUser(data: { name: string; user_id: number; email: string }) { + return http.put(`/api/v2/user/${data.user_id}`, data); +} + +function updateUserAdmin(data: { admin: boolean; user_id: number }) { + return http.put(`/api/v2/user/${data.user_id}/admin`, { admin: data.admin }); +} + +function deactivateUser(data: { user_id: number }) { + return http.delete(`/api/v2/user/${data.user_id}`); +} + +function activateUser(data: { user_id: number }) { + return http.put(`/api/v2/user/${data.user_id}/reactivate`); +} + +function resetPassword(data: { user_id: number; password: string }) { + return http.put(`/api/v2/user/${data.user_id}/password`, { password: data.password }); +} + +function syncLdapUser() { + return http.get('/api/setting/syncLdapUser').then(res => { + if (isSuccess(res)) return res.data; + return Promise.reject(res); + }); +} + +export const UserAPI = { + getUsers, + createUser, + updateUser, + updateUserAdmin, + deactivateUser, + activateUser, + resetPassword, + syncLdapUser +}; diff --git a/frontend/src/routes/settings/user/user.hooks.ts b/frontend/src/routes/settings/user/user.hooks.ts new file mode 100644 index 0000000..13c0d43 --- /dev/null +++ b/frontend/src/routes/settings/user/user.hooks.ts @@ -0,0 +1,40 @@ +import { CommonAPI } from '@src/common/common.api'; +import { isSuccess } from '@src/utils/http'; +import { Dispatch, SetStateAction, useState, useEffect } from 'react'; +import { UserAPI } from './user.api'; + +interface GetUsersParams { + include_deactivated: boolean; + cluster_id?: number; +} + +export function useGlobalUsers(params: GetUsersParams): { + users: any[]; + setUsers: Dispatch>; + getUsers: (extraParams?: GetUsersParams) => Promise; + loading: boolean; +} { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + useEffect(() => { + getUsers(); + }, []); + + async function getUsers(extraParams?: GetUsersParams) { + setLoading(true); + const res = await UserAPI.getUsers({ + ...params, + ...extraParams, + }); + setLoading(false); + if (isSuccess(res)) { + setUsers(res.data); + } + } + return { + users, + setUsers, + getUsers, + loading, + }; +} diff --git a/frontend/src/routes/settings/user/user.less b/frontend/src/routes/settings/user/user.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/routes/settings/user/user.tsx b/frontend/src/routes/settings/user/user.tsx new file mode 100644 index 0000000..6c026ad --- /dev/null +++ b/frontend/src/routes/settings/user/user.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { UserList } from './list/list'; + +export function User(props: any) { + const { match } = props; + return ( + <> + + + + + + ); +} diff --git a/frontend/src/routes/settings/user/user.utils.ts b/frontend/src/routes/settings/user/user.utils.ts new file mode 100644 index 0000000..f735676 --- /dev/null +++ b/frontend/src/routes/settings/user/user.utils.ts @@ -0,0 +1,25 @@ +import passwordGenerator from 'password-generator'; + +function isStrongEnough(password: string) { + return /^(?![a-zA-Z]+$)(?![A-Z\d]+$)(?![A-Z_]+$)(?![a-z\d]+$)(?![a-z_]+$)(?![\d_]+$)[a-zA-Z\d_]{6,12}$/.test( + password, + ); +} + +export function generatePassword() { + let password = passwordGenerator(12, false, /[a-zA-Z\d_]/); + while (!isStrongEnough(password)) { + password = passwordGenerator(12, false, /[a-zA-Z\d_]/); + } + return password; +} + +export function copyText(text: string) { + const textArea = document.createElement('textarea'); + textArea.style.opacity = '0'; + document.body.appendChild(textArea); + textArea.value = text; + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); +} diff --git a/frontend/src/routes/space/access-cluster/access-cluster.data.ts b/frontend/src/routes/space/access-cluster/access-cluster.data.ts new file mode 100644 index 0000000..9ff3c97 --- /dev/null +++ b/frontend/src/routes/space/access-cluster/access-cluster.data.ts @@ -0,0 +1,14 @@ +export enum AccessClusterStepsEnum { + 'space-register', + 'connect-cluster', + 'managed-options', + 'node-verify', + 'cluster-verify', + 'finish', +} + +export const ACCESS_CLUSTER_REQUEST_INIT_PARAMS = { + clusterId: '0', + requestId: '0', + currentEventType: '1', +} diff --git a/frontend/src/routes/space/access-cluster/access-cluster.recoil.ts b/frontend/src/routes/space/access-cluster/access-cluster.recoil.ts new file mode 100644 index 0000000..0abe5fe --- /dev/null +++ b/frontend/src/routes/space/access-cluster/access-cluster.recoil.ts @@ -0,0 +1,43 @@ +import { isSuccess } from "@src/utils/http"; +import { atom, selector } from "recoil"; +import { SpaceAPI } from "../space.api"; +import { ACCESS_CLUSTER_REQUEST_INIT_PARAMS } from "./access-cluster.data"; + +export const nextStepDisabledState = atom({ + key: 'nextStepDisabled', + default: false, +}); + +export const requestInfoState = atom({ + key: 'requestInfoState', + default: ACCESS_CLUSTER_REQUEST_INIT_PARAMS, +}); + +export const stepDisabledState = atom({ + key: 'stepDisabledState', + default: { + next: false, + prev: false, + }, +}); + +// export const requestInfoQuery = selector({ +// key: 'requestInfoQuery', +// get: async ({get}) => { +// const requestInfo = get(requestInfoState); +// console.log(requestInfo); +// // if (+requestInfo.clusterId === 0) { +// // return JSON.parse(localStorage.getItem('requestInfo') || JSON.stringify(ACCESS_CLUSTER_REQUEST_INIT_PARAMS)); +// // } +// const res = await SpaceAPI.spaceGet(requestInfo.clusterId); +// console.log(res); +// if (isSuccess(res)) { +// localStorage.setItem('requestInfo', JSON.stringify(res.data)); +// return res.data; +// } +// return ACCESS_CLUSTER_REQUEST_INIT_PARAMS; +// }, +// set: ({set}, newValue: any) => { +// set(requestInfoState, newValue); +// } +// }); diff --git a/frontend/src/routes/space/access-cluster/access-cluster.tsx b/frontend/src/routes/space/access-cluster/access-cluster.tsx new file mode 100644 index 0000000..6665342 --- /dev/null +++ b/frontend/src/routes/space/access-cluster/access-cluster.tsx @@ -0,0 +1,192 @@ +import ProCard from '@ant-design/pro-card'; +import { Button, message, Row, Space, Steps } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { Redirect, useRouteMatch, useHistory } from 'react-router'; +import CacheRoute, { CacheSwitch } from 'react-router-cache-route'; +import { pathToRegexp } from 'path-to-regexp'; +import { NewSpaceInfoContext } from '@src/common/common.context'; +import { useForm } from 'antd/lib/form/Form'; +import { AccessClusterStepsEnum } from './access-cluster.data'; +import { SpaceRegister } from '../components/space-register/space-register'; +import { ConnectCluster } from './steps/connect-cluster/connect-cluster'; +import { ManagedOptions } from './steps/managed-options/managed-options'; +import { NodeVerify } from '../components/node-verify/node-verify'; +import { isSuccess } from '@src/utils/http'; +import { SpaceAPI } from '../space.api'; +import { ClusterAccessParams } from '../space.interface'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { requestInfoState, stepDisabledState } from './access-cluster.recoil'; +import { ClusterVerify } from './steps/cluster-verify/cluster-verify'; +import { SpaceAccessFinish } from './steps/finish/finish'; +const { Step } = Steps; + +export function AccessCluster(props: any) { + const match = useRouteMatch<{requestId: string}>(); + const history = useHistory(); + const [step, setStep] = React.useState(0); + const [loading, setLoading] = useState(false); + const [requestInfo, setRequestInfo] = useRecoilState(requestInfoState); + const [stepDisabled, setStepDisabled] = useRecoilState(stepDisabledState); + const hidePrevSteps = [AccessClusterStepsEnum['space-register'], AccessClusterStepsEnum['node-verify'], AccessClusterStepsEnum['cluster-verify'], AccessClusterStepsEnum.finish]; + + useEffect(() => { + if (history.location.pathname === '/space/list') { + return; + } + const regexp = pathToRegexp(`${match.path}/:step`); + const paths = regexp.exec(history.location.pathname); + const step = (paths as string[])[2]; + setStep(AccessClusterStepsEnum[step]); + + setStepDisabled({...stepDisabled, next: false}); + + if (match.params.requestId && +match.params.requestId !== 0) { + getRequestInfo(); + } + }, [history.location.pathname]); + + const [form] = useForm(); + + async function getRequestInfo() { + const requestId = match.params.requestId; + const res = await SpaceAPI.getRequestInfo(requestId); + if (isSuccess(res)) { + setRequestInfo(res.data); + } + } + + async function nextStep() { + const value = form.getFieldsValue(); + const newStep = step + 1; + setLoading(true); + const params: ClusterAccessParams = { + ...requestInfo.reqInfo, + cluster_id: requestInfo.clusterId, + request_id: requestInfo.requestId, + event_type: (step + 1).toString(), + } + if (value && step === AccessClusterStepsEnum['space-register']) { + params.spaceInfo = { + describe: value.describe, + name: value.name, + spaceAdminUsers: value.spaceAdminUsers, + } + } + if (value && step === AccessClusterStepsEnum['connect-cluster']) { + params.clusterAccessInfo = { + address: value.address, + httpPort: value.httpPort, + passwd: value.passwd || '', + queryPort: value.queryPort, + type: value.type, + user: value.user, + } + } + + if (value && step === AccessClusterStepsEnum['managed-options']) { + params.authInfo = { + sshKey: value.sshKey, + sshPort: value.sshPort, + sshUser: value.sshUser, + } + params.installInfo = value.installInfo + } + + const res = await SpaceAPI.accessCluster(params); + setLoading(false); + if (isSuccess(res)) { + setRequestInfo(res.data); + setStep(newStep); + setStepDisabled({...stepDisabled, next: false}); + setTimeout(() => { + history.push(`/space/access/${res.data.requestId}/${AccessClusterStepsEnum[newStep]}`); + }, 0) + + } else { + message.error(res.msg); + } + } + + + function prevStep() { + const newStep = step - 1; + setStep(newStep); + setStepDisabled({...stepDisabled, prev: false}); + history.push(`/space/access/${requestInfo.requestId}/${AccessClusterStepsEnum[newStep]}`); + } + + function finish() { + history.push('/space/list'); + } + + return ( + <> + + +
+ + + + + + + + +
+
+ + + + + + + + + + + + {hidePrevSteps.includes(step) ? ( + <> + ) : ( + + )} + {step === AccessClusterStepsEnum['finish'] ? ( + + ) : ( + + )} + + +
+
+
+ + ); +} diff --git a/frontend/src/routes/space/access-cluster/steps/cluster-verify/cluster-verify.tsx b/frontend/src/routes/space/access-cluster/steps/cluster-verify/cluster-verify.tsx new file mode 100644 index 0000000..9480aeb --- /dev/null +++ b/frontend/src/routes/space/access-cluster/steps/cluster-verify/cluster-verify.tsx @@ -0,0 +1,133 @@ +import React, { useContext, useEffect, useLayoutEffect, useState } from 'react'; +import { PageContainer } from '@ant-design/pro-layout'; +import ProCard from '@ant-design/pro-card'; +import { Button, message, Row, Space, Steps, Table, Tabs } from 'antd'; +import { useHistory, useRouteMatch } from 'react-router'; +import TabPane from '@ant-design/pro-card/lib/components/TabPane'; +import { isSuccess } from '@src/utils/http'; +import { NewSpaceInfoContext } from '@src/common/common.context'; +import { DorisNodeTypeEnum } from '@src/routes/space/new-cluster/types/params.type'; +import { SpaceAPI } from '@src/routes/space/space.api'; +import { useRequest } from 'ahooks'; +import { IResult } from '@src/interfaces/http.interface'; +import { OperateStatusEnum } from '@src/routes/space/space.data'; +import { useRecoilState } from 'recoil'; +import { stepDisabledState } from '../../access-cluster.recoil'; +const Step = Steps.Step; + +export function ClusterVerify(props: any) { + const [activeKey, setActiveKey] = useState(DorisNodeTypeEnum.FE); + const {reqInfo} = useContext(NewSpaceInfoContext); + const match = useRouteMatch<{spaceId: string}>(); + const [instance, setInstance] = useState([]); + const [nodeTypes, setNodeTypes] = useState([]); + const [feNodes, setFENodes] = useState([]); + const [beNodes, setBENodes] = useState([]); + const [brokerNodes, setBrokerNodes] = useState([]); + const [stepDisabled, setStepDisabled] = useRecoilState(stepDisabledState); + + + const columns = [ + { + title: '序号', + dataIndex: 'instanceId', + key: 'instanceId', + }, + { + title: '节点IP', + dataIndex: 'nodeHost', + key: 'nodeHost', + }, + { + title: '校验结果', + key: 'operateStatus', + render: (record: any) => { + return ( + + + + ) + } + }, + ]; + const getClusterInstance = useRequest, any>( + (clusterId: string) => { + return SpaceAPI.getClusterInstance({clusterId}); + }, + { + manual: true, + pollingInterval: 2000, + onSuccess: (res: any) => { + if (isSuccess(res)) { + const data: any[] = res.data; + setInstance(res.data); + const types = []; + const feNodes = res.data.filter(item => item.moduleName?.toUpperCase() === DorisNodeTypeEnum.FE); + const beNodes = res.data.filter(item => item.moduleName?.toUpperCase() === DorisNodeTypeEnum.BE); + const brokerNodes = res.data.filter(item => item.moduleName?.toUpperCase() === DorisNodeTypeEnum.BROKER); + setFENodes(feNodes); + setBENodes(beNodes); + setBrokerNodes(brokerNodes); + if (feNodes.length > 0) { + types.push({key: DorisNodeTypeEnum.FE, tab: 'FE节点', moduleName: DorisNodeTypeEnum.FE }); + } + if (beNodes.length > 0) { + types.push({key: DorisNodeTypeEnum.BE, tab: 'BE节点', moduleName: DorisNodeTypeEnum.BE }); + } + if (brokerNodes.length > 0) { + types.push({key: DorisNodeTypeEnum.BROKER, tab: 'Broker节点', moduleName: DorisNodeTypeEnum.BROKER }); + } + setNodeTypes(types); + const CANCEL_STATUS = [OperateStatusEnum.PROCESSING, OperateStatusEnum.INIT]; + if (data.filter(item => CANCEL_STATUS.includes(item.operateStatus)).length === 0) { + getClusterInstance.cancel(); + } + if (data.filter(item => item.operateStatus !== OperateStatusEnum.SUCCESS).length > 0) { + setStepDisabled({...stepDisabled, next: true}); + } else { + setStepDisabled({...stepDisabled, next: false}); + } + } + }, + onError: () => { + if (reqInfo.cluster_id) { + message.error('请求出错'); + getClusterInstance.cancel(); + } + }, + }, + ); + + + useEffect(() => { + if (reqInfo.cluster_id) { + console.log(reqInfo.cluster_id) + getClusterInstance.run(reqInfo.cluster_id); + } + }, [reqInfo.cluster_id]); + + + return ( + 校验集群, + }} + > + setActiveKey(key)} type="card"> + {nodeTypes.map(item => ( + + + ))} + + {activeKey === DorisNodeTypeEnum.FE && ( +
+ )} + {activeKey === DorisNodeTypeEnum.BE && ( +
+ )} + {activeKey === DorisNodeTypeEnum.BROKER && ( +
+ )} + + ); +} diff --git a/frontend/src/routes/space/access-cluster/steps/connect-cluster/connect-cluster.tsx b/frontend/src/routes/space/access-cluster/steps/connect-cluster/connect-cluster.tsx new file mode 100644 index 0000000..c95e0d4 --- /dev/null +++ b/frontend/src/routes/space/access-cluster/steps/connect-cluster/connect-cluster.tsx @@ -0,0 +1,113 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { PageContainer } from '@ant-design/pro-layout'; +import { Button, Form, Input, Modal, Space } from 'antd'; +import { NewSpaceInfoContext } from '@src/common/common.context'; +import { SpaceAPI } from '@src/routes/space/space.api'; +import styles from '../../../space.less'; +import { stepDisabledState } from '../../access-cluster.recoil'; +import { useRecoilState } from 'recoil'; + +const tip = { + default: '请进行链接测试', + fault: '链接测试未通过' +} + +export function ConnectCluster(props: any) { + const { form, reqInfo, step } = useContext(NewSpaceInfoContext); + const [testFlag, setTestFlag] = useState('none'); + const [stepDisabled, setStepDisabled] = useRecoilState(stepDisabledState); + + useEffect(() => { + form.setFieldsValue({...reqInfo.clusterAccessInfo}); + setStepDisabled({...stepDisabled, next: true}); + }, [reqInfo.cluster_id, step]); + + + const handleLinkTest = () => { + const values = form.getFieldsValue(); + SpaceAPI.spaceValidate({ + address: values.address.trim(), + httpPort: values.httpPort, + passwd: values.passwd || '', + queryPort: values.queryPort, + user: values.user.trim(), + }).then(res => { + const { msg, data, code } = res; + if (code === 0) { + Modal.success({ + title: "集群连接成功", + content: msg, + }); + setStepDisabled({...stepDisabled, next: false}); + setTestFlag('success') + } else { + Modal.error({ + title: "集群连接失败", + content: msg, + }); + setStepDisabled({...stepDisabled, next: true}); + setTestFlag('failed'); + } + }); + } + + return ( + +
+ + + + + + + + + + + + + + + + + + + + +   + { + (testFlag !== 'success') && +
+
{testFlag === 'failed' ? tip.fault: tip.default}
+
+ } +
+
+ ); +} + diff --git a/frontend/src/routes/space/access-cluster/steps/finish/finish.tsx b/frontend/src/routes/space/access-cluster/steps/finish/finish.tsx new file mode 100644 index 0000000..1ac76e5 --- /dev/null +++ b/frontend/src/routes/space/access-cluster/steps/finish/finish.tsx @@ -0,0 +1,24 @@ +import { PageContainer } from '@ant-design/pro-layout'; +import { Result, Button, Checkbox, Form, Input, Row, Space } from 'antd'; +import React from 'react'; +import { useHistory } from 'react-router'; + +export function SpaceAccessFinish(props: any) { + return ( + 完成创建, + }} + > + +
空间接管成功
+ + } + /> + , +
+ ); +} diff --git a/frontend/src/routes/space/access-cluster/steps/managed-options/managed-options.tsx b/frontend/src/routes/space/access-cluster/steps/managed-options/managed-options.tsx new file mode 100644 index 0000000..69af889 --- /dev/null +++ b/frontend/src/routes/space/access-cluster/steps/managed-options/managed-options.tsx @@ -0,0 +1,61 @@ +import { Divider, Form, Input, PageHeader } from 'antd'; +import React, { useContext, useEffect } from 'react'; +import ProCard from '@ant-design/pro-card'; +import { NewSpaceInfoContext } from '@src/common/common.context'; +import TextArea from 'antd/lib/input/TextArea'; + +export function ManagedOptions(props: any) { + const { form, reqInfo } = useContext(NewSpaceInfoContext); + useEffect(() => { + form.setFieldsValue({...reqInfo.authInfo}); + }, [reqInfo.cluster_id]); + return ( + 托管选项} headerBordered> + + + 请提前完成Manager节点与其他节点间SSH信任,并在下方填入Manager节点的SSH信息。如何进行SSH信任? + + +
+ + + + + + + +