diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index 611d7cbd215f1..b3fe0bc2dd07e 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -13,6 +13,7 @@ import { registerSuggestionsApi } from './server/routes/api/suggestions'; import * as systemApi from './server/lib/system_api'; import handleEsError from './server/lib/handle_es_error'; import mappings from './mappings.json'; +import { getUiSettingDefaults } from './ui_setting_defaults'; import { injectVars } from './inject_vars'; @@ -106,7 +107,9 @@ export default function (kibana) { translations: [ resolve(__dirname, './translations/en.json') ], - mappings + + mappings, + uiSettingDefaults: getUiSettingDefaults(), }, preInit: async function (server) { diff --git a/src/ui/ui_settings/defaults.js b/src/core_plugins/kibana/ui_setting_defaults.js similarity index 89% rename from src/ui/ui_settings/defaults.js rename to src/core_plugins/kibana/ui_setting_defaults.js index 3890333f23507..65947a341cf66 100644 --- a/src/ui/ui_settings/defaults.js +++ b/src/core_plugins/kibana/ui_setting_defaults.js @@ -1,6 +1,6 @@ import moment from 'moment-timezone'; -export function getDefaultSettings() { +export function getUiSettingDefaults() { const weekdays = moment.weekdays().slice(); const [defaultWeekday] = weekdays; @@ -283,47 +283,6 @@ export function getDefaultSettings() { value: 2000, description: 'The maximum number of buckets a single datasource can return' }, - // Timelion stuff - 'timelion:showTutorial': { - value: false, - description: 'Should I show the tutorial by default when entering the timelion app?' - }, - 'timelion:es.timefield': { - value: '@timestamp', - description: 'Default field containing a timestamp when using .es()' - }, - 'timelion:es.default_index': { - value: '_all', - description: 'Default elasticsearch index to search with .es()' - }, - 'timelion:target_buckets': { - value: 200, - description: 'The number of buckets to shoot for when using auto intervals' - }, - 'timelion:max_buckets': { - value: 2000, - description: 'The maximum number of buckets a single datasource can return' - }, - 'timelion:default_columns': { - value: 2, - description: 'Number of columns on a timelion sheet by default' - }, - 'timelion:default_rows': { - value: 2, - description: 'Number of rows on a timelion sheet by default' - }, - 'timelion:min_interval': { - value: '1ms', - description: 'The smallest interval that will be calculated when using "auto"' - }, - 'timelion:graphite.url': { - value: 'https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', - description: '[experimental] The URL of your graphite host' - }, - 'timelion:quandl.key': { - value: 'someKeyHere', - description: '[experimental] 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 ' + diff --git a/src/core_plugins/tests_bundle/index.js b/src/core_plugins/tests_bundle/index.js index e78f4bd008df9..335457290293e 100644 --- a/src/core_plugins/tests_bundle/index.js +++ b/src/core_plugins/tests_bundle/index.js @@ -1,5 +1,4 @@ import { union } from 'lodash'; -import { getDefaultSettings } from '../../ui/ui_settings/defaults'; import findSourceFiles from './find_source_files'; import { fromRoot } from '../../utils'; @@ -57,7 +56,11 @@ export default (kibana) => { }); } - env.defaultUiSettings = getDefaultSettings(); + env.defaultUiSettings = plugins.kbnServer.uiExports.consumers + // find the first uiExportsConsumer that has a getUiSettingDefaults method + // See src/ui/ui_settings/ui_exports_consumer.js + .find(consumer => typeof consumer.getUiSettingDefaults === 'function') + .getUiSettingDefaults(); return new UiBundle({ id: 'tests', diff --git a/src/core_plugins/timelion/index.js b/src/core_plugins/timelion/index.js index c510267c823ca..aee3cb9a24f90 100644 --- a/src/core_plugins/timelion/index.js +++ b/src/core_plugins/timelion/index.js @@ -36,7 +36,50 @@ export default function (kibana) { visTypes: [ 'plugins/timelion/vis' ], - mappings: require('./mappings.json') + mappings: require('./mappings.json'), + + uiSettingDefaults: { + 'timelion:showTutorial': { + value: false, + description: 'Should I show the tutorial by default when entering the timelion app?' + }, + 'timelion:es.timefield': { + value: '@timestamp', + description: 'Default field containing a timestamp when using .es()' + }, + 'timelion:es.default_index': { + value: '_all', + description: 'Default elasticsearch index to search with .es()' + }, + 'timelion:target_buckets': { + value: 200, + description: 'The number of buckets to shoot for when using auto intervals' + }, + 'timelion:max_buckets': { + value: 2000, + description: 'The maximum number of buckets a single datasource can return' + }, + 'timelion:default_columns': { + value: 2, + description: 'Number of columns on a timelion sheet by default' + }, + 'timelion:default_rows': { + value: 2, + description: 'Number of rows on a timelion sheet by default' + }, + 'timelion:min_interval': { + value: '1ms', + description: 'The smallest interval that will be calculated when using "auto"' + }, + 'timelion:graphite.url': { + value: 'https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', + description: '[experimental] The URL of your graphite host' + }, + 'timelion:quandl.key': { + value: 'someKeyHere', + description: '[experimental] Your API key from www.quandl.com' + } + } }, init: require('./init.js'), }); diff --git a/src/server/kbn_server.js b/src/server/kbn_server.js index e4d18a7b8afb9..570b2fdec4a1f 100644 --- a/src/server/kbn_server.js +++ b/src/server/kbn_server.js @@ -15,7 +15,7 @@ import pluginsScanMixin from './plugins/scan'; import pluginsCheckEnabledMixin from './plugins/check_enabled'; import pluginsCheckVersionMixin from './plugins/check_version'; import configCompleteMixin from './config/complete'; -import uiMixin, { uiSettingsMixin } from '../ui'; +import uiMixin from '../ui'; import optimizeMixin from '../optimize'; import pluginsInitializeMixin from './plugins/initialize'; import { indexPatternsMixin } from './index_patterns'; @@ -66,9 +66,6 @@ export default class KbnServer { // setup saved object routes savedObjectsMixin, - // setup server.uiSettings - uiSettingsMixin, - // ensure that all bundles are built, or that the // lazy bundle server is running optimizeMixin, diff --git a/src/ui/index.js b/src/ui/index.js index 8d4eb73c85117..1a978dc86f291 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -9,13 +9,15 @@ import UiBundleCollection from './ui_bundle_collection'; import UiBundlerEnv from './ui_bundler_env'; import { UiI18n } from './ui_i18n'; -export { uiSettingsMixin } from './ui_settings'; +import { uiSettingsMixin } from './ui_settings'; export default async (kbnServer, server, config) => { const uiExports = kbnServer.uiExports = new UiExports({ urlBasePath: config.get('server.basePath') }); + await kbnServer.mixin(uiSettingsMixin); + const uiI18n = kbnServer.uiI18n = new UiI18n(config.get('i18n.defaultLocale')); uiI18n.addUiExportConsumer(uiExports); diff --git a/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js index 11159061ef74f..a81d7d6d8d3a3 100644 --- a/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js +++ b/src/ui/ui_settings/__tests__/ui_settings_mixin_integration.js @@ -51,6 +51,7 @@ describe('uiSettingsMixin()', () => { const kbnServer = { server, config, + uiExports: { addConsumer: sinon.stub() }, status: new ServerStatus(server), ready: sinon.stub().returns(readyPromise), }; @@ -133,9 +134,14 @@ describe('uiSettingsMixin()', () => { sandbox.stub(uiSettingsServiceFactoryNS, 'uiSettingsServiceFactory'); sinon.assert.notCalled(uiSettingsServiceFactory); - const football = {}; - decorations.server.uiSettingsServiceFactory(football); - sinon.assert.calledWith(uiSettingsServiceFactory, server, football); + decorations.server.uiSettingsServiceFactory({ + foo: 'bar' + }); + sinon.assert.calledOnce(uiSettingsServiceFactory); + sinon.assert.calledWithExactly(uiSettingsServiceFactory, server, { + foo: 'bar', + getDefaults: sinon.match.func, + }); }); }); @@ -168,7 +174,10 @@ describe('uiSettingsMixin()', () => { sandbox.stub(getUiSettingsServiceForRequestNS, 'getUiSettingsServiceForRequest'); decorations.request.getUiSettingsService(); - const readInterceptor = getUiSettingsServiceForRequest.firstCall.args[2]; + const options = getUiSettingsServiceForRequest.firstCall.args[2]; + expect(options).to.have.property('readInterceptor'); + + const { readInterceptor } = options; expect(readInterceptor).to.be.a('function'); status.green(); diff --git a/src/ui/ui_settings/__tests__/ui_settings_service.js b/src/ui/ui_settings/__tests__/ui_settings_service.js index c7706414b0a0c..ed121e004bc69 100644 --- a/src/ui/ui_settings/__tests__/ui_settings_service.js +++ b/src/ui/ui_settings/__tests__/ui_settings_service.js @@ -1,8 +1,8 @@ import { isEqual } from 'lodash'; import expect from 'expect.js'; import { errors as esErrors } from 'elasticsearch'; +import Chance from 'chance'; -import { getDefaultSettings } from '../defaults'; import { UiSettingsService } from '../ui_settings_service'; import { createCallClusterStub } from './lib'; @@ -10,10 +10,13 @@ import { createCallClusterStub } from './lib'; const INDEX = '.kibana'; const TYPE = 'config'; const ID = 'kibana-version'; +const chance = new Chance(); function setup(options = {}) { const { readInterceptor, + getDefaults, + defaults = {}, esDocSource = {}, callCluster = createCallClusterStub(INDEX, TYPE, ID, esDocSource) } = options; @@ -22,6 +25,7 @@ function setup(options = {}) { index: INDEX, type: TYPE, id: ID, + getDefaults: getDefaults || (() => defaults), readInterceptor, callCluster, }); @@ -114,34 +118,26 @@ describe('ui settings', () => { }); describe('#getDefaults()', () => { - it('is promised the default values', async () => { - const { - uiSettings - } = setup(); - const defaults = await uiSettings.getDefaults(); - expect(isEqual(defaults, getDefaultSettings())).to.equal(true); - }); - - - describe('defaults for formatters', async () => { - - const defaults = getDefaultSettings(); - const mapping = JSON.parse(defaults['format:defaultTypeMap'].value); - const expected = { - ip: { id: 'ip', params: {} }, - date: { id: 'date', params: {} }, - number: { id: 'number', params: {} }, - boolean: { id: 'boolean', params: {} }, - _source: { id: '_source', params: {} }, - _default_: { id: 'string', params: {} } - }; + it('returns a promise for the defaults', async () => { + const { uiSettings } = setup(); + const promise = uiSettings.getDefaults(); + expect(promise).to.be.a(Promise); + expect(await promise).to.eql({}); + }); + }); - Object.keys(mapping).forEach((dataType) => { - it(`should configure ${dataType}`, () => { - expect(expected.hasOwnProperty(dataType)).to.equal(true); - expect(mapping[dataType].id).to.equal(expected[dataType].id); - expect(JSON.stringify(mapping[dataType].params)).to.equal(JSON.stringify(expected[dataType].params)); - }); + describe('getDefaults() argument', () => { + it('casts sync `getDefaults()` to promise', () => { + const getDefaults = () => ({ key: { value: chance.word() } }); + const { uiSettings } = setup({ getDefaults }); + expect(uiSettings.getDefaults()).to.be.a(Promise); + }); + + it('returns the defaults returned by getDefaults() argument', async () => { + const value = chance.word(); + const { uiSettings } = setup({ defaults: { key: { value } } }); + expect(await uiSettings.getDefaults()).to.eql({ + key: { value } }); }); }); @@ -244,27 +240,26 @@ describe('ui settings', () => { it(`without user configuration it's equal to the defaults`, async () => { const esDocSource = {}; - const { uiSettings } = setup({ esDocSource }); + const defaults = { key: { value: chance.word() } }; + const { uiSettings } = setup({ esDocSource, defaults }); const result = await uiSettings.getRaw(); - expect(isEqual(result, getDefaultSettings())).to.equal(true); + expect(result).to.eql(defaults); }); it(`user configuration gets merged with defaults`, async () => { const esDocSource = { foo: 'bar' }; - const { uiSettings } = setup({ esDocSource }); + const defaults = { key: { value: chance.word() } }; + const { uiSettings } = setup({ esDocSource, defaults }); const result = await uiSettings.getRaw(); - const merged = getDefaultSettings(); - merged.foo = { userValue: 'bar' }; - expect(isEqual(result, merged)).to.equal(true); - }); - it(`user configuration gets merged into defaults`, async () => { - const esDocSource = { dateFormat: 'YYYY-MM-DD' }; - const { uiSettings } = setup({ esDocSource }); - const result = await uiSettings.getRaw(); - const merged = getDefaultSettings(); - merged.dateFormat.userValue = 'YYYY-MM-DD'; - expect(isEqual(result, merged)).to.equal(true); + expect(result).to.eql({ + foo: { + userValue: 'bar', + }, + key: { + value: defaults.key.value, + }, + }); }); }); @@ -276,42 +271,32 @@ describe('ui settings', () => { assertGetQuery(); }); - it(`returns key value pairs`, async () => { - const esDocSource = {}; - const { uiSettings } = setup({ esDocSource }); - const result = await uiSettings.getAll(); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach((key) => { - expectation[key] = defaults[key].value; + it(`returns defaults when es doc is empty`, async () => { + const esDocSource = { }; + const defaults = { foo: { value: 'bar' } }; + const { uiSettings } = setup({ esDocSource, defaults }); + expect(await uiSettings.getAll()).to.eql({ + foo: 'bar' }); - expect(isEqual(result, expectation)).to.equal(true); }); - it(`returns key value pairs including user configuration`, async () => { - const esDocSource = { something: 'user-provided' }; - const { uiSettings } = setup({ esDocSource }); - const result = await uiSettings.getAll(); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach((key) => { - expectation[key] = defaults[key].value; - }); - expectation.something = 'user-provided'; - expect(isEqual(result, expectation)).to.equal(true); - }); + it(`merges user values, including ones without defaults, into key value pairs`, async () => { + const esDocSource = { + foo: 'user-override', + bar: 'user-provided', + }; - it(`returns key value pairs including user configuration for existing settings`, async () => { - const esDocSource = { dateFormat: 'YYYY-MM-DD' }; - const { uiSettings } = setup({ esDocSource }); - const result = await uiSettings.getAll(); - const defaults = getDefaultSettings(); - const expectation = {}; - Object.keys(defaults).forEach((key) => { - expectation[key] = defaults[key].value; + const defaults = { + foo: { + value: 'default' + }, + }; + + const { uiSettings } = setup({ esDocSource, defaults }); + expect(await uiSettings.getAll()).to.eql({ + foo: 'user-override', + bar: 'user-provided', }); - expectation.dateFormat = 'YYYY-MM-DD'; - expect(isEqual(result, expectation)).to.equal(true); }); }); @@ -325,9 +310,9 @@ describe('ui settings', () => { it(`returns the promised value for a key`, async () => { const esDocSource = {}; - const { uiSettings } = setup({ esDocSource }); + const defaults = { dateFormat: { value: chance.word() } }; + const { uiSettings } = setup({ esDocSource, defaults }); const result = await uiSettings.get('dateFormat'); - const defaults = getDefaultSettings(); expect(result).to.equal(defaults.dateFormat.value); }); @@ -371,23 +356,21 @@ describe('ui settings', () => { describe('#getAll()', () => { it('merges intercept value with defaults', async () => { const { uiSettings } = setup({ + defaults: { + foo: { value: 'foo' }, + bar: { value: 'bar' }, + }, + readInterceptor: () => ({ foo: 'not foo' }), }); - const defaults = getDefaultSettings(); - const defaultValues = Object.keys(defaults).reduce((acc, key) => ({ - ...acc, - [key]: defaults[key].value, - }), {}); - expect(await uiSettings.getAll()).to.eql({ - ...defaultValues, foo: 'not foo', + bar: 'bar' }); }); }); }); - }); diff --git a/src/ui/ui_settings/ui_exports_consumer.js b/src/ui/ui_settings/ui_exports_consumer.js new file mode 100644 index 0000000000000..74962c8845049 --- /dev/null +++ b/src/ui/ui_settings/ui_exports_consumer.js @@ -0,0 +1,43 @@ +/** + * The UiExports class accepts consumer objects that it consults while + * trying to consume all of the `uiExport` declarations provided by + * plugins. + * + * UiExportConsumer is instantiated and passed to UiExports, then for + * every `uiExport` declaration the `exportConsumer(type)` method is + * with the key of the declaration. If this consumer knows how to handle + * that key we return a function that will be called with the plugins + * and values of all declarations using that key. + * + * With this, the consumer merges all of the declarations into the + * _uiSettingDefaults map, ensuring that there are not collisions along + * the way. + * + * @class UiExportsConsumer + */ +export class UiExportsConsumer { + _uiSettingDefaults = {}; + + exportConsumer(type) { + switch (type) { + case 'uiSettingDefaults': + return (plugin, settingDefinitions) => { + Object.keys(settingDefinitions).forEach((key) => { + if (key in this._uiSettingDefaults) { + throw new Error(`uiSettingDefaults for key "${key}" are already defined`); + } + + this._uiSettingDefaults[key] = settingDefinitions[key]; + }); + }; + } + } + + /** + * Get the map of uiSettingNames to "default" specifications + * @return {Object} + */ + getUiSettingDefaults() { + return this._uiSettingDefaults; + } +} diff --git a/src/ui/ui_settings/ui_settings_mixin.js b/src/ui/ui_settings/ui_settings_mixin.js index 92651ef59e7e5..62b59db991542 100644 --- a/src/ui/ui_settings/ui_settings_mixin.js +++ b/src/ui/ui_settings/ui_settings_mixin.js @@ -1,10 +1,15 @@ import { uiSettingsServiceFactory } from './ui_settings_service_factory'; import { getUiSettingsServiceForRequest } from './ui_settings_service_for_request'; import { mirrorStatus } from './mirror_status'; +import { UiExportsConsumer } from './ui_exports_consumer'; export function uiSettingsMixin(kbnServer, server, config) { const status = kbnServer.status.create('ui settings'); + // reads the "uiSettingDefaults" from uiExports + const uiExportsConsumer = new UiExportsConsumer(); + kbnServer.uiExports.addConsumer(uiExportsConsumer); + if (!config.get('uiSettings.enabled')) { status.disabled('uiSettings.enabled config is set to `false`'); return; @@ -17,23 +22,33 @@ export function uiSettingsMixin(kbnServer, server, config) { // If the ui settings status isn't green we shouldn't be attempting to get // user settings, since we can't be sure that all the necessary conditions // (e.g. elasticsearch being available) are met. - const readUiSettingsInterceptor = () => { + const readInterceptor = () => { if (status.state !== 'green') { return {}; } }; + const getDefaults = () => ( + uiExportsConsumer.getUiSettingDefaults() + ); + // don't return, just let it happen when the plugins are ready kbnServer.ready().then(() => { mirrorStatus(status, kbnServer.status.getForPluginId('elasticsearch')); }); - server.decorate('server', 'uiSettingsServiceFactory', function (options) { - return uiSettingsServiceFactory(server, options); + server.decorate('server', 'uiSettingsServiceFactory', (options = {}) => { + return uiSettingsServiceFactory(server, { + getDefaults, + ...options + }); }); server.decorate('request', 'getUiSettingsService', function () { - return getUiSettingsServiceForRequest(server, this, readUiSettingsInterceptor); + return getUiSettingsServiceForRequest(server, this, { + getDefaults, + readInterceptor, + }); }); server.decorate('server', 'uiSettings', () => { diff --git a/src/ui/ui_settings/ui_settings_service.js b/src/ui/ui_settings/ui_settings_service.js index f929d79d91437..57d0db8e9c96b 100644 --- a/src/ui/ui_settings/ui_settings_service.js +++ b/src/ui/ui_settings/ui_settings_service.js @@ -1,8 +1,6 @@ import { defaultsDeep, noop } from 'lodash'; import { errors as esErrors } from 'elasticsearch'; -import { getDefaultSettings } from './defaults'; - function hydrateUserSettings(userSettings) { return Object.keys(userSettings) .map(key => ({ key, userValue: userSettings[key] })) @@ -32,17 +30,21 @@ export class UiSettingsService { id, callCluster, readInterceptor = noop, + // we use a function for getDefaults() so that defaults can be different in + // different scenarios, and so they can change over time + getDefaults = () => ({}), } = options; this._callCluster = callCluster; + this._getDefaults = getDefaults; this._readInterceptor = readInterceptor; this._index = index; this._type = type; this._id = id; } - getDefaults() { - return getDefaultSettings(); + async getDefaults() { + return await this._getDefaults(); } // returns a Promise for the value of the requested setting @@ -65,7 +67,7 @@ export class UiSettingsService { async getRaw() { const userProvided = await this.getUserProvided(); - return defaultsDeep(userProvided, this.getDefaults()); + return defaultsDeep(userProvided, await this.getDefaults()); } async getUserProvided(options) { diff --git a/src/ui/ui_settings/ui_settings_service_factory.js b/src/ui/ui_settings/ui_settings_service_factory.js index c300bc9bc4bf9..c0b70223a3dda 100644 --- a/src/ui/ui_settings/ui_settings_service_factory.js +++ b/src/ui/ui_settings/ui_settings_service_factory.js @@ -8,6 +8,8 @@ import { UiSettingsService } from './ui_settings_service'; * @param {Object} options * @property {AsyncFunction} options.callCluster function that accepts a method name and * param object which causes a request via some elasticsearch client + * @property {AsyncFunction} [options.getDefaults] async function that returns defaults/details about + * the uiSettings. * @property {AsyncFunction} [options.readInterceptor] async function that is called when the * UiSettingsService does a read() an has an oportunity to intercept the * request and return an alternate `_source` value to use. @@ -18,7 +20,8 @@ export function uiSettingsServiceFactory(server, options) { const { callCluster, - readInterceptor + readInterceptor, + getDefaults, } = options; return new UiSettingsService({ @@ -27,5 +30,6 @@ export function uiSettingsServiceFactory(server, options) { id: config.get('pkg.version'), callCluster, readInterceptor, + getDefaults, }); } diff --git a/src/ui/ui_settings/ui_settings_service_for_request.js b/src/ui/ui_settings/ui_settings_service_for_request.js index 650bc98a53781..d43657b6d8f42 100644 --- a/src/ui/ui_settings/ui_settings_service_for_request.js +++ b/src/ui/ui_settings/ui_settings_service_for_request.js @@ -2,14 +2,36 @@ import { uiSettingsServiceFactory } from './ui_settings_service_factory'; const BY_REQUEST_CACHE = new WeakMap(); -export function getUiSettingsServiceForRequest(server, request, readInterceptor) { +/** + * Get/create an instance of UiSettingsService bound to a specific request. + * Each call is cached (keyed on the request object itself) and subsequent + * requests will get the first UiSettingsService instance even if the `options` + * have changed. + * + * @param {Hapi.Server} server + * @param {Hapi.Request} request + * @param {Object} [options={}] + * @property {AsyncFunction} [options.getDefaults] async function that returns defaults/details about + * the uiSettings. + * @property {AsyncFunction} [options.readInterceptor] async function that is called when the + * UiSettingsService does a read() and has an oportunity to intercept the + * request and return an alternate `_source` value to use. + * @return {UiSettingsService} + */ +export function getUiSettingsServiceForRequest(server, request, options = {}) { if (BY_REQUEST_CACHE.has(request)) { return BY_REQUEST_CACHE.get(request); } + const { + readInterceptor, + getDefaults + } = options; + const adminCluster = server.plugins.elasticsearch.getCluster('admin'); const uiSettingsService = uiSettingsServiceFactory(server, { readInterceptor, + getDefaults, callCluster(...args) { return adminCluster.callWithRequest(request, ...args); }