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:
Manoj Kumar 2025-07-07 13:56:58 +05:30 committed by GitHub
parent 749ddb975f
commit 8e4fe1c370
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 224 additions and 16 deletions

14
ui/package-lock.json generated
View File

@ -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",

View File

@ -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
View File

@ -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"
}
}

View 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>

View File

@ -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>

View File

@ -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)
})
})