Commit d2a342a9 by Torkel Ödegaard

Merge branch 'elastic_annotations'

Conflicts:
	src/css/bootstrap.dark.min.css
	src/css/bootstrap.light.min.css
	src/css/default.min.css
parents 337cbb28 c61e4c02
......@@ -12,6 +12,7 @@
- [Issue #610](https://github.com/grafana/grafana/issues/610). InfluxDB: Support for InfluxdB v0.8 list series response schemea (series typeahead)
- [Issue #266](https://github.com/grafana/grafana/issues/266). Graphite: New option cacheTimeout to override graphite default memcache timeout
- [Issue #606](https://github.com/grafana/grafana/issues/606). General: New global option in config.js to specify admin password (useful to hinder users from accidentally make changes)
- [Issue #201](https://github.com/grafana/grafana/issues/201). Annotations: Elasticsearch datasource support for events
**Changes**
- [Issue #536](https://github.com/grafana/grafana/issues/536). Graphite: Use unix epoch for Graphite from/to for absolute time ranges
......
......@@ -13,18 +13,10 @@ function (_, crypto) {
* @type {Object}
*/
var defaults = {
elasticsearch : "http://"+window.location.hostname+":9200",
datasources : {
default: {
url: "http://"+window.location.hostname+":8080",
default: true
}
},
datasources : {},
panels : ['graph', 'text'],
plugins : {},
default_route : '/dashboard/file/default.json',
grafana_index : 'grafana-dash',
elasticsearch_all_disabled : false,
playlist_timespan : "1m",
unsaved_changes_warning : true,
admin: {}
......@@ -57,13 +49,21 @@ function (_, crypto) {
return datasource;
};
// backward compatible with old config
if (options.graphiteUrl) {
settings.datasources = {
graphite: {
settings.datasources.graphite = {
type: 'graphite',
url: options.graphiteUrl,
default: true
};
}
if (options.elasticsearch) {
settings.datasources.elasticsearch = {
type: 'elasticsearch',
url: options.elasticsearch,
index: options.grafana_index,
grafanaDB: true
};
}
......@@ -73,10 +73,6 @@ function (_, crypto) {
if (datasource.type === 'influxdb') { parseMultipleHosts(datasource); }
});
var elasticParsed = parseBasicAuth({ url: settings.elasticsearch });
settings.elasticsearchBasicAuth = elasticParsed.basicAuth;
settings.elasticsearch = elasticParsed.url;
if (settings.plugins.panels) {
settings.panels = _.union(settings.panels, settings.plugins.panels);
}
......
......@@ -2,16 +2,18 @@ define([
'angular',
'underscore',
'moment',
'config',
'filesaver'
],
function (angular, _, moment) {
function (angular, _, moment, config) {
'use strict';
var module = angular.module('grafana.controllers');
module.controller('dashLoader', function($scope, $rootScope, $http, alertSrv, $location, playlistSrv, elastic) {
module.controller('dashLoader', function($scope, $rootScope, $http, alertSrv, $location, playlistSrv, datasourceSrv) {
$scope.init = function() {
$scope.db = datasourceSrv.getGrafanaDB();
$scope.onAppEvent('save-dashboard', function() {
$scope.saveDashboard();
});
......@@ -19,7 +21,6 @@ function (angular, _, moment) {
$scope.onAppEvent('zoom-out', function() {
$scope.zoom(2);
});
};
$scope.exitFullscreen = function() {
......@@ -38,6 +39,9 @@ function (angular, _, moment) {
if(type === 'save') {
return (_l.save_elasticsearch);
}
if(type === 'share') {
return (_l.save_temp);
}
return false;
};
......@@ -52,7 +56,7 @@ function (angular, _, moment) {
};
$scope.saveForSharing = function() {
elastic.saveForSharing($scope.dashboard)
$scope.db.saveDashboardTemp($scope.dashboard)
.then(function(result) {
$scope.share = { url: result.url, title: result.title };
......@@ -62,10 +66,32 @@ function (angular, _, moment) {
});
};
$scope.passwordCache = function(pwd) {
if (!window.sessionStorage) { return null; }
if (!pwd) { return window.sessionStorage["grafanaAdminPassword"]; }
window.sessionStorage["grafanaAdminPassword"] = pwd;
};
$scope.isAdmin = function() {
if (!config.admin || !config.admin.password) { return true; }
if (this.passwordCache() === config.admin.password) { return true; }
var password = window.prompt("Admin password", "");
this.passwordCache(password);
if (password === config.admin.password) { return true; }
alertSrv.set('Save failed', 'Password incorrect', 'error');
return false;
};
$scope.saveDashboard = function() {
elastic.saveDashboard($scope.dashboard, $scope.dashboard.title)
if (!this.isAdmin()) { return false; }
$scope.db.saveDashboard($scope.dashboard, $scope.dashboard.title)
.then(function(result) {
alertSrv.set('Dashboard Saved', 'Dashboard has been saved to Elasticsearch as "' + result.title + '"','success', 5000);
alertSrv.set('Dashboard Saved', 'Dashboard has been saved as "' + result.title + '"','success', 5000);
$location.path(result.url);
......@@ -81,7 +107,9 @@ function (angular, _, moment) {
return;
}
elastic.deleteDashboard(id).then(function(id) {
if (!this.isAdmin()) { return false; }
$scope.db.deleteDashboard(id).then(function(id) {
alertSrv.set('Dashboard Deleted', id + ' has been deleted', 'success', 5000);
}, function() {
alertSrv.set('Dashboard Not Deleted', 'An error occurred deleting the dashboard', 'error', 5000);
......
......@@ -11,8 +11,7 @@ function (angular, app, _) {
module.controller('GraphiteImportCtrl', function($scope, $rootScope, $timeout, datasourceSrv) {
$scope.init = function() {
console.log('hej!');
$scope.datasources = datasourceSrv.listOptions();
$scope.datasources = datasourceSrv.getMetricSources();
$scope.setDatasource(null);
};
......@@ -96,7 +95,8 @@ function (angular, app, _) {
currentRow.panels.push(panel);
});
$scope.dashboard.dash_load(newDashboard);
$scope.emitAppEvent('setup-dashboard', newDashboard);
$scope.dismiss();
}
});
......
......@@ -9,13 +9,14 @@ function (angular, _, config, $) {
var module = angular.module('grafana.controllers');
module.controller('SearchCtrl', function($scope, $rootScope, $element, $location, elastic) {
module.controller('SearchCtrl', function($scope, $rootScope, $element, $location, datasourceSrv) {
$scope.init = function() {
$scope.giveSearchFocus = 0;
$scope.selectedIndex = -1;
$scope.results = {dashboards: [], tags: [], metrics: []};
$scope.query = { query: 'title:' };
$scope.db = datasourceSrv.getGrafanaDB();
$scope.onAppEvent('open-search', $scope.openSearch);
};
......@@ -57,40 +58,12 @@ function (angular, _, config, $) {
};
};
$scope.searchDasboards = function(queryString) {
var tagsOnly = queryString.indexOf('tags!:') === 0;
if (tagsOnly) {
var tagsQuery = queryString.substring(6, queryString.length);
queryString = 'tags:' + tagsQuery + '*';
}
else {
if (queryString.length === 0) {
queryString = 'title:';
}
if (queryString[queryString.length - 1] !== '*') {
queryString += '*';
}
}
var query = {
query: { query_string: { query: queryString } },
facets: { tags: { terms: { field: "tags", order: "term", size: 50 } } },
size: 20,
sort: ["_uid"]
};
return elastic.post('/dashboard/_search', query)
$scope.searchDashboards = function(queryString) {
return $scope.db.searchDashboards(queryString)
.then(function(results) {
if(_.isUndefined(results.hits)) {
$scope.results.dashboards = [];
$scope.results.tags = [];
return;
}
$scope.tagsOnly = tagsOnly;
$scope.results.dashboards = results.hits.hits;
$scope.results.tags = results.facets.tags.terms;
$scope.tagsOnly = results.dashboards.length === 0 && results.tags.length > 0;
$scope.results.dashboards = results.dashboards;
$scope.results.tags = results.tags;
});
};
......@@ -118,12 +91,9 @@ function (angular, _, config, $) {
$scope.selectedIndex = -1;
var queryStr = $scope.query.query.toLowerCase();
if (queryStr.indexOf('m:') !== 0) {
queryStr = queryStr.replace(' and ', ' AND ');
$scope.searchDasboards(queryStr);
return;
}
$scope.searchDashboards(queryStr);
};
$scope.openSearch = function (evt) {
......
......@@ -12,7 +12,7 @@ function (angular, app, _) {
var module = angular.module('grafana.panels.annotations', []);
app.useModule(module);
module.controller('AnnotationsEditorCtrl', function($scope, datasourceSrv, $rootScope) {
module.controller('AnnotationsEditorCtrl', function($scope, datasourceSrv) {
var annotationDefaults = {
name: '',
......
......@@ -220,7 +220,7 @@ function (angular, app, $, _, kbn, moment, timeSeries) {
$scope.editorTabs = _.pluck($scope.panelMeta.fullEditorTabs,'title');
$scope.hiddenSeries = {};
$scope.datasources = datasourceSrv.listOptions();
$scope.datasources = datasourceSrv.getMetricSources();
$scope.setDatasource($scope.panel.datasource);
if ($scope.panel.targets.length === 0) {
......
......@@ -27,7 +27,7 @@
<li ng-show="dashboard.loader.save_elasticsearch">
<form class="input-prepend nomargin save-dashboard-dropdown-save-form">
<input class='input-medium' ng-model="dashboard.title" type="text" ng-model="elasticsearch.title"/>
<input class='input-medium' ng-model="dashboard.title" type="text" />
<button class="btn" ng-click="saveDashboard()"><i class="icon-save"></i></button>
</form>
</li>
......@@ -47,8 +47,11 @@
<li ng-show="dashboard.loader.save_local">
<a class="link" ng-click="exportDashboard()">Export dashboard</a>
</li>
<li ng-show="showDropdown('share')"><a bs-tooltip="'Share'" data-placement="bottom" ng-click="saveForSharing()" config-modal="app/partials/dashLoaderShare.html">Share temp copy</i></a></li>
<li ng-show="showDropdown('share')">
<a bs-tooltip="'Share'" data-placement="bottom" ng-click="saveForSharing()" config-modal="app/partials/dashLoaderShare.html">
Share temp copy
</a>
</li>
</ul>
</li>
......
......@@ -2,7 +2,7 @@
<div class="pull-right editor-title">Dashboard settings</div>
<div ng-model="editor.index" bs-tabs style="text-transform:capitalize;">
<div ng-repeat="tab in ['General', 'Rows','Controls', 'Metrics', 'Import']" data-title="{{tab}}">
<div ng-repeat="tab in ['General', 'Rows','Controls', 'Import']" data-title="{{tab}}">
</div>
<div ng-repeat="tab in dashboard.nav" data-title="{{tab.type}}">
</div>
......@@ -112,10 +112,6 @@
</div>
<div ng-if="editor.index == 3">
<ng-include src="'app/partials/loadmetrics.html'"></ng-include>
</div>
<div ng-if="editor.index == 4">
<ng-include src="'app/partials/import.html'"></ng-include>
</div>
......
<h2>Elasticsearch</h2>
<div class="editor-row">
<div class="section">
<h5>Index name</h5>
<div class="editor-option">
<input type="text" class="span4" ng-model='currentAnnotation.index' placeholder="events-*"></input>
</div>
</div>
<div class="section">
<h5>Search query (lucene) <tip>Use [[filterName]] in query to replace part of the query with a filter value</h5>
<div class="editor-option">
<input type="text" class="span6" ng-model='currentAnnotation.query' placeholder="tags:deploy"></input>
</div>
</div>
</div>
<div class="editor-row">
<div class="section">
<h5>Field mappings</h5>
<div class="editor-option">
<label class="small">Time</label>
<input type="text" class="input-small" ng-model='currentAnnotation.timeField' placeholder="@timestamp"></input>
</div>
<div class="editor-option">
<label class="small">Title</label>
<input type="text" class="input-small" ng-model='currentAnnotation.titleField' placeholder="desc"></input>
</div>
<div class="editor-option">
<label class="small">Tags</label>
<input type="text" class="input-small" ng-model='currentAnnotation.tagsField' placeholder="tags"></input>
</div>
<div class="editor-option">
<label class="small">Text</label>
<input type="text" class="input-small" ng-model='currentAnnotation.textField' placeholder=""></input>
</div>
</div>
</div>
define([
'angular',
'jquery',
'config'
],
function (angular, $, config) {
function (angular) {
"use strict";
var module = angular.module('grafana.routes');
......@@ -20,36 +18,14 @@ function (angular, $, config) {
});
});
module.controller('DashFromElasticProvider', function($scope, $rootScope, elastic, $routeParams, alertSrv) {
module.controller('DashFromElasticProvider', function($scope, $rootScope, datasourceSrv, $routeParams, alertSrv) {
var elasticsearch_load = function(id) {
var url = '/dashboard/' + id;
// hack to check if it is a temp dashboard
if (window.location.href.indexOf('dashboard/temp') > 0) {
url = '/temp/' + id;
}
return elastic.get(url)
.then(function(result) {
if (result._source && result._source.dashboard) {
return angular.fromJson(result._source.dashboard);
} else {
return false;
}
}, function(data, status) {
if(status === 0) {
alertSrv.set('Error',"Could not contact Elasticsearch at " +
config.elasticsearch + ". Please ensure that Elasticsearch is reachable from your browser.",'error');
} else {
alertSrv.set('Error',"Could not find dashboard " + id, 'error');
}
return false;
});
};
elasticsearch_load($routeParams.id).then(function(dashboard) {
var db = datasourceSrv.getGrafanaDB();
db.getDashboard($routeParams.id)
.then(function(dashboard) {
$scope.emitAppEvent('setup-dashboard', dashboard);
}).then(null, function(error) {
alertSrv.set('Error', error, 'error');
});
});
......
......@@ -8,7 +8,6 @@ define([
'./annotationsSrv',
'./playlistSrv',
'./unsavedChangesSrv',
'./elasticsearch/es-client',
'./dashboard/dashboardKeyBindings',
'./dashboard/dashboardModel',
],
......
......@@ -37,6 +37,7 @@ define([
var promises = _.map(annotations, function(annotation) {
var datasource = datasourceSrv.get(annotation.datasource);
return datasource.annotationQuery(annotation, filterSrv, rangeUnparsed)
.then(this.receiveAnnotationResults)
.then(null, errorHandler);
......@@ -64,7 +65,7 @@ define([
function addAnnotation(options) {
var tooltip = "<small><b>" + options.title + "</b><br/>";
if (options.tags) {
tooltip += (options.tags || '') + '<br/>';
tooltip += '<span class="tag label label-tag">' + (options.tags || '') + '</span><br/>';
}
if (timezone === 'browser') {
......
......@@ -14,6 +14,9 @@ function (angular, _, config) {
module.service('datasourceSrv', function($q, filterSrv, $http, $injector) {
var datasources = {};
var metricSources = [];
var annotationSources = [];
var grafanaDB = {};
this.init = function() {
_.each(config.datasources, function(value, key) {
......@@ -27,6 +30,26 @@ function (angular, _, config) {
this.default = datasources[_.keys(datasources)[0]];
this.default.default = true;
}
// create list of different source types
_.each(datasources, function(value, key) {
if (value.supportMetrics) {
metricSources.push({
name: value.name,
value: value.default ? null : key,
});
}
if (value.supportAnnotations) {
annotationSources.push({
name: key,
editorSrc: value.annotationEditorSrc,
});
}
if (value.grafanaDB) {
grafanaDB = value;
}
});
};
this.datasourceFactory = function(ds) {
......@@ -56,25 +79,15 @@ function (angular, _, config) {
};
this.getAnnotationSources = function() {
var results = [];
_.each(datasources, function(value, key) {
if (value.supportAnnotations) {
results.push({
name: key,
editorSrc: value.annotationEditorSrc,
});
}
});
return results;
return annotationSources;
};
this.listOptions = function() {
return _.map(config.datasources, function(value, key) {
return {
name: value.default ? key + ' (default)' : key,
value: value.default ? null : key
this.getMetricSources = function() {
return metricSources;
};
});
this.getGrafanaDB = function() {
return grafanaDB;
};
this.init();
......
define([
'angular',
'config'
],
function(angular, config) {
"use strict";
var module = angular.module('grafana.services');
module.service('elastic', function($http, $q) {
this._request = function(method, url, data) {
var options = {
url: config.elasticsearch + "/" + config.grafana_index + url,
method: method,
data: data
};
if (config.elasticsearchBasicAuth) {
options.headers = {
"Authorization": "Basic " + config.elasticsearchBasicAuth
};
}
return $http(options);
};
this.get = function(url) {
return this._request('GET', url)
.then(function(results) {
return results.data;
});
};
this.post = function(url, data) {
return this._request('POST', url, data)
.then(function(results) {
return results.data;
});
};
this.deleteDashboard = function(id) {
if (!this.isAdmin()) { return $q.reject("Invalid admin password"); }
return this._request('DELETE', '/dashboard/' + id)
.then(function(result) {
return result.data._id;
}, function(err) {
throw err.data;
});
};
this.saveForSharing = function(dashboard) {
var data = {
user: 'guest',
group: 'guest',
title: dashboard.title,
tags: dashboard.tags,
dashboard: angular.toJson(dashboard)
};
var ttl = dashboard.loader.save_temp_ttl;
return this._request('POST', '/temp/?ttl=' + ttl, data)
.then(function(result) {
var baseUrl = window.location.href.replace(window.location.hash,'');
var url = baseUrl + "#dashboard/temp/" + result.data._id;
return { title: dashboard.title, url: url };
}, function(err) {
throw "Failed to save to temp dashboard to elasticsearch " + err.data;
});
};
this.passwordCache = function(pwd) {
if (!window.sessionStorage) { return null; }
if (!pwd) { return window.sessionStorage["grafanaAdminPassword"]; }
window.sessionStorage["grafanaAdminPassword"] = pwd;
};
this.isAdmin = function() {
if (!config.admin || !config.admin.password) { return true; }
if (this.passwordCache() === config.admin.password) { return true; }
var password = window.prompt("Admin password", "");
this.passwordCache(password);
return password === config.admin.password;
};
this.saveDashboard = function(dashboard, title) {
if (!this.isAdmin()) { return $q.reject("Invalid admin password"); }
var dashboardClone = angular.copy(dashboard);
title = dashboardClone.title = title ? title : dashboard.title;
var data = {
user: 'guest',
group: 'guest',
title: title,
tags: dashboardClone.tags,
dashboard: angular.toJson(dashboardClone)
};
return this._request('PUT', '/dashboard/' + encodeURIComponent(title), data)
.then(function() {
return { title: title, url: '/dashboard/elasticsearch/' + title };
}, function(err) {
throw 'Failed to save to elasticsearch ' + err.data;
});
};
});
});
......@@ -19,10 +19,197 @@ function (angular, _, $, config, kbn, moment) {
this.url = datasource.url;
this.name = datasource.name;
this.supportAnnotations = true;
this.supportMetrics = false;
this.index = datasource.index;
this.grafanaDB = datasource.grafanaDB;
this.annotationEditorSrc = 'app/partials/elasticsearch/annotation_editor.html';
}
ElasticDatasource.prototype._request = function(method, url, index, data) {
var options = {
url: this.url + "/" + index + url,
method: method,
data: data
};
if (this.basicAuth) {
options.headers = {
"Authorization": "Basic " + this.basicAuth
};
}
return $http(options);
};
ElasticDatasource.prototype._get = function(url) {
return this._request('GET', url, this.index)
.then(function(results) {
return results.data;
});
};
ElasticDatasource.prototype._post = function(url, data) {
return this._request('POST', url, this.index, data)
.then(function(results) {
return results.data;
});
};
ElasticDatasource.prototype.annotationQuery = function(annotation, filterSrv, rangeUnparsed) {
var range = {};
var timeField = annotation.timeField || '@timestamp';
var queryString = annotation.query || '*';
var tagsField = annotation.tagsField || 'tags';
var titleField = annotation.titleField || 'desc';
var textField = annotation.textField || null;
range[annotation.timeField]= {
from: rangeUnparsed.from,
to: rangeUnparsed.to,
};
var filter = { "bool": { "must": [{ "range": range }] } };
var query = { "bool": { "should": [{ "query_string": { "query": queryString } }] } };
var data = { "query" : { "filtered": { "query" : query, "filter": filter } }, "size": 100 };
return this._request('POST', '/_search', annotation.index, data).then(function(results) {
var list = [];
var hits = results.data.hits.hits;
for (var i = 0; i < hits.length; i++) {
var source = hits[i]._source;
var event = {
annotation: annotation,
time: moment.utc(source[timeField]).valueOf(),
title: source[titleField],
};
if (source[tagsField]) {
if (_.isArray(source[tagsField])) {
event.tags = source[tagsField].join(', ');
}
else {
event.tags = source[tagsField];
}
}
if (textField && source[textField]) {
event.text = source[textField];
}
list.push(event);
}
return list;
});
};
ElasticDatasource.prototype.getDashboard = function(id) {
var url = '/dashboard/' + id;
// hack to check if it is a temp dashboard
if (window.location.href.indexOf('dashboard/temp') > 0) {
url = '/temp/' + id;
}
return this._get(url)
.then(function(result) {
if (result._source && result._source.dashboard) {
return angular.fromJson(result._source.dashboard);
} else {
return false;
}
}, function(data) {
if(data.status === 0) {
throw "Could not contact Elasticsearch. Please ensure that Elasticsearch is reachable from your browser.";
} else {
throw "Could not find dashboard " + id;
}
});
};
ElasticDatasource.prototype.saveDashboard = function(dashboard, title) {
var dashboardClone = angular.copy(dashboard);
title = dashboardClone.title = title ? title : dashboard.title;
var data = {
user: 'guest',
group: 'guest',
title: title,
tags: dashboardClone.tags,
dashboard: angular.toJson(dashboardClone)
};
return this._request('PUT', '/dashboard/' + encodeURIComponent(title), this.index, data)
.then(function() {
return { title: title, url: '/dashboard/elasticsearch/' + title };
}, function(err) {
throw 'Failed to save to elasticsearch ' + err.data;
});
};
ElasticDatasource.prototype.saveDashboardTemp = function(dashboard) {
var data = {
user: 'guest',
group: 'guest',
title: dashboard.title,
tags: dashboard.tags,
dashboard: angular.toJson(dashboard)
};
var ttl = dashboard.loader.save_temp_ttl;
return this._request('POST', '/temp/?ttl=' + ttl, this.index, data)
.then(function(result) {
var baseUrl = window.location.href.replace(window.location.hash,'');
var url = baseUrl + "#dashboard/temp/" + result.data._id;
return { title: dashboard.title, url: url };
}, function(err) {
throw "Failed to save to temp dashboard to elasticsearch " + err.data;
});
};
ElasticDatasource.prototype.deleteDashboard = function(id) {
return this._request('DELETE', '/dashboard/' + id, this.index)
.then(function(result) {
return result.data._id;
}, function(err) {
throw err.data;
});
};
ElasticDatasource.prototype.searchDashboards = function(queryString) {
var tagsOnly = queryString.indexOf('tags!:') === 0;
if (tagsOnly) {
var tagsQuery = queryString.substring(6, queryString.length);
queryString = 'tags:' + tagsQuery + '*';
}
else {
if (queryString.length === 0) {
queryString = 'title:';
}
if (queryString[queryString.length - 1] !== '*') {
queryString += '*';
}
}
var query = {
query: { query_string: { query: queryString } },
facets: { tags: { terms: { field: "tags", order: "term", size: 50 } } },
size: 20,
sort: ["_uid"]
};
return this._post('/dashboard/_search', query)
.then(function(results) {
if(_.isUndefined(results.hits)) {
return { dashboards: [], tags: [] };
}
return { dashboards: results.hits.hits, tags: results.facets.terms };
});
};
return ElasticDatasource;
......
......@@ -21,6 +21,7 @@ function (angular, _, $, config, kbn, moment) {
this.name = datasource.name;
this.render_method = datasource.render_method || 'POST';
this.supportAnnotations = true;
this.supportMetrics = true;
this.annotationEditorSrc = 'app/partials/graphite/annotation_editor.html';
this.cacheTimeout = datasource.cacheTimeout;
}
......
......@@ -23,6 +23,7 @@ function (angular, _, kbn, InfluxSeries) {
};
this.supportAnnotations = true;
this.supportMetrics = true;
this.annotationEditorSrc = 'app/partials/influxdb/annotation_editor.html';
}
......
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -128,4 +128,6 @@
}
}
.annotation-tags {
color: @purple;
}
......@@ -12,7 +12,6 @@ module.exports = function(config) {
'app/routes/**/*.js',
'app/app.js',
'vendor/angular/**/*.js',
'vendor/elasticjs/elastic-angular-client.js'
],
dest: '<%= tempDir %>'
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment