summaryrefslogtreecommitdiffstats
path: root/public/js
diff options
context:
space:
mode:
Diffstat (limited to 'public/js')
-rw-r--r--public/js/bootstrap.js28
-rw-r--r--public/js/define.js118
-rw-r--r--public/js/helpers.js91
-rw-r--r--public/js/icinga.js279
-rw-r--r--public/js/icinga/behavior/actiontable.js498
-rw-r--r--public/js/icinga/behavior/application-state.js40
-rw-r--r--public/js/icinga/behavior/autofocus.js28
-rw-r--r--public/js/icinga/behavior/collapsible.js470
-rw-r--r--public/js/icinga/behavior/copy-to-clipboard.js41
-rw-r--r--public/js/icinga/behavior/datetime-picker.js222
-rw-r--r--public/js/icinga/behavior/detach.js73
-rw-r--r--public/js/icinga/behavior/dropdown.js66
-rw-r--r--public/js/icinga/behavior/filtereditor.js77
-rw-r--r--public/js/icinga/behavior/flyover.js85
-rw-r--r--public/js/icinga/behavior/form.js96
-rw-r--r--public/js/icinga/behavior/input-enrichment.js148
-rw-r--r--public/js/icinga/behavior/modal.js254
-rw-r--r--public/js/icinga/behavior/navigation.js464
-rw-r--r--public/js/icinga/behavior/selectable.js49
-rw-r--r--public/js/icinga/eventlistener.js78
-rw-r--r--public/js/icinga/events.js425
-rw-r--r--public/js/icinga/history.js338
-rw-r--r--public/js/icinga/loader.js1367
-rw-r--r--public/js/icinga/logger.js129
-rw-r--r--public/js/icinga/module.js134
-rw-r--r--public/js/icinga/storage.js549
-rw-r--r--public/js/icinga/timer.js176
-rw-r--r--public/js/icinga/timezone.js105
-rw-r--r--public/js/icinga/ui.js645
-rw-r--r--public/js/icinga/utils.js582
-rw-r--r--public/js/logout.js16
31 files changed, 7671 insertions, 0 deletions
diff --git a/public/js/bootstrap.js b/public/js/bootstrap.js
new file mode 100644
index 0000000..425c8ab
--- /dev/null
+++ b/public/js/bootstrap.js
@@ -0,0 +1,28 @@
+;(function () {
+ let html = document.documentElement;
+ window.name = html.dataset.icingaWindowName;
+ window.icinga = new Icinga({
+ baseUrl: html.dataset.icingaBaseUrl,
+ locale: html.lang,
+ timezone: html.dataset.icingaTimezone
+ });
+
+ if (! ('icingaIsIframe' in document.documentElement.dataset)) {
+ html.classList.replace('no-js', 'js');
+ }
+
+ if (window.getComputedStyle) {
+ let matched;
+ let element = document.getElementById('layout');
+ let name = window
+ .getComputedStyle(html)['font-family']
+ .replace(/['",]/g, '');
+
+ if (null !== (matched = name.match(/^([a-z]+)-layout$/))) {
+ element.classList.replace('default-layout', name);
+ if ('object' === typeof window.console) {
+ window.console.log('Icinga Web 2: setting initial layout to ' + name);
+ }
+ }
+ }
+})();
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..8c0e2fd
--- /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" hidden 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..16f7195
--- /dev/null
+++ b/public/js/icinga/behavior/collapsible.js
@@ -0,0 +1,470 @@
+/*! 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 collapseAfter = Number(collapsible.dataset.collapseAfter)
+ if (isNaN(collapseAfter)) {
+ collapseAfter = Number(collapsible.dataset.visibleRows);
+ if (isNaN(collapseAfter)) {
+ collapseAfter = this.defaultVisibleRows;
+ }
+
+ collapseAfter *= 2;
+ }
+
+ if (collapseAfter === 0) {
+ return true;
+ }
+
+ return collapsible.querySelectorAll(rowSelector).length > collapseAfter;
+ } 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/copy-to-clipboard.js b/public/js/icinga/behavior/copy-to-clipboard.js
new file mode 100644
index 0000000..cdd2615
--- /dev/null
+++ b/public/js/icinga/behavior/copy-to-clipboard.js
@@ -0,0 +1,41 @@
+(function (Icinga) {
+
+ "use strict";
+
+ try {
+ var CopyToClipboard = require('icinga/icinga-php-library/widget/CopyToClipboard');
+ } catch (e) {
+ console.warn('Unable to provide copy to clipboard feature. Libraries not available:', e);
+ return;
+ }
+
+ class CopyToClipboardBehavior extends Icinga.EventListener {
+ constructor(icinga)
+ {
+ super(icinga);
+
+ this.on('rendered', '#main > .container', this.onRendered, this);
+
+ /**
+ * Clipboard buttons
+ *
+ * @type {WeakMap<object, CopyToClipboard>}
+ * @private
+ */
+ this._clipboards = new WeakMap();
+ }
+
+ onRendered(event)
+ {
+ let _this = event.data.self;
+
+ event.currentTarget.querySelectorAll('[data-icinga-clipboard]').forEach(button => {
+ _this._clipboards.set(button, new CopyToClipboard(button));
+ });
+ }
+ }
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ Icinga.Behaviors.CopyToClipboardBehavior = CopyToClipboardBehavior;
+})(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..4a13f31
--- /dev/null
+++ b/public/js/icinga/behavior/modal.js
@@ -0,0 +1,254 @@
+/*! 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 $redirectTarget = $a.closest('.container');
+
+ _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 container to use it as redirect target
+ $modal.data('redirectTarget', $redirectTarget);
+
+ // 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, $redirectTarget, 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) {
+ 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');
+ }
+
+ let $autoSubmittedBy;
+ if (! $autoSubmittedBy && event.detail && event.detail.submittedBy) {
+ $autoSubmittedBy = $(event.detail.submittedBy);
+ }
+
+ // Prevent our other JS from running
+ $modal[0].dataset.noIcingaAjax = '';
+
+ var req = _this.icinga.loader.submitForm($form, $autoSubmittedBy, $button);
+ req.addToHistory = false;
+ 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);
+ }
+ }).always(function () {
+ delete $modal[0].dataset.noIcingaAjax;
+ });
+
+ if (! ('baseTarget' in $form[0].dataset)) {
+ req.$redirectTarget = $modal.data('redirectTarget');
+ }
+
+ 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) {
+ let form = event.currentTarget.form;
+ let modal = form.closest('#modal');
+
+ // Prevent our other JS from running
+ modal.dataset.noIcingaAjax = '';
+
+ form.dispatchEvent(new CustomEvent('submit', {
+ cancelable: true,
+ bubbles: true,
+ detail: { submittedBy: 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..1878d8f
--- /dev/null
+++ b/public/js/icinga/events.js
@@ -0,0 +1,425 @@
+/*! 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) {
+ let form = event.currentTarget.form;
+
+ if (form.closest('[data-no-icinga-ajax]')) {
+ return;
+ }
+
+ form.dispatchEvent(new CustomEvent('submit', {
+ cancelable: true,
+ bubbles: true,
+ detail: { submittedBy: 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..97891a7
--- /dev/null
+++ b/public/js/icinga/loader.js
@@ -0,0 +1,1367 @@
+/*! 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);
+ }
+
+ // Overwrite the URL only if the form is not auto submitted
+ if (! $autoSubmittedBy && $button.hasAttr('formaction')) {
+ url = $button.attr('formaction');
+ }
+
+ 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');
+ }
+ }
+ }
+
+ let extraHeaders = {};
+ if ($autoSubmittedBy) {
+ let id;
+ if (($autoSubmittedBy.attr('name') || $autoSubmittedBy.attr('id'))) {
+ id = $autoSubmittedBy.attr('name') || $autoSubmittedBy.attr('id');
+ } else {
+ let formSelector = icinga.utils.getCSSPath($form);
+ let nearestKnownParent = $autoSubmittedBy.closest(
+ formSelector + ' [name],' + formSelector + ' [id]'
+ );
+ if (nearestKnownParent) {
+ id = nearestKnownParent.attr('name') || nearestKnownParent.attr('id');
+ }
+ }
+
+ if (id) {
+ extraHeaders['X-Icinga-AutoSubmittedBy'] = 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';
+ }
+
+ if ($target.is('#col2')) {
+ headers['X-Icinga-Col1-State'] = this.icinga.history.getCol1State();
+ headers['X-Icinga-Col2-State'] = this.icinga.history.getCol2State().replace(/^#!/, '');
+ }
+
+ // 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,
+ $redirectTarget = req.$redirectTarget,
+ 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) {
+ const _this = event.data.self;
+ let $refreshTarget = $('#col2');
+ let refreshUrl;
+
+ const 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');
+ }
+
+ const refreshReq = _this.loadUrl(refreshUrl, $refreshTarget);
+ refreshReq.autoRefreshInterval = req.getResponseHeader('X-Icinga-Refresh');
+ refreshReq.autorefresh = true;
+ refreshReq.scripted = true;
+
+ $(window).off('popstate.__back__');
+ });
+
+ // Navigate back, no redirect desired
+ window.history.back();
+
+ return true;
+ } else if (redirect.match(/__CLOSE__/)) {
+ if (req.$target.is('#col1') && req.$redirectTarget.is('#col1')) {
+ icinga.logger.warn('Cannot close #col1');
+ return false;
+ }
+
+ if (req.$redirectTarget.is('.container') && ! req.$redirectTarget.is('#main > :scope')) {
+ // If it is a container that is not a top level container, we just empty it
+ req.$redirectTarget.empty();
+ return true;
+ }
+
+ if (! req.$redirectTarget.is('#col2')) {
+ icinga.logger.debug('Cannot close container', req.$redirectTarget);
+ return false;
+ }
+
+ // Close right column as requested
+ icinga.ui.layout1col();
+
+ if (!! req.getResponseHeader('X-Icinga-Extra-Updates')) {
+ icinga.logger.debug('Not refreshing #col1 due to outstanding extra updates');
+ return true;
+ }
+
+ $redirectTarget = $('#col1');
+ redirect = icinga.history.getCol1State();
+ } else if (redirect.match(/__REFRESH__/)) {
+ if (req.$redirectTarget.is('#col1')) {
+ redirect = icinga.history.getCol1State();
+ } else if (req.$redirectTarget.is('#col2')) {
+ redirect = icinga.history.getCol2State().replace(/^#!/, '');
+ } else {
+ icinga.logger.error('Unable to refresh. Not a primary column: ', req.$redirectTarget);
+ return false;
+ }
+ }
+
+ var useHttp = req.getResponseHeader('X-Icinga-Redirect-Http');
+ if (useHttp === 'yes') {
+ window.location.replace(redirect);
+ return true;
+ }
+
+ this.redirectToUrl(redirect, $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;
+ r.referrer = referrer;
+ 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 {
+ 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;
+ var isRedirect = !! req.getResponseHeader('X-Icinga-Redirect');
+ if (target) {
+ if (target === 'ignore') {
+ return;
+ }
+
+ var $newTarget = this.identifyLinkTarget(target, req.$target);
+ if ($newTarget.length) {
+ if (isRedirect) {
+ req.$redirectTarget = $newTarget;
+ } else {
+ // 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;
+ }
+
+ if (target === 'layout') {
+ oldNotifications = $('#notifications li').detach();
+ this.icinga.ui.layout1col();
+ newBody = true;
+ } else if ($newTarget.attr('id') === 'col2') {
+ if (_this.icinga.ui.isOneColLayout()) {
+ _this.icinga.ui.layout2col();
+ } else if (target === '_next') {
+ _this.icinga.ui.moveToLeft();
+ }
+ }
+ }
+ }
+
+ 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 (isRedirect) {
+ 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 autoSubmit = false;
+ var currentUrl = this.icinga.utils.parseUrl(req.$target.data('icingaUrl'));
+ if (req.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 = $(req.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;
+ }
+
+ if (req.getResponseHeader('X-Icinga-Reload-Window') === 'yes') {
+ window.location.reload();
+ return;
+ }
+
+ 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].startsWith('#') ? parts[0] : '#' + 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');
+ if (! url) {
+ _this.icinga.logger.debug(
+ 'Superfluous extra update. The target\'s container has no url', el);
+ return;
+ }
+ } else {
+ _this.icinga.logger.error('Invalid extra update', el);
+ return;
+ }
+
+ if (url === '__CLOSE__') {
+ if ($target.is('#col2')) {
+ _this.icinga.ui.layout1col();
+ } else if ($target.is('#main > :scope')) {
+ _this.icinga.logger.warn('Invalid target ID. Cannot close ', $target);
+ } else if ($target.is('.container')) {
+ // If it is a container that is not a top level container, we just empty it
+ $target.empty();
+ }
+ } else {
+ _this.loadUrl(url, $target).addToHistory = false;
+ }
+ });
+ }
+
+ if ((textStatus === 'abort' && typeof req.referrer !== 'undefined') || this.processRedirectHeader(req)) {
+ return;
+ }
+
+ // 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.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);
+ }
+ }
+
+ 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);
+ }
+
+ 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
+ );
+
+ if (req.scripted) {
+ 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 = {
+ x: $scrollableContent.scrollTop(),
+ y: $scrollableContent.scrollLeft()
+ };
+ scrollTarget = _this.icinga.utils.getCSSPath($scrollableContent);
+ } else {
+ scrollPos = {
+ x: $container.scrollTop(),
+ y: $container.scrollLeft()
+ };
+ }
+ } else {
+ scrollPos = {
+ x: 0,
+ y: 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);
+
+ // Fallback for browsers without support for focus({preventScroll: true})
+ requestAnimationFrame(() => {
+ if ($scrollTarget.scrollTop() !== scrollPos.x) {
+ $scrollTarget.scrollTop(scrollPos.x);
+ }
+ if ($scrollTarget.scrollLeft() !== scrollPos.y) {
+ $scrollTarget.scrollLeft(scrollPos.y);
+ }
+ });
+ }
+
+ // 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..0e6ee82
--- /dev/null
+++ b/public/js/icinga/ui.js
@@ -0,0 +1,645 @@
+/*! 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'));
+ $('#col1').trigger('column-moved', 'col2');
+ },
+
+ 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();
+ $(col2).trigger('column-moved', 'col1');
+ },
+
+ 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) {
+ this.icinga.loader.stopPendingRequestsFor($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');
+ $c.trigger('close-column');
+ this.icinga.history.pushCurrentState();
+ $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.substring(0, partialTime.index) + minute.toString() + 'm '
+ + second.toString() + 's' + el.innerHTML.substring(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;
+ }
+
+ if (minute === 0 && second === 0 && el.dataset.agoLabel) {
+ el.innerText = el.dataset.agoLabel;
+ el.classList.remove('time-until');
+ el.classList.add('time-ago');
+
+ return;
+ }
+ }
+ el.innerHTML = el.innerHTML.substring(0, partialTime.index) + invert + minute.toString() + 'm '
+ + second.toString() + 's' + el.innerHTML.substring(partialTime.index + partialTime[0].length);
+ }
+ });
+ },
+
+ createFontSizeCalculator: function () {
+ var $el = $('<div id="fontsize-calc">&nbsp;</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 {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '"': '&quot;',
+ "'": '&#039;'
+ }[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));
diff --git a/public/js/logout.js b/public/js/logout.js
new file mode 100644
index 0000000..b9ac400
--- /dev/null
+++ b/public/js/logout.js
@@ -0,0 +1,16 @@
+;(function () {
+ /**
+ * When JavaScript is available, trigger an XmlHTTPRequest with the non-existing user 'logout' and abort it
+ * before it is able to finish. This will cause the browser to show a new authentication prompt in the next
+ * request.
+ */
+ document.getElementById('logout-in-progress').hidden = true;
+ document.getElementById('logout-successful').hidden = false;
+ try {
+ var xhttp = new XMLHttpRequest();
+ xhttp.open('GET', 'arbitrary url', true, 'logout', 'logout');
+ xhttp.send('');
+ xhttp.abort();
+ } catch (e) {
+ }
+})();