diff options
Diffstat (limited to 'public/js')
28 files changed, 7491 insertions, 0 deletions
diff --git a/public/js/define.js b/public/js/define.js new file mode 100644 index 0000000..a3ce8c6 --- /dev/null +++ b/public/js/define.js @@ -0,0 +1,118 @@ +/*! Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +(function(window) { + + 'use strict'; + + /** + * Provide a reference to be later required by foreign code + * + * @param {string} name Optional, defaults to the name (and path) of the file + * @param {string[]} requirements Optional, list of required references, may be relative if from the same package + * @param {function} factory Required, function that accepts as many params as there are requirements and that + * produces a value to be referenced + */ + var define = function (name, requirements, factory) { + define.defines[name] = { + requirements: requirements, + factory: factory, + ref: null + } + + define.resolve(name); + } + + /** + * Return whether the given name references a value + * + * @param {string} name The absolute name of the reference + * @return {boolean} + */ + define.has = function (name) { + return name in define.defines && define.defines[name]['ref'] !== null; + } + + /** + * Get the value of a reference + * + * @param {string} name The absolute name of the reference + * @return {*} + */ + define.get = function (name) { + return define.defines[name]['ref']; + } + + /** + * Set the value of a reference + * + * @param {string} name The absolute name of the reference + * @param {*} ref The value to reference + */ + define.set = function (name, ref) { + define.defines[name]['ref'] = ref; + } + + /** + * Resolve a reference and, if successful, dependent references + * + * @param {string} name The absolute name of the reference + * @return {boolean} + */ + define.resolve = function (name) { + var requirements = define.defines[name]['requirements']; + + var exports, ref; + var requiredRefs = []; + for (var i = 0; i < requirements.length; i++) { + if (define.has(requirements[i])) { + ref = define.get(requirements[i]); + } else if (requirements[i] === 'exports') { + exports = ref = {}; + } else { + return false; + } + + requiredRefs.push(ref); + } + + var factory = define.defines[name]['factory']; + var resolved = factory.apply(null, requiredRefs); + + if (typeof exports === 'object') { + if (typeof resolved !== 'undefined') { + throw new Error('Factory for ' + name + ' returned, although exports were populated'); + } + + resolved = exports; + } + + define.set(name, resolved); + + for (var definedName in define.defines) { + if (define.defines[definedName]['requirements'].indexOf(name) >= 0) { + define.resolve(definedName); + } + } + } + + /** + * Require a reference + * + * @param {string} name The absolute name of the reference + * @return {*} + */ + var require = function(name) { + if (define.has(name)) { + return define.get(name); + } + + throw new ReferenceError(name + ' is not defined'); + } + + define.icinga = true; + define.defines = {}; + + window.define = define; + window.require = require; + +})(window); diff --git a/public/js/helpers.js b/public/js/helpers.js new file mode 100644 index 0000000..bcb2736 --- /dev/null +++ b/public/js/helpers.js @@ -0,0 +1,91 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/* jQuery Plugins */ +(function ($) { + + 'use strict'; + + /* Get data value or default */ + $.fn.getData = function (name, fallback) { + var value = this.data(name); + if (typeof value !== 'undefined') { + return value; + } + + return fallback; + }; + + /* Whether a HTML tag has a specific attribute */ + $.fn.hasAttr = function(name) { + // We have inconsistent behaviour across browsers (false VS undef) + var val = this.attr(name); + return typeof val !== 'undefined' && val !== false; + }; + + /* Get class list */ + $.fn.classes = function (callback) { + + var classes = []; + + $.each(this, function (i, el) { + var c = $(el).attr('class'); + if (typeof c === 'string') { + $.each(c.split(/\s+/), function(i, p) { + if (classes.indexOf(p) === -1) { + classes.push(p); + } + }); + } + }); + + if (typeof callback === 'function') { + for (var i in classes) { + if (classes.hasOwnProperty(i)) { + callback(classes[i]); + } + } + } + + return classes; + }; + + /* Serialize form elements to an object */ + $.fn.serializeObject = function() + { + var o = {}; + var a = this.serializeArray(); + $.each(a, function() { + if (o[this.name] !== undefined) { + if (!o[this.name].push) { + o[this.name] = [o[this.name]]; + } + o[this.name].push(this.value || ''); + } else { + o[this.name] = this.value || ''; + } + }); + return o; + }; + + $.fn.offsetTopRelativeTo = function($ancestor) { + if (typeof $ancestor === 'undefined') { + return false; + } + + var el = this[0]; + var offset = el.offsetTop; + var $parent = $(el.offsetParent); + + if ($parent.is('body') || $parent.is($ancestor)) { + return offset; + } + + if (el.tagName === 'TR') { + // TODO: Didn't found a better way, this will probably break sooner or later + return $parent.offsetTopRelativeTo($ancestor); + } + + return offset + $parent.offsetTopRelativeTo($ancestor); + }; + +})(jQuery); diff --git a/public/js/icinga.js b/public/js/icinga.js new file mode 100644 index 0000000..e8b8bcc --- /dev/null +++ b/public/js/icinga.js @@ -0,0 +1,279 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Icinga starts here. + * + * Usage example: + * + * <code> + * var icinga = new Icinga({ + * baseUrl: '/icinga', + * }); + * </code> + */ +(function(window, $) { + + 'use strict'; + + var Icinga = function (config) { + + this.initialized = false; + + /** + * Our config object + */ + this.config = config; + + /** + * Icinga.Logger + */ + this.logger = null; + + /** + * Icinga.UI + */ + this.ui = null; + + /** + * Icinga.Loader + */ + this.loader = null; + + /** + * Icinga.Events + */ + this.events = null; + + /** + * Icinga.Timer + */ + this.timer = null; + + /** + * Icinga.History + */ + this.history = null; + + /** + * Icinga.Utils + */ + this.utils = null; + + /** + * Additional site behavior + */ + this.behaviors = {}; + + /** + * Loaded modules + */ + this.modules = {}; + + var _this = this; + $(document).ready(function () { + _this.initialize(); + _this = null; + }); + }; + + Icinga.prototype = { + + /** + * Icinga startup, will be triggerd once the document is ready + */ + initialize: function () { + if (this.initialized) { + return false; + } + + this.timezone = new Icinga.Timezone(); + this.utils = new Icinga.Utils(this); + this.logger = new Icinga.Logger(this); + this.timer = new Icinga.Timer(this); + this.ui = new Icinga.UI(this); + this.loader = new Icinga.Loader(this); + this.events = new Icinga.Events(this); + this.history = new Icinga.History(this); + var _this = this; + $.each(Icinga.Behaviors, function(name, Behavior) { + _this.behaviors[name.toLowerCase()] = new Behavior(_this); + }); + + this.timezone.initialize(); + this.timer.initialize(); + this.events.initialize(); + this.history.initialize(); + this.ui.initialize(); + this.loader.initialize(); + + this.logger.info('Icinga is ready, running on jQuery ', $().jquery); + this.initialized = true; + + // Trigger our own post-init event, `onLoad` is not reliable enough + $(document).trigger('icinga-init'); + }, + + /** + * Load a given module by name + * + * @param {string} name + * + * @return {boolean} + */ + loadModule: function (name) { + + if (this.isLoadedModule(name)) { + this.logger.error('Cannot load module ' + name + ' twice'); + return false; + } + + if (! this.hasModule(name)) { + this.logger.error('Cannot find module ' + name); + return false; + } + + this.modules[name] = new Icinga.Module( + this, + name, + Icinga.availableModules[name] + ); + return true; + }, + + /** + * Whether a module matching the given name exists or is loaded + * + * @param {string} name + * + * @return {boolean} + */ + hasModule: function (name) { + return this.isLoadedModule(name) || + 'undefined' !== typeof Icinga.availableModules[name]; + }, + + /** + * Return whether the given module is loaded + * + * @param {string} name The name of the module + * + * @returns {Boolean} + */ + isLoadedModule: function (name) { + return 'undefined' !== typeof this.modules[name]; + }, + + /** + * Ensure we have loaded the javascript code for a module + * + * @param {string} moduleName + */ + ensureModule: function(moduleName) { + if (this.hasModule(moduleName) && ! this.isLoadedModule(moduleName)) { + this.loadModule(moduleName); + } + }, + + /** + * If a container contains sub-containers for other modules, + * make sure the javascript code for each module is loaded. + * + * Containers are identified by "data-icinga-module" which + * holds the module name. + * + * @param container + */ + ensureSubModules: function (container) { + var icinga = this; + + $(container).find('[data-icinga-module]').each(function () { + var moduleName = $(this).data('icingaModule'); + if (moduleName) { + icinga.ensureModule(moduleName); + } + }); + }, + + /** + * Get a module by name + * + * @param {string} name + * + * @return {object} + */ + module: function (name) { + + if (this.hasModule(name) && !this.isLoadedModule(name)) { + this.modules[name] = new Icinga.Module( + this, + name, + Icinga.availableModules[name] + ); + } + + return this.modules[name]; + }, + + /** + * Clean up and unload all Icinga components + */ + destroy: function () { + + $.each(this.modules, function (name, module) { + module.destroy(); + }); + + this.timezone.destroy(); + this.timer.destroy(); + this.events.destroy(); + this.loader.destroy(); + this.ui.destroy(); + this.logger.debug('Icinga has been destroyed'); + this.logger.destroy(); + this.utils.destroy(); + + this.modules = []; + this.timer = this.events = this.loader = this.ui = this.logger = + this.utils = null; + this.initialized = false; + }, + + reload: function () { + setTimeout(function () { + var oldjQuery = window.jQuery; + var oldConfig = window.icinga.config; + var oldIcinga = window.Icinga; + window.icinga.destroy(); + window.Icinga = undefined; + window.$ = undefined; + window.jQuery = undefined; + jQuery = undefined; + $ = undefined; + + oldjQuery.getScript( + oldConfig.baseUrl.replace(/\/$/, '') + '/js/icinga.min.js' + ).done(function () { + var jQuery = window.jQuery; + window.icinga = new window.Icinga(oldConfig); + window.icinga.initialize(); + window.icinga.ui.reloadCss(); + oldjQuery = undefined; + oldConfig = undefined; + oldIcinga = undefined; + }).fail(function () { + window.jQuery = oldjQuery; + window.$ = window.jQuery; + window.Icinga = oldIcinga; + window.icinga = new Icinga(oldConfig); + window.icinga.ui.reloadCss(); + }); + }, 0); + } + + }; + + window.Icinga = Icinga; + + Icinga.availableModules = {}; + +})(window, jQuery); diff --git a/public/js/icinga/behavior/actiontable.js b/public/js/icinga/behavior/actiontable.js new file mode 100644 index 0000000..0f914f7 --- /dev/null +++ b/public/js/icinga/behavior/actiontable.js @@ -0,0 +1,498 @@ +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.Behavior.ActionTable + * + * A multi selection that distincts between the table rows using the row action URL filter + */ +(function(Icinga, $) { + + "use strict"; + + /** + * Remove one leading and trailing bracket and all text outside those brackets + * + * @param str {String} + * @returns {string} + */ + var stripBrackets = function (str) { + return str.replace(/^[^\(]*\(/, '').replace(/\)[^\)]*$/, ''); + }; + + /** + * Parse the filter query contained in the given url filter string + * + * @param filterString {String} + * + * @returns {Array} An object containing each row filter + */ + var parseSelectionQuery = function(filterString) { + var selections = []; + $.each(stripBrackets(filterString).split('|'), function(i, row) { + var tuple = {}; + $.each(stripBrackets(row).split('&'), function(i, keyValue) { + var s = keyValue.split('='); + tuple[s[0]] = decodeURIComponent(s[1]); + }); + selections.push(tuple); + }); + return selections; + }; + + /** + * Handle the selection of an action table + * + * @param table {HTMLElement} The table + * @param icinga {Icinga} + * + * @constructor + */ + var Selection = function(table, icinga) { + this.$el = $(table); + this.icinga = icinga; + this.col = this.$el.closest('div.container').attr('id'); + + if (this.hasMultiselection()) { + if (! this.getMultiselectionKeys().length) { + icinga.logger.error('multiselect table has no data-icinga-multiselect-data'); + } + if (! this.getMultiselectionUrl()) { + icinga.logger.error('multiselect table has no data-icinga-multiselect-url'); + } + } + }; + + Selection.prototype = { + + /** + * The container id in which this selection happens + */ + col: null, + + /** + * Return all rows as jQuery selector + * + * @returns {jQuery} + */ + rows: function() { + return this.$el.find('tr'); + }, + + /** + * Return all row action links as jQuery selector + * + * @returns {jQuery} + */ + rowActions: function() { + return this.$el.find('tr[href]'); + }, + + /** + * Return all selected rows as jQuery selector + * + * @returns {jQuery} + */ + selections: function() { + return this.$el.find('tr.active'); + }, + + /** + * If this selection allows selecting multiple rows + * + * @returns {Boolean} + */ + hasMultiselection: function() { + return this.$el.hasClass('multiselect'); + }, + + /** + * Return all filter keys that are significant when applying the selection + * + * @returns {Array} + */ + getMultiselectionKeys: function() { + var data = this.$el.data('icinga-multiselect-data'); + return (data && data.split(',')) || []; + }, + + /** + * Return the main target URL that is used when multi selecting rows + * + * This URL may differ from the url that is used when applying single rows + * + * @returns {String} + */ + getMultiselectionUrl: function() { + return this.$el.data('icinga-multiselect-url'); + }, + + /** + * Check whether the given url is + * + * @param {String} url + */ + hasMultiselectionUrl: function(url) { + var urls = this.$el.data('icinga-multiselect-url').split(' '); + + var related = this.$el.data('icinga-multiselect-controllers'); + if (related && related.length) { + urls = urls.concat(this.$el.data('icinga-multiselect-controllers').split(' ')); + } + + var hasSelection = false; + $.each(urls, function (i, object) { + if (url.indexOf(object) === 0) { + hasSelection = true; + } + }); + return hasSelection; + }, + + /** + * Read all filter data from the given row + * + * @param row {jQuery} The row element + * + * @returns {Object} An object containing all filter data in this row as key-value pairs + */ + getRowData: function(row) { + var params = this.icinga.utils.parseUrl(row.attr('href')).params; + var tuple = {}; + var keys = this.getMultiselectionKeys(); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + for (var j = 0; j < params.length; j++) { + if (params[j].key === key && (params[j].value || params[j].value === null)) { + tuple[key] = params[j].value ? decodeURIComponent(params[j].value): params[j].value; + break; + } + } + } + return tuple; + }, + + /** + * Deselect all selected rows + */ + clear: function() { + this.selections().removeClass('active'); + }, + + /** + * Add all rows that match the given filter to the selection + * + * @param filter {jQuery|Object} Either an object containing filter variables or the actual row to select + */ + select: function(filter) { + if (filter instanceof jQuery) { + filter.addClass('active'); + return; + } + var _this = this; + this.rowActions() + .filter( + function (i, el) { + return _this.icinga.utils.objectsEqual(_this.getRowData($(el)), filter); + } + ) + .closest('tr') + .addClass('active'); + }, + + /** + * Toggle the selection of the row between on and off + * + * @param row {jQuery} The row to toggle + */ + toggle: function(row) { + row.toggleClass('active'); + }, + + /** + * Add a new selection range to the closest table, using the selected row as + * range target. + * + * @param row {jQuery} The target of the selected range. + * + * @returns {boolean} If the selection was changed. + */ + range: function(row) { + var from, to; + var selected = row.first().get(0); + this.rows().each(function(i, el) { + if ($(el).hasClass('active') || el === selected) { + if (!from) { + from = el; + } + to = el; + } + }); + var inRange = false; + this.rows().each(function(i, el) { + if (el === from) { + inRange = true; + } + if (inRange) { + $(el).addClass('active'); + } + if (el === to) { + inRange = false; + } + }); + return false; + }, + + /** + * Select rows that target the given url + * + * @param url {String} The target url + */ + selectUrl: function(url) { + var formerHref = this.$el.closest('.container').data('icinga-actiontable-former-href') + + var $row = this.rows().filter('[href="' + url + '"]'); + + if ($row.length) { + this.clear(); + $row.addClass('active'); + } else { + if (this.col !== 'col2') { + // rows sometimes need to be displayed as active when related actions + // like command actions are being opened. Do not do this for col2, as it + // would always select the opened URL itself. + var $row = this.rows().filter('[href$="' + icinga.utils.parseUrl(url).query + '"]'); + if ($row.length) { + this.clear(); + $row.addClass('active'); + } else { + var $row = this.rows().filter('[href$="' + formerHref + '"]'); + if ($row.length) { + this.clear(); + $row.addClass('active'); + } else { + var tbl = this.$el; + if (ActionTable.prototype.tables( + tbl.closest('.dashboard').find('.container')).not(tbl).find('tr.active').length + ) { + this.clear(); + } + } + } + } + } + }, + + /** + * Convert all currently selected rows into an url query string + * + * @returns {String} The filter string + */ + toQuery: function() { + var _this = this; + var selections = this.selections(); + var queries = []; + var utils = this.icinga.utils; + if (selections.length === 1) { + return $(selections[0]).attr('href'); + } else if (selections.length > 1 && _this.hasMultiselection()) { + selections.each(function (i, el) { + var parts = []; + $.each(_this.getRowData($(el)), function(key, value) { + var condition = utils.fixedEncodeURIComponent(key); + if (value !== null) { + condition += '=' + utils.fixedEncodeURIComponent(value); + } + + parts.push(condition); + }); + queries.push('(' + parts.join('&') + ')'); + }); + return _this.getMultiselectionUrl() + '?(' + queries.join('|') + ')'; + } else { + return ''; + } + }, + + /** + * Refresh the displayed active columns using the current page location + */ + refresh: function() { + var hash = icinga.history.getCol2State().replace(/^#!/, ''); + if (this.hasMultiselection()) { + var query = parseSelectionQuery(hash); + if (query.length > 1 && this.hasMultiselectionUrl(this.icinga.utils.parseUrl(hash).path)) { + this.clear(); + // select all rows with matching filters + var _this = this; + $.each(query, function(i, selection) { + _this.select(selection); + }); + } + if (query.length > 1) { + return; + } + } + this.selectUrl(hash); + } + }; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + var ActionTable = function (icinga) { + Icinga.EventListener.call(this, icinga); + + /** + * If currently loading + * + * @var Boolean + */ + this.loading = false; + + this.on('rendered', '#main .container', this.onRendered, this); + this.on('beforerender', '#main .container', this.beforeRender, this); + this.on('click', 'table.action tr[href], table.table-row-selectable tr[href]', this.onRowClicked, this); + }; + ActionTable.prototype = new Icinga.EventListener(); + + /** + * Return all active tables in this table, or in the context as jQuery selector + * + * @param context {HTMLElement} + * @returns {jQuery} + */ + ActionTable.prototype.tables = function(context) { + if (context) { + return $(context).find('table.action, table.table-row-selectable'); + } + return $('table.action, table.table-row-selectable'); + }; + + /** + * Handle clicks on table rows and update selection and history + */ + ActionTable.prototype.onRowClicked = function (event) { + var _this = event.data.self; + var $target = $(event.target); + var $tr = $(event.currentTarget); + var table = new Selection($tr.closest('table.action, table.table-row-selectable')[0], _this.icinga); + + if ($tr.closest('[data-no-icinga-ajax]').length > 0) { + return true; + } + + // some rows may contain form actions that trigger a different action, pass those through + if (!$target.hasClass('rowaction') && $target.closest('form').length && + ($target.closest('a').length || // allow regular link clinks + $target.closest('button').length || // allow submitting forms + $target.closest('input').length || $target.closest('label').length)) { // allow selecting form elements + return; + } + + event.stopPropagation(); + event.preventDefault(); + + // update selection + if (table.hasMultiselection()) { + if (event.ctrlKey || event.metaKey) { + // add to selection + table.toggle($tr); + } else if (event.shiftKey) { + // range selection + table.range($tr); + } else { + // single selection + table.clear(); + table.select($tr); + } + } else { + table.clear(); + table.select($tr); + } + + var count = table.selections().length; + if (count > 0) { + var query = table.toQuery(); + _this.icinga.loader.loadUrl(query, _this.icinga.loader.getLinkTargetFor($tr)); + } else { + if (_this.icinga.loader.getLinkTargetFor($tr).attr('id') === 'col2') { + _this.icinga.ui.layout1col(); + } + } + + // redraw all table selections + _this.tables().each(function () { + new Selection(this, _this.icinga).refresh(); + }); + + // update selection info + $('.selection-info-count').text(count); + return false; + }; + + /** + * Render the selection and prepare selection rows + */ + ActionTable.prototype.onRendered = function(evt) { + var container = evt.target; + var _this = evt.data.self; + + if (evt.currentTarget !== container) { + // Nested containers are not processed multiple times + return; + } + + // initialize all rows with the correct row action + $('table.action tr, table.table-row-selectable tr', container).each(function(idx, el) { + + // decide which row action to use: links declared with the class rowaction take + // the highest precedence before hrefs defined in the tr itself and regular links + var $a = $('a[href].rowaction', el).first(); + if ($a.length) { + $(el).attr('href', $a.attr('href')); + return; + } + if ($(el).attr('href') && $(el).attr('href').length) { + return; + } + $a = $('a[href]', el).first(); + if ($a.length) { + $(el).attr('href', $a.attr('href')); + } + }); + + // draw all active selections that have disappeared on reload + _this.tables().each(function(i, el) { + new Selection(el, _this.icinga).refresh(); + }); + + // update displayed selection counter + var table = new Selection(_this.tables(container).first()); + $(container).find('.selection-info-count').text(table.selections().length); + }; + + ActionTable.prototype.beforeRender = function(evt) { + var container = evt.target; + var _this = evt.data.self; + + if (evt.currentTarget !== container) { + // Nested containers are not processed multiple times + return; + } + + var active = _this.tables().find('tr.active'); + if (active.length) { + $(container).data('icinga-actiontable-former-href', active.attr('href')); + } + }; + + ActionTable.prototype.clearAll = function () { + var _this = this; + this.tables().each(function () { + new Selection(this, _this.icinga).clear(); + }); + $('.selection-info-count').text('0'); + }; + + Icinga.Behaviors.ActionTable = ActionTable; + +}) (Icinga, jQuery); diff --git a/public/js/icinga/behavior/application-state.js b/public/js/icinga/behavior/application-state.js new file mode 100644 index 0000000..213c96c --- /dev/null +++ b/public/js/icinga/behavior/application-state.js @@ -0,0 +1,40 @@ +/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +(function(Icinga, $) { + + 'use strict'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + var ApplicationState = function (icinga) { + Icinga.EventListener.call(this, icinga); + this.on('rendered', '#layout', this.onRendered, this); + this.icinga = icinga; + }; + + ApplicationState.prototype = new Icinga.EventListener(); + + ApplicationState.prototype.onRendered = function(e) { + if (e.currentTarget !== e.target) { + // Nested containers are ignored + return; + } + + if (! $('#application-state').length + && ! $('#login').length + && ! $('#guest-error').length + && ! $('#setup').length + ) { + var _this = e.data.self; + + $('#layout').append( + '<div id="application-state" class="container" style="display: none" data-icinga-url="' + + _this.icinga.loader.baseUrl + + '/application-state" data-icinga-refresh="60"></div>' + ); + } + }; + + Icinga.Behaviors.ApplicationState = ApplicationState; + +})(Icinga, jQuery); diff --git a/public/js/icinga/behavior/autofocus.js b/public/js/icinga/behavior/autofocus.js new file mode 100644 index 0000000..e131d9e --- /dev/null +++ b/public/js/icinga/behavior/autofocus.js @@ -0,0 +1,28 @@ +/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +(function(Icinga, $) { + + 'use strict'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + var Autofocus = function (icinga) { + Icinga.EventListener.call(this, icinga); + this.on('rendered', this.onRendered, this); + }; + + Autofocus.prototype = new Icinga.EventListener(); + + Autofocus.prototype.onRendered = function(e) { + setTimeout(function() { + if (document.activeElement === e.target + || document.activeElement === document.body + ) { + e.data.self.icinga.ui.focusElement($(e.target).find('.autofocus')); + } + }, 0); + }; + + Icinga.Behaviors.Autofocus = Autofocus; + +})(Icinga, jQuery); diff --git a/public/js/icinga/behavior/collapsible.js b/public/js/icinga/behavior/collapsible.js new file mode 100644 index 0000000..c0b5349 --- /dev/null +++ b/public/js/icinga/behavior/collapsible.js @@ -0,0 +1,463 @@ +/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +;(function(Icinga) { + + 'use strict'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + let $ = window.$; + + try { + $ = require('icinga/icinga-php-library/notjQuery'); + } catch (e) { + console.warn('[Collapsible] notjQuery unavailable. Using jQuery for now'); + } + + /** + * Behavior for collapsible containers. + * + * @param icinga Icinga The current Icinga Object + */ + class Collapsible extends Icinga.EventListener { + constructor(icinga) { + super(icinga); + + this.on('layout-change', this.onLayoutChange, this); + this.on('rendered', '#main > .container, #modal-content', this.onRendered, this); + this.on('click', '.collapsible + .collapsible-control, .collapsible .collapsible-control', + this.onControlClicked, this); + + this.icinga = icinga; + this.defaultVisibleRows = 2; + this.defaultVisibleHeight = 36; + + this.state = new Icinga.Storage.StorageAwareMap.withStorage( + Icinga.Storage.BehaviorStorage('collapsible'), + 'expanded' + ) + .on('add', this.onExpand, this) + .on('delete', this.onCollapse, this); + } + + /** + * Initializes all collapsibles. Triggered on rendering of a container. + * + * @param event Event The `onRender` event triggered by the rendered container + */ + onRendered(event) { + let _this = event.data.self, + toCollapse = [], + toExpand = []; + + event.target.querySelectorAll('.collapsible').forEach(collapsible => { + // Assumes that any newly rendered elements are expanded + if (! ('canCollapse' in collapsible.dataset) && _this.canCollapse(collapsible)) { + if (_this.setupCollapsible(collapsible)) { + toCollapse.push([collapsible, _this.calculateCollapsedHeight(collapsible)]); + } else if (_this.isDetails(collapsible)) { + // Except if it's a <details> element, which may not be expanded by default + toExpand.push(collapsible); + } + } + }); + + // Elements are all collapsed in a row now, after height calculations are done. + // This avoids reflows since instantly collapsing an element will cause one if + // the height of the next element is being calculated. + for (const collapseInfo of toCollapse) { + _this.collapse(collapseInfo[0], collapseInfo[1]); + } + + for (const collapsible of toExpand) { + _this.expand(collapsible); + } + } + + /** + * Updates all collapsibles. + * + * @param event Event The `layout-change` event triggered by window resizing or column changes + */ + onLayoutChange(event) { + let _this = event.data.self; + let toCollapse = []; + + document.querySelectorAll('.collapsible').forEach(collapsible => { + if ('canCollapse' in collapsible.dataset) { + if (! _this.canCollapse(collapsible)) { + let toggleSelector = collapsible.dataset.toggleElement; + if (! this.isDetails(collapsible)) { + if (! toggleSelector) { + collapsible.nextElementSibling.remove(); + } else { + let toggle = document.getElementById(toggleSelector); + if (toggle) { + toggle.classList.remove('collapsed'); + delete toggle.dataset.canCollapse; + } + } + } + + delete collapsible.dataset.canCollapse; + _this.expand(collapsible); + } + } else if (_this.canCollapse(collapsible) && _this.setupCollapsible(collapsible)) { + // It's expanded but shouldn't + toCollapse.push([collapsible, _this.calculateCollapsedHeight(collapsible)]); + } + }); + + setTimeout(function () { + for (const collapseInfo of toCollapse) { + _this.collapse(collapseInfo[0], collapseInfo[1]); + } + }, 0); + } + + /** + * A collapsible got expanded in another window, try to apply this here as well + * + * @param {string} collapsiblePath + */ + onExpand(collapsiblePath) { + let collapsible = document.querySelector(collapsiblePath); + + if (collapsible && 'canCollapse' in collapsible.dataset) { + if ('stateCollapses' in collapsible.dataset) { + this.collapse(collapsible, this.calculateCollapsedHeight(collapsible)); + } else { + this.expand(collapsible); + } + } + } + + /** + * A collapsible got collapsed in another window, try to apply this here as well + * + * @param {string} collapsiblePath + */ + onCollapse(collapsiblePath) { + let collapsible = document.querySelector(collapsiblePath); + + if (collapsible && this.canCollapse(collapsible)) { + if ('stateCollapses' in collapsible.dataset) { + this.expand(collapsible); + } else { + this.collapse(collapsible, this.calculateCollapsedHeight(collapsible)); + } + } + } + + /** + * Event handler for toggling collapsibles. Switches the collapsed state of the respective container. + * + * @param event Event The `onClick` event triggered by the clicked collapsible-control element + */ + onControlClicked(event) { + let _this = event.data.self, + target = event.currentTarget; + + let collapsible = target.previousElementSibling; + if ('collapsibleAt' in target.dataset) { + collapsible = document.querySelector(target.dataset.collapsibleAt); + } else if (! collapsible) { + collapsible = target.closest('.collapsible'); + } + + if (! collapsible) { + _this.icinga.logger.error( + '[Collapsible] Collapsible control has no associated .collapsible: ', target); + + return; + } else if ('noPersistence' in collapsible.dataset) { + if (collapsible.classList.contains('collapsed')) { + _this.expand(collapsible); + } else { + _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible)); + } + } else { + let collapsiblePath = _this.icinga.utils.getCSSPath(collapsible), + stateCollapses = 'stateCollapses' in collapsible.dataset; + + if (_this.state.has(collapsiblePath)) { + _this.state.delete(collapsiblePath); + + if (stateCollapses) { + _this.expand(collapsible); + } else { + _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible)); + } + } else { + _this.state.set(collapsiblePath); + + if (stateCollapses) { + _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible)); + } else { + _this.expand(collapsible); + } + } + } + + if (_this.isDetails(collapsible)) { + // The browser handles these clicks as well, and would toggle the state again + event.preventDefault(); + } + } + + /** + * Setup the given collapsible + * + * @param collapsible The given collapsible container element + * + * @returns {boolean} Whether it needs to collapse or not + */ + setupCollapsible(collapsible) { + if (this.isDetails(collapsible)) { + let summary = collapsible.querySelector(':scope > summary'); + if (! summary.classList.contains('collapsible-control')) { + summary.classList.add('collapsible-control'); + } + + if (collapsible.open) { + collapsible.dataset.stateCollapses = ''; + } + } else if (!! collapsible.dataset.toggleElement) { + let toggleSelector = collapsible.dataset.toggleElement, + toggle = collapsible.querySelector(toggleSelector), + externalToggle = false; + if (! toggle) { + if (collapsible.nextElementSibling && collapsible.nextElementSibling.matches(toggleSelector)) { + toggle = collapsible.nextElementSibling; + } else { + externalToggle = true; + toggle = document.getElementById(toggleSelector); + } + } + + if (! toggle) { + if (externalToggle) { + this.icinga.logger.error( + '[Collapsible] External control with id `' + + toggleSelector + + '` not found for .collapsible', + collapsible + ); + } else { + this.icinga.logger.error( + '[Collapsible] Control `' + toggleSelector + '` not found in .collapsible', collapsible); + } + + return false; + } else if (externalToggle) { + collapsible.dataset.hasExternalToggle = ''; + + toggle.dataset.canCollapse = ''; + toggle.dataset.collapsibleAt = this.icinga.utils.getCSSPath(collapsible); + $(toggle).on('click', e => { + // Only required as onControlClicked() is compatible with Icinga.EventListener + e.data = { self: this }; + this.onControlClicked(e); + }); + } else if (! toggle.classList.contains('collapsible-control')) { + toggle.classList.add('collapsible-control'); + } + } else { + setTimeout(function () { + let collapsibleControl = document + .getElementById('collapsible-control-ghost') + .cloneNode(true); + collapsibleControl.removeAttribute('id'); + collapsible.parentNode.insertBefore(collapsibleControl, collapsible.nextElementSibling); + }, 0); + } + + collapsible.dataset.canCollapse = ''; + + if ('noPersistence' in collapsible.dataset) { + return ! ('stateCollapses' in collapsible.dataset); + } + + if ('stateCollapses' in collapsible.dataset) { + return this.state.has(this.icinga.utils.getCSSPath(collapsible)); + } else { + return ! this.state.has(this.icinga.utils.getCSSPath(collapsible)); + } + } + + /** + * Return an appropriate row element selector + * + * @param collapsible The given collapsible container element + * + * @returns {string} + */ + getRowSelector(collapsible) { + if (!! collapsible.dataset.visibleHeight) { + return ''; + } + + if (collapsible.tagName === 'TABLE') { + return ':scope > tbody > tr'; + } else if (collapsible.tagName === 'UL' || collapsible.tagName === 'OL') { + return ':scope > li:not(.collapsible-control)'; + } + + return ''; + } + + /** + * Check whether the given collapsible needs to collapse + * + * @param collapsible The given collapsible container element + * + * @returns {boolean} + */ + canCollapse(collapsible) { + if (this.isDetails(collapsible)) { + return collapsible.querySelector(':scope > summary') !== null; + } + + let rowSelector = this.getRowSelector(collapsible); + if (!! rowSelector) { + let visibleRows = Number(collapsible.dataset.visibleRows); + if (isNaN(visibleRows)) { + visibleRows = this.defaultVisibleRows; + } else if (visibleRows === 0) { + return true; + } + + return collapsible.querySelectorAll(rowSelector).length > visibleRows * 2; + } else { + let maxHeight = Number(collapsible.dataset.visibleHeight); + if (isNaN(maxHeight)) { + maxHeight = this.defaultVisibleHeight; + } else if (maxHeight === 0) { + return true; + } + + let actualHeight = collapsible.scrollHeight - parseFloat( + window.getComputedStyle(collapsible).getPropertyValue('padding-top') + ); + + return actualHeight >= maxHeight * 2; + } + } + + /** + * Calculate the height the given collapsible should have when collapsed + * + * @param collapsible + */ + calculateCollapsedHeight(collapsible) { + let height; + + if (this.isDetails(collapsible)) { + return -1; + } + + let rowSelector = this.getRowSelector(collapsible); + if (!! rowSelector) { + height = collapsible.scrollHeight; + height -= parseFloat(window.getComputedStyle(collapsible).getPropertyValue('padding-bottom')); + + let visibleRows = Number(collapsible.dataset.visibleRows); + if (isNaN(visibleRows)) { + visibleRows = this.defaultVisibleRows; + } + + let rows = Array.from(collapsible.querySelectorAll(rowSelector)).slice(visibleRows); + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + + if (row.previousElementSibling === null) { // very first element + height -= row.offsetHeight; + height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-top')); + } else if (i < rows.length - 1) { // every element but the last one + let prevBottomBorderAt = row.previousElementSibling.offsetTop; + prevBottomBorderAt += row.previousElementSibling.offsetHeight; + height -= row.offsetTop - prevBottomBorderAt + row.offsetHeight; + } else { // the last element + height -= row.offsetHeight; + height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-top')); + height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-bottom')); + } + } + } else { + height = Number(collapsible.dataset.visibleHeight); + if (isNaN(height)) { + height = this.defaultVisibleHeight; + } + + height += parseFloat(window.getComputedStyle(collapsible).getPropertyValue('padding-top')); + + if ( + !! collapsible.dataset.toggleElement + && ! ('hasExternalToggle' in collapsible.dataset) + && (! collapsible.nextElementSibling + || ! collapsible.nextElementSibling.matches(collapsible.dataset.toggleElement)) + ) { + let toggle = collapsible.querySelector(collapsible.dataset.toggleElement); + height += toggle.offsetHeight; // TODO: Very expensive at times. (50ms+) Check why! + height += parseFloat(window.getComputedStyle(toggle).getPropertyValue('margin-top')); + height += parseFloat(window.getComputedStyle(toggle).getPropertyValue('margin-bottom')); + } + } + + return height; + } + + /** + * Collapse the given collapsible + * + * @param collapsible The given collapsible container element + * @param toHeight {int} The height in pixels to collapse to + */ + collapse(collapsible, toHeight) { + if (this.isDetails(collapsible)) { + collapsible.open = false; + } else { + collapsible.style.cssText = 'display: block; height: ' + toHeight + 'px; padding-bottom: 0'; + + if ('hasExternalToggle' in collapsible.dataset) { + document.getElementById(collapsible.dataset.toggleElement).classList.add('collapsed'); + } + } + + collapsible.classList.add('collapsed'); + } + + /** + * Expand the given collapsible + * + * @param collapsible The given collapsible container element + */ + expand(collapsible) { + collapsible.classList.remove('collapsed'); + + if (this.isDetails(collapsible)) { + collapsible.open = true; + } else { + collapsible.style.cssText = ''; + + if ('hasExternalToggle' in collapsible.dataset) { + document.getElementById(collapsible.dataset.toggleElement).classList.remove('collapsed'); + } + } + } + + /** + * Get whether the given collapsible is a <details> element + * + * @param collapsible + * + * @return {Boolean} + */ + isDetails(collapsible) { + return collapsible.tagName === 'DETAILS'; + } + } + + Icinga.Behaviors.Collapsible = Collapsible; + +})(Icinga); diff --git a/public/js/icinga/behavior/datetime-picker.js b/public/js/icinga/behavior/datetime-picker.js new file mode 100644 index 0000000..fb0ddff --- /dev/null +++ b/public/js/icinga/behavior/datetime-picker.js @@ -0,0 +1,222 @@ +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +/** + * DatetimePicker - Behavior for inputs that should show a date and time picker + */ +;(function(Icinga, $) { + + 'use strict'; + + try { + var Flatpickr = require('icinga/icinga-php-library/vendor/flatpickr'); + var notjQuery = require('icinga/icinga-php-library/notjQuery'); + } catch (e) { + console.warn('Unable to provide datetime picker. Libraries not available:', e); + return; + } + + Icinga.Behaviors = Icinga.Behaviors || {}; + + /** + * Behavior for datetime pickers. + * + * @param icinga {Icinga} The current Icinga Object + */ + var DatetimePicker = function(icinga) { + Icinga.EventListener.call(this, icinga); + this.icinga = icinga; + + /** + * The formats the server expects + * + * In a syntax flatpickr understands. Based on https://flatpickr.js.org/formatting/ + * + * @type {string} + */ + this.server_full_format = 'Y-m-d\\TH:i:S'; + this.server_date_format = 'Y-m-d'; + this.server_time_format = 'H:i:S'; + + /** + * The flatpickr instances created + * + * @type {Map<Flatpickr, string>} + * @private + */ + this._pickers = new Map(); + + this.on('rendered', '#main > .container, #modal-content', this.onRendered, this); + this.on('close-column', this.onCloseContainer, this); + this.on('close-modal', this.onCloseContainer, this); + }; + + DatetimePicker.prototype = new Icinga.EventListener(); + + /** + * Add flatpickr widget on selected inputs + * + * @param event {Event} + */ + DatetimePicker.prototype.onRendered = function(event) { + var _this = event.data.self; + var containerId = event.target.dataset.icingaContainerId; + var inputs = event.target.querySelectorAll('input[data-use-datetime-picker]'); + + // Cleanup left-over pickers from the previous content + _this.cleanupPickers(containerId); + + $.each(inputs, function () { + if (this.type !== 'text') { + // Ignore native inputs. Browser widgets are (mostly) superior. + // TODO: This makes the type distinction below useless. + // Refactor this once we decided how we continue here in the future. + return; + } + + var server_format = _this.server_full_format; + if (this.type === 'date') { + server_format = _this.server_date_format; + } else if (this.type === 'time') { + server_format = _this.server_time_format; + } + + // Inject calendar container into a new empty div, inside the column/modal but outside the form. + // See https://github.com/flatpickr/flatpickr/issues/2054 for details. + var appendTo = document.createElement('div'); + this.form.parentNode.insertBefore(appendTo, this.form.nextSibling); + + var enableTime = server_format !== _this.server_date_format; + var disableDate = server_format === _this.server_time_format; + var dateTimeFormatter = _this.createFormatter(! disableDate, enableTime); + var options = { + locale: _this.loadFlatpickrLocale(), + appendTo: appendTo, + altInput: true, + enableTime: enableTime, + noCalendar: disableDate, + dateFormat: server_format, + formatDate: function (date, format, locale) { + return format === this.dateFormat + ? Flatpickr.formatDate(date, format, locale) + : dateTimeFormatter.format(date); + } + }; + + for (name in this.dataset) { + if (name.length > 9 && name.substr(0, 9) === 'flatpickr') { + var value = this.dataset[name]; + if (value === '') { + value = true; + } + + options[name.charAt(9).toLowerCase() + name.substr(10)] = value; + } + } + + var element = this; + if (!! options.wrap) { + element = this.parentNode; + } + + var fp = Flatpickr(element, options); + fp.calendarContainer.classList.add('icinga-datetime-picker'); + + if (! !!options.wrap) { + this.parentNode.insertBefore(_this.renderIcon(), fp.altInput.nextSibling); + } + + _this._pickers.set(fp, containerId); + }); + }; + + /** + * Cleanup all flatpickr instances in the closed container + * + * @param event {Event} + */ + DatetimePicker.prototype.onCloseContainer = function (event) { + var _this = event.data.self; + var containerId = event.target.dataset.icingaContainerId; + + _this.cleanupPickers(containerId); + }; + + /** + * Destroy all flatpickr instances in the container with the given id + * + * @param containerId {String} + */ + DatetimePicker.prototype.cleanupPickers = function (containerId) { + this._pickers.forEach(function (cId, fp) { + if (cId === containerId) { + this._pickers.delete(fp); + fp.destroy(); + } + }, this); + }; + + /** + * Close all other flatpickr instances and keep the given one + * + * @param fp {Flatpickr} + */ + DatetimePicker.prototype.closePickers = function (fp) { + var containerId = this._pickers.get(fp); + this._pickers.forEach(function (cId, fp2) { + if (cId === containerId && fp2 !== fp) { + fp2.close(); + } + }, this); + }; + + DatetimePicker.prototype.createFormatter = function (withDate, withTime) { + var options = {}; + if (withDate) { + options.year = 'numeric'; + options.month = 'numeric'; + options.day = 'numeric'; + } + if (withTime) { + options.hour = 'numeric'; + options.minute = 'numeric'; + options.timeZoneName = 'short'; + options.timeZone = this.icinga.config.timezone; + } + + return new Intl.DateTimeFormat([this.icinga.config.locale, 'en'], options); + }; + + DatetimePicker.prototype.loadFlatpickrLocale = function () { + switch (this.icinga.config.locale) { + case 'ar': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/ar').Arabic; + case 'de': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/de').German; + case 'es': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/es').Spanish; + case 'fi': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/fi').Finnish; + case 'fr': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/fr').French; + case 'it': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/it').Italian; + case 'ja': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/ja').Japanese; + case 'pt': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/pt').Portuguese; + case 'ru': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/ru').Russian; + case 'uk': + return require('icinga/icinga-php-library/vendor/flatpickr/l10n/uk').Ukrainian; + default: + return 'default'; + } + }; + + DatetimePicker.prototype.renderIcon = function () { + return notjQuery.render('<i class="icon fa fa-calendar" role="image"></i>'); + }; + + Icinga.Behaviors.DatetimePicker = DatetimePicker; + +})(Icinga, jQuery); diff --git a/public/js/icinga/behavior/detach.js b/public/js/icinga/behavior/detach.js new file mode 100644 index 0000000..16fe157 --- /dev/null +++ b/public/js/icinga/behavior/detach.js @@ -0,0 +1,73 @@ +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.Behavior.Detach + * + * Detaches DOM elements before an auto-refresh and attaches them back afterwards + */ +(function(Icinga, $) { + + 'use strict'; + + function Detach(icinga) { + Icinga.EventListener.call(this, icinga); + } + + Detach.prototype = new Icinga.EventListener(); + + /** + * Mutates the HTML before it is placed in the DOM after a reload + * + * @param content {string} The content to be rendered + * @param $container {jQuery} The target container + * @param action {string} The URL that caused the reload + * @param autorefresh {bool} Whether the rendering is due to an auto-refresh + * + * @return {string|null} The content to be rendered or null, when nothing should be changed + */ + Detach.prototype.renderHook = function(content, $container, action, autorefresh) { + // Exit early + if (! autorefresh) { + return content; + } else { + var containerId = $container.attr('id'); + + if (containerId === 'menu' || containerId === 'application-state') { + return content; + } + } + + if (! $container.find('.detach:first').length) { + return content; + } + + var $content = $('<div></div>').append(content); + var icinga = this.icinga; + + $content.find('.detach').each(function() { + // Selector only works w/ IDs because it was initially built to work w/ absolute paths only + var $detachTarget = $(this); + var detachTargetId = $detachTarget.attr('id'); + if (detachTargetId === undefined) { + return; + } + + var selector = '#' + detachTargetId + ':first'; + var $detachSource = $container.find(selector); + + if ($detachSource.length) { + icinga.logger.debug('Detaching ' + selector); + $detachSource.detach(); + $detachTarget.replaceWith($detachSource); + $detachTarget.remove(); + } + }); + + return $content.html(); + }; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + Icinga.Behaviors.Detach = Detach; + +}) (Icinga, jQuery); diff --git a/public/js/icinga/behavior/dropdown.js b/public/js/icinga/behavior/dropdown.js new file mode 100644 index 0000000..691e634 --- /dev/null +++ b/public/js/icinga/behavior/dropdown.js @@ -0,0 +1,66 @@ +/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +;(function(Icinga, $) { + + "use strict"; + + /** + * Toggle the CSS class active of the dropdown navigation item + * + * Called when the dropdown toggle has been activated via mouse or keyobard. This will expand/collpase the dropdown + * menu according to CSS. + * + * @param {object} e Event + */ + function setActive(e) { + $(this).parent().toggleClass('active'); + } + + /** + * Clear active state of the dropdown navigation item when the mouse leaves the navigation item + * + * @param {object} e Event + */ + function clearActive(e) { + $(this).removeClass('active'); + } + + /** + * Clear active state of the dropdown navigation item when the navigation items loses focus + * + * @param {object} e Event + */ + function clearFocus(e) { + var $dropdown = $(this); + // Timeout is required to wait for the next element in the DOM to receive focus + setTimeout(function() { + if (! $.contains($dropdown[0], document.activeElement)) { + $dropdown.removeClass('active'); + } + }, 10); + } + + Icinga.Behaviors = Icinga.Behaviors || {}; + + /** + * Behavior for dropdown navigation items + * + * The dropdown behavior listens for activity on dropdown navigation items for toggling the CSS class + * active on them. CSS is responsible for the expanded and collapsed state. + * + * @param {Icinga} icinga + * + * @constructor + */ + var Dropdown = function (icinga) { + Icinga.EventListener.call(this, icinga); + this.on('click', '.dropdown-nav-item > a', setActive, this); + this.on('mouseleave', '.dropdown-nav-item', clearActive, this); + this.on('focusout', '.dropdown-nav-item', clearFocus, this); + }; + + Dropdown.prototype = new Icinga.EventListener(); + + Icinga.Behaviors.Dropdown = Dropdown; + +})(Icinga, jQuery); diff --git a/public/js/icinga/behavior/filtereditor.js b/public/js/icinga/behavior/filtereditor.js new file mode 100644 index 0000000..ffcad01 --- /dev/null +++ b/public/js/icinga/behavior/filtereditor.js @@ -0,0 +1,77 @@ +/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.Behavior.FilterEditor + * + * Initially expanded, but collapsable subtrees + */ +(function(Icinga, $) { + + 'use strict'; + + var containerId = /^col(\d+)$/; + var filterEditors = {}; + + function FilterEditor(icinga) { + Icinga.EventListener.call(this, icinga); + + this.on('beforerender', '#main > .container', this.beforeRender, this); + this.on('rendered', '#main > .container', this.onRendered, this); + } + + FilterEditor.prototype = new Icinga.EventListener(); + + FilterEditor.prototype.beforeRender = function(event) { + if (event.currentTarget !== event.target) { + // Nested containers are ignored + return; + } + + var $container = $(event.target); + var match = containerId.exec($container.attr('id')); + + if (match !== null) { + var id = match[1]; + var subTrees = {}; + filterEditors[id] = subTrees; + + $container.find('.tree .handle').each(function () { + var $li = $(this).closest('li'); + + subTrees[$li.find('select').first().attr('name')] = $li.hasClass('collapsed'); + }); + } + }; + + FilterEditor.prototype.onRendered = function(event) { + if (event.currentTarget !== event.target) { + // Nested containers are ignored + return; + } + + var $container = $(event.target); + var match = containerId.exec($container.attr('id')); + + if (match !== null) { + var id = match[1]; + + if (typeof filterEditors[id] !== "undefined") { + var subTrees = filterEditors[id]; + delete filterEditors[id]; + + $container.find('.tree .handle').each(function () { + var $li = $(this).closest('li'); + var name = $li.find('select').first().attr('name'); + if (typeof subTrees[name] !== "undefined" && subTrees[name] !== $li.hasClass('collapsed')) { + $li.toggleClass('collapsed'); + } + }); + } + } + }; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + Icinga.Behaviors.FilterEditor = FilterEditor; + +}) (Icinga, jQuery); diff --git a/public/js/icinga/behavior/flyover.js b/public/js/icinga/behavior/flyover.js new file mode 100644 index 0000000..207d577 --- /dev/null +++ b/public/js/icinga/behavior/flyover.js @@ -0,0 +1,85 @@ +/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.Behavior.Flyover + * + * A toggleable flyover + */ +(function(Icinga, $) { + + 'use strict'; + + var expandedFlyovers = {}; + + function Flyover(icinga) { + Icinga.EventListener.call(this, icinga); + + this.on('rendered', '#main > .container', this.onRendered, this); + this.on('click', this.onClick, this); + this.on('click', '.flyover-toggle', this.onClickFlyoverToggle, this); + } + + Flyover.prototype = new Icinga.EventListener(); + + Flyover.prototype.onRendered = function(event) { + // Re-expand expanded containers after an auto-refresh + + $(event.target).find('.flyover').each(function() { + var $this = $(this); + + if (typeof expandedFlyovers['#' + $this.attr('id')] !== 'undefined') { + var $container = $this.closest('.container'); + + if ($this.offset().left - $container.offset().left > $container.innerWidth() / 2) { + $this.addClass('flyover-right'); + } + + $this.toggleClass('flyover-expanded'); + } + }); + }; + + Flyover.prototype.onClick = function(event) { + // Close flyover on click outside the flyover + var $target = $(event.target); + + if (! $target.closest('.flyover').length) { + var _this = event.data.self; + $.each(expandedFlyovers, function (id) { + _this.onClickFlyoverToggle({target: $('.flyover-toggle', id)[0]}); + }); + } + }; + + Flyover.prototype.onClickFlyoverToggle = function(event) { + var $flyover = $(event.target).closest('.flyover'); + + $flyover.toggleClass('flyover-expanded'); + + var $container = $flyover.closest('.container'); + if ($flyover.hasClass('flyover-expanded')) { + if ($flyover.offset().left - $container.offset().left > $container.innerWidth() / 2) { + $flyover.addClass('flyover-right'); + } + + if ($flyover.is('[data-flyover-suspends-auto-refresh]')) { + $container[0].dataset.suspendAutorefresh = ''; + } + + expandedFlyovers['#' + $flyover.attr('id')] = null; + } else { + $flyover.removeClass('flyover-right'); + + if ($flyover.is('[data-flyover-suspends-auto-refresh]')) { + delete $container[0].dataset.suspendAutorefresh; + } + + delete expandedFlyovers['#' + $flyover.attr('id')]; + } + }; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + Icinga.Behaviors.Flyover = Flyover; + +})(Icinga, jQuery); diff --git a/public/js/icinga/behavior/form.js b/public/js/icinga/behavior/form.js new file mode 100644 index 0000000..ca9db3b --- /dev/null +++ b/public/js/icinga/behavior/form.js @@ -0,0 +1,96 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Controls behavior of form elements, depending reload and + */ +(function(Icinga, $) { + + "use strict"; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + var Form = function (icinga) { + Icinga.EventListener.call(this, icinga); + this.on('rendered', '.container', this.onRendered, this); + + // store the modification state of all input fields + this.inputs = new WeakMap(); + }; + Form.prototype = new Icinga.EventListener(); + + /** + * @param event + */ + Form.prototype.onRendered = function (event) { + var _this = event.data.self; + var container = event.target; + + container.querySelectorAll('form input').forEach(function (input) { + if (! _this.inputs.has(input) && input.type !== 'hidden') { + _this.inputs.set(input, input.value); + _this.icinga.logger.debug('registering "' + input.value + '" as original input value'); + } + }); + }; + + /** + * Mutates the HTML before it is placed in the DOM after a reload + * + * @param content {String} The content to be rendered + * @param $container {jQuery} The target container where the html will be rendered in + * @param action {String} The action-url that caused the reload + * @param autorefresh {Boolean} Whether the rendering is due to an autoRefresh + * @param autoSubmit {Boolean} Whether the rendering is due to an autoSubmit + * + * @returns {string|NULL} The content to be rendered, or NULL, when nothing should be changed + */ + Form.prototype.renderHook = function(content, $container, action, autorefresh, autoSubmit) { + if ($container.attr('id') === 'menu') { + var $search = $container.find('#search'); + if ($search[0] === document.activeElement) { + return null; + } + if ($search.length) { + var $content = $('<div></div>').append(content); + $content.find('#search').attr('value', $search.val()).addClass('active'); + return $content.html(); + } + return content; + } + + if (! autorefresh || autoSubmit) { + return content; + } + + var _this = this; + var changed = false; + $container[0].querySelectorAll('form input').forEach(function (input) { + if (_this.inputs.has(input) && _this.inputs.get(input) !== input.value) { + changed = true; + _this.icinga.logger.debug( + '"' + _this.inputs.get(input) + '" was changed ("' + input.value + '") and aborts reload...' + ); + } + }); + if (changed) { + return null; + } + + var origFocus = document.activeElement; + var containerId = $container.attr('id'); + if ($container.has(origFocus).length + && $(origFocus).length + && ! $(origFocus).hasClass('autofocus') + && $(origFocus).closest('form').length + && $(origFocus).not(':input[type=button], :input[type=submit], :input[type=reset]').length + ) { + this.icinga.logger.debug('Not changing content for ' + containerId + ' form has focus'); + return null; + } + + return content; + }; + + Icinga.Behaviors.Form = Form; + +}) (Icinga, jQuery); diff --git a/public/js/icinga/behavior/input-enrichment.js b/public/js/icinga/behavior/input-enrichment.js new file mode 100644 index 0000000..1540941 --- /dev/null +++ b/public/js/icinga/behavior/input-enrichment.js @@ -0,0 +1,148 @@ +/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +/** + * InputEnrichment - Behavior for forms with enriched inputs + */ +(function(Icinga) { + + "use strict"; + + try { + var SearchBar = require('icinga/icinga-php-library/widget/SearchBar'); + var SearchEditor = require('icinga/icinga-php-library/widget/SearchEditor'); + var FilterInput = require('icinga/icinga-php-library/widget/FilterInput'); + var TermInput = require('icinga/icinga-php-library/widget/TermInput'); + var Completer = require('icinga/icinga-php-library/widget/Completer'); + } catch (e) { + console.warn('Unable to provide input enrichments. Libraries not available:', e); + return; + } + + Icinga.Behaviors = Icinga.Behaviors || {}; + + /** + * @param icinga + * @constructor + */ + let InputEnrichment = function (icinga) { + Icinga.EventListener.call(this, icinga); + + this.on('beforerender', '#main > .container, #modal-content', this.onBeforeRender, this); + this.on('rendered', '#main > .container, #modal-content', this.onRendered, this); + + /** + * Enriched inputs + * + * @type {WeakMap<object, SearchEditor|SearchBar|FilterInput|TermInput|Completer>} + * @private + */ + this._enrichments = new WeakMap(); + + /** + * Cached enrichments + * + * Holds values only during the time between `beforerender` and `rendered` + * + * @type {{}} + * @private + */ + this._cachedEnrichments = {}; + }; + InputEnrichment.prototype = new Icinga.EventListener(); + + /** + * @param data + */ + InputEnrichment.prototype.update = function (data) { + var input = document.querySelector(data[0]); + if (input !== null && this._enrichments.has(input)) { + this._enrichments.get(input).updateTerms(data[1]); + } + }; + + /** + * @param event + * @param content + * @param action + * @param autorefresh + * @param scripted + */ + InputEnrichment.prototype.onBeforeRender = function (event, content, action, autorefresh, scripted) { + if (! autorefresh) { + return; + } + + let _this = event.data.self; + let inputs = event.target.querySelectorAll('[data-enrichment-type]'); + + // Remember current instances + inputs.forEach((input) => { + let enrichment = _this._enrichments.get(input); + if (enrichment) { + _this._cachedEnrichments[_this.icinga.utils.getDomPath(input).join(' > ')] = enrichment; + } + }); + }; + + /** + * @param event + * @param autorefresh + * @param scripted + */ + InputEnrichment.prototype.onRendered = function (event, autorefresh, scripted) { + let _this = event.data.self; + let container = event.target; + + if (autorefresh) { + // Apply remembered instances + for (let inputPath in _this._cachedEnrichments) { + let enrichment = _this._cachedEnrichments[inputPath]; + let input = container.querySelector(inputPath); + if (input !== null) { + enrichment.refresh(input); + _this._enrichments.set(input, enrichment); + } else { + enrichment.destroy(); + } + + delete _this._cachedEnrichments[inputPath]; + } + } + + // Create new instances + let inputs = container.querySelectorAll('[data-enrichment-type]'); + inputs.forEach((input) => { + let enrichment = _this._enrichments.get(input); + if (! enrichment) { + switch (input.dataset.enrichmentType) { + case 'search-bar': + enrichment = (new SearchBar(input)).bind(); + break; + case 'search-editor': + enrichment = (new SearchEditor(input)).bind(); + break; + case 'filter': + enrichment = (new FilterInput(input)).bind(); + enrichment.restoreTerms(); + + if (_this._enrichments.has(input.form)) { + _this._enrichments.get(input.form).setFilterInput(enrichment); + } + + break; + case 'terms': + enrichment = (new TermInput(input)).bind(); + enrichment.restoreTerms(); + break; + case 'completion': + enrichment = (new Completer(input)).bind(); + } + + _this._enrichments.set(input, enrichment); + } + }); + }; + + Icinga.Behaviors.InputEnrichment = InputEnrichment; + +})(Icinga); diff --git a/public/js/icinga/behavior/modal.js b/public/js/icinga/behavior/modal.js new file mode 100644 index 0000000..0083f76 --- /dev/null +++ b/public/js/icinga/behavior/modal.js @@ -0,0 +1,231 @@ +/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +;(function(Icinga, $) { + + 'use strict'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + /** + * Behavior for modal dialogs. + * + * @param icinga {Icinga} The current Icinga Object + */ + var Modal = function(icinga) { + Icinga.EventListener.call(this, icinga); + + this.icinga = icinga; + this.$layout = $('#layout'); + this.$ghost = $('#modal-ghost'); + + this.on('submit', '#modal form', this.onFormSubmit, this); + this.on('change', '#modal form select.autosubmit', this.onFormAutoSubmit, this); + this.on('change', '#modal form input.autosubmit', this.onFormAutoSubmit, this); + this.on('click', '[data-icinga-modal]', this.onModalToggleClick, this); + this.on('mousedown', '#layout > #modal', this.onModalLeave, this); + this.on('click', '.modal-header > button', this.onModalClose, this); + this.on('keydown', this.onKeyDown, this); + }; + + Modal.prototype = new Icinga.EventListener(); + + /** + * Event handler for toggling modals. Shows the link target in a modal dialog. + * + * @param event {Event} The `onClick` event triggered by the clicked modal-toggle element + * @returns {boolean} + */ + Modal.prototype.onModalToggleClick = function(event) { + var _this = event.data.self; + var $a = $(event.currentTarget); + var url = $a.attr('href'); + var $modal = _this.$ghost.clone(); + var $urlTarget = _this.icinga.loader.getLinkTargetFor($a, false); + + _this.modalOpener = event.currentTarget; + + // Disable pointer events to block further function calls + _this.modalOpener.style.pointerEvents = 'none'; + + // Add showCompact, we don't want controls in a modal + url = _this.icinga.utils.addUrlFlag(url, 'showCompact'); + + // Set the toggle's base target on the modal to use it as redirect target + $modal.data('redirectTarget', $urlTarget); + + // Final preparations, the id is required so that it's not `display:none` anymore + $modal.attr('id', 'modal'); + _this.$layout.append($modal); + + var req = _this.icinga.loader.loadUrl(url, $modal.find('#modal-content')); + req.addToHistory = false; + req.done(function () { + _this.setTitle($modal, req.$target.data('icingaTitle').replace(/\s::\s.*/, '')); + _this.show($modal); + _this.focus($modal); + }); + req.fail(function (req, _, errorThrown) { + if (req.status >= 500) { + // Yes, that's done twice (by us and by the base fail handler), + // but `renderContentToContainer` does too many useful things.. + _this.icinga.loader.renderContentToContainer(req.responseText, $urlTarget, req.action); + } else if (req.status > 0) { + var msg = $(req.responseText).find('.error-message').text(); + if (msg && msg !== errorThrown) { + errorThrown += ': ' + msg; + } + + _this.icinga.loader.createNotice('error', errorThrown); + } + + _this.hide($modal); + }); + + return false; + }; + + /** + * Event handler for form submits within a modal. + * + * @param event {Event} The `submit` event triggered by a form within the modal + * @param $autoSubmittedBy {jQuery} The element triggering the auto submit, if any + * @returns {boolean} + */ + Modal.prototype.onFormSubmit = function(event, $autoSubmittedBy) { + var _this = event.data.self; + var $form = $(event.currentTarget).closest('form'); + var $modal = $form.closest('#modal'); + + var $button; + var $rememberedSubmittButton = $form.data('submitButton'); + if (typeof $rememberedSubmittButton != 'undefined') { + if ($form.has($rememberedSubmittButton)) { + $button = $rememberedSubmittButton; + } + $form.removeData('submitButton'); + } + + var req = _this.icinga.loader.submitForm($form, $autoSubmittedBy, $button); + req.addToHistory = false; + req.$redirectTarget = $modal.data('redirectTarget'); + req.done(function (data, textStatus, req) { + var title = req.getResponseHeader('X-Icinga-Title'); + if (!! title) { + _this.setTitle($modal, decodeURIComponent(title).replace(/\s::\s.*/, '')); + } + + if (req.getResponseHeader('X-Icinga-Redirect')) { + _this.hide($modal); + } + }); + + if (typeof $autoSubmittedBy === 'undefined') { + // otherwise the form is submitted several times by clicking the "Submit" button several times + $form.find('input[type=submit],button[type=submit],button:not([type])').prop('disabled', true); + } + + event.stopPropagation(); + event.preventDefault(); + return false; + }; + + /** + * Event handler for form auto submits within a modal. + * + * @param event {Event} The `change` event triggered by a form input within the modal + * @returns {boolean} + */ + Modal.prototype.onFormAutoSubmit = function(event) { + return event.data.self.onFormSubmit(event, $(event.currentTarget)); + }; + + /** + * Event handler for closing the modal. Closes it when the user clicks on the overlay. + * + * @param event {Event} The `click` event triggered by clicking on the overlay + */ + Modal.prototype.onModalLeave = function(event) { + var _this = event.data.self; + var $target = $(event.target); + + if ($target.is('#modal')) { + _this.hide($target); + } + }; + + /** + * Event handler for closing the modal. Closes it when the user clicks on the close button. + * + * @param event {Event} The `click` event triggered by clicking on the close button + */ + Modal.prototype.onModalClose = function(event) { + var _this = event.data.self; + + _this.hide($(event.currentTarget).closest('#modal')); + }; + + /** + * Event handler for closing the modal. Closes it when the user pushed ESC. + * + * @param event {Event} The `keydown` event triggered by pushing a key + */ + Modal.prototype.onKeyDown = function(event) { + var _this = event.data.self; + + if (! event.isDefaultPrevented() && event.key === 'Escape') { + let $modal = _this.$layout.children('#modal'); + if ($modal.length) { + _this.hide($modal); + } + } + }; + + /** + * Make final preparations and add the modal to the DOM + * + * @param $modal {jQuery} The modal element + */ + Modal.prototype.show = function($modal) { + $modal.addClass('active'); + }; + + /** + * Set a title for the modal + * + * @param $modal {jQuery} The modal element + * @param title {string} The title + */ + Modal.prototype.setTitle = function($modal, title) { + $modal.find('.modal-header > h1').html(title); + }; + + /** + * Focus the modal + * + * @param $modal {jQuery} The modal element + */ + Modal.prototype.focus = function($modal) { + this.icinga.ui.focusElement($modal.find('.modal-window')); + }; + + /** + * Hide the modal and remove it from the DOM + * + * @param $modal {jQuery} The modal element + */ + Modal.prototype.hide = function($modal) { + // Remove pointerEvent none style to make the button clickable again + this.modalOpener.style.pointerEvents = ''; + this.modalOpener = null; + + $modal.removeClass('active'); + // Using `setTimeout` here to let the transition finish + setTimeout(function () { + $modal.find('#modal-content').trigger('close-modal'); + $modal.remove(); + }, 200); + }; + + Icinga.Behaviors.Modal = Modal; + +})(Icinga, jQuery); diff --git a/public/js/icinga/behavior/navigation.js b/public/js/icinga/behavior/navigation.js new file mode 100644 index 0000000..df0e1e6 --- /dev/null +++ b/public/js/icinga/behavior/navigation.js @@ -0,0 +1,464 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +(function(Icinga, $) { + + "use strict"; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + var Navigation = function (icinga) { + Icinga.EventListener.call(this, icinga); + this.on('click', '#menu a', this.linkClicked, this); + this.on('click', '#menu tr[href]', this.linkClicked, this); + this.on('rendered', '#menu', this.onRendered, this); + this.on('mouseenter', '#menu .primary-nav .nav-level-1 > .nav-item', this.showFlyoutMenu, this); + this.on('mouseleave', '#menu .primary-nav', this.hideFlyoutMenu, this); + this.on('click', '#toggle-sidebar', this.toggleSidebar, this); + + this.on('click', '#menu .config-nav-item button', this.toggleConfigFlyout, this); + this.on('mouseenter', '#menu .config-menu .config-nav-item', this.showConfigFlyout, this); + this.on('mouseleave', '#menu .config-menu .config-nav-item', this.hideConfigFlyout, this); + + this.on('keydown', '#menu .config-menu .config-nav-item', this.onKeyDown, this); + + /** + * The DOM-Path of the active item + * + * @see getDomPath + * + * @type {null|Array} + */ + this.active = null; + + /** + * The menu + * + * @type {jQuery} + */ + this.$menu = null; + + /** + * Local storage + * + * @type {Icinga.Storage} + */ + this.storage = Icinga.Storage.BehaviorStorage('navigation'); + + this.storage.setBackend(window.sessionStorage); + + // Restore collapsed sidebar if necessary + if (this.storage.get('sidebar-collapsed')) { + $('#layout').addClass('sidebar-collapsed'); + } + }; + + Navigation.prototype = new Icinga.EventListener(); + + /** + * Activate menu items if their class is set to active or if the current URL matches their link + * + * @param {Object} e Event + */ + Navigation.prototype.onRendered = function(e) { + var _this = e.data.self; + + _this.$menu = $(e.target); + + if (! _this.active) { + // There is no stored menu item, therefore it is assumed that this is the first rendering + // of the navigation after the page has been opened. + + // initialise the menu selected by the backend as active. + var $active = _this.$menu.find('li.active'); + if ($active.length) { + $active.each(function() { + _this.setActiveAndSelected($(this)); + }); + } else { + // if no item is marked as active, try to select the menu from the current URL + _this.setActiveAndSelectedByUrl($('#col1').data('icingaUrl')); + } + } + + _this.refresh(); + }; + + /** + * Re-render the menu selection according to the current state + */ + Navigation.prototype.refresh = function() { + // restore selection to current active element + if (this.active) { + var $el = $(this.icinga.utils.getElementByDomPath(this.active)); + this.setActiveAndSelected($el); + + /* + * Recreate the html content of the menu item to force the browser to update the layout, or else + * the link would only be visible as active after another click or page reload in Gecko and WebKit. + * + * fixes #7897 + */ + if ($el.is('li')) { + $el.html($el.html()); + } + } + }; + + /** + * Handle a link click in the menu + * + * @param event + */ + Navigation.prototype.linkClicked = function(event) { + var $a = $(this); + var href = $a.attr('href'); + var _this = event.data.self; + var icinga = _this.icinga; + + // Check for ctrl or cmd click to open new tab and don't unfold other menus + if (event.ctrlKey || event.metaKey) { + return false; + } + + if (href.match(/#/)) { + // ...it may be a menu section without a dedicated link. + // Switch the active menu item: + _this.setActiveAndSelected($a); + } else { + _this.setActiveAndSelected($(event.target)); + } + + // update target url of the menu container to the clicked link + var $menu = $('#menu'); + var menuDataUrl = icinga.utils.parseUrl($menu.data('icinga-url')); + menuDataUrl = icinga.utils.addUrlParams(menuDataUrl.path, { url: href }); + $menu.data('icinga-url', menuDataUrl); + }; + + /** + * Activate a menu item based on the current URL + * + * Activate a menu item that is an exact match or fall back to items that match the base URL + * + * @param url {String} The url to match + */ + Navigation.prototype.setActiveAndSelectedByUrl = function(url) { + var $menu = $('#menu'); + + if (! $menu.length) { + return; + } + + // try to active the first item that has an exact URL match + this.setActiveAndSelected($menu.find('[href="' + url + '"]')); + + // the url may point to the search field, which must be activated too + if (! this.active) { + this.setActiveAndSelected($menu.find('form[action="' + this.icinga.utils.parseUrl(url).path + '"]')); + } + + // some urls may have custom filters which won't match any menu item, in that case search + // for a menu item that points to the base action without any filters + if (! this.active) { + this.setActiveAndSelected($menu.find('[href="' + this.icinga.utils.parseUrl(url).path + '"]').first()); + } + }; + + /** + * Try to select a new URL by + * + * @param url + */ + Navigation.prototype.trySetActiveAndSelectedByUrl = function(url) { + var active = this.active; + this.setActiveAndSelectedByUrl(url); + + if (! this.active && active) { + this.setActiveAndSelected($(this.icinga.utils.getElementByDomPath(active))); + } + }; + + /** + * Remove all active elements + */ + Navigation.prototype.clear = function() { + if (this.$menu) { + this.$menu.find('.active').removeClass('active'); + } + }; + + /** + * Remove all selected elements + */ + Navigation.prototype.clearSelected = function() { + if (this.$menu) { + this.$menu.find('.selected').removeClass('selected'); + } + }; + + /** + * Select all menu items in the selector as active and unfold surrounding menus when necessary + * + * @param $item {jQuery} The jQuery selector + */ + Navigation.prototype.select = function($item) { + // support selecting the url of the menu entry + var $input = $item.find('input'); + $item = $item.closest('li'); + + if ($item.length) { + // select the current item + var $selectedMenu = $item.addClass('active'); + + // unfold the containing menu + var $outerMenu = $selectedMenu.parent().closest('li'); + if ($outerMenu.length) { + $outerMenu.addClass('active'); + } + } else if ($input.length) { + $input.addClass('active'); + } + }; + + Navigation.prototype.setActiveAndSelected = function ($el) { + if ($el.length > 1) { + $el.each((key, el) => { + if (! this.active) { + this.setActiveAndSelected($(el)); + } + }); + } else if ($el.length) { + let parent = $el[0].closest('.nav-level-1 > .nav-item, .config-menu'); + + if ($el[0].offsetHeight || $el[0].offsetWidth || parent.offsetHeight || parent.offsetWidth) { + // It's either a visible menu item or a config menu item + this.setActive($el); + this.setSelected($el); + } + } + }; + + /** + * Change the active menu element + * + * @param $el {jQuery} A selector pointing to the active element + */ + Navigation.prototype.setActive = function($el) { + this.clear(); + this.select($el); + if ($el.closest('li')[0]) { + this.active = this.icinga.utils.getDomPath($el.closest('li')[0]); + } else if ($el.find('input')[0]) { + this.active = this.icinga.utils.getDomPath($el[0]); + } else { + this.active = null; + } + // TODO: push to history + }; + + Navigation.prototype.setSelected = function($el) { + this.clearSelected(); + $el = $el.closest('li'); + + if ($el.length) { + $el.addClass('selected'); + } + }; + + /** + * Reset the active element to nothing + */ + Navigation.prototype.resetActive = function() { + this.clear(); + this.active = null; + }; + + /** + * Reset the selected element to nothing + */ + Navigation.prototype.resetSelected = function() { + this.clearSelected(); + this.selected = null; + }; + + /** + * Show the fly-out menu + * + * @param e + */ + Navigation.prototype.showFlyoutMenu = function(e) { + var $layout = $('#layout'); + + if ($layout.hasClass('minimal-layout')) { + return; + } + + var $target = $(this); + var $flyout = $target.find('.nav-level-2'); + + if (! $flyout.length) { + $layout.removeClass('menu-hovered'); + $target.siblings().not($target).removeClass('hover'); + return; + } + + var delay = 300; + + if ($layout.hasClass('menu-hovered')) { + delay = 0; + } + + setTimeout(function() { + try { + if (! $target.is(':hover')) { + return; + } + } catch(e) { /* Bypass because if IE8 */ } + + $layout.addClass('menu-hovered'); + $target.siblings().not($target).removeClass('hover'); + $target.addClass('hover'); + + $flyout.css({ + bottom: 'auto', + top: $target.offset().top + $target.outerHeight() + }); + + var rect = $flyout[0].getBoundingClientRect(); + + if (rect.y + rect.height > window.innerHeight) { + $flyout.css({ + bottom: 0, + top: 'auto' + }); + } + }, delay); + }; + + /** + * Hide the fly-out menu + * + * @param e + */ + Navigation.prototype.hideFlyoutMenu = function(e) { + var $layout = $('#layout'); + var $nav = $(e.currentTarget); + var $hovered = $nav.find('.nav-level-1 > .nav-item.hover'); + + if (! $hovered.length) { + $layout.removeClass('menu-hovered'); + + return; + } + + setTimeout(function() { + try { + if ($hovered.is(':hover') || $nav.is(':hover')) { + return; + } + } catch(e) { /* Bypass because if IE8 */ }; + $hovered.removeClass('hover'); + $layout.removeClass('menu-hovered'); + }, 600); + }; + + /** + * Collapse or expand sidebar + * + * @param {Object} e Event + */ + Navigation.prototype.toggleSidebar = function(e) { + var _this = e.data.self; + var $layout = $('#layout'); + $layout.toggleClass('sidebar-collapsed'); + _this.storage.set('sidebar-collapsed', $layout.is('.sidebar-collapsed')); + $(window).trigger('resize'); + }; + + /** + * Toggle config flyout visibility + * + * @param {Object} e Event + */ + Navigation.prototype.toggleConfigFlyout = function(e) { + var _this = e.data.self; + if ($('#layout').is('.config-flyout-open')) { + _this.hideConfigFlyout(e); + } else { + _this.showConfigFlyout(e); + } + } + + /** + * Hide config flyout + * + * @param {Object} e Event + */ + Navigation.prototype.hideConfigFlyout = function(e) { + $('#layout').removeClass('config-flyout-open'); + if (e.target) { + delete $(e.target).closest('.container')[0].dataset.suspendAutorefresh; + } + } + + /** + * Show config flyout + * + * @param {Object} e Event + */ + Navigation.prototype.showConfigFlyout = function(e) { + $('#layout').addClass('config-flyout-open'); + $(e.target).closest('.container')[0].dataset.suspendAutorefresh = ''; + } + + /** + * Hide, config flyout when "Enter" key is pressed to follow `.flyout` nav item link + * + * @param {Object} e Event + */ + Navigation.prototype.onKeyDown = function(e) { + var _this = e.data.self; + + if (e.key == 'Enter' && $(document.activeElement).is('.flyout a')) { + _this.hideConfigFlyout(e); + } + } + + /** + * Called when the history changes + * + * @param url The url of the new state + * @param data The active menu item of the new state + */ + Navigation.prototype.onPopState = function (url, data) { + // 1. get selection data and set active menu + if (data) { + var active = this.icinga.utils.getElementByDomPath(data); + if (!active) { + this.logger.fail( + 'Could not restore active menu from history, path in DOM not found.', + data, + url + ); + return; + } + this.setActiveAndSelected($(active)) + } else { + this.resetActive(); + this.resetSelected(); + } + }; + + /** + * Called when the current state gets pushed onto the history, can return a value + * to be preserved as the current state + * + * @returns {null|Array} The currently active menu item + */ + Navigation.prototype.onPushState = function () { + return this.active; + }; + + Icinga.Behaviors.Navigation = Navigation; + +})(Icinga, jQuery); diff --git a/public/js/icinga/behavior/selectable.js b/public/js/icinga/behavior/selectable.js new file mode 100644 index 0000000..3f32840 --- /dev/null +++ b/public/js/icinga/behavior/selectable.js @@ -0,0 +1,49 @@ +/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +;(function(Icinga, $) { + 'use strict'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + /** + * Select all contents from the target of the given event + * + * @param {object} e Event + */ + function onSelect(e) { + var b = document.body, + r; + if (b.createTextRange) { + r = b.createTextRange(); + r.moveToElementText(e.target); + r.select(); + } else if (window.getSelection) { + var s = window.getSelection(); + r = document.createRange(); + r.selectNodeContents(e.target); + s.removeAllRanges(); + s.addRange(r); + } + } + + /** + * Behavior for text that is selectable via double click + * + * @param {Icinga} icinga + * + * @constructor + */ + var Selectable = function(icinga) { + Icinga.EventListener.call(this, icinga); + this.on('rendered', this.onRendered, this); + }; + + $.extend(Selectable.prototype, new Icinga.EventListener(), { + onRendered: function(e) { + $(e.target).find('.selectable').on('dblclick', onSelect); + } + }); + + Icinga.Behaviors.Selectable = Selectable; + +})(Icinga, jQuery); diff --git a/public/js/icinga/eventlistener.js b/public/js/icinga/eventlistener.js new file mode 100644 index 0000000..678e775 --- /dev/null +++ b/public/js/icinga/eventlistener.js @@ -0,0 +1,78 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * EventListener contains event handlers and can bind / and unbind them from + * event emitting objects + */ +(function(Icinga, $) { + + "use strict"; + + var EventListener = function (icinga) { + this.icinga = icinga; + this.handlers = []; + }; + + /** + * Add an handler to this EventLister + * + * @param evt {String} The name of the triggering event + * @param cond {String} The filter condition + * @param fn {Function} The event handler to execute + * @param scope {Object} The optional 'this' of the called function + */ + EventListener.prototype.on = function(evt, cond, fn, scope) { + if (typeof cond === 'function') { + scope = fn; + fn = cond; + cond = 'body'; + } + this.icinga.logger.debug('on: ' + evt + '(' + cond + ')'); + this.handlers.push({ evt: evt, cond: cond, fn: fn, scope: scope }); + }; + + /** + * Bind all listeners to the given event emitter + * + * All event handlers will be executed when the associated event is + * triggered on the given Emitter. + * + * @param emitter {String} An event emitter that supports the function + * 'on' to register listeners + */ + EventListener.prototype.bind = function (emitter) { + var _this = this; + + if (typeof emitter.jquery === 'undefined') { + emitter = $(emitter); + } + + $.each(this.handlers, function(i, handler) { + _this.icinga.logger.debug('bind: ' + handler.evt + '(' + handler.cond + ')'); + emitter.on( + handler.evt, handler.cond, + { + self: handler.scope || emitter, + icinga: _this.icinga + }, handler.fn + ); + }); + }; + + /** + * Unbind all listeners from the given event emitter + * + * @param emitter {String} An event emitter that supports the function + * 'off' to un-register listeners. + */ + EventListener.prototype.unbind = function (emitter) { + var _this = this; + $.each(this.handlers, function(i, handler) { + _this.icinga.logger.debug('unbind: ' + handler.evt + '(' + handler.cond + ')'); + emitter.off(handler.evt, handler.cond, handler.fn); + }); + }; + + Icinga.EventListener = EventListener; + +}) (Icinga, jQuery); diff --git a/public/js/icinga/events.js b/public/js/icinga/events.js new file mode 100644 index 0000000..186a41c --- /dev/null +++ b/public/js/icinga/events.js @@ -0,0 +1,415 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.Events + * + * Event handlers + */ +(function (Icinga, $) { + + 'use strict'; + + Icinga.Events = function (icinga) { + this.icinga = icinga; + + this.searchValue = ''; + this.searchTimer = null; + }; + + Icinga.Events.prototype = { + + /** + * Icinga will call our initialize() function once it's ready + */ + initialize: function () { + this.applyGlobalDefaults(); + }, + + /** + * Global default event handlers + */ + applyGlobalDefaults: function () { + $(document).on('visibilitychange', { self: this }, this.onVisibilityChange); + + $.each(this.icinga.behaviors, function (name, behavior) { + behavior.bind($(document)); + }); + + // Initialize module javascript (Applies only to module.js code) + this.icinga.ensureSubModules(document); + + // We catch resize events + $(window).on('resize', { self: this.icinga.ui }, this.icinga.ui.onWindowResize); + + // Trigger 'rendered' event also on page loads + $(document).on('icinga-init', { self: this }, this.onInit); + + // Destroy Icinga, clean up and interrupt pending requests on unload + $( window ).on('unload', { self: this }, this.onUnload); + $( window ).on('beforeunload', { self: this }, this.onUnload); + + // Remove notifications on click + $(document).on('click', '#notifications li', function () { $(this).remove(); }); + + // We want to catch each link click + $(document).on('click', 'a', { self: this }, this.linkClicked); + $(document).on('click', 'tr[href]', { self: this }, this.linkClicked); + + $(document).on('click', 'input[type="submit"], button[type="submit"]', this.rememberSubmitButton); + // We catch all form submit events + $(document).on('submit', 'form', { self: this }, this.submitForm); + + // We support an 'autosubmit' class on dropdown form elements + $(document).on('change', 'form select.autosubmit', { self: this }, this.autoSubmitForm); + $(document).on('change', 'form input.autosubmit', { self: this }, this.autoSubmitForm); + + // Automatically check a radio button once a specific input is focused + $(document).on('focus', 'form select[data-related-radiobtn]', { self: this }, this.autoCheckRadioButton); + $(document).on('focus', 'form input[data-related-radiobtn]', { self: this }, this.autoCheckRadioButton); + + $(document).on('rendered', '#menu', { self: this }, this.onRenderedMenu); + $(document).on('keyup', '#search', { self: this }, this.autoSubmitSearch); + + $(document).on('click', '.tree .handle', { self: this }, this.treeNodeToggle); + + $(document).on('click', '#search + .search-reset', this.clearSearch); + + $(document).on('rendered', '.container', { self: this }, this.loadDashlets); + + // TBD: a global autocompletion handler + // $(document).on('keyup', 'form.auto input', this.formChangeDelayed); + // $(document).on('change', 'form.auto input', this.formChanged); + // $(document).on('change', 'form.auto select', this.submitForm); + }, + + treeNodeToggle: function () { + var $parent = $(this).closest('li'); + if ($parent.hasClass('collapsed')) { + $('li', $parent).addClass('collapsed'); + $parent.removeClass('collapsed'); + } else { + $parent.addClass('collapsed'); + } + }, + + onInit: function (event) { + $('body').removeClass('loading'); + + // Trigger the initial `rendered` events + $('.container').trigger('rendered'); + + // Additionally trigger a `rendered` event on the layout, some behaviors may + // want to differentiate whether a container or the entire layout is rendered + $('#layout').trigger('rendered'); + }, + + onUnload: function (event) { + var icinga = event.data.self.icinga; + icinga.logger.info('Unloading Icinga'); + icinga.destroy(); + }, + + onVisibilityChange: function (event) { + var icinga = event.data.self.icinga; + + if (document.visibilityState === undefined || document.visibilityState === 'visible') { + icinga.loader.autorefreshSuspended = false; + icinga.logger.debug('Page visible, enabling auto-refresh'); + } else { + icinga.loader.autorefreshSuspended = true; + icinga.logger.debug('Page invisible, disabling auto-refresh'); + } + }, + + autoCheckRadioButton: function (event) { + var $input = $(event.currentTarget); + var $radio = $('#' + $input.attr('data-related-radiobtn')); + if ($radio.length) { + $radio.prop('checked', true); + } + return true; + }, + + onRenderedMenu: function(event) { + var _this = event.data.self; + var $target = $(event.target); + + var $searchField = $target.find('input.search'); + // Remember initial search field value if any + if ($searchField.length && $searchField.val().length) { + _this.searchValue = $searchField.val(); + } + }, + + autoSubmitSearch: function(event) { + var _this = event.data.self; + var $searchField = $(event.target); + + if ($searchField.val() === _this.searchValue) { + return; + } + _this.searchValue = $searchField.val(); + + if (_this.searchTimer !== null) { + clearTimeout(_this.searchTimer); + _this.searchTimer = null; + } + var _event = $.extend({}, event); // event seems gc'd once the timeout is over + _this.searchTimer = setTimeout(function () { + _this.submitForm(_event, $searchField); + _this.searchTimer = null; + }, 500); + }, + + loadDashlets: function(event) { + var _this = event.data.self; + var $target = $(event.target); + + if ($target.children('.dashboard').length) { + $target.find('.dashboard > .container').each(function () { + var $dashlet = $(this); + var url = $dashlet.data('icingaUrl'); + if (typeof url !== 'undefined') { + _this.icinga.loader.loadUrl(url, $dashlet).autorefresh = true; + } + }); + } + }, + + rememberSubmitButton: function(e) { + var $button = $(this); + var $form = $button.closest('form'); + $form.data('submitButton', $button); + }, + + autoSubmitForm: function (event) { + return event.data.self.submitForm(event, $(event.currentTarget)); + }, + + /** + * + */ + submitForm: function (event, $autoSubmittedBy) { + var _this = event.data.self; + + // .closest is not required unless subelements to trigger this + var $form = $(event.currentTarget).closest('form'); + + if ($form.closest('[data-no-icinga-ajax]').length > 0) { + return true; + } + + var $button; + var $rememberedSubmittButton = $form.data('submitButton'); + if (typeof $rememberedSubmittButton != 'undefined') { + if ($form.has($rememberedSubmittButton)) { + $button = $rememberedSubmittButton; + } + $form.removeData('submitButton'); + } + + if (typeof $button === 'undefined') { + var $el; + + if (typeof event.originalEvent !== 'undefined' + && typeof event.originalEvent.explicitOriginalTarget === 'object') { // Firefox + $el = $(event.originalEvent.explicitOriginalTarget); + _this.icinga.logger.debug('events/submitForm: Button is event.originalEvent.explicitOriginalTarget'); + } else { + $el = $(event.currentTarget); + _this.icinga.logger.debug('events/submitForm: Button is event.currentTarget'); + } + + if ($el && ($el.is('input[type=submit]') || $el.is('button[type=submit]'))) { + $button = $el; + } else { + _this.icinga.logger.debug( + 'events/submitForm: Can not determine submit button, using the first one in form' + ); + } + } + + if (! $autoSubmittedBy && event.detail && event.detail.submittedBy) { + $autoSubmittedBy = $(event.detail.submittedBy); + } + + _this.icinga.loader.submitForm($form, $autoSubmittedBy, $button); + + event.stopPropagation(); + event.preventDefault(); + return false; + }, + + handleExternalTarget: function($node) { + var linkTarget = $node.attr('target'); + + // TODO: Let remote links pass through. Right now they only work + // combined with target="_blank" or target="_self" + // window.open is used as return true; didn't work reliable + if (linkTarget === '_blank' || linkTarget === '_self') { + window.open($node.attr('href'), linkTarget); + return true; + } + return false; + }, + + /** + * Someone clicked a link or tr[href] + */ + linkClicked: function (event) { + var _this = event.data.self; + var icinga = _this.icinga; + var $a = $(this); + var $eventTarget = $(event.target); + var href = $a.attr('href'); + var linkTarget = $a.attr('target'); + var $target; + var formerUrl; + + if (! href) { + return; + } + + if (href.match(/^(?:(?:mailto|javascript|data):|[a-z]+:\/\/)/)) { + event.stopPropagation(); + return true; + } + + if ($a.closest('[data-no-icinga-ajax]').length > 0) { + return true; + } + + // Check for ctrl or cmd click to open new tab unless clicking on a multiselect row + if ((event.ctrlKey || event.metaKey) && href !== '#' && $a.is('a')) { + window.open(href, linkTarget); + return false; + } + + // Special checks for link clicks in action tables + if (! $a.is('tr[href]') && $a.closest('table.action').length > 0) { + + // ignore clicks to ANY link with special key pressed + if ($a.closest('table.multiselect').length > 0 && (event.ctrlKey || event.metaKey || event.shiftKey)) { + return true; + } + + // ignore inner links matching the row URL + if ($a.attr('href') === $a.closest('tr[href]').attr('href')) { + return true; + } + } + + // window.open is used as return true; didn't work reliable + if (linkTarget === '_blank' || linkTarget === '_self') { + window.open(href, linkTarget); + return false; + } + + if (! $eventTarget.is($a)) { + if ($eventTarget.is('input') || $eventTarget.is('button')) { + // Ignore form elements in action rows + return; + } else { + var $button = $('input[type=submit]:focus').add('button[type=submit]:focus'); + if ($button.length > 0 && $.contains($button[0], $eventTarget[0])) { + // Ignore any descendant of form elements + return; + } + } + } + + // ignore multiselect table row clicks + if ($a.is('tr') && $a.closest('table.multiselect').length > 0) { + return; + } + + // Handle all other links as XHR requests + event.stopPropagation(); + event.preventDefault(); + + // This is an anchor only + if (href.substr(0, 1) === '#' && href.length > 1 + && href.substr(1, 1) !== '!' + ) { + icinga.ui.focusElement(href.substr(1), $a.closest('.container')); + return; + } + + // activate spinner indicator + if ($a.hasClass('spinner')) { + $a.addClass('active'); + } + + // If link has hash tag... + if (href.match(/#/)) { + if (href === '#') { + if ($a.hasClass('close-container-control')) { + if (! icinga.ui.isOneColLayout()) { + var $cont = $a.closest('.container').first(); + if ($cont.attr('id') === 'col1') { + icinga.ui.moveToLeft(); + icinga.ui.layout1col(); + } else { + icinga.ui.layout1col(); + } + icinga.history.pushCurrentState(); + } + } + return false; + } + $target = icinga.loader.getLinkTargetFor($a); + + formerUrl = $target.data('icingaUrl'); + if (typeof formerUrl !== 'undefined' && formerUrl.split(/#/)[0] === href.split(/#/)[0]) { + icinga.ui.focusElement(href.split(/#/)[1], $target); + $target.data('icingaUrl', href); + if (formerUrl !== href) { + icinga.history.pushCurrentState(); + } + return false; + } + } else { + $target = icinga.loader.getLinkTargetFor($a); + } + + // Load link URL + icinga.loader.loadUrl(href, $target); + + if ($a.closest('#menu').length > 0) { + // Menu links should remove all but the first layout column + icinga.ui.layout1col(); + } + + return false; + }, + + clearSearch: function (event) { + $(event.target).parent().find('#search').attr('value', ''); + }, + + unbindGlobalHandlers: function () { + $.each(this.icinga.behaviors, function (name, behavior) { + behavior.unbind($(document)); + }); + $(window).off('resize', this.onWindowResize); + $(window).off('load', this.onLoad); + $(window).off('unload', this.onUnload); + $(window).off('beforeunload', this.onUnload); + $(document).off('scroll', '.container', this.onContainerScroll); + $(document).off('click', 'a', this.linkClicked); + $(document).off('submit', 'form', this.submitForm); + $(document).off('change', 'form select.autosubmit', this.submitForm); + $(document).off('change', 'form input.autosubmit', this.submitForm); + $(document).off('focus', 'form select[data-related-radiobtn]', this.autoCheckRadioButton); + $(document).off('focus', 'form input[data-related-radiobtn]', this.autoCheckRadioButton); + $(document).off('visibilitychange', this.onVisibilityChange); + }, + + destroy: function() { + // This is gonna be hard, clean up the mess + this.unbindGlobalHandlers(); + this.icinga = null; + } + }; + +}(Icinga, jQuery)); diff --git a/public/js/icinga/history.js b/public/js/icinga/history.js new file mode 100644 index 0000000..150be7c --- /dev/null +++ b/public/js/icinga/history.js @@ -0,0 +1,338 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.History + * + * This is where we care about the browser History API + */ +(function (Icinga, $) { + + 'use strict'; + + Icinga.History = function (icinga) { + + /** + * YES, we need Icinga + */ + this.icinga = icinga; + + /** + * Our base url + */ + this.baseUrl = icinga.config.baseUrl; + + /** + * Initial URL at load time + */ + this.initialUrl = location.href; + + /** + * Whether the History API is enabled + */ + this.enabled = false; + }; + + Icinga.History.prototype = { + + /** + * Icinga will call our initialize() function once it's ready + */ + initialize: function () { + + // History API will not be enabled without browser support, no fallback + if ('undefined' !== typeof window.history && + typeof window.history.pushState === 'function' + ) { + this.enabled = true; + this.icinga.logger.debug('History API enabled'); + this.applyLocationBar(true); + $(window).on('popstate', { self: this }, this.onHistoryChange); + } + + }, + + /** + * Get the current state (url and title) as object + * + * @returns {object} + */ + getCurrentState: function () { + if (! this.enabled) { + return null; + } + + var title = null; + var url = null; + + // We only store URLs of containers sitting directly under #main: + $('#main > .container').each(function (idx, container) { + var $container = $(container), + cUrl = $container.data('icingaUrl'), + cTitle = $container.data('icingaTitle'); + + // TODO: I'd prefer to have the rightmost URL first + if ('undefined' !== typeof cUrl) { + // TODO: solve this on server side cUrl = icinga.utils.removeUrlParams(cUrl, blacklist); + if (! url) { + url = cUrl; + } else { + url = url + '#!' + cUrl; + } + } + + if (typeof cTitle !== 'undefined') { + title = cTitle; // Only uses the rightmost title + } + }); + + return { + title: title, + url: url, + }; + }, + + /** + * Detect active URLs and push combined URL to history + * + * TODO: How should we handle POST requests? e.g. search VS login + */ + pushCurrentState: function () { + // No history API, no action + if (! this.enabled) { + return; + } + + var state = this.getCurrentState(); + + // Did we find any URL? Then push it! + if (state.url) { + this.icinga.logger.debug('Pushing current state to history'); + this.push(state.url); + } + if (state.title) { + this.icinga.ui.setTitle(state.title); + } + }, + + /** + * Replace the current history entry with the current state + */ + replaceCurrentState: function () { + if (! this.enabled) { + return; + } + + var state = this.getCurrentState(); + + if (state.url) { + this.icinga.logger.debug('Replacing current history state'); + this.lastPushUrl = state.url; + window.history.replaceState( + this.getBehaviorState(), + null, + state.url + ); + } + }, + + /** + * Push the given url as the new history state, unless the history is disabled + * + * @param {string} url The full url path, including anchor + */ + pushUrl: function (url) { + // No history API, no action + if (!this.enabled) { + return; + } + this.push(url); + }, + + /** + * Execute the history state, preserving the current state of behaviors + * + * Used internally by the history and should not be called externally, instead use {@link pushUrl}. + * + * @param {string} url + */ + push: function (url) { + url = url.replace(/[\?&]?_(render|reload)=[a-z0-9]+/g, ''); + if (this.lastPushUrl === url) { + this.icinga.logger.debug( + 'Ignoring history state push for url ' + url + ' as it\' currently on top of the stack' + ); + return; + } + this.lastPushUrl = url; + window.history.pushState( + this.getBehaviorState(), + null, + url + ); + }, + + /** + * Fetch the current state of all JS behaviors that need history support + * + * @return {Object} A key-value map, mapping behavior names to state + */ + getBehaviorState: function () { + var data = {}; + $.each(this.icinga.behaviors, function (i, behavior) { + if (behavior.onPushState instanceof Function) { + data[i] = behavior.onPushState(); + } + }); + return data; + }, + + /** + * Event handler for pop events + * + * TODO: Fix active selection, multiple cols + */ + onHistoryChange: function (event) { + + var _this = event.data.self, + icinga = _this.icinga; + + icinga.logger.debug('Got a history change'); + + // We might find browsers showing strange behaviour, this log could help + if (event.originalEvent.state === null) { + icinga.logger.debug('No more history steps available'); + } else { + icinga.logger.debug('History state', event.originalEvent.state); + } + + // keep the last pushed url in sync with history changes + _this.lastPushUrl = location.href; + + _this.applyLocationBar(); + + // notify behaviors of the state change + $.each(this.icinga.behaviors, function (i, behavior) { + if (behavior.onPopState instanceof Function && history.state) { + behavior.onPopState(location.href, history.state[i]); + } + }); + }, + + /** + * Update the application containers to match the current url + * + * Read the pane url from the current URL and load the corresponding panes into containers to + * match the current history state. + * + * @param {Boolean} onload Set to true when the main pane should not be updated, defaults to false + */ + applyLocationBar: function (onload = false) { + let col2State = this.getCol2State(); + + if (onload && document.querySelector('#layout > #login')) { + // The user landed on the login + let redirectInput = document.querySelector('#login form input[name=redirect]'); + redirectInput.value = redirectInput.value + col2State; + return; + } + + let col1 = document.getElementById('col1'), + col2 = document.getElementById('col2'), + col1Url = document.location.pathname + document.location.search; + + let col2Url; + if (col2State && col2State.match(/^#!/)) { + col2Url = col2State.split(/#!/)[1]; + } + + // This uses jQuery only because of its internal data attribute cache -.- + let currentCol1Url = $(col1).data('icingaUrl'), + currentCol2Url = $(col2).data('icingaUrl'); + + let loadCol1 = ! onload, + loadCol2 = !! col2Url; + if (currentCol2Url === col1Url) { + // User navigated forward + this.icinga.ui.moveToLeft(); + loadCol1 = false; + } else if (currentCol1Url === col2Url) { + // User navigated back + this.icinga.ui.moveToRight(); + loadCol2 = false; + } + + if (loadCol1 && currentCol1Url !== col1Url) { + let anchor = this.getPaneAnchor(0); + if (anchor) { + col1Url += '#' + anchor; + } + + this.icinga.loader.loadUrl(col1Url, $(col1)).addToHistory = false; + } + + if (loadCol2 && currentCol2Url !== col2Url) { + let col2Req = this.icinga.loader.loadUrl(col2Url, $(col2)); + col2Req.addToHistory = false; + col2Req.scripted = onload; + + this.icinga.ui.layout2col(); + } else if (! loadCol2 && ! col2Url) { + this.icinga.ui.layout1col(); + } + }, + + /** + * Get the state of the selected pane + * + * @param col {int} The column index 0 or 1 + * + * @returns {String} The string representing the state + */ + getPaneAnchor: function (col) { + if (col !== 1 && col !== 0) { + throw 'Trying to get anchor for non-existing column: ' + col; + } + var panes = document.location.toString().split('#!')[col]; + return panes && panes.split('#')[1] || ''; + }, + + /** + * Get the side pane state after (and including) the #! + * + * @returns {string} The pane url + */ + getCol2State: function () { + var hash = document.location.hash; + if (hash) { + if (hash.match(/^#[^!]/)) { + var hashs = hash.split('#'); + hashs.shift(); + hashs.shift(); + hash = '#' + hashs.join('#'); + } + } + return hash || ''; + }, + + /** + * Return the main pane state fragment + * + * @returns {string} The main url including anchors, without #! + */ + getCol1State: function () { + var anchor = this.getPaneAnchor(0); + var hash = window.location.pathname + window.location.search + + (anchor.length ? ('#' + anchor) : ''); + return hash || ''; + }, + + /** + * Cleanup + */ + destroy: function () { + $(window).off('popstate', this.onHistoryChange); + this.icinga = null; + } + }; + +}(Icinga, jQuery)); diff --git a/public/js/icinga/loader.js b/public/js/icinga/loader.js new file mode 100644 index 0000000..2ad05d2 --- /dev/null +++ b/public/js/icinga/loader.js @@ -0,0 +1,1323 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.Loader + * + * This is where we take care of XHR requests, responses and failures. + */ +(function(Icinga, $) { + + 'use strict'; + + Icinga.Loader = function (icinga) { + + /** + * YES, we need Icinga + */ + this.icinga = icinga; + + /** + * Our base url + */ + this.baseUrl = icinga.config.baseUrl; + + this.failureNotice = null; + + /** + * Pending requests + */ + this.requests = {}; + + this.iconCache = {}; + + /** + * Whether auto-refresh is enabled + */ + this.autorefreshEnabled = true; + + /** + * Whether auto-refresh is suspended due to visibility of page + */ + this.autorefreshSuspended = false; + }; + + Icinga.Loader.prototype = { + + initialize: function () { + this.icinga.timer.register(this.autorefresh, this, 500); + }, + + submitForm: function ($form, $autoSubmittedBy, $button) { + var icinga = this.icinga; + var url = $form.attr('action'); + var method = $form.attr('method'); + var encoding = $form.attr('enctype'); + var progressTimer; + var $target; + var data; + + if (typeof method === 'undefined') { + method = 'POST'; + } else { + method = method.toUpperCase(); + } + + if (typeof encoding === 'undefined') { + encoding = 'application/x-www-form-urlencoded'; + } + + if (typeof $autoSubmittedBy === 'undefined') { + $autoSubmittedBy = false; + } + + if (typeof $button === 'undefined') { + $button = $('input[type=submit]:focus', $form).add('button[type=submit]:focus', $form); + } + + if ($button.length === 0) { + $button = $('input[type=submit]', $form).add('button[type=submit]', $form).first(); + } + + if ($button.length) { + // Activate spinner + if ($button.hasClass('spinner')) { + $button.addClass('active'); + } + + $target = this.getLinkTargetFor($button); + } else { + $target = this.getLinkTargetFor($form); + } + + if (! url) { + // Use the URL of the target container if the form's action is not set + url = $target.closest('.container').data('icinga-url'); + } + + icinga.logger.debug('Submitting form: ' + method + ' ' + url, method); + + if (method === 'GET') { + var dataObj = $form.serializeObject(); + + if (! $autoSubmittedBy) { + if ($button.length && $button.attr('name') !== 'undefined') { + dataObj[$button.attr('name')] = $button.attr('value'); + } + } + + url = icinga.utils.addUrlParams(url, dataObj); + } else { + if (encoding === 'multipart/form-data') { + data = new window.FormData($form[0]); + } else { + data = $form.serializeArray(); + } + + if (! $autoSubmittedBy) { + if ($button.length && $button.attr('name') !== 'undefined') { + if (encoding === 'multipart/form-data') { + data.append($button.attr('name'), $button.attr('value')); + } else { + data.push({ + name: $button.attr('name'), + value: $button.attr('value') + }); + } + } + } + } + + // Disable all form controls to prevent resubmission except for our search input + // Note that disabled form inputs will not be enabled via JavaScript again + if (! $autoSubmittedBy + && ! $form.is('[role="search"]') + && $target.attr('id') === $form.closest('.container').attr('id') + ) { + $form.find('input[type=submit],button[type=submit],button:not([type])').prop('disabled', true); + } + + // Show a spinner depending on how the form is being submitted + if ($autoSubmittedBy && $autoSubmittedBy.siblings('.spinner').length) { + $autoSubmittedBy.siblings('.spinner').first().addClass('active'); + } else if ($button.length && $button.is('button') && $button.hasClass('animated')) { + $button.addClass('active'); + } else if ($button.length && $button.attr('data-progress-label')) { + var isInput = $button.is('input'); + if (isInput) { + $button.prop('value', $button.attr('data-progress-label') + '...'); + } else { + $button.html($button.attr('data-progress-label') + '...'); + } + + // Use a fixed width to prevent the button from wobbling + $button.css('width', $button.css('width')); + + progressTimer = icinga.timer.register(function () { + var label = isInput ? $button.prop('value') : $button.html(); + var dots = label.substr(-3); + + // Using empty spaces here to prevent centered labels from wobbling + if (dots === '...') { + label = label.slice(0, -2) + ' '; + } else if (dots === '.. ') { + label = label.slice(0, -1) + '.'; + } else if (dots === '. ') { + label = label.slice(0, -2) + '. '; + } + + if (isInput) { + $button.prop('value', label); + } else { + $button.html(label); + } + }, null, 100); + } else if ($button.length && $button.next().hasClass('spinner')) { + $('i', $button.next()).addClass('active'); + } else if ($form.attr('data-progress-element')) { + var $progressElement = $('#' + $form.attr('data-progress-element')); + if ($progressElement.length) { + if ($progressElement.hasClass('spinner')) { + $('i', $progressElement).addClass('active'); + } else { + $('i.spinner', $progressElement).addClass('active'); + } + } + } + + var extraHeaders = {}; + if ($autoSubmittedBy && ($autoSubmittedBy.attr('name') || $autoSubmittedBy.attr('id'))) { + extraHeaders['X-Icinga-AutoSubmittedBy'] = $autoSubmittedBy.attr('name') || $autoSubmittedBy.attr('id'); + } + + var req = this.loadUrl(url, $target, data, method, undefined, undefined, undefined, extraHeaders); + req.forceFocus = $autoSubmittedBy ? $autoSubmittedBy : $button.length ? $button : null; + req.autosubmit = !! $autoSubmittedBy; + req.addToHistory = method === 'GET'; + req.progressTimer = progressTimer; + + if ($autoSubmittedBy) { + if ($autoSubmittedBy.closest('.controls').length) { + $('.content', req.$target).addClass('impact'); + } else { + req.$target.addClass('impact'); + } + } + + return req; + }, + + /** + * Load the given URL to the given target + * + * @param {string} url URL to be loaded + * @param {object} target Target jQuery element + * @param {object} data Optional parameters, usually for POST requests + * @param {string} method HTTP method, default is 'GET' + * @param {string} action How to handle the response ('replace' or 'append'), default is 'replace' + * @param {boolean} autorefresh Whether the cause is a autorefresh or not + * @param {object} progressTimer A timer to be stopped when the request is done + * @param {object} extraHeaders Extra header entries + */ + loadUrl: function (url, $target, data, method, action, autorefresh, progressTimer, extraHeaders) { + var id = null; + + // Default method is GET + if ('undefined' === typeof method) { + method = 'GET'; + } + if ('undefined' === typeof action) { + action = 'replace'; + } + if ('undefined' === typeof autorefresh) { + autorefresh = false; + } + + this.icinga.logger.debug('Loading ', url, ' to ', $target); + + // We should do better and ignore requests without target and/or id + if (typeof $target !== 'undefined' && $target.attr('id')) { + id = $target.attr('id'); + } + + // If we have a pending request for the same target... + if (typeof this.requests[id] !== 'undefined') { + // ... ignore the new request if it is already pending with the same URL. Only abort GETs, as those + // are the only methods that are guaranteed to return the same value + if (this.requests[id].url === url && method === 'GET') { + if (autorefresh) { + return false; + } + + this.icinga.logger.debug('Request to ', url, ' is already running for ', $target); + return this.requests[id]; + } + + // ...or abort the former request otherwise + this.icinga.logger.debug( + 'Aborting pending request loading ', + url, + ' to ', + $target + ); + + this.requests[id].abort(); + } + + // Not sure whether we need this Accept-header + var headers = { 'X-Icinga-Accept': 'text/html' }; + + if (!! id) { + headers['X-Icinga-Container'] = id; + } + + if (autorefresh) { + headers['X-Icinga-Autorefresh'] = '1'; + } + + // Ask for a new window id in case we don't already have one + if (this.icinga.ui.hasWindowId()) { + var windowId = this.icinga.ui.getWindowId(); + var containerId = this.icinga.ui.getUniqueContainerId($target); + if (containerId) { + windowId = windowId + '_' + containerId; + } + headers['X-Icinga-WindowId'] = windowId; + } else { + headers['X-Icinga-WindowId'] = 'undefined'; + } + + if (typeof extraHeaders !== 'undefined') { + headers = $.extend(headers, extraHeaders); + } + + // This is jQuery's default content type + var contentType = 'application/x-www-form-urlencoded; charset=UTF-8'; + + var isFormData = typeof window.FormData !== 'undefined' && data instanceof window.FormData; + if (isFormData) { + // Setting false is mandatory as the form's data + // won't be recognized by the server otherwise + contentType = false; + } + + var _this = this; + var req = $.ajax({ + type : method, + url : url, + data : data, + headers: headers, + context: _this, + contentType: contentType, + processData: ! isFormData + }); + + req.$target = $target; + req.$redirectTarget = $target; + req.url = url; + req.done(this.onResponse); + req.fail(this.onFailure); + req.always(this.onComplete); + req.autorefresh = autorefresh; + req.autosubmit = false; + req.scripted = false; + req.method = method; + req.action = action; + req.addToHistory = true; + req.progressTimer = progressTimer; + + if (url.match(/#/)) { + req.forceFocus = url.split(/#/)[1]; + } + + if (id) { + this.requests[id] = req; + } + if (! autorefresh) { + setTimeout(function () { + // The column may have not been shown before. To make the transition + // delay working we have to wait for the column getting rendered + if (! req.autosubmit && req.state() === 'pending') { + req.$target.addClass('impact'); + } + }, 0); + } + this.icinga.ui.refreshDebug(); + return req; + }, + + /** + * Create an URL relative to the Icinga base Url, still unused + * + * @param {string} url Relative url + */ + url: function (url) { + if (typeof url === 'undefined') { + return this.baseUrl; + } + return this.baseUrl + url; + }, + + stopPendingRequestsFor: function ($el) { + var id; + if (typeof $el === 'undefined' || ! (id = $el.attr('id'))) { + return; + } + + if (typeof this.requests[id] !== 'undefined') { + this.requests[id].abort(); + } + }, + + filterAutorefreshingContainers: function () { + return $(this).data('icingaRefresh') > 0 && ! $(this).is('[data-suspend-autorefresh]'); + }, + + autorefresh: function () { + var _this = this; + + $('.container').filter(this.filterAutorefreshingContainers).each(function (idx, el) { + var $el = $(el); + var id = $el.attr('id'); + + // Always request application-state + if (id !== 'application-state' && (! _this.autorefreshEnabled || _this.autorefreshSuspended)) { + // Continue + return true; + } + + if (typeof _this.requests[id] !== 'undefined') { + _this.icinga.logger.debug('No refresh, request pending for ', id); + return; + } + + var interval = $el.data('icingaRefresh'); + var lastUpdate = $el.data('lastUpdate'); + + if (typeof interval === 'undefined' || ! interval) { + _this.icinga.logger.info('No interval, setting default', id); + interval = 10; + } + + if (typeof lastUpdate === 'undefined' || ! lastUpdate) { + _this.icinga.logger.info('No lastUpdate, setting one', id); + $el.data('lastUpdate',(new Date()).getTime()); + return; + } + interval = interval * 1000; + + // TODO: + if ((lastUpdate + interval) > (new Date()).getTime()) { + // self.icinga.logger.info( + // 'Skipping refresh', + // id, + // lastUpdate, + // interval, + // (new Date()).getTime() + // ); + return; + } + + if (_this.loadUrl($el.data('icingaUrl'), $el, undefined, undefined, undefined, true) === false) { + _this.icinga.logger.debug( + 'NOT autorefreshing ' + id + ', even if ' + interval + ' ms passed. Request pending?' + ); + } else { + _this.icinga.logger.debug( + 'Autorefreshing ' + id + ' ' + interval + ' ms passed' + ); + } + el = null; + }); + }, + + /** + * Disable the autorefresh mechanism + */ + disableAutorefresh: function () { + this.autorefreshEnabled = false; + }, + + /** + * Enable the autorefresh mechanism + */ + enableAutorefresh: function () { + this.autorefreshEnabled = true; + }, + + processNotificationHeader: function(req) { + var header = req.getResponseHeader('X-Icinga-Notification'); + var _this = this; + if (! header) return false; + var list = header.split('&'); + $.each(list, function(idx, el) { + var parts = decodeURIComponent(el).split(' '); + _this.createNotice(parts.shift(), parts.join(' ')); + }); + return true; + }, + + /** + * Process the X-Icinga-Redirect HTTP Response Header + * + * If the response includes the X-Icinga-Redirect header, redirects to the URL associated with the header. + * + * @param {object} req Current request + * + * @returns {boolean} Whether we're about to redirect + */ + processRedirectHeader: function(req) { + var icinga = this.icinga, + redirect = req.getResponseHeader('X-Icinga-Redirect'); + + if (! redirect) { + return false; + } + + redirect = decodeURIComponent(redirect); + if (redirect.match(/__SELF__/)) { + if (req.autorefresh) { + // Redirect to the current window's URL in case it's an auto-refresh request. If authenticated + // externally this ensures seamless re-login if the session's expired + redirect = redirect.replace( + /__SELF__/, + encodeURIComponent( + document.location.pathname + document.location.search + document.location.hash + ) + ); + } else { + // Redirect to the URL which required authentication. When clicking a link this ensures that we + // redirect to the link's URL instead of the current window's URL (see above) + redirect = redirect.replace(/__SELF__/, req.url); + } + } else if (redirect.match(/__BACK__/)) { + if (req.$redirectTarget.is('#col1')) { + icinga.logger.warn('Cannot navigate back. Redirect target is #col1'); + return false; + } + + var originUrl = req.$target.data('icingaUrl'); + + $(window).on('popstate.__back__', { self: this }, function (event) { + var _this = event.data.self; + var $refreshTarget = $('#col2'); + var refreshUrl; + + var hash = icinga.history.getCol2State(); + if (hash && hash.match(/^#!/)) { + // TODO: These three lines are copied from history.js, I don't like this + var parts = hash.split(/#!/); + + if (parts[1] === originUrl) { + // After a page load a call to back() seems not to have an effect + icinga.ui.layout1col(); + } else { + refreshUrl = parts[1]; + } + } + + if (typeof refreshUrl === 'undefined' && icinga.ui.isOneColLayout()) { + refreshUrl = icinga.history.getCol1State(); + $refreshTarget = $('#col1'); + } + + // loadUrl won't run this request, as due to the history handling it's already running. + // The important bit though is that it returns this (already running) request. This way + // it's possible to set `scripted = true` on the request. (`addToHistory = true` should + // already be the case, though it's added again just in case it's not already `true`..) + var req = _this.loadUrl(refreshUrl, $refreshTarget); + req.addToHistory = false; + req.scripted = true; + + setTimeout(function () { + // TODO: Find a better solution than a hardcoded one + // This is still the *cheat* to get live results + // (in case there's a delay and a change is not instantly effective) + var req = _this.loadUrl(refreshUrl, $refreshTarget); + req.addToHistory = false; + req.scripted = true; + }, 1000); + + $(window).off('popstate.__back__'); + }); + + // Navigate back, no redirect desired + window.history.back(); + + return true; + } else if (redirect.match(/__CLOSE__/)) { + if (req.$redirectTarget.is('#col1')) { + icinga.logger.warn('Cannot close #col1'); + return false; + } + + // Close right column as requested + icinga.ui.layout1col(); + + // Refresh left column and produce a new history state for it + var $col1 = $('#col1'); + var col1Url = icinga.history.getCol1State(); + var refresh = this.loadUrl(col1Url, $col1); + refresh.scripted = true; + + var _this = this; + setTimeout(function () { + // TODO: Find a better solution than a hardcoded one + // This is still the *cheat* to get live results + // (in case there's a delay and a change is not instantly effective) + var secondRefresh = _this.loadUrl(col1Url, $col1); + if (secondRefresh !== refresh) { + // Only change these properties if it's not still the first refresh + secondRefresh.addToHistory = false; + secondRefresh.scripted = true; + } + }, 1000); + + return true; + } + + var useHttp = req.getResponseHeader('X-Icinga-Redirect-Http'); + if (useHttp === 'yes') { + window.location.replace(redirect); + return true; + } + + this.redirectToUrl(redirect, req.$redirectTarget, req); + return true; + }, + + /** + * Redirect to the given url + * + * @param {string} url + * @param {object} $target + * @param {XMLHttpRequest} referrer + */ + redirectToUrl: function (url, $target, referrer) { + var icinga = this.icinga, + rerenderLayout, + autoRefreshInterval, + forceFocus, + origin; + + if (typeof referrer !== 'undefined') { + rerenderLayout = referrer.getResponseHeader('X-Icinga-Rerender-Layout'); + autoRefreshInterval = referrer.getResponseHeader('X-Icinga-Refresh'); + forceFocus = referrer.forceFocus; + origin = referrer.url; + } + + icinga.logger.debug( + 'Got redirect for ', $target, ', URL was ' + url + ); + + if (rerenderLayout) { + var parts = url.split(/#!/); + url = parts.shift(); + var redirectionUrl = icinga.utils.addUrlFlag(url, 'renderLayout'); + var r = this.loadUrl(redirectionUrl, $('#layout')); + r.historyUrl = url; + if (parts.length) { + r.loadNext = parts; + } else if (!! document.location.hash) { + // Retain detail URL if the layout is rerendered + parts = document.location.hash.split('#!').splice(1); + if (parts.length) { + r.loadNext = $.grep(parts, function (url) { + if (url !== origin) { + icinga.logger.debug('Retaining detail url ' + url); + return true; + } + + icinga.logger.debug('Discarding detail url ' + url + ' as it\'s the origin of the redirect'); + return false; + }); + } + } + } else { + if (url.match(/#!/)) { + var parts = url.split(/#!/); + icinga.ui.layout2col(); + this.loadUrl(parts.shift(), $('#col1')); + this.loadUrl(parts.shift(), $('#col2')); + } else { + if ($target.attr('id') === 'col2') { // TODO: multicol + if (($target.data('icingaUrl') || '').split('?')[0] === url.split('?')[0]) { + // Don't do anything in this case + } else if ($('#col1').data('icingaUrl').split('?')[0] === url.split('?')[0]) { + icinga.ui.layout1col(); + $target = $('#col1'); + delete(this.requests['col2']); + } + } + + var req = this.loadUrl(url, $target); + req.forceFocus = url === origin ? forceFocus : null; + req.autoRefreshInterval = autoRefreshInterval; + req.referrer = referrer; + } + } + }, + + /** + * Handle successful XHR response + */ + onResponse: function (data, textStatus, req) { + var _this = this; + if (this.failureNotice !== null) { + if (! this.failureNotice.hasClass('fading-out')) { + this.failureNotice.remove(); + } + this.failureNotice = null; + } + + var target = req.getResponseHeader('X-Icinga-Container'); + var newBody = false; + var oldNotifications = false; + if (target) { + if (target === 'ignore') { + return; + } + + var $newTarget = this.identifyLinkTarget(target, req.$target); + if ($newTarget.length) { + // If we change the target, oncomplete will fail to clean up + // This fixes the problem, not using req.$target would be better + delete this.requests[req.$target.attr('id')]; + + req.$target = $newTarget; + req.$redirectTarget = $newTarget; + + if (target === 'layout') { + oldNotifications = $('#notifications li').detach(); + this.icinga.ui.layout1col(); + newBody = true; + } + } + } + + if (req.autorefresh && req.$target.is('[data-suspend-autorefresh]')) { + return; + } + + this.icinga.logger.debug( + 'Got response for ', req.$target, ', URL was ' + req.url + ); + this.processNotificationHeader(req); + + var cssreload = req.getResponseHeader('X-Icinga-Reload-Css'); + if (cssreload) { + this.icinga.ui.reloadCss(); + } + + if (req.getResponseHeader('X-Icinga-Redirect')) { + return; + } + + if (req.getResponseHeader('X-Icinga-Announcements') === 'refresh') { + var announceReq = _this.loadUrl(_this.url('/layout/announcements'), $('#announcements')); + announceReq.addToHistory = false; + announceReq.scripted = true; + } + + var classes; + + if (target !== 'layout') { + var moduleName = req.getResponseHeader('X-Icinga-Module'); + classes = $.grep(req.$target.classes(), function (el) { + if (el === 'icinga-module' || el.match(/^module\-/)) { + return false; + } + return true; + }); + if (moduleName) { + // Lazy load module javascript (Applies only to module.js code) + _this.icinga.ensureModule(moduleName); + + req.$target.data('icingaModule', moduleName); + classes.push('icinga-module'); + classes.push('module-' + moduleName); + } else { + req.$target.removeData('icingaModule'); + if (req.$target.attr('data-icinga-module')) { + req.$target.removeAttr('data-icinga-module'); + } + } + req.$target.attr('class', classes.join(' ')); + + var refresh = req.autoRefreshInterval || req.getResponseHeader('X-Icinga-Refresh'); + if (refresh) { + req.$target.data('icingaRefresh', refresh); + } else { + req.$target.removeData('icingaRefresh'); + if (req.$target.attr('data-icinga-refresh')) { + req.$target.removeAttr('data-icinga-refresh'); + } + } + } + + var title = req.getResponseHeader('X-Icinga-Title'); + if (title && (target === 'layout' || req.$target.is('#layout'))) { + this.icinga.ui.setTitle(decodeURIComponent(title)); + } else if (title && ! req.autorefresh && req.$target.closest('.dashboard').length === 0) { + req.$target.data('icingaTitle', decodeURIComponent(title)); + } + + // Set a window identifier if the server asks us to do so + var windowId = req.getResponseHeader('X-Icinga-WindowId'); + if (windowId) { + this.icinga.ui.setWindowId(windowId); + } + + var referrer = req.referrer; + if (typeof referrer === 'undefined') { + referrer = req; + } + + var autoSubmit = false; + var currentUrl = this.icinga.utils.parseUrl(req.$target.data('icingaUrl')); + if (referrer.method === 'POST') { + var newUrl = this.icinga.utils.parseUrl(req.url); + if (newUrl.path === currentUrl.path && this.icinga.utils.arraysEqual(newUrl.params, currentUrl.params)) { + autoSubmit = true; + } + } + + req.$target.data('icingaUrl', req.url); + + if (typeof req.progressTimer !== 'undefined') { + this.icinga.timer.unregister(req.progressTimer); + } + + var contentSeparator = req.getResponseHeader('X-Icinga-Multipart-Content'); + if (!! contentSeparator) { + var locationQuery = req.getResponseHeader('X-Icinga-Location-Query'); + if (locationQuery !== null) { + let url = currentUrl.path + (locationQuery ? '?' + locationQuery : ''); + if (req.autosubmit || autoSubmit) { + // Also update a form's action if it doesn't differ from the container's url + var $form = $(referrer.forceFocus).closest('form'); + var formAction = $form.attr('action'); + if (!! formAction) { + formAction = this.icinga.utils.parseUrl(formAction); + if (formAction.path === currentUrl.path + && this.icinga.utils.arraysEqual(formAction.params, currentUrl.params) + ) { + $form.attr('action', url); + } + } + } + + req.$target.data('icingaUrl', url); + this.icinga.history.replaceCurrentState(); + } + + $.each(req.responseText.split(contentSeparator), function (idx, el) { + var match = el.match(/for=(Behavior:)?(\S+)\s+([^]*)/m); + if (!! match) { + if (match[1]) { + var behavior = _this.icinga.behaviors[match[2].toLowerCase()]; + if (typeof behavior !== 'undefined' && typeof behavior.update === 'function') { + behavior.update(JSON.parse(match[3])); + } else { + _this.icinga.logger.warn( + 'Invalid behavior. Cannot update behavior "' + match[2] + '"'); + } + } else { + var $target = $('#' + match[2]); + if ($target.length) { + var forceFocus; + if (req.forceFocus + && typeof req.forceFocus.jquery !== 'undefined' + && $.contains($target[0], req.forceFocus[0]) + ) { + forceFocus = req.forceFocus; + } + + _this.renderContentToContainer( + match[3], + $target, + 'replace', + req.autorefresh, + forceFocus, + req.autosubmit || autoSubmit, + req.scripted + ); + } else { + _this.icinga.logger.warn( + 'Invalid target ID. Cannot render multipart to #' + match[2]); + } + } + } else { + _this.icinga.logger.error('Ill-formed multipart', el); + } + }) + } else { + this.renderContentToContainer( + req.responseText, + req.$target, + req.action, + req.autorefresh, + req.forceFocus, + req.autosubmit || autoSubmit, + req.scripted + ); + } + + if (oldNotifications) { + oldNotifications.appendTo($('#notifications')); + } + if (newBody) { + this.icinga.ui.fixDebugVisibility().triggerWindowResize(); + } + }, + + /** + * Regardless of whether a request succeeded of failed, clean up + */ + onComplete: function (dataOrReq, textStatus, reqOrError) { + var _this = this; + var req; + + if (typeof dataOrReq === 'object') { + req = dataOrReq; + } else { + req = reqOrError; + } + + // Remove 'impact' class if there was such + if (req.$target.hasClass('impact')) { + req.$target.removeClass('impact'); + } else { + var $impact = req.$target.find('.impact').first(); + if ($impact.length) { + $impact.removeClass('impact'); + } + } + + if (req.getResponseHeader('X-Icinga-Reload-Window') === 'yes') { + window.location.reload(); + return; + } + + if (! req.autorefresh && ! req.autosubmit) { + // TODO: Hook for response/url? + var url = req.url; + + if (req.$target[0].id === 'col1') { + this.icinga.behaviors.navigation.trySetActiveAndSelectedByUrl(url); + } + + var $forms = $('[action="' + this.icinga.utils.parseUrl(url).path + '"]'); + var $matches = $.merge($('[href="' + url + '"]'), $forms); + $matches.each(function (idx, el) { + var $el = $(el); + if ($el.closest('#menu').length) { + if ($el.is('form')) { + $('input', $el).addClass('active'); + } + // Interrupt .each, only one menu item shall be active + return false; + } + }); + } + + // Update history when necessary + if (! req.autorefresh && req.addToHistory) { + if (req.$target.hasClass('container')) { + // We only want to care about top-level containers + if (req.$target.parent().closest('.container').length === 0) { + this.icinga.history.pushCurrentState(); + } + } else { + // Request wasn't for a container, so it's usually the body + // or the full layout. Push request URL to history: + var url = typeof req.historyUrl !== 'undefined' ? req.historyUrl : req.url; + this.icinga.history.pushUrl(url); + } + } + + req.$target.data('lastUpdate', (new Date()).getTime()); + delete this.requests[req.$target.attr('id')]; + this.icinga.ui.fadeNotificationsAway(); + + var extraUpdates = req.getResponseHeader('X-Icinga-Extra-Updates'); + if (!! extraUpdates && req.getResponseHeader('X-Icinga-Redirect-Http') !== 'yes') { + $.each(extraUpdates.split(','), function (idx, el) { + var parts = el.trim().split(';'); + var $target; + var url; + if (parts.length === 2) { + $target = $('#' + parts[0]); + if (! $target.length) { + _this.icinga.logger.warn('Invalid target ID. Cannot load extra URL', el); + return; + } + + url = parts[1]; + } else if (parts.length === 1) { + $target = $(parts[0]).closest(".container").not(req.$target); + if (! $target.length) { + _this.icinga.logger.warn('Invalid target ID. Cannot load extra URL', el); + return; + } + + url = $target.data('icingaUrl'); + } else { + _this.icinga.logger.error('Invalid extra update', el); + return; + } + + _this.loadUrl(url, $target).addToHistory = false; + }); + } + + if (this.processRedirectHeader(req)) { + return; + } + + if (typeof req.loadNext !== 'undefined' && req.loadNext.length) { + if ($('#col2').length) { + var r = this.loadUrl(req.loadNext[0], $('#col2')); + r.addToHistory = req.addToHistory; + this.icinga.ui.layout2col(); + } else { + this.icinga.logger.error('Failed to load URL for #col2', req.loadNext); + } + } + + // Lazy load module javascript (Applies only to module.js code) + this.icinga.ensureSubModules(req.$target); + + req.$target.find('.container').each(function () { + $(this).trigger('rendered', [req.autorefresh, req.scripted, req.autosubmit]); + }); + req.$target.trigger('rendered', [req.autorefresh, req.scripted, req.autosubmit]); + + this.icinga.ui.refreshDebug(); + }, + + /** + * Handle failed XHR response + */ + onFailure: function (req, textStatus, errorThrown) { + var url = req.url; + + /* + * Test if a manual actions comes in and autorefresh is active: Stop refreshing + */ + if (req.addToHistory && ! req.autorefresh) { + req.$target.data('icingaRefresh', 0); + req.$target.data('icingaUrl', url); + icinga.history.pushCurrentState(); + } + + if (typeof req.progressTimer !== 'undefined') { + this.icinga.timer.unregister(req.progressTimer); + } + + if (req.status > 0 && req.status < 501) { + this.icinga.logger.error( + req.status, + errorThrown + ':', + $(req.responseText).text().replace(/\s+/g, ' ').slice(0, 100) + ); + this.renderContentToContainer( + req.responseText, + req.$target, + req.action, + req.autorefresh, + undefined, + req.autosubmit, + req.scripted + ); + } else { + if (errorThrown === 'abort') { + this.icinga.logger.debug( + 'Request to ' + url + ' has been aborted for ', + req.$target + ); + + // Aborted requests should not be added to browser history + req.addToHistory = false; + } else { + if (this.failureNotice === null) { + var now = new Date(); + var padString = this.icinga.utils.padString; + this.failureNotice = this.createNotice( + 'error', + 'The connection to the Icinga web server was lost at ' + + now.getFullYear() + + '-' + padString(now.getMonth() + 1, 0, 2) + + '-' + padString(now.getDate(), 0, 2) + + ' ' + padString(now.getHours(), 0, 2) + + ':' + padString(now.getMinutes(), 0, 2) + + '.', + true + ); + } + + this.icinga.logger.error( + 'Failed to contact web server loading ', + url, + ' for ', + req.$target + ); + } + } + }, + + /** + * Create a notification. Can be improved. + */ + createNotice: function (severity, message, persist) { + var c = severity, + icon; + if (persist) { + c += ' persist'; + } + + switch (severity) { + case 'success': + icon = 'check-circle'; + break; + case 'error': + icon = 'times'; + break; + case 'warning': + icon = 'exclamation-triangle'; + break; + case 'info': + icon = 'info-circle'; + break; + } + + var $notice = $( + '<li class="' + c + '">' + + '<i class="icon fa fa-' + icon + '"></i>' + + this.icinga.utils.escape(message) + '</li>' + ).appendTo($('#notifications')); + + if (!persist) { + this.icinga.ui.fadeNotificationsAway(); + } + + return $notice; + }, + + /** + * Detect the link/form target for a given element (link, form, whatever) + * + * @param {object} $el jQuery set with the element + * @param {boolean} prepare Pass `false` to disable column preparation + */ + getLinkTargetFor: function($el, prepare) + { + if (typeof prepare === 'undefined') { + prepare = true; + } + + // If everything else fails, our target is the first column... + var $target = $('#col1'); + + // ...but usually we will use our own container... + var $container = $el.closest('.container'); + if ($container.length) { + $target = $container; + } + + // You can of course override the default behaviour: + if ($el.closest('[data-base-target]').length) { + var targetId = $el.closest('[data-base-target]').data('baseTarget'); + + $target = this.identifyLinkTarget(targetId, $el); + if (! $target.length) { + this.icinga.logger.warn('Link target "#' + targetId + '" does not exist in DOM.'); + } + } + + if (prepare) { + this.icinga.ui.prepareColumnFor($el, $target); + } + + return $target; + }, + + /** + * Identify link target by the given id + * + * The id may also be one of the column aliases: `_next`, `_self` and `_main` + * + * @param {string} id + * @param {object} $of + * @return {object} + */ + identifyLinkTarget: function (id, $of) { + var $target; + + if (id === '_next') { + if (this.icinga.ui.hasOnlyOneColumn()) { + $target = $('#col1'); + } else { + $target = $('#col2'); + } + } else if (id === '_self') { + $target = $of.closest('.container'); + } else if (id === '_main') { + $target = $('#col1'); + } else { + $target = $('#' + id); + } + + return $target; + }, + + /** + * Smoothly render given HTML to given container + */ + renderContentToContainer: function (content, $container, action, autorefresh, forceFocus, autoSubmit, scripted) { + // Container update happens here + var scrollPos = false; + var _this = this; + var containerId = $container.attr('id'); + + var activeElementPath = false; + var navigationAnchor = false; + var focusFallback = false; + + if (forceFocus && forceFocus.length) { + if (typeof forceFocus === 'string') { + navigationAnchor = forceFocus; + } else { + activeElementPath = this.icinga.utils.getCSSPath($(forceFocus)); + } + } else if (document.activeElement && document.activeElement.id === 'search') { + activeElementPath = '#search'; + } else if (document.activeElement + && document.activeElement !== document.body + && $.contains($container[0], document.activeElement) + ) { + // Active element in container + var $activeElement = $(document.activeElement); + var $pagination = $activeElement.closest('.pagination-control'); + if ($pagination.length) { + focusFallback = { + 'parent': this.icinga.utils.getCSSPath($pagination), + 'child': '.active > a' + }; + } + activeElementPath = this.icinga.utils.getCSSPath($activeElement); + } + + var scrollTarget = $container; + if (typeof containerId !== 'undefined') { + if (autorefresh || autoSubmit) { + if ($container.css('display') === 'flex' && $container.is('.container')) { + var $scrollableContent = $container.children('.content'); + scrollPos = $scrollableContent.scrollTop(); + scrollTarget = _this.icinga.utils.getCSSPath($scrollableContent); + } else { + scrollPos = $container.scrollTop(); + } + } else { + scrollPos = 0; + } + } + + $container.trigger('beforerender', [content, action, autorefresh, scripted, autoSubmit]); + + var discard = false; + $.each(_this.icinga.behaviors, function(name, behavior) { + if (behavior.renderHook) { + var changed = behavior.renderHook(content, $container, action, autorefresh, autoSubmit); + if (changed === null) { + discard = true; + } else { + content = changed; + } + } + }); + + $('.container', $container).each(function() { + _this.stopPendingRequestsFor($(this)); + }); + + if (! discard) { + if ($container.closest('.dashboard').length) { + var title = $('h1', $container).first().detach(); + $container.html(title).append(content); + } else if (action === 'replace') { + $container.html(content); + } else { + $container.append(content); + } + } + + this.icinga.ui.assignUniqueContainerIds(); + + if (! discard && navigationAnchor) { + var $element = $container.find('#' + navigationAnchor); + if ($element.length) { + // data-icinga-no-scroll-on-focus is NOT designed to avoid scrolling for non-XHR requests + setTimeout(this.icinga.ui.focusElement.bind(this.icinga.ui), 0, + $element, $container, ! $element.is('[data-icinga-no-scroll-on-focus]')); + } + } else if (! activeElementPath) { + // Active element was not in this container + if (! autorefresh && ! autoSubmit && ! scripted) { + setTimeout(function() { + if (typeof $container.attr('tabindex') === 'undefined') { + $container.attr('tabindex', -1); + } + // Do not touch focus in case a module or component already placed it + if ($(document.activeElement).closest('.container').attr('id') !== containerId) { + _this.icinga.ui.focusElement($container); + } + }, 0); + } + } else { + setTimeout(function() { + var $activeElement = $(activeElementPath); + + if ($activeElement.length && $activeElement.is(':visible')) { + $activeElement[0].focus({preventScroll: autorefresh || autoSubmit}); + } else if (! autorefresh && ! autoSubmit && ! scripted) { + if (focusFallback) { + _this.icinga.ui.focusElement($(focusFallback.parent).find(focusFallback.child)); + } else if (typeof $container.attr('tabindex') === 'undefined') { + $container.attr('tabindex', -1); + } + _this.icinga.ui.focusElement($container); + } + }, 0); + } + + if (scrollPos !== false) { + var $scrollTarget = $(scrollTarget); + $scrollTarget.scrollTop(scrollPos); + + // Fallback for browsers without support for focus({preventScroll: true}) + setTimeout(function () { + if ($scrollTarget.scrollTop() !== scrollPos) { + $scrollTarget.scrollTop(scrollPos); + } + }, 0); + } + + // Re-enable all click events (disabled as of performance reasons) + // $('*').off('click'); + }, + + /** + * On shutdown we kill all pending requests + */ + destroy: function() { + $.each(this.requests, function(id, request) { + request.abort(); + }); + this.icinga = null; + this.requests = {}; + } + + }; + +}(Icinga, jQuery)); diff --git a/public/js/icinga/logger.js b/public/js/icinga/logger.js new file mode 100644 index 0000000..471393c --- /dev/null +++ b/public/js/icinga/logger.js @@ -0,0 +1,129 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.Logger + * + * Well, log output. Rocket science. + */ +(function (Icinga) { + + 'use strict'; + + Icinga.Logger = function (icinga) { + + this.icinga = icinga; + + this.logLevel = 'info'; + + this.logLevels = { + 'debug': 0, + 'info' : 1, + 'warn' : 2, + 'error': 3 + }; + + }; + + Icinga.Logger.prototype = { + + /** + * Whether the browser has a console object + */ + hasConsole: function () { + return 'undefined' !== typeof console; + }, + + /** + * Raise or lower current log level + * + * Messages below this threshold will be silently discarded + */ + setLevel: function (level) { + if ('undefined' !== typeof this.numericLevel(level)) { + this.logLevel = level; + } + return this; + }, + + /** + * Log a debug message + */ + debug: function () { + return this.writeToConsole('debug', arguments); + }, + + /** + * Log an informational message + */ + info: function () { + return this.writeToConsole('info', arguments); + }, + + /** + * Log a warning message + */ + warn: function () { + return this.writeToConsole('warn', arguments); + }, + + /** + * Log an error message + */ + error: function () { + return this.writeToConsole('error', arguments); + }, + + /** + * Write a log message with the given level to the console + */ + writeToConsole: function (level, args) { + + args = Array.prototype.slice.call(args); + + // We want our log messages to carry precise timestamps + args.unshift(this.icinga.utils.timeWithMs()); + + if (this.hasConsole() && this.hasLogLevel(level)) { + if (typeof console[level] !== 'undefined') { + if (typeof console[level].apply === 'function') { + console[level].apply(console, args); + } else { + args.unshift('[' + level + ']'); + console[level](args.join(' ')); + } + } else if ('undefined' !== typeof console.log) { + args.unshift('[' + level + ']'); + console.log(args.join(' ')); + } + } + return this; + }, + + /** + * Return the numeric identifier for a given log level + */ + numericLevel: function (level) { + var ret = this.logLevels[level]; + if ('undefined' === typeof ret) { + throw 'Got invalid log level ' + level; + } + return ret; + }, + + /** + * Whether a given log level exists + */ + hasLogLevel: function (level) { + return this.numericLevel(level) >= this.numericLevel(this.logLevel); + }, + + /** + * There isn't much to clean up here + */ + destroy: function () { + this.enabled = false; + this.icinga = null; + } + }; + +}(Icinga)); diff --git a/public/js/icinga/module.js b/public/js/icinga/module.js new file mode 100644 index 0000000..2c2368e --- /dev/null +++ b/public/js/icinga/module.js @@ -0,0 +1,134 @@ +/*! Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +/** + * This is how we bootstrap JS code in our modules + */ +(function(Icinga, $) { + + 'use strict'; + + Icinga.Module = function (icinga, name, prototyp) { + + // The Icinga instance + this.icinga = icinga; + + // Applied event handlers + this.handlers = []; + + // Event handlers registered by this module + this.registeredHandlers = []; + + // The module name + this.name = name; + + // The JS prototype for this module + this.prototyp = prototyp; + + // Once initialized, this will be an instance of the modules prototype + this.object = {}; + + // Initialize this module + this.initialize(); + }; + + Icinga.Module.prototype = { + + initialize: function () { + + if (typeof this.prototyp !== 'function') { + this.icinga.logger.error( + 'Unable to load module "' + this.name + '", constructor is missing' + ); + return false; + } + + try { + + // The constructor of the modules prototype must be prepared to get an + // instance of Icinga.Module + this.object = new this.prototyp(this); + this.applyHandlers(); + } catch(e) { + this.icinga.logger.error( + 'Failed to load module ' + this.name + ': ', + e + ); + + return false; + } + + // That's all, the module is ready + this.icinga.logger.debug( + 'Module ' + this.name + ' has been initialized' + ); + + return true; + }, + + /** + * Register this modules event handlers + */ + on: function (event, filter, handler) { + if (typeof handler === 'undefined') { + handler = filter; + filter = '.module-' + this.name; + } else { + filter = '.module-' + this.name + ' ' + filter; + } + this.registeredHandlers.push({event: event, filter: filter, handler: handler}); + + }, + + applyHandlers: function () { + var _this = this; + + $.each(this.registeredHandlers, function (key, on) { + _this.bindEventHandler( + on.event, + on.filter, + on.handler + ); + }); + _this = null; + + return this; + }, + + /** + * Effectively bind the given event handler + */ + bindEventHandler: function (event, filter, handler) { + var _this = this; + this.icinga.logger.debug('Bound ' + filter + ' .' + event + '()'); + this.handlers.push([event, filter, handler]); + $(document).on(event, filter, handler.bind(_this.object)); + }, + + /** + * Unbind all event handlers bound by this module + */ + unbindEventHandlers: function () { + $.each(this.handlers, function (idx, handler) { + $(document).off(handler[0], handler[1], handler[2]); + }); + }, + + /** + * Allow to destroy and clean up this module + */ + destroy: function () { + + this.unbindEventHandlers(); + + if (typeof this.object.destroy === 'function') { + this.object.destroy(); + } + + this.object = null; + this.icinga = null; + this.prototyp = null; + } + + }; + +}(Icinga, jQuery)); diff --git a/public/js/icinga/storage.js b/public/js/icinga/storage.js new file mode 100644 index 0000000..fa312d2 --- /dev/null +++ b/public/js/icinga/storage.js @@ -0,0 +1,549 @@ +/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +;(function(Icinga) { + + 'use strict'; + + const KEY_TTL = 7776000000; // 90 days (90×24×60×60×1000) + + /** + * Icinga.Storage + * + * localStorage access + * + * @param {string} prefix + */ + Icinga.Storage = function(prefix) { + + /** + * Prefix to use for keys + * + * @type {string} + */ + this.prefix = prefix; + + /** + * Storage backend + * + * @type {Storage} + */ + this.backend = window.localStorage; + }; + + /** + * Callbacks for storage events on particular keys + * + * @type {{function}} + */ + Icinga.Storage.subscribers = {}; + + /** + * Pass storage events to subscribers + * + * @param {StorageEvent} event + */ + window.addEventListener('storage', function(event) { + var url = icinga.utils.parseUrl(event.url); + if (! url.path.startsWith(icinga.config.baseUrl)) { + // A localStorage is shared between all paths on the same origin. + // So we need to make sure it's us who made a change. + return; + } + + if (typeof Icinga.Storage.subscribers[event.key] !== 'undefined') { + var newValue = null, + oldValue = null; + if (!! event.newValue) { + try { + newValue = JSON.parse(event.newValue); + } catch(error) { + icinga.logger.error('[Storage] Failed to parse new value (\`' + event.newValue + + '\`) for key "' + event.key + '". Error was: ' + error); + event.storageArea.removeItem(event.key); + return; + } + } + if (!! event.oldValue) { + try { + oldValue = JSON.parse(event.oldValue); + } catch(error) { + icinga.logger.warn('[Storage] Failed to parse old value (\`' + event.oldValue + + '\`) of key "' + event.key + '". Error was: ' + error); + oldValue = null; + } + } + + Icinga.Storage.subscribers[event.key].forEach(function (subscriber) { + subscriber[0].call(subscriber[1], newValue, oldValue, event); + }); + } + }); + + /** + * Create a new storage with `behavior.<name>` as prefix + * + * @param {string} name + * + * @returns {Icinga.Storage} + */ + Icinga.Storage.BehaviorStorage = function(name) { + return new Icinga.Storage('behavior.' + name); + }; + + Icinga.Storage.prototype = { + + /** + * Set the storage backend + * + * @param {Storage} backend + */ + setBackend: function(backend) { + this.backend = backend; + }, + + /** + * Prefix the given key + * + * @param {string} key + * + * @returns {string} + */ + prefixKey: function(key) { + var prefix = 'icinga.'; + if (typeof this.prefix !== 'undefined') { + prefix = prefix + this.prefix + '.'; + } + + return prefix + key; + }, + + /** + * Store the given key-value pair + * + * @param {string} key + * @param {*} value + * + * @returns {void} + */ + set: function(key, value) { + this.backend.setItem(this.prefixKey(key), JSON.stringify(value)); + }, + + /** + * Get value for the given key + * + * @param {string} key + * + * @returns {*} + */ + get: function(key) { + key = this.prefixKey(key); + var value = this.backend.getItem(key); + + try { + return JSON.parse(value); + } catch(error) { + icinga.logger.error('[Storage] Failed to parse value (\`' + value + + '\`) of key "' + key + '". Error was: ' + error); + this.backend.removeItem(key); + return null; + } + }, + + /** + * Remove given key from storage + * + * @param {string} key + * + * @returns {void} + */ + remove: function(key) { + this.backend.removeItem(this.prefixKey(key)); + }, + + /** + * Subscribe with a callback for events on a particular key + * + * @param {string} key + * @param {function} callback + * @param {object} context + * + * @returns {void} + */ + onChange: function(key, callback, context) { + if (this.backend !== window.localStorage) { + throw new Error('[Storage] Only the localStorage emits events'); + } + + var prefixedKey = this.prefixKey(key); + + if (typeof Icinga.Storage.subscribers[prefixedKey] === 'undefined') { + Icinga.Storage.subscribers[prefixedKey] = []; + } + + Icinga.Storage.subscribers[prefixedKey].push([callback, context]); + } + }; + + /** + * Icinga.Storage.StorageAwareMap + * + * @param {object} items + * @constructor + */ + Icinga.Storage.StorageAwareMap = function(items) { + + /** + * Storage object + * + * @type {Icinga.Storage} + */ + this.storage = undefined; + + /** + * Storage key + * + * @type {string} + */ + this.key = undefined; + + /** + * Event listeners for our internal events + * + * @type {{}} + */ + this.eventListeners = { + 'add': [], + 'delete': [] + }; + + /** + * The internal (real) map + * + * @type {Map<*>} + */ + this.data = new Map(); + + // items is not passed directly because IE11 doesn't support constructor arguments + if (typeof items !== 'undefined' && !! items) { + Object.keys(items).forEach(function(key) { + this.data.set(key, items[key]); + }, this); + } + }; + + /** + * Create a new StorageAwareMap for the given storage and key + * + * @param {Icinga.Storage} storage + * @param {string} key + * + * @returns {Icinga.Storage.StorageAwareMap} + */ + Icinga.Storage.StorageAwareMap.withStorage = function(storage, key) { + var items = storage.get(key); + if (typeof items !== 'undefined' && !! items) { + Object.keys(items).forEach(function(key) { + var value = items[key]; + + if (typeof value !== 'object' || typeof value['lastAccess'] === 'undefined') { + items[key] = {'value': value, 'lastAccess': Date.now()}; + } else if (Date.now() - value['lastAccess'] > KEY_TTL) { + delete items[key]; + } + }, this); + } + + if (!! items && Object.keys(items).length) { + storage.set(key, items); + } else if (items !== null) { + storage.remove(key); + } + + return (new Icinga.Storage.StorageAwareMap(items).setStorage(storage, key)); + }; + + Icinga.Storage.StorageAwareMap.prototype = { + + /** + * Bind this map to the given storage and key + * + * @param {Icinga.Storage} storage + * @param {string} key + * + * @returns {this} + */ + setStorage: function(storage, key) { + this.storage = storage; + this.key = key; + + if (storage.backend === window.localStorage) { + storage.onChange(key, this.onChange, this); + } + + return this; + }, + + /** + * Return a boolean indicating this map got a storage + * + * @returns {boolean} + */ + hasStorage: function() { + return typeof this.storage !== 'undefined' && typeof this.key !== 'undefined'; + }, + + /** + * Update the storage + * + * @returns {void} + */ + updateStorage: function() { + if (! this.hasStorage()) { + return; + } + + if (this.size > 0) { + this.storage.set(this.key, this.toObject()); + } else { + this.storage.remove(this.key); + } + }, + + /** + * Update the map + * + * @param {object} newValue + */ + onChange: function(newValue) { + // Check for deletions first. Uses keys() to iterate over a copy + this.keys().forEach(function (key) { + if (newValue === null || typeof newValue[key] === 'undefined') { + var value = this.data.get(key)['value']; + this.data.delete(key); + this.trigger('delete', key, value); + } + }, this); + + if (newValue === null) { + return; + } + + // Now check for new entries + Object.keys(newValue).forEach(function(key) { + var known = this.data.has(key); + // Always override any known value as we want to keep track of all `lastAccess` changes + this.data.set(key, newValue[key]); + + if (! known) { + this.trigger('add', key, newValue[key]['value']); + } + }, this); + }, + + /** + * Register an event handler to handle storage updates + * + * Available events are: add, delete. The callback receives the + * key and its value as first and second argument, respectively. + * + * @param {string} event + * @param {function} callback + * @param {object} thisArg + * + * @returns {this} + */ + on: function(event, callback, thisArg) { + if (typeof this.eventListeners[event] === 'undefined') { + throw new Error('Invalid event "' + event + '"'); + } + + this.eventListeners[event].push([callback, thisArg]); + return this; + }, + + /** + * Trigger all event handlers for the given event + * + * @param {string} event + * @param {string} key + * @param {*} value + */ + trigger: function(event, key, value) { + this.eventListeners[event].forEach(function (handler) { + var thisArg = handler[1]; + if (typeof thisArg === 'undefined') { + thisArg = this; + } + + handler[0].call(thisArg, key, value); + }); + }, + + /** + * Return the number of key/value pairs in the map + * + * @returns {number} + */ + get size() { + return this.data.size; + }, + + /** + * Set the value for the key in the map + * + * @param {string} key + * @param {*} value Default null + * + * @returns {this} + */ + set: function(key, value) { + if (typeof value === 'undefined') { + value = null; + } + + this.data.set(key, {'value': value, 'lastAccess': Date.now()}); + + this.updateStorage(); + return this; + }, + + /** + * Remove all key/value pairs from the map + * + * @returns {void} + */ + clear: function() { + this.data.clear(); + this.updateStorage(); + }, + + /** + * Remove the given key from the map + * + * @param {string} key + * + * @returns {boolean} + */ + delete: function(key) { + var retVal = this.data.delete(key); + + this.updateStorage(); + return retVal; + }, + + /** + * Return a list of [key, value] pairs for every item in the map + * + * @returns {Array} + */ + entries: function() { + var list = []; + + if (this.size > 0) { + this.data.forEach(function (value, key) { + list.push([key, value['value']]); + }); + } + + return list; + }, + + /** + * Execute a provided function once for each item in the map, in insertion order + * + * @param {function} callback + * @param {object} thisArg + * + * @returns {void} + */ + forEach: function(callback, thisArg) { + if (typeof thisArg === 'undefined') { + thisArg = this; + } + + this.data.forEach(function(value, key) { + callback.call(thisArg, value['value'], key); + }); + }, + + /** + * Return the value associated to the key, or undefined if there is none + * + * @param {string} key + * + * @returns {*} + */ + get: function(key) { + var value = this.data.get(key)['value']; + this.set(key, value); // Update `lastAccess` + + return value; + }, + + /** + * Return a boolean asserting whether a value has been associated to the key in the map + * + * @param {string} key + * + * @returns {boolean} + */ + has: function(key) { + return this.data.has(key); + }, + + /** + * Return an array of keys in the map + * + * @returns {Array} + */ + keys: function() { + var list = []; + + if (this.size > 0) { + // .forEach() is used because IE11 doesn't support .keys() + this.data.forEach(function(_, key) { + list.push(key); + }); + } + + return list; + }, + + /** + * Return an array of values in the map + * + * @returns {Array} + */ + values: function() { + var list = []; + + if (this.size > 0) { + // .forEach() is used because IE11 doesn't support .values() + this.data.forEach(function(value) { + list.push(value['value']); + }); + } + + return list; + }, + + /** + * Return this map as simple object + * + * @returns {object} + */ + toObject: function() { + var obj = {}; + + if (this.size > 0) { + this.data.forEach(function (value, key) { + obj[key] = value; + }); + } + + return obj; + } + }; + +}(Icinga)); diff --git a/public/js/icinga/timer.js b/public/js/icinga/timer.js new file mode 100644 index 0000000..0fea4d9 --- /dev/null +++ b/public/js/icinga/timer.js @@ -0,0 +1,176 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.Timer + * + * Timer events are triggered once a second. Runs all reegistered callback + * functions and is able to preserve a desired scope. + */ +(function(Icinga, $) { + + 'use strict'; + + Icinga.Timer = function (icinga) { + + /** + * We keep a reference to the Icinga instance even if we don't need it + */ + this.icinga = icinga; + + /** + * The Interval object + */ + this.ticker = null; + + /** + * Fixed default interval is 250ms + */ + this.interval = 250; + + /** + * Our registerd observers + */ + this.observers = []; + + /** + * Counter + */ + this.stepCounter = 0; + + this.start = (new Date()).getTime(); + + + this.lastRuntime = []; + + this.isRunning = false; + }; + + Icinga.Timer.prototype = { + + /** + * The initialization function starts our ticker + */ + initialize: function () { + this.isRunning = true; + + var _this = this; + var f = function () { + if (_this.isRunning) { + _this.tick(); + setTimeout(f, _this.interval); + } + }; + f(); + }, + + /** + * We will trigger our tick function once a second. It will call each + * registered observer. + */ + tick: function () { + + var icinga = this.icinga; + + $.each(this.observers, function (idx, observer) { + if (observer.isDue()) { + observer.run(); + } else { + // Not due + } + }); + icinga = null; + }, + + /** + * Register a given callback function to be run within an optional scope. + */ + register: function (callback, scope, interval) { + + var observer; + + try { + + if (typeof scope === 'undefined') { + observer = new Icinga.Timer.Interval(callback, interval); + } else { + observer = new Icinga.Timer.Interval( + callback.bind(scope), + interval + ); + } + + this.observers.push(observer); + + } catch(err) { + this.icinga.logger.error(err); + } + + return observer; + }, + + unregister: function (observer) { + + var idx = $.inArray(observer, this.observers); + if (idx > -1) { + this.observers.splice(idx, 1); + } + + return this; + }, + + /** + * Our destroy function will clean up everything. Unused right now. + */ + destroy: function () { + this.isRunning = false; + + this.icinga = null; + $.each(this.observers, function (idx, observer) { + observer.destroy(); + }); + + this.observers = []; + } + }; + + Icinga.Timer.Interval = function (callback, interval) { + + if ('undefined' === typeof interval) { + throw 'Timer interval is required'; + } + + if (interval < 100) { + throw 'Timer interval cannot be less than 100ms, got ' + interval; + } + + this.lastRun = (new Date()).getTime(); + + this.interval = interval; + + this.scheduledNextRun = this.lastRun + interval; + + this.callback = callback; + }; + + Icinga.Timer.Interval.prototype = { + + isDue: function () { + return this.scheduledNextRun < (new Date()).getTime(); + }, + + run: function () { + this.lastRun = (new Date()).getTime(); + + while (this.scheduledNextRun < this.lastRun) { + this.scheduledNextRun += this.interval; + } + + this.callback(); + }, + + destroy: function () { + this.callback = null; + } + }; + +}(Icinga, jQuery)); diff --git a/public/js/icinga/timezone.js b/public/js/icinga/timezone.js new file mode 100644 index 0000000..1c2647b --- /dev/null +++ b/public/js/icinga/timezone.js @@ -0,0 +1,105 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +(function(Icinga, $) { + + 'use strict'; + + /** + * Get the maximum timezone offset + * + * @returns {Number} + */ + Date.prototype.getStdTimezoneOffset = function() { + var year = new Date().getFullYear(); + var offsetInJanuary = new Date(year, 0, 2).getTimezoneOffset(); + var offsetInJune = new Date(year, 5, 2).getTimezoneOffset(); + + return Math.max(offsetInJanuary, offsetInJune); + }; + + /** + * Test for daylight saving time zone + * + * @returns {boolean} + */ + Date.prototype.isDst = function() { + return this.getStdTimezoneOffset() !== this.getTimezoneOffset(); + }; + + /** + * Write timezone information into a cookie + * + * @constructor + */ + Icinga.Timezone = function() { + this.cookieName = 'icingaweb2-tzo'; + }; + + Icinga.Timezone.prototype = { + /** + * Initialize interface method + */ + initialize: function () { + this.writeTimezone(); + }, + + destroy: function() { + // PASS + }, + + /** + * Write timezone information into cookie + */ + writeTimezone: function() { + var date = new Date(); + var timezoneOffset = (date.getTimezoneOffset()*60) * -1; + var dst = date.isDst(); + + if (this.readCookie(this.cookieName)) { + return; + } + + this.writeCookie(this.cookieName, timezoneOffset + '-' + Number(dst), 1); + }, + + /** + * Write cookie data + * + * @param {String} name + * @param {String} value + * @param {Number} days + */ + writeCookie: function(name, value, days) { + var expires = ''; + + if (days) { + var date = new Date(); + date.setTime(date.getTime()+(days*24*60*60*1000)); + var expires = '; expires=' + date.toGMTString(); + } + document.cookie = name + '=' + value + expires + '; path=/'; + }, + + /** + * Read cookie data + * + * @param {String} name + * @returns {*} + */ + readCookie: function(name) { + var nameEq = name + '='; + var ca = document.cookie.split(';'); + for(var i=0;i < ca.length;i++) { + var c = ca[i]; + while (c.charAt(0)==' ') { + c = c.substring(1,c.length); + } + if (c.indexOf(nameEq) == 0) { + return c.substring(nameEq.length,c.length); + } + } + return null; + } + }; + +})(Icinga, jQuery); diff --git a/public/js/icinga/ui.js b/public/js/icinga/ui.js new file mode 100644 index 0000000..b41de06 --- /dev/null +++ b/public/js/icinga/ui.js @@ -0,0 +1,634 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Icinga.UI + * + * Our user interface + */ +(function(Icinga, $) { + + 'use strict'; + + Icinga.UI = function (icinga) { + + this.icinga = icinga; + + this.currentLayout = 'default'; + + this.debug = false; + + this.debugTimer = null; + + this.timeCounterTimer = null; + + // detect currentLayout + var classList = $('#layout').attr('class').split(/\s+/); + var _this = this; + var matched; + $.each(classList, function(index, item) { + if (null !== (matched = item.match(/^([a-z]+)-layout$/))) { + var layout = matched[1]; + if (layout !== 'fullscreen') { + _this.currentLayout = layout; + // Break loop + return false; + } + } + }); + }; + + Icinga.UI.prototype = { + + initialize: function () { + $('html').removeClass('no-js').addClass('js'); + this.enableTimeCounters(); + this.triggerWindowResize(); + this.fadeNotificationsAway(); + + $(document).on('click', '#mobile-menu-toggle', this.toggleMobileMenu); + $(document).on('keypress', '#search',{ self: this, type: 'key' }, this.closeMobileMenu); + $(document).on('mouseleave', '#sidebar', { self: this, type: 'leave' }, this.closeMobileMenu); + $(document).on('click', '#sidebar a', { self: this, type: 'navigate' }, this.closeMobileMenu); + }, + + fadeNotificationsAway: function() { + var icinga = this.icinga; + $('#notifications li') + .not('.fading-out') + .not('.persist') + .addClass('fading-out') + .delay(7000) + .fadeOut('slow', + function() { + $(this).remove(); + }); + }, + + toggleDebug: function() { + if (this.debug) { + return this.disableDebug(); + } else { + return this.enableDebug(); + } + }, + + enableDebug: function () { + if (this.debug === true) { return this; } + this.debug = true; + this.debugTimer = this.icinga.timer.register( + this.refreshDebug, + this, + 1000 + ); + this.fixDebugVisibility(); + + return this; + }, + + fixDebugVisibility: function () { + if (this.debug) { + $('#responsive-debug').css({display: 'block'}); + } else { + $('#responsive-debug').css({display: 'none'}); + } + return this; + }, + + disableDebug: function () { + if (this.debug === false) { return; } + + this.debug = false; + this.icinga.timer.unregister(this.debugTimer); + this.debugTimer = null; + this.fixDebugVisibility(); + return this; + }, + + reloadCss: function () { + var icinga = this.icinga; + icinga.logger.info('Reloading CSS'); + $('link').each(function() { + var $oldLink = $(this); + if ($oldLink.hasAttr('type') && $oldLink.attr('type').indexOf('css') > -1) { + var $newLink = $oldLink.clone().attr( + 'href', + icinga.utils.addUrlParams( + $oldLink.attr('href'), + { id: new Date().getTime() } // Only required for Firefox to reload CSS automatically + ) + ).on('load', function() { + $oldLink.remove(); + $('head').trigger('css-reloaded'); + }); + + $newLink.appendTo($('head')); + } + }); + }, + + enableTimeCounters: function () { + this.timeCounterTimer = this.icinga.timer.register( + this.refreshTimeSince, + this, + 1000 + ); + return this; + }, + + disableTimeCounters: function () { + this.icinga.timer.unregister(this.timeCounterTimer); + this.timeCounterTimer = null; + return this; + }, + + /** + * Focus the given element and scroll to its position + * + * @param {string} element The name or id of the element to focus + * @param {object} [$container] The container containing the element + * @param {boolean} [scroll] Whether the viewport should be scrolled to the focused element + */ + focusElement: function(element, $container, scroll) { + var $element = element; + + if (typeof scroll === 'undefined') { + scroll = true; + } + + if (typeof element === 'string') { + if ($container && $container.length) { + $element = $container.find('#' + element); + } else { + $element = $('#' + element); + } + + if (! $element.length) { + // The name attribute is actually deprecated, on anchor tags, + // but we'll possibly handle links from another source + // (module etc) so that's used as a fallback + if ($container && $container.length) { + $element = $container.find('[name="' + element.replace(/'/, '\\\'') + '"]'); + } else { + $element = $('[name="' + element.replace(/'/, '\\\'') + '"]'); + } + } + } + + if ($element.length) { + if (! this.isFocusable($element)) { + $element.attr('tabindex', -1); + } + + $element[0].focus(); + + if (scroll && $container && $container.length) { + if (! $container.is('.container')) { + $container = $container.closest('.container'); + } + + if ($container.css('display') === 'flex' && $container.is('.container')) { + var $controls = $container.find('.controls'); + var $content = $container.find('.content'); + $content.scrollTop($element.offsetTopRelativeTo($content) - $controls.outerHeight() - ( + $element.outerHeight(true) - $element.innerHeight() + )); + } else { + $container.scrollTop($element.first().position().top); + } + } + } + }, + + isFocusable: function ($element) { + return $element.is('*[tabindex], a[href], input:not([disabled]), button:not([disabled])' + + ', select:not([disabled]), textarea:not([disabled]), iframe, area[href], object' + + ', embed, *[contenteditable]'); + }, + + moveToLeft: function () { + var col2 = this.cutContainer($('#col2')); + var kill = this.cutContainer($('#col1')); + this.pasteContainer($('#col1'), col2); + this.icinga.behaviors.navigation.trySetActiveAndSelectedByUrl($('#col1').data('icingaUrl')); + }, + + moveToRight: function () { + let col1 = document.getElementById('col1'), + col2 = document.getElementById('col2'), + col1Backup = this.cutContainer($(col1)); + + this.cutContainer($(col2)); // Clear col2 states + this.pasteContainer($(col2), col1Backup); + this.layout2col(); + }, + + cutContainer: function ($col) { + var props = { + 'elements': $('#' + $col.attr('id') + ' > *').detach(), + 'data': { + 'data-icinga-url': $col.data('icingaUrl'), + 'data-icinga-title': $col.data('icingaTitle'), + 'data-icinga-refresh': $col.data('icingaRefresh'), + 'data-last-update': $col.data('lastUpdate'), + 'data-icinga-module': $col.data('icingaModule'), + 'data-icinga-container-id': $col[0].dataset.icingaContainerId + }, + 'class': $col.attr('class') + }; + this.icinga.loader.stopPendingRequestsFor($col); + $col.removeData('icingaUrl'); + $col.removeData('icingaTitle'); + $col.removeData('icingaRefresh'); + $col.removeData('lastUpdate'); + $col.removeData('icingaModule'); + delete $col[0].dataset.icingaContainerId; + $col.removeAttr('class').attr('class', 'container'); + return props; + }, + + pasteContainer: function ($col, backup) { + backup['elements'].appendTo($col); + $col.attr('class', backup['class']); // TODO: ie memleak? remove first? + $col.data('icingaUrl', backup['data']['data-icinga-url']); + $col.data('icingaTitle', backup['data']['data-icinga-title']); + $col.data('icingaRefresh', backup['data']['data-icinga-refresh']); + $col.data('lastUpdate', backup['data']['data-last-update']); + $col.data('icingaModule', backup['data']['data-icinga-module']); + $col[0].dataset.icingaContainerId = backup['data']['data-icinga-container-id']; + }, + + triggerWindowResize: function () { + this.onWindowResize({data: {self: this}}); + }, + + /** + * Our window got resized, let's fix our UI + */ + onWindowResize: function (event) { + var _this = event.data.self; + + if (_this.layoutHasBeenChanged()) { + _this.icinga.logger.info( + 'Layout change detected, switching to', + _this.currentLayout + ); + } + + _this.refreshDebug(); + }, + + /** + * Returns whether the layout is too small for more than one column + * + * @returns {boolean} True when more than one column is available + */ + hasOnlyOneColumn: function () { + return this.currentLayout === 'poor' || this.currentLayout === 'minimal'; + }, + + layoutHasBeenChanged: function () { + + var layout = $('html').css('fontFamily').replace(/['",]/g, ''); + var matched; + + if (null !== (matched = layout.match(/^([a-z]+)-layout$/))) { + if (matched[1] === this.currentLayout && + $('#layout').hasClass(layout) + ) { + return false; + } else { + $('#layout').removeClass(this.currentLayout + '-layout').addClass(layout); + this.currentLayout = matched[1]; + if (this.currentLayout === 'poor' || this.currentLayout === 'minimal') { + this.layout1col(); + } else if (this.icinga.initialized) { + // layout1col() also triggers this, that's why an else is required + $('#layout').trigger('layout-change'); + } + return true; + } + } + this.icinga.logger.error( + 'Someone messed up our responsiveness hacks, html font-family is', + layout + ); + return false; + }, + + /** + * Returns whether only one column is displayed + * + * @returns {boolean} True when only one column is displayed + */ + isOneColLayout: function () { + return ! $('#layout').hasClass('twocols'); + }, + + layout1col: function () { + if (this.isOneColLayout()) { return; } + this.icinga.logger.debug('Switching to single col'); + $('#layout').removeClass('twocols'); + this.closeContainer($('#col2')); + + if (this.icinga.initialized) { + $('#layout').trigger('layout-change'); + } + + // one-column layouts never have any selection active + $('#col1').removeData('icinga-actiontable-former-href'); + this.icinga.behaviors.actiontable.clearAll(); + }, + + closeContainer: function($c) { + $c.removeData('icingaUrl'); + $c.removeData('icingaTitle'); + $c.removeData('icingaRefresh'); + $c.removeData('lastUpdate'); + $c.removeData('icingaModule'); + delete $c[0].dataset.icingaContainerId; + $c.removeAttr('class').attr('class', 'container'); + this.icinga.loader.stopPendingRequestsFor($c); + $c.trigger('close-column'); + $c.html(''); + }, + + layout2col: function () { + if (! this.isOneColLayout()) { return; } + this.icinga.logger.debug('Switching to double col'); + $('#layout').addClass('twocols'); + + if (this.icinga.initialized) { + $('#layout').trigger('layout-change'); + } + }, + + prepareColumnFor: function ($el, $target) { + var explicitTarget; + + if ($target.attr('id') === 'col2') { + if ($el.closest('#col2').length) { + explicitTarget = $el.closest('[data-base-target]').data('baseTarget'); + if (typeof explicitTarget !== 'undefined' && explicitTarget === '_next') { + this.moveToLeft(); + } + } else { + this.layout2col(); + } + } else { // if ($target.attr('id') === 'col1') + explicitTarget = $el.closest('[data-base-target]').data('baseTarget'); + if (typeof explicitTarget !== 'undefined' && explicitTarget === '_main') { + this.layout1col(); + } + } + }, + + getAvailableColumnSpace: function () { + return $('#main').width() / this.getDefaultFontSize(); + }, + + setColumnCount: function (count) { + if (count === 3) { + $('#main > .container').css({ + width: '33.33333%' + }); + } else if (count === 2) { + $('#main > .container').css({ + width: '50%' + }); + } else { + $('#main > .container').css({ + width: '100%' + }); + } + }, + + setTitle: function (title) { + document.title = title; + return this; + }, + + getColumnCount: function () { + return $('#main > .container').length; + }, + + /** + * Assign a unique ID to each .container without such + * + * This usually applies to dashlets + */ + assignUniqueContainerIds: function() { + var currentMax = 0; + $('.container').each(function() { + var $el = $(this); + var m; + if (!$el.attr('id')) { + return; + } + if (m = $el.attr('id').match(/^ciu_(\d+)$/)) { + if (parseInt(m[1]) > currentMax) { + currentMax = parseInt(m[1]); + } + } + }); + $('.container').each(function() { + var $el = $(this); + if (!!$el.attr('id')) { + return; + } + currentMax++; + $el.attr('id', 'ciu_' + currentMax); + }); + }, + + refreshDebug: function () { + if (! this.debug) { + return; + } + + var size = this.getDefaultFontSize().toString(); + var winWidth = $( window ).width(); + var winHeight = $( window ).height(); + var loading = ''; + + $.each(this.icinga.loader.requests, function (el, req) { + if (loading === '') { + loading = '<br />Loading:<br />'; + } + loading += el + ' => ' + encodeURI(req.url); + }); + + $('#responsive-debug').html( + ' Time: ' + + this.icinga.utils.formatHHiiss(new Date()) + + '<br /> 1em: ' + + size + + 'px<br /> Win: ' + + winWidth + + 'x'+ + winHeight + + 'px<br />' + + ' Layout: ' + + this.currentLayout + + loading + ); + }, + + /** + * Refresh partial time counters + * + * This function runs every second. + */ + refreshTimeSince: function () { + $('.time-ago, .time-since').each(function (idx, el) { + var partialTime = /(\d{1,2})m (\d{1,2})s/.exec(el.innerHTML); + if (partialTime !== null) { + var minute = parseInt(partialTime[1], 10), + second = parseInt(partialTime[2], 10); + if (second < 59) { + ++second; + } else { + ++minute; + second = 0; + } + el.innerHTML = el.innerHTML.substr(0, partialTime.index) + minute.toString() + 'm ' + + second.toString() + 's' + el.innerHTML.substr(partialTime.index + partialTime[0].length); + } + }); + + $('.time-until').each(function (idx, el) { + var partialTime = /(-?)(\d{1,2})m (\d{1,2})s/.exec(el.innerHTML); + if (partialTime !== null) { + var minute = parseInt(partialTime[2], 10), + second = parseInt(partialTime[3], 10), + invert = partialTime[1]; + if (invert.length) { + // Count up because partial time is negative + if (second < 59) { + ++second; + } else { + ++minute; + second = 0; + } + } else { + // Count down because partial time is positive + if (second === 0) { + if (minute === 0) { + // Invert counter + minute = 0; + second = 1; + invert = '-'; + } else { + --minute; + second = 59; + } + } else { + --second; + } + } + el.innerHTML = el.innerHTML.substr(0, partialTime.index) + invert + minute.toString() + 'm ' + + second.toString() + 's' + el.innerHTML.substr(partialTime.index + partialTime[0].length); + } + }); + }, + + createFontSizeCalculator: function () { + var $el = $('<div id="fontsize-calc"> </div>'); + $('#layout').append($el); + return $el; + }, + + getDefaultFontSize: function () { + var $calc = $('#fontsize-calc'); + if (! $calc.length) { + $calc = this.createFontSizeCalculator(); + } + return $calc.width() / 1000; + }, + + /** + * Toggle mobile menu + * + * @param {object} e Event + */ + toggleMobileMenu: function(e) { + $('#sidebar').toggleClass('expanded'); + }, + + /** + * Close mobile menu when the enter key is pressed during search or the user leaves the sidebar + * + * @param {object} e Event + */ + closeMobileMenu: function(e) { + if (e.data.self.currentLayout !== 'minimal') { + return; + } + + if (e.data.type === 'key') { + if (e.which === 13) { + $('#sidebar').removeClass('expanded'); + $(e.target)[0].blur(); + } + } else { + $('#sidebar').removeClass('expanded'); + } + }, + + toggleFullscreen: function () { + $('#layout').toggleClass('fullscreen-layout'); + }, + + getUniqueContainerId: function (container) { + if (typeof container.jquery !== 'undefined') { + if (! container.length) { + return null; + } + + container = container[0]; + } else if (typeof container === 'undefined') { + return null; + } + + var containerId = container.dataset.icingaContainerId || null; + if (containerId === null) { + /** + * Only generate an id if it's not for col1 or the menu (which are using the non-suffixed window id). + * This is based on the assumption that the server only knows about the menu and first column + * and therefore does not need to protect its ids. (As the menu is most likely part of the sidebar) + */ + var col1 = document.getElementById('col1'); + if (container.id !== 'menu' && col1 !== null && ! col1.contains(container)) { + containerId = this.icinga.utils.generateId(6); // Random because the content may move + container.dataset.icingaContainerId = containerId; + } + } + + return containerId; + }, + + getWindowId: function () { + if (! this.hasWindowId()) { + return undefined; + } + return window.name.match(/^Icinga-([a-zA-Z0-9]+)$/)[1]; + }, + + hasWindowId: function () { + var res = window.name.match(/^Icinga-([a-zA-Z0-9]+)$/); + return typeof res === 'object' && null !== res; + }, + + setWindowId: function (id) { + this.icinga.logger.debug('Setting new window id', id); + window.name = 'Icinga-' + id; + }, + + destroy: function () { + // This is gonna be hard, clean up the mess + this.icinga = null; + this.debugTimer = null; + this.timeCounterTimer = null; + } + }; + +}(Icinga, jQuery)); diff --git a/public/js/icinga/utils.js b/public/js/icinga/utils.js new file mode 100644 index 0000000..280e4f6 --- /dev/null +++ b/public/js/icinga/utils.js @@ -0,0 +1,582 @@ +/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +/** + * Icinga utility functions + */ +(function(Icinga, $) { + + 'use strict'; + + Icinga.Utils = function (icinga) { + + /** + * Utility functions may need access to their Icinga instance + */ + this.icinga = icinga; + + /** + * We will use this to create an URL helper only once + */ + this.urlHelper = null; + }; + + Icinga.Utils.prototype = { + + timeWithMs: function (now) { + + if (typeof now === 'undefined') { + now = new Date(); + } + + var ms = now.getMilliseconds() + ''; + while (ms.length < 3) { + ms = '0' + ms; + } + + return now.toLocaleTimeString() + '.' + ms; + }, + + timeShort: function (now) { + + if (typeof now === 'undefined') { + now = new Date(); + } + + return now.toLocaleTimeString().replace(/:\d{2}$/, ''); + }, + + formatHHiiss: function (date) { + var hours = date.getHours(); + var minutes = date.getMinutes(); + var seconds = date.getSeconds(); + if (hours < 10) hours = '0' + hours; + if (minutes < 10) minutes = '0' + minutes; + if (seconds < 10) seconds = '0' + seconds; + return hours + ':' + minutes + ':' + seconds; + }, + + /** + * Format the given byte-value into a human-readable string + * + * @param {number} The amount of bytes to format + * @returns {string} The formatted string + */ + formatBytes: function (bytes) { + var log2 = Math.log(bytes) / Math.LN2; + var pot = Math.floor(log2 / 10); + var unit = (['b', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB'])[pot]; + return ((bytes / Math.pow(1024, pot)).toFixed(2)) + ' ' + unit; + }, + + /** + * Return whether the given element is visible in the users view + * + * Borrowed from: http://stackoverflow.com/q/487073 + * + * @param {selector} element The element to check + * @returns {Boolean} + */ + isVisible: function(element) { + var $element = $(element); + if (!$element.length) { + return false; + } + + var docViewTop = $(window).scrollTop(); + var docViewBottom = docViewTop + $(window).height(); + var elemTop = $element.offset().top; + var elemBottom = elemTop + $element.height(); + + return ((elemBottom >= docViewTop) && (elemTop <= docViewBottom) && + (elemBottom <= docViewBottom) && (elemTop >= docViewTop)); + }, + + getUrlHelper: function () { + if (this.urlHelper === null) { + this.urlHelper = document.createElement('a'); + } + + return this.urlHelper; + }, + + /** + * Parse a given Url and return an object + */ + parseUrl: function (url) { + + var a = this.getUrlHelper(); + a.href = url; + + var result = { + source : url, + protocol: a.protocol.replace(':', ''), + host : a.hostname, + port : a.port, + query : a.search, + file : (a.pathname.match(/\/([^\/?#]+)$/i) || [,''])[1], + hash : a.hash.replace('#',''), + path : a.pathname.replace(/^([^\/])/,'/$1'), + relative: (a.href.match(/tps?:\/\/[^\/]+(.+)/) || [,''])[1], + segments: a.pathname.replace(/^\//,'').split('/'), + params : this.parseParams(a) + }; + a = null; + + return result; + }, + + // Local URLs only + addUrlParams: function (url, params) { + var parts = this.parseUrl(url), + result = parts.path, + newparams = parts.params; + + // We overwrite existing params + $.each(params, function (key, value) { + key = encodeURIComponent(key); + value = typeof value !== 'string' || !! value ? encodeURIComponent(value) : null; + + var found = false; + for (var i = 0; i < newparams.length; i++) { + if (newparams[i].key === key) { + newparams[i].value = value; + found = true; + break; + } + } + + if (! found) { + newparams.push({ key: key, value: value }); + } + }); + + if (newparams.length) { + result += '?' + this.buildQuery(newparams); + } + + if (parts.hash.length) { + result += '#' + parts.hash; + } + + return result; + }, + + // Local URLs only + removeUrlParams: function (url, params) { + var parts = this.parseUrl(url), + result = parts.path, + newparams = parts.params; + + $.each(params, function (_, key) { + key = encodeURIComponent(key); + + for (var i = 0; i < newparams.length; i++) { + if (newparams[i].key === key) { + newparams.splice(i, 1); + return; + } + } + }); + + if (newparams.length) { + result += '?' + this.buildQuery(newparams); + } + + if (parts.hash.length) { + result += '#' + parts.hash; + } + + return result; + }, + + /** + * Return a query string for the given params + * + * @param {Array} params + * @return {string} + */ + buildQuery: function (params) { + var query = ''; + + for (var i = 0; i < params.length; i++) { + if (!! query) { + query += '&'; + } + + query += params[i].key; + switch (params[i].value) { + case true: + break; + case false: + query += '=0'; + break; + case null: + query += '='; + break; + default: + query += '=' + params[i].value; + } + } + + return query; + }, + + /** + * Parse url params + */ + parseParams: function (a) { + var params = [], + segment = a.search.replace(/^\?/,'').split('&'), + len = segment.length, + i = 0, + key, + value, + equalPos; + + for (; i < len; i++) { + if (! segment[i]) { + continue; + } + + equalPos = segment[i].indexOf('='); + if (equalPos !== -1) { + key = segment[i].slice(0, equalPos); + value = segment[i].slice(equalPos + 1); + } else { + key = segment[i]; + value = true; + } + + params.push({ key: key, value: value }); + } + + return params; + }, + + /** + * Add the specified flag to the given URL + * + * @param {string} url + * @param {string} flag + * + * @returns {string} + */ + addUrlFlag: function (url, flag) { + var pos = url.search(/#(?!!)/); + + if (url.indexOf('?') !== -1) { + flag = '&' + flag; + } else { + flag = '?' + flag; + } + + if (pos === -1) { + return url + flag; + } + + return url.slice(0, pos) + flag + url.slice(pos); + }, + + /** + * Check whether two HTMLElements overlap + * + * @param a {HTMLElement} + * @param b {HTMLElement} + * + * @returns {Boolean} whether elements overlap, will return false when one + * element is not in the DOM + */ + elementsOverlap: function(a, b) + { + // a bounds + var aoff = $(a).offset(); + if (!aoff) { + return false; + } + var at = aoff.top; + var ah = a.offsetHeight || (a.getBBox && a.getBBox().height); + var al = aoff.left; + var aw = a.offsetWidth || (a.getBBox && a.getBBox().width); + + // b bounds + var boff = $(b).offset(); + if (!boff) { + return false; + } + var bt = boff.top; + var bh = b.offsetHeight || (b.getBBox && b.getBBox().height); + var bl = boff.left; + var bw = b.offsetWidth || (b.getBBox && b.getBBox().width); + + return !(at > (bt + bh) || bt > (at + ah)) && !(bl > (al + aw) || al > (bl + bw)); + }, + + /** + * Create a selector that can be used to fetch the element the same position in the DOM-Tree + * + * Create the path to the given element in the DOM-Tree, comparable to an X-Path. Climb the + * DOM tree upwards until an element with an unique ID is found, this id is used as the anchor, + * all other elements will be addressed by their position in the parent. + * + * @param {HTMLElement} el The element to extract the path for. + * + * @returns {Array} The path of the element, that can be passed to getElementByPath + */ + getDomPath: function (el) { + if (! el) { + return []; + } + if (el.id !== '') { + return ['#' + el.id]; + } + if (el === document.body) { + return ['body']; + } + + var siblings = el.parentNode.childNodes; + var index = 0; + for (var i = 0; i < siblings.length; i ++) { + if (siblings[i].nodeType === 1) { + index ++; + } + + if (siblings[i] === el) { + var p = this.getDomPath(el.parentNode); + p.push(':nth-child(' + (index) + ')'); + return p; + } + } + }, + + /** + * Get the CSS selector to the given node + * + * @param {HTMLElement} element + * + * @returns {string} + */ + getCSSPath: function(element) { + if (typeof element === 'undefined') { + throw 'Requires a element'; + } + + if (typeof element.jquery !== 'undefined') { + if (! element.length) { + throw 'Requires a element'; + } + + element = element[0]; + } + + var path = []; + + while (true) { + let id = element.id; + if (typeof id !== 'undefined' && typeof id !== 'string') { + // Sometimes there may be a form element with the name "id" + id = element.getAttribute("id"); + } + + if (!! id) { + // Only use ids if they're truly unique + let results = document.querySelectorAll('* #' + this.escapeCSSSelector(id)); + if (results.length === 1) { + path.push('#' + id); + break; + } + } + + var tagName = element.tagName; + var parent = element.parentElement; + + if (! parent) { + path.push(tagName.toLowerCase()); + break; + } + + if (parent.children.length) { + var index = 0; + do { + if (element.tagName === tagName) { + index++; + } + } while ((element = element.previousElementSibling)); + + path.push(tagName.toLowerCase() + ':nth-of-type(' + index + ')'); + } else { + path.push(tagName.toLowerCase()); + } + + element = parent; + } + + return path.reverse().join(' > '); + }, + + /** + * Escape the given string to be used in a CSS selector + * + * @param {string} selector + * @returns {string} + */ + escapeCSSSelector: function (selector) { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(selector); + } + + return selector.replaceAll(/^(\d)/, '\\\\3$1 '); + }, + + /** + * Climbs up the given dom path and returns the element + * + * This is the counterpart + * + * @param path {Array} The selector + * @returns {HTMLElement} The corresponding element + */ + getElementByDomPath: function (path) { + var $element; + $.each(path, function (i, selector) { + if (! $element) { + $element = $(selector); + } else { + $element = $element.children(selector).first(); + if (! $element[0]) { + return false; + } + } + }); + return $element[0]; + }, + + objectKeys: Object.keys || function (obj) { + var keys = []; + $.each(obj, function (key) { + keys.push(key); + }); + return keys; + }, + + objectsEqual: function equals(obj1, obj2) { + var obj1Keys = Object.keys(obj1); + var obj2Keys = Object.keys(obj2); + if (obj1Keys.length !== obj2Keys.length) { + return false; + } + + return obj1Keys.concat(obj2Keys) + .every(function (key) { + return obj1[key] === obj2[key]; + }); + }, + + arraysEqual: function (array1, array2) { + if (array1.length !== array2.length) { + return false; + } + + var value1, value2; + for (var i = 0; i < array1.length; i++) { + value1 = array1[i]; + value2 = array2[i]; + + if (typeof value1 === 'object') { + if (typeof value2 !== 'object' || ! this.objectsEqual(value1, value2)) { + return false; + } + } else if (value1 !== value2) { + return false; + } + } + + return true; + }, + + /** + * Cleanup + */ + destroy: function () { + this.urlHelper = null; + this.icinga = null; + }, + + /** + * Encode the parenthesis too + * + * @param str {String} A component of a URI + * + * @returns {String} Encoded component + */ + fixedEncodeURIComponent: function (str) { + return encodeURIComponent(str).replace(/[()]/g, function(c) { + return '%' + c.charCodeAt(0).toString(16); + }); + }, + + escape: function (str) { + return String(str).replace( + /[&<>"']/gm, + function (c) { + return { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[c]; + } + ); + }, + + /** + * Pad a string with another one + * + * @param {String} str the string to pad + * @param {String} padding the string to use for padding + * @param {Number} minLength the minimum length of the result + * + * @returns {String} the padded string + */ + padString: function(str, padding, minLength) { + str = String(str); + padding = String(padding); + while (str.length < minLength) { + str = padding + str; + } + return str; + }, + + /** + * Shuffle a string + * + * @param {String} str The string to shuffle + * + * @returns {String} The shuffled string + */ + shuffleString: function(str) { + var a = str.split(""), + n = a.length; + + for(var i = n - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var tmp = a[i]; + a[i] = a[j]; + a[j] = tmp; + } + return a.join(""); + }, + + /** + * Generate an id + * + * @param {Number} len The desired length of the id + * + * @returns {String} The id + */ + generateId: function(len) { + return this.shuffleString('abcefghijklmnopqrstuvwxyz').substr(0, len); + } + }; + +}(Icinga, jQuery)); |