[Wizard] Creates a new CSV Add Data Wizard

This commit is contained in:
Matthew Bargar 2016-03-17 12:37:44 -04:00
parent 5918737d86
commit 57c391aa4e
17 changed files with 587 additions and 10 deletions

View file

@ -132,6 +132,7 @@
"moment": "2.10.6",
"moment-timezone": "0.4.1",
"node-uuid": "1.4.7",
"papaparse": "4.1.2",
"raw-loader": "0.5.1",
"request": "2.61.0",
"rimraf": "2.4.3",

View file

@ -0,0 +1,57 @@
<file-upload ng-if="!wizard.file" on-locate="wizard.file = file" upload-selector="button.upload">
<h2><em>Pick a CSV file to get started.</em>
Please follow the instructions below.
</h2>
<div class="upload-wizard-file-upload-container">
<div class="upload-instructions">Drop your file here</div>
<div class="upload-instructions-separator">or</div>
<button class="btn btn-primary btn-lg controls upload" ng-click>
Select File
</button>
<div>Maximum upload file size: 1 GB</div>
</div>
</file-upload>
<div class="upload-wizard-file-preview-container" ng-if="wizard.file">
<h2><em>Review the sample below.</em>
Click next if it looks like we parsed your file correctly.
</h2>
<div ng-if="!!wizard.formattedErrors.length" class="alert alert-warning parse-error">
<ul>
<li ng-repeat="error in wizard.formattedErrors">{{ error }}</li>
</ul>
</div>
<div class="advanced-options form-inline">
<span class="form-group">
<label>Delimiter</label>
<select ng-model="wizard.parseOptions.delimiter"
ng-options="option.value as option.label for option in wizard.delimiterOptions"
class="form-control">
</select>
</span>
<span class="form-group">
<label>Filename:</label>
{{ wizard.file.name }}
</span>
</div>
<div class="preview">
<table class="table table-condensed">
<thead>
<tr>
<th ng-repeat="col in wizard.columns track by $index">
<span title="{{ col }}">{{ col | limitTo:12 }}{{ col.length > 12 ? '...' : '' }}</span>
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="row in wizard.rows">
<td ng-repeat="cell in row track by $index">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,79 @@
import _ from 'lodash';
import Papa from 'papaparse';
import modules from 'ui/modules';
import template from './parse_csv_step.html';
import './styles/_add_data_parse_csv_step.less';
modules.get('apps/settings')
.directive('parseCsvStep', function () {
return {
restrict: 'E',
template: template,
scope: {
file: '=',
parseOptions: '=',
samples: '='
},
bindToController: true,
controllerAs: 'wizard',
controller: function ($scope) {
this.delimiterOptions = [
{
label: 'comma',
value: ','
},
{
label: 'tab',
value: '\t'
},
{
label: 'space',
value: ' '
},
{
label: 'semicolon',
value: ';'
},
{
label: 'pipe',
value: '|'
}
];
this.parse = () => {
if (!this.file) return;
const config = _.assign(
{
header: true,
preview: 10,
dynamicTyping: true,
complete: (results) => {
$scope.$apply(() => {
this.formattedErrors = _.map(results.errors, (error) => {
return `${error.type} at row ${error.row} - ${error.message}`;
});
this.columns = results.meta.fields;
this.rows = _.map(results.data, _.values);
this.samples = results.data;
this.parseOptions = _.defaults({}, this.parseOptions, {delimiter: results.meta.delimiter});
});
}
},
this.parseOptions
);
Papa.parse(this.file, config);
};
$scope.$watch('wizard.parseOptions', this.parse, true);
$scope.$watch('wizard.file', () => {
delete this.formattedErrors;
this.parse();
});
this.parse();
}
};
});

View file

@ -0,0 +1,64 @@
@import (reference) "../../../styles/_add_data_wizard";
.upload-wizard-file-upload-container {
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
background-color: @settings-add-data-wizard-form-control-bg;
border: @settings-add-data-wizard-parse-csv-container-border 1px dashed;
text-align: center;
.upload-instructions {
font-size: 2em;
}
.upload-instructions-separator {
margin: 15px 0;
}
button {
width: inherit;
}
button.upload {
align-self: center;
margin-bottom: 15px;
}
}
.upload-wizard-file-preview-container {
.preview {
overflow: auto;
max-height: 500px;
border: @settings-add-data-wizard-parse-csv-container-border 1px solid;
table {
margin-bottom: 0;
.table-striped()
}
}
.parse-error {
margin-top: 2em;
}
.advanced-options {
display: flex;
align-items: center;
.form-group {
display: flex;
align-items: center;
padding-right: 15px;
label {
padding-right: 8px;
margin-bottom: 0;
}
}
padding-bottom: 10px;
}
}

View file

@ -0,0 +1,22 @@
<div ng-if="!uploadStep.created && !uploadStep.formattedErrors.length">
<h2><em>Sit back, relax, we'll take it from here.</em></h2>
<div class="loading-message well">
We're loading your data now. This may take some time if you selected a large file.
</div>
</div>
<div ng-if="uploadStep.created || !!uploadStep.formattedErrors.length" class="bulk-results">
<h2><em>Upload complete.</em> Let's take a look:</h2>
<div ng-if="uploadStep.created" class="alert alert-success">
Created <strong>{{ uploadStep.created }}</strong> documents!<br/>
</div>
<div class="alert alert-warning" ng-if="!!uploadStep.formattedErrors.length">
We encountered errors while indexing your data
<ul>
<li ng-repeat="error in uploadStep.formattedErrors">{{ error }}</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,44 @@
import modules from 'ui/modules';
import template from './upload_data_step.html';
import _ from 'lodash';
import IngestProvider from 'ui/ingest';
modules.get('apps/settings')
.directive('uploadDataStep', function () {
return {
template: template,
scope: {
results: '='
},
bindToController: true,
controllerAs: 'uploadStep',
controller: function ($scope, $http, Notifier, $window, Private) {
const ingest = Private(IngestProvider);
const notify = new Notifier({
location: 'Add Data'
});
const usePipeline = !_.isEmpty(_.get(this.results, 'pipeline.processors'));
ingest.uploadCSV(this.results.file, this.results.indexPattern.id, this.results.parseOptions.delimiter, usePipeline)
.then(
(res) => {
this.created = 0;
this.formattedErrors = [];
_.forEach(res.data, (response) => {
this.created += response.created;
this.formattedErrors = this.formattedErrors.concat(_.map(_.get(response, 'errors.index'), (doc) => {
return `${doc._id.split('-', 1)[0].replace('L', 'Line ').trim()}: ${doc.error.type} - ${doc.error.reason}`;
}));
if (!_.isEmpty(_.get(response, 'errors.other'))) {
this.formattedErrors = this.formattedErrors.concat(response.errors.other);
}
});
},
(err) => {
notify.error(err);
$window.scrollTo(0, 0);
}
);
}
};
});

View file

@ -20,6 +20,13 @@
<div>
Pick this option if you have log file data you'd like to send to Elasticsearch.
</div>
<h4>
<a href="#/settings/indices/create/upload">Upload</a>
</h4>
<div>
Got CSVs? Upload them here. No pain, all gain.
</div>
</div>
</kbn-settings-indices>
</kbn-settings-app>

View file

@ -5,6 +5,7 @@ import 'plugins/kibana/settings/sections/indices/_create';
import 'plugins/kibana/settings/sections/indices/_edit';
import 'plugins/kibana/settings/sections/indices/_field_editor';
import 'plugins/kibana/settings/sections/indices/filebeat/index';
import 'plugins/kibana/settings/sections/indices/upload/index';
import uiRoutes from 'ui/routes';
import uiModules from 'ui/modules';
import indexTemplate from 'plugins/kibana/settings/sections/indices/index.html';

View file

@ -52,6 +52,11 @@
}
}
.btn-lg {
padding: 6px 35px;
font-size: 1.2em;
}
.form-group {
margin-bottom: 5px;
}

View file

@ -0,0 +1,117 @@
<div class="wizard-container">
<div class="wizard-step-headings" ng-class="{complete: wizard.complete}">
<span ng-class="{active: wizard.currentStep === 0}"
class="wizard-step-heading"
ng-click="wizard.setCurrentStep(0)">
1. Select
</span>
<span ng-class="{active: wizard.currentStep === 1, aheadActive: wizard.currentStep < 1}"
class="wizard-step-heading"
ng-click="wizard.currentStep < 1 || wizard.setCurrentStep(1)">
2. Parse
</span>
<span ng-class="{active: wizard.currentStep === 2, aheadActive: wizard.currentStep < 2}"
class="wizard-step-heading"
ng-click="wizard.currentStep < 2 || wizard.setCurrentStep(2)">
3. Review
</span>
<span ng-class="{active: wizard.currentStep === 3, aheadActive: wizard.currentStep < 3}"
class="wizard-step-heading"
ng-click="wizard.currentStep < 3 || wizard.setCurrentStep(3)">
4. Upload
</span>
</div>
<div ng-switch="wizard.currentStep">
<div ng-switch-when="0">
<parse-csv-step file="wizard.stepResults.file" parse-options="wizard.stepResults.parseOptions" samples="wizard.stepResults.samples"></parse-csv-step>
<div class="wizard-nav-buttons">
<div>
<button
class="btn btn-secondary"
ng-disabled="!wizard.stepResults.file"
ng-click="wizard.stepResults = undefined">
Reset
</button>
</div>
<div>
<button
class="btn btn-primary"
ng-disabled="!wizard.stepResults.samples"
ng-click="wizard.nextStep()">
Next
</button>
</div>
<div></div>
</div>
</div>
<div ng-switch-when="1">
<pipeline-setup
pipeline="wizard.stepResults.pipeline"
samples="wizard.stepResults.samples">
</pipeline-setup>
<div class="wizard-nav-buttons">
<div>
<button
class="btn btn-secondary"
ng-click="wizard.prevStep()">
Prev
</button>
</div>
<div>
<button
class="btn btn-primary"
ng-click="wizard.nextStep()">
Next
</button>
</div>
<div></div>
</div>
</div>
<div ng-switch-when="2">
<pattern-review-step
index-pattern="wizard.stepResults.indexPattern"
pipeline="wizard.stepResults.pipeline"
sample-doc="wizard.stepResults.pipeline.output">
</pattern-review-step>
<div class="wizard-nav-buttons">
<div>
<button
class="btn btn-secondary"
ng-click="wizard.prevStep()">
Prev
</button>
</div>
<div>
<button
class="btn btn-primary"
ng-disabled="!wizard.stepResults.indexPattern || !wizard.stepResults.indexPattern.id"
ng-click="wizard.save()">
Save
</button>
</div>
<div></div>
</div>
</div>
<div ng-switch-when="3">
<upload-data-step results="wizard.stepResults"></upload-data-step>
<div class="wizard-nav-buttons">
<div></div>
<div>
<button
class="btn btn-primary"
ng-click="wizard.nextStep()">
Done
</button>
</div>
<div></div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,99 @@
import modules from 'ui/modules';
import template from 'plugins/kibana/settings/sections/indices/upload/directives/upload_wizard.html';
import IngestProvider from 'ui/ingest';
import 'plugins/kibana/settings/sections/indices/add_data_steps/pattern_review_step';
import 'plugins/kibana/settings/sections/indices/add_data_steps/parse_csv_step';
import 'plugins/kibana/settings/sections/indices/add_data_steps/pipeline_setup';
import 'plugins/kibana/settings/sections/indices/add_data_steps/upload_data_step';
modules.get('apps/settings')
.directive('uploadWizard', function () {
return {
restrict: 'E',
template: template,
scope: {},
bindToController: true,
controllerAs: 'wizard',
controller: function ($scope, AppState, safeConfirm, kbnUrl, $http, Notifier, $window, config, Private) {
const ingest = Private(IngestProvider);
const $state = this.state = new AppState();
var notify = new Notifier({
location: 'Add Data'
});
var totalSteps = 4;
this.stepResults = {};
this.setCurrentStep = (step) => {
if (!this.complete) {
$state.currentStep = step;
$state.save();
}
};
this.setCurrentStep(0);
this.nextStep = () => {
if ($state.currentStep + 1 < totalSteps) {
this.setCurrentStep($state.currentStep + 1);
}
else if ($state.currentStep + 1 === totalSteps) {
kbnUrl.change('/discover');
}
};
this.prevStep = () => {
if ($state.currentStep > 0) {
this.setCurrentStep($state.currentStep - 1);
}
};
this.save = () => {
const processors = this.stepResults.pipeline.processors.map(processor => processor.model);
return ingest.save(this.stepResults.indexPattern, processors)
.then(
() => {
this.nextStep();
},
(err) => {
notify.error(err);
$window.scrollTo(0,0);
}
);
};
$scope.$watch('wizard.state.currentStep', (newValue, oldValue) => {
if (this.complete) {
$state.currentStep = totalSteps - 1;
$state.save();
return;
}
if (newValue + 1 === totalSteps) {
this.complete = true;
}
if (newValue < oldValue) {
return safeConfirm('Going back will reset any changes you\'ve made to this step, do you want to continue?')
.then(
() => {
if ($state.currentStep < 1) {
delete this.stepResults.pipeline;
}
if ($state.currentStep < 2) {
delete this.stepResults.indexPattern;
}
this.currentStep = newValue;
},
() => {
$state.currentStep = oldValue;
$state.save();
}
);
}
else {
this.currentStep = newValue;
}
});
}
};
});

View file

@ -0,0 +1,3 @@
<kbn-settings-app section="Upload a CSV">
<upload-wizard />
</kbn-settings-app>

View file

@ -0,0 +1,7 @@
import routes from 'ui/routes';
import template from 'plugins/kibana/settings/sections/indices/upload/index.html';
import './directives/upload_wizard';
routes.when('/settings/indices/create/upload', {
template: template
});

View file

@ -204,4 +204,3 @@ kbn-settings-indices {
.kbn-settings-indices-create {
.time-and-pattern > div {}
}

View file

@ -26,7 +26,7 @@ describe('Ingest Service', function () {
it('Sets the default index if there isn\'t one already', function () {
$httpBackend
.when('POST', '../api/kibana/ingest')
.when('POST', '/api/kibana/ingest')
.respond('ok');
expect(config.get('defaultIndex')).to.be(null);
@ -38,7 +38,7 @@ describe('Ingest Service', function () {
it('Returns error from ingest API if there is one', function (done) {
$httpBackend
.expectPOST('../api/kibana/ingest')
.expectPOST('/api/kibana/ingest')
.respond(400);
ingest.save({id: 'foo'})
@ -57,7 +57,7 @@ describe('Ingest Service', function () {
it('Broadcasts an ingest:updated event on the rootScope upon succesful save', function () {
$httpBackend
.when('POST', '../api/kibana/ingest')
.when('POST', '/api/kibana/ingest')
.respond('ok');
ingest.save({id: 'foo'});
@ -75,7 +75,7 @@ describe('Ingest Service', function () {
it('Calls the DELETE endpoint of the ingest API with the given id', function () {
$httpBackend
.expectDELETE('../api/kibana/ingest/foo')
.expectDELETE('/api/kibana/ingest/foo')
.respond('ok');
ingest.delete('foo');
@ -84,7 +84,7 @@ describe('Ingest Service', function () {
it('Returns error from ingest API if there is one', function (done) {
$httpBackend
.expectDELETE('../api/kibana/ingest/foo')
.expectDELETE('/api/kibana/ingest/foo')
.respond(404);
ingest.delete('foo')
@ -103,7 +103,7 @@ describe('Ingest Service', function () {
it('Broadcasts an ingest:updated event on the rootScope upon succesful save', function () {
$httpBackend
.when('DELETE', '../api/kibana/ingest/foo')
.when('DELETE', '/api/kibana/ingest/foo')
.respond('ok');
ingest.delete('foo');
@ -114,11 +114,53 @@ describe('Ingest Service', function () {
});
});
describe('uploadCSV', function () {
it('throws an error if file and index pattern are not provided', function () {
expect(ingest.uploadCSV).to.throwException(/file is required/);
expect(ingest.uploadCSV).withArgs('foo').to.throwException(/index pattern is required/);
});
it('POSTs to the kibana _data endpoint with the correct params and the file attached as multipart/form-data', function () {
$httpBackend
.expectPOST('/api/kibana/foo/_data?csv_delimiter=;&pipeline=true', function (data) {
// The assertions we can do here are limited because of poor browser support for FormData methods
return data instanceof FormData;
})
.respond('ok');
const file = new Blob(['foo,bar'], {type : 'text/csv'});
ingest.uploadCSV(file, 'foo', ';', true);
$httpBackend.flush();
});
it('Returns error from the data API if there is one', function (done) {
$httpBackend
.expectPOST('/api/kibana/foo/_data?csv_delimiter=;&pipeline=true')
.respond(404);
const file = new Blob(['foo,bar'], {type : 'text/csv'});
ingest.uploadCSV(file, 'foo', ';', true)
.then(
() => {
throw new Error('expected an error response');
},
(error) => {
expect(error.status).to.be(404);
done();
}
);
$httpBackend.flush();
});
});
describe('getProcessors', () => {
it('Calls the processors GET endpoint of the ingest API', function () {
$httpBackend
.expectGET('../api/kibana/ingest/processors')
.expectGET('/api/kibana/ingest/processors')
.respond('ok');
ingest.getProcessors();
@ -127,7 +169,7 @@ describe('Ingest Service', function () {
it('Throws user-friendly error when there is an error in the request', function (done) {
$httpBackend
.when('GET', '../api/kibana/ingest/processors')
.when('GET', '/api/kibana/ingest/processors')
.respond(404);
ingest.getProcessors()

View file

@ -1,10 +1,11 @@
import { keysToCamelCaseShallow, keysToSnakeCaseShallow } from '../../../plugins/kibana/common/lib/case_conversion';
import _ from 'lodash';
import angular from 'angular';
import chrome from 'ui/chrome';
export default function IngestProvider($rootScope, $http, config, $q) {
const ingestAPIPrefix = '../api/kibana/ingest';
const ingestAPIPrefix = chrome.addBasePath('/api/kibana/ingest');
this.save = function (indexPattern, pipeline) {
if (_.isEmpty(indexPattern)) {
@ -71,4 +72,30 @@ export default function IngestProvider($rootScope, $http, config, $q) {
});
};
this.uploadCSV = function (file, indexPattern, delimiter, pipeline) {
if (_.isUndefined(file)) {
throw new Error('file is required');
}
if (_.isUndefined(indexPattern)) {
throw new Error('index pattern is required');
}
const formData = new FormData();
formData.append('csv', file);
const params = {};
if (!_.isUndefined(delimiter)) {
params.csv_delimiter = delimiter;
}
if (!_.isUndefined(pipeline)) {
params.pipeline = pipeline;
}
return $http.post(chrome.addBasePath(`/api/kibana/${indexPattern}/_data`), formData, {
params: params,
transformRequest: angular.identity,
headers: {'Content-Type': undefined}
});
};
}

View file

@ -138,6 +138,9 @@
@settings-filebeat-wizard-processor-container-overlay-bg: fade(#000, 10%);
// Settings - Add Data Wizard - Parse CSV
@settings-add-data-wizard-parse-csv-container-border: @kibanaBlue3;
// Visualize ===================================================================
@visualize-show-spy-border: @gray-lighter;
@visualize-show-spy-bg: @white;