/* ** Zabbix ** Copyright (C) 2001-2023 Zabbix SIA ** ** This program is free software; you can redistribute it and/or modify ** it under the terms of the GNU General Public License as published by ** the Free Software Foundation; either version 2 of the License, or ** (at your option) any later version. ** ** This program is distributed in the hope that it will be useful, ** but WITHOUT ANY WARRANTY; without even the implied warranty of ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ** GNU General Public License for more details. ** ** You should have received a copy of the GNU General Public License ** along with this program; if not, write to the Free Software ** Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. **/ /** * Default value in seconds, for poller interval. */ ZBX_Notifications.POLL_INTERVAL = 30; ZBX_Notifications.ALARM_SEVERITY_RESOLVED = -1; ZBX_Notifications.ALARM_INFINITE_SERVER = -1; ZBX_Notifications.ALARM_ONCE_PLAYER = -1; ZBX_Notifications.ALARM_ONCE_SERVER = 1; /** * Fetches and renders notifications. Server always returns full list of actual notifications that this class will * render into DOM. Last focused ZBX_BrowserTab instance is the active one. Active ZBX_BrowserTab instance is the only * one that polls server, meanwhile other instances are inactive. This is achieved by synchronizing state of active tab * via ZBX_LocalStorage and responding to it's change event. * * Only methods prefixed with are the "action dispatchers", methods prefixed with responds to * these "actions" by passing the new received state value through a method prefixed with that will adjust * instance's internal state, that in turn can be dispatched as "action". Other methods prefixed with responds * to other events than localStorage change event - (poll, focus, timeout..) and still they would reuse * domain methods and issue an action via if needed and call to render method explicitly. The is * not reused on the instance that produces the action. This is so to reduce complexity and increase maintainability, * because when an action produces an action, logic diverges deep, instead various domain within logic * and call `render` once, then into localStorage once. * * Methods prefixed with uses only consumed internal state and should not any changes. * * @param {ZBX_LocalStorage} store * @param {ZBX_BrowserTab} tab */ function ZBX_Notifications(store, tab) { if (!(store instanceof ZBX_LocalStorage) || !(tab instanceof ZBX_BrowserTab)) { throw 'Unmatched signature!'; } this.active = false; this.poll_interval = ZBX_Notifications.POLL_INTERVAL; this.store = store; this.tab = tab; this.collection = new ZBX_NotificationCollection(); this.alarm = new ZBX_NotificationsAlarm(new ZBX_NotificationsAudio()); this.fetchUpdates(); this.consumeList(this._cached_list); this.consumeUserSettings(this._cached_user_settings); this.consumeAlarmState(this._cached_alarm_state); // Latest data page is being reloaded in background. var all_tabids = this.tab.getAllTabIds(), any_active_tab = (all_tabids.indexOf(this._cached_active_tabid) !== -1); // If pages are opened in background, and has never yet received focusIn event. if (!any_active_tab || document.hasFocus()) { this.becomeActive(); } else { this.becomeInactive(); } /* * Fetched store is immediately rendered if this is not the only session, data as can be trusted then. * Then if this is active instance it will always poll server once at construction, and then rerender if needed. */ if (all_tabids.length > 1) { this.render(); } if (this.active) { this.pushUpdates(); } this.restartMainLoop(); this.bindEventHandlers(); } /** * Binds to click events, LS update events and tab events. */ ZBX_Notifications.prototype.bindEventHandlers = function() { this.tab.onBeforeUnload(this.handleTabBeforeUnload.bind(this)); this.tab.onFocus(this.handleTabFocusIn.bind(this)); this.tab.onCrashed(this.handleTabFocusIn.bind(this)); this.collection.btn_snooze.onclick = this.handleSnoozeClicked.bind(this); this.collection.btn_close.onclick = this.handleCloseClicked.bind(this); this.collection.btn_mute.onclick = this.handleMuteClicked.bind(this); this.store.onKeySync('notifications.active_tabid', this.handlePushedActiveTabid.bind(this)); this.store.onKeySync('notifications.list', this.handlePushedList.bind(this)); this.store.onKeySync('notifications.user_settings', this.handlePushedUserSettings.bind(this)); this.store.onKeySync('notifications.alarm_state', this.handlePushedAlarmState.bind(this)); this.alarm.onChange(this.handleAlarmStateChanged.bind(this)); }; /** * Reads all from store. */ ZBX_Notifications.prototype.fetchUpdates = function() { this._cached_list = this.store.readKey('notifications.list', []); this._cached_user_settings = this.store.readKey('notifications.user_settings', { msg_timeout: ZBX_Notifications.POLL_INTERVAL * 2 }); this._cached_active_tabid = this.store.readKey('notifications.active_tabid', ''); this._cached_alarm_state = this.store.readKey('notifications.alarm_state', this.alarm.produce()); }; /** * @param {string} id */ ZBX_Notifications.prototype.removeById = function(id) { if (id.constructor != String) { id += ''; } this.collection.removeById(id); }; /** * @param {string} id * * @return {ZBX_Notification} */ ZBX_Notifications.prototype.getById = function(id) { return this.collection.getById(id); }; /** * @param {object} alarm_state */ ZBX_Notifications.prototype.consumeAlarmState = function(alarm_state) { this.alarm.consume(alarm_state, this.getById(alarm_state.start)); }; /** * Used to speed up poll interval, in case if user has set message timeout to be short enough it is possible * to miss a recovered event for notification that is long gone, because of how Problems API is implemented. * * @param {objects} user_settings * * @return {integer} */ ZBX_Notifications.prototype.calcPollInterval = function(user_settings) { var min_timeout = Math.floor(user_settings.msg_timeout / 2); if (min_timeout < 1) { min_timeout = 1; } else if (min_timeout > ZBX_Notifications.POLL_INTERVAL) { min_timeout = ZBX_Notifications.POLL_INTERVAL; } return min_timeout; }; /** * @param {objects} user_settings */ ZBX_Notifications.prototype.consumeUserSettings = function(user_settings) { var poll_interval = this.calcPollInterval(user_settings); if (this.poll_interval != poll_interval) { this.poll_interval = poll_interval; this._main_loop_id && this.restartMainLoop(); } this._cached_user_settings = user_settings; if (user_settings.muted) { this.alarm.mute(); } else { this.alarm.unmute(); } if (this._cached_user_settings.disabled) { this.alarm.stop(); this.pushAlarmState(this.alarm.produce()); this.dropStore(); } }; /** * Consumes list into virtual DOM (collection). Computes and resets display timeouts for notification objects. * After display timeout collection is mutated and rendered. This loop is reused (acceptNotification) to choose * a notification to be played - most recent, most severe. Then it is written into alarm_state that once consumed, * will know if this notification has been played or not. * * @param {array} list Ordered list of raw notification objects. */ ZBX_Notifications.prototype.consumeList = function(list) { this.collection.consumeList(list); this._cached_list = this.collection.getRawList(); this.alarm.reset(); this.collection.map(function(notif) { this.alarm.acceptNotification(notif); notif.display_timeoutid && clearTimeout(notif.display_timeoutid); notif.display_timeoutid = setTimeout(function() { this.removeById(notif.getId()); this.debounceRender(); this.pushUpdates(); }.bind(this), notif.calcDisplayTimeout(this._cached_user_settings)); }.bind(this)); }; /** * Stops ticking. */ ZBX_Notifications.prototype.stopMainLoop = function() { if (this._main_loop_id) { clearInterval(this._main_loop_id); } }; /** * Sets interval for main loop. Tick is immediately executed. */ ZBX_Notifications.prototype.restartMainLoop = function() { this.stopMainLoop(); this._main_loop_id = setInterval(this.mainLoop.bind(this), this.poll_interval * 1000); this.mainLoop(); }; /** * Invokes render once after some timeout if not called again during last timeout. * * @param {integer} ms Optional milliseconds for debounce. */ ZBX_Notifications.prototype.debounceRender = function(ms) { ms = ms || 50; if (this._render_timeoutid) { clearTimeout(this._render_timeoutid); } this._render_timeoutid = setTimeout(this.render.bind(this), ms); }; /** * Write ZBX_LocalStorage only values that were updated by methods. For example, during a new user_settings * consumption it came clear that alarm has to be updated, only if that happened, alarm will be pushed. */ ZBX_Notifications.prototype.pushUpdates = function() { if (this.active) { this.pushActiveTabid(this.tab.uid); } this.pushUserSettings(this._cached_user_settings); this.pushList(this.collection.getRawList()); this.pushAlarmState(this.alarm.produce()); }; /** * @param {array} list */ ZBX_Notifications.prototype.pushList = function(list) { this.store.writeKey('notifications.list', list); }; /** * @param {object} user_settings */ ZBX_Notifications.prototype.pushUserSettings = function(user_settings) { this.store.writeKey('notifications.user_settings', user_settings); }; /** * @param {object} alarm */ ZBX_Notifications.prototype.pushAlarmState = function(alarm_state) { this.store.writeKey('notifications.alarm_state', alarm_state); }; /** * @param {string} tabid */ ZBX_Notifications.prototype.pushActiveTabid = function(tabid) { this.store.writeKey('notifications.active_tabid', tabid); }; /** * This logic is a response - if other instance writes this tabid into LS, when current tab receives focusIn event * or at new instance creation depending on context (for example single tab scenario without receiving focusIn event). */ ZBX_Notifications.prototype.becomeActive = function() { if (this.active) { return; } this._cached_active_tabid = this.tab.uid; this.active = true; this.pushActiveTabid(this.tab.uid); this.fetchUpdates(); this.consumeAlarmState(this._cached_alarm_state); this.renderAudio(); }; /** * Notification instance may only ever become inactive when another instance becomes active. At single tab unload case * various artifacts like seek position are transferred explicitly. */ ZBX_Notifications.prototype.becomeInactive = function() { if (this.active) { // No need to push everything. this.pushAlarmState(this.alarm.produce()); } this._cached_active_tabid = ''; this.active = false; this.renderAudio(); }; /** * Backup store still remains, this is used mainly for single instance session case on tab unload event. */ ZBX_Notifications.prototype.dropStore = function() { this.store.eachKeyRegex('^notifications\\.', function(key) { key.truncatePrimary(); }); }; /** * @param {object} user_settings */ ZBX_Notifications.prototype.handlePushedUserSettings = function(user_settings) { this.consumeUserSettings(user_settings); this.consumeList(this.collection.getRawList()); this.render(); }; /** * @param {array} list */ ZBX_Notifications.prototype.handlePushedList = function(list) { this.consumeList(list); this.render(); }; /** * @param {object} alarm_state */ ZBX_Notifications.prototype.handlePushedAlarmState = function(alarm_state) { this.alarm.refresh(); this.consumeAlarmState(alarm_state); this.render(); }; /** * @param {string} tabid */ ZBX_Notifications.prototype.handlePushedActiveTabid = function(tabid) { (tabid === this.tab.uid) ? this.becomeActive() : this.becomeInactive(); }; /** * When active tab is unloaded, any sibling tab is set to become active. If single session, then we drop LS (privacy). * We cannot know if this unload will happen because of navigation, scripted reload or a tab was just closed. * Latter is always assumed, so when navigating active tab, focus is deligated onto to any tab if possible, * then this tab might reclaim focus again at construction if during that time document has focus. * At slow connection during page navigation there will be another active tab polling for notifications (if multitab). * Here `tab` is referred as ZBX_Notifications instance and `focus` - whether instance is `active` (not focused). * * @param {ZBX_BrowseTab} removed_tab Current tab instance. * @param {array} other_tabids List of alive tab ids (without current tabid). */ ZBX_Notifications.prototype.handleTabBeforeUnload = function(removed_tab, other_tabids) { if (this.active && other_tabids.length) { this.pushActiveTabid(other_tabids[0]); this.becomeInactive(); /* * Solves problem happening in case when navigating to another top level domain. Chrome dispatches 'focusin' * event right after beforeunload event. It is crucial to not to respond to that, otherwise nonexisting tab * becomes active. */ this.becomeActive = function() {}; } else if (this.active) { this.pushAlarmState(this.alarm.produce()); this.dropStore(); } }; /** * Responds when this instance tab receives focus event. */ ZBX_Notifications.prototype.handleTabFocusIn = function() { this.becomeActive(); }; /** * @param {MouseEvent} e */ ZBX_Notifications.prototype.handleCloseClicked = function(e) { this .fetch('notifications.read', {ids: this.getEventIds()}) .then((resp) => { if ('error' in resp) { throw {error: resp.error}; } resp.ids.forEach(function(id) { this.removeById(id); this.debounceRender(); }.bind(this)); this.alarm.reset(); this.pushUpdates(); }) .catch((exception) => { if (typeof exception === 'object' && 'error' in exception) { clearMessages(); const message_box = makeMessageBox('bad', exception.error.messages, exception.error.title); addMessage(message_box); } else { console.log('Could not read notifications:', exception); } }); }; /** * @param {MouseEvent} e */ ZBX_Notifications.prototype.handleSnoozeClicked = function(e) { if (this.alarm.isSnoozed(this._cached_list)) { return; } this.collection.map(function(notif) { notif.updateRaw({snoozed: true}); }); this.consumeList(this.collection.getRawList()); this.pushUpdates(); this.render(); }; /** * @param {MouseEvent} e */ ZBX_Notifications.prototype.handleMuteClicked = function(e) { this .fetch('notifications.mute', {muted: this.alarm.muted ? 0 : 1}) .then((resp) => { if ('error' in resp) { throw {error: resp.error}; } this._cached_user_settings.muted = (resp.muted == 1); this.alarm.consume({muted: this._cached_user_settings.muted}); this.pushUpdates(); this.render(); }) .catch((exception) => { clearMessages(); let title, messages; if (typeof exception === 'object' && 'error' in exception) { title = exception.error.title; messages = exception.error.messages; } else { messages = [t('Unexpected server error.')]; } const message_box = makeMessageBox('bad', messages, title); addMessage(message_box); }); }; /** * Handles server response. * * @param {object} resp Server response object. Contains settings and list of notifications. */ ZBX_Notifications.prototype.handleMainLoopResp = function(resp) { if (resp.error) { this.stopMainLoop(); this.store.truncateBackup(); this.dropStore(); return; } this.consumeUserSettings(resp.settings); this.consumeList(resp.notifications); this.render(); this.pushUpdates(); }; /** * @param {ZBX_NotificationsAlarm} alarm_state */ ZBX_Notifications.prototype.handleAlarmStateChanged = function(alarm_state) { this.pushAlarmState(alarm_state.produce()); }; /** * Collection renders whole list of notifications and snooze, and mute buttons, not all state is passed down here, just * user configuration, the list state to be rendered, has been consumed by collection before. */ ZBX_Notifications.prototype.renderCollection = function() { this.collection.render(this._cached_user_settings.severity_styles, this.alarm); }; /** * Render everything. Any painting optimization may be considered levels deeper. */ ZBX_Notifications.prototype.render = function() { this.renderCollection(); this.renderAudio(); }; /** * Alarm is stopped for inactive instance. */ ZBX_Notifications.prototype.renderAudio = function() { if (this.active) { this.alarm.render(this._cached_user_settings, this._cached_list); } else { this.alarm.stop(); } }; /** * @param {string} resource A value for 'action' parameter. * @param {object} params Form data to be sent. * * @return {Promise} */ ZBX_Notifications.prototype.fetch = function(resource, params) { return new Promise(function(resolve, reject) { sendAjaxData('zabbix.php?action=' + resource, { data: params || {}, success: resolve, error: reject }); }); }; /** * @return {array} */ ZBX_Notifications.prototype.getEventIds = function() { return this.collection.getIds(); }; /** * Main loop periodically executes at some interval. Only if this instance is 'active' notifications are fetched * and rendered. */ ZBX_Notifications.prototype.mainLoop = function() { if (!this.active) { return; } this .fetch('notifications.get', {known_eventids: this.getEventIds()}) .then((resp) => { if ('error' in resp) { throw {error: resp.error}; } this.handleMainLoopResp(resp); }) .catch((exception) => { if (typeof exception === 'object' && 'error' in exception) { clearMessages(); const message_box = makeMessageBox('bad', exception.error.messages, exception.error.title); addMessage(message_box); } else { console.log('Could not get notifications:', exception); } }); }; /** * Utilities. */ ZBX_Notifications.util = {}; /** * @param {Node} node Display none node. * * @return {Promise} */ ZBX_Notifications.util.getNodeHeight = function(node) { node.style.display = 'block'; node.style.position = 'absolute'; node.style.visibility = 'hidden'; node.style.overflow = 'hidden'; return new Promise(function(resolve, failed) { function readHeight() { if (!node.offsetHeight) { requestAnimationFrame(readHeight); } else { node.removeAttribute('style'); resolve(node.offsetHeight); } } readHeight(); }); }; /** * Fully IE11 compatible slideUp animation using CSS. * * @param {Node} node * @param {integer} duration Animation duration in milliseconds. * @param {integer} delay Milliseconds to wait before animating. * * @return {Promise} Resolved once animation should have finished. */ ZBX_Notifications.util.slideDown = function(node, duration, delay) { delay = delay || 0; duration = duration || 200; return new Promise(function(resolved, failed) { ZBX_Notifications.util.getNodeHeight(node).then(function(height) { var padding = window.getComputedStyle(node).padding; node.style.height = '0px'; node.style.padding = '0px'; node.style.overflow = 'hidden'; node.style.boxSizing = 'border-box'; node.style.transitionDuration = duration + 'ms'; node.style.transitionProperty = 'opacity, height, margin, padding'; setTimeout(function() { node.style.height = height + 'px'; node.style.padding = padding; setTimeout(function() { node.removeAttribute('style'); }, duration); resolved(node); }, delay); }); }); }; /** * @param {Node} node */ ZBX_Notifications.util.fadeIn = function(node) { node.style.opacity = 0; node.style.display = 'inherit'; var op = 0; var id = setInterval(function() { op += 0.1; if (op > 1) { return clearInterval(id); } node.style.opacity = op; }, 50); }; /** * @param {Node} node * * @return {Promise} Resolved once animation should have finished. */ ZBX_Notifications.util.fadeOut = function(node) { var opacity = 1, intervalid; return new Promise(function(resolved, failed) { if (node.style.display === 'none') { return resolved(node); } node.style.opacity = opacity; intervalid = setInterval(function() { opacity -= 0.1; if (opacity < 0) { node.style.display = 'none'; resolved(node); return clearInterval(intervalid); } node.style.opacity = opacity; }, 50); }); }; /** * Fully IE11 compatible slideUp animation using CSS. * * @param {Node} node * @param {integer} duration Animation duration in milliseconds. * @param {integer} delay Milliseconds to wait before animating. * * @return {Promise} Resolved once animation should have finished. */ ZBX_Notifications.util.slideUp = function(node, duration, delay) { delay = delay || 0; node.style.overflow = 'hidden'; node.style.boxSizing = 'border-box'; node.style.transitionDuration = duration + 'ms'; node.style.transitionProperty = 'height, margin, padding'; node.style.height = node.offsetHeight + 'px'; setTimeout(function() { node.style.height = '0px'; node.style.padding = '0px'; node.style.margin = '0px'; }, delay); return new Promise(function(resolved, failed) { setTimeout(resolved.bind(null, node), delay + duration); }); }; /** * @param {ZBX_NotificationsAudio} player */ function ZBX_NotificationsAlarm(player) { this.player = player; this.severity = -2; this.start = ''; this.end = ''; this.timeout = 0; this.muted = true; this.notif = null; this.on_changed_cbs = []; this.old_id = this.getId(); } /** * An alarm is identified by notification and it's current severity. * * @return {string} */ ZBX_NotificationsAlarm.prototype.getId = function() { if (this.notif) { return this.notif.getId() + '_' + this.severity; } return ''; }; /** * Invokes callbacks. */ ZBX_NotificationsAlarm.prototype.dispatchChanged = function() { this.on_changed_cbs.forEach(function(callback) { callback(this); }.bind(this)); }; /** * This mechanism exists to prevent or explicitly allow seek position to be applied at render. */ ZBX_NotificationsAlarm.prototype.refresh = function() { this.old_id = ''; }; /** * Subscribes a callback. */ ZBX_NotificationsAlarm.prototype.onChange = function(callback) { this.on_changed_cbs.push(callback); }; /** * Calculated property. */ ZBX_NotificationsAlarm.prototype.markAsPlayed = function() { this.end = this.getId(); }; /** * @return {bool} */ ZBX_NotificationsAlarm.prototype.isPlayed = function() { return (this.getId() === this.end); }; /** * @param {array} list List of raw notifications. * * @return {bool} */ ZBX_NotificationsAlarm.prototype.isSnoozed = function(list) { for (var i = 0; i < list.length; i++) { if (!list[i].snoozed) { return false; } } return (list.length == 0) ? false : true; }; /** * @return {bool} */ ZBX_NotificationsAlarm.prototype.isStopped = function() { return !this.getId(); }; /** * @param {object} alarm_state * @param {ZBX_Notification} notif */ ZBX_NotificationsAlarm.prototype.consume = function(alarm_state, notif) { if (notif) { this.notif = notif; } for (var field in alarm_state) { this[field] = alarm_state[field]; } }; /** * Does not update state, just renders player stopped. */ ZBX_NotificationsAlarm.prototype.stop = function() { this.notif = null; this.player.stop(); }; ZBX_NotificationsAlarm.prototype.mute = function() { this.muted = true; this.player.mute(); }; ZBX_NotificationsAlarm.prototype.unmute = function() { this.muted = false; this.player.unmute(); }; /** * @param {object} user_settings * @param {array} list List of raw notification objects. */ ZBX_NotificationsAlarm.prototype.render = function(user_settings, list) { user_settings.muted ? this.mute() : this.unmute(); if (this.isStopped() || this.isPlayed() || this.isSnoozed(list)) { return this.player.stop(); } this.player.file(user_settings.files[this.severity]); if (this.old_id !== this.getId()) { this.player.seek(0); this.player.stop(); } this.player.tune({ playOnce: (this.calcTimeout(user_settings) == ZBX_Notifications.ALARM_ONCE_PLAYER), messageTimeout: (this.notif.calcDisplayTimeout(user_settings) / 1000) >> 0, callback: function() { this.markAsPlayed(); this.dispatchChanged(); }.bind(this) }); this.player.timeout(this.calcTimeout(user_settings)); this.old_id = this.getId(); }; /** * @param {object} user_settings * * @return {integer} */ ZBX_NotificationsAlarm.prototype.calcTimeout = function(user_settings) { if (user_settings.alarm_timeout == ZBX_Notifications.ALARM_INFINITE_SERVER) { return (this.notif.calcDisplayTimeout(user_settings) / 1000) >> 0; } if (user_settings.alarm_timeout == ZBX_Notifications.ALARM_ONCE_SERVER) { return ZBX_Notifications.ALARM_ONCE_PLAYER; } if (this.timeout == 0) { return user_settings.alarm_timeout; } return this.timeout; }; /** * @return {object} */ ZBX_NotificationsAlarm.prototype.produce = function() { return { start: this.start, end: this.end, muted: this.muted, severity: this.severity, seek: this.player.getSeek(), timeout: this.player.getTimeout(), supported: !! this.player.audio }; }; /* * Resets crucial fields to accept notifications. */ ZBX_NotificationsAlarm.prototype.reset = function() { this.old_id = this.getId(); this.start = ''; this.severity = -2; this.notif = null; }; /** * Appends notification to state in context. * * @param {ZBX_Notification} notif */ ZBX_NotificationsAlarm.prototype.acceptNotification = function(notif) { var raw = notif.getRaw(), severity = raw.resolved ? ZBX_Notifications.ALARM_SEVERITY_RESOLVED : raw.severity; if (raw.snoozed) { return; } if (this.severity < severity) { this.severity = severity; this.notif = notif; this.start = notif.getId(); } }; /** * Registering instance. */ ZABBIX.namespace('instances.notifications', new ZBX_Notifications( ZABBIX.namespace('instances.localStorage'), ZABBIX.namespace('instances.browserTab') )); /** * Appends list node to DOM when document is ready, then make it draggable. */ $(function() { let wrapper = document.querySelector(".wrapper"), main = document.querySelector("main"), ntf_node = ZABBIX.namespace('instances.notifications.collection.node'), store = ZABBIX.namespace('instances.localStorage'), ntf_pos = store.readKey('web.notifications.pos', null), pos_top = 10, pos_side = 10, side = 'right'; if (main !== null) { main.appendChild(ntf_node); } if (ntf_pos !== null && 'top' in ntf_pos) { side = ('right' in ntf_pos ? 'right' : ('left' in ntf_pos ? 'left' : null)); if (side !== null) { pos_top = Math.max(-main.offsetTop, Math.min(ntf_pos.top, wrapper.scrollHeight - ntf_node.offsetHeight)); pos_side = Math.max(0, Math.min(ntf_pos[side], Math.floor(wrapper.scrollWidth - ntf_node.offsetWidth) / 2)); } } ntf_node.style.top = pos_top + 'px'; ntf_node.style[side] = pos_side + 'px'; $(ntf_node).draggable({handle: '>.dashboard-widget-head', start: function(event, ui) { ui.helper.data('containment', { min_top: -main.offsetTop, max_top: wrapper.scrollHeight - this.offsetHeight - main.offsetTop, min_left: 0, max_left: wrapper.scrollWidth - this.offsetWidth }); }, drag: function(event, ui) { let containment = ui.helper.data('containment'); ui.position.top = Math.max(Math.min(ui.position.top, containment.max_top), containment.min_top); ui.position.left = Math.max(Math.min(ui.position.left, containment.max_left), containment.min_left); }, stop: function(event, ui) { ntf_pos = {top: ui.position.top}; if (ui.position.left < (wrapper.scrollWidth - this.offsetWidth) / 2) { ntf_pos.left = ui.position.left; this.style.right = null; } else { ntf_pos.right = wrapper.scrollWidth - this.offsetWidth - ui.position.left; this.style.left = null; this.style.right = ntf_pos.right + 'px'; } store.writeKey('web.notifications.pos', ntf_pos); } }); });