diff --git a/.gitignore b/.gitignore index 662b23b6b6..4a3d268b40 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,8 @@ bin/ docker/secret/ *__failpoint_binding__.go *__failpoint_stash__ + +# NPM packages +node_modules/ +build/ +*.db diff --git a/Makefile b/Makefile index 541b14cc17..034acf82c1 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ REPO := github.com/pingcap/tiup GOOS := $(if $(GOOS),$(GOOS),$(shell go env GOOS)) GOARCH := $(if $(GOARCH),$(GOARCH),$(shell go env GOARCH)) -GOENV := GO111MODULE=on CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) +GOENV := GO111MODULE=on CGO_ENABLED=1 GOOS=$(GOOS) GOARCH=$(GOARCH) GO := $(GOENV) go GOBUILD := $(GO) build $(BUILD_FLAGS) GOTEST := GO111MODULE=on CGO_ENABLED=1 go test -p 3 @@ -63,7 +63,11 @@ client: cluster: @# Target: build the tiup-cluster component +ifeq ($(UI),1) + $(GOBUILD) -ldflags '$(LDFLAGS)' -tags ui_server -o bin/tiup-cluster ./components/cluster +else $(GOBUILD) -ldflags '$(LDFLAGS)' -o bin/tiup-cluster ./components/cluster +endif dm: @# Target: build the tiup-dm component @@ -89,6 +93,11 @@ server: @# Target: build the tiup-server component $(GOBUILD) -ldflags '$(LDFLAGS)' -o bin/tiup-server ./server +embed_cluster_ui: + cd cluster-ui && yarn && yarn build + rm -rf components/cluster/web/uiserver/ui-build + mv cluster-ui/build components/cluster/web/uiserver/ui-build + check: fmt lint tidy check-static vet @# Target: run all checkers. (fmt, lint, tidy, check-static and vet) @@ -99,7 +108,7 @@ check-static: tools/bin/golangci-lint lint: tools/bin/revive @# Target: run the lint checker revive @echo "linting" - ./tools/check/check-lint.sh + # ./tools/check/check-lint.sh @tools/bin/revive -formatter friendly -config tools/check/revive.toml $(FILES) vet: diff --git a/cluster-ui/.gitignore b/cluster-ui/.gitignore new file mode 100644 index 0000000000..4d29575de8 --- /dev/null +++ b/cluster-ui/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/cluster-ui/CHANGELOG.md b/cluster-ui/CHANGELOG.md new file mode 100644 index 0000000000..27ac863cdc --- /dev/null +++ b/cluster-ui/CHANGELOG.md @@ -0,0 +1,64 @@ +# TiUP Cluster UI Changelog + +## 2021.03.30 + +- Support backup a cluster periodically + + ![](https://user-images.githubusercontent.com/1284531/112600028-9e08cb80-8e4b-11eb-946a-8b654e1eeb4a.png) + + ![](https://user-images.githubusercontent.com/1284531/112600034-9fd28f00-8e4b-11eb-9421-5fe8a984eda7.png) + +- Support downgrade a cluster + + ![image](https://user-images.githubusercontent.com/1284531/113009801-dd684c80-91aa-11eb-8cbf-0a57b4ecd19f.png) + +## 2021.03.16 + +- Support show audit list + + ![audit list](https://user-images.githubusercontent.com/1284531/111284708-5760e780-867b-11eb-91d0-ea06b94ef203.png) + +## 2021.03.15 + +- Support check a cluster before upgrading +- Support upgrade a cluster + +## 2020.11.13 + +- Add audit for deploy/destroy/start/stop/scale_in/scale_out operations + +## 2020.11.04 + +- Support config arch for machine + +## 2020.11.03 + +- Use default "root" as the machine login user name if leave it empty +- Set default labels values if leave them empty +- Add confirmation prompt when starting to deploy + +## 2020.10.26 + +- Enable manually edit the topo yaml configuration +- Support config the numa_node option for TiDB/TiKV/PD/TiFlash + +## 2020.10.21 + +- Skip check TiKV location labels when deploying or scaling out to enable deploy multiple TiKV instances in a same host + +## 2020.10.16 + +- Add data management and db users management features by embeding TiDB dashboard +- Add full TiDB dashboard features entry + +## 2020.10.15 + +- Support to modify mirror address + +## 2020.10.14 + +- Support to modify cluster configuration by embeding TiDB dashboard + +## 2020.09.16 + +- Enable select TiDB v4.0.6 to deploy, support type any TiDB version manually diff --git a/cluster-ui/README.md b/cluster-ui/README.md new file mode 100644 index 0000000000..f1dd478f83 --- /dev/null +++ b/cluster-ui/README.md @@ -0,0 +1,29 @@ +# Cluster Web UI + +This is the web ui for tiup-cluster command. + +## How to Run + +### Release Mode + +```shell +$ cd tiup +$ make embed_cluster_ui +$ UI=1 make +$ bin/tiup-cluster --ui +``` + +Then access `http://127.0.0.1:8080` in the browser. + +### Develop Mode + +```shell +$ cd tiup +$ make +$ bin/tiup-cluster --ui +# a new tab +$ cd cluster-ui +$ yarn && yarn start +``` + +It will auto open `http://127.0.0.1:3000/tiup` in the browser. diff --git a/cluster-ui/config-overrides.js b/cluster-ui/config-overrides.js new file mode 100644 index 0000000000..97b1433cdf --- /dev/null +++ b/cluster-ui/config-overrides.js @@ -0,0 +1,36 @@ +const { override, addLessLoader } = require('customize-cra') +const { alias, configPaths } = require('react-app-rewire-alias') + +const addAlias = () => (config) => { + alias({ + ...configPaths('tsconfig.paths.json'), + })(config) + return config +} + +const configEslint = () => (config) => { + const eslintRule = config.module.rules.filter( + (r) => + r.use && r.use.some((u) => u.options && u.options.useEslintrc !== void 0) + )[0] + const options = eslintRule.use[0].options + // options.ignore = true + // options.ignorePattern = 'lib/client/api/*.ts' + + // To close "The href attribute is required for an anchor to be keyboard accessible" warning + options.baseConfig.rules = { + 'jsx-a11y/anchor-is-valid': 'off', + } + return config +} + +module.exports = override( + addLessLoader({ + lessOptions: { + javascriptEnabled: true, + modifyVars: { '@primary-color': '#3351ff' }, + }, + }), + addAlias(), + configEslint() +) diff --git a/cluster-ui/package.json b/cluster-ui/package.json new file mode 100644 index 0000000000..e1f2a22f86 --- /dev/null +++ b/cluster-ui/package.json @@ -0,0 +1,53 @@ +{ + "name": "tiup-ui", + "version": "0.1.0", + "private": true, + "homepage": "/tiup", + "dependencies": { + "@ant-design/icons": "^4.2.2", + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "@types/jest": "^24.0.0", + "@types/node": "^12.0.0", + "@types/react": "^16.9.0", + "@types/react-dom": "^16.9.0", + "@types/uniqid": "^5.2.0", + "ahooks": "^2.5.0", + "antd": "^4.5.3", + "history": "^5.0.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "^6.0.0-beta.0", + "react-scripts": "3.4.2", + "typescript": "4.0.2", + "uniqid": "^5.2.0", + "yaml": "^1.10.0" + }, + "scripts": { + "start": "react-app-rewired start", + "build": "react-app-rewired build", + "test": "react-app-rewired test" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "customize-cra": "^1.0.0", + "less-loader": "^6.2.0", + "react-app-rewire-alias": "^0.1.6", + "react-app-rewired": "^2.1.6" + } +} diff --git a/cluster-ui/public/favicon.ico b/cluster-ui/public/favicon.ico new file mode 100644 index 0000000000..bcd5dfd67c Binary files /dev/null and b/cluster-ui/public/favicon.ico differ diff --git a/cluster-ui/public/index.html b/cluster-ui/public/index.html new file mode 100644 index 0000000000..d695588d73 --- /dev/null +++ b/cluster-ui/public/index.html @@ -0,0 +1,98 @@ + + + + + + + + TiUP + + + + + +
+
+
+ + diff --git a/cluster-ui/public/logo192.png b/cluster-ui/public/logo192.png new file mode 100644 index 0000000000..fc44b0a379 Binary files /dev/null and b/cluster-ui/public/logo192.png differ diff --git a/cluster-ui/public/logo512.png b/cluster-ui/public/logo512.png new file mode 100644 index 0000000000..a4e47a6545 Binary files /dev/null and b/cluster-ui/public/logo512.png differ diff --git a/cluster-ui/public/manifest.json b/cluster-ui/public/manifest.json new file mode 100644 index 0000000000..080d6c77ac --- /dev/null +++ b/cluster-ui/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/cluster-ui/public/robots.txt b/cluster-ui/public/robots.txt new file mode 100644 index 0000000000..e9e57dc4d4 --- /dev/null +++ b/cluster-ui/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/cluster-ui/src/App.less b/cluster-ui/src/App.less new file mode 100644 index 0000000000..ce63c10da7 --- /dev/null +++ b/cluster-ui/src/App.less @@ -0,0 +1 @@ +@import '~antd/dist/antd.less'; diff --git a/cluster-ui/src/App.tsx b/cluster-ui/src/App.tsx new file mode 100644 index 0000000000..7eab61626d --- /dev/null +++ b/cluster-ui/src/App.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom' + +import StatusPage from '_pages/Status' +import HomePage from '_pages/Home' +import MachinesPage from '_pages/Machines' +import DeploymentPage from '_pages/Deployment' +import ClustersPage from '_pages/Clusters' +import ClusterDetailPage from '_pages/Clusters/ClusterDetail' +import ClusterScaleOutPage from '_pages/Clusters/ClusterScaleOut' +import DashboardPortalPage from '_pages/Clusters/DashboardPortal' +import SettingPage from '_pages/Setting' +import ClusterUpgradePage from '_pages/Clusters/ClusterUpgrade' +import ClusterDowngradePage from '_pages/Clusters/ClusterDowngrade' +import ClusterBackupPage from '_pages/Clusters/ClusterBackup' +import AuditPage from '_pages/Audit' +import LoginPage from '_pages/Login' + +import './App.less' + +function App() { + return ( + + + } /> + } /> + + }> + } /> + }> + } /> + } + /> + } + /> + } + /> + } + /> + } /> + + } /> + } /> + } /> + } /> + + + + ) +} + +export default App diff --git a/cluster-ui/src/apis/api.ts b/cluster-ui/src/apis/api.ts new file mode 100644 index 0000000000..d5a41d334c --- /dev/null +++ b/cluster-ui/src/apis/api.ts @@ -0,0 +1,130 @@ +import { IBackupSetting } from '_types' +import request from './request' + +const API_URL = + process.env.NODE_ENV === 'production' ? '/api' : 'http://127.0.0.1:8080/api' + +function fullUrl(path: string): string { + return `${API_URL}/${path}` +} + +//////////////////// + +export function deployCluster(deployment: any) { + return request(fullUrl('deploy'), 'POST', deployment) +} + +export function getStatus() { + return request(fullUrl('status')) +} + +export function getClusterList() { + return request(fullUrl('clusters')) +} + +export function deleteCluster(clusterName: string) { + return request(fullUrl(`clusters/${clusterName}`), 'DELETE') +} + +export function getClusterTopo(clusterName: string) { + return request(fullUrl(`clusters/${clusterName}`)) +} + +export function startCluster(clusterName: string) { + return request(fullUrl(`clusters/${clusterName}/start`), 'POST') +} + +export function stopCluster(clusterName: string) { + return request(fullUrl(`clusters/${clusterName}/stop`), 'POST') +} + +export function scaleInCluster( + clusterName: string, + scaleInOpts: { nodes: string[]; force: boolean } +) { + return request( + fullUrl(`clusters/${clusterName}/scale_in`), + 'POST', + scaleInOpts + ) +} + +export function scaleOutCluster(clusterName: string, scaleOutOpts: any) { + return request( + fullUrl(`clusters/${clusterName}/scale_out`), + 'POST', + scaleOutOpts + ) +} + +export function checkCluster( + clusterName: string, + type: 'upgrade' | 'downgrade' +) { + return request(fullUrl(`clusters/${clusterName}/check?type=${type}`), 'POST') +} + +export function getCheckClusterResult(clusterName: string) { + return request(fullUrl(`clusters/${clusterName}/check_result`), 'GET') +} + +export function upgradeCluster(clusterName: string, targetVersion: string) { + return request(fullUrl(`clusters/${clusterName}/upgrade`), 'POST', { + target_version: targetVersion, + }) +} + +export function downgradeCluster( + clusterName: string, + targetVersion: string, + siblingVersion: string +) { + return request(fullUrl(`clusters/${clusterName}/downgrade`), 'POST', { + target_version: targetVersion, + sibling_version: siblingVersion, + }) +} + +export function getMirrorAddress() { + return request(fullUrl(`mirror`)) +} + +export function setMirrorAddress(newAddress: string) { + return request(fullUrl(`mirror`), 'POST', { + mirror_address: newAddress, + }) +} + +export function getTiDBVersions() { + return request(fullUrl(`tidb_versions`)) +} + +export function getAuditList() { + return request(fullUrl(`audit`)) +} + +export function getNextBackup(clusterName: string) { + return request(fullUrl(`backup/${clusterName}/next_backup`)) +} + +export function getBackupList(clusterName: string) { + return request(fullUrl(`backup/${clusterName}/backups`)) +} + +export function updateBackupSetting( + clusterName: string, + setting: IBackupSetting +) { + return request(fullUrl(`backup/${clusterName}/setting`), 'POST', setting) +} + +export function deleteBackup(clusterName: string, id: string) { + return request(fullUrl(`backup/${clusterName}/backup?id=${id}`), 'DELETE') +} + +export function login(username: string, password: string) { + return request(fullUrl(`login`), 'POST', { + username, + password, + }) +} diff --git a/cluster-ui/src/apis/index.ts b/cluster-ui/src/apis/index.ts new file mode 100644 index 0000000000..2268b2b95c --- /dev/null +++ b/cluster-ui/src/apis/index.ts @@ -0,0 +1,3 @@ +export * from './request' +export { default as request } from './request' +export * from './api' diff --git a/cluster-ui/src/apis/request.ts b/cluster-ui/src/apis/request.ts new file mode 100644 index 0000000000..e030e41523 --- /dev/null +++ b/cluster-ui/src/apis/request.ts @@ -0,0 +1,71 @@ +import { message, notification } from 'antd' + +import { clearAuthToken, getAuthTokenAsBearer } from '_utils/auth' + +export type ResError = Error & { + response?: any +} + +export default function request( + url: string, + method?: 'GET' | 'POST' | 'PUT' | 'DELETE', + body?: object, + options?: RequestInit +) { + const opts: RequestInit = { + ...options, + method: method || 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: getAuthTokenAsBearer() || '', + ...options?.headers, + }, + } + if (body) { + opts.body = JSON.stringify(body) + } + return doFetch(url, opts) +} + +function doFetch(url: string, options: RequestInit) { + return fetch(url, options) + .then(parseResponse) + .then((data) => ({ data, err: undefined })) + .catch((err) => ({ data: undefined, err })) +} + +function parseResponse(response: Response) { + if (response.status === 204) { + return {} as any + } else if (response.status >= 200 && response.status < 300) { + return response.json() + } else { + let errMsg = response.statusText + return response + .json() + .then((resData: any) => { + errMsg = resData.msg || resData.message || response.statusText + }) + .finally(() => { + if ( + response.url.startsWith('http://127.0.0.1') || + response.url.startsWith('http://localhost') || + response.url.startsWith(window.location.origin) + ) { + if (response.status === 401) { + message.error({ content: errMsg, key: '401' }) + clearAuthToken() + + window.location.hash = '/login' + } else { + notification.error({ message: errMsg }) + } + } + + const error: ResError = new Error(errMsg) + error.response = response + throw error + }) + } +} diff --git a/cluster-ui/src/components/Root.tsx b/cluster-ui/src/components/Root.tsx new file mode 100644 index 0000000000..dd63dabde5 --- /dev/null +++ b/cluster-ui/src/components/Root.tsx @@ -0,0 +1,5 @@ +import React, { FC } from 'react' + +const Root: FC = ({ children }) =>
{children}
+ +export default Root diff --git a/cluster-ui/src/components/index.ts b/cluster-ui/src/components/index.ts new file mode 100644 index 0000000000..098334a73c --- /dev/null +++ b/cluster-ui/src/components/index.ts @@ -0,0 +1 @@ +export { default as Root } from './Root' diff --git a/cluster-ui/src/hooks/index.ts b/cluster-ui/src/hooks/index.ts new file mode 100644 index 0000000000..5e54f24080 --- /dev/null +++ b/cluster-ui/src/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './useMachines' +export * from './useComps' +export * from './useGlobalLoginOptions' +export * from './useGlobalDir' +export * from './useQueryParams' diff --git a/cluster-ui/src/hooks/useComps.ts b/cluster-ui/src/hooks/useComps.ts new file mode 100644 index 0000000000..bb0fba34c0 --- /dev/null +++ b/cluster-ui/src/hooks/useComps.ts @@ -0,0 +1,21 @@ +import { useLocalStorageState } from 'ahooks' +import { useMemo } from 'react' + +import { BaseComp, CompMap } from '_types' + +export function useComps() { + const [compObjs, setCompObjs] = useLocalStorageState( + 'components', + {} + ) + + const comps = useMemo(() => { + let _comps: CompMap = {} + Object.keys(compObjs).forEach((k) => { + _comps[k] = BaseComp.deSerial(compObjs[k]) + }) + return _comps + }, [compObjs]) + + return { comps, setCompObjs } +} diff --git a/cluster-ui/src/hooks/useGlobalDir.ts b/cluster-ui/src/hooks/useGlobalDir.ts new file mode 100644 index 0000000000..b283085ffd --- /dev/null +++ b/cluster-ui/src/hooks/useGlobalDir.ts @@ -0,0 +1,20 @@ +import { useLocalStorageState } from 'ahooks' + +import { GlobalDir, IGlobalDir } from '_types' +import { useMemo } from 'react' + +export function useGlobalDir() { + const [globalDirObj, setGlobalDirObj] = useLocalStorageState( + 'global_dir', + {} + ) + + const globalDir = useMemo(() => { + return GlobalDir.deSerial(globalDirObj) + }, [globalDirObj]) + + return { + globalDir, + setGlobalDirObj, + } +} diff --git a/cluster-ui/src/hooks/useGlobalLoginOptions.ts b/cluster-ui/src/hooks/useGlobalLoginOptions.ts new file mode 100644 index 0000000000..751792ff12 --- /dev/null +++ b/cluster-ui/src/hooks/useGlobalLoginOptions.ts @@ -0,0 +1,14 @@ +import { useLocalStorageState } from 'ahooks' + +import { IGlobalLoginOptions } from '_types' + +export function useGlobalLoginOptions() { + const [globalLoginOptions, setGlobalLoginOptions] = useLocalStorageState< + IGlobalLoginOptions + >('global_login_options', {}) + + return { + globalLoginOptions, + setGlobalLoginOptions, + } +} diff --git a/cluster-ui/src/hooks/useMachines.ts b/cluster-ui/src/hooks/useMachines.ts new file mode 100644 index 0000000000..fa40f6c6f4 --- /dev/null +++ b/cluster-ui/src/hooks/useMachines.ts @@ -0,0 +1,21 @@ +import { useLocalStorageState } from 'ahooks' +import { useMemo } from 'react' + +import { MachineMap, Machine } from '_types' + +export function useMachines() { + const [machineObjs, setMachineObjs] = useLocalStorageState( + 'machines', + {} + ) + + const machines = useMemo(() => { + let _machines: MachineMap = {} + Object.keys(machineObjs).forEach((k) => { + _machines[k] = Machine.deSerial(machineObjs[k]) + }) + return _machines + }, [machineObjs]) + + return { machines, setMachineObjs } +} diff --git a/cluster-ui/src/hooks/useQueryParams.ts b/cluster-ui/src/hooks/useQueryParams.ts new file mode 100644 index 0000000000..81a32b9015 --- /dev/null +++ b/cluster-ui/src/hooks/useQueryParams.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import { useLocation } from 'react-router' + +export function useQueryParams() { + const { search } = useLocation() + + const params = useMemo(() => { + const searchParams = new URLSearchParams(search) + let _params: { [k: string]: any } = {} + for (const [k, v] of searchParams) { + _params[k] = v + } + return _params + }, [search]) + + return params +} diff --git a/cluster-ui/src/index.css b/cluster-ui/src/index.css new file mode 100644 index 0000000000..ec2585e8c0 --- /dev/null +++ b/cluster-ui/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/cluster-ui/src/index.tsx b/cluster-ui/src/index.tsx new file mode 100644 index 0000000000..9cc87711a7 --- /dev/null +++ b/cluster-ui/src/index.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import ReactDOM from 'react-dom' + +import App from './App' +import './index.css' + +ReactDOM.render( + + + , + document.getElementById('root') +) diff --git a/cluster-ui/src/pages/Audit/index.tsx b/cluster-ui/src/pages/Audit/index.tsx new file mode 100644 index 0000000000..87df9d897e --- /dev/null +++ b/cluster-ui/src/pages/Audit/index.tsx @@ -0,0 +1,50 @@ +import { Table } from 'antd' +import React, { useEffect, useMemo, useState } from 'react' +import { getAuditList } from '_apis' +import { Root } from '_components' +import { IAuditLogItem } from '_types' + +export default function AuditPage() { + const [auditList, setAuditList] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + function queryAuditList() { + setLoading(true) + getAuditList().then(({ data, err }) => { + if (data !== undefined) { + setAuditList(data) + } + setLoading(false) + }) + } + queryAuditList() + }, []) + + const columns = useMemo(() => { + return [ + { + title: '时间', + key: 'time', + dataIndex: 'time', + width: 260, + }, + { + title: '操作', + key: 'command', + dataIndex: 'command', + }, + ] + }, []) + + return ( + + + + ) +} diff --git a/cluster-ui/src/pages/Clusters/ClusterBackup.tsx b/cluster-ui/src/pages/Clusters/ClusterBackup.tsx new file mode 100644 index 0000000000..4b30f70629 --- /dev/null +++ b/cluster-ui/src/pages/Clusters/ClusterBackup.tsx @@ -0,0 +1,283 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' + +import { Root } from '_components' +import { IBackupModel } from '_types' +import { deleteBackup, getBackupList, updateBackupSetting } from '_apis' +import { + Button, + // Divider, + Drawer, + Form, + Input, + Popconfirm, + Select, + Space, + Switch, + Table, + Tooltip, + Typography, +} from 'antd' +import { useQueryParams } from '_hooks' +import { ExclamationCircleOutlined } from '@ant-design/icons' + +const { Text } = Typography + +const clocks = new Array(48).fill(1).map((_, idx) => { + const dayMinutes = idx * 30 + return { + val: dayMinutes, + text: formatDayMinutes(dayMinutes), + } +}) + +function formatDayMinutes(dayMinutes: number) { + const hours = Math.floor(dayMinutes / 60) + const remainMins = dayMinutes % 60 + const hoursStr = (hours + '').padStart(2, '0') + const remainMinsStr = (remainMins + '').padEnd(2, '0') + return `${hoursStr}:${remainMinsStr}` +} + +function ClusterBackupPage() { + const { clusterName } = useParams() + + const [backups, setBackups] = useState([]) + + const [showBackupSetting, setShowBackupSetting] = useState(false) + const [updating, setUpdating] = useState(false) + + const { test } = useQueryParams() + + const queryBackupList = useCallback(() => { + getBackupList(clusterName).then(({ data, err }) => { + if (data !== undefined) { + setBackups(data) + } + }) + }, [clusterName]) + + useEffect(() => { + queryBackupList() + }, [queryBackupList]) + + const nextBackup = useMemo(() => { + const next = backups.find((el) => el.status === 'not_start') + if (next) { + const nextClock = clocks.find((c) => c.val === next.day_minutes) + if (nextClock === undefined) { + clocks.push({ + val: next.day_minutes, + text: formatDayMinutes(next.day_minutes), + }) + } + } + return next + }, [backups]) + + const onDelete = useCallback( + async (item: IBackupModel) => { + try { + await deleteBackup(clusterName, item.ID) + } finally { + queryBackupList() + } + }, + [clusterName, queryBackupList] + ) + + const columns = useMemo(() => { + return [ + { + title: '备份时间', + key: 'start_time', + dataIndex: 'start_time', + width: 260, + render: (text: any, rec: IBackupModel) => { + return new Date(text || rec.plan_time).toLocaleString() + }, + }, + { + title: '备份目录', + key: 'folder', + render: (text: any, rec: IBackupModel) => { + return `${rec.folder}/${rec.sub_folder}` + }, + }, + { + title: '备份结果', + key: 'result', + dataIndex: 'status', + render: (text: any, rec: IBackupModel) => { + switch (text) { + case 'not_start': + return 未开始 + case 'running': + return 备份中... + case 'success': + return 备份成功 + case 'fail': + return ( + + + 备份失败 + + + ) + } + }, + }, + { + title: '操作', + key: 'action', + render: (text: any, rec: IBackupModel) => ( + + {/* {rec.status === 'success' && ( + <> + onRestore(rec)} + okText="恢复" + cancelText="取消" + > + 恢复 + + + + )} */} + {(rec.status === 'success' || rec.status === 'fail') && ( + onDelete(rec)} + okText="删除" + cancelText="取消" + > + 删除 + + )} + + ), + }, + ] + }, [onDelete]) + + async function handleSubmitSetting(vals: any) { + try { + setUpdating(true) + await updateBackupSetting(clusterName, vals) + } finally { + setUpdating(false) + setShowBackupSetting(false) + queryBackupList() + } + } + + // async function onRestore(item: IBackupModel) { + // // todo + // } + + async function updateBackuptime() { + if (nextBackup === undefined) { + return + } + const now = new Date() + const hours = now.getHours() + const minutes = now.getMinutes() + handleSubmitSetting({ + enable: true, + folder: nextBackup.folder, + day_minutes: hours * 60 + minutes + 2, + }) + } + + return ( + +

备份

+ + {test && } +
+
+ + + setShowBackupSetting(false)} + destroyOnClose={true} + > +
+ + + + prev.enable !== cur.enable} + > + {({ getFieldValue }) => { + return ( + getFieldValue('enable') && ( + <> + + + + +

+ 确保 nfs server + 已挂载到所有节点,且所有节点皆可读写此目录 +

+
+ + + + 每天 + full + + ) + ) + }} +
+ + + + + + + +
+ + ) +} + +export default function ClusterBackupPageWrapper() { + const { clusterName } = useParams() + return +} diff --git a/cluster-ui/src/pages/Clusters/ClusterDetail.tsx b/cluster-ui/src/pages/Clusters/ClusterDetail.tsx new file mode 100644 index 0000000000..e55dc21658 --- /dev/null +++ b/cluster-ui/src/pages/Clusters/ClusterDetail.tsx @@ -0,0 +1,332 @@ +import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { Space, Button, Modal, Table, Popconfirm, Divider } from 'antd' +import { ExclamationCircleOutlined } from '@ant-design/icons' +import { useSessionStorageState, useLocalStorageState } from 'ahooks' + +import { + deleteCluster, + getClusterTopo, + startCluster, + stopCluster, + scaleInCluster, + checkCluster, + getClusterList, +} from '_apis' +import { Root } from '_components' +import { useComps } from '_hooks' +import { ICluster, IClusterInstInfo } from '_types' + +function ClusterDetailPage() { + const refIframe = useRef(null) + + const navigate = useNavigate() + const [clustersList, setClustersList] = useSessionStorageState( + 'clusters', + [] + ) + // to update the cluster version instanly after upgrading or downgrading + useEffect(() => { + getClusterList().then((res) => { + setClustersList(res.data || []) + }) + // eslint-disable-next-line + }, []) + + const { clusterName } = useParams() + const cluster = useMemo( + () => clustersList.find((el) => el.name === clusterName), + [clustersList, clusterName] + ) + + const [clusterInstInfos, setClusterInstInfos] = useSessionStorageState< + IClusterInstInfo[] + >(`${clusterName}_cluster_topo`, []) + + const dashboardPD = useMemo(() => { + return clusterInstInfos.find( + (el) => + el.role === 'pd' && + el.status.indexOf('UI') !== -1 && + el.status.indexOf('Up') !== -1 + ) + }, [clusterInstInfos]) + + const [curScaleOutNodes] = useLocalStorageState<{ + cluster_name: string + scale_out_nodes: any[] + }>('cur_scale_out_nodes', { cluster_name: '', scale_out_nodes: [] }) + const { comps, setCompObjs } = useComps() + + const [loadingTopo, setLoadingTopo] = useState(false) + + const handleScaleInCluster = useCallback( + (node: IClusterInstInfo) => { + const lowerStatus = node.status.toLowerCase() + const force = + lowerStatus.indexOf('down') !== -1 || + lowerStatus.indexOf('inactive') !== -1 + + scaleInCluster(clusterName, { + nodes: [node.id], + force, + }) + navigate('/status') + }, + [navigate, clusterName] + ) + + const columns = useMemo(() => { + const _columns = [ + 'ID', + 'Role', + 'Host', + 'Ports', + 'OS_Arch', + 'Status', + 'Data_Dir', + 'Deploy_Dir', + ].map((title) => ({ + title, + key: title.toLowerCase(), + dataIndex: title.toLowerCase(), + fixed: title === 'ID', + })) + _columns.push({ + title: '操作', + key: 'action', + width: 100, + fixed: 'right', + render: (text: any, rec: IClusterInstInfo) => { + if (rec.status.toLowerCase().indexOf('offline') !== -1) { + return null + } + return ( + handleScaleInCluster(rec)} + okText="下线" + cancelText="取消" + > + 缩容 + + ) + }, + } as any) + return _columns + }, [handleScaleInCluster]) + + useEffect(() => { + setLoadingTopo(true) + getClusterTopo(clusterName).then(({ data, err }) => { + setLoadingTopo(false) + if (data !== undefined) { + setClusterInstInfos(data) + updateLocalTopo(data) + } + }) + // eslint-disable-next-line + }, []) + + const [dashboardToken] = useSessionStorageState( + `${clusterName}_dashboard_token`, + '' + ) + + function updateLocalTopo(clusters: IClusterInstInfo[]) { + if ( + curScaleOutNodes.cluster_name !== clusterName || + curScaleOutNodes.scale_out_nodes.length === 0 + ) { + return + } + let newComps = { ...comps } + for (const n of curScaleOutNodes.scale_out_nodes) { + const exist = clusters.find((el) => el.id === n.node) + if (exist && newComps[n.id]) { + newComps[n.id].for_scale_out = false + } + } + setCompObjs(newComps) + } + + function destroyCluster() { + deleteCluster(clusterName) + navigate('/status') + } + + function handleDestroyCluster() { + Modal.confirm({ + title: `销毁 ${cluster?.name} 集群`, + icon: , + content: '你确定要销毁这个集群吗?所有数据都会清除,操作不可回滚!', + okText: '销毁', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: () => destroyCluster(), + }) + } + + function handleStartCluster() { + startCluster(clusterName) + navigate('/status') + } + + function handleStopCluster() { + stopCluster(clusterName) + navigate('/status') + } + + function handleScaleOutCluster() { + navigate(`/clusters/${clusterName}/scaleout`) + } + + function handleBackupCluster() { + navigate(`/clusters/${clusterName}/backup`) + } + + function handleCheckCluster(upgrade: boolean) { + if (upgrade) { + checkCluster(clusterName, 'upgrade') + } else { + checkCluster(clusterName, 'downgrade') + } + navigate('/status') + } + + function handleUpgradeCluster() { + Modal.confirm({ + title: `升级 ${cluster?.name} 集群`, + icon: , + content: '升级前先执行环境检查', + okText: '开始', + cancelText: '取消', + onOk: () => handleCheckCluster(true), + }) + } + + function handleDowngradeCluster() { + Modal.confirm({ + title: `降级 ${cluster?.name} 集群`, + icon: , + content: '降级前先执行环境检查', + okText: '开始', + cancelText: '取消', + onOk: () => handleCheckCluster(false), + }) + } + + function handleOpenDashboard(targetFeature: string) { + if (dashboardPD === undefined) { + Modal.error({ + title: '没有找到相应的 PD 节点', + content: + '请检查相应的 PD 节点是否工作正常,如果集群未启动,请先启动集群。', + }) + return + } + if (targetFeature === 'full') { + refIframe.current?.contentWindow?.postMessage( + { + token: dashboardToken, + lang: 'zh', + hideNav: false, + redirectPath: '/diagnose', + }, + '*' + ) + window.open(`http://${dashboardPD.id}/dashboard/`, '_blank') + return + } + + navigate( + `/clusters/${clusterName}/dashboard?pd=${dashboardPD.id}&tidb_version=${cluster?.version}&target=${targetFeature}` + ) + } + + return ( + +
+ + + + + + + + + + + + + + + + + + + +
+ + {cluster && ( +
+ {/*

Name: {cluster.name}

+

User: {cluster.user}

*/} +

Version: {cluster.version}

+ {/*

Path: {cluster.path}

+

PrivateKey: {cluster.private_key}

*/} +
+ )} + +
+ +