Quota UI rework

This commit is contained in:
Fabricio Duarte 2026-06-12 10:56:51 -03:00
parent 2fd83e13b1
commit e335605738
44 changed files with 3540 additions and 1149 deletions

View File

@ -285,6 +285,7 @@ public class ApiConstants {
public static final String HEALTH = "health";
public static final String HEADERS = "headers";
public static final String HIDE_IP_ADDRESS_USAGE = "hideipaddressusage";
public static final String HISTORY = "history";
public static final String HOST_ID = "hostid";
public static final String HOST_IDS = "hostids";
public static final String HOST_IP = "hostip";

View File

@ -25,7 +25,7 @@ import com.cloud.utils.db.GenericDao;
public interface QuotaCreditsDao extends GenericDao<QuotaCreditsVO, Long> {
List<QuotaCreditsVO> findCredits(Long accountId, Long domainId, Date startDate, Date endDate, boolean recursive);
List<QuotaCreditsVO> findCredits(Long accountId, List<Long> domainIds, Date startDate, Date endDate);
QuotaCreditsVO saveCredits(QuotaCreditsVO credits);

View File

@ -21,7 +21,6 @@ import java.util.List;
import javax.inject.Inject;
import com.cloud.domain.dao.DomainDao;
import com.cloud.utils.db.Filter;
import com.cloud.utils.db.SearchBuilder;
import org.apache.cloudstack.quota.vo.QuotaBalanceVO;
@ -39,8 +38,6 @@ import com.cloud.utils.db.TransactionStatus;
@Component
public class QuotaCreditsDaoImpl extends GenericDaoBase<QuotaCreditsVO, Long> implements QuotaCreditsDao {
@Inject
DomainDao domainDao;
@Inject
QuotaBalanceDao quotaBalanceDao;
@ -50,19 +47,18 @@ public class QuotaCreditsDaoImpl extends GenericDaoBase<QuotaCreditsVO, Long> im
quotaCreditsVoSearch = createSearchBuilder();
quotaCreditsVoSearch.and("updatedOn", quotaCreditsVoSearch.entity().getUpdatedOn(), SearchCriteria.Op.BETWEEN);
quotaCreditsVoSearch.and("accountId", quotaCreditsVoSearch.entity().getAccountId(), SearchCriteria.Op.EQ);
quotaCreditsVoSearch.and("domainId", quotaCreditsVoSearch.entity().getDomainId(), SearchCriteria.Op.IN);
quotaCreditsVoSearch.and("domainIds", quotaCreditsVoSearch.entity().getDomainId(), SearchCriteria.Op.IN);
quotaCreditsVoSearch.done();
}
@Override
public List<QuotaCreditsVO> findCredits(Long accountId, Long domainId, Date startDate, Date endDate, boolean recursive) {
public List<QuotaCreditsVO> findCredits(Long accountId, List<Long> domainIds, Date startDate, Date endDate) {
SearchCriteria<QuotaCreditsVO> sc = quotaCreditsVoSearch.create();
Filter filter = new Filter(QuotaCreditsVO.class, "updatedOn", true, 0L, Long.MAX_VALUE);
sc.setParametersIfNotNull("accountId", accountId);
if (domainId != null) {
List<Long> domainIds = recursive ? domainDao.getDomainAndChildrenIds(domainId) : List.of(domainId);
sc.setParameters("domainId", domainIds.toArray());
if (domainIds != null) {
sc.setParameters("domainIds", domainIds.toArray());
}
if (ObjectUtils.allNotNull(startDate, endDate)) {

View File

@ -21,14 +21,13 @@ import com.cloud.user.Account;
import org.apache.cloudstack.api.ACL;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ApiErrorCode;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.Parameter;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.response.AccountResponse;
import org.apache.cloudstack.api.response.DomainResponse;
import org.apache.cloudstack.api.response.ProjectResponse;
import org.apache.cloudstack.api.response.QuotaCreditsResponse;
import org.apache.cloudstack.api.response.QuotaResponseBuilder;
import org.apache.cloudstack.context.CallContext;
import org.apache.cloudstack.quota.QuotaService;
import javax.inject.Inject;
@ -42,22 +41,35 @@ public class QuotaCreditsCmd extends BaseCmd {
@Inject
QuotaService _quotaService;
@Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, required = true, description = "Account Id for which quota credits need to be added")
@Deprecated
@Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, description = "Name of the Account for which Quota credits will be added. Deprecated, please use '" +
ApiConstants.ACCOUNT_ID + "' instead.")
private String accountName;
@ACL
@Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, required = true, entityType = DomainResponse.class, description = "Domain for which quota credits need to be added")
@Deprecated
@Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class,
description = "Domain of the Account specified by '" + ApiConstants.ACCOUNT + "' for which Quota credits will be added. " +
"Deprecated, please use '" + ApiConstants.ACCOUNT_ID + "' instead.")
private Long domainId;
@Parameter(name = ApiConstants.VALUE, type = CommandType.DOUBLE, required = true, description = "Value of the credits to be added+, subtracted-")
@ACL
@Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class,
description = "ID of the Account for which Quota credits will be added. Can not be specified with '" + ApiConstants.PROJECT_ID + "'.")
private Long accountId;
@ACL
@Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class,
description = "ID of the Project for which qQuota credits will be added. Can not be specified with '" + ApiConstants.ACCOUNT_ID + "'.")
private Long projectId;
@Parameter(name = ApiConstants.VALUE, type = CommandType.DOUBLE, required = true, description = "Amount of credits to be added (in case of a positive value) or subtracted (in case of a negative value).")
private Double value;
@Parameter(name = "min_balance", type = CommandType.DOUBLE, required = false, description = "Minimum balance threshold of the Account")
@Parameter(name = "min_balance", type = CommandType.DOUBLE, description = "An email will be sent to the Account when the Quota credits get below this threshold.")
private Double minBalance;
@Parameter(name = "quota_enforce", type = CommandType.BOOLEAN, required = false, description = "Account for which quota enforce is set to false will not be locked when there is no credit balance")
@Parameter(name = "quota_enforce", type = CommandType.BOOLEAN, description = "Whether to lock the Account when Quota credits are below zero.")
private Boolean quotaEnforce;
public Double getMinBalance() {
@ -100,31 +112,21 @@ public class QuotaCreditsCmd extends BaseCmd {
this.value = value;
}
public Long getAccountId() {
return accountId;
}
public Long getProjectId() {
return projectId;
}
public QuotaCreditsCmd() {
super();
}
@Override
public void execute() {
Long accountId = null;
Account account = _accountService.getActiveAccountByName(accountName, domainId);
if (account != null) {
accountId = account.getAccountId();
}
if (accountId == null) {
throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "The Account does not exists or has been removed/disabled");
}
if (getValue() == null) {
throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Please send a valid non-empty quota value");
}
if (getQuotaEnforce() != null) {
_quotaService.setLockAccount(accountId, getQuotaEnforce());
}
if (getMinBalance() != null) {
_quotaService.setMinBalance(accountId, getMinBalance());
}
final QuotaCreditsResponse response = _responseBuilder.addQuotaCredits(accountId, getDomainId(), getValue(), CallContext.current().getCallingUserId(), getQuotaEnforce());
QuotaCreditsResponse response = _responseBuilder.addQuotaCredits(this);
response.setResponseName(getCommandName());
response.setObjectName("quotacredits");
setResponseObject(response);
@ -132,10 +134,6 @@ public class QuotaCreditsCmd extends BaseCmd {
@Override
public long getEntityOwnerId() {
Account account = _accountService.getActiveAccountByName(accountName, domainId);
if (account != null) {
return account.getAccountId();
}
return Account.ACCOUNT_ID_SYSTEM;
}

View File

@ -18,7 +18,6 @@ package org.apache.cloudstack.api.command;
import com.cloud.utils.Pair;
import org.apache.cloudstack.api.ACL;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.BaseCmd;
@ -26,8 +25,10 @@ import org.apache.cloudstack.api.Parameter;
import org.apache.cloudstack.api.response.AccountResponse;
import org.apache.cloudstack.api.response.DomainResponse;
import org.apache.cloudstack.api.response.ListResponse;
import org.apache.cloudstack.api.response.ProjectResponse;
import org.apache.cloudstack.api.response.QuotaCreditsResponse;
import org.apache.cloudstack.api.response.QuotaResponseBuilder;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.time.DateUtils;
@ -44,12 +45,15 @@ public class QuotaCreditsListCmd extends BaseCmd {
@Inject
QuotaResponseBuilder quotaResponseBuilder;
@ACL
@Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, description = "ID of the account for which the credit statement will be generated.")
@Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class,
description = "ID of the Account for which the credit statement will be generated. Can not be specified with '" + ApiConstants.PROJECT_ID + "'.")
private Long accountId;
@ACL
@Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, description = "ID of the domain for which credit statement will be generated. " +
@Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class,
description = "ID of the Project for which the credit statement will be generated. Can not be specified with '" + ApiConstants.ACCOUNT_ID + "'.")
private Long projectId;
@Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, description = "ID of the Domain for which credit statement will be generated. " +
"Available only for administrators.")
private Long domainId;
@ -97,14 +101,18 @@ public class QuotaCreditsListCmd extends BaseCmd {
this.startDate = startDate;
}
public Boolean getRecursive() {
return recursive;
public boolean isRecursive() {
return BooleanUtils.isTrue(recursive);
}
public void setRecursive(Boolean recursive) {
this.recursive = recursive;
}
public Long getProjectId() {
return projectId;
}
@Override
public void execute() {
Pair<List<QuotaCreditsResponse>, Integer> responses = quotaResponseBuilder.createQuotaCreditsListResponse(this);
@ -116,7 +124,10 @@ public class QuotaCreditsListCmd extends BaseCmd {
@Override
public long getEntityOwnerId() {
return -1;
if (ObjectUtils.allNull(accountId, projectId)) {
return -1;
}
return _accountService.finalizeAccountId(accountId, null, null, projectId);
}
}

View File

@ -19,6 +19,7 @@ package org.apache.cloudstack.api.response;
import com.cloud.user.User;
import org.apache.cloudstack.api.command.QuotaBalanceCmd;
import org.apache.cloudstack.api.command.QuotaConfigureEmailCmd;
import org.apache.cloudstack.api.command.QuotaCreditsCmd;
import org.apache.cloudstack.api.command.QuotaCreditsListCmd;
import org.apache.cloudstack.api.command.QuotaEmailTemplateListCmd;
import org.apache.cloudstack.api.command.QuotaEmailTemplateUpdateCmd;
@ -54,7 +55,7 @@ public interface QuotaResponseBuilder {
Pair<List<QuotaSummaryResponse>, Integer> createQuotaSummaryResponse(QuotaSummaryCmd cmd);
QuotaCreditsResponse addQuotaCredits(Long accountId, Long domainId, Double amount, Long updatedBy, Boolean enforce);
QuotaCreditsResponse addQuotaCredits(QuotaCreditsCmd cmd);
List<QuotaEmailTemplateResponse> listQuotaEmailTemplates(QuotaEmailTemplateListCmd cmd);

View File

@ -44,7 +44,6 @@ import com.cloud.domain.dao.DomainDao;
import com.cloud.event.ActionEvent;
import com.cloud.event.EventTypes;
import com.cloud.exception.InvalidParameterValueException;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.network.dao.IPAddressDao;
import com.cloud.network.dao.IPAddressVO;
import com.cloud.network.dao.NetworkDao;
@ -67,6 +66,9 @@ import com.cloud.user.UserVO;
import com.cloud.utils.DateUtil;
import com.cloud.utils.Pair;
import com.cloud.utils.db.EntityManager;
import com.cloud.utils.db.Transaction;
import com.cloud.utils.db.TransactionCallback;
import com.cloud.utils.db.TransactionLegacy;
import com.cloud.utils.exception.CloudRuntimeException;
import com.cloud.vm.VMInstanceVO;
import com.cloud.vm.dao.VMInstanceDao;
@ -77,6 +79,7 @@ import org.apache.cloudstack.api.InternalIdentity;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.command.QuotaBalanceCmd;
import org.apache.cloudstack.api.command.QuotaConfigureEmailCmd;
import org.apache.cloudstack.api.command.QuotaCreditsCmd;
import org.apache.cloudstack.api.command.QuotaCreditsListCmd;
import org.apache.cloudstack.api.command.QuotaEmailTemplateListCmd;
import org.apache.cloudstack.api.command.QuotaEmailTemplateUpdateCmd;
@ -412,45 +415,52 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder {
QuotaTypes quotaType = QuotaTypes.listQuotaTypes().get(type);
QuotaStatementItemResponse item = new QuotaStatementItemResponse(type);
item.setQuotaUsed(usageRecords.stream().map(QuotaUsageJoinVO::getQuotaUsed).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
BigDecimal quotaUsed = usageRecords.stream().map(QuotaUsageJoinVO::getQuotaUsed).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
item.setQuotaUsed(quotaUsed);
item.setUsageUnit(quotaType.getQuotaUnit());
item.setUsageName(quotaType.getQuotaName());
setStatementItemResources(item, usageType, usageRecords, showResources);
if (showResources) {
setStatementItemResources(item, usageType, usageRecords);
} else {
List<QuotaStatementItemHistoryResponse> history = createQuotaConsumptionHistory(usageRecords, quotaUsed);
item.setHistory(history);
}
return item;
}
protected void setStatementItemResources(QuotaStatementItemResponse statementItem, int usageType, List<QuotaUsageJoinVO> quotaUsageRecords, boolean showResources) {
if (!showResources) {
return;
}
protected void setStatementItemResources(QuotaStatementItemResponse statementItem, int usageType, List<QuotaUsageJoinVO> quotaUsageRecords) {
List<QuotaStatementItemResourceResponse> itemDetails = new ArrayList<>();
Map<Long, BigDecimal> quotaUsagesValuesAggregatedById = quotaUsageRecords
Map<Long, List<QuotaUsageJoinVO>> quotaUsagesAggregatedByResourceId = quotaUsageRecords
.stream()
.filter(quotaUsageJoinVo -> getResourceIdByUsageType(quotaUsageJoinVo, usageType) != null)
.collect(Collectors.groupingBy(
quotaUsageJoinVo -> getResourceIdByUsageType(quotaUsageJoinVo, usageType),
Collectors.reducing(new BigDecimal(0), QuotaUsageJoinVO::getQuotaUsed, BigDecimal::add)
.collect(Collectors.groupingBy(quotaUsageJoinVo -> getResourceIdByUsageType(quotaUsageJoinVo, usageType)
));
for (Map.Entry<Long, BigDecimal> entry : quotaUsagesValuesAggregatedById.entrySet()) {
QuotaStatementItemResourceResponse detail = new QuotaStatementItemResourceResponse();
detail.setQuotaUsed(entry.getValue());
for (Map.Entry<Long, List<QuotaUsageJoinVO>> entry : quotaUsagesAggregatedByResourceId.entrySet()) {
QuotaUsageResourceVO resource = getResourceFromIdAndType(entry.getKey(), usageType);
QuotaStatementItemResourceResponse detail = new QuotaStatementItemResourceResponse();
if (resource != null) {
detail.setResourceId(resource.getUuid());
detail.setDisplayName(resource.getName());
detail.setRemoved(resource.isRemoved());
} else {
detail.setDisplayName("<untraceable>");
}
BigDecimal quotaUsed = entry.getValue().stream()
.map(QuotaUsageJoinVO::getQuotaUsed)
.reduce(BigDecimal.ZERO, BigDecimal::add);
List<QuotaStatementItemHistoryResponse> history = createQuotaConsumptionHistory(entry.getValue(), quotaUsed);
detail.setQuotaUsed(quotaUsed);
detail.setHistory(history);
itemDetails.add(detail);
}
statementItem.setResources(itemDetails);
}
@ -524,6 +534,34 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder {
return null;
}
protected List<QuotaStatementItemHistoryResponse> createQuotaConsumptionHistory(List<QuotaUsageJoinVO> quotaUsage, BigDecimal quotaUsed) {
if (quotaUsed.equals(BigDecimal.ZERO)) {
logger.debug("Not generating Quota consumption history because the item has not consumed any Quota in the period.");
return null;
}
Map<Date, QuotaStatementItemHistoryResponse> history = new HashMap<>();
for (QuotaUsageJoinVO record : quotaUsage) {
if (ObjectUtils.anyNull(record.getUsageItemId(), record.getQuotaUsed())) {
continue;
}
QuotaStatementItemHistoryResponse item = history.computeIfAbsent(
record.getEndDate(),
key -> new QuotaStatementItemHistoryResponse()
);
if (item.getStartDate() == null || item.getStartDate().after(record.getStartDate())) {
item.setStartDate(record.getStartDate());
}
item.setEndDate(record.getEndDate());
item.setQuotaConsumed(item.getQuotaConsumed().add(record.getQuotaUsed()));
history.put(record.getEndDate(), item);
}
return history.values().stream().sorted(Comparator.comparing(QuotaStatementItemHistoryResponse::getEndDate)).collect(Collectors.toList());
}
@Override
public Pair<List<QuotaTariffVO>, Integer> listQuotaTariffPlans(final QuotaTariffListCmd cmd) {
Date startDate = cmd.getEffectiveDate();
@ -661,51 +699,79 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder {
}
@Override
public QuotaCreditsResponse addQuotaCredits(Long accountId, Long domainId, Double amount, Long updatedBy, Boolean enforce) {
public QuotaCreditsResponse addQuotaCredits(QuotaCreditsCmd cmd) {
Double value = cmd.getValue();
if (value == null) {
throw new InvalidParameterValueException("Please specify a valid amount of credits.");
}
Long accountId = _accountMgr.finalizeAccountId(cmd.getAccountId(), cmd.getAccountName(), cmd.getDomainId(), cmd.getProjectId());
AccountVO account = _accountDao.findById(accountId);
Long domainId = account.getDomainId();
Date depositedOn = new Date();
QuotaBalanceVO qb = _quotaBalanceDao.findLaterBalanceEntry(accountId, domainId, depositedOn);
if (qb != null) {
throw new InvalidParameterValueException(String.format("Incorrect deposit date [%s], as there are balance entries after this date.",
depositedOn));
}
QuotaCreditsVO credits = new QuotaCreditsVO(accountId, domainId, new BigDecimal(amount), updatedBy);
credits.setUpdatedOn(depositedOn);
QuotaCreditsVO result = quotaCreditsDao.saveCredits(credits);
if (result == null) {
logger.error("Unable to add credits to account ID [{}].", accountId);
throw new CloudRuntimeException("Unable to add credits to account.");
}
final AccountVO account = _accountDao.findById(accountId);
if (account == null) {
throw new InvalidParameterValueException("Account does not exist with account id " + accountId);
}
final boolean lockAccountEnforcement = "true".equalsIgnoreCase(QuotaConfig.QuotaEnableEnforcement.value());
final BigDecimal currentAccountBalance = _quotaBalanceDao.getLastQuotaBalance(accountId, domainId);
logger.debug("Depositing [{}] credits on adjusted date [{}]; current balance is [{}].", amount,
DateUtil.displayDateInTimezone(QuotaManagerImpl.getUsageAggregationTimeZone(), depositedOn), currentAccountBalance);
// update quota account with the balance
_quotaService.saveQuotaAccount(account, currentAccountBalance, depositedOn);
if (lockAccountEnforcement) {
if (currentAccountBalance.compareTo(new BigDecimal(0)) >= 0) {
if (account.getState() == Account.State.LOCKED) {
logger.info("UnLocking account " + account.getAccountName() + " , due to positive balance " + currentAccountBalance);
_accountMgr.enableAccount(account.getAccountName(), domainId, accountId);
}
} else { // currentAccountBalance < 0 then lock the account
if (_quotaManager.isLockable(account) && account.getState() == Account.State.ENABLED && enforce) {
logger.info("Locking account " + account.getAccountName() + " , due to negative balance " + currentAccountBalance);
_accountMgr.lockAccount(account.getAccountName(), domainId, accountId);
}
}
}
boolean lockAccountEnforcement = "true".equalsIgnoreCase(QuotaConfig.QuotaEnableEnforcement.value());
QuotaCreditsVO result = Transaction.execute(TransactionLegacy.USAGE_DB, (TransactionCallback<QuotaCreditsVO>) status -> persistQuotaCredits(cmd, value, depositedOn, account, lockAccountEnforcement));
UserVO creditor = getCreditorForQuotaCredits(result);
return createQuotaCreditsResponse(result, creditor);
}
protected QuotaCreditsVO persistQuotaCredits(QuotaCreditsCmd cmd, Double value, Date depositedOn, AccountVO account, boolean lockAccountEnforcement) {
Long accountId = account.getId();
Long domainId = account.getDomainId();
long callingUserId = CallContext.current().getCallingUserId();
QuotaCreditsVO credits = new QuotaCreditsVO(accountId, domainId, new BigDecimal(value), callingUserId);
credits.setUpdatedOn(depositedOn);
QuotaCreditsVO result = quotaCreditsDao.saveCredits(credits);
BigDecimal currentAccountBalance = _quotaBalanceDao.getLastQuotaBalance(accountId, domainId);
logger.debug("Depositing [{}] credits on adjusted date [{}]; current balance is [{}].", value,
DateUtil.displayDateInTimezone(QuotaManagerImpl.getUsageAggregationTimeZone(), depositedOn), currentAccountBalance);
_quotaService.saveQuotaAccount(account, currentAccountBalance, depositedOn);
Boolean enforceQuota = cmd.getQuotaEnforce();
if (enforceQuota != null) {
_quotaService.setLockAccount(accountId, enforceQuota);
}
Double minBalance = cmd.getMinBalance();
if (minBalance != null) {
_quotaService.setMinBalance(accountId, minBalance);
}
if (lockAccountEnforcement) {
lockOrUnlockAccountIfRequired(currentAccountBalance, account, enforceQuota);
}
return result;
}
protected void lockOrUnlockAccountIfRequired(BigDecimal currentAccountBalance, AccountVO account, Boolean enforceQuota) {
Long accountId = account.getId();
Long domainId = account.getDomainId();
String accountName = account.getAccountName();
if (currentAccountBalance.compareTo(BigDecimal.ZERO) >= 0) {
if (account.getState() == Account.State.LOCKED) {
logger.info("Unlocking Account [{}] due to positive balance.", accountName);
_accountMgr.enableAccount(accountName, domainId, accountId);
}
return;
}
if (enforceQuota && account.getState() == Account.State.ENABLED && _quotaManager.isLockable(account)) {
logger.info("Locking Account [{}] due to negative balance.", accountName);
_accountMgr.lockAccount(accountName, domainId, accountId);
}
}
private QuotaEmailTemplateResponse createQuotaEmailResponse(QuotaEmailTemplatesVO template) {
QuotaEmailTemplateResponse response = new QuotaEmailTemplateResponse();
response.setTemplateType(template.getTemplateName());
@ -1036,26 +1102,16 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder {
}
protected List<QuotaCreditsVO> getCreditsForQuotaCreditsList(QuotaCreditsListCmd cmd) {
Long accountId = cmd.getAccountId();
Long domainId = cmd.getDomainId();
Long accountId = getAccountIdForQuotaStatement(cmd.getEntityOwnerId(), null);
Pair<Long, List<Long>> baseDomainAndFilteredDomains = getDomainIdsForQuotaStatement(accountId, cmd.getDomainId(), cmd.isRecursive());
Date startDate = cmd.getStartDate();
Date endDate = cmd.getEndDate();
boolean isRecursive = cmd.getRecursive();
if (ObjectUtils.allNull(accountId, domainId)) {
throw new InvalidParameterValueException("Please provide either account ID or domain ID.");
}
if (startDate.after(endDate)) {
throw new InvalidParameterValueException("The start date must be before the end date.");
}
Account caller = CallContext.current().getCallingAccount();
if (domainId != null && _accountMgr.isNormalUser(caller.getAccountId())) {
throw new PermissionDeniedException("Regular users are not allowed to generate domain statements.");
}
return quotaCreditsDao.findCredits(accountId, domainId, startDate, endDate, isRecursive);
return quotaCreditsDao.findCredits(accountId, baseDomainAndFilteredDomains.second(), startDate, endDate);
}
/**
@ -1208,6 +1264,14 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder {
return createQuotaResourceStatementResponse(resourceUuid, usageType, quotaResourceStatementItemResponseList, totalQuotaUsed);
}
/**
* Determines the appropriate Account ID to use for Quota statement-related operations while ensuring correct permissions.
*
* @param providedAccountId the ID of the Account provided to the command.
* @param fallbackAccountId the ID of a fallback Account to use for User Accounts if no specific Account ID was provided.
* If null, then we fallback to the User Account itself.
* @return the account ID to be used for the Quota statement, or null if no specific Account limitation is required.
*/
protected Long getAccountIdForQuotaStatement(long providedAccountId, Long fallbackAccountId) {
Account caller = CallContext.current().getCallingAccount();
@ -1235,6 +1299,17 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder {
return caller.getAccountId();
}
/**
* Determines the Domains for which a Quota statement should be generated while ensuring correct permissions.
*
* @param finalAccountId the Account ID determined via <code>org.apache.cloudstack.api.response.QuotaResponseBuilderImpl#getAccountIdForQuotaStatement(long, java.lang.Long)</code>.
* @param providedDomainId the Domain ID provided to the command.
* @param isRecursive the recursion flag provided to the command.
* @return A pair containing:
* - The base Domain's ID as the first element. This can be null if we are not limiting by Domain.
* - A list containing the base Domain's ID and optionally its children if
* the recursion flag is true. Also nullable.
*/
protected Pair<Long, List<Long>> getDomainIdsForQuotaStatement(Long finalAccountId, Long providedDomainId, boolean isRecursive) {
if (finalAccountId != null) {
// Access to the provided account has already been validated

View File

@ -0,0 +1,68 @@
//Licensed to the Apache Software Foundation (ASF) under one
//or more contributor license agreements. See the NOTICE file
//distributed with this work for additional information
//regarding copyright ownership. The ASF licenses this file
//to you 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.
package org.apache.cloudstack.api.response;
import com.cloud.serializer.Param;
import com.google.gson.annotations.SerializedName;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.BaseResponse;
import java.math.BigDecimal;
import java.util.Date;
public class QuotaStatementItemHistoryResponse extends BaseResponse {
@SerializedName(ApiConstants.START_DATE)
@Param(description = "Start date of the item.")
private Date startDate = null;
@SerializedName(ApiConstants.END_DATE)
@Param(description = "End date of the item.")
private Date endDate = null;
@SerializedName(ApiConstants.QUOTA_CONSUMED)
@Param(description = "Amount of quota consumed.")
private BigDecimal quotaConsumed = BigDecimal.ZERO;
public QuotaStatementItemHistoryResponse() {
}
public Date getStartDate() {
return startDate;
}
public void setStartDate(Date startDate) {
this.startDate = startDate;
}
public Date getEndDate() {
return endDate;
}
public void setEndDate(Date endDate) {
this.endDate = endDate;
}
public BigDecimal getQuotaConsumed() {
return quotaConsumed;
}
public void setQuotaConsumed(BigDecimal quotaConsumed) {
this.quotaConsumed = quotaConsumed;
}
}

View File

@ -17,6 +17,7 @@
package org.apache.cloudstack.api.response;
import java.math.BigDecimal;
import java.util.List;
import com.google.gson.annotations.SerializedName;
@ -43,6 +44,10 @@ public class QuotaStatementItemResourceResponse extends BaseResponse {
@Param(description = "Indicates whether the resource is removed or active.")
private boolean removed;
@SerializedName(ApiConstants.HISTORY)
@Param(description = "Quota consumption history.")
private List<QuotaStatementItemHistoryResponse> history;
public void setQuotaUsed(BigDecimal quotaUsed) {
this.quotaUsed = quotaUsed;
}
@ -58,4 +63,9 @@ public class QuotaStatementItemResourceResponse extends BaseResponse {
public void setRemoved(boolean removed) {
this.removed = removed;
}
public void setHistory(List<QuotaStatementItemHistoryResponse> history) {
this.history = history;
}
}

View File

@ -48,6 +48,10 @@ public class QuotaStatementItemResponse extends BaseResponse {
@Param(description = "Item's resources.")
private List<QuotaStatementItemResourceResponse> resources;
@SerializedName(ApiConstants.HISTORY)
@Param(description = "Quota consumption history.")
private List<QuotaStatementItemHistoryResponse> history;
public QuotaStatementItemResponse(final int usageType) {
this.usageType = usageType;
}
@ -92,4 +96,7 @@ public class QuotaStatementItemResponse extends BaseResponse {
this.resources = resources;
}
public void setHistory(List<QuotaStatementItemHistoryResponse> history) {
this.history = history;
}
}

View File

@ -80,7 +80,7 @@ public class QuotaCreditsCmdTest extends TestCase {
Mockito.when(accountService.getActiveAccountByName(nullable(String.class), nullable(Long.class))).thenReturn(acc);
Mockito.when(responseBuilder.addQuotaCredits(nullable(Long.class), nullable(Long.class), nullable(Double.class), nullable(Long.class), nullable(Boolean.class))).thenReturn(new QuotaCreditsResponse());
Mockito.when(responseBuilder.addQuotaCredits(cmd)).thenReturn(new QuotaCreditsResponse());
// No value provided test
try {
@ -94,7 +94,7 @@ public class QuotaCreditsCmdTest extends TestCase {
cmd.execute();
Mockito.verify(quotaService, Mockito.times(0)).setLockAccount(anyLong(), anyBoolean());
Mockito.verify(quotaService, Mockito.times(1)).setMinBalance(anyLong(), anyDouble());
Mockito.verify(responseBuilder, Mockito.times(1)).addQuotaCredits(nullable(Long.class), nullable(Long.class), nullable(Double.class), nullable(Long.class), nullable(Boolean.class));
Mockito.verify(responseBuilder, Mockito.times(1)).addQuotaCredits(cmd);
}
}

View File

@ -44,6 +44,7 @@ import org.apache.cloudstack.api.InternalIdentity;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.command.QuotaBalanceCmd;
import org.apache.cloudstack.api.command.QuotaConfigureEmailCmd;
import org.apache.cloudstack.api.command.QuotaCreditsCmd;
import org.apache.cloudstack.api.command.QuotaCreditsListCmd;
import org.apache.cloudstack.api.command.QuotaEmailTemplateListCmd;
import org.apache.cloudstack.api.command.QuotaEmailTemplateUpdateCmd;
@ -52,6 +53,7 @@ import org.apache.cloudstack.api.command.QuotaValidateActivationRuleCmd;
import org.apache.cloudstack.context.CallContext;
import org.apache.cloudstack.discovery.ApiDiscoveryService;
import org.apache.cloudstack.jsinterpreter.JsInterpreterHelper;
import org.apache.cloudstack.quota.QuotaManager;
import org.apache.cloudstack.quota.QuotaService;
import org.apache.cloudstack.quota.QuotaStatement;
import org.apache.cloudstack.quota.activationrule.presetvariables.PresetVariableDefinition;
@ -192,6 +194,12 @@ public class QuotaResponseBuilderImplTest extends TestCase {
@Mock
EntityManager entityManagerMock;
@Mock
QuotaManager quotaManagerMock;
@Mock
QuotaBalanceVO quotaBalanceVoMock;
@Before
public void setup() {
CallContext.register(callerUserMock, callerAccountMock);
@ -243,28 +251,6 @@ public class QuotaResponseBuilderImplTest extends TestCase {
assertNull(tariffResponse.getActivationRule());
}
@Test
public void testAddQuotaCredits() {
final long accountId = 2L;
final long domainId = 1L;
final double amount = 11.0;
final long updatedBy = 2L;
QuotaCreditsVO credit = new QuotaCreditsVO();
credit.setCredit(new BigDecimal(amount));
Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenReturn(credit);
Mockito.when(quotaBalanceDaoMock.getLastQuotaBalance(Mockito.anyLong(), Mockito.anyLong())).thenReturn(new BigDecimal(111));
Mockito.doReturn(userVoMock).when(quotaResponseBuilderSpy).getCreditorForQuotaCredits(credit);
AccountVO account = new AccountVO();
account.setState(Account.State.LOCKED);
Mockito.when(accountDaoMock.findById(Mockito.anyLong())).thenReturn(account);
QuotaCreditsResponse resp = quotaResponseBuilderSpy.addQuotaCredits(accountId, domainId, amount, updatedBy, true);
assertTrue(resp.getCredit().compareTo(credit.getCredit()) == 0);
}
@Test
public void testListQuotaEmailTemplates() {
QuotaEmailTemplateListCmd cmd = new QuotaEmailTemplateListCmd();
@ -710,33 +696,18 @@ public class QuotaResponseBuilderImplTest extends TestCase {
}
private QuotaCreditsListCmd createQuotaCreditsListCmdForTests() {
Mockito.doReturn(false).when(accountManagerMock).isNormalUser(Mockito.anyLong());
QuotaCreditsListCmd cmd = new QuotaCreditsListCmd();
cmd.setAccountId(1L);
cmd.setDomainId(2L);
QuotaCreditsListCmd cmd = Mockito.mock(QuotaCreditsListCmd.class);
Mockito.doReturn(1L).when(cmd).getEntityOwnerId();
Mockito.doReturn(2L).when(cmd).getDomainId();
Mockito.doReturn(new Date()).when(cmd).getStartDate();
Mockito.doReturn(new Date()).when(cmd).getEndDate();
return cmd;
}
@Test(expected = InvalidParameterValueException.class)
public void getCreditsForQuotaCreditsListTestThrowsInvalidParameterValueExceptionWhenBothAccountIdAndDomainIdAreNull() {
QuotaCreditsListCmd cmd = new QuotaCreditsListCmd();
quotaResponseBuilderSpy.getCreditsForQuotaCreditsList(cmd);
}
@Test(expected = InvalidParameterValueException.class)
public void getCreditsForQuotaCreditsListTestThrowsInvalidParameterValueExceptionWhenStartDateIsAfterEndDate() {
QuotaCreditsListCmd cmd = createQuotaCreditsListCmdForTests();
cmd.setStartDate(new Date());
cmd.setEndDate(DateUtils.addDays(new Date(), -1));
quotaResponseBuilderSpy.getCreditsForQuotaCreditsList(cmd);
}
@Test(expected = PermissionDeniedException.class)
public void getCreditsForQuotaCreditsListTestThrowsPermissionDeniedExceptionWhenDomainIdIsProvidedAndCallerIsNormalUser() {
QuotaCreditsListCmd cmd = createQuotaCreditsListCmdForTests();
Mockito.doReturn(true).when(accountManagerMock).isNormalUser(Mockito.anyLong());
Mockito.doReturn(DateUtils.addDays(new Date(), -1)).when(cmd).getEndDate();
quotaResponseBuilderSpy.getCreditsForQuotaCreditsList(cmd);
}
@ -747,7 +718,7 @@ public class QuotaResponseBuilderImplTest extends TestCase {
List<QuotaCreditsVO> expected = new ArrayList<>();
expected.add(new QuotaCreditsVO());
Mockito.doReturn(expected).when(quotaCreditsDaoMock).findCredits(Mockito.anyLong(), Mockito.anyLong(), Mockito.any(), Mockito.any(), Mockito.anyBoolean());
Mockito.doReturn(expected).when(quotaCreditsDaoMock).findCredits(Mockito.anyLong(), Mockito.any(), Mockito.any(), Mockito.any());
List<QuotaCreditsVO> result = quotaResponseBuilderSpy.getCreditsForQuotaCreditsList(cmd);
@ -1007,7 +978,6 @@ public class QuotaResponseBuilderImplTest extends TestCase {
@Test
public void createStatementItemTestReturnItem() {
List<QuotaUsageJoinVO> quotaUsages = getQuotaUsagesForTest();
Mockito.doNothing().when(quotaResponseBuilderSpy).setStatementItemResources(Mockito.any(), Mockito.anyInt(), Mockito.any(), Mockito.anyBoolean());
QuotaStatementItemResponse result = quotaResponseBuilderSpy.createStatementItem(0, quotaUsages, false);
@ -1018,15 +988,6 @@ public class QuotaResponseBuilderImplTest extends TestCase {
Assert.assertEquals(quotaTypeExpected.getQuotaName(), result.getUsageName());
}
@Test
public void setStatementItemResourcesTestDoNotShowResourcesDoNothing() {
QuotaStatementItemResponse item = new QuotaStatementItemResponse(1);
quotaResponseBuilderSpy.setStatementItemResources(item, 0, getQuotaUsagesForTest(), false);
Assert.assertNull(item.getResources());
}
@Test
public void getAccountIdForQuotaStatementTestReturnsProvidedAccount() {
long providedAccountId = 200L;
@ -1185,4 +1146,221 @@ public class QuotaResponseBuilderImplTest extends TestCase {
Assert.assertNotNull(result);
Assert.assertEquals(mockResource, result);
}
@Test
public void lockOrUnlockAccountIfRequiredTestPositiveBalanceUnlocksAccount() {
Mockito.doReturn(Account.State.LOCKED).when(accountMock).getState();
quotaResponseBuilderSpy.lockOrUnlockAccountIfRequired(BigDecimal.TEN, accountMock, true);
Mockito.verify(accountManagerMock).enableAccount(accountMock.getAccountName(), domainVoMock.getId(), accountMock.getId());
Mockito.verify(accountManagerMock, Mockito.never()).lockAccount(Mockito.anyString(), Mockito.anyLong(), Mockito.anyLong());
}
@Test
public void lockOrUnlockAccountIfRequiredTestNegativeBalanceLocksAccount() {
Mockito.doReturn(Account.State.ENABLED).when(accountMock).getState();
Mockito.doReturn(true).when(quotaManagerMock).isLockable(accountMock);
quotaResponseBuilderSpy.lockOrUnlockAccountIfRequired(BigDecimal.valueOf(-10), accountMock, true);
Mockito.verify(accountManagerMock).lockAccount(accountMock.getAccountName(), domainVoMock.getId(), accountMock.getId());
Mockito.verify(accountManagerMock, Mockito.never()).enableAccount(Mockito.anyString(), Mockito.anyLong(), Mockito.anyLong());
}
@Test
public void addQuotaCreditsTestValidParameters() {
QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class);
Mockito.doReturn(10D).when(cmd).getValue();
Mockito.doReturn(BigDecimal.TEN).when(quotaCreditsVoMock).getCredit();
Mockito.doReturn(accountMock).when(accountDaoMock).findById(Mockito.anyLong());
Mockito.doReturn(null).when(quotaBalanceDaoMock).findLaterBalanceEntry(Mockito.anyLong(), Mockito.anyLong(),
Mockito.any());
Mockito.doReturn(quotaCreditsVoMock).when(quotaResponseBuilderSpy).persistQuotaCredits(Mockito.any(), Mockito.anyDouble(),
Mockito.any(), Mockito.any(), Mockito.anyBoolean());
Mockito.doReturn(userVoMock).when(quotaResponseBuilderSpy).getCreditorForQuotaCredits(Mockito.any());
QuotaCreditsResponse response = quotaResponseBuilderSpy.addQuotaCredits(cmd);
Assert.assertEquals(BigDecimal.TEN, response.getCredit());
}
@Test(expected = InvalidParameterValueException.class)
public void addQuotaCreditsTestThrowsExceptionWhenValueIsNull() {
QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class);
Mockito.doReturn(null).when(cmd).getValue();
quotaResponseBuilderSpy.addQuotaCredits(cmd);
}
@Test(expected = InvalidParameterValueException.class)
public void addQuotaCreditsTestThrowsExceptionWhenDepositDateIsIncorrect() {
QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class);
Mockito.doReturn(100.0).when(cmd).getValue();
Mockito.doReturn(accountMock).when(accountDaoMock).findById(Mockito.anyLong());
Mockito.doReturn(quotaBalanceVoMock).when(quotaBalanceDaoMock).findLaterBalanceEntry(Mockito.anyLong(), Mockito.anyLong(), Mockito.any());
quotaResponseBuilderSpy.addQuotaCredits(cmd);
}
@Test
public void persistQuotaCreditsTestSavesCreditsAndBalanceSuccessfully() {
QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class);
Long accountId = 1L;
Long domainId = 2L;
Double value = 10D;
Date depositedOn = new Date();
AccountVO account = Mockito.mock(AccountVO.class);
BigDecimal currentBalance = BigDecimal.ZERO;
Mockito.doReturn(accountId).when(account).getId();
Mockito.doReturn(domainId).when(account).getDomainId();
Mockito.doReturn(null).when(cmd).getQuotaEnforce();
Mockito.doReturn(null).when(cmd).getMinBalance();
Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
Mockito.when(quotaBalanceDaoMock.getLastQuotaBalance(accountId, domainId)).thenReturn(currentBalance);
QuotaCreditsVO result = quotaResponseBuilderSpy.persistQuotaCredits(cmd, value, depositedOn, account, false);
Assert.assertNotNull(result);
Assert.assertEquals(BigDecimal.TEN, result.getCredit());
Assert.assertEquals(accountId, result.getAccountId());
Assert.assertEquals(depositedOn, result.getUpdatedOn());
Mockito.verify(quotaServiceMock).saveQuotaAccount(account, currentBalance, depositedOn);
Mockito.verify(quotaServiceMock, Mockito.never()).setLockAccount(Mockito.anyLong(), Mockito.anyBoolean());
Mockito.verify(quotaServiceMock, Mockito.never()).setMinBalance(Mockito.anyLong(), Mockito.anyDouble());
Mockito.verify(quotaResponseBuilderSpy, Mockito.never()).lockOrUnlockAccountIfRequired(Mockito.any(), Mockito.any(), Mockito.anyBoolean());
}
@Test
public void persistQuotaCreditsTestCallsSetLockAccountWhenQuotaEnforceProvided() {
QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class);
Long accountId = 1L;
Double value = 100.0;
Date depositedOn = new Date();
AccountVO account = Mockito.mock(AccountVO.class);
Mockito.doReturn(accountId).when(account).getId();
Mockito.when(cmd.getQuotaEnforce()).thenReturn(Boolean.TRUE);
Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
quotaResponseBuilderSpy.persistQuotaCredits(cmd, value, depositedOn, account, false);
Mockito.verify(quotaServiceMock).setLockAccount(accountId, Boolean.TRUE);
}
@Test
public void persistQuotaCreditsTestCallsSetMinBalanceWhenProvided() {
QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class);
Long accountId = 1L;
Double value = 100.0;
Date depositedOn = new Date();
AccountVO account = Mockito.mock(AccountVO.class);
Mockito.when(cmd.getMinBalance()).thenReturn(50.0);
Mockito.doReturn(accountId).when(account).getId();
Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
quotaResponseBuilderSpy.persistQuotaCredits(cmd, value, depositedOn, account, false);
Mockito.verify(quotaServiceMock).setMinBalance(accountId, 50.0);
}
@Test
public void persistQuotaCreditsTestLocksOrUnlocksAccountWhenEnforcementIsEnabledGlobally() {
QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class);
Long accountId = 1L;
Long domainId = 2L;
Double value = 100.0;
Date depositedOn = new Date();
AccountVO account = Mockito.mock(AccountVO.class);
BigDecimal currentBalance = BigDecimal.ZERO;
Mockito.doReturn(accountId).when(account).getId();
Mockito.doReturn(domainId).when(account).getDomainId();
Mockito.when(cmd.getMinBalance()).thenReturn(50.0);
Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenAnswer(invocation -> invocation.getArgument(0));
Mockito.when(quotaBalanceDaoMock.getLastQuotaBalance(accountId, domainId)).thenReturn(currentBalance);
quotaResponseBuilderSpy.persistQuotaCredits(cmd, value, depositedOn, account, true);
Mockito.verify(quotaResponseBuilderSpy).lockOrUnlockAccountIfRequired(currentBalance, account, false);
}
@Test
public void createQuotaConsumptionHistoryTestReturnsNullForZeroQuotaUsed() {
List<QuotaStatementItemHistoryResponse> result = quotaResponseBuilderSpy.createQuotaConsumptionHistory(new ArrayList<>(), BigDecimal.ZERO);
Assert.assertNull(result);
}
@Test
public void createQuotaConsumptionHistoryTestIgnoresNullQuotaUsed() {
Date now = new Date();
List<QuotaUsageJoinVO> usageRecords = new ArrayList<>();
QuotaUsageJoinVO record1 = new QuotaUsageJoinVO();
record1.setStartDate(now);
record1.setEndDate(now);
record1.setQuotaUsed(null);
QuotaUsageJoinVO record2 = new QuotaUsageJoinVO();
record2.setStartDate(new Date(now.getTime() + 1000));
record2.setEndDate(new Date(now.getTime() + 1000));
record2.setQuotaUsed(BigDecimal.valueOf(10));
usageRecords.add(record1);
usageRecords.add(record2);
BigDecimal totalQuotaUsed = BigDecimal.valueOf(10);
List<QuotaStatementItemHistoryResponse> result = quotaResponseBuilderSpy.createQuotaConsumptionHistory(usageRecords, totalQuotaUsed);
Assert.assertNotNull(result);
Assert.assertEquals(1, result.size());
Assert.assertEquals(BigDecimal.valueOf(10), result.get(0).getQuotaConsumed());
}
@Test
public void createQuotaConsumptionHistoryTestCorrectlyAggregatesRecords() {
List<QuotaUsageJoinVO> usageRecords = new ArrayList<>();
Date now = new Date();
QuotaUsageJoinVO record1 = new QuotaUsageJoinVO();
record1.setStartDate(now);
record1.setEndDate(new Date(now.getTime() + 1000));
record1.setQuotaUsed(BigDecimal.valueOf(5));
QuotaUsageJoinVO record2 = new QuotaUsageJoinVO();
record2.setStartDate(new Date(now.getTime() + 2000));
record2.setEndDate(new Date(now.getTime() + 3000));
record2.setQuotaUsed(BigDecimal.valueOf(15));
QuotaUsageJoinVO record3 = new QuotaUsageJoinVO();
record3.setStartDate(new Date(now.getTime() + 2000));
record3.setEndDate(new Date(now.getTime() + 3000));
record3.setQuotaUsed(BigDecimal.valueOf(5));
usageRecords.add(record1);
usageRecords.add(record2);
usageRecords.add(record3);
BigDecimal totalQuotaUsed = BigDecimal.valueOf(20);
List<QuotaStatementItemHistoryResponse> result = quotaResponseBuilderSpy.createQuotaConsumptionHistory(usageRecords, totalQuotaUsed);
Assert.assertNotNull(result);
Assert.assertEquals(2, result.size());
QuotaStatementItemHistoryResponse firstHistory = result.get(0);
QuotaStatementItemHistoryResponse secondHistory = result.get(1);
Assert.assertEquals(BigDecimal.valueOf(5), firstHistory.getQuotaConsumed());
Assert.assertEquals(record1.getStartDate(), firstHistory.getStartDate());
Assert.assertEquals(record1.getEndDate(), firstHistory.getEndDate());
Assert.assertEquals(BigDecimal.valueOf(20), secondHistory.getQuotaConsumed());
Assert.assertEquals(record2.getStartDate(), secondHistory.getStartDate());
Assert.assertEquals(record2.getEndDate(), secondHistory.getEndDate());
}
}

View File

@ -3947,7 +3947,7 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M
if (getActiveAccountById(accountId) != null) {
return accountId;
}
throw new InvalidParameterValueException(String.format("Unable to find account with the specified ID."));
throw new InvalidParameterValueException("Unable to find account with the specified ID.");
}
if (accountName == null && domainId == null) {

1249
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -65,6 +65,7 @@
"vue": "^3.2.31",
"vue-chartjs": "^4.0.7",
"vue-clipboard2": "^0.3.1",
"vue-code-highlight": "^0.7.8",
"vue-cropper": "^1.0.2",
"vue-i18n": "^9.1.6",
"vue-loader": "^16.8.3",

View File

@ -253,6 +253,7 @@
"label.action.vmstoragesnapshot.create": "Take Instance volume Snapshot",
"label.actions": "Actions",
"label.active": "Active",
"label.activeaccounts": "Active accounts",
"label.activate.project": "Activate project",
"label.activeviewersessions": "Active sessions",
"label.add": "Add",
@ -657,6 +658,7 @@
"label.consoleproxy": "Console proxy",
"label.console.proxy": "Console proxy",
"label.console.proxy.vm": "Console proxy VM",
"label.consumption": "Consumption",
"label.contains": "Contains",
"label.continue": "Continue",
"label.continue.install": "Continue with installation",
@ -725,6 +727,8 @@
"label.creating": "Creating",
"label.creating.iprange": "Creating IP ranges",
"label.credit": "Credit",
"label.credits": "Credits",
"label.creditor": "Creditor",
"label.cron": "Cron expression",
"label.cron.mode": "Cron mode",
"label.crosszones": "Cross Zones",
@ -1011,6 +1015,7 @@
"label.egressdefaultpolicy": "Default egress policy",
"label.elastic": "Elastic",
"label.email": "Email",
"label.emailtemplate": "Email Template",
"label.enable.autoscale.vmgroup": "Enable AutoScaling Group",
"label.enable.csi": "Enable CloudStack CSI Driver",
"label.enable.custom.action": "Enable Custom Action",
@ -1082,6 +1087,9 @@
"label.expungevmgraceperiod": "Expunge Instance grace period (in sec)",
"label.expunged": "Expunged",
"label.expunging": "Expunging",
"label.export.data.csv": "Export data as CSV",
"label.export.details.csv": "Export details as CSV",
"label.export.resources.csv": "Export resources as CSV",
"label.export.rules": "Export Rules",
"label.ext.hostname.tooltip": "External Host Name or IP Address",
"label.extension": "Extension",
@ -2020,23 +2028,38 @@
"label.quota": "Quota",
"label.quota.add.credits": "Add credits",
"label.quota.configuration": "Quota configuration",
"label.quota.credits": "Credits",
"label.quota.consumed": "Quota consumed",
"label.quota.current.balance": "Current balance",
"label.quota.email.edit": "Edit Email Template",
"label.quota.enforce": "Enforce Quota",
"label.quota.filter.period": "Filtering data from <b>{startDate}</b> to <b>{endDate}</b>",
"label.quota.last.balance": "Last balance at the day",
"label.quota.period.today": "Today",
"label.quota.period.this.week": "This week",
"label.quota.period.this.month": "This month",
"label.quota.period.last.month": "Last month",
"label.quota.period.this.year": "This year",
"label.quota.period.last.year": "Last year",
"label.quota.period.custom": "Custom",
"label.quota.select.period": "Select a period",
"label.quota.statement": "Statement",
"label.quota.statement.balance": "Quota balance",
"label.quota.statement.quota": "Quota usage",
"label.quota.statement.history": "History (non-cumulative)",
"label.quota.statement.cumulative.history": "History (cumulative)",
"label.quota.statement.tariff": "Quota tariff",
"label.quota.summary": "Summary",
"label.quota.tariff": "Tariff",
"label.quota.tariff.activationrule": "Activation rule",
"label.quota.tariff.effectivedate": "Effective date",
"label.quota.tariff.hasactivationrule": "Has activation rule?",
"label.quota.tariff.position": "Position",
"label.quota.tariff.value": "Tariff value",
"label.quota.total": "Total",
"label.quota.total.consumption": "Total quota consumption",
"label.quota.type.name": "Usage Type",
"label.quota.type.unit": "Usage unit",
"label.quota.usage": "Quota consumption",
"label.quota.usage.types.summary": "Usage types summary",
"label.quota.usage.resources.by.type": "Resources by usage type",
"label.quota.usage.details.by.resource": "Details by resource",
"label.quota.validate.activation.rule": "Validate activation rule",
"label.quota.value": "Quota value",
"label.quotastate": "Quota state",
@ -2130,6 +2153,7 @@
"label.remove.vpc": "Remove VPC",
"label.remove.vpc.offering": "Remove VPC Offering",
"label.removed": "Removed",
"label.removedaccounts": "Removed Accounts",
"label.removing": "Removing",
"label.replace": "Replace",
"label.replace.acl": "Replace ACL",
@ -3036,6 +3060,10 @@
"message.action.primary.storage.scope.cluster": "Please confirm that you want to change the scope from Zone to the specified Cluster.<br>This operation will update the database and disconnect the storage pool from all hosts that were previously connected to the primary storage and are not part of the specified cluster.",
"message.action.primary.storage.scope.zone": "Please confirm that you want to change the scope from Cluster to Zone.<br>This operation will update the database and connect the storage pool to all hosts of the zone running the same hypervisor as set on the storage pool.",
"message.action.primarystorage.enable.maintenance.mode": "Warning: placing the primary storage into maintenance mode will cause all Instances using volumes from it to be stopped. Do you want to continue?",
"message.action.quota.credit.add.error.accountrequired": "Please, inform the account",
"message.action.quota.credit.add.error.domainidrequired": "Please, inform the domain",
"message.action.quota.credit.add.error.valuerequired": "Please, inform the amount of credits",
"message.action.quota.credit.add.success": "Successfully added {credit} credits to account \"{account}\"",
"message.action.quota.tariff.create.error.namerequired": "Please, inform a name for the quota tariff.",
"message.action.quota.tariff.create.error.usagetyperequired": "Please, select the usage type of the quota tariff.",
"message.action.quota.tariff.create.error.valuerequired": "Please, inform a value for the quota tariff.",
@ -3778,6 +3806,7 @@
"message.public.traffic.in.basic.zone": "Public traffic is generated when Instances in the cloud access the Internet or provide services to clients over the Internet. Publicly accessible IPs must be allocated for this purpose. When a Instance is created, an IP from this set of Public IPs will be allocated to the Instance in addition to the guest IP address. Static 1-1 NAT will be set up automatically between the public IP and the guest IP. End Users can also use the CloudStack UI to acquire additional IPs to implement static NAT between their Instances and the public IP.",
"message.quota.tariff.create.success": "Successfully created quota tariff \"{quotaTariff}\"",
"message.quota.tariff.update.success": "Successfully updated quota tariff \"{quotaTariff}\"",
"message.quota.usage.resource.warn": "Resources that are tagged as <untraceable> do not have constant metadata (if removed, the data is deleted) and cannot be retrieved.",
"message.read.accept.license.agreements": "Please read and accept the terms for the license agreements.",
"message.read.admin.guide.scaling.up": "Please read the dynamic scaling section in the admin guide before scaling up.",
"message.recover.vm": "Please confirm that you would like to recover this Instance.",
@ -3812,6 +3841,7 @@
"message.remove.sticky.policy.processing": "Removing sticky policy...",
"message.remove.vpc": "Please confirm that you want to remove the VPC",
"message.request.failed": "Request failed.",
"message.request.no.data": "There is no data to show.",
"message.required.add.least.ip": "Please add at least 1 IP Range",
"message.required.traffic.type": "All required traffic types should be added and with multiple physical networks each traffic type should have a label.",
"message.required.tagged.physical.network": "There can only be one untagged physical network with guest traffic type.",
@ -4182,6 +4212,8 @@
"migrate.from": "Migrate from",
"migrate.to": "Migrate to",
"migrationPolicy": "Migration policy",
"placeholder.quota.credit.add.min_balance": "Account's minimum balance",
"placeholder.quota.credit.add.value": "Amount of credits",
"placeholder.quota.tariff.activationrule": "Quota tariff's activation rule",
"placeholder.quota.tariff.description": "Quota tariff's description",
"placeholder.quota.tariff.enddate": "Quota tariff's end date",

View File

@ -233,6 +233,7 @@
"label.action.vmstoragesnapshot.create": "Criar snapshot de volume da VM",
"label.actions": "A\u00e7\u00f5es",
"label.active": "Ativo",
"label.activeaccounts": "Contas ativas",
"label.activate.project": "Ativar projeto",
"label.activeviewersessions": "Sess\u00f5es ativas",
"label.add": "Adicionar",
@ -596,6 +597,7 @@
"label.consoleproxy": "Console proxy",
"label.console.proxy": "Console proxy",
"label.console.proxy.vm": "VM da console proxy",
"label.consumption": "Consumo",
"label.continue": "Continuar",
"label.continue.install": "Continuar com a instala\u00e7\u00e3o",
"label.controlnodes": "Controlar nodos",
@ -657,6 +659,8 @@
"label.creating": "Criando",
"label.creating.iprange": "Criando intervalos de IP",
"label.credit": "Cr\u00e9dito",
"label.credits": "Cr\u00e9ditos",
"label.creditor": "Credor",
"label.cron": "Express\u00e3o Cron",
"label.cron.mode": "Modo Cron",
"label.crosszones": "Inter zonas",
@ -927,7 +931,8 @@
"label.egress.rules": "Regras de sa\u00edda",
"label.egressdefaultpolicy": "Pol\u00edtica padr\u00e3o de egress\u00e3o",
"label.elastic": "El\u00e1stico",
"label.email": "Email",
"label.email": "E-mail",
"label.emailtemplate": "Template de e-mail",
"label.enable.host": "Habilita host",
"label.enable.network.offering": "Habilita oferta de rede",
"label.enable.oauth": "Ativar Login OAuth",
@ -1476,7 +1481,7 @@
"label.migrate.instance.single.storage": "Migrar todo(s) o(s) volume(s) da Inst\u00e2ncia para um \u00fanico armazenamento prim\u00e1rio",
"label.migrate.instance.specific.storages": "Migrar volume(s) da Inst\u00e2ncia para armazenamentos prim\u00e1rios espec\u00edficos",
"label.migrate.with.storage": "Migrar com armazenamento",
"label.min_balance": "Saldo m\u00edn",
"label.min_balance": "Saldo m\u00ednimo",
"label.minimumsemanticversion": "Vers\u00e3o sem\u00e2ntica m\u00ednima",
"label.minmembers": "M\u00edn membros",
"label.minorsequence": "Sequ\u00eancia Menor",
@ -1820,27 +1825,43 @@
"label.quota": "Cota",
"label.quota.add.credits": "Adicionar cr\u00e9ditos",
"label.quota.configuration": "Configura\u00e7\u00e3o da cota",
"label.quota.credits": "Cr\u00e9ditos",
"label.quota.email.edit": "Editar template de email",
"label.quota.consumed": "Cota consumida",
"label.quota.current.balance": "Saldo atual",
"label.quota.email.edit": "Editar template de e-mail",
"label.quota.filter.period": "Filtrando dados entre <b>{startDate}</b> e <b>{endDate}</b>",
"label.quota.last.balance": "Hor\u00e1rio do \u00faltimo balan\u00e7o do dia",
"label.quota.period.today": "Hoje",
"label.quota.period.this.week": "Essa semana",
"label.quota.period.this.month": "Esse m\u00eas",
"label.quota.period.last.month": "M\u00eas passado",
"label.quota.period.this.year": "Esse ano",
"label.quota.period.last.year": "Ano passado",
"label.quota.period.custom": "Personalizado",
"label.quota.enforce": "Impor cota",
"label.quota.select.period": "Selecione um per\u00edodo",
"label.quota.statement": "Demonstrativo",
"label.quota.statement.balance": "Saldo",
"label.quota.statement.quota": "Utiliza\u00e7\u00e3o",
"label.quota.statement.history": "Hist\u00f3rico (n\u00e3o-cumulativo)",
"label.quota.statement.cumulative.history": "Hist\u00f3rico (cumulativo)",
"label.quota.statement.tariff": "Tarifa",
"label.quota.summary": "Relat\u00f3rios",
"label.quotastate": "Estado da cota",
"label.quotastate": "Estado do Quota",
"label.quota_enforce": "Impor Cota",
"label.summary": "Sum\u00e1rio",
"label.quota.tariff": "Tarifa",
"label.quota.tariff.activationrule": "Regra de ativa\u00e7\u00e3o",
"label.quota.tariff.effectivedate": "Data efetiva",
"label.quota.tariff.hasactivationrule": "Possui regra de ativa\u00e7\u00e3o?",
"label.quota.tariff.position": "Posi\u00e7\u00e3o",
"label.quota.tariff.value": "Valor",
"label.quota.total": "Total",
"label.quota.total.consumption": "Consumo total",
"label.quota.type.name": "Tipo de uso",
"label.quota.type.unit": "Unidade do uso",
"label.action.update.object.storage" : "Atualizar Object Storage",
"label.quota.usage": "Consumo da cota",
"label.quota.usage.types.summary": "Sum\u00e1rio dos tipos",
"label.quota.usage.resources.by.type": "Recursos por tipo",
"label.quota.usage.details.by.resource": "Detalhes por recurso",
"label.quota.validate.activation.rule": "Validar regra de ativa\u00e7\u00e3o",
"label.quota.value": "Valor",
"label.rados.monitor": "Monitor RADOS",
@ -2278,14 +2299,12 @@
"label.tagged": "Etiquetado",
"label.tags": "Etiquetas",
"label.target.iqn": "IQN alvo",
"label.tariffactions": "A\u00e7\u00f5es",
"label.tariffvalue": "Valor da tarifa",
"label.tcp": "TCP",
"label.tcp.proxy": "TCP proxy",
"label.template": "Template",
"label.template.select.existing": "Selecione um template existente",
"label.template.temporary.import": "Utilize um template tempor\u00e1rio para importar",
"label.templatebody": "Corpo do email",
"label.templatebody": "Corpo do e-mail",
"label.templatefileupload": "Arquivo local",
"label.templateid": "Selecione um template",
"label.templateiso": "Template/ISO",
@ -2728,6 +2747,10 @@
"message.action.primary.storage.scope.cluster": "Por favor, confirme que voc\u00ea deseja alterar o escopo de zona para o cluster especificado.<br>Esta opera\u00e7\u00e3o atualizar\u00e1 o banco de dados e desconectar\u00e1 o pool de armazenamento de todos os hosts que estavam conectados anteriormente ao armazenamento prim\u00e1rio e n\u00e3o fazem parte do cluster especificado.",
"message.action.primary.storage.scope.zone": "Por favor, confirme que voc\u00ea deseja alterar o escopo de cluster para zona.<br>Esta opera\u00e7\u00e3o atualizar\u00e1 o banco de dados e conectar\u00e1 o pool de armazenamento a todos os hosts da zona executando o mesmo hypervisor definido no pool de armazenamento.",
"message.action.primarystorage.enable.maintenance.mode": "Aviso: colocar o armazenamento prim\u00e1rio em modo de manuten\u00e7\u00e3o ir\u00e1 causar a parada de todas as VMs hospedadas nesta unidade. Deseja continuar?",
"message.action.quota.credit.add.error.accountrequired": "Por favor, informe a conta",
"message.action.quota.credit.add.error.domainidrequired": "Por favor, informe o dom\u00ednio",
"message.action.quota.credit.add.error.valuerequired": "Por favor, informe a quantidade de cr\u00e9ditos",
"message.action.quota.credit.add.success": "{credit} cr\u00e9ditos adicionados para a conta \"{account}\"",
"message.action.quota.tariff.create.error.namerequired": "Por favor, informe o nome da tarifa.",
"message.action.quota.tariff.create.error.usagetyperequired": "Por favor, selecione o tipo da tarifa.",
"message.action.quota.tariff.create.error.valuerequired": "Por favor, informe o valor da tarifa.",
@ -3384,6 +3407,7 @@
"message.public.traffic.in.basic.zone": "O tr\u00e1fego p\u00fablico \u00e9 gerado quando as VMs na nuvem acessam a internet ou prestam servi\u00e7os aos clientes atrav\u00e9s da internet. Os IPs acess\u00edveis ao p\u00fablico devem ser alocados para essa finalidade. Quando uma inst\u00e2ncia \u00e9 criada, um IP a partir deste conjunto de IPs P\u00fablicos ser\u00e3o destinados \u00e0 inst\u00e2ncia, al\u00e9m do endere\u00e7o IP guest. Um NAT est\u00e1tico 1-1 ser\u00e1 criada automaticamente entre o IP p\u00fablico e IP guest. Os usu\u00e1rios finais tamb\u00e9m podem usar a interface de usu\u00e1rio CloudStack para adquirir IPs adicionais afim de se implementar NAT est\u00e1tico entre suas inst\u00e2ncias e o IP p\u00fablico.",
"message.quota.tariff.create.success": "Tarifa \"{quotaTariff}\" criada com sucesso",
"message.quota.tariff.update.success": "Tarifa \"{quotaTariff}\" atualizada com sucesso",
"message.quota.usage.resource.warn": "Recursos que s\u00e3o marcados como <untraceable> n\u00e3o possuem metadados constantes (se removido, o dado \u00e9 completamente deletado) e n\u00e3o podem ser retornados.",
"message.read.accept.license.agreements": "Leia e aceite os termos dos contratos de licen\u00e7a.",
"message.read.admin.guide.scaling.up": "Por favor leia a sess\u00e3o sobre escalonamento din\u00e2mico no guia do administrador antes de escalonar.",
"message.recover.vm": "Por favor, confirme a recupera\u00e7\u00e3o desta VM.",
@ -3417,6 +3441,7 @@
"message.remove.sticky.policy.processing": "Removendo sticky policy",
"message.remove.vpc": "Favor confirmar que voc\u00ea deseja remover a VPC",
"message.request.failed": "Falha na solicita\u00e7\u00e3o",
"message.request.no.data": "N\u00e3o h\u00e1 dados para mostrar.",
"message.required.add.least.ip": "Por favor, adicionar pelo menos UM intervalo IP",
"message.required.tagged.physical.network": "S\u00f3 pode haver uma rede f\u00edsica n\u00e3o marcada com tipo de tr\u00e1fego convidado.",
"message.required.traffic.type": "Erro na configura\u00e7\u00e3o! Todos os tipos de tr\u00e1fego necess\u00e1rios devem ser adicionados e com m\u00faltiplas redes f\u00edsicas cada rede deve ter uma etiqueta.",
@ -3749,6 +3774,8 @@
"migrate.from": "Migrar de",
"migrate.to": "Migrar para",
"migrationPolicy": "Pol\u00edtica de migra\u00e7\u00e3o",
"placeholder.quota.credit.add.min_balance": "Saldo m\u00ednimo",
"placeholder.quota.credit.add.value": "Quantidade de cr\u00e9ditos",
"placeholder.quota.tariff.activationrule": "Regra de ativa\u00e7\u00e3o",
"placeholder.quota.tariff.description": "Descri\u00e7\u00e3o",
"placeholder.quota.tariff.enddate": "Data de t\u00e9rmino",

View File

@ -33,6 +33,7 @@ const additionalGetAPICommandsList = [
'quotatarifflist',
'quotaisenabled',
'quotastatement',
'quotaemailtemplatelist',
'verifyoauthcodeandgetuser'
]

View File

@ -100,6 +100,9 @@
<div v-else-if="['created', 'sent', 'lastannotated', 'collectiontime', 'lastboottime', 'lastserverstart', 'lastserverstop', 'removed', 'effectiveDate', 'endDate'].includes(item)">
{{ $toLocaleDate(dataResource[item]) }}
</div>
<code-highlight v-else-if="['activationRule'].includes(item)" language="javascript">
{{ dataResource[item] }}
</code-highlight>
<div style="white-space: pre-wrap;" v-else-if="$route.meta.name === 'quotatariff' && item === 'description'">{{ dataResource[item] }}</div>
<div v-else-if="$route.meta.name === 'userdata' && item === 'userdata'">
<div style="white-space: pre-wrap;"> {{ decodeUserData(dataResource.userdata)}} </div>
@ -224,6 +227,8 @@ import VmwareData from './VmwareData'
import ObjectListTable from '@/components/view/ObjectListTable'
import ExternalConfigurationDetails from '@/views/extension/ExternalConfigurationDetails'
import { genericCompare } from '@/utils/sort'
import CodeHighlight from 'vue-code-highlight/src/CodeHighlight.vue'
import 'vue-code-highlight/themes/prism-okaidia.css'
export default {
name: 'DetailsTab',
@ -232,7 +237,8 @@ export default {
HostInfo,
VmwareData,
ObjectListTable,
ExternalConfigurationDetails
ExternalConfigurationDetails,
CodeHighlight
},
props: {
resource: {

View File

@ -482,7 +482,7 @@
</span>
<project-outlined v-else />
<router-link v-if="!isStatic && resource.projectid" :to="{ path: '/project/' + resource.projectid }">{{ resource.project || resource.projectname || resource.projectid }}</router-link>
<router-link v-else :to="{ path: '/project', query: { name: resource.projectname }}">{{ resource.projectname }}</router-link>
<span v-else>{{ resource.projectname || resource.projectid }}</span>
</div>
</div>
@ -813,6 +813,18 @@
<span v-else>{{ resource.domain || resource.domainid }}</span>
</div>
</div>
<div class="resource-detail-item" v-if="resource.currency">
<div class="resource-detail-item__label">{{ $t('label.currency') }}</div>
<div class="resource-detail-item__details">
<span>{{ resource.currency }}</span>
</div>
</div>
<div class="resource-detail-item" v-if="resource.balance">
<div class="resource-detail-item__label">{{ $t('label.quota.current.balance') }}</div>
<div class="resource-detail-item__details">
<span>{{ resource.balance }}</span>
</div>
</div>
<div class="resource-detail-item" v-if="resource.payloadurl">
<div class="resource-detail-item__label">{{ $t('label.payloadurl') }}</div>
<div class="resource-detail-item__details">

View File

@ -205,7 +205,7 @@
</span>
</template>
<template v-if="column.key === 'templatetype'">
<span>{{ text }}</span>
<router-link :to="{ path: $route.path + '/' + record.templatetype }">{{ text }}</router-link>
</template>
<template v-if="column.key === 'gpu'">
<span v-if="record.gpucardname && record.vgpuprofilename">
@ -674,20 +674,19 @@
</span>
</template>
</template>
<template v-if="text && !text.startsWith('PrjAcct-')">
<router-link
v-if="'quota' in record && $router.resolve(`${$route.path}/${record.account}`).matched[0].redirect !== '/exception/404'"
:to="{ path: `${$route.path}/${record.account}`, query: { account: record.account, domainid: record.domainid, quota: true } }"
>{{ text }}</router-link>
<router-link
:to="{ path: '/account/' + record.accountid }"
v-else-if="record.accountid"
>{{ text }}</router-link>
<router-link
:to="{ path: '/account', query: { name: record.account, domainid: record.domainid, dataView: true } }"
v-else-if="$store.getters.userInfo.roletype !== 'User'"
>{{ text }}</router-link>
<span v-else>{{ text }}</span>
<template v-if="text">
<template v-if="!text.startsWith('PrjAcct-')">
<router-link
v-if="$route.path.startsWith('/quotasummary') && $router.resolve(`${$route.path}/${record.accountid}`) !== '404'"
:to="{ path: `${$route.path}/${record.accountid}` }">{{ text }}</router-link>
<span v-else>{{ text }}</span>
</template>
<template v-else>
<router-link
v-if="$route.path.startsWith('/quotasummary') && $router.resolve(`${$route.path}/${record.accountid}`) !== '404'"
:to="{ path: `${$route.path}/${record.accountid}` }">{{ (record.projectname || record.account).concat(' (').concat($t('label.project')).concat(')') }}</router-link>
<span v-else>{{ text }}</span>
</template>
</template>
</template>
<template v-if="column.key === 'resource'">
@ -1027,16 +1026,6 @@
/>
<slot></slot>
</template>
<template v-if="column.key === 'tariffActions'">
<tooltip-button
:tooltip="$t('label.edit')"
v-if="editableValueKey !== record.key"
:disabled="!('quotaTariffUpdate' in $store.getters.apis)"
icon="edit-outlined"
@onClick="editTariffValue(record)"
/>
<slot></slot>
</template>
<template v-if="column.key === 'vmScheduleActions'">
<tooltip-button
:tooltip="$t('label.edit')"
@ -1227,8 +1216,8 @@ export default {
'vmsnapshot', 'backup', 'guestnetwork', 'vpc', 'publicip', 'vpnuser', 'vpncustomergateway', 'vnfapp',
'project', 'account', 'systemvm', 'router', 'computeoffering', 'systemoffering',
'diskoffering', 'backupoffering', 'networkoffering', 'vpcoffering', 'ilbvm', 'kubernetes', 'comment', 'buckets',
'webhook', 'webhookdeliveries', 'sharedfs', 'ipv4subnets', 'asnumbers', 'guestos', 'gpucard', 'gpudevices', 'vgpuprofile'
].includes(this.$route.name)
'webhook', 'webhookdeliveries', 'sharedfs', 'ipv4subnets', 'asnumbers', 'guestos', 'gpucard', 'gpudevices', 'vgpuprofile',
'quotatariff'].includes(this.$route.name)
},
getDateAtTimeZone (date, timezone) {
return date ? moment(date).tz(timezone).format('YYYY-MM-DD HH:mm:ss') : null
@ -1394,9 +1383,6 @@ export default {
data.push(data.splice(index, 1)[0])
this.updateOrder(data)
},
editTariffValue (record) {
this.$emit('edit-tariff-action', true, record)
},
updateVMSchedule (record) {
this.$emit('update-vm-schedule', record)
},

View File

@ -0,0 +1,43 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<a-button type="dashed" @click="action" class="w-100 mb-10 mt-10">
<download-outlined />
{{ $t(label) }}
</a-button>
</template>
<script>
export default {
name: 'ExportToCsvButton',
props: {
action: {
type: Function,
required: true
},
label: {
type: String,
default: 'label.export.data.csv'
}
}
}
</script>
<style lang="scss" scoped>
@import '@/style/common/common.scss';
</style>

View File

@ -0,0 +1,56 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<bar
:chart-options="chartOptions"
:chart-data="chartData"
:width="chartWidth"
:height="chartHeight"
/>
</template>
<script>
import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, TimeScale, LinearScale } from 'chart.js'
import 'chartjs-adapter-moment'
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, TimeScale, LinearScale)
export default {
name: 'BarChart',
components: { Bar },
props: {
chartData: {
type: Object,
required: true
},
chartOptions: {
type: Object,
required: true
},
chartWidth: {
type: Number,
default: 1024
},
chartHeight: {
type: Number,
default: 300
}
}
}
</script>

View File

@ -217,7 +217,7 @@ export default {
data: element.data.map(d => d.stat),
hidden: this.hideLine(element.data.map(d => d.stat)),
pointRadius: element.pointRadius,
fill: 'origin'
fill: element.fill || 'origin'
}
)
}

View File

@ -30,29 +30,37 @@ export default {
title: 'label.quota.summary',
icon: 'bars-outlined',
permission: ['quotaSummary'],
columns: ['account',
{
state: (record) => record.state.toLowerCase()
},
{
quotastate: (record) => record.quotaenabled ? 'Enabled' : 'Disabled'
}, 'domain', 'currency', 'balance'
],
columnNames: ['account', 'accountstate', 'quotastate', 'domain', 'currency', 'currentbalance'],
details: ['account', 'domain', 'state', 'currency', 'balance', 'quota', 'startdate', 'enddate'],
component: shallowRef(() => import('@/views/plugins/quota/QuotaSummary.vue')),
tabs: [
{
name: 'details',
component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue')))
name: 'consumption',
component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaUsageTab.vue')))
},
{
name: 'quota.statement.quota',
component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaUsage.vue')))
name: 'balance',
component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaBalanceTab.vue')))
},
{
name: 'quota.statement.balance',
component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaBalance.vue')))
name: 'credits',
component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaCreditTab.vue')))
}
],
columns: [
'account',
{
field: 'state',
customTitle: 'accountState',
state: (record) => record.accountremoved || (record.projectid && record.projectremoved) ? 'disabled' : 'enabled'
},
{
field: 'quotastate',
customTitle: 'quotaState',
quotastate: (record) => !record.quotaenabled || record.accountremoved || (record.projectid && record.projectremoved) ? 'disabled' : 'enabled'
},
'domain',
'currency',
{
field: 'balance',
customTitle: 'quota.current.balance'
}
],
actions: [
@ -61,16 +69,9 @@ export default {
icon: 'plus-outlined',
docHelp: 'plugins/quota.html#quota-credits',
label: 'label.quota.add.credits',
dataView: true,
args: ['value', 'min_balance', 'quota_enforce'],
mapping: {
account: {
value: (record) => { return record.account }
},
domainid: {
value: (record) => { return record.domainid }
}
}
listView: true,
popup: true,
component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/AddQuotaCredit.vue')))
}
]
},
@ -83,7 +84,7 @@ export default {
customParamHandler: (params, query) => {
params.listall = false
if (['all', 'removed'].includes(query.filter) || params.id) {
if (['all', 'removed'].includes(query.filter) || params.uuid) {
params.listall = true
}
@ -109,6 +110,11 @@ export default {
field: 'tariffValue',
customTitle: 'quota.tariff.value'
},
{
field: 'hasActivationRule',
customTitle: 'quota.tariff.hasactivationrule',
hasActivationRule: (record) => record.activationRule ? i18n.global.t('label.yes') : i18n.global.t('label.no')
},
{
field: 'executionPosition',
customTitle: 'quota.tariff.position',
@ -145,7 +151,11 @@ export default {
field: 'endDate',
customTitle: 'end.date'
},
'removed'
'removed',
{
field: 'activationRule',
customTitle: 'quota.tariff.activationrule'
}
],
filters: ['all', 'active', 'removed'],
searchFilters: ['usagetype'],
@ -173,13 +183,16 @@ export default {
label: 'label.action.quota.tariff.remove',
message: 'message.action.quota.tariff.remove',
dataView: true,
show: (record) => !record.removed
show: (record) => !record.removed,
groupAction: true,
popup: true,
groupMap: (selection) => { return selection.map(x => { return { id: x } }) }
}
]
},
{
name: 'quotaemailtemplate',
title: 'label.templatetype',
title: 'label.emailtemplate',
icon: 'mail-outlined',
permission: ['quotaEmailTemplateList'],
columns: ['templatetype', 'templatesubject', 'templatebody'],

View File

@ -0,0 +1,37 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
.w-100 {
width: 100%;
}
.mt-10 {
margin-top: 10px;
}
.mb-10 {
margin-bottom: 10px;
}
.m-20-0 {
margin: 20px 0;
}
.dotted-underline {
text-decoration: underline dotted;
cursor: default;
}

70
ui/src/utils/chart.js Normal file
View File

@ -0,0 +1,70 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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 { TIME_UNITS } from './units'
export const defaultDisplayFormats = {
day: 'DD MMM YYYY',
week: 'DD MMM YYYY',
month: 'MMM YYYY',
quarter: 'MMM YYYY',
year: 'YYYY'
}
export const getUnitToTimeCartesianAxis = (baseUnit, dataLength) => {
const maxLabels = 15
if (dataLength <= maxLabels) {
return baseUnit
}
const units = [
'millisecond',
'second',
'minute',
'hour',
'day',
'week',
'month',
'quarter',
'year'
]
let index = units.indexOf(baseUnit)
let unitToReturn = baseUnit
if (index >= 0 && index < units.length) {
let unitTime = 0
for (index; index < units.length; index++) {
unitTime = TIME_UNITS[units[index]]
const nextUnitTime = TIME_UNITS[units[index + 1]]
if ((dataLength / (nextUnitTime / unitTime)) <= maxLabels) {
return units[index + 1]
}
unitToReturn = units[index]
}
}
return unitToReturn
}
export const getChartColorObject = (hexColor = '#1890FF') => ({
backgroundColor: hexColor.concat('80'),
borderColor: hexColor,
borderWidth: 1.5
})

View File

@ -65,16 +65,21 @@ export function parseDateToDatePicker (value) {
}
export function toLocalDate ({ date, timezoneoffset = store.getters.timezoneoffset, usebrowsertimezone = store.getters.usebrowsertimezone }) {
if (usebrowsertimezone) {
// Since GMT+530 is returned as -330 (minutes to GMT)
timezoneoffset = new Date().getTimezoneOffset() / -60
}
timezoneoffset = getTimezoneOffset({ timezoneoffset, usebrowsertimezone })
const milliseconds = Date.parse(date)
// e.g. "Tue, 08 Jun 2010 19:13:49 GMT"; "Tue, 25 May 2010 12:07:01 UTC"
return new Date(milliseconds + (timezoneoffset * 60 * 60 * 1000))
}
export function getTimezoneOffset ({ timezoneoffset = store.getters.timezoneoffset, usebrowsertimezone = store.getters.usebrowsertimezone }) {
if (!usebrowsertimezone) {
return timezoneoffset
}
// Since GMT+530 is returned as -330 (mins to GMT)
return new Date().getTimezoneOffset() / -60
}
export function toLocaleDate ({ date, timezoneoffset = store.getters.timezoneoffset, usebrowsertimezone = store.getters.usebrowsertimezone, dateOnly = false, hourOnly = false }) {
if (!date) {
return null

55
ui/src/utils/export.js Normal file
View File

@ -0,0 +1,55 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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 dayjs from 'dayjs'
export function exportDataToCsv ({ data = null, keys = null, headers = null, columnDelimiter = ',', lineDelimiter = '\n', fileName = 'data', dateFormat = undefined }) {
if (data === null || !data.length || keys === null || !keys.filter(key => key !== null && key !== '').length) {
return null
}
let dataParsed = ''
dataParsed += (headers || keys).join(columnDelimiter)
dataParsed += lineDelimiter
data.forEach(item => {
keys.forEach(key => {
if (item[key] === undefined) {
item[key] = ''
}
if (typeof item[key] === 'string' && item[key].includes(columnDelimiter)) {
dataParsed += `"${item[key]}"`
} else if (dateFormat && item[key] instanceof dayjs) {
dataParsed += `"${item[key].format(dateFormat)}"`
} else {
dataParsed += item[key]
}
dataParsed += columnDelimiter
})
dataParsed = dataParsed.slice(0, -1)
dataParsed += lineDelimiter
})
const hiddenElement = document.createElement('a')
hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(dataParsed)
hiddenElement.target = '_blank'
hiddenElement.download = `${fileName}.csv`
hiddenElement.click()
hiddenElement.remove()
}

View File

@ -19,106 +19,135 @@
export const QUOTA_TYPES = [
{
id: 1,
type: 'RUNNING_VM'
type: 'RUNNING_VM',
chartColor: '#1890ff'
},
{
id: 2,
type: 'ALLOCATED_VM'
type: 'ALLOCATED_VM',
chartColor: '#fadb14'
},
{
id: 3,
type: 'IP_ADDRESS'
type: 'IP_ADDRESS',
chartColor: '#ffd6e7'
},
{
id: 4,
type: 'NETWORK_BYTES_SENT'
type: 'NETWORK_BYTES_SENT',
chartColor: '#adc6ff'
},
{
id: 5,
type: 'NETWORK_BYTES_RECEIVED'
type: 'NETWORK_BYTES_RECEIVED',
chartColor: '#10239e'
},
{
id: 6,
type: 'VOLUME'
type: 'VOLUME',
chartColor: '#722ed1'
},
{
id: 7,
type: 'TEMPLATE'
type: 'TEMPLATE',
chartColor: '#08979c'
},
{
id: 8,
type: 'ISO'
type: 'ISO',
chartColor: '#87e8de'
},
{
id: 9,
type: 'SNAPSHOT'
type: 'SNAPSHOT',
chartColor: '#f5222d'
},
{
id: 10,
type: 'SECURITY_GROUP'
type: 'SECURITY_GROUP',
chartColor: '#d46b08'
},
{
id: 11,
type: 'LOAD_BALANCER_POLICY'
type: 'LOAD_BALANCER_POLICY',
chartColor: '#ffd666'
},
{
id: 12,
type: 'PORT_FORWARDING_RULE'
type: 'PORT_FORWARDING_RULE',
chartColor: '#7cb305'
},
{
id: 13,
type: 'NETWORK_OFFERING'
type: 'NETWORK_OFFERING',
chartColor: '#ffbb96'
},
{
id: 14,
type: 'VPN_USERS'
type: 'VPN_USERS',
chartColor: '#95de64'
},
{
id: 21,
type: 'VM_DISK_IO_READ'
type: 'VM_DISK_IO_READ',
chartColor: '#ffe7ba'
},
{
id: 22,
type: 'VM_DISK_IO_WRITE'
type: 'VM_DISK_IO_WRITE',
chartColor: '#5b8c00'
},
{
id: 23,
type: 'VM_DISK_BYTES_READ'
type: 'VM_DISK_BYTES_READ',
chartColor: '#0050b3'
},
{
id: 24,
type: 'VM_DISK_BYTES_WRITE'
type: 'VM_DISK_BYTES_WRITE',
chartColor: '#520339'
},
{
id: 25,
type: 'VM_SNAPSHOT'
type: 'VM_SNAPSHOT',
chartColor: '#9e1068'
},
{
id: 26,
type: 'VOLUME_SECONDARY'
type: 'VOLUME_SECONDARY',
chartColor: '#061178'
},
{
id: 27,
type: 'VM_SNAPSHOT_ON_PRIMARY'
type: 'VM_SNAPSHOT_ON_PRIMARY',
chartColor: '#ad2102'
},
{
id: 28,
type: 'BACKUP'
type: 'BACKUP',
chartColor: '#00474f'
},
{
id: 29,
type: 'BUCKET'
type: 'BUCKET',
chartColor: '#13a8a8'
},
{
id: 30,
type: 'NETWORK'
type: 'NETWORK',
chartColor: '#c75314'
},
{
id: 31,
type: 'VPC'
type: 'VPC',
chartColor: '#018391'
}
]
export const getQuotaTypes = () => {
return QUOTA_TYPES.sort((a, b) => a.type.localeCompare(b.type))
}
export const getQuotaTypeByName = (type) => {
return QUOTA_TYPES.find(quotaType => quotaType.type === type)
}

28
ui/src/utils/units.js Normal file
View File

@ -0,0 +1,28 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
export const TIME_UNITS = {
millisecond: 1,
second: 1000,
minute: 60 * 1000,
hour: 60 * 60 * 1000,
day: 24 * 60 * 60 * 1000,
week: 7 * 24 * 60 * 60 * 1000,
month: 30 * 24 * 60 * 60 * 1000,
quarter: 91 * 24 * 60 * 60 * 1000,
year: 365 * 24 * 60 * 60 * 1000
}

View File

@ -554,7 +554,7 @@
<div v-if="dataView">
<slot
name="resource"
v-if="$route.path.startsWith('/quotasummary') || $route.path.startsWith('/publicip')"
v-if="$route.path.startsWith('/publicip')"
></slot>
<resource-view
v-else
@ -1063,7 +1063,7 @@ export default {
const customRender = {}
for (var columnKey of this.columnKeys) {
let key = columnKey
let title = columnKey === 'cidr' && this.columnKeys.includes('ip6cidr') ? 'ipv4.cidr' : columnKey
let title = columnKey === 'cidr' && this.columnKeys.includes('ip6cidr') ? 'ipv4.cidr' : key
if (typeof columnKey === 'object') {
if ('customTitle' in columnKey && 'field' in columnKey) {
key = columnKey.field
@ -1071,7 +1071,7 @@ export default {
customRender[key] = columnKey[key]
} else {
key = Object.keys(columnKey)[0]
title = Object.keys(columnKey)[0]
title = (typeof title === 'object') ? key : title
customRender[key] = columnKey[key]
}
}
@ -1116,6 +1116,11 @@ export default {
params.details = 'group,nics,secgrp,tmpl,servoff,diskoff,iso,volume,affgrp,backoff'
}
if (this.apiName === 'quotaTariffList' && !('quotaTariffCreate' in store.getters.apis || 'quotaTariffUpdate' in store.getters.apis)) {
const index = this.columns.findIndex(col => col.dataIndex === 'hasActivationRule')
this.columns.splice(index, 1)
}
this.loading = true
if (this.$route.path.startsWith('/cniconfiguration')) {
params.forcks = true
@ -1153,6 +1158,10 @@ export default {
if (this.$route.path.startsWith('/tungstenfirewallpolicy/')) {
params.firewallpolicyuuid = this.$route.params.id
}
if (this.apiName === 'quotaSummary' && params.id) {
params.accountid = params.id
delete params.id
}
}
if (this.$store.getters.listAllProjects && !this.projectView) {
@ -1199,7 +1208,7 @@ export default {
break
}
if ('id' in this.$route.params && this.$route.params.id !== params.id) {
if ('id' in this.$route.params && !(this.$route.params.id === params.id || this.apiName === 'quotaSummary' && this.$route.params.id === params.accountid)) {
console.log('DEBUG - Discarding API response as its `id` does not match the uuid on the browser path')
return
}

View File

@ -0,0 +1,168 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<a-spin :spinning="loading">
<a-form
class="form"
layout="vertical"
:ref="formRef"
:model="form"
:rules="rules"
@finish="handleSubmit"
v-ctrl-enter="handleSubmit">
<ownership-selection @fetch-owner="fetchOwnerOptions" />
<a-form-item ref="value" name="value">
<template #label>
<tooltip-label :title="$t('label.value')" :tooltip="apiParams.value.description"/>
</template>
<a-input-number
v-model:value="form.value"
:placeholder="$t('placeholder.quota.credit.add.value')" />
</a-form-item>
<a-form-item ref="min_balance" name="min_balance">
<template #label>
<tooltip-label :title="$t('label.min_balance')" :tooltip="apiParams.min_balance.description"/>
</template>
<a-input-number
v-model:value="form.min_balance"
:placeholder="$t('placeholder.quota.credit.add.min_balance')" />
</a-form-item>
<a-form-item ref="quota_enforce" name="quota_enforce">
<template #label>
<tooltip-label :title="$t('label.quota.enforce')" :tooltip="apiParams.quota_enforce.description"/>
</template>
<a-switch
v-model:checked="form.quota_enforce" />
</a-form-item>
<div :span="24" class="action-button">
<a-button @click="closeModal">{{ $t('label.cancel') }}</a-button>
<a-button type="primary" ref="submit" @click="handleSubmit">{{ $t('label.ok') }}</a-button>
</div>
</a-form>
</a-spin>
</template>
<script>
import { getAPI } from '@/api'
import OwnershipSelection from '@/views/compute/wizard/OwnershipSelection.vue'
import TooltipLabel from '@/components/widgets/TooltipLabel'
import { ref, reactive, toRaw } from 'vue'
import { mixinForm } from '@/utils/mixin'
import store from '@/store'
export default {
name: 'AddQuotaCredit',
mixins: [mixinForm],
components: {
OwnershipSelection,
TooltipLabel
},
data () {
return {
loading: false,
domainList: [],
accountList: [],
domainId: undefined,
domainLoading: false,
domainError: false,
owner: {
projectid: store.getters.project?.id,
domainid: store.getters.project?.id ? null : store.getters.userInfo.domainid,
account: store.getters.project?.id ? null : store.getters.userInfo.account,
name: store.getters.project?.id ? store.getters.project.name : store.getters.userInfo.account
}
}
},
inject: ['parentFetchData'],
beforeCreate () {
this.apiParams = this.$getApiParams('quotaCredits')
},
created () {
this.initForm()
console.log(store.getters.project)
console.log(store.getters.userInfo)
},
methods: {
initForm () {
this.formRef = ref()
this.form = reactive({})
this.rules = reactive({
domainid: [{ required: true, message: this.$t('message.action.quota.credit.add.error.domainidrequired') }],
accountid: [{ required: true, message: this.$t('message.action.quota.credit.add.error.accountrequired') }],
value: [{ required: true, message: this.$t('message.action.quota.credit.add.error.valuerequired') }]
})
},
handleSubmit (e) {
e.preventDefault()
if (this.loading) return
this.formRef.value.validate().then(() => {
const formRaw = toRaw(this.form)
const values = this.handleRemoveFields(formRaw)
values.ignoreproject = true
if (this.owner.projectid) {
values.projectid = this.owner.projectid
} else {
values.account = this.owner.account
values.domainid = this.owner.domainid
}
this.loading = true
getAPI('quotaCredits', values).then(response => {
this.$message.success(this.$t('message.action.quota.credit.add.success',
{ credit: response.quotacreditsresponse.quotacredits.credit, account: this.owner.name }))
this.parentFetchData()
this.closeModal()
}).catch(error => {
this.$notifyError(error)
}).finally(() => {
this.loading = false
})
}).catch((error) => {
this.formRef.value.scrollToField(error.errorFields[0].name)
})
},
closeModal () {
this.$emit('close-action')
},
fetchOwnerOptions (OwnerOptions) {
console.log(OwnerOptions)
this.owner = {}
if (OwnerOptions.selectedAccountType === 'Account') {
if (!OwnerOptions.selectedAccount) {
return
}
this.owner.account = OwnerOptions.selectedAccount
this.owner.domainid = OwnerOptions.selectedDomain
this.owner.name = OwnerOptions.selectedAccount
} else if (OwnerOptions.selectedAccountType === 'Project') {
if (!OwnerOptions.selectedProject) {
return
}
this.owner.projectid = OwnerOptions.selectedProject
this.owner.name = OwnerOptions.projects.find(p => p.id === OwnerOptions.selectedProject).name
}
}
}
}
</script>
<style lang="scss" scoped>
@import '@/style/objects/form.scss';
</style>

View File

@ -62,7 +62,6 @@
<tooltip-label :title="$t('label.quota.tariff.value')" :tooltip="apiParams.value.description"/>
</template>
<a-input-number
class="full-width-input"
v-model:value="form.value"
:placeholder="$t('placeholder.quota.tariff.value')" />
</a-form-item>
@ -85,7 +84,6 @@
<tooltip-label :title="$t('label.quota.tariff.position')" :tooltip="apiParams.position.description" />
</template>
<a-input-number
class="full-width-input"
v-model:value="form.position"
:placeholder="$t('placeholder.quota.tariff.position')" />
</a-form-item>
@ -94,7 +92,6 @@
<tooltip-label :title="$t('label.start.date')" :tooltip="apiParams.startdate.description"/>
</template>
<a-date-picker
class="full-width-input"
v-model:value="form.startDate"
:disabled-date="disabledStartDate"
:placeholder="$t('placeholder.quota.tariff.startdate')"

View File

@ -38,7 +38,6 @@
<tooltip-label :title="$t('label.quota.tariff.value')" :tooltip="apiParams.value.description"/>
</template>
<a-input-number
class="full-width-input"
v-model:value="form.value"
:placeholder="$t('placeholder.quota.tariff.value')" />
</a-form-item>
@ -61,7 +60,6 @@
<tooltip-label :title="$t('label.quota.tariff.position')" :tooltip="apiParams.position.description"/>
</template>
<a-input-number
class="full-width-input"
v-model:value="form.position"
:placeholder="$t('placeholder.quota.tariff.position')" />
</a-form-item>
@ -70,7 +68,6 @@
<tooltip-label :title="$t('label.end.date')" :tooltip="apiParams.enddate.description"/>
</template>
<a-date-picker
class="full-width-input"
v-model:value="form.endDate"
:disabled-date="disabledEndDate"
:placeholder="$t('placeholder.quota.tariff.enddate')"

View File

@ -1,145 +0,0 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<a-modal
v-if="showAction"
style="top: 20px;"
centered
:confirmLoading="loading"
:title="$t('label.quota.configuration')"
:closable="true"
:maskClosable="false"
:visible="showAction"
:footer="null"
@cancel="onClose"
>
<a-form
:ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
@finish="submitTariff"
v-ctrl-enter="submitTariff"
>
<a-form-item name="value" ref="value" :label="$t('label.quota.value')">
<a-input
v-focus="true"
v-model:value="form.value"></a-input>
</a-form-item>
<a-form-item name="startdate" ref="startdate" :label="$t('label.quota.tariff.effectivedate')">
<a-date-picker
:disabledDate="disabledDate"
style="width: 100%"
v-model:value="form.startdate"></a-date-picker>
</a-form-item>
<div :span="24" class="action-button">
<a-button @click="onClose">{{ $t('label.cancel') }}</a-button>
<a-button type="primary" ref="submit" @click="submitTariff">{{ $t('label.ok') }}</a-button>
</div>
</a-form>
</a-modal>
</template>
<script>
import { ref, reactive, toRaw } from 'vue'
import { postAPI } from '@/api'
import moment from 'moment'
export default {
name: 'EditTariffValueWizard',
props: {
showAction: {
type: Boolean,
default: () => false
},
resource: {
type: Object,
required: true
}
},
data () {
return {
loading: false,
pattern: 'YYYY-MM-DD'
}
},
inject: ['parentFetchData'],
created () {
this.initForm()
},
methods: {
initForm () {
this.formRef = ref()
this.form = reactive({
value: this.resource.tariffValue
})
this.rules = reactive({
value: [{ required: true, message: this.$t('message.error.required.input') }],
startdate: [{ type: 'object', required: true, message: this.$t('message.error.date') }]
})
},
onClose () {
this.$emit('edit-tariff-action', false)
},
submitTariff (e) {
e.preventDefault()
if (this.loading) return
this.formRef.value.validate().then(() => {
const values = toRaw(this.form)
const params = {}
params.usageType = this.resource.usageType
params.value = values.value
params.startdate = values.startdate.format(this.pattern)
this.loading = true
postAPI('quotaTariffUpdate', params).then(json => {
const tariffResponse = json.quotatariffupdateresponse.quotatariff || {}
if (Object.keys(tariffResponse).length > 0) {
const effectiveDate = moment(tariffResponse.effectiveDate).format(this.pattern)
const query = this.$route.query
if (query.startdate !== effectiveDate) {
this.$router.replace({ path: 'quotatariff', query: { startdate: effectiveDate } })
}
this.parentFetchData()
}
this.$message.success(`${this.$t('message.setting.updated')} ${this.resource.description}`)
this.onClose()
}).catch(error => {
this.$notification.error({
message: this.$t('message.request.failed'),
description: (error.response && error.response.headers && error.response.headers['x-description']) || error.message
})
}).finally(() => {
this.loading = false
})
}).catch((error) => {
this.formRef.value.scrollToField(error.errorFields[0].name)
})
},
disabledDate (current) {
return current && current < moment().endOf('day')
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,161 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<div>
<a-modal
v-model:visible="showFilterQuotaDataByPeriodModal"
:title="$t('label.quota.select.period')"
:maskClosable="false"
:footer="null">
<a-form
class="form-layout"
:model="form"
@finish="handleSubmit"
v-ctrl-enter="handleSubmit">
<a-form-item ref="dates" name="dates" style="width: 100%">
<a-range-picker
class="w-100"
v-model:value="form.dates"
show-time />
</a-form-item>
<div :span="24" class="action-button">
<a-button @click="closeFilterQuotaDataByPeriodModal">{{ this.$t('label.cancel') }}</a-button>
<a-button ref="submit" type="primary" @click="handleSubmit">{{ this.$t('label.ok') }}</a-button>
</div>
</a-form>
</a-modal>
<div class="chart-row">
<a-space direction="vertical">
<div>
<a-radio-group
v-model:value="periodSelected"
buttonStyle="solid"
@change="handlePeriodChange">
<a-radio-button value="day">
{{ $t('label.quota.period.today') }}
</a-radio-button>
<a-radio-button value="week">
{{ $t('label.quota.period.this.week') }}
</a-radio-button>
<a-radio-button value="month">
{{ $t('label.quota.period.this.month') }}
</a-radio-button>
<a-radio-button value="lastmonth">
{{ $t('label.quota.period.last.month') }}
</a-radio-button>
<a-radio-button value="year">
{{ $t('label.quota.period.this.year') }}
</a-radio-button>
<a-radio-button value="lastyear">
{{ $t('label.quota.period.last.year') }}
</a-radio-button>
<a-radio-button value="custom" @click="openFilterQuotaDataByPeriodModal">
{{ $t('label.quota.period.custom') }}
</a-radio-button>
</a-radio-group>
</div>
</a-space>
<div class="mt-10">
<filter-outlined style="margin-right: 5px"/>
<span v-html="getPeriodToString()" />
</div>
</div>
</div>
</template>
<script>
import { dayjs, toLocaleDate } from '@/utils/date'
import { reactive, toRaw } from 'vue'
export default {
name: 'FilterQuotaDataByPeriodView',
data () {
return {
periodSelected: 'month',
showFilterQuotaDataByPeriodModal: false,
startDate: undefined,
endDate: undefined
}
},
created () {
this.initForm()
this.handlePeriodChange()
},
methods: {
initForm () {
this.form = reactive({ dates: [this.startDate, this.endDate] })
},
handlePeriodChange () {
let end = dayjs()
let start
switch (this.periodSelected) {
case 'day':
case 'week':
case 'month':
case 'year':
start = dayjs().startOf(this.periodSelected)
break
case 'lastmonth': {
const lastMonth = dayjs().subtract(1, 'months')
start = dayjs(lastMonth).startOf('month')
end = dayjs(lastMonth).endOf('month')
break
}
case 'lastyear': {
const lastYear = dayjs().subtract(1, 'years')
start = dayjs(lastYear).startOf('year')
end = dayjs(lastYear).endOf('year')
break
}
default:
return
}
this.triggerFetchData([start, end])
},
openFilterQuotaDataByPeriodModal () {
this.showFilterQuotaDataByPeriodModal = true
},
closeFilterQuotaDataByPeriodModal () {
this.showFilterQuotaDataByPeriodModal = false
},
getPeriodToString () {
return this.$t('label.quota.filter.period', {
startDate: toLocaleDate({ date: this.startDate }),
endDate: toLocaleDate({ date: this.endDate })
})
},
handleSubmit () {
const formRaw = toRaw(this.form)
this.triggerFetchData(formRaw.dates)
},
triggerFetchData (values) {
this.startDate = values[0]
this.endDate = values[1]
this.initForm()
this.closeFilterQuotaDataByPeriodModal()
this.$emit('fetchData', this.startDate, this.endDate)
}
}
}
</script>
<style lang="scss" scoped>
@import '@/style/common/common.scss';
</style>

View File

@ -1,173 +0,0 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<div>
<a-table
size="small"
:loading="loading"
:columns="columns"
:dataSource="dataSource"
:rowKey="record => record.name"
:pagination="false"
:scroll="{ y: '55vh' }"
>
<template #bodyCell="{ column, text }">
<template v-if="column.key === 'quota'">
<span v-if="text!==null">{{ `${currency} ${text}` }}</span>
</template>
<template v-if="column.key === 'credit'">
<span v-if="text!==null">{{ `${currency} ${text}` }}</span>
</template>
</template>
</a-table>
</div>
</template>
<script>
import { getAPI } from '@/api'
import moment from 'moment'
export default {
name: 'QuotaBalance',
props: {
resource: {
type: Object,
required: true
},
tab: {
type: String,
default: () => ''
}
},
data () {
return {
loading: false,
pattern: 'YYYY-MM-DD',
currency: '',
dataSource: [],
account: null
}
},
computed: {
columns () {
return [
{
key: 'date',
title: this.$t('label.date'),
dataIndex: 'date',
width: 'calc(100% / 3)'
},
{
key: 'quota',
title: this.$t('label.quota.value'),
dataIndex: 'quota',
width: 'calc(100% / 3)'
},
{
key: 'credit',
title: this.$t('label.credit'),
dataIndex: 'credit',
width: 'calc(100% / 3)'
}
]
}
},
watch: {
tab () {
if (this.tab === 'quota.statement.balance') {
this.fetchData()
}
}
},
created () {
this.fetchData()
},
methods: {
async fetchData () {
this.dataSource = []
this.loading = true
this.account = this.$route.query && this.$route.query.account ? this.$route.query.account : null
try {
const resource = await this.fetchResource()
const quotaBalance = await this.quotaBalance(resource)
this.currency = quotaBalance.currency
this.dataSource = await this.createDataSource(quotaBalance)
this.loading = false
} catch (e) {
console.log(e)
this.loading = false
}
},
createDataSource (quotaBalance) {
const dataSource = []
const credits = quotaBalance.credits || []
dataSource.push({
date: moment(quotaBalance.enddate).format(this.pattern),
quota: quotaBalance.endquota,
credit: null
})
dataSource.push({
date: moment(quotaBalance.startdate).format(this.pattern),
quota: quotaBalance.startquota,
credit: null
})
credits.map(item => {
dataSource.push({
date: moment(item.updated_on).format(this.pattern),
quota: null,
credit: item.credits
})
})
return dataSource
},
fetchResource () {
return new Promise((resolve, reject) => {
const params = {}
params.domainid = this.resource.domainid
params.account = this.account
getAPI('quotaBalance', params).then(json => {
const quotaBalance = json.quotabalanceresponse.balance || {}
resolve(quotaBalance)
}).catch(error => {
reject(error)
})
})
},
quotaBalance (resource) {
return new Promise((resolve, reject) => {
const params = {}
params.domainid = this.resource.domainid
params.account = this.account
params.startdate = moment(this.resource.startdate).format(this.pattern)
params.enddate = moment(resource.startdate).format(this.pattern)
getAPI('quotaBalance', params).then(json => {
const quotaBalance = json.quotabalanceresponse.balance || {}
resolve(quotaBalance)
}).catch(error => {
reject(error)
})
})
}
}
}
</script>

View File

@ -0,0 +1,204 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<div>
<filter-quota-data-by-period-view @fetchData="fetchData"/>
<div v-if="dataSource.length > 0">
<export-to-csv-button :action="exportDataToCsv" />
<bar-chart :chart-options="getBalancesChartOptions()" :chart-data="getBalancesChartData()"/>
<a-table
size="small"
:loading="loading"
:columns="columns"
:dataSource="dataSource"
:rowKey="record => record.date"
:pagination="false"
:scroll="{ y: '55vh' }">
<template #title>
{{ $t('label.currency') }}: <b>{{ currency }}</b>
</template>
<template #date="{ text }">
{{ text }}
</template>
<template #lastBalanceHour="{ text }">
{{ text }}
</template>
<template #balance="{ text }">
{{ parseFloat(text).toFixed(2) }}
</template>
</a-table>
</div>
</div>
</template>
<script>
import { getAPI } from '@/api'
import BarChart from '@/components/view/charts/BarChart.vue'
import * as dateUtils from '@/utils/date'
import * as exportUtils from '@/utils/export'
import FilterQuotaDataByPeriodView from './FilterQuotaDataByPeriodView.vue'
import ExportToCsvButton from '@/components/view/buttons/ExportToCsvButton.vue'
import * as chartUtils from '@/utils/chart'
export default {
name: 'QuotaBalance',
components: {
FilterQuotaDataByPeriodView,
BarChart,
ExportToCsvButton
},
data () {
return {
loading: false,
currency: '',
dataSource: [],
startDate: undefined,
endDate: undefined
}
},
computed: {
columns () {
return [
{
title: this.$t('label.date'),
dataIndex: 'date',
width: 'calc(100% / 3)',
slots: { customRender: 'date' },
sorter: (a, b) => a.lastBalance.localeCompare(b.lastBalance),
defaultSortOrder: 'descend'
},
{
title: this.$t('label.quota.last.balance'),
dataIndex: 'lastBalanceHour',
width: 'calc(100% / 3)',
slots: { customRender: 'lastBalanceHour' },
sorter: (a, b) => a.lastBalance.localeCompare(b.lastBalance),
defaultSortOrder: 'descend'
},
{
title: this.$t('label.balance'),
dataIndex: 'balance',
width: 'calc(100% / 3)',
slots: { customRender: 'balance' },
sorter: (a, b) => a.balance - b.balance
}
]
}
},
methods: {
async fetchData (startDate, endDate) {
if (this.loading) return
this.startDate = dateUtils.parseDayJsObject({ value: startDate })
this.endDate = dateUtils.parseDayJsObject({ value: endDate })
this.dataSource = []
this.loading = true
try {
const data = await this.getQuotaBalance() || {}
this.currency = data.currency
this.dataSource = this.getLastBalanceOfEachDate(data.balances)
} finally {
this.loading = false
}
},
async getQuotaBalance () {
const params = {
accountid: this.$route.params?.id,
startDate: this.startDate,
endDate: this.endDate
}
return await getAPI('quotaBalance', params)
.then(json => json.quotabalanceresponse.balance)
.catch(error => { error && this.$notification.info({ message: this.$t('message.request.no.data') }) })
},
getLastBalanceOfEachDate (data) {
if (!data) return []
return data.reduce((reduced, currentItem) => {
const lastBalance = dateUtils.parseDayJsObject({ value: currentItem.date, keepMoment: false })
const balance = currentItem.balance
const date = dateUtils.toLocaleDate({ date: lastBalance, dateOnly: true })
const lastBalanceHour = dateUtils.toLocaleDate({ date: lastBalance, hourOnly: true })
const found = reduced.find(item => item.date === date)
if (!found) {
reduced.push({ lastBalance, balance, date, lastBalanceHour })
} else {
if (found.lastBalance < lastBalance) {
found.balance = balance
found.lastBalance = lastBalance
found.date = date
found.lastBalanceHour = lastBalanceHour
}
}
return reduced
}, [])
},
exportDataToCsv () {
exportUtils.exportDataToCsv({
data: this.dataSource,
headers: ['date', 'balance'],
keys: ['lastBalance', 'balance'],
fileName: `quota-balances-of-user-${this.$route.params.id}-between-${this.startDate}-and-${this.endDate}`
})
},
getBalancesChartData () {
const datasets = []
datasets.push({
label: this.$t('label.balance'),
data: this.dataSource.map(row => row.balance),
...chartUtils.getChartColorObject()
})
return {
labels: this.dataSource.map(row => row.date),
datasets
}
},
getBalancesChartOptions () {
return {
scales: {
xAxis: {
type: 'time',
time: {
unit: chartUtils.getUnitToTimeCartesianAxis('day', this.dataSource.length),
displayFormats: chartUtils.defaultDisplayFormats
}
}
},
plugins: {
tooltip: {
callbacks: {
title: (tooltipItem) => dateUtils.dayjs(tooltipItem[0].label).format(chartUtils.defaultDisplayFormats.day),
label: (tooltipItem) => parseFloat(tooltipItem.raw).toFixed(2)
}
}
}
}
}
}
}
</script>
<style lang="scss" scoped>
@import '@/style/common/common.scss';
</style>

View File

@ -0,0 +1,200 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<div>
<filter-quota-data-by-period-view @fetchData="fetchData"/>
<div v-if="dataSource.length > 0">
<export-to-csv-button :action="exportDataToCsv" />
<bar-chart :chart-options="getCreditsChartOptions()" :chart-data="getCreditsChartData()"/>
<a-table
size="small"
:loading="loading"
:columns="columns"
:dataSource="dataSource"
:rowKey="record => record.creditedon"
:pagination="false"
:scroll="{ y: '55vh' }">
<template #title>
{{ $t('label.currency') }}: <b>{{ currency }}</b>
</template>
<template #creditedOn="{ text }">
{{ $toLocaleDate(text) }}
</template>
<template #credit="{ text }">
{{ parseFloat(text).toFixed(2) }}
</template>
<template #creditor="{ text, record }">
<router-link :to="{ path: '/accountuser/' + record.creditoruserid }">{{ text }}</router-link>
</template>
</a-table>
</div>
</div>
</template>
<script>
import { getAPI } from '@/api'
import BarChart from '@/components/view/charts/BarChart.vue'
import * as dateUtils from '@/utils/date'
import * as exportUtils from '@/utils/export'
import FilterQuotaDataByPeriodView from './FilterQuotaDataByPeriodView.vue'
import ExportToCsvButton from '@/components/view/buttons/ExportToCsvButton.vue'
import * as chartUtils from '@/utils/chart'
export default {
name: 'QuotaCreditTab',
components: {
FilterQuotaDataByPeriodView,
BarChart,
ExportToCsvButton
},
data () {
return {
loading: false,
currency: '',
dataSource: [],
startDate: undefined,
endDate: undefined
}
},
computed: {
columns () {
return [
{
title: this.$t('label.date'),
dataIndex: 'creditedon',
width: 'calc(100% / 3)',
sorter: (a, b) => a.creditedon.localeCompare(b.creditedon),
defaultSortOrder: 'descend',
slots: { customRender: 'creditedOn' }
},
{
title: this.$t('label.credit'),
dataIndex: 'credit',
width: 'calc(100% / 3)',
sorter: (a, b) => a.credit - b.credit,
slots: { customRender: 'credit' }
},
{
title: this.$t('label.creditor'),
dataIndex: 'creditorusername',
width: 'calc(100% / 3)',
sorter: (a, b) => a.creditorusername.localeCompare(b.creditorusername),
slots: { customRender: 'creditor' }
}
]
}
},
methods: {
async fetchData (startDate, endDate) {
if (this.loading) return
this.startDate = dateUtils.parseDayJsObject({ value: startDate })
this.endDate = dateUtils.parseDayJsObject({ value: endDate })
this.dataSource = []
this.loading = true
try {
const data = await this.getQuotaCreditsList()
if (!data) {
return
}
this.currency = data[0]?.currency
this.dataSource = data.map(row => ({
...row,
date: dateUtils.parseDayJsObject({ value: row.creditedon, keepMoment: false })
}))
} finally {
this.loading = false
}
},
async getQuotaCreditsList () {
const params = {
accountid: this.$route.params?.id,
startdate: this.startDate,
enddate: this.endDate
}
return await getAPI('quotaCreditsList', params)
.then(json => json.quotacreditslistresponse.credit || {})
.catch(error => { error && this.$notification.info({ message: this.$t('message.request.no.data') }) })
},
exportDataToCsv () {
exportUtils.exportDataToCsv({
data: this.dataSource,
keys: ['date', 'credit', 'creditorusername'],
fileName: `credits-of-account-${this.$route.params.id}-between-${this.startDate}-and-${this.endDate}`
})
},
getCreditsChartData () {
const datasets = []
const data = []
const res = {}
this.dataSource.map(value => {
const date = this.$toLocalDate(value.creditedon).split('T')[0]
if (!res[date]) {
res[date] = { date, credit: 0 }
data.push(res[date])
}
res[date].credit += value.credit
return res
})
datasets.push({
label: this.$t('label.credit'),
data: data.map(row => row.credit),
...chartUtils.getChartColorObject()
})
return {
labels: data.map(row => row.date),
datasets
}
},
getCreditsChartOptions () {
return {
scales: {
xAxis: {
type: 'time',
time: {
unit: chartUtils.getUnitToTimeCartesianAxis('day', this.dataSource.length),
displayFormats: chartUtils.defaultDisplayFormats
}
}
},
plugins: {
tooltip: {
callbacks: {
title: (tooltipItem) => dateUtils.dayjs(tooltipItem[0].label).format(chartUtils.defaultDisplayFormats.day),
label: (tooltipItem) => parseFloat(tooltipItem.raw).toFixed(2)
}
}
}
}
}
}
}
</script>
<style lang="scss" scoped>
@import '@/style/common/common.scss';
</style>

View File

@ -1,65 +0,0 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<div>
<autogen-view
@change-resource="changeResource"/>
<quota-summary-resource
v-if="isSummaryResource"
:resource="resource"
:tabs="$route.meta.tabs"/>
</div>
</template>
<script>
import AutogenView from '@/views/AutogenView.vue'
import QuotaSummaryResource from '@/views/plugins/quota/QuotaSummaryResource'
export default {
name: 'QuotaSummary',
components: {
AutogenView,
QuotaSummaryResource
},
data () {
return {
resource: {}
}
},
provide: function () {
return {
parentChangeResource: this.changeResource
}
},
computed: {
isSummaryResource () {
if (this.$route.path.startsWith('/quotasummary')) {
if (this.$route.query && 'quota' in this.$route.query && this.$route.query.quota) {
return true
}
}
return false
}
},
methods: {
changeResource (resource) {
this.resource = resource
}
}
}
</script>

View File

@ -1,98 +0,0 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<resource-view
:loading="loading"
:resource="quotaResource"
:tabs="tabs"
:historyTab="activeTab"
@onTabChange="(tab) => { activeTab = tab }"/>
</template>
<script>
import { getAPI } from '@/api'
import moment from 'moment'
import ResourceView from '@/components/view/ResourceView'
export default {
name: 'QuotaSummaryResource',
components: {
ResourceView
},
props: {
resource: {
type: Object,
default: () => {}
},
tabs: {
type: Array,
default: () => []
}
},
data () {
return {
loading: false,
quotaResource: {},
networkService: null,
pattern: 'YYYY-MM-DD',
activeTab: ''
}
},
created () {
this.fetchData()
},
watch: {
resource: {
deep: true,
handler () {
if (Object.keys(this.resource).length === 0) {
this.fetchData()
}
}
}
},
inject: ['parentChangeResource'],
methods: {
fetchData () {
const params = {}
if (Object.keys(this.$route.query).length > 0) {
Object.assign(params, this.$route.query)
}
this.loading = true
getAPI('quotaBalance', params).then(json => {
const quotaBalance = json.quotabalanceresponse.balance || {}
if (Object.keys(quotaBalance).length > 0) {
quotaBalance.currency = `${quotaBalance.currency} ${quotaBalance.startquota}`
quotaBalance.startdate = moment(quotaBalance.startdate).format(this.pattern)
quotaBalance.account = this.$route.params.id ? this.$route.params.id : null
quotaBalance.domainid = this.$route.query.domainid ? this.$route.query.domainid : null
}
this.quotaResource = Object.assign({}, this.quotaResource, quotaBalance)
this.parentChangeResource(this.quotaResource)
}).finally(() => {
this.loading = false
})
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,158 +0,0 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<div>
<a-table
size="small"
:loading="loading"
:columns="columns"
:dataSource="dataSource"
:rowKey="record => record.name"
:pagination="false"
:scroll="{ y: '55vh' }"
>
<template #bodyCell="{ column, text }">
<template v-if="column.key === 'quota'">
<span v-if="text!==undefined">{{ `${currency} ${text}` }}</span>
</template>
</template>
</a-table>
</div>
</template>
<script>
import { getAPI } from '@/api'
import moment from 'moment'
export default {
name: 'QuotaUsage',
props: {
resource: {
type: Object,
required: true
},
tab: {
type: String,
default: () => ''
}
},
data () {
return {
loading: false,
dataSource: [],
pattern: 'YYYY-MM-DD',
currency: '',
totalQuota: 0,
account: null
}
},
computed: {
columns () {
return [
{
key: 'name',
title: this.$t('label.quota.type.name'),
dataIndex: 'name',
width: 'calc(100% / 3)'
},
{
key: 'unit',
title: this.$t('label.quota.type.unit'),
dataIndex: 'unit',
width: 'calc(100% / 3)'
},
{
key: 'quota',
title: this.$t('label.quota.usage'),
dataIndex: 'quota',
width: 'calc(100% / 3)'
}
]
}
},
watch: {
tab () {
if (this.tab === 'quota.statement.quota') {
this.fetchData()
}
}
},
created () {
this.fetchData()
},
methods: {
async fetchData () {
this.loading = true
this.dataSource = []
this.account = this.$route.query && this.$route.query.account ? this.$route.query.account : null
try {
const resource = await this.quotaBalance()
const quotaStatement = await this.quotaStatement(resource)
const quotaUsage = quotaStatement.quotausage
this.dataSource = this.dataSource.concat(quotaUsage)
this.currency = quotaStatement.currency
this.totalQuota = quotaStatement.totalquota
this.dataSource.push({
name: `${this.$t('label.quota.total')}: `,
quota: this.totalQuota
})
this.dataSource.unshift({
type: 0,
name: `${this.$t('startdate')}: ${moment(this.resource.startdate).format(this.pattern)}`,
unit: `${this.$t('enddate')}: ${moment(this.resource.enddate).format(this.pattern)}`
})
this.loading = false
} catch (e) {
console.log(e)
this.loading = false
}
},
quotaBalance () {
return new Promise((resolve, reject) => {
const params = {}
params.domainid = this.resource.domainid
params.account = this.account
getAPI('quotaBalance', params).then(json => {
const quotaBalance = json.quotabalanceresponse.balance || {}
resolve(quotaBalance)
}).catch(error => {
reject(error)
})
})
},
quotaStatement (resource) {
return new Promise((resolve, reject) => {
const params = {}
params.domainid = this.resource.domainid
params.account = this.account
params.startdate = moment(this.resource.startdate).format(this.pattern)
params.enddate = moment(resource.startdate).format(this.pattern)
getAPI('quotaStatement', params).then(json => {
const quotaStatement = json.quotastatementresponse.statement || {}
resolve(quotaStatement)
}).catch(error => {
reject(error)
})
})
}
}
}
</script>

View File

@ -0,0 +1,741 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<div>
<filter-quota-data-by-period-view @fetchData="fetchData"/>
<div v-if="dataSource.length > 0">
<hr class="m-20-0" />
<div class="chart-row">
<a-space direction="vertical">
<div>
<a-radio-group
v-model:value="graphType"
buttonStyle="solid"
@change="handlePeriodChange">
<a-radio-button value="bar_chart">
{{ $t('label.total') }}
</a-radio-button>
<a-radio-button value="line_chart">
{{ $t('label.quota.statement.history') }}
</a-radio-button>
<a-radio-button value="incremental_chart">
{{ $t('label.quota.statement.cumulative.history') }}
</a-radio-button>
</a-radio-group>
</div>
</a-space>
</div>
<div style="font-size: 18px">
<strong> {{ $t('label.quota.usage.types.summary') }} </strong>
</div>
<export-to-csv-button :action="exportDataToCsv" />
<bar-chart v-if="graphType === 'bar_chart'" :chart-options="getUsageTypeChartOptions()" :chart-data="getUsageTypeBarChartData()"/>
<resource-stats-line-chart
v-else
:chart-labels="usageLineChartLabels"
:chart-data="getGraphType(this.usageTypeChartData)"
:yAxisIncrementValue="getYaxisIncrement(this.getGraphType(this.yAxisMax.usageType))"
:yAxisMeasurementUnit="''"
/>
<a-table
size="small"
:loading="loading"
:columns="columns"
:dataSource="dataSource.filter(row => row.quota > 0)"
:rowKey="record => record.name"
:pagination="false"
:scroll="{ y: '55vh' }">
<template #nameRedirect="props">
<a @click="handleSelectedTypeChange(`${props.record.type}-${props.record.name}`)">{{ $t(props.text) }}</a>
</template>
<template #unit="{ text }">
{{ $t(text) }}
</template>
<template #quota="{ text }">
<a-tooltip placement="right">
<template #title>
{{ text }}
</template>
<span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span>
</a-tooltip>
</template>
<template #footer >
<div style="text-align: right;">
{{ $t('label.currency') }}: <b>{{ currency }}</b><br/>
{{ $t('label.quota.total.consumption') }}:
<a-tooltip placement="bottom">
<template #title>
{{ totalQuota }}
</template>
<b class="dotted-underline">{{ parseFloat(totalQuota).toFixed(2) }}</b>
</a-tooltip>
</div>
</template>
</a-table>
<hr class="m-20-0" id="resource-by-type" />
<strong>
<tooltip-label style="font-size: 18px" :title="$t('label.quota.usage.resources.by.type')" :tooltip="$t('message.quota.usage.resource.warn')"/>
</strong>
<a-select
v-model:value="selectedType"
class="w-100"
style="margin: 5px 0 10px 0px"
show-search
v-model="selectedType"
@change="handleSelectedTypeChange">
<a-select-option
v-for="quotaType of getQuotaTypesFiltered()"
:value="`${quotaType.id}-${quotaType.type}`"
:key="quotaType.id">
{{ $t(quotaType.type) }}
</a-select-option>
</a-select>
<export-to-csv-button v-if="dataSourceResource.length > 0" :action="exportResourcesToCsv" :label="`label.export.resources.csv`" />
<bar-chart v-if="dataSourceResource.length > 0 && graphType === 'bar_chart'" :chart-options="getUsageTypeChartOptions()" :chart-data="getResourceByUsageTypeBarChartData()"/>
<resource-stats-line-chart
v-else-if="dataSourceResource.length > 0 && graphType !== 'bar_chart'"
:chart-labels="resourceLineChartLabels"
:chart-data="getGraphType(this.resourceByTypeChartData)"
:yAxisIncrementValue="getYaxisIncrement(this.getGraphType(this.yAxisMax.resourceByType))"
:yAxisMeasurementUnit="''"
/>
<a-table
size="small"
:loading="loadingResources"
:columns="resourceColumns"
:dataSource="dataSourceResource"
:rowKey="(record) => record.displayname"
:pagination="false"
:scroll="{ y: '55vh' }">
<template #title v-if="dataSourceResource.length > 0">
<div>{{ $t('label.currency') }}: <b>{{ currency }}</b></div>
</template>
<template #displayName="props">
<span v-if="!props.text">
-
</span>
<span v-if="!props.text === '<untraceable>' || !props.record.resourceid">
{{ props.text }}
</span>
<a v-else @click="handleSelectedResourceChange(props.record.resourceid)">
{{ props.text }}
</a>
</template>
<template #quota="{ text }">
<a-tooltip placement="right">
<template #title>
{{ text }}
</template>
<span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span>
</a-tooltip>
</template>
</a-table>
<hr class="m-20-0" id="details-by-resource" />
<strong>
<tooltip-label style="font-size: 18px" :title="$t('label.quota.usage.details.by.resource')"/>
</strong>
<a-select
v-model:value="selectedResource"
class="w-100"
style="margin: 5px 0 10px 0px"
show-search
v-model="selectedResource"
@change="handleSelectedResourceChange"
:disabled="getResources().length == 0">
<a-select-option
v-for="item of getResources()"
:value="item.id"
:key="item.id">
{{ $t(item.name) }}
</a-select-option>
</a-select>
<export-to-csv-button v-if="dataSourceResourceDetails.length > 0" :action="exportResourceDetailsToCsv" :label="`label.export.details.csv`" />
<bar-chart v-if="dataSourceResourceDetails.length > 0 && graphType === 'bar_chart'" :chart-options="getUsageTypeChartOptions()" :chart-data="getResourceDetailsBarChartData()"/>
<resource-stats-line-chart
v-else-if="dataSourceResourceDetails.length > 0 && graphType !== 'bar_chart'"
:chart-labels="tariffLineChartLabels"
:chart-data="getGraphType(this.usageResourceDetailsChartData)"
:yAxisIncrementValue="getYaxisIncrement(this.getGraphType(this.yAxisMax.resourceDetails))"
:yAxisMeasurementUnit="''"
/>
<a-table
size="small"
:loading="loadingResourceDetails"
:columns="resourceDetailsColumns"
:dataSource="dataSourceResourceDetails"
:rowKey="record => record.tariffname + '-' + record.startDate"
:pagination="false"
:scroll="{ y: '55vh' }">
<template #title v-if="dataSourceResourceDetails.length > 0">
<div>{{ $t('label.currency') }}: <b>{{ currency }}</b></div>
</template>
<template #tariffName="props">
<a v-if="'quotaTariffList' in $store.getters.apis" :href="`#/quotatariff/${props.record.tariffid}`" target="_blank">
{{ props.text }}
</a>
<span v-else>
{{ props.text }}
</span>
</template>
<template #endDate="{ text }">
{{ $toLocaleDate(text) }}
</template>
<template #startDate="{ text }">
{{ $toLocaleDate(text) }}
</template>
<template #quota="{ text }">
<a-tooltip placement="right">
<template #title>
{{ text }}
</template>
<span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span>
</a-tooltip>
</template>
</a-table>
</div>
</div>
</template>
<script>
import { getAPI } from '@/api'
import FilterQuotaDataByPeriodView from './FilterQuotaDataByPeriodView.vue'
import BarChart from '@/components/view/charts/BarChart.vue'
import ResourceStatsLineChart from '@/components/view/stats/ResourceStatsLineChart.vue'
import ExportToCsvButton from '@/components/view/buttons/ExportToCsvButton.vue'
import { getChartColorObject } from '@/utils/chart'
import { getQuotaTypeByName, getQuotaTypes } from '@/utils/quota'
import TooltipLabel from '@/components/widgets/TooltipLabel'
import * as exportUtils from '@/utils/export'
import * as dateUtils from '@/utils/date'
export default {
name: 'QuotaUsageTab',
components: {
FilterQuotaDataByPeriodView,
BarChart,
ExportToCsvButton,
ResourceStatsLineChart,
TooltipLabel
},
data () {
return {
dataSource: [],
selectedType: '',
loadingResources: false,
dataSourceResource: [],
selectedResource: '',
loadingResourceDetails: false,
dataSourceResourceDetails: [],
startDate: undefined,
endDate: undefined,
graphType: 'bar_chart',
usageLineChartLabels: [],
resourceLineChartLabels: [],
tariffLineChartLabels: [],
usageTypeChartData: {},
resourceByTypeChartData: {},
usageResourceDetailsChartData: {},
yAxisMax: {}
}
},
watch: {
graphType (newGraphType) {
if (newGraphType === 'bar_chart') {
return
}
this.fetchDataForUsageTypeLineGraph()
if (!this.selectedType) {
return
}
this.fetchDataForResourceByTypeLineGraph()
if (!this.selectedResource) {
return
}
this.fetchDataForResourceDetailsLineGraph()
}
},
computed: {
columns () {
return [
{
title: this.$t('label.quota.type.name'),
dataIndex: 'name',
width: 'calc(100% / 3)',
slots: { customRender: 'nameRedirect' },
sorter: (a, b) => a.name.localeCompare(b.name)
},
{
title: this.$t('label.quota.type.unit'),
dataIndex: 'unit',
width: 'calc(100% / 3)',
slots: { customRender: 'unit' },
sorter: (a, b) => a.unit.localeCompare(b.unit)
},
{
title: this.$t('label.quota.consumed'),
dataIndex: 'quota',
width: 'calc(100% / 3)',
slots: { customRender: 'quota' },
sorter: (a, b) => a.quota - b.quota,
defaultSortOrder: 'descend'
}
]
},
resourceColumns () {
return [
{
title: this.$t('label.resource'),
dataIndex: 'displayname',
width: '50%',
slots: { customRender: 'displayName' },
sorter: (a, b) => a.displayname.localeCompare(b.displayname),
defaultSortOrder: 'ascend'
},
{
title: this.$t('label.quota.consumed'),
dataIndex: 'quotaconsumed',
width: '50%',
slots: { customRender: 'quota' },
sorter: (a, b) => a.quotaconsumed - b.quotaconsumed
}
]
},
resourceDetailsColumns () {
return [
{
title: this.$t('label.quota.tariff'),
dataIndex: 'tariffname',
slots: { customRender: 'tariffName' },
sorter: (a, b) => a.tariffname.localeCompare(b.tariffname)
},
{
title: this.$t('label.start.date'),
dataIndex: 'startDate',
slots: { customRender: 'startDate' },
sorter: (a, b) => a.startDate.localeCompare(b.startDate),
defaultSortOrder: 'descend'
},
{
title: this.$t('label.end.date'),
dataIndex: 'endDate',
slots: { customRender: 'endDate' },
sorter: (a, b) => a.endDate.localeCompare(b.endDate)
},
{
title: this.$t('label.quota.consumed'),
dataIndex: 'quotaconsumed',
slots: { customRender: 'quota' },
sorter: (a, b) => a.quotaused - b.quotaused
}
]
}
},
methods: {
async fetchData (startDate, endDate, keepMoment = true) {
if (this.loading) return
this.startDate = dateUtils.parseDayJsObject({ value: startDate, keepMoment: keepMoment })
this.endDate = dateUtils.parseDayJsObject({ value: endDate, keepMoment: keepMoment })
this.loading = true
this.dataSource = []
this.dataSourceResource = []
this.dataSourceResourceDetails = []
this.selectedResource = ''
this.selectedType = ''
try {
const quotaStatement = await this.getQuotaStatement({
startdate: this.startDate,
enddate: this.endDate
})
if (!quotaStatement) {
return
}
this.dataSource = quotaStatement.quotausage.filter(row => row.quota !== 0)
this.currency = quotaStatement.currency
this.totalQuota = quotaStatement.totalquota
if (this.graphType !== 'bar_chart') {
this.fetchDataForUsageTypeLineGraph()
}
} finally {
this.loading = false
}
},
async fetchResourceData () {
if (this.selectedType === '' || this.loadingResources) return
this.dataSourceResource = []
this.loadingResources = true
try {
const quotaStatement = await this.getQuotaStatement({
startDate: this.startDate,
endDate: this.endDate,
showResources: true,
type: this.selectedType.split('-')[0]
})
this.dataSourceResource = quotaStatement.quotausage[0].resources
this.dataSourceResource = this.dataSourceResource.filter(row => row.quotaconsumed !== 0)
if (this.graphType !== 'bar_chart') {
this.fetchDataForResourceByTypeLineGraph()
}
} finally {
this.loadingResources = false
}
},
async fetchResourceDetailsData () {
if (this.selectedResource === '' || this.loadingResourceDetails) return
this.dataSourceResourceDetails = []
this.loadingResourceDetails = true
try {
this.dataSourceResourceDetails = await getAPI('quotaResourceStatement', {
startDate: this.startDate,
endDate: this.endDate, // TODO: remover
usageType: this.selectedType.split('-')[0],
id: this.selectedResource,
accountId: this.$route.params?.id
}).then(json => json.quotaresourcestatementresponse?.quotaresourcestatement?.items || [])
.catch(error => { error && this.$notification.info({ message: this.$t('message.request.no.data') }) })
this.dataSourceResourceDetails = this.dataSourceResourceDetails.map(detail => ({
...detail,
startDate: dateUtils.parseDayJsObject({ value: detail.startdate }),
endDate: dateUtils.parseDayJsObject({ value: detail.enddate })
})).filter(row => row.quotaconsumed !== 0)
if (this.graphType !== 'bar_chart') {
this.fetchDataForResourceDetailsLineGraph()
}
} finally {
this.loadingResourceDetails = false
}
},
async getQuotaStatement (apiParams) {
const params = {
ignoreproject: true,
accountid: this.$route.params?.id,
...apiParams
}
return await getAPI('quotaStatement', params)
.then(json => json.quotastatementresponse.statement || {})
.catch(error => {
if (error) {
this.$notification.info({ message: this.$t('message.request.no.data') })
}
})
},
getUsageTypeChartOptions () {
return { responsive: true }
},
getUsageTypeBarChartData () {
const datasets = []
for (const row of this.dataSource) {
datasets.push({
label: this.$t(row.name),
data: [row.quota],
...this.getColor(row)
})
}
return { labels: [this.$t('label.quota.type.name')], datasets }
},
getResourceByUsageTypeBarChartData () {
const datasets = []
for (const row of this.dataSourceResource) {
datasets.push({
label: row.displayname,
data: [row.quotaconsumed],
...this.getColor(row)
})
}
return { labels: [this.$t('label.resource')], datasets }
},
getResourceDetailsBarChartData () {
const tariffs = {}
for (const row of this.dataSourceResourceDetails) {
if (tariffs[row.tariffname] === undefined) {
tariffs[row.tariffname] = row.quotaconsumed
} else {
tariffs[row.tariffname] += row.quotaconsumed
}
}
const datasets = []
for (const key in tariffs) {
datasets.push({
label: key,
data: [tariffs[key]],
...this.getColor({ tariffname: key })
})
}
return { labels: [this.$t('label.quota.tariff')], datasets }
},
setUsageTypeChartData () {
this.usageLineChartLabels = this.getLineChartLabelsForData(this.dataSource)
this.usageTypeChartData = this.getChartData(this.dataSource, this.usageLineChartLabels)
},
setResourceByTypeChartData () {
this.resourceLineChartLabels = this.getLineChartLabelsForData(this.dataSourceResource)
this.resourceByTypeChartData = this.getChartData(this.dataSourceResource, this.resourceLineChartLabels)
},
setResourceDetailsChartData () {
const transformedData = {}
this.dataSourceResourceDetails.sort((a, b) => new Date(a.enddate) - new Date(b.enddate))
this.dataSourceResourceDetails.forEach((obj) => {
if (!(obj.tariffname in transformedData)) {
transformedData[obj.tariffname] = { tariffname: obj.tariffname, history: [] }
}
transformedData[obj.tariffname].history.push(obj)
})
const filteredDataSource = Object.values(transformedData)
this.tariffLineChartLabels = this.getLineChartLabelsForData(filteredDataSource)
this.usageResourceDetailsChartData = this.getChartData(filteredDataSource, this.tariffLineChartLabels)
},
getAdjustedDate (date) {
return this.$toLocalDate(date)
},
getLineChartLabelsForData (data) {
const lineChartLabels = [this.getAdjustedDate(this.startDate)]
for (const row of data) {
let lastIsZero = true
for (let i = 0; i < row.history.length; i++) {
const item = row.history[i]
if (item.quotaconsumed === 0) {
if (lastIsZero) {
continue
}
lastIsZero = true
}
if (lastIsZero) {
this.pushDateToLabelsIfNotPresent(lineChartLabels, this.getAdjustedDate(item.startdate))
}
this.pushDateToLabelsIfNotPresent(lineChartLabels, this.getAdjustedDate(item.enddate))
lastIsZero = false
}
}
this.pushDateToLabelsIfNotPresent(lineChartLabels, this.getAdjustedDate(this.endDate))
lineChartLabels.sort((a, b) => new Date(a) - new Date(b))
return lineChartLabels
},
pushDateToLabelsIfNotPresent (lineChartLabels, date) {
const hasDate = lineChartLabels.some(d => {
const diff = Math.abs(new Date(date) - new Date(d).getTime())
return diff < 5 * 1000
})
if (!hasDate) {
lineChartLabels.push(date)
return true
}
return false
},
setYAxisMax () {
this.yAxisMax.usageType = {}
this.yAxisMax.usageType.incremental = Math.round(Math.max(...this.dataSource.map(obj => obj.quotaconsumed)) * 1.2)
const max = []
for (const row of this.dataSource) {
max.push(Math.max(...Object.values(row.history.map(h => h.quotaconsumed))))
}
this.yAxisMax.usageType.history = Math.max(...max)
},
setYAxisInitialMaxResourceByType () {
this.yAxisMax.resourceByType = {}
this.yAxisMax.resourceByType.incremental = Math.max(...this.dataSourceResource.map(obj => obj.quotaconsumed))
const max = []
for (const row of this.dataSourceResource) {
max.push(Math.max(...Object.values(row.history.map(h => h.quotaconsumed))))
}
this.yAxisMax.resourceByType.history = Math.round(Math.max(...max) * 1.2)
},
setYAxisInitialMaxResourceDetails () {
this.yAxisMax.resourceDetails = {}
const historyMax = []
const incrementalMax = []
for (const row in this.usageResourceDetailsChartData.history) {
historyMax.push(Math.max(...this.usageResourceDetailsChartData.history[row].data.map(obj => obj.stat)))
}
for (const row in this.usageResourceDetailsChartData.incremental) {
incrementalMax.push(Math.max(...this.usageResourceDetailsChartData.incremental[row].data.map(obj => obj.stat)))
}
this.yAxisMax.resourceDetails.history = (Math.max(...historyMax))
this.yAxisMax.resourceDetails.incremental = (Math.max(...incrementalMax))
},
getGraphType (data) {
if (this.graphType === 'line_chart') {
return data.history
}
return data.incremental
},
fetchDataForUsageTypeLineGraph () {
this.setUsageTypeChartData()
this.setYAxisMax()
},
fetchDataForResourceByTypeLineGraph () {
this.setResourceByTypeChartData()
this.setYAxisInitialMaxResourceByType()
},
fetchDataForResourceDetailsLineGraph () {
this.setResourceDetailsChartData()
this.setYAxisInitialMaxResourceDetails()
},
getChartData (data, lineChartLabels) {
const chartData = {
history: [],
incremental: []
}
for (const row of data) {
const name = this.$t(this.getName(row))
const historyData = { label: name, data: [], fill: false, ...this.getColor(row) }
const incrementalData = { label: name, data: [], fill: false, ...this.getColor(row) }
let i = 0
let accumulatedQuota = 0
for (const label of lineChartLabels) {
let labelQuota = 0
while (true) {
if (i >= row.history.length) {
break
}
const item = row.history[i]
if (this.getAdjustedDate(item.enddate) > label) {
break
}
labelQuota += item.quotaconsumed
i++
}
accumulatedQuota += labelQuota
historyData.data.push({ stat: labelQuota })
incrementalData.data.push({ stat: accumulatedQuota })
}
chartData.history.push(historyData)
chartData.incremental.push(incrementalData)
}
return chartData
},
getColor (row) {
const quotaType = getQuotaTypeByName(row.name)
if (quotaType) {
return getChartColorObject(quotaType.chartColor)
}
console.log(row)
return getChartColorObject(this.textToDeterministicColor(this.getName(row)))
},
textToDeterministicColor (text) {
console.log(text)
let hash = 0
for (let i = text.length - 1; i >= 0; i--) {
hash = ((hash << 5) - hash) + text.charCodeAt(i)
hash = hash & hash
}
// Use multiple parts of the hash for better color variance
const hash1 = Math.abs(hash)
const hash2 = Math.abs(hash * 73)
const hash3 = Math.abs(hash * 97)
let color = '#'
color += (hash1 & 0xFF).toString(16).padStart(2, '0')
color += (hash2 & 0xFF).toString(16).padStart(2, '0')
color += (hash3 & 0xFF).toString(16).padStart(2, '0')
return color
},
getName (row) {
if (row.name) {
return this.$t(row.name)
}
return row.displayname || row.tariffname
},
getYaxisIncrement (max) {
if (max < 1) {
return 1
}
return Math.pow(10, Math.floor(Math.log10(max)))
},
exportDataToCsv () {
exportUtils.exportDataToCsv({
data: this.dataSource,
keys: ['type', 'name', 'unit', 'quota'],
fileName: `quota-usage-of-account-${this.$route.params.id}-between-${this.startDate}-and-${this.endDate}`
})
},
exportResourcesToCsv () {
exportUtils.exportDataToCsv({
data: this.dataSourceResource.map(row => ({ ...row, name: row.displayname })),
keys: ['resourceid', 'name', 'quotaconsumed'],
fileName: `quota-usage-of-resources-of-type-${this.selectedType}-of-account-${this.$route.params.id}-between-${this.startDate}-and-${this.endDate}`
})
},
exportResourceDetailsToCsv () {
exportUtils.exportDataToCsv({
data: this.dataSourceResourceDetails.map(row => ({
...row,
startdate: dateUtils.parseDayJsObject({ value: row.startdate, keepMoment: false }),
enddate: dateUtils.parseDayJsObject({ value: row.enddate, keepMoment: false })
})),
keys: ['tariffid', 'tariffname', 'startdate', 'enddate', 'quotaconsumed'],
fileName: `detailed-quota-usage-of-resource-${this.selectedResource}-of-type-${this.selectedType}-of-account-${this.$route.params.id}-between-${this.startDate}-and-${this.endDate}`
})
},
getQuotaTypesFiltered () {
const quotaTypesRetrieved = this.dataSource.map(item => item.name)
return getQuotaTypes().filter((item) => quotaTypesRetrieved.includes(item.type))
},
getResources () {
return this.dataSourceResource.filter(item => item.resourceid).map(item => ({ id: item.resourceid, name: item.displayname }))
},
async handleSelectedTypeChange (value) {
this.selectedType = value
this.selectedResource = ''
this.dataSourceResourceDetails = []
document.getElementById('resource-by-type').scrollIntoView({ behavior: 'smooth' })
await this.fetchResourceData()
},
async handleSelectedResourceChange (value) {
if (!value) return
this.selectedResource = value
document.getElementById('details-by-resource').scrollIntoView({ behavior: 'smooth' })
await this.fetchResourceDetailsData()
}
}
}
</script>
<style lang="scss" scoped>
@import '@/style/common/common.scss';
</style>