feat: Add quota info when create volume

1. Add quota info display when create volume
2. Add volume type quota check when create volume
3. Update quota ring/line chart to support infinity value

Change-Id: I0f300beb16bcf50008126afab9dac529c1749d09
This commit is contained in:
Jingwei.Zhang 2022-06-09 16:27:42 +08:00
parent d133705f6f
commit f942c48352
9 changed files with 233 additions and 46 deletions

View File

@ -23,6 +23,8 @@ import { parse } from 'qs';
import FormItem from 'components/FormItem'; import FormItem from 'components/FormItem';
import { CancelToken } from 'axios'; import { CancelToken } from 'axios';
import { getPath, getLinkRender } from 'utils/route-map'; import { getPath, getLinkRender } from 'utils/route-map';
import InfoButton from 'components/InfoButton';
import QuotaChart from 'components/QuotaChart';
import styles from './index.less'; import styles from './index.less';
export default class BaseForm extends React.Component { export default class BaseForm extends React.Component {
@ -253,6 +255,14 @@ export default class BaseForm extends React.Component {
return false; return false;
} }
get showQuota() {
return false;
}
get quotaInfo() {
return null;
}
getSubmitData(data) { getSubmitData(data) {
return { ...data }; return { ...data };
} }
@ -624,6 +634,35 @@ export default class BaseForm extends React.Component {
); );
} }
renderQuota() {
if (!this.showQuota) {
return null;
}
let props = {};
if (!this.quotaInfo || !this.quotaInfo.length) {
props.loading = true;
} else {
props = {
loading: false,
quotas: this.quotaInfo,
};
}
return <QuotaChart {...props} />;
}
renderRightTopExtra() {
const content = this.renderQuota();
if (!content) {
return null;
}
const checkValue = JSON.stringify(this.quotaInfo);
return (
<div className={styles['right-top-extra-wrapper']}>
<InfoButton content={content} checkValue={checkValue} />
</div>
);
}
render() { render() {
const wrapperPadding = const wrapperPadding =
this.listUrl || this.isStep || (this.isModal && this.tips) this.listUrl || this.isStep || (this.isModal && this.tips)
@ -646,6 +685,7 @@ export default class BaseForm extends React.Component {
> >
<Spin spinning={this.isSubmitting} tip={this.renderSubmittingTip()}> <Spin spinning={this.isSubmitting} tip={this.renderSubmittingTip()}>
{tips} {tips}
{this.renderRightTopExtra()}
<div className={classnames(styles.form, 'sl-form')} style={formStyle}> <div className={classnames(styles.form, 'sl-form')} style={formStyle}>
{this.renderForms()} {this.renderForms()}
</div> </div>

View File

@ -119,3 +119,23 @@
.progress-wrapper { .progress-wrapper {
width: 170px; width: 170px;
} }
.right-top-extra-wrapper {
position: absolute;
top: 0;
right: 30px;
z-index: 100;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 2px 30px 0 rgba(0, 0, 0, 20%);
:global {
.ant-card-head {
min-width: 32px;
.ant-card-extra {
padding: 8px 0;
}
}
}
}

View File

@ -52,6 +52,7 @@ export default function QuotaInfo(props) {
pagination={false} pagination={false}
title={() => fullTitle} title={() => fullTitle}
bordered bordered
size="small"
/> />
</div> </div>
); );

View File

@ -25,13 +25,15 @@ export default function Line(props) {
title = '', title = '',
secondTitle = t('Quota'), secondTitle = t('Quota'),
} = props; } = props;
let left = limit - used - reserved - add; const isLimit = limit !== -1;
const limitStr = !isLimit ? t('Infinity') : limit;
let left = isLimit ? limit - used - reserved - add : 1;
left = left < 0 ? 0 : left; left = left < 0 ? 0 : left;
const usedTip = `${t('Used')}: ${used}`; const usedTip = `${t('Used')}: ${used}`;
const reservedTip = reserved ? '' : `${t('Reserved')}: ${reserved}`; const reservedTip = reserved ? '' : `${t('Reserved')}: ${reserved}`;
const newTip = `${t('New')}: ${add}`; const newTip = `${t('New')}: ${add}`;
const leftTip = `${t('Left')}: ${left}`; const leftTip = `${t('Left')}: ${left}`;
const tips = [usedTip, newTip, leftTip]; const tips = isLimit ? [usedTip, newTip, leftTip] : [usedTip, newTip];
if (reserved) { if (reserved) {
tips.splice(1, 0, reservedTip); tips.splice(1, 0, reservedTip);
} }
@ -43,17 +45,19 @@ export default function Line(props) {
const resourceTitle = ( const resourceTitle = (
<span> <span>
{`${title} ${secondTitle}: `}{' '} {`${title} ${secondTitle}: `}{' '}
<span style={{ color: usedColor }}>{`${allCount}/${limit}`}</span> <span style={{ color: usedColor }}>{`${allCount}/${limitStr}`}</span>
</span> </span>
); );
const progress = ( const progress = isLimit ? (
<Progress <Progress
percent={allPercent} percent={allPercent}
success={{ percent: usedPercent, strokeColor: typeColors.used }} success={{ percent: usedPercent, strokeColor: typeColors.used }}
strokeColor={typeColors.add} strokeColor={typeColors.add}
showInfo={false} showInfo={false}
/> />
) : (
<Progress percent={0} showInfo={false} />
); );
return ( return (

View File

@ -20,6 +20,7 @@ import {
Legend, Legend,
View, View,
Annotation, Annotation,
Tooltip,
} from 'bizcharts'; } from 'bizcharts';
export const typeColors = { export const typeColors = {
@ -54,24 +55,28 @@ export default function Ring(props) {
secondTitle = t('Quota'), secondTitle = t('Quota'),
hasLabel = false, hasLabel = false,
} = props; } = props;
const left = limit - used - reserved - add; const isLimit = limit !== -1;
const showTip = isLimit;
const limitNumber = !isLimit ? Infinity : limit;
const limitStr = !isLimit ? t('Infinity') : limit;
const left = !isLimit ? 1 : limit - used - reserved - add;
const data = [ const data = [
{ {
type: t('Used'), type: t('Used'),
value: used, value: isLimit ? used : 0,
color: typeColors.used, color: typeColors.used,
}, },
]; ];
if (reserved) { if (reserved) {
data.push({ data.push({
type: t('Reserved'), type: t('Reserved'),
value: reserved, value: isLimit ? reserved : 0,
color: typeColors.reserved, color: typeColors.reserved,
}); });
} }
data.push({ data.push({
type: t('New'), type: t('New'),
value: add, value: isLimit ? add : 0,
color: typeColors.add, color: typeColors.add,
}); });
data.push({ data.push({
@ -79,18 +84,20 @@ export default function Ring(props) {
value: left, value: left,
color: typeColors.left, color: typeColors.left,
}); });
const colors = data.map((it) => it.color); const colors = data.map((it) => it.color);
const width = hasLabel ? 200 : 120; const width = hasLabel ? 200 : 120;
const style = { width }; const style = { width };
const height = width; const height = width;
const allCount = used + add + reserved; const allCount = used + add + reserved;
const percent = (allCount / limit) * 100; const percent = isLimit ? (allCount / limitNumber) * 100 : 0;
return ( return (
<div style={style}> <div style={style}>
<Chart placeholder={false} height={height} padding="auto" autoFit> <Chart placeholder={false} height={height} padding="auto" autoFit>
<Legend visible={hasLabel} /> <Legend visible={showTip && hasLabel} />
<Tooltip visible={showTip} />
{/* 绘制图形 */} {/* 绘制图形 */}
<View data={data}> <View data={data}>
<Coordinate type="theta" innerRadius={0.75} /> <Coordinate type="theta" innerRadius={0.75} />
@ -122,7 +129,7 @@ export default function Ring(props) {
/> />
<Annotation.Text <Annotation.Text
position={['50%', '70%']} position={['50%', '70%']}
content={`${allCount}/${limit}`} content={`${allCount}/${limitStr}`}
style={{ style={{
lineHeight: '240px', lineHeight: '240px',
fontSize: '14', fontSize: '14',

View File

@ -19,8 +19,8 @@ import Line from './Line';
import QuotaInfo from './Info'; import QuotaInfo from './Info';
function renderItem(props) { function renderItem(props) {
const { type = 'ring', limit } = props; const { type = 'ring', limit, unlimitByTable = false } = props;
if (limit === -1) { if (limit === -1 && unlimitByTable) {
return <QuotaInfo {...props} />; return <QuotaInfo {...props} />;
} }
if (type === 'ring') { if (type === 'ring') {

View File

@ -1805,6 +1805,7 @@
"Quota is not enough for extend share.": "Quota is not enough for extend share.", "Quota is not enough for extend share.": "Quota is not enough for extend share.",
"Quota is not enough for extend volume.": "Quota is not enough for extend volume.", "Quota is not enough for extend volume.": "Quota is not enough for extend volume.",
"Quota: Insufficient quota to create resources, please adjust resource quantity or quota(left { quota }, input { input }).": "Quota: Insufficient quota to create resources, please adjust resource quantity or quota(left { quota }, input { input }).", "Quota: Insufficient quota to create resources, please adjust resource quantity or quota(left { quota }, input { input }).": "Quota: Insufficient quota to create resources, please adjust resource quantity or quota(left { quota }, input { input }).",
"Quota: Insufficient { name } quota to create resources, please adjust resource quantity or quota(left { left }, input { input }).": "Quota: Insufficient { name } quota to create resources, please adjust resource quantity or quota(left { left }, input { input }).",
"Quota: Project quotas sufficient resources can be created": "Quota: Project quotas sufficient resources can be created", "Quota: Project quotas sufficient resources can be created": "Quota: Project quotas sufficient resources can be created",
"RAM": "RAM", "RAM": "RAM",
"RAM(MiB)": "RAM(MiB)", "RAM(MiB)": "RAM(MiB)",
@ -2790,6 +2791,7 @@
"{interval, plural, =1 {one week} other {# weeks} } later delete": "{interval, plural, =1 {one week} other {# weeks} } later delete", "{interval, plural, =1 {one week} other {# weeks} } later delete": "{interval, plural, =1 {one week} other {# weeks} } later delete",
"{minutes} minutes {leftSeconds} seconds": "{minutes} minutes {leftSeconds} seconds", "{minutes} minutes {leftSeconds} seconds": "{minutes} minutes {leftSeconds} seconds",
"{name} type": "{name} type", "{name} type": "{name} type",
"{name} type gigabytes": "{name} type gigabytes",
"{name} type gigabytes(GiB)": "{name} type gigabytes(GiB)", "{name} type gigabytes(GiB)": "{name} type gigabytes(GiB)",
"{name} type snapshots": "{name} type snapshots", "{name} type snapshots": "{name} type snapshots",
"{name} {id} could not be found.": "{name} {id} could not be found.", "{name} {id} could not be found.": "{name} {id} could not be found.",

View File

@ -1805,6 +1805,7 @@
"Quota is not enough for extend share.": "配额不足以扩容共享。", "Quota is not enough for extend share.": "配额不足以扩容共享。",
"Quota is not enough for extend volume.": "配额不足以扩容云硬盘。", "Quota is not enough for extend volume.": "配额不足以扩容云硬盘。",
"Quota: Insufficient quota to create resources, please adjust resource quantity or quota(left { quota }, input { input }).": "配额:项目配额不足,无法创建资源,请进行资源数量或配额的调整(剩余{ quota },输入{ input })。", "Quota: Insufficient quota to create resources, please adjust resource quantity or quota(left { quota }, input { input }).": "配额:项目配额不足,无法创建资源,请进行资源数量或配额的调整(剩余{ quota },输入{ input })。",
"Quota: Insufficient { name } quota to create resources, please adjust resource quantity or quota(left { left }, input { input }).": "配额:{ name } 配额不足,无法创建资源,请进行资源数量或配额的调整(剩余{ left },输入{ input })。",
"Quota: Project quotas sufficient resources can be created": "配额:项目配额充足,可创建资源", "Quota: Project quotas sufficient resources can be created": "配额:项目配额充足,可创建资源",
"RAM": "内存", "RAM": "内存",
"RAM(MiB)": "内存MiB", "RAM(MiB)": "内存MiB",
@ -2790,6 +2791,7 @@
"{interval, plural, =1 {one week} other {# weeks} } later delete": "{interval}周后删除", "{interval, plural, =1 {one week} other {# weeks} } later delete": "{interval}周后删除",
"{minutes} minutes {leftSeconds} seconds": "{minutes}分{leftSeconds}秒", "{minutes} minutes {leftSeconds} seconds": "{minutes}分{leftSeconds}秒",
"{name} type": "{name} 类型", "{name} type": "{name} 类型",
"{name} type gigabytes": "{name} 类型容量",
"{name} type gigabytes(GiB)": "{name} 类型容量(GiB)", "{name} type gigabytes(GiB)": "{name} 类型容量(GiB)",
"{name} type snapshots": "{name} 类型快照", "{name} type snapshots": "{name} 类型快照",
"{name} {id} could not be found.": "您查看的资源{name} {id} 无法获取", "{name} {id} could not be found.": "您查看的资源{name} {id} 无法获取",

View File

@ -26,11 +26,11 @@ import globalImageStore from 'stores/glance/image';
import globalVolumeStore from 'stores/cinder/volume'; import globalVolumeStore from 'stores/cinder/volume';
import globalVolumeTypeStore from 'stores/cinder/volume-type'; import globalVolumeTypeStore from 'stores/cinder/volume-type';
import globalBackupStore from 'stores/cinder/backup'; import globalBackupStore from 'stores/cinder/backup';
import { InputNumber, Badge } from 'antd'; import { InputNumber, Badge, message as $message } from 'antd';
import { toJS } from 'mobx'; import { toJS } from 'mobx';
import { FormAction } from 'containers/Action'; import { FormAction } from 'containers/Action';
import classnames from 'classnames'; import classnames from 'classnames';
import { isFinite } from 'lodash'; import { isEmpty, isObject } from 'lodash';
import { import {
getImageSystemTabs, getImageSystemTabs,
getImageOS, getImageOS,
@ -94,8 +94,7 @@ export class Create extends FormAction {
} }
get errorText() { get errorText() {
const { status } = this.state; if (this.msg) {
if (status === 'error') {
return t( return t(
'Unable to create volume: insufficient quota to create resources.' 'Unable to create volume: insufficient quota to create resources.'
); );
@ -103,6 +102,78 @@ export class Create extends FormAction {
return super.errorText; return super.errorText;
} }
get showQuota() {
return true;
}
getVolumeQuota() {
const quotaAll = toJS(this.volumeStore.quotaSet) || {};
if (isEmpty(quotaAll)) {
return [];
}
Object.values(quotaAll).forEach((it) => {
if (isObject(it)) {
it.used = it.in_use;
}
});
const { volume_type } = this.state;
const { name } = volume_type || {};
const result = {
volumes: quotaAll.volumes,
gigabytes: quotaAll.gigabytes,
};
if (name) {
result[`volumes_${name}`] = quotaAll[`volumes_${name}`];
result[`gigabytes_${name}`] = quotaAll[`gigabytes_${name}`];
}
return result;
}
get quotaInfo() {
const quota = this.getVolumeQuota();
const { volumes = {}, gigabytes = {} } = quota;
const { limit } = volumes || {};
if (!limit) {
return [];
}
const { volume_type, size = 0, count = 1 } = this.state;
const { name } = volume_type || {};
const volume = {
...volumes,
add: count,
name: 'volume',
title: t('Volume'),
};
const sizeInfo = {
...gigabytes,
add: count * size,
name: 'gigabytes',
title: t('volume gigabytes'),
type: 'line',
};
if (!name) {
return [volume, sizeInfo];
}
const typeQuota = quota[`volumes_${name}`] || {};
const typeSizeQuota = quota[`gigabytes_${name}`] || {};
const detailInfo = {
...typeQuota,
add: count,
name: `volumes_${name}`,
title: t('{name} type', { name }),
type: 'line',
};
const detailSizeInfo = {
...typeSizeQuota,
add: count * size,
name: `gigabytes_${name}`,
title: t('{name} type gigabytes', { name }),
type: 'line',
};
return [volume, sizeInfo, detailInfo, detailSizeInfo];
}
get defaultValue() { get defaultValue() {
const size = this.quotaIsLimit && this.maxSize < 10 ? this.maxSize : 10; const size = this.quotaIsLimit && this.maxSize < 10 ? this.maxSize : 10;
const { initVolumeType } = this.state; const { initVolumeType } = this.state;
@ -167,23 +238,19 @@ export class Create extends FormAction {
} }
get quota() { get quota() {
const { volumes: { limit = 10, in_use = 0 } = {} } = const { volumes = {} } = this.getVolumeQuota();
toJS(this.volumeStore.quotaSet) || {}; return volumes;
if (limit === -1) {
return Infinity;
}
return limit - in_use;
} }
get quotaIsLimit() { get quotaIsLimit() {
const { gigabytes: { limit } = {} } = toJS(this.volumeStore.quotaSet) || {}; const { gigabytes: { limit } = {} } = this.getVolumeQuota();
return limit !== -1; return limit !== -1;
} }
get maxSize() { get maxSize() {
const { gigabytes: { limit = 10, in_use = 0 } = {} } = const { gigabytes: { limit = 10, in_use = 0, reserved = 0 } = {} } =
toJS(this.volumeStore.quotaSet) || {}; this.getVolumeQuota();
return limit - in_use; return limit !== -1 ? limit - in_use - reserved : 1000;
} }
getAvailZones() { getAvailZones() {
@ -204,6 +271,7 @@ export class Create extends FormAction {
this.setState( this.setState(
{ {
initVolumeType, initVolumeType,
volume_type: types[0],
}, },
() => { () => {
this.updateFormValue('volume_type', initVolumeType); this.updateFormValue('volume_type', initVolumeType);
@ -325,7 +393,7 @@ export class Create extends FormAction {
}; };
get nameForStateUpdate() { get nameForStateUpdate() {
return ['source', 'image', 'snapshot']; return ['source', 'image', 'snapshot', 'size', 'volume_type'];
} }
get formItems() { get formItems() {
@ -481,20 +549,9 @@ export class Create extends FormAction {
onCountChangeCallback() {} onCountChangeCallback() {}
onCountChange = (value) => { onCountChange = (value) => {
let msg = t('Quota: Project quotas sufficient resources can be created');
let status = 'success';
if (isFinite(this.quota) && value > this.quota) {
msg = t(
'Quota: Insufficient quota to create resources, please adjust resource quantity or quota(left { quota }, input { input }).',
{ quota: this.quota, input: value }
);
status = 'error';
}
this.msg = msg;
this.setState( this.setState(
{ {
count: value, count: value,
status,
}, },
() => { () => {
if (this.onCountChangeCallback) { if (this.onCountChangeCallback) {
@ -504,23 +561,77 @@ export class Create extends FormAction {
); );
}; };
checkQuotaDetail = (quota) => {
if (!quota || isEmpty(quota)) {
return true;
}
const { limit, add, reserved = 0 } = quota || {};
if (limit === -1) {
return true;
}
return limit >= add + reserved;
};
getQuotaErrorMsg = (quota) => {
const { limit, used, reserved, add, title } = quota;
const left = limit - used - reserved;
return t(
'Quota: Insufficient { name } quota to create resources, please adjust resource quantity or quota(left { left }, input { input }).',
{
name: title,
left,
input: add,
}
);
};
checkQuotaAll = () => {
const results = this.quotaInfo;
if (!results.length) {
return '';
}
const [quota = {}, sizeQuota = {}, typeQuota = {}, typeSizeQuota = {}] =
results;
let msg = '';
const quotas = [quota, sizeQuota, typeQuota, typeSizeQuota];
const errorQuota = quotas.find((it) => !this.checkQuotaDetail(it));
if (errorQuota) {
msg = this.getQuotaErrorMsg(errorQuota);
}
return msg;
};
renderBadge() { renderBadge() {
const { status } = this.state; const msg = this.checkQuotaAll();
if (status === 'success') { if (!msg) {
this.msg = '';
return null; return null;
} }
return <Badge status={status} text={this.msg} />; if (msg && this.msg !== msg) {
$message.error(msg);
this.msg = msg;
}
return <Badge status="error" text={msg} />;
} }
renderExtra() { renderExtra() {
return this.renderBadge(); return this.renderBadge();
} }
getCountMax = () => {
const { limit, used, reserved } = this.quota;
if (!limit || limit === -1) {
return 100;
}
return limit - used - reserved;
};
renderFooterLeft() { renderFooterLeft() {
const { count = 1 } = this.state; const { count = 1 } = this.state;
const max = this.getCountMax();
const configs = { const configs = {
min: 1, min: 1,
max: 100, max,
precision: 0, precision: 0,
onChange: this.onCountChange, onChange: this.onCountChange,
formatter: (value) => `$ ${value}`.replace(/\D/g, ''), formatter: (value) => `$ ${value}`.replace(/\D/g, ''),
@ -539,9 +650,9 @@ export class Create extends FormAction {
} }
onSubmit = (data) => { onSubmit = (data) => {
const { count, status } = this.state; const { count } = this.state;
if (status === 'error') { if (this.msg) {
return Promise.reject(); return Promise.reject(this.msg);
} }
const { const {
backup, backup,