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)
This commit is contained in:
Vishesh 2024-07-30 14:38:17 +05:30 committed by GitHub
parent 7214c13e10
commit 5baac44139
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 983 additions and 43 deletions

View File

@ -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;

View File

@ -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",

View File

@ -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
}
}
})
}
}
}
}

View File

@ -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)
}

View File

@ -224,7 +224,6 @@ export function asyncRouterMap () {
generateRouterMap(tools),
generateRouterMap(quota),
generateRouterMap(cloudian),
{
path: '/exception',
name: 'exception',

View File

@ -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',

View File

@ -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)

View File

@ -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
}

View File

@ -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) {

View File

@ -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'

View File

@ -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>

View File

@ -19,18 +19,32 @@
<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-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-left: 12px; margin-top: 4px"
style="margin-top: 4px"
:loading="viewLoading"
size="small"
shape="round"
@click="fetchData()" >
<template #icon><reload-outlined /></template>
size="small"
@click="fetchData()"
>
<template #icon>
<ReloadOutlined />
</template>
{{ $t('label.refresh') }}
</a-button>
</a-tooltip>
</template>
</breadcrumb>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col