Copilot commented on code in PR #9520: URL: https://github.com/apache/cloudstack/pull/9520#discussion_r2736751645
########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" Review Comment: `:key="filter"` uses the entire filter object as the Vue key, which can lead to duplicate/unstable keys (e.g., all become "[object Object]") and incorrect tag reuse when the list changes. Use a stable unique key derived from filter identity (e.g., filter.key + tagIdx, or the v-for index). ```suggestion v-for="(filter, index) in this.searchFilters" :key="index" ``` ########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" + > + <a-col v-if="!['page', 'pagesize', 'q', 'keyword', 'tags'].includes(filter.key)"> + <a-tag + v-if="!filter.isTag" + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="retrieveFieldLabel(filter.key) + ': ' + filter.value" + :placement="tooltipPlacement" + > Review Comment: `tooltipPlacement` is referenced in the template but is not defined as a prop/data/computed value in this component, so it will always be `undefined` and may produce runtime warnings. Define it (as other tooltip components do) or remove the binding and rely on the tooltip’s default placement. ########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" + > + <a-col v-if="!['page', 'pagesize', 'q', 'keyword', 'tags'].includes(filter.key)"> + <a-tag + v-if="!filter.isTag" + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="retrieveFieldLabel(filter.key) + ': ' + filter.value" + :placement="tooltipPlacement" + > + {{ retrieveFieldLabel(filter.key) }} : {{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + <a-tag + v-else + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="$t('label.tag') + ': ' + filter.key + '=' + filter.value" + :placement="tooltipPlacement" + > + {{ $t('label.tag') }}: {{ filter.key }}={{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + </a-col> + </template> + </a-row> +</template> + +<script> + +import { api } from '@/api/index' + +export default { + name: 'SearchFilter', + props: { + filters: { + type: Array, + default: () => [] + }, + apiName: { + type: String, + default: () => '' + }, + filterKey: { + type: String, + default: () => '' + }, + filterValue: { + type: String, + default: () => '' + }, + isTag: { + type: Boolean, + default: () => false + } + }, + emits: ['removeFilter'], + data () { + return { + searchFilters: [], + apiMap: { + type: this.getType, + hypervisor: this.getHypervisor, + zoneid: { + apiName: 'listZones', + responseKey1: 'listzonesresponse', + responseKey2: 'zone', + field: 'name' + }, + domainid: { + apiName: 'listDomains', + responseKey1: 'listdomainsresponse', + responseKey2: 'domain', + field: 'name' + }, + account: { + apiName: 'listAccounts', + responseKey1: 'listaccountsresponse', + responseKey2: 'account', + field: 'name' + }, + imagestoreid: { + apiName: 'listImageStores', + responseKey1: 'listimagestoresresponse', + responseKey2: 'imagestore', + field: 'name' + }, + storageid: { + apiName: 'listStoragePools', + responseKey1: 'liststoragepoolsresponse', + responseKey2: 'storagepool', + field: 'name' + }, + podid: { + apiName: 'listPods', + responseKey1: 'listpodsresponse', + responseKey2: 'pod', + field: 'name' + }, + clusterid: { + apiName: 'listClusters', + responseKey1: 'listclustersresponse', + responseKey2: 'cluster', + field: 'name' + }, + hostid: { + apiName: 'listHosts', + responseKey1: 'listhostsresponse', + responseKey2: 'host', + field: 'name' + }, + groupid: { + apiName: 'listInstanceGroups', + responseKey1: 'listinstancegroupsresponse', + responseKey2: 'instancegroup', + field: 'name' + }, + managementserverid: { + apiName: 'listManagementServers', + responseKey1: 'listmanagementserversresponse', + responseKey2: 'managementserver', + field: 'name' + }, + serviceofferingid: { + apiName: 'listServiceOfferings', + responseKey1: 'listserviceofferingsresponse', + responseKey2: 'serviceoffering', + field: 'name' + }, + diskofferingid: { + apiName: 'listDiskOfferings', + responseKey1: 'listdiskofferingsresponse', + responseKey2: 'diskoffering', + field: 'name' + }, + networkid: { + apiName: 'listNetworks', + responseKey1: 'listnetworksresponse', + responseKey2: 'network', + field: 'name' + } + } + } + }, + updated () { + this.searchFilters = this.filters + const promises = [] + for (const idx in this.filters) { + const filter = this.filters[idx] + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + continue + } + promises.push(new Promise((resolve) => { + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + resolve() + } + if (filter.key === 'tags') { + this.searchFilters[idx] = { + key: filter.key, + value: filter.value, + isTag: true + } + } else { + this.getSearchFilters(filter.key, filter.value).then((value) => { + this.searchFilters[idx] = { + key: filter.key, + value: value, + isTag: filter.isTag + } + }) + } + resolve() + })) + } + Promise.all(promises) + }, + methods: { + getTrimmedText (text, length) { + if (!text) { + return '' + } + return (text.length <= length) ? text : (text.substring(0, length - 3) + '...') + }, + retrieveFieldLabel (fieldName) { + if (fieldName === 'groupid') { + fieldName = 'group' + } + if (fieldName === 'keyword') { + if ('listAnnotations' in this.$store.getters.apis) { + return this.$t('label.annotation') + } else { + return this.$t('label.name') + } + } + return this.$t('label.' + fieldName) + }, + async getSearchFilters (key, value) { + const val = this.getStaticFieldValue(key, value) + if (val !== '') { + return val + } else { + return this.getDynamicFieldValue(key, value) + } + }, + getStaticFieldValue (key, value) { + let formattedValue = '' + if (key.includes('type')) { + if (this.$route.path === '/guestnetwork' || this.$route.path.includes('/guestnetwork/')) { + formattedValue = this.getGuestNetworkType(value) + } else if (this.$route.path === '/role' || this.$route.path.includes('/role/')) { + formattedValue = this.getRoleType(value) + } + } + + if (key.includes('scope')) { + formattedValue = this.getScope(value) + } + + if (key.includes('state')) { + formattedValue = this.getState(value) + } + + if (key.includes('level')) { + formattedValue = this.getLevel(value) + } + + if (key.includes('entitytype')) { + formattedValue = this.getEntityType(value) + } + + if (key.includes('accounttype')) { + formattedValue = this.getAccountType(value) + } + + if (key.includes('systemvmtype')) { + formattedValue = this.getSystemVmType(value) + } + + if (key.includes('scope')) { + formattedValue = this.getStoragePoolScope(value) + } + + if (key.includes('provider')) { + formattedValue = this.getImageStoreProvider(value) + } + + if (key.includes('resourcetype')) { + formattedValue = value + } + return formattedValue + }, + async getDynamicFieldValue (key, value) { + let formattedValue = '' + + if (typeof this.apiMap[key] === 'function') { + formattedValue = await this.apiMap[key](value) + } else if (this.apiMap[key]) { + const apiName = this.apiMap[key].apiName + const responseKey1 = this.apiMap[key].responseKey1 + const responseKey2 = this.apiMap[key].responseKey2 + const field = this.apiMap[key].field + formattedValue = await this.getResourceNameById(apiName, responseKey1, responseKey2, value, field) + } + if (formattedValue === '') { + formattedValue = value + } + return formattedValue + }, + getHypervisor (value) { + return new Promise((resolve) => { + api('listHypervisors').then(json => { + if (json?.listhypervisorsresponse?.hypervisor) { + for (const key in json.listhypervisorsresponse.hypervisor) { + const hypervisor = json.listhypervisorsresponse.hypervisor[key] + if (hypervisor.name === value) { + resolve(hypervisor.name) + } + } + } + }).catch(() => { + resolve(null) + }) + }) + }, + getResourceNameById (apiName, responseKey1, responseKey2, id, field) { + return new Promise((resolve) => { + if (!this.$isValidUuid(id)) { + return resolve('') + } + api(apiName, { listAll: true, id: id }).then(json => { + if (json[responseKey1] && json[responseKey1][responseKey2]) { + resolve(json[responseKey1][responseKey2][0][field]) + } + }).catch(() => { + resolve('') + }) + }) + }, + getType (type) { + if (this.$route.path === '/alert') { + return this.getAlertType(type) + } else if (this.$route.path === '/affinitygroup') { + return this.getAffinityGroupType(type) + } + }, + getAlertType (type) { + return new Promise((resolve) => { + api('listAlertTypes').then(json => { + const alertTypes = {} + for (const key in json.listalerttypesresponse.alerttype) { + const alerttype = json.listalerttypesresponse.alerttype[key] + alertTypes[alerttype.id] = alerttype.name + } + resolve(alertTypes[type]) + }).catch(() => { + resolve(null) + }) + }) + }, + getAffinityGroupType (type) { + return new Promise((resolve) => { + api('listAffinityGroupTypes').then(json => { + const alertTypes = {} + for (const key in json.listaffinitygrouptypesresponse.affinityGroupType) { + const affinityGroupType = json.listaffinitygrouptypesresponse.affinityGroupType[key] + if (affinityGroupType.type === 'host anti-affinity') { + alertTypes[affinityGroupType.type] = 'host anti-affinity (Strict)' + } else if (affinityGroupType.type === 'host affinity') { + alertTypes[affinityGroupType.type] = 'host affinity (Strict)' + } else if (affinityGroupType.type === 'non-strict host anti-affinity') { + alertTypes[affinityGroupType.type] = 'host anti-affinity (Non-Strict)' + } else if (affinityGroupType.type === 'non-strict host affinity') { + alertTypes[affinityGroupType.type] = 'host affinity (Non-Strict)' + } + } + this.alertTypes = alertTypes + resolve(alertTypes[type]) + }).catch(() => { + resolve(null) + }) + }) + }, + getGuestNetworkType (value) { + switch (value.toLowerCase()) { + case 'isolated': + return this.$t('label.isolated') + case 'shared': + return this.$t('label.shared') + case 'l2': + return this.$t('label.l2') + } + }, + getAccountType (type) { + const types = [] + if (this.apiName.indexOf('listAccounts') > -1) { + switch (type) { + case '1': + return 'Admin' + case '2': + return 'DomainAdmin' + case '0': + return 'User' + } + } + return types Review Comment: `getAccountType()` maps user to `'0'`, but the search UI options use `'3'` for user accounts (see `SearchView.vue`’s account type options). This mismatch will display the raw value instead of a label. Align the mapping with the values produced by the search form and return `''` when not applicable (not an array). ```suggestion if (this.apiName.indexOf('listAccounts') > -1) { switch (String(type)) { case '1': return 'Admin' case '2': return 'DomainAdmin' case '3': case '0': return 'User' default: return '' } } return '' ``` ########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" + > + <a-col v-if="!['page', 'pagesize', 'q', 'keyword', 'tags'].includes(filter.key)"> + <a-tag + v-if="!filter.isTag" + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="retrieveFieldLabel(filter.key) + ': ' + filter.value" + :placement="tooltipPlacement" + > + {{ retrieveFieldLabel(filter.key) }} : {{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + <a-tag + v-else + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="$t('label.tag') + ': ' + filter.key + '=' + filter.value" + :placement="tooltipPlacement" + > + {{ $t('label.tag') }}: {{ filter.key }}={{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + </a-col> + </template> + </a-row> +</template> + +<script> + +import { api } from '@/api/index' + +export default { + name: 'SearchFilter', + props: { + filters: { + type: Array, + default: () => [] + }, + apiName: { + type: String, + default: () => '' + }, + filterKey: { + type: String, + default: () => '' + }, + filterValue: { + type: String, + default: () => '' + }, + isTag: { + type: Boolean, + default: () => false + } + }, + emits: ['removeFilter'], + data () { + return { + searchFilters: [], + apiMap: { + type: this.getType, + hypervisor: this.getHypervisor, + zoneid: { + apiName: 'listZones', + responseKey1: 'listzonesresponse', + responseKey2: 'zone', + field: 'name' + }, + domainid: { + apiName: 'listDomains', + responseKey1: 'listdomainsresponse', + responseKey2: 'domain', + field: 'name' + }, + account: { + apiName: 'listAccounts', + responseKey1: 'listaccountsresponse', + responseKey2: 'account', + field: 'name' + }, + imagestoreid: { + apiName: 'listImageStores', + responseKey1: 'listimagestoresresponse', + responseKey2: 'imagestore', + field: 'name' + }, + storageid: { + apiName: 'listStoragePools', + responseKey1: 'liststoragepoolsresponse', + responseKey2: 'storagepool', + field: 'name' + }, + podid: { + apiName: 'listPods', + responseKey1: 'listpodsresponse', + responseKey2: 'pod', + field: 'name' + }, + clusterid: { + apiName: 'listClusters', + responseKey1: 'listclustersresponse', + responseKey2: 'cluster', + field: 'name' + }, + hostid: { + apiName: 'listHosts', + responseKey1: 'listhostsresponse', + responseKey2: 'host', + field: 'name' + }, + groupid: { + apiName: 'listInstanceGroups', + responseKey1: 'listinstancegroupsresponse', + responseKey2: 'instancegroup', + field: 'name' + }, + managementserverid: { + apiName: 'listManagementServers', + responseKey1: 'listmanagementserversresponse', + responseKey2: 'managementserver', + field: 'name' + }, + serviceofferingid: { + apiName: 'listServiceOfferings', + responseKey1: 'listserviceofferingsresponse', + responseKey2: 'serviceoffering', + field: 'name' + }, + diskofferingid: { + apiName: 'listDiskOfferings', + responseKey1: 'listdiskofferingsresponse', + responseKey2: 'diskoffering', + field: 'name' + }, + networkid: { + apiName: 'listNetworks', + responseKey1: 'listnetworksresponse', + responseKey2: 'network', + field: 'name' + } + } + } + }, + updated () { + this.searchFilters = this.filters + const promises = [] + for (const idx in this.filters) { + const filter = this.filters[idx] + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + continue + } + promises.push(new Promise((resolve) => { + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + resolve() + } + if (filter.key === 'tags') { + this.searchFilters[idx] = { + key: filter.key, + value: filter.value, + isTag: true + } + } else { + this.getSearchFilters(filter.key, filter.value).then((value) => { + this.searchFilters[idx] = { + key: filter.key, + value: value, + isTag: filter.isTag + } + }) + } + resolve() + })) + } + Promise.all(promises) + }, + methods: { + getTrimmedText (text, length) { + if (!text) { + return '' + } + return (text.length <= length) ? text : (text.substring(0, length - 3) + '...') + }, + retrieveFieldLabel (fieldName) { + if (fieldName === 'groupid') { + fieldName = 'group' + } + if (fieldName === 'keyword') { + if ('listAnnotations' in this.$store.getters.apis) { + return this.$t('label.annotation') + } else { + return this.$t('label.name') + } + } + return this.$t('label.' + fieldName) + }, + async getSearchFilters (key, value) { + const val = this.getStaticFieldValue(key, value) + if (val !== '') { + return val + } else { + return this.getDynamicFieldValue(key, value) + } + }, + getStaticFieldValue (key, value) { + let formattedValue = '' + if (key.includes('type')) { + if (this.$route.path === '/guestnetwork' || this.$route.path.includes('/guestnetwork/')) { + formattedValue = this.getGuestNetworkType(value) + } else if (this.$route.path === '/role' || this.$route.path.includes('/role/')) { + formattedValue = this.getRoleType(value) + } + } + + if (key.includes('scope')) { + formattedValue = this.getScope(value) + } + + if (key.includes('state')) { + formattedValue = this.getState(value) + } + + if (key.includes('level')) { + formattedValue = this.getLevel(value) + } + + if (key.includes('entitytype')) { + formattedValue = this.getEntityType(value) + } + + if (key.includes('accounttype')) { + formattedValue = this.getAccountType(value) + } + + if (key.includes('systemvmtype')) { + formattedValue = this.getSystemVmType(value) + } + + if (key.includes('scope')) { + formattedValue = this.getStoragePoolScope(value) + } + Review Comment: `getStaticFieldValue()` sets `formattedValue` twice for `scope` (lines 243–245 and 267–269). For non-storage-pool pages, `getStoragePoolScope()` returns `undefined`, overwriting the earlier translated scope value; since `getSearchFilters()` treats any non-empty-string as “handled”, this can surface `undefined`/blank values. Avoid the duplicate `scope` block or ensure helper functions return `''` when not applicable so `getSearchFilters()` falls back correctly. ########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" + > + <a-col v-if="!['page', 'pagesize', 'q', 'keyword', 'tags'].includes(filter.key)"> + <a-tag + v-if="!filter.isTag" + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="retrieveFieldLabel(filter.key) + ': ' + filter.value" + :placement="tooltipPlacement" + > + {{ retrieveFieldLabel(filter.key) }} : {{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + <a-tag + v-else + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="$t('label.tag') + ': ' + filter.key + '=' + filter.value" + :placement="tooltipPlacement" + > + {{ $t('label.tag') }}: {{ filter.key }}={{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + </a-col> + </template> + </a-row> +</template> + +<script> + +import { api } from '@/api/index' + +export default { + name: 'SearchFilter', + props: { + filters: { + type: Array, + default: () => [] + }, + apiName: { + type: String, + default: () => '' + }, + filterKey: { + type: String, + default: () => '' + }, + filterValue: { + type: String, + default: () => '' + }, + isTag: { + type: Boolean, + default: () => false + } + }, + emits: ['removeFilter'], + data () { + return { + searchFilters: [], + apiMap: { + type: this.getType, + hypervisor: this.getHypervisor, + zoneid: { + apiName: 'listZones', + responseKey1: 'listzonesresponse', + responseKey2: 'zone', + field: 'name' + }, + domainid: { + apiName: 'listDomains', + responseKey1: 'listdomainsresponse', + responseKey2: 'domain', + field: 'name' + }, + account: { + apiName: 'listAccounts', + responseKey1: 'listaccountsresponse', + responseKey2: 'account', + field: 'name' + }, + imagestoreid: { + apiName: 'listImageStores', + responseKey1: 'listimagestoresresponse', + responseKey2: 'imagestore', + field: 'name' + }, + storageid: { + apiName: 'listStoragePools', + responseKey1: 'liststoragepoolsresponse', + responseKey2: 'storagepool', + field: 'name' + }, + podid: { + apiName: 'listPods', + responseKey1: 'listpodsresponse', + responseKey2: 'pod', + field: 'name' + }, + clusterid: { + apiName: 'listClusters', + responseKey1: 'listclustersresponse', + responseKey2: 'cluster', + field: 'name' + }, + hostid: { + apiName: 'listHosts', + responseKey1: 'listhostsresponse', + responseKey2: 'host', + field: 'name' + }, + groupid: { + apiName: 'listInstanceGroups', + responseKey1: 'listinstancegroupsresponse', + responseKey2: 'instancegroup', + field: 'name' + }, + managementserverid: { + apiName: 'listManagementServers', + responseKey1: 'listmanagementserversresponse', + responseKey2: 'managementserver', + field: 'name' + }, + serviceofferingid: { + apiName: 'listServiceOfferings', + responseKey1: 'listserviceofferingsresponse', + responseKey2: 'serviceoffering', + field: 'name' + }, + diskofferingid: { + apiName: 'listDiskOfferings', + responseKey1: 'listdiskofferingsresponse', + responseKey2: 'diskoffering', + field: 'name' + }, + networkid: { + apiName: 'listNetworks', + responseKey1: 'listnetworksresponse', + responseKey2: 'network', + field: 'name' + } + } + } + }, + updated () { + this.searchFilters = this.filters + const promises = [] + for (const idx in this.filters) { + const filter = this.filters[idx] + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + continue + } + promises.push(new Promise((resolve) => { + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + resolve() + } + if (filter.key === 'tags') { + this.searchFilters[idx] = { + key: filter.key, + value: filter.value, + isTag: true + } + } else { + this.getSearchFilters(filter.key, filter.value).then((value) => { + this.searchFilters[idx] = { + key: filter.key, + value: value, + isTag: filter.isTag + } + }) + } + resolve() + })) + } + Promise.all(promises) + }, + methods: { + getTrimmedText (text, length) { + if (!text) { + return '' + } + return (text.length <= length) ? text : (text.substring(0, length - 3) + '...') + }, + retrieveFieldLabel (fieldName) { + if (fieldName === 'groupid') { + fieldName = 'group' + } + if (fieldName === 'keyword') { + if ('listAnnotations' in this.$store.getters.apis) { + return this.$t('label.annotation') + } else { + return this.$t('label.name') + } + } + return this.$t('label.' + fieldName) + }, + async getSearchFilters (key, value) { + const val = this.getStaticFieldValue(key, value) + if (val !== '') { + return val + } else { + return this.getDynamicFieldValue(key, value) + } + }, + getStaticFieldValue (key, value) { + let formattedValue = '' + if (key.includes('type')) { + if (this.$route.path === '/guestnetwork' || this.$route.path.includes('/guestnetwork/')) { + formattedValue = this.getGuestNetworkType(value) + } else if (this.$route.path === '/role' || this.$route.path.includes('/role/')) { + formattedValue = this.getRoleType(value) + } + } + + if (key.includes('scope')) { + formattedValue = this.getScope(value) + } + + if (key.includes('state')) { + formattedValue = this.getState(value) + } + + if (key.includes('level')) { + formattedValue = this.getLevel(value) + } + + if (key.includes('entitytype')) { + formattedValue = this.getEntityType(value) + } + + if (key.includes('accounttype')) { + formattedValue = this.getAccountType(value) + } + + if (key.includes('systemvmtype')) { + formattedValue = this.getSystemVmType(value) + } + + if (key.includes('scope')) { + formattedValue = this.getStoragePoolScope(value) + } + + if (key.includes('provider')) { + formattedValue = this.getImageStoreProvider(value) + } + + if (key.includes('resourcetype')) { + formattedValue = value + } + return formattedValue + }, + async getDynamicFieldValue (key, value) { + let formattedValue = '' + + if (typeof this.apiMap[key] === 'function') { + formattedValue = await this.apiMap[key](value) + } else if (this.apiMap[key]) { + const apiName = this.apiMap[key].apiName + const responseKey1 = this.apiMap[key].responseKey1 + const responseKey2 = this.apiMap[key].responseKey2 + const field = this.apiMap[key].field + formattedValue = await this.getResourceNameById(apiName, responseKey1, responseKey2, value, field) + } + if (formattedValue === '') { + formattedValue = value + } + return formattedValue + }, + getHypervisor (value) { + return new Promise((resolve) => { + api('listHypervisors').then(json => { + if (json?.listhypervisorsresponse?.hypervisor) { + for (const key in json.listhypervisorsresponse.hypervisor) { + const hypervisor = json.listhypervisorsresponse.hypervisor[key] + if (hypervisor.name === value) { + resolve(hypervisor.name) + } + } + } + }).catch(() => { + resolve(null) + }) + }) Review Comment: `getHypervisor()` does not resolve the Promise when the API call succeeds but no matching hypervisor is found (or the response is missing the expected list). This leaves the awaiting code hanging indefinitely. Ensure the Promise always resolves (e.g., resolve('') after the loop / when the response has no matches). ########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" + > + <a-col v-if="!['page', 'pagesize', 'q', 'keyword', 'tags'].includes(filter.key)"> + <a-tag + v-if="!filter.isTag" + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="retrieveFieldLabel(filter.key) + ': ' + filter.value" + :placement="tooltipPlacement" + > + {{ retrieveFieldLabel(filter.key) }} : {{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + <a-tag + v-else + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="$t('label.tag') + ': ' + filter.key + '=' + filter.value" + :placement="tooltipPlacement" + > + {{ $t('label.tag') }}: {{ filter.key }}={{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + </a-col> + </template> + </a-row> +</template> + +<script> + +import { api } from '@/api/index' + +export default { + name: 'SearchFilter', + props: { + filters: { + type: Array, + default: () => [] + }, + apiName: { + type: String, + default: () => '' + }, + filterKey: { + type: String, + default: () => '' + }, + filterValue: { + type: String, + default: () => '' + }, + isTag: { + type: Boolean, + default: () => false + } + }, + emits: ['removeFilter'], + data () { + return { + searchFilters: [], + apiMap: { + type: this.getType, + hypervisor: this.getHypervisor, + zoneid: { + apiName: 'listZones', + responseKey1: 'listzonesresponse', + responseKey2: 'zone', + field: 'name' + }, + domainid: { + apiName: 'listDomains', + responseKey1: 'listdomainsresponse', + responseKey2: 'domain', + field: 'name' + }, + account: { + apiName: 'listAccounts', + responseKey1: 'listaccountsresponse', + responseKey2: 'account', + field: 'name' + }, + imagestoreid: { + apiName: 'listImageStores', + responseKey1: 'listimagestoresresponse', + responseKey2: 'imagestore', + field: 'name' + }, + storageid: { + apiName: 'listStoragePools', + responseKey1: 'liststoragepoolsresponse', + responseKey2: 'storagepool', + field: 'name' + }, + podid: { + apiName: 'listPods', + responseKey1: 'listpodsresponse', + responseKey2: 'pod', + field: 'name' + }, + clusterid: { + apiName: 'listClusters', + responseKey1: 'listclustersresponse', + responseKey2: 'cluster', + field: 'name' + }, + hostid: { + apiName: 'listHosts', + responseKey1: 'listhostsresponse', + responseKey2: 'host', + field: 'name' + }, + groupid: { + apiName: 'listInstanceGroups', + responseKey1: 'listinstancegroupsresponse', + responseKey2: 'instancegroup', + field: 'name' + }, + managementserverid: { + apiName: 'listManagementServers', + responseKey1: 'listmanagementserversresponse', + responseKey2: 'managementserver', + field: 'name' + }, + serviceofferingid: { + apiName: 'listServiceOfferings', + responseKey1: 'listserviceofferingsresponse', + responseKey2: 'serviceoffering', + field: 'name' + }, + diskofferingid: { + apiName: 'listDiskOfferings', + responseKey1: 'listdiskofferingsresponse', + responseKey2: 'diskoffering', + field: 'name' + }, + networkid: { + apiName: 'listNetworks', + responseKey1: 'listnetworksresponse', + responseKey2: 'network', + field: 'name' + } + } + } + }, + updated () { + this.searchFilters = this.filters + const promises = [] + for (const idx in this.filters) { + const filter = this.filters[idx] + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + continue + } + promises.push(new Promise((resolve) => { + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + resolve() + } + if (filter.key === 'tags') { + this.searchFilters[idx] = { + key: filter.key, + value: filter.value, + isTag: true + } + } else { + this.getSearchFilters(filter.key, filter.value).then((value) => { + this.searchFilters[idx] = { + key: filter.key, + value: value, + isTag: filter.isTag + } + }) + } + resolve() + })) + } + Promise.all(promises) + }, + methods: { + getTrimmedText (text, length) { + if (!text) { + return '' + } + return (text.length <= length) ? text : (text.substring(0, length - 3) + '...') + }, + retrieveFieldLabel (fieldName) { + if (fieldName === 'groupid') { + fieldName = 'group' + } + if (fieldName === 'keyword') { + if ('listAnnotations' in this.$store.getters.apis) { + return this.$t('label.annotation') + } else { + return this.$t('label.name') + } + } + return this.$t('label.' + fieldName) + }, + async getSearchFilters (key, value) { + const val = this.getStaticFieldValue(key, value) + if (val !== '') { + return val + } else { + return this.getDynamicFieldValue(key, value) + } + }, + getStaticFieldValue (key, value) { + let formattedValue = '' + if (key.includes('type')) { + if (this.$route.path === '/guestnetwork' || this.$route.path.includes('/guestnetwork/')) { + formattedValue = this.getGuestNetworkType(value) + } else if (this.$route.path === '/role' || this.$route.path.includes('/role/')) { + formattedValue = this.getRoleType(value) + } + } + + if (key.includes('scope')) { + formattedValue = this.getScope(value) + } + + if (key.includes('state')) { + formattedValue = this.getState(value) + } + + if (key.includes('level')) { + formattedValue = this.getLevel(value) + } + + if (key.includes('entitytype')) { + formattedValue = this.getEntityType(value) + } + + if (key.includes('accounttype')) { + formattedValue = this.getAccountType(value) + } + + if (key.includes('systemvmtype')) { + formattedValue = this.getSystemVmType(value) + } + + if (key.includes('scope')) { + formattedValue = this.getStoragePoolScope(value) + } + + if (key.includes('provider')) { + formattedValue = this.getImageStoreProvider(value) + } + + if (key.includes('resourcetype')) { + formattedValue = value + } + return formattedValue + }, + async getDynamicFieldValue (key, value) { + let formattedValue = '' + + if (typeof this.apiMap[key] === 'function') { + formattedValue = await this.apiMap[key](value) + } else if (this.apiMap[key]) { + const apiName = this.apiMap[key].apiName + const responseKey1 = this.apiMap[key].responseKey1 + const responseKey2 = this.apiMap[key].responseKey2 + const field = this.apiMap[key].field + formattedValue = await this.getResourceNameById(apiName, responseKey1, responseKey2, value, field) + } + if (formattedValue === '') { + formattedValue = value + } + return formattedValue + }, + getHypervisor (value) { + return new Promise((resolve) => { + api('listHypervisors').then(json => { + if (json?.listhypervisorsresponse?.hypervisor) { + for (const key in json.listhypervisorsresponse.hypervisor) { + const hypervisor = json.listhypervisorsresponse.hypervisor[key] + if (hypervisor.name === value) { + resolve(hypervisor.name) + } + } + } + }).catch(() => { + resolve(null) + }) + }) + }, + getResourceNameById (apiName, responseKey1, responseKey2, id, field) { + return new Promise((resolve) => { + if (!this.$isValidUuid(id)) { + return resolve('') + } + api(apiName, { listAll: true, id: id }).then(json => { + if (json[responseKey1] && json[responseKey1][responseKey2]) { + resolve(json[responseKey1][responseKey2][0][field]) Review Comment: `getResourceNameById()` only resolves when `json[responseKey1][responseKey2]` exists; otherwise the Promise never resolves, causing the UI to hang waiting for a label. Ensure you resolve `''` when the expected keys/items are missing or the array is empty. ```suggestion const items = json && json[responseKey1] && json[responseKey1][responseKey2] if (Array.isArray(items) && items.length > 0 && items[0] && items[0][field] !== undefined) { resolve(items[0][field]) } else { resolve('') ``` ########## ui/src/views/AutogenView.vue: ########## @@ -1126,6 +1138,42 @@ export default { eventBus.emit('action-closing', { action: this.currentAction }) this.closeAction() }, + getActiveFilters () { + const queryParams = Object.assign({}, this.$route.query) + const activeFilters = [] + for (const filter in queryParams) { + if (!filter.startsWith('tags[')) { + activeFilters.push({ + key: filter, + value: queryParams[filter], + isTag: false + }) Review Comment: `getActiveFilters()` currently turns every query param into a displayed “search filter”, including internal routing params like `action` (used to auto-open list actions and then removed in `fetchData`). This can cause transient/incorrect filter chips and allow users to remove non-search params. Consider explicitly excluding non-search query keys (e.g., `action`, and any other internal params) when building `activeFilters`. ########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" + > + <a-col v-if="!['page', 'pagesize', 'q', 'keyword', 'tags'].includes(filter.key)"> + <a-tag + v-if="!filter.isTag" + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="retrieveFieldLabel(filter.key) + ': ' + filter.value" + :placement="tooltipPlacement" + > + {{ retrieveFieldLabel(filter.key) }} : {{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + <a-tag + v-else + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="$t('label.tag') + ': ' + filter.key + '=' + filter.value" + :placement="tooltipPlacement" + > + {{ $t('label.tag') }}: {{ filter.key }}={{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + </a-col> + </template> + </a-row> +</template> + +<script> + +import { api } from '@/api/index' + +export default { + name: 'SearchFilter', + props: { + filters: { + type: Array, + default: () => [] + }, + apiName: { + type: String, + default: () => '' + }, + filterKey: { + type: String, + default: () => '' + }, + filterValue: { + type: String, + default: () => '' + }, + isTag: { + type: Boolean, + default: () => false + } + }, + emits: ['removeFilter'], + data () { + return { + searchFilters: [], + apiMap: { + type: this.getType, + hypervisor: this.getHypervisor, Review Comment: `apiMap.type`/`apiMap.hypervisor` store unbound method references. When invoked as `this.apiMap[key](value)`, `this` inside `getType`/`getHypervisor` will be `apiMap` (not the component), breaking accesses like `this.$route`. Bind/call these functions with the component context (e.g., store lambdas or call with `.call(this, value)`). ```suggestion type: (value) => this.getType(value), hypervisor: (value) => this.getHypervisor(value), ``` ########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" + > + <a-col v-if="!['page', 'pagesize', 'q', 'keyword', 'tags'].includes(filter.key)"> + <a-tag + v-if="!filter.isTag" + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="retrieveFieldLabel(filter.key) + ': ' + filter.value" + :placement="tooltipPlacement" + > + {{ retrieveFieldLabel(filter.key) }} : {{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + <a-tag + v-else + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="$t('label.tag') + ': ' + filter.key + '=' + filter.value" + :placement="tooltipPlacement" + > + {{ $t('label.tag') }}: {{ filter.key }}={{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + </a-col> + </template> + </a-row> +</template> + +<script> + +import { api } from '@/api/index' + +export default { + name: 'SearchFilter', + props: { + filters: { + type: Array, + default: () => [] + }, + apiName: { + type: String, + default: () => '' + }, + filterKey: { + type: String, + default: () => '' + }, + filterValue: { + type: String, + default: () => '' + }, + isTag: { + type: Boolean, + default: () => false + } + }, + emits: ['removeFilter'], + data () { + return { + searchFilters: [], + apiMap: { + type: this.getType, + hypervisor: this.getHypervisor, + zoneid: { + apiName: 'listZones', + responseKey1: 'listzonesresponse', + responseKey2: 'zone', + field: 'name' + }, + domainid: { + apiName: 'listDomains', + responseKey1: 'listdomainsresponse', + responseKey2: 'domain', + field: 'name' + }, + account: { + apiName: 'listAccounts', + responseKey1: 'listaccountsresponse', + responseKey2: 'account', + field: 'name' + }, + imagestoreid: { + apiName: 'listImageStores', + responseKey1: 'listimagestoresresponse', + responseKey2: 'imagestore', + field: 'name' + }, + storageid: { + apiName: 'listStoragePools', + responseKey1: 'liststoragepoolsresponse', + responseKey2: 'storagepool', + field: 'name' + }, + podid: { + apiName: 'listPods', + responseKey1: 'listpodsresponse', + responseKey2: 'pod', + field: 'name' + }, + clusterid: { + apiName: 'listClusters', + responseKey1: 'listclustersresponse', + responseKey2: 'cluster', + field: 'name' + }, + hostid: { + apiName: 'listHosts', + responseKey1: 'listhostsresponse', + responseKey2: 'host', + field: 'name' + }, + groupid: { + apiName: 'listInstanceGroups', + responseKey1: 'listinstancegroupsresponse', + responseKey2: 'instancegroup', + field: 'name' + }, + managementserverid: { + apiName: 'listManagementServers', + responseKey1: 'listmanagementserversresponse', + responseKey2: 'managementserver', + field: 'name' + }, + serviceofferingid: { + apiName: 'listServiceOfferings', + responseKey1: 'listserviceofferingsresponse', + responseKey2: 'serviceoffering', + field: 'name' + }, + diskofferingid: { + apiName: 'listDiskOfferings', + responseKey1: 'listdiskofferingsresponse', + responseKey2: 'diskoffering', + field: 'name' + }, + networkid: { + apiName: 'listNetworks', + responseKey1: 'listnetworksresponse', + responseKey2: 'network', + field: 'name' + } + } + } + }, + updated () { + this.searchFilters = this.filters + const promises = [] + for (const idx in this.filters) { + const filter = this.filters[idx] + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + continue + } + promises.push(new Promise((resolve) => { + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + resolve() + } + if (filter.key === 'tags') { + this.searchFilters[idx] = { + key: filter.key, + value: filter.value, + isTag: true + } + } else { + this.getSearchFilters(filter.key, filter.value).then((value) => { + this.searchFilters[idx] = { + key: filter.key, + value: value, + isTag: filter.isTag + } + }) + } + resolve() + })) + } + Promise.all(promises) + }, + methods: { + getTrimmedText (text, length) { + if (!text) { + return '' + } + return (text.length <= length) ? text : (text.substring(0, length - 3) + '...') + }, + retrieveFieldLabel (fieldName) { + if (fieldName === 'groupid') { + fieldName = 'group' + } + if (fieldName === 'keyword') { + if ('listAnnotations' in this.$store.getters.apis) { + return this.$t('label.annotation') + } else { + return this.$t('label.name') + } + } + return this.$t('label.' + fieldName) + }, + async getSearchFilters (key, value) { + const val = this.getStaticFieldValue(key, value) + if (val !== '') { + return val + } else { + return this.getDynamicFieldValue(key, value) + } + }, + getStaticFieldValue (key, value) { + let formattedValue = '' + if (key.includes('type')) { + if (this.$route.path === '/guestnetwork' || this.$route.path.includes('/guestnetwork/')) { + formattedValue = this.getGuestNetworkType(value) + } else if (this.$route.path === '/role' || this.$route.path.includes('/role/')) { + formattedValue = this.getRoleType(value) + } + } + + if (key.includes('scope')) { + formattedValue = this.getScope(value) + } + + if (key.includes('state')) { + formattedValue = this.getState(value) + } + + if (key.includes('level')) { + formattedValue = this.getLevel(value) + } + + if (key.includes('entitytype')) { + formattedValue = this.getEntityType(value) + } + + if (key.includes('accounttype')) { + formattedValue = this.getAccountType(value) + } + + if (key.includes('systemvmtype')) { + formattedValue = this.getSystemVmType(value) + } + + if (key.includes('scope')) { + formattedValue = this.getStoragePoolScope(value) + } + + if (key.includes('provider')) { + formattedValue = this.getImageStoreProvider(value) + } + + if (key.includes('resourcetype')) { + formattedValue = value + } + return formattedValue + }, + async getDynamicFieldValue (key, value) { + let formattedValue = '' + + if (typeof this.apiMap[key] === 'function') { + formattedValue = await this.apiMap[key](value) + } else if (this.apiMap[key]) { + const apiName = this.apiMap[key].apiName + const responseKey1 = this.apiMap[key].responseKey1 + const responseKey2 = this.apiMap[key].responseKey2 + const field = this.apiMap[key].field + formattedValue = await this.getResourceNameById(apiName, responseKey1, responseKey2, value, field) + } + if (formattedValue === '') { + formattedValue = value + } + return formattedValue + }, + getHypervisor (value) { + return new Promise((resolve) => { + api('listHypervisors').then(json => { + if (json?.listhypervisorsresponse?.hypervisor) { + for (const key in json.listhypervisorsresponse.hypervisor) { + const hypervisor = json.listhypervisorsresponse.hypervisor[key] + if (hypervisor.name === value) { + resolve(hypervisor.name) + } + } + } + }).catch(() => { + resolve(null) + }) + }) + }, + getResourceNameById (apiName, responseKey1, responseKey2, id, field) { + return new Promise((resolve) => { + if (!this.$isValidUuid(id)) { + return resolve('') + } + api(apiName, { listAll: true, id: id }).then(json => { + if (json[responseKey1] && json[responseKey1][responseKey2]) { + resolve(json[responseKey1][responseKey2][0][field]) + } + }).catch(() => { + resolve('') + }) + }) + }, + getType (type) { + if (this.$route.path === '/alert') { + return this.getAlertType(type) + } else if (this.$route.path === '/affinitygroup') { + return this.getAffinityGroupType(type) + } + }, + getAlertType (type) { + return new Promise((resolve) => { + api('listAlertTypes').then(json => { + const alertTypes = {} + for (const key in json.listalerttypesresponse.alerttype) { + const alerttype = json.listalerttypesresponse.alerttype[key] + alertTypes[alerttype.id] = alerttype.name + } + resolve(alertTypes[type]) + }).catch(() => { + resolve(null) + }) + }) + }, + getAffinityGroupType (type) { + return new Promise((resolve) => { + api('listAffinityGroupTypes').then(json => { + const alertTypes = {} + for (const key in json.listaffinitygrouptypesresponse.affinityGroupType) { + const affinityGroupType = json.listaffinitygrouptypesresponse.affinityGroupType[key] + if (affinityGroupType.type === 'host anti-affinity') { + alertTypes[affinityGroupType.type] = 'host anti-affinity (Strict)' + } else if (affinityGroupType.type === 'host affinity') { + alertTypes[affinityGroupType.type] = 'host affinity (Strict)' + } else if (affinityGroupType.type === 'non-strict host anti-affinity') { + alertTypes[affinityGroupType.type] = 'host anti-affinity (Non-Strict)' + } else if (affinityGroupType.type === 'non-strict host affinity') { + alertTypes[affinityGroupType.type] = 'host affinity (Non-Strict)' + } + } + this.alertTypes = alertTypes + resolve(alertTypes[type]) + }).catch(() => { + resolve(null) + }) + }) + }, + getGuestNetworkType (value) { + switch (value.toLowerCase()) { + case 'isolated': + return this.$t('label.isolated') + case 'shared': + return this.$t('label.shared') + case 'l2': + return this.$t('label.l2') + } + }, + getAccountType (type) { + const types = [] + if (this.apiName.indexOf('listAccounts') > -1) { + switch (type) { + case '1': + return 'Admin' + case '2': + return 'DomainAdmin' + case '0': + return 'User' + } + } + return types + }, + getSystemVmType (type) { + if (this.apiName.indexOf('listSystemVms') > -1) { + switch (type.toLowerCase()) { + case 'consoleproxy': + return this.$t('label.console.proxy.vm') + case 'secondarystoragevm': + return this.$t('label.secondary.storage.vm') + } + } + }, + getStoragePoolScope (scope) { + if (this.apiName.indexOf('listStoragePools') > -1) { + switch (scope.toUpperCase()) { + case 'CLUSTER': + return this.$t('label.cluster') + case 'ZONE': + return this.$t('label.zone') + case 'REGION': + return this.$t('label.region') + case 'GLOBAL': + return this.$t('label.global') + } + } + }, + getImageStoreProvider (provider) { + if (this.apiName.indexOf('listImageStores') > -1) { + switch (provider.toUpperCase()) { + case 'NFS': + return 'NFS' + case 'SMB': + return 'SMB/CIFS' + case 'S3': + return 'S3' + case 'SWIFT': + return 'Swift' + } + } + }, + getRoleType (role) { + switch (role.toLowerCase()) { + case 'Admin'.toLowerCase(): + return 'Admin' + case 'ResourceAdmin'.toLowerCase(): + return 'ResourceAdmin' + case 'DomainAdmin'.toLowerCase(): + return 'DomainAdmin' + case 'User'.toLowerCase(): + return 'User' + } + }, + getScope (scope) { + switch (scope.toLowerCase()) { + case 'local': + return this.$t('label.local') + case 'domain': + return this.$t('label.domain') + case 'global': + return this.$t('label.global') + } + }, + getState (state) { + if (this.apiName.includes('listVolumes')) { + switch (state.toLowerCase()) { + case 'allocated': + return this.$t('label.allocated') + case 'ready': + return this.$t('label.isready') + case 'destroy': + return this.$t('label.destroy') + case 'expunging': + return this.$t('label.expunging') + case 'expunged': + return this.$t('label.expunged') + case 'migrating': + return this.$t('label.migrating') + } + } else if (this.apiName.includes('listKubernetesClusters')) { + switch (state.toLowerCase()) { + case 'created': + return this.$t('label.created') + case 'starting': + return this.$t('label.starting') + case 'running': + return this.$t('label.running') + case 'stopping': + return this.$t('label.stopping') + case 'stopped': + return this.$t('label.stopped') + case 'scaling': + return this.$t('label.scaling') + case 'upgrading': + return this.$t('label.upgrading') + case 'alert': + return this.$t('label.alert') + case 'recovering': + return this.$t('label.recovering') + case 'destroyed': + return this.$t('label.destroyed') + case 'destroying': + return this.$t('label.destroying') + case 'error': + return this.$t('label.error') + } + } else if (this.apiName.indexOf('listWebhooks') > -1) { + switch (state.toLowerCase()) { + case 'enabled': + return this.$t('label.enabled') + case 'disabled': + return this.$t('label.disabled') + } + } + }, + getEntityType (type) { + let entityType = '' + if (this.apiName.indexOf('listAnnotations') > -1) { + const allowedTypes = { + VM: 'Virtual Machine', + HOST: 'Host', + VOLUME: 'Volume', + SNAPSHOT: 'Snapshot', + VM_SNAPSHOT: 'VM Snapshot', + INSTANCE_GROUP: 'Instance Group', + NETWORK: 'Network', + VPC: 'VPC', + PUBLIC_IP_ADDRESS: 'Public IP Address', + VPN_CUSTOMER_GATEWAY: 'VPC Customer Gateway', + TEMPLATE: 'Template', + ISO: 'ISO', + SSH_KEYPAIR: 'SSH Key Pair', + DOMAIN: 'Domain', + SERVICE_OFFERING: 'Service Offfering', Review Comment: Typo in display label: "Service Offfering" should be "Service Offering". ```suggestion SERVICE_OFFERING: 'Service Offering', ``` ########## ui/src/components/view/SearchFilter.vue: ########## @@ -0,0 +1,555 @@ +// 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-row + style="overflow: auto" + :wrap="false" + > + <template + v-for="(filter) in this.searchFilters" + :key="filter" + > + <a-col v-if="!['page', 'pagesize', 'q', 'keyword', 'tags'].includes(filter.key)"> + <a-tag + v-if="!filter.isTag" + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="retrieveFieldLabel(filter.key) + ': ' + filter.value" + :placement="tooltipPlacement" + > + {{ retrieveFieldLabel(filter.key) }} : {{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + <a-tag + v-else + closable + @close="() => $emit('removeFilter', filter)" + > + <a-tooltip + :title="$t('label.tag') + ': ' + filter.key + '=' + filter.value" + :placement="tooltipPlacement" + > + {{ $t('label.tag') }}: {{ filter.key }}={{ getTrimmedText(filter.value, 20) }} + </a-tooltip> + </a-tag> + </a-col> + </template> + </a-row> +</template> + +<script> + +import { api } from '@/api/index' + +export default { + name: 'SearchFilter', + props: { + filters: { + type: Array, + default: () => [] + }, + apiName: { + type: String, + default: () => '' + }, + filterKey: { + type: String, + default: () => '' + }, + filterValue: { + type: String, + default: () => '' + }, + isTag: { + type: Boolean, + default: () => false + } + }, + emits: ['removeFilter'], + data () { + return { + searchFilters: [], + apiMap: { + type: this.getType, + hypervisor: this.getHypervisor, + zoneid: { + apiName: 'listZones', + responseKey1: 'listzonesresponse', + responseKey2: 'zone', + field: 'name' + }, + domainid: { + apiName: 'listDomains', + responseKey1: 'listdomainsresponse', + responseKey2: 'domain', + field: 'name' + }, + account: { + apiName: 'listAccounts', + responseKey1: 'listaccountsresponse', + responseKey2: 'account', + field: 'name' + }, + imagestoreid: { + apiName: 'listImageStores', + responseKey1: 'listimagestoresresponse', + responseKey2: 'imagestore', + field: 'name' + }, + storageid: { + apiName: 'listStoragePools', + responseKey1: 'liststoragepoolsresponse', + responseKey2: 'storagepool', + field: 'name' + }, + podid: { + apiName: 'listPods', + responseKey1: 'listpodsresponse', + responseKey2: 'pod', + field: 'name' + }, + clusterid: { + apiName: 'listClusters', + responseKey1: 'listclustersresponse', + responseKey2: 'cluster', + field: 'name' + }, + hostid: { + apiName: 'listHosts', + responseKey1: 'listhostsresponse', + responseKey2: 'host', + field: 'name' + }, + groupid: { + apiName: 'listInstanceGroups', + responseKey1: 'listinstancegroupsresponse', + responseKey2: 'instancegroup', + field: 'name' + }, + managementserverid: { + apiName: 'listManagementServers', + responseKey1: 'listmanagementserversresponse', + responseKey2: 'managementserver', + field: 'name' + }, + serviceofferingid: { + apiName: 'listServiceOfferings', + responseKey1: 'listserviceofferingsresponse', + responseKey2: 'serviceoffering', + field: 'name' + }, + diskofferingid: { + apiName: 'listDiskOfferings', + responseKey1: 'listdiskofferingsresponse', + responseKey2: 'diskoffering', + field: 'name' + }, + networkid: { + apiName: 'listNetworks', + responseKey1: 'listnetworksresponse', + responseKey2: 'network', + field: 'name' + } + } + } + }, + updated () { + this.searchFilters = this.filters + const promises = [] + for (const idx in this.filters) { + const filter = this.filters[idx] + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + continue + } + promises.push(new Promise((resolve) => { + if (this.searchFilters[idx] && this.searchFilters[idx].value !== filter.value) { + resolve() + } + if (filter.key === 'tags') { + this.searchFilters[idx] = { + key: filter.key, + value: filter.value, + isTag: true + } + } else { + this.getSearchFilters(filter.key, filter.value).then((value) => { + this.searchFilters[idx] = { + key: filter.key, + value: value, + isTag: filter.isTag + } + }) + } + resolve() + })) + } + Promise.all(promises) Review Comment: The `updated()` hook assigns `this.searchFilters = this.filters` and then writes to `this.searchFilters[idx]`, which mutates the `filters` prop array (same reference) and can also trigger repeated update cycles. Replace this with a `watch` on `filters` that clones before mutation, or render directly from `filters` and keep formatted values in separate state keyed by filter identity. ```suggestion watch: { filters: { immediate: true, handler (newFilters) { const clonedFilters = newFilters.map(filter => ({ ...filter })) const promises = [] for (let idx = 0; idx < clonedFilters.length; idx++) { const filter = clonedFilters[idx] promises.push(new Promise((resolve) => { if (filter.key === 'tags') { clonedFilters[idx] = { key: filter.key, value: filter.value, isTag: true } resolve() } else { this.getSearchFilters(filter.key, filter.value).then((value) => { clonedFilters[idx] = { key: filter.key, value: value, isTag: filter.isTag } resolve() }) } })) } Promise.all(promises).then(() => { this.searchFilters = clonedFilters }) } } ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
