ui: introduce section-level “advisories” with quick-fix actions (#11763)

* ui: introduce section-level “advisories” with quick-fix actions

This change adds a lightweight “advisories” mechanism to section configs and ships the first advisory to help operators satisfy some of the CKS prerequisites.

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>

* fix

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>

* fix endpoint.url check

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>

* label consistency

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>

* Update ui/src/components/view/AdvisoriesView.vue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* improvements

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>

* remove comments

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>

* allow disabling

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>

---------

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Abhishek Kumar 2026-01-29 13:07:52 +05:30 committed by GitHub
parent 98debd235f
commit 10e0d42f45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 452 additions and 1 deletions

View File

@ -292,8 +292,10 @@
"label.add.isolated.network": "Add Isolated Network",
"label.add.kubernetes.cluster": "Add Kubernetes Cluster",
"label.add.acl.name": "ACL name",
"label.add.latest.kubernetes.iso": "Add latest Kubernetes ISO",
"label.add.ldap.account": "Add LDAP Account",
"label.add.logical.router": "Add Logical Router to this Network",
"label.add.minimum.required.compute.offering": "Add minimum required Compute Offering",
"label.add.more": "Add more",
"label.add.nodes": "Add Nodes to Kubernetes Cluster",
"label.add.netscaler.device": "Add Netscaler Device",
@ -1102,6 +1104,7 @@
"label.firstname": "First name",
"label.firstname.lower": "firstname",
"label.fix.errors": "Fix errors",
"label.fix.global.setting": "Fix Global Setting",
"label.fixed": "Fixed Offering",
"label.for": "for",
"label.forcks": "For CKS",
@ -1135,6 +1138,9 @@
"label.globo.dns.configuration": "GloboDNS configuration",
"label.glustervolume": "Volume",
"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.kubernetes.isos": "Go to Kubernetes ISOs",
"label.gpu": "GPU",
"label.gpucardid": "GPU Card",
"label.gpucardname": "GPU Card",
@ -3058,12 +3064,17 @@
"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.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.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...",
"message.remove.ip.v6.firewall.rule.success": "Removed IPv6 firewall rule",
"message.remove.sslcert.failed": "Failed to remove SSL certificate from load balancer",
"message.remove.sslcert.processing": "Removing SSL certificate from load balancer...",
"message.add.latest.kubernetes.iso.failed": "Failed to add latest Kubernetes ISO",
"message.add.minimum.required.compute.offering.kubernetes.cluster.failed": "Failed to add minimum required Compute Offering for Kubernetes cluster nodes",
"message.add.netris.controller": "Add Netris Provider",
"message.add.nsx.controller": "Add NSX Provider",
"message.add.network": "Add a new network for Zone: <b><span id=\"zone_name\"></span></b>",
@ -3104,9 +3115,13 @@
"message.add.vpn.gateway": "Please confirm that you want to add a VPN Gateway.",
"message.add.vpn.gateway.failed": "Adding VPN gateway failed",
"message.add.vpn.gateway.processing": "Adding VPN gateway...",
"message.added.latest.kubernetes.iso": "Latest Kubernetes ISO added successfully",
"message.added.minimum.required.compute.offering.kubernetes.cluster": "Minimum required Compute Offering for Kubernetes cluster nodes added successfully",
"message.added.vpc.offering": "Added VPC offering",
"message.adding.firewall.policy": "Adding Firewall Policy",
"message.adding.host": "Adding host",
"message.adding.latest.kubernetes.iso": "Adding latest Kubernetes ISO",
"message.adding.minimum.required.compute.offering.kubernetes.cluster": "Adding minimum required Compute Offering for Kubernetes cluster nodes",
"message.adding.netscaler.device": "Adding Netscaler device",
"message.adding.netscaler.provider": "Adding Netscaler provider",
"message.adding.nodes.to.cluster": "Adding nodes to Kubernetes cluster",
@ -3544,6 +3559,8 @@
"message.failed.to.remove": "Failed to remove",
"message.forgot.password.success": "An email has been sent to your email address with instructions on how to reset your password.",
"message.generate.keys": "Please confirm that you would like to generate new API/Secret keys for this User.",
"message.global.setting.updated": "Global Setting updated successfully.",
"message.global.setting.update.failed": "Failed to update Global Setting.",
"message.chart.statistic.info": "The shown charts are self-adjustable, that means, if the value gets close to the limit or overpass it, it will grow to adjust the shown value",
"message.chart.statistic.info.hypervisor.additionals": "The metrics data depend on the hypervisor plugin used for each hypervisor. The behavior can vary across different hypervisors. For instance, with KVM, metrics are real-time statistics provided by libvirt. In contrast, with VMware, the metrics are averaged data for a given time interval controlled by configuration.",
"message.guest.traffic.in.advanced.zone": "Guest Network traffic is communication between end-user Instances. Specify a range of VLAN IDs or VXLAN Network identifiers (VNIs) to carry guest traffic for each physical Network.",

View File

@ -140,3 +140,7 @@ export function oauthlogin (arg) {
}
})
}
export function getBaseUrl () {
return vueProps.axios.defaults.baseURL
}

View File

@ -0,0 +1,161 @@
// 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>
<div>
<div v-for="advisory in advisories" :key="advisory.id" style="margin-bottom: 10px;">
<a-alert
:type="advisory.severity || 'info'"
:show-icon="true"
:closable="true"
:message="$t(advisory.message)"
@close="onAlertClose(advisory)">
<template #description>
<a-space direction="horizontal" size="small">
<span v-for="(action, idx) in advisory.actions" :key="idx">
<a-button
v-if="typeof action.show === 'function' ? action.show($store) : action.show"
size="small"
:type="(action.primary || advisory.actions.length === 1) ? 'primary' : 'default'"
@click="onAlertBtnClick(action, advisory)">
{{ $t(action.label) }}
</a-button>
</span>
</a-space>
</template>
</a-alert>
</div>
</div>
</template>
<script>
const DISMISSED_ADVISORIES_KEY = 'dismissed_advisories'
export default {
name: 'AdvisoriesView',
components: {
},
props: {},
data () {
return {
advisories: []
}
},
created () {
this.evaluateAdvisories()
},
computed: {
},
methods: {
async evaluateAdvisories () {
this.advisories = []
const metaAdvisories = this.$route.meta.advisories || []
const dismissedAdvisories = this.$localStorage.get(DISMISSED_ADVISORIES_KEY) || []
const advisoryPromises = metaAdvisories.map(async advisory => {
if (dismissedAdvisories.includes(advisory.id)) {
return null
}
const active = await Promise.resolve(advisory.condition(this.$store))
if (active) {
return advisory
} else if (advisory.dismissOnConditionFail) {
this.dismissAdvisory(advisory.id, true)
}
return null
})
const results = await Promise.all(advisoryPromises)
this.advisories = results.filter(a => a !== null)
},
onAlertClose (advisory) {
this.dismissAdvisory(advisory.id)
},
dismissAdvisory (advisoryId, skipUpdateLocal) {
let dismissedAdvisories = this.$localStorage.get(DISMISSED_ADVISORIES_KEY) || []
dismissedAdvisories = dismissedAdvisories.filter(id => id !== advisoryId)
dismissedAdvisories.push(advisoryId)
this.$localStorage.set(DISMISSED_ADVISORIES_KEY, dismissedAdvisories)
if (skipUpdateLocal) {
return
}
this.advisories = this.advisories.filter(advisory => advisory.id !== advisoryId)
},
undismissAdvisory (advisory, evaluate) {
let dismissedAdvisories = this.$localStorage.get(DISMISSED_ADVISORIES_KEY) || []
dismissedAdvisories = dismissedAdvisories.filter(id => id !== advisory.id)
this.$localStorage.set(DISMISSED_ADVISORIES_KEY, dismissedAdvisories)
if (evaluate) {
Promise.resolve(advisory.condition(this.$store)).then(active => {
if (active) {
this.advisories.push(advisory)
}
})
} else {
this.advisories.push(advisory)
}
},
handleAdvisoryActionError (action, advisory, evaluate) {
if (action.errorMessage) {
this.showActionMessage('error', advisory.id, action.errorMessage)
}
this.undismissAdvisory(advisory, evaluate)
},
handleAdvisoryActionResult (action, advisory, result) {
if (result && action.successMessage) {
this.showActionMessage('success', advisory.id, action.successMessage)
return
}
this.handleAdvisoryActionError(action, advisory, false)
},
showActionMessage (type, key, content) {
const data = {
content: this.$t(content),
key: key,
duration: type === 'loading' ? 0 : 3
}
if (type === 'loading') {
this.$message.loading(data)
} else if (type === 'success') {
this.$message.success(data)
} else if (type === 'error') {
this.$message.error(data)
} else {
this.$message.info(data)
}
},
onAlertBtnClick (action, advisory) {
this.dismissAdvisory(advisory.id)
if (typeof action.run !== 'function') {
return
}
if (action.loadingLabel) {
this.showActionMessage('loading', advisory.id, action.loadingLabel)
}
const result = action.run(this.$store, this.$router)
if (result instanceof Promise) {
result.then(success => {
this.handleAdvisoryActionResult(action, advisory, success)
}).catch(() => {
this.handleAdvisoryActionError(action, advisory, true)
})
} else {
this.handleAdvisoryActionResult(action, advisory, result)
}
}
}
}
</script>

View File

@ -81,6 +81,7 @@ function generateRouterMap (section) {
filters: child.filters,
params: child.params ? child.params : {},
columns: child.columns,
advisories: !vueProps.$config.advisoriesDisabled ? child.advisories : undefined,
details: child.details,
searchFilters: child.searchFilters,
related: child.related,
@ -180,6 +181,10 @@ function generateRouterMap (section) {
map.meta.columns = section.columns
}
if (!vueProps.$config.advisoriesDisabled && section.advisories) {
map.meta.advisories = section.advisories
}
if (section.actions) {
map.meta.actions = section.actions
}

View File

@ -18,6 +18,8 @@
import { shallowRef, defineAsyncComponent } from 'vue'
import store from '@/store'
import { isZoneCreated } from '@/utils/zone'
import { getAPI, postAPI, getBaseUrl } from '@/api'
import { getLatestKubernetesIsoParams } from '@/utils/acsrepo'
import kubernetesIcon from '@/assets/icons/kubernetes.svg?inline'
export default {
@ -582,6 +584,182 @@ export default {
}
],
resourceType: 'KubernetesCluster',
advisories: [
{
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
},
actions: [
{
primary: true,
label: 'label.add.minimum.required.compute.offering',
loadingLabel: 'message.adding.minimum.required.compute.offering.kubernetes.cluster',
show: (store) => { return ('createServiceOffering' in store.getters.apis) },
run: async () => {
const params = {
name: 'CKS Instance',
cpunumber: 2,
cpuspeed: 1000,
memory: 2048,
iscustomized: false,
issystem: false
}
try {
const json = await postAPI('createServiceOffering', params)
if (json?.createserviceofferingresponse?.serviceoffering) {
return true
}
} catch (error) {}
return false
},
successMessage: 'message.added.minimum.required.compute.offering.kubernetes.cluster',
errorMessage: 'message.add.minimum.required.compute.offering.kubernetes.cluster.failed'
},
{
label: 'label.go.to.compute.offerings',
show: (store) => { return ('listServiceOfferings' in store.getters.apis) },
run: (store, router) => {
router.push({ name: 'computeoffering' })
return false
}
}
]
},
{
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
},
actions: [
{
primary: true,
label: 'label.add.latest.kubernetes.iso',
loadingLabel: 'message.adding.latest.kubernetes.iso',
show: (store) => { return ('addKubernetesSupportedVersion' in store.getters.apis) },
run: async () => {
let arch = 'x86_64'
if ('listClusters' in store.getters.apis) {
try {
const json = await getAPI('listClusters', { allocationstate: 'Enabled', page: 1, pagesize: 1 })
const cluster = json?.listclustersresponse?.cluster?.[0] || {}
arch = cluster.architecture || 'x86_64'
} catch (error) {}
}
const params = await getLatestKubernetesIsoParams(arch)
try {
const json = await postAPI('addKubernetesSupportedVersion', params)
if (json?.addkubernetessupportedversionresponse?.kubernetessupportedversion) {
return true
}
} catch (error) {}
return false
},
successMessage: 'message.added.latest.kubernetes.iso',
errorMessage: 'message.add.latest.kubernetes.iso.failed'
},
{
label: 'label.go.to.kubernetes.isos',
show: true,
run: (store, router) => {
router.push({ name: 'kubernetesiso' })
return false
}
}
]
},
{
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)) {
return false
}
let url = ''
const baseUrl = getBaseUrl()
if (baseUrl.startsWith('/')) {
url = window.location.origin + baseUrl
}
if (!url || url.startsWith('http://localhost')) {
return false
}
const params = {
name: 'endpoint.url'
}
const json = await getAPI('listConfigurations', params)
const configuration = json?.listconfigurationsresponse?.configuration?.[0] || {}
return !configuration.value || configuration.value.startsWith('http://localhost')
},
actions: [
{
primary: true,
label: 'label.fix.global.setting',
show: (store) => { return ('updateConfiguration' in store.getters.apis) },
run: async () => {
let url = ''
const baseUrl = getBaseUrl()
if (baseUrl.startsWith('/')) {
url = window.location.origin + baseUrl
}
const params = {
name: 'endpoint.url',
value: url
}
try {
const json = await postAPI('updateConfiguration', params)
if (json?.updateconfigurationresponse?.configuration) {
return true
}
} catch (error) {}
return false
},
successMessage: 'message.global.setting.updated',
errorMessage: 'message.global.setting.update.failed'
},
{
label: 'label.go.to.global.settings',
show: (store) => { return ('listConfigurations' in store.getters.apis) },
run: (store, router) => {
router.push({ name: 'globalsetting' })
return false
}
}
]
}
],
actions: [
{
api: 'createKubernetesCluster',

View File

@ -0,0 +1,81 @@
// 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.
const BASE_KUBERNETES_ISO_URL = 'https://download.cloudstack.org/cks/'
function getDefaultLatestKubernetesIsoParams (arch) {
return {
name: 'v1.33.1-calico-' + arch,
semanticversion: '1.33.1',
url: BASE_KUBERNETES_ISO_URL + 'setup-v1.33.1-calico-' + arch + '.iso',
arch: arch,
mincpunumber: 2,
minmemory: 2048
}
}
/**
* Returns the latest Kubernetes ISO info for the given architecture.
* Falls back to a hardcoded default if fetching fails.
* @param {string} arch
* @returns {Promise<{name: string, semanticversion: string, url: string, arch: string}>}
*/
export async function getLatestKubernetesIsoParams (arch) {
arch = arch || 'x86_64'
try {
const html = await fetch(BASE_KUBERNETES_ISO_URL, { cache: 'no-store' }).then(r => r.text())
const hrefs = [...html.matchAll(/href="([^"]+\.iso)"/gi)].map(m => m[1])
// Prefer files that explicitly include the arch (e.g. ...-x86_64.iso)
let isoHrefs = hrefs.filter(h => new RegExp(`${arch}\\.iso$`, 'i').test(h))
// Fallback: older files without arch suffix (e.g. setup-1.28.4.iso)
if (isoHrefs.length === 0) {
isoHrefs = hrefs.filter(h => /setup-\d+\.\d+\.\d+\.iso$/i.test(h))
}
const entries = isoHrefs.map(h => {
const m = h.match(/setup-(?:v)?(\d+\.\d+\.\d+)(?:-calico)?(?:-(x86_64|arm64))?/i)
return m
? {
name: h.replace('.iso', ''),
semanticversion: m[1],
url: new URL(h, BASE_KUBERNETES_ISO_URL).toString(),
arch: m[2] || arch,
mincpunumber: 2,
minmemory: 2048
}
: null
}).filter(Boolean)
if (entries.length === 0) throw new Error('No matching ISOs found')
entries.sort((a, b) => {
const pa = a.semanticversion.split('.').map(Number)
const pb = b.semanticversion.split('.').map(Number)
for (let i = 0; i < 3; i++) {
if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
}
return 0
})
return entries[0]
} catch {
return { ...getDefaultLatestKubernetesIsoParams(arch) }
}
}

View File

@ -540,6 +540,9 @@
class="row-element"
v-else
>
<advisories-view
v-if="$route.meta.advisories && !loading"
/>
<list-view
:loading="loading"
:columns="columns"
@ -604,6 +607,7 @@ import ResourceIcon from '@/components/view/ResourceIcon'
import BulkActionProgress from '@/components/view/BulkActionProgress'
import TooltipLabel from '@/components/widgets/TooltipLabel'
import DetailsInput from '@/components/widgets/DetailsInput'
import AdvisoriesView from '@/components/view/AdvisoriesView'
export default {
name: 'Resource',
@ -617,7 +621,8 @@ export default {
TooltipLabel,
OsLogo,
ResourceIcon,
DetailsInput
DetailsInput,
AdvisoriesView
},
mixins: [mixinDevice],
provide: function () {