mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 01:47:39 -05:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e135c94f | ||
|
|
212ade2da7 | ||
|
|
f939bf6108 | ||
|
|
3360cd934b | ||
|
|
f35a0970ac | ||
|
|
97ca722a11 | ||
|
|
e554c9bdd7 | ||
|
|
567a732e1e | ||
|
|
5b532d03a0 | ||
|
|
cd838e5a7e | ||
|
|
bb096be00c | ||
|
|
7b78bfe191 | ||
|
|
a45ba0bf30 | ||
|
|
5ce3699a58 | ||
|
|
1cd836ac8d | ||
|
|
c83705119d | ||
|
|
fdd2cfe1d1 | ||
|
|
8327baa2f6 | ||
|
|
84df2fb85c | ||
|
|
cab6f9e58d | ||
|
|
d2ace5c1cf | ||
|
|
5eddddb7b5 | ||
|
|
6408b9e5e1 | ||
|
|
b0159c8246 | ||
|
|
b056e49ec5 | ||
|
|
fef5c287d7 | ||
|
|
09c65ee9dc | ||
|
|
a2385a1779 | ||
|
|
95529ce8f0 | ||
|
|
1a6e5b425a | ||
|
|
87e0962c5a | ||
|
|
1625149221 | ||
|
|
4d60c735ed | ||
|
|
1d28b7901c | ||
|
|
2b13ef1063 |
4
Makefile
4
Makefile
@@ -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)
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +154,11 @@ class Manager(object):
|
|||||||
if self.workers:
|
if self.workers:
|
||||||
return self.workers
|
return self.workers
|
||||||
|
|
||||||
|
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)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ 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
|
||||||
|
|||||||
56
rd_service/data/query_runner_mysql.py
Normal file
56
rd_service/data/query_runner_mysql.py
Normal 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
|
||||||
@@ -30,12 +30,22 @@ CREATE TABLE "dashboards" (
|
|||||||
"is_archived" boolean NOT NULL
|
"is_archived" boolean NOT NULL
|
||||||
)
|
)
|
||||||
;
|
;
|
||||||
|
CREATE TABLE "visualizations" (
|
||||||
|
"id" serial NOT NULL PRIMARY KEY,
|
||||||
|
"type" varchar(100) NOT NULL,
|
||||||
|
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"description" varchar(4096),
|
||||||
|
"options" text NOT NULL
|
||||||
|
)
|
||||||
|
;
|
||||||
CREATE TABLE "widgets" (
|
CREATE TABLE "widgets" (
|
||||||
"id" serial NOT NULL PRIMARY KEY,
|
"id" serial NOT NULL PRIMARY KEY,
|
||||||
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
|
|
||||||
"type" varchar(100) NOT NULL,
|
"type" varchar(100) NOT NULL,
|
||||||
"width" integer NOT NULL,
|
"width" integer NOT NULL,
|
||||||
"options" text NOT NULL,
|
"options" text NOT NULL,
|
||||||
|
"query_id" integer,
|
||||||
|
"visualization_id" integer NOT NULL REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED,
|
||||||
"dashboard_id" integer NOT NULL REFERENCES "dashboards" ("id") DEFERRABLE INITIALLY DEFERRED
|
"dashboard_id" integer NOT NULL REFERENCES "dashboards" ("id") DEFERRABLE INITIALLY DEFERRED
|
||||||
)
|
)
|
||||||
;
|
;
|
||||||
|
|||||||
50
rd_service/migrate.py
Normal file
50
rd_service/migrate.py
Normal 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()
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
var directives = angular.module('redash.directives', []);
|
var directives = angular.module('redash.directives', []);
|
||||||
|
|
||||||
|
directives.directive('rdTab', ['$location', function($location) {
|
||||||
|
return {
|
||||||
|
restrict: 'E',
|
||||||
|
scope: {
|
||||||
|
'id': '@',
|
||||||
|
'name': '@'
|
||||||
|
},
|
||||||
|
transclude: true,
|
||||||
|
template: '<li class="rd-tab" ng-class="{active: id==selectedTab}"><a href="#{{id}}">{{name}}<span ng-transclude></span></a></li>',
|
||||||
|
replace: true,
|
||||||
|
link: function(scope) {
|
||||||
|
scope.$watch(function(){return scope.$parent.selectedTab}, function(tab) {
|
||||||
|
scope.selectedTab = tab;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
|
||||||
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
|
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
|
||||||
return {
|
return {
|
||||||
restrict: 'E',
|
restrict: 'E',
|
||||||
@@ -22,7 +44,89 @@ directives.directive('rdTabs', ['$location', '$rootScope', function($location, $
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}])
|
}]);
|
||||||
|
|
||||||
|
directives.directive('editVisulatizationForm', ['Visualization', 'growl', function(Visualization, growl) {
|
||||||
|
return {
|
||||||
|
restrict: 'E',
|
||||||
|
templateUrl: '/views/edit_visualization.html',
|
||||||
|
replace: true,
|
||||||
|
scope: {
|
||||||
|
query: '=',
|
||||||
|
vis: '=?'
|
||||||
|
},
|
||||||
|
link: function(scope, element, attrs) {
|
||||||
|
scope.advancedMode = false;
|
||||||
|
scope.visTypes = {
|
||||||
|
'Chart': Visualization.prototype.TYPES.CHART,
|
||||||
|
'Cohort': Visualization.prototype.TYPES.COHORT,
|
||||||
|
'Table': Visualization.prototype.TYPES.GRID
|
||||||
|
};
|
||||||
|
scope.seriesTypes = {
|
||||||
|
'Line': Visualization.prototype.SERIES_TYPES.LINE,
|
||||||
|
'Bar': Visualization.prototype.SERIES_TYPES.BAR,
|
||||||
|
'Area': Visualization.prototype.SERIES_TYPES.AREA
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!scope.vis) {
|
||||||
|
// create new visualization
|
||||||
|
// wait for query to load to populate with defaults
|
||||||
|
var unwatch = scope.$watch('query', function(q) {
|
||||||
|
if (q && q.id) {
|
||||||
|
unwatch();
|
||||||
|
scope.vis = {
|
||||||
|
'query_id': q.id,
|
||||||
|
'type': Visualization.prototype.TYPES.CHART,
|
||||||
|
'name': q.name,
|
||||||
|
'description': q.description,
|
||||||
|
'options': newOptions()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newOptions(chartType) {
|
||||||
|
if (chartType === Visualization.prototype.TYPES.COHORT) {
|
||||||
|
// empty config at the moment
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart
|
||||||
|
return {
|
||||||
|
'series': {
|
||||||
|
'type': Visualization.prototype.SERIES_TYPES.LINE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.toggleAdvancedMode = function() {
|
||||||
|
scope.advancedMode = !scope.advancedMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
scope.typeChanged = function() {
|
||||||
|
scope.vis.options = newOptions();
|
||||||
|
};
|
||||||
|
|
||||||
|
scope.submit = function() {
|
||||||
|
Visualization.save(scope.vis, function success(result) {
|
||||||
|
growl.addSuccessMessage("Visualization saved");
|
||||||
|
|
||||||
|
scope.vis = result;
|
||||||
|
|
||||||
|
var visIds = _.pluck(scope.query.visualizations, 'id');
|
||||||
|
var index = visIds.indexOf(result.id);
|
||||||
|
if (index > -1) {
|
||||||
|
scope.query.visualizations[index] = result;
|
||||||
|
} else {
|
||||||
|
scope.query.visualizations.push(result);
|
||||||
|
}
|
||||||
|
}, function error() {
|
||||||
|
growl.addErrorMessage("Visualization could not be saved");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
|
||||||
directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Dashboard', function($http, $location, $timeout, Dashboard) {
|
directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Dashboard', function($http, $location, $timeout, Dashboard) {
|
||||||
return {
|
return {
|
||||||
@@ -33,35 +137,52 @@ directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Da
|
|||||||
templateUrl: '/views/edit_dashboard.html',
|
templateUrl: '/views/edit_dashboard.html',
|
||||||
replace: true,
|
replace: true,
|
||||||
link: function($scope, element, attrs) {
|
link: function($scope, element, attrs) {
|
||||||
$scope.$watch('dashboard.widgets', function() {
|
var gridster = element.find(".gridster ul").gridster({
|
||||||
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_margins: [5, 5],
|
||||||
widget_base_dimensions: [260, 100],
|
widget_base_dimensions: [260, 100],
|
||||||
min_cols: 2,
|
min_cols: 2,
|
||||||
max_cols: 2,
|
max_cols: 2,
|
||||||
serialize_params: function($w, wgd) {
|
serialize_params: function($w, wgd) {
|
||||||
return { col: wgd.col, row: wgd.row, id: $w.data('widget-id') }
|
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.saveDashboard = function() {
|
||||||
$scope.saveInProgress = true;
|
$scope.saveInProgress = true;
|
||||||
@@ -100,10 +221,9 @@ directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Da
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}])
|
}]);
|
||||||
|
|
||||||
|
directives.directive('newWidgetForm', ['$http', 'Query', function($http, Query) {
|
||||||
directives.directive('newWidgetForm', ['$http', function($http) {
|
|
||||||
return {
|
return {
|
||||||
restrict: 'E',
|
restrict: 'E',
|
||||||
scope: {
|
scope: {
|
||||||
@@ -112,25 +232,45 @@ directives.directive('newWidgetForm', ['$http', function($http) {
|
|||||||
templateUrl: '/views/new_widget_form.html',
|
templateUrl: '/views/new_widget_form.html',
|
||||||
replace: true,
|
replace: true,
|
||||||
link: function($scope, element, attrs) {
|
link: function($scope, element, attrs) {
|
||||||
$scope.widgetTypes = [{name: 'Chart', value: 'chart'}, {name: 'Table', value: 'grid'}, {name: 'Cohort', value: 'cohort'}];
|
$scope.widgetSizes = [{name: 'Regular', value: 1}, {name: 'Double', value: 2}];
|
||||||
$scope.widgetSizes = [{name: 'Regular Size', value: 1}, {name: 'Double Size', value: 2}];
|
|
||||||
|
|
||||||
var reset = function() {
|
var reset = function() {
|
||||||
$scope.saveInProgress = false;
|
$scope.saveInProgress = false;
|
||||||
$scope.widgetType = 'chart';
|
|
||||||
$scope.widgetSize = 1;
|
$scope.widgetSize = 1;
|
||||||
$scope.queryId = null;
|
$scope.queryId = null;
|
||||||
|
$scope.selectedVis = null;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reset();
|
reset();
|
||||||
|
|
||||||
|
$scope.toggleView = function(viewName) {
|
||||||
|
$scope.currentView = ($scope.currentView == viewName) ? '' : viewName;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.loadVisualizations = function() {
|
||||||
|
if (!$scope.queryId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Query.get({
|
||||||
|
id: $scope.queryId
|
||||||
|
}, function(query) {
|
||||||
|
if (query) {
|
||||||
|
$scope.query = query;
|
||||||
|
if(query.visualizations.length) {
|
||||||
|
$scope.selectedVis = query.visualizations[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$scope.saveWidget = function() {
|
$scope.saveWidget = function() {
|
||||||
$scope.saveInProgress = true;
|
$scope.saveInProgress = true;
|
||||||
|
|
||||||
var widget = {
|
var widget = {
|
||||||
'query_id': $scope.queryId,
|
'visualization_id': $scope.selectedVis.id,
|
||||||
'dashboard_id': $scope.dashboard.id,
|
'dashboard_id': $scope.dashboard.id,
|
||||||
'type': $scope.widgetType,
|
|
||||||
'options': {},
|
'options': {},
|
||||||
'width': $scope.widgetSize
|
'width': $scope.widgetSize
|
||||||
}
|
}
|
||||||
@@ -208,6 +348,25 @@ directives.directive('editInPlace', function () {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// http://stackoverflow.com/a/17904092/1559840
|
||||||
|
directives.directive('jsonText', function() {
|
||||||
|
return {
|
||||||
|
restrict: 'A',
|
||||||
|
require: 'ngModel',
|
||||||
|
link: function(scope, element, attr, ngModel) {
|
||||||
|
function into(input) {
|
||||||
|
return JSON.parse(input);
|
||||||
|
}
|
||||||
|
function out(data) {
|
||||||
|
return JSON.stringify(data, undefined, 2);
|
||||||
|
}
|
||||||
|
ngModel.$parsers.push(into);
|
||||||
|
ngModel.$formatters.push(out);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
directives.directive('rdTimer', ['$timeout', function ($timeout) {
|
directives.directive('rdTimer', ['$timeout', function ($timeout) {
|
||||||
return {
|
return {
|
||||||
restrict: 'E',
|
restrict: 'E',
|
||||||
@@ -237,3 +396,4 @@ directives.directive('rdTimer', ['$timeout', function ($timeout) {
|
|||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
}]);
|
}]);
|
||||||
|
})();
|
||||||
|
|||||||
@@ -1,5 +1,86 @@
|
|||||||
|
(function(){
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
var defaultOptions = {
|
||||||
|
title: {
|
||||||
|
"text": null
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
valueDecimals: 2,
|
||||||
|
formatter: function () {
|
||||||
|
if (moment.isMoment(this.x)) {
|
||||||
|
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
|
||||||
|
pointsCount = this.points.length;
|
||||||
|
|
||||||
|
$.each(this.points, function (i, point) {
|
||||||
|
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
|
||||||
|
Highcharts.numberFormat(point.y);
|
||||||
|
|
||||||
|
if (pointsCount > 1 && point.percentage) {
|
||||||
|
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var s = "<b>" + this.points[0].key + "</b>";
|
||||||
|
$.each(this.points, function (i, point) {
|
||||||
|
s+= '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
|
||||||
|
Highcharts.numberFormat(point.y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
shared: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'datetime'
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
title: {
|
||||||
|
text: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exporting: {
|
||||||
|
chartOptions: {
|
||||||
|
title: {
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
contextButton: {
|
||||||
|
menuItems: [
|
||||||
|
{
|
||||||
|
text: 'Toggle % Stacking',
|
||||||
|
onclick: function () {
|
||||||
|
var newStacking = "normal";
|
||||||
|
if (this.series[0].options.stacking == "normal") {
|
||||||
|
newStacking = "percent";
|
||||||
|
}
|
||||||
|
|
||||||
|
_.each(this.series, function (series) {
|
||||||
|
series.update({stacking: newStacking}, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
"column": {
|
||||||
|
"stacking": "normal",
|
||||||
|
"pointPadding": 0,
|
||||||
|
"borderWidth": 1,
|
||||||
|
"groupPadding": 0,
|
||||||
|
"shadow": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: []
|
||||||
|
};
|
||||||
|
|
||||||
angular.module('highchart', [])
|
angular.module('highchart', [])
|
||||||
.directive('chart', ['$timeout', function ($timeout) {
|
.directive('chart', ['$timeout', function ($timeout) {
|
||||||
return {
|
return {
|
||||||
@@ -22,13 +103,12 @@ angular.module('highchart', [])
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var deepCopy = true;
|
var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
|
||||||
var newSettings = {};
|
|
||||||
$.extend(deepCopy, newSettings, chartsDefaults, scope.options);
|
|
||||||
|
|
||||||
// Making sure that the DOM is ready before creating the chart element, so it gets proper width.
|
// Update when options change
|
||||||
$timeout(function(){
|
scope.$watch('options', function(newOptions) {
|
||||||
scope.chart = new Highcharts.Chart(newSettings);
|
initChart(newOptions);
|
||||||
|
}, true);
|
||||||
|
|
||||||
//Update when charts data changes
|
//Update when charts data changes
|
||||||
scope.$watch(function () {
|
scope.$watch(function () {
|
||||||
@@ -37,10 +117,28 @@ angular.module('highchart', [])
|
|||||||
if (!length || length == 0) {
|
if (!length || length == 0) {
|
||||||
scope.chart.showLoading();
|
scope.chart.showLoading();
|
||||||
} else {
|
} else {
|
||||||
|
drawChart();
|
||||||
|
};
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
function initChart(options) {
|
||||||
|
if (scope.chart) {
|
||||||
|
scope.chart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
$.extend(true, chartOptions, options);
|
||||||
|
|
||||||
|
scope.chart = new Highcharts.Chart(chartOptions);
|
||||||
|
drawChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart() {
|
||||||
while(scope.chart.series.length > 0) {
|
while(scope.chart.series.length > 0) {
|
||||||
scope.chart.series[0].remove(true);
|
scope.chart.series[0].remove(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo series.type
|
||||||
|
|
||||||
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
|
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
|
||||||
scope.chart.xAxis[0].update({type: 'category'});
|
scope.chart.xAxis[0].update({type: 'category'});
|
||||||
|
|
||||||
@@ -67,17 +165,17 @@ angular.module('highchart', [])
|
|||||||
scope.chart.counters.color = 0;
|
scope.chart.counters.color = 0;
|
||||||
|
|
||||||
_.each(scope.series, function(s) {
|
_.each(scope.series, function(s) {
|
||||||
scope.chart.addSeries(s);
|
// here we override the series with the visualization config
|
||||||
|
var _s = $.extend(true, {}, s, chartOptions['series']);
|
||||||
|
scope.chart.addSeries(_s);
|
||||||
})
|
})
|
||||||
|
|
||||||
scope.chart.redraw();
|
scope.chart.redraw();
|
||||||
scope.chart.hideLoading();
|
scope.chart.hideLoading();
|
||||||
};
|
}
|
||||||
}, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
}]);
|
}]);
|
||||||
|
})();
|
||||||
@@ -1,83 +1,20 @@
|
|||||||
var renderers = angular.module('redash.renderers', []);
|
var renderers = angular.module('redash.renderers', []);
|
||||||
var defaultChartOptions = {
|
|
||||||
"title": {
|
renderers.directive('visualizationRenderer', function() {
|
||||||
"text": null
|
return {
|
||||||
|
restrict: 'E',
|
||||||
|
scope: {
|
||||||
|
visualization: '=',
|
||||||
|
queryResult: '='
|
||||||
},
|
},
|
||||||
"tooltip": {
|
template: '<div ng-switch on="visualization.type">' +
|
||||||
valueDecimals: 2,
|
'<chart-renderer ng-switch-when="CHART" options="visualization.options" query-result="queryResult"></chart-renderer>' +
|
||||||
formatter: function () {
|
'<grid-renderer ng-switch-when="GRID" options="visualization.options" query-result="queryResult"></grid-renderer>' +
|
||||||
if (moment.isMoment(this.x)) {
|
'<cohort-renderer ng-switch-when="COHORT" options="visualization.options" query-result="queryResult"></cohort-renderer>' +
|
||||||
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
|
'</div>',
|
||||||
pointsCount = this.points.length;
|
replace: false
|
||||||
|
|
||||||
$.each(this.points, function (i, point) {
|
|
||||||
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
|
|
||||||
Highcharts.numberFormat(point.y);
|
|
||||||
|
|
||||||
if (pointsCount > 1 && point.percentage) {
|
|
||||||
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
var s = "<b>" + this.points[0].key + "</b>";
|
|
||||||
$.each(this.points, function (i, point) {
|
|
||||||
s+= '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
|
|
||||||
Highcharts.numberFormat(point.y);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return s;
|
|
||||||
},
|
|
||||||
shared: true
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'datetime'
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
title: {
|
|
||||||
text: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
exporting: {
|
|
||||||
chartOptions: {
|
|
||||||
title: {
|
|
||||||
text: this.description
|
|
||||||
}
|
|
||||||
},
|
|
||||||
buttons: {
|
|
||||||
contextButton: {
|
|
||||||
menuItems: [
|
|
||||||
{
|
|
||||||
text: 'Toggle % Stacking',
|
|
||||||
onclick: function () {
|
|
||||||
var newStacking = "normal";
|
|
||||||
if (this.series[0].options.stacking == "normal") {
|
|
||||||
newStacking = "percent";
|
|
||||||
}
|
|
||||||
|
|
||||||
_.each(this.series, function (series) {
|
|
||||||
series.update({stacking: newStacking}, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
credits: {
|
|
||||||
enabled: false
|
|
||||||
},
|
|
||||||
plotOptions: {
|
|
||||||
"column": {
|
|
||||||
"stacking": "normal",
|
|
||||||
"pointPadding": 0,
|
|
||||||
"borderWidth": 1,
|
|
||||||
"groupPadding": 0,
|
|
||||||
"shadow": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"series": []
|
|
||||||
};
|
|
||||||
|
|
||||||
renderers.directive('chartRenderer', function () {
|
renderers.directive('chartRenderer', function () {
|
||||||
return {
|
return {
|
||||||
@@ -90,8 +27,13 @@ renderers.directive('chartRenderer', function () {
|
|||||||
replace: false,
|
replace: false,
|
||||||
controller: ['$scope', function ($scope) {
|
controller: ['$scope', function ($scope) {
|
||||||
$scope.chartSeries = [];
|
$scope.chartSeries = [];
|
||||||
$scope.chartOptions = defaultChartOptions;
|
$scope.chartOptions = {};
|
||||||
|
|
||||||
|
$scope.$watch('options', function(chartOptions) {
|
||||||
|
if (chartOptions) {
|
||||||
|
$scope.chartOptions = chartOptions;
|
||||||
|
}
|
||||||
|
});
|
||||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||||
if (!data || $scope.queryResult.getData() == null) {
|
if (!data || $scope.queryResult.getData() == null) {
|
||||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||||
@@ -99,7 +41,7 @@ renderers.directive('chartRenderer', function () {
|
|||||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||||
|
|
||||||
_.each($scope.queryResult.getChartData(), function (s) {
|
_.each($scope.queryResult.getChartData(), function (s) {
|
||||||
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}, $scope.options));
|
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -142,13 +84,7 @@ renderers.directive('gridRenderer', function () {
|
|||||||
var gridData = _.map($scope.queryResult.getData(), function (row) {
|
var gridData = _.map($scope.queryResult.getData(), function (row) {
|
||||||
var newRow = {};
|
var newRow = {};
|
||||||
_.each(row, function (val, key) {
|
_.each(row, function (val, key) {
|
||||||
// TODO: hack to detect date fields, needed only for backward compatability
|
|
||||||
if (val > 1000 * 1000 * 1000 * 100) {
|
|
||||||
newRow[$scope.queryResult.getColumnCleanName(key)] = moment(val);
|
|
||||||
} else {
|
|
||||||
newRow[$scope.queryResult.getColumnCleanName(key)] = val;
|
newRow[$scope.queryResult.getColumnCleanName(key)] = val;
|
||||||
}
|
|
||||||
|
|
||||||
})
|
})
|
||||||
return newRow;
|
return newRow;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ body {
|
|||||||
padding-top: 70px;
|
padding-top: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
a.page-title {
|
a.page-title {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -56,6 +60,10 @@ a.navbar-brand {
|
|||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-heading > a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/* angular-growl */
|
/* angular-growl */
|
||||||
.growl {
|
.growl {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -194,3 +202,15 @@ to add those CSS styles here. */
|
|||||||
-moz-border-radius: 6px 0 6px 6px;
|
-moz-border-radius: 6px 0 6px 6px;
|
||||||
border-radius: 6px 0 6px 6px;
|
border-radius: 6px 0 6px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rd-tab .remove {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #A09797;
|
||||||
|
padding: 0 3px 1px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.rd-tab .remove:hover {
|
||||||
|
color: white;
|
||||||
|
background-color: #FF8080;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
@@ -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}})"
|
||||||
|
|||||||
@@ -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>
|
||||||
<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>
|
</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>
|
||||||
|
|||||||
31
rd_ui/app/views/edit_visualization.html
Normal file
31
rd_ui/app/views/edit_visualization.html
Normal 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>
|
||||||
@@ -7,15 +7,34 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>
|
<p>
|
||||||
<input type="text" class="form-control" placeholder="Query Id" ng-model="queryId">
|
<form class="form-inline" role="form" ng-submit="loadVisualizations()">
|
||||||
|
<div class="form-group">
|
||||||
|
<input class="form-control" placeholder="Query Id" ng-model="queryId">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" ng-disabled="!queryId">
|
||||||
|
<span class="glyphicon glyphicon-refresh"></span> Load
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<div ng-show="query">
|
||||||
<select class="form-control" ng-model="widgetType" ng-options="c.value as c.name for c in widgetTypes"></select>
|
<div class="form-group">
|
||||||
</p>
|
<label for="">Choose Visualation</label>
|
||||||
<p>
|
<select ng-model="selectedVis" ng-options="vis as vis.name group by vis.type for vis in query.visualizations" class="form-control"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<a ng-click="toggleView('addNew')" class="link">+ Add New</a>
|
||||||
|
<div class="well" ng-show="currentView=='addNew'">
|
||||||
|
<edit-visulatization-form query="query"></edit-visulatization-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="">Widget Size</label>
|
||||||
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select>
|
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select>
|
||||||
</p>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-default" ng-disabled="saveInProgress" data-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-default" ng-disabled="saveInProgress" data-dismiss="modal">Close</button>
|
||||||
|
|||||||
@@ -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)"> ×</span>
|
||||||
|
</rd-tab>
|
||||||
|
<rd-tab id="add" name="+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>
|
||||||
Reference in New Issue
Block a user