Compare commits

...

21 Commits

Author SHA1 Message Date
Arik Fraimovich
a9e135c94f Exclude venv dir from the release package 2014-02-05 09:48:11 +02:00
Arik Fraimovich
212ade2da7 Add query_id back to widgets in tables.sql until we remove it from Model 2014-02-05 09:47:51 +02:00
Arik Fraimovich
f939bf6108 Merge pull request #76 from EverythingMe/fix_75
Fix #75: Large numbers shown as NaN/NaN/NaN NaN:NaN
2014-02-04 23:45:01 -08:00
Arik Fraimovich
3360cd934b Remove backward compatability workaround (fixes #75) 2014-02-05 09:41:03 +02:00
Arik Fraimovich
f35a0970ac Merge pull request #74 from EverythingMe/update_readme
Readme update (added reference to mailing list & IRC channel)
2014-02-04 07:37:04 -08:00
Arik Fraimovich
97ca722a11 Small fix to README. 2014-02-04 17:02:24 +02:00
Arik Fraimovich
e554c9bdd7 Merge pull request #73 from EverythingMe/viz
Visualization Support
2014-02-04 07:01:40 -08:00
Arik Fraimovich
567a732e1e Readme update (added reference to mailing list & IRC channel) 2014-02-04 16:58:36 +02:00
Amir Nissim
5b532d03a0 version bump 0.2 2014-02-04 16:56:35 +02:00
Amir Nissim
cd838e5a7e migrating Widgets to Visualizations 2014-02-04 16:11:48 +02:00
Amir Nissim
bb096be00c Visualization.name length to 255 (should match Query.name length) 2014-02-04 15:16:07 +02:00
Amir Nissim
7b78bfe191 add Visualization and SERIES types 2014-02-03 16:35:16 +02:00
Amir Nissim
a45ba0bf30 Dashboard visualizations 2014-02-03 16:12:29 +02:00
Amir Nissim
5ce3699a58 QueryFiddle: Live chart type editing 2014-02-03 15:01:41 +02:00
Amir Nissim
1cd836ac8d Live visualization config POC (title only) 2014-02-02 18:20:18 +02:00
Amir Nissim
c83705119d delete visualizations 2014-02-02 13:23:01 +02:00
Amir Nissim
fdd2cfe1d1 edit and create visualizations 2014-02-02 13:23:01 +02:00
Amir Nissim
8327baa2f6 create Visualization cont. 2014-02-02 13:23:01 +02:00
Amir Nissim
84df2fb85c create Visualization [WIP] 2014-02-02 13:23:01 +02:00
Amir Nissim
cab6f9e58d Visualization models 2014-02-02 13:23:01 +02:00
Amir Nissim
d2ace5c1cf Visualization UI:
* queryfiddle page
 * new widget form
2014-02-02 13:23:01 +02:00
16 changed files with 860 additions and 427 deletions

View File

@@ -1,5 +1,5 @@
NAME=redash NAME=redash
VERSION=0.1 VERSION=0.2
FULL_VERSION=$(VERSION).$(CIRCLE_BUILD_NUM) FULL_VERSION=$(VERSION).$(CIRCLE_BUILD_NUM)
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(FULL_VERSION).tar.gz FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(FULL_VERSION).tar.gz
@@ -10,7 +10,7 @@ deps:
cd rd_ui && grunt build cd rd_ui && grunt build
pack: pack:
tar -zcv -f $(FILENAME) --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" * tar -zcv -f $(FILENAME) --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
upload: upload:
python bin/upload_version.py $(FULL_VERSION) $(FILENAME) python bin/upload_version.py $(FULL_VERSION) $(FILENAME)

View File

@@ -21,6 +21,11 @@ You can try out the demo instance: http://rd-demo.herokuapp.com/ (login with any
Due to Heroku dev plan limits, it has a small database of flights (see schema [here](http://rd-demo.herokuapp.com/dashboard/schema)). Also due to another Heroku limitation, it is running with the regular user, hence you can DELETE or INSERT data/tables. Please be nice and don't do this. Due to Heroku dev plan limits, it has a small database of flights (see schema [here](http://rd-demo.herokuapp.com/dashboard/schema)). Also due to another Heroku limitation, it is running with the regular user, hence you can DELETE or INSERT data/tables. Please be nice and don't do this.
## Getting help
* [Google Group (mailing list)](https://groups.google.com/forum/#!forum/redash-users): the best place to get updates about new releases or ask general questions.
* #redash IRC channel on [Freenode](http://www.freenode.net/).
## Technology ## Technology
* [AngularJS](http://angularjs.org/) * [AngularJS](http://angularjs.org/)

View File

@@ -51,7 +51,8 @@ class Query(models.Model):
app_label = 'redash' app_label = 'redash'
db_table = 'queries' db_table = 'queries'
def to_dict(self, with_result=True, with_stats=False): def to_dict(self, with_result=True, with_stats=False,
with_visualizations=False):
d = { d = {
'id': self.id, 'id': self.id,
'latest_query_data_id': self.latest_query_data_id, 'latest_query_data_id': self.latest_query_data_id,
@@ -75,6 +76,10 @@ class Query(models.Model):
if with_result and self.latest_query_data_id: if with_result and self.latest_query_data_id:
d['latest_query_data'] = self.latest_query_data.to_dict() d['latest_query_data'] = self.latest_query_data.to_dict()
if with_visualizations:
d['visualizations'] = [vis.to_dict(with_query=False)
for vis in self.visualizations.all()]
return d return d
@classmethod @classmethod
@@ -148,10 +153,41 @@ class Dashboard(models.Model):
return u"%s=%s" % (self.id, self.name) return u"%s=%s" % (self.id, self.name)
class Visualization(models.Model):
id = models.AutoField(primary_key=True)
type = models.CharField(max_length=100)
query = models.ForeignKey(Query, related_name='visualizations')
name = models.CharField(max_length=255)
description = models.CharField(max_length=4096)
options = models.TextField()
class Meta:
app_label = 'redash'
db_table = 'visualizations'
def to_dict(self, with_query=True):
d = {
'id': self.id,
'type': self.type,
'name': self.name,
'description': self.description,
'options': json.loads(self.options),
}
if with_query:
d['query'] = self.query.to_dict()
return d
def __unicode__(self):
return u"%s=>%s" % (self.id, self.query_id)
class Widget(models.Model): class Widget(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
query = models.ForeignKey(Query)
type = models.CharField(max_length=100) type = models.CharField(max_length=100)
query = models.ForeignKey(Query, related_name='widgets')
visualization = models.ForeignKey(Visualization, related_name='widgets')
width = models.IntegerField() width = models.IntegerField()
options = models.TextField() options = models.TextField()
dashboard = models.ForeignKey(Dashboard, related_name='widgets') dashboard = models.ForeignKey(Dashboard, related_name='widgets')
@@ -163,10 +199,10 @@ class Widget(models.Model):
def to_dict(self): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
'query': self.query.to_dict(),
'type': self.type, 'type': self.type,
'width': self.width, 'width': self.width,
'options': json.loads(self.options), 'options': json.loads(self.options),
'visualization': self.visualization.to_dict(),
'dashboard_id': self.dashboard_id 'dashboard_id': self.dashboard_id
} }

View File

@@ -30,12 +30,22 @@ CREATE TABLE "dashboards" (
"is_archived" boolean NOT NULL "is_archived" boolean NOT NULL
) )
; ;
CREATE TABLE "visualizations" (
"id" serial NOT NULL PRIMARY KEY,
"type" varchar(100) NOT NULL,
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
"name" varchar(255) NOT NULL,
"description" varchar(4096),
"options" text NOT NULL
)
;
CREATE TABLE "widgets" ( CREATE TABLE "widgets" (
"id" serial NOT NULL PRIMARY KEY, "id" serial NOT NULL PRIMARY KEY,
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
"type" varchar(100) NOT NULL, "type" varchar(100) NOT NULL,
"width" integer NOT NULL, "width" integer NOT NULL,
"options" text NOT NULL, "options" text NOT NULL,
"query_id" integer,
"visualization_id" integer NOT NULL REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED,
"dashboard_id" integer NOT NULL REFERENCES "dashboards" ("id") DEFERRABLE INITIALLY DEFERRED "dashboard_id" integer NOT NULL REFERENCES "dashboards" ("id") DEFERRABLE INITIALLY DEFERRED
) )
; ;

50
rd_service/migrate.py Normal file
View File

@@ -0,0 +1,50 @@
import json
import settings
from data.models import *
# first run:
# CREATE TABLE "visualizations" (
# "id" serial NOT NULL PRIMARY KEY,
# "type" varchar(100) NOT NULL,
# "query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
# "name" varchar(255) NOT NULL,
# "description" varchar(4096),
# "options" text NOT NULL
# )
# ;
# ALTER TABLE widgets ADD COLUMN "visualization_id" integer REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED;
if __name__ == '__main__':
print 'migrating Widgets -> Visualizations ...'
for query in Query.objects.filter(name__icontains="cohort"):
vis = Visualization(query=query, name=query.name,
description=query.description,
type="COHORT", options="{}")
vis.save()
for widget in Widget.objects.all():
print 'processing widget %d' % widget.id
query = widget.query
vis_type = widget.type.upper()
vis = query.visualizations.filter(type=vis_type)
if vis:
print 'found'
widget.visualization = vis[0]
widget.save()
else:
options = json.loads(widget.options)
vis_options = {"series": options} if options else {}
vis_options = json.dumps(vis_options)
vis = Visualization(query=query, name=query.name,
description=query.description,
type=vis_type, options=vis_options)
vis.save()
widget.visualization = vis
widget.save()

View File

@@ -167,7 +167,7 @@ class WidgetsHandler(BaseAuthenticatedHandler):
class DashboardHandler(BaseAuthenticatedHandler): class DashboardHandler(BaseAuthenticatedHandler):
def get(self, dashboard_slug=None): def get(self, dashboard_slug=None):
if dashboard_slug: if dashboard_slug:
dashboard = data.models.Dashboard.objects.prefetch_related('widgets__query__latest_query_data').get(slug=dashboard_slug) dashboard = data.models.Dashboard.objects.prefetch_related('widgets__visualization__query__latest_query_data').get(slug=dashboard_slug)
self.write_json(dashboard.to_dict(with_widgets=True)) self.write_json(dashboard.to_dict(with_widgets=True))
else: else:
dashboards = [d.to_dict() for d in dashboards = [d.to_dict() for d in
@@ -204,6 +204,7 @@ class QueriesHandler(BaseAuthenticatedHandler):
query_def['created_at'] = dateutil.parser.parse(query_def['created_at']) query_def['created_at'] = dateutil.parser.parse(query_def['created_at'])
query_def.pop('latest_query_data', None) query_def.pop('latest_query_data', None)
query_def.pop('visualizations', None)
if id: if id:
query = data.models.Query(**query_def) query = data.models.Query(**query_def)
@@ -221,7 +222,7 @@ class QueriesHandler(BaseAuthenticatedHandler):
if id: if id:
q = data.models.Query.objects.get(pk=id) q = data.models.Query.objects.get(pk=id)
if q: if q:
self.write_json(q.to_dict()) self.write_json(q.to_dict(with_visualizations=True))
else: else:
self.send_error(404) self.send_error(404)
else: else:
@@ -251,6 +252,30 @@ class QueryResultsHandler(BaseAuthenticatedHandler):
self.write({'job': job.to_dict()}) self.write({'job': job.to_dict()})
class VisualizationHandler(BaseAuthenticatedHandler):
def get(self, id):
pass
def post(self, id=None):
kwargs = json.loads(self.request.body)
kwargs['options'] = json.dumps(kwargs['options'])
if id:
vis = data.models.Visualization(**kwargs)
fields = kwargs.keys()
fields.remove('id')
vis.save(update_fields=fields)
else:
vis = data.models.Visualization(**kwargs)
vis.save()
self.write_json(vis.to_dict(with_query=False))
def delete(self, id):
vis = data.models.Visualization.objects.get(pk=id)
vis.delete()
class CsvQueryResultsHandler(BaseAuthenticatedHandler): class CsvQueryResultsHandler(BaseAuthenticatedHandler):
def get_current_user(self): def get_current_user(self):
user = super(CsvQueryResultsHandler, self).get_current_user() user = super(CsvQueryResultsHandler, self).get_current_user()
@@ -312,6 +337,7 @@ def get_application(static_path, is_debug, redis_connection, data_manager):
(r"/api/queries(?:/([0-9]*))?", QueriesHandler), (r"/api/queries(?:/([0-9]*))?", QueriesHandler),
(r"/api/query_results(?:/([0-9]*))?", QueryResultsHandler), (r"/api/query_results(?:/([0-9]*))?", QueryResultsHandler),
(r"/api/jobs/(.*)", JobsHandler), (r"/api/jobs/(.*)", JobsHandler),
(r"/api/visualizations(?:/([0-9]*))?", VisualizationHandler),
(r"/api/widgets(?:/([0-9]*))?", WidgetsHandler), (r"/api/widgets(?:/([0-9]*))?", WidgetsHandler),
(r"/api/dashboards(?:/(.*))?", DashboardHandler), (r"/api/dashboards(?:/(.*))?", DashboardHandler),
(r"/admin/(.*)", MainHandler), (r"/admin/(.*)", MainHandler),

View File

@@ -7,7 +7,7 @@
var WidgetCtrl = function ($scope, $http, $location, Query) { var WidgetCtrl = function ($scope, $http, $location, Query) {
$scope.deleteWidget = function() { $scope.deleteWidget = function() {
if (!confirm('Are you sure you want to remove "' + $scope.widget.query.name + '" from the dashboard?')) { if (!confirm('Are you sure you want to remove "' + $scope.widget.visualization.name + '" from the dashboard?')) {
return; return;
} }
@@ -24,7 +24,7 @@
$location.path('/queries/' + query.id); $location.path('/queries/' + query.id);
} }
$scope.query = new Query($scope.widget.query); $scope.query = new Query($scope.widget.visualization.query);
$scope.queryResult = $scope.query.getQueryResult(); $scope.queryResult = $scope.query.getQueryResult();
$scope.updateTime = (new Date($scope.queryResult.getUpdatedAt())).toISOString(); $scope.updateTime = (new Date($scope.queryResult.getUpdatedAt())).toISOString();
@@ -33,12 +33,14 @@
$scope.updateTime = ''; $scope.updateTime = '';
} }
var QueryFiddleCtrl = function ($scope, $window, $routeParams, $http, $location, growl, notifications, Query) { var QueryFiddleCtrl = function ($scope, $window, $location, $routeParams, $http, $location, growl, notifications, Query, Visualization) {
var DEFAULT_TAB = 'table';
var pristineHash = null; var pristineHash = null;
$scope.dirty = undefined;
var leavingPageText = "You will lose your changes if you leave"; var leavingPageText = "You will lose your changes if you leave";
$scope.dirty = undefined;
$scope.newVisualization = undefined;
$window.onbeforeunload = function(){ $window.onbeforeunload = function(){
if (currentUser.canEdit($scope.query) && $scope.dirty) { if (currentUser.canEdit($scope.query) && $scope.dirty) {
return leavingPageText; return leavingPageText;
@@ -72,8 +74,9 @@
$scope.$parent.pageTitle = "Query Fiddle"; $scope.$parent.pageTitle = "Query Fiddle";
$scope.tabs = [{'key': 'table', 'name': 'Table'}, {'key': 'chart', 'name': 'Chart'}, $scope.$watch(function() {return $location.hash()}, function(hash) {
{'key': 'pivot', 'name': 'Pivot Table'}, {'key': 'cohort', 'name': 'Cohort'}]; $scope.selectedTab = hash || DEFAULT_TAB;
});
$scope.lockButton = function (lock) { $scope.lockButton = function (lock) {
$scope.queryExecuting = lock; $scope.queryExecuting = lock;
@@ -211,13 +214,26 @@
$scope.queryResult = $scope.query.getQueryResult(0); $scope.queryResult = $scope.query.getQueryResult(0);
$scope.lockButton(true); $scope.lockButton(true);
$scope.cancelling = false; $scope.cancelling = false;
} };
$scope.cancelExecution = function() { $scope.cancelExecution = function() {
$scope.cancelling = true; $scope.cancelling = true;
$scope.queryResult.cancelExecution(); $scope.queryResult.cancelExecution();
} };
$scope.deleteVisualization = function($e, vis) {
$e.preventDefault();
if (confirm('Are you sure you want to delete ' + vis.name + ' ?')) {
Visualization.delete(vis);
if ($scope.selectedTab == vis.id) {
$scope.selectedTab = DEFAULT_TAB;
}
$scope.query.visualizations =
$scope.query.visualizations.filter(function(v) {
return vis.id !== v.id;
});
}
};
} }
var QueriesCtrl = function($scope, $http, $location, $filter, Query) { var QueriesCtrl = function($scope, $http, $location, $filter, Query) {
@@ -373,7 +389,7 @@
.controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl]) .controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl])
.controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl]) .controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl])
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl]) .controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
.controller('QueryFiddleCtrl', ['$scope', '$window', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', QueryFiddleCtrl]) .controller('QueryFiddleCtrl', ['$scope', '$window', '$location', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', 'Visualization', QueryFiddleCtrl])
.controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl]) .controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl])
.controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]); .controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]);
})(); })();

View File

@@ -1,4 +1,26 @@
(function() {
'use strict';
var directives = angular.module('redash.directives', []); var directives = angular.module('redash.directives', []);
directives.directive('rdTab', ['$location', function($location) {
return {
restrict: 'E',
scope: {
'id': '@',
'name': '@'
},
transclude: true,
template: '<li class="rd-tab" ng-class="{active: id==selectedTab}"><a href="#{{id}}">{{name}}<span ng-transclude></span></a></li>',
replace: true,
link: function(scope) {
scope.$watch(function(){return scope.$parent.selectedTab}, function(tab) {
scope.selectedTab = tab;
});
}
}
}]);
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) { directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
return { return {
restrict: 'E', restrict: 'E',
@@ -22,7 +44,89 @@ directives.directive('rdTabs', ['$location', '$rootScope', function($location, $
}); });
} }
} }
}]) }]);
directives.directive('editVisulatizationForm', ['Visualization', 'growl', function(Visualization, growl) {
return {
restrict: 'E',
templateUrl: '/views/edit_visualization.html',
replace: true,
scope: {
query: '=',
vis: '=?'
},
link: function(scope, element, attrs) {
scope.advancedMode = false;
scope.visTypes = {
'Chart': Visualization.prototype.TYPES.CHART,
'Cohort': Visualization.prototype.TYPES.COHORT,
'Table': Visualization.prototype.TYPES.GRID
};
scope.seriesTypes = {
'Line': Visualization.prototype.SERIES_TYPES.LINE,
'Bar': Visualization.prototype.SERIES_TYPES.BAR,
'Area': Visualization.prototype.SERIES_TYPES.AREA
};
if (!scope.vis) {
// create new visualization
// wait for query to load to populate with defaults
var unwatch = scope.$watch('query', function(q) {
if (q && q.id) {
unwatch();
scope.vis = {
'query_id': q.id,
'type': Visualization.prototype.TYPES.CHART,
'name': q.name,
'description': q.description,
'options': newOptions()
};
}
}, true);
}
function newOptions(chartType) {
if (chartType === Visualization.prototype.TYPES.COHORT) {
// empty config at the moment
return {};
}
// Chart
return {
'series': {
'type': Visualization.prototype.SERIES_TYPES.LINE
}
};
}
scope.toggleAdvancedMode = function() {
scope.advancedMode = !scope.advancedMode;
};
scope.typeChanged = function() {
scope.vis.options = newOptions();
};
scope.submit = function() {
Visualization.save(scope.vis, function success(result) {
growl.addSuccessMessage("Visualization saved");
scope.vis = result;
var visIds = _.pluck(scope.query.visualizations, 'id');
var index = visIds.indexOf(result.id);
if (index > -1) {
scope.query.visualizations[index] = result;
} else {
scope.query.visualizations.push(result);
}
}, function error() {
growl.addErrorMessage("Visualization could not be saved");
});
};
}
}
}]);
directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Dashboard', function($http, $location, $timeout, Dashboard) { directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Dashboard', function($http, $location, $timeout, Dashboard) {
return { return {
@@ -66,7 +170,7 @@ directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Da
row: rowIndex+1, row: rowIndex+1,
ySize: 1, ySize: 1,
xSize: widget.width, xSize: widget.width,
name: widget.query.name name: widget.visualization.name
}); });
}); });
}); });
@@ -117,10 +221,9 @@ directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Da
} }
} }
}]) }]);
directives.directive('newWidgetForm', ['$http', 'Query', function($http, Query) {
directives.directive('newWidgetForm', ['$http', function($http) {
return { return {
restrict: 'E', restrict: 'E',
scope: { scope: {
@@ -129,25 +232,45 @@ directives.directive('newWidgetForm', ['$http', function($http) {
templateUrl: '/views/new_widget_form.html', templateUrl: '/views/new_widget_form.html',
replace: true, replace: true,
link: function($scope, element, attrs) { link: function($scope, element, attrs) {
$scope.widgetTypes = [{name: 'Chart', value: 'chart'}, {name: 'Table', value: 'grid'}, {name: 'Cohort', value: 'cohort'}]; $scope.widgetSizes = [{name: 'Regular', value: 1}, {name: 'Double', value: 2}];
$scope.widgetSizes = [{name: 'Regular Size', value: 1}, {name: 'Double Size', value: 2}];
var reset = function() { var reset = function() {
$scope.saveInProgress = false; $scope.saveInProgress = false;
$scope.widgetType = 'chart';
$scope.widgetSize = 1; $scope.widgetSize = 1;
$scope.queryId = null; $scope.queryId = null;
$scope.selectedVis = null;
} }
reset(); reset();
$scope.toggleView = function(viewName) {
$scope.currentView = ($scope.currentView == viewName) ? '' : viewName;
};
$scope.loadVisualizations = function() {
if (!$scope.queryId) {
return;
}
Query.get({
id: $scope.queryId
}, function(query) {
if (query) {
$scope.query = query;
if(query.visualizations.length) {
$scope.selectedVis = query.visualizations[0];
}
}
});
};
$scope.saveWidget = function() { $scope.saveWidget = function() {
$scope.saveInProgress = true; $scope.saveInProgress = true;
var widget = { var widget = {
'query_id': $scope.queryId, 'visualization_id': $scope.selectedVis.id,
'dashboard_id': $scope.dashboard.id, 'dashboard_id': $scope.dashboard.id,
'type': $scope.widgetType,
'options': {}, 'options': {},
'width': $scope.widgetSize 'width': $scope.widgetSize
} }
@@ -225,6 +348,25 @@ directives.directive('editInPlace', function () {
}; };
}); });
// http://stackoverflow.com/a/17904092/1559840
directives.directive('jsonText', function() {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attr, ngModel) {
function into(input) {
return JSON.parse(input);
}
function out(data) {
return JSON.stringify(data, undefined, 2);
}
ngModel.$parsers.push(into);
ngModel.$formatters.push(out);
}
};
});
directives.directive('rdTimer', ['$timeout', function ($timeout) { directives.directive('rdTimer', ['$timeout', function ($timeout) {
return { return {
restrict: 'E', restrict: 'E',
@@ -254,3 +396,4 @@ directives.directive('rdTimer', ['$timeout', function ($timeout) {
}] }]
}; };
}]); }]);
})();

View File

@@ -1,5 +1,86 @@
(function(){
'use strict'; 'use strict';
var defaultOptions = {
title: {
"text": null
},
tooltip: {
valueDecimals: 2,
formatter: function () {
if (moment.isMoment(this.x)) {
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
pointsCount = this.points.length;
$.each(this.points, function (i, point) {
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
if (pointsCount > 1 && point.percentage) {
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
}
});
} else {
var s = "<b>" + this.points[0].key + "</b>";
$.each(this.points, function (i, point) {
s+= '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
});
}
return s;
},
shared: true
},
xAxis: {
type: 'datetime'
},
yAxis: {
title: {
text: null
}
},
exporting: {
chartOptions: {
title: {
text: ''
}
},
buttons: {
contextButton: {
menuItems: [
{
text: 'Toggle % Stacking',
onclick: function () {
var newStacking = "normal";
if (this.series[0].options.stacking == "normal") {
newStacking = "percent";
}
_.each(this.series, function (series) {
series.update({stacking: newStacking}, true);
});
}
}
]
}
}
},
credits: {
enabled: false
},
plotOptions: {
"column": {
"stacking": "normal",
"pointPadding": 0,
"borderWidth": 1,
"groupPadding": 0,
"shadow": false
}
},
series: []
};
angular.module('highchart', []) angular.module('highchart', [])
.directive('chart', ['$timeout', function ($timeout) { .directive('chart', ['$timeout', function ($timeout) {
return { return {
@@ -22,13 +103,12 @@ angular.module('highchart', [])
} }
}; };
var deepCopy = true; var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
var newSettings = {};
$.extend(deepCopy, newSettings, chartsDefaults, scope.options);
// Making sure that the DOM is ready before creating the chart element, so it gets proper width. // Update when options change
$timeout(function(){ scope.$watch('options', function(newOptions) {
scope.chart = new Highcharts.Chart(newSettings); initChart(newOptions);
}, true);
//Update when charts data changes //Update when charts data changes
scope.$watch(function () { scope.$watch(function () {
@@ -37,10 +117,28 @@ angular.module('highchart', [])
if (!length || length == 0) { if (!length || length == 0) {
scope.chart.showLoading(); scope.chart.showLoading();
} else { } else {
drawChart();
};
}, true);
function initChart(options) {
if (scope.chart) {
scope.chart.destroy();
}
$.extend(true, chartOptions, options);
scope.chart = new Highcharts.Chart(chartOptions);
drawChart();
}
function drawChart() {
while(scope.chart.series.length > 0) { while(scope.chart.series.length > 0) {
scope.chart.series[0].remove(true); scope.chart.series[0].remove(true);
} }
// todo series.type
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) { if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
scope.chart.xAxis[0].update({type: 'category'}); scope.chart.xAxis[0].update({type: 'category'});
@@ -67,17 +165,17 @@ angular.module('highchart', [])
scope.chart.counters.color = 0; scope.chart.counters.color = 0;
_.each(scope.series, function(s) { _.each(scope.series, function(s) {
scope.chart.addSeries(s); // here we override the series with the visualization config
var _s = $.extend(true, {}, s, chartOptions['series']);
scope.chart.addSeries(_s);
}) })
scope.chart.redraw(); scope.chart.redraw();
scope.chart.hideLoading(); scope.chart.hideLoading();
}; }
}, true);
});
} }
}; };
}]); }]);
})();

View File

@@ -1,83 +1,20 @@
var renderers = angular.module('redash.renderers', []); var renderers = angular.module('redash.renderers', []);
var defaultChartOptions = {
"title": { renderers.directive('visualizationRenderer', function() {
"text": null return {
restrict: 'E',
scope: {
visualization: '=',
queryResult: '='
}, },
"tooltip": { template: '<div ng-switch on="visualization.type">' +
valueDecimals: 2, '<chart-renderer ng-switch-when="CHART" options="visualization.options" query-result="queryResult"></chart-renderer>' +
formatter: function () { '<grid-renderer ng-switch-when="GRID" options="visualization.options" query-result="queryResult"></grid-renderer>' +
if (moment.isMoment(this.x)) { '<cohort-renderer ng-switch-when="COHORT" options="visualization.options" query-result="queryResult"></cohort-renderer>' +
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>', '</div>',
pointsCount = this.points.length; replace: false
$.each(this.points, function (i, point) {
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
if (pointsCount > 1 && point.percentage) {
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
} }
}); });
} else {
var s = "<b>" + this.points[0].key + "</b>";
$.each(this.points, function (i, point) {
s+= '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
});
}
return s;
},
shared: true
},
xAxis: {
type: 'datetime'
},
yAxis: {
title: {
text: null
}
},
exporting: {
chartOptions: {
title: {
text: this.description
}
},
buttons: {
contextButton: {
menuItems: [
{
text: 'Toggle % Stacking',
onclick: function () {
var newStacking = "normal";
if (this.series[0].options.stacking == "normal") {
newStacking = "percent";
}
_.each(this.series, function (series) {
series.update({stacking: newStacking}, true);
});
}
}
]
}
}
},
credits: {
enabled: false
},
plotOptions: {
"column": {
"stacking": "normal",
"pointPadding": 0,
"borderWidth": 1,
"groupPadding": 0,
"shadow": false
}
},
"series": []
};
renderers.directive('chartRenderer', function () { renderers.directive('chartRenderer', function () {
return { return {
@@ -90,8 +27,13 @@ renderers.directive('chartRenderer', function () {
replace: false, replace: false,
controller: ['$scope', function ($scope) { controller: ['$scope', function ($scope) {
$scope.chartSeries = []; $scope.chartSeries = [];
$scope.chartOptions = defaultChartOptions; $scope.chartOptions = {};
$scope.$watch('options', function(chartOptions) {
if (chartOptions) {
$scope.chartOptions = chartOptions;
}
});
$scope.$watch('queryResult && queryResult.getData()', function (data) { $scope.$watch('queryResult && queryResult.getData()', function (data) {
if (!data || $scope.queryResult.getData() == null) { if (!data || $scope.queryResult.getData() == null) {
$scope.chartSeries.splice(0, $scope.chartSeries.length); $scope.chartSeries.splice(0, $scope.chartSeries.length);
@@ -99,7 +41,7 @@ renderers.directive('chartRenderer', function () {
$scope.chartSeries.splice(0, $scope.chartSeries.length); $scope.chartSeries.splice(0, $scope.chartSeries.length);
_.each($scope.queryResult.getChartData(), function (s) { _.each($scope.queryResult.getChartData(), function (s) {
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}, $scope.options)); $scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}));
}); });
} }
}); });
@@ -142,13 +84,7 @@ renderers.directive('gridRenderer', function () {
var gridData = _.map($scope.queryResult.getData(), function (row) { var gridData = _.map($scope.queryResult.getData(), function (row) {
var newRow = {}; var newRow = {};
_.each(row, function (val, key) { _.each(row, function (val, key) {
// TODO: hack to detect date fields, needed only for backward compatability
if (val > 1000 * 1000 * 1000 * 100) {
newRow[$scope.queryResult.getColumnCleanName(key)] = moment(val);
} else {
newRow[$scope.queryResult.getColumnCleanName(key)] = val; newRow[$scope.queryResult.getColumnCleanName(key)] = val;
}
}) })
return newRow; return newRow;
}); });

View File

@@ -253,7 +253,7 @@
} }
return QueryResult; return QueryResult;
} };
var Query = function ($resource, QueryResult) { var Query = function ($resource, QueryResult) {
var Query = $resource('/api/queries/:id', {id: '@id'}); var Query = $resource('/api/queries/:id', {id: '@id'});
@@ -263,6 +263,7 @@
ttl = this.ttl; ttl = this.ttl;
} }
var queryResult = null; var queryResult = null;
if (this.latest_query_data && ttl != 0) { if (this.latest_query_data && ttl != 0) {
queryResult = new QueryResult({'query_result': this.latest_query_data}); queryResult = new QueryResult({'query_result': this.latest_query_data});
@@ -273,17 +274,37 @@
} }
return queryResult; return queryResult;
} };
Query.prototype.getHash = function() { Query.prototype.getHash = function() {
return [this.name, this.description, this.query].join('!#'); return [this.name, this.description, this.query].join('!#');
} };
return Query; return Query;
};
var Visualization = function($resource) {
var Visualization = $resource('/api/visualizations/:id', {id: '@id'});
Visualization.prototype = {
TYPES: {
'CHART': 'CHART',
'COHORT': 'COHORT',
'GRID': 'GRID'
},
SERIES_TYPES: {
'LINE': 'line',
'BAR': 'bar',
'AREA': 'area'
} }
};
return Visualization;
};
angular.module('redash.services', []) angular.module('redash.services', [])
.factory('QueryResult', ['$resource', '$timeout', QueryResult]) .factory('QueryResult', ['$resource', '$timeout', QueryResult])
.factory('Query', ['$resource', 'QueryResult', Query]) .factory('Query', ['$resource', 'QueryResult', Query])
.factory('Visualization', ['$resource', Visualization])
})(); })();

View File

@@ -2,6 +2,10 @@ body {
padding-top: 70px; padding-top: 70px;
} }
a.link {
cursor: pointer;
}
a.page-title { a.page-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -56,6 +60,10 @@ a.navbar-brand {
margin-bottom: 0px; margin-bottom: 0px;
} }
.panel-heading > a {
color: inherit;
}
/* angular-growl */ /* angular-growl */
.growl { .growl {
position: fixed; position: fixed;
@@ -194,3 +202,15 @@ to add those CSS styles here. */
-moz-border-radius: 6px 0 6px 6px; -moz-border-radius: 6px 0 6px 6px;
border-radius: 6px 0 6px 6px; border-radius: 6px 0 6px 6px;
} }
.rd-tab .remove {
cursor: pointer;
color: #A09797;
padding: 0 3px 1px 4px;
font-size: 11px;
}
.rd-tab .remove:hover {
color: white;
background-color: #FF8080;
border-radius: 50%;
}

View File

@@ -8,7 +8,7 @@
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" href="#edit_dashboard_dialog" tooltip="Edit Dashboard (Name/Layout)"><span <button type="button" class="btn btn-default btn-xs" data-toggle="modal" href="#edit_dashboard_dialog" tooltip="Edit Dashboard (Name/Layout)"><span
class="glyphicon glyphicon-cog"></span></button> class="glyphicon glyphicon-cog"></span></button>
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" <button type="button" class="btn btn-default btn-xs" data-toggle="modal"
href="#add_query_dialog" tooltip="Add Widget (Chart/Table)"><span class="glyphicon glyphicon-import"></span> href="#add_query_dialog" tooltip="Add Widget (Chart/Table)"><span class="glyphicon glyphicon-plus"></span>
</button> </button>
</span> </span>
</h2> </h2>
@@ -29,11 +29,8 @@
</h3> </h3>
</div> </div>
<div ng-switch on="widget.type" class="panel-body"> <visualization-renderer visualization="widget.visualization" query-result="queryResult"></visualization-renderer class="panel-body">
<chart-renderer ng-switch-when="chart" query-result="queryResult" options="widget.options"></chart-renderer>
<grid-renderer ng-switch-when="grid" query-result="queryResult"></grid-renderer>
<cohort-renderer ng-switch-when="cohort" query-result="queryResult"></cohort-renderer>
</div>
<div class="panel-footer"> <div class="panel-footer">
<span class="label label-default" <span class="label label-default"
tooltip="next update {{nextUpdateTime}} (query runtime: {{queryResult.getRuntime() | durationHumanize}})" tooltip="next update {{nextUpdateTime}} (query runtime: {{queryResult.getRuntime() | durationHumanize}})"

View File

@@ -0,0 +1,31 @@
<form role="form" name="visForm" ng-submit="submit()">
<div class="form-group">
<label class="control-label">Name</label>
<input type="text" class="form-control" ng-model="vis.name" placeholder="{{query.name}}">
</div>
<div class="form-group">
<label class="control-label">Description</label>
<textarea class="form-control" ng-model="vis.description" placeholder="{{query.description}}"></textarea>
</div>
<div class="form-group">
<label class="control-label">Visualization Type</label>
<select required ng-model="vis.type" ng-options="value as key for (key, value) in visTypes" class="form-control" ng-change="typeChanged()"></select>
</div>
<div class="form-group" ng-show="vis.type == visTypes.Chart">
<label class="control-label">Chart Type</label>
<select required ng-model="vis.options.series.type" ng-options="value as key for (key, value) in seriesTypes" class="form-control"></select>
</div>
<div class="form-group">
<a class="link" ng-click="toggleAdvancedMode()">Advanced</a>
<textarea json-text class="form-control" rows="5" ng-model="vis.options" ng-show="advancedMode"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>

View File

@@ -7,15 +7,34 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p> <p>
<input type="text" class="form-control" placeholder="Query Id" ng-model="queryId"> <form class="form-inline" role="form" ng-submit="loadVisualizations()">
<div class="form-group">
<input class="form-control" placeholder="Query Id" ng-model="queryId">
</div>
<button type="submit" class="btn btn-primary" ng-disabled="!queryId">
<span class="glyphicon glyphicon-refresh"></span> Load
</button>
</form>
</p> </p>
<p> <div ng-show="query">
<select class="form-control" ng-model="widgetType" ng-options="c.value as c.name for c in widgetTypes"></select> <div class="form-group">
</p> <label for="">Choose Visualation</label>
<p> <select ng-model="selectedVis" ng-options="vis as vis.name group by vis.type for vis in query.visualizations" class="form-control"></select>
</div>
<div class="form-group">
<a ng-click="toggleView('addNew')" class="link">&plus; Add New</a>
<div class="well" ng-show="currentView=='addNew'">
<edit-visulatization-form query="query"></edit-visulatization-form>
</div>
</div>
<div class="form-group">
<label for="">Widget Size</label>
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select> <select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select>
</p> </div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-default" ng-disabled="saveInProgress" data-dismiss="modal">Close</button> <button type="button" class="btn btn-default" ng-disabled="saveInProgress" data-dismiss="modal">Close</button>

View File

@@ -51,22 +51,47 @@
</div> </div>
<div class="row" ng-show="queryResult.getStatus() == 'done'"> <div class="row" ng-show="queryResult.getStatus() == 'done'">
<rd-tabs tabs-collection='tabs' selected-tab='selectedTab'></rd-tabs> <ul class="nav nav-tabs">
<rd-tab id="table" name="Table"></rd-tab>
<rd-tab id="pivot" name="Pivot Table"></rd-tab>
<rd-tab id="{{vis.id}}" name="{{vis.name}}" ng-repeat="vis in query.visualizations">
<span class="remove" ng-click="deleteVisualization($event, vis)"> &times;</span>
</rd-tab>
<rd-tab id="add" name="&plus;New" removeable="true"></rd-tab>
</ul>
<div ng-show="selectedTab.key == 'chart'" class="col-lg-12"> <div class="col-lg-12" ng-show="selectedTab == 'table'">
<chart-renderer query-result="queryResult"></chart-renderer>
</div>
<div class="col-lg-12" ng-show="selectedTab.key == 'table'">
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer> <grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
</div> </div>
<div class="col-lg-12" ng-show="selectedTab.key == 'pivot'"> <div class="col-lg-12" ng-show="selectedTab == 'pivot'">
<pivot-table-renderer query-result="queryResult"></pivot-table-renderer> <pivot-table-renderer query-result="queryResult"></pivot-table-renderer>
</div> </div>
<div class="col-lg-12" ng-show="selectedTab.key == 'cohort'"> <div class="col-lg-12" ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
<cohort-renderer query-result="queryResult"></cohort-renderer> <div class="row">
<p>
<div class="col-lg-6">
<edit-visulatization-form vis="vis" query="query"></edit-visulatization-form>
</div>
<div class="col-lg-6">
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
</div>
</p>
</div>
</div>
<div class="col-lg-12" ng-show="selectedTab == 'add'">
<div class="row">
<p>
<div class="col-lg-6">
<edit-visulatization-form vis="newVisualization" query="query"></edit-visulatization-form>
</div>
<div class="col-lg-6">
<visualization-renderer visualization="newVisualization" query-result="queryResult"></visualization-renderer>
</div>
</p>
</div>
</div> </div>
</div> </div>
</div> </div>