mirror of
https://github.com/apache/cloudstack.git
synced 2025-10-26 01:32:18 +02:00
Allow configuring Announcement banner by admin (#10951)
* Allow configuring Announcement banner by admin * add license * revert un-necessary changes from package-lock.json * banner should use 100% width and push down content down * fix grey area issue * show error page if config.json is not valid
This commit is contained in:
parent
749ddb975f
commit
8e4fe1c370
14
ui/package-lock.json
generated
14
ui/package-lock.json
generated
@ -2816,6 +2816,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"optional": true
|
||||
},
|
||||
"@types/uglify-js": {
|
||||
"version": "3.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.16.0.tgz",
|
||||
@ -9035,6 +9041,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"requires": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
"chartjs-adapter-moment": "^1.0.0",
|
||||
"core-js": "^3.21.1",
|
||||
"cronstrue": "^2.26.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"enquire.js": "^2.1.6",
|
||||
"js-cookie": "^2.2.1",
|
||||
"lodash": "^4.17.15",
|
||||
|
||||
12
ui/public/config.json
vendored
12
ui/public/config.json
vendored
@ -100,5 +100,15 @@
|
||||
"imageSelectionInterface": "modern",
|
||||
"showUserCategoryForModernImageSelection": true,
|
||||
"showAllCategoryForModernImageSelection": false,
|
||||
"docHelpMappings": {}
|
||||
"docHelpMappings": {},
|
||||
"announcementBanner": {
|
||||
"enabled": false,
|
||||
"showIcon": false,
|
||||
"closable": true,
|
||||
"persistDismissal": true,
|
||||
"type": "info",
|
||||
"message": "🤔 <strong>Sample Announcement</strong>: New Feature Available: Check out our latest dashboard improvements! <a href='/features'>Learn more</a>",
|
||||
"startDate": "2025-06-01T00:00:00Z",
|
||||
"endDate": "2025-07-16T00:00:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
145
ui/src/components/header/AnnouncementBanner.vue
Normal file
145
ui/src/components/header/AnnouncementBanner.vue
Normal file
@ -0,0 +1,145 @@
|
||||
// Licensed to the Apache Software Foundation (ASF) under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
<template>
|
||||
<a-affix v-if="showBanner" class="announcement-banner-container">
|
||||
<a-alert
|
||||
:type="bannerConfig.type || 'default'"
|
||||
:show-icon="bannerConfig.showIcon !== false"
|
||||
:closable="bannerConfig.closable !== false"
|
||||
:banner="true"
|
||||
@close="handleClose"
|
||||
:style="[ { border: borderColor }]"
|
||||
>
|
||||
<template #message>
|
||||
<div class="banner-content" v-html="sanitizedMessage" :style="[$store.getters.darkMode ? { color: 'rgba(255, 255, 255, 0.65)' } : { color: '#888' }]" />
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-affix>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export default {
|
||||
name: 'AnnouncementBanner',
|
||||
data () {
|
||||
return {
|
||||
showBanner: false,
|
||||
bannerConfig: {},
|
||||
dismissed: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sanitizedMessage () {
|
||||
if (!this.bannerConfig.message) return ''
|
||||
const cleanHTML = DOMPurify.sanitize(this.bannerConfig.message, {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'div', 'span', 'br', 'strong', 'b', 'em', 'i', 'u',
|
||||
'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'small', 'mark', 'del', 'ins', 'sub', 'sup'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'id', 'style'],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
FORBID_TAGS: ['script', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button'],
|
||||
FORBID_ATTR: ['onclick', 'onload', 'onerror', 'onmouseover', 'onfocus', 'onblur']
|
||||
})
|
||||
return cleanHTML
|
||||
},
|
||||
borderColor () {
|
||||
const colorMap = {
|
||||
error: '#ffa39e',
|
||||
warning: '#ffe58f',
|
||||
success: '#b7eb8f',
|
||||
info: '#b3cde3'
|
||||
}
|
||||
const color = colorMap[this.bannerConfig.type]
|
||||
return color ? `1px solid ${color}` : '0px'
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.loadBannerConfig()
|
||||
},
|
||||
methods: {
|
||||
loadBannerConfig () {
|
||||
const config = this.$config?.announcementBanner || {}
|
||||
if (config && config.enabled && config.message) {
|
||||
this.bannerConfig = config
|
||||
if (config.persistDismissal) {
|
||||
const dismissedKey = `cs-banner-dismissed-${this.getBannerHash()}`
|
||||
this.dismissed = this.$localStorage.get(dismissedKey) === 'true'
|
||||
}
|
||||
if (!this.dismissed && this.isWithinDisplayPeriod()) {
|
||||
this.showBanner = true
|
||||
}
|
||||
}
|
||||
},
|
||||
isWithinDisplayPeriod () {
|
||||
const config = this.bannerConfig
|
||||
const now = new Date()
|
||||
|
||||
if (config.startDate) {
|
||||
const startDate = new Date(config.startDate)
|
||||
if (now < startDate) return false
|
||||
}
|
||||
|
||||
if (config.endDate) {
|
||||
const endDate = new Date(config.endDate)
|
||||
if (now > endDate) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
handleClose () {
|
||||
this.showBanner = false
|
||||
if (this.bannerConfig.persistDismissal) {
|
||||
const dismissedKey = `cs-banner-dismissed-${this.getBannerHash()}`
|
||||
this.$localStorage.set(dismissedKey, 'true')
|
||||
}
|
||||
if (this.bannerConfig.onClose) {
|
||||
this.bannerConfig.onClose()
|
||||
}
|
||||
},
|
||||
getBannerHash () {
|
||||
// Create a simple hash of the message content for dismissal tracking
|
||||
let hash = 0
|
||||
const str = this.bannerConfig.message || ''
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcement-banner-container {
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
line-height: 1.7;
|
||||
text-align: center
|
||||
}
|
||||
</style>
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<announcement-banner />
|
||||
<a-affix v-if="this.$store.getters.maintenanceInitiated" >
|
||||
<a-alert :message="$t('message.maintenance.initiated')" type="error" banner :showIcon="false" class="maintenanceHeader" />
|
||||
</a-affix>
|
||||
@ -131,6 +132,7 @@ import { isAdmin } from '@/role'
|
||||
import { getAPI } from '@/api'
|
||||
import Drawer from '@/components/widgets/Drawer'
|
||||
import Setting from '@/components/view/Setting.vue'
|
||||
import AnnouncementBanner from '@/components/header/AnnouncementBanner.vue'
|
||||
|
||||
export default {
|
||||
name: 'GlobalLayout',
|
||||
@ -139,7 +141,8 @@ export default {
|
||||
GlobalHeader,
|
||||
GlobalFooter,
|
||||
Drawer,
|
||||
Setting
|
||||
Setting,
|
||||
AnnouncementBanner
|
||||
},
|
||||
mixins: [mixin, mixinDevice],
|
||||
data () {
|
||||
@ -331,4 +334,8 @@ export default {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.layout.ant-layout .sidemenu .ant-header-fixedHeader {
|
||||
top: auto !important
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
import { createApp, h } from 'vue'
|
||||
import { vueApp, vueProps } from './vue-app'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
@ -60,20 +61,50 @@ vueApp.use(imagesUtilPlugin)
|
||||
vueApp.use(extensions)
|
||||
vueApp.use(directives)
|
||||
|
||||
fetch('config.json?ts=' + Date.now()).then(response => response.json()).then(config => {
|
||||
vueProps.$config = config
|
||||
let basUrl = config.apiBase
|
||||
if (config.multipleServer) {
|
||||
basUrl = (config.servers[0].apiHost || '') + config.servers[0].apiBase
|
||||
const renderError = (err) => {
|
||||
console.error('Fatal error during app initialization: ', err)
|
||||
const ErrorComponent = {
|
||||
render: () => h(
|
||||
'div',
|
||||
{ style: 'font-family: sans-serif; text-align: center; padding: 2rem;' },
|
||||
[
|
||||
h('h2', { style: 'color: #ff4d4f;' }, 'We\'re experiencing a problem'),
|
||||
h('p', 'The application could not be loaded due to a configuration issue. Please try again later.'),
|
||||
h('details', { style: 'margin-top: 20px;' }, [
|
||||
h('summary', { style: 'cursor: pointer;' }, 'Technical details'),
|
||||
h('pre', {
|
||||
style: 'text-align: left; display: inline-block; margin-top: 10px;'
|
||||
}, 'Missing or malformed config.json. Please ensure the file is present, accessible, and contains valid JSON. Check the browser console for more information.')
|
||||
])
|
||||
]
|
||||
)
|
||||
}
|
||||
createApp(ErrorComponent).mount('#app')
|
||||
}
|
||||
|
||||
vueProps.axios.defaults.baseURL = basUrl
|
||||
|
||||
loadLanguageAsync().then(() => {
|
||||
vueApp.use(store)
|
||||
.use(router)
|
||||
.use(i18n)
|
||||
.use(bootstrap)
|
||||
.mount('#app')
|
||||
fetch('config.json?ts=' + Date.now())
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch config.json: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then(config => {
|
||||
vueProps.$config = config
|
||||
let baseUrl = config.apiBase
|
||||
if (config.multipleServer) {
|
||||
baseUrl = (config.servers[0].apiHost || '') + config.servers[0].apiBase
|
||||
}
|
||||
|
||||
vueProps.axios.defaults.baseURL = baseUrl
|
||||
|
||||
loadLanguageAsync().then(() => {
|
||||
vueApp.use(store)
|
||||
.use(router)
|
||||
.use(i18n)
|
||||
.use(bootstrap)
|
||||
.mount('#app')
|
||||
})
|
||||
}).catch(error => {
|
||||
renderError(error)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user