From 4cc4925bddc33da66004f8329bb10f98b9b36466 Mon Sep 17 00:00:00 2001 From: Travis Wu Date: Tue, 15 Sep 2026 11:29:24 +0800 Subject: [PATCH] feat(volume): batch Change Type so a backend can be rebalanced Change Type already existed per volume, but moving more than one volume meant opening the dialog once per row. The two cases that need it are an operator rebalancing a backend (a subset of its volumes, not a full evacuation) and a tenant moving a set of volumes onto a faster tier. The volume list already has row checkboxes -- Delete is a batch action -- so the work is making the existing action accept a selection: - `items` from the framework decides batch vs single. The framework enables a batch button on any selection and never consults `allowed`, so `disableSubmit` repeats the per-item check and the tip names the volumes to deselect, matching how batch Delete refuses a mixed set. - The type list drops the current type only when every selected volume shares one; a mixed selection keeps all of them on offer, and volumes already on the chosen type are skipped rather than sent to cinder to be refused. The tip says so. - Submission is allSettled, so one failure reports against its own volume instead of losing the rest -- the same shape Create uses for multi-volume creation, which BaseForm.onOk already unpacks per instance. - migration_policy stays on-demand, so an in-use volume moves live. Also drops the !isOsDisk condition, which came from the initial upstream import and refused what nova permits. swap_volume has no root-disk guard in compute/api.py or the libvirt driver -- its gates are instance state, size, multiattach and native LUKS -- so a running BFV instance's root volume retypes like any other in-use volume. CubeCOS's own preflight has no root blocker either, so the console was refusing what its CLI allows, and a rebalance that cannot touch boot disks only half-drains a backend. Status remains the gate. Added to the admin list too, as a row action beside Migrate and as a batch action, since the rebalance case is the operator's. Verified by a production webpack build; not yet exercised on a cluster. Signed-off-by: Travis Wu Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013cE5vKGkQQn3nAnn9TLKAU --- src/locales/en.json | 2 + src/locales/ko-kr.json | 2 + src/locales/ru.json | 2 + src/locales/zh-hans.json | 2 + src/locales/zh-hant.json | 2 + .../containers/Volume/actions/ChangeType.jsx | 129 +++++++++++++++--- .../containers/Volume/actions/index.jsx | 7 +- 7 files changed, 127 insertions(+), 19 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index adde95b19..b8381205c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2530,6 +2530,7 @@ "System is error, please try again later.": "System is error, please try again later.", "TCP": "TCP", "TCP Connections": "TCP Connections", + "The following volumes can not change type and need to be deselected: {names}": "The following volumes can not change type and need to be deselected: {names}", "This instance has no recorded hotplug ceiling yet": "This instance has no recorded hotplug ceiling yet", "This instance opts out of live resize": "This instance opts out of live resize", "TLS Disabled": "TLS Disabled", @@ -2842,6 +2843,7 @@ "VMDK - Hyper-V compatible image format": "VMDK - Hyper-V compatible image format", "VNC": "VNC", "VNIC Type": "VNIC Type", + "Volumes already using the selected type are skipped.": "Volumes already using the selected type are skipped.", "VPN": "VPN", "VPN EndPoint Groups": "VPN EndPoint Groups", "VPN Gateways": "VPN Gateways", diff --git a/src/locales/ko-kr.json b/src/locales/ko-kr.json index 5d0a998ca..829f77d16 100644 --- a/src/locales/ko-kr.json +++ b/src/locales/ko-kr.json @@ -2528,6 +2528,7 @@ "System is error, please try again later.": "", "TCP": "", "TCP Connections": "", + "The following volumes can not change type and need to be deselected: {names}": "다음 볼륨은 유형을 변경할 수 없으므로 선택을 해제하세요: {names}", "This instance has no recorded hotplug ceiling yet": "이 인스턴스에는 아직 기록된 핫플러그 상한이 없습니다", "This instance opts out of live resize": "이 인스턴스는 라이브 크기 조정을 사용하지 않습니다", "TLS Disabled": "", @@ -2840,6 +2841,7 @@ "VMDK - Hyper-V compatible image format": "VMDK - Hyper-V 호환 이미지 포맷", "VNC": "VNC", "VNIC Type": "VNIC 타입", + "Volumes already using the selected type are skipped.": "이미 선택한 유형을 사용 중인 볼륨은 건너뜁니다.", "VPN": "VPN", "VPN EndPoint Groups": "VPN 엔드포인트 그룹", "VPN Gateways": "VPN 게이트웨이", diff --git a/src/locales/ru.json b/src/locales/ru.json index ac5fd1eed..2704c71b4 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2529,6 +2529,7 @@ "System is error, please try again later.": "Система в состоянии ошибки, пожалуйста, попробуйте позже.", "TCP": "TCP", "TCP Connections": "TCP-соединения", + "The following volumes can not change type and need to be deselected: {names}": "Следующие диски не могут изменить тип, снимите с них выделение: {names}", "This instance has no recorded hotplug ceiling yet": "У этого инстанса ещё нет записанного предела горячего подключения", "This instance opts out of live resize": "Для этого инстанса живое изменение размера отключено", "TLS Disabled": "TLS отключен", @@ -2841,6 +2842,7 @@ "VMDK - Hyper-V compatible image format": "VMDK - Формат образа, совместимый с Hyper-V", "VNC": "VNC", "VNIC Type": "Тип VNIC", + "Volumes already using the selected type are skipped.": "Диски, уже использующие выбранный тип, пропускаются.", "VPN": "VPN", "VPN EndPoint Groups": "Группы конечных точек VPN", "VPN Gateways": "Шлюзы VPN", diff --git a/src/locales/zh-hans.json b/src/locales/zh-hans.json index f8d7395fe..30e82ca67 100644 --- a/src/locales/zh-hans.json +++ b/src/locales/zh-hans.json @@ -2529,6 +2529,7 @@ "System is error, please try again later.": "系统出错,请稍后再试。", "TCP": "TCP", "TCP Connections": "TCP连接数", + "The following volumes can not change type and need to be deselected: {names}": "以下云硬盘无法修改类型,请取消选择:{names}", "This instance has no recorded hotplug ceiling yet": "此虚拟机尚未记录热插拔上限", "This instance opts out of live resize": "该云主机已禁用热修改配置", "TLS Disabled": "TLS禁用", @@ -2841,6 +2842,7 @@ "VMDK - Hyper-V compatible image format": "VMDK - Hyper-V 兼容的图像格式", "VNC": "", "VNIC Type": "VNIC类型", + "Volumes already using the selected type are skipped.": "已经是所选类型的云硬盘将被跳过。", "VPN": "VPN", "VPN EndPoint Groups": "VPN端点组", "VPN Gateways": "VPN网关", diff --git a/src/locales/zh-hant.json b/src/locales/zh-hant.json index 18c15486e..8cce83b5b 100644 --- a/src/locales/zh-hant.json +++ b/src/locales/zh-hant.json @@ -2529,6 +2529,7 @@ "System is error, please try again later.": "系統發生錯誤,請稍後再試。", "TCP": "TCP", "TCP Connections": "TCP 連線數", + "The following volumes can not change type and need to be deselected: {names}": "以下雲硬碟無法變更類型,請取消選取:{names}", "This instance has no recorded hotplug ceiling yet": "此虛擬機尚未記錄熱插拔上限", "This instance opts out of live resize": "此虛擬機已停用即時變更規格", "TLS Disabled": "已停用 TLS", @@ -2841,6 +2842,7 @@ "VMDK - Hyper-V compatible image format": "VMDK - Hyper-V 相容的映像檔格式", "VNC": "VNC", "VNIC Type": "VNIC 類型", + "Volumes already using the selected type are skipped.": "已經是所選類型的雲硬碟將被略過。", "VPN": "VPN", "VPN EndPoint Groups": "VPN 端點群組", "VPN Gateways": "VPN 閘道", diff --git a/src/pages/storage/containers/Volume/actions/ChangeType.jsx b/src/pages/storage/containers/Volume/actions/ChangeType.jsx index 3683d6b1d..075915437 100644 --- a/src/pages/storage/containers/Volume/actions/ChangeType.jsx +++ b/src/pages/storage/containers/Volume/actions/ChangeType.jsx @@ -12,11 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. +import React from 'react'; import { inject, observer } from 'mobx-react'; +import { isArray, uniq } from 'lodash'; import { ModalAction } from 'containers/Action'; +import { allSettled } from 'utils'; import globalVolumeStore from 'stores/cinder/volume'; import globalVolumeTypeStore from 'stores/cinder/volume-type'; -import { isAvailableOrInUse, isOsDisk } from 'resources/cinder/volume'; +import { isAvailableOrInUse } from 'resources/cinder/volume'; + +// Nova's swap_volume has no root-disk guard, so a boot volume retypes like any +// other in-use one. Status is the only gate. +const canChangeType = (item) => isAvailableOrInUse(item); export class ChangeType extends ModalAction { static id = 'change-type'; @@ -29,12 +36,63 @@ export class ChangeType extends ModalAction { static policy = 'volume:retype'; - static allowed = (item) => - Promise.resolve(isAvailableOrInUse(item) && !isOsDisk(item)); + static allowed = (item) => Promise.resolve(canChangeType(item)); + + // A batch button is enabled on any selection, so re-check each one here. + static disableSubmit = ({ items }) => + isArray(items) && items.some((it) => !canChangeType(it)); + + get isBatch() { + const { items } = this.props; + return isArray(items) && items.length > 0; + } + + get selectedVolumes() { + return this.isBatch ? this.props.items : [this.item]; + } + + get currentTypes() { + return uniq( + this.selectedVolumes.map((it) => it.volume_type).filter((it) => !!it) + ); + } + + // Mixed selections keep every type on offer, so some volumes may already be + // on the chosen one. + get skipsSameType() { + return this.currentTypes.length > 1; + } + + get blockedVolumes() { + return this.selectedVolumes.filter((it) => !canChangeType(it)); + } get tips() { - return t( - 'If the capacity of the disk is large, the type modify operation may take several hours. Please be cautious.' + const tips = [ + t( + 'If the capacity of the disk is large, the type modify operation may take several hours. Please be cautious.' + ), + ]; + if (this.skipsSameType) { + tips.push(t('Volumes already using the selected type are skipped.')); + } + if (this.blockedVolumes.length) { + tips.push( + t( + 'The following volumes can not change type and need to be deselected: {names}', + { names: this.getNames(this.blockedVolumes) } + ) + ); + } + if (tips.length === 1) { + return tips[0]; + } + return ( + <> + {tips.map((it) => ( +
{it}
+ ))} + ); } @@ -52,34 +110,65 @@ export class ChangeType extends ModalAction { return true; } + getNames = (volumes) => volumes.map((it) => it.name || it.id).join(', '); + get volumeTypes() { - const { volume_type } = this.item; const { data = [] } = this.volumeTypeStore.list; - const list = data - .filter((it) => it.name !== volume_type) - .map((item) => - // TODO: filter no current volume type - ({ label: item.name, value: item.id }) - ); - return list; + const [onlyType] = this.currentTypes; + const excluded = this.currentTypes.length === 1 ? onlyType : null; + return data + .filter((it) => it.name !== excluded) + .map((item) => ({ label: item.name, value: item.id })); + } + + // Volumes already on the target type would be refused by cinder. + getRetypeTargets(newType) { + const { data = [] } = this.volumeTypeStore.list; + const { name: targetName } = data.find((it) => it.id === newType) || {}; + return this.selectedVolumes.filter( + (it) => canChangeType(it) && it.volume_type !== targetName + ); + } + + get instanceName() { + if (!this.isBatch) { + return this.item.name || this.itemId; + } + const { new_type } = this.values || {}; + return this.getNames(this.getRetypeTargets(new_type)); } get defaultValue() { const { name, id, volume_type, size } = this.item; const value = { - volume: `${name || id}(${volume_type} | ${size}GiB)`, volume_type: (this.volumeTypes[0] || {}).value, }; + if (!this.isBatch) { + value.volume = `${name || id}(${volume_type} | ${size}GiB)`; + } return value; } + renderVolumes() { + return ( +
+ {this.selectedVolumes.map((it) => ( +
+ {`${it.name || it.id}(${it.volume_type} | ${it.size}GiB)`} +
+ ))} +
+ ); + } + get formItems() { return [ { name: 'volume', - label: t('Volume'), + label: this.isBatch ? t('Volumes') : t('Volume'), type: 'label', iconType: 'volume', + content: this.isBatch ? this.renderVolumes() : undefined, }, { name: 'new_type', @@ -92,13 +181,19 @@ export class ChangeType extends ModalAction { } onSubmit = (values) => { - const { id } = this.item; const { new_type } = values; const body = { new_type, migration_policy: 'on-demand', }; - return this.store.retype(id, body); + if (!this.isBatch) { + return this.store.retype(this.item.id, body); + } + return allSettled( + this.getRetypeTargets(new_type).map((it) => + this.store.retype(it.id, body) + ) + ); }; } diff --git a/src/pages/storage/containers/Volume/actions/index.jsx b/src/pages/storage/containers/Volume/actions/index.jsx index bd3a70869..ce6787c43 100644 --- a/src/pages/storage/containers/Volume/actions/index.jsx +++ b/src/pages/storage/containers/Volume/actions/index.jsx @@ -65,7 +65,7 @@ const actionConfigs = { }, ], }, - batchActions: [Delete], + batchActions: [ChangeType, Delete], primaryActions: [Create, AcceptVolumeTransfer], }; @@ -98,12 +98,15 @@ const adminConfig = { { action: UpdateStatus, }, + { + action: ChangeType, + }, { action: Migrate, }, ], }, - batchActions: [Delete], + batchActions: [ChangeType, Delete], primaryActions: [], };