diff --git a/src/containers/BaseDetail/index.jsx b/src/containers/BaseDetail/index.jsx index 23541f63..343f7b86 100644 --- a/src/containers/BaseDetail/index.jsx +++ b/src/containers/BaseDetail/index.jsx @@ -66,6 +66,11 @@ export default class BaseDetail extends React.Component { return this.props.rootStore.routing; } + get path() { + const { location: { pathname = '' } = {} } = this.props; + return pathname || ''; + } + get leftCards() { return []; } diff --git a/src/containers/TabDetail/index.jsx b/src/containers/TabDetail/index.jsx index 0e515314..9a8b984d 100644 --- a/src/containers/TabDetail/index.jsx +++ b/src/containers/TabDetail/index.jsx @@ -78,6 +78,11 @@ export default class DetailBase extends React.Component { return this.props.rootStore.routing; } + get path() { + const { location: { pathname = '' } = {} } = this.props; + return pathname || ''; + } + get isAdminPage() { const { pathname } = this.props.location; return isAdminPage(pathname); diff --git a/src/layouts/admin-menu.jsx b/src/layouts/admin-menu.jsx index 66a25a26..7dc8f3cc 100644 --- a/src/layouts/admin-menu.jsx +++ b/src/layouts/admin-menu.jsx @@ -61,6 +61,21 @@ const renderMenu = (t) => { }, ], }, + { + path: '/compute/instance-snapshot-admin', + name: t('Instance Snapshot'), + key: 'instanceSnapshotAdmin', + level: 1, + children: [ + { + path: /^\/compute\/instance-snapshot-admin\/detail\/[^/]+$/, + name: t('Instance Snapshot Detail'), + key: 'instanceSnapshotDetailAdmin', + level: 2, + routePath: '/compute/instance-snapshot-admin/detail/:id', + }, + ], + }, { path: '/compute/flavor-admin', name: t('Flavor'), diff --git a/src/layouts/menu.jsx b/src/layouts/menu.jsx index db761e3b..8e0a519a 100644 --- a/src/layouts/menu.jsx +++ b/src/layouts/menu.jsx @@ -72,6 +72,21 @@ const renderMenu = (t) => { }, ], }, + { + path: '/compute/instance-snapshot', + name: t('Instance Snapshot'), + key: 'instanceSnapshot', + level: 1, + children: [ + { + path: /^\/compute\/instance-snapshot\/detail\/[^/]+$/, + name: t('Instance Snapshot Detail'), + key: 'instanceSnapshotDetail', + level: 2, + routePath: '/compute/instance-snapshot/detail/:id', + }, + ], + }, { path: '/compute/flavor', name: t('Flavor'), diff --git a/src/locales/en.json b/src/locales/en.json index 92fafd89..c7f62806 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -323,10 +323,7 @@ "Checksum": "Checksum", "Chile": "Chile", "China": "China", - "Choose a External Network": "Choose a External Network", "Choose a Network Driver": "Choose a Network Driver", - "Choose a Private Network": "Choose a Private Network", - "Choose a Private Network at first": "Choose a Private Network at first", "Choose a host to live migrate instance to. If not selected, the scheduler will auto select target host.": "Choose a host to live migrate instance to. If not selected, the scheduler will auto select target host.", "Choose a host to migrate instance to. If not selected, the scheduler will auto select target host.": "Choose a host to migrate instance to. If not selected, the scheduler will auto select target host.", "Choosing a QoS policy can limit bandwidth and DSCP": "Choosing a QoS policy can limit bandwidth and DSCP", @@ -629,6 +626,7 @@ "Delete Image": "Delete Image", "Delete In Progress": "Delete In Progress", "Delete Instance": "Delete Instance", + "Delete Instance Snapshot": "Delete Instance Snapshot", "Delete Keypair": "Delete Keypair", "Delete Listener": "Delete Listener", "Delete Load Balancer": "Delete Load Balancer", @@ -778,6 +776,7 @@ "Edit IPsec Site Connection": "Edit IPsec Site Connection", "Edit Image": "Edit Image", "Edit Instance": "Edit Instance", + "Edit Instance Snapshot": "Edit Instance Snapshot", "Edit Listener": "Edit Listener", "Edit Load Balancer": "Edit Load Balancer", "Edit Member": "Edit Member", @@ -1181,6 +1180,8 @@ "Instance IP": "Instance IP", "Instance Info": "Instance Info", "Instance Name": "Instance Name", + "Instance Snapshot": "Instance Snapshot", + "Instance Snapshot Detail": "Instance Snapshot Detail", "Instance Status": "Instance Status", "Instances": "Instances", "Instances \"{ name }\" are locked, can not delete them.": "Instances \"{ name }\" are locked, can not delete them.", @@ -2067,6 +2068,7 @@ "Snapshot Instance": "Snapshot Instance", "Snapshot Name": "Snapshot Name", "Snapshots": "Snapshots", + "Snapshots can be converted into volume and used to create an instance from the volume.": "Snapshots can be converted into volume and used to create an instance from the volume.", "Snapshotting": "Snapshotting", "Soft Delete Instance": "Soft Delete Instance", "Soft Deleted": "Soft Deleted", @@ -2615,6 +2617,7 @@ "delete group": "delete group", "delete image": "delete image", "delete instance": "delete instance", + "delete instance snapshot": "delete instance snapshot", "delete ipsec site connection": "delete ipsec site connection", "delete ironic instance": "delete ironic instance", "delete keypair": "delete keypair", @@ -2652,6 +2655,7 @@ "edit default pool": "edit default pool", "edit health monitor": "edit health monitor", "edit image": "edit image", + "edit instance snapshot": "edit instance snapshot", "edit member": "edit member", "edit system permission": "edit system permission", "egress": "egress", @@ -2674,6 +2678,7 @@ "insert": "insert", "instance": "instance", "instance snapshot": "instance snapshot", + "instance snapshots": "instance snapshots", "instance: {name}.": "instance: {name}.", "instances": "instances", "ipsec site connection": "ipsec site connection", diff --git a/src/locales/zh.json b/src/locales/zh.json index dc8ce959..10f7c6ec 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -323,10 +323,7 @@ "Checksum": "校验和", "Chile": "智利", "China": "中国大陆", - "Choose a External Network": "选择外部网络", "Choose a Network Driver": "选择网络驱动程序", - "Choose a Private Network": "选择专用网络", - "Choose a Private Network at first": "首先选择一个专用网络", "Choose a host to live migrate instance to. If not selected, the scheduler will auto select target host.": "选择计算节点来热迁移云主机,如果没有选择,调度器会自动选择目标计算节点。", "Choose a host to migrate instance to. If not selected, the scheduler will auto select target host.": "选择计算节点来迁移云主机,如果没有选择,调度器会自动选择目标计算节点。", "Choosing a QoS policy can limit bandwidth and DSCP": "选择QoS策略可以限制带宽和DSCP", @@ -629,6 +626,7 @@ "Delete Image": "删除镜像", "Delete In Progress": "正在删除", "Delete Instance": "删除云主机", + "Delete Instance Snapshot": "删除云主机快照", "Delete Keypair": "删除密钥", "Delete Listener": "删除监听器", "Delete Load Balancer": "删除负载均衡", @@ -778,6 +776,7 @@ "Edit IPsec Site Connection": "编辑IPsec站点连接", "Edit Image": "编辑镜像", "Edit Instance": "编辑云主机", + "Edit Instance Snapshot": "编辑云主机快照", "Edit Listener": "编辑监听器", "Edit Load Balancer": "编辑负载均衡", "Edit Member": "编辑成员", @@ -1181,6 +1180,8 @@ "Instance IP": "云主机IP", "Instance Info": "云主机信息", "Instance Name": "云主机名称", + "Instance Snapshot": "云主机快照", + "Instance Snapshot Detail": "云主机快照详情", "Instance Status": "云主机状态", "Instances": "云主机", "Instances \"{ name }\" are locked, can not delete them.": "云主机\"{ name }\"被锁定,无法删除。", @@ -2067,6 +2068,7 @@ "Snapshot Instance": "创建云主机快照", "Snapshot Name": "快照名称", "Snapshots": "快照", + "Snapshots can be converted into volume and used to create an instance from the volume.": "快照可以转换成云硬盘,用于从云硬盘启动云主机。", "Snapshotting": "创建快照中", "Soft Delete Instance": "软删除云主机", "Soft Deleted": "软删除", @@ -2615,6 +2617,7 @@ "delete group": "删除组", "delete image": "删除镜像", "delete instance": "删除云主机", + "delete instance snapshot": "删除云主机快照", "delete ipsec site connection": "删除IPsec站点连接", "delete ironic instance": "删除裸机", "delete keypair": "删除密钥", @@ -2652,6 +2655,7 @@ "edit default pool": "编辑资源池", "edit health monitor": "编辑健康检查器", "edit image": "编辑镜像", + "edit instance snapshot": "编辑云主机快照", "edit member": "编辑成员", "edit system permission": "编辑系统角色", "egress": "出方向", @@ -2674,6 +2678,7 @@ "insert": "插入", "instance": "云主机", "instance snapshot": "云主机快照", + "instance snapshots": "云主机快照", "instance: {name}.": "实例名称:{name}。", "instances": "云主机", "ipsec site connection": "IPsec站点连接", diff --git a/src/pages/compute/containers/Image/Detail/BaseDetail.jsx b/src/pages/compute/containers/Image/Detail/BaseDetail.jsx index e5c564f2..fc6062e4 100644 --- a/src/pages/compute/containers/Image/Detail/BaseDetail.jsx +++ b/src/pages/compute/containers/Image/Detail/BaseDetail.jsx @@ -24,8 +24,7 @@ import { isObject, isArray } from 'lodash'; export class BaseDetail extends Base { get isImageDetail() { - const { pathname } = this.props.location; - return pathname.indexOf('image') >= 0; + return this.path.includes('image'); } get leftCards() { diff --git a/src/pages/compute/containers/Image/Detail/index.jsx b/src/pages/compute/containers/Image/Detail/index.jsx index d0b33193..66e7418f 100644 --- a/src/pages/compute/containers/Image/Detail/index.jsx +++ b/src/pages/compute/containers/Image/Detail/index.jsx @@ -15,6 +15,8 @@ import { inject, observer } from 'mobx-react'; import { imageStatus } from 'resources/glance/image'; import { ImageStore } from 'stores/glance/image'; +import { InstanceSnapshotStore } from 'stores/glance/instance-snapshot'; +import actionConfigsSnapshot from 'pages/compute/containers/InstanceSnapshot/actions'; import Base from 'containers/TabDetail'; import BaseDetail from './BaseDetail'; import actionConfigs from '../actions'; @@ -29,18 +31,25 @@ export class ImageDetail extends Base { } get isImageDetail() { - const { pathname } = this.props.location; - return pathname.indexOf('image') >= 0; + return this.path.includes('image'); } get listUrl() { + if (!this.isImageDetail) { + return this.getRoutePath('instanceSnapshot'); + } return this.getRoutePath('image'); } get actionConfigs() { + if (this.isImageDetail) { + return this.isAdminPage + ? actionConfigs.actionConfigsAdmin + : actionConfigs.actionConfigs; + } return this.isAdminPage - ? actionConfigs.actionConfigsAdmin - : actionConfigs.actionConfigs; + ? actionConfigsSnapshot.adminConfigs + : actionConfigsSnapshot.actionConfigs; } get detailInfos() { @@ -87,7 +96,9 @@ export class ImageDetail extends Base { } init() { - this.store = new ImageStore(); + this.store = this.isImageDetail + ? new ImageStore() + : new InstanceSnapshotStore(); } } diff --git a/src/pages/compute/containers/Image/Image.jsx b/src/pages/compute/containers/Image/Image.jsx index 2168903b..25d49e26 100644 --- a/src/pages/compute/containers/Image/Image.jsx +++ b/src/pages/compute/containers/Image/Image.jsx @@ -52,7 +52,7 @@ export class Image extends Base { } get isFilterByBackend() { - return true; + return false; } get isSortByBackend() { @@ -67,7 +67,7 @@ export class Image extends Base { return !this.isAdminPage; } - updateFetchParamsByPage = (params) => { + updateFetchParams = (params) => { if (this.isAdminPage) { return { ...params, diff --git a/src/pages/compute/containers/Instance/Detail/index.jsx b/src/pages/compute/containers/Instance/Detail/index.jsx index 96313cb6..aa0e3a53 100644 --- a/src/pages/compute/containers/Instance/Detail/index.jsx +++ b/src/pages/compute/containers/Instance/Detail/index.jsx @@ -28,6 +28,7 @@ import { toJS } from 'mobx'; import BaseDetail from './BaseDetail'; import SecurityGroup from './SecurityGroup'; import ActionLog from './ActionLog'; +import Snapshots from '../../InstanceSnapshot'; import actionConfigs from '../actions'; export class InstanceDetail extends Base { @@ -44,8 +45,7 @@ export class InstanceDetail extends Base { } get isRecycleBinDetail() { - const { pathname } = this.props.location; - return pathname.indexOf('recycle-bin') >= 0; + return this.path.includes('recycle-bin'); } get listUrl() { @@ -123,6 +123,11 @@ export class InstanceDetail extends Base { key: 'BaseDetail', component: BaseDetail, }, + { + title: t('Instance Snapshot'), + key: 'snapshots', + component: Snapshots, + }, { title: t('Interface'), key: 'interface', diff --git a/src/pages/compute/containers/InstanceSnapshot/actions/CreateVolume.jsx b/src/pages/compute/containers/InstanceSnapshot/actions/CreateVolume.jsx new file mode 100644 index 00000000..d84eb7b0 --- /dev/null +++ b/src/pages/compute/containers/InstanceSnapshot/actions/CreateVolume.jsx @@ -0,0 +1,169 @@ +// Copyright 2022 99cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { inject, observer } from 'mobx-react'; +import { toJS } from 'mobx'; +import { ModalAction } from 'containers/Action'; +import globalVolumeStore from 'stores/cinder/volume'; +import { InstanceSnapshotStore } from 'stores/glance/instance-snapshot'; + +export class CreateVolume extends ModalAction { + static id = 'create'; + + static title = t('Create Volume'); + + init() { + this.volumeStore = globalVolumeStore; + this.snapshotStore = new InstanceSnapshotStore(); + this.getVolumeTypes(); + this.getMinSize(); + } + + get name() { + return t('Create Volume'); + } + + get instanceName() { + return this.values.name; + } + + static policy = 'volume:create_from_image'; + + static allowed = () => Promise.resolve(true); + + async getVolumeTypes() { + const { id } = this.item; + // eslint-disable-next-line no-unused-vars + const [_, snapshot] = await Promise.all([ + this.volumeStore.fetchVolumeTypes(), + this.snapshotStore.fetchDetail({ id }), + ]); + const { volumeDetail: { volume_type: volumeType } = {} } = snapshot; + const typeItem = this.volumeTypes.find((it) => it.label === volumeType); + if (typeItem) { + this.volumeType = typeItem.value; + } + this.updateFormValue('volume_type', this.volumeType); + } + + async getMinSize() { + const { id } = this.item; + if (this.snapshot && this.snapshot.volume_size) { + return; + } + await this.snapshotStore.fetchDetail({ id }); + this.updateDefaultValue(); + } + + get volumeTypes() { + return this.volumeStore.volumeTypes; + } + + get tips() { + return t( + 'Snapshots can be converted into volume and used to create an instance from the volume.' + ); + } + + get defaultValue() { + const { name } = this.item; + const value = { + snapshot: name, + size: this.minSize, + volume_type: this.volumeType, + }; + return value; + } + + get bdmData() { + const { block_device_mapping: bdm = '[]' } = this.item; + return JSON.parse(bdm); + } + + get snapshot() { + return this.bdmData.find((it) => it.boot_index === 0); + } + + get minSize() { + const { min_disk, size } = this.item; + const biggerSize = Math.max( + min_disk, + Math.ceil(size / 1024 / 1024 / 1024), + 1, + (this.snapshot || {}).volume_size || 1 + ); + if (biggerSize) { + return biggerSize; + } + const { snapshotDetail: { size: snapshotSize = 0 } = {} } = + toJS(this.snapshotStore.detail) || {}; + return Math.max(snapshotSize, 1); + } + + get formItems() { + const { more } = this.state; + return [ + { + name: 'snapshot', + label: t('Snapshot'), + type: 'label', + iconType: 'snapshot', + }, + { + name: 'name', + label: t('Name'), + type: 'input-name', + placeholder: t('Please input name'), + required: true, + }, + { + name: 'size', + label: t('Capacity (GiB)'), + type: 'input-int', + min: this.minSize, + extra: `${t('Min size')}: ${this.minSize}GiB`, + required: true, + }, + { + name: 'more', + type: 'more', + label: t('Advanced Options'), + }, + { + name: 'volume_type', + label: t('Volume Type'), + type: 'select', + options: this.volumeTypes, + placeholder: t('Please select volume type'), + hidden: !more, + }, + ]; + } + + onSubmit = ({ name, size, volume_type }) => { + const body = { + imageRef: this.item.id, + name, + size, + }; + if (volume_type) { + body.volume_type = volume_type; + } else { + body.volume_type = this.volumeType; + } + return globalVolumeStore.create(body); + }; +} + +export default inject('rootStore')(observer(CreateVolume)); diff --git a/src/pages/compute/containers/InstanceSnapshot/actions/Delete.jsx b/src/pages/compute/containers/InstanceSnapshot/actions/Delete.jsx new file mode 100644 index 00000000..a4bebd8a --- /dev/null +++ b/src/pages/compute/containers/InstanceSnapshot/actions/Delete.jsx @@ -0,0 +1,42 @@ +// Copyright 2022 99cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ConfirmAction } from 'containers/Action'; +import globalImageStore from 'stores/glance/image'; + +export default class Delete extends ConfirmAction { + get id() { + return 'delete'; + } + + get title() { + return t('Delete Instance Snapshot'); + } + + get isDanger() { + return true; + } + + get buttonText() { + return t('Delete'); + } + + get actionName() { + return t('delete instance snapshot'); + } + + policy = 'delete_image'; + + onSubmit = (data) => globalImageStore.delete({ id: data.id }); +} diff --git a/src/pages/compute/containers/InstanceSnapshot/actions/Edit.jsx b/src/pages/compute/containers/InstanceSnapshot/actions/Edit.jsx new file mode 100644 index 00000000..a09300e0 --- /dev/null +++ b/src/pages/compute/containers/InstanceSnapshot/actions/Edit.jsx @@ -0,0 +1,89 @@ +// Copyright 2022 99cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { inject, observer } from 'mobx-react'; +import { ModalAction } from 'containers/Action'; +import globalImageStore from 'stores/glance/image'; +import { get, has } from 'lodash'; + +export class EditAction extends ModalAction { + static id = 'edit'; + + static title = t('Edit Instance Snapshot'); + + static buttonText = t('Edit'); + + get name() { + return t('edit instance snapshot'); + } + + get defaultValue() { + const { name, description } = this.item; + const value = { + name, + description, + }; + return value; + } + + static policy = 'modify_image'; + + static allowed = () => Promise.resolve(true); + + get formItems() { + return [ + { + name: 'name', + label: t('Name'), + type: 'input-name', + placeholder: t('Please input name'), + isImage: true, + required: true, + }, + { + name: 'description', + label: t('Description'), + type: 'textarea', + }, + ]; + } + + onSubmit = (values) => { + const { id } = this.item; + const changeValues = []; + Object.keys(values).forEach((key) => { + if (has(this.item, key) && get(this.item, key) !== values[key]) { + const item = { + op: 'replace', + path: `/${key}`, + value: values[key], + }; + changeValues.push(item); + } else if (!has(this.item, key) && values[key]) { + const item = { + op: 'add', + path: `/${key}`, + value: values[key], + }; + changeValues.push(item); + } + }); + if (changeValues.length === 0) { + return Promise.resolve(); + } + return globalImageStore.update({ id }, changeValues); + }; +} + +export default inject('rootStore')(observer(EditAction)); diff --git a/src/pages/compute/containers/InstanceSnapshot/actions/index.jsx b/src/pages/compute/containers/InstanceSnapshot/actions/index.jsx new file mode 100644 index 00000000..43b39b10 --- /dev/null +++ b/src/pages/compute/containers/InstanceSnapshot/actions/index.jsx @@ -0,0 +1,46 @@ +// Copyright 2022 99cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import CreateVolume from './CreateVolume'; +import Edit from './Edit'; +import Delete from './Delete'; + +const actionConfigs = { + rowActions: { + firstAction: Edit, + moreActions: [ + { + action: CreateVolume, + }, + { + action: Delete, + }, + ], + }, + batchActions: [Delete], +}; + +const adminConfigs = { + rowActions: { + firstAction: Edit, + moreActions: [ + { + action: Delete, + }, + ], + }, + batchActions: [Delete], +}; + +export default { actionConfigs, adminConfigs }; diff --git a/src/pages/compute/containers/InstanceSnapshot/index.jsx b/src/pages/compute/containers/InstanceSnapshot/index.jsx new file mode 100644 index 00000000..0d51e878 --- /dev/null +++ b/src/pages/compute/containers/InstanceSnapshot/index.jsx @@ -0,0 +1,109 @@ +// Copyright 2022 99cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { observer, inject } from 'mobx-react'; +import Base from 'containers/List'; +import { transitionStatusList } from 'resources/glance/image'; +import globalInstanceSnapshotStore, { + InstanceSnapshotStore, +} from 'stores/glance/instance-snapshot'; +import { emptyActionConfig } from 'utils/constants'; +import { getBaseSnapshotColumns } from 'resources/glance/instance-snapshot'; +import actionConfigs from './actions'; + +export class Snapshots extends Base { + init() { + this.store = this.inDetailPage + ? new InstanceSnapshotStore() + : globalInstanceSnapshotStore; + this.downloadStore = this.inDetailPage + ? this.store + : new InstanceSnapshotStore(); + } + + get policy() { + return 'get_images'; + } + + get name() { + return t('instance snapshots'); + } + + get isRecycleBinDetail() { + return this.path.includes('recycle-bin'); + } + + get actionConfigs() { + if (this.isRecycleBinDetail) { + return emptyActionConfig; + } + return this.isAdminPage + ? actionConfigs.adminConfigs + : actionConfigs.actionConfigs; + } + + get transitionStatusList() { + return transitionStatusList; + } + + get isFilterByBackend() { + return false; + } + + get isSortByBackend() { + return true; + } + + get defaultSortKey() { + return 'created_at'; + } + + get adminPageHasProjectFilter() { + return true; + } + + get projectFilterKey() { + return 'owner'; + } + + updateFetchParams = (params) => ({ + ...params, + owner: this.inDetailPage ? this.props.detail.tenant_id : null, + }); + + get currentProjectId() { + return this.props.detail.tenant_id; + } + + getColumns = () => getBaseSnapshotColumns(this); + + get searchFilters() { + return [ + { + label: t('Name'), + name: 'name', + }, + { + label: t('Status'), + name: 'status', + options: [ + { label: t('Active'), key: 'active' }, + { label: t('Saving'), key: 'saving' }, + ], + }, + ]; + } +} + +export default inject('rootStore')(observer(Snapshots)); diff --git a/src/pages/compute/routes/index.js b/src/pages/compute/routes/index.js index e4c233ce..788785cd 100644 --- a/src/pages/compute/routes/index.js +++ b/src/pages/compute/routes/index.js @@ -24,6 +24,7 @@ import CreateIronic from '../containers/Instance/actions/CreateIronic'; import TabImage from '../containers/Image'; import ImageAdmin from '../containers/Image/Image'; import ImageCreate from '../containers/Image/actions/Create'; +import InstanceSnapshot from '../containers/InstanceSnapshot'; import Keypair from '../containers/Keypair'; import KeypairDetail from '../containers/Keypair/Detail'; import ServerGroup from '../containers/ServerGroup'; @@ -60,6 +61,26 @@ export default [ component: CreateIronic, exact: true, }, + { + path: `${PATH}/instance-snapshot`, + component: InstanceSnapshot, + exact: true, + }, + { + path: `${PATH}/instance-snapshot-admin`, + component: InstanceSnapshot, + exact: true, + }, + { + path: `${PATH}/instance-snapshot/detail/:id`, + component: ImageDetail, + exact: true, + }, + { + path: `${PATH}/instance-snapshot-admin/detail/:id`, + component: ImageDetail, + exact: true, + }, { path: `${PATH}/flavor`, component: Flavor, exact: true }, { path: `${PATH}/flavor-admin`, component: Flavor, exact: true }, { diff --git a/src/resources/glance/image.jsx b/src/resources/glance/image.jsx index 118ad246..8a65d0ac 100644 --- a/src/resources/glance/image.jsx +++ b/src/resources/glance/image.jsx @@ -123,10 +123,12 @@ export const isOwner = (item) => { }; export const isSnapshot = (item) => { - const { block_device_mapping: bdm = '[]', image_type } = item; + // bfv vm has bdm; non-bfv has instance_uuid, image_type is added by frontend + const { block_device_mapping: bdm = '[]', image_type, instance_uuid } = item; return ( image_type === 'snapshot' || - get(JSON.parse(bdm)[0] || {}, 'source_type') === 'snapshot' + get(JSON.parse(bdm)[0] || {}, 'source_type') === 'snapshot' || + instance_uuid ); }; diff --git a/src/resources/glance/instance-snapshot.js b/src/resources/glance/instance-snapshot.js new file mode 100644 index 00000000..3b0c677d --- /dev/null +++ b/src/resources/glance/instance-snapshot.js @@ -0,0 +1,53 @@ +// Copyright 2022 99cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { imageStatus, imageFormats } from 'resources/glance/image'; + +export const getBaseSnapshotColumns = (self) => [ + { + title: t('ID/Name'), + dataIndex: 'name', + routeName: self.getRouteName('instanceSnapshotDetail'), + }, + { + title: t('Project ID/Name'), + dataIndex: 'project_name', + isHideable: true, + hidden: !self.isAdminPage, + sorter: false, + }, + { + title: t('Description'), + dataIndex: 'description', + isHideable: true, + sorter: false, + }, + { + title: t('Disk Format'), + dataIndex: 'disk_format', + isHideable: true, + render: (value) => imageFormats[value] || '-', + }, + { + title: t('Status'), + dataIndex: 'status', + render: (value) => imageStatus[value] || '-', + }, + { + title: t('Created At'), + dataIndex: 'created_at', + isHideable: true, + valueRender: 'sinceTime', + }, +]; diff --git a/src/stores/glance/image.js b/src/stores/glance/image.js index d87eac11..6104d43a 100644 --- a/src/stores/glance/image.js +++ b/src/stores/glance/image.js @@ -15,7 +15,7 @@ import { action, observable } from 'mobx'; import client from 'client'; import Base from 'stores/base'; -import { imageOS } from 'resources/glance/image'; +import { imageOS, isSnapshot } from 'resources/glance/image'; import { isString } from 'lodash'; export class ImageStore extends Base { @@ -41,12 +41,13 @@ export class ImageStore extends Base { } }; + updateParamsSort = this.updateParamsSortPage; + get paramsFuncPage() { return (params) => { const { current, all_projects, ...rest } = params; return { ...rest, - // image_type: 'image', }; }; } @@ -80,6 +81,13 @@ export class ImageStore extends Base { }; } + listDidFetch(items) { + if (items.length === 0) { + return items; + } + return items.filter((it) => !isSnapshot(it)); + } + @action async uploadImage(imageId, file, conf) { return this.client.uploadFile(imageId, file, conf); diff --git a/src/stores/glance/instance-snapshot.js b/src/stores/glance/instance-snapshot.js new file mode 100644 index 00000000..75f08d87 --- /dev/null +++ b/src/stores/glance/instance-snapshot.js @@ -0,0 +1,158 @@ +// Copyright 2022 99cloud +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import client from 'client'; +import { isSnapshot } from 'src/resources/glance/image'; +import Base from '../base'; + +export class InstanceSnapshotStore extends Base { + get client() { + return client.glance.images; + } + + get listFilterByProject() { + return true; + } + + get fetchListByLimit() { + return true; + } + + updateParamsSortPage = (params, sortKey, sortOrder) => { + if (sortKey && sortOrder) { + params.sort_key = sortKey; + params.sort_dir = sortOrder === 'descend' ? 'desc' : 'asc'; + } + }; + + updateParamsSort = this.updateParamsSortPage; + + get paramsFunc() { + return this.paramsFuncPage; + } + + get paramsFuncPage() { + return (params, all_projects) => { + const { id, current, owner, ...rest } = params; + const newParams = { + ...rest, + }; + if (owner) { + newParams.owner = owner; + } else if (!all_projects) { + newParams.owner = this.currentProjectId; + } + return newParams; + }; + } + + get mapperBeforeFetchProject() { + return (data) => ({ + ...data, + project_name: data.owner_project_name || data.project_name, + project_id: data.owner || data.project_id, + }); + } + + async listDidFetch(items, allProjects, filters) { + if (items.length === 0) { + return items; + } + const newItems = items.filter(isSnapshot); + const { id } = filters; + if (!id) { + return newItems; + } + const volumeParams = {}; + const snapshotParams = { all_tenants: allProjects }; + const results = await Promise.all([ + client.cinder.snapshots.list(snapshotParams), + client.nova.servers.volumeAttachments.list(id, volumeParams), + ]); + const snapshotsAll = results[0].snapshots; + const volumesAll = results[1].volumeAttachments; + const data = []; + newItems.forEach((item) => { + const { block_device_mapping: bdm = '[]', instance_id } = item; + if (instance_id === id) { + data.push(item); + } else { + const snapshot = JSON.parse(bdm).find((it) => it.boot_index === 0); + if (snapshot) { + item.snapshotId = snapshot.snapshot_id; + const snapshotDetail = snapshotsAll.find( + (it) => it.id === snapshot.snapshot_id + ); + if (snapshotDetail) { + const volumeId = snapshotDetail.volume_id; + const volume = volumesAll.find((it) => it.volumeId === volumeId); + if (volume) { + data.push(item); + } + } + } else { + const { instance_uuid: instanceId } = item; + if (id === instanceId) { + data.push(item); + } + } + } + }); + return data; + } + + async detailDidFetch(item) { + item.originData = { ...item }; + const { block_device_mapping: bdm = '[]' } = item; + const snapshot = JSON.parse(bdm).find((it) => it.boot_index === 0); + let instanceId = null; + let instanceName = ''; + if (snapshot) { + const { snapshot_id: snapshotId } = snapshot; + item.snapshotId = snapshotId; + const snapshotResult = await client.cinder.snapshots.show(snapshotId); + const snapshotDetail = snapshotResult.snapshot; + item.snapshotDetail = snapshotDetail; + const { volume_id: volumeId } = snapshotDetail; + const volumeResult = await client.cinder.volumes.show(volumeId); + const volumeDetail = volumeResult.volume; + item.volumeDetail = volumeDetail; + instanceId = + volumeDetail.attachments.length > 0 + ? volumeDetail.attachments[0].server_id + : ''; + } else { + // fix for not bfv instance + const { instance_uuid } = item; + instanceId = instance_uuid; + } + let instanceResult = {}; + try { + if (instanceId) { + instanceResult = await client.nova.servers.show(instanceId); + const { server: { name } = {} } = instanceResult; + instanceName = name; + } + } catch (e) {} + item.instance = { + server_id: instanceId, + server_name: instanceName, + }; + item.instanceDetail = instanceResult.server || {}; + return item; + } +} + +const globalInstanceSnapshotStore = new InstanceSnapshotStore(); +export default globalInstanceSnapshotStore; diff --git a/src/stores/nova/instance.js b/src/stores/nova/instance.js index bda47b33..3d804e40 100644 --- a/src/stores/nova/instance.js +++ b/src/stores/nova/instance.js @@ -373,6 +373,8 @@ export class ServerStore extends Base { name: image, metadata: { usage_type: 'common', + image_type: 'snapshot', + instance_id: id, }, }, };