Compare commits

...

40 Commits

Author SHA1 Message Date
Arik Fraimovich
42a0659012 Merge pull request #83 from EverythingMe/viz
Visualization Followups + workers bugfix
2014-02-06 19:46:28 +02:00
Amir Nissim
6386f0f9aa fix issue where start_workers failed when settings.CONNECTION_ADAPTER does not exist 2014-02-06 16:40:58 +02:00
Amir Nissim
9aaf17d478 Fixes #80:
* Create default 'Table' visualization for all queries
 * remove 'Table' type when creating new visualization
 * Set type as the default visualization name (instead of the query name)
 * Remove description field and advanced mode
 * Remove section for adding new visualization in new widget dialog
2014-02-06 16:35:29 +02:00
Arik Fraimovich
1f908f9040 Merge pull request #79 from EverythingMe/viz
fix migration to set 'bars' as default
2014-02-05 17:55:23 +02:00
Amir Nissim
b51ef059f5 fix migration to set 'bars' as default 2014-02-05 17:54:12 +02:00
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
Arik Fraimovich
5eddddb7b5 Merge pull request #69 from ekampf/feature/mysql
MySQL Support
2014-01-30 06:20:58 -08:00
Eran Kampf
6408b9e5e1 Fixed MySQL Runner 2014-01-30 16:15:03 +02:00
Eran Kampf
b0159c8246 Redshift shouldn't be here 2014-01-30 16:03:58 +02:00
Eran Kampf
b056e49ec5 Removed unnecessary logging 2014-01-30 11:28:49 +02:00
Eran Kampf
fef5c287d7 Returned redshift code 2014-01-30 11:28:11 +02:00
Eran Kampf
09c65ee9dc Merge branch 'refs/heads/master' into feature/mysql 2014-01-30 11:21:33 +02:00
Eran Kampf
a2385a1779 Removed unecessary logging 2014-01-29 21:02:12 +02:00
Eran Kampf
95529ce8f0 Separated query runners to diff files 2014-01-29 20:57:09 +02:00
Eran Kampf
1a6e5b425a Include MySQL example 2014-01-29 19:30:59 +02:00
Eran Kampf
87e0962c5a MySQL query runner 2014-01-29 19:02:21 +02:00
Arik Fraimovich
1625149221 Merge pull request #66 from EverythingMe/bug-9
Dashboard: update layout editor when adding/removing widgets. fixes #9
2014-01-26 07:28:54 -08:00
Arik Fraimovich
4d60c735ed Fix to upload script 2014-01-26 17:04:12 +02:00
Arik Fraimovich
1d28b7901c Use only filename; without path 2014-01-26 16:59:59 +02:00
Amir Nissim
2b13ef1063 Dashboard: update layout editor when adding/removing widgets. fixes #9 2014-01-23 18:12:44 +02:00
22 changed files with 962 additions and 444 deletions

View File

@@ -1,5 +1,5 @@
NAME=redash
VERSION=0.1
VERSION=0.2
FULL_VERSION=$(VERSION).$(CIRCLE_BUILD_NUM)
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(FULL_VERSION).tar.gz
@@ -10,7 +10,7 @@ deps:
cd rd_ui && grunt build
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:
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.
## 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
* [AngularJS](http://angularjs.org/)

View File

@@ -5,9 +5,9 @@ import json
import requests
if __name__ == '__main__':
# create release
version = sys.argv[1]
filename = sys.argv[2]
filepath = sys.argv[2]
filename = filepath.split('/')[-1]
github_token = os.environ['GITHUB_TOKEN']
auth = (github_token, 'x-oauth-basic')
commit_sha = os.environ['CIRCLE_SHA1']
@@ -25,7 +25,7 @@ if __name__ == '__main__':
upload_url = response.json()['upload_url']
upload_url = upload_url.replace('{?name}', '')
with open(filename) as file_content:
with open(filepath) as file_content:
headers = {'Content-Type': 'application/gzip'}
response = requests.post(upload_url, file_content, params={'name': filename}, auth=auth, headers=headers, verify=False)

View File

@@ -9,8 +9,8 @@ import psycopg2
import qr
import redis
import time
import query_runner
import worker
import settings
from utils import gen_query_hash
@@ -154,7 +154,12 @@ class Manager(object):
if self.workers:
return self.workers
runner = query_runner.redshift(connection_string)
if getattr(settings, 'CONNECTION_ADAPTER', None) == "mysql":
import query_runner_mysql
runner = query_runner_mysql.mysql(connection_string)
else:
import query_runner
runner = query_runner.redshift(connection_string)
redis_connection_params = self.redis_connection.connection_pool.connection_kwargs
self.workers = [worker.Worker(self, redis_connection_params, runner)

View File

@@ -51,7 +51,14 @@ class Query(models.Model):
app_label = 'redash'
db_table = 'queries'
def to_dict(self, with_result=True, with_stats=False):
def create_default_visualizations(self):
table_visualization = Visualization(query=self, name="Table",
description=self.description,
type="TABLE", options="{}")
table_visualization.save()
def to_dict(self, with_result=True, with_stats=False,
with_visualizations=False):
d = {
'id': self.id,
'latest_query_data_id': self.latest_query_data_id,
@@ -75,6 +82,10 @@ class Query(models.Model):
if with_result and self.latest_query_data_id:
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
@classmethod
@@ -148,10 +159,41 @@ class Dashboard(models.Model):
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):
id = models.AutoField(primary_key=True)
query = models.ForeignKey(Query)
type = models.CharField(max_length=100)
query = models.ForeignKey(Query, related_name='widgets')
visualization = models.ForeignKey(Visualization, related_name='widgets')
width = models.IntegerField()
options = models.TextField()
dashboard = models.ForeignKey(Dashboard, related_name='widgets')
@@ -163,10 +205,10 @@ class Widget(models.Model):
def to_dict(self):
return {
'id': self.id,
'query': self.query.to_dict(),
'type': self.type,
'width': self.width,
'options': json.loads(self.options),
'visualization': self.visualization.to_dict(),
'dashboard_id': self.dashboard_id
}

View File

@@ -6,17 +6,17 @@ QueryRunner is the function that the workers use, to execute queries. This is th
Because the worker just pass the query, this can be used with any data store that has some sort of
query language (for example: HiveQL).
"""
import logging
import json
import psycopg2
import sys
import select
from .utils import JSONEncoder
def redshift(connection_string):
def column_friendly_name(column_name):
return column_name
def wait(conn):
while 1:
state = conn.poll()
@@ -28,24 +28,24 @@ def redshift(connection_string):
select.select([conn.fileno()], [], [])
else:
raise psycopg2.OperationalError("poll() returned %s" % state)
def query_runner(query):
connection = psycopg2.connect(connection_string, async=True)
wait(connection)
cursor = connection.cursor()
try:
cursor.execute(query)
wait(connection)
column_names = [col.name for col in cursor.description]
rows = [dict(zip(column_names, row)) for row in cursor]
columns = [{'name': col.name,
'friendly_name': column_friendly_name(col.name),
'type': None} for col in cursor.description]
data = {'columns': columns, 'rows': rows}
json_data = json.dumps(data, cls=JSONEncoder)
error = None
@@ -61,7 +61,7 @@ def redshift(connection_string):
raise sys.exc_info()[1], None, sys.exc_info()[2]
finally:
connection.close()
return json_data, error
return query_runner
return query_runner

View File

@@ -0,0 +1,56 @@
"""
QueryRunner is the function that the workers use, to execute queries. This is the Redshift
(PostgreSQL in fact) version, but easily we can write another to support additional databases
(MySQL and others).
Because the worker just pass the query, this can be used with any data store that has some sort of
query language (for example: HiveQL).
"""
import logging
import json
import MySQLdb
import sys
import select
from .utils import JSONEncoder
def mysql(connection_string):
if connection_string.endswith(';'):
connection_string = connection_string[0:-1]
def query_runner(query):
connections_params = [entry.split('=')[1] for entry in connection_string.split(';')]
connection = MySQLdb.connect(*connections_params)
cursor = connection.cursor()
logging.debug("mysql got query: %s", query)
try:
cursor.execute(query)
data = cursor.fetchall()
num_fields = len(cursor.description)
column_names = [i[0] for i in cursor.description]
rows = [dict(zip(column_names, row)) for row in data]
columns = [{'name': col_name,
'friendly_name': col_name,
'type': None} for col_name in column_names]
data = {'columns': columns, 'rows': rows}
json_data = json.dumps(data, cls=JSONEncoder)
error = None
cursor.close()
except MySQLdb.Error, e:
json_data = None
error = e.message
except Exception as e:
raise sys.exc_info()[1], None, sys.exc_info()[2]
finally:
connection.close()
return json_data, error
return query_runner

View File

@@ -30,12 +30,22 @@ CREATE TABLE "dashboards" (
"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" (
"id" serial NOT NULL PRIMARY KEY,
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
"type" varchar(100) NOT NULL,
"width" integer 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
)
;
@@ -43,4 +53,4 @@ CREATE INDEX "queries_latest_query_data_id" ON "queries" ("latest_query_data_id"
CREATE INDEX "widgets_query_id" ON "widgets" ("query_id");
CREATE INDEX "widgets_dashboard_id" ON "widgets" ("dashboard_id");
COMMIT;
COMMIT;

67
rd_service/migrate.py Normal file
View File

@@ -0,0 +1,67 @@
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__':
default_options = {"series": {"type": "bar"}}
# create 'table' visualization for all queries
print 'creating TABLE visualizations ...'
for query in Query.objects.all():
vis = Visualization(query=query, name="Table",
description=query.description,
type="TABLE", options="{}")
vis.save()
# create 'cohort' visualization for all queries named with 'cohort'
print 'creating COHORT visualizations ...'
for query in Query.objects.filter(name__icontains="cohort"):
vis = Visualization(query=query, name="Cohort",
description=query.description,
type="COHORT", options="{}")
vis.save()
# create visualization for every widget (unless it already exists)
print 'migrating Widgets -> Visualizations ...'
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 'visualization exists'
widget.visualization = vis[0]
widget.save()
else:
vis_name = widget.type.title()
options = json.loads(widget.options)
vis_options = {"series": options} if options else default_options
vis_options = json.dumps(vis_options)
vis = Visualization(query=query, name=vis_name,
description=query.description,
type=vis_type, options=vis_options)
print 'created visualization %s' % vis_type
vis.save()
widget.visualization = vis
widget.save()

View File

@@ -167,7 +167,7 @@ class WidgetsHandler(BaseAuthenticatedHandler):
class DashboardHandler(BaseAuthenticatedHandler):
def get(self, dashboard_slug=None):
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))
else:
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.pop('latest_query_data', None)
query_def.pop('visualizations', None)
if id:
query = data.models.Query(**query_def)
@@ -214,6 +215,7 @@ class QueriesHandler(BaseAuthenticatedHandler):
query_def['user'] = self.current_user
query = data.models.Query(**query_def)
query.save()
query.create_default_visualizations()
self.write_json(query.to_dict(with_result=False))
@@ -221,7 +223,7 @@ class QueriesHandler(BaseAuthenticatedHandler):
if id:
q = data.models.Query.objects.get(pk=id)
if q:
self.write_json(q.to_dict())
self.write_json(q.to_dict(with_visualizations=True))
else:
self.send_error(404)
else:
@@ -251,6 +253,30 @@ class QueryResultsHandler(BaseAuthenticatedHandler):
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):
def get_current_user(self):
user = super(CsvQueryResultsHandler, self).get_current_user()
@@ -312,6 +338,7 @@ def get_application(static_path, is_debug, redis_connection, data_manager):
(r"/api/queries(?:/([0-9]*))?", QueriesHandler),
(r"/api/query_results(?:/([0-9]*))?", QueryResultsHandler),
(r"/api/jobs/(.*)", JobsHandler),
(r"/api/visualizations(?:/([0-9]*))?", VisualizationHandler),
(r"/api/widgets(?:/([0-9]*))?", WidgetsHandler),
(r"/api/dashboards(?:/(.*))?", DashboardHandler),
(r"/admin/(.*)", MainHandler),

View File

@@ -5,7 +5,12 @@ Example settings module. You should make your own copy as settings.py and enter
import django.conf
REDIS_URL = "redis://localhost:6379"
# Either "pg" or "mysql"
CONNECTION_ADAPTER = "mysql"
# Connection string for the database that is used to run queries against
# -- example mysql CONNECTION_STRING = "Server=;User=;Pwd=;Database="
# -- example pg CONNECTION_STRING = "user= password= host= port=5439 dbname="
CONNECTION_STRING = "user= password= host= port=5439 dbname="
# Connection string for the operational databases (where we store the queries, results, etc)
INTERNAL_DB_CONNECTION_STRING = "dbname=postgres"

View File

@@ -7,7 +7,7 @@
var WidgetCtrl = function ($scope, $http, $location, Query) {
$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;
}
@@ -24,7 +24,7 @@
$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.updateTime = (new Date($scope.queryResult.getUpdatedAt())).toISOString();
@@ -33,12 +33,14 @@
$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;
$scope.dirty = undefined;
var leavingPageText = "You will lose your changes if you leave";
$scope.dirty = undefined;
$scope.newVisualization = undefined;
$window.onbeforeunload = function(){
if (currentUser.canEdit($scope.query) && $scope.dirty) {
return leavingPageText;
@@ -72,8 +74,9 @@
$scope.$parent.pageTitle = "Query Fiddle";
$scope.tabs = [{'key': 'table', 'name': 'Table'}, {'key': 'chart', 'name': 'Chart'},
{'key': 'pivot', 'name': 'Pivot Table'}, {'key': 'cohort', 'name': 'Cohort'}];
$scope.$watch(function() {return $location.hash()}, function(hash) {
$scope.selectedTab = hash || DEFAULT_TAB;
});
$scope.lockButton = function (lock) {
$scope.queryExecuting = lock;
@@ -211,13 +214,26 @@
$scope.queryResult = $scope.query.getQueryResult(0);
$scope.lockButton(true);
$scope.cancelling = false;
}
};
$scope.cancelExecution = function() {
$scope.cancelling = true;
$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) {
@@ -373,7 +389,7 @@
.controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl])
.controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl])
.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('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]);
})();

View File

@@ -1,239 +1,402 @@
var directives = angular.module('redash.directives', []);
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
return {
restrict: 'E',
scope: {
tabsCollection: '=',
selectedTab: '='
},
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="#{{tab.key}}">{{tab.name}}</a></li></ul>',
replace: true,
link: function($scope, element, attrs) {
$scope.selectTab = function(tabKey) {
$scope.selectedTab = _.find($scope.tabsCollection, function(tab) { return tab.key == tabKey; });
}
(function() {
'use strict';
$scope.$watch(function() { return $location.hash()}, function(hash) {
if (hash) {
$scope.selectTab($location.hash());
} else {
$scope.selectTab($scope.tabsCollection[0].key);
}
});
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('editDashboardForm', ['$http', '$location', '$timeout', 'Dashboard', function($http, $location, $timeout, Dashboard) {
return {
restrict: 'E',
scope: {
dashboard: '='
},
templateUrl: '/views/edit_dashboard.html',
replace: true,
link: function($scope, element, attrs) {
$scope.$watch('dashboard.widgets', function() {
if ($scope.dashboard.widgets) {
$scope.layout = [];
_.each($scope.dashboard.widgets, function(row, rowIndex) {
_.each(row, function(widget, colIndex) {
$scope.layout.push({
id: widget.id,
col: colIndex+1,
row: rowIndex+1,
ySize: 1,
xSize: widget.width,
name: widget.query.name
})
})
});
$timeout(function () {
$(".gridster ul").gridster({
widget_margins: [5, 5],
widget_base_dimensions: [260, 100],
min_cols: 2,
max_cols: 2,
serialize_params: function ($w, wgd) {
return { col: wgd.col, row: wgd.row, id: $w.data('widget-id') }
}
});
});
}
});
$scope.saveDashboard = function() {
$scope.saveInProgress = true;
// TODO: we should use the dashboard service here.
if ($scope.dashboard.id) {
var positions = $(element).find('.gridster ul').data('gridster').serialize();
var layout = [];
_.each(_.sortBy(positions, function (pos) {
return pos.row * 10 + pos.col;
}), function (pos) {
var row = pos.row - 1;
var col = pos.col - 1;
layout[row] = layout[row] || [];
if (col > 0 && layout[row][col - 1] == undefined) {
layout[row][col - 1] = pos.id;
} else {
layout[row][col] = pos.id;
}
});
$scope.dashboard.layout = layout;
layout = JSON.stringify(layout);
$http.post('/api/dashboards/' + $scope.dashboard.id, {'name': $scope.dashboard.name, 'layout': layout}).success(function(response) {
$scope.dashboard = new Dashboard(response);
$scope.saveInProgress = false;
$(element).modal('hide');
})
} else {
$http.post('/api/dashboards', {'name': $scope.dashboard.name}).success(function(response) {
$(element).modal('hide');
$location.path('/dashboard/' + response.slug).replace();
})
}
}
}
}
}])
directives.directive('newWidgetForm', ['$http', function($http) {
return {
restrict: 'E',
scope: {
dashboard: '='
},
templateUrl: '/views/new_widget_form.html',
replace: true,
link: function($scope, element, attrs) {
$scope.widgetTypes = [{name: 'Chart', value: 'chart'}, {name: 'Table', value: 'grid'}, {name: 'Cohort', value: 'cohort'}];
$scope.widgetSizes = [{name: 'Regular Size', value: 1}, {name: 'Double Size', value: 2}];
var reset = function() {
$scope.saveInProgress = false;
$scope.widgetType = 'chart';
$scope.widgetSize = 1;
$scope.queryId = null;
}
reset();
$scope.saveWidget = function() {
$scope.saveInProgress = true;
var widget = {
'query_id': $scope.queryId,
'dashboard_id': $scope.dashboard.id,
'type': $scope.widgetType,
'options': {},
'width': $scope.widgetSize
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
return {
restrict: 'E',
scope: {
tabsCollection: '=',
selectedTab: '='
},
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="#{{tab.key}}">{{tab.name}}</a></li></ul>',
replace: true,
link: function($scope, element, attrs) {
$scope.selectTab = function(tabKey) {
$scope.selectedTab = _.find($scope.tabsCollection, function(tab) { return tab.key == tabKey; });
}
$http.post('/api/widgets', widget).success(function(response) {
// update dashboard layout
$scope.dashboard.layout = response['layout'];
if (response['new_row']) {
$scope.dashboard.widgets.push([response['widget']]);
$scope.$watch(function() { return $location.hash()}, function(hash) {
if (hash) {
$scope.selectTab($location.hash());
} else {
$scope.dashboard.widgets[$scope.dashboard.widgets.length-1].push(response['widget']);
$scope.selectTab($scope.tabsCollection[0].key);
}
});
}
}
}]);
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
};
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': '',
'description': q.description,
'options': newOptions()
};
}
}, true);
}
function newOptions(chartType) {
if (chartType === Visualization.prototype.TYPES.COHORT) {
// empty config at the moment
return {};
}
// close the dialog
$('#add_query_dialog').modal('hide');
reset();
// Chart
return {
'series': {
'type': Visualization.prototype.SERIES_TYPES.LINE
}
};
}
scope.$watch('vis.type', function(type) {
// if not edited by user, set name to match type
if (type && scope.vis && !scope.visForm.name.$dirty) {
// poor man's titlecase
scope.vis.name = scope.vis.type[0] + scope.vis.type.slice(1).toLowerCase();
}
});
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) {
return {
restrict: 'E',
scope: {
dashboard: '='
},
templateUrl: '/views/edit_dashboard.html',
replace: true,
link: function($scope, element, attrs) {
var gridster = element.find(".gridster ul").gridster({
widget_margins: [5, 5],
widget_base_dimensions: [260, 100],
min_cols: 2,
max_cols: 2,
serialize_params: function($w, wgd) {
return {
col: wgd.col,
row: wgd.row,
id: $w.data('widget-id')
}
}
}).data('gridster');
var gsItemTemplate = '<li data-widget-id="{id}" class="widget panel panel-default gs-w">' +
'<div class="panel-heading">{name}' +
'</div></li>';
$scope.$watch('dashboard.widgets', function(widgets) {
$timeout(function () {
gridster.remove_all_widgets();
if (widgets && widgets.length) {
var layout = [];
_.each(widgets, function(row, rowIndex) {
_.each(row, function(widget, colIndex) {
layout.push({
id: widget.id,
col: colIndex+1,
row: rowIndex+1,
ySize: 1,
xSize: widget.width,
name: widget.visualization.name
});
});
});
_.each(layout, function(item) {
var el = gsItemTemplate.replace('{id}', item.id).replace('{name}', item.name);
gridster.add_widget(el, item.xSize, item.ySize, item.col, item.row);
});
}
});
}, true);
$scope.saveDashboard = function() {
$scope.saveInProgress = true;
// TODO: we should use the dashboard service here.
if ($scope.dashboard.id) {
var positions = $(element).find('.gridster ul').data('gridster').serialize();
var layout = [];
_.each(_.sortBy(positions, function (pos) {
return pos.row * 10 + pos.col;
}), function (pos) {
var row = pos.row - 1;
var col = pos.col - 1;
layout[row] = layout[row] || [];
if (col > 0 && layout[row][col - 1] == undefined) {
layout[row][col - 1] = pos.id;
} else {
layout[row][col] = pos.id;
}
});
$scope.dashboard.layout = layout;
layout = JSON.stringify(layout);
$http.post('/api/dashboards/' + $scope.dashboard.id, {'name': $scope.dashboard.name, 'layout': layout}).success(function(response) {
$scope.dashboard = new Dashboard(response);
$scope.saveInProgress = false;
$(element).modal('hide');
})
} else {
$http.post('/api/dashboards', {'name': $scope.dashboard.name}).success(function(response) {
$(element).modal('hide');
$location.path('/dashboard/' + response.slug).replace();
})
}
}
}
}
}]);
directives.directive('newWidgetForm', ['$http', 'Query', function($http, Query) {
return {
restrict: 'E',
scope: {
dashboard: '='
},
templateUrl: '/views/new_widget_form.html',
replace: true,
link: function($scope, element, attrs) {
$scope.widgetSizes = [{name: 'Regular', value: 1}, {name: 'Double', value: 2}];
var reset = function() {
$scope.saveInProgress = false;
$scope.widgetSize = 1;
$scope.queryId = null;
$scope.selectedVis = null;
}
reset();
$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.saveInProgress = true;
var widget = {
'visualization_id': $scope.selectedVis.id,
'dashboard_id': $scope.dashboard.id,
'options': {},
'width': $scope.widgetSize
}
$http.post('/api/widgets', widget).success(function(response) {
// update dashboard layout
$scope.dashboard.layout = response['layout'];
if (response['new_row']) {
$scope.dashboard.widgets.push([response['widget']]);
} else {
$scope.dashboard.widgets[$scope.dashboard.widgets.length-1].push(response['widget']);
}
// close the dialog
$('#add_query_dialog').modal('hide');
reset();
})
}
}
}
}])
// From: http://jsfiddle.net/joshdmiller/NDFHg/
directives.directive('editInPlace', function () {
return {
restrict: 'E',
scope: {
value: '=',
ignoreBlanks: '=',
editable: '='
},
template: function(tElement, tAttrs) {
var elType = tAttrs.editor || 'input';
var placeholder = tAttrs.placeholder || 'Click to edit';
return '<span ng-click="editable && edit()" ng-bind="value" ng-class="{editable: editable}"></span>' +
'<span ng-click="editable && edit()" ng-show="editable && !value" ng-class="{editable: editable}">' + placeholder + '</span>' +
'<{elType} ng-model="value" class="form-control" rows="2"></{elType}>'.replace('{elType}', elType);
},
link: function ($scope, element, attrs) {
// Let's get a reference to the input element, as we'll want to reference it.
var inputElement = angular.element(element.children()[2]);
// This directive should have a set class so we can style it.
element.addClass('edit-in-place');
// Initially, we're not editing.
$scope.editing = false;
// ng-click handler to activate edit-in-place
$scope.edit = function () {
if ($scope.ignoreBlanks) {
$scope.oldValue = $scope.value;
}
$scope.editing = true;
// We control display through a class on the directive itself. See the CSS.
element.addClass('active');
// And we must focus the element.
// `angular.element()` provides a chainable array, like jQuery so to access a native DOM function,
// we have to reference the first element in the array.
inputElement[0].focus();
};
$(inputElement).blur(function() {
if ($scope.ignoreBlanks && _.isEmpty($scope.value)) {
$scope.value = $scope.oldValue;
}
$scope.editing = false;
element.removeClass('active');
})
}
};
});
}
}
}])
// 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);
// From: http://jsfiddle.net/joshdmiller/NDFHg/
directives.directive('editInPlace', function () {
return {
restrict: 'E',
scope: {
value: '=',
ignoreBlanks: '=',
editable: '='
},
template: function(tElement, tAttrs) {
var elType = tAttrs.editor || 'input';
var placeholder = tAttrs.placeholder || 'Click to edit';
return '<span ng-click="editable && edit()" ng-bind="value" ng-class="{editable: editable}"></span>' +
'<span ng-click="editable && edit()" ng-show="editable && !value" ng-class="{editable: editable}">' + placeholder + '</span>' +
'<{elType} ng-model="value" class="form-control" rows="2"></{elType}>'.replace('{elType}', elType);
},
link: function ($scope, element, attrs) {
// Let's get a reference to the input element, as we'll want to reference it.
var inputElement = angular.element(element.children()[2]);
}
};
});
// This directive should have a set class so we can style it.
element.addClass('edit-in-place');
directives.directive('rdTimer', ['$timeout', function ($timeout) {
return {
restrict: 'E',
scope: { timestamp: '=' },
template: '{{currentTime}}',
controller: ['$scope' ,function ($scope) {
$scope.currentTime = "00:00:00";
var currentTimeout = null;
// Initially, we're not editing.
$scope.editing = false;
// ng-click handler to activate edit-in-place
$scope.edit = function () {
if ($scope.ignoreBlanks) {
$scope.oldValue = $scope.value;
var updateTime = function() {
$scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format("HH:mm:ss")
currentTimeout = $timeout(updateTime, 1000);
}
$scope.editing = true;
// We control display through a class on the directive itself. See the CSS.
element.addClass('active');
// And we must focus the element.
// `angular.element()` provides a chainable array, like jQuery so to access a native DOM function,
// we have to reference the first element in the array.
inputElement[0].focus();
};
$(inputElement).blur(function() {
if ($scope.ignoreBlanks && _.isEmpty($scope.value)) {
$scope.value = $scope.oldValue;
var cancelTimer = function() {
if (currentTimeout) {
$timeout.cancel(currentTimeout);
currentTimeout = null;
}
}
$scope.editing = false;
element.removeClass('active');
})
}
};
});
directives.directive('rdTimer', ['$timeout', function ($timeout) {
return {
restrict: 'E',
scope: { timestamp: '=' },
template: '{{currentTime}}',
controller: ['$scope' ,function ($scope) {
$scope.currentTime = "00:00:00";
var currentTimeout = null;
updateTime();
var updateTime = function() {
$scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format("HH:mm:ss")
currentTimeout = $timeout(updateTime, 1000);
}
var cancelTimer = function() {
if (currentTimeout) {
$timeout.cancel(currentTimeout);
currentTimeout = null;
}
}
updateTime();
$scope.$on('$destroy', function () {
cancelTimer();
});
}]
};
}]);
$scope.$on('$destroy', function () {
cancelTimer();
});
}]
};
}]);
})();

View File

@@ -1,34 +1,114 @@
'use strict';
(function(){
'use strict';
angular.module('highchart', [])
.directive('chart', ['$timeout', function ($timeout) {
return {
restrict: 'E',
template: '<div></div>',
scope: {
options: "=options",
series: "=series"
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;
},
transclude: true,
replace: true,
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";
}
link: function (scope, element, attrs) {
var chartsDefaults = {
chart: {
renderTo: element[0],
type: attrs.type || null,
height: attrs.height || null,
width: attrs.width || null
}
};
_.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: []
};
var deepCopy = true;
var newSettings = {};
$.extend(deepCopy, newSettings, chartsDefaults, scope.options);
angular.module('highchart', [])
.directive('chart', ['$timeout', function ($timeout) {
return {
restrict: 'E',
template: '<div></div>',
scope: {
options: "=options",
series: "=series"
},
transclude: true,
replace: true,
// Making sure that the DOM is ready before creating the chart element, so it gets proper width.
$timeout(function(){
scope.chart = new Highcharts.Chart(newSettings);
link: function (scope, element, attrs) {
var chartsDefaults = {
chart: {
renderTo: element[0],
type: attrs.type || null,
height: attrs.height || null,
width: attrs.width || null
}
};
var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
// Update when options change
scope.$watch('options', function(newOptions) {
initChart(newOptions);
}, true);
//Update when charts data changes
scope.$watch(function () {
@@ -37,47 +117,65 @@ angular.module('highchart', [])
if (!length || length == 0) {
scope.chart.showLoading();
} else {
while(scope.chart.series.length > 0) {
scope.chart.series[0].remove(true);
}
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
scope.chart.xAxis[0].update({type: 'category'});
// We need to make sure that for each category, each series has a value.
var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')}));
_.each(scope.series, function(s) {
// TODO: move this logic to Query#getChartData
var yValues = _.groupBy(s.data, 'x');
var newData = _.sortBy(_.map(categories, function(category) {
return {
name: category,
y: yValues[category] && yValues[category][0].y
}
}), 'name');
s.data = newData;
});
} else {
scope.chart.xAxis[0].update({type: 'datetime'});
}
scope.chart.counters.color = 0;
_.each(scope.series, function(s) {
scope.chart.addSeries(s);
})
scope.chart.redraw();
scope.chart.hideLoading();
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) {
scope.chart.series[0].remove(true);
}
// todo series.type
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
scope.chart.xAxis[0].update({type: 'category'});
// We need to make sure that for each category, each series has a value.
var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')}));
_.each(scope.series, function(s) {
// TODO: move this logic to Query#getChartData
var yValues = _.groupBy(s.data, 'x');
var newData = _.sortBy(_.map(categories, function(category) {
return {
name: category,
y: yValues[category] && yValues[category][0].y
}
}), 'name');
s.data = newData;
});
} else {
scope.chart.xAxis[0].update({type: 'datetime'});
}
scope.chart.counters.color = 0;
_.each(scope.series, function(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.hideLoading();
}
}
};
}]);
})();

View File

@@ -1,83 +1,19 @@
var renderers = angular.module('redash.renderers', []);
var defaultChartOptions = {
"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;
renderers.directive('visualizationRenderer', function() {
return {
restrict: 'E',
scope: {
visualization: '=',
queryResult: '='
},
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": []
};
template: '<div ng-switch on="visualization.type">' +
'<chart-renderer ng-switch-when="CHART" options="visualization.options" query-result="queryResult"></chart-renderer>' +
'<cohort-renderer ng-switch-when="COHORT" options="visualization.options" query-result="queryResult"></cohort-renderer>' +
'</div>',
replace: false
}
});
renderers.directive('chartRenderer', function () {
return {
@@ -90,8 +26,13 @@ renderers.directive('chartRenderer', function () {
replace: false,
controller: ['$scope', function ($scope) {
$scope.chartSeries = [];
$scope.chartOptions = defaultChartOptions;
$scope.chartOptions = {};
$scope.$watch('options', function(chartOptions) {
if (chartOptions) {
$scope.chartOptions = chartOptions;
}
});
$scope.$watch('queryResult && queryResult.getData()', function (data) {
if (!data || $scope.queryResult.getData() == null) {
$scope.chartSeries.splice(0, $scope.chartSeries.length);
@@ -99,7 +40,7 @@ renderers.directive('chartRenderer', function () {
$scope.chartSeries.splice(0, $scope.chartSeries.length);
_.each($scope.queryResult.getChartData(), function (s) {
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}, $scope.options));
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}));
});
}
});
@@ -142,13 +83,7 @@ renderers.directive('gridRenderer', function () {
var gridData = _.map($scope.queryResult.getData(), function (row) {
var newRow = {};
_.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;
});
@@ -264,4 +199,4 @@ renderers.directive('cohortRenderer', function() {
});
}
}
})
})

View File

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

View File

@@ -2,6 +2,10 @@ body {
padding-top: 70px;
}
a.link {
cursor: pointer;
}
a.page-title {
overflow: hidden;
text-overflow: ellipsis;
@@ -56,6 +60,10 @@ a.navbar-brand {
margin-bottom: 0px;
}
.panel-heading > a {
color: inherit;
}
/* angular-growl */
.growl {
position: fixed;
@@ -193,4 +201,16 @@ to add those CSS styles here. */
-webkit-border-radius: 6px 0 6px 6px;
-moz-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
class="glyphicon glyphicon-cog"></span></button>
<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>
</span>
</h2>
@@ -29,11 +29,8 @@
</h3>
</div>
<div ng-switch on="widget.type" 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>
<visualization-renderer visualization="widget.visualization" query-result="queryResult"></visualization-renderer class="panel-body">
<div class="panel-footer">
<span class="label label-default"
tooltip="next update {{nextUpdateTime}} (query runtime: {{queryResult.getRuntime() | durationHumanize}})"

View File

@@ -10,17 +10,9 @@
<input type="text" class="form-control" placeholder="Dashboard Name" ng-model="dashboard.name">
</p>
<p ng-show="layout!='null'">
<div class="gridster">
<ul>
<li ng-repeat="widget in layout" data-row="{{widget.row}}" data-col="{{widget.col}}"
data-sizey="{{widget.ySize}}" data-sizex="{{widget.xSize}}" data-widget-id="{{widget.id}}"
class="widget panel panel-default">
<div class="panel-heading">{{widget.name}}</div>
</li>
</ul>
</div>
</p>
<div class="gridster">
<ul></ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-disabled="saveInProgress" data-dismiss="modal">Close</button>

View File

@@ -0,0 +1,21 @@
<form role="form" name="visForm" ng-submit="submit()">
<div class="form-group">
<label class="control-label">Name</label>
<input name="name" type="text" class="form-control" ng-model="vis.name" placeholder="{{vis.type}}">
</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">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>

View File

@@ -7,15 +7,27 @@
</div>
<div class="modal-body">
<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>
<select class="form-control" ng-model="widgetType" ng-options="c.value as c.name for c in widgetTypes"></select>
</p>
<p>
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select>
</p>
<div ng-show="query">
<div class="form-group">
<label for="">Choose Visualation</label>
<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">
<label for="">Widget Size</label>
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-disabled="saveInProgress" data-dismiss="modal">Close</button>

View File

@@ -51,22 +51,48 @@
</div>
<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>
<!-- hide the table visualization -->
<rd-tab id="{{vis.id}}" name="{{vis.name}}" ng-hide="vis.type=='TABLE'" 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">
<chart-renderer query-result="queryResult"></chart-renderer>
</div>
<div class="col-lg-12" ng-show="selectedTab.key == 'table'">
<div class="col-lg-12" ng-show="selectedTab == 'table'">
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
</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>
</div>
<div class="col-lg-12" ng-show="selectedTab.key == 'cohort'">
<cohort-renderer query-result="queryResult"></cohort-renderer>
<div class="col-lg-12" ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
<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>