summaryrefslogtreecommitdiffstats
path: root/public/js/icinga/storage.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/js/icinga/storage.js')
-rw-r--r--public/js/icinga/storage.js549
1 files changed, 549 insertions, 0 deletions
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));