added saved_object component, which generalizes the saved_____ stuff

This commit is contained in:
Spencer Alger 2014-04-10 14:29:37 -07:00
parent 8abbc82475
commit f441d2d421
17 changed files with 447 additions and 170 deletions

3
.gitignore vendored
View file

@ -1,2 +1,3 @@
.DS_Store
node_modules
node_modules
!src/bower_components/**/*

View file

@ -19,25 +19,23 @@ define(function (require) {
gridster,
widgets;
if (_.isUndefined($scope.control) || _.isUndefined($scope.grid)) {
return;
}
elem.addClass('gridster');
width = elem.width();
var init = function () {
initGrid();
elem.on('click', 'li i.remove', function (event) {
var target = event.target.parentNode.parentNode;
gridster.remove_widget(target);
});
$scope.control.unserializeGrid($scope.grid);
$scope.$watch('grid', function () {
initGrid();
$scope.control.unserializeGrid($scope.grid);
});
};
var initGrid = function () {
var initGrid = _.once(function () {
gridster = elem.gridster({
autogenerate_stylesheet: false,
widget_margins: [5, 5],
@ -58,13 +56,17 @@ define(function (require) {
}
}).data('gridster');
gridster.generate_stylesheet({namespace: '.gridster'});
};
});
$scope.control.clearGrid = function (cb) {
gridster.remove_all_widgets();
};
$scope.control.unserializeGrid = function (grid) {
if (typeof grid === 'string') {
grid = JSON.stringify(grid);
}
gridster.remove_all_widgets();
_.each(grid, function (panel) {
$scope.control.addWidget(panel);
});

View file

@ -3,82 +3,44 @@ define(function (require) {
var _ = require('lodash');
var inherits = require('utils/inherits');
require('saved_object/saved_object');
// Used only by the savedDashboards service, usually no reason to change this
module.factory('SavedDashboard', function (configFile, courier, Promise, createNotifier, CouriersDocSource) {
module.factory('SavedDashboard', function (configFile, courier, Promise, createNotifier, SavedObject) {
// Create a notified for setting alerts
var notify = createNotifier({
location: 'Saved Dashboard'
});
// SavedDashboard constructor. Usually you'd interact with an instance of this
// ID is option, otherwise one will be generated on save.
// SavedDashboard constructor. Usually you'd interact with an instance of this.
// ID is option, without it one will be generated on save.
function SavedDashboard(id) {
SavedObject.call(this, {
// this object will be saved at {{configFile.kibanaIndex}}/dashboard/{{id}}
type: 'dashboard',
// Keep a reference to this
var dash = this;
// if this is ==null then the SavedObject will be assigned the defaults
id: id,
// Intializes a docSource for dash
CouriersDocSource.call(dash, courier);
// if the index is not defined, we will push this mapping into ES
mapping: {
title: 'string',
hits: 'integer',
description: 'string',
panelsJSON: 'string'
},
// Wrap this once so that accidental re-init's don't cause extra ES calls
dash.init = _.once(function () {
// If we haven't saved to ES, there's no point is asking ES for the dashboard
// just return whatever we have
if (dash.unsaved) return Promise.resolved(dash);
// defeault values to assign to the doc
defaults: {
title: 'New Dashboard',
hits: 0,
description: '',
panelsJSON: '[]'
},
// Otherwise, get the dashboard.
return dash.fetch().then(function applyUpdate(resp) {
if (!resp.found) throw new Error('Unable to find that Dashboard...');
// Since the dashboard was found, we know it has been saved before
dash.unsaved = false;
// Set the ID of our docSource based on ES response
dash.set('id', resp._id);
// Give dash.details all of the properties of _source
_.assign(dash.details, resp._source);
// Any time dash is updated, re-call applyUpdate
dash.onUpdate().then(applyUpdate, notify.fatal);
return dash;
});
// should a search source be made available for this SavedObject
searchSource: false
});
// Properties needed for Elasticsearch
dash.index(configFile.kibanaIndex)
.type('dashboard')
.id(id || void 0);
// If we need to do anything different on first save, we know if the dash is unsaved
// eg, change location.url on first save
// If there is no id passed in, the dashboard has not been saved yet.
dash.unsaved = !id;
// Attach some new properties in a new object so they don't collide
// Effectively the contents of _source
dash.details = {
title: 'New Dashboard',
panels: []
};
// Persist our dash object back into Elasticsearch
dash.save = function () {
// dash.doIndex stores dash.detail as the document in elasticxsearch
return dash.doIndex(dash.details)
.then(function (id) {
dash.set('id', id);
return id;
});
};
}
// Sets savedDashboard.prototype to an instance of CourierDocSource
inherits(SavedDashboard, CouriersDocSource);
// Sets savedDashboard.prototype to an instance of SavedObject
inherits(SavedDashboard, SavedObject);
return SavedDashboard;
});

View file

@ -3,11 +3,11 @@
<div class="container-fluid">
<div class="navbar-header">
<span class="navbar-brand pull-left" ng-click="editingTitle = true" ng-hide="editingTitle">
{{dash.details.title}}
{{dash.title}}
</span>
<span class="pull-left" ng-show="editingTitle">
<form class="navbar-form" ng-submit="editingTitle = false">
<input type="text" ng-model="dash.details.title" class="form-control"/>
<input type="text" ng-model="dash.title" class="form-control"/>
</form>
</span>
</div>
@ -22,7 +22,7 @@
<config config-template="configTemplate" config-object="configurable" config-close="configClose" config-submit="configSubmit"></config>
<div class="container-default">
<ul dashboard-grid grid="dash.details.panels" control="gridControl"></ul>
<ul dashboard-grid grid="panels" control="gridControl"></ul>
</div>
</div>

View file

@ -61,39 +61,41 @@ define(function (require) {
// All inputs go here.
$scope.input = {
search: ''
search: void 0
};
// Setup configurable values for config directive, after objects are initialized
$scope.configurable = {
dashboard: dash.details,
dashboard: dash,
input: $scope.input
};
$scope.$on('$destroy', _.bindKey(dash, 'cancelPending'));
$scope.$on('$destroy', dash.destroy);
var dashboardSearch = function () {
//ignore first run, just the watcher getting initialized
$scope.$watch('dash.panelsJSON', function (val) {
$scope.panels = JSON.parse(val || '[]');
});
dashboardSearch = function (query) {
if (_.isString(query) && query.length > 0) {
query = {match: {title: {query: query, type: 'phrase_prefix'}}};
} else {
query = {match_all: {}};
var dashboardSearch = function (query) {
if (query === void 0) return;
if (_.isString(query) && query.length > 0) {
query = {match: {title: {query: query, type: 'phrase_prefix'}}};
} else {
query = {match_all: {}};
}
es.search({
index: configFile.kibanaIndex,
type: 'dashboard',
size: 10,
body: {
query: query
}
es.search({
index: configFile.kibanaIndex,
type: 'dashboard',
size: 10,
body: {
query: query
}
})
.then(function (res) {
$scope.configurable.searchResults = res.hits.hits;
});
};
})
.then(function (res) {
$scope.configurable.searchResults = res.hits.hits;
});
};
$scope.$watch('configurable.input.search', dashboardSearch);
@ -118,12 +120,13 @@ define(function (require) {
};
$scope.save = function () {
var wasUnsaved = dash.unsaved;
dash.details.panels = $scope.gridControl.serializeGrid();
dash.panelsJSON = JSON.stringify($scope.gridControl.serializeGrid() || []);
return dash.save()
.then(function (res) {
if (wasUnsaved) $location.url('/dashboard/' + encodeURIComponent(dash.get('id')));
if (dash.id !== $routeParams.id) {
$location.url('/dashboard/' + encodeURIComponent(dash.id));
}
return true;
})
.catch(notify.fatal);

View file

@ -3,7 +3,10 @@
<input type="text" ng-model="configurable.input.search" class="form-control"/>
</div>
<ul class="nav nav-pills">
<li ng-repeat="res in configurable.searchResults | orderBy:'_source.title'" ng-class="{active: configurable.dashboard.title == res._source.title}">
<a ng-href="#/dashboard/{{res._id}}">{{res._source.title}}</a>
<li
ng-repeat="res in configurable.searchResults | orderBy:'_source.fields.title'"
ng-class="{active: configurable.dashboard.title == res._source.fields.title}">
<pre>{{res}}</pre>
<a ng-href="#/dashboard/{{res._id}}">{{res._source.fields.title}}</a>
</li>
</ul>

View file

@ -40,6 +40,8 @@ define(function (require) {
* PUBLIC API
******/
config.file = configFile;
/**
* Executes once and returns a promise that is resolved once the
* config has loaded for the first time.

View file

@ -155,25 +155,6 @@ define(function (require) {
throw new Error('Aborting all pending requests failed.');
}
};
courier._docUpdated = function (doc) {
var key = doc._versionKey();
// filter out the matching requests from the _pendingRequests queue
var pending = this._pendingRequests;
pending.splice(0).filter(function (req) {
var isDoc = req.source._getType() === 'doc';
var keyMatches = isDoc && req.source._versionKey() === key;
if (!keyMatches) {
// put it back into the pending queue
pending.push(req);
return false;
}
req.defer.resolve();
});
};
}
return new Courier();

View file

@ -0,0 +1,40 @@
define(function (require) {
var _ = require('lodash');
return function (Promise, es, $injector) {
var docUpdated = $injector.invoke(require('./_doc_updated'));
/**
* Backend for doUpdate and doIndex
* @param {String} method - the client method to call
* @param {Boolean} validateVersion - should our knowledge
* of the the docs current version be sent to es?
* @param {String} body - HTTP request body
*/
return function (courier, method, validateVersion, body) {
var doc = this;
// straight assignment will causes undefined values
var params = _.pick(this._state, ['id', 'type', 'index']);
params.body = body;
params.ignore = [409];
if (validateVersion && params.id) {
params.version = doc._getVersion();
}
return es[method](params)
.then(function (resp) {
if (resp.status === 409) throw new courier.errors.VersionConflict(resp);
doc._storeVersion(resp._version);
docUpdated(courier, doc, null);
doc.id(resp._id);
return resp._id;
})
.catch(function (err) {
// cast the error
throw new courier.errors.RequestFailure(err);
});
};
};
});

View file

@ -0,0 +1,29 @@
define(function (require) {
return function (Promise) {
/**
* Notify other docs that docs like this one have been updated
* @param {DocSource} doc - the doc that was updated, used to match other listening parties
* @return {undefined}
*/
return function (courier, doc, body) {
var key = doc._versionKey();
if (!body) body = doc.fetch();
// filter out the matching requests from the _pendingRequests queue
var pending = courier._pendingRequests;
pending.splice(0).filter(function (req) {
var isDoc = req.source._getType() === 'doc';
var keyMatches = isDoc && req.source._versionKey() === key;
if (!keyMatches) {
// put it back into the pending queue
pending.push(req);
return false;
}
Promise.cast(body).then(req.defer.resolve);
});
};
};
});

View file

@ -143,7 +143,19 @@ define(function (require) {
* @param {Function} cb - callback
*/
SourceAbstract.prototype.fetch = function () {
return couriersFetch[this._getType()](this);
var courier = this._courier;
var source = this;
return couriersFetch[this._getType()](this)
.then(function (res) {
courier._pendingRequests.splice(0).forEach(function (req) {
if (req.source === source) {
req.defer.resolve(_.cloneDeep(res));
} else {
courier._pendingRequests.push(req);
}
});
return res;
});
};
/**

View file

@ -2,15 +2,15 @@ define(function (require) {
var _ = require('lodash');
var inherits = require('utils/inherits');
var listenerCount = require('utils/event_emitter').listenerCount;
require('./abstract');
var module = require('modules').get('kibana/courier');
module.factory('CouriersDocSource', function (couriersErrors, CouriersSourceAbstract, Promise, es) {
module.factory('CouriersDocSource', function (couriersErrors, CouriersSourceAbstract, Promise, es, $injector) {
var VersionConflict = couriersErrors.VersionConflict;
var RequestFailure = couriersErrors.RequestFailure;
var sendToEs = $injector.invoke(require('./_doc_send_to_es'));
function DocSource(courier, initialState) {
CouriersSourceAbstract.call(this, courier, initialState);
@ -18,6 +18,8 @@ define(function (require) {
// move onResults over to onUpdate, because that makes more sense
this.onUpdate = this.onResults;
this.onResults = void 0;
this._sendToEs = sendToEs;
}
inherits(DocSource, CouriersSourceAbstract);
@ -44,7 +46,7 @@ define(function (require) {
*/
DocSource.prototype.doUpdate = function (fields) {
if (!this._state.id) return this.doIndex(fields);
return this._sendToEs('update', true, { doc: fields });
return this._sendToEs(this._courier, 'update', false, { doc: fields });
};
/**
@ -53,7 +55,7 @@ define(function (require) {
* @return {[type]} [description]
*/
DocSource.prototype.doIndex = function (body) {
return this._sendToEs('index', false, body);
return this._sendToEs(this._courier, 'index', true, body);
};
/*****
@ -91,6 +93,8 @@ define(function (require) {
*/
DocSource.prototype._versionKey = function () {
var state = this._state;
if (!state.index || !state.type || !state.id) return;
return 'DocVersion:' + (
[
state.index,
@ -118,7 +122,10 @@ define(function (require) {
* @return {[type]} [description]
*/
DocSource.prototype._getStoredVersion = function () {
var v = localStorage.getItem(this._versionKey());
var key = this._versionKey();
if (!key) return;
var v = localStorage.getItem(key);
this._version = v ? _.parseInt(v) : void 0;
return this._version;
};
@ -131,50 +138,19 @@ define(function (require) {
DocSource.prototype._storeVersion = function (version) {
if (!version) return this._clearVersion();
var id = this._versionKey();
localStorage.setItem(id, version);
var key = this._versionKey();
if (!key) return;
this._version = version;
localStorage.setItem(key, version);
};
/**
* Clears the stored version for a DocSource
*/
DocSource.prototype._clearVersion = function () {
var id = this._versionKey();
localStorage.removeItem(id);
};
/**
* Backend for doUpdate and doIndex
* @param {String} method - the client method to call
* @param {Boolean} validateVersion - should our knowledge
* of the the docs current version be sent to es?
* @param {String} body - HTTP request body
*/
DocSource.prototype._sendToEs = function (method, validateVersion, body) {
var source = this;
var courier = this._courier;
// straight assignment will causes undefined values
var params = _.pick(this._state, ['id', 'type', 'index']);
params.body = body;
params.ignore = [409];
if (validateVersion) {
params.version = source._getVersion();
}
return es[method](params)
.then(function (resp) {
if (resp.status === 409) throw new VersionConflict(resp);
source._storeVersion(resp._version);
courier._docUpdated(source);
return resp._id;
})
.catch(function (err) {
// cast the error
return new RequestFailure(err);
});
var key = this._versionKey();
if (!key) return;
localStorage.removeItem(key);
};
return DocSource;

View file

@ -50,6 +50,14 @@ define(function (require) {
return this;
};
/**
* Get the parent of this SearchSource
* @return {SearchSource}
*/
SearchSource.prototype.parent = function () {
return this._parent;
};
/******
* PRIVATE APIS
******/

View file

@ -31,6 +31,7 @@ define(function (require) {
all.forEach(function (req) {
req.defer.reject(err);
});
throw err;
});
};

View file

@ -0,0 +1,88 @@
define(function () {
var _ = require('lodash');
/**
* Create the mappingSetup module by passing in it's dependencies.
*/
return function (configFile, es, courier) {
var mappingSetup = {};
/**
* Use to create the mappings, but that should only happen one at a time
*/
var activeTypeCreations = {};
/**
* Get the list of type's mapped in elasticsearch
* @return {[type]} [description]
*/
var getKnownKibanaTypes = _.once(function () {
var indexName = configFile.kibanaIndex;
return es.indices.getFieldMapping({
// only concerned with types in this kibana index
index: indexName,
// check all types
type: '*',
// limit the response to just the _source field for each index
field: '_source'
}).then(function (resp) {
return _.keys(resp[indexName].mappings);
});
});
mappingSetup.isDefined = function (type) {
return getKnownKibanaTypes()
.then(function (knownTypes) {
// if the type is in the knownTypes array already
return !!(~knownTypes.indexOf(type));
});
};
mappingSetup.setup = function (type, mapping) {
// if there is already a creation running for this index type
if (activeTypeCreations[type]) {
// return a promise that will reexecute the setup once the
// current is complete.
return activeTypeCreations[type].then(function () {
return mappingSetup.setup(type, mapping);
});
}
var prom = getKnownKibanaTypes()
.then(function (knownTypes) {
// if the type is in the knownTypes array already
if (~knownTypes.indexOf(type)) return false;
// we need to create the mapping
var body = {};
body[type] = {
properties: mapping
};
return es.indices.putMapping({
index: configFile.kibanaIndex,
type: type,
body: body
}).then(function (resp) {
// add this type to the list of knownTypes
knownTypes.push(type);
// cast the response to "true", meaning
// the mapping exists
return true;
});
})
// wether this fails or not, remove it from the activeTypeCreations obj
// once complete
.finally(function () {
delete activeTypeCreations[type];
});
activeTypeCreations[type] = prom;
return prom;
};
return mappingSetup;
};
});

View file

@ -0,0 +1,169 @@
define(function (require) {
var module = require('modules').get('kibana/saved_object');
var _ = require('lodash');
module.factory('SavedObject', function (courier, configFile, Promise, createNotifier, $injector) {
var mappingSetup = $injector.invoke(require('./_mapping_setup'));
function SavedObject(config) {
if (!_.isObject(config)) config = {};
// save an easy reference to this
var obj = this;
/************
* Initialize config vars
************/
// the doc which is used to store this object
var docSource = courier.createSource('doc');
// type name for this object, used as the ES-type
var type = config.type;
// Create a notifier for sending alerts
var notify = createNotifier({
location: 'Saved ' + type
});
// mapping definition for the fields that this object
// will expose, the actual mapping will place this under it's
// "fields:" propety definition.
var fieldMapping = config.mapping || {};
// default field values, assigned when the source is loaded
var defaults =
// optional search source which this object configures
obj.searchSource = config.searchSource && courier.createSource('search');
// the id of the document
obj.id = config.id || void 0;
/**
* Asynchronously initialize this object - will only run
* once even if called multiple times.
*
* @return {Promise}
* @resolved {SavedObject}
*/
this.init = _.once(function () {
// ensure that the type is defined
if (!type) throw new Error('You must define a type name to use SavedObject objects.');
// tell the docSource where to find the doc
docSource
.index(configFile.kibanaIndex)
.type(type)
.id(obj.id);
// check that the mapping for this type is defined
return mappingSetup.isDefined(type)
.then(function (defined) {
// if it is already defined skip this step
if (defined) return true;
// we need to setup the mapping, flesh it out first
var mapping = {
// wrap the mapping in a "fields" key, so that it won't collide
// with things we add, like "searchSource"
fields: {
// allow shortcuts for the field types, by just setting the value
// to the type name
properties: _.mapValues(mapping, function (val, prop) {
if (typeof val !== 'string') return val;
return {
type: val
};
})
},
// setup the searchSource mapping, even if it is not used but this type yet
searchSourceJSON: {
type: 'string'
}
};
// tell mappingSetup to set type
return mappingSetup.setup(type, mapping);
})
.then(function () {
// If there is not id, then there is no document to fetch from elasticsearch
if (!obj.id) {
// just assign the defaults and be done
_.assign(obj, defaults);
return false;
}
// fetch the object from ES
return docSource.fetch()
.then(function applyDocSourceResp(resp) {
if (!resp.found) throw new Error('Unable to find that ' + type + '.');
// assign the defaults to the response
_.defaults(resp._source.fields, defaults);
// Give obj all of the values in _source.fields
_.assign(obj, resp._source.fields);
// if we have a searchSource, set it's state based on the searchSourceJSON field
if (obj.searchSource) {
var state = {};
try {
state = JSON.parse(resp._source.searchSourceJSON);
} catch (e) {}
obj.searchSource.set(state);
}
// Any time obj is updated, re-call applyDocSourceResp
docSource.onUpdate().then(applyDocSourceResp, notify.fatal);
});
})
.then(function () {
// return our obj as the result of init()
return obj;
});
});
/**
* Save this object
*
* @return {Promise}
* @resolved {String} - The id of the doc
*/
this.save = function () {
var body = {
fields: {}
};
_.forOwn(fieldMapping, function (mapping, fieldName) {
if (obj[fieldName] != null) {
body.fields[fieldName] = obj[fieldName];
}
});
if (obj.searchSource) {
body.searchSourceJSON = JSON.stringify(obj.searchSource);
}
return docSource.doIndex(body).then(function (id) {
obj.id = id;
return id;
});
};
/**
* Destroy this object
*
* @return {undefined}
*/
this.destroy = function () {
docSource.cancelPending();
};
}
return SavedObject;
});
});

View file

@ -27,7 +27,7 @@ define(function (require) {
function checkForES() {
notify.lifecycle('es check');
return es.ping()
return es.ping({ requestTimeout: 2000 })
.catch(function () {
throw new Error('Unable to connect to Elasticsearch at "' + configFile.elasticsearch + '"');
})