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(/</g, '<')
.replace(/>/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>
+ <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>
+ <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">