This is an automated email from the ASF dual-hosted git repository.

rohit pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new 5baac44139d ui: add UI too to view and download usage records (#8615)
5baac44139d is described below

commit 5baac44139d57d13541393974fe3192666509321
Author: Vishesh <[email protected]>
AuthorDate: Tue Jul 30 14:38:17 2024 +0530

    ui: add UI too to view and download usage records (#8615)
    
    This PR adds a new UI tool for admins for viewing and downloading usage 
records.
    
    This PR also makes startdate and enddate as non required params for 
generateUsageRecords. (Fixes: #7133)
---
 .../admin/usage/GenerateUsageRecordsCmd.java       |   4 +-
 ui/public/locales/en.json                          |  31 +
 ui/src/components/view/ListView.vue                |  49 +-
 ui/src/components/widgets/Status.vue               |   6 +
 ui/src/config/router.js                            |   1 -
 ui/src/config/section/tools.js                     |   8 +
 ui/src/core/lazy_lib/icons_use.js                  |   2 +
 ui/src/utils/util.js                               |  24 +
 ui/src/views/AutogenView.vue                       |   2 +-
 ui/src/views/iam/RolePermissionTab.vue             |  27 +-
 ui/src/views/infra/UsageRecords.vue                | 834 +++++++++++++++++++++
 ui/src/views/tools/ManageInstances.vue             |  38 +-
 12 files changed, 983 insertions(+), 43 deletions(-)

diff --git 
a/api/src/main/java/org/apache/cloudstack/api/command/admin/usage/GenerateUsageRecordsCmd.java
 
b/api/src/main/java/org/apache/cloudstack/api/command/admin/usage/GenerateUsageRecordsCmd.java
index 491b0fe85ba..a0314586d92 100644
--- 
a/api/src/main/java/org/apache/cloudstack/api/command/admin/usage/GenerateUsageRecordsCmd.java
+++ 
b/api/src/main/java/org/apache/cloudstack/api/command/admin/usage/GenerateUsageRecordsCmd.java
@@ -47,13 +47,13 @@ public class GenerateUsageRecordsCmd extends BaseCmd {
 
     @Parameter(name = ApiConstants.END_DATE,
                type = CommandType.DATE,
-               required = true,
+               required = false,
                description = "End date range for usage record query. Use 
yyyy-MM-dd as the date format, e.g. startDate=2009-06-03.")
     private Date endDate;
 
     @Parameter(name = ApiConstants.START_DATE,
                type = CommandType.DATE,
-               required = true,
+               required = false,
                description = "Start date range for usage record query. Use 
yyyy-MM-dd as the date format, e.g. startDate=2009-06-01.")
     private Date startDate;
 
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 253f20294f5..8f2c9fa6d15 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -618,6 +618,7 @@
 "label.datetime.filter.starting": "Starting <b>{startDate}</b>.",
 "label.datetime.filter.up.to": "Up to <b>{endDate}</b>.",
 "label.day": "Day",
+"label.days": "Days",
 "label.day.of.month": "Day of month",
 "label.day.of.week": "Day of week",
 "label.db.usage.metrics": "DB/Usage server",
@@ -806,6 +807,7 @@
 "label.done": "Done",
 "label.down": "Down",
 "label.download": "Download",
+"label.download.csv": "Download CSV",
 "label.download.kubeconfig.cluster": "Download kubeconfig for the cluster 
<br><br> The <code><b>kubectl</b></code> command-line tool uses kubeconfig 
files to find the information it needs to choose a cluster and communicate with 
the API server of a cluster.",
 "label.download.kubectl": "Download <code><b>kubectl</b></code> tool for 
cluster's Kubernetes version",
 "label.download.kubernetes.cluster.config": "Download Kubernetes cluster 
config",
@@ -924,6 +926,7 @@
 "label.fetch.instances": "Fetch Instances",
 "label.fetch.latest": "Fetch latest",
 "label.filename": "File Name",
+"label.fetched": "Fetched",
 "label.files": "Alternate files to retrieve",
 "label.filter": "Filter",
 "label.filter.annotations.all": "All comments",
@@ -1229,6 +1232,8 @@
 "label.label": "Label",
 "label.last.updated": "Last update",
 "label.lastannotated": "Last annotation date",
+"label.lastheartbeat": "Last heartbeat",
+"label.lastsuccessfuljob": "Last successful job",
 "label.lastboottime": "Boot time of the management server machine",
 "label.lastname": "Last name",
 "label.lastname.lower": "lastname",
@@ -1483,6 +1488,7 @@
 "label.no.items": "No available Items",
 "label.no.matching.offering": "No matching offering found",
 "label.no.matching.network": "No matching Networks found",
+"label.no.usage.records": "No usage records found",
 "label.noderootdisksize": "Node root disk size (in GB)",
 "label.nodiskcache": "No disk cache",
 "label.none": "None",
@@ -1523,6 +1529,7 @@
 "label.of": "of",
 "label.of.month": "of month",
 "label.offerha": "Offer HA",
+"label.offeringid": "Offering ID",
 "label.offeringtype": "Compute offering type",
 "label.ok": "OK",
 "label.only.end.date.and.time": "Only end date and time",
@@ -1700,6 +1707,8 @@
 "label.publicnetwork": "Public Network",
 "label.publicport": "Public port",
 "label.purgeresources": "Purge Resources",
+"label.purge.usage.records.success": "Successfuly purged usage records",
+"label.purge.usage.records.error": "Failed while purging usage records",
 "label.purpose": "Purpose",
 "label.qostype": "QoS type",
 "label.quickview": "Quick view",
@@ -1731,7 +1740,14 @@
 "label.rados.secret": "RADOS secret",
 "label.rados.user": "RADOS user",
 "label.ram": "RAM",
+"label.range.today": "Today",
+"label.range.yesterday": "Yesterday",
+"label.range.last.1week": "Last 1 week",
+"label.range.last.2week": "Last 2 weeks",
+"label.range.last.1month": "Last 1 month",
+"label.range.last.3month": "Last 3 months",
 "label.raw.data": "Raw data",
+"label.rawusage": "Raw usage (in hours)",
 "label.rbd": "RBD",
 "label.rbdid": "Cephx user",
 "label.rbdmonitor": "Ceph monitor",
@@ -1975,6 +1991,7 @@
 "label.sharedrouteripv6": "IPv6 address for the VR in this shared Network.",
 "label.sharewith": "Share with",
 "label.showing": "Showing",
+"label.show.usage.records": "Show usage records",
 "label.shrinkok": "Shrink OK",
 "label.shutdown": "Shutdown",
 "label.shutdown.provider": "Shutdown provider",
@@ -2283,8 +2300,22 @@
 "label.upload.volume.from.url": "Upload volume from URL",
 "label.url": "URL",
 "label.usage.explanation": "Note: Only the usage server that owns the active 
usage job is shown here.",
+"label.usage": "Usage",
+"label.usage.records.downloading": "Downloading usage records",
+"label.usage.records.fetch.child.domains": "Fetch usage records for child 
domains",
+"label.usage.records.usagetype.required": "Usage type is required with 
resource ID",
+"label.usage.records.generate": "Generate usage records",
+"label.usage.records.generate.after": "Usage records will be created for the 
period after ",
+"label.usage.records.generated": "A job has been created to generate usage 
records.",
+"label.usage.records.generate.description": "If the scheduled usage job was 
not run or failed, this will generate records(only if there any records to be 
generated)",
+"label.usage.records.purge": "Purge usage records",
+"label.usage.records.purge.days": "Purge records older than",
+"label.usage.records.purge.days.description": "Purge records older than the 
specified number of days.",
+"label.usage.records.purge.alert": "Purging usage records will permanently 
delete the records from the database. Depending on the data being deleted, this 
can increase load on the database and may take a while. Are you sure you want 
to continue?",
+"label.usageid": "Resource ID",
 "label.usageinterface": "Usage interface",
 "label.usagename": "Usage type",
+"label.usagetype": "Usage type",
 "label.usageunit": "Unit",
 "label.usageislocal": "A Usage Server is installed locally",
 "label.usagetypedescription": "Usage description",
diff --git a/ui/src/components/view/ListView.vue 
b/ui/src/components/view/ListView.vue
index c24f23371cf..2a379b5bf52 100644
--- a/ui/src/components/view/ListView.vue
+++ b/ui/src/components/view/ListView.vue
@@ -25,6 +25,7 @@
     :pagination="false"
     :rowSelection="explicitlyAllowRowSelection || enableGroupAction() || 
$route.name === 'event' ? {selectedRowKeys: selectedRowKeys, onChange: 
onSelectChange, columnWidth: 30} : null"
     :rowClassName="getRowClassName"
+    @resizeColumn="handleResizeColumn"
     style="overflow-y: auto"
   >
     <template #customFilterDropdown>
@@ -98,6 +99,9 @@
       <template v-if="column.key === 'templatetype'">
         <span>{{ text }}</span>
       </template>
+      <template v-if="column.key === 'templateid'">
+        <router-link :to="{ path: '/template/' + record.templateid }">{{ text 
}}</router-link>
+      </template>
       <template v-if="column.key === 'type'">
         <span v-if="['USER.LOGIN', 'USER.LOGOUT', 'ROUTER.HEALTH.CHECKS', 
'FIREWALL.CLOSE', 'ALERT.SERVICE.DOMAINROUTER'].includes(text)">{{ 
$t(text.toLowerCase()) }}</span>
         <span v-else>{{ text }}</span>
@@ -245,7 +249,7 @@
       </template>
       <template v-if="column.key === 'vpcname'">
         <a v-if="record.vpcid">
-          <router-link :to="{ path: '/vpc/' + record.vpcid }">{{ text 
}}</router-link>
+          <router-link :to="{ path: '/vpc/' + record.vpcid }">{{ text || 
record.vpcid }}</router-link>
         </a>
         <span v-else>{{ text }}</span>
       </template>
@@ -276,6 +280,9 @@
       <template v-if="column.key === 'level'">
         <router-link :to="{ path: '/event/' + record.id }">{{ text 
}}</router-link>
       </template>
+      <template v-if="column.key === 'usageType'">
+        {{ usageTypeMap[record.usagetype] }}
+      </template>
 
       <template v-if="column.key === 'clustername'">
         <router-link :to="{ path: '/cluster/' + record.clusterid }">{{ text 
}}</router-link>
@@ -319,7 +326,7 @@
         <span v-else>{{ text }}</span>
       </template>
       <template v-if="column.key === 'zone'">
-        <router-link v-if="record.zoneid && !record.zoneid.includes(',') && 
$router.resolve('/zone/' + record.zoneid).matched[0].redirect !== 
'/exception/404'" :to="{ path: '/zone/' + record.zoneid }">{{ text 
}}</router-link>
+        <router-link v-if="record.zoneid && !record.zoneid.includes(',') && 
$router.resolve('/zone/' + record.zoneid).matched[0].redirect !== 
'/exception/404'" :to="{ path: '/zone/' + record.zoneid }">{{ text || 
record.zoneid }}</router-link>
         <span v-else>{{ text }}</span>
       </template>
       <template v-if="column.key === 'zonename'">
@@ -374,6 +381,9 @@
       <template v-if="column.key === 'payloadurl'">
         <copy-label :label="text" />
       </template>
+      <template v-if="column.key === 'usageid'">
+        <copy-label :label="text" />
+      </template>
       <template v-if="column.key === 'eventtype'">
         <router-link v-if="$router.resolve('/event/' + 
record.eventid).matched[0].redirect !== '/exception/404'" :to="{ path: 
'/event/' + record.eventid }">{{ text }}</router-link>
         <span v-else>{{ text }}</span>
@@ -406,6 +416,9 @@
       <template v-if="column.key === 'duration' && ['webhook', 
'webhookdeliveries'].includes($route.path.split('/')[1])">
         <span>  {{ getDuration(record.startdate, record.enddate) }} </span>
       </template>
+      <template v-if="['startdate', 'enddate'].includes(column.key) && 
['usage'].includes($route.path.split('/')[1])">
+        {{ $toLocaleDate(text.replace('\'T\'', ' ')) }}
+      </template>
       <template v-if="column.key === 'order'">
         <div class="shift-btns">
           <a-tooltip :name="text" placement="top">
@@ -482,6 +495,13 @@
           icon="reload-outlined"
           :disabled="!('updateConfiguration' in $store.getters.apis)" />
       </template>
+      <template v-if="column.key === 'usageActions'">
+        <tooltip-button
+          :tooltip="$t('label.view')"
+          icon="search-outlined"
+          @onClick="$emit('view-usage-record', record)" />
+        <slot></slot>
+      </template>
       <template v-if="column.key === 'tariffActions'">
         <tooltip-button
           :tooltip="$t('label.edit')"
@@ -618,6 +638,7 @@ export default {
           disable: 'storageallocateddisablethreshold'
         }
       },
+      usageTypeMap: {},
       resourceIdToValidLinksMap: {}
     }
   },
@@ -632,6 +653,9 @@ export default {
       }
     }
   },
+  created () {
+    this.getUsageTypes()
+  },
   computed: {
     hasSelected () {
       return this.selectedRowKeys.length > 0
@@ -942,6 +966,9 @@ export default {
       }
       return name
     },
+    handleResizeColumn (w, col) {
+      col.width = w
+    },
     updateSelectedColumns (name) {
       this.$emit('update-selected-columns', name)
     },
@@ -965,6 +992,24 @@ export default {
       }
       var duration = Date.parse(enddate) - Date.parse(startdate)
       return (duration > 0 ? duration / 1000.0 : 0) + ''
+    },
+    getUsageTypes () {
+      if (this.$route.path.split('/')[1] === 'usage') {
+        api('listUsageTypes').then(json => {
+          if (json && json.listusagetypesresponse && 
json.listusagetypesresponse.usagetype) {
+            this.usageTypes = json.listusagetypesresponse.usagetype.map(x => {
+              return {
+                id: x.usagetypeid,
+                value: x.description
+              }
+            })
+            this.usageTypeMap = {}
+            for (var usageType of this.usageTypes) {
+              this.usageTypeMap[usageType.id] = usageType.value
+            }
+          }
+        })
+      }
     }
   }
 }
diff --git a/ui/src/components/widgets/Status.vue 
b/ui/src/components/widgets/Status.vue
index 22b7849aa61..50340c2faa2 100644
--- a/ui/src/components/widgets/Status.vue
+++ b/ui/src/components/widgets/Status.vue
@@ -87,6 +87,12 @@ export default {
           case 'InProgress':
             state = this.$t('state.inprogress')
             break
+          case 'Down':
+            state = this.$t('state.down')
+            break
+          case 'Up':
+            state = this.$t('state.up')
+            break
         }
         return state.charAt(0).toUpperCase() + state.slice(1)
       }
diff --git a/ui/src/config/router.js b/ui/src/config/router.js
index 9d9cd0d4491..90fae577ce4 100644
--- a/ui/src/config/router.js
+++ b/ui/src/config/router.js
@@ -224,7 +224,6 @@ export function asyncRouterMap () {
       generateRouterMap(tools),
       generateRouterMap(quota),
       generateRouterMap(cloudian),
-
       {
         path: '/exception',
         name: 'exception',
diff --git a/ui/src/config/section/tools.js b/ui/src/config/section/tools.js
index 3c326f2f50e..a07228ca87b 100644
--- a/ui/src/config/section/tools.js
+++ b/ui/src/config/section/tools.js
@@ -61,6 +61,14 @@ export default {
         }
       ]
     },
+    {
+      name: 'usage',
+      title: 'label.usage',
+      icon: 'ContainerOutlined',
+      permission: ['listUsageRecords'],
+      meta: { title: 'label.usage', icon: 'ContainerOutlined' },
+      component: () => import('@/views/infra/UsageRecords.vue')
+    },
     {
       name: 'manageinstances',
       title: 'label.action.import.export.instances',
diff --git a/ui/src/core/lazy_lib/icons_use.js 
b/ui/src/core/lazy_lib/icons_use.js
index f3b2491a689..d877fe8bf23 100644
--- a/ui/src/core/lazy_lib/icons_use.js
+++ b/ui/src/core/lazy_lib/icons_use.js
@@ -56,6 +56,7 @@ import {
   ClusterOutlined,
   CodeOutlined,
   CompassOutlined,
+  ContainerOutlined,
   ControlOutlined,
   CopyOutlined,
   CreditCardOutlined,
@@ -220,6 +221,7 @@ export default {
     app.component('CloudUploadOutlined', CloudUploadOutlined)
     app.component('ClusterOutlined', ClusterOutlined)
     app.component('CodeOutlined', CodeOutlined)
+    app.component('ContainerOutlined', ContainerOutlined)
     app.component('ControlOutlined', ControlOutlined)
     app.component('CompassOutlined', CompassOutlined)
     app.component('CopyOutlined', CopyOutlined)
diff --git a/ui/src/utils/util.js b/ui/src/utils/util.js
index a55d05dbd8f..fad4d1e0f5d 100644
--- a/ui/src/utils/util.js
+++ b/ui/src/utils/util.js
@@ -68,3 +68,27 @@ export function sanitizeReverse (value) {
     .replace(/&lt;/g, '<')
     .replace(/&gt;/g, '>')
 }
+
+export function toCsv ({ keys = null, data = null, columnDelimiter = ',', 
lineDelimiter = '\n' }) {
+  if (data === null || !data.length) {
+    return null
+  }
+
+  let result = ''
+  result += keys.join(columnDelimiter)
+  result += lineDelimiter
+
+  data.forEach(item => {
+    keys.forEach(key => {
+      if (item[key] === undefined) {
+        item[key] = ''
+      }
+      result += typeof item[key] === 'string' && 
item[key].includes(columnDelimiter) ? `"${item[key]}"` : item[key]
+      result += columnDelimiter
+    })
+    result = result.slice(0, -1)
+    result += lineDelimiter
+  })
+
+  return result
+}
diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue
index 76458a186a3..36eb6d4de1d 100644
--- a/ui/src/views/AutogenView.vue
+++ b/ui/src/views/AutogenView.vue
@@ -738,7 +738,7 @@ export default {
       })
     },
     fetchData (params = {}) {
-      if (this.$route.name === 'deployVirtualMachine') {
+      if (['deployVirtualMachine', 'usage'].includes(this.$route.name)) {
         return
       }
       if (this.routeName !== this.$route.name) {
diff --git a/ui/src/views/iam/RolePermissionTab.vue 
b/ui/src/views/iam/RolePermissionTab.vue
index 49a9e982361..6d2bd71b284 100644
--- a/ui/src/views/iam/RolePermissionTab.vue
+++ b/ui/src/views/iam/RolePermissionTab.vue
@@ -111,6 +111,7 @@ import draggable from 'vuedraggable'
 import PermissionEditable from './PermissionEditable'
 import RuleDelete from './RuleDelete'
 import TooltipButton from '@/components/widgets/TooltipButton'
+import { toCsv } from '@/utils/util.js'
 
 export default {
   name: 'RolePermissionTab',
@@ -249,32 +250,8 @@ export default {
         this.updateTable = false
       })
     },
-    rulesDataToCsv ({ data = null, columnDelimiter = ',', lineDelimiter = '\n' 
}) {
-      if (data === null || !data.length) {
-        return null
-      }
-
-      const keys = ['rule', 'permission', 'description']
-      let result = ''
-      result += keys.join(columnDelimiter)
-      result += lineDelimiter
-
-      data.forEach(item => {
-        keys.forEach(key => {
-          if (item[key] === undefined) {
-            item[key] = ''
-          }
-          result += typeof item[key] === 'string' && 
item[key].includes(columnDelimiter) ? `"${item[key]}"` : item[key]
-          result += columnDelimiter
-        })
-        result = result.slice(0, -1)
-        result += lineDelimiter
-      })
-
-      return result
-    },
     exportRolePermissions () {
-      const rulesCsvData = this.rulesDataToCsv({ data: this.rules })
+      const rulesCsvData = toCsv({ keys: ['rule', 'permission', 
'description'], data: this.rules })
       const hiddenElement = document.createElement('a')
       hiddenElement.href = 'data:text/csv;charset=utf-8,' + 
encodeURI(rulesCsvData)
       hiddenElement.target = '_blank'
diff --git a/ui/src/views/infra/UsageRecords.vue 
b/ui/src/views/infra/UsageRecords.vue
new file mode 100644
index 00000000000..3ecfff5dc3b
--- /dev/null
+++ b/ui/src/views/infra/UsageRecords.vue
@@ -0,0 +1,834 @@
+// 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-affix :offsetTop="this.$store.getters.shutdownTriggered ? 103 : 78">
+    <a-card class="breadcrumb-card">
+      <a-row>
+        <a-col
+          :span="device === 'mobile' ? 24 : 12"
+          style="padding-left: 12px; margin-top: 10px"
+        >
+          <breadcrumb :resource="resource">
+            <template #end>
+              <a-tooltip placement="bottom">
+                <template #title>{{ $t('label.refresh') }}</template>
+                <a-button
+                  style="margin-top: 4px"
+                  :loading="serverMetricsLoading"
+                  shape="round"
+                  size="small"
+                  @click="fetchData(); listUsageRecords()"
+                >
+                  <template #icon>
+                    <ReloadOutlined />
+                  </template>
+                  {{ $t('label.refresh') }}
+                </a-button>
+              </a-tooltip>
+            </template>
+          </breadcrumb>
+        </a-col>
+        <a-col
+          :span="device === 'mobile' ? 24 : 12"
+          :style="device === 'mobile' ? { float: 'right', 'margin-top': 
'12px', 'margin-bottom': '-6px', display: 'table' } : { float: 'right', 
display: 'table', 'margin-top': '6px' }"
+        >
+          <a-row justify="end">
+            <a-col>
+              <tooltip-button
+                type="primary"
+                icon="hdd-outlined"
+                :tooltip="$t('label.usage.records.generate')"
+                @onClick="generateModal = true"
+              />
+            </a-col>&nbsp;&nbsp;
+            <a-col>
+              <tooltip-button
+                type="danger"
+                icon="delete-outlined"
+                :tooltip="$t('label.usage.records.purge')"
+                @onClick="() => purgeModal = true"
+              />
+            </a-col>
+          </a-row>
+        </a-col>
+      </a-row>
+    </a-card>
+  </a-affix>
+  <a-col>
+    <a-card size="small" :loading="serverMetricsLoading">
+      <a-row justify="space-around">
+        <a-card-grid style="width: 30%; text-align: center; font-size: small;">
+          <a-statistic
+            :title="$t('label.server')"
+            :value="serverStats.hostname"
+            valueStyle="font-size: medium"
+          >
+            <template #prefix>
+              <status :text="serverStats.state || ''" />
+            </template>
+          </a-statistic>
+        </a-card-grid>
+        <a-card-grid style="width: 35%; text-align: center; font-size: small;">
+          <a-statistic
+            :title="$t('label.lastheartbeat')"
+            :value="$toLocaleDate(serverStats.lastheartbeat)"
+            valueStyle="font-size: medium"
+          />
+          <a-card-meta :description="getTimeSince(serverStats.collectiontime)" 
/>
+        </a-card-grid>
+        <a-card-grid style="width: 35%; text-align: center; font-size: small;">
+          <a-statistic
+            :title="$t('label.lastsuccessfuljob')"
+            :value="$toLocaleDate(serverStats.lastsuccessfuljob)"
+            valueStyle="font-size: medium"
+          />
+          <a-card-meta 
:description="getTimeSince(serverStats.lastsuccessfuljob)" />
+        </a-card-grid>
+      </a-row>
+    </a-card>
+  </a-col>
+  <a-row justify="space-between">
+    <a-col :span="24">
+      <a-card>
+        <a-form
+          :ref="formRef"
+          :model="form"
+          :rules="rules"
+          layout="inline"
+          @finish="handleSearch"
+        >
+          <a-col :span="4">
+            <a-row>
+              <a-col :span="24">
+                <a-form-item
+                  ref="domain"
+                  name="domain"
+                >
+                  <a-auto-complete
+                    v-model:value="form.domain"
+                    :options="domains"
+                    :placeholder="$t('label.domain')"
+                    :filter-option="filterOption"
+                    style="width: 100%;"
+                    @select="getAccounts"
+                    :dropdownMatchSelectWidth="false"
+                  />
+                </a-form-item>
+              </a-col>
+            </a-row>&nbsp;
+            <a-row>
+              <a-col :span="24">
+                <a-form-item
+                  ref="isRecursive"
+                  name="isRecursive"
+                >
+                  <a-checkbox v-model:checked="form.isRecursive">{{ 
$t('label.usage.records.fetch.child.domains')
+                  }}</a-checkbox>
+                </a-form-item>
+              </a-col>
+            </a-row>
+          </a-col>
+          <a-col :span="3">
+            <a-form-item
+              ref="account"
+              name="account"
+            >
+            <a-auto-complete
+                v-model:value="form.account"
+                :options="accounts"
+                :placeholder="$t('label.account')"
+                :filter-option="filterOption"
+                :disabled="form.isRecursive"
+                :dropdownMatchSelectWidth="false"
+                @select="selectAccount"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="3">
+            <a-form-item
+              ref="type"
+              name="type"
+            >
+              <a-select
+                v-model:value="form.type"
+                :options="usageTypes"
+                :placeholder="$t('label.usagetype')"
+                :filterOption="filterOption"
+                @select="selectUsageType"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="3">
+            <a-form-item
+              ref="id"
+              name="id"
+            >
+              <a-input
+                v-model:value="form.id"
+                :placeholder="$t('label.resourceid')"
+                :allowClear="true"
+                @change="handleResourceIdChange"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="4">
+            <a-form-item
+              ref="dateRange"
+              name="dateRange"
+            >
+              <a-range-picker
+                :ranges="rangePresets"
+                v-model:value="form.dateRange"
+                :disabled-date="disabledDate"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col>
+            <a-row justify="space-between">
+              <a-form-item>
+                <a-button
+                  type="primary"
+                  html-type="submit"
+                  @click="handleSearch"
+                  :loading="loading"
+                >
+                  <search-outlined />
+                  {{ $t('label.show.usage.records') }}
+                </a-button>
+              </a-form-item>
+              <a-form-item>
+                <a-button
+                  type="primary"
+                  @click="downloadRecords"
+                  :loading="loading"
+                >
+                  <download-outlined />
+                  {{ $t('label.download.csv') }}
+                </a-button>
+              </a-form-item>
+              <a-form-item>
+                <a-button @click="clearFilters">
+                  {{ $t('label.clear') }}
+                </a-button>
+              </a-form-item>
+            </a-row>
+          </a-col>
+        </a-form>
+      </a-card>
+    </a-col>
+  </a-row>
+  <a-row justify="space-around">
+    <a-col :span="24">
+      <list-view
+        :loading="tableLoading"
+        :columns="columns"
+        :items="usageRecords"
+        :columnKeys="columnKeys"
+        :selectedColumns="selectedColumnKeys"
+        ref="listview"
+        @update-selected-columns="updateSelectedColumns"
+        @view-usage-record="viewUsageRecord"
+        @refresh="this.fetchData"
+      />
+      <a-pagination
+        :current="page"
+        :pageSize="pageSize"
+        :total="totalUsageRecords"
+        :showTotal="total => `${$t('label.showing')} ${Math.min(total, 1 + 
((page - 1) * pageSize))}-${Math.min(page * pageSize, total)} ${$t('label.of')} 
${total} ${$t('label.items')}`"
+        :pageSizeOptions="['20', '50', '100']"
+        @change="handleTableChange"
+        :showSizeChanger="true"
+        :showQuickJumper="true"
+      >
+      </a-pagination>
+    </a-col>
+  </a-row>
+
+  <a-modal
+    :title="$t('label.usage.records.generate')"
+    :cancelText="$t('label.cancel')"
+    :closable="true"
+    :maskClosable="true"
+    :destroyOnClose="true"
+    :visible="generateModal"
+    @ok="generateUsageRecords"
+    @cancel="generateModal = false"
+  >
+    <a-alert
+      :message="$t('label.usage.records.generate.description')"
+      type="info"
+      show-icon
+    >
+    </a-alert>
+    <br/>
+    {{ $t('label.usage.records.generate.after') + 
$toLocaleDate(serverStats.lastsuccessfuljob) }}
+  </a-modal>
+
+  <a-modal
+    :title="$t('label.usage.records.purge')"
+    :visible="purgeModal"
+    :okText="$t('label.usage.records.purge')"
+    :okButtonProps="{ type: 'danger' }"
+    :cancelText="$t('label.cancel')"
+    :closable="true"
+    :maskClosable="true"
+    :destroyOnClose="true"
+    @ok="purgeUsageRecords"
+    @cancel="purgeModal = false"
+  >
+    <a-row>
+      <a-alert
+        :description="$t('label.usage.records.purge.alert')"
+        type="error"
+        show-icon
+      />
+
+    </a-row>
+    <br />
+    <a-row justify="space-between">
+      <tooltip-label
+        bold
+        :title="$t('label.usage.records.purge.days')"
+        :tooltip="$t('label.usage.records.purge.days.description')"
+      />
+      <a-input-number
+        :min="0"
+        v-model:value="purgeDays"
+        style="width: 128px;"
+      >
+        <template #addonAfter>{{ $t('label.days') }}</template>
+      </a-input-number>
+    </a-row>
+  </a-modal>
+  <a-modal
+    :title="$t('label.usage.records.downloading')"
+    :visible="downloadModal"
+    :closable="false"
+    :maskClosable="false"
+    :destroyOnClose="true"
+    :footer="null"
+  >
+    <a-progress
+      :percent="downloadPercent"
+      :status="downloadStatus"
+    />
+    <a-spin size="small" /> {{ [$t('label.fetched'), downloadedRecords, 
$t('label.of'), downloadTotalRecords,
+    $t('label.items')].join(' ') }}
+  </a-modal>
+  <a-modal
+    :visible="viewModal"
+    :cancelText="$t('label.close')"
+    :closable="true"
+    :maskClosable="true"
+    :okButtonProps="{ style: { display: 'none' } }"
+    :destroyOnClose="true"
+    width="50%"
+    @cancel="viewModal = false"
+  >
+    <pre style="text-align: start; white-space: break-spaces;">{{ 
JSON.stringify(recordView, null, 2) }}</pre>
+  </a-modal>
+</template>
+
+<script>
+import { ref, reactive, toRaw } from 'vue'
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+import utc from 'dayjs/plugin/utc'
+import { api } from '@/api'
+import { toCsv } from '@/utils/util.js'
+import { mixinForm } from '@/utils/mixin'
+
+import Breadcrumb from '@/components/widgets/Breadcrumb'
+import ChartCard from '@/components/widgets/ChartCard'
+import ListView from '@/components/view/ListView'
+import TooltipLabel from '@/components/widgets/TooltipLabel'
+import TooltipButton from '@/components/widgets/TooltipButton'
+import Status from '@/components/widgets/Status'
+
+dayjs.extend(relativeTime)
+dayjs.extend(utc)
+
+export default {
+  name: 'UsageRecords',
+  mixins: [mixinForm],
+  components: {
+    Breadcrumb,
+    ChartCard,
+    ListView,
+    Status,
+    TooltipLabel,
+    TooltipButton
+  },
+  props: {
+    resource: {
+      type: Object,
+      default: function () {
+        return {}
+      }
+    }
+  },
+  data () {
+    var selectedColumnKeys = ['account', 'domain', 'usageType', 'usageid', 
'startdate', 'enddate', 'rawusage', 'description']
+    return {
+      serverMetricsLoading: true,
+      serverStats: {},
+      loading: false,
+      tableLoading: false,
+      usageRecords: [],
+      totalUsageRecords: 0,
+      columnKeys: [...selectedColumnKeys,
+        'zone', 'virtualmachinename', 'cpunumber', 'cpuspeed', 'memory', 
'project', 'templateid', 'offeringid', 'size', 'type', 'vpcname'
+      ],
+      selectedColumnKeys: selectedColumnKeys,
+      selectedColumns: [],
+      columns: [],
+      page: 1,
+      pageSize: 20,
+      usageTypes: [],
+      domains: [],
+      accounts: [],
+      account: null,
+      domain: null,
+      usageType: null,
+      usageTypeMap: {},
+      usageRecordKeys: {},
+      generateModal: false,
+      downloadModal: false,
+      viewModal: false,
+      purgeModal: false,
+      purgeDays: ref(365),
+      downloadPercent: 0,
+      downloadedRecords: 0,
+      downloadTotalRecords: 0,
+      downloadStatus: 'active',
+      rangePresets: {},
+      recordView: {}
+    }
+  },
+  beforeCreate () {
+    this.apiParams = this.$getApiParams('listUsageRecords')
+  },
+  created () {
+    this.rangePresets[this.$t('label.range.today')] = [dayjs(), dayjs()]
+    this.rangePresets[this.$t('label.range.yesterday')] = [dayjs().add(-1, 
'd'), dayjs().add(-1, 'd')]
+    this.rangePresets[this.$t('label.range.last.1week')] = [dayjs().add(-1, 
'w'), dayjs()]
+    this.rangePresets[this.$t('label.range.last.2week')] = [dayjs().add(-2, 
'w'), dayjs()]
+    this.rangePresets[this.$t('label.range.last.1month')] = [dayjs().add(-1, 
'M'), dayjs()]
+    this.rangePresets[this.$t('label.range.last.3month')] = [dayjs().add(-90, 
'M'), dayjs()]
+    this.initForm()
+    this.fetchData()
+    this.updateColumns()
+  },
+  methods: {
+    clearFilters () {
+      this.formRef.value.resetFields()
+      this.rules.type = {}
+      this.domain = null
+      this.account = null
+      this.usageType = null
+      this.page = 1
+      this.pageSize = 20
+
+      this.getAccounts()
+    },
+    disabledDate (current) {
+      return current && current > dayjs().endOf('day')
+    },
+    filterOption (input, option) {
+      return option.value.toUpperCase().indexOf(input.toUpperCase()) >= 0
+    },
+    initForm () {
+      this.formRef = ref()
+      this.form = reactive({
+        domain: null,
+        account: null,
+        type: null,
+        id: null,
+        dateRange: [],
+        isRecursive: false
+      })
+      this.rules = reactive({
+        dateRange: [{ type: 'array', required: true, message: 
this.$t('label.required') }],
+        type: { type: 'string', required: false, message: 
this.$t('label.usage.records.usagetype.required') }
+      })
+    },
+    fetchData () {
+      this.listUsageServerMetrics()
+      this.getUsageTypes()
+      this.getAllUsageRecordColumns()
+      this.getDomains()
+      this.getAccounts()
+      if (!this.$store.getters.customColumns[this.$store.getters.userInfo.id]) 
{
+        this.$store.getters.customColumns[this.$store.getters.userInfo.id] = {}
+        
this.$store.getters.customColumns[this.$store.getters.userInfo.id][this.$route.path]
 = this.selectedColumnKeys
+      } else {
+        this.selectedColumnKeys = 
this.$store.getters.customColumns[this.$store.getters.userInfo.id][this.$route.path]
 || this.selectedColumnKeys
+        this.updateSelectedColumns()
+      }
+      this.updateSelectedColumns()
+    },
+    viewUsageRecord (record) {
+      this.viewModal = true
+      this.recordView = record
+    },
+    handleResourceIdChange () {
+      this.rules.type.required = this.form.id && this.form.id.trim()
+    },
+    handleTableChange (page, pageSize) {
+      if (this.pageSize !== pageSize) {
+        page = 1
+      }
+      if (this.page !== page || this.pageSize !== pageSize) {
+        this.page = page
+        this.pageSize = pageSize
+        this.listUsageRecords()
+        document.documentElement.scrollIntoView()
+      }
+    },
+    listUsageServerMetrics () {
+      this.serverMetricsLoading = true
+      api('listUsageServerMetrics').then(json => {
+        this.stats = []
+        if (json && json.listusageservermetricsresponse && 
json.listusageservermetricsresponse.usageMetrics) {
+          this.serverStats = json.listusageservermetricsresponse.usageMetrics
+        }
+      }).finally(f => {
+        this.serverMetricsLoading = false
+      })
+    },
+    handleSearch () {
+      if (this.loading) return
+      this.formRef.value.clearValidate()
+      this.formRef.value.validate().then(() => {
+        this.page = 1
+        this.listUsageRecords()
+      }).catch(error => {
+        this.formRef.value.scrollToField(error.errorFields[0].name)
+      })
+    },
+    selectAccount (value, option) {
+      if (option && option.id) {
+        this.account = option
+      } else {
+        this.account = null
+        if (this.formRef?.value) {
+          this.formRef.value.resetFields('account')
+        }
+      }
+    },
+    selectUsageType (value, option) {
+      if (option && option.id) {
+        this.usageType = option
+      } else {
+        this.usageType = null
+        if (this.formRef?.value) {
+          this.formRef.value.resetFields('type')
+        }
+      }
+    },
+    getDomains () {
+      api('listDomains', { listAll: true }).then(json => {
+        if (json && json.listdomainsresponse && 
json.listdomainsresponse.domain) {
+          this.domains = [{ id: null, value: '' }, 
...json.listdomainsresponse.domain.map(x => {
+            return {
+              id: x.id,
+              value: x.path
+            }
+          })]
+        }
+      })
+    },
+    getAccounts (value, option) {
+      var params = {
+        listAll: true
+      }
+      if (option && option.id) {
+        params.domainid = option.id
+        this.domain = option
+      } else {
+        this.domain = null
+        if (this.formRef?.value) {
+          this.formRef.value.resetFields('domain')
+        }
+      }
+      api('listAccounts', params).then(json => {
+        if (json && json.listaccountsresponse && 
json.listaccountsresponse.account) {
+          this.accounts = [{ id: null, value: '' }, 
...json.listaccountsresponse.account.map(x => {
+            return {
+              id: x.id,
+              value: x.name
+            }
+          })]
+        }
+      })
+    },
+    getParams (page, pageSize) {
+      const formRaw = toRaw(this.form)
+      const values = this.handleRemoveFields(formRaw)
+      var params = {
+        page: page || this.page,
+        pagesize: pageSize || this.pageSize
+      }
+      if (values.dateRange) {
+        if (this.$store.getters.usebrowsertimezone) {
+          params.startdate = 
dayjs.utc(dayjs(values.dateRange[0]).startOf('day')).format('YYYY-MM-DD 
HH:mm:ss')
+          params.enddate = 
dayjs.utc(dayjs(values.dateRange[0]).endOf('day')).format('YYYY-MM-DD HH:mm:ss')
+        } else {
+          params.startdate = 
dayjs(values.dateRange[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss')
+          params.enddate = 
dayjs(values.dateRange[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss')
+        }
+      }
+      if (values.domain) {
+        params.domainid = this.domain.id
+      }
+      if (values.account) {
+        params.accountid = this.account.id
+      }
+      if (values.type) {
+        params.type = this.usageType.id
+      }
+      if (values.isRecursive) {
+        params.isrecursive = true
+      }
+      if (values.id) {
+        params.usageid = values.id
+      }
+      return params
+    },
+    listUsageRecords () {
+      this.tableLoading = true
+      this.loading = true
+      var params = this.getParams()
+      if (!(params.startdate && params.enddate)) {
+        this.tableLoading = false
+        this.loading = false
+        return
+      }
+      api('listUsageRecords', params).then(json => {
+        if (json && json.listusagerecordsresponse) {
+          this.usageRecords = json?.listusagerecordsresponse?.usagerecord || []
+          this.totalUsageRecords = json?.listusagerecordsresponse?.count || 0
+          let count = 1
+          for (var record of this.usageRecords) {
+            // Set id to ensure a unique value of rowKey to avoid duplicates
+            record.id = count++
+          }
+        }
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(f => {
+        this.tableLoading = false
+        this.loading = false
+      })
+    },
+    getUsageTypes () {
+      api('listUsageTypes').then(json => {
+        if (json && json.listusagetypesresponse && 
json.listusagetypesresponse.usagetype) {
+          this.usageTypes = [{ id: null, value: '' }, 
...json.listusagetypesresponse.usagetype.map(x => {
+            return {
+              id: x.usagetypeid,
+              value: x.description
+            }
+          })]
+          this.usageTypeMap = {}
+          for (var usageType of this.usageTypes) {
+            this.usageTypeMap[usageType.id] = usageType.value
+          }
+        }
+      })
+    },
+    getTimeSince (date) {
+      if (date === undefined || date === null) {
+        return ''
+      }
+      return dayjs(date).fromNow()
+    },
+    updateSelectedColumns (key) {
+      if (this.selectedColumnKeys.includes(key)) {
+        this.selectedColumnKeys = this.selectedColumnKeys.filter(x => x !== 
key)
+      } else {
+        this.selectedColumnKeys.push(key)
+      }
+      this.updateColumns()
+      if (!this.$store.getters.customColumns[this.$store.getters.userInfo.id]) 
{
+        this.$store.getters.customColumns[this.$store.getters.userInfo.id] = {}
+      }
+      
this.$store.getters.customColumns[this.$store.getters.userInfo.id][this.$route.path]
 = this.selectedColumnKeys
+      this.$store.dispatch('SetCustomColumns', 
this.$store.getters.customColumns)
+    },
+    updateColumns () {
+      this.columns = []
+      for (var columnKey of this.columnKeys) {
+        if (!this.selectedColumnKeys.includes(columnKey)) continue
+        var title
+        var dataIndex = columnKey
+        var resizable = true
+        switch (columnKey) {
+          case 'templateid':
+            title = this.$t('label.templatename')
+            break
+          case 'startdate':
+            title = this.$t('label.start.date.and.time')
+            break
+          case 'enddate':
+            title = this.$t('label.end.date.and.time')
+            break
+          case 'usageActions':
+            title = this.$t('label.view')
+            break
+          case 'virtualmachinename':
+            dataIndex = 'name'
+            break
+          default:
+            title = this.$t('label.' + String(columnKey).toLowerCase())
+        }
+        this.columns.push({
+          key: columnKey,
+          title: title,
+          dataIndex: dataIndex,
+          resizable: resizable
+        })
+      }
+      this.columns.push({
+        key: 'usageActions',
+        title: this.$t('label.view'),
+        dataIndex: 'usageActions',
+        resizable: false
+      })
+      if (this.columns.length > 0) {
+        this.columns[this.columns.length - 1].customFilterDropdown = true
+      }
+    },
+    downloadRecords () {
+      if (this.loading) return
+      this.formRef.value.validate().then(() => {
+        this.downloadModal = true
+        this.downloadPercent = 0
+        this.downloadStatus = 'active'
+        this.loading = true
+        var params = this.getParams(1, 0) // to get count
+        api('listUsageRecords', params).then(json => {
+          if (Object.getOwnPropertyNames(json.listusagerecordsresponse).length 
=== 0 || json.listusagerecordsresponse.count === 0) {
+            this.$notifyError({
+              response: { data: null },
+              message: this.$t('label.no.usage.records')
+            })
+            this.loading = false
+            this.downloadStatus = 'exception'
+            this.downloadModal = false
+          } else {
+            var totalRecords = json.listusagerecordsresponse.count
+            this.downloadTotalRecords = totalRecords
+            var pageSize = 500
+            var totalPages = Math.ceil(totalRecords / pageSize)
+            var records = []
+            var promises = []
+            for (var i = 1; i <= totalPages; i++) {
+              var p = this.fetchUsageRecords({ ...params, page: i, pagesize: 
pageSize }).then(data => {
+                records = records.concat(data)
+                this.downloadPercent = Math.round((records.length / 
totalRecords) * 100)
+                this.downloadedRecords += records.length
+              })
+              promises.push(p)
+            }
+            return Promise.allSettled(promises).then(() => {
+              this.downloadPercent = 100
+              this.downloadStatus = 'success'
+              this.downloadCsv(records, 'usage-records.csv')
+              this.loading = false
+              this.downloadModal = false
+            }).catch(error => {
+              this.$notifyError(error)
+              this.loading = false
+              this.downloadStatus = 'exception'
+              this.downloadModal = false
+            })
+          }
+        })
+      }).catch(error => {
+        this.formRef.value.scrollToField(error.errorFields[0].name)
+      })
+    },
+    downloadCsv (records, filename) {
+      var csv = toCsv({ keys: this.usageRecordKeys, data: records })
+      const hiddenElement = document.createElement('a')
+      hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csv)
+      hiddenElement.target = '_blank'
+      hiddenElement.download = filename
+      hiddenElement.click()
+      hiddenElement.remove()
+    },
+    fetchUsageRecords (params) {
+      return new Promise((resolve, reject) => {
+        api('listUsageRecords', params).then(json => {
+          return resolve(json.listusagerecordsresponse.usagerecord)
+        }).catch(error => {
+          return reject(error)
+        })
+      })
+    },
+    getAllUsageRecordColumns () {
+      api('listApis', { name: 'listUsageRecords' }).then(json => {
+        if (json && json.listapisresponse && json.listapisresponse.api) {
+          var apiResponse = json.listapisresponse.api.filter(x => x.name === 
'listUsageRecords')[0].response
+          this.usageRecordKeys = []
+          apiResponse.forEach(x => {
+            if (x && x.name) {
+              this.usageRecordKeys.push(x.name)
+            }
+          })
+          this.usageRecordKeys.sort()
+        }
+      })
+    },
+    parseDates (date) {
+      return this.$toLocaleDate(dayjs(date))
+    },
+    generateUsageRecords () {
+      api('generateUsageRecords').then(json => {
+        this.$message.success(this.$t('label.usage.records.generated'))
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(f => {
+        this.generateModal = false
+      })
+    },
+    purgeUsageRecords () {
+      var params = {
+        interval: this.purgeDays
+      }
+      api('removeRawUsageRecords', params).then(json => {
+        this.$message.success(this.$t('label.purge.usage.records.success'))
+      }).catch(error => {
+        this.$message.error(this.$t('label.purge.usage.records.error') + ': ' 
+ error.message)
+      }).finally(f => {
+        this.purgeModal = false
+      })
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.breadcrumb-card {
+  margin-left: -24px;
+  margin-right: -24px;
+  margin-top: -16px;
+  margin-bottom: 12px;
+}
+</style>
diff --git a/ui/src/views/tools/ManageInstances.vue 
b/ui/src/views/tools/ManageInstances.vue
index b61c8a9efa5..160122d7903 100644
--- a/ui/src/views/tools/ManageInstances.vue
+++ b/ui/src/views/tools/ManageInstances.vue
@@ -19,19 +19,33 @@
   <a-row :gutter="12" v-if="isPageAllowed">
     <a-col :md="24">
       <a-card class="breadcrumb-card">
-        <a-col :md="24" style="display: flex">
-          <breadcrumb style="padding-top: 6px; padding-left: 8px" />
-          <a-button
-            style="margin-left: 12px; margin-top: 4px"
-            :loading="viewLoading"
-            size="small"
-            shape="round"
-            @click="fetchData()" >
-            <template #icon><reload-outlined /></template>
-            {{ $t('label.refresh') }}
-          </a-button>
+      <a-row>
+        <a-col
+          :span="device === 'mobile' ? 24 : 12"
+          style="padding-left: 12px; margin-top: 10px"
+        >
+          <breadcrumb :resource="resource">
+            <template #end>
+              <a-tooltip placement="bottom">
+                <template #title>{{ $t('label.refresh') }}</template>
+                <a-button
+                  style="margin-top: 4px"
+                  :loading="viewLoading"
+                  shape="round"
+                  size="small"
+                  @click="fetchData()"
+                >
+                  <template #icon>
+                    <ReloadOutlined />
+                  </template>
+                  {{ $t('label.refresh') }}
+                </a-button>
+              </a-tooltip>
+            </template>
+          </breadcrumb>
         </a-col>
-      </a-card>
+      </a-row>
+    </a-card>
     </a-col>
     <a-col
       :md="24">

Reply via email to