This is an automated email from the ASF dual-hosted git repository.

dahn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new f9451fce3a3 Notify users when upgrades are available or restart is 
required for network or VPC (#7610)
f9451fce3a3 is described below

commit f9451fce3a33d90c8a13716c69ebfd13aff6b077
Author: Vishesh <vishes...@gmail.com>
AuthorDate: Wed Sep 4 12:53:57 2024 +0530

    Notify users when upgrades are available or restart is required for network 
or VPC (#7610)
    
    Co-authored-by: Harikrishna <harikrishna.patn...@gmail.com>
    Co-authored-by: dahn <daan.hoogl...@gmail.com>
---
 ui/package.json                           |  1 +
 ui/public/locales/en.json                 |  6 +++
 ui/src/components/header/HeaderNotice.vue |  8 ++--
 ui/src/components/page/GlobalFooter.vue   | 22 ++++++++++
 ui/src/components/view/ListView.vue       |  7 +++
 ui/src/components/view/SearchView.vue     | 12 ++++-
 ui/src/config/section/network.js          |  4 +-
 ui/src/store/getters.js                   |  1 +
 ui/src/store/modules/user.js              | 73 ++++++++++++++++++++++++++++++-
 ui/src/store/mutation-types.js            |  1 +
 ui/src/utils/util.js                      | 10 +++++
 11 files changed, 138 insertions(+), 7 deletions(-)

diff --git a/ui/package.json b/ui/package.json
index 8a9a37e0490..df8c5d5f82b 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -60,6 +60,7 @@
     "npm-check-updates": "^6.0.1",
     "nprogress": "^0.2.0",
     "qrious": "^4.0.2",
+    "semver": "^7.6.3",
     "vue": "^3.2.31",
     "vue-chartjs": "^4.0.7",
     "vue-clipboard2": "^0.3.1",
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index c87ee0070f7..1bdcc675346 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -1446,6 +1446,7 @@
 "label.network.offering": "Network offering",
 "label.network.offerings": "Network offerings",
 "label.network.policy": "Network Policy",
+"label.network.restart.required": "Network restart required",
 "label.network.route.table": "Network route table",
 "label.network.routing.policy": "Network routing policy",
 "label.network.permissions": "Network permissions",
@@ -1476,6 +1477,7 @@
 "label.new.secondaryip.description": "Enter new secondary IP address",
 "label.new.tag": "New tag",
 "label.new.vm": "New Instance",
+"label.new.version.available": "New version available",
 "label.newdiskoffering": "New offering",
 "label.newinstance": "New Instance",
 "label.newname": "New name",
@@ -2463,6 +2465,7 @@
 "label.vpc.id": "VPC ID",
 "label.vpc.offerings": "VPC offerings",
 "label.vpc.virtual.router": "VPC virtual router",
+"label.vpc.restart.required": "VPC restart required",
 "label.vpcid": "VPC",
 "label.vpclimit": "VPC limits",
 "label.vpcname": "VPC",
@@ -3185,6 +3188,7 @@
 "message.network.offering.mac.learning.warning": "WARNING: In order to use MAC 
Learning you must ensure your hypervisor hosts are running ESXi 6.7+ and the 
Network uses distributed vSwitch 6.6.0+.",
 "message.network.offering.promiscuous.mode": "Applicable for guest Networks on 
VMware hypervisor only.\nReject - The switch drops any outbound frame from a 
virtual machine adapter with a source MAC address that is different from the 
one in the .vmx configuration file.\nAccept - The switch does not perform 
filtering, and permits all outbound frames.\nNone - Default to value from 
global setting.",
 "message.network.removenic": "Please confirm that want to remove this NIC, 
which will also remove the associated Network from the Instance.",
+"message.network.restart.required": "Restart is required for network(s). Click 
here to view network(s) which require restart.",
 "message.network.secondaryip": "Please confirm that you would like to acquire 
a new secondary IP for this NIC. \n NOTE: You need to manually configure the 
newly-acquired secondary IP inside the virtual machine.",
 "message.network.selection": "Choose one or more Networks to attach the 
Instance to.",
 "message.network.selection.new.network": "A new Network can also be created 
here.",
@@ -3192,6 +3196,7 @@
 "message.network.usage.info.data.points": "Each data point represents the 
difference in data traffic since the last data point.",
 "message.network.usage.info.sum.of.vnics": "The Network usage shown is made up 
of the sum of data traffic from all the vNICs in the Instance.",
 "message.nfs.mount.options.description": "Comma separated list of NFS mount 
options for KVM hosts. Supported options : vers=[3,4.0,4.1,4.2], 
nconnect=[1...16]",
+"message.new.version.available": "A new version of CloudStack is available. 
Click here to check the details",
 "message.no.data.to.show.for.period": "No data to show for the selected 
period.",
 "message.no.description": "No description entered.",
 "message.offering.internet.protocol.warning": "WARNING: IPv6 supported 
Networks use static routing and will require upstream routes to be configured 
manually.",
@@ -3526,6 +3531,7 @@
 "message.volume.state.primary.storage.suitability": "The suitability of a 
primary storage for a volume depends on the disk offering of the volume and on 
the virtual machine allocation (if the volume is attached to a virtual 
machine).",
 "message.volumes.managed": "Volumes controlled by CloudStack.",
 "message.volumes.unmanaged": "Volumes not controlled by CloudStack.",
+"message.vpc.restart.required": "Restart is required for VPC(s). Click here to 
view VPC(s) which require restart.",
 "message.vr.alert.upon.network.offering.creation.l2": "As virtual routers are 
not created for L2 Networks, the compute offering will not be used.",
 "message.vr.alert.upon.network.offering.creation.others": "As none of the 
obligatory services for creating a virtual router (VPN, DHCP, DNS, Firewall, 
LB, UserData, SourceNat, StaticNat, PortForwarding) are enabled, the virtual 
router will not be created and the compute offering will not be used.",
 "message.warn.change.primary.storage.scope": "This feature is tested and 
supported for the following configurations:<br>KVM - NFS/Ceph - 
DefaultPrimary<br>VMware - NFS - DefaultPrimary<br>*There might be extra steps 
involved to make it work for other configurations.",
diff --git a/ui/src/components/header/HeaderNotice.vue 
b/ui/src/components/header/HeaderNotice.vue
index 82dc4d45edc..f83ffcc538a 100644
--- a/ui/src/components/header/HeaderNotice.vue
+++ b/ui/src/components/header/HeaderNotice.vue
@@ -47,10 +47,12 @@
                 </a-avatar>
               </template>
               <template #description>
-                <span v-if="getResourceName(notice.description, 'name') && 
notice.path">
-                  <router-link :to="{ path: notice.path}"> {{ 
getResourceName(notice.description, "name") + ' - ' }}</router-link>
+                <span v-if="getResourceName(notice.description, 'name') && 
notice.path && !['VPC_RESTART_REQUIRED', 
'NETWORK_RESTART_REQUIRED'].includes(notice.key)">
+                  <router-link :to="{ path: notice.path}">{{ 
getResourceName(notice.description, "name") + ' - ' }}</router-link>
+                  {{ getResourceName(notice.description, "msg") }}</span>
+                <span v-else-if="notice.path && ['VPC_RESTART_REQUIRED', 
'NETWORK_RESTART_REQUIRED'].includes(notice.key)">
+                  <router-link :to="{ path: notice.path, query: notice.query 
}">{{ notice.description }}</router-link>
                 </span>
-                <span v-if="getResourceName(notice.description, 'name') && 
notice.path"> {{ getResourceName(notice.description, "msg") }}</span>
                 <span v-else>{{ notice.description }}</span>
               </template>
             </a-list-item-meta>
diff --git a/ui/src/components/page/GlobalFooter.vue 
b/ui/src/components/page/GlobalFooter.vue
index 854cecc78ac..a89e5f2d8b3 100644
--- a/ui/src/components/page/GlobalFooter.vue
+++ b/ui/src/components/page/GlobalFooter.vue
@@ -22,6 +22,15 @@
     </div>
     <div class="line" v-if="$store.getters.userInfo.roletype === 'Admin'">
       CloudStack {{ $store.getters.features.cloudstackversion }}
+      <span v-if="showVersionUpdate()">
+        <a-divider type="vertical" />
+        <a
+          :href="'https://github.com/apache/cloudstack/releases/tag/' + 
$store.getters.latestVersion.version"
+          target="_blank">
+            <info-circle-outlined />
+            {{ $t('label.new.version.available') + ': ' + 
$store.getters.latestVersion.version }}
+        </a>
+      </span>
       <a-divider type="vertical" />
       <a href="https://github.com/apache/cloudstack/discussions"; 
target="_blank">
         <github-outlined />
@@ -32,11 +41,24 @@
 </template>
 
 <script>
+import semver from 'semver'
+import { getParsedVersion } from '@/utils/util'
+
 export default {
   name: 'LayoutFooter',
   data () {
     return {
     }
+  },
+  methods: {
+    showVersionUpdate () {
+      if (this.$store.getters?.features?.cloudstackversion && 
this.$store.getters?.latestVersion?.version) {
+        const currentVersion = 
getParsedVersion(this.$store.getters?.features?.cloudstackversion)
+        const latestVersion = 
getParsedVersion(this.$store.getters?.latestVersion?.version)
+        return semver.valid(currentVersion) && semver.valid(latestVersion) && 
semver.gt(latestVersion, currentVersion)
+      }
+      return false
+    }
   }
 }
 </script>
diff --git a/ui/src/components/view/ListView.vue 
b/ui/src/components/view/ListView.vue
index 27bd6a2fb36..2f94d6436a4 100644
--- a/ui/src/components/view/ListView.vue
+++ b/ui/src/components/view/ListView.vue
@@ -90,6 +90,13 @@
           <span v-else>
             <router-link :to="{ path: $route.path + '/' + record.id }" 
v-if="record.id">{{ text }}</router-link>
             <router-link :to="{ path: $route.path + '/' + record.name }" 
v-else>{{ text }}</router-link>
+            <span 
v-if="['guestnetwork','vpc'].includes($route.path.split('/')[1]) && 
record.restartrequired && !record.vpcid">
+              &nbsp;
+              <a-tooltip>
+                <template #title>{{ $t('label.restartrequired') }}</template>
+                <warning-outlined style="color: #f5222d"/>
+              </a-tooltip>
+            </span>
           </span>
         </span>
       </template>
diff --git a/ui/src/components/view/SearchView.vue 
b/ui/src/components/view/SearchView.vue
index 0d18ab9a85c..8d702eafdcd 100644
--- a/ui/src/components/view/SearchView.vue
+++ b/ui/src/components/view/SearchView.vue
@@ -303,7 +303,7 @@ export default {
         }
         if (['zoneid', 'domainid', 'imagestoreid', 'storageid', 'state', 
'account', 'hypervisor', 'level',
           'clusterid', 'podid', 'groupid', 'entitytype', 'accounttype', 
'systemvmtype', 'scope', 'provider',
-          'type', 'scope', 'managementserverid', 'serviceofferingid', 
'diskofferingid', 'usagetype'].includes(item)
+          'type', 'scope', 'managementserverid', 'serviceofferingid', 
'diskofferingid', 'usagetype', 'restartrequired'].includes(item)
         ) {
           type = 'list'
         } else if (item === 'tags') {
@@ -395,6 +395,16 @@ export default {
         this.fields[providerIndex].loading = false
       }
 
+      if (arrayField.includes('restartrequired')) {
+        const restartRequiredIndex = this.fields.findIndex(item => item.name 
=== 'restartrequired')
+        this.fields[restartRequiredIndex].loading = true
+        this.fields[restartRequiredIndex].opts = [
+          { id: 'true', name: 'label.yes' },
+          { id: 'false', name: 'label.no' }
+        ]
+        this.fields[restartRequiredIndex].loading = false
+      }
+
       if (arrayField.includes('resourcetype')) {
         const resourceTypeIndex = this.fields.findIndex(item => item.name === 
'resourcetype')
         this.fields[resourceTypeIndex].loading = true
diff --git a/ui/src/config/section/network.js b/ui/src/config/section/network.js
index 26b4f279e3d..1ff704a306f 100644
--- a/ui/src/config/section/network.js
+++ b/ui/src/config/section/network.js
@@ -54,7 +54,7 @@ export default {
         return fields
       },
       filters: ['all', 'account', 'domainpath', 'shared'],
-      searchFilters: ['keyword', 'zoneid', 'domainid', 'account', 'type', 
'tags'],
+      searchFilters: ['keyword', 'zoneid', 'domainid', 'account', 'type', 
'restartrequired', 'tags'],
       related: [{
         name: 'vm',
         title: 'label.instances',
@@ -218,7 +218,7 @@ export default {
         return fields
       },
       details: ['name', 'id', 'displaytext', 'cidr', 'networkdomain', 
'ip6routes', 'ispersistent', 'redundantvpcrouter', 'restartrequired', 
'zonename', 'account', 'domain', 'dns1', 'dns2', 'ip6dns1', 'ip6dns2', 
'publicmtu'],
-      searchFilters: ['name', 'zoneid', 'domainid', 'account', 'tags'],
+      searchFilters: ['name', 'zoneid', 'domainid', 'account', 
'restartrequired', 'tags'],
       related: [{
         name: 'vm',
         title: 'label.instances',
diff --git a/ui/src/store/getters.js b/ui/src/store/getters.js
index 67b168be8c2..405fd11bad1 100644
--- a/ui/src/store/getters.js
+++ b/ui/src/store/getters.js
@@ -28,6 +28,7 @@ const getters = {
   apis: state => state.user.apis,
   features: state => state.user.features,
   userInfo: state => state.user.info,
+  latestVersion: state => state.user.latestVersion,
   addRouters: state => state.permission.addRouters,
   multiTab: state => state.app.multiTab,
   listAllProjects: state => state.app.listAllProjects,
diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js
index 24302a94033..a5b48acf99d 100644
--- a/ui/src/store/modules/user.js
+++ b/ui/src/store/modules/user.js
@@ -18,12 +18,15 @@
 import Cookies from 'js-cookie'
 import message from 'ant-design-vue/es/message'
 import notification from 'ant-design-vue/es/notification'
+import semver from 'semver'
 
 import { vueProps } from '@/vue-app'
 import router from '@/router'
 import store from '@/store'
 import { oauthlogin, login, logout, api } from '@/api'
 import { i18n } from '@/locales'
+import { axios } from '../../utils/request'
+import { getParsedVersion } from '@/utils/util'
 
 import {
   ACCESS_TOKEN,
@@ -38,7 +41,8 @@ import {
   DARK_MODE,
   CUSTOM_COLUMNS,
   OAUTH_DOMAIN,
-  OAUTH_PROVIDER
+  OAUTH_PROVIDER,
+  LATEST_CS_VERSION
 } from '@/store/mutation-types'
 
 const user = {
@@ -167,6 +171,12 @@ const user = {
     },
     SET_OAUTH_PROVIDER_USED_TO_LOGIN: (state, provider) => {
       vueProps.$localStorage.set(OAUTH_PROVIDER, provider)
+    },
+    SET_LATEST_VERSION: (state, version) => {
+      if (version?.fetchedTs > 0) {
+        vueProps.$localStorage.set(LATEST_CS_VERSION, version)
+        state.latestVersion = version
+      }
     }
   },
 
@@ -212,6 +222,8 @@ const user = {
           commit('SET_2FA_PROVIDER', result.providerfor2fa)
           commit('SET_2FA_ISSUER', result.issuerfor2fa)
           commit('SET_LOGIN_FLAG', false)
+          const latestVersion = vueProps.$localStorage.get(LATEST_CS_VERSION, 
{ version: '', fetchedTs: 0 })
+          commit('SET_LATEST_VERSION', latestVersion)
           notification.destroy()
 
           resolve()
@@ -259,6 +271,8 @@ const user = {
           commit('SET_2FA_PROVIDER', result.providerfor2fa)
           commit('SET_2FA_ISSUER', result.issuerfor2fa)
           commit('SET_LOGIN_FLAG', false)
+          const latestVersion = vueProps.$localStorage.get(LATEST_CS_VERSION, 
{ version: '', fetchedTs: 0 })
+          commit('SET_LATEST_VERSION', latestVersion)
           notification.destroy()
 
           resolve()
@@ -277,10 +291,12 @@ const user = {
         const cachedCustomColumns = vueProps.$localStorage.get(CUSTOM_COLUMNS, 
{})
         const domainStore = vueProps.$localStorage.get(DOMAIN_STORE, {})
         const darkMode = vueProps.$localStorage.get(DARK_MODE, false)
+        const latestVersion = vueProps.$localStorage.get(LATEST_CS_VERSION, { 
version: '', fetchedTs: 0 })
         const hasAuth = Object.keys(cachedApis).length > 0
 
         commit('SET_DOMAIN_STORE', domainStore)
         commit('SET_DARK_MODE', darkMode)
+        commit('SET_LATEST_VERSION', latestVersion)
         if (hasAuth) {
           console.log('Login detected, using cached APIs')
           commit('SET_ZONES', cachedZones)
@@ -294,6 +310,7 @@ const user = {
             const result = response.listusersresponse.user[0]
             commit('SET_INFO', result)
             commit('SET_NAME', result.firstname + ' ' + result.lastname)
+            store.dispatch('SetCsLatestVersion', result.rolename)
             resolve(cachedApis)
           }).catch(error => {
             reject(error)
@@ -332,12 +349,41 @@ const user = {
           }).catch(error => {
             reject(error)
           })
+
+          api('listNetworks', { restartrequired: true, forvpc: false 
}).then(response => {
+            if (response.listnetworksresponse.count > 0) {
+              store.dispatch('AddHeaderNotice', {
+                key: 'NETWORK_RESTART_REQUIRED',
+                title: i18n.global.t('label.network.restart.required'),
+                description: i18n.global.t('message.network.restart.required'),
+                path: '/guestnetwork/',
+                query: { restartrequired: true, forvpc: false },
+                status: 'done',
+                timestamp: new Date()
+              })
+            }
+          }).catch(ignored => {})
+
+          api('listVPCs', { restartrequired: true }).then(response => {
+            if (response.listvpcsresponse.count > 0) {
+              store.dispatch('AddHeaderNotice', {
+                key: 'VPC_RESTART_REQUIRED',
+                title: i18n.global.t('label.vpc.restart.required'),
+                description: i18n.global.t('message.vpc.restart.required'),
+                path: '/vpc/',
+                query: { restartrequired: true },
+                status: 'done',
+                timestamp: new Date()
+              })
+            }
+          }).catch(ignored => {})
         }
 
         api('listUsers', { username: Cookies.get('username') }).then(response 
=> {
           const result = response.listusersresponse.user[0]
           commit('SET_INFO', result)
           commit('SET_NAME', result.firstname + ' ' + result.lastname)
+          store.dispatch('SetCsLatestVersion', result.rolename)
         }).catch(error => {
           reject(error)
         })
@@ -367,6 +413,8 @@ const user = {
           commit('SET_CLOUDIAN', cloudian)
         }).catch(ignored => {
         })
+      }).catch(error => {
+        console.error(error)
       })
     },
 
@@ -488,6 +536,29 @@ const user = {
     SetDomainStore ({ commit }, domainStore) {
       commit('SET_DOMAIN_STORE', domainStore)
     },
+    SetCsLatestVersion ({ commit }, rolename) {
+      const lastFetchTs = store.getters.latestVersion?.fetchedTs ? 
store.getters.latestVersion.fetchedTs : 0
+      if (rolename === 'Root Admin' && (+new Date() - lastFetchTs) > 24 * 60 * 
60 * 1000) {
+        axios.get(
+          'https://api.github.com/repos/apache/cloudstack/releases'
+        ).then(response => {
+          let latestReleaseVersion = getParsedVersion(response[0].tag_name)
+          let latestTag = response[0].tag_name
+
+          for (const release of response) {
+            if (release.tag_name.toLowerCase().includes('rc')) {
+              continue
+            }
+            const parsedVersion = getParsedVersion(release.tag_name)
+            if (semver.gte(parsedVersion, latestReleaseVersion)) {
+              latestReleaseVersion = parsedVersion
+              latestTag = release.tag_name
+              commit('SET_LATEST_VERSION', { version: latestTag, fetchedTs: 
(+new Date()) })
+            }
+          }
+        }).catch(ignored => {})
+      }
+    },
     SetDarkMode ({ commit }, darkMode) {
       commit('SET_DARK_MODE', darkMode)
     },
diff --git a/ui/src/store/mutation-types.js b/ui/src/store/mutation-types.js
index 77aeb8fb7b6..93dfc9fbc20 100644
--- a/ui/src/store/mutation-types.js
+++ b/ui/src/store/mutation-types.js
@@ -35,6 +35,7 @@ export const USE_BROWSER_TIMEZONE = 'USE_BROWSER_TIMEZONE'
 export const SERVER_MANAGER = 'SERVER_MANAGER'
 export const DOMAIN_STORE = 'DOMAIN_STORE'
 export const DARK_MODE = 'DARK_MODE'
+export const LATEST_CS_VERSION = 'LATEST_CS_VERSION'
 export const VUE_VERSION = 'VUE_VERSION'
 export const CUSTOM_COLUMNS = 'CUSTOM_COLUMNS'
 export const RELOAD_ALL_PROJECTS = 'RELOAD_ALL_PROJECTS'
diff --git a/ui/src/utils/util.js b/ui/src/utils/util.js
index fad4d1e0f5d..8773f07446e 100644
--- a/ui/src/utils/util.js
+++ b/ui/src/utils/util.js
@@ -15,6 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
+import semver from 'semver'
+
 export function timeFix () {
   const time = new Date()
   const hour = time.getHours()
@@ -69,6 +71,14 @@ export function sanitizeReverse (value) {
     .replace(/&gt;/g, '>')
 }
 
+export function getParsedVersion (version) {
+  version = version.split('-')[0]
+  if (semver.valid(version) === null) {
+    version = version.split('.').slice(1).join('.')
+  }
+  return version
+}
+
 export function toCsv ({ keys = null, data = null, columnDelimiter = ',', 
lineDelimiter = '\n' }) {
   if (data === null || !data.length) {
     return null

Reply via email to