From 91d2cb03ccb45a63902b43988720391fe1598569 Mon Sep 17 00:00:00 2001 From: jo-hnny Date: Mon, 29 May 2023 15:08:18 +0800 Subject: [PATCH] feat(console): support vm snapshot (#2289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(console): 修复判断是否可见缺少了children的问题 * feat(console): 创建虚拟机快照 * feat(console): 支持虚拟机快照恢复 * feat(console): 添加了统一的接口错误提示 * feat(console): 支持在快照列表中显示恢复状态 * feat(console): 新建快照成功之后跳转到快照列表 --- web/console/config/validateConfig.ts | 9 + web/console/helpers/index.ts | 2 + .../resource/ResourceContainerPanel.tsx | 4 +- .../components/resource/ResourceListPanel.tsx | 2 +- .../components/actionButton.tsx | 6 +- .../components/createSnapshotButton.tsx | 79 ++++++ .../components/delSnapshotButton.tsx | 15 ++ .../virtual-machine/components/index.ts | 6 + .../components/recoverySnapshotButton.tsx | 40 +++ .../resource/virtual-machine/index.tsx | 2 + .../pages/list/action-panel.tsx | 16 +- .../virtual-machine/pages/list/index.tsx | 23 +- .../snapshotTablePanel/snapTablePanel.tsx | 231 ++++++++++++++++++ web/console/src/webApi/request.ts | 34 +-- web/console/src/webApi/virtual-machine.ts | 111 +++++++++ 15 files changed, 558 insertions(+), 22 deletions(-) create mode 100644 web/console/config/validateConfig.ts create mode 100644 web/console/src/modules/cluster/components/resource/virtual-machine/components/createSnapshotButton.tsx create mode 100644 web/console/src/modules/cluster/components/resource/virtual-machine/components/delSnapshotButton.tsx create mode 100644 web/console/src/modules/cluster/components/resource/virtual-machine/components/recoverySnapshotButton.tsx create mode 100644 web/console/src/modules/cluster/components/resource/virtual-machine/pages/snapshotTablePanel/snapTablePanel.tsx diff --git a/web/console/config/validateConfig.ts b/web/console/config/validateConfig.ts new file mode 100644 index 0000000000..610694d355 --- /dev/null +++ b/web/console/config/validateConfig.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const nameRule = (key: string) => { + return z + .string() + .min(1, { message: `${key}不能为空` }) + .max(63, { message: `${key}长度不能超过63个字符` }) + .regex(/^([A-Za-z0-9][-A-Za-z0-9_./]*)?[A-Za-z0-9]$/, { message: `${key}格式不正确` }); +}; diff --git a/web/console/helpers/index.ts b/web/console/helpers/index.ts index e202de679d..f466e4615d 100644 --- a/web/console/helpers/index.ts +++ b/web/console/helpers/index.ts @@ -44,3 +44,5 @@ export * from './csrf'; export * from './isInIframe'; export { satisfyClusterVersion } from './satisfyClusterVersion'; export { cutNsStartClusterId, parseQueryString, reduceK8sQueryString, reduceK8sRestfulPath, reduceNs } from './urlUtil'; + +export * from './path'; diff --git a/web/console/src/modules/cluster/components/resource/ResourceContainerPanel.tsx b/web/console/src/modules/cluster/components/resource/ResourceContainerPanel.tsx index 51e173ede4..39100939c6 100644 --- a/web/console/src/modules/cluster/components/resource/ResourceContainerPanel.tsx +++ b/web/console/src/modules/cluster/components/resource/ResourceContainerPanel.tsx @@ -32,7 +32,7 @@ import { UpdateResourcePanel } from './resourceEdition/UpdateResourcePanel'; import { ResourceListPanel } from './ResourceListPanel'; import { HPAPanel } from '@src/modules/cluster/components/scale/hpa'; import { CronHpaPanel } from '@src/modules/cluster/components/scale/cronhpa'; -import { VMDetailPanel } from './virtual-machine'; +import { VMDetailPanel, SnapshotTablePanel } from './virtual-machine'; interface ResourceContainerPanelState { /** 共享锁 */ @@ -173,6 +173,8 @@ export class ResourceContainerPanel extends React.Component; + } else if (mode === 'snapshot' && resourceName === 'virtual-machine') { + return ; } else { // 判断应该展示什么组件 switch (mode) { diff --git a/web/console/src/modules/cluster/components/resource/ResourceListPanel.tsx b/web/console/src/modules/cluster/components/resource/ResourceListPanel.tsx index 3bde155760..b0ca352ea6 100644 --- a/web/console/src/modules/cluster/components/resource/ResourceListPanel.tsx +++ b/web/console/src/modules/cluster/components/resource/ResourceListPanel.tsx @@ -47,7 +47,7 @@ import { ResourceLogPanel } from './resourceTableOperation/ResourceLogPanel'; import { ResourceTablePanel } from './resourceTableOperation/ResourceTablePanel'; import { HPAPanel } from '../scale/hpa'; import { CronHpaPanel } from '../scale/cronhpa'; -import { VMListPanel } from './virtual-machine'; +import { VMListPanel, SnapshotTablePanel } from './virtual-machine'; const loadingElement: JSX.Element = (
diff --git a/web/console/src/modules/cluster/components/resource/virtual-machine/components/actionButton.tsx b/web/console/src/modules/cluster/components/resource/virtual-machine/components/actionButton.tsx index 3924247ad7..a6d13c8930 100644 --- a/web/console/src/modules/cluster/components/resource/virtual-machine/components/actionButton.tsx +++ b/web/console/src/modules/cluster/components/resource/virtual-machine/components/actionButton.tsx @@ -8,6 +8,7 @@ interface ActionButtonProps { onSuccess?: () => void; children: React.ReactNode; disabled?: boolean; + body?: React.ReactNode; } export const ActionButton = ({ @@ -16,7 +17,8 @@ export const ActionButton = ({ title, confirm, children, - disabled = false + disabled = false, + body }: ActionButtonProps) => { const [visible, setVisible] = useState(false); @@ -35,6 +37,8 @@ export const ActionButton = ({ setVisible(false)}> + {body && {body}} + + + + +
+ ( + + + + )} + /> + +
+ + + + + +
+ + ); +}; diff --git a/web/console/src/modules/cluster/components/resource/virtual-machine/components/delSnapshotButton.tsx b/web/console/src/modules/cluster/components/resource/virtual-machine/components/delSnapshotButton.tsx new file mode 100644 index 0000000000..0e7fb58a15 --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/virtual-machine/components/delSnapshotButton.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { ActionButton } from './actionButton'; +import { virtualMachineAPI } from '@src/webApi'; + +export const DelSnapshotButton = ({ type, clusterId, namespace, name, onSuccess = () => {} }) => { + function del() { + return virtualMachineAPI.delSnapshot({ clusterId, namespace, name }); + } + + return ( + + 删除 + + ); +}; diff --git a/web/console/src/modules/cluster/components/resource/virtual-machine/components/index.ts b/web/console/src/modules/cluster/components/resource/virtual-machine/components/index.ts index 465f90cb6c..19686fc231 100644 --- a/web/console/src/modules/cluster/components/resource/virtual-machine/components/index.ts +++ b/web/console/src/modules/cluster/components/resource/virtual-machine/components/index.ts @@ -7,3 +7,9 @@ export { ShutdownButton } from './shutdownButton'; export { VNCButton } from './vncButton'; export { VmMonitorDialog } from './vmMonitorDialog'; + +export { CreateSnapshotButton } from './createSnapshotButton'; + +export { DelSnapshotButton } from './delSnapshotButton'; + +export { RecoverySnapshotButton } from './recoverySnapshotButton'; diff --git a/web/console/src/modules/cluster/components/resource/virtual-machine/components/recoverySnapshotButton.tsx b/web/console/src/modules/cluster/components/resource/virtual-machine/components/recoverySnapshotButton.tsx new file mode 100644 index 0000000000..81b19cd905 --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/virtual-machine/components/recoverySnapshotButton.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { ActionButton } from './actionButton'; +import { virtualMachineAPI } from '@src/webApi'; +import { Text, Alert } from 'tea-component'; + +export const RecoverySnapshotButton = ({ + type, + clusterId, + namespace, + name, + vmName, + disabled, + onSuccess = () => {} +}) => { + function recovery() { + return virtualMachineAPI.recoverySnapshot({ clusterId, namespace, name, vmName }); + } + + return ( + + 请确保虚拟机处于关机状态! + + 您将要对虚拟机{vmName}恢复快照{name}, + 恢复操作将会覆盖当前状态下虚拟机数据 + + 您是否要继续执行? + + } + > + 恢复 + + ); +}; diff --git a/web/console/src/modules/cluster/components/resource/virtual-machine/index.tsx b/web/console/src/modules/cluster/components/resource/virtual-machine/index.tsx index 7462955f1a..dc4f64cc36 100644 --- a/web/console/src/modules/cluster/components/resource/virtual-machine/index.tsx +++ b/web/console/src/modules/cluster/components/resource/virtual-machine/index.tsx @@ -1,3 +1,5 @@ export { VMListPanel } from './pages/list'; export { VMCreatePanel } from './pages/create'; export { VMDetailPanel } from './pages/detail'; + +export { SnapshotTablePanel } from './pages//snapshotTablePanel/snapTablePanel'; diff --git a/web/console/src/modules/cluster/components/resource/virtual-machine/pages/list/action-panel.tsx b/web/console/src/modules/cluster/components/resource/virtual-machine/pages/list/action-panel.tsx index b68a18cac1..f23efe377d 100644 --- a/web/console/src/modules/cluster/components/resource/virtual-machine/pages/list/action-panel.tsx +++ b/web/console/src/modules/cluster/components/resource/virtual-machine/pages/list/action-panel.tsx @@ -27,6 +27,16 @@ export const VMListActionPanel = ({ route, reFetch, vmList, onQueryChange }) => + + } right={ @@ -46,7 +56,11 @@ export const VMListActionPanel = ({ route, reFetch, vmList, onQueryChange }) => ? namespaceListLoadable?.contents?.map(value => ({ value })) : [] } - onChange={value => setNamespaceSelection(value)} + onChange={value => { + setNamespaceSelection(value); + const urlParams = router.resolve(route); + router.navigate(urlParams, Object.assign({}, route.queries, { np: value })); + }} /> { { key: 'actions', header: '操作', - render({ name, status }) { + render({ name, status, supportSnapshot }) { return ( <> @@ -124,6 +124,19 @@ export const VMListPanel = ({ route }) => { + + + { + const urlParams = router.resolve(route); + router.navigate(Object.assign({}, urlParams, { mode: 'snapshot' }), route.queries); + }} + /> + @@ -172,7 +185,11 @@ export const VMListPanel = ({ route }) => { }`, createTime: metadata?.creationTimestamp, - id: metadata?.uid + id: metadata?.uid, + + supportSnapshot: + metadata?.annotations?.['tkestack.io/support-snapshot'] === 'true' && + (realStatus === 'Running' || realStatus === 'Stopped') }; }) ?? [], diff --git a/web/console/src/modules/cluster/components/resource/virtual-machine/pages/snapshotTablePanel/snapTablePanel.tsx b/web/console/src/modules/cluster/components/resource/virtual-machine/pages/snapshotTablePanel/snapTablePanel.tsx new file mode 100644 index 0000000000..9b97a27caf --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/virtual-machine/pages/snapshotTablePanel/snapTablePanel.tsx @@ -0,0 +1,231 @@ +import React, { useState } from 'react'; +import { Table, TableColumn, Justify, SearchBox, Pagination, Button, Select, Text, Icon } from 'tea-component'; +import { useFetch } from '@src/modules/common/hooks'; +import { virtualMachineAPI, namespaceAPI } from '@src/webApi'; +import { getParamByUrl } from '@helper'; +import { useRequest } from 'ahooks'; +import { router } from '@src/modules/cluster/router'; +import dayjs from 'dayjs'; +import { DelSnapshotButton, RecoverySnapshotButton } from '../../components'; +import { TeaFormLayout } from '@src/modules/common/layouts/TeaFormLayout'; + +const { autotip } = Table.addons; + +const defaultPageSize = 10; + +const statusMap = { + Succeeded: '正常', + Failed: '异常', + Recovering: '恢复中', + RecoverFailed: '恢复失败' +}; + +export const SnapshotTablePanel = ({ route }) => { + const clusterId = getParamByUrl('clusterId'); + const [namespace, setNamespace] = useState(() => getParamByUrl('np')); + const [query, setQuery] = useState(''); + + const { data: namespaceList = [] } = useRequest( + async () => { + const rsp = await namespaceAPI.fetchNamespaceList(clusterId); + + return rsp?.items?.map(item => ({ value: item?.metadata?.name })) ?? []; + }, + { + ready: Boolean(clusterId), + refreshDeps: [clusterId] + } + ); + + const { + data = [], + status, + reFetch, + paging + } = useFetch( + async ({ paging, continueToken }) => { + const [rsp, storeRsp] = await Promise.all([ + virtualMachineAPI.fetchSnapshotList( + { clusterId, namespace }, + { limit: paging?.pageSize, continueToken, query } + ), + + virtualMachineAPI.fetchRecoveryStoreList({ clusterId, namespace }) + ]); + + const storeItems = storeRsp?.items ?? []; + + const items = (rsp?.items ?? []).map(item => { + const itemStore = storeItems.find( + store => + store?.spec?.target?.virtualMachineSnapshotName === item?.metadata?.name && + store?.status?.complete === false + ); + + if (itemStore) { + const conditions = itemStore.status.conditions ?? []; + const lastConditions = conditions?.[conditions.length - 1]; + + if (lastConditions?.type === 'Progressing') { + item.status.phase = 'Recovering'; + } else if (lastConditions?.type === 'Failure') { + item.status.phase = 'RecoverFailed'; + item.status.reason = lastConditions?.reason; + } + } + + return item; + }); + + const newContinueToken = rsp?.metadata?.continue || null; + + const restCount = rsp?.metadata?.remainingItemCount ?? 0; + + return { + data: items, + continueToken: newContinueToken, + totalCount: (paging.pageIndex - 1) * paging.pageSize + rsp?.items?.length + restCount + }; + }, + [clusterId, namespace, query], + { + mode: 'continue', + defaultPageSize, + fetchAble: !!(clusterId && namespace) + } + ); + + const columns: TableColumn[] = [ + { + key: 'metadata.name', + header: '快照名称', + render(snapshot) { + return {snapshot?.metadata?.name}; + } + }, + + { + key: 'status.phase', + header: '状态', + render(snapshot) { + const status = snapshot?.status?.phase; + const reason = snapshot?.status?.reason; + + const theme = status === 'Succeeded' ? 'success' : 'danger'; + + return ( + <> + {statusMap?.[status] ?? status} + + {reason && } + + ); + } + }, + + { + key: 'spec.source.name', + header: '目标VM', + render(snapshot) { + return {snapshot?.spec?.source?.name}; + } + }, + + // { + // key: 'sdSize', + // header: '恢复磁盘大小' + // }, + + { + key: 'metadata.creationTimestamp', + header: '生成时间', + render(snapshot) { + const createTime = snapshot?.metadata?.creationTimestamp; + + return createTime ? dayjs(createTime).format('YYYY-MM-DD HH:mm:ss') : '-'; + } + }, + + { + key: 'action', + header: '操作', + render(snapshot) { + return ( + <> + + + + + ); + } + } + ]; + + return ( + + + + + 命名空间 + + +