Compare commits

...

35 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
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 953 additions and 445 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

@@ -5,9 +5,9 @@ import json
import requests import requests
if __name__ == '__main__': if __name__ == '__main__':
# create release
version = sys.argv[1] version = sys.argv[1]
filename = sys.argv[2] filepath = sys.argv[2]
filename = filepath.split('/')[-1]
github_token = os.environ['GITHUB_TOKEN'] github_token = os.environ['GITHUB_TOKEN']
auth = (github_token, 'x-oauth-basic') auth = (github_token, 'x-oauth-basic')
commit_sha = os.environ['CIRCLE_SHA1'] commit_sha = os.environ['CIRCLE_SHA1']
@@ -25,7 +25,7 @@ if __name__ == '__main__':
upload_url = response.json()['upload_url'] upload_url = response.json()['upload_url']
upload_url = upload_url.replace('{?name}', '') upload_url = upload_url.replace('{?name}', '')
with open(filename) as file_content: with open(filepath) as file_content:
headers = {'Content-Type': 'application/gzip'} headers = {'Content-Type': 'application/gzip'}
response = requests.post(upload_url, file_content, params={'name': filename}, auth=auth, headers=headers, verify=False) 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 qr
import redis import redis
import time import time
import query_runner
import worker import worker
import settings
from utils import gen_query_hash from utils import gen_query_hash
@@ -153,8 +153,13 @@ class Manager(object):
def start_workers(self, workers_count, connection_string): def start_workers(self, workers_count, connection_string):
if self.workers: if self.workers:
return self.workers return self.workers
runner = query_runner.redshift(connection_string) if settings.CONNECTION_ADAPTER == "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 redis_connection_params = self.redis_connection.connection_pool.connection_kwargs
self.workers = [worker.Worker(self, redis_connection_params, runner) self.workers = [worker.Worker(self, redis_connection_params, runner)

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

@@ -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 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). query language (for example: HiveQL).
""" """
import logging
import json import json
import psycopg2 import psycopg2
import sys import sys
import select import select
from .utils import JSONEncoder from .utils import JSONEncoder
def redshift(connection_string): def redshift(connection_string):
def column_friendly_name(column_name): def column_friendly_name(column_name):
return column_name return column_name
def wait(conn): def wait(conn):
while 1: while 1:
state = conn.poll() state = conn.poll()
@@ -28,24 +28,24 @@ def redshift(connection_string):
select.select([conn.fileno()], [], []) select.select([conn.fileno()], [], [])
else: else:
raise psycopg2.OperationalError("poll() returned %s" % state) raise psycopg2.OperationalError("poll() returned %s" % state)
def query_runner(query): def query_runner(query):
connection = psycopg2.connect(connection_string, async=True) connection = psycopg2.connect(connection_string, async=True)
wait(connection) wait(connection)
cursor = connection.cursor() cursor = connection.cursor()
try: try:
cursor.execute(query) cursor.execute(query)
wait(connection) wait(connection)
column_names = [col.name for col in cursor.description] column_names = [col.name for col in cursor.description]
rows = [dict(zip(column_names, row)) for row in cursor] rows = [dict(zip(column_names, row)) for row in cursor]
columns = [{'name': col.name, columns = [{'name': col.name,
'friendly_name': column_friendly_name(col.name), 'friendly_name': column_friendly_name(col.name),
'type': None} for col in cursor.description] 'type': None} for col in cursor.description]
data = {'columns': columns, 'rows': rows} data = {'columns': columns, 'rows': rows}
json_data = json.dumps(data, cls=JSONEncoder) json_data = json.dumps(data, cls=JSONEncoder)
error = None error = None
@@ -61,7 +61,7 @@ def redshift(connection_string):
raise sys.exc_info()[1], None, sys.exc_info()[2] raise sys.exc_info()[1], None, sys.exc_info()[2]
finally: finally:
connection.close() connection.close()
return json_data, error 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 "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
) )
; ;
@@ -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_query_id" ON "widgets" ("query_id");
CREATE INDEX "widgets_dashboard_id" ON "widgets" ("dashboard_id"); CREATE INDEX "widgets_dashboard_id" ON "widgets" ("dashboard_id");
COMMIT; COMMIT;

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

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

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,239 +1,399 @@
var directives = angular.module('redash.directives', []); (function() {
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) { 'use strict';
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; });
}
$scope.$watch(function() { return $location.hash()}, function(hash) { var directives = angular.module('redash.directives', []);
if (hash) {
$scope.selectTab($location.hash()); directives.directive('rdTab', ['$location', function($location) {
} else { return {
$scope.selectTab($scope.tabsCollection[0].key); 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) { directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
return { return {
restrict: 'E', restrict: 'E',
scope: { scope: {
dashboard: '=' tabsCollection: '=',
}, selectedTab: '='
templateUrl: '/views/edit_dashboard.html', },
replace: true, 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>',
link: function($scope, element, attrs) { replace: true,
$scope.$watch('dashboard.widgets', function() { link: function($scope, element, attrs) {
if ($scope.dashboard.widgets) { $scope.selectTab = function(tabKey) {
$scope.layout = []; $scope.selectedTab = _.find($scope.tabsCollection, function(tab) { return tab.key == tabKey; });
_.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
} }
$http.post('/api/widgets', widget).success(function(response) { $scope.$watch(function() { return $location.hash()}, function(hash) {
// update dashboard layout if (hash) {
$scope.dashboard.layout = response['layout']; $scope.selectTab($location.hash());
if (response['new_row']) {
$scope.dashboard.widgets.push([response['widget']]);
} else { } 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,
'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 {};
} }
// close the dialog // Chart
$('#add_query_dialog').modal('hide'); return {
reset(); '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) {
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.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.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. directives.directive('rdTimer', ['$timeout', function ($timeout) {
element.addClass('edit-in-place'); return {
restrict: 'E',
scope: { timestamp: '=' },
template: '{{currentTime}}',
controller: ['$scope' ,function ($scope) {
$scope.currentTime = "00:00:00";
var currentTimeout = null;
// Initially, we're not editing. var updateTime = function() {
$scope.editing = false; $scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format("HH:mm:ss")
currentTimeout = $timeout(updateTime, 1000);
// ng-click handler to activate edit-in-place
$scope.edit = function () {
if ($scope.ignoreBlanks) {
$scope.oldValue = $scope.value;
} }
$scope.editing = true; var cancelTimer = function() {
if (currentTimeout) {
// We control display through a class on the directive itself. See the CSS. $timeout.cancel(currentTimeout);
element.addClass('active'); currentTimeout = null;
}
// 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');
})
}
};
});
directives.directive('rdTimer', ['$timeout', function ($timeout) { updateTime();
return {
restrict: 'E',
scope: { timestamp: '=' },
template: '{{currentTime}}',
controller: ['$scope' ,function ($scope) {
$scope.currentTime = "00:00:00";
var currentTimeout = null;
var updateTime = function() { $scope.$on('$destroy', function () {
$scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format("HH:mm:ss") cancelTimer();
currentTimeout = $timeout(updateTime, 1000); });
} }]
};
var cancelTimer = function() { }]);
if (currentTimeout) { })();
$timeout.cancel(currentTimeout);
currentTimeout = null;
}
}
updateTime();
$scope.$on('$destroy', function () {
cancelTimer();
});
}]
};
}]);

View File

@@ -1,34 +1,114 @@
'use strict'; (function(){
'use strict';
angular.module('highchart', []) var defaultOptions = {
.directive('chart', ['$timeout', function ($timeout) { title: {
return { "text": null
restrict: 'E', },
template: '<div></div>', tooltip: {
scope: { valueDecimals: 2,
options: "=options", formatter: function () {
series: "=series" 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, shared: true
replace: 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) { _.each(this.series, function (series) {
var chartsDefaults = { series.update({stacking: newStacking}, true);
chart: { });
renderTo: element[0], }
type: attrs.type || null, }
height: attrs.height || null, ]
width: attrs.width || null }
} }
}; },
credits: {
enabled: false
},
plotOptions: {
"column": {
"stacking": "normal",
"pointPadding": 0,
"borderWidth": 1,
"groupPadding": 0,
"shadow": false
}
},
series: []
};
var deepCopy = true; angular.module('highchart', [])
var newSettings = {}; .directive('chart', ['$timeout', function ($timeout) {
$.extend(deepCopy, newSettings, chartsDefaults, scope.options); 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. link: function (scope, element, attrs) {
$timeout(function(){ var chartsDefaults = {
scope.chart = new Highcharts.Chart(newSettings); 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 //Update when charts data changes
scope.$watch(function () { scope.$watch(function () {
@@ -37,47 +117,65 @@ angular.module('highchart', [])
if (!length || length == 0) { if (!length || length == 0) {
scope.chart.showLoading(); scope.chart.showLoading();
} else { } else {
while(scope.chart.series.length > 0) { drawChart();
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();
}; };
}, true); }, 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,20 @@
var renderers = angular.module('redash.renderers', []); 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) { renderers.directive('visualizationRenderer', function() {
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' + return {
Highcharts.numberFormat(point.y); restrict: 'E',
scope: {
if (pointsCount > 1 && point.percentage) { visualization: '=',
s += " (" + Highcharts.numberFormat(point.percentage) + "%)"; queryResult: '='
}
});
} 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 template: '<div ng-switch on="visualization.type">' +
}, '<chart-renderer ng-switch-when="CHART" options="visualization.options" query-result="queryResult"></chart-renderer>' +
xAxis: { '<grid-renderer ng-switch-when="GRID" options="visualization.options" query-result="queryResult"></grid-renderer>' +
type: 'datetime' '<cohort-renderer ng-switch-when="COHORT" options="visualization.options" query-result="queryResult"></cohort-renderer>' +
}, '</div>',
yAxis: { replace: false
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 newRow[$scope.queryResult.getColumnCleanName(key)] = val;
if (val > 1000 * 1000 * 1000 * 100) {
newRow[$scope.queryResult.getColumnCleanName(key)] = moment(val);
} else {
newRow[$scope.queryResult.getColumnCleanName(key)] = val;
}
}) })
return newRow; return newRow;
}); });
@@ -264,4 +200,4 @@ renderers.directive('cohortRenderer', function() {
}); });
} }
} }
}) })

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;
@@ -193,4 +201,16 @@ to add those CSS styles here. */
-webkit-border-radius: 6px 0 6px 6px; -webkit-border-radius: 6px 0 6px 6px;
-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

@@ -10,17 +10,9 @@
<input type="text" class="form-control" placeholder="Dashboard Name" ng-model="dashboard.name"> <input type="text" class="form-control" placeholder="Dashboard Name" ng-model="dashboard.name">
</p> </p>
<p ng-show="layout!='null'"> <div class="gridster">
<div class="gridster"> <ul></ul>
<ul> </div>
<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> </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

@@ -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>
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select> </div>
</p>
<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>
</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>