From 8f7af7d093408b88a9a3a02e4098909c386bc485 Mon Sep 17 00:00:00 2001 From: spalger Date: Fri, 19 Aug 2016 17:26:15 -0700 Subject: [PATCH 01/11] [state] store url states into session storage Kibana currently stores it's entire application state in the URL by rison encoding it and sticking it into a query string parameter, _a for AppState and _g for GlobalState. This has functioned fine for a while, but Internet Explorer's short URL length restriction are starting to become a problem for more and more users. To provide these users with a workaround this adds an advanced config option that will store the state in sessionStorage instead of the URL. This is accomplished by hashing the serialized version of the state, storing a short version of the hash in the URL, and storing the whole serialized state in sessionStorage using the hash + state-type as a key. Since sessionStorage is limited in size, we must clean up old stored states after they become unreachable to the application. This is done using the new `LazyLruStore` class, a wrapper around sessionStorage. This wrapper helps us maintain the list of stored states based on the time they are accessed (On each set the access time is updates). It's cleanup style is configured with it's maxItems, idealClearRatio, and maxIdealClearPercent configurations. The defaults for which should be sufficient. `maxItems`: limits the store to n items, removing the oldest item when the list overflows `idealClearRatio+maxIdealClearPercent`: when `store.setItem(key, value)` throws an error we try to clear space equal to `idealClearRatio * (key+value).length`, but no more space than `totalSize * maxIdealClearPercent` --- .../public/discover/controllers/discover.js | 23 +- src/ui/public/chrome/directives/kbn_chrome.js | 6 +- src/ui/public/crypto/index.js | 1 + src/ui/public/crypto/sha256.js | 194 ++++++++++++ .../share/directives/share_object_url.js | 11 +- .../__tests__/hashing_store.js | 139 +++++++++ .../__tests__/lazy_lru_store.js | 291 ++++++++++++++++++ .../state_management/__tests__/state.js | 119 +++++-- .../__tests__/unhash_states.js | 87 ++++++ .../public/state_management/hashing_store.js | 103 +++++++ .../public/state_management/lazy_lru_store.js | 276 +++++++++++++++++ src/ui/public/state_management/state.js | 124 +++++++- .../public/state_management/unhash_states.js | 43 +++ src/ui/public/url/__tests__/url.js | 17 +- src/ui/public/url/url.js | 2 +- src/ui/settings/defaults.js | 6 + 16 files changed, 1394 insertions(+), 48 deletions(-) create mode 100644 src/ui/public/crypto/index.js create mode 100644 src/ui/public/crypto/sha256.js create mode 100644 src/ui/public/state_management/__tests__/hashing_store.js create mode 100644 src/ui/public/state_management/__tests__/lazy_lru_store.js create mode 100644 src/ui/public/state_management/__tests__/unhash_states.js create mode 100644 src/ui/public/state_management/hashing_store.js create mode 100644 src/ui/public/state_management/lazy_lru_store.js create mode 100644 src/ui/public/state_management/unhash_states.js diff --git a/src/core_plugins/kibana/public/discover/controllers/discover.js b/src/core_plugins/kibana/public/discover/controllers/discover.js index 053e13766eaf8..111aa678b127b 100644 --- a/src/core_plugins/kibana/public/discover/controllers/discover.js +++ b/src/core_plugins/kibana/public/discover/controllers/discover.js @@ -2,7 +2,6 @@ import _ from 'lodash'; import angular from 'angular'; import moment from 'moment'; import getSort from 'ui/doc_table/lib/get_sort'; -import rison from 'rison-node'; import dateMath from '@elastic/datemath'; import 'ui/doc_table'; import 'ui/visualize'; @@ -26,8 +25,7 @@ import AggTypesBucketsIntervalOptionsProvider from 'ui/agg_types/buckets/_interv import uiRoutes from 'ui/routes'; import uiModules from 'ui/modules'; import indexTemplate from 'plugins/kibana/discover/index.html'; - - +import StateProvider from 'ui/state_management/state'; const app = uiModules.get('apps/discover', [ 'kibana/notify', @@ -43,18 +41,25 @@ uiRoutes template: indexTemplate, reloadOnSearch: false, resolve: { - ip: function (Promise, courier, config, $location) { + ip: function (Promise, courier, config, $location, Private) { + const State = Private(StateProvider); return courier.indexPatterns.getIds() .then(function (list) { - const stateRison = $location.search()._a; - - let state; - try { state = rison.decode(stateRison); } - catch (e) { state = {}; } + /** + * In making the indexPattern modifiable it was placed in appState. Unfortunately, + * the load order of AppState conflicts with the load order of many other things + * so in order to get the name of the index we should use, and to switch to the + * default if necessary, we parse the appState with a temporary State object and + * then destroy it immediatly after we're done + * + * @type {State} + */ + const state = new State('_a', {}); const specified = !!state.index; const exists = _.contains(list, state.index); const id = exists ? state.index : config.get('defaultIndex'); + state.destroy(); return Promise.props({ list: list, diff --git a/src/ui/public/chrome/directives/kbn_chrome.js b/src/ui/public/chrome/directives/kbn_chrome.js index 1ff7435e94d9a..e5c0c6480cdd4 100644 --- a/src/ui/public/chrome/directives/kbn_chrome.js +++ b/src/ui/public/chrome/directives/kbn_chrome.js @@ -4,6 +4,7 @@ import { remove } from 'lodash'; import './kbn_chrome.less'; import UiModules from 'ui/modules'; import { isSystemApiRequest } from 'ui/system_api'; +import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; export default function (chrome, internals) { @@ -28,7 +29,8 @@ export default function (chrome, internals) { }, controllerAs: 'chrome', - controller($scope, $rootScope, $location, $http) { + controller($scope, $rootScope, $location, $http, Private) { + const unhashStates = Private(UnhashStatesProvider); // are we showing the embedded version of the chrome? internals.setVisibleDefault(!$location.search().embed); @@ -36,7 +38,7 @@ export default function (chrome, internals) { // listen for route changes, propogate to tabs const onRouteChange = function () { let { href } = window.location; - internals.trackPossibleSubUrl(href); + internals.trackPossibleSubUrl(unhashStates.inAbsUrl(href)); }; $rootScope.$on('$routeChangeSuccess', onRouteChange); diff --git a/src/ui/public/crypto/index.js b/src/ui/public/crypto/index.js new file mode 100644 index 0000000000000..9951cf805cb85 --- /dev/null +++ b/src/ui/public/crypto/index.js @@ -0,0 +1 @@ +export { Sha256 } from './sha256'; diff --git a/src/ui/public/crypto/sha256.js b/src/ui/public/crypto/sha256.js new file mode 100644 index 0000000000000..697798a261a9a --- /dev/null +++ b/src/ui/public/crypto/sha256.js @@ -0,0 +1,194 @@ +// ported from https://github.com/spalger/sha.js/blob/6557630d508873e262e94bff70c50bdd797c1df7/sha256.js +// and https://github.com/spalger/sha.js/blob/6557630d508873e262e94bff70c50bdd797c1df7/hash.js + +/** + * A JavaScript implementation of the Secure Hash Algorithm, SHA-256, as defined + * in FIPS 180-2 + * Version 2.2-beta Copyright Angel Marin, Paul Johnston 2000 - 2009. + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * + */ + +const K = [ + 0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, + 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, + 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, + 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, + 0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, + 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, + 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, + 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967, + 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, + 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, + 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, + 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, + 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, + 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, + 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, + 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2, +]; + +const W = new Array(64); + +export class Sha256 { + constructor() { + this.init(); + + this._w = W; // new Array(64) + + const blockSize = 64; + const finalSize = 56; + this._block = new Buffer(blockSize); + this._finalSize = finalSize; + this._blockSize = blockSize; + this._len = 0; + this._s = 0; + } + + init() { + this._a = 0x6a09e667; + this._b = 0xbb67ae85; + this._c = 0x3c6ef372; + this._d = 0xa54ff53a; + this._e = 0x510e527f; + this._f = 0x9b05688c; + this._g = 0x1f83d9ab; + this._h = 0x5be0cd19; + + return this; + } + + update(data, enc) { + if (typeof data === 'string') { + enc = enc || 'utf8'; + data = new Buffer(data, enc); + } + + const l = this._len += data.length; + let s = this._s || 0; + let f = 0; + const buffer = this._block; + + while (s < l) { + const t = Math.min(data.length, f + this._blockSize - (s % this._blockSize)); + const ch = (t - f); + + for (let i = 0; i < ch; i++) { + buffer[(s % this._blockSize) + i] = data[i + f]; + } + + s += ch; + f += ch; + + if ((s % this._blockSize) === 0) { + this._update(buffer); + } + } + this._s = s; + + return this; + } + + digest(enc) { + // Suppose the length of the message M, in bits, is l + const l = this._len * 8; + + // Append the bit 1 to the end of the message + this._block[this._len % this._blockSize] = 0x80; + + // and then k zero bits, where k is the smallest non-negative solution to the equation (l + 1 + k) === finalSize mod blockSize + this._block.fill(0, this._len % this._blockSize + 1); + + if (l % (this._blockSize * 8) >= this._finalSize * 8) { + this._update(this._block); + this._block.fill(0); + } + + // to this append the block which is equal to the number l written in binary + // TODO: handle case where l is > Math.pow(2, 29) + this._block.writeInt32BE(l, this._blockSize - 4); + + const hash = this._update(this._block) || this._hash(); + + return enc ? hash.toString(enc) : hash; + } + + _update(M) { + const W = this._w; + + let a = this._a | 0; + let b = this._b | 0; + let c = this._c | 0; + let d = this._d | 0; + let e = this._e | 0; + let f = this._f | 0; + let g = this._g | 0; + let h = this._h | 0; + + let i; + for (i = 0; i < 16; ++i) W[i] = M.readInt32BE(i * 4); + for (; i < 64; ++i) W[i] = (gamma1(W[i - 2]) + W[i - 7] + gamma0(W[i - 15]) + W[i - 16]) | 0; + + for (let j = 0; j < 64; ++j) { + const T1 = (h + sigma1(e) + ch(e, f, g) + K[j] + W[j]) | 0; + const T2 = (sigma0(a) + maj(a, b, c)) | 0; + + h = g; + g = f; + f = e; + e = (d + T1) | 0; + d = c; + c = b; + b = a; + a = (T1 + T2) | 0; + } + + this._a = (a + this._a) | 0; + this._b = (b + this._b) | 0; + this._c = (c + this._c) | 0; + this._d = (d + this._d) | 0; + this._e = (e + this._e) | 0; + this._f = (f + this._f) | 0; + this._g = (g + this._g) | 0; + this._h = (h + this._h) | 0; + } + + _hash() { + const H = new Buffer(32); + + H.writeInt32BE(this._a, 0); + H.writeInt32BE(this._b, 4); + H.writeInt32BE(this._c, 8); + H.writeInt32BE(this._d, 12); + H.writeInt32BE(this._e, 16); + H.writeInt32BE(this._f, 20); + H.writeInt32BE(this._g, 24); + H.writeInt32BE(this._h, 28); + + return H; + } +} + +function ch(x, y, z) { + return z ^ (x & (y ^ z)); +} + +function maj(x, y, z) { + return (x & y) | (z & (x | y)); +} + +function sigma0(x) { + return (x >>> 2 | x << 30) ^ (x >>> 13 | x << 19) ^ (x >>> 22 | x << 10); +} + +function sigma1(x) { + return (x >>> 6 | x << 26) ^ (x >>> 11 | x << 21) ^ (x >>> 25 | x << 7); +} + +function gamma0(x) { + return (x >>> 7 | x << 25) ^ (x >>> 18 | x << 14) ^ (x >>> 3); +} + +function gamma1(x) { + return (x >>> 17 | x << 15) ^ (x >>> 19 | x << 13) ^ (x >>> 10); +} diff --git a/src/ui/public/share/directives/share_object_url.js b/src/ui/public/share/directives/share_object_url.js index d20a1c4c00305..c92d292d9772e 100644 --- a/src/ui/public/share/directives/share_object_url.js +++ b/src/ui/public/share/directives/share_object_url.js @@ -4,7 +4,8 @@ import '../styles/index.less'; import LibUrlShortenerProvider from '../lib/url_shortener'; import uiModules from 'ui/modules'; import shareObjectUrlTemplate from 'ui/share/views/share_object_url.html'; - +import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; +import { memoize } from 'lodash'; app.directive('shareObjectUrl', function (Private, Notifier) { const urlShortener = Private(LibUrlShortenerProvider); @@ -69,8 +70,14 @@ app.directive('shareObjectUrl', function (Private, Notifier) { }); }; + // since getUrl() is called within a watcher we cache the unhashing step + const unhashStatesInAbsUrl = memoize((absUrl) => { + return Private(UnhashStatesProvider).inAbsUrl(absUrl); + }); + $scope.getUrl = function () { - let url = $location.absUrl(); + let url = unhashStatesInAbsUrl($location.absUrl()); + if ($scope.shareAsEmbed) { url = url.replace('?', '?embed=true&'); } diff --git a/src/ui/public/state_management/__tests__/hashing_store.js b/src/ui/public/state_management/__tests__/hashing_store.js new file mode 100644 index 0000000000000..ce15fcae34827 --- /dev/null +++ b/src/ui/public/state_management/__tests__/hashing_store.js @@ -0,0 +1,139 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { encode as encodeRison } from 'rison-node'; + +import StubBrowserStorage from 'test_utils/stub_browser_storage'; +import { HashingStore } from 'ui/state_management/hashing_store'; + +const setup = ({ createHash } = {}) => { + const store = new StubBrowserStorage(); + const hashingStore = new HashingStore({ store, createHash }); + return { store, hashingStore }; +}; + +describe('State Management Hashing Store', () => { + describe('#add', () => { + it('adds a value to the store and returns its hash', () => { + const { hashingStore, store } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hash).to.be.a('string'); + expect(hash).to.be.ok(); + expect(store).to.have.length(1); + }); + + it('json encodes the values it stores', () => { + const { hashingStore, store } = setup(); + const val = { toJSON() { return 1; } }; + const hash = hashingStore.add(val); + expect(hashingStore.lookup(hash)).to.eql(1); + }); + + it('addresses values with a short hash', () => { + const val = { foo: 'bar' }; + const longHash = 'longlonglonglonglonglonglonglonglonglonghash'; + const { hashingStore } = setup({ + createHash: () => longHash + }); + + const hash = hashingStore.add(val); + expect(hash.length < longHash.length).to.be.ok(); + }); + + it('addresses values with a slightly longer hash when short hashes collide', () => { + const fixtures = [ + { + hash: '1234567890-1', + val: { foo: 'bar' } + }, + { + hash: '1234567890-2', + val: { foo: 'baz' } + }, + { + hash: '1234567890-3', + val: { foo: 'boo' } + } + ]; + + const matchVal = json => f => JSON.stringify(f.val) === json; + const { hashingStore } = setup({ + createHash: val => { + const fixture = fixtures.find(matchVal(val)); + return fixture.hash; + } + }); + + const hash1 = hashingStore.add(fixtures[0].val); + const hash2 = hashingStore.add(fixtures[1].val); + const hash3 = hashingStore.add(fixtures[2].val); + + expect(hash3).to.have.length(hash2.length + 1); + expect(hash2).to.have.length(hash1.length + 1); + }); + + it('bubbles up the error if the store fails to setItem', () => { + const { store, hashingStore } = setup(); + const err = new Error(); + sinon.stub(store, 'setItem').throws(err); + expect(() => { + hashingStore.add({}); + }).to.throwError(e => expect(e).to.be(err)); + }); + }); + + describe('#lookup', () => { + it('reads a value from the store by its hash', () => { + const { hashingStore } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hashingStore.lookup(hash)).to.eql(val); + }); + + it('returns null when the value is not in the store', () => { + const { hashingStore } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hashingStore.lookup(`${hash} break`)).to.be(null); + }); + }); + + describe('#remove', () => { + it('removes the value by its hash', () => { + const { hashingStore } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hashingStore.lookup(hash)).to.eql(val); + hashingStore.remove(hash); + expect(hashingStore.lookup(hash)).to.be(null); + }); + }); + + describe('#isHash', () => { + it('can identify values that look like hashes', () => { + const { hashingStore } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hashingStore.isHash(hash)).to.be(true); + }); + + describe('rison', () => { + const tests = [ + ['object', { foo: 'bar' }], + ['number', 1], + ['number', 1000], + ['number', Math.round(Math.random() * 10000000)], + ['string', 'this is a string'], + ['array', [1,2,3]], + ]; + + tests.forEach(([type, val]) => { + it(`is not fooled by rison ${type} "${val}"`, () => { + const { hashingStore } = setup(); + const rison = encodeRison(val); + expect(hashingStore.isHash(rison)).to.be(false); + }); + }); + }); + }); +}); diff --git a/src/ui/public/state_management/__tests__/lazy_lru_store.js b/src/ui/public/state_management/__tests__/lazy_lru_store.js new file mode 100644 index 0000000000000..175dfe014db2e --- /dev/null +++ b/src/ui/public/state_management/__tests__/lazy_lru_store.js @@ -0,0 +1,291 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { times, sum, padLeft } from 'lodash'; + +import StubBrowserStorage from 'test_utils/stub_browser_storage'; +import { LazyLruStore } from '../lazy_lru_store'; + +const setup = (opts = {}) => { + const { + id = 'testLru', + store = new StubBrowserStorage(), + maxItems, + maxSetAttempts, + idealClearRatio, + maxIdealClearPercent + } = opts; + + const lru = new LazyLruStore({ + id, + store, + maxItems, + maxSetAttempts, + idealClearRatio, + maxIdealClearPercent + }); + + return { lru, store }; +}; + +describe('State Management LazyLruStore', () => { + describe('#getItem()', () => { + it('returns null when item not found', () => { + const { lru } = setup(); + expect(lru.getItem('item1')).to.be(null); + }); + + it('returns stored value when item found', () => { + const { lru } = setup(); + lru.setItem('item1', '1'); + expect(lru.getItem('item1')).to.be('1'); + }); + }); + + describe('#setItem()', () => { + it('stores the item in the underlying store', () => { + const { lru, store } = setup(); + expect(store).to.have.length(0); + lru.setItem('item1', '1'); + expect(store).to.have.length(1); + }); + + it('makes space for new item when necessary', () => { + const { lru, store } = setup({ idealClearRatio: 1 }); + store._setSizeLimit(lru.getStorageOverhead() + 6); + lru.setItem('item1', '1'); + expect(store).to.have.length(1); + lru.setItem('item2', '2'); + expect(store).to.have.length(1); + + expect(lru.getItem('item1')).to.be(null); + expect(lru.getItem('item2')).to.be('2'); + }); + + it('overwrites existing values', () => { + const { lru, store } = setup(); + lru.setItem('item1', '1'); + expect(store).to.have.length(1); + lru.setItem('item1', '2'); + expect(store).to.have.length(1); + expect(lru.getItem('item1')).to.be('2'); + }); + + it('stores items as strings', () => { + const { lru } = setup(); + lru.setItem('item1', 1); + expect(lru.getItem('item1')).to.be('1'); + }); + + it('bubbles up the error when unable to clear the necessary space', () => { + const { lru, store } = setup(); + store._setSizeLimit(lru.getStorageOverhead() + 2); + lru.setItem('1', '1'); + sinon.stub(store, 'removeItem'); + expect(() => { + lru.setItem('2', '2'); + }).to.throwError(/quota/); + }); + }); + + describe('#removeItem()', () => { + it('removes items from the underlying store', () => { + const { lru, store } = setup(); + lru.setItem('item1', '1'); + expect(store).to.have.length(1); + lru.removeItem('item1'); + expect(store).to.have.length(0); + expect(lru.getItem('item1')).to.be(null); + }); + + it('ignores unknown items', () => { + const { lru, store } = setup(); + expect(store).to.have.length(0); + expect(() => { + lru.removeItem('item1'); + }).to.not.throwError(); + expect(store).to.have.length(0); + }); + }); + + describe('#getStorageOverhead()', () => { + it('returns the number of bytes added to each storage item, used for testing', () => { + const { store } = setup(); + const id1 = new LazyLruStore({ id: '1', store }); + const id11 = new LazyLruStore({ id: '11', store }); + expect(id1.getStorageOverhead()).to.be(id11.getStorageOverhead() - 1); + }); + }); + + describe('space management', () => { + let clock; + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('tries to clear space if setItem fails because the quota was exceeded', () => { + const { lru, store } = setup(); + const itemSize = lru.getStorageOverhead() + 10; // each item key length + val length is 10 + + store._setSizeLimit(itemSize * 3); + + lru.setItem('item1', 'item1'); + clock.tick(1); // move clock forward so removal based on time is predictable + lru.setItem('item2', 'item2'); + clock.tick(1); + lru.setItem('item3', 'item3'); + clock.tick(1); + lru.setItem('item4', 'item4'); + clock.tick(1); + lru.setItem('item5', 'item5'); + clock.tick(1); + + expect(store).to.have.length(3); + expect(lru.getItem('item1')).to.be(null); + expect(lru.getItem('item2')).to.be(null); + expect(lru.getItem('item3')).to.be('item3'); + expect(lru.getItem('item4')).to.be('item4'); + expect(lru.getItem('item5')).to.be('item5'); + }); + + context('when small items are being written to a large existing collection', () => { + context('with idealClearRatio = 6', () => { + it('clears 6 times the amount of space necessary', () => { + const { lru, store } = setup({ idealClearRatio: 6 }); + + const overhead = lru.getStorageOverhead(); + const getItemSize = i => overhead + `${i.key}${i.value}`.length; + + const items = times(100, i => { + // pad n so that 1 and 100 take up equal space in the store + const n = padLeft(i + 1, 3, '0'); + return { key: `key${n}`, value: `value${n}` }; + }); + const lastItem = items[items.length - 1]; + + // set the size limit so that the last item causes a cleanup, which + store._setSizeLimit(sum(items.map(getItemSize)) - getItemSize(lastItem)); + + for (const i of items) { + lru.setItem(i.key, i.value); + clock.tick(1); // move clock forward so removal based on time is predictable + } + + // the current ratio is 6:1, so when the last item fails + // to set, 6 items are cleared to make space for it + expect(store).to.have.length(94); + expect(lru.getItem('key001')).to.be(null); + expect(lru.getItem('key002')).to.be(null); + expect(lru.getItem('key003')).to.be(null); + expect(lru.getItem('key004')).to.be(null); + expect(lru.getItem('key005')).to.be(null); + expect(lru.getItem('key006')).to.be(null); + expect(lru.getItem('key007')).to.be('value007'); + }); + }); + + context('with idealClearRatio = 100 and maxIdealClearPercent = 0.1', () => { + it('clears 10% of the store', () => { + const { lru, store } = setup({ idealClearRatio: 100, maxIdealClearPercent: 0.1 }); + + const overhead = lru.getStorageOverhead(); + const getItemSize = i => overhead + `${i.key}${i.value}`.length; + + const items = times(100, i => { + // pad n so that 1 and 100 take up equal space in the store + const n = padLeft(i + 1, 3, '0'); + return { key: `key${n}`, value: `value${n}` }; + }); + const lastItem = items[items.length - 1]; + + // set the size limit so that the last item causes a cleanup, which + store._setSizeLimit(sum(items.map(getItemSize)) - getItemSize(lastItem)); + + for (const i of items) { + lru.setItem(i.key, i.value); + clock.tick(1); // move clock forward so removal based on time is predictable + } + + // with the ratio set to 100:1 the store will try to clear + // 100x the stored values, but that could be the entire store + // so it is limited by the maxIdealClearPercent (10% here) + // so the store should now contain values 11-100 + expect(store).to.have.length(90); + expect(lru.getItem('key001')).to.be(null); + expect(lru.getItem('key002')).to.be(null); + expect(lru.getItem('key003')).to.be(null); + expect(lru.getItem('key004')).to.be(null); + expect(lru.getItem('key005')).to.be(null); + expect(lru.getItem('key006')).to.be(null); + expect(lru.getItem('key007')).to.be(null); + expect(lru.getItem('key008')).to.be(null); + expect(lru.getItem('key009')).to.be(null); + expect(lru.getItem('key010')).to.be(null); + expect(lru.getItem('key011')).to.be('value011'); + expect(lru.getItem('key012')).to.be('value012'); + expect(lru.getItem('key100')).to.be('value100'); + }); + }); + }); + }); + + describe('maxSetAttempts setting', () => { + it('must be >= 1', () => { + expect(() => setup({ maxSetAttempts: 0 })).to.throwError(TypeError); + expect(() => setup({ maxSetAttempts: -1 })).to.throwError(TypeError); + expect(() => setup({ maxSetAttempts: 0.9 })).to.throwError(TypeError); + expect(() => setup({ maxSetAttempts: 1 })).to.not.throwError(TypeError); + }); + + context('= 1', () => { + it('will cause sets to a full storage to throw', () => { + const { lru, store } = setup({ maxSetAttempts: 1 }); + store._setSizeLimit(lru.getStorageOverhead() + 2); + lru.setItem('1', '1'); + expect(() => { + lru.setItem('2', '2'); + }).to.throwError(/quota/i); + }); + }); + + context('= 5', () => { + it('will try to set 5 times and remove 4', () => { + const { store, lru } = setup({ maxSetAttempts: 5 }); + + // trick lru into thinking it can clear space + lru.setItem('1', '1'); + // but prevent removing items + const removeStub = sinon.stub(store, 'removeItem'); + + // throw on the first 4 set attempts + const setStub = sinon.stub(store, 'setItem') + .onCall(0).throws() + .onCall(1).throws() + .onCall(2).throws() + .onCall(3).throws() + .stub; + + lru.setItem('1', '1'); + sinon.assert.callCount(removeStub, 4); + sinon.assert.callCount(setStub, 5); + }); + }); + }); + + context('with maxItems set', () => { + it('trims the list when starting with more than max items', () => { + const { store, lru: lruNoMax } = setup(); + lruNoMax.setItem('1', '1'); + lruNoMax.setItem('2', '2'); + lruNoMax.setItem('3', '3'); + lruNoMax.setItem('4', '4'); + expect(store).to.have.length(4); + + const { lru } = setup({ store, maxItems: 3 }); + expect(store).to.have.length(3); + }); + }); +}); diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index d0233594e5b44..62ffc09adf677 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -3,46 +3,71 @@ import _ from 'lodash'; import sinon from 'sinon'; import expect from 'expect.js'; import ngMock from 'ng_mock'; +import { encode as encodeRison } from 'rison-node'; import 'ui/private'; +import Notifier from 'ui/notify/notifier'; import StateManagementStateProvider from 'ui/state_management/state'; +import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; +import { HashingStore } from 'ui/state_management/hashing_store'; +import StubBrowserStorage from 'test_utils/stub_browser_storage'; import EventsProvider from 'ui/events'; describe('State Management', function () { + const notify = new Notifier(); let $rootScope; let $location; let State; let Events; + let setup; beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (_$rootScope_, _$location_, Private) { + beforeEach(ngMock.inject(function (_$rootScope_, _$location_, Private, config) { $location = _$location_; $rootScope = _$rootScope_; State = Private(StateManagementStateProvider); Events = Private(EventsProvider); + Notifier.prototype._notifs.splice(0); + + setup = opts => { + const { param, initial, storeInHash } = (opts || {}); + sinon.stub(config, 'get').withArgs('state:storeInSessionStorage').returns(!!storeInHash); + const store = new StubBrowserStorage(); + const hashingStore = new HashingStore({ store }); + const state = new State(param, initial, { hashingStore, notify }); + + const getUnhashedSearch = (state) => { + const unhashStates = Private(UnhashStatesProvider); + return unhashStates.inParsedQueryString($location.search(), [ state ]); + }; + + return { notify, store, hashingStore, state, getUnhashedSearch }; + }; })); + afterEach(() => Notifier.prototype._notifs.splice(0)); + describe('Provider', function () { it('should reset the state to the defaults', function () { - let state = new State('_s', { message: ['test'] }); + const { state, getUnhashedSearch } = setup({ initial: { message: ['test'] } }); state.reset(); - let search = $location.search(); + let search = getUnhashedSearch(state); expect(search).to.have.property('_s'); expect(search._s).to.equal('(message:!(test))'); expect(state.message).to.eql(['test']); }); it('should apply the defaults upon initialization', function () { - let state = new State('_s', { message: 'test' }); + const { state } = setup({ initial: { message: 'test' } }); expect(state).to.have.property('message', 'test'); }); it('should inherit from Events', function () { - let state = new State(); + const { state } = setup(); expect(state).to.be.an(Events); }); it('should emit an event if reset with changes', function (done) { - let state = new State('_s', { message: 'test' }); + const { state } = setup({ initial: { message: ['test'] } }); state.on('reset_with_changes', function (keys) { expect(keys).to.eql(['message']); done(); @@ -54,7 +79,7 @@ describe('State Management', function () { }); it('should not emit an event if reset without changes', function () { - let state = new State('_s', { message: 'test' }); + const { state } = setup({ initial: { message: 'test' } }); state.on('reset_with_changes', function () { expect().fail(); }); @@ -67,29 +92,29 @@ describe('State Management', function () { describe('Search', function () { it('should save to $location.search()', function () { - let state = new State('_s', { test: 'foo' }); + const { state, getUnhashedSearch } = setup({ initial: { test: 'foo' } }); state.save(); - let search = $location.search(); + let search = getUnhashedSearch(state); expect(search).to.have.property('_s'); expect(search._s).to.equal('(test:foo)'); }); it('should emit an event if changes are saved', function (done) { - let state = new State(); + const { state, getUnhashedSearch } = setup(); state.on('save_with_changes', function (keys) { expect(keys).to.eql(['test']); done(); }); state.test = 'foo'; state.save(); - let search = $location.search(); + let search = getUnhashedSearch(state); $rootScope.$apply(); }); }); describe('Fetch', function () { it('should emit an event if changes are fetched', function (done) { - let state = new State(); + const { state } = setup(); state.on('fetch_with_changes', function (keys) { expect(keys).to.eql(['foo']); done(); @@ -101,7 +126,7 @@ describe('State Management', function () { }); it('should have events that attach to scope', function (done) { - let state = new State(); + const { state } = setup(); state.on('test', function (message) { expect(message).to.equal('foo'); done(); @@ -111,7 +136,7 @@ describe('State Management', function () { }); it('should fire listeners for #onUpdate() on #fetch()', function (done) { - let state = new State(); + const { state } = setup(); state.on('fetch_with_changes', function (keys) { expect(keys).to.eql(['foo']); done(); @@ -123,7 +148,7 @@ describe('State Management', function () { }); it('should apply defaults to fetches', function () { - let state = new State('_s', { message: 'test' }); + const { state } = setup({ initial: { message: 'test' } }); $location.search({ _s: '(foo:bar)' }); state.fetch(); expect(state).to.have.property('foo', 'bar'); @@ -131,7 +156,7 @@ describe('State Management', function () { }); it('should call fetch when $routeUpdate is fired on $rootScope', function () { - let state = new State(); + const { state } = setup(); let spy = sinon.spy(state, 'fetch'); $rootScope.$emit('$routeUpdate', 'test'); sinon.assert.calledOnce(spy); @@ -139,9 +164,9 @@ describe('State Management', function () { it('should clear state when missing form URL', function () { let stateObj; - let state = new State(); + const { state } = setup(); - // set satte via URL + // set state via URL $location.search({ _s: '(foo:(bar:baz))' }); state.fetch(); stateObj = state.toObject(); @@ -160,4 +185,62 @@ describe('State Management', function () { expect(stateObj).to.eql({}); }); }); + + + describe('Hashing', () => { + it('stores state values in a hashingStore, writing the hash to the url', () => { + const { state, hashingStore } = setup({ storeInHash: true }); + state.foo = 'bar'; + state.save(); + const urlVal = $location.search()[state.getQueryParamName()]; + + expect(hashingStore.isHash(urlVal)).to.be(true); + expect(hashingStore.lookup(urlVal)).to.eql({ foo: 'bar' }); + }); + + it('should replace rison in the URL with a hash', () => { + const { state, hashingStore } = setup({ storeInHash: true }); + const obj = { foo: { bar: 'baz' } }; + const rison = encodeRison(obj); + + $location.search({ _s: rison }); + state.fetch(); + + const urlVal = $location.search()._s; + expect(urlVal).to.not.be(rison); + expect(hashingStore.isHash(urlVal)).to.be(true); + expect(hashingStore.lookup(urlVal)).to.eql(obj); + }); + + context('error handling', () => { + it('notifies the user when a hash value does not map to a stored value', () => { + const { state, hashingStore, notify } = setup({ storeInHash: true }); + const search = $location.search(); + const badHash = hashingStore.add({}); + hashingStore.remove(badHash); + + search[state.getQueryParamName()] = badHash; + $location.search(search); + + expect(notify._notifs).to.have.length(0); + state.fetch(); + expect(notify._notifs).to.have.length(1); + expect(notify._notifs[0].content).to.match(/use the share functionality/i); + }); + + it('presents fatal error linking to github when hashingStore.add fails', () => { + const { state, hashingStore, notify } = setup({ storeInHash: true }); + const fatalStub = sinon.stub(notify, 'fatal').throws(); + sinon.stub(hashingStore, 'add').throws(); + + expect(() => { + state.toQueryParam(); + }).to.throwError(); + + sinon.assert.calledOnce(fatalStub); + expect(fatalStub.firstCall.args[0]).to.be.an(Error); + expect(fatalStub.firstCall.args[0].message).to.match(/github\.com/); + }); + }); + }); }); diff --git a/src/ui/public/state_management/__tests__/unhash_states.js b/src/ui/public/state_management/__tests__/unhash_states.js new file mode 100644 index 0000000000000..19b613d57853c --- /dev/null +++ b/src/ui/public/state_management/__tests__/unhash_states.js @@ -0,0 +1,87 @@ +import expect from 'expect.js'; +import ngMock from 'ng_mock'; +import sinon from 'auto-release-sinon'; + +import StateProvider from 'ui/state_management/state'; +import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; + +describe('State Management Unhash States', () => { + let setup; + + beforeEach(ngMock.module('kibana')); + beforeEach(ngMock.inject(Private => { + setup = () => { + const unhashStates = Private(UnhashStatesProvider); + + const State = Private(StateProvider); + const testState = new State('testParam'); + sinon.stub(testState, 'translateHashToRison').withArgs('hash').returns('replacement'); + + return { unhashStates, testState }; + }; + })); + + describe('#inAbsUrl()', () => { + it('does nothing if missing input', () => { + const { unhashStates } = setup(); + expect(() => { + unhashStates.inAbsUrl(); + }).to.not.throwError(); + }); + + it('does nothing if just a host and port', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if just a path', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if just a path and query', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana?foo=bar'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if empty hash with query', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana?foo=bar#'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if empty hash without query', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana#'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if empty hash without query', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana#'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if hash is just a path', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana#/discover'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if hash does not have matching query string vals', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana#/discover?foo=bar'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('replaces query string vals in hash for matching states with output of state.toRISON()', () => { + const { unhashStates, testState } = setup(); + const url = 'https://localhost:5601/#/?foo=bar&testParam=hash'; + const exp = 'https://localhost:5601/#/?foo=bar&testParam=replacement'; + expect(unhashStates.inAbsUrl(url, [testState])).to.be(exp); + }); + }); +}); diff --git a/src/ui/public/state_management/hashing_store.js b/src/ui/public/state_management/hashing_store.js new file mode 100644 index 0000000000000..ace62df06d209 --- /dev/null +++ b/src/ui/public/state_management/hashing_store.js @@ -0,0 +1,103 @@ +import angular from 'angular'; +import { sortBy } from 'lodash'; +import { Sha256 } from 'ui/crypto'; + +import StubBrowserStorage from 'test_utils/stub_browser_storage'; +import { LazyLruStore } from './lazy_lru_store'; + +const TAG = 'h@'; + +/** + * The HashingStore is a wrapper around a browser store object + * that hashes the items added to it and stores them by their + * hash. This hash is then returned so that the item can be received + * at a later time. + */ +export class HashingStore { + constructor({ store, createHash, maxItems } = {}) { + this._store = store || window.sessionStorage; + if (createHash) this._createHash = createHash; + } + + /** + * Determine if the passed value looks like a hash + * + * @param {string} hash + * @return {boolean} + */ + isHash(hash) { + return String(hash).slice(0, TAG.length) === TAG; + } + + /** + * Find the value stored for the given hash + * + * @param {string} hash + * @return {any} + */ + lookup(hash) { + try { + return JSON.parse(this._store.getItem(hash)); + } catch (err) { + return null; + } + } + + /** + * Compute the hash of an object, store the object, and return + * the hash + * + * @param {any} the value to hash + * @return {string} the hash of the value + */ + add(object) { + const json = angular.toJson(object); + const hash = this._getShortHash(json); + this._store.setItem(hash, json); + return hash; + } + + /** + * Remove a value identified by the hash from the store + * + * @param {string} hash + * @return {undefined} + */ + remove(hash) { + this._store.removeItem(hash); + } + + // private api + + /** + * calculate the full hash of a json object + * + * @private + * @param {string} json + * @return {string} hash + */ + _createHash(json) { + return new Sha256().update(json, 'utf8').digest('hex'); + } + + /** + * Calculate the full hash for a json blob and then shorten in until + * it until it doesn't collide with other short hashes in the store + * + * @private + * @param {string} json + * @param {string} shortHash + */ + _getShortHash(json) { + const fullHash = `${TAG}${this._createHash(json)}`; + + let short; + for (let i = 7; i < fullHash.length; i++) { + short = fullHash.slice(0, i); + const existing = this._store.getItem(short); + if (existing === null || existing === json) break; + } + + return short; + } +} diff --git a/src/ui/public/state_management/lazy_lru_store.js b/src/ui/public/state_management/lazy_lru_store.js new file mode 100644 index 0000000000000..d9b00253250ea --- /dev/null +++ b/src/ui/public/state_management/lazy_lru_store.js @@ -0,0 +1,276 @@ +import { sortBy } from 'lodash'; + +import Notifier from 'ui/notify/notifier'; + +/** + * The maximum number of times that we will try to + * clear space after a call to setItem on the store fails + * + * @type {Number} + */ +const DEFAULT_MAX_SET_ATTEMPTS = 3; + +/** + * When trying to clear enough space for a key+chunk, + * multiply the necessary space by this to produce the + * "ideal" amount of space to clear. + * + * By clearing the "ideal" amount instead of just the + * necessary amount we prevent extra calls cleanup calls. + * + * The "ideal" amount is limited by the MAX_IDEAL_CLEAR_PERCENT + * + * @type {Number} + */ +const DEFAULT_IDEAL_CLEAR_RATIO = 100; + +/** + * A limit to the amount of space that can be cleared + * by the inflation caused by the IDEAL_CLEAR_RATIO + * @type {Number} + */ +const DEFAULT_MAX_IDEAL_CLEAR_PERCENT = 0.3; + +export class LazyLruStore { + constructor(opts = {}) { + const { + id, + store, + notify = new Notifier(`LazyLruStore (re: probably history hashing)`), + maxItems = Infinity, + maxSetAttempts = DEFAULT_MAX_SET_ATTEMPTS, + idealClearRatio = DEFAULT_IDEAL_CLEAR_RATIO, + maxIdealClearPercent = DEFAULT_MAX_IDEAL_CLEAR_PERCENT, + } = opts; + + if (!id) throw new TypeError('id is required'); + if (!store) throw new TypeError('store is required'); + if (maxSetAttempts < 1) throw new TypeError('maxSetAttempts must be >= 1'); + if (idealClearRatio < 1) throw new TypeError('idealClearRatio must be >= 1'); + if (maxIdealClearPercent < 0 || maxIdealClearPercent > 1) { + throw new TypeError('maxIdealClearPercent must be between 0 and 1'); + } + + this._id = id; + this._prefix = `lru:${this._id}:`; + this._store = store; + this._notify = notify; + this._maxItems = maxItems; + this._itemCountGuess = this._getItemCount(); + this._maxSetAttempts = maxSetAttempts; + this._idealClearRatio = idealClearRatio; + this._maxIdealClearPercent = maxIdealClearPercent; + + this._verifyMaxItems(); + } + + getItem(key) { + const chunk = this._store.getItem(this._getStoreKey(key)); + if (chunk === null) return null; + const { val } = this._parseChunk(chunk); + return val; + } + + setItem(key, val) { + const newKey = !this._storeHasKey(key); + this._attemptToSet(this._getStoreKey(key), this._getChunk(val)); + if (newKey) this._itemCountGuess += 1; + this._verifyMaxItems(); + } + + removeItem(key) { + if (!this._storeHasKey(key)) return; + this._store.removeItem(this._getStoreKey(key)); + this._itemCountGuess -= 1; + this._verifyMaxItems(); + } + + getStorageOverhead() { + return (this._getStoreKey('') + this._getChunk('')).length; + } + + // private api + + _getStoreKey(key) { + return `${this._prefix}${key}`; + } + + _storeHasKey(key) { + return this._store.getItem(this._getStoreKey(key)) !== null; + } + + /** + * Convert a JSON blob into a chunk, the wrapper around values + * that tells us when they were last stored + * + * @private + * @param {string} val + * @return {string} chunk + */ + _getChunk(val) { + return `${Date.now()}/${val}`; + } + + /** + * Parse a chunk into it's store time and val values + * + * @private + * @param {string} the chunk, probably read from the store + * @return {object} parsed + * @property {number} parsed.time + * @property {string} parsed.val + */ + _parseChunk(chunk) { + const splitIndex = chunk.indexOf('/'); + const time = parseInt(chunk.slice(0, splitIndex), 10); + const val = chunk.slice(splitIndex + 1); + return { time, val }; + } + + /** + * Attempt to a set a key on the store, if the setItem call + * fails then the assumption is that the store is out of space + * so we call this._makeSpaceFor(key, chunk). If this call + * reports that enough space for the key and chunk were cleared, + * then this function will call itself again, this time sending + * attempt + 1 as the attempt number. If this loop continues + * and attempt meets or exceeds the this._maxSetAttempts then a fatal + * error will be sent to notify, as the users session is no longer + * usable. + * + * @private + * @param {string} key + * @param {string} chunk + * @param {number} [attempt=1] + */ + _attemptToSet(key, chunk, attempt = 1) { + try { + this._store.setItem(key, chunk); + } catch (error) { + if (attempt >= this._maxSetAttempts) { + throw error; + } + + const madeEnoughSpace = this._makeSpaceFor(key, chunk); + if (madeEnoughSpace) { + this._attemptToSet(key, chunk, attempt + 1); + } else { + throw error; + } + } + } + + /** + * Walk all items in the store to find items stored using the same + * this._prefix. Collect the time that key was last set, and the + * byte-size of that item, and report all values found along + * with the total bytes + * + * @private + * @return {object} index + * @property {object[]} index.itemsByOldestAccess + * @property {number} index.totalBytes + */ + _indexStoredItems() { + const store = this._store; + const notify = this._notify; + + const items = []; + let totalBytes = 0; + + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + + if (key.slice(0, this._prefix.length) !== this._prefix) { + continue; + } + + const chunk = store.getItem(key); + const { time } = this._parseChunk(chunk); + const bytes = key.length + chunk.length; + items.push({ key, time, bytes }); + totalBytes += bytes; + } + + const itemsByOldestAccess = sortBy(items, 'time'); + return { itemsByOldestAccess, totalBytes }; + } + + _getItemCount() { + const { itemsByOldestAccess } = this._indexStoredItems(); + return itemsByOldestAccess.length; + } + + /** + * Check that the itemCountGuess has not exceeded the maxItems, + * if it has, trim the item list to meet the maxItem count + */ + _verifyMaxItems() { + if (this._maxItems > this._itemCountGuess) return; + + const { itemsByOldestAccess } = this._indexStoredItems(); + // update our guess to make sure it's accurate + this._itemCountGuess = itemsByOldestAccess.length; + // remove all items from the beginning of the list, leaving this._maxItems in the list + itemsByOldestAccess + .slice(0, -this._maxItems) + .forEach(item => this._doItemAutoRemoval(item)); + } + + /** + * Determine how much space to clear so that we can store the specified + * key and chunk into the store. Then clear that data and return true of + * false if we were successfull + * + * @private + * @param {string} key + * @param {string} chunk + * @return {boolean} success + */ + _makeSpaceFor(key, chunk) { + const notify = this._notify; + return notify.event(`trying to make room in lru ${this._id}`, () => { + const { totalBytes, itemsByOldestAccess } = this._indexStoredItems(); + + // pick how much space we are going to try to clear + // by finding a value that is at least the size of + // the key + chunk but up to the key + chunk * IDEAL_CLEAR_RATIO + const freeMin = key.length + chunk.length; + const freeIdeal = freeMin * this._idealClearRatio; + const toClear = Math.max(freeMin, Math.min(freeIdeal, totalBytes * this._maxIdealClearPercent)); + notify.log(`PLAN: min ${freeMin} bytes, target ${toClear} bytes`); + + let remainingToClear = toClear; + let removedItemCount = 0; + while (itemsByOldestAccess.length > 0 && remainingToClear > 0) { + const item = itemsByOldestAccess.shift(); + remainingToClear -= item.bytes; + removedItemCount += 1; + this._doItemAutoRemoval(item); + } + + const success = remainingToClear <= 0; + + const label = success ? 'SUCCESS' : 'FAILURE'; + const removedBytes = toClear - remainingToClear; + notify.log(`${label}: removed ${removedItemCount} items for ${removedBytes} bytes`); + return success; + }); + } + + /** + * Extracted helper for automated removal of items with logging + * + * @private + * @param {object} item + * @property {string} item.key + * @property {number} item.time + * @property {number} item.bytes + */ + _doItemAutoRemoval(item) { + const timeString = new Date(item.time).toISOString(); + this._notify.log(`REMOVE: entry "${item.key}" from ${timeString}, freeing ${item.bytes} bytes`); + this._store.removeItem(item.key); + this._itemCountGuess -= 1; + } +} diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index 7b934567e01ff..7196c389bc82f 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import angular from 'angular'; import rison from 'rison-node'; import applyDiff from 'ui/utils/diff_object'; import qs from 'ui/utils/query_string'; @@ -6,17 +7,29 @@ import EventsProvider from 'ui/events'; import Notifier from 'ui/notify/notifier'; import KbnUrlProvider from 'ui/url'; -const notify = new Notifier(); -export default function StateProvider(Private, $rootScope, $location) { +import { HashingStore } from './hashing_store'; +import { LazyLruStore } from './lazy_lru_store'; + +const MAX_BROWSER_HISTORY = 50; + +export default function StateProvider(Private, $rootScope, $location, config) { const Events = Private(EventsProvider); _.class(State).inherits(Events); - function State(urlParam, defaults) { + function State(urlParam, defaults, { hashingStore, notify } = {}) { State.Super.call(this); let self = this; self.setDefaults(defaults); self._urlParam = urlParam || '_s'; + this._notify = notify || new Notifier(); + self._hasher = hashingStore || new HashingStore({ + store: new LazyLruStore({ + id: `${this._urlParam}:state`, + store: window.sessionStorage, + maxItems: MAX_BROWSER_HISTORY + }) + }); // When the URL updates we need to fetch the values from the URL self._cleanUpListeners = _.partial(_.callEach, [ @@ -45,15 +58,38 @@ export default function StateProvider(Private, $rootScope, $location) { } State.prototype._readFromURL = function () { - let search = $location.search(); + const search = $location.search(); + const urlVal = search[this._urlParam]; + + if (!urlVal) { + return null; + } + + if (this._hasher.isHash(urlVal)) { + return this._parseQueryParamValue(urlVal); + } + + let risonEncoded; + let unableToParse; try { - return search[this._urlParam] ? rison.decode(search[this._urlParam]) : null; + risonEncoded = rison.decode(urlVal); } catch (e) { - notify.error('Unable to parse URL'); - search[this._urlParam] = rison.encode(this._defaults); + unableToParse = true; + } + + if (unableToParse) { + this._notify.error('Unable to parse URL'); + search[this._urlParam] = this.toQueryParam(this._defaults); $location.search(search).replace(); - return null; } + + if (risonEncoded) { + search[this._urlParam] = this.toQueryParam(risonEncoded); + $location.search(search).replace(); + return risonEncoded; + } + + return null; }; /** @@ -95,9 +131,8 @@ export default function StateProvider(Private, $rootScope, $location) { stash = {}; } - _.defaults(state, this._defaults); // apply diff to state from stash, will change state in place via side effect - let diffResults = applyDiff(stash, state); + let diffResults = applyDiff(stash, _.defaults({}, state, this._defaults)); if (diffResults.keys.length) { this.emit('save_with_changes', diffResults.keys); @@ -105,7 +140,7 @@ export default function StateProvider(Private, $rootScope, $location) { // persist the state in the URL let search = $location.search(); - search[this._urlParam] = this.toRISON(); + search[this._urlParam] = this.toQueryParam(state); if (replace) { $location.search(search).replace(); } else { @@ -149,6 +184,73 @@ export default function StateProvider(Private, $rootScope, $location) { this._defaults = defaults || {}; }; + /** + * Parse the query param value to it's unserialized + * value. Hashes are restored to their pre-hashed state. + * + * @param {string} queryParam - value from the query string + * @return {any} - the stored value, or null if hash does not resolve + */ + State.prototype._parseQueryParamValue = function (queryParam) { + if (!this._hasher.isHash(queryParam)) { + return rison.decode(queryParam); + } + + const stored = this._hasher.lookup(queryParam); + if (stored === null) { + this._notify.error('Unable to completely restore the URL, be sure to use the share functionality.'); + } + + return stored; + }; + + /** + * Lookup the value for a hash and return it's value + * in rison format + * + * @param {string} hash + * @return {string} rison + */ + State.prototype.translateHashToRison = function (hash) { + return rison.encode(this._parseQueryParamValue(hash)); + }; + + /** + * Produce the hash version of the state in it's current position + * + * @return {string} + */ + State.prototype.toQueryParam = function (state = this.toObject()) { + if (!config.get('state:storeInSessionStorage')) { + return rison.encode(state); + } + + try { + return this._hasher.add(state); + } catch (err) { + this._notify.log('Unable to create hash of State due to error: ' + (state.stack || state.message)); + this._notify.fatal( + new Error( + 'Kibana is unable to store history items in your session ' + + 'because it is full and there don\'t seem to be items any items safe ' + + 'to delete.\n' + + '\n' + + 'This can usually be fixed by moving to a fresh tab, but could ' + + 'be caused by a larger issue. If you are seeing this message regularly, ' + + 'please file an issue at https://github.com/elastic/kibana/issues.' + ) + ); + } + }; + + /** + * Get the query string parameter name where this state writes and reads + * @return {string} + */ + State.prototype.getQueryParamName = function () { + return this._urlParam; + }; + return State; }; diff --git a/src/ui/public/state_management/unhash_states.js b/src/ui/public/state_management/unhash_states.js new file mode 100644 index 0000000000000..e007a29d6d718 --- /dev/null +++ b/src/ui/public/state_management/unhash_states.js @@ -0,0 +1,43 @@ +import { parse as parseUrl, format as formatUrl } from 'url'; +import { mapValues } from 'lodash'; + +export function UnhashStatesProvider(getAppState, globalState) { + const getDefaultStates = () => [getAppState(), globalState].filter(Boolean); + + this.inAbsUrl = (urlWithHashes, states = getDefaultStates()) => { + if (!urlWithHashes) return urlWithHashes; + + const urlWithHashesParsed = parseUrl(urlWithHashes, true); + if (!urlWithHashesParsed.hostname) { + // passing a url like "localhost:5601" or "/app/kibana" should be prevented + throw new TypeError( + 'Only absolute urls should be passed to `unhashStates.inAbsUrl()`. ' + + 'Unable to detect url hostname.' + ); + } + + if (!urlWithHashesParsed.hash) return urlWithHashes; + + const appUrl = urlWithHashesParsed.hash.slice(1); // trim the # + if (!appUrl) return urlWithHashes; + + const appUrlParsed = parseUrl(urlWithHashesParsed.hash.slice(1), true); + if (!appUrlParsed.query) return urlWithHashes; + + const appQueryWithoutHashes = this.inParsedQueryString(appUrlParsed.query || {}, states); + return formatUrl({ + ...urlWithHashesParsed, + hash: formatUrl({ + pathname: appUrlParsed.pathname, + query: appQueryWithoutHashes, + }) + }); + }; + + this.inParsedQueryString = (parsedQueryString, states = getDefaultStates()) => { + return mapValues(parsedQueryString, (val, key) => { + const state = states.find(s => key === s.getQueryParamName()); + return state ? state.translateHashToRison(val) : val; + }); + }; +} diff --git a/src/ui/public/url/__tests__/url.js b/src/ui/public/url/__tests__/url.js index 57f2483b7beb4..d7385c748596a 100644 --- a/src/ui/public/url/__tests__/url.js +++ b/src/ui/public/url/__tests__/url.js @@ -15,6 +15,13 @@ let $location; let $rootScope; let appState; +class StubAppState { + constructor() { + this.getQueryParamName = () => '_a'; + this.toQueryParam = () => 'stateQueryParam'; + this.destroy = sinon.stub(); + } +} function init() { ngMock.module('kibana/url', 'kibana', function ($provide, PrivateProvider) { @@ -24,7 +31,7 @@ function init() { }; }); - appState = { destroy: sinon.stub() }; + appState = new StubAppState(); PrivateProvider.swap(AppStateProvider, $decorate => { const AppState = $decorate(); AppState.getAppState = () => appState; @@ -277,11 +284,11 @@ describe('kbnUrl', function () { expect($location.search()).to.eql(search); expect($location.hash()).to.be(hash); - kbnUrl.change(newPath, null, {foo: 'bar'}); + kbnUrl.change(newPath, null, new StubAppState()); // verify the ending state expect($location.path()).to.be(newPath); - expect($location.search()).to.eql({_a: '(foo:bar)'}); + expect($location.search()).to.eql({ _a: 'stateQueryParam' }); expect($location.hash()).to.be(''); }); }); @@ -344,11 +351,11 @@ describe('kbnUrl', function () { expect($location.search()).to.eql(search); expect($location.hash()).to.be(hash); - kbnUrl.redirect(newPath, null, {foo: 'bar'}); + kbnUrl.redirect(newPath, null, new StubAppState()); // verify the ending state expect($location.path()).to.be(newPath); - expect($location.search()).to.eql({_a: '(foo:bar)'}); + expect($location.search()).to.eql({ _a: 'stateQueryParam' }); expect($location.hash()).to.be(''); }); diff --git a/src/ui/public/url/url.js b/src/ui/public/url/url.js index ef2442730db7e..d26a8e8976e63 100644 --- a/src/ui/public/url/url.js +++ b/src/ui/public/url/url.js @@ -154,7 +154,7 @@ function KbnUrlProvider($injector, $location, $rootScope, $parse, Private) { if (replace) $location.replace(); if (appState) { - $location.search('_a', rison.encode(appState)); + $location.search(appState.getQueryParamName(), appState.toQueryParam()); } let next = { diff --git a/src/ui/settings/defaults.js b/src/ui/settings/defaults.js index 143e25032102e..fec88985c7d73 100644 --- a/src/ui/settings/defaults.js +++ b/src/ui/settings/defaults.js @@ -281,6 +281,12 @@ export default function defaultSettingsProvider() { 'timelion:quandl.key': { value: 'someKeyHere', description: 'Your API key from www.quandl.com' + }, + 'state:storeInSessionStorage': { + value: false, + description: 'The URL can sometimes grow to be too large for some browsers to ' + + 'handle. To counter-act this we are testing if storing parts of the URL in ' + + 'sessions storage could help. Please let us know how it goes!' } }; }; From 55a923dd77fa8a58668e530da863493b9bc05ff4 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Mon, 22 Aug 2016 22:33:45 -0700 Subject: [PATCH 02/11] Refactor state-hashing files into state_hashing and state_storage subdirectories. --- src/ui/public/chrome/directives/kbn_chrome.js | 12 ++- .../share/directives/share_object_url.js | 14 ++- .../state_management/__tests__/state.js | 9 +- .../__tests__/unhash_states.js | 87 ------------------- src/ui/public/state_management/state.js | 4 +- .../state_hashing/__tests__/unhash_url.js | 73 ++++++++++++++++ .../get_unhashable_states_provider.js | 5 ++ .../state_management/state_hashing/index.js | 11 +++ .../state_hashing/unhash_query_string.js | 8 ++ .../state_hashing/unhash_url.js | 36 ++++++++ .../_tests__}/hashing_store.js | 2 +- .../_tests__}/lazy_lru_store.js | 0 .../{ => state_storage}/hashing_store.js | 0 .../{ => state_storage}/lazy_lru_store.js | 0 .../public/state_management/unhash_states.js | 43 --------- 15 files changed, 158 insertions(+), 146 deletions(-) delete mode 100644 src/ui/public/state_management/__tests__/unhash_states.js create mode 100644 src/ui/public/state_management/state_hashing/__tests__/unhash_url.js create mode 100644 src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js create mode 100644 src/ui/public/state_management/state_hashing/index.js create mode 100644 src/ui/public/state_management/state_hashing/unhash_query_string.js create mode 100644 src/ui/public/state_management/state_hashing/unhash_url.js rename src/ui/public/state_management/{__tests__ => state_storage/_tests__}/hashing_store.js (98%) rename src/ui/public/state_management/{__tests__ => state_storage/_tests__}/lazy_lru_store.js (100%) rename src/ui/public/state_management/{ => state_storage}/hashing_store.js (100%) rename src/ui/public/state_management/{ => state_storage}/lazy_lru_store.js (100%) delete mode 100644 src/ui/public/state_management/unhash_states.js diff --git a/src/ui/public/chrome/directives/kbn_chrome.js b/src/ui/public/chrome/directives/kbn_chrome.js index e5c0c6480cdd4..1b191dda6809a 100644 --- a/src/ui/public/chrome/directives/kbn_chrome.js +++ b/src/ui/public/chrome/directives/kbn_chrome.js @@ -4,7 +4,10 @@ import { remove } from 'lodash'; import './kbn_chrome.less'; import UiModules from 'ui/modules'; import { isSystemApiRequest } from 'ui/system_api'; -import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; +import { + getUnhashableStatesProvider, + unhashUrl, +} from 'ui/state_management/state_hashing'; export default function (chrome, internals) { @@ -30,15 +33,16 @@ export default function (chrome, internals) { controllerAs: 'chrome', controller($scope, $rootScope, $location, $http, Private) { - const unhashStates = Private(UnhashStatesProvider); + const getUnhashableStates = Private(getUnhashableStatesProvider); // are we showing the embedded version of the chrome? internals.setVisibleDefault(!$location.search().embed); // listen for route changes, propogate to tabs const onRouteChange = function () { - let { href } = window.location; - internals.trackPossibleSubUrl(unhashStates.inAbsUrl(href)); + const urlWithHashes = window.location.href; + const urlWithStates = unhashUrl(urlWithHashes, getUnhashableStates()); + internals.trackPossibleSubUrl(urlWithStates); }; $rootScope.$on('$routeChangeSuccess', onRouteChange); diff --git a/src/ui/public/share/directives/share_object_url.js b/src/ui/public/share/directives/share_object_url.js index c92d292d9772e..8faac1cb34ad6 100644 --- a/src/ui/public/share/directives/share_object_url.js +++ b/src/ui/public/share/directives/share_object_url.js @@ -4,7 +4,10 @@ import '../styles/index.less'; import LibUrlShortenerProvider from '../lib/url_shortener'; import uiModules from 'ui/modules'; import shareObjectUrlTemplate from 'ui/share/views/share_object_url.html'; -import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; +import { + getUnhashableStatesProvider, + unhashUrl, +} from 'ui/state_management/state_hashing'; import { memoize } from 'lodash'; app.directive('shareObjectUrl', function (Private, Notifier) { @@ -71,12 +74,15 @@ app.directive('shareObjectUrl', function (Private, Notifier) { }; // since getUrl() is called within a watcher we cache the unhashing step - const unhashStatesInAbsUrl = memoize((absUrl) => { - return Private(UnhashStatesProvider).inAbsUrl(absUrl); + const unhashAndCacheUrl = memoize((urlWithHashes, unhashableStates) => { + const urlWithStates = unhashUrl(urlWithHashes, unhashableStates); + return urlWithStates; }); $scope.getUrl = function () { - let url = unhashStatesInAbsUrl($location.absUrl()); + const urlWithHashes = $location.absUrl(); + const getUnhashableStates = Private(getUnhashableStatesProvider); + let url = unhashAndCacheUrl(urlWithHashes, getUnhashableStates()); if ($scope.shareAsEmbed) { url = url.replace('?', '?embed=true&'); diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index 62ffc09adf677..3fb02df49eb92 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -7,8 +7,8 @@ import { encode as encodeRison } from 'rison-node'; import 'ui/private'; import Notifier from 'ui/notify/notifier'; import StateManagementStateProvider from 'ui/state_management/state'; -import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; -import { HashingStore } from 'ui/state_management/hashing_store'; +import { unhashQueryString } from 'ui/state_management/state_hashing'; +import { HashingStore } from 'ui/state_management/state_storage/hashing_store'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; import EventsProvider from 'ui/events'; @@ -35,9 +35,8 @@ describe('State Management', function () { const hashingStore = new HashingStore({ store }); const state = new State(param, initial, { hashingStore, notify }); - const getUnhashedSearch = (state) => { - const unhashStates = Private(UnhashStatesProvider); - return unhashStates.inParsedQueryString($location.search(), [ state ]); + const getUnhashedSearch = state => { + return unhashQueryString($location.search(), [ state ]); }; return { notify, store, hashingStore, state, getUnhashedSearch }; diff --git a/src/ui/public/state_management/__tests__/unhash_states.js b/src/ui/public/state_management/__tests__/unhash_states.js deleted file mode 100644 index 19b613d57853c..0000000000000 --- a/src/ui/public/state_management/__tests__/unhash_states.js +++ /dev/null @@ -1,87 +0,0 @@ -import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import sinon from 'auto-release-sinon'; - -import StateProvider from 'ui/state_management/state'; -import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; - -describe('State Management Unhash States', () => { - let setup; - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(Private => { - setup = () => { - const unhashStates = Private(UnhashStatesProvider); - - const State = Private(StateProvider); - const testState = new State('testParam'); - sinon.stub(testState, 'translateHashToRison').withArgs('hash').returns('replacement'); - - return { unhashStates, testState }; - }; - })); - - describe('#inAbsUrl()', () => { - it('does nothing if missing input', () => { - const { unhashStates } = setup(); - expect(() => { - unhashStates.inAbsUrl(); - }).to.not.throwError(); - }); - - it('does nothing if just a host and port', () => { - const { unhashStates } = setup(); - const url = 'https://localhost:5601'; - expect(unhashStates.inAbsUrl(url)).to.be(url); - }); - - it('does nothing if just a path', () => { - const { unhashStates } = setup(); - const url = 'https://localhost:5601/app/kibana'; - expect(unhashStates.inAbsUrl(url)).to.be(url); - }); - - it('does nothing if just a path and query', () => { - const { unhashStates } = setup(); - const url = 'https://localhost:5601/app/kibana?foo=bar'; - expect(unhashStates.inAbsUrl(url)).to.be(url); - }); - - it('does nothing if empty hash with query', () => { - const { unhashStates } = setup(); - const url = 'https://localhost:5601/app/kibana?foo=bar#'; - expect(unhashStates.inAbsUrl(url)).to.be(url); - }); - - it('does nothing if empty hash without query', () => { - const { unhashStates } = setup(); - const url = 'https://localhost:5601/app/kibana#'; - expect(unhashStates.inAbsUrl(url)).to.be(url); - }); - - it('does nothing if empty hash without query', () => { - const { unhashStates } = setup(); - const url = 'https://localhost:5601/app/kibana#'; - expect(unhashStates.inAbsUrl(url)).to.be(url); - }); - - it('does nothing if hash is just a path', () => { - const { unhashStates } = setup(); - const url = 'https://localhost:5601/app/kibana#/discover'; - expect(unhashStates.inAbsUrl(url)).to.be(url); - }); - - it('does nothing if hash does not have matching query string vals', () => { - const { unhashStates } = setup(); - const url = 'https://localhost:5601/app/kibana#/discover?foo=bar'; - expect(unhashStates.inAbsUrl(url)).to.be(url); - }); - - it('replaces query string vals in hash for matching states with output of state.toRISON()', () => { - const { unhashStates, testState } = setup(); - const url = 'https://localhost:5601/#/?foo=bar&testParam=hash'; - const exp = 'https://localhost:5601/#/?foo=bar&testParam=replacement'; - expect(unhashStates.inAbsUrl(url, [testState])).to.be(exp); - }); - }); -}); diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index 7196c389bc82f..3f42887c50fed 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -7,8 +7,8 @@ import EventsProvider from 'ui/events'; import Notifier from 'ui/notify/notifier'; import KbnUrlProvider from 'ui/url'; -import { HashingStore } from './hashing_store'; -import { LazyLruStore } from './lazy_lru_store'; +import { HashingStore } from './state_storage/hashing_store'; +import { LazyLruStore } from './state_storage/lazy_lru_store'; const MAX_BROWSER_HISTORY = 50; diff --git a/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js b/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js new file mode 100644 index 0000000000000..6cf07677b3c8e --- /dev/null +++ b/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js @@ -0,0 +1,73 @@ +import expect from 'expect.js'; +import ngMock from 'ng_mock'; +import sinon from 'auto-release-sinon'; + +import StateProvider from 'ui/state_management/state'; +import { unhashUrl } from 'ui/state_management/state_hashing'; + +describe('unhashUrl', () => { + let unhashableStates; + + beforeEach(ngMock.module('kibana')); + + beforeEach(ngMock.inject(Private => { + const State = Private(StateProvider); + const unhashableState = new State('testParam'); + sinon.stub(unhashableState, 'translateHashToRison').withArgs('hash').returns('replacement'); + unhashableStates = [unhashableState]; + })); + + describe('does nothing', () => { + it('if missing input', () => { + expect(() => { + unhashUrl(); + }).to.not.throwError(); + }); + + it('if just a host and port', () => { + const url = 'https://localhost:5601'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if just a path', () => { + const url = 'https://localhost:5601/app/kibana'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if just a path and query', () => { + const url = 'https://localhost:5601/app/kibana?foo=bar'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if empty hash with query', () => { + const url = 'https://localhost:5601/app/kibana?foo=bar#'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if empty hash without query', () => { + const url = 'https://localhost:5601/app/kibana#'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if empty hash without query', () => { + const url = 'https://localhost:5601/app/kibana#'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if hash is just a path', () => { + const url = 'https://localhost:5601/app/kibana#/discover'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if hash does not have matching query string vals', () => { + const url = 'https://localhost:5601/app/kibana#/discover?foo=bar'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + }); + + it('replaces query string vals in hash for matching states with output of state.toRISON()', () => { + const urlWithHashes = 'https://localhost:5601/#/?foo=bar&testParam=hash'; + const exp = 'https://localhost:5601/#/?foo=bar&testParam=replacement'; + expect(unhashUrl(urlWithHashes, unhashableStates)).to.be(exp); + }); +}); diff --git a/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js b/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js new file mode 100644 index 0000000000000..7dfc64f3a4398 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js @@ -0,0 +1,5 @@ +export default function getUnhashableStatesProvider(getAppState, globalState) { + return function getUnhashableStates() { + return [getAppState(), globalState].filter(Boolean); + }; +} diff --git a/src/ui/public/state_management/state_hashing/index.js b/src/ui/public/state_management/state_hashing/index.js new file mode 100644 index 0000000000000..6905a1fd28b61 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/index.js @@ -0,0 +1,11 @@ +export { + default as getUnhashableStatesProvider, +} from './get_unhashable_states_provider'; + +export { + default as unhashQueryString, +} from './unhash_query_string'; + +export { + default as unhashUrl, +} from './unhash_url'; diff --git a/src/ui/public/state_management/state_hashing/unhash_query_string.js b/src/ui/public/state_management/state_hashing/unhash_query_string.js new file mode 100644 index 0000000000000..f75dbf97e9042 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/unhash_query_string.js @@ -0,0 +1,8 @@ +import { mapValues } from 'lodash'; + +export default function unhashQueryString(parsedQueryString, states) { + return mapValues(parsedQueryString, (val, key) => { + const state = states.find(s => key === s.getQueryParamName()); + return state ? state.translateHashToRison(val) : val; + }); +} diff --git a/src/ui/public/state_management/state_hashing/unhash_url.js b/src/ui/public/state_management/state_hashing/unhash_url.js new file mode 100644 index 0000000000000..3671b653dee22 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/unhash_url.js @@ -0,0 +1,36 @@ +import { + parse as parseUrl, + format as formatUrl, +} from 'url'; + +import unhashQueryString from './unhash_query_string'; + +export default function unhashUrl(urlWithHashes, states) { + if (!urlWithHashes) return urlWithHashes; + + const urlWithHashesParsed = parseUrl(urlWithHashes, true); + if (!urlWithHashesParsed.hostname) { + // passing a url like "localhost:5601" or "/app/kibana" should be prevented + throw new TypeError( + 'Only absolute urls should be passed to `unhashUrl()`. ' + + 'Unable to detect url hostname.' + ); + } + + if (!urlWithHashesParsed.hash) return urlWithHashes; + + const appUrl = urlWithHashesParsed.hash.slice(1); // trim the # + if (!appUrl) return urlWithHashes; + + const appUrlParsed = parseUrl(urlWithHashesParsed.hash.slice(1), true); + if (!appUrlParsed.query) return urlWithHashes; + + const appQueryWithoutHashes = unhashQueryString(appUrlParsed.query || {}, states); + return formatUrl({ + ...urlWithHashesParsed, + hash: formatUrl({ + pathname: appUrlParsed.pathname, + query: appQueryWithoutHashes, + }) + }); +} diff --git a/src/ui/public/state_management/__tests__/hashing_store.js b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js similarity index 98% rename from src/ui/public/state_management/__tests__/hashing_store.js rename to src/ui/public/state_management/state_storage/_tests__/hashing_store.js index ce15fcae34827..89b0fcb7af068 100644 --- a/src/ui/public/state_management/__tests__/hashing_store.js +++ b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { encode as encodeRison } from 'rison-node'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; -import { HashingStore } from 'ui/state_management/hashing_store'; +import { HashingStore } from 'ui/state_management/state_storage/hashing_store'; const setup = ({ createHash } = {}) => { const store = new StubBrowserStorage(); diff --git a/src/ui/public/state_management/__tests__/lazy_lru_store.js b/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js similarity index 100% rename from src/ui/public/state_management/__tests__/lazy_lru_store.js rename to src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js diff --git a/src/ui/public/state_management/hashing_store.js b/src/ui/public/state_management/state_storage/hashing_store.js similarity index 100% rename from src/ui/public/state_management/hashing_store.js rename to src/ui/public/state_management/state_storage/hashing_store.js diff --git a/src/ui/public/state_management/lazy_lru_store.js b/src/ui/public/state_management/state_storage/lazy_lru_store.js similarity index 100% rename from src/ui/public/state_management/lazy_lru_store.js rename to src/ui/public/state_management/state_storage/lazy_lru_store.js diff --git a/src/ui/public/state_management/unhash_states.js b/src/ui/public/state_management/unhash_states.js deleted file mode 100644 index e007a29d6d718..0000000000000 --- a/src/ui/public/state_management/unhash_states.js +++ /dev/null @@ -1,43 +0,0 @@ -import { parse as parseUrl, format as formatUrl } from 'url'; -import { mapValues } from 'lodash'; - -export function UnhashStatesProvider(getAppState, globalState) { - const getDefaultStates = () => [getAppState(), globalState].filter(Boolean); - - this.inAbsUrl = (urlWithHashes, states = getDefaultStates()) => { - if (!urlWithHashes) return urlWithHashes; - - const urlWithHashesParsed = parseUrl(urlWithHashes, true); - if (!urlWithHashesParsed.hostname) { - // passing a url like "localhost:5601" or "/app/kibana" should be prevented - throw new TypeError( - 'Only absolute urls should be passed to `unhashStates.inAbsUrl()`. ' + - 'Unable to detect url hostname.' - ); - } - - if (!urlWithHashesParsed.hash) return urlWithHashes; - - const appUrl = urlWithHashesParsed.hash.slice(1); // trim the # - if (!appUrl) return urlWithHashes; - - const appUrlParsed = parseUrl(urlWithHashesParsed.hash.slice(1), true); - if (!appUrlParsed.query) return urlWithHashes; - - const appQueryWithoutHashes = this.inParsedQueryString(appUrlParsed.query || {}, states); - return formatUrl({ - ...urlWithHashesParsed, - hash: formatUrl({ - pathname: appUrlParsed.pathname, - query: appQueryWithoutHashes, - }) - }); - }; - - this.inParsedQueryString = (parsedQueryString, states = getDefaultStates()) => { - return mapValues(parsedQueryString, (val, key) => { - const state = states.find(s => key === s.getQueryParamName()); - return state ? state.translateHashToRison(val) : val; - }); - }; -} From df8e5ac1fa7078495b4b4ee3fd76e2cbf3af368d Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Tue, 23 Aug 2016 09:36:14 -0700 Subject: [PATCH 03/11] Export HashingStore and LazyLruStore from index module. --- src/ui/public/state_management/__tests__/state.js | 2 +- src/ui/public/state_management/state.js | 6 ++++-- .../state_storage/_tests__/hashing_store.js | 2 +- .../state_storage/_tests__/lazy_lru_store.js | 2 +- .../public/state_management/state_storage/hashing_store.js | 2 +- src/ui/public/state_management/state_storage/index.js | 7 +++++++ .../state_management/state_storage/lazy_lru_store.js | 2 +- 7 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 src/ui/public/state_management/state_storage/index.js diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index 3fb02df49eb92..c9e25b07494ac 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -8,7 +8,7 @@ import 'ui/private'; import Notifier from 'ui/notify/notifier'; import StateManagementStateProvider from 'ui/state_management/state'; import { unhashQueryString } from 'ui/state_management/state_hashing'; -import { HashingStore } from 'ui/state_management/state_storage/hashing_store'; +import { HashingStore } from 'ui/state_management/state_storage'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; import EventsProvider from 'ui/events'; diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index 3f42887c50fed..f06862010c738 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -7,8 +7,10 @@ import EventsProvider from 'ui/events'; import Notifier from 'ui/notify/notifier'; import KbnUrlProvider from 'ui/url'; -import { HashingStore } from './state_storage/hashing_store'; -import { LazyLruStore } from './state_storage/lazy_lru_store'; +import { + HashingStore, + LazyLruStore, +} from './state_storage'; const MAX_BROWSER_HISTORY = 50; diff --git a/src/ui/public/state_management/state_storage/_tests__/hashing_store.js b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js index 89b0fcb7af068..c8d42ac321cd5 100644 --- a/src/ui/public/state_management/state_storage/_tests__/hashing_store.js +++ b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { encode as encodeRison } from 'rison-node'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; -import { HashingStore } from 'ui/state_management/state_storage/hashing_store'; +import { HashingStore } from 'ui/state_management/state_storage'; const setup = ({ createHash } = {}) => { const store = new StubBrowserStorage(); diff --git a/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js b/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js index 175dfe014db2e..2fa5f5cf335f8 100644 --- a/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js +++ b/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js @@ -3,7 +3,7 @@ import sinon from 'sinon'; import { times, sum, padLeft } from 'lodash'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; -import { LazyLruStore } from '../lazy_lru_store'; +import { LazyLruStore } from '..'; const setup = (opts = {}) => { const { diff --git a/src/ui/public/state_management/state_storage/hashing_store.js b/src/ui/public/state_management/state_storage/hashing_store.js index ace62df06d209..b968f29f087ce 100644 --- a/src/ui/public/state_management/state_storage/hashing_store.js +++ b/src/ui/public/state_management/state_storage/hashing_store.js @@ -13,7 +13,7 @@ const TAG = 'h@'; * hash. This hash is then returned so that the item can be received * at a later time. */ -export class HashingStore { +export default class HashingStore { constructor({ store, createHash, maxItems } = {}) { this._store = store || window.sessionStorage; if (createHash) this._createHash = createHash; diff --git a/src/ui/public/state_management/state_storage/index.js b/src/ui/public/state_management/state_storage/index.js new file mode 100644 index 0000000000000..5c80b0f471901 --- /dev/null +++ b/src/ui/public/state_management/state_storage/index.js @@ -0,0 +1,7 @@ +export { + default as HashingStore, +} from './hashing_store'; + +export { + default as LazyLruStore, +} from './lazy_lru_store'; diff --git a/src/ui/public/state_management/state_storage/lazy_lru_store.js b/src/ui/public/state_management/state_storage/lazy_lru_store.js index d9b00253250ea..14f015287796d 100644 --- a/src/ui/public/state_management/state_storage/lazy_lru_store.js +++ b/src/ui/public/state_management/state_storage/lazy_lru_store.js @@ -31,7 +31,7 @@ const DEFAULT_IDEAL_CLEAR_RATIO = 100; */ const DEFAULT_MAX_IDEAL_CLEAR_PERCENT = 0.3; -export class LazyLruStore { +export default class LazyLruStore { constructor(opts = {}) { const { id, From ed12206f017a6a7c22677a4d561defefd5225433 Mon Sep 17 00:00:00 2001 From: spalger Date: Tue, 23 Aug 2016 13:03:53 -0700 Subject: [PATCH 04/11] [share] stop caching unhashUrl() call --- .../public/share/directives/share_object_url.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/ui/public/share/directives/share_object_url.js b/src/ui/public/share/directives/share_object_url.js index 8faac1cb34ad6..74682dd3de33c 100644 --- a/src/ui/public/share/directives/share_object_url.js +++ b/src/ui/public/share/directives/share_object_url.js @@ -12,6 +12,7 @@ import { memoize } from 'lodash'; app.directive('shareObjectUrl', function (Private, Notifier) { const urlShortener = Private(LibUrlShortenerProvider); + const getUnhashableStates = Private(getUnhashableStatesProvider); return { restrict: 'E', @@ -73,21 +74,15 @@ app.directive('shareObjectUrl', function (Private, Notifier) { }); }; - // since getUrl() is called within a watcher we cache the unhashing step - const unhashAndCacheUrl = memoize((urlWithHashes, unhashableStates) => { - const urlWithStates = unhashUrl(urlWithHashes, unhashableStates); - return urlWithStates; - }); - $scope.getUrl = function () { const urlWithHashes = $location.absUrl(); - const getUnhashableStates = Private(getUnhashableStatesProvider); - let url = unhashAndCacheUrl(urlWithHashes, getUnhashableStates()); + const urlWithStates = unhashUrl(urlWithHashes, getUnhashableStates()); if ($scope.shareAsEmbed) { - url = url.replace('?', '?embed=true&'); + return urlWithStates.replace('?', '?embed=true&'); } - return url; + + return urlWithStates; }; $scope.$watch('getUrl()', updateUrl); From b51b5cb3fe75904aebfc5b3520e0905dc6350751 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Tue, 23 Aug 2016 16:49:30 -0700 Subject: [PATCH 05/11] Rename HashingStore interface methods for consistency and clarity. Remove unused '#remove' method. --- .../state_management/__tests__/state.js | 30 ++++++------- src/ui/public/state_management/state.js | 23 +++++----- .../state_storage/_tests__/hashing_store.js | 45 +++++++------------ .../state_storage/_tests__/lazy_lru_store.js | 2 +- .../state_storage/hashing_store.js | 30 +++++-------- .../state_storage/lazy_lru_store.js | 18 ++++---- 6 files changed, 64 insertions(+), 84 deletions(-) diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index c9e25b07494ac..fd7e7ec4148a5 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -13,7 +13,7 @@ import StubBrowserStorage from 'test_utils/stub_browser_storage'; import EventsProvider from 'ui/events'; describe('State Management', function () { - const notify = new Notifier(); + const notifier = new Notifier(); let $rootScope; let $location; let State; @@ -33,13 +33,13 @@ describe('State Management', function () { sinon.stub(config, 'get').withArgs('state:storeInSessionStorage').returns(!!storeInHash); const store = new StubBrowserStorage(); const hashingStore = new HashingStore({ store }); - const state = new State(param, initial, { hashingStore, notify }); + const state = new State(param, initial, { hashingStore, notifier }); const getUnhashedSearch = state => { return unhashQueryString($location.search(), [ state ]); }; - return { notify, store, hashingStore, state, getUnhashedSearch }; + return { notifier, store, hashingStore, state, getUnhashedSearch }; }; })); @@ -185,7 +185,6 @@ describe('State Management', function () { }); }); - describe('Hashing', () => { it('stores state values in a hashingStore, writing the hash to the url', () => { const { state, hashingStore } = setup({ storeInHash: true }); @@ -194,7 +193,7 @@ describe('State Management', function () { const urlVal = $location.search()[state.getQueryParamName()]; expect(hashingStore.isHash(urlVal)).to.be(true); - expect(hashingStore.lookup(urlVal)).to.eql({ foo: 'bar' }); + expect(hashingStore.getItemAtHash(urlVal)).to.eql({ foo: 'bar' }); }); it('should replace rison in the URL with a hash', () => { @@ -208,29 +207,28 @@ describe('State Management', function () { const urlVal = $location.search()._s; expect(urlVal).to.not.be(rison); expect(hashingStore.isHash(urlVal)).to.be(true); - expect(hashingStore.lookup(urlVal)).to.eql(obj); + expect(hashingStore.getItemAtHash(urlVal)).to.eql(obj); }); context('error handling', () => { it('notifies the user when a hash value does not map to a stored value', () => { - const { state, hashingStore, notify } = setup({ storeInHash: true }); + const { state, hashingStore, notifier } = setup({ storeInHash: true }); const search = $location.search(); - const badHash = hashingStore.add({}); - hashingStore.remove(badHash); + const badHash = hashingStore._getShortHash('{"a": "b"}'); search[state.getQueryParamName()] = badHash; $location.search(search); - expect(notify._notifs).to.have.length(0); + expect(notifier._notifs).to.have.length(0); state.fetch(); - expect(notify._notifs).to.have.length(1); - expect(notify._notifs[0].content).to.match(/use the share functionality/i); + expect(notifier._notifs).to.have.length(1); + expect(notifier._notifs[0].content).to.match(/use the share functionality/i); }); - it('presents fatal error linking to github when hashingStore.add fails', () => { - const { state, hashingStore, notify } = setup({ storeInHash: true }); - const fatalStub = sinon.stub(notify, 'fatal').throws(); - sinon.stub(hashingStore, 'add').throws(); + it('presents fatal error linking to github when hashingStore.hashAndSetItem fails', () => { + const { state, hashingStore, notifier } = setup({ storeInHash: true }); + const fatalStub = sinon.stub(notifier, 'fatal').throws(); + sinon.stub(hashingStore, 'hashAndSetItem').throws(); expect(() => { state.toQueryParam(); diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index f06862010c738..f9e267b13855d 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -18,14 +18,14 @@ export default function StateProvider(Private, $rootScope, $location, config) { const Events = Private(EventsProvider); _.class(State).inherits(Events); - function State(urlParam, defaults, { hashingStore, notify } = {}) { + function State(urlParam, defaults, { hashingStore, notifier } = {}) { State.Super.call(this); let self = this; self.setDefaults(defaults); self._urlParam = urlParam || '_s'; - this._notify = notify || new Notifier(); - self._hasher = hashingStore || new HashingStore({ + this._notifier = notifier || new Notifier(); + self._hashingStore = hashingStore || new HashingStore({ store: new LazyLruStore({ id: `${this._urlParam}:state`, store: window.sessionStorage, @@ -67,7 +67,7 @@ export default function StateProvider(Private, $rootScope, $location, config) { return null; } - if (this._hasher.isHash(urlVal)) { + if (this._hashingStore.isHash(urlVal)) { return this._parseQueryParamValue(urlVal); } @@ -80,7 +80,7 @@ export default function StateProvider(Private, $rootScope, $location, config) { } if (unableToParse) { - this._notify.error('Unable to parse URL'); + this._notifier.error('Unable to parse URL'); search[this._urlParam] = this.toQueryParam(this._defaults); $location.search(search).replace(); } @@ -194,13 +194,13 @@ export default function StateProvider(Private, $rootScope, $location, config) { * @return {any} - the stored value, or null if hash does not resolve */ State.prototype._parseQueryParamValue = function (queryParam) { - if (!this._hasher.isHash(queryParam)) { + if (!this._hashingStore.isHash(queryParam)) { return rison.decode(queryParam); } - const stored = this._hasher.lookup(queryParam); + const stored = this._hashingStore.getItemAtHash(queryParam); if (stored === null) { - this._notify.error('Unable to completely restore the URL, be sure to use the share functionality.'); + this._notifier.error('Unable to completely restore the URL, be sure to use the share functionality.'); } return stored; @@ -228,10 +228,11 @@ export default function StateProvider(Private, $rootScope, $location, config) { } try { - return this._hasher.add(state); + const hash = this._hashingStore.hashAndSetItem(state); + return hash; } catch (err) { - this._notify.log('Unable to create hash of State due to error: ' + (state.stack || state.message)); - this._notify.fatal( + this._notifier.log('Unable to create hash of State due to error: ' + (state.stack || state.message)); + this._notifier.fatal( new Error( 'Kibana is unable to store history items in your session ' + 'because it is full and there don\'t seem to be items any items safe ' + diff --git a/src/ui/public/state_management/state_storage/_tests__/hashing_store.js b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js index c8d42ac321cd5..58b89e5f7d22c 100644 --- a/src/ui/public/state_management/state_storage/_tests__/hashing_store.js +++ b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js @@ -11,12 +11,12 @@ const setup = ({ createHash } = {}) => { return { store, hashingStore }; }; -describe('State Management Hashing Store', () => { - describe('#add', () => { +describe('Hashing Store', () => { + describe('#hashAndSetItem', () => { it('adds a value to the store and returns its hash', () => { const { hashingStore, store } = setup(); const val = { foo: 'bar' }; - const hash = hashingStore.add(val); + const hash = hashingStore.hashAndSetItem(val); expect(hash).to.be.a('string'); expect(hash).to.be.ok(); expect(store).to.have.length(1); @@ -25,8 +25,8 @@ describe('State Management Hashing Store', () => { it('json encodes the values it stores', () => { const { hashingStore, store } = setup(); const val = { toJSON() { return 1; } }; - const hash = hashingStore.add(val); - expect(hashingStore.lookup(hash)).to.eql(1); + const hash = hashingStore.hashAndSetItem(val); + expect(hashingStore.getItemAtHash(hash)).to.eql(1); }); it('addresses values with a short hash', () => { @@ -36,7 +36,7 @@ describe('State Management Hashing Store', () => { createHash: () => longHash }); - const hash = hashingStore.add(val); + const hash = hashingStore.hashAndSetItem(val); expect(hash.length < longHash.length).to.be.ok(); }); @@ -64,48 +64,37 @@ describe('State Management Hashing Store', () => { } }); - const hash1 = hashingStore.add(fixtures[0].val); - const hash2 = hashingStore.add(fixtures[1].val); - const hash3 = hashingStore.add(fixtures[2].val); + const hash1 = hashingStore.hashAndSetItem(fixtures[0].val); + const hash2 = hashingStore.hashAndSetItem(fixtures[1].val); + const hash3 = hashingStore.hashAndSetItem(fixtures[2].val); expect(hash3).to.have.length(hash2.length + 1); expect(hash2).to.have.length(hash1.length + 1); }); - it('bubbles up the error if the store fails to setItem', () => { + it('bubbles up the error if the store fails to hashAndSetItem', () => { const { store, hashingStore } = setup(); const err = new Error(); sinon.stub(store, 'setItem').throws(err); expect(() => { - hashingStore.add({}); + hashingStore.hashAndSetItem({}); }).to.throwError(e => expect(e).to.be(err)); }); }); - describe('#lookup', () => { + describe('#getItemAtHash', () => { it('reads a value from the store by its hash', () => { const { hashingStore } = setup(); const val = { foo: 'bar' }; - const hash = hashingStore.add(val); - expect(hashingStore.lookup(hash)).to.eql(val); + const hash = hashingStore.hashAndSetItem(val); + expect(hashingStore.getItemAtHash(hash)).to.eql(val); }); it('returns null when the value is not in the store', () => { const { hashingStore } = setup(); const val = { foo: 'bar' }; - const hash = hashingStore.add(val); - expect(hashingStore.lookup(`${hash} break`)).to.be(null); - }); - }); - - describe('#remove', () => { - it('removes the value by its hash', () => { - const { hashingStore } = setup(); - const val = { foo: 'bar' }; - const hash = hashingStore.add(val); - expect(hashingStore.lookup(hash)).to.eql(val); - hashingStore.remove(hash); - expect(hashingStore.lookup(hash)).to.be(null); + const hash = hashingStore.hashAndSetItem(val); + expect(hashingStore.getItemAtHash(`${hash} break`)).to.be(null); }); }); @@ -113,7 +102,7 @@ describe('State Management Hashing Store', () => { it('can identify values that look like hashes', () => { const { hashingStore } = setup(); const val = { foo: 'bar' }; - const hash = hashingStore.add(val); + const hash = hashingStore.hashAndSetItem(val); expect(hashingStore.isHash(hash)).to.be(true); }); diff --git a/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js b/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js index 2fa5f5cf335f8..9f1a0a4f0aec9 100644 --- a/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js +++ b/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js @@ -27,7 +27,7 @@ const setup = (opts = {}) => { return { lru, store }; }; -describe('State Management LazyLruStore', () => { +describe('LazyLruStore', () => { describe('#getItem()', () => { it('returns null when item not found', () => { const { lru } = setup(); diff --git a/src/ui/public/state_management/state_storage/hashing_store.js b/src/ui/public/state_management/state_storage/hashing_store.js index b968f29f087ce..c444e4df32fe7 100644 --- a/src/ui/public/state_management/state_storage/hashing_store.js +++ b/src/ui/public/state_management/state_storage/hashing_store.js @@ -5,15 +5,13 @@ import { Sha256 } from 'ui/crypto'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; import { LazyLruStore } from './lazy_lru_store'; -const TAG = 'h@'; - /** * The HashingStore is a wrapper around a browser store object * that hashes the items added to it and stores them by their * hash. This hash is then returned so that the item can be received * at a later time. */ -export default class HashingStore { +class HashingStore { constructor({ store, createHash, maxItems } = {}) { this._store = store || window.sessionStorage; if (createHash) this._createHash = createHash; @@ -22,11 +20,11 @@ export default class HashingStore { /** * Determine if the passed value looks like a hash * - * @param {string} hash + * @param {string} str * @return {boolean} */ - isHash(hash) { - return String(hash).slice(0, TAG.length) === TAG; + isHash(str) { + return String(str).indexOf(HashingStore.HASH_TAG) === 0; } /** @@ -35,7 +33,7 @@ export default class HashingStore { * @param {string} hash * @return {any} */ - lookup(hash) { + getItemAtHash(hash) { try { return JSON.parse(this._store.getItem(hash)); } catch (err) { @@ -50,23 +48,13 @@ export default class HashingStore { * @param {any} the value to hash * @return {string} the hash of the value */ - add(object) { + hashAndSetItem(object) { const json = angular.toJson(object); const hash = this._getShortHash(json); this._store.setItem(hash, json); return hash; } - /** - * Remove a value identified by the hash from the store - * - * @param {string} hash - * @return {undefined} - */ - remove(hash) { - this._store.removeItem(hash); - } - // private api /** @@ -89,7 +77,7 @@ export default class HashingStore { * @param {string} shortHash */ _getShortHash(json) { - const fullHash = `${TAG}${this._createHash(json)}`; + const fullHash = `${HashingStore.HASH_TAG}${this._createHash(json)}`; let short; for (let i = 7; i < fullHash.length; i++) { @@ -101,3 +89,7 @@ export default class HashingStore { return short; } } + +HashingStore.HASH_TAG = 'h@'; + +export default HashingStore; diff --git a/src/ui/public/state_management/state_storage/lazy_lru_store.js b/src/ui/public/state_management/state_storage/lazy_lru_store.js index 14f015287796d..a25489b7ac8f4 100644 --- a/src/ui/public/state_management/state_storage/lazy_lru_store.js +++ b/src/ui/public/state_management/state_storage/lazy_lru_store.js @@ -36,7 +36,7 @@ export default class LazyLruStore { const { id, store, - notify = new Notifier(`LazyLruStore (re: probably history hashing)`), + notifier = new Notifier(`LazyLruStore (re: probably history hashing)`), maxItems = Infinity, maxSetAttempts = DEFAULT_MAX_SET_ATTEMPTS, idealClearRatio = DEFAULT_IDEAL_CLEAR_RATIO, @@ -54,7 +54,7 @@ export default class LazyLruStore { this._id = id; this._prefix = `lru:${this._id}:`; this._store = store; - this._notify = notify; + this._notifier = notifier; this._maxItems = maxItems; this._itemCountGuess = this._getItemCount(); this._maxSetAttempts = maxSetAttempts; @@ -135,7 +135,7 @@ export default class LazyLruStore { * then this function will call itself again, this time sending * attempt + 1 as the attempt number. If this loop continues * and attempt meets or exceeds the this._maxSetAttempts then a fatal - * error will be sent to notify, as the users session is no longer + * error will be sent to notifier, as the users session is no longer * usable. * * @private @@ -173,7 +173,7 @@ export default class LazyLruStore { */ _indexStoredItems() { const store = this._store; - const notify = this._notify; + const notifier = this._notifier; const items = []; let totalBytes = 0; @@ -228,8 +228,8 @@ export default class LazyLruStore { * @return {boolean} success */ _makeSpaceFor(key, chunk) { - const notify = this._notify; - return notify.event(`trying to make room in lru ${this._id}`, () => { + const notifier = this._notifier; + return notifier.event(`trying to make room in lru ${this._id}`, () => { const { totalBytes, itemsByOldestAccess } = this._indexStoredItems(); // pick how much space we are going to try to clear @@ -238,7 +238,7 @@ export default class LazyLruStore { const freeMin = key.length + chunk.length; const freeIdeal = freeMin * this._idealClearRatio; const toClear = Math.max(freeMin, Math.min(freeIdeal, totalBytes * this._maxIdealClearPercent)); - notify.log(`PLAN: min ${freeMin} bytes, target ${toClear} bytes`); + notifier.log(`PLAN: min ${freeMin} bytes, target ${toClear} bytes`); let remainingToClear = toClear; let removedItemCount = 0; @@ -253,7 +253,7 @@ export default class LazyLruStore { const label = success ? 'SUCCESS' : 'FAILURE'; const removedBytes = toClear - remainingToClear; - notify.log(`${label}: removed ${removedItemCount} items for ${removedBytes} bytes`); + notifier.log(`${label}: removed ${removedItemCount} items for ${removedBytes} bytes`); return success; }); } @@ -269,7 +269,7 @@ export default class LazyLruStore { */ _doItemAutoRemoval(item) { const timeString = new Date(item.time).toISOString(); - this._notify.log(`REMOVE: entry "${item.key}" from ${timeString}, freeing ${item.bytes} bytes`); + this._notifier.log(`REMOVE: entry "${item.key}" from ${timeString}, freeing ${item.bytes} bytes`); this._store.removeItem(item.key); this._itemCountGuess -= 1; } From df2c116561d241facd5a6ba9aebdef077f8a3bf1 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Tue, 23 Aug 2016 22:17:20 -0700 Subject: [PATCH 06/11] Simplify HashingStore interface. Extract createHash method into a createStorageHash module. --- .../state_management/__tests__/state.js | 11 ++++-- src/ui/public/state_management/state.js | 25 +++++++------ .../state_storage/_tests__/hashing_store.js | 36 +++++++++---------- .../state_storage/create_storage_hash.js | 5 +++ .../state_storage/hashing_store.js | 27 +++----------- .../state_management/state_storage/index.js | 4 +++ 6 files changed, 53 insertions(+), 55 deletions(-) create mode 100644 src/ui/public/state_management/state_storage/create_storage_hash.js diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index fd7e7ec4148a5..261ab27f48928 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -7,8 +7,13 @@ import { encode as encodeRison } from 'rison-node'; import 'ui/private'; import Notifier from 'ui/notify/notifier'; import StateManagementStateProvider from 'ui/state_management/state'; -import { unhashQueryString } from 'ui/state_management/state_hashing'; -import { HashingStore } from 'ui/state_management/state_storage'; +import { + unhashQueryString, +} from 'ui/state_management/state_hashing'; +import { + createStorageHash, + HashingStore, +} from 'ui/state_management/state_storage'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; import EventsProvider from 'ui/events'; @@ -32,7 +37,7 @@ describe('State Management', function () { const { param, initial, storeInHash } = (opts || {}); sinon.stub(config, 'get').withArgs('state:storeInSessionStorage').returns(!!storeInHash); const store = new StubBrowserStorage(); - const hashingStore = new HashingStore({ store }); + const hashingStore = new HashingStore(createStorageHash, store); const state = new State(param, initial, { hashingStore, notifier }); const getUnhashedSearch = state => { diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index f9e267b13855d..cf05585431054 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -8,6 +8,7 @@ import Notifier from 'ui/notify/notifier'; import KbnUrlProvider from 'ui/url'; import { + createStorageHash, HashingStore, LazyLruStore, } from './state_storage'; @@ -21,34 +22,36 @@ export default function StateProvider(Private, $rootScope, $location, config) { function State(urlParam, defaults, { hashingStore, notifier } = {}) { State.Super.call(this); - let self = this; - self.setDefaults(defaults); - self._urlParam = urlParam || '_s'; + this.setDefaults(defaults); + this._urlParam = urlParam || '_s'; this._notifier = notifier || new Notifier(); - self._hashingStore = hashingStore || new HashingStore({ - store: new LazyLruStore({ + + this._hashingStore = hashingStore || (() => { + const lazyLruStore = new LazyLruStore({ id: `${this._urlParam}:state`, store: window.sessionStorage, maxItems: MAX_BROWSER_HISTORY - }) - }); + }); + + return new HashingStore(createStorageHash, lazyLruStore); + })(); // When the URL updates we need to fetch the values from the URL - self._cleanUpListeners = _.partial(_.callEach, [ + this._cleanUpListeners = _.partial(_.callEach, [ // partial route update, no app reload - $rootScope.$on('$routeUpdate', function () { + $rootScope.$on('$routeUpdate', () => { self.fetch(); }), // beginning of full route update, new app will be initialized before // $routeChangeSuccess or $routeChangeError - $rootScope.$on('$routeChangeStart', function () { + $rootScope.$on('$routeChangeStart', () => { if (!self._persistAcrossApps) { self.destroy(); } }), - $rootScope.$on('$routeChangeSuccess', function () { + $rootScope.$on('$routeChangeSuccess', () => { if (self._persistAcrossApps) { self.fetch(); } diff --git a/src/ui/public/state_management/state_storage/_tests__/hashing_store.js b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js index 58b89e5f7d22c..d80ac3d7dc14a 100644 --- a/src/ui/public/state_management/state_storage/_tests__/hashing_store.js +++ b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js @@ -1,20 +1,22 @@ import expect from 'expect.js'; import sinon from 'sinon'; import { encode as encodeRison } from 'rison-node'; - import StubBrowserStorage from 'test_utils/stub_browser_storage'; -import { HashingStore } from 'ui/state_management/state_storage'; +import { + createStorageHash, + HashingStore, +} from 'ui/state_management/state_storage'; -const setup = ({ createHash } = {}) => { +const setup = createStorageHash => { const store = new StubBrowserStorage(); - const hashingStore = new HashingStore({ store, createHash }); + const hashingStore = new HashingStore(createStorageHash, store); return { store, hashingStore }; }; describe('Hashing Store', () => { describe('#hashAndSetItem', () => { it('adds a value to the store and returns its hash', () => { - const { hashingStore, store } = setup(); + const { hashingStore, store } = setup(createStorageHash); const val = { foo: 'bar' }; const hash = hashingStore.hashAndSetItem(val); expect(hash).to.be.a('string'); @@ -23,7 +25,7 @@ describe('Hashing Store', () => { }); it('json encodes the values it stores', () => { - const { hashingStore, store } = setup(); + const { hashingStore, store } = setup(createStorageHash); const val = { toJSON() { return 1; } }; const hash = hashingStore.hashAndSetItem(val); expect(hashingStore.getItemAtHash(hash)).to.eql(1); @@ -32,9 +34,7 @@ describe('Hashing Store', () => { it('addresses values with a short hash', () => { const val = { foo: 'bar' }; const longHash = 'longlonglonglonglonglonglonglonglonglonghash'; - const { hashingStore } = setup({ - createHash: () => longHash - }); + const { hashingStore } = setup(() => longHash); const hash = hashingStore.hashAndSetItem(val); expect(hash.length < longHash.length).to.be.ok(); @@ -57,11 +57,9 @@ describe('Hashing Store', () => { ]; const matchVal = json => f => JSON.stringify(f.val) === json; - const { hashingStore } = setup({ - createHash: val => { - const fixture = fixtures.find(matchVal(val)); - return fixture.hash; - } + const { hashingStore } = setup(val => { + const fixture = fixtures.find(matchVal(val)); + return fixture.hash; }); const hash1 = hashingStore.hashAndSetItem(fixtures[0].val); @@ -73,7 +71,7 @@ describe('Hashing Store', () => { }); it('bubbles up the error if the store fails to hashAndSetItem', () => { - const { store, hashingStore } = setup(); + const { store, hashingStore } = setup(createStorageHash); const err = new Error(); sinon.stub(store, 'setItem').throws(err); expect(() => { @@ -84,14 +82,14 @@ describe('Hashing Store', () => { describe('#getItemAtHash', () => { it('reads a value from the store by its hash', () => { - const { hashingStore } = setup(); + const { hashingStore } = setup(createStorageHash); const val = { foo: 'bar' }; const hash = hashingStore.hashAndSetItem(val); expect(hashingStore.getItemAtHash(hash)).to.eql(val); }); it('returns null when the value is not in the store', () => { - const { hashingStore } = setup(); + const { hashingStore } = setup(createStorageHash); const val = { foo: 'bar' }; const hash = hashingStore.hashAndSetItem(val); expect(hashingStore.getItemAtHash(`${hash} break`)).to.be(null); @@ -100,7 +98,7 @@ describe('Hashing Store', () => { describe('#isHash', () => { it('can identify values that look like hashes', () => { - const { hashingStore } = setup(); + const { hashingStore } = setup(createStorageHash); const val = { foo: 'bar' }; const hash = hashingStore.hashAndSetItem(val); expect(hashingStore.isHash(hash)).to.be(true); @@ -118,7 +116,7 @@ describe('Hashing Store', () => { tests.forEach(([type, val]) => { it(`is not fooled by rison ${type} "${val}"`, () => { - const { hashingStore } = setup(); + const { hashingStore } = setup(createStorageHash); const rison = encodeRison(val); expect(hashingStore.isHash(rison)).to.be(false); }); diff --git a/src/ui/public/state_management/state_storage/create_storage_hash.js b/src/ui/public/state_management/state_storage/create_storage_hash.js new file mode 100644 index 0000000000000..b90cde31c0523 --- /dev/null +++ b/src/ui/public/state_management/state_storage/create_storage_hash.js @@ -0,0 +1,5 @@ +import { Sha256 } from 'ui/crypto'; + +export default function createStorageHash(json) { + return new Sha256().update(json, 'utf8').digest('hex'); +} diff --git a/src/ui/public/state_management/state_storage/hashing_store.js b/src/ui/public/state_management/state_storage/hashing_store.js index c444e4df32fe7..a1c194efeb10a 100644 --- a/src/ui/public/state_management/state_storage/hashing_store.js +++ b/src/ui/public/state_management/state_storage/hashing_store.js @@ -1,9 +1,4 @@ import angular from 'angular'; -import { sortBy } from 'lodash'; -import { Sha256 } from 'ui/crypto'; - -import StubBrowserStorage from 'test_utils/stub_browser_storage'; -import { LazyLruStore } from './lazy_lru_store'; /** * The HashingStore is a wrapper around a browser store object @@ -12,9 +7,9 @@ import { LazyLruStore } from './lazy_lru_store'; * at a later time. */ class HashingStore { - constructor({ store, createHash, maxItems } = {}) { - this._store = store || window.sessionStorage; - if (createHash) this._createHash = createHash; + constructor(createStorageHash, store) { + this._createStorageHash = createStorageHash; + this._store = store; } /** @@ -49,25 +44,13 @@ class HashingStore { * @return {string} the hash of the value */ hashAndSetItem(object) { + // The object may contain Angular $$ properties, so let's ignore them. const json = angular.toJson(object); const hash = this._getShortHash(json); this._store.setItem(hash, json); return hash; } - // private api - - /** - * calculate the full hash of a json object - * - * @private - * @param {string} json - * @return {string} hash - */ - _createHash(json) { - return new Sha256().update(json, 'utf8').digest('hex'); - } - /** * Calculate the full hash for a json blob and then shorten in until * it until it doesn't collide with other short hashes in the store @@ -77,7 +60,7 @@ class HashingStore { * @param {string} shortHash */ _getShortHash(json) { - const fullHash = `${HashingStore.HASH_TAG}${this._createHash(json)}`; + const fullHash = `${HashingStore.HASH_TAG}${this._createStorageHash(json)}`; let short; for (let i = 7; i < fullHash.length; i++) { diff --git a/src/ui/public/state_management/state_storage/index.js b/src/ui/public/state_management/state_storage/index.js index 5c80b0f471901..99db210733cae 100644 --- a/src/ui/public/state_management/state_storage/index.js +++ b/src/ui/public/state_management/state_storage/index.js @@ -1,3 +1,7 @@ +export { + default as createStorageHash, +} from './create_storage_hash'; + export { default as HashingStore, } from './hashing_store'; From 06ed9339bb9766e99b5b1de13e888dcb95ef65f5 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Thu, 25 Aug 2016 15:05:06 -0700 Subject: [PATCH 07/11] Remove LazyLruStore, HashingStore, and createStorageHash. - Replace with HashedItemStore, createStateHash, and isStateHash. - Refactor stubBrowserStorage. --- .../__tests__/stub_browser_storage.js | 30 +- src/test_utils/stub_browser_storage.js | 93 +++-- src/ui/public/chrome/api/__tests__/apps.js | 4 +- .../state_management/__tests__/state.js | 35 +- src/ui/public/state_management/state.js | 87 ++--- .../_tests__/hashed_item_store.js | 334 ++++++++++++++++++ .../state_storage/_tests__/hashing_store.js | 126 ------- .../state_storage/_tests__/lazy_lru_store.js | 291 --------------- .../state_storage/_tests__/state_hash.js | 56 +++ .../state_storage/create_storage_hash.js | 5 - .../state_storage/hashed_item_store.js | 174 +++++++++ .../hashed_item_store_singleton.js | 3 + .../state_storage/hashing_store.js | 78 ---- .../state_management/state_storage/index.js | 13 +- .../state_storage/lazy_lru_store.js | 276 --------------- .../state_storage/state_hash.js | 29 ++ 16 files changed, 743 insertions(+), 891 deletions(-) create mode 100644 src/ui/public/state_management/state_storage/_tests__/hashed_item_store.js delete mode 100644 src/ui/public/state_management/state_storage/_tests__/hashing_store.js delete mode 100644 src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js create mode 100644 src/ui/public/state_management/state_storage/_tests__/state_hash.js delete mode 100644 src/ui/public/state_management/state_storage/create_storage_hash.js create mode 100644 src/ui/public/state_management/state_storage/hashed_item_store.js create mode 100644 src/ui/public/state_management/state_storage/hashed_item_store_singleton.js delete mode 100644 src/ui/public/state_management/state_storage/hashing_store.js delete mode 100644 src/ui/public/state_management/state_storage/lazy_lru_store.js create mode 100644 src/ui/public/state_management/state_storage/state_hash.js diff --git a/src/test_utils/__tests__/stub_browser_storage.js b/src/test_utils/__tests__/stub_browser_storage.js index 83e4a7440ddd3..215e564d3723a 100644 --- a/src/test_utils/__tests__/stub_browser_storage.js +++ b/src/test_utils/__tests__/stub_browser_storage.js @@ -49,10 +49,10 @@ describe('StubBrowserStorage', () => { }); }); - describe('size limiting', () => { + describe('#setStubbedSizeLimit', () => { it('allows limiting the storage size', () => { const store = new StubBrowserStorage(); - store._setSizeLimit(10); + store.setStubbedSizeLimit(10); store.setItem('abc', 'def'); // store size is 6, key.length + val.length expect(() => { store.setItem('ghi', 'jkl'); @@ -61,25 +61,41 @@ describe('StubBrowserStorage', () => { it('allows defining the limit as infinity', () => { const store = new StubBrowserStorage(); - store._setSizeLimit(Infinity); + store.setStubbedSizeLimit(Infinity); store.setItem('abc', 'def'); store.setItem('ghi', 'jkl'); // unlike the previous test, this doesn't throw }); - it('requires setting the limit before keys', () => { + it('throws an error if the limit is below the current size', () => { const store = new StubBrowserStorage(); store.setItem('key', 'val'); expect(() => { - store._setSizeLimit(10); - }).throwError(/before setting/); + store.setStubbedSizeLimit(5); + }).throwError(Error); }); it('respects removed items', () => { const store = new StubBrowserStorage(); - store._setSizeLimit(10); + store.setStubbedSizeLimit(10); store.setItem('abc', 'def'); store.removeItem('abc'); store.setItem('ghi', 'jkl'); // unlike the previous test, this doesn't throw }); }); + + describe('#getStubbedSizeLimit', () => { + it('returns the size limit', () => { + const store = new StubBrowserStorage(); + store.setStubbedSizeLimit(10); + expect(store.getStubbedSizeLimit()).to.equal(10); + }); + }); + + describe('#getStubbedSize', () => { + it('returns the size', () => { + const store = new StubBrowserStorage(); + store.setItem(1, 1); + expect(store.getStubbedSize()).to.equal(2); + }); + }); }); diff --git a/src/test_utils/stub_browser_storage.js b/src/test_utils/stub_browser_storage.js index cf628e0eca515..e601e78bfe813 100644 --- a/src/test_utils/stub_browser_storage.js +++ b/src/test_utils/stub_browser_storage.js @@ -1,92 +1,109 @@ -const keys = Symbol('keys'); -const values = Symbol('values'); -const remainingSize = Symbol('remainingSize'); - export default class StubBrowserStorage { constructor() { - this[keys] = []; - this[values] = []; - this[remainingSize] = 5000000; // 5mb, minimum browser storage size + this._keys = []; + this._values = []; + this._size = 0; + this._sizeLimit = 5000000; // 5mb, minimum browser storage size } + // ----------------------------------------------------------------------------------------------- + // Browser-specific methods. + // ----------------------------------------------------------------------------------------------- + get length() { - return this[keys].length; + return this._keys.length; } key(i) { - return this[keys][i]; + return this._keys[i]; } getItem(key) { key = String(key); - const i = this[keys].indexOf(key); + const i = this._keys.indexOf(key); if (i === -1) return null; - return this[values][i]; + return this._values[i]; } setItem(key, value) { key = String(key); value = String(value); - this._takeUpSpace(this._calcSizeOfAdd(key, value)); + const sizeOfAddition = this._getSizeOfAddition(key, value); + this._updateSize(sizeOfAddition); - const i = this[keys].indexOf(key); + const i = this._keys.indexOf(key); if (i === -1) { - this[keys].push(key); - this[values].push(value); + this._keys.push(key); + this._values.push(value); } else { - this[values][i] = value; + this._values[i] = value; } } removeItem(key) { key = String(key); - this._takeUpSpace(this._calcSizeOfRemove(key)); + const sizeOfRemoval = this._getSizeOfRemoval(key); + this._updateSize(sizeOfRemoval); - const i = this[keys].indexOf(key); + const i = this._keys.indexOf(key); if (i === -1) return; - this[keys].splice(i, 1); - this[values].splice(i, 1); + this._keys.splice(i, 1); + this._values.splice(i, 1); } - // non-standard api methods - _getKeys() { - return this[keys].slice(); + // ----------------------------------------------------------------------------------------------- + // Test-specific methods. + // ----------------------------------------------------------------------------------------------- + + getStubbedKeys() { + return this._keys.slice(); } - _getValues() { - return this[values].slice(); + getStubbedValues() { + return this._values.slice(); } - _setSizeLimit(limit) { - if (this[keys].length) { - throw new Error('You must call _setSizeLimit() before setting any values'); + setStubbedSizeLimit(sizeLimit) { + // We can't reconcile a size limit with the "stored" items, if the stored items size exceeds it. + if (sizeLimit < this._size) { + throw new Error(`You can't set a size limit smaller than the current size.`); } - this[remainingSize] = limit; + this._sizeLimit = sizeLimit; + } + + getStubbedSizeLimit() { + return this._sizeLimit; + } + + getStubbedSize() { + return this._size; } - _calcSizeOfAdd(key, value) { - const i = this[keys].indexOf(key); + _getSizeOfAddition(key, value) { + const i = this._keys.indexOf(key); if (i === -1) { return key.length + value.length; } - return value.length - this[values][i].length; + // Return difference of what's been stored, and what *will* be stored. + return value.length - this._values[i].length; } - _calcSizeOfRemove(key) { - const i = this[keys].indexOf(key); + _getSizeOfRemoval(key) { + const i = this._keys.indexOf(key); if (i === -1) { return 0; } - return 0 - (key.length + this[values][i].length); + // Return negative value. + return -(key.length + this._values[i].length); } - _takeUpSpace(delta) { - if (this[remainingSize] - delta < 0) { + _updateSize(delta) { + if (this._size + delta > this._sizeLimit) { throw new Error('something about quota exceeded, browsers are not consistent here'); } - this[remainingSize] -= delta; + this._size += delta; } } diff --git a/src/ui/public/chrome/api/__tests__/apps.js b/src/ui/public/chrome/api/__tests__/apps.js index 7e6040b1aa8ce..613920e0dcb86 100644 --- a/src/ui/public/chrome/api/__tests__/apps.js +++ b/src/ui/public/chrome/api/__tests__/apps.js @@ -152,8 +152,8 @@ describe('Chrome API :: apps', function () { expect(chrome.getLastUrlFor('app')).to.equal(null); chrome.setLastUrlFor('app', 'url'); expect(chrome.getLastUrlFor('app')).to.equal('url'); - expect(store._getKeys().length).to.equal(1); - expect(store._getValues().shift()).to.equal('url'); + expect(store.getStubbedKeys().length).to.equal(1); + expect(store.getStubbedValues().shift()).to.equal('url'); }); }); }); diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index 261ab27f48928..a24dc482e36f9 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -11,9 +11,10 @@ import { unhashQueryString, } from 'ui/state_management/state_hashing'; import { - createStorageHash, - HashingStore, + createStateHash, + isStateHash, } from 'ui/state_management/state_storage'; +import HashedItemStore from 'ui/state_management/state_storage/hashed_item_store'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; import EventsProvider from 'ui/events'; @@ -37,14 +38,14 @@ describe('State Management', function () { const { param, initial, storeInHash } = (opts || {}); sinon.stub(config, 'get').withArgs('state:storeInSessionStorage').returns(!!storeInHash); const store = new StubBrowserStorage(); - const hashingStore = new HashingStore(createStorageHash, store); - const state = new State(param, initial, { hashingStore, notifier }); + const hashedItemStore = new HashedItemStore(store); + const state = new State(param, initial, hashedItemStore, notifier); const getUnhashedSearch = state => { return unhashQueryString($location.search(), [ state ]); }; - return { notifier, store, hashingStore, state, getUnhashedSearch }; + return { notifier, store, hashedItemStore, state, getUnhashedSearch }; }; })); @@ -191,18 +192,18 @@ describe('State Management', function () { }); describe('Hashing', () => { - it('stores state values in a hashingStore, writing the hash to the url', () => { - const { state, hashingStore } = setup({ storeInHash: true }); + it('stores state values in a hashedItemStore, writing the hash to the url', () => { + const { state, hashedItemStore } = setup({ storeInHash: true }); state.foo = 'bar'; state.save(); const urlVal = $location.search()[state.getQueryParamName()]; - expect(hashingStore.isHash(urlVal)).to.be(true); - expect(hashingStore.getItemAtHash(urlVal)).to.eql({ foo: 'bar' }); + expect(isStateHash(urlVal)).to.be(true); + expect(hashedItemStore.getItem(urlVal)).to.eql(JSON.stringify({ foo: 'bar' })); }); it('should replace rison in the URL with a hash', () => { - const { state, hashingStore } = setup({ storeInHash: true }); + const { state, hashedItemStore } = setup({ storeInHash: true }); const obj = { foo: { bar: 'baz' } }; const rison = encodeRison(obj); @@ -211,15 +212,15 @@ describe('State Management', function () { const urlVal = $location.search()._s; expect(urlVal).to.not.be(rison); - expect(hashingStore.isHash(urlVal)).to.be(true); - expect(hashingStore.getItemAtHash(urlVal)).to.eql(obj); + expect(isStateHash(urlVal)).to.be(true); + expect(hashedItemStore.getItem(urlVal)).to.eql(JSON.stringify(obj)); }); context('error handling', () => { it('notifies the user when a hash value does not map to a stored value', () => { - const { state, hashingStore, notifier } = setup({ storeInHash: true }); + const { state, hashedItemStore, notifier } = setup({ storeInHash: true }); const search = $location.search(); - const badHash = hashingStore._getShortHash('{"a": "b"}'); + const badHash = createStateHash('{"a": "b"}', () => null); search[state.getQueryParamName()] = badHash; $location.search(search); @@ -230,10 +231,10 @@ describe('State Management', function () { expect(notifier._notifs[0].content).to.match(/use the share functionality/i); }); - it('presents fatal error linking to github when hashingStore.hashAndSetItem fails', () => { - const { state, hashingStore, notifier } = setup({ storeInHash: true }); + it('presents fatal error linking to github when setting item fails', () => { + const { state, hashedItemStore, notifier } = setup({ storeInHash: true }); const fatalStub = sinon.stub(notifier, 'fatal').throws(); - sinon.stub(hashingStore, 'hashAndSetItem').throws(); + sinon.stub(hashedItemStore, 'setItem').returns(false); expect(() => { state.toQueryParam(); diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index cf05585431054..58768980d0aac 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -8,58 +8,52 @@ import Notifier from 'ui/notify/notifier'; import KbnUrlProvider from 'ui/url'; import { - createStorageHash, - HashingStore, - LazyLruStore, + createStateHash, + hashedItemStoreSingleton, + isStateHash, } from './state_storage'; -const MAX_BROWSER_HISTORY = 50; - export default function StateProvider(Private, $rootScope, $location, config) { const Events = Private(EventsProvider); _.class(State).inherits(Events); - function State(urlParam, defaults, { hashingStore, notifier } = {}) { + function State( + urlParam, + defaults, + hashedItemStore = hashedItemStoreSingleton, + notifier = new Notifier() + ) { State.Super.call(this); this.setDefaults(defaults); this._urlParam = urlParam || '_s'; - this._notifier = notifier || new Notifier(); - - this._hashingStore = hashingStore || (() => { - const lazyLruStore = new LazyLruStore({ - id: `${this._urlParam}:state`, - store: window.sessionStorage, - maxItems: MAX_BROWSER_HISTORY - }); - - return new HashingStore(createStorageHash, lazyLruStore); - })(); + this._notifier = notifier; + this._hashedItemStore = hashedItemStore; // When the URL updates we need to fetch the values from the URL this._cleanUpListeners = _.partial(_.callEach, [ // partial route update, no app reload $rootScope.$on('$routeUpdate', () => { - self.fetch(); + this.fetch(); }), // beginning of full route update, new app will be initialized before // $routeChangeSuccess or $routeChangeError $rootScope.$on('$routeChangeStart', () => { - if (!self._persistAcrossApps) { - self.destroy(); + if (!this._persistAcrossApps) { + this.destroy(); } }), $rootScope.$on('$routeChangeSuccess', () => { - if (self._persistAcrossApps) { - self.fetch(); + if (this._persistAcrossApps) { + this.fetch(); } }) ]); // Initialize the State with fetch - self.fetch(); + this.fetch(); } State.prototype._readFromURL = function () { @@ -70,7 +64,7 @@ export default function StateProvider(Private, $rootScope, $location, config) { return null; } - if (this._hashingStore.isHash(urlVal)) { + if (isStateHash(urlVal)) { return this._parseQueryParamValue(urlVal); } @@ -197,16 +191,17 @@ export default function StateProvider(Private, $rootScope, $location, config) { * @return {any} - the stored value, or null if hash does not resolve */ State.prototype._parseQueryParamValue = function (queryParam) { - if (!this._hashingStore.isHash(queryParam)) { + if (!isStateHash(queryParam)) { return rison.decode(queryParam); } - const stored = this._hashingStore.getItemAtHash(queryParam); - if (stored === null) { + const json = this._hashedItemStore.getItem(queryParam); + if (json === null) { this._notifier.error('Unable to completely restore the URL, be sure to use the share functionality.'); } - return stored; + const state = JSON.parse(json); + return state; }; /** @@ -230,23 +225,29 @@ export default function StateProvider(Private, $rootScope, $location, config) { return rison.encode(state); } - try { - const hash = this._hashingStore.hashAndSetItem(state); + // We need to strip out Angular-specific properties. + const json = angular.toJson(state); + const hash = createStateHash(json, hash => { + return this._hashedItemStore.getItem(hash); + }); + const isItemSet = this._hashedItemStore.setItem(hash, json); + + if (isItemSet) { return hash; - } catch (err) { - this._notifier.log('Unable to create hash of State due to error: ' + (state.stack || state.message)); - this._notifier.fatal( - new Error( - 'Kibana is unable to store history items in your session ' + - 'because it is full and there don\'t seem to be items any items safe ' + - 'to delete.\n' + - '\n' + - 'This can usually be fixed by moving to a fresh tab, but could ' + - 'be caused by a larger issue. If you are seeing this message regularly, ' + - 'please file an issue at https://github.com/elastic/kibana/issues.' - ) - ); } + + // If we ran out of space trying to persist the state, notify the user. + this._notifier.fatal( + new Error( + 'Kibana is unable to store history items in your session ' + + 'because it is full and there don\'t seem to be items any items safe ' + + 'to delete.\n' + + '\n' + + 'This can usually be fixed by moving to a fresh tab, but could ' + + 'be caused by a larger issue. If you are seeing this message regularly, ' + + 'please file an issue at https://github.com/elastic/kibana/issues.' + ) + ); }; /** diff --git a/src/ui/public/state_management/state_storage/_tests__/hashed_item_store.js b/src/ui/public/state_management/state_storage/_tests__/hashed_item_store.js new file mode 100644 index 0000000000000..ca36f49e2c1ef --- /dev/null +++ b/src/ui/public/state_management/state_storage/_tests__/hashed_item_store.js @@ -0,0 +1,334 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import bluebird from 'bluebird'; + +import StubBrowserStorage from 'test_utils/stub_browser_storage'; +import HashedItemStore from '../hashed_item_store'; + +describe('hashedItemStore', () => { + describe('interface', () => { + describe('#constructor', () => { + it('retrieves persisted index from sessionStorage', () => { + const sessionStorage = new StubBrowserStorage(); + sinon.spy(sessionStorage, 'getItem'); + + const hashedItemStore = new HashedItemStore(sessionStorage); + sinon.assert.calledWith(sessionStorage.getItem, HashedItemStore.PERSISTED_INDEX_KEY); + sessionStorage.getItem.restore(); + }); + + it('sorts indexed items by touched property', () => { + const a = { + hash: 'a', + touched: 0, + }; + const b = { + hash: 'b', + touched: 2, + }; + const c = { + hash: 'c', + touched: 1, + }; + const sessionStorage = new StubBrowserStorage(); + if (!HashedItemStore.PERSISTED_INDEX_KEY) { + // This is very brittle and depends upon HashedItemStore implementation details, + // so let's protect ourselves from accidentally breaking this test. + throw new Error('Missing HashedItemStore.PERSISTED_INDEX_KEY'); + } + sessionStorage.setItem(HashedItemStore.PERSISTED_INDEX_KEY, JSON.stringify({a, b, c})); + + const hashedItemStore = new HashedItemStore(sessionStorage); + expect(hashedItemStore._indexedItems).to.eql([a, c, b]); + }); + }); + + describe('#setItem', () => { + describe('if the item exists in sessionStorage', () => { + let sessionStorage; + let hashedItemStore; + const hash = 'a'; + const item = JSON.stringify({}); + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + it('persists the item in sessionStorage', () => { + hashedItemStore.setItem(hash, item); + expect(sessionStorage.getItem(hash)).to.equal(item); + }); + + it('returns true', () => { + const result = hashedItemStore.setItem(hash, item); + expect(result).to.equal(true); + }); + }); + + describe(`if the item doesn't exist in sessionStorage`, () => { + describe(`if there's storage space`, () => { + let sessionStorage; + let hashedItemStore; + const hash = 'a'; + const item = JSON.stringify({}); + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + it('persists the item in sessionStorage', () => { + hashedItemStore.setItem(hash, item); + expect(sessionStorage.getItem(hash)).to.equal(item); + }); + + it('returns true', () => { + const result = hashedItemStore.setItem(hash, item); + expect(result).to.equal(true); + }); + }); + + describe(`if there isn't storage space`, () => { + let fakeTimer; + let sessionStorage; + let hashedItemStore; + let storageSizeLimit; + const hash = 'a'; + const item = JSON.stringify({}); + + function setItemLater(hash, item) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.setItem(hash, item); + } + + beforeEach(() => { + // Control time. + fakeTimer = sinon.useFakeTimers(Date.now()); + + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + + // Add some items that will be removed. + setItemLater('b', item); + + // Do this a little later so that this item is newer. + setItemLater('c', item); + + // Cap the storage at its current size. + storageSizeLimit = sessionStorage.getStubbedSize(); + sessionStorage.setStubbedSizeLimit(storageSizeLimit); + }); + + afterEach(() => { + // Stop controlling time. + fakeTimer.restore(); + }); + + describe('and the item will fit', () => { + it('removes older items until the new item fits', () => { + setItemLater(hash, item); + expect(sessionStorage.getItem('b')).to.equal(null); + expect(sessionStorage.getItem('c')).to.equal(item); + }); + + it('persists the item in sessionStorage', () => { + setItemLater(hash, item); + expect(sessionStorage.getItem(hash)).to.equal(item); + }); + + it('returns true', () => { + const result = setItemLater(hash, item); + expect(result).to.equal(true); + }); + }); + + describe(`and the item won't fit`, () => { + let itemTooBigToFit; + + beforeEach(() => { + // Make sure the item is longer than the storage size limit. + itemTooBigToFit = ''; + const length = storageSizeLimit + 1; + for (let i = 0; i < length; i++) { + itemTooBigToFit += 'a'; + } + }); + + it('removes all items', () => { + setItemLater(hash, itemTooBigToFit); + expect(sessionStorage.getItem('b')).to.equal(null); + expect(sessionStorage.getItem('c')).to.equal(null); + }); + + it(`doesn't persist the item in sessionStorage`, () => { + setItemLater(hash, itemTooBigToFit); + expect(sessionStorage.getItem(hash)).to.equal(null); + }); + + it('returns false', () => { + const result = setItemLater(hash, itemTooBigToFit); + expect(result).to.equal(false); + }); + }); + }); + }); + }); + + describe('#getItem', () => { + describe('if the item exists in sessionStorage', () => { + let fakeTimer; + let sessionStorage; + let hashedItemStore; + + function setItemLater(hash, item) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.setItem(hash, item); + } + + function getItemLater(hash) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.getItem(hash); + } + + beforeEach(() => { + // Control time. + fakeTimer = sinon.useFakeTimers(Date.now()); + + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + hashedItemStore.setItem('1', 'a'); + }); + + afterEach(() => { + // Stop controlling time. + fakeTimer.restore(); + }); + + it('returns the item', () => { + const retrievedItem = hashedItemStore.getItem('1'); + expect(retrievedItem).to.be('a'); + }); + + it('prevents the item from being first to be removed when freeing up storage spage', () => { + // Do this a little later so that this item is newer. + setItemLater('2', 'b'); + + // Wait a bit, then retrieve/touch the first item, making *it* newer, and 2 as the oldest. + getItemLater('1'); + + // Cap the storage at its current size. + const storageSizeLimit = sessionStorage.getStubbedSize(); + sessionStorage.setStubbedSizeLimit(storageSizeLimit); + + // Add a new item, causing the second item to be removed, but not the first. + setItemLater('3', 'c'); + expect(hashedItemStore.getItem('2')).to.equal(null); + expect(hashedItemStore.getItem('1')).to.equal('a'); + }); + }); + + describe(`if the item doesn't exist in sessionStorage`, () => { + let sessionStorage; + let hashedItemStore; + const hash = 'a'; + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + it('returns null', () => { + const retrievedItem = hashedItemStore.getItem(hash); + expect(retrievedItem).to.be(null); + }); + }); + }); + }); + + describe('behavior', () => { + let fakeTimer; + let sessionStorage; + let hashedItemStore; + + function setItemLater(hash, item) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.setItem(hash, item); + } + + function getItemLater(hash) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.getItem(hash); + } + + beforeEach(() => { + // Control time. + fakeTimer = sinon.useFakeTimers(Date.now()); + + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + afterEach(() => { + // Stop controlling time. + fakeTimer.restore(); + }); + + it('orders items to be removed based on when they were last retrieved', () => { + setItemLater('1', 'a'); + setItemLater('2', 'b'); + setItemLater('3', 'c'); + setItemLater('4', 'd'); + + // Cap the storage at its current size. + const storageSizeLimit = sessionStorage.getStubbedSize(); + sessionStorage.setStubbedSizeLimit(storageSizeLimit); + + // Expect items to be removed in order: 1, 3, 2, 4. + getItemLater('1'); + getItemLater('3'); + getItemLater('2'); + getItemLater('4'); + + setItemLater('5', 'e'); + expect(hashedItemStore.getItem('1')).to.equal(null); + expect(hashedItemStore.getItem('3')).to.equal('c'); + expect(hashedItemStore.getItem('2')).to.equal('b'); + expect(hashedItemStore.getItem('4')).to.equal('d'); + expect(hashedItemStore.getItem('5')).to.equal('e'); + + setItemLater('6', 'f'); + expect(hashedItemStore.getItem('3')).to.equal(null); + expect(hashedItemStore.getItem('2')).to.equal('b'); + expect(hashedItemStore.getItem('4')).to.equal('d'); + expect(hashedItemStore.getItem('5')).to.equal('e'); + expect(hashedItemStore.getItem('6')).to.equal('f'); + + setItemLater('7', 'g'); + expect(hashedItemStore.getItem('2')).to.equal(null); + expect(hashedItemStore.getItem('4')).to.equal('d'); + expect(hashedItemStore.getItem('5')).to.equal('e'); + expect(hashedItemStore.getItem('6')).to.equal('f'); + expect(hashedItemStore.getItem('7')).to.equal('g'); + + setItemLater('8', 'h'); + expect(hashedItemStore.getItem('4')).to.equal(null); + expect(hashedItemStore.getItem('5')).to.equal('e'); + expect(hashedItemStore.getItem('6')).to.equal('f'); + expect(hashedItemStore.getItem('7')).to.equal('g'); + expect(hashedItemStore.getItem('8')).to.equal('h'); + + setItemLater('9', 'i'); + expect(hashedItemStore.getItem('5')).to.equal(null); + expect(hashedItemStore.getItem('6')).to.equal('f'); + expect(hashedItemStore.getItem('7')).to.equal('g'); + expect(hashedItemStore.getItem('8')).to.equal('h'); + expect(hashedItemStore.getItem('9')).to.equal('i'); + }); + }); +}); diff --git a/src/ui/public/state_management/state_storage/_tests__/hashing_store.js b/src/ui/public/state_management/state_storage/_tests__/hashing_store.js deleted file mode 100644 index d80ac3d7dc14a..0000000000000 --- a/src/ui/public/state_management/state_storage/_tests__/hashing_store.js +++ /dev/null @@ -1,126 +0,0 @@ -import expect from 'expect.js'; -import sinon from 'sinon'; -import { encode as encodeRison } from 'rison-node'; -import StubBrowserStorage from 'test_utils/stub_browser_storage'; -import { - createStorageHash, - HashingStore, -} from 'ui/state_management/state_storage'; - -const setup = createStorageHash => { - const store = new StubBrowserStorage(); - const hashingStore = new HashingStore(createStorageHash, store); - return { store, hashingStore }; -}; - -describe('Hashing Store', () => { - describe('#hashAndSetItem', () => { - it('adds a value to the store and returns its hash', () => { - const { hashingStore, store } = setup(createStorageHash); - const val = { foo: 'bar' }; - const hash = hashingStore.hashAndSetItem(val); - expect(hash).to.be.a('string'); - expect(hash).to.be.ok(); - expect(store).to.have.length(1); - }); - - it('json encodes the values it stores', () => { - const { hashingStore, store } = setup(createStorageHash); - const val = { toJSON() { return 1; } }; - const hash = hashingStore.hashAndSetItem(val); - expect(hashingStore.getItemAtHash(hash)).to.eql(1); - }); - - it('addresses values with a short hash', () => { - const val = { foo: 'bar' }; - const longHash = 'longlonglonglonglonglonglonglonglonglonghash'; - const { hashingStore } = setup(() => longHash); - - const hash = hashingStore.hashAndSetItem(val); - expect(hash.length < longHash.length).to.be.ok(); - }); - - it('addresses values with a slightly longer hash when short hashes collide', () => { - const fixtures = [ - { - hash: '1234567890-1', - val: { foo: 'bar' } - }, - { - hash: '1234567890-2', - val: { foo: 'baz' } - }, - { - hash: '1234567890-3', - val: { foo: 'boo' } - } - ]; - - const matchVal = json => f => JSON.stringify(f.val) === json; - const { hashingStore } = setup(val => { - const fixture = fixtures.find(matchVal(val)); - return fixture.hash; - }); - - const hash1 = hashingStore.hashAndSetItem(fixtures[0].val); - const hash2 = hashingStore.hashAndSetItem(fixtures[1].val); - const hash3 = hashingStore.hashAndSetItem(fixtures[2].val); - - expect(hash3).to.have.length(hash2.length + 1); - expect(hash2).to.have.length(hash1.length + 1); - }); - - it('bubbles up the error if the store fails to hashAndSetItem', () => { - const { store, hashingStore } = setup(createStorageHash); - const err = new Error(); - sinon.stub(store, 'setItem').throws(err); - expect(() => { - hashingStore.hashAndSetItem({}); - }).to.throwError(e => expect(e).to.be(err)); - }); - }); - - describe('#getItemAtHash', () => { - it('reads a value from the store by its hash', () => { - const { hashingStore } = setup(createStorageHash); - const val = { foo: 'bar' }; - const hash = hashingStore.hashAndSetItem(val); - expect(hashingStore.getItemAtHash(hash)).to.eql(val); - }); - - it('returns null when the value is not in the store', () => { - const { hashingStore } = setup(createStorageHash); - const val = { foo: 'bar' }; - const hash = hashingStore.hashAndSetItem(val); - expect(hashingStore.getItemAtHash(`${hash} break`)).to.be(null); - }); - }); - - describe('#isHash', () => { - it('can identify values that look like hashes', () => { - const { hashingStore } = setup(createStorageHash); - const val = { foo: 'bar' }; - const hash = hashingStore.hashAndSetItem(val); - expect(hashingStore.isHash(hash)).to.be(true); - }); - - describe('rison', () => { - const tests = [ - ['object', { foo: 'bar' }], - ['number', 1], - ['number', 1000], - ['number', Math.round(Math.random() * 10000000)], - ['string', 'this is a string'], - ['array', [1,2,3]], - ]; - - tests.forEach(([type, val]) => { - it(`is not fooled by rison ${type} "${val}"`, () => { - const { hashingStore } = setup(createStorageHash); - const rison = encodeRison(val); - expect(hashingStore.isHash(rison)).to.be(false); - }); - }); - }); - }); -}); diff --git a/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js b/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js deleted file mode 100644 index 9f1a0a4f0aec9..0000000000000 --- a/src/ui/public/state_management/state_storage/_tests__/lazy_lru_store.js +++ /dev/null @@ -1,291 +0,0 @@ -import expect from 'expect.js'; -import sinon from 'sinon'; -import { times, sum, padLeft } from 'lodash'; - -import StubBrowserStorage from 'test_utils/stub_browser_storage'; -import { LazyLruStore } from '..'; - -const setup = (opts = {}) => { - const { - id = 'testLru', - store = new StubBrowserStorage(), - maxItems, - maxSetAttempts, - idealClearRatio, - maxIdealClearPercent - } = opts; - - const lru = new LazyLruStore({ - id, - store, - maxItems, - maxSetAttempts, - idealClearRatio, - maxIdealClearPercent - }); - - return { lru, store }; -}; - -describe('LazyLruStore', () => { - describe('#getItem()', () => { - it('returns null when item not found', () => { - const { lru } = setup(); - expect(lru.getItem('item1')).to.be(null); - }); - - it('returns stored value when item found', () => { - const { lru } = setup(); - lru.setItem('item1', '1'); - expect(lru.getItem('item1')).to.be('1'); - }); - }); - - describe('#setItem()', () => { - it('stores the item in the underlying store', () => { - const { lru, store } = setup(); - expect(store).to.have.length(0); - lru.setItem('item1', '1'); - expect(store).to.have.length(1); - }); - - it('makes space for new item when necessary', () => { - const { lru, store } = setup({ idealClearRatio: 1 }); - store._setSizeLimit(lru.getStorageOverhead() + 6); - lru.setItem('item1', '1'); - expect(store).to.have.length(1); - lru.setItem('item2', '2'); - expect(store).to.have.length(1); - - expect(lru.getItem('item1')).to.be(null); - expect(lru.getItem('item2')).to.be('2'); - }); - - it('overwrites existing values', () => { - const { lru, store } = setup(); - lru.setItem('item1', '1'); - expect(store).to.have.length(1); - lru.setItem('item1', '2'); - expect(store).to.have.length(1); - expect(lru.getItem('item1')).to.be('2'); - }); - - it('stores items as strings', () => { - const { lru } = setup(); - lru.setItem('item1', 1); - expect(lru.getItem('item1')).to.be('1'); - }); - - it('bubbles up the error when unable to clear the necessary space', () => { - const { lru, store } = setup(); - store._setSizeLimit(lru.getStorageOverhead() + 2); - lru.setItem('1', '1'); - sinon.stub(store, 'removeItem'); - expect(() => { - lru.setItem('2', '2'); - }).to.throwError(/quota/); - }); - }); - - describe('#removeItem()', () => { - it('removes items from the underlying store', () => { - const { lru, store } = setup(); - lru.setItem('item1', '1'); - expect(store).to.have.length(1); - lru.removeItem('item1'); - expect(store).to.have.length(0); - expect(lru.getItem('item1')).to.be(null); - }); - - it('ignores unknown items', () => { - const { lru, store } = setup(); - expect(store).to.have.length(0); - expect(() => { - lru.removeItem('item1'); - }).to.not.throwError(); - expect(store).to.have.length(0); - }); - }); - - describe('#getStorageOverhead()', () => { - it('returns the number of bytes added to each storage item, used for testing', () => { - const { store } = setup(); - const id1 = new LazyLruStore({ id: '1', store }); - const id11 = new LazyLruStore({ id: '11', store }); - expect(id1.getStorageOverhead()).to.be(id11.getStorageOverhead() - 1); - }); - }); - - describe('space management', () => { - let clock; - beforeEach(() => { - clock = sinon.useFakeTimers(Date.now()); - }); - - afterEach(() => { - clock.restore(); - }); - - it('tries to clear space if setItem fails because the quota was exceeded', () => { - const { lru, store } = setup(); - const itemSize = lru.getStorageOverhead() + 10; // each item key length + val length is 10 - - store._setSizeLimit(itemSize * 3); - - lru.setItem('item1', 'item1'); - clock.tick(1); // move clock forward so removal based on time is predictable - lru.setItem('item2', 'item2'); - clock.tick(1); - lru.setItem('item3', 'item3'); - clock.tick(1); - lru.setItem('item4', 'item4'); - clock.tick(1); - lru.setItem('item5', 'item5'); - clock.tick(1); - - expect(store).to.have.length(3); - expect(lru.getItem('item1')).to.be(null); - expect(lru.getItem('item2')).to.be(null); - expect(lru.getItem('item3')).to.be('item3'); - expect(lru.getItem('item4')).to.be('item4'); - expect(lru.getItem('item5')).to.be('item5'); - }); - - context('when small items are being written to a large existing collection', () => { - context('with idealClearRatio = 6', () => { - it('clears 6 times the amount of space necessary', () => { - const { lru, store } = setup({ idealClearRatio: 6 }); - - const overhead = lru.getStorageOverhead(); - const getItemSize = i => overhead + `${i.key}${i.value}`.length; - - const items = times(100, i => { - // pad n so that 1 and 100 take up equal space in the store - const n = padLeft(i + 1, 3, '0'); - return { key: `key${n}`, value: `value${n}` }; - }); - const lastItem = items[items.length - 1]; - - // set the size limit so that the last item causes a cleanup, which - store._setSizeLimit(sum(items.map(getItemSize)) - getItemSize(lastItem)); - - for (const i of items) { - lru.setItem(i.key, i.value); - clock.tick(1); // move clock forward so removal based on time is predictable - } - - // the current ratio is 6:1, so when the last item fails - // to set, 6 items are cleared to make space for it - expect(store).to.have.length(94); - expect(lru.getItem('key001')).to.be(null); - expect(lru.getItem('key002')).to.be(null); - expect(lru.getItem('key003')).to.be(null); - expect(lru.getItem('key004')).to.be(null); - expect(lru.getItem('key005')).to.be(null); - expect(lru.getItem('key006')).to.be(null); - expect(lru.getItem('key007')).to.be('value007'); - }); - }); - - context('with idealClearRatio = 100 and maxIdealClearPercent = 0.1', () => { - it('clears 10% of the store', () => { - const { lru, store } = setup({ idealClearRatio: 100, maxIdealClearPercent: 0.1 }); - - const overhead = lru.getStorageOverhead(); - const getItemSize = i => overhead + `${i.key}${i.value}`.length; - - const items = times(100, i => { - // pad n so that 1 and 100 take up equal space in the store - const n = padLeft(i + 1, 3, '0'); - return { key: `key${n}`, value: `value${n}` }; - }); - const lastItem = items[items.length - 1]; - - // set the size limit so that the last item causes a cleanup, which - store._setSizeLimit(sum(items.map(getItemSize)) - getItemSize(lastItem)); - - for (const i of items) { - lru.setItem(i.key, i.value); - clock.tick(1); // move clock forward so removal based on time is predictable - } - - // with the ratio set to 100:1 the store will try to clear - // 100x the stored values, but that could be the entire store - // so it is limited by the maxIdealClearPercent (10% here) - // so the store should now contain values 11-100 - expect(store).to.have.length(90); - expect(lru.getItem('key001')).to.be(null); - expect(lru.getItem('key002')).to.be(null); - expect(lru.getItem('key003')).to.be(null); - expect(lru.getItem('key004')).to.be(null); - expect(lru.getItem('key005')).to.be(null); - expect(lru.getItem('key006')).to.be(null); - expect(lru.getItem('key007')).to.be(null); - expect(lru.getItem('key008')).to.be(null); - expect(lru.getItem('key009')).to.be(null); - expect(lru.getItem('key010')).to.be(null); - expect(lru.getItem('key011')).to.be('value011'); - expect(lru.getItem('key012')).to.be('value012'); - expect(lru.getItem('key100')).to.be('value100'); - }); - }); - }); - }); - - describe('maxSetAttempts setting', () => { - it('must be >= 1', () => { - expect(() => setup({ maxSetAttempts: 0 })).to.throwError(TypeError); - expect(() => setup({ maxSetAttempts: -1 })).to.throwError(TypeError); - expect(() => setup({ maxSetAttempts: 0.9 })).to.throwError(TypeError); - expect(() => setup({ maxSetAttempts: 1 })).to.not.throwError(TypeError); - }); - - context('= 1', () => { - it('will cause sets to a full storage to throw', () => { - const { lru, store } = setup({ maxSetAttempts: 1 }); - store._setSizeLimit(lru.getStorageOverhead() + 2); - lru.setItem('1', '1'); - expect(() => { - lru.setItem('2', '2'); - }).to.throwError(/quota/i); - }); - }); - - context('= 5', () => { - it('will try to set 5 times and remove 4', () => { - const { store, lru } = setup({ maxSetAttempts: 5 }); - - // trick lru into thinking it can clear space - lru.setItem('1', '1'); - // but prevent removing items - const removeStub = sinon.stub(store, 'removeItem'); - - // throw on the first 4 set attempts - const setStub = sinon.stub(store, 'setItem') - .onCall(0).throws() - .onCall(1).throws() - .onCall(2).throws() - .onCall(3).throws() - .stub; - - lru.setItem('1', '1'); - sinon.assert.callCount(removeStub, 4); - sinon.assert.callCount(setStub, 5); - }); - }); - }); - - context('with maxItems set', () => { - it('trims the list when starting with more than max items', () => { - const { store, lru: lruNoMax } = setup(); - lruNoMax.setItem('1', '1'); - lruNoMax.setItem('2', '2'); - lruNoMax.setItem('3', '3'); - lruNoMax.setItem('4', '4'); - expect(store).to.have.length(4); - - const { lru } = setup({ store, maxItems: 3 }); - expect(store).to.have.length(3); - }); - }); -}); diff --git a/src/ui/public/state_management/state_storage/_tests__/state_hash.js b/src/ui/public/state_management/state_storage/_tests__/state_hash.js new file mode 100644 index 0000000000000..80e924f1edc48 --- /dev/null +++ b/src/ui/public/state_management/state_storage/_tests__/state_hash.js @@ -0,0 +1,56 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { encode as encodeRison } from 'rison-node'; + +import { + createStateHash, + isStateHash, +} from '../state_hash'; + +describe('stateHash', () => { + const existingJsonProvider = () => null; + + describe('#createStateHash', () => { + + describe('returns a hash', () => { + const json = JSON.stringify({a: 'a'}); + const hash = createStateHash(json, existingJsonProvider); + expect(isStateHash(hash)).to.be(true); + }); + + describe('returns the same hash for the same input', () => { + const json = JSON.stringify({a: 'a'}); + const hash1 = createStateHash(json, existingJsonProvider); + const hash2 = createStateHash(json, existingJsonProvider); + expect(hash1).to.equal(hash2); + }); + + describe('returns a different hash for different input', () => { + const json1 = JSON.stringify({a: 'a'}); + const hash1 = createStateHash(json1, existingJsonProvider); + + const json2 = JSON.stringify({a: 'b'}); + const hash2 = createStateHash(json2, existingJsonProvider); + expect(hash1).to.not.equal(hash2); + }); + }); + + describe('#isStateHash', () => { + it('returns true for values created using #createStateHash', () => { + const json = JSON.stringify({a: 'a'}); + const hash = createStateHash(json, existingJsonProvider); + expect(isStateHash(hash)).to.be(true); + }); + + it('returns false for values not created using #createStateHash', () => { + const json = JSON.stringify({a: 'a'}); + expect(isStateHash(json)).to.be(false); + }); + + it('returns false for RISON', () => { + // We're storing RISON in the URL, so let's test against this specifically. + const rison = encodeRison({a: 'a'}); + expect(isStateHash(rison)).to.be(false); + }); + }); +}); diff --git a/src/ui/public/state_management/state_storage/create_storage_hash.js b/src/ui/public/state_management/state_storage/create_storage_hash.js deleted file mode 100644 index b90cde31c0523..0000000000000 --- a/src/ui/public/state_management/state_storage/create_storage_hash.js +++ /dev/null @@ -1,5 +0,0 @@ -import { Sha256 } from 'ui/crypto'; - -export default function createStorageHash(json) { - return new Sha256().update(json, 'utf8').digest('hex'); -} diff --git a/src/ui/public/state_management/state_storage/hashed_item_store.js b/src/ui/public/state_management/state_storage/hashed_item_store.js new file mode 100644 index 0000000000000..331903e8980b2 --- /dev/null +++ b/src/ui/public/state_management/state_storage/hashed_item_store.js @@ -0,0 +1,174 @@ +/** + * The HashedItemStore associates JSON objects with states in browser history and persists these + * objects in sessionStorage. We persist them so that when a tab is closed and re-opened, we can + * retain access to the state objects referenced by the browser history. + * + * Because there is a limit on how much data we can put into sessionStorage, the HashedItemStore + * will attempt to remove old items from storage once that limit is reached. + * + * ------------------------------------------------------------------------------------------------- + * + * Consideration 1: We can't (easily) mirror the browser history + * + * If we use letters to indicate a unique state object, and numbers to represent the same state + * occurring again (due to action by the user), a history could look like this: + * + * Old < - - - - - - - - > New + * A1 | B1 | C1 | A2 | D1 | E1 + * + * If the user navigates back to C1 and starts to create new states, persisted history states will + * become inaccessible: + * + * Old < - - - - - - - - - - -> New + * A1 | B1 | C1 | F1 | G1 | H1 | I1 (new history states) + * A2 | D1 | E1 (inaccessible persisted history states) + * + * Theoretically, we could build a mirror of the browser history. When the onpopstate event is + * dispatched, we could determine whether we have gone back or forward in history. Then, when + * a new state is persisted, we could delete all of the persisted items which are no longer + * accessible. (Note that this would require reference-counting so that A isn't removed while D and + * E are, since A would still have a remaining reference from A1). + * + * However, the History API doesn't allow us to read from the history beyond the current state. This + * means that if a session is restored, we can't rebuild this browser history mirror. + * + * Due to this imperfect implementation, HashedItemStore ignores the possibility of inaccessible + * history states. In the future, we could implement this history mirror and persist it in + * sessionStorage too. Then, when restoring a session, we can just retrieve it from sessionStorage. + * + * ------------------------------------------------------------------------------------------------- + * + * Consideration 2: We can't tell when we've hit the browser history limit + * + * Because some of our persisted history states may no longer be referenced by the browser history, + * and we have no way of knowing which ones, we have no way of knowing whether we've persisted a + * number of accessible states beyond the browser history length limit. + * + * More fundamentally, the browser history length limit is a browser implementation detail, so it + * can change from browser to browser, or over time. Respecting this limit would introduce a lot of + * (unnecessary?) complexity. + * + * For these reasons, HashedItemStore doesn't concern itself with this constraint. + */ + +import { + sortBy, + values, +} from 'lodash'; + +export default class HashedItemStore { + + /** + * HashedItemStore uses objects called indexed items to refer to items that have been persisted + * in sessionStorage. An indexed item is shaped {hash, touched}. The touched date is when the item + * was last referenced by the browser history. + */ + constructor(sessionStorage) { + this._sessionStorage = sessionStorage; + + // Store indexed items in descending order by touched (oldest first, newest last). We'll use + // this to remove older items when we run out of storage space. + this._indexedItems = []; + // Associate item hashes with the corresponding indexed items. We'll use this to quickly look + // up an item and update its touched date when it reoccurs in the browser history. + this._hashToIndexedItemMap = {}; + + // Build index from the persisted index. This happens when we re-open a closed tab. + const persistedItemIndex = this._sessionStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY); + + if (persistedItemIndex) { + this._hashToIndexedItemMap = JSON.parse(persistedItemIndex) || {}; + this._indexedItems = values(this._hashToIndexedItemMap); + + // Order items by touched date (oldest first, newest last). + this._indexedItems = sortBy(this._indexedItems, 'touched'); + } + } + + setItem(hash, item) { + const isItemPersisted = this._persistItem(hash, item); + + if (isItemPersisted) { + this._touchHash(hash); + } + + return isItemPersisted; + } + + getItem(hash) { + const item = this._sessionStorage.getItem(hash); + + if (item !== null) { + this._touchHash(hash); + } + + return item; + } + + _persistItem(hash, item) { + try { + this._sessionStorage.setItem(hash, item); + return true; + } catch (e) { + // If there was an error then we need to make some space for the item. + if (this._indexedItems.length === 0) { + // If there's nothing left to remove, then we've run out of space and we're trying to + // persist too large an item. + return false; + } + + // We need to try to make some space for the item by removing older items (i.e. items that + // haven't been accessed recently). + this._removeOldestItem(); + + // Try to persist again. + return this._persistItem(hash, item); + } + } + + _removeOldestItem() { + const oldestIndexedItem = this._indexedItems.shift(); + + // Remove oldest item from index. + delete this._hashToIndexedItemMap[oldestIndexedItem.hash]; + + // Remove oldest item from storage. + this._sessionStorage.removeItem(oldestIndexedItem.hash); + } + + _touchHash(hash) { + // Touching a hash indicates that it's been used recently, so it won't be the first in line + // when we remove items to free up storage space. + if (this._hashToIndexedItemMap[hash]) { + const indexedItem = this._hashToIndexedItemMap[hash]; + + // If item is already indexed, update the touched date. + indexedItem.touched = Date.now(); + + // Since the items are already sorted by touched and we're only changing one item, we can + // avoid a "costly" sort by just moving it to the end of the array. + const index = this._indexedItems.indexOf(indexedItem); + this._indexedItems.splice(index, 1); + this._indexedItems.push(indexedItem); + } else { + // If the item isn't indexed, create it... + const indexedItem = { + hash, + touched: Date.now(), + }; + + // ...and index it. + this._indexedItems.push(indexedItem); + this._hashToIndexedItemMap[hash] = indexedItem; + } + + // Regardless of whether this is a new or updated item, we need to persist the index. + this._sessionStorage.setItem( + HashedItemStore.PERSISTED_INDEX_KEY, + JSON.stringify(this._hashToIndexedItemMap) + ); + } + +} + +HashedItemStore.PERSISTED_INDEX_KEY = 'kibana.hashedItemIndex'; diff --git a/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js b/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js new file mode 100644 index 0000000000000..bd0094a24851a --- /dev/null +++ b/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js @@ -0,0 +1,3 @@ +import HashedItemStore from './hashed_item_store'; + +export default new HashedItemStore(window.sessionStorage); diff --git a/src/ui/public/state_management/state_storage/hashing_store.js b/src/ui/public/state_management/state_storage/hashing_store.js deleted file mode 100644 index a1c194efeb10a..0000000000000 --- a/src/ui/public/state_management/state_storage/hashing_store.js +++ /dev/null @@ -1,78 +0,0 @@ -import angular from 'angular'; - -/** - * The HashingStore is a wrapper around a browser store object - * that hashes the items added to it and stores them by their - * hash. This hash is then returned so that the item can be received - * at a later time. - */ -class HashingStore { - constructor(createStorageHash, store) { - this._createStorageHash = createStorageHash; - this._store = store; - } - - /** - * Determine if the passed value looks like a hash - * - * @param {string} str - * @return {boolean} - */ - isHash(str) { - return String(str).indexOf(HashingStore.HASH_TAG) === 0; - } - - /** - * Find the value stored for the given hash - * - * @param {string} hash - * @return {any} - */ - getItemAtHash(hash) { - try { - return JSON.parse(this._store.getItem(hash)); - } catch (err) { - return null; - } - } - - /** - * Compute the hash of an object, store the object, and return - * the hash - * - * @param {any} the value to hash - * @return {string} the hash of the value - */ - hashAndSetItem(object) { - // The object may contain Angular $$ properties, so let's ignore them. - const json = angular.toJson(object); - const hash = this._getShortHash(json); - this._store.setItem(hash, json); - return hash; - } - - /** - * Calculate the full hash for a json blob and then shorten in until - * it until it doesn't collide with other short hashes in the store - * - * @private - * @param {string} json - * @param {string} shortHash - */ - _getShortHash(json) { - const fullHash = `${HashingStore.HASH_TAG}${this._createStorageHash(json)}`; - - let short; - for (let i = 7; i < fullHash.length; i++) { - short = fullHash.slice(0, i); - const existing = this._store.getItem(short); - if (existing === null || existing === json) break; - } - - return short; - } -} - -HashingStore.HASH_TAG = 'h@'; - -export default HashingStore; diff --git a/src/ui/public/state_management/state_storage/index.js b/src/ui/public/state_management/state_storage/index.js index 99db210733cae..91342388a3ab8 100644 --- a/src/ui/public/state_management/state_storage/index.js +++ b/src/ui/public/state_management/state_storage/index.js @@ -1,11 +1,8 @@ export { - default as createStorageHash, -} from './create_storage_hash'; + default as hashedItemStoreSingleton, +} from './hashed_item_store_singleton'; export { - default as HashingStore, -} from './hashing_store'; - -export { - default as LazyLruStore, -} from './lazy_lru_store'; + createStateHash, + isStateHash, +} from './state_hash'; diff --git a/src/ui/public/state_management/state_storage/lazy_lru_store.js b/src/ui/public/state_management/state_storage/lazy_lru_store.js deleted file mode 100644 index a25489b7ac8f4..0000000000000 --- a/src/ui/public/state_management/state_storage/lazy_lru_store.js +++ /dev/null @@ -1,276 +0,0 @@ -import { sortBy } from 'lodash'; - -import Notifier from 'ui/notify/notifier'; - -/** - * The maximum number of times that we will try to - * clear space after a call to setItem on the store fails - * - * @type {Number} - */ -const DEFAULT_MAX_SET_ATTEMPTS = 3; - -/** - * When trying to clear enough space for a key+chunk, - * multiply the necessary space by this to produce the - * "ideal" amount of space to clear. - * - * By clearing the "ideal" amount instead of just the - * necessary amount we prevent extra calls cleanup calls. - * - * The "ideal" amount is limited by the MAX_IDEAL_CLEAR_PERCENT - * - * @type {Number} - */ -const DEFAULT_IDEAL_CLEAR_RATIO = 100; - -/** - * A limit to the amount of space that can be cleared - * by the inflation caused by the IDEAL_CLEAR_RATIO - * @type {Number} - */ -const DEFAULT_MAX_IDEAL_CLEAR_PERCENT = 0.3; - -export default class LazyLruStore { - constructor(opts = {}) { - const { - id, - store, - notifier = new Notifier(`LazyLruStore (re: probably history hashing)`), - maxItems = Infinity, - maxSetAttempts = DEFAULT_MAX_SET_ATTEMPTS, - idealClearRatio = DEFAULT_IDEAL_CLEAR_RATIO, - maxIdealClearPercent = DEFAULT_MAX_IDEAL_CLEAR_PERCENT, - } = opts; - - if (!id) throw new TypeError('id is required'); - if (!store) throw new TypeError('store is required'); - if (maxSetAttempts < 1) throw new TypeError('maxSetAttempts must be >= 1'); - if (idealClearRatio < 1) throw new TypeError('idealClearRatio must be >= 1'); - if (maxIdealClearPercent < 0 || maxIdealClearPercent > 1) { - throw new TypeError('maxIdealClearPercent must be between 0 and 1'); - } - - this._id = id; - this._prefix = `lru:${this._id}:`; - this._store = store; - this._notifier = notifier; - this._maxItems = maxItems; - this._itemCountGuess = this._getItemCount(); - this._maxSetAttempts = maxSetAttempts; - this._idealClearRatio = idealClearRatio; - this._maxIdealClearPercent = maxIdealClearPercent; - - this._verifyMaxItems(); - } - - getItem(key) { - const chunk = this._store.getItem(this._getStoreKey(key)); - if (chunk === null) return null; - const { val } = this._parseChunk(chunk); - return val; - } - - setItem(key, val) { - const newKey = !this._storeHasKey(key); - this._attemptToSet(this._getStoreKey(key), this._getChunk(val)); - if (newKey) this._itemCountGuess += 1; - this._verifyMaxItems(); - } - - removeItem(key) { - if (!this._storeHasKey(key)) return; - this._store.removeItem(this._getStoreKey(key)); - this._itemCountGuess -= 1; - this._verifyMaxItems(); - } - - getStorageOverhead() { - return (this._getStoreKey('') + this._getChunk('')).length; - } - - // private api - - _getStoreKey(key) { - return `${this._prefix}${key}`; - } - - _storeHasKey(key) { - return this._store.getItem(this._getStoreKey(key)) !== null; - } - - /** - * Convert a JSON blob into a chunk, the wrapper around values - * that tells us when they were last stored - * - * @private - * @param {string} val - * @return {string} chunk - */ - _getChunk(val) { - return `${Date.now()}/${val}`; - } - - /** - * Parse a chunk into it's store time and val values - * - * @private - * @param {string} the chunk, probably read from the store - * @return {object} parsed - * @property {number} parsed.time - * @property {string} parsed.val - */ - _parseChunk(chunk) { - const splitIndex = chunk.indexOf('/'); - const time = parseInt(chunk.slice(0, splitIndex), 10); - const val = chunk.slice(splitIndex + 1); - return { time, val }; - } - - /** - * Attempt to a set a key on the store, if the setItem call - * fails then the assumption is that the store is out of space - * so we call this._makeSpaceFor(key, chunk). If this call - * reports that enough space for the key and chunk were cleared, - * then this function will call itself again, this time sending - * attempt + 1 as the attempt number. If this loop continues - * and attempt meets or exceeds the this._maxSetAttempts then a fatal - * error will be sent to notifier, as the users session is no longer - * usable. - * - * @private - * @param {string} key - * @param {string} chunk - * @param {number} [attempt=1] - */ - _attemptToSet(key, chunk, attempt = 1) { - try { - this._store.setItem(key, chunk); - } catch (error) { - if (attempt >= this._maxSetAttempts) { - throw error; - } - - const madeEnoughSpace = this._makeSpaceFor(key, chunk); - if (madeEnoughSpace) { - this._attemptToSet(key, chunk, attempt + 1); - } else { - throw error; - } - } - } - - /** - * Walk all items in the store to find items stored using the same - * this._prefix. Collect the time that key was last set, and the - * byte-size of that item, and report all values found along - * with the total bytes - * - * @private - * @return {object} index - * @property {object[]} index.itemsByOldestAccess - * @property {number} index.totalBytes - */ - _indexStoredItems() { - const store = this._store; - const notifier = this._notifier; - - const items = []; - let totalBytes = 0; - - for (let i = 0; i < store.length; i++) { - const key = store.key(i); - - if (key.slice(0, this._prefix.length) !== this._prefix) { - continue; - } - - const chunk = store.getItem(key); - const { time } = this._parseChunk(chunk); - const bytes = key.length + chunk.length; - items.push({ key, time, bytes }); - totalBytes += bytes; - } - - const itemsByOldestAccess = sortBy(items, 'time'); - return { itemsByOldestAccess, totalBytes }; - } - - _getItemCount() { - const { itemsByOldestAccess } = this._indexStoredItems(); - return itemsByOldestAccess.length; - } - - /** - * Check that the itemCountGuess has not exceeded the maxItems, - * if it has, trim the item list to meet the maxItem count - */ - _verifyMaxItems() { - if (this._maxItems > this._itemCountGuess) return; - - const { itemsByOldestAccess } = this._indexStoredItems(); - // update our guess to make sure it's accurate - this._itemCountGuess = itemsByOldestAccess.length; - // remove all items from the beginning of the list, leaving this._maxItems in the list - itemsByOldestAccess - .slice(0, -this._maxItems) - .forEach(item => this._doItemAutoRemoval(item)); - } - - /** - * Determine how much space to clear so that we can store the specified - * key and chunk into the store. Then clear that data and return true of - * false if we were successfull - * - * @private - * @param {string} key - * @param {string} chunk - * @return {boolean} success - */ - _makeSpaceFor(key, chunk) { - const notifier = this._notifier; - return notifier.event(`trying to make room in lru ${this._id}`, () => { - const { totalBytes, itemsByOldestAccess } = this._indexStoredItems(); - - // pick how much space we are going to try to clear - // by finding a value that is at least the size of - // the key + chunk but up to the key + chunk * IDEAL_CLEAR_RATIO - const freeMin = key.length + chunk.length; - const freeIdeal = freeMin * this._idealClearRatio; - const toClear = Math.max(freeMin, Math.min(freeIdeal, totalBytes * this._maxIdealClearPercent)); - notifier.log(`PLAN: min ${freeMin} bytes, target ${toClear} bytes`); - - let remainingToClear = toClear; - let removedItemCount = 0; - while (itemsByOldestAccess.length > 0 && remainingToClear > 0) { - const item = itemsByOldestAccess.shift(); - remainingToClear -= item.bytes; - removedItemCount += 1; - this._doItemAutoRemoval(item); - } - - const success = remainingToClear <= 0; - - const label = success ? 'SUCCESS' : 'FAILURE'; - const removedBytes = toClear - remainingToClear; - notifier.log(`${label}: removed ${removedItemCount} items for ${removedBytes} bytes`); - return success; - }); - } - - /** - * Extracted helper for automated removal of items with logging - * - * @private - * @param {object} item - * @property {string} item.key - * @property {number} item.time - * @property {number} item.bytes - */ - _doItemAutoRemoval(item) { - const timeString = new Date(item.time).toISOString(); - this._notifier.log(`REMOVE: entry "${item.key}" from ${timeString}, freeing ${item.bytes} bytes`); - this._store.removeItem(item.key); - this._itemCountGuess -= 1; - } -} diff --git a/src/ui/public/state_management/state_storage/state_hash.js b/src/ui/public/state_management/state_storage/state_hash.js new file mode 100644 index 0000000000000..9303f971830f9 --- /dev/null +++ b/src/ui/public/state_management/state_storage/state_hash.js @@ -0,0 +1,29 @@ +import { Sha256 } from 'ui/crypto'; + +// This prefix is used to identify hash strings that have been encoded in the URL. +const HASH_PREFIX = 'h@'; + +export function createStateHash(json, existingJsonProvider) { + if (typeof json !== 'string') { + throw new Error('createHash only accepts strings (JSON).'); + } + + const hash = new Sha256().update(json, 'utf8').digest('hex'); + + let shortenedHash; + + // Shorten the hash to at minimum 7 characters. We just need to make sure that it either: + // a) hasn't been used yet + // b) or has been used already, but with the JSON we're currently hashing. + for (let i = 7; i < hash.length; i++) { + shortenedHash = hash.slice(0, i); + const existingJson = existingJsonProvider(shortenedHash); + if (existingJson === null || existingJson === json) break; + } + + return `${HASH_PREFIX}${shortenedHash}`; +} + +export function isStateHash(str) { + return String(str).indexOf(HASH_PREFIX) === 0; +} From 6f9c708c6f1cf4d383e73e9e5e62bc5430ae5cbc Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 31 Aug 2016 18:12:42 -0700 Subject: [PATCH 08/11] incorporate feedback from @thomasneirynck --- .../state_management/__tests__/state.js | 6 +- src/ui/public/state_management/state.js | 5 +- .../hashed_item_store.js | 3 +- .../{_tests__ => __tests__}/state_hash.js | 1 - .../state_storage/hashed_item_store.js | 63 ++++++------------- 5 files changed, 24 insertions(+), 54 deletions(-) rename src/ui/public/state_management/state_storage/{_tests__ => __tests__}/hashed_item_store.js (99%) rename src/ui/public/state_management/state_storage/{_tests__ => __tests__}/state_hash.js (98%) diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index a24dc482e36f9..28b04c9dcc27a 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -1,5 +1,3 @@ - -import _ from 'lodash'; import sinon from 'sinon'; import expect from 'expect.js'; import ngMock from 'ng_mock'; @@ -112,7 +110,7 @@ describe('State Management', function () { }); state.test = 'foo'; state.save(); - let search = getUnhashedSearch(state); + getUnhashedSearch(state); $rootScope.$apply(); }); }); @@ -218,7 +216,7 @@ describe('State Management', function () { context('error handling', () => { it('notifies the user when a hash value does not map to a stored value', () => { - const { state, hashedItemStore, notifier } = setup({ storeInHash: true }); + const { state, notifier } = setup({ storeInHash: true }); const search = $location.search(); const badHash = createStateHash('{"a": "b"}', () => null); diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index 58768980d0aac..835f0e198cbcf 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -2,10 +2,8 @@ import _ from 'lodash'; import angular from 'angular'; import rison from 'rison-node'; import applyDiff from 'ui/utils/diff_object'; -import qs from 'ui/utils/query_string'; import EventsProvider from 'ui/events'; import Notifier from 'ui/notify/notifier'; -import KbnUrlProvider from 'ui/url'; import { createStateHash, @@ -200,8 +198,7 @@ export default function StateProvider(Private, $rootScope, $location, config) { this._notifier.error('Unable to completely restore the URL, be sure to use the share functionality.'); } - const state = JSON.parse(json); - return state; + return JSON.parse(json); }; /** diff --git a/src/ui/public/state_management/state_storage/_tests__/hashed_item_store.js b/src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js similarity index 99% rename from src/ui/public/state_management/state_storage/_tests__/hashed_item_store.js rename to src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js index ca36f49e2c1ef..fa6095ad01a8a 100644 --- a/src/ui/public/state_management/state_storage/_tests__/hashed_item_store.js +++ b/src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js @@ -1,6 +1,5 @@ import expect from 'expect.js'; import sinon from 'sinon'; -import bluebird from 'bluebird'; import StubBrowserStorage from 'test_utils/stub_browser_storage'; import HashedItemStore from '../hashed_item_store'; @@ -12,7 +11,7 @@ describe('hashedItemStore', () => { const sessionStorage = new StubBrowserStorage(); sinon.spy(sessionStorage, 'getItem'); - const hashedItemStore = new HashedItemStore(sessionStorage); + new HashedItemStore(sessionStorage); sinon.assert.calledWith(sessionStorage.getItem, HashedItemStore.PERSISTED_INDEX_KEY); sessionStorage.getItem.restore(); }); diff --git a/src/ui/public/state_management/state_storage/_tests__/state_hash.js b/src/ui/public/state_management/state_storage/__tests__/state_hash.js similarity index 98% rename from src/ui/public/state_management/state_storage/_tests__/state_hash.js rename to src/ui/public/state_management/state_storage/__tests__/state_hash.js index 80e924f1edc48..97e93fcab4d67 100644 --- a/src/ui/public/state_management/state_storage/_tests__/state_hash.js +++ b/src/ui/public/state_management/state_storage/__tests__/state_hash.js @@ -1,5 +1,4 @@ import expect from 'expect.js'; -import sinon from 'sinon'; import { encode as encodeRison } from 'rison-node'; import { diff --git a/src/ui/public/state_management/state_storage/hashed_item_store.js b/src/ui/public/state_management/state_storage/hashed_item_store.js index 331903e8980b2..15a52629deb3a 100644 --- a/src/ui/public/state_management/state_storage/hashed_item_store.js +++ b/src/ui/public/state_management/state_storage/hashed_item_store.js @@ -51,10 +51,7 @@ * For these reasons, HashedItemStore doesn't concern itself with this constraint. */ -import { - sortBy, - values, -} from 'lodash'; +import { pull, sortBy } from 'lodash'; export default class HashedItemStore { @@ -69,19 +66,12 @@ export default class HashedItemStore { // Store indexed items in descending order by touched (oldest first, newest last). We'll use // this to remove older items when we run out of storage space. this._indexedItems = []; - // Associate item hashes with the corresponding indexed items. We'll use this to quickly look - // up an item and update its touched date when it reoccurs in the browser history. - this._hashToIndexedItemMap = {}; - // Build index from the persisted index. This happens when we re-open a closed tab. + // Potentially restore a previously persisted index. This happens when + // we re-open a closed tab. const persistedItemIndex = this._sessionStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY); - if (persistedItemIndex) { - this._hashToIndexedItemMap = JSON.parse(persistedItemIndex) || {}; - this._indexedItems = values(this._hashToIndexedItemMap); - - // Order items by touched date (oldest first, newest last). - this._indexedItems = sortBy(this._indexedItems, 'touched'); + this._indexedItems = sortBy(JSON.parse(persistedItemIndex) || [], 'touched'); } } @@ -105,6 +95,10 @@ export default class HashedItemStore { return item; } + _getIndexedItem(hash) { + return this._indexedItems.find(indexedItem => indexedItem.hash === hash); + } + _persistItem(hash, item) { try { this._sessionStorage.setItem(hash, item); @@ -128,10 +122,6 @@ export default class HashedItemStore { _removeOldestItem() { const oldestIndexedItem = this._indexedItems.shift(); - - // Remove oldest item from index. - delete this._hashToIndexedItemMap[oldestIndexedItem.hash]; - // Remove oldest item from storage. this._sessionStorage.removeItem(oldestIndexedItem.hash); } @@ -139,36 +129,23 @@ export default class HashedItemStore { _touchHash(hash) { // Touching a hash indicates that it's been used recently, so it won't be the first in line // when we remove items to free up storage space. - if (this._hashToIndexedItemMap[hash]) { - const indexedItem = this._hashToIndexedItemMap[hash]; - - // If item is already indexed, update the touched date. - indexedItem.touched = Date.now(); - - // Since the items are already sorted by touched and we're only changing one item, we can - // avoid a "costly" sort by just moving it to the end of the array. - const index = this._indexedItems.indexOf(indexedItem); - this._indexedItems.splice(index, 1); - this._indexedItems.push(indexedItem); - } else { - // If the item isn't indexed, create it... - const indexedItem = { - hash, - touched: Date.now(), - }; - - // ...and index it. - this._indexedItems.push(indexedItem); - this._hashToIndexedItemMap[hash] = indexedItem; - } + + // either get or create an indexedItem + const indexedItem = this._getIndexedItem(hash) || { hash }; + + // set/update the touched time to now so that it's the "newest" item in the index + indexedItem.touched = Date.now(); + + // ensure that the item is last in the index + pull(this._indexedItems, indexedItem); + this._indexedItems.push(indexedItem); // Regardless of whether this is a new or updated item, we need to persist the index. this._sessionStorage.setItem( HashedItemStore.PERSISTED_INDEX_KEY, - JSON.stringify(this._hashToIndexedItemMap) + JSON.stringify(this._indexedItems) ); } - } -HashedItemStore.PERSISTED_INDEX_KEY = 'kibana.hashedItemIndex'; +HashedItemStore.PERSISTED_INDEX_KEY = 'kbn.hashedItemsIndex.v1'; From 445ceba48f91be4c7f7f0286643c458a22c76f63 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 31 Aug 2016 18:45:37 -0700 Subject: [PATCH 09/11] [state] enable storing the state in session storage by default --- src/ui/settings/defaults.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/settings/defaults.js b/src/ui/settings/defaults.js index fec88985c7d73..ea22536b03916 100644 --- a/src/ui/settings/defaults.js +++ b/src/ui/settings/defaults.js @@ -283,7 +283,7 @@ export default function defaultSettingsProvider() { description: 'Your API key from www.quandl.com' }, 'state:storeInSessionStorage': { - value: false, + value: true, description: 'The URL can sometimes grow to be too large for some browsers to ' + 'handle. To counter-act this we are testing if storing parts of the URL in ' + 'sessions storage could help. Please let us know how it goes!' From a26b8505652bc32f74666c41bfa48106274f5ad1 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 31 Aug 2016 19:15:47 -0700 Subject: [PATCH 10/11] [crypto] add license from sha.js --- src/ui/public/crypto/sha256.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/ui/public/crypto/sha256.js b/src/ui/public/crypto/sha256.js index 697798a261a9a..1c6de18b1dfab 100644 --- a/src/ui/public/crypto/sha256.js +++ b/src/ui/public/crypto/sha256.js @@ -7,6 +7,28 @@ * Version 2.2-beta Copyright Angel Marin, Paul Johnston 2000 - 2009. * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet * + * Copyright (c) 2013-2014 sha.js contributors + * + * Permission is hereby granted, free of charge, + * to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to + * deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, + * merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom + * the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR + * ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ const K = [ From 458630d49c2f04ce044d51619665f5cbd11fa42b Mon Sep 17 00:00:00 2001 From: spalger Date: Tue, 6 Sep 2016 17:06:17 -0700 Subject: [PATCH 11/11] [state] disable saving to sessionStorage by default Rather than enable a behavior we would rather not keep by default, we'll keep it opt-in and link to it so that users who have issues can find the setting --- src/ui/public/chrome/api/angular.js | 20 ++++++++++++++----- .../error_url_overflow.html | 1 + src/ui/settings/defaults.js | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/ui/public/chrome/api/angular.js b/src/ui/public/chrome/api/angular.js index e8f9b30115e6b..198ddfef4cdfd 100644 --- a/src/ui/public/chrome/api/angular.js +++ b/src/ui/public/chrome/api/angular.js @@ -5,7 +5,7 @@ import modules from 'ui/modules'; import Notifier from 'ui/notify/notifier'; import { UrlOverflowServiceProvider } from '../../error_url_overflow'; -const URL_LIMIT_WARN_WITHIN = 150; +const URL_LIMIT_WARN_WITHIN = 1000; module.exports = function (chrome, internals) { @@ -57,10 +57,20 @@ module.exports = function (chrome, internals) { try { if (urlOverflow.check($location.absUrl()) <= URL_LIMIT_WARN_WITHIN) { - notify.warning(` - The URL has gotten big and may cause Kibana - to stop working. Please simplify the data on screen. - `); + notify.directive({ + template: ` +

+ The URL has gotten big and may cause Kibana + to stop working. Please either enable the + state:storeInSessionStorage + option in the advanced + settings or simplify the onscreen visuals. +

+ ` + }, { + type: 'error', + actions: [{ text: 'close' }] + }); } } catch (e) { const { host, path, search, protocol } = parseUrl(window.location.href); diff --git a/src/ui/public/error_url_overflow/error_url_overflow.html b/src/ui/public/error_url_overflow/error_url_overflow.html index a69ce9975352e..143ccb25e2db6 100644 --- a/src/ui/public/error_url_overflow/error_url_overflow.html +++ b/src/ui/public/error_url_overflow/error_url_overflow.html @@ -9,6 +9,7 @@

Ok, how do I fix this?

This usually only happens with big, complex dashboards, so you have some options:

    +
  1. Enable the state:storeInSessionStorage option in the advanced settings. This will prevent the URLs from getting long, but makes them a bit less portable.
  2. Remove some stuff from your dashboard. This will reduce the length of the URL and keep IE in a good place.
  3. Don't use IE. Every other supported browser we know of doesn't have this limit.
diff --git a/src/ui/settings/defaults.js b/src/ui/settings/defaults.js index ea22536b03916..fec88985c7d73 100644 --- a/src/ui/settings/defaults.js +++ b/src/ui/settings/defaults.js @@ -283,7 +283,7 @@ export default function defaultSettingsProvider() { description: 'Your API key from www.quandl.com' }, 'state:storeInSessionStorage': { - value: true, + value: false, description: 'The URL can sometimes grow to be too large for some browsers to ' + 'handle. To counter-act this we are testing if storing parts of the URL in ' + 'sessions storage could help. Please let us know how it goes!'