Skip to content

Commit 8e4fe1c

Browse files
authored
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
1 parent 749ddb9 commit 8e4fe1c

File tree

6 files changed

+223
-15
lines changed

6 files changed

+223
-15
lines changed

ui/package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"chartjs-adapter-moment": "^1.0.0",
5050
"core-js": "^3.21.1",
5151
"cronstrue": "^2.26.0",
52+
"dompurify": "^3.2.6",
5253
"enquire.js": "^2.1.6",
5354
"js-cookie": "^2.2.1",
5455
"lodash": "^4.17.15",

ui/public/config.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,15 @@
100100
"imageSelectionInterface": "modern",
101101
"showUserCategoryForModernImageSelection": true,
102102
"showAllCategoryForModernImageSelection": false,
103-
"docHelpMappings": {}
103+
"docHelpMappings": {},
104+
"announcementBanner": {
105+
"enabled": false,
106+
"showIcon": false,
107+
"closable": true,
108+
"persistDismissal": true,
109+
"type": "info",
110+
"message": "🤔 <strong>Sample Announcement</strong>: New Feature Available: Check out our latest dashboard improvements! <a href='/features'>Learn more</a>",
111+
"startDate": "2025-06-01T00:00:00Z",
112+
"endDate": "2025-07-16T00:00:00Z"
113+
}
104114
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
<template>
19+
<a-affix v-if="showBanner" class="announcement-banner-container">
20+
<a-alert
21+
:type="bannerConfig.type || 'default'"
22+
:show-icon="bannerConfig.showIcon !== false"
23+
:closable="bannerConfig.closable !== false"
24+
:banner="true"
25+
@close="handleClose"
26+
:style="[ { border: borderColor }]"
27+
>
28+
<template #message>
29+
<div class="banner-content" v-html="sanitizedMessage" :style="[$store.getters.darkMode ? { color: 'rgba(255, 255, 255, 0.65)' } : { color: '#888' }]" />
30+
</template>
31+
</a-alert>
32+
</a-affix>
33+
</template>
34+
35+
<script>
36+
import DOMPurify from 'dompurify'
37+
38+
export default {
39+
name: 'AnnouncementBanner',
40+
data () {
41+
return {
42+
showBanner: false,
43+
bannerConfig: {},
44+
dismissed: false
45+
}
46+
},
47+
computed: {
48+
sanitizedMessage () {
49+
if (!this.bannerConfig.message) return ''
50+
const cleanHTML = DOMPurify.sanitize(this.bannerConfig.message, {
51+
ALLOWED_TAGS: [
52+
'p', 'div', 'span', 'br', 'strong', 'b', 'em', 'i', 'u',
53+
'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
54+
'small', 'mark', 'del', 'ins', 'sub', 'sup'
55+
],
56+
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'id', 'style'],
57+
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
58+
FORBID_TAGS: ['script', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button'],
59+
FORBID_ATTR: ['onclick', 'onload', 'onerror', 'onmouseover', 'onfocus', 'onblur']
60+
})
61+
return cleanHTML
62+
},
63+
borderColor () {
64+
const colorMap = {
65+
error: '#ffa39e',
66+
warning: '#ffe58f',
67+
success: '#b7eb8f',
68+
info: '#b3cde3'
69+
}
70+
const color = colorMap[this.bannerConfig.type]
71+
return color ? `1px solid ${color}` : '0px'
72+
}
73+
},
74+
mounted () {
75+
this.loadBannerConfig()
76+
},
77+
methods: {
78+
loadBannerConfig () {
79+
const config = this.$config?.announcementBanner || {}
80+
if (config && config.enabled && config.message) {
81+
this.bannerConfig = config
82+
if (config.persistDismissal) {
83+
const dismissedKey = `cs-banner-dismissed-${this.getBannerHash()}`
84+
this.dismissed = this.$localStorage.get(dismissedKey) === 'true'
85+
}
86+
if (!this.dismissed && this.isWithinDisplayPeriod()) {
87+
this.showBanner = true
88+
}
89+
}
90+
},
91+
isWithinDisplayPeriod () {
92+
const config = this.bannerConfig
93+
const now = new Date()
94+
95+
if (config.startDate) {
96+
const startDate = new Date(config.startDate)
97+
if (now < startDate) return false
98+
}
99+
100+
if (config.endDate) {
101+
const endDate = new Date(config.endDate)
102+
if (now > endDate) return false
103+
}
104+
return true
105+
},
106+
handleClose () {
107+
this.showBanner = false
108+
if (this.bannerConfig.persistDismissal) {
109+
const dismissedKey = `cs-banner-dismissed-${this.getBannerHash()}`
110+
this.$localStorage.set(dismissedKey, 'true')
111+
}
112+
if (this.bannerConfig.onClose) {
113+
this.bannerConfig.onClose()
114+
}
115+
},
116+
getBannerHash () {
117+
// Create a simple hash of the message content for dismissal tracking
118+
let hash = 0
119+
const str = this.bannerConfig.message || ''
120+
for (let i = 0; i < str.length; i++) {
121+
const char = str.charCodeAt(i)
122+
hash = ((hash << 5) - hash) + char
123+
hash = hash & hash // Convert to 32bit integer
124+
}
125+
return Math.abs(hash).toString()
126+
}
127+
}
128+
}
129+
</script>
130+
131+
<style scoped>
132+
.announcement-banner-container {
133+
z-index: 1000;
134+
top: 0;
135+
margin: 0;
136+
width: 100%;
137+
justify-content: center;
138+
align-items: center;
139+
}
140+
141+
.banner-content {
142+
line-height: 1.7;
143+
text-align: center
144+
}
145+
</style>

ui/src/components/page/GlobalLayout.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
<template>
1919
<div>
20+
<announcement-banner />
2021
<a-affix v-if="this.$store.getters.maintenanceInitiated" >
2122
<a-alert :message="$t('message.maintenance.initiated')" type="error" banner :showIcon="false" class="maintenanceHeader" />
2223
</a-affix>
@@ -131,6 +132,7 @@ import { isAdmin } from '@/role'
131132
import { getAPI } from '@/api'
132133
import Drawer from '@/components/widgets/Drawer'
133134
import Setting from '@/components/view/Setting.vue'
135+
import AnnouncementBanner from '@/components/header/AnnouncementBanner.vue'
134136
135137
export default {
136138
name: 'GlobalLayout',
@@ -139,7 +141,8 @@ export default {
139141
GlobalHeader,
140142
GlobalFooter,
141143
Drawer,
142-
Setting
144+
Setting,
145+
AnnouncementBanner
143146
},
144147
mixins: [mixin, mixinDevice],
145148
data () {
@@ -331,4 +334,8 @@ export default {
331334
position: absolute;
332335
}
333336
337+
.layout.ant-layout .sidemenu .ant-header-fixedHeader {
338+
top: auto !important
339+
}
340+
334341
</style>

ui/src/main.js

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18+
import { createApp, h } from 'vue'
1819
import { vueApp, vueProps } from './vue-app'
1920
import router from './router'
2021
import store from './store'
@@ -60,20 +61,50 @@ vueApp.use(imagesUtilPlugin)
6061
vueApp.use(extensions)
6162
vueApp.use(directives)
6263

63-
fetch('config.json?ts=' + Date.now()).then(response => response.json()).then(config => {
64-
vueProps.$config = config
65-
let basUrl = config.apiBase
66-
if (config.multipleServer) {
67-
basUrl = (config.servers[0].apiHost || '') + config.servers[0].apiBase
64+
const renderError = (err) => {
65+
console.error('Fatal error during app initialization: ', err)
66+
const ErrorComponent = {
67+
render: () => h(
68+
'div',
69+
{ style: 'font-family: sans-serif; text-align: center; padding: 2rem;' },
70+
[
71+
h('h2', { style: 'color: #ff4d4f;' }, 'We\'re experiencing a problem'),
72+
h('p', 'The application could not be loaded due to a configuration issue. Please try again later.'),
73+
h('details', { style: 'margin-top: 20px;' }, [
74+
h('summary', { style: 'cursor: pointer;' }, 'Technical details'),
75+
h('pre', {
76+
style: 'text-align: left; display: inline-block; margin-top: 10px;'
77+
}, 'Missing or malformed config.json. Please ensure the file is present, accessible, and contains valid JSON. Check the browser console for more information.')
78+
])
79+
]
80+
)
6881
}
82+
createApp(ErrorComponent).mount('#app')
83+
}
6984

70-
vueProps.axios.defaults.baseURL = basUrl
85+
fetch('config.json?ts=' + Date.now())
86+
.then(response => {
87+
if (!response.ok) {
88+
throw new Error(`Failed to fetch config.json: ${response.status} ${response.statusText}`)
89+
}
90+
return response.json()
91+
})
92+
.then(config => {
93+
vueProps.$config = config
94+
let baseUrl = config.apiBase
95+
if (config.multipleServer) {
96+
baseUrl = (config.servers[0].apiHost || '') + config.servers[0].apiBase
97+
}
98+
99+
vueProps.axios.defaults.baseURL = baseUrl
71100

72-
loadLanguageAsync().then(() => {
73-
vueApp.use(store)
74-
.use(router)
75-
.use(i18n)
76-
.use(bootstrap)
77-
.mount('#app')
101+
loadLanguageAsync().then(() => {
102+
vueApp.use(store)
103+
.use(router)
104+
.use(i18n)
105+
.use(bootstrap)
106+
.mount('#app')
107+
})
108+
}).catch(error => {
109+
renderError(error)
78110
})
79-
})

0 commit comments

Comments
 (0)