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]


Reply via email to