Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import/export saved objects #3573

Merged
merged 6 commits into from
Apr 20, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions src/kibana/components/courier/saved_object/saved_object.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,7 @@ define(function (require) {
_.assign(self, self._source);

return Promise.try(function () {
// if we have a searchSource, set it's state based on the searchSourceJSON field
if (self.searchSource) {
var state = {};
try {
state = JSON.parse(meta.searchSourceJSON);
} catch (e) {}

var oldState = self.searchSource.toJSON();
var fnProps = _.transform(oldState, function (dynamic, val, name) {
if (_.isFunction(val)) dynamic[name] = val;
}, {});

self.searchSource.set(_.defaults(state, fnProps));
}
parseSearchSource(meta.searchSourceJSON);
})
.then(hydrateIndexPattern)
.then(function () {
Expand All @@ -153,6 +140,23 @@ define(function (require) {
});
});

function parseSearchSource(searchSourceJson) {
if (!self.searchSource) return;

// if we have a searchSource, set its state based on the searchSourceJSON field
var state = {};
try {
state = JSON.parse(searchSourceJson);
} catch (e) {}

var oldState = self.searchSource.toJSON();
var fnProps = _.transform(oldState, function (dynamic, val, name) {
if (_.isFunction(val)) dynamic[name] = val;
}, {});

self.searchSource.set(_.defaults(state, fnProps));
}

/**
* After creation or fetching from ES, ensure that the searchSources index indexPattern
* is an bonafide IndexPattern object.
Expand Down Expand Up @@ -229,7 +233,7 @@ define(function (require) {
return docSource.doCreate(source)
.then(finish)
.catch(function (err) {
var confirmMessage = 'Are you sure you want to overwrite this?';
var confirmMessage = 'Are you sure you want to overwrite ' + self.title + '?';
if (_.deepGet(err, 'origError.status') === 409 && window.confirm(confirmMessage)) {
return docSource.doIndex(source).then(finish);
}
Expand Down Expand Up @@ -264,7 +268,6 @@ define(function (require) {
});
});
};

}

return SavedObject;
Expand Down
32 changes: 32 additions & 0 deletions src/kibana/directives/file_upload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
define(function (require) {
var module = require('modules').get('kibana');
var $ = require('jquery');

module.directive('fileUpload', function ($parse) {
return {
restrict: 'A',
link: function ($scope, $elem, attrs) {
var onUpload = $parse(attrs.fileUpload);

var $fileInput = $('<input type="file" style="opacity: 0" id="testfile" />');
$elem.after($fileInput);

$fileInput.on('change', function (e) {
var reader = new FileReader();
reader.onload = function (e) {
$scope.$apply(function () {
onUpload($scope, {fileContents: e.target.result});
});
};

var target = e.srcElement || e.target;
if (target && target.files && target.files.length) reader.readAsText(target.files[0]);
});

$elem.on('click', function (e) {
$fileInput.trigger('click');
});
}
};
});
});
21 changes: 14 additions & 7 deletions src/kibana/plugins/settings/sections/objects/_objects.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<kbn-settings-app section="objects">
<kbn-settings-objects class="container">
<h2>Edit Saved Objects</h2>
<div class="header">
<h2 class="title">Edit Saved Objects</h2>
<button class="btn btn-default controls" ng-click="exportAll()"><i aria-hidden="true" class="fa fa-download"></i> Export</button>
<button file-upload="importAll(fileContents)" class="btn btn-default controls" ng-click><i aria-hidden="true" class="fa fa-upload"></i> Import</button>
</div>
<p>
From here you can delete saved objects, such as saved searches. You can also edit the raw data of saved objects. Typically objects are only modified via their associated application, which is probably what you should use instead of this screen. Each tab is limited to 100 results. You can use the filter to find objects not in the default list.
</p>
Expand All @@ -20,13 +24,16 @@ <h2>Edit Saved Objects</h2>
<div class="tab-content">
<div class="action-bar">
<label>
<input type="checkbox" ng-model="deleteAll">
<input type="checkbox" ng-checked="currentTab.data.length > 0 && selectedItems.length == currentTab.data.length" ng-click="toggleAll()" />
Select All
</label>
<a ng-disabled="!deleteAllBtn"
<a ng-disabled="selectedItems.length == 0"
confirm-click="bulkDelete()"
confirmation="Are you sure want to delete the selected {{service.title}}? This action is irreversible!"
class="delete-all btn btn-danger btn-xs" aria-label="Delete Selected"><i aria-hidden="true" class="fa fa-trash"></i> Delete Selected</a>
confirmation="Are you sure want to delete the selected {{currentTab.title}}? This action is irreversible!"
class="btn btn-xs btn-danger" aria-label="Delete"><i aria-hidden="true" class="fa fa-trash"></i> Delete</a>
<a ng-disabled="selectedItems.length == 0"
ng-click="bulkExport()"
class="btn btn-xs btn-default" aria-label="Export"><i aria-hidden="true" class="fa fa-download"></i> Export</a>
</div>
<div ng-repeat="service in services" ng-class="{ active: state.tab === service.title }" class="tab-pane">
<ul class="list-unstyled">
Expand All @@ -51,8 +58,8 @@ <h2>Edit Saved Objects</h2>

<div class="pull-left">
<input
ng-click="item.checked = !item.checked; toggleDeleteBtn(service)"
ng-checked="item.checked"
ng-click="toggleItem(item)"
ng-checked="selectedItems.indexOf(item) >= 0"
type="checkbox" >
</div>

Expand Down
158 changes: 116 additions & 42 deletions src/kibana/plugins/settings/sections/objects/_objects.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
define(function (require) {
var _ = require('lodash');
var angular = require('angular');
var saveAs = require('file_saver');
var registry = require('plugins/settings/saved_object_registry');
var objectIndexHTML = require('text!plugins/settings/sections/objects/_objects.html');

require('directives/file_upload');

require('routes')
.when('/settings/objects', {
template: objectIndexHTML
Expand All @@ -12,86 +16,156 @@ define(function (require) {
.directive('kbnSettingsObjects', function (config, Notifier, Private, kbnUrl) {
return {
restrict: 'E',
controller: function ($scope, $injector, $q, AppState) {
controller: function ($scope, $injector, $q, AppState, es) {
var notify = new Notifier({ location: 'Saved Objects' });

var $state = $scope.state = new AppState();

var resetCheckBoxes = function () {
$scope.deleteAll = false;
_.each($scope.services, function (service) {
_.each(service.data, function (item) {
item.checked = false;
});
});
};
$scope.currentTab = null;
$scope.selectedItems = [];

var getData = function (filter) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you have familiarized yourself with this code, would you mind giving some more context to what "data" is here? From what I can tell everywhere but here data refers to hits, but here it looks like data is referring to Section or Tab.

Sidenote: Since the method is returning an object, perhaps we should give that object a definition (either in comments or a class).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, data is almost always the hits that come back per registered service (i.e. dashboards, searches, visualizations).

$q.all(services).then(function (data) {...

Here, data could better be renamed something like you've said, maybe tab or section (or, in other places in this same code, it's referred to as service, which is also confusing). It's what's being returned from the preceding function ({service: service, serviceName: ...}).

I believe that's the only place data is referring to something other than hits.

var services = registry.all().map(function (obj) {
var service = $injector.get(obj.service);
return service.find(filter).then(function (data) {
return { service: obj.service, title: obj.title, data: data.hits, total: data.total };
return {
service: service,
serviceName: obj.service,
title: obj.title,
type: service.type,
data: data.hits,
total: data.total
};
});
});

$q.all(services).then(function (data) {
$scope.services = _.sortBy(data, 'title');
if (!$state.tab) {
$scope.changeTab($scope.services[0]);
}
var tab = $scope.services[0];
if ($state.tab) tab = _.find($scope.services, {title: $state.tab});
$scope.changeTab(tab);
});
};

$scope.$watch('deleteAll', function (checked) {
var service = _.find($scope.services, { title: $state.tab });
if (!service) return;
_.each(service.data, function (item) {
item.checked = checked;
});
$scope.toggleDeleteBtn(service);
});
$scope.toggleAll = function () {
if ($scope.selectedItems.length === $scope.currentTab.data.length) {
$scope.selectedItems.length = 0;
} else {
$scope.selectedItems = [].concat($scope.currentTab.data);
}
};

$scope.toggleItem = function (item) {
var i = $scope.selectedItems.indexOf(item);
if (i >= 0) {
$scope.selectedItems.splice(i, 1);
} else {
$scope.selectedItems.push(item);
}
};

$scope.open = function (item) {
kbnUrl.change(item.url.substr(1));
};

$scope.edit = function (service, item) {
var params = {
service: service.service,
service: service.serviceName,
id: item.id
};

kbnUrl.change('/settings/objects/{{ service }}/{{ id }}', params);
};

$scope.toggleDeleteBtn = function (service) {
$scope.deleteAllBtn = _.some(service.data, { checked: true});
$scope.bulkDelete = function () {
$scope.currentTab.service.delete(_.pluck($scope.selectedItems, 'id')).then(refreshData);
};

$scope.bulkDelete = function () {
var serviceObj = _.find($scope.services, { title: $state.tab });
if (!serviceObj) return;
var service = $injector.get(serviceObj.service);
var ids = _(serviceObj.data)
.filter({ checked: true})
.pluck('id')
.value();
service.delete(ids).then(function (resp) {
serviceObj.data = _.filter(serviceObj.data, function (obj) {
return !obj.checked;
});
resetCheckBoxes();
$scope.bulkExport = function () {
var objs = $scope.selectedItems.map(_.partialRight(_.extend, {type: $scope.currentTab.type}));
retrieveAndExportDocs(objs);
};

$scope.exportAll = function () {
var objs = $scope.services.map(function (service) {
return service.data.map(_.partialRight(_.extend, {type: service.type}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took me a minute to see what was going on here and on line 84. Maybe wrap this up like transformHitsToExportBody() or something.

});
retrieveAndExportDocs(_.flatten(objs));
};

$scope.changeTab = function (obj) {
$state.tab = obj.title;
function retrieveAndExportDocs(objs) {
es.mget({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably return the promise here.

index: config.file.kibana_index,
body: {docs: objs.map(transformToMget)}
})
.then(function (response) {
saveToFile(response.docs.map(_.partialRight(_.pick, '_id', '_type', '_source')));
});
}

// Takes an object and returns the associated data needed for an mget API request
function transformToMget(obj) {
return {_id: obj.id, _type: obj.type};
}

function saveToFile(results) {
var blob = new Blob([angular.toJson(results, true)], {type: 'application/json'});
saveAs(blob, 'export.json');
}

$scope.importAll = function (fileContents) {
var docs;
try {
docs = JSON.parse(fileContents);
} catch (e) {
notify.error('The file could not be processed.');
}

return es.mget({
index: config.file.kibana_index,
body: {docs: docs.map(_.partialRight(_.pick, '_id', '_type'))}
})
.then(function (response) {
var existingDocs = _.where(response.docs, {found: true});
var confirmMessage = 'The following objects will be overwritten:\n\n';
if (existingDocs.length === 0 || window.confirm(confirmMessage + _.pluck(existingDocs, '_id').join('\n'))) {
return es.bulk({
index: config.file.kibana_index,
body: _.flatten(docs.map(transformToBulk))
})
.then(refreshIndex)
.then(refreshData, notify.error);
}
});
};

// Takes a doc and returns the associated two entries for an index bulk API request
function transformToBulk(doc) {
return [
{index: _.pick(doc, '_id', '_type')},
doc._source
];
}

function refreshIndex() {
return es.indices.refresh({
index: config.file.kibana_index
});
}

function refreshData() {
return getData($scope.advancedFilter);
}

$scope.changeTab = function (tab) {
$scope.currentTab = tab;
$scope.selectedItems.length = 0;
$state.tab = tab.title;
$state.save();
resetCheckBoxes();
};

$scope.$watch('advancedFilter', function (filter) {
getData(filter);
});

}
};
});
Expand Down
2 changes: 1 addition & 1 deletion src/kibana/plugins/settings/sections/objects/_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ define(function (require) {
*
* @param {array} memo The stack of fields
* @param {mixed} value The value of the field
* @param {stirng} key The key of the field
* @param {string} key The key of the field
* @param {object} collection This is a reference the collection being reduced
* @param {array} parents The parent keys to the field
* @returns {array}
Expand Down
8 changes: 7 additions & 1 deletion src/kibana/plugins/settings/styles/main.less
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,18 @@ kbn-settings-objects {
font-weight: normal;
}

.delete-all {
.btn {
font-size: 10px;
margin-left: 20px;
}
}

.header {
.title, .controls {
padding-right: 1em;
display: inline-block;
}
}
}

kbn-settings-advanced {
Expand Down