diff --git a/ui/public/config.json b/ui/public/config.json index a067803ba7a2..81f06c0dbb47 100644 --- a/ui/public/config.json +++ b/ui/public/config.json @@ -116,5 +116,6 @@ "message": "🤔 Sample Announcement: New Feature Available: Check out our latest dashboard improvements! Learn more", "startDate": "2025-06-01T00:00:00Z", "endDate": "2025-07-16T00:00:00Z" - } + }, + "advisoriesDisabled": false } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 8bcc5d0a94bf..1257c30a75fa 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1149,7 +1149,12 @@ "label.go.back": "Go back", "label.go.to.compute.offerings": "Go to Compute Offerings", "label.go.to.global.settings": "Go to Global Settings", +"label.go.to.networks": "Go to Networks", +"label.go.to.templates": "Go to Templates", +"label.go.to.isos": "Go to ISOs", +"label.go.to.volumes": "Go to Volumes", "label.go.to.kubernetes.isos": "Go to Kubernetes ISOs", +"label.go.to.snapshots": "Go to Volume Snapshots", "label.gpu": "GPU", "label.gpucardid": "GPU Card", "label.gpucardname": "GPU Card", @@ -3075,9 +3080,14 @@ "message.add.ip.v6.firewall.rule.failed": "Failed to add IPv6 firewall rule", "message.add.ip.v6.firewall.rule.processing": "Adding IPv6 firewall rule...", "message.add.ip.v6.firewall.rule.success": "Added IPv6 firewall rule", +"message.advisory.instance.compute.offering.missing": "No compute offering found for deploying an Instance.", +"message.advisory.instance.image.missing": "No suitable Template/ISO/Volume/Volume Snapshot found for deploying an Instance. Please make sure you have a Template/ISO/Volume/Snapshot ready for Instance deployment.", +"message.advisory.instance.network.missing": "No suitable Network found for deploying an Instance. Please create a Network to be used by the Instance.", "message.advisory.cks.endpoint.url.not.configured": "Endpoint URL which will be used by Kubernetes clusters is not configured correctly", "message.advisory.cks.min.offering": "No suitable Compute Offering found for Kubernetes cluster nodes with minimum required resources (2 vCPU, 2 GB RAM)", "message.advisory.cks.version.check": "No Kubernetes version found that can be used to deploy a Kubernetes cluster", +"message.advisory.vnf.appliance.compute.offering.missing": "No compute offering found for deploying a VNF appliance.", +"message.advisory.vnf.appliance.template.missing": "No VNF Template found for deploying a VNF appliance. Please make sure you have a VNF Template for appliance deployment.", "message.redeliver.webhook.delivery": "Redeliver this Webhook delivery", "message.remove.ip.v6.firewall.rule.failed": "Failed to remove IPv6 firewall rule", "message.remove.ip.v6.firewall.rule.processing": "Removing IPv6 firewall rule...", diff --git a/ui/src/config/router.js b/ui/src/config/router.js index 43e8efd7b5d3..1de08942422a 100644 --- a/ui/src/config/router.js +++ b/ui/src/config/router.js @@ -44,6 +44,8 @@ import tools from '@/config/section/tools' import quota from '@/config/section/plugin/quota' import cloudian from '@/config/section/plugin/cloudian' +const isAdvisoriesDisabled = () => vueProps.$config.advisoriesDisabled ?? false + function generateRouterMap (section) { var map = { name: section.name, @@ -81,7 +83,7 @@ function generateRouterMap (section) { filters: child.filters, params: child.params ? child.params : {}, columns: child.columns, - advisories: !vueProps.$config.advisoriesDisabled ? child.advisories : undefined, + advisories: !isAdvisoriesDisabled() ? child.advisories : undefined, details: child.details, searchFilters: child.searchFilters, related: child.related, @@ -181,7 +183,7 @@ function generateRouterMap (section) { map.meta.columns = section.columns } - if (!vueProps.$config.advisoriesDisabled && section.advisories) { + if (!isAdvisoriesDisabled() && section.advisories) { map.meta.advisories = section.advisories } diff --git a/ui/src/config/section/compute.js b/ui/src/config/section/compute.js index 32e888bb53dc..65cab7686718 100644 --- a/ui/src/config/section/compute.js +++ b/ui/src/config/section/compute.js @@ -21,6 +21,7 @@ import { isZoneCreated } from '@/utils/zone' import { getAPI, postAPI, getBaseUrl } from '@/api' import { getLatestKubernetesIsoParams } from '@/utils/acsrepo' import kubernetesIcon from '@/assets/icons/kubernetes.svg?inline' +import { hasNoItems } from '@/utils/advisory' export default { name: 'compute', @@ -100,6 +101,119 @@ export default { tabs: [{ component: shallowRef(defineAsyncComponent(() => import('@/views/compute/InstanceTab.vue'))) }], + advisories: [ + { + id: 'instance-image-check', + severity: 'warning', + message: 'message.advisory.instance.image.missing', + condition: async (store) => { + return await hasNoItems(store, + 'listTemplates', + { isvnf: false, templatefilter: 'executable', isready: true }) && + await hasNoItems(store, 'listIsos', { isofilter: 'executable', bootable: true, isready: true }) && + await hasNoItems(store, 'listVolumes', { state: 'Ready' }) && + await hasNoItems(store, 'listSnapshots') + }, + actions: [ + { + label: 'label.register.template', + show: (store) => { return ('registerTemplate' in store.getters.apis) }, + primary: true, + run: (store, router) => { + router.push({ name: 'template', query: { action: 'registerTemplate' } }) + return false + } + }, + { + label: 'label.go.to.templates', + show: (store) => { return ('listTemplates' in store.getters.apis) }, + run: (store, router) => { + router.push({ name: 'template' }) + return false + } + }, + { + label: 'label.go.to.isos', + show: (store) => { return ('listIsos' in store.getters.apis) }, + run: (store, router) => { + router.push({ name: 'iso' }) + return false + } + }, + { + label: 'label.go.to.volumes', + show: (store) => { return ('listVolumes' in store.getters.apis) }, + run: (store, router) => { + router.push({ name: 'volume' }) + return false + } + }, + { + label: 'label.go.to.snapshots', + show: (store) => { return ('listSnapshots' in store.getters.apis) }, + run: (store, router) => { + router.push({ name: 'snapshot' }) + return false + } + } + ] + }, + { + id: 'instance-compute-offering-check', + severity: 'warning', + message: 'message.advisory.instance.compute.offering.missing', + condition: async (store) => { + return await hasNoItems(store, 'listServiceOfferings', { issystem: false }) + }, + actions: [ + { + label: 'label.add.compute.offering', + show: (store) => { return ('createServiceOffering' in store.getters.apis) }, + primary: true, + run: (store, router) => { + router.push({ name: 'computeoffering', query: { action: 'createServiceOffering' } }) + return false + } + }, + { + label: 'label.go.to.compute.offerings', + show: (store) => { return ('listServiceOfferings' in store.getters.apis) }, + run: (store, router) => { + router.push({ name: 'computeoffering' }) + return false + } + } + ] + }, + { + id: 'instance-network-check', + severity: 'warning', + message: 'message.advisory.instance.network.missing', + dismissOnConditionFail: true, + condition: async (store) => { + return await hasNoItems(store, 'listNetworks') + }, + actions: [ + { + label: 'label.add.network', + show: (store) => { return ('createNetwork' in store.getters.apis) }, + primary: true, + run: (store, router) => { + router.push({ name: 'guestnetwork', query: { action: 'createNetwork' } }) + return false + } + }, + { + label: 'label.go.to.networks', + show: (store) => { return ('listNetworks' in store.getters.apis) }, + run: (store, router) => { + router.push({ name: 'guestnetworks' }) + return false + } + } + ] + } + ], actions: [ { api: 'deployVirtualMachine', @@ -589,23 +703,12 @@ export default { id: 'cks-min-offering', severity: 'warning', message: 'message.advisory.cks.min.offering', - docsHelp: 'plugins/cloudstack-kubernetes-service.html', - dismissOnConditionFail: true, condition: async (store) => { - if (!('listServiceOfferings' in store.getters.apis)) { - return false - } - const params = { - cpunumber: 2, - memory: 2048, - issystem: false - } - try { - const json = await getAPI('listServiceOfferings', params) - const offerings = json?.listserviceofferingsresponse?.serviceoffering || [] - return !offerings.some(o => !o.iscustomized) - } catch (error) {} - return false + return await hasNoItems(store, + 'listServiceOfferings', + { cpunumber: 2, memory: 2048, issystem: false }, + o => !o.iscustomized + ) }, actions: [ { @@ -647,19 +750,8 @@ export default { id: 'cks-version-check', severity: 'warning', message: 'message.advisory.cks.version.check', - docsHelp: 'plugins/cloudstack-kubernetes-service.html', - dismissOnConditionFail: true, condition: async (store) => { - const api = 'listKubernetesSupportedVersions' - if (!(api in store.getters.apis)) { - return false - } - try { - const json = await getAPI(api, {}) - const versions = json?.listkubernetessupportedversionsresponse?.kubernetessupportedversion || [] - return versions.length === 0 - } catch (error) {} - return false + return await hasNoItems(store, 'listKubernetesSupportedVersions') }, actions: [ { @@ -702,7 +794,6 @@ export default { id: 'cks-endpoint-url', severity: 'warning', message: 'message.advisory.cks.endpoint.url.not.configured', - docsHelp: 'plugins/cloudstack-kubernetes-service.html', dismissOnConditionFail: true, condition: async (store) => { if (!['Admin'].includes(store.getters.userInfo.roletype)) { diff --git a/ui/src/config/section/network.js b/ui/src/config/section/network.js index 33b39d271726..09bc36e77ff9 100644 --- a/ui/src/config/section/network.js +++ b/ui/src/config/section/network.js @@ -20,6 +20,7 @@ import store from '@/store' import tungsten from '@/assets/icons/tungsten.svg?inline' import { isAdmin } from '@/role' import { isZoneCreated } from '@/utils/zone' +import { hasNoItems } from '@/utils/advisory' export default { name: 'network', @@ -397,6 +398,66 @@ export default { tabs: [{ component: shallowRef(defineAsyncComponent(() => import('@/views/compute/InstanceTab.vue'))) }], + advisories: [ + { + id: 'vnfapp-image-check', + severity: 'warning', + message: 'message.advisory.vnf.appliance.template.missing', + condition: async (store) => { + return await hasNoItems(store, + 'listVnfTemplates', + { isvnf: true, templatefilter: 'executable', isready: true }) + }, + actions: [ + { + label: 'label.register.template', + show: (store) => { return ('registerTemplate' in store.getters.apis) }, + primary: true, + run: (store, router) => { + router.push({ name: 'template', query: { action: 'registerTemplate' } }) + return false + } + }, + { + label: 'label.go.to.templates', + show: (store) => { return ('listTemplates' in store.getters.apis) }, + primary: false, + run: (store, router) => { + router.push({ name: 'template' }) + return false + } + } + ] + }, + { + id: 'vnfapp-compute-offering-check', + severity: 'warning', + message: 'message.advisory.vnf.appliance.compute.offering.missing', + condition: async (store) => { + return await hasNoItems(store, 'listServiceOfferings', { issystem: false }) + }, + actions: [ + { + label: 'label.add.compute.offering', + show: (store) => { return ('createServiceOffering' in store.getters.apis) }, + primary: true, + run: (store, router) => { + router.push({ name: 'computeoffering', query: { action: 'createServiceOffering' } }) + return false + } + }, + { + label: 'label.go.to.compute.offerings', + show: (store) => { return ('listServiceOfferings' in store.getters.apis) }, + primary: false, + run: (store, router) => { + router.push({ name: 'computeoffering' }) + return false + } + } + ] + } + ], actions: [ { api: 'deployVnfAppliance', diff --git a/ui/src/utils/advisory/index.js b/ui/src/utils/advisory/index.js new file mode 100644 index 000000000000..6ce944da085a --- /dev/null +++ b/ui/src/utils/advisory/index.js @@ -0,0 +1,76 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +import { getAPI } from '@/api' + +/** + * Generic helper to check if an API has no items (useful for advisory conditions) + * @param {Object} store - Vuex store instance + * @param {string} apiName - Name of the API to call (e.g., 'listNetworks') + * @param {Object} params - Optional parameters to merge with defaults + * @param {Function} filterFunc - Optional function to filter items. If provided, returns true if no items match the filter. + * @param {string} itemsKey - Optional key for items array in response. If not provided, will be deduced from apiName + * @returns {Promise} - Returns true if no items exist (advisory should be shown), false otherwise + */ +export async function hasNoItems (store, apiName, params = {}, filterFunc = null, itemsKey = null) { + if (!(apiName in store.getters.apis)) { + return false + } + + // If itemsKey not provided, deduce it from apiName + if (!itemsKey) { + // Remove 'list' prefix: listNetworks -> Networks + let key = apiName.replace(/^list/i, '') + // Convert to lowercase + key = key.toLowerCase() + // Handle plural forms: remove trailing 's' or convert 'ies' to 'y' + if (key.endsWith('ies')) { + key = key.slice(0, -3) + 'y' + } else if (key.endsWith('s')) { + key = key.slice(0, -1) + } + itemsKey = key + } + + const allParams = { + listall: true, + ...params + } + + if (filterFunc == null) { + allParams.page = 1 + allParams.pageSize = 1 + } + + console.debug(`Checking if API ${apiName} has no items with params`, allParams) + + try { + const json = await getAPI(apiName, allParams) + // Auto-derive response key: listNetworks -> listnetworksresponse + const responseKey = `${apiName.toLowerCase()}response` + const items = json?.[responseKey]?.[itemsKey] || [] + if (filterFunc) { + const a = !items.some(filterFunc) + console.debug(`API ${apiName} has ${items.length} items, after filter has items: ${items.filter(filterFunc)[0]}, returning ${a}`) + const it = items.filter(filterFunc) + console.debug(`Filtered items:`, it) + return a + } + return items.length === 0 + } catch (error) { + return false + } +}