Copilot commented on code in PR #13449: URL: https://github.com/apache/cloudstack/pull/13449#discussion_r3508701769
########## ui/src/views/plugins/quota/QuotaUsageTab.vue: ########## @@ -0,0 +1,735 @@ +// 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"> + <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="getBarChartOptions()" :chart-data="getUsageTypeBarChartData()"/> + <resource-stats-line-chart + v-else + :chart-labels="usageLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.usageLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.usageTypes))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'name'"> + <a @click="handleSelectedTypeChange(`${record.type}-${record.name}`)">{{ $t(text) }}</a> + </template> + <template v-if="column.dataIndex === 'unit'"> + {{ $t(text) }} + </template> + <template v-if="column.dataIndex === 'quota'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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"> Review Comment: `a-select` binds the same state twice (`v-model:value` and `v-model`), which can lead to conflicting updates (binding both `value` and default `modelValue`). Keep only one binding (`v-model:value`) for Ant Design Vue. ########## ui/src/views/plugins/quota/QuotaUsageTab.vue: ########## @@ -0,0 +1,735 @@ +// 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"> + <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="getBarChartOptions()" :chart-data="getUsageTypeBarChartData()"/> + <resource-stats-line-chart + v-else + :chart-labels="usageLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.usageLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.usageTypes))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'name'"> + <a @click="handleSelectedTypeChange(`${record.type}-${record.name}`)">{{ $t(text) }}</a> + </template> + <template v-if="column.dataIndex === 'unit'"> + {{ $t(text) }} + </template> + <template v-if="column.dataIndex === 'quota'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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="getBarChartOptions()" :chart-data="getResourceBarChartData()"/> + <resource-stats-line-chart + v-else-if="dataSourceResource.length > 0 && graphType !== 'bar_chart'" + :chart-labels="resourceLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.resourceLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.resources))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'displayname'"> + <span v-if="!text"> + - + </span> + <span v-if="!text === '<untraceable>' || !record.resourceid"> + {{ text }} + </span> + <a v-else @click="handleSelectedResourceChange(record.resourceid)"> + {{ text }} + </a> + </template> + <template v-if="column.dataIndex === 'quotaconsumed'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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"> Review Comment: This `a-select` also binds `selectedResource` twice (`v-model:value` and `v-model`). Remove the duplicate `v-model` to avoid conflicting bindings. ########## ui/src/views/plugins/quota/QuotaBalanceTab.vue: ########## @@ -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 + } Review Comment: `quotaBalance` API parameters are `startdate`/`enddate` (lowercase, per API constants). Using `startDate`/`endDate` will be ignored by the backend, so the balance history won't be filtered as intended. ########## ui/src/views/plugins/quota/QuotaUsageTab.vue: ########## @@ -0,0 +1,735 @@ +// 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"> + <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="getBarChartOptions()" :chart-data="getUsageTypeBarChartData()"/> + <resource-stats-line-chart + v-else + :chart-labels="usageLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.usageLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.usageTypes))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'name'"> + <a @click="handleSelectedTypeChange(`${record.type}-${record.name}`)">{{ $t(text) }}</a> + </template> + <template v-if="column.dataIndex === 'unit'"> + {{ $t(text) }} + </template> + <template v-if="column.dataIndex === 'quota'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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="getBarChartOptions()" :chart-data="getResourceBarChartData()"/> + <resource-stats-line-chart + v-else-if="dataSourceResource.length > 0 && graphType !== 'bar_chart'" + :chart-labels="resourceLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.resourceLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.resources))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'displayname'"> + <span v-if="!text"> + - + </span> + <span v-if="!text === '<untraceable>' || !record.resourceid"> + {{ text }} + </span> + <a v-else @click="handleSelectedResourceChange(record.resourceid)"> + {{ text }} + </a> Review Comment: The condition `!text === '<untraceable>'` is always false (boolean compared to string), so `<untraceable>` rows (or rows without `resourceid`) may incorrectly render as clickable links. Use an `v-else-if` branch that checks `text === '<untraceable>' || !record.resourceid`. ########## ui/src/views/plugins/quota/AddQuotaCredit.vue: ########## @@ -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') }] + }) Review Comment: The form rules require `domainid` and `accountid`, but the form model never sets these fields (there are no matching `<a-form-item name="domainid/accountid">`). This will cause validation to fail and block submitting credits. ########## ui/src/components/view/ListView.vue: ########## @@ -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> Review Comment: `$router.resolve(...)` returns a RouteLocation object, so comparing it to the string `'404'` is always true. Use the same 404 detection used elsewhere in the UI (`.matched[0].redirect !== '/exception/404'`) to avoid generating broken links. ########## ui/src/components/view/ListView.vue: ########## @@ -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> Review Comment: Same issue here: `$router.resolve(...) !== '404'` is never false, so this will render links even when the route resolves to `/exception/404`. Use `.matched[0].redirect` like other link checks. ########## plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java: ########## @@ -661,49 +699,77 @@ protected void validateEndDateOnCreatingNewQuotaTariff(QuotaTariffVO newQuotaTar } @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); + 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); - 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, + BigDecimal currentAccountBalance = _quotaBalanceDao.getLastQuotaBalance(accountId, domainId); Review Comment: `quotaCreditsDao.saveCredits(...)` can return null (the previous implementation handled this). Without the null-check, later code may NPE and the API will return a less clear error. ########## plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsCmd.java: ########## @@ -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; Review Comment: Typo in parameter description: "qQuota" -> "Quota". ########## ui/src/views/plugins/quota/QuotaUsageTab.vue: ########## @@ -0,0 +1,735 @@ +// 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"> + <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="getBarChartOptions()" :chart-data="getUsageTypeBarChartData()"/> + <resource-stats-line-chart + v-else + :chart-labels="usageLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.usageLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.usageTypes))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'name'"> + <a @click="handleSelectedTypeChange(`${record.type}-${record.name}`)">{{ $t(text) }}</a> + </template> + <template v-if="column.dataIndex === 'unit'"> + {{ $t(text) }} + </template> + <template v-if="column.dataIndex === 'quota'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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="getBarChartOptions()" :chart-data="getResourceBarChartData()"/> + <resource-stats-line-chart + v-else-if="dataSourceResource.length > 0 && graphType !== 'bar_chart'" + :chart-labels="resourceLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.resourceLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.resources))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'displayname'"> + <span v-if="!text"> + - + </span> + <span v-if="!text === '<untraceable>' || !record.resourceid"> + {{ text }} + </span> + <a v-else @click="handleSelectedResourceChange(record.resourceid)"> + {{ text }} + </a> + </template> + <template v-if="column.dataIndex === 'quotaconsumed'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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="dataSourceTariffs.length > 0" :action="exportResourceDetailsToCsv" :label="`label.export.details.csv`" /> + <bar-chart v-if="dataSourceTariffs.length > 0 && graphType === 'bar_chart'" :chart-options="getBarChartOptions()" :chart-data="getTariffsBarChartData()"/> + <resource-stats-line-chart + v-else-if="dataSourceTariffs.length > 0 && graphType !== 'bar_chart'" + :chart-labels="tariffLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.tariffLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.tariffs))" + :yAxisMeasurementUnit="''" + /> + <a-table + size="small" + :loading="loadingTariffs" + :columns="resourceDetailsColumns" + :dataSource="dataSourceTariffs" + :rowKey="record => record.tariffname + '-' + record.startdate" + :pagination="false" + :scroll="{ y: '55vh' }"> + <template #title v-if="dataSourceTariffs.length > 0"> + <div>{{ $t('label.currency') }}: <b>{{ currency }}</b></div> + </template> + <template #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'tariffname'"> + <a v-if="'quotaTariffList' in $store.getters.apis" :href="`#/quotatariff/${record.tariffid}`" target="_blank"> + {{ text }} + </a> + <span v-else> + {{ text }} + </span> + </template> + <template v-if="column.dataIndex === 'enddate'"> + {{ $toLocaleDate(text) }} + </template> + <template v-if="column.dataIndex === 'startdate'"> + {{ $toLocaleDate(text) }} + </template> + <template v-if="column.dataIndex === 'quotaconsumed'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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: '', + loadingTariffs: false, + dataSourceTariffs: [], + startDate: undefined, + endDate: undefined, + graphType: 'bar_chart', + usageLineChartLabels: [], + resourceLineChartLabels: [], + tariffLineChartLabels: [], + usageLineChartData: {}, + resourceLineChartData: {}, + tariffLineChartData: {}, + YAxisMax: {} + } + }, + watch: { + graphType (newGraphType) { + if (newGraphType === 'bar_chart') { + return + } + this.prepareDataForUsageTypeLineGraph() + if (!this.selectedType) { + return + } + this.prepareDataForResourceLineGraph() + if (!this.selectedResource) { + return + } + this.prepareDataForTariffLineGraph() + } + }, + computed: { + columns () { + return [ + { + title: this.$t('label.quota.type.name'), + dataIndex: 'name', + width: 'calc(100% / 3)', + sorter: (a, b) => a.name.localeCompare(b.name) + }, + { + title: this.$t('label.quota.type.unit'), + dataIndex: 'unit', + width: 'calc(100% / 3)', + sorter: (a, b) => a.unit.localeCompare(b.unit) + }, + { + title: this.$t('label.quota.consumed'), + dataIndex: 'quota', + width: 'calc(100% / 3)', + sorter: (a, b) => a.quota - b.quota, + defaultSortOrder: 'descend' + } + ] + }, + resourceColumns () { + return [ + { + title: this.$t('label.resource'), + dataIndex: 'displayname', + width: '50%', + sorter: (a, b) => a.displayname.localeCompare(b.displayname), + defaultSortOrder: 'ascend' + }, + { + title: this.$t('label.quota.consumed'), + dataIndex: 'quotaconsumed', + width: '50%', + sorter: (a, b) => a.quotaconsumed - b.quotaconsumed + } + ] + }, + resourceDetailsColumns () { + return [ + { + title: this.$t('label.quota.tariff'), + dataIndex: 'tariffname', + sorter: (a, b) => a.tariffname.localeCompare(b.tariffname) + }, + { + title: this.$t('label.start.date'), + dataIndex: 'startdate', + sorter: (a, b) => a.startdate.localeCompare(b.startdate), + defaultSortOrder: 'descend' + }, + { + title: this.$t('label.end.date'), + dataIndex: 'enddate', + sorter: (a, b) => a.enddate.localeCompare(b.enddate) + }, + { + title: this.$t('label.quota.consumed'), + dataIndex: 'quotaconsumed', + sorter: (a, b) => a.quotaused - b.quotaused Review Comment: `resourceDetailsColumns` sorts using `quotaused`, but the column is `quotaconsumed`. This makes sorting wrong and can throw if `quotaused` is undefined. ########## ui/src/views/plugins/quota/AddQuotaCredit.vue: ########## @@ -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 = {} Review Comment: Debug `console.log` in `fetchOwnerOptions` should be removed; it will spam the console whenever ownership changes. ########## ui/src/views/plugins/quota/QuotaUsageTab.vue: ########## @@ -0,0 +1,735 @@ +// 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"> + <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="getBarChartOptions()" :chart-data="getUsageTypeBarChartData()"/> + <resource-stats-line-chart + v-else + :chart-labels="usageLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.usageLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.usageTypes))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'name'"> + <a @click="handleSelectedTypeChange(`${record.type}-${record.name}`)">{{ $t(text) }}</a> + </template> + <template v-if="column.dataIndex === 'unit'"> + {{ $t(text) }} + </template> + <template v-if="column.dataIndex === 'quota'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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="getBarChartOptions()" :chart-data="getResourceBarChartData()"/> + <resource-stats-line-chart + v-else-if="dataSourceResource.length > 0 && graphType !== 'bar_chart'" + :chart-labels="resourceLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.resourceLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.resources))" + :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 #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'displayname'"> + <span v-if="!text"> + - + </span> + <span v-if="!text === '<untraceable>' || !record.resourceid"> + {{ text }} + </span> + <a v-else @click="handleSelectedResourceChange(record.resourceid)"> + {{ text }} + </a> + </template> + <template v-if="column.dataIndex === 'quotaconsumed'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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="dataSourceTariffs.length > 0" :action="exportResourceDetailsToCsv" :label="`label.export.details.csv`" /> + <bar-chart v-if="dataSourceTariffs.length > 0 && graphType === 'bar_chart'" :chart-options="getBarChartOptions()" :chart-data="getTariffsBarChartData()"/> + <resource-stats-line-chart + v-else-if="dataSourceTariffs.length > 0 && graphType !== 'bar_chart'" + :chart-labels="tariffLineChartLabels" + :chart-data="getEntryForCurrentGraphType(this.tariffLineChartData)" + :yAxisIncrementValue="getYAxisIncrement(getEntryForCurrentGraphType(this.YAxisMax.tariffs))" + :yAxisMeasurementUnit="''" + /> + <a-table + size="small" + :loading="loadingTariffs" + :columns="resourceDetailsColumns" + :dataSource="dataSourceTariffs" + :rowKey="record => record.tariffname + '-' + record.startdate" + :pagination="false" + :scroll="{ y: '55vh' }"> + <template #title v-if="dataSourceTariffs.length > 0"> + <div>{{ $t('label.currency') }}: <b>{{ currency }}</b></div> + </template> + <template #bodyCell="{ column, text, record }"> + <template v-if="column.dataIndex === 'tariffname'"> + <a v-if="'quotaTariffList' in $store.getters.apis" :href="`#/quotatariff/${record.tariffid}`" target="_blank"> + {{ text }} + </a> + <span v-else> + {{ text }} + </span> + </template> + <template v-if="column.dataIndex === 'enddate'"> + {{ $toLocaleDate(text) }} + </template> + <template v-if="column.dataIndex === 'startdate'"> + {{ $toLocaleDate(text) }} + </template> + <template v-if="column.dataIndex === 'quotaconsumed'"> + <a-tooltip placement="right"> + <template #title> + {{ text }} + </template> + <span class="dotted-underline">{{ parseFloat(text).toFixed(2) }}</span> + </a-tooltip> + </template> + </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: '', + loadingTariffs: false, + dataSourceTariffs: [], + startDate: undefined, + endDate: undefined, + graphType: 'bar_chart', + usageLineChartLabels: [], + resourceLineChartLabels: [], + tariffLineChartLabels: [], + usageLineChartData: {}, + resourceLineChartData: {}, + tariffLineChartData: {}, + YAxisMax: {} + } + }, + watch: { + graphType (newGraphType) { + if (newGraphType === 'bar_chart') { + return + } + this.prepareDataForUsageTypeLineGraph() + if (!this.selectedType) { + return + } + this.prepareDataForResourceLineGraph() + if (!this.selectedResource) { + return + } + this.prepareDataForTariffLineGraph() + } + }, + computed: { + columns () { + return [ + { + title: this.$t('label.quota.type.name'), + dataIndex: 'name', + width: 'calc(100% / 3)', + sorter: (a, b) => a.name.localeCompare(b.name) + }, + { + title: this.$t('label.quota.type.unit'), + dataIndex: 'unit', + width: 'calc(100% / 3)', + sorter: (a, b) => a.unit.localeCompare(b.unit) + }, + { + title: this.$t('label.quota.consumed'), + dataIndex: 'quota', + width: 'calc(100% / 3)', + sorter: (a, b) => a.quota - b.quota, + defaultSortOrder: 'descend' + } + ] + }, + resourceColumns () { + return [ + { + title: this.$t('label.resource'), + dataIndex: 'displayname', + width: '50%', + sorter: (a, b) => a.displayname.localeCompare(b.displayname), + defaultSortOrder: 'ascend' + }, + { + title: this.$t('label.quota.consumed'), + dataIndex: 'quotaconsumed', + width: '50%', + sorter: (a, b) => a.quotaconsumed - b.quotaconsumed + } + ] + }, + resourceDetailsColumns () { + return [ + { + title: this.$t('label.quota.tariff'), + dataIndex: 'tariffname', + sorter: (a, b) => a.tariffname.localeCompare(b.tariffname) + }, + { + title: this.$t('label.start.date'), + dataIndex: 'startdate', + sorter: (a, b) => a.startdate.localeCompare(b.startdate), + defaultSortOrder: 'descend' + }, + { + title: this.$t('label.end.date'), + dataIndex: 'enddate', + sorter: (a, b) => a.enddate.localeCompare(b.enddate) + }, + { + title: this.$t('label.quota.consumed'), + dataIndex: 'quotaconsumed', + 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.dataSourceTariffs = [] + 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) + if (this.dataSource.length === 0) { + this.$notification.info({ message: this.$t('message.request.no.data') }) + } + + this.currency = quotaStatement.currency + this.totalQuota = quotaStatement.totalquota + if (this.graphType !== 'bar_chart') { + this.prepareDataForUsageTypeLineGraph() + } + } 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.filter(row => row.quotaconsumed !== 0) + if (this.dataSourceResource.length === 0) { + this.$notification.info({ message: this.$t('message.request.no.data') }) + } + + if (this.graphType !== 'bar_chart') { + this.prepareDataForResourceLineGraph() + } + } finally { + this.loadingResources = false + } + }, + async fetchTariffData () { + if (this.selectedResource === '' || this.loadingTariffs) return + + this.dataSourceTariffs = [] + this.loadingTariffs = true + + try { + const quotaResourceStatement = await getAPI('quotaResourceStatement', { + startdate: this.startDate, + enddate: this.endDate, + usagetype: this.selectedType.split('-')[0], + id: this.selectedResource, + accountid: this.$route.params?.id, + ignoreproject: true + }).then(json => json.quotaresourcestatementresponse?.quotaresourcestatement?.items || []) + + this.dataSourceTariffs = quotaResourceStatement.map(quotaUsage => ({ + ...quotaUsage, + startdate: dateUtils.parseDayJsObject({ value: quotaUsage.startdate, keepMoment: false }), + enddate: dateUtils.parseDayJsObject({ value: quotaUsage.enddate, keepMoment: false }) + })).filter(row => row.quotaconsumed !== 0) + if (this.dataSourceTariffs.length === 0) { + this.$notification.info({ message: this.$t('message.request.no.data') }) + } + + if (this.graphType !== 'bar_chart') { + this.prepareDataForTariffLineGraph() + } + } finally { + this.loadingTariffs = false + } + }, + async getQuotaStatement (apiParams) { + const params = { + ignoreproject: true, + accountid: this.$route.params?.id, + ...apiParams + } + + return await getAPI('quotaStatement', params) + .then(json => json.quotastatementresponse.statement || {}) + }, + getBarChartOptions () { + 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 } + }, + getResourceBarChartData () { + 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 } + }, + getTariffsBarChartData () { + const aggregatedTariffs = this.aggregateTariffQuotas() + const datasets = [] + for (const key in aggregatedTariffs) { + datasets.push({ + label: key, + data: [aggregatedTariffs[key]], + ...this.getColor({ tariffname: key }) + }) + } + return { labels: [this.$t('label.quota.tariff')], datasets } + }, + aggregateTariffQuotas () { + const tariffs = {} + for (const row of this.dataSourceTariffs) { + const currentValue = tariffs[row.tariffname] ?? 0 + tariffs[row.tariffname] = currentValue + row.quotaconsumed + } + return tariffs + }, + setUsageTypeLineChartData () { + this.usageLineChartLabels = this.getLineChartLabelsForData(this.dataSource) + this.usageLineChartData = this.prepareLineChartData(this.dataSource, this.usageLineChartLabels) + }, + setResourceLineChartData () { + this.resourceLineChartLabels = this.getLineChartLabelsForData(this.dataSourceResource) + this.resourceLineChartData = this.prepareLineChartData(this.dataSourceResource, this.resourceLineChartLabels) + }, + setTariffLineChartData () { + this.dataSourceTariffs.sort((a, b) => new Date(a.enddate) - new Date(b.enddate)) + const usageGroupedByTariffName = this.groupUsageByTariffName() + + const transformedData = Object.values(usageGroupedByTariffName) + this.tariffLineChartLabels = this.getLineChartLabelsForData(transformedData) + this.tariffLineChartData = this.prepareLineChartData(transformedData, this.tariffLineChartLabels) + }, + groupUsageByTariffName () { + const groupedData = {} + this.dataSourceTariffs.forEach((obj) => { + if (!(obj.tariffname in groupedData)) { + groupedData[obj.tariffname] = { tariffname: obj.tariffname, history: [] } + } + groupedData[obj.tariffname].history.push(obj) + }) + return groupedData + }, + getLineChartLabelForDate (date) { + return this.$toLocalDate(date) + }, + getLineChartLabelsForData (data) { + const lineChartLabels = [this.getLineChartLabelForDate(this.startDate)] + + for (const row of data) { + let isPreviousZero = true + for (let i = 0; i < row.history.length; i++) { + const item = row.history[i] + const isCurrentZero = item.quotaconsumed === 0 + + if (isCurrentZero && isPreviousZero) { + continue + } + + if (isPreviousZero) { + // Last was zero, but we are not. Push our startdate to have an accurate curve + this.pushDateToLabelsIfNotPresent(lineChartLabels, this.getLineChartLabelForDate(item.startdate)) + } + + this.pushDateToLabelsIfNotPresent(lineChartLabels, this.getLineChartLabelForDate(item.enddate)) + isPreviousZero = isCurrentZero + } + } + + this.pushDateToLabelsIfNotPresent(lineChartLabels, this.getLineChartLabelForDate(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 // Do not push the label if there is already one within 5 minutes Review Comment: The code checks for labels within `5 * 1000` ms (5 seconds) but the comment says "5 minutes". This is misleading during maintenance/debugging; either update the comment or the constant. ########## ui/src/views/AutogenView.vue: ########## @@ -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) + } Review Comment: If `hasActivationRule` is not present in `this.columns`, `findIndex` returns `-1` and `splice(-1, 1)` will remove the last column unintentionally. Guard the splice with `index >= 0`. ########## ui/src/views/plugins/quota/QuotaCreditTab.vue: ########## @@ -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 || {}) Review Comment: When `quotaCreditsList` returns no `credit` key (common for empty list responses), this code returns `{}` and `fetchData()` will crash on `data.map(...)`. Default to an empty array instead. ########## ui/src/views/plugins/quota/AddQuotaCredit.vue: ########## @@ -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) + }, Review Comment: Debug `console.log` statements were left in the component lifecycle hook; these will leak internal state to the browser console and add noise for users. ########## ui/src/utils/export.js: ########## @@ -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) { Review Comment: `dayjs` is a function, so `item[key] instanceof dayjs` will never be true. This prevents `dateFormat` from being applied. Use `dayjs.isDayjs(item[key])` instead. ########## plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java: ########## @@ -661,49 +699,77 @@ protected void validateEndDateOnCreatingNewQuotaTariff(QuotaTariffVO newQuotaTar } @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); + 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); - 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, + BigDecimal currentAccountBalance = _quotaBalanceDao.getLastQuotaBalance(accountId, domainId); + logger.debug("Depositing [{}] credits on adjusted date [{}]; current balance is [{}].", value, DateUtil.displayDateInTimezone(QuotaManagerImpl.getUsageAggregationTimeZone(), depositedOn), currentAccountBalance); - // update quota account with the balance _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) { - 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); - } + 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; } - UserVO creditor = getCreditorForQuotaCredits(result); - return createQuotaCreditsResponse(result, creditor); + if (enforceQuota && account.getState() == Account.State.ENABLED && _quotaManager.isLockable(account)) { Review Comment: `enforceQuota` can be null (parameter is optional). `if (enforceQuota && ...)` will throw a NullPointerException due to auto-unboxing. Also, historical behavior implies null should behave like "enforced" unless explicitly false. ########## plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsCmd.java: ########## @@ -100,42 +112,28 @@ public void setValue(Double value) { 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); } @Override public long getEntityOwnerId() { - Account account = _accountService.getActiveAccountByName(accountName, domainId); - if (account != null) { - return account.getAccountId(); - } return Account.ACCOUNT_ID_SYSTEM; } Review Comment: `getEntityOwnerId()` always returns `Account.ACCOUNT_ID_SYSTEM`, even when `accountid`/`projectid` (or deprecated `account`/`domainid`) are provided. This is inconsistent with other quota commands (e.g. `QuotaBalanceCmd`) and can break event ownership/auditing and potentially ACL behavior. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
