Compare commits

..

74 Commits
0.2.0 ... 0.4.0

Author SHA1 Message Date
Maxime Beauchemin
e64367149e v0.4.0 2015-09-26 21:39:45 -07:00
Maxime Beauchemin
4c78b4f34e Merge pull request #33 from mistercrunch/nvd3
Adding nvd3 support
2015-09-26 21:15:39 -07:00
Maxime Beauchemin
89e92904e1 Adding nvd3 support 2015-09-26 21:14:44 -07:00
Maxime Beauchemin
703b6f612b Forgot to add some files for unit tests 2015-09-26 15:56:08 -07:00
Maxime Beauchemin
f1a64c0988 Allowing 2 widgets to overflow 2015-09-25 16:52:15 -07:00
Maxime Beauchemin
8d4c3ea381 Increasing test coverage to 68% 2015-09-25 16:42:56 -07:00
Maxime Beauchemin
aea304d814 Making sum metrics for numeric columns 2015-09-25 16:18:31 -07:00
Maxime Beauchemin
1d6d821008 Changing default to 100 items per page 2015-09-25 16:15:13 -07:00
Maxime Beauchemin
82bf5437cd More screenshots 2015-09-25 15:50:40 -07:00
Maxime Beauchemin
2d23feed1f Merge pull request #32 from mistercrunch/tests
Adding a foundation for unit tests
2015-09-25 15:44:36 -07:00
Maxime Beauchemin
c4b24cb9cc Adding a foundation for unit tests 2015-09-25 15:43:50 -07:00
Maxime Beauchemin
f95e84442f Trying POST instead of GET for testconn 2015-09-25 08:18:27 -07:00
Maxime Beauchemin
83e37f66fa Adding fields to list_columns of DatabaseView 2015-09-23 22:55:21 -07:00
Maxime Beauchemin
74ae4ee704 ADding help text around sqlalchemy URI 2015-09-23 21:28:01 -07:00
Maxime Beauchemin
9fab75bb1f Merge pull request #31 from mistercrunch/test_connection
Adding a button to test connections
2015-09-23 21:23:06 -07:00
Maxime Beauchemin
e854798730 Adding a button to test connections 2015-09-23 21:21:48 -07:00
Maxime Beauchemin
caafb1ef4c Changing title 2015-09-23 20:17:20 -07:00
Maxime Beauchemin
63d55c9f93 Polish 2015-09-23 16:43:55 -07:00
Maxime Beauchemin
71274ae609 Beautifying the index page 2015-09-23 14:34:14 -07:00
Maxime Beauchemin
e126ab4eb8 Improvements 2015-09-23 10:40:50 -07:00
Maxime Beauchemin
f395c83bae Improving the main page 2015-09-23 08:33:46 -07:00
Maxime Beauchemin
591c90d0c0 Merge pull request #30 from mistercrunch/word_cloud
Word cloud widget!
2015-09-22 23:44:25 -07:00
Maxime Beauchemin
2cbc2e36e3 Adding a word cloud widget 2015-09-22 23:40:58 -07:00
Maxime Beauchemin
a28d9d4bb1 Bugfix 2015-09-22 23:11:59 -07:00
Maxime Beauchemin
11c291b9a1 Merge pull request #29 from mistercrunch/markup_widgets
Adding support for markup (html/markdown) widgets
2015-09-22 22:58:57 -07:00
Maxime Beauchemin
24882884b8 Forgot to git add viz_markup.html 2015-09-22 18:00:38 -07:00
Maxime Beauchemin
f5e355a26f Forgot to git add viz_markup.html 2015-09-22 17:58:44 -07:00
Maxime Beauchemin
e4a5f34f71 Uncommenting try block 2015-09-22 16:53:06 -07:00
Maxime Beauchemin
449441fed5 Adding support for markup (html/markdown) widgets 2015-09-22 16:34:11 -07:00
Maxime Beauchemin
3d8fbaa966 Improvments to the dashboard view 2015-09-22 11:55:40 -07:00
Maxime Beauchemin
ff29f905c9 Loading examples with a CLI subcommand 2015-09-22 09:52:41 -07:00
Maxime Beauchemin
4d1d3ad0f6 Minor bugfix 2015-09-21 15:49:34 -07:00
Maxime Beauchemin
e1b3c7e63b Bugfixes+ better docs 2015-09-21 13:48:02 -07:00
Maxime Beauchemin
2cbe25c6b5 Changing an icon 2015-09-20 21:56:23 -07:00
Maxime Beauchemin
c09dca514d Adding a BigNumber widget 2015-09-20 13:50:07 -07:00
Maxime Beauchemin
72ec6ae570 Cosmetics 2015-09-19 13:52:47 -07:00
Maxime Beauchemin
256193ce9f Merge pull request #28 from noddi/bugfix/default_sqlite_path
Fix default Sqlite path.
2015-09-19 07:30:53 -07:00
Bartosz Ługowski
6c8f268587 Fix default Sqlite path in README. 2015-09-19 11:40:16 +02:00
Bartosz Ługowski
8ac35bd610 Fix default Sqlite path. 2015-09-19 11:21:49 +02:00
Maxime Beauchemin
e0d6d20993 Adding a command to load a sample dataset 2015-09-18 23:48:41 -07:00
Maxime Beauchemin
4edbbd350d More bug squashing 2015-09-18 17:27:46 -07:00
Maxime Beauchemin
6f1fa5152a Merge pull request #27 from mistercrunch/dash
More refactor and bugfixes
2015-09-18 15:30:53 -07:00
Maxime Beauchemin
e5e2988e2d More refactor and bugfixes 2015-09-18 15:29:49 -07:00
Maxime Beauchemin
116b1c01f5 Merge pull request #26 from mistercrunch/dash
Bugfix
2015-09-18 14:31:54 -07:00
Maxime Beauchemin
16550b9753 Bugfix 2015-09-18 14:30:54 -07:00
Maxime Beauchemin
c1f28a3e74 Merge pull request #25 from mistercrunch/dash
Adding basic dashboarding support!
2015-09-18 13:42:02 -07:00
Maxime Beauchemin
cd09b0ddef Bugfixes 2015-09-18 11:12:27 -07:00
Maxime Beauchemin
f6753afa75 Panoramix v1 dashboards is up 2015-09-17 18:06:47 -07:00
Maxime Beauchemin
521b000ab6 Checkpoint 2015-09-15 12:33:26 -07:00
Maxime Beauchemin
359a81eee3 Getting back into a working state 2015-09-15 09:17:59 -07:00
Maxime Beauchemin
0bc2e71ac6 Changing the way viz templates are defined using macros instead 2015-09-14 08:50:01 -07:00
Maxime Beauchemin
6daf92e3c1 About to start ajaxifying 2015-09-14 08:04:32 -07:00
Maxime Beauchemin
a5b896414d Dashboards 2015-09-13 19:07:54 -07:00
Maxime Beauchemin
95b080160f Pinning flask-login 2015-09-12 23:27:06 -07:00
Maxime Beauchemin
36351918c9 Dashboards 2015-09-12 23:25:43 -07:00
Maxime Beauchemin
e755854c29 Save and embed 2015-09-11 21:49:57 -07:00
Maxime Beauchemin
5f20a080f4 Setting up alembic 2015-09-11 15:32:42 -07:00
Maxime Beauchemin
ca3959783c New script 2015-09-09 22:21:38 -07:00
Maxime
9a63a312b6 Warn on row limit reached 2015-09-09 20:55:47 +00:00
Maxime
5825f4539d Allowing for form field description and tooltips 2015-09-09 20:10:42 +00:00
Maxime Beauchemin
53fe171466 Merge pull request #23 from mistercrunch/where
Custom WHERE clause for tables (not druid) + error handling refactor
2015-09-09 12:49:54 -07:00
Maxime
fab0670669 Custom WHERE clause for tables (not druid) + error handling refactor 2015-09-09 19:48:20 +00:00
Maxime
67c5f637d1 Linting 2015-09-09 17:52:11 +00:00
Maxime Beauchemin
9858304468 Merge pull request #22 from mistercrunch/forms
Form factory refactor
2015-09-09 10:00:17 -07:00
Maxime Beauchemin
7f9d96b024 Merge pull request #20 from wbchn/master
add tzinfo config, useful when start druid without utc timezone
2015-09-09 09:59:16 -07:00
Maxime Beauchemin
1283bc0788 Form factory refactor 2015-09-08 23:50:45 -07:00
Maxime
6dd81a3e95 Adding argparse for port and debug mode on bin/panoramix 2015-09-08 21:56:27 +00:00
Maxime
483935cc12 Marking distribution as is_timeseries=False 2015-09-08 21:01:40 +00:00
Maxime Beauchemin
bca1e15e44 Temp DB in /tmp 2015-09-07 08:39:42 -07:00
wangbin
11aaaf3e11 add tzinfo config, useful when start druid without utc timezone 2015-09-06 17:11:11 +08:00
Maxime Beauchemin
c95c2522ab v0.2.1 2015-09-05 15:08:38 -07:00
Maxime Beauchemin
58c2186f56 Adding a few screenshots to README 2015-09-05 14:27:18 -07:00
Maxime Beauchemin
94ef801c6d Merge pull request #19 from mistercrunch/setup
Preparing pypi package
2015-09-05 13:45:33 -07:00
Maxime Beauchemin
efc54e0f65 Preparing pypi package 2015-09-05 13:37:55 -07:00
63 changed files with 4067 additions and 7959 deletions

5
.gitignore vendored
View File

@@ -1,7 +1,12 @@
*.pyc
.DS_Store
.coverage
build
*.db
tmp
panoramix_config.py
local_config.py
env
dist
panoramix.egg-info/
app.db

4
MANIFEST.in Normal file
View File

@@ -0,0 +1,4 @@
recursive-include panoramix/templates *
recursive-include panoramix/static *
recursive-include panoramix/data *
recursive-include panoramix/migrations *

View File

@@ -4,25 +4,49 @@ Panoramix
Panoramix is a data exploration platform designed to be visual, intuitive
and interactive.
![img](http://i.imgur.com/bi09J9X.png)
![img](http://i.imgur.com/aOaH0ty.png)
Panoramix
---------
Panoramix's main goal is to make it easy to slice, dice and visualize data.
It empowers its user to perform **analytics at the speed of thought**.
Panoramix provides:
* A quick way to intuitively visualize datasets
* Create and share simple dashboards
* A rich set of visualizations to analyze your data, as well as a flexible
way to extend the capabilities
* An extensible, high granularity security model allowing intricate rules
on who can access which features, and integration with major
authentication providers (database, OpenID, LDAP, OAuth & REMOTE_USER
through Flask AppBuiler)
* A simple semantic layer, allowing to control how data sources are
displayed in the UI,
by defining which fields should show up in which dropdown and which
aggregation and function (metrics) are made available to the user
* Deep integration with Druid allows for Panoramix to stay blazing fast while
slicing and dicing large, realtime datasets
Buzz Phrases
------------
* Analytics at the speed of thought!
* Analytics at the speed of thought!
* Instantaneous learning curve
* Realtime analytics when querying [Druid.io](http://druid.io)
* Extentsible to infinity
![img](http://i.imgur.com/aOaH0ty.png)
Database Support
----------------
Panoramix was originally designed on to of Druid.io, but quickly broadened
Panoramix was originally designed on to of Druid.io, but quickly broadened
its scope to support other databases through the use of SqlAlchemy, a Python
ORM that is compatible with
[many external databases](http://docs.sqlalchemy.org/en/rel_1_0/core/engines.html).
ORM that is compatible with
[most common databases](http://docs.sqlalchemy.org/en/rel_1_0/core/engines.html).
What's Druid?
What is Druid?
-------------
From their website at http://druid.io
@@ -33,27 +57,6 @@ and fast data aggregation. Existing Druid deployments have scaled to
trillions of events and petabytes of data. Druid is best used to
power analytic dashboards and applications.*
Panoramix
---------
Panoramix's main goal is to make it easy to slice, dice and visualize data
out of Druid. It empowers its user to perform **analytics
at the speed of thought**.
Panoramix started as a hackathon project at Airbnb in while running a POC
(proof of concept) on using Druid.
Panoramix provides:
* A way to query intuitively a Druid dataset, allowing for grouping, filtering
limiting and defining a time granularity
* Many charts and visualization to analyze your data, as well as a flexible
way to extend the visualization capabilities
* An extensible, high granularity security model allowing intricate rules
on who can access which features, and integration with major
authentication providers (through Flask AppBuiler)
* A simple semantic layer, allowing to control how Druid datasources are
displayed in the UI,
by defining which fields should show up in which dropdown and which
aggregation and function (metrics) are made available to the user
Installation
------------
@@ -67,8 +70,11 @@ pip install panoramix
# Create an admin user
fabmanager create-admin --app panoramix
# Start the web server
panoramix
# Load some data to play with
panoramix load_examples
# Start the development web server
panoramix runserver -d
```
After installation, you should be able to point your browser to the right
@@ -102,7 +108,7 @@ PANORAMIX_WEBSERVER_PORT = 8088
SECRET_KEY = '\2\1thisismyscretkey\1\2\e\y\y\h'
# The SQLAlchemy connection string.
SQLALCHEMY_DATABASE_URI = 'sqlite:///tmp/panoramix.db'
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/panoramix.db'
# Flask-WTF flag for CSRF
CSRF_ENABLED = True
@@ -123,3 +129,11 @@ the [Flask App Builder Documentation](http://flask-appbuilder.readthedocs.org/en
``Admin->Refresh Metadata`` menu item to populate
* Navigate to your datasources
More screenshots
----------------
![img](http://i.imgur.com/Rt6gNQ9.png)
![img](http://i.imgur.com/t7VOtqQ.png)
![img](http://i.imgur.com/PaiFQnH.png)
![img](http://i.imgur.com/CdcGHuC.png)

21
TODO.md
View File

@@ -1,7 +1,26 @@
# TODO
* Add a per-datasource permission
* in/notin filters autocomplete
* DRUID: Allow for post aggregations (ratios!)
* compare time ranges
* csv export out of table view
* Save / bookmark / url shortener
* SQL: Find a way to manage granularity
* Create ~/.panoramix/ to host DB and config, generate default config there
* Reintroduce query and stopwatch
* Sort tooltip
* Make "Test Connection" test further
* Consistent colors for same entities
* Contribution to total
* Arbitrary expressions
* Group bucketing
* ToT
* Layers
## Test
* Line types
* Intelligence around series name
* Shapes
* Line highlighting - draw attention
## Bug

View File

@@ -1,25 +1,30 @@
import logging
import os
from flask import Flask
from flask.ext.appbuilder import SQLA, AppBuilder, IndexView
from flask.ext.migrate import Migrate
from panoramix import config
"""
Logging configuration
"""
APP_DIR = os.path.dirname(__file__)
CONFIG_MODULE = os.environ.get('PANORAMIX_CONFIG', 'panoramix.config')
# Logging configuration
logging.basicConfig(format='%(asctime)s:%(levelname)s:%(name)s:%(message)s')
logging.getLogger().setLevel(logging.DEBUG)
app = Flask(__name__)
app.config.from_object('panoramix.config')
app.config.from_object(CONFIG_MODULE)
db = SQLA(app)
migrate = Migrate(app, db, directory=APP_DIR + "/migrations")
class MyIndexView(IndexView):
index_template = 'index.html'
appbuilder = AppBuilder(
app, db.session, base_template='panoramix/base.html',
indexview=MyIndexView)
indexview=MyIndexView,
security_manager_class=app.config.get("CUSTOM_SECURITY_MANAGER"))
get_session = appbuilder.get_session
from panoramix import views

View File

View File

@@ -1,19 +1,340 @@
#!/usr/bin/env python
from panoramix import app, config
import csv
import gzip
import json
from subprocess import Popen
from flask.ext.script import Manager
from flask.ext.migrate import MigrateCommand
from panoramix import db
from sqlalchemy import Column, Integer, String, Table
if __name__ == "__main__":
if config.DEBUG:
from panoramix import app
from panoramix import models
config = app.config
manager = Manager(app)
manager.add_command('db', MigrateCommand)
from flask.ext.appbuilder import Base
@manager.option(
'-d', '--debug', action='store_true',
help="Start the web server in debug mode")
@manager.option(
'-p', '--port', default=config.get("PANORAMIX_WEBSERVER_PORT"),
help="Specify the port on which to run the web server")
def runserver(debug, port):
"""Starts a Panoramix web server"""
debug = debug or config.get("DEBUG")
if debug:
app.run(
host='0.0.0.0',
port=int(config.PANORAMIX_WEBSERVER_PORT),
port=int(port),
debug=True)
else:
cmd = (
"gunicorn "
"-w 8 "
"-b 0.0.0.0:{config.PANORAMIX_WEBSERVER_PORT} "
"-b 0.0.0.0:{port} "
"panoramix:app").format(**locals())
print("Starting server with command: " + cmd)
Popen(cmd, shell=True).wait()
@manager.option(
'-s', '--sample', action='store_true',
help="Only load 1000 rows (faster, used for testing)")
def load_examples(sample):
"""Loads a set of Slices and Dashboards and a supporting dataset """
print("Loading examples into {}".format(db))
BirthNames = Table(
"birth_names", Base.metadata,
Column("id", Integer, primary_key=True),
Column("state", String(10)),
Column("year", Integer),
Column("name", String(128)),
Column("num", Integer),
Column("ds", String(20)),
Column("gender", String(10)),
)
try:
BirthNames.drop(db.engine)
except:
pass
BirthNames.create(db.engine)
session = db.session()
with gzip.open(config.get("BASE_DIR") + '/data/birth_names.csv.gz') as f:
bb_csv = csv.reader(f)
for i, (state, year, name, gender, num) in enumerate(bb_csv):
if i == 0:
continue
if num == "NA":
num = 0
ds = str(year) + '-01-01'
db.engine.execute(
BirthNames.insert(),
state=state,
year=year,
ds=ds,
name=name, num=num, gender=gender)
if i % 5000 == 0:
print("{} loaded out of 82527 rows".format(i))
session.commit()
session.commit()
if sample and i>1000: break
print("Done loading table!")
print("-" * 80)
print("Creating database reference")
DB = models.Database
dbobj = session.query(DB).filter_by(database_name='main').first()
if not dbobj:
dbobj = DB(database_name="main")
print config.get("SQLALCHEMY_DATABASE_URI")
dbobj.sqlalchemy_uri = config.get("SQLALCHEMY_DATABASE_URI")
session.add(dbobj)
session.commit()
print("Creating table reference")
TBL = models.Table
obj = session.query(TBL).filter_by(table_name='birth_names').first()
if not obj:
obj = TBL(table_name = 'birth_names')
obj.main_dttm_col = 'ds'
obj.default_endpoint = "/panoramix/datasource/table/1/?viz_type=table&granularity=one+day&since=100+years&until=now&row_limit=10&where=&flt_col_0=ds&flt_op_0=in&flt_eq_0=&flt_col_1=ds&flt_op_1=in&flt_eq_1=&slice_name=TEST&datasource_name=birth_names&datasource_id=1&datasource_type=table"
obj.database = dbobj
obj.columns = [models.TableColumn(
column_name="num", sum=True, type="INTEGER")]
models.Table
session.add(obj)
session.commit()
obj.fetch_metadata()
tbl = obj
print("Creating some slices")
def get_slice_json(slice_name, **kwargs):
defaults = {
"compare_lag": "10",
"compare_suffix": "o10Y",
"datasource_id": "1",
"datasource_name": "birth_names",
"datasource_type": "table",
"limit": "25",
"flt_col_1": "gender",
"flt_eq_1": "",
"flt_op_1": "in",
"granularity": "all",
"groupby": [],
"metric": 'sum__num',
"metrics": ["sum__num"],
"row_limit": config.get("ROW_LIMIT"),
"since": "100 years",
"slice_name": slice_name,
"until": "now",
"viz_type": "table",
"where": "",
"markup_type": "markdown",
}
d = defaults.copy()
d.update(kwargs)
return json.dumps(d, indent=4, sort_keys=True)
Slice = models.Slice
slices = []
slice_name = "Girls"
slc = session.query(Slice).filter_by(slice_name=slice_name).first()
if not slc:
slc = Slice(
slice_name=slice_name,
viz_type='table',
datasource_type='table',
table=tbl,
params=get_slice_json(
slice_name, groupby=['name'], flt_eq_1="girl", row_limit=50))
session.add(slc)
slices.append(slc)
slice_name = "Boys"
slc = session.query(Slice).filter_by(slice_name=slice_name).first()
if not slc:
slc = Slice(
slice_name=slice_name,
viz_type='table',
datasource_type='table',
table=tbl,
params=get_slice_json(
slice_name, groupby=['name'], flt_eq_1="boy", row_limit=50))
session.add(slc)
slices.append(slc)
slice_name = "Participants"
slc = session.query(Slice).filter_by(slice_name=slice_name).first()
if not slc:
slc = Slice(
slice_name=slice_name,
viz_type='big_number',
datasource_type='table',
table=tbl,
params=get_slice_json(
slice_name, viz_type="big_number", granularity="1 day",
compare_lag="5", compare_suffix="over 5Y"))
session.add(slc)
slices.append(slc)
slice_name = "Genders"
slc = session.query(Slice).filter_by(slice_name=slice_name).first()
if not slc:
slc = Slice(
slice_name=slice_name,
viz_type='pie',
datasource_type='table',
table=tbl,
params=get_slice_json(
slice_name, viz_type="pie", groupby=['gender']))
session.add(slc)
slices.append(slc)
slice_name = "States"
slc = session.query(Slice).filter_by(slice_name=slice_name).first()
if not slc:
slc = Slice(
slice_name=slice_name,
viz_type='dist_bar',
datasource_type='table',
table=tbl,
params=get_slice_json(
slice_name, flt_eq_1="other", viz_type="dist_bar",
groupby=['state'], flt_op_1='not in', flt_col_1='state'))
session.add(slc)
slices.append(slc)
slice_name = "Trends"
slc = session.query(Slice).filter_by(slice_name=slice_name).first()
if not slc:
slc = Slice(
slice_name=slice_name,
viz_type='line',
datasource_type='table',
table=tbl,
params=get_slice_json(
slice_name, viz_type="line", groupby=['name'],
granularity='1 day'))
session.add(slc)
slices.append(slc)
slice_name = "Title"
slc = session.query(Slice).filter_by(slice_name=slice_name).first()
code = """
### Birth Names Dashboard
The source dataset came from [here](https://github.com/hadley/babynames)
![img](http://monblog.system-linux.net/image/tux/baby-tux_overlord59-tux.png)
"""
if not slc:
slc = Slice(
slice_name=slice_name,
viz_type='markup',
datasource_type='table',
table=tbl,
params=get_slice_json(
slice_name, viz_type="markup", markup_type="markdown",
code=code))
session.add(slc)
slices.append(slc)
slice_name = "Name Cloud"
slc = session.query(Slice).filter_by(slice_name=slice_name).first()
if not slc:
slc = Slice(
slice_name=slice_name,
viz_type='word_cloud',
datasource_type='table',
table=tbl,
params=get_slice_json(
slice_name, viz_type="word_cloud", size_from="10",
groupby=['name'], size_to="70", rotation="square",
limit='100'))
session.add(slc)
slices.append(slc)
print("Creating a dashboard")
Dash = models.Dashboard
dash = session.query(Dash).filter_by(dashboard_title="Births").first()
if not dash:
dash = Dash(
dashboard_title="Births",
position_json="""
[
{
"size_y": 4,
"size_x": 2,
"col": 3,
"slice_id": "1",
"row": 3
},
{
"size_y": 4,
"size_x": 2,
"col": 1,
"slice_id": "2",
"row": 3
},
{
"size_y": 2,
"size_x": 2,
"col": 1,
"slice_id": "3",
"row": 1
},
{
"size_y": 2,
"size_x": 2,
"col": 3,
"slice_id": "4",
"row": 1
},
{
"size_y": 3,
"size_x": 7,
"col": 5,
"slice_id": "5",
"row": 4
},
{
"size_y": 5,
"size_x": 11,
"col": 1,
"slice_id": "6",
"row": 7
},
{
"size_y": 3,
"size_x": 3,
"col": 9,
"slice_id": "7",
"row": 1
},
{
"size_y": 3,
"size_x": 4,
"col": 5,
"slice_id": "8",
"row": 1
}
]
"""
)
session.add(dash)
for s in slices:
dash.slices.append(s)
session.commit()
if __name__ == "__main__":
manager.run()

View File

@@ -1,6 +1,9 @@
import os
from flask_appbuilder.security.manager import AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH
basedir = os.path.abspath(os.path.dirname(__file__))
from flask_appbuilder.security.manager import AUTH_DB
# from flask_appbuilder.security.manager import (
# AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH)
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
from dateutil import tz
"""
All configuration in this file can be overridden by providing a local_config
@@ -9,41 +12,49 @@ in your PYTHONPATH.
There' a ``from local_config import *`` at the end of this file.
"""
#---------------------------------------------------------
# ---------------------------------------------------------
# Panoramix specifix config
#---------------------------------------------------------
ROW_LIMIT = 5000
# ---------------------------------------------------------
ROW_LIMIT = 50000
WEBSERVER_THREADS = 8
PANORAMIX_WEBSERVER_PORT = 8088
#---------------------------------------------------------
CUSTOM_SECURITY_MANAGER = None
# ---------------------------------------------------------
# Your App secret key
SECRET_KEY = '\2\1thisismyscretkey\1\2\e\y\y\h'
# The SQLAlchemy connection string.
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db')
#SQLALCHEMY_DATABASE_URI = 'mysql://myapp@localhost/myapp'
#SQLALCHEMY_DATABASE_URI = 'postgresql://root:password@localhost/myapp'
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/panoramix.db'
# SQLALCHEMY_DATABASE_URI = 'mysql://myapp@localhost/myapp'
# SQLALCHEMY_DATABASE_URI = 'postgresql://root:password@localhost/myapp'
# Flask-WTF flag for CSRF
CSRF_ENABLED = True
#Whether to run the web server in debug mode or not
# Whether to run the web server in debug mode or not
DEBUG = True
#------------------------------
# ------------------------------
# GLOBALS FOR APP Builder
#------------------------------
# ------------------------------
# Uncomment to setup Your App name
APP_NAME = "Panoramix"
# Uncomment to setup Setup an App icon
APP_ICON = "/static/chaudron_white.png"
#----------------------------------------------------
# Druid query timezone
# tz.tzutc() : Using utc timezone
# tz.tzlocal() : Using local timezone
# other tz can be overridden by providing a local_config
DRUID_TZ = tz.tzutc()
# ----------------------------------------------------
# AUTHENTICATION CONFIG
#----------------------------------------------------
# ----------------------------------------------------
# The authentication type
# AUTH_OID : Is for OpenID
# AUTH_DB : Is for database (username/password()
@@ -52,37 +63,37 @@ APP_ICON = "/static/chaudron_white.png"
AUTH_TYPE = AUTH_DB
# Uncomment to setup Full admin role name
#AUTH_ROLE_ADMIN = 'Admin'
# AUTH_ROLE_ADMIN = 'Admin'
# Uncomment to setup Public role name, no authentication needed
#AUTH_ROLE_PUBLIC = 'Public'
# AUTH_ROLE_PUBLIC = 'Public'
# Will allow user self registration
#AUTH_USER_REGISTRATION = True
# AUTH_USER_REGISTRATION = True
# The default user self registration role
#AUTH_USER_REGISTRATION_ROLE = "Public"
# AUTH_USER_REGISTRATION_ROLE = "Public"
# When using LDAP Auth, setup the ldap server
#AUTH_LDAP_SERVER = "ldap://ldapserver.new"
# AUTH_LDAP_SERVER = "ldap://ldapserver.new"
# Uncomment to setup OpenID providers example for OpenID authentication
#OPENID_PROVIDERS = [
# OPENID_PROVIDERS = [
# { 'name': 'Yahoo', 'url': 'https://me.yahoo.com' },
# { 'name': 'AOL', 'url': 'http://openid.aol.com/<username>' },
# { 'name': 'Flickr', 'url': 'http://www.flickr.com/<username>' },
# { 'name': 'MyOpenID', 'url': 'https://www.myopenid.com' }]
#---------------------------------------------------
# ---------------------------------------------------
# Babel config for translations
#---------------------------------------------------
# ---------------------------------------------------
# Setup default language
BABEL_DEFAULT_LOCALE = 'en'
# Your application default translation path
BABEL_DEFAULT_FOLDER = 'translations'
# The allowed translation for you app
LANGUAGES = {
'en': {'flag':'us', 'name':'English'},
'fr': {'flag':'fr', 'name':'French'},
'en': {'flag': 'us', 'name': 'English'},
'fr': {'flag': 'fr', 'name': 'French'},
}
"""
'pt': {'flag':'pt', 'name':'Portuguese'},
@@ -92,36 +103,39 @@ LANGUAGES = {
'zh': {'flag':'cn', 'name':'Chinese'},
'ru': {'flag':'ru', 'name':'Russian'}
"""
#---------------------------------------------------
# ---------------------------------------------------
# Image and file configuration
#---------------------------------------------------
# ---------------------------------------------------
# The file upload folder, when using models with files
UPLOAD_FOLDER = basedir + '/app/static/uploads/'
UPLOAD_FOLDER = BASE_DIR + '/app/static/uploads/'
# The image upload folder, when using models with images
IMG_UPLOAD_FOLDER = basedir + '/app/static/uploads/'
IMG_UPLOAD_FOLDER = BASE_DIR + '/app/static/uploads/'
# The image upload url, when using models with images
IMG_UPLOAD_URL = '/static/uploads/'
# Setup image size default is (300, 200, True)
#IMG_SIZE = (300, 200, True)
# IMG_SIZE = (300, 200, True)
# ---------------------------------------------------
# Theme configuration
# these are located on static/appbuilder/css/themes
# you can create your own and easily use them placing them on the same dir structure to override
#APP_THEME = "bootstrap-theme.css" # default bootstrap
#APP_THEME = "cerulean.css"
#APP_THEME = "amelia.css"
#APP_THEME = "cosmo.css"
#APP_THEME = "cyborg.css"
#APP_THEME = "flatly.css"
#APP_THEME = "journal.css"
#APP_THEME = "readable.css"
#APP_THEME = "simplex.css"
#APP_THEME = "slate.css"
#APP_THEME = "spacelab.css"
#APP_THEME = "united.css"
#APP_THEME = "yeti.css"
# you can create your own and easily use them placing them on the
# same dir structure to override
# ---------------------------------------------------
# APP_THEME = "bootstrap-theme.css" # default bootstrap
# APP_THEME = "cerulean.css"
# APP_THEME = "amelia.css"
# APP_THEME = "cosmo.css"
# APP_THEME = "cyborg.css"
# APP_THEME = "flatly.css"
# APP_THEME = "journal.css"
# APP_THEME = "readable.css"
# APP_THEME = "simplex.css"
# APP_THEME = "slate.css"
# APP_THEME = "spacelab.css"
# APP_THEME = "united.css"
# APP_THEME = "yeti.css"
try:
from panoramix_config import *

View File

Binary file not shown.

159
panoramix/forms.py Normal file
View File

@@ -0,0 +1,159 @@
from wtforms import (
Field, Form, SelectMultipleField, SelectField, TextField, TextAreaField,
BooleanField)
from copy import copy
class OmgWtForm(Form):
field_order = tuple()
css_classes = dict()
@property
def fields(self):
fields = []
for field in self.field_order:
if hasattr(self, field):
obj = getattr(self, field)
if isinstance(obj, Field):
fields.append(getattr(self, field))
return fields
def get_field(self, fieldname):
return getattr(self, fieldname)
def field_css_classes(self, fieldname):
if fieldname in self.css_classes:
return " ".join(self.css_classes[fieldname])
return ""
def form_factory(viz):
datasource = viz.datasource
from panoramix.viz import viz_types
row_limits = [10, 50, 100, 500, 1000, 5000, 10000]
series_limits = [0, 5, 10, 25, 50, 100, 500]
group_by_choices = [(s, s) for s in datasource.groupby_column_names]
# Pool of all the fields that can be used in Panoramix
px_form_fields = {
'viz_type': SelectField(
'Viz',
choices=[(k, v.verbose_name) for k, v in viz_types.items()],
description="The type of visualization to display"),
'metrics': SelectMultipleField(
'Metrics', choices=datasource.metrics_combo,
description="One or many metrics to display"),
'metric': SelectField(
'Metric', choices=datasource.metrics_combo,
description="One or many metrics to display"),
'groupby': SelectMultipleField(
'Group by',
choices=[(s, s) for s in datasource.groupby_column_names],
description="One or many fields to group by"),
'granularity': TextField(
'Time Granularity', default="one day",
description=(
"The time granularity for the visualization. Note that you "
"can type and use simple natural language as in '10 seconds', "
"'1 day' or '56 weeks'")),
'since': TextField(
'Since', default="one day ago", description=(
"Timestamp from filter. This supports free form typing and "
"natural language as in '1 day ago', '28 days' or '3 years'")),
'until': TextField('Until', default="now"),
'row_limit':
SelectField(
'Row limit', choices=[(s, s) for s in row_limits]),
'limit':
SelectField(
'Series limit', choices=[(s, s) for s in series_limits],
description=(
"Limits the number of time series that get displayed")),
'rolling_type': SelectField(
'Rolling',
choices=[(s, s) for s in ['mean', 'sum', 'std']],
description=(
"Defines a rolling window function to apply")),
'rolling_periods': TextField('Periods', description=(
"Defines the size of the rolling window function, "
"relative to the 'granularity' field")),
'series': SelectField('Series', choices=group_by_choices),
'entity': SelectField('Entity', choices=group_by_choices),
'x': SelectField('X Axis', choices=datasource.metrics_combo),
'y': SelectField('Y Axis', choices=datasource.metrics_combo),
'size': SelectField('Bubble Size', choices=datasource.metrics_combo),
'where': TextField('Custom WHERE clause'),
'compare_lag': TextField('Comparison Period Lag',
description="Based on granularity, number of time periods to compare against"),
'compare_suffix': TextField('Comparison suffix',
description="Suffix to apply after the percentage display"),
'markup_type': SelectField(
"Markup Type",
choices=[(s, s) for s in ['markdown', 'html']],
default="markdown",
description="Pick your favorite markup language"),
'rotation': SelectField(
"Rotation",
choices=[(s, s) for s in ['random', 'flat', 'square']],
default="random",
description="Rotation to apply to words in the cloud"),
'code': TextAreaField("Code", description="Put your code here"),
'size_from': TextField(
"Font Size From",
default="20",
description="Font size for the smallest value in the list"),
'size_to': TextField(
"Font Size To",
default="150",
description="Font size for the biggest value in the list"),
'show_brush': BooleanField(
"Range Selector", default=True,
description="Whether to display the time range interactive selector"),
'show_legend': BooleanField(
"Legend", default=True,
description="Whether to display the legend (toggles)"),
'rich_tooltip': BooleanField(
"Rich Tooltip", default=True,
description="The rich tooltip shows a list of all series for that point in time"),
'y_axis_zero': BooleanField(
"Y Axis Zero", default=False,
description="Force the Y axis to start at 0 instead of the minimum value"),
'y_log_scale': BooleanField(
"Y Log", default=False,
description="Use a log scale for the Y axis"),
}
field_css_classes = {k: ['form-control'] for k in px_form_fields.keys()}
select2 = [
'viz_type', 'metrics', 'groupby',
'row_limit', 'rolling_type', 'series',
'entity', 'x', 'y', 'size', 'rotation', 'metric', 'limit',
'markup_type',]
field_css_classes['since'] += ['select2_free_since']
field_css_classes['until'] += ['select2_free_until']
field_css_classes['granularity'] += ['select2_free_granularity']
for field in ('show_brush', 'show_legend', 'rich_tooltip'):
field_css_classes[field] += ['input-sm']
for field in select2:
field_css_classes[field] += ['select2']
class QueryForm(OmgWtForm):
field_order = copy(viz.form_fields)
css_classes = field_css_classes
for i in range(10):
setattr(QueryForm, 'flt_col_' + str(i), SelectField(
'Filter 1',
choices=[(s, s) for s in datasource.filterable_column_names]))
setattr(QueryForm, 'flt_op_' + str(i), SelectField(
'Filter 1', choices=[(m, m) for m in ['in', 'not in']]))
setattr(QueryForm, 'flt_eq_' + str(i), TextField("Super"))
for ff in viz.form_fields:
if isinstance(ff, basestring):
ff = [ff]
for s in ff:
setattr(QueryForm, s, px_form_fields[s])
# datasource type specific form elements
if datasource.__class__.__name__ == 'Table':
QueryForm.field_order += ['where']
setattr(QueryForm, 'where', px_form_fields['where'])
return QueryForm

View File

@@ -1,7 +1,5 @@
import pandas
from collections import defaultdict
import copy
import json
from pandas.io.json import dumps
@@ -9,16 +7,21 @@ class BaseHighchart(object):
stockchart = False
tooltip_formatter = ""
target_div = 'chart'
@property
def javascript_cmd(self):
def json(self):
js = dumps(self.chart)
js = (
return (
js.replace('"{{TOOLTIP_FORMATTER}}"', self.tooltip_formatter)
.replace("\n", " ")
)
@property
def javascript_cmd(self):
js = self.json
if self.stockchart:
return "new Highcharts.StockChart(%s);" % js
return "new Highcharts.Chart(%s);" %js
return "new Highcharts.Chart(%s);" % js
class Highchart(BaseHighchart):
@@ -84,7 +87,7 @@ class Highchart(BaseHighchart):
if sort_legend_y:
if 'tooltip' not in chart:
chart['tooltip'] = {
'formatter': "{{TOOLTIP_FORMATTER}}"
#'formatter': "{{TOOLTIP_FORMATTER}}"
}
if self.zoom:
chart["zoomType"] = self.zoom
@@ -127,7 +130,6 @@ class Highchart(BaseHighchart):
"""
return tf
def serialize_series(self):
df = self.df
chart = self.chart
@@ -140,7 +142,8 @@ class Highchart(BaseHighchart):
continue
sec = name in self.secondary_y
d = {
"name": name if not sec or self.mark_right else name + " (right)",
"name":
name if not sec or self.mark_right else name + " (right)",
"yAxis": int(sec),
"data": zip(df.index, data.tolist())
}
@@ -150,8 +153,6 @@ class Highchart(BaseHighchart):
d['compare'] = self.compare # either `value` or `percent`
if self.chart_type in ("area", "column", "bar") and self.stacked:
d["stacking"] = 'normal'
#if kwargs.get("style"):
# d["dashStyle"] = pd2hc_linestyle(kwargs["style"].get(name, "-"))
chart["series"].append(d)
def serialize_xaxis(self):
@@ -195,7 +196,7 @@ class Highchart(BaseHighchart):
class HighchartBubble(BaseHighchart):
def __init__(self, df, target_div='chart', height=800):
def __init__(self, df, target_div=None, height=None):
self.df = df
self.chart = {
'chart': {
@@ -219,7 +220,6 @@ class HighchartBubble(BaseHighchart):
chart['chart']["height"] = height
def series(self):
#df = self.df[['name', 'x', 'y', 'z']]
df = self.df
series = defaultdict(list)
for row in df.to_dict(orient='records'):

1
panoramix/migrations/README Executable file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

View File

@@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

89
panoramix/migrations/env.py Executable file
View File

@@ -0,0 +1,89 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging
from panoramix import db, models
from flask.ext.appbuilder import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.readthedocs.org/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
#compare_type=True,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,22 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision}
Create Date: ${create_date}
"""
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,215 @@
"""Init
Revision ID: 4e6a06bad7a8
Revises: None
Create Date: 2015-09-21 17:30:38.442998
"""
# revision identifiers, used by Alembic.
revision = '4e6a06bad7a8'
down_revision = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('clusters',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('cluster_name', sa.String(length=250), nullable=True),
sa.Column('coordinator_host', sa.String(length=256), nullable=True),
sa.Column('coordinator_port', sa.Integer(), nullable=True),
sa.Column('coordinator_endpoint', sa.String(length=256), nullable=True),
sa.Column('broker_host', sa.String(length=256), nullable=True),
sa.Column('broker_port', sa.Integer(), nullable=True),
sa.Column('broker_endpoint', sa.String(length=256), nullable=True),
sa.Column('metadata_last_refreshed', sa.DateTime(), nullable=True),
sa.Column('created_by_fk', sa.Integer(), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('cluster_name')
)
op.create_table('dashboards',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dashboard_title', sa.String(length=500), nullable=True),
sa.Column('position_json', sa.Text(), nullable=True),
sa.Column('created_by_fk', sa.Integer(), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('dbs',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('database_name', sa.String(length=250), nullable=True),
sa.Column('sqlalchemy_uri', sa.String(length=1024), nullable=True),
sa.Column('created_by_fk', sa.Integer(), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('database_name')
)
op.create_table('datasources',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('datasource_name', sa.String(length=250), nullable=True),
sa.Column('is_featured', sa.Boolean(), nullable=True),
sa.Column('is_hidden', sa.Boolean(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('default_endpoint', sa.Text(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('cluster_name', sa.String(length=250), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=False),
sa.Column('created_by_fk', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['cluster_name'], ['clusters.cluster_name'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['ab_user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('datasource_name')
)
op.create_table('tables',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('table_name', sa.String(length=250), nullable=True),
sa.Column('main_dttm_col', sa.String(length=250), nullable=True),
sa.Column('default_endpoint', sa.Text(), nullable=True),
sa.Column('database_id', sa.Integer(), nullable=False),
sa.Column('created_by_fk', sa.Integer(), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['database_id'], ['dbs.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('table_name')
)
op.create_table('columns',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('datasource_name', sa.String(length=250), nullable=True),
sa.Column('column_name', sa.String(length=256), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('groupby', sa.Boolean(), nullable=True),
sa.Column('count_distinct', sa.Boolean(), nullable=True),
sa.Column('sum', sa.Boolean(), nullable=True),
sa.Column('max', sa.Boolean(), nullable=True),
sa.Column('min', sa.Boolean(), nullable=True),
sa.Column('filterable', sa.Boolean(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_fk', sa.Integer(), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['datasource_name'], ['datasources.datasource_name'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('metrics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('metric_name', sa.String(length=512), nullable=True),
sa.Column('verbose_name', sa.String(length=1024), nullable=True),
sa.Column('metric_type', sa.String(length=32), nullable=True),
sa.Column('datasource_name', sa.String(length=250), nullable=True),
sa.Column('json', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['datasource_name'], ['datasources.datasource_name'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('slices',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('slice_name', sa.String(length=250), nullable=True),
sa.Column('druid_datasource_id', sa.Integer(), nullable=True),
sa.Column('table_id', sa.Integer(), nullable=True),
sa.Column('datasource_type', sa.String(length=200), nullable=True),
sa.Column('datasource_name', sa.String(length=2000), nullable=True),
sa.Column('viz_type', sa.String(length=250), nullable=True),
sa.Column('params', sa.Text(), nullable=True),
sa.Column('created_by_fk', sa.Integer(), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['druid_datasource_id'], ['datasources.id'], ),
sa.ForeignKeyConstraint(['table_id'], ['tables.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('sql_metrics',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('metric_name', sa.String(length=512), nullable=True),
sa.Column('verbose_name', sa.String(length=1024), nullable=True),
sa.Column('metric_type', sa.String(length=32), nullable=True),
sa.Column('table_id', sa.Integer(), nullable=True),
sa.Column('expression', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_fk', sa.Integer(), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['table_id'], ['tables.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('table_columns',
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('changed_on', sa.DateTime(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('table_id', sa.Integer(), nullable=True),
sa.Column('column_name', sa.String(length=256), nullable=True),
sa.Column('is_dttm', sa.Boolean(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('type', sa.String(length=32), nullable=True),
sa.Column('groupby', sa.Boolean(), nullable=True),
sa.Column('count_distinct', sa.Boolean(), nullable=True),
sa.Column('sum', sa.Boolean(), nullable=True),
sa.Column('max', sa.Boolean(), nullable=True),
sa.Column('min', sa.Boolean(), nullable=True),
sa.Column('filterable', sa.Boolean(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_fk', sa.Integer(), nullable=True),
sa.Column('changed_by_fk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['changed_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['created_by_fk'], ['ab_user.id'], ),
sa.ForeignKeyConstraint(['table_id'], ['tables.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('dashboard_slices',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dashboard_id', sa.Integer(), nullable=True),
sa.Column('slice_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['dashboard_id'], ['dashboards.id'], ),
sa.ForeignKeyConstraint(['slice_id'], ['slices.id'], ),
sa.PrimaryKeyConstraint('id')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('dashboard_slices')
op.drop_table('table_columns')
op.drop_table('sql_metrics')
op.drop_table('slices')
op.drop_table('metrics')
op.drop_table('columns')
op.drop_table('tables')
op.drop_table('datasources')
op.drop_table('dbs')
op.drop_table('dashboards')
op.drop_table('clusters')
### end Alembic commands ###

View File

@@ -1,32 +1,161 @@
from flask.ext.appbuilder import Model
from datetime import timedelta
from flask.ext.appbuilder.models.mixins import AuditMixin
from flask import request, redirect, flash, Response
from sqlalchemy import Column, Integer, String, ForeignKey, Text, Boolean, DateTime
from sqlalchemy import create_engine, MetaData, desc
from sqlalchemy import Table as sqlaTable
from sqlalchemy.orm import relationship
from dateutil.parser import parse
from flask import flash
from flask.ext.appbuilder import Model
from flask.ext.appbuilder.models.mixins import AuditMixin
from pandas import read_sql_query
from pydruid import client
from pydruid.utils.filters import Dimension, Filter
from pandas import read_sql_query
from sqlalchemy.sql import table, literal_column
from sqlalchemy import select, and_, text, String
from sqlalchemy import (
Column, Integer, String, ForeignKey, Text, Boolean, DateTime)
from sqlalchemy import Table as sqlaTable
from sqlalchemy import create_engine, MetaData, desc, select, and_, Table
from sqlalchemy.orm import relationship
from sqlalchemy.sql import table, literal_column, text
from flask import request
from copy import deepcopy, copy
from collections import namedtuple
from datetime import datetime
import logging
import json
import sqlparse
import requests
import textwrap
from panoramix import db, get_session
from panoramix import app, db, get_session, utils
from panoramix.viz import viz_types
from sqlalchemy.ext.declarative import declared_attr
config = app.config
QueryResult = namedtuple('namedtuple', ['df', 'query', 'duration'])
class AuditMixinNullable(AuditMixin):
@declared_attr
def created_by_fk(cls):
return Column(Integer, ForeignKey('ab_user.id'),
default=cls.get_user_id, nullable=True)
@declared_attr
def changed_by_fk(cls):
return Column(Integer, ForeignKey('ab_user.id'),
default=cls.get_user_id, onupdate=cls.get_user_id, nullable=True)
class Slice(Model, AuditMixinNullable):
"""A slice is essentially a report or a view on data"""
__tablename__ = 'slices'
id = Column(Integer, primary_key=True)
slice_name = Column(String(250))
druid_datasource_id = Column(Integer, ForeignKey('datasources.id'))
table_id = Column(Integer, ForeignKey('tables.id'))
datasource_type = Column(String(200))
datasource_name = Column(String(2000))
viz_type = Column(String(250))
params = Column(Text)
table = relationship(
'Table', foreign_keys=[table_id], backref='slices')
druid_datasource = relationship(
'Datasource', foreign_keys=[druid_datasource_id], backref='slices')
def __repr__(self):
return self.slice_name
@property
def datasource(self):
return self.table or self.druid_datasource
@property
@utils.memoized
def viz(self):
d = json.loads(self.params)
viz = viz_types[self.viz_type](
self.datasource,
form_data=d)
return viz
@property
def datasource_id(self):
datasource = self.datasource
return datasource.id if datasource else None
@property
def slice_url(self):
try:
d = json.loads(self.params)
except Exception as e:
d = {}
from werkzeug.urls import Href
href = Href(
"/panoramix/datasource/{self.datasource_type}/"
"{self.datasource_id}/".format(self=self))
return href(d)
@property
def edit_url(self):
return "/slicemodelview/edit/{}".format(self.id)
@property
def slice_link(self):
url = self.slice_url
return '<a href="{url}">{self.slice_name}</a>'.format(**locals())
@property
def js_files(self):
from panoramix.viz import viz_types
return viz_types[self.viz_type].js_files
@property
def css_files(self):
from panoramix.viz import viz_types
return viz_types[self.viz_type].css_files
def get_viz(self):
pass
dashboard_slices = Table('dashboard_slices', Model.metadata,
Column('id', Integer, primary_key=True),
Column('dashboard_id', Integer, ForeignKey('dashboards.id')),
Column('slice_id', Integer, ForeignKey('slices.id')),
)
class Dashboard(Model, AuditMixinNullable):
"""A dash to slash"""
__tablename__ = 'dashboards'
id = Column(Integer, primary_key=True)
dashboard_title = Column(String(500))
position_json = Column(Text)
slices = relationship(
'Slice', secondary=dashboard_slices, backref='dashboards')
def __repr__(self):
return self.dashboard_title
@property
def url(self):
return "/panoramix/dashboard/{}/".format(self.id)
def dashboard_link(self):
return '<a href="{self.url}">{self.dashboard_title}</a>'.format(self=self)
@property
def js_files(self):
l = []
for o in self.slices:
l += [f for f in o.js_files if f not in l]
return l
@property
def css_files(self):
l = []
for o in self.slices:
l += o.css_files
return list(set(l))
class Queryable(object):
@property
def column_names(self):
@@ -40,13 +169,13 @@ class Queryable(object):
def filterable_column_names(self):
return sorted([c.column_name for c in self.columns if c.filterable])
class Database(Model, AuditMixin):
class Database(Model, AuditMixinNullable):
__tablename__ = 'dbs'
id = Column(Integer, primary_key=True)
database_name = Column(String(255), unique=True)
database_name = Column(String(250), unique=True)
sqlalchemy_uri = Column(String(1024))
def __repr__(self):
return self.database_name
@@ -61,13 +190,13 @@ class Database(Model, AuditMixin):
autoload_with=self.get_sqla_engine())
class Table(Model, Queryable, AuditMixin):
class Table(Model, Queryable, AuditMixinNullable):
type = "table"
__tablename__ = 'tables'
id = Column(Integer, primary_key=True)
table_name = Column(String(255), unique=True)
main_datetime_column_id = Column(Integer, ForeignKey('table_columns.id'))
main_datetime_column = relationship(
'TableColumn', foreign_keys=[main_datetime_column_id])
table_name = Column(String(250), unique=True)
main_dttm_col = Column(String(250))
default_endpoint = Column(Text)
database_id = Column(Integer, ForeignKey('dbs.id'), nullable=False)
database = relationship(
@@ -75,13 +204,16 @@ class Table(Model, Queryable, AuditMixin):
baselink = "tableview"
def __repr__(self):
return self.table_name
@property
def name(self):
return self.table_name
@property
def table_link(self):
url = "/panoramix/table/{}/".format(self.id)
url = "/panoramix/datasource/{self.type}/{self.id}/".format(self=self)
return '<a href="{url}">{self.table_name}</a>'.format(**locals())
@property
@@ -99,7 +231,9 @@ class Table(Model, Queryable, AuditMixin):
limit_spec=None,
filter=None,
is_timeseries=True,
timeseries_limit=15, row_limit=None):
timeseries_limit=15,
row_limit=None,
extras=None):
"""
Unused, legacy way of querying by building a SQL string without
using the sqlalchemy expression API (new approach which supports
@@ -114,7 +248,9 @@ class Table(Model, Queryable, AuditMixin):
to_dttm_iso = to_dttm.isoformat()
if metrics:
main_metric_expr = [m.expression for m in self.metrics if m.metric_name == metrics[0]][0]
main_metric_expr = [
m.expression for m in self.metrics
if m.metric_name == metrics[0]][0]
else:
main_metric_expr = "COUNT(*)"
@@ -149,29 +285,30 @@ class Table(Model, Queryable, AuditMixin):
on_clause = " AND ".join(["{g} = __{g}".format(g=g) for g in groupby])
limiting_join = ""
if timeseries_limit and groupby:
inner_select = ", ".join(["{g} as __{g}".format(g=g) for g in inner_groupby_exprs])
inner_select = ", ".join([
"{g} as __{g}".format(g=g) for g in inner_groupby_exprs])
inner_groupby_exprs = ", ".join(inner_groupby_exprs)
limiting_join = (
"JOIN ( \n"
" SELECT {inner_select} \n"
" FROM {self.table_name} \n"
" WHERE \n"
" {where_clause}\n"
" GROUP BY {inner_groupby_exprs}\n"
" ORDER BY {main_metric_expr} DESC\n"
" LIMIT {timeseries_limit}\n"
") z ON {on_clause}\n"
"JOIN ( \n"
" SELECT {inner_select} \n"
" FROM {self.table_name} \n"
" WHERE \n"
" {where_clause}\n"
" GROUP BY {inner_groupby_exprs}\n"
" ORDER BY {main_metric_expr} DESC\n"
" LIMIT {timeseries_limit}\n"
") z ON {on_clause}\n"
).format(**locals())
sql = (
"SELECT\n"
" {select_exprs}\n"
"FROM {self.table_name}\n"
"{limiting_join}"
"WHERE\n"
" {where_clause}\n"
"GROUP BY\n"
" {groupby_exprs}\n"
"SELECT\n"
" {select_exprs}\n"
"FROM {self.table_name}\n"
"{limiting_join}"
"WHERE\n"
" {where_clause}\n"
"GROUP BY\n"
" {groupby_exprs}\n"
).format(**locals())
df = read_sql_query(
sql=sql,
@@ -189,18 +326,23 @@ class Table(Model, Queryable, AuditMixin):
limit_spec=None,
filter=None,
is_timeseries=True,
timeseries_limit=15, row_limit=None):
timeseries_limit=15, row_limit=None,
extras=None):
qry_start_dttm = datetime.now()
if not self.main_dttm_col:
raise Exception(
"Datetime column not provided as part table configuration")
timestamp = literal_column(
self.main_datetime_column.column_name).label('timestamp')
self.main_dttm_col).label('timestamp')
metrics_exprs = [
literal_column(m.expression).label(m.metric_name)
for m in self.metrics if m.metric_name in metrics]
if metrics:
main_metric_expr = literal_column(
[m.expression for m in self.metrics if m.metric_name == metrics[0]][0])
main_metric_expr = literal_column([
m.expression for m in self.metrics
if m.metric_name == metrics[0]][0])
else:
main_metric_expr = literal_column("COUNT(*)")
@@ -210,7 +352,8 @@ class Table(Model, Queryable, AuditMixin):
if groupby:
select_exprs = [literal_column(s) for s in groupby]
groupby_exprs = [literal_column(s) for s in groupby]
inner_groupby_exprs = [literal_column(s).label('__' + s) for s in groupby]
inner_groupby_exprs = [
literal_column(s).label('__' + s) for s in groupby]
if granularity != "all":
select_exprs += [timestamp]
groupby_exprs += [timestamp]
@@ -231,6 +374,8 @@ class Table(Model, Queryable, AuditMixin):
if op == 'not in':
cond = ~cond
where_clause_and.append(cond)
if extras and 'where' in extras:
where_clause_and += [text(extras['where'])]
qry = qry.where(and_(*where_clause_and))
qry = qry.order_by(desc(main_metric_expr))
qry = qry.limit(row_limit)
@@ -244,7 +389,8 @@ class Table(Model, Queryable, AuditMixin):
subq = subq.limit(timeseries_limit)
on_clause = []
for gb in groupby:
on_clause.append(literal_column(gb)==literal_column("__" + gb))
on_clause.append(
literal_column(gb) == literal_column("__" + gb))
from_clause = from_clause.join(subq.alias(), and_(*on_clause))
@@ -260,8 +406,8 @@ class Table(Model, Queryable, AuditMixin):
return QueryResult(
df=df, duration=datetime.now() - qry_start_dttm, query=sql)
def fetch_metadata(self):
table = self.database.get_table(self.table_name)
try:
table = self.database.get_table(self.table_name)
except Exception as e:
@@ -283,8 +429,8 @@ class Table(Model, Queryable, AuditMixin):
dbcol = (
db.session
.query(TC)
.filter(TC.table==self)
.filter(TC.column_name==col.name)
.filter(TC.table == self)
.filter(TC.column_name == col.name)
.first()
)
db.session.flush()
@@ -296,11 +442,13 @@ class Table(Model, Queryable, AuditMixin):
str(datatype).startswith('STRING')):
dbcol.groupby = True
dbcol.filterable = True
elif str(datatype).upper() in ('DOUBLE', 'FLOAT', 'INT', 'BIGINT'):
dbcol.sum = True
db.session.merge(self)
self.columns.append(dbcol)
if not any_date_col and 'date' in datatype.lower():
any_date_col = dbcol
any_date_col = col.name
if dbcol.sum:
metrics.append(M(
@@ -343,34 +491,32 @@ class Table(Model, Queryable, AuditMixin):
for metric in metrics:
m = (
db.session.query(M)
.filter(M.metric_name==metric.metric_name)
.filter(M.table==self)
.filter(M.metric_name == metric.metric_name)
.filter(M.table_id == self.id)
.first()
)
metric.table = self
metric.table_id = self.id
if not m:
db.session.add(metric)
db.session.commit()
if not self.main_datetime_column:
self.main_datetime_column = any_date_col
if not self.main_dttm_col:
self.main_dttm_col = any_date_col
class SqlMetric(Model, AuditMixin):
class SqlMetric(Model, AuditMixinNullable):
__tablename__ = 'sql_metrics'
id = Column(Integer, primary_key=True)
metric_name = Column(String(512))
verbose_name = Column(String(1024))
metric_type = Column(String(32))
table_id = Column(Integer,ForeignKey('tables.id'))
table_id = Column(Integer, ForeignKey('tables.id'))
table = relationship(
'Table', backref='metrics', foreign_keys=[table_id])
expression = Column(Text)
description = Column(Text)
class TableColumn(Model, AuditMixin):
class TableColumn(Model, AuditMixinNullable):
__tablename__ = 'table_columns'
id = Column(Integer, primary_key=True)
table_id = Column(Integer, ForeignKey('tables.id'))
@@ -390,10 +536,15 @@ class TableColumn(Model, AuditMixin):
def __repr__(self):
return self.column_name
class Cluster(Model, AuditMixin):
@property
def isnum(self):
return self.type in ('LONG', 'DOUBLE', 'FLOAT')
class Cluster(Model, AuditMixinNullable):
__tablename__ = 'clusters'
id = Column(Integer, primary_key=True)
cluster_name = Column(String(255), unique=True)
cluster_name = Column(String(250), unique=True)
coordinator_host = Column(String(256))
coordinator_port = Column(Integer)
coordinator_endpoint = Column(String(256))
@@ -423,21 +574,23 @@ class Cluster(Model, AuditMixin):
class Datasource(Model, AuditMixin, Queryable):
type = "druid"
baselink = "datasourcemodelview"
__tablename__ = 'datasources'
id = Column(Integer, primary_key=True)
datasource_name = Column(String(255), unique=True)
datasource_name = Column(String(250), unique=True)
is_featured = Column(Boolean, default=False)
is_hidden = Column(Boolean, default=False)
description = Column(Text)
default_endpoint = Column(Text)
user_id = Column(Integer, ForeignKey('ab_user.id'))
owner = relationship('User', backref='datasources', foreign_keys=[user_id])
cluster_name = Column(String(255),
ForeignKey('clusters.cluster_name'))
cluster = relationship('Cluster', backref='datasources', foreign_keys=[cluster_name])
cluster_name = Column(
String(250), ForeignKey('clusters.cluster_name'))
cluster = relationship(
'Cluster', backref='datasources', foreign_keys=[cluster_name])
@property
def metrics_combo(self):
@@ -454,7 +607,9 @@ class Datasource(Model, AuditMixin, Queryable):
@property
def datasource_link(self):
url = "/panoramix/datasource/{}/".format(self.datasource_name)
url = (
"/panoramix/datasource/"
"{self.type}/{self.id}/").format(self=self)
return '<a href="{url}">{self.datasource_name}</a>'.format(**locals())
def get_metric_obj(self, metric_name):
@@ -512,7 +667,6 @@ class Datasource(Model, AuditMixin, Queryable):
col_obj.type = cols[col]['type']
col_obj.datasource = datasource
col_obj.generate_metrics()
#session.commit()
def query(
self, groupby, metrics,
@@ -522,9 +676,14 @@ class Datasource(Model, AuditMixin, Queryable):
filter=None,
is_timeseries=True,
timeseries_limit=None,
row_limit=None):
row_limit=None,
extras=None):
qry_start_dttm = datetime.now()
# add tzinfo to native datetime with config
from_dttm = from_dttm.replace(tzinfo=config.get("DRUID_TZ"))
to_dttm = to_dttm.replace(tzinfo=config.get("DRUID_TZ"))
query_str = ""
aggregations = {
m.metric_name: m.json_obj
@@ -538,25 +697,25 @@ class Datasource(Model, AuditMixin, Queryable):
dimensions=groupby,
aggregations=aggregations,
granularity=granularity,
intervals= from_dttm.isoformat() + '/' + to_dttm.isoformat(),
intervals=from_dttm.isoformat() + '/' + to_dttm.isoformat(),
)
filters = None
for col, op, eq in filter:
cond = None
if op == '==':
cond = Dimension(col)==eq
cond = Dimension(col) == eq
elif op == '!=':
cond = ~(Dimension(col)==eq)
cond = ~(Dimension(col) == eq)
elif op in ('in', 'not in'):
fields = []
splitted = eq.split(',')
if len(splitted) > 1:
for s in eq.split(','):
s = s.strip()
fields.append(Filter.build_filter(Dimension(col)==s))
fields.append(Filter.build_filter(Dimension(col) == s))
cond = Filter(type="or", fields=fields)
else:
cond = Dimension(col)==eq
cond = Dimension(col) == eq
if op == 'not in':
cond = ~cond
if filters:
@@ -589,7 +748,7 @@ class Datasource(Model, AuditMixin, Queryable):
query_str += json.dumps(client.query_dict, indent=2) + "\n"
query_str += "//\nPhase 2 (built based on phase one's results)\n"
df = client.export_pandas()
if not df is None and not df.empty:
if df is not None and not df.empty:
dims = qry['dimensions']
filters = []
for index, row in df.iterrows():
@@ -630,7 +789,6 @@ class Datasource(Model, AuditMixin, Queryable):
duration=datetime.now() - qry_start_dttm)
#class Metric(Model, AuditMixin):
class Metric(Model):
__tablename__ = 'metrics'
id = Column(Integer, primary_key=True)
@@ -638,7 +796,7 @@ class Metric(Model):
verbose_name = Column(String(1024))
metric_type = Column(String(32))
datasource_name = Column(
String(256),
String(250),
ForeignKey('datasources.datasource_name'))
datasource = relationship('Datasource', backref='metrics')
json = Column(Text)
@@ -648,16 +806,16 @@ class Metric(Model):
def json_obj(self):
try:
obj = json.loads(self.json)
except Exception as e:
except:
obj = {}
return obj
class Column(Model, AuditMixin):
class Column(Model, AuditMixinNullable):
__tablename__ = 'columns'
id = Column(Integer, primary_key=True)
datasource_name = Column(
String(256),
String(250),
ForeignKey('datasources.datasource_name'))
datasource = relationship('Datasource', backref='columns')
column_name = Column(String(256))
@@ -688,11 +846,14 @@ class Column(Model, AuditMixin):
json=json.dumps({'type': 'count', 'name': 'count'})
))
# Somehow we need to reassign this for UDAFs
corrected_type = 'DOUBLE' if self.type in ('DOUBLE', 'FLOAT') else self.type
if self.type in ('DOUBLE', 'FLOAT'):
corrected_type = 'DOUBLE'
else:
corrected_type = self.type
if self.sum and self.isnum:
mt = corrected_type.lower() + 'Sum'
name='sum__' + self.column_name
name = 'sum__' + self.column_name
metrics.append(Metric(
metric_name=name,
metric_type='sum',
@@ -702,7 +863,7 @@ class Column(Model, AuditMixin):
))
if self.min and self.isnum:
mt = corrected_type.lower() + 'Min'
name='min__' + self.column_name
name = 'min__' + self.column_name
metrics.append(Metric(
metric_name=name,
metric_type='min',
@@ -712,7 +873,7 @@ class Column(Model, AuditMixin):
))
if self.max and self.isnum:
mt = corrected_type.lower() + 'Max'
name='max__' + self.column_name
name = 'max__' + self.column_name
metrics.append(Metric(
metric_name=name,
metric_type='max',
@@ -722,7 +883,7 @@ class Column(Model, AuditMixin):
))
if self.count_distinct:
mt = 'count_distinct'
name='count_distinct__' + self.column_name
name = 'count_distinct__' + self.column_name
metrics.append(Metric(
metric_name=name,
verbose_name='COUNT(DISTINCT {})'.format(self.column_name),
@@ -736,9 +897,9 @@ class Column(Model, AuditMixin):
for metric in metrics:
m = (
session.query(M)
.filter(M.metric_name==metric.metric_name)
.filter(M.datasource_name==self.datasource_name)
.filter(Cluster.cluster_name==self.datasource.cluster_name)
.filter(M.metric_name == metric.metric_name)
.filter(M.datasource_name == self.datasource_name)
.filter(Cluster.cluster_name == self.datasource.cluster_name)
.first()
)
metric.datasource_name = self.datasource_name

BIN
panoramix/static/bubble.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

BIN
panoramix/static/cloud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

View File

@@ -0,0 +1,387 @@
// Word cloud layout by Jason Davies, http://www.jasondavies.com/word-cloud/
// Algorithm due to Jonathan Feinberg, http://static.mrfeinberg.com/bv_ch03.pdf
(function() {
if (typeof define === "function" && define.amd) define(["d3"], cloud);
else cloud(this.d3);
function cloud(d3) {
d3.layout.cloud = function cloud() {
var size = [256, 256],
text = cloudText,
font = cloudFont,
fontSize = cloudFontSize,
fontStyle = cloudFontNormal,
fontWeight = cloudFontNormal,
rotate = cloudRotate,
padding = cloudPadding,
spiral = archimedeanSpiral,
words = [],
timeInterval = Infinity,
event = d3.dispatch("word", "end"),
timer = null,
random = Math.random,
cloud = {};
cloud.start = function() {
var board = zeroArray((size[0] >> 5) * size[1]),
bounds = null,
n = words.length,
i = -1,
tags = [],
data = words.map(function(d, i) {
d.text = text.call(this, d, i);
d.font = font.call(this, d, i);
d.style = fontStyle.call(this, d, i);
d.weight = fontWeight.call(this, d, i);
d.rotate = rotate.call(this, d, i);
d.size = ~~fontSize.call(this, d, i);
d.padding = padding.call(this, d, i);
return d;
}).sort(function(a, b) { return b.size - a.size; });
if (timer) clearInterval(timer);
timer = setInterval(step, 0);
step();
return cloud;
function step() {
var start = Date.now();
while (Date.now() - start < timeInterval && ++i < n && timer) {
var d = data[i];
d.x = (size[0] * (random() + .5)) >> 1;
d.y = (size[1] * (random() + .5)) >> 1;
cloudSprite(d, data, i);
if (d.hasText && place(board, d, bounds)) {
tags.push(d);
event.word(d);
if (bounds) cloudBounds(bounds, d);
else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}];
// Temporary hack
d.x -= size[0] >> 1;
d.y -= size[1] >> 1;
}
}
if (i >= n) {
cloud.stop();
event.end(tags, bounds);
}
}
}
cloud.stop = function() {
if (timer) {
clearInterval(timer);
timer = null;
}
return cloud;
};
function place(board, tag, bounds) {
var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}],
startX = tag.x,
startY = tag.y,
maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]),
s = spiral(size),
dt = random() < .5 ? 1 : -1,
t = -dt,
dxdy,
dx,
dy;
while (dxdy = s(t += dt)) {
dx = ~~dxdy[0];
dy = ~~dxdy[1];
if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta) break;
tag.x = startX + dx;
tag.y = startY + dy;
if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 ||
tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue;
// TODO only check for collisions within current bounds.
if (!bounds || !cloudCollide(tag, board, size[0])) {
if (!bounds || collideRects(tag, bounds)) {
var sprite = tag.sprite,
w = tag.width >> 5,
sw = size[0] >> 5,
lx = tag.x - (w << 4),
sx = lx & 0x7f,
msx = 32 - sx,
h = tag.y1 - tag.y0,
x = (tag.y + tag.y0) * sw + (lx >> 5),
last;
for (var j = 0; j < h; j++) {
last = 0;
for (var i = 0; i <= w; i++) {
board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
}
x += sw;
}
delete tag.sprite;
return true;
}
}
}
return false;
}
cloud.timeInterval = function(_) {
return arguments.length ? (timeInterval = _ == null ? Infinity : _, cloud) : timeInterval;
};
cloud.words = function(_) {
return arguments.length ? (words = _, cloud) : words;
};
cloud.size = function(_) {
return arguments.length ? (size = [+_[0], +_[1]], cloud) : size;
};
cloud.font = function(_) {
return arguments.length ? (font = d3.functor(_), cloud) : font;
};
cloud.fontStyle = function(_) {
return arguments.length ? (fontStyle = d3.functor(_), cloud) : fontStyle;
};
cloud.fontWeight = function(_) {
return arguments.length ? (fontWeight = d3.functor(_), cloud) : fontWeight;
};
cloud.rotate = function(_) {
return arguments.length ? (rotate = d3.functor(_), cloud) : rotate;
};
cloud.text = function(_) {
return arguments.length ? (text = d3.functor(_), cloud) : text;
};
cloud.spiral = function(_) {
return arguments.length ? (spiral = spirals[_] || _, cloud) : spiral;
};
cloud.fontSize = function(_) {
return arguments.length ? (fontSize = d3.functor(_), cloud) : fontSize;
};
cloud.padding = function(_) {
return arguments.length ? (padding = d3.functor(_), cloud) : padding;
};
cloud.random = function(_) {
return arguments.length ? (random = _, cloud) : random;
};
return d3.rebind(cloud, event, "on");
};
function cloudText(d) {
return d.text;
}
function cloudFont() {
return "serif";
}
function cloudFontNormal() {
return "normal";
}
function cloudFontSize(d) {
return Math.sqrt(d.value);
}
function cloudRotate() {
return (~~(Math.random() * 6) - 3) * 30;
}
function cloudPadding() {
return 1;
}
// Fetches a monochrome sprite bitmap for the specified text.
// Load in batches for speed.
function cloudSprite(d, data, di) {
if (d.sprite) return;
c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
var x = 0,
y = 0,
maxh = 0,
n = data.length;
--di;
while (++di < n) {
d = data[di];
c.save();
c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font;
var w = c.measureText(d.text + "m").width * ratio,
h = d.size << 1;
if (d.rotate) {
var sr = Math.sin(d.rotate * cloudRadians),
cr = Math.cos(d.rotate * cloudRadians),
wcr = w * cr,
wsr = w * sr,
hcr = h * cr,
hsr = h * sr;
w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5;
h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
} else {
w = (w + 0x1f) >> 5 << 5;
}
if (h > maxh) maxh = h;
if (x + w >= (cw << 5)) {
x = 0;
y += maxh;
maxh = 0;
}
if (y + h >= ch) break;
c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
if (d.rotate) c.rotate(d.rotate * cloudRadians);
c.fillText(d.text, 0, 0);
if (d.padding) c.lineWidth = 2 * d.padding, c.strokeText(d.text, 0, 0);
c.restore();
d.width = w;
d.height = h;
d.xoff = x;
d.yoff = y;
d.x1 = w >> 1;
d.y1 = h >> 1;
d.x0 = -d.x1;
d.y0 = -d.y1;
d.hasText = true;
x += w;
}
var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data,
sprite = [];
while (--di >= 0) {
d = data[di];
if (!d.hasText) continue;
var w = d.width,
w32 = w >> 5,
h = d.y1 - d.y0;
// Zero the buffer
for (var i = 0; i < h * w32; i++) sprite[i] = 0;
x = d.xoff;
if (x == null) return;
y = d.yoff;
var seen = 0,
seenRow = -1;
for (var j = 0; j < h; j++) {
for (var i = 0; i < w; i++) {
var k = w32 * j + (i >> 5),
m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
sprite[k] |= m;
seen |= m;
}
if (seen) seenRow = j;
else {
d.y0++;
h--;
j--;
y++;
}
}
d.y1 = d.y0 + seenRow;
d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
}
}
// Use mask-based collision detection.
function cloudCollide(tag, board, sw) {
sw >>= 5;
var sprite = tag.sprite,
w = tag.width >> 5,
lx = tag.x - (w << 4),
sx = lx & 0x7f,
msx = 32 - sx,
h = tag.y1 - tag.y0,
x = (tag.y + tag.y0) * sw + (lx >> 5),
last;
for (var j = 0; j < h; j++) {
last = 0;
for (var i = 0; i <= w; i++) {
if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0))
& board[x + i]) return true;
}
x += sw;
}
return false;
}
function cloudBounds(bounds, d) {
var b0 = bounds[0],
b1 = bounds[1];
if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0;
if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0;
if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1;
if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1;
}
function collideRects(a, b) {
return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y;
}
function archimedeanSpiral(size) {
var e = size[0] / size[1];
return function(t) {
return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)];
};
}
function rectangularSpiral(size) {
var dy = 4,
dx = dy * size[0] / size[1],
x = 0,
y = 0;
return function(t) {
var sign = t < 0 ? -1 : 1;
// See triangular numbers: T_n = n * (n + 1) / 2.
switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
case 0: x += dx; break;
case 1: y += dy; break;
case 2: x -= dx; break;
default: y -= dy; break;
}
return [x, y];
};
}
// TODO reuse arrays?
function zeroArray(n) {
var a = [],
i = -1;
while (++i < n) a[i] = 0;
return a;
}
var cloudRadians = Math.PI / 180,
cw = 1 << 11 >> 5,
ch = 1 << 11,
canvas,
ratio = 1;
if (typeof document !== "undefined") {
canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2);
canvas.width = (cw << 5) / ratio;
canvas.height = ch / ratio;
} else {
// Attempt to use node-canvas.
canvas = new Canvas(cw << 5, ch);
}
var c = canvas.getContext("2d"),
spirals = {
archimedean: archimedeanSpiral,
rectangular: rectangularSpiral
};
c.fillStyle = c.strokeStyle = "red";
c.textAlign = "center";
}
})();

BIN
panoramix/static/dash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,2 @@
/*! gridster.js - v0.5.6 - 2014-09-25 - * http://gridster.net/ - Copyright (c) 2014 ducksboard; Licensed MIT */
.gridster{position:relative}.gridster>*{margin:0 auto;-webkit-transition:height .4s,width .4s;-moz-transition:height .4s,width .4s;-o-transition:height .4s,width .4s;-ms-transition:height .4s,width .4s;transition:height .4s,width .4s}.gridster .gs-w{z-index:2;position:absolute}.ready .gs-w:not(.preview-holder){-webkit-transition:opacity .3s,left .3s,top .3s;-moz-transition:opacity .3s,left .3s,top .3s;-o-transition:opacity .3s,left .3s,top .3s;transition:opacity .3s,left .3s,top .3s}.ready .gs-w:not(.preview-holder),.ready .resize-preview-holder{-webkit-transition:opacity .3s,left .3s,top .3s,width .3s,height .3s;-moz-transition:opacity .3s,left .3s,top .3s,width .3s,height .3s;-o-transition:opacity .3s,left .3s,top .3s,width .3s,height .3s;transition:opacity .3s,left .3s,top .3s,width .3s,height .3s}.gridster .preview-holder{z-index:1;position:absolute;background-color:#fff;border-color:#fff;opacity:.3}.gridster .player-revert{z-index:10!important;-webkit-transition:left .3s,top .3s!important;-moz-transition:left .3s,top .3s!important;-o-transition:left .3s,top .3s!important;transition:left .3s,top .3s!important}.gridster .dragging,.gridster .resizing{z-index:10!important;-webkit-transition:all 0s!important;-moz-transition:all 0s!important;-o-transition:all 0s!important;transition:all 0s!important}.gs-resize-handle{position:absolute;z-index:1}.gs-resize-handle-both{width:20px;height:20px;bottom:-8px;right:-8px;background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/Pg08IS0tIEdlbmVyYXRvcjogQWRvYmUgRmlyZXdvcmtzIENTNiwgRXhwb3J0IFNWRyBFeHRlbnNpb24gYnkgQWFyb24gQmVhbGwgKGh0dHA6Ly9maXJld29ya3MuYWJlYWxsLmNvbSkgLiBWZXJzaW9uOiAwLjYuMSAgLS0+DTwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DTxzdmcgaWQ9IlVudGl0bGVkLVBhZ2UlMjAxIiB2aWV3Qm94PSIwIDAgNiA2IiBzdHlsZT0iYmFja2dyb3VuZC1jb2xvcjojZmZmZmZmMDAiIHZlcnNpb249IjEuMSINCXhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiDQl4PSIwcHgiIHk9IjBweCIgd2lkdGg9IjZweCIgaGVpZ2h0PSI2cHgiDT4NCTxnIG9wYWNpdHk9IjAuMzAyIj4NCQk8cGF0aCBkPSJNIDYgNiBMIDAgNiBMIDAgNC4yIEwgNCA0LjIgTCA0LjIgNC4yIEwgNC4yIDAgTCA2IDAgTCA2IDYgTCA2IDYgWiIgZmlsbD0iIzAwMDAwMCIvPg0JPC9nPg08L3N2Zz4=);background-position:top left;background-repeat:no-repeat;cursor:se-resize;z-index:20}.gs-resize-handle-x{top:0;bottom:13px;right:-5px;width:10px;cursor:e-resize}.gs-resize-handle-y{left:0;right:13px;bottom:-5px;height:10px;cursor:s-resize}.gs-w:hover .gs-resize-handle,.resizing .gs-resize-handle{opacity:1}.gs-resize-handle,.gs-w.dragging .gs-resize-handle{opacity:0}.gs-resize-disabled .gs-resize-handle{display:none!important}[data-max-sizex="1"] .gs-resize-handle-x,[data-max-sizey="1"] .gs-resize-handle-y,[data-max-sizey="1"][data-max-sizex="1"] .gs-resize-handle{display:none!important}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

643
panoramix/static/nv.d3.css Normal file
View File

@@ -0,0 +1,643 @@
/* nvd3 version 1.8.1 (https://github.com/novus/nvd3) 2015-06-15 */
.nvd3 .nv-axis {
pointer-events:none;
opacity: 1;
}
.nvd3 .nv-axis path {
fill: none;
stroke: #000;
stroke-opacity: .75;
shape-rendering: crispEdges;
}
.nvd3 .nv-axis path.domain {
stroke-opacity: .75;
}
.nvd3 .nv-axis.nv-x path.domain {
stroke-opacity: 0;
}
.nvd3 .nv-axis line {
fill: none;
stroke: #e5e5e5;
shape-rendering: crispEdges;
}
.nvd3 .nv-axis .zero line,
/*this selector may not be necessary*/ .nvd3 .nv-axis line.zero {
stroke-opacity: .75;
}
.nvd3 .nv-axis .nv-axisMaxMin text {
font-weight: bold;
}
.nvd3 .x .nv-axis .nv-axisMaxMin text,
.nvd3 .x2 .nv-axis .nv-axisMaxMin text,
.nvd3 .x3 .nv-axis .nv-axisMaxMin text {
text-anchor: middle
}
.nvd3 .nv-axis.nv-disabled {
opacity: 0;
}
.nvd3 .nv-bars rect {
fill-opacity: .75;
transition: fill-opacity 250ms linear;
-moz-transition: fill-opacity 250ms linear;
-webkit-transition: fill-opacity 250ms linear;
}
.nvd3 .nv-bars rect.hover {
fill-opacity: 1;
}
.nvd3 .nv-bars .hover rect {
fill: lightblue;
}
.nvd3 .nv-bars text {
fill: rgba(0,0,0,0);
}
.nvd3 .nv-bars .hover text {
fill: rgba(0,0,0,1);
}
.nvd3 .nv-multibar .nv-groups rect,
.nvd3 .nv-multibarHorizontal .nv-groups rect,
.nvd3 .nv-discretebar .nv-groups rect {
stroke-opacity: 0;
transition: fill-opacity 250ms linear;
-moz-transition: fill-opacity 250ms linear;
-webkit-transition: fill-opacity 250ms linear;
}
.nvd3 .nv-multibar .nv-groups rect:hover,
.nvd3 .nv-multibarHorizontal .nv-groups rect:hover,
.nvd3 .nv-candlestickBar .nv-ticks rect:hover,
.nvd3 .nv-discretebar .nv-groups rect:hover {
fill-opacity: 1;
}
.nvd3 .nv-discretebar .nv-groups text,
.nvd3 .nv-multibarHorizontal .nv-groups text {
font-weight: bold;
fill: rgba(0,0,0,1);
stroke: rgba(0,0,0,0);
}
/* boxplot CSS */
.nvd3 .nv-boxplot circle {
fill-opacity: 0.5;
}
.nvd3 .nv-boxplot circle:hover {
stroke: #AAA;
}
.nvd3 .nv-boxplot rect:hover {
fill-opacity: 1;
}
.nvd3 line.nv-boxplot-median {
stroke: black;
}
.nv-boxplot-tick:hover {
stroke-width: 2.5px;
}
/* bullet */
.nvd3.nv-bullet { font: 10px sans-serif; }
.nvd3.nv-bullet .nv-measure { fill-opacity: .8; }
.nvd3.nv-bullet .nv-measure:hover { fill-opacity: 1; }
.nvd3.nv-bullet .nv-marker { stroke: #000; stroke-width: 2px; }
.nvd3.nv-bullet .nv-markerTriangle { stroke: #000; fill: #fff; stroke-width: 1.5px; }
.nvd3.nv-bullet .nv-tick line { stroke: #666; stroke-width: .5px; }
.nvd3.nv-bullet .nv-range.nv-s0 { fill: #eee; }
.nvd3.nv-bullet .nv-range.nv-s1 { fill: #ddd; }
.nvd3.nv-bullet .nv-range.nv-s2 { fill: #ccc; }
.nvd3.nv-bullet .nv-title { font-size: 14px; font-weight: bold; }
.nvd3.nv-bullet .nv-subtitle { fill: #999; }
.nvd3.nv-bullet .nv-range {
fill: #bababa;
fill-opacity: .4;
}
.nvd3.nv-bullet .nv-range:hover {
fill-opacity: .7;
}
.nvd3.nv-candlestickBar .nv-ticks .nv-tick {
stroke-width: 1px;
}
.nvd3.nv-candlestickBar .nv-ticks .nv-tick.hover {
stroke-width: 2px;
}
.nvd3.nv-candlestickBar .nv-ticks .nv-tick.positive rect {
stroke: #2ca02c;
fill: #2ca02c;
}
.nvd3.nv-candlestickBar .nv-ticks .nv-tick.negative rect {
stroke: #d62728;
fill: #d62728;
}
.with-transitions .nv-candlestickBar .nv-ticks .nv-tick {
transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
-moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
-webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
}
.nvd3.nv-candlestickBar .nv-ticks line {
stroke: #333;
}
.nvd3 .nv-legend .nv-disabled rect {
/*fill-opacity: 0;*/
}
.nvd3 .nv-check-box .nv-box {
fill-opacity:0;
stroke-width:2;
}
.nvd3 .nv-check-box .nv-check {
fill-opacity:0;
stroke-width:4;
}
.nvd3 .nv-series.nv-disabled .nv-check-box .nv-check {
fill-opacity:0;
stroke-opacity:0;
}
.nvd3 .nv-controlsWrap .nv-legend .nv-check-box .nv-check {
opacity: 0;
}
/* line plus bar */
.nvd3.nv-linePlusBar .nv-bar rect {
fill-opacity: .75;
}
.nvd3.nv-linePlusBar .nv-bar rect:hover {
fill-opacity: 1;
}
.nvd3 .nv-groups path.nv-line {
fill: none;
}
.nvd3 .nv-groups path.nv-area {
stroke: none;
}
.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point {
fill-opacity: 0;
stroke-opacity: 0;
}
.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point {
fill-opacity: .5 !important;
stroke-opacity: .5 !important;
}
.with-transitions .nvd3 .nv-groups .nv-point {
transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
-moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
-webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
}
.nvd3.nv-scatter .nv-groups .nv-point.hover,
.nvd3 .nv-groups .nv-point.hover {
stroke-width: 7px;
fill-opacity: .95 !important;
stroke-opacity: .95 !important;
}
.nvd3 .nv-point-paths path {
stroke: #aaa;
stroke-opacity: 0;
fill: #eee;
fill-opacity: 0;
}
.nvd3 .nv-indexLine {
cursor: ew-resize;
}
/********************
* SVG CSS
*/
/********************
Default CSS for an svg element nvd3 used
*/
svg.nvd3-svg {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-ms-user-select: none;
-moz-user-select: none;
user-select: none;
display: block;
width:100%;
height:100%;
}
/********************
Box shadow and border radius styling
*/
.nvtooltip.with-3d-shadow, .with-3d-shadow .nvtooltip {
-moz-box-shadow: 0 5px 10px rgba(0,0,0,.2);
-webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2);
box-shadow: 0 5px 10px rgba(0,0,0,.2);
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
}
.nvd3 text {
font: normal 12px Arial;
}
.nvd3 .title {
font: bold 14px Arial;
}
.nvd3 .nv-background {
fill: white;
fill-opacity: 0;
}
.nvd3.nv-noData {
font-size: 18px;
font-weight: bold;
}
/**********
* Brush
*/
.nv-brush .extent {
fill-opacity: .125;
shape-rendering: crispEdges;
}
.nv-brush .resize path {
fill: #eee;
stroke: #666;
}
/**********
* Legend
*/
.nvd3 .nv-legend .nv-series {
cursor: pointer;
}
.nvd3 .nv-legend .nv-disabled circle {
fill-opacity: 0;
}
/* focus */
.nvd3 .nv-brush .extent {
fill-opacity: 0 !important;
}
.nvd3 .nv-brushBackground rect {
stroke: #000;
stroke-width: .4;
fill: #fff;
fill-opacity: .7;
}
.nvd3.nv-ohlcBar .nv-ticks .nv-tick {
stroke-width: 1px;
}
.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover {
stroke-width: 2px;
}
.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive {
stroke: #2ca02c;
}
.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative {
stroke: #d62728;
}
.nvd3 .background path {
fill: none;
stroke: #EEE;
stroke-opacity: .4;
shape-rendering: crispEdges;
}
.nvd3 .foreground path {
fill: none;
stroke-opacity: .7;
}
.nvd3 .nv-parallelCoordinates-brush .extent
{
fill: #fff;
fill-opacity: .6;
stroke: gray;
shape-rendering: crispEdges;
}
.nvd3 .nv-parallelCoordinates .hover {
fill-opacity: 1;
stroke-width: 3px;
}
.nvd3 .missingValuesline line {
fill: none;
stroke: black;
stroke-width: 1;
stroke-opacity: 1;
stroke-dasharray: 5, 5;
}
.nvd3.nv-pie path {
stroke-opacity: 0;
transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
-moz-transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
-webkit-transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
}
.nvd3.nv-pie .nv-pie-title {
font-size: 24px;
fill: rgba(19, 196, 249, 0.59);
}
.nvd3.nv-pie .nv-slice text {
stroke: #000;
stroke-width: 0;
}
.nvd3.nv-pie path {
stroke: #fff;
stroke-width: 1px;
stroke-opacity: 1;
}
.nvd3.nv-pie .hover path {
fill-opacity: .7;
}
.nvd3.nv-pie .nv-label {
pointer-events: none;
}
.nvd3.nv-pie .nv-label rect {
fill-opacity: 0;
stroke-opacity: 0;
}
/* scatter */
.nvd3 .nv-groups .nv-point.hover {
stroke-width: 20px;
stroke-opacity: .5;
}
.nvd3 .nv-scatter .nv-point.hover {
fill-opacity: 1;
}
.nv-noninteractive {
pointer-events: none;
}
.nv-distx, .nv-disty {
pointer-events: none;
}
/* sparkline */
.nvd3.nv-sparkline path {
fill: none;
}
.nvd3.nv-sparklineplus g.nv-hoverValue {
pointer-events: none;
}
.nvd3.nv-sparklineplus .nv-hoverValue line {
stroke: #333;
stroke-width: 1.5px;
}
.nvd3.nv-sparklineplus,
.nvd3.nv-sparklineplus g {
pointer-events: all;
}
.nvd3 .nv-hoverArea {
fill-opacity: 0;
stroke-opacity: 0;
}
.nvd3.nv-sparklineplus .nv-xValue,
.nvd3.nv-sparklineplus .nv-yValue {
stroke-width: 0;
font-size: .9em;
font-weight: normal;
}
.nvd3.nv-sparklineplus .nv-yValue {
stroke: #f66;
}
.nvd3.nv-sparklineplus .nv-maxValue {
stroke: #2ca02c;
fill: #2ca02c;
}
.nvd3.nv-sparklineplus .nv-minValue {
stroke: #d62728;
fill: #d62728;
}
.nvd3.nv-sparklineplus .nv-currentValue {
font-weight: bold;
font-size: 1.1em;
}
/* stacked area */
.nvd3.nv-stackedarea path.nv-area {
fill-opacity: .7;
stroke-opacity: 0;
transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
-moz-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
-webkit-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
}
.nvd3.nv-stackedarea path.nv-area.hover {
fill-opacity: .9;
}
.nvd3.nv-stackedarea .nv-groups .nv-point {
stroke-opacity: 0;
fill-opacity: 0;
}
.nvtooltip {
position: absolute;
background-color: rgba(255,255,255,1.0);
color: rgba(0,0,0,1.0);
padding: 1px;
border: 1px solid rgba(0,0,0,.2);
z-index: 10000;
display: block;
font-family: Arial;
font-size: 11px;
text-align: left;
pointer-events: none;
white-space: nowrap;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.nvtooltip {
background: rgba(255,255,255, 0.8);
border: 1px solid rgba(0,0,0,0.5);
border-radius: 4px;
}
/*Give tooltips that old fade in transition by
putting a "with-transitions" class on the container div.
*/
.nvtooltip.with-transitions, .with-transitions .nvtooltip {
transition: opacity 50ms linear;
-moz-transition: opacity 50ms linear;
-webkit-transition: opacity 50ms linear;
transition-delay: 200ms;
-moz-transition-delay: 200ms;
-webkit-transition-delay: 200ms;
}
.nvtooltip.x-nvtooltip,
.nvtooltip.y-nvtooltip {
padding: 8px;
}
.nvtooltip h3 {
margin: 0;
padding: 4px 14px;
line-height: 18px;
font-weight: normal;
background-color: rgba(247,247,247,0.75);
color: rgba(0,0,0,1.0);
text-align: center;
border-bottom: 1px solid #ebebeb;
-webkit-border-radius: 5px 5px 0 0;
-moz-border-radius: 5px 5px 0 0;
border-radius: 5px 5px 0 0;
}
.nvtooltip p {
margin: 0;
padding: 5px 14px;
text-align: center;
}
.nvtooltip span {
display: inline-block;
margin: 2px 0;
}
.nvtooltip table {
margin: 6px;
border-spacing:0;
}
.nvtooltip table td {
padding: 2px 9px 2px 0;
vertical-align: middle;
}
.nvtooltip table td.key {
font-weight:normal;
}
.nvtooltip table td.value {
text-align: right;
font-weight: bold;
}
.nvtooltip table tr.highlight td {
padding: 1px 9px 1px 0;
border-bottom-style: solid;
border-bottom-width: 4px;
border-top-style: solid;
border-top-width: 4px;
font-weight: bold;
font-size: 15px;
}
.nvtooltip table td.legend-color-guide div {
width: 8px;
height: 8px;
vertical-align: middle;
}
.nvtooltip table td.legend-color-guide div {
width: 12px;
height: 12px;
border: 1px solid #999;
}
.nvtooltip .footer {
padding: 3px;
text-align: center;
}
.nvtooltip-pending-removal {
pointer-events: none;
display: none;
}
/****
Interactive Layer
*/
.nvd3 .nv-interactiveGuideLine {
pointer-events:none;
}
.nvd3 line.nv-guideline {
stroke: #ccc;
}

8
panoramix/static/nv.d3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
panoramix/static/slice.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -2,8 +2,8 @@
{% import 'appbuilder/baselib.html' as baselib %}
{% block body %}
{% include 'appbuilder/general/confirm.html' %}
{% include 'appbuilder/general/alert.html' %}
{% include 'appbuilder/general/confirm.html' %}
{% include 'appbuilder/general/alert.html' %}
{% block navbar %}
<header class="top" role="header">
@@ -11,6 +11,8 @@
</header>
{% endblock %}
{% block uncontained %}{% endblock %}
<div class="container">
<div class="row">
{% block messages %}

View File

@@ -1,13 +1,148 @@
{% extends "appbuilder/base.html" %}
{% block content %}
<div class="container">
<div class="jumbotron">
<h1>Panoramix</h1>
<p>Panoramix is an interactive visualization platform built on top of Druid.io</p>
{% block head_css %}
{{super()}}
<style>
.carousel img {
max-height: 500px;
}
.carousel {
overflow: hidden;
}
div.carousel-caption{
background: rgba(0,0,0,0.5);
border-radius: 20px;
top: 150px;
bottom: auto !important;
}
.carousel-inner > .item > img {
margin: 0 auto;
}
div.navbar {
margin: 0px;
}
.carousel-indicators li {
background-color: #AAA;
border: 1px solid black;
}
.carousel-indicators .active {
background-color: #000;
border: 5px solid black;
}
</style>
{% endblock %}
{% block uncontained %}
<div id="carousel" style="height: 500px;" class="carousel slide" data-ride="carousel">
<!-- Indicators -->
<ol class="carousel-indicators">
<li data-target="#carousel" data-slide-to="0" class="active"></li>
<li data-target="#carousel" data-slide-to="1"></li>
<li data-target="#carousel" data-slide-to="2"></li>
<li data-target="#carousel" data-slide-to="3"></li>
<li data-target="#carousel" data-slide-to="4"></li>
</ol>
<!-- Wrapper for slides -->
<div class="carousel-inner" role="listbox">
<div class="item active text-center">
<img src="{{ url_for("static", filename="dash.png") }}">
<div class="carousel-caption">
<div>
<h1 style="font-size: 80px;">Panoramix</h1>
<p style="font-size: 25px;">
an open source data visualization platform
</p>
</div>
</div>
</div>
<div class="text-center">
<img width="250" src="/static/tux_panoramix.png">
<div class="item">
<img src="{{ url_for("static", filename="bubble.png") }}">
<div class="carousel-caption">
<h1>Explore your data
</h1>
<p>
Intuitively navigate your data while slicing, dicing, and
visualizing through a rich set of widgets
</p>
</div>
</div>
<div class="item">
<img src="{{ url_for("static", filename="dash.png") }}">
<div class="carousel-caption">
<h1>Create and share dashboards</h1>
<p>Assemble many data visualization "slices" into a rich collection</p>
</div>
</div>
<div class="item">
<img src="{{ url_for("static", filename="cloud.png") }}">
<div class="carousel-caption">
<h1>Extend</h1>
<p>Join the community and take part in extending the widget library</p>
</div>
</div>
<div class="item">
<img style="max-height: none;" src="{{ url_for("static", filename="servers.jpg") }}">
<div class="carousel-caption">
<h1>Connect</h1>
<p>
Access data from MySql, Presto.db, Postgres, RedShift, Oracle, MsSql,
SQLite, and more through the SqlAlchemy integration. You can also
query realtime data blazingly fast out of Druid.io
</p>
</div>
</div>
</div>
<!-- Controls -->
<div>
<a class="left carousel-control" href="#carousel" role="button" data-slide="prev">
<span class="glyphicon glyphicon-chevron-left" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
</a>
<a class="right carousel-control" href="#carousel" role="button" data-slide="next">
<span class="glyphicon glyphicon-chevron-right" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a>
</div>
</div>
<hr/>
<div class="container">
<div class="row text-center">
<div class="col-lg-4">
<img class="img-circle" src="{{ url_for('static', filename='cardash.jpg') }}" width="140" height="140">
<h2>Dashboards</h2>
<p>Browse the dashboards list</p>
<p><a class="btn btn-default" href="/dashboardmodelview/list/" role="button">
<i class="fa fa-dashboard"></i>
</a></p>
</div><!-- /.col-lg-4 -->
<div class="col-lg-4">
<img class="img-circle" src="{{ url_for('static', filename='slice.jpg') }}" width="140" height="140">
<h2>Slices</h2>
<p>"Slices" are individual views into a single dataset</p>
<p><a class="btn btn-default" href="/slicemodelview/list/" role="button">
<i class="fa fa-line-chart"></i>
</a></p>
</div><!-- /.col-lg-4 -->
<div class="col-lg-4">
<img class="img-circle" src="{{ url_for('static', filename='gallery.jpg') }}" alt="Generic placeholder image" width="140" height="140">
<h2>Gallery</h2>
<p>Navigate through the growing set of visualizations</p>
<p><a class="btn btn-default" href="#" onclick="alert('Not ready yet!');" role="button">
<i class="fa fa-picture-o"></i>
</a></p>
</div><!-- /.col-lg-4 -->
</div>
</div>
{% endblock %}
{% block tail_js %}
{{ super() }}
<script>
$( document ).ready(function() {
$('#carousel').carousel();
console.log($('#carousel'));
});
</script>
{% endblock %}

View File

@@ -0,0 +1,194 @@
{% extends "panoramix/base.html" %}
{% block head_css %}
{{ super() }}
{% for css in dashboard.css_files %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename=css) }}">
{% endfor %}
<link rel="stylesheet" href="{{ url_for('static', filename="jquery.gridster.min.css") }}">
{% for slice in dashboard.slices %}
{% set viz = slice.viz %}
{% import viz.template as viz_macros %}
{{ viz_macros.viz_css(viz) }}
{% endfor %}
<style>
a i{
cursor: pointer;
}
i.drag{
cursor: move; !important
}
.gridster .preview-holder {
z-index: 1;
position: absolute;
background-color: #AAA;
border-color: #AAA;
opacity: 0.3;
}
.gridster li {
list-style-type: none;
border: 1px solid gray;
overflow: hidden;
box-shadow: 2px 2px 2px #AAA;
border-radius: 5px;
background-color: white;
//overflow: auto;
}
.gridster .dragging,
.gridster .resizing {
opacity: 0.5;
}
img.loading {
width: 20px;
margin: 5px;
}
.title {
text-align: center;
}
.slice_title {
text-align: center;
font-weight: bold;
font-size: 14px;
padding: 5px;
}
div.gridster {
visibility: hidden
}
div.slice_content {
width: 100%;
height: 100%;
}
table.widget_header {
width: 100%;
height: 20px;
}
td.icons {
width: 50px;
}
td.icons nobr {
display: none;
}
div.header {
font-weight: bold;
}
</style>
{% endblock %}
{% block content_fluid %}
<div class="title">
<div class="row">
<div class="col-md-1 text-left"></div>
<div class="col-md-10 text-middle">
<h2>
{{ dashboard.dashboard_title }}
<a id="savedash"><i class="fa fa-save"></i></a>
</h2>
</div>
<div class="col-md-1 text-right">
</div>
</div>
</div>
<div class="gridster content_fluid">
<ul>
{% for slice in dashboard.slices %}
{% set pos = pos_dict.get(slice.id, {}) %}
{% set viz = slice.viz %}
{% import viz.template as viz_macros %}
<li
id="slice_{{ slice.id }}"
slice_id="{{ slice.id }}"
data-row="{{ pos.row or 1 }}"
data-col="{{ pos.col or loop.index }}"
data-sizex="{{ pos.size_x or 4 }}"
data-sizey="{{ pos.size_y or 4 }}">
<table class="widget_header">
<tbody>
<tr>
<td class="icons">
<nobr class="icons">
<a><i class="fa fa-arrows drag"></i></a>
<a class="refresh"><i class="fa fa-refresh"></i></a>
</nobr>
</td>
<td>
<div class="text-center header"><nobr>{{ slice.slice_name }}</nobr></div>
</td>
<td class="icons text-right">
<nobr>
<a href="{{ slice.slice_url }}"><i class="fa fa-play"></i></a>
<a href="{{ slice.edit_url }}"><i class="fa fa-gear"></i></a>
<a class="closewidget"><i class="fa fa-close"></i></a>
</br>
</td>
</tr>
</tbody>
</table>
{{ viz_macros.viz_html(viz) }}
</li>
{% endfor %}
</ul>
</div>
{% endblock %}
{% block tail %}
{{ super() }}
{% for js in dashboard.js_files %}
<script src="{{ url_for('static', filename=js) }}"></script>
{% endfor %}
<script src="{{ url_for("static", filename="jquery.gridster.with-extras.min.js") }}"></script>
<script>
f = d3.format(".4s");
</script>
<script>
$( document ).ready(function() {
var gridster = $(".gridster ul").gridster({
widget_margins: [5, 5],
widget_base_dimensions: [100, 100],
draggable: {
handle: '.drag',
},
resize: {
enabled: true,
stop: function(e, ui, $widget) {
$widget.find("a.refresh").click();
}
},
serialize_params:function($w, wgd) {
return {
slice_id: $($w).attr('slice_id'),
col: wgd.col,
row: wgd.row,
size_x: wgd.size_x,
size_y: wgd.size_y
};
},
}).data('gridster');
$("div.gridster").css('visibility', 'visible');
$("#savedash").click(function(){
var data = gridster.serialize();
console.log(data);
$.ajax({
type: "POST",
url: '/panoramix/save_dash/{{ dashboard.id }}/',
data: {data: JSON.stringify(data)},
success: function(){console.log('Sucess!')},
});
});
$("a.closewidget").click(function(){
var li = $(this).parents("li");
gridster.remove_widget(li);
});
$("table.widget_header").mouseover(function(){
$(this).find("td.icons nobr").show();
});
$("table.widget_header").mouseout(function(){
$(this).find("td.icons nobr").hide();
});
});
</script>
{% for slice in dashboard.slices %}
{% set viz = slice.viz %}
{% import viz.template as viz_macros %}
{{ viz_macros.viz_js(viz) }}
{% endfor %}
{% endblock %}

View File

@@ -2,34 +2,39 @@
{% block head_css %}
{{super()}}
<style>
.select2-container-multi .select2-choices {
height: 70px;
overflow: auto;
}
.no-gutter > [class*='col-'] {
.select2-container-multi .select2-choices {
height: 70px;
overflow: auto;
}
.no-gutter > [class*='col-'] {
padding-right:0;
padding-left:0;
}
form div.form-control {
margin-bottom: 5px !important;
}
form input.form-control {
margin-bottom: 5px !important;
}
.tooltip-inner {
max-width: 350px;
//width: 350px;
}
}
form div.form-control {
margin-bottom: 5px !important;
}
form input.form-control {
margin-bottom: 5px !important;
}
.tooltip-inner {
max-width: 350px;
//width: 350px;
}
img.loading {
width: 30px;
}
</style>
{% endblock %}
{% block content_fluid %}
{% set datasource = viz.datasource %}
{% set form = viz.form %}
<div class="container-fluid">
<div class="col-md-3">
<h3>
{{ datasource.name }}
{% if datasource.description %}
<i class="fa fa-info-circle" data-toggle="tooltip" data-placement="bottom" title="{{ datasource.description }}"></i>
<i class="fa fa-info-circle" data-toggle="tooltip" data-placement="bottom" title="{{ datasource.description }}"></i>
{% endif %}
<a href="/{{ datasource.baselink }}/edit/{{ datasource.id }}">
<i class="fa fa-edit"></i>
@@ -38,85 +43,97 @@ form input.form-control {
<hr>
<form id="query" method="GET" style="display: none;">
<div>{{ form.viz_type.label }}: {{ form.viz_type(class_="form-control select2") }}</div>
{% if 'metrics' not in viz.hidden_fields %}
<div>{{ form.metrics.label }}: {{ form.metrics(class_="form-control select2") }}</div>
{% endif %}
{% if 'granularity' not in viz.hidden_fields %}
<div>{{ form.granularity.label }}
<i class="fa fa-info-circle" data-toggle="tooltip" data-placement="right"
title="Supports natural language time as in '10 seconds', '1 day' or '1 week'"
id="blah"></i>
{{ form.granularity(class_="form-control select2_free_granularity") }}</div>
{% endif %}
<div class="row">
<div class="form-group">
<div class="col-xs-6">{{ form.since.label }}
<i class="fa fa-info-circle" data-toggle="tooltip" data-placement="right"
title="Supports natural language time as in '1 day ago', '28 days' or '3 years'"
id="blah"></i>
{{ form.since(class_="form-control select2_free_since") }}</div>
<div class="col-xs-6">{{ form.until.label }}
{{ form.until(class_="form-control select2_free_until") }}</div>
{% for fieldname in form.field_order %}
{% if not fieldname.__iter__ %}
<div>
{% set field = form.get_field(fieldname)%}
<div>
{{ field.label }}
{% if field.description %}
<i class="fa fa-info-circle" data-toggle="tooltip" data-placement="right"
title="{{ field.description }}"></i>
{% endif %}:
</div>
<div>
{{ field(class_=form.field_css_classes(field.name)) }}
</div>
</div>
{% else %}
<div class="row">
<div class="form-group">
{% for name in fieldname %}
<div class="col-xs-{{ (12 / fieldname|length) | int }}">
{% set field = form.get_field(name)%}
{{ field.label }}
{% if field.description %}
<i class="fa fa-info-circle" data-toggle="tooltip" data-placement="right"
title="{{ field.description }}"></i>
{% endif %}:
{{ field(class_=form.field_css_classes(field.name)) }}
</div>
{% endfor %}
</div>
</div>
{% if 'groupby' not in viz.hidden_fields %}
<div>{{ form.groupby.label }}: {{ form.groupby(class_="form-control select2") }}</div>
{% endif %}
{% block extra_fields %}{% endblock %}
{% endfor %}
<hr>
<h4>Filters</h4>
<div id="flt0" style="display: none;">
<span class="">{{ form.flt_col_0(class_="form-control inc") }}</span>
<div class="row">
<span class="col col-sm-4">{{ form.flt_op_0(class_="form-control inc") }}</span>
<span class="col col-sm-6">{{ form.flt_eq_0(class_="form-control inc") }}</span>
<button type="button" class="btn btn-sm remove" aria-label="Delete filter">
<span class="glyphicon glyphicon-minus" aria-hidden="true"></span>
</button>
</div>
<hr style="margin: 5px 0px;"/>
<div id="flt0" style="display: none;">
<span class="">{{ form.flt_col_0(class_="form-control inc") }}</span>
<div class="row">
<span class="col col-sm-4">{{ form.flt_op_0(class_="form-control inc") }}</span>
<span class="col col-sm-6">{{ form.flt_eq_0(class_="form-control inc") }}</span>
<button type="button" class="btn btn-sm remove" aria-label="Delete filter">
<span class="glyphicon glyphicon-minus" aria-hidden="true"></span>
</button>
</div>
<hr style="margin: 5px 0px;"/>
</div>
<div id="filters"></div>
<button type="button" id="plus" class="btn btn-sm" aria-label="Add a filter">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
</button>
<hr>
<button type="button" class="btn btn-primary" id="druidify">Druidify!</button>
<button type="button" class="btn btn-default" id="bookmark">Bookmark</button>
<button type="button" class="btn btn-primary" id="druidify">
<i class="fa fa-bolt"></i>
Druidify!
</button>
<button type="button" class="btn btn-default" id="save">
<i class="fa fa-save"></i>
Save as Slice
</button>
<hr style="margin-bottom: 0px;">
<img src="{{ url_for("static", filename="tux_panoramix.png") }}" width=250>
<input type="hidden" id="slice_name" name="slice_name" value="TEST">
<input type="hidden" id="action" name="action" value="">
<input type="hidden" name="datasource_name" value="{{ datasource.name }}">
<input type="hidden" name="datasource_id" value="{{ datasource.id }}">
<input type="hidden" name="datasource_type" value="{{ datasource.type }}">
</form><br>
</div>
<div class="col-md-9">
<h3>{{ viz.verbose_name }}
{% if results %}
<span class="label label-success">
{{ "{0:0.4f}".format(results.duration.total_seconds()) }} s
</span>
<span class="label label-info btn"
data-toggle="modal" data-target="#query_modal">query</span>
{% endif %}
{% if False %}
<span class="label label-success">
{{ "{0:0.4f}".format(results.duration.total_seconds()) }} s
</span>
<span class="label label-info btn"
data-toggle="modal" data-target="#query_modal">query</span>
{% endif %}
</h3>
<hr/>
{% block viz %}
{% if error_msg %}
<span class="alert alert-danger">{{ error_msg }}</span>
{% endif %}
{% endblock %}
<div class="viz" style="height: 700px;">
{% block viz_html %}
{% if viz.error_msg %}
<div class="alert alert-danger">{{ viz.error_msg }}</div>
{% endif %}
{% if viz.warning_msg %}
<div class="alert alert-warning">{{ viz.warning_msg }}</div>
{% endif %}
{% endblock %}
</div>
{% if debug %}
<h3>Results</h3>
<pre>
{{ results }}
</pre>
<h3>Latest Segment Metadata</h3>
<pre>
{{ latest_metadata }}
</pre>
{% endif %}
</div>
</div>
<div class="modal fade" id="query_modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
@@ -127,7 +144,7 @@ form input.form-control {
<h4 class="modal-title" id="myModalLabel">Query</h4>
</div>
<div class="modal-body">
<pre>{{ results.query }}</pre>
<pre>{{ '' }}</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
@@ -139,32 +156,30 @@ form input.form-control {
{% block tail_js %}
{{ super() }}
<script src="{{ url_for("static", filename="d3.min.js") }}"></script>
<script>
$( document ).ready(function() {
f = d3.format(".4s");
function getParam(name) {
$( document ).ready(function() {
function getParam(name) {
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
results = regex.exec(location.search);
return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
}
$(".select2").select2();
$("form").slideDown("slow");
$('[data-toggle="tooltip"]').tooltip();
$(".select2").select2();
$("form").slideDown("slow");
$('[data-toggle="tooltip"]').tooltip();
function set_filters(){
for (var i=1; i<10; i++){
var eq = getParam("flt_eq_" + i);
if (eq !=''){
add_filter(i);
function set_filters(){
for (var i=1; i<10; i++){
var eq = getParam("flt_eq_" + i);
if (eq !=''){
add_filter(i);
}
}
}
}
set_filters();
set_filters();
function add_filter(i) {
function add_filter(i) {
cp = $("#flt0").clone();
$(cp).appendTo("#filters");
$(cp).slideDown("slow");
@@ -178,81 +193,91 @@ $( document ).ready(function() {
$(cp).find('.remove').click(function() {
$(this).parent().parent().slideUp("slow", function(){$(this).remove()});
});
}
$("#plus").click(add_filter);
$("#bookmark").click(function () {alert("Not implemented yet...");})
add_filter();
$("#druidify").click(function () {
var i = 1;
}
// removing empty filters
$("#filters > div").each(function(){
if ($(this).find("#flt_eq_0").val() == '')
function druidify(){
var i = 1;
// removing empty filters
$("#filters > div").each(function(){
if ($(this).find("#flt_eq_0").val() == '')
$(this).slideUp();
});
});
// Assigning the right id to form elements in filters
$("#filters > div").each(function(){
$(this).attr("id", function(){return "flt_" + i;})
$(this).find("#flt_col_0")
// Assigning the right id to form elements in filters
$("#filters > div").each(function(){
$(this).attr("id", function(){return "flt_" + i;})
$(this).find("#flt_col_0")
.attr("id", function(){return "flt_col_" + i;})
.attr("name", function(){return "flt_col_" + i;});
$(this).find("#flt_op_0")
$(this).find("#flt_op_0")
.attr("id", function(){return "flt_op_" + i;})
.attr("name", function(){return "flt_op_" + i;});
$(this).find("#flt_eq_0")
$(this).find("#flt_eq_0")
.attr("id", function(){return "flt_eq_" + i;})
.attr("name", function(){return "flt_eq_" + i;});
i++;
});
$("#query").submit();
});
i++;
});
$("#query").submit();
}
function create_choices (term, data) {
$("#plus").click(add_filter);
$("#save").click(function () {
var slice_name = prompt("Name your slice!");
if (slice_name != "" && slice_name != null) {
$("#slice_name").val(slice_name);
$("#action").val("save");
druidify();
}
})
add_filter();
$("#druidify").click(druidify);
function create_choices (term, data) {
if ($(data).filter(function() {
return this.text.localeCompare(term)===0;
return this.text.localeCompare(term)===0;
}).length===0)
{return {id:term, text:term};}
}
$(".select2_free_since").select2({
}
$(".select2_free_since").select2({
createSearchChoice: create_choices,
multiple: false,
data: [
{id: '-1 hour', text: '-1 hour'},
{id: '-12 hours', text: '-12 hours'},
{id: '-1 day', text: '-1 day'},
{id: '-7 days', text: '-7 days'},
{id: '-28 days', text: '-28 days'},
{id: '-90 days', text: '-90 days'},
{id: '{{ form.data.since }}', text: '{{ form.data.since }}'},
{id: '-1 hour', text: '-1 hour'},
{id: '-12 hours', text: '-12 hours'},
{id: '-1 day', text: '-1 day'},
{id: '-7 days', text: '-7 days'},
{id: '-28 days', text: '-28 days'},
{id: '-90 days', text: '-90 days'},
{id: '{{ viz.form.data.since }}', text: '{{ viz.form.data.since }}'},
]
});
$(".select2_free_until").select2({
});
$(".select2_free_until").select2({
createSearchChoice: create_choices,
multiple: false,
data: [
{id: '{{ form.data.until }}', text: '{{ form.data.until }}'},
{id: 'now', text: 'now'},
{id: '-1 day', text: '-1 day'},
{id: '-7 days', text: '-7 days'},
{id: '-28 days', text: '-28 days'},
{id: '-90 days', text: '-90 days'},
{id: '{{ viz.form.data.until }}', text: '{{ viz.form.data.until }}'},
{id: 'now', text: 'now'},
{id: '-1 day', text: '-1 day'},
{id: '-7 days', text: '-7 days'},
{id: '-28 days', text: '-28 days'},
{id: '-90 days', text: '-90 days'},
]
});
$(".select2_free_granularity").select2({
});
$(".select2_free_granularity").select2({
createSearchChoice: create_choices,
multiple: false,
data: [
{id: '{{ form.data.granularity }}', text: '{{ form.data.granularity }}'},
{id: 'all', text: 'all'},
{id: '5 seconds', text: '5 seconds'},
{id: '30 seconds', text: '30 seconds'},
{id: '1 minute', text: '1 minute'},
{id: '5 minutes', text: '5 minutes'},
{id: '1 day', text: '1 day'},
{id: '7 days', text: '7 days'},
{id: '{{ viz.form.data.granularity }}', text: '{{ viz.form.data.granularity }}'},
{id: 'all', text: 'all'},
{id: '5 seconds', text: '5 seconds'},
{id: '30 seconds', text: '30 seconds'},
{id: '1 minute', text: '1 minute'},
{id: '5 minutes', text: '5 minutes'},
{id: '1 day', text: '1 day'},
{id: '7 days', text: '7 days'},
]
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends "appbuilder/general/model/add.html" %}
{% import "panoramix/models/database/macros.html" as macros %}
{% block tail_js %}
{{ super() }}
{{ macros.testconn() }}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends "appbuilder/general/model/edit.html" %}
{% import "panoramix/models/database/macros.html" as macros %}
{% block tail_js %}
{{ super() }}
{{ macros.testconn() }}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% macro testconn() %}
<script>
$("#sqlalchemy_uri").parent()
.append('<button id="testconn" class="btn">Test Connection</button>');
$("#testconn").click(function() {
var url = "/panoramix/testconn";
$.ajax({
method: "POST",
url: url,
data: { uri: $("#sqlalchemy_uri").val() }
}).done(function() {
alert("success");
}).fail(function(error) {
alert("ERROR: " + error.responseText);
});
return false;
});
</script>
{% endmacro %}

View File

@@ -0,0 +1,41 @@
{% import viz.template as viz_macros %}
{% if viz.args.get("json") == "true" %}
{{ viz.get_json() }}
{% else %}
{% if viz.args.get("standalone") == "true" %}
{% extends 'panoramix/viz_standalone.html' %}
{% else %}
{% extends 'panoramix/datasource.html' %}
{% endif %}
{% block viz_html %}
{{ viz_macros.viz_html(viz) }}
{% endblock %}
{% block head_css %}
{{super()}}
{% if viz.args.get("skip_libs") != "true" %}
{% for css in viz.css_files %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename=css) }}">
{% endfor %}
{% endif %}
{{ viz_macros.viz_css(viz) }}
{% endblock %}
{% block tail %}
{{super()}}
{% if viz.args.get("skip_libs") != "true" %}
<script src="{{ url_for("static", filename="d3.min.js") }}"></script>
<script>
f = d3.format(".4s");
</script>
{% for js in viz.js_files %}
<script src="{{ url_for('static', filename=js) }}"></script>
{% endfor %}
{% endif %}
{{ viz_macros.viz_js(viz) }}
{% endblock %}
{% endif %}

View File

@@ -0,0 +1,199 @@
{% macro viz_html(viz) %}
<div id="{{ viz.token }}" style="height: 100%;">
<img src="{{ url_for("static", filename="loading.gif") }}" class="loading">
</div>
{% endmacro %}
{% macro viz_js(viz) %}
<script>
$( document ).ready(function() {
var div = d3.select("#{{ viz.token }}");
var render = function(){
url = "/";
var url = "{{ viz.get_url(json="true")|safe }}";
d3.json(url, function(error, json){
div.html("");
//Define the percentage bounds that define color from red to green
if(error != null){
var err = '<div class="alert alert-danger">' + error.responseText + '</div>';
div.html(err);
return '';
}
var color_range = [-1,1];
var compare_pos = -23
var target_url = 'd3js.org';
var f = d3.format('.3s');
var fp = d3.format('+.1%');
var xy = div.node().getBoundingClientRect();
var width = xy.width;
var height = xy.height-30;
var svg = div.append('svg');
svg.attr("width", width);
svg.attr("height", height);
data = example_data();
data = json.data;
var compare_suffix = ' ' + json.compare_suffix;
var v_compare = null;
var v = data[data.length -1][1];
if (json.compare_lag >0){
pos = data.length - (json.compare_lag+1);
if(pos >=0){
v_compare = (v / data[pos][1])-1;
}
}
var date_ext = d3.extent(data, function(d){return d[0]});
var value_ext = d3.extent(data, function(d){return d[1]});
var margin=20;
var scale_x = d3.time.scale.utc().domain(date_ext).range([margin, width-margin]);
var scale_y = d3.scale.linear().domain(value_ext).range([height-(margin),margin]);
var colorRange = [d3.hsl(0,1,0.3), d3.hsl(120, 1, 0.3)];
var scale_color = d3.scale
.linear().domain(color_range)
.interpolate(d3.interpolateHsl)
.range(colorRange).clamp(true);
var line = d3.svg.line()
.x(function(d) { return scale_x(d[0])})
.y(function(d) { return scale_y(d[1])})
.interpolate("basis");
//Drawing trend line
var g = svg.append('g');
var path = g.append('path')
.attr('d', function(d){return line(data);})
.attr('stroke-width', 5)
.attr('opacity', 0.5)
.attr('fill', "none")
.attr('stroke-linecap',"round")
.attr('stroke', "grey");
var g = svg.append('g')
.attr('class', 'digits')
.attr('opacity', 1);
var y = height/2;
if(v_compare != null)
y = (height/8) * 3;
//Printing big number
g.append('text')
.attr('x', width/2)
.attr('y', y)
.attr('class', 'big')
.attr('alignment-baseline', 'middle')
.attr('id', 'bigNumber')
.style('font-weight', 'bold')
.style('cursor', 'pointer')
.text(f(v))
.style('font-size', d3.min([height, width])/3.5)
.attr('fill','white');
var c = scale_color(v_compare);
//Printing compare %
if(v_compare != null){
g.append('text')
.attr('x', width/2)
.attr('y', (height/16) *12)
.text(fp(v_compare) + compare_suffix)
.style('font-size', d3.min([height, width])/8)
.style('text-anchor', 'middle')
.attr('fill', c)
.attr('stroke', c);
}
var g_axis = svg.append('g').attr('class', 'axis').attr('opacity',0);
var g = g_axis.append('g');
var x_axis = d3.svg.axis()
.scale(scale_x)
.orient('bottom')
//.tickFormat(d3.time.format('%I%p'))
.ticks(4);
g.call(x_axis);
g.attr('transform', 'translate(0,'+ (height-margin) +')');
var g = g_axis.append('g').attr('transform', 'translate('+(width-margin)+',0)');
var y_axis = d3.svg.axis()
.scale(scale_y)
.orient('left')
.tickFormat(d3.format('.3s'))
.tickValues(value_ext);
g.call(y_axis);
g.selectAll('text')
.style('text-anchor','end')
.attr('y','-5')
.attr('x','1');
g.selectAll("text")
.style('font-size','10px');
/*
g_axis.selectAll('path.domain')
.attr('stroke-width:1px;');
*/
div.on('mouseover', function(d){
var div = d3.select(this);
div.select('path').transition().duration(500).attr('opacity', 1)
.style('stroke-width', '2px');
div.select('g.digits').transition().duration(500).attr('opacity', 0.1);
div.select('g.axis').transition().duration(500).attr('opacity', 1);
})
.on('mouseout', function(d){
var div = d3.select(this);
div.select('path').transition().duration(500).attr('opacity', 0.5)
.style('stroke-width', '5px');
div.select('g.digits').transition().duration(500).attr('opacity', 1);
div.select('g.axis').transition().duration(500).attr('opacity', 0);
});
});
};
render();
$(div).parent().find("a.refresh").click(render);
});
example_data = function(){
//Building a random growing trend
var rnd = d3.random.normal(5000000, 500000);
var data = [];
for(i=0; i<24; i++){
data.push([
new Date(2015, 1, i, 1, 0, 0, 0),
rnd() + (i*(rnd()/40))
]);
}
return data;
}
</script>
{% endmacro %}
{% macro viz_css(viz) %}
<style>
g.axis text{
font-size:10px;
font-weight:normal;
color: gray;
fill: gray;
text-anchor:middle;
alignment-baseline: middle;
font-weight: none;
}
text.big{
stroke: black;
text-anchor:middle;
fill: black;
}
g.tick line {
stroke-width: 1px;
stroke: grey;
}
.domain {
fill: none;
stroke: black;
stroke-width; 1;
}
</style>
{% endmacro %}

View File

@@ -1,64 +1,48 @@
{% extends "panoramix/datasource.html" %}
{% block viz %}
{{ super() }}
<div id="chart"></div>
{% endblock %}
{% macro viz_html(viz) %}
<div id="{{ viz.token }}" style="height:100%; width: 100%">
<img src="{{ url_for("static", filename="loading.gif") }}" class="loading">
<div class="chart" style="height:100%; width: 100%"></div>
</div>
{% endmacro %}
{% block extra_fields %}
{% if form.compare %}
<div>{{ form.compare.label }}: {{ form.compare(class_="form-control") }}</div>
{% endif %}
{% if form.rolling_type %}
<div class="row">
<span class="col col-sm-5">{{ form.rolling_type.label }}: {{ form.rolling_type(class_="form-control select2") }}</span>
<span class="col col-sm-4">{{ form.rolling_periods.label }}: {{ form.rolling_periods(class_="form-control") }}</span>
</div>
{% endif %}
{% if form.limit %}
<div>{{ form.limit.label }}: {{ form.limit(class_="form-control select2") }}</div>
{% endif %}
{% if form.series %}
<div>{{ form.series.label }}: {{ form.series(class_="form-control select2") }}</div>
{% endif %}
{% if form.entity %}
<div>{{ form.entity.label }}: {{ form.entity(class_="form-control select2") }}</div>
{% endif %}
{% if form.size %}
<div>{{ form.size.label }}: {{ form.size(class_="form-control select2") }}</div>
{% endif %}
{% if form.x %}
<div>{{ form.x.label }}: {{ form.x(class_="form-control select2") }}</div>
{% endif %}
{% if form.y %}
<div>{{ form.y.label }}: {{ form.y(class_="form-control select2") }}</div>
{% endif %}
{% endblock %}
{% block tail %}
{{ super() }}
{% if viz.stockchart %}
<script src="{{ url_for("static", filename="highstock.js") }}"></script>
{% else %}
<script src="{{ url_for("static", filename="highcharts.js") }}"></script>
{% endif %}
<script src="{{ url_for("static", filename="highcharts-more.js") }}"></script>
{% macro viz_js(viz) %}
<script>
$( document ).ready(function() {
$( document ).ready(function() {
Highcharts.setOptions({
colors: [
"#FF5A5F", "#007A87", "#7B0051", "#00D1C1", "#8CE071", "#FFB400",
"#FFAA91", "#B4A76C", "#9CA299", "#565A5C"
],
global: {
useUTC: false
},
colors: [
"#FF5A5F", "#007A87", "#7B0051", "#00D1C1", "#8CE071", "#FFB400",
"#FFAA91", "#B4A76C", "#9CA299", "#565A5C"
],
global: {
useUTC: false
},
});
$("#viz_type").click(function(){
$("#queryform").submit();
})
{% if chart_js %}
{{ chart_js|safe }}
{% endif %}
});
var token = $("#{{ viz.token }}");
var loading = $("#{{ viz.token }}").find("img.loading");
var chart = $("#{{ viz.token }}").find("div.chart");
var refresh = function(){
chart.hide();
loading.show();
var url = "{{ viz.get_url(json="true")|safe }}";
$.getJSON(url, function(data){
chart.width(token.width());
chart.height(token.height()-40);
chart.highcharts('{{ viz.chart_call }}', data);
chart.show();
token.find("img.loading").hide();
})
.fail(function(xhr) {
var err = '<div class="alert alert-danger">' + xhr.responseText + '</div>';
loading.hide();
chart.show();
chart.html(err);
});
};
refresh();
token.parent().find("a.refresh").click(refresh);
});
</script>
{% endblock %}
{% endmacro %}
{% macro viz_css(viz) %}
{% endmacro %}

View File

@@ -0,0 +1,9 @@
{% macro viz_html(viz) %}
<div style="padding: 10px;overflow: auto; height: 100%;">{{ viz.rendered()|safe }}</div>
{% endmacro %}
{% macro viz_js(viz) %}
{% endmacro %}
{% macro viz_css(viz) %}
{% endmacro %}

View File

@@ -0,0 +1,99 @@
{% macro viz_html(viz) %}
<div id="{{ viz.token }}" style="height:100%; width: 100%">
<img src="{{ url_for("static", filename="loading.gif") }}" class="loading">
<div class="chart" style="height:100%; width: 100%"></div>
</div>
{% endmacro %}
{% macro viz_js(viz) %}
<script>
$( document ).ready(function() {
function UTC(dttm){
return v = new Date(dttm.getUTCFullYear(), dttm.getUTCMonth(), dttm.getUTCDate(), dttm.getUTCHours(), dttm.getUTCMinutes(), dttm.getUTCSeconds());
}
var tickMultiFormat = d3.time.format.multi([
[".%L", function(d) { return d.getMilliseconds(); }],
[":%S", function(d) { return d.getSeconds(); }],
["%I:%M", function(d) { return d.getMinutes(); }],
["%I %p", function(d) { return d.getHours(); }],
["%a %d", function(d) { return d.getDay() && d.getDate() != 1; }],
["%b %d", function(d) { return d.getDate() != 1; }],
["%B", function(d) { return d.getMonth(); }],
["%Y", function() { return true; }]
]);
colors = [
"#FF5A5F", "#007A87", "#7B0051", "#00D1C1", "#8CE071", "#FFB400",
"#FFAA91", "#B4A76C", "#9CA299", "#565A5C"
];
var token = d3.select("#{{ viz.token }}");
var jtoken = $("#{{ viz.token }}");
var loading = $("#{{ viz.token }}").find("img.loading");
var chart = $("#{{ viz.token }}").find("div.chart");
var refresh = function(){
chart.hide();
loading.show();
var url = "{{ viz.get_url(json="true")|safe }}";
$.getJSON(url, function(data){
nv.addGraph(function() {
{% if viz.chart_type == 'nvd3_line' %}
{% if viz.args.show_brush == 'y' %}
var chart = nv.models.lineWithFocusChart()
var xext = chart.xAxis.scale().domain();
chart
.x2Axis
.tickFormat(function (d) {return tickMultiFormat(UTC(new Date(d))); })
.tickValues([]);
{% elif viz.chart_type == 'nvd3_line' %}
var chart = nv.models.lineChart()
{% endif %}
chart.xScale(d3.time.scale());
chart.xAxis
.showMaxMin(false)
.tickFormat(function (d) {return tickMultiFormat(new Date(d)); });
chart.showLegend({{ "{}".format(viz.args.show_legend=='y')|lower }});
{% elif viz.chart_type == 'nvd3_bar' %}
var chart = nv.models.multiBarChart()
.showControls(true) //Allow user to switch between 'Grouped' and 'Stacked' mode.
.groupSpacing(0.1); //Distance between each group of bars.
chart.xAxis
.showMaxMin(false)
.tickFormat(function (d) {return tickMultiFormat(UTC(new Date(d))); });
{% endif %}
chart.yAxis.tickFormat(d3.format('.3s'));
{% if viz.chart_type == "nvd3_line" and viz.args.rich_tooltip == 'y' %}
chart.useInteractiveGuideline(true);
{% endif %}
{% if viz.args.y_axis_zero == 'y' %}
chart.forceY([0, 1]);
{% elif viz.args.y_log_scale == 'y' %}
chart.yScale(d3.scale.log());
{% endif %}
token.select('.chart').append("svg")
.datum(data)
.transition().duration(500)
.call(chart);
return chart;
});
chart.show();
loading.hide();
}).fail(function(xhr) {
var err = '<div class="alert alert-danger">' + xhr.responseText + '</div>';
loading.hide();
chart.show();
chart.html(err);
});
};
refresh();
jtoken.parent().find("a.refresh").click(refresh);
});
</script>
{% endmacro %}
{% macro viz_css(viz) %}
{% endmacro %}

View File

@@ -0,0 +1,13 @@
<html>
<head>
{% if viz.args.get("skip_libs") != "true" %}
{% block head %}
<script src="{{url_for('appbuilder.static',filename='js/jquery-latest.js')}}"></script>
{% endblock %}
{% endif %}
{% block tail %}{% endblock %}
</head>
<body>
{% block viz_html %}{% endblock %}
</body>
</html>

View File

@@ -1,55 +1,62 @@
{% extends "panoramix/datasource.html" %}
{% block head_css %}
{{super()}}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='dataTables.bootstrap.css') }}">
{% endblock %}
{% block viz %}
{{ super() }}
{% if not error_msg %}
<table class="dataframe table table-striped table-bordered table-condensed">
<thead>
<tr>
{% for col in df.columns if not col.endswith('__perc') %}
<th>{{ col }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in df.to_dict(orient="records") %}
<tr>
{% for col in df.columns if not col.endswith('__perc') %}
{% if col + '__perc' in df.columns %}
<td style="background-image: linear-gradient(to right, lightgrey, lightgrey {{ row[col+'__perc'] }}%, rgba(0,0,0,0) {{ row[col+'__perc'] }}%">{{ row[col] }}</td>
{% else %}
<td>{{ row[col] }}</td>
{% endif %}
{% endfor %}
</tr>
{% macro viz_html(viz) %}
{% if viz.args.get("async") == "true" %}
{% set df = viz.get_df() %}
<table class="dataframe table table-striped table-bordered table-condensed">
<thead>
<tr>
{% for col in df.columns if not col.endswith('__perc') %}
<th>{{ col }}</th>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
</tr>
</thead>
<tbody>
{% for row in df.to_dict(orient="records") %}
<tr>
{% for col in df.columns if not col.endswith('__perc') %}
{% if col + '__perc' in df.columns %}
<td style="background-image: linear-gradient(to right, lightgrey, lightgrey {{ row[col+'__perc'] }}%, rgba(0,0,0,0) {{ row[col+'__perc'] }}%">
{{ row[col] }}
</td>
{% else %}
<td>{{ row[col] }}</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div id="{{ viz.token }}" style="display: none;overflow: auto; height: 100%;"></div>
<img src="{{ url_for("static", filename="loading.gif") }}" class="loading">
{% endif %}
{% endmacro %}
{% block extra_fields %}
<div>{{ form.row_limit.label }}: {{ form.row_limit(class_="form-control select2") }}</div>
{% endblock %}
{% block tail %}
{{ super() }}
<script src="{{ url_for('static', filename='jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', filename='dataTables.bootstrap.js') }}"></script>
<script>
//$('table').css('background-color', 'white');
$(document).ready(function() {
var table = $('table').DataTable({
paging: false,
{% macro viz_js(viz) %}
{% if viz.args.get("async") != "true" %}
<script>
$( document ).ready(function() {
var url = "{{ viz.get_url(async="true", standalone="true", skip_libs="true")|safe }}";
var token = $("#{{ viz.token }}");
token.load(url, function(response, status, xhr){
if(status=="error"){
var err = '<div class="alert alert-danger">' + xhr.responseText + '</div>';
token.html(err);
token.show();
}
else{
var table = token.find('table').DataTable({
paging: false,
searching: false,
});
table.column('-1').order( 'desc' ).draw();
}
token.show();
token.parent().find("img.loading").hide();
});
table.column('-1').order( 'desc' ).draw();
});
</script>
{% endblock %}
});
</script>
{% endif %}
{% endmacro %}
{% macro viz_css(viz) %}
{% endmacro %}

View File

@@ -0,0 +1,76 @@
{% macro viz_html(viz) %}
<div id="{{ viz.token }}" style="height: 100%;">
<img src="{{ url_for("static", filename="loading.gif") }}" class="loading">
</div>
{% endmacro %}
{% macro viz_js(viz) %}
<script>
$( document ).ready(function() {
var token = d3.select("#{{ viz.token }}");
function refresh() {
var range = [
{{ viz.args.get('size_from') or 20 }},
{{ viz.args.get('size_to') or 100 }}
];
var rotation = "{{ viz.args.get("rotation", "random") }}";
if (rotation == "square")
var f_rotation = function() { return ~~(Math.random() * 2) * 90; };
else if (rotation == "flat")
var f_rotation = function() { return 0};
else
var f_rotation = function() { return (~~(Math.random() * 6) - 3) * 30;};
var url = "{{ viz.get_url(json="true")|safe }}";
var box = token.node().getBoundingClientRect();
var size = [box.width, box.height-25];
d3.json(url, function(error, data){
if(error != null){
var err = '<div class="alert alert-danger">' + error.responseText + '</div>';
token.html(err);
return '';
}
scale = d3.scale.linear()
.range(range)
.domain(d3.extent(data, function(d) { return d.size; }));
var fill = d3.scale.category20();
var layout = d3.layout.cloud()
.size(size)
.words(data)
.padding(5)
.rotate(f_rotation)
.font("serif")
.fontSize(function(d) { return scale(d.size); })
.on("end", draw);
layout.start();
function draw(words) {
token.selectAll("*").remove();
token.append("svg")
.attr("width", layout.size()[0])
.attr("height", layout.size()[1])
.append("g")
.attr("transform", "translate(" + layout.size()[0] / 2 + "," + layout.size()[1] / 2 + ")")
.selectAll("text")
.data(words)
.enter().append("text")
.style("font-size", function(d) { return d.size + "px"; })
.style("font-family", "Impact")
.style("fill", function(d, i) { return fill(i); })
.attr("text-anchor", "middle")
.attr("transform", function(d) {
return "translate(" + [d.x, d.y] + ") rotate(" + d.rotate + ")";
})
.text(function(d) { return d.text; });
}
});
}
refresh();
jtoken = $(token);
jtoken.parent().find("a.refresh").click(refresh);
});
</script>
{% endmacro %}
{% macro viz_css(viz) %}
{% endmacro %}

View File

@@ -1,5 +1,36 @@
from datetime import datetime
import hashlib
from sqlalchemy.types import TypeDecorator, TEXT
import json
import parsedatetime
import functools
class memoized(object):
"""Decorator that caches a function's return value each time it is called.
If called later with the same arguments, the cached value is returned, and
not re-evaluated.
"""
def __init__(self, func):
self.func = func
self.cache = {}
def __call__(self, *args):
try:
return self.cache[args]
except KeyError:
value = self.func(*args)
self.cache[args] = value
return value
except TypeError:
# uncachable -- for instance, passing a list as an argument.
# Better to not cache than to blow up entirely.
return self.func(*args)
def __repr__(self):
"""Return the function's docstring."""
return self.func.__doc__
def __get__(self, obj, objtype):
"""Support instance methods."""
return functools.partial(self.__call__, obj)
def parse_human_datetime(s):
@@ -33,3 +64,34 @@ def parse_human_timedelta(s):
d = datetime(
d.tm_year, d.tm_mon, d.tm_mday, d.tm_hour, d.tm_min, d.tm_sec)
return d - dttm
class JSONEncodedDict(TypeDecorator):
"""Represents an immutable structure as a json-encoded string."""
impl = TEXT
def process_bind_param(self, value, dialect):
if value is not None:
value = json.dumps(value)
return value
def process_result_value(self, value, dialect):
if value is not None:
value = json.loads(value)
return value
def color(s):
"""
Get a consistent color from the same string using a hash function
>>> color("foo")
'#FF5A5F'
"""
colors = [
"#FF5A5F", "#007A87", "#7B0051", "#00D1C1", "#8CE071", "#FFB400",
"#FFAA91", "#B4A76C", "#9CA299", "#565A5C"
]
h = hashlib.md5(s)
i = int(h.hexdigest(), 16)
return colors[i % len(colors)]

View File

@@ -3,34 +3,45 @@ import json
import logging
from flask import request, redirect, flash, Response
from flask.ext.appbuilder.models.sqla.interface import SQLAInterface
from flask.ext.appbuilder import ModelView, CompactCRUDMixin, BaseView, expose
from flask.ext.appbuilder.actions import action
from flask.ext.appbuilder.models.sqla.interface import SQLAInterface
from flask.ext.appbuilder.security.decorators import has_access
from pydruid.client import doublesum
from sqlalchemy import create_engine
from wtforms.validators import ValidationError
from flask.ext.appbuilder.actions import action
from panoramix import appbuilder, db, models, viz, utils, app
config = app.config
def validate_json(form, field):
try:
json.loads(field.data)
except Exception as e:
logging.exception(e)
raise ValidationError("Json isn't valid")
class DeleteMixin(object):
@action("muldelete", "Delete", "Delete all Really?", "fa-trash", single=False)
@action(
"muldelete", "Delete", "Delete all Really?", "fa-trash", single=False)
def muldelete(self, items):
self.datamodel.delete_all(items)
self.update_redirect()
return redirect(self.get_redirect())
class TableColumnInlineView(CompactCRUDMixin, ModelView):
class PanoramixModelView(ModelView):
page_size = 100
class TableColumnInlineView(CompactCRUDMixin, PanoramixModelView):
datamodel = SQLAInterface(models.TableColumn)
can_delete = False
edit_columns = [
'column_name', 'description', 'table', 'groupby', 'filterable',
'column_name', 'description', 'groupby', 'filterable', 'table',
'count_distinct', 'sum', 'min', 'max']
list_columns = [
'column_name', 'type', 'groupby', 'filterable', 'count_distinct',
@@ -39,7 +50,7 @@ class TableColumnInlineView(CompactCRUDMixin, ModelView):
appbuilder.add_view_no_menu(TableColumnInlineView)
class ColumnInlineView(CompactCRUDMixin, ModelView):
class ColumnInlineView(CompactCRUDMixin, PanoramixModelView):
datamodel = SQLAInterface(models.Column)
edit_columns = [
'column_name', 'description', 'datasource', 'groupby',
@@ -53,25 +64,23 @@ class ColumnInlineView(CompactCRUDMixin, ModelView):
def post_update(self, col):
col.generate_metrics()
def post_update(self, col):
col.generate_metrics()
appbuilder.add_view_no_menu(ColumnInlineView)
class SqlMetricInlineView(CompactCRUDMixin, ModelView):
class SqlMetricInlineView(CompactCRUDMixin, PanoramixModelView):
datamodel = SQLAInterface(models.SqlMetric)
list_columns = ['metric_name', 'verbose_name', 'metric_type' ]
list_columns = ['metric_name', 'verbose_name', 'metric_type']
edit_columns = [
'metric_name', 'description', 'verbose_name', 'metric_type',
'table', 'expression']
'expression', 'table']
add_columns = edit_columns
page_size = 100
appbuilder.add_view_no_menu(SqlMetricInlineView)
class MetricInlineView(CompactCRUDMixin, ModelView):
class MetricInlineView(CompactCRUDMixin, PanoramixModelView):
datamodel = SQLAInterface(models.Metric)
list_columns = ['metric_name', 'verbose_name', 'metric_type' ]
list_columns = ['metric_name', 'verbose_name', 'metric_type']
edit_columns = [
'metric_name', 'description', 'verbose_name', 'metric_type',
'datasource', 'json']
@@ -84,7 +93,53 @@ class MetricInlineView(CompactCRUDMixin, ModelView):
appbuilder.add_view_no_menu(MetricInlineView)
class ClusterModelView(ModelView, DeleteMixin):
class DatabaseView(PanoramixModelView, DeleteMixin):
datamodel = SQLAInterface(models.Database)
list_columns = ['database_name', 'created_by', 'created_on']
add_columns = ['database_name', 'sqlalchemy_uri']
edit_columns = add_columns
add_template = "panoramix/models/database/add.html"
edit_template = "panoramix/models/database/edit.html"
description_columns = {
'sqlalchemy_uri': (
"Refer to the SqlAlchemy docs for more information on how "
"to structure your URI here: "
"http://docs.sqlalchemy.org/en/rel_1_0/core/engines.html")
}
appbuilder.add_view(
DatabaseView,
"Databases",
icon="fa-database",
category="Sources",
category_icon='fa-database',)
class TableView(PanoramixModelView, DeleteMixin):
datamodel = SQLAInterface(models.Table)
list_columns = ['table_link', 'database']
add_columns = ['table_name', 'database', 'default_endpoint']
edit_columns = [
'table_name', 'database', 'main_dttm_col', 'default_endpoint']
related_views = [TableColumnInlineView, SqlMetricInlineView]
def post_add(self, table):
table.fetch_metadata()
def post_update(self, table):
table.fetch_metadata()
appbuilder.add_view(
TableView,
"Tables",
category="Sources",
icon='fa-table',)
appbuilder.add_separator("Sources")
class ClusterModelView(PanoramixModelView, DeleteMixin):
datamodel = SQLAInterface(models.Cluster)
add_columns = [
'cluster_name',
@@ -97,45 +152,45 @@ class ClusterModelView(ModelView, DeleteMixin):
appbuilder.add_view(
ClusterModelView,
"Druid Clusters",
icon="fa-server",
category="Admin",
category_icon='fa-cogs',)
icon="fa-cubes",
category="Sources",
category_icon='fa-database',)
class DatabaseView(ModelView, DeleteMixin):
datamodel = SQLAInterface(models.Database)
list_columns = ['database_name']
add_columns = ['database_name', 'sqlalchemy_uri']
edit_columns = add_columns
class SliceModelView(PanoramixModelView, DeleteMixin):
datamodel = SQLAInterface(models.Slice)
can_add = False
list_columns = [
'slice_link', 'viz_type', 'datasource_type',
'datasource', 'created_by']
edit_columns = [
'slice_name', 'viz_type', 'druid_datasource',
'table', 'dashboards', 'params']
appbuilder.add_view(
DatabaseView,
"Databases",
icon="fa-database",
category="Admin",
category_icon='fa-cogs',)
SliceModelView,
"Slices",
icon="fa-bar-chart",
category="",
category_icon='',)
class TableView(ModelView, DeleteMixin):
datamodel = SQLAInterface(models.Table)
list_columns = ['table_link', 'database']
add_columns = ['table_name', 'database', 'default_endpoint']
edit_columns = ['table_name', 'database', 'main_datetime_column', 'default_endpoint']
related_views = [TableColumnInlineView, SqlMetricInlineView]
class DashboardModelView(PanoramixModelView, DeleteMixin):
datamodel = SQLAInterface(models.Dashboard)
list_columns = ['dashboard_link', 'created_by']
edit_columns = ['dashboard_title', 'slices', 'position_json']
add_columns = edit_columns
def post_add(self, table):
table.fetch_metadata()
def post_update(self, table):
table.fetch_metadata()
appbuilder.add_view(
TableView,
"Tables",
icon='fa-table',)
DashboardModelView,
"Dashboards",
icon="fa-dashboard",
category="",
category_icon='',)
class DatasourceModelView(ModelView, DeleteMixin):
class DatasourceModelView(PanoramixModelView, DeleteMixin):
datamodel = SQLAInterface(models.Datasource)
list_columns = [
'datasource_link', 'cluster', 'owner', 'is_featured', 'is_hidden']
@@ -156,6 +211,7 @@ class DatasourceModelView(ModelView, DeleteMixin):
appbuilder.add_view(
DatasourceModelView,
"Druid Datasources",
category="Sources",
icon="fa-cube")
@@ -171,60 +227,141 @@ def ping():
class Panoramix(BaseView):
@has_access
@expose("/table/<table_id>/")
def table(self, table_id):
@expose("/datasource/<datasource_type>/<datasource_id>/")
def datasource(self, datasource_type, datasource_id):
action = request.args.get('action')
if action == 'save':
session = db.session()
d = request.args.to_dict(flat=False)
del d['action']
as_list = ('metrics', 'groupby')
for k in d:
v = d.get(k)
if k in as_list and not isinstance(v, list):
d[k] = [v] if v else []
if k not in as_list and isinstance(v, list):
d[k] = v[0]
table = (
db.session
.query(models.Table)
.filter_by(id=table_id)
.first()
)
table_id = druid_datasource_id = None
datasource_type = request.args.get('datasource_type')
if datasource_type in ('datasource', 'druid'):
druid_datasource_id = request.args.get('datasource_id')
elif datasource_type == 'table':
table_id = request.args.get('datasource_id')
slice_name = request.args.get('slice_name')
obj = models.Slice(
params=json.dumps(d, indent=4, sort_keys=True),
viz_type=request.args.get('viz_type'),
datasource_name=request.args.get('datasource_name'),
druid_datasource_id=druid_datasource_id,
table_id=table_id,
datasource_type=datasource_type,
slice_name=slice_name,
)
session.add(obj)
session.commit()
flash("Slice <{}> has been added to the pie".format(slice_name), "info")
redirect(obj.slice_url)
if datasource_type == "table":
datasource = (
db.session
.query(models.Table)
.filter_by(id=datasource_id)
.first()
)
else:
datasource = (
db.session
.query(models.Datasource)
.filter_by(id=datasource_id)
.first()
)
if not datasource:
flash("The datasource seem to have been deleted", "alert")
viz_type = request.args.get("viz_type")
if not viz_type and table.default_endpoint:
return redirect(table.default_endpoint)
if not viz_type:
viz_type = "table"
obj = viz.viz_types[viz_type](
table,
form_data=request.args, view=self)
if request.args.get("json"):
return Response(
json.dumps(obj.get_query(), indent=4),
status=200,
mimetype="application/json")
if not hasattr(obj, 'df') or obj.df is None or obj.df.empty:
pass
#return obj.render_no_data()
return obj.render()
@has_access
@expose("/datasource/<datasource_name>/")
def datasource(self, datasource_name):
viz_type = request.args.get("viz_type")
datasource = (
db.session
.query(models.Datasource)
.filter_by(datasource_name=datasource_name)
.first()
)
if not viz_type and datasource.default_endpoint:
return redirect(datasource.default_endpoint)
if not viz_type:
viz_type = "table"
obj = viz.viz_types[viz_type](
datasource,
form_data=request.args, view=self)
if request.args.get("json"):
form_data=request.args)
if request.args.get("json") == "true":
try:
payload = obj.get_json()
status=200
except Exception as e:
logging.exception(e)
payload = str(e)
status=500
return Response(
json.dumps(obj.get_query(), indent=4),
status=200,
payload,
status=status,
mimetype="application/json")
if not hasattr(obj, 'df') or obj.df is None or obj.df.empty:
return obj.render_no_data()
else:
try:
resp = self.render_template("panoramix/viz.html", viz=obj)
except Exception as e:
if config.get("DEBUG"):
raise(e)
return Response(
str(e),
status=500,
mimetype="application/json")
return resp
return obj.render()
@has_access
@expose("/save_dash/<dashboard_id>/", methods=['GET', 'POST'])
def save_dash(self, dashboard_id):
data = json.loads(request.form.get('data'))
slice_ids = [int(d['slice_id']) for d in data]
print slice_ids
session = db.session()
Dash = models.Dashboard
dash = session.query(Dash).filter_by(id=dashboard_id).first()
dash.slices = [o for o in dash.slices if o.id in slice_ids]
print dash.slices
dash.position_json = json.dumps(data, indent=4)
session.merge(dash)
session.commit()
session.close()
return "SUCCESS"
@has_access
@expose("/testconn", methods=["POST"])
def testconn(self):
try:
uri = request.form.get('uri')
db = create_engine(uri)
db.connect()
return "SUCCESS"
except Exception as e:
return Response(
str(e),
status=500,
mimetype="application/json")
@has_access
@expose("/dashboard/<id_>/")
def dashboard(self, id_):
session = db.session()
dashboard = (
session
.query(models.Dashboard)
.filter(models.Dashboard.id == id_)
.first()
)
pos_dict = {}
if dashboard.position_json:
pos_dict = {
int(o['slice_id']):o for o in json.loads(dashboard.position_json)}
return self.render_template(
"panoramix/dashboard.html", dashboard=dashboard,
pos_dict=pos_dict)
@has_access
@expose("/refresh_datasources/")
@@ -259,9 +396,6 @@ appbuilder.add_view_no_menu(Panoramix)
appbuilder.add_link(
"Refresh Druid Metadata",
href='/panoramix/refresh_datasources/',
category='Admin',
category_icon='fa-cogs',
category='Sources',
category_icon='fa-database',
icon="fa-cog")
#models.Metric.__table__.drop(db.engine)
db.create_all()

View File

@@ -1,99 +1,88 @@
from datetime import datetime
from flask import flash, request
import pandas as pd
from collections import OrderedDict
from panoramix import utils
from panoramix.highchart import Highchart, HighchartBubble
from wtforms import Form, SelectMultipleField, SelectField, TextField
import config
import logging
from datetime import datetime
import json
import uuid
from flask import flash
from markdown import markdown
from pandas.io.json import dumps
from werkzeug.datastructures import MultiDict
from werkzeug.urls import Href
import numpy as np
import pandas as pd
from panoramix import app, utils
from panoramix.highchart import Highchart, HighchartBubble
from panoramix.forms import form_factory
config = app.config
CHART_ARGS = {
'height': 700,
'title': None,
'target_div': 'chart',
}
class OmgWtForm(Form):
field_order = (
'viz_type', 'granularity', 'since', 'group_by', 'limit')
def fields(self):
fields = []
for field in self.field_order:
if hasattr(self, field):
obj = getattr(self, field)
if isinstance(obj, Field):
fields.append(getattr(self, field))
return fields
def form_factory(datasource, form_args=None, extra_fields_dict=None):
extra_fields_dict = extra_fields_dict or {}
if form_args:
limit = form_args.get("limit")
try:
limit = int(limit)
if limit not in limits:
limits.append(limit)
limits = sorted(limits)
except:
pass
class QueryForm(OmgWtForm):
viz_type = SelectField(
'Viz',
choices=[(k, v.verbose_name) for k, v in viz_types.items()])
metrics = SelectMultipleField('Metrics', choices=datasource.metrics_combo)
groupby = SelectMultipleField(
'Group by', choices=[
(s, s) for s in datasource.groupby_column_names])
granularity = TextField('Time Granularity', default="one day")
since = TextField('Since', default="one day ago")
until = TextField('Until', default="now")
for i in range(10):
setattr(QueryForm, 'flt_col_' + str(i), SelectField(
'Filter 1', choices=[(s, s) for s in datasource.filterable_column_names]))
setattr(QueryForm, 'flt_op_' + str(i), SelectField(
'Filter 1', choices=[(m, m) for m in ['in', 'not in']]))
setattr(QueryForm, 'flt_eq_' + str(i), TextField("Super"))
for k, v in extra_fields_dict.items():
setattr(QueryForm, k, v)
return QueryForm
class BaseViz(object):
verbose_name = "Base Viz"
template = "panoramix/datasource.html"
hidden_fields = []
def __init__(self, datasource, form_data, view):
self.datasource = datasource
self.form_class = self.form_class()
self.view = view
self.form_data = form_data
self.metrics = form_data.getlist('metrics') or ['count']
self.groupby = form_data.getlist('groupby') or []
template = None
form_fields = [
'viz_type', 'metrics', 'groupby', 'granularity',
('since', 'until')]
js_files = []
css_files = []
def __init__(self, datasource, form_data):
self.datasource = datasource
if isinstance(form_data, MultiDict):
self.args = form_data.to_dict(flat=False)
else:
self.args = form_data
self.form_data = form_data
self.token = self.args.get('token', 'token_' + uuid.uuid4().hex[:8])
as_list = ('metrics', 'groupby')
for k, v in self.args.items():
if k in as_list and not isinstance(v, list):
self.args[k] = [v]
elif k not in as_list and isinstance(v, list) and v:
self.args[k] = v[0]
self.metrics = self.args.get('metrics') or ['count']
self.groupby = self.args.get('groupby') or []
def get_url(self, **kwargs):
d = self.args.copy()
if 'action' in d:
del d['action']
d.update(kwargs)
href = Href(
'/panoramix/datasource/{self.datasource.type}/'
'{self.datasource.id}/'.format(**locals()))
return href(d)
def get_df(self):
self.error_msg = ""
self.results = None
try:
self.results = self.bake_query()
self.df = self.results.df
if self.df is not None:
if 'timestamp' in self.df.columns:
self.df.timestamp = pd.to_datetime(self.df.timestamp)
except Exception as e:
logging.exception(e)
self.error_msg = str(e)
self.results = self.bake_query()
df = self.results.df
if df is None or df.empty:
raise Exception("No data, review your incantations!")
else:
if 'timestamp' in df.columns:
df.timestamp = pd.to_datetime(df.timestamp)
return df
@property
def form(self):
return self.form_class(self.form_data)
@property
def form_class(self):
return form_factory(self.datasource, request.args)
return form_factory(self)
def query_filters(self):
args = self.form_data
args = self.args
# Building filters
filters = []
for i in range(1, 10):
@@ -111,17 +100,16 @@ class BaseViz(object):
"""
Building a query object
"""
ds = self.datasource
args = self.form_data
groupby = args.getlist("groupby") or []
metrics = args.getlist("metrics") or ['count']
args = self.args
groupby = args.get("groupby") or []
metrics = args.get("metrics") or ['count']
granularity = args.get("granularity", "1 day")
if granularity != "all":
granularity = utils.parse_human_timedelta(
granularity).total_seconds() * 1000
limit = int(args.get("limit", 0))
row_limit = int(
args.get("row_limit", config.ROW_LIMIT))
args.get("row_limit", config.get("ROW_LIMIT")))
since = args.get("since", "1 year ago")
from_dttm = utils.parse_human_datetime(since)
if from_dttm > datetime.now():
@@ -130,7 +118,13 @@ class BaseViz(object):
to_dttm = utils.parse_human_datetime(until)
if from_dttm >= to_dttm:
flash("The date range doesn't seem right.", "danger")
from_dttm = to_dttm # Making them identicial to not raise
from_dttm = to_dttm # Making them identical to not raise
# extras are used to query elements specific to a datasource type
# for instance the extra where clause that applies only to Tables
extras = {
'where': args.get("where", '')
}
d = {
'granularity': granularity,
'from_dttm': from_dttm,
@@ -141,24 +135,17 @@ class BaseViz(object):
'row_limit': row_limit,
'filter': self.query_filters(),
'timeseries_limit': limit,
'extras': extras,
}
return d
def render_no_data(self):
self.template = "panoramix/no_data.html"
return BaseViz.render(self)
def render(self, *args, **kwargs):
form = self.form_class(self.form_data)
return self.view.render_template(
self.template, form=form, viz=self, datasource=self.datasource,
results=self.results,
*args, **kwargs)
class TableViz(BaseViz):
verbose_name = "Table View"
template = 'panoramix/viz_table.html'
form_fields = BaseViz.form_fields + ['row_limit']
css_files = ['dataTables.bootstrap.css']
js_files = ['jquery.dataTables.min.js', 'dataTables.bootstrap.js']
def query_obj(self):
d = super(TableViz, self).query_obj()
@@ -166,79 +153,102 @@ class TableViz(BaseViz):
d['timeseries_limit'] = None
return d
def render(self):
if self.error_msg:
return super(TableViz, self).render(error_msg=self.error_msg)
def get_df(self):
df = super(TableViz, self).get_df()
if (
self.form_data.get("granularity") == "all" and
'timestamp' in df):
del df['timestamp']
for m in self.metrics:
df[m + '__perc'] = np.rint((df[m] / np.max(df[m])) * 100)
return df
df = self.df
row_limit = request.args.get("row_limit")
if df is None or df.empty:
return super(TableViz, self).render(error_msg="No data.")
else:
if self.form_data.get("granularity") == "all" and 'timestamp' in df:
del df['timestamp']
for m in self.metrics:
import numpy as np
df[m + '__perc'] = np.rint((df[m] / np.max(df[m])) * 100)
return super(TableViz, self).render(df=df)
def form_class(self):
limits = [10, 50, 100, 500, 1000, 5000, 10000]
return form_factory(self.datasource, request.args,
extra_fields_dict={
'row_limit':
SelectField('Row limit', choices=[(s, s) for s in limits])
})
class MarkupViz(BaseViz):
verbose_name = "Markup Widget"
template = 'panoramix/viz_markup.html'
form_fields = ['viz_type', 'markup_type', 'code']
def rendered(self):
markup_type = self.form_data.get("markup_type")
code = self.form_data.get("code", '')
if markup_type == "markdown":
return markdown(code)
elif markup_type == "html":
return code
class WordCloudViz(BaseViz):
"""
Integration with the nice library at:
https://github.com/jasondavies/d3-cloud
"""
verbose_name = "Word Cloud"
template = 'panoramix/viz_word_cloud.html'
form_fields = [
'viz_type',
('since', 'until'),
'groupby', 'metric', 'limit',
('size_from', 'size_to'),
'rotation',
]
js_files = ['d3.layout.cloud.js']
def query_obj(self):
d = super(WordCloudViz, self).query_obj()
d['granularity'] = 'all'
metric = self.args.get('metric')
if not metric:
raise Exception("Pick a metric!")
d['metrics'] = [self.args.get('metric')]
d['groupby'] = [d['groupby'][0]]
return d
def get_json(self):
df = self.get_df()
df.columns = ['text', 'size']
return df.to_json(orient="records")
class NVD3Viz(BaseViz):
verbose_name = "Base NVD3 Viz"
template = 'panoramix/viz_nvd3.html'
chart_kind = 'line'
js_files = ['nv.d3.min.js']
css_files = ['nv.d3.css']
class HighchartsViz(BaseViz):
verbose_name = "Base Highcharts Viz"
template = 'panoramix/viz_highcharts.html'
chart_kind = 'line'
chart_call = "Chart"
stacked = False
chart_type = 'not_stock'
compare = False
js_files = ['highstock.js']
class BubbleViz(HighchartsViz):
verbose_name = "Bubble Chart"
chart_type = 'bubble'
hidden_fields = ['granularity', 'metrics', 'groupby']
def form_class(self):
datasource = self.datasource
limits = [0, 5, 10, 25, 50, 100, 500]
return form_factory(self.datasource, request.args,
extra_fields_dict={
#'compare': TextField('Period Compare',),
'series': SelectField(
'Series', choices=[
(s, s) for s in datasource.groupby_column_names]),
'entity': SelectField(
'Entity', choices=[
(s, s) for s in datasource.groupby_column_names]),
'x': SelectField(
'X Axis', choices=datasource.metrics_combo),
'y': SelectField(
'Y Axis', choices=datasource.metrics_combo),
'size': SelectField(
'Bubble Size', choices=datasource.metrics_combo),
'limit': SelectField(
'Limit', choices=[(s, s) for s in limits]),
})
form_fields = [
'viz_type', 'since', 'until',
'series', 'entity', 'x', 'y', 'size', 'limit']
js_files = ['highstock.js', 'highcharts-more.js']
def query_obj(self):
args = self.form_data
d = super(BubbleViz, self).query_obj()
d['granularity'] = 'all'
d['groupby'] = list({
request.args.get('series'),
request.args.get('entity')
})
self.x_metric = request.args.get('x')
self.y_metric = request.args.get('y')
self.z_metric = request.args.get('size')
self.entity = request.args.get('entity')
self.series = request.args.get('series')
args.get('series'),
args.get('entity')
})
self.x_metric = args.get('x')
self.y_metric = args.get('y')
self.z_metric = args.get('size')
self.entity = args.get('entity')
self.series = args.get('series')
d['metrics'] = [
self.z_metric,
self.x_metric,
@@ -248,45 +258,83 @@ class BubbleViz(HighchartsViz):
raise Exception("Pick a metric for x, y and size")
return d
def render(self):
metrics = self.metrics
def get_df(self):
df = super(BubbleViz, self).get_df()
df = df.fillna(0)
df['x'] = df[[self.x_metric]]
df['y'] = df[[self.y_metric]]
df['z'] = df[[self.z_metric]]
df['name'] = df[[self.entity]]
df['group'] = df[[self.series]]
return df
if not self.error_msg:
df = self.df.fillna(0)
df['x'] = df[[self.x_metric]]
df['y'] = df[[self.y_metric]]
df['z'] = df[[self.z_metric]]
df['name'] = df[[self.entity]]
df['group'] = df[[self.series]]
chart = HighchartBubble(df)
return super(BubbleViz, self).render(chart_js=chart.javascript_cmd)
else:
return super(BubbleViz, self).render(error_msg=self.error_msg)
def get_json(self):
df = self.get_df()
chart = HighchartBubble(df)
return chart.json
class BigNumberViz(BaseViz):
verbose_name = "Big Number"
template = 'panoramix/viz_bignumber.html'
js_files = ['d3.min.js']
form_fields = [
'viz_type',
'granularity',
('since', 'until'),
'metric',
'compare_lag',
'compare_suffix',
#('rolling_type', 'rolling_periods'),
]
def query_obj(self):
d = super(BigNumberViz, self).query_obj()
metric = self.args.get('metric')
if not metric:
raise Exception("Pick a metric!")
d['metrics'] = [self.args.get('metric')]
return d
def get_json(self):
args = self.args
df = self.get_df()
df = df.sort(columns=df.columns[0])
df['timestamp'] = df[[0]].astype(np.int64) // 10**9
compare_lag = args.get("compare_lag", "")
compare_lag = int(compare_lag) if compare_lag.isdigit() else 0
d = {
'data': df.values.tolist(),
'compare_lag': compare_lag,
'compare_suffix': args.get('compare_suffix', ''),
}
return json.dumps(d)
class TimeSeriesViz(HighchartsViz):
verbose_name = "Time Series - Line Chart"
chart_type = "spline"
stockchart = True
chart_call = "StockChart"
sort_legend_y = True
js_files = ['highstock.js', 'highcharts-more.js']
form_fields = [
'viz_type',
'granularity', ('since', 'until'),
'metrics',
'groupby', 'limit',
('rolling_type', 'rolling_periods'),
]
def render(self):
if request.args.get("granularity") == "all":
self.error_msg = (
"You have to select a time granularity for this view")
return super(TimeSeriesViz, self).render(error_msg=self.error_msg)
def get_df(self):
args = self.args
df = super(TimeSeriesViz, self).get_df()
metrics = self.metrics
df = self.df
df = df.pivot_table(
index="timestamp",
columns=self.groupby,
values=metrics,)
rolling_periods = request.args.get("rolling_periods")
limit = request.args.get("limit")
rolling_type = request.args.get("rolling_type")
rolling_periods = args.get("rolling_periods")
rolling_type = args.get("rolling_type")
if rolling_periods and rolling_type:
if rolling_type == 'mean':
df = pd.rolling_mean(df, int(rolling_periods))
@@ -294,47 +342,108 @@ class TimeSeriesViz(HighchartsViz):
df = pd.rolling_std(df, int(rolling_periods))
elif rolling_type == 'sum':
df = pd.rolling_sum(df, int(rolling_periods))
return df
def get_json(self):
df = self.get_df()
chart = Highchart(
df,
compare=self.compare,
chart_type=self.chart_type,
stacked=self.stacked,
stockchart=self.stockchart,
sort_legend_y=self.sort_legend_y,
**CHART_ARGS)
return super(TimeSeriesViz, self).render(chart_js=chart.javascript_cmd)
return chart.json
def form_class(self):
limits = [0, 5, 10, 25, 50, 100, 500]
return form_factory(self.datasource, request.args,
extra_fields_dict={
#'compare': TextField('Period Compare',),
'rolling_type': SelectField(
'Rolling',
choices=[(s, s) for s in ['mean', 'sum', 'std']]),
'rolling_periods': TextField('Periods',),
'limit': SelectField(
'Series limit', choices=[(s, s) for s in limits])
})
def bake_query(self):
"""
Doing a 2 phase query where we limit the number of series.
"""
return self.datasource.query(**self.query_obj())
class NVD3TimeSeriesViz(NVD3Viz):
verbose_name = "NVD3 - Time Series - Line Chart"
chart_type = "nvd3_line"
form_fields = [
'viz_type',
'granularity', ('since', 'until'),
'metrics',
'groupby', 'limit',
('rolling_type', 'rolling_periods'),
('show_brush', 'show_legend'),
('rich_tooltip', 'y_axis_zero'),
'y_log_scale',
]
def get_df(self):
args = self.args
df = super(NVD3TimeSeriesViz, self).get_df()
metrics = self.metrics
df = df.pivot_table(
index="timestamp",
columns=self.groupby,
values=metrics,)
rolling_periods = args.get("rolling_periods")
rolling_type = args.get("rolling_type")
if rolling_periods and rolling_type:
if rolling_type == 'mean':
df = pd.rolling_mean(df, int(rolling_periods))
elif rolling_type == 'std':
df = pd.rolling_std(df, int(rolling_periods))
elif rolling_type == 'sum':
df = pd.rolling_sum(df, int(rolling_periods))
return df
def get_json(self):
df = self.get_df()
series = df.to_dict('series')
datas = []
for name, ys in series.items():
if df[name].dtype.kind not in "biufc":
continue
df.tz_localize(None)
df.index.tz_localize(None)
df['timestamp'] = pd.to_datetime(df.index, utc=False)
if isinstance(name, basestring):
series_title = name
elif len(self.metrics) > 1:
series_title = ", ".join(name)
else:
series_title = ", ".join(name[1:])
d = {
"key": series_title,
"color": utils.color(series_title),
"values": [
{'x': ds, 'y': ys[i]}
for i, ds in enumerate(df.timestamp)]
}
datas.append(d)
return dumps(datas)
class NVD3TimeSeriesBarViz(NVD3TimeSeriesViz):
verbose_name = "NVD3 - Time Series - Bar Chart"
chart_type = "nvd3_bar"
form_fields = [
'viz_type',
'granularity', ('since', 'until'),
'metrics',
'groupby', 'limit',
('rolling_type', 'rolling_periods'),
'show_legend',
]
class TimeSeriesCompareViz(TimeSeriesViz):
verbose_name = "Time Series - Percent Change"
compare = 'percent'
class TimeSeriesCompareValueViz(TimeSeriesViz):
verbose_name = "Time Series - Value Change"
compare = 'value'
class TimeSeriesAreaViz(TimeSeriesViz):
verbose_name = "Time Series - Stacked Area Chart"
stacked=True
stacked = True
chart_type = "area"
@@ -349,50 +458,49 @@ class TimeSeriesStackedBarViz(TimeSeriesViz):
stacked = True
class DistributionBarViz(HighchartsViz):
verbose_name = "Distribution - Bar Chart"
chart_type = "column"
def query_obj(self):
d = super(DistributionBarViz, self).query_obj()
d['granularity'] = "all"
return d
def render(self):
df = self.df
df = df.pivot_table(
index=self.groupby,
values=self.metrics)
df = df.sort(self.metrics[0], ascending=False)
chart = Highchart(
df, chart_type=self.chart_type, **CHART_ARGS)
return super(DistributionBarViz, self).render(
chart_js=chart.javascript_cmd)
class DistributionPieViz(HighchartsViz):
verbose_name = "Distribution - Pie Chart"
chart_type = "pie"
js_files = ['highstock.js']
form_fields = [
'viz_type', 'metrics', 'groupby',
('since', 'until'), 'limit']
def query_obj(self):
d = super(DistributionPieViz, self).query_obj()
d['granularity'] = "all"
d['is_timeseries'] = False
return d
def render(self):
df = self.df
def get_df(self):
df = super(DistributionPieViz, self).get_df()
df = df.pivot_table(
index=self.groupby,
values=[self.metrics[0]])
df = df.sort(self.metrics[0], ascending=False)
return df
def get_json(self):
df = self.get_df()
chart = Highchart(
df, chart_type=self.chart_type, **CHART_ARGS)
return super(DistributionPieViz, self).render(
chart_js=chart.javascript_cmd)
self.chart_js = chart.javascript_cmd
return chart.json
class DistributionBarViz(DistributionPieViz):
verbose_name = "Distribution - Bar Chart"
chart_type = "column"
viz_types = OrderedDict([
['table', TableViz],
['nvd3_line', NVD3TimeSeriesViz],
['nvd3_bar', NVD3TimeSeriesBarViz],
['line', TimeSeriesViz],
['big_number', BigNumberViz],
['compare', TimeSeriesCompareViz],
['compare_value', TimeSeriesCompareValueViz],
['area', TimeSeriesAreaViz],
@@ -401,4 +509,6 @@ viz_types = OrderedDict([
['dist_bar', DistributionBarViz],
['pie', DistributionPieViz],
['bubble', BubbleViz],
['markup', MarkupViz],
['word_cloud', WordCloudViz],
])

View File

@@ -1,8 +1,12 @@
coverage
flask
flask-alembic
flask-appbuilder
flask-migrate
flask-testing
gunicorn
markdown
mysql-python
nose
pandas
parsedatetime
pydruid

4
run_tests.sh Executable file
View File

@@ -0,0 +1,4 @@
rm /tmp/panoramix_unittests.db
export PANORAMIX_CONFIG=tests.panoramix_test_config
panoramix/bin/panoramix db upgrade
nosetests tests/core_tests.py --with-coverage --cover-package=panoramix

View File

@@ -1,6 +1,6 @@
from setuptools import setup, find_packages
version = '0.2.0'
version = '0.4.0'
setup(
name='panoramix',
@@ -9,20 +9,25 @@ setup(
"and druid.io"),
version=version,
packages=find_packages(),
package_data={'': ['panoramix/alembic.ini']},
package_data={'': [
'panoramix/migrations/alembic.ini',
'panoramix/data/birth_names.csv.gz',
]},
include_package_data=True,
zip_safe=False,
scripts=['panoramix/bin/panoramix'],
install_requires=[
'flask-appbuilder>=1.4.5',
'flask-alembic>=1.2.1',
'gunicorn>=19.3.0',
'pandas>=0.16.2',
'pydruid>=0.2.2',
'parsedatetime>=1.5',
'python-dateutil>=2.4.2',
'requests>=2.7.0',
'sqlparse>=0.1.16',
'flask-appbuilder>=1.4.5, <2.0.0',
'flask-login==0.2.11',
'flask-migrate>=1.5.1, <2.0.0',
'flask-script>=2.0.5, <3.0.0',
'gunicorn>=19.3.0, <20.0.0',
'pandas>=0.16.2, <1.0.0',
'parsedatetime>=1.5, <2.0.0',
'pydruid>=0.2.2, <0.3',
'python-dateutil>=2.4.2, <3.0.0',
'requests>=2.7.0, <3.0.0',
'sqlparse>=0.1.16, <0.2.0',
],
author='Maxime Beauchemin',
author_email='maximebeauchemin@gmail.com',

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,207 +0,0 @@
body { padding-top: 70px; }
a.navbar-brand span {
color: white;
}
nav{
-webkit-box-shadow: 0px 3px 3px #AAA;
-moz-box-shadow: 0px 3px 3px #AAA;
box-shadow: 0px 3px 3px #AAA;
z-index:999;
}
a.navbar-brand {
cursor: default;
}
td>span.glyphicon{
padding-left: 3px;
padding-top: 3px;
}
button.btn {
border: 1px solid black;
}
div.rich_doc {
padding: 5px 10px;
border: 1px solid #dddddd;
background: white;
border-radius: 4px;
}
span.status_square {
width:10px;
height:10px;
border:1px solid grey;
display:inline-block;
padding-left: 0px;
cursor: pointer;
}
div.squares{
float:right;
font-size: 1;
}
div.task_row{
}
span.success{
background-color:green;
}
span.up_for_retry{
background-color:yellow;
}
span.started{
background-color:lime;
}
span.error{
background-color:red;
}
span.queued{
background-color:gray;
}
.tooltip-inner {
text-align:left !important;
font-size: 12px;
}
input#execution_date {
margin-bottom: 0px;
}
table.highlighttable{
width: 100%;
table-layout:fixed;
}
div.linenodiv {
padding-right: 1px !important;
}
.linenos {
width: 50px;
border: none;
}
div.linenodiv pre {
padding-left: 4px;
padding-right: 4px;
color: #AAA;
background-color: #FCFCFC;
text-align:right;
}
pre {
overflow: auto;
word-wrap: normal;
white-space: pre;
}
pre code {
overflow-wrap: normal;
white-space: pre;
}
input, select {
margin: 0px;
}
.code {
font-family: monospace;
}
#sql {
border: 1px solid #CCC;
border-radius: 5px;
}
.ace_editor div {
font: inherit!important
}
#ace_container {
margin: 10px 0px;
}
#sql_ace {
visibility: hidden;
}
.no-wrap {
white-space: nowrap;
}
div.form-inline{
margin-bottom: 5px;
}
body div.panel {
padding: 0px;
}
.blur {
filter:url(#blur-effect-1);
}
div.legend_item {
-moz-border-radius: 5px/5px;
-webkit-border-radius: 5px 5px;
border-radius: 5px/5px;
float:right;
margin: 0px 10px 0px 0px;
padding:0px 5px;
border:solid 2px grey;
font-size: 12px;
}
div.legend_circle{
-moz-border-radius: 10px/10px;
-webkit-border-radius: 10px 10px;
border-radius: 10px/10px;
width:15px;
height:15px;
border:1px solid grey;
float:left;
margin-top: 2px;
margin-right: 0px;
}
div.square {
width:12px;
height:12px;
float: right;
margin-top: 2px;
border:1px solid black;
}
.btn:active, .btn.active {
box-shadow: inset 0 6px 6px rgba(0, 0, 0, 0.4);
}
.hll { background-color: #ffffcc }
.c { color: #408080; font-style: italic } /* Comment */
.err { border: 1px solid #FF0000 } /* Error */
.k { color: #008000; font-weight: bold } /* Keyword */
.o { color: #666666 } /* Operator */
.cm { color: #408080; font-style: italic } /* Comment.Multiline */
.cp { color: #BC7A00 } /* Comment.Preproc */
.c1 { color: #408080; font-style: italic } /* Comment.Single */
.cs { color: #408080; font-style: italic } /* Comment.Special */
.gd { color: #A00000 } /* Generic.Deleted */
.ge { font-style: italic } /* Generic.Emph */
.gr { color: #FF0000 } /* Generic.Error */
.gh { color: #000080; font-weight: bold } /* Generic.Heading */
.gi { color: #00A000 } /* Generic.Inserted */
.go { color: #888888 } /* Generic.Output */
.gp { color: #000080; font-weight: bold } /* Generic.Prompt */
.gs { font-weight: bold } /* Generic.Strong */
.gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.gt { color: #0044DD } /* Generic.Traceback */
.kc { color: #008000; font-weight: bold } /* Keyword.Constant */
.kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
.kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
.kp { color: #008000 } /* Keyword.Pseudo */
.kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
.kt { color: #B00040 } /* Keyword.Type */
.m { color: #666666 } /* Literal.Number */
.s { color: #BA2121 } /* Literal.String */
.na { color: #7D9029 } /* Name.Attribute */
.nb { color: #008000 } /* Name.Builtin */
.nc { color: #0000FF; font-weight: bold } /* Name.Class */
.no { color: #880000 } /* Name.Constant */
.nd { color: #AA22FF } /* Name.Decorator */
.ni { color: #999999; font-weight: bold } /* Name.Entity */
.ne { color: #D2413A; font-weight: bold } /* Name.Exception */
.nf { color: #0000FF } /* Name.Function */
.nl { color: #A0A000 } /* Name.Label */
.nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
.nt { color: #008000; font-weight: bold } /* Name.Tag */
.nv { color: #19177C } /* Name.Variable */
.ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
.w { color: #bbbbbb } /* Text.Whitespace */
.mb { color: #666666 } /* Literal.Number.Bin */
.mf { color: #666666 } /* Literal.Number.Float */
.mh { color: #666666 } /* Literal.Number.Hex */
.mi { color: #666666 } /* Literal.Number.Integer */
.mo { color: #666666 } /* Literal.Number.Oct */
.sb { color: #BA2121 } /* Literal.String.Backtick */
.sc { color: #BA2121 } /* Literal.String.Char */
.sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.s2 { color: #BA2121 } /* Literal.String.Double */

View File

@@ -1,495 +0,0 @@
/*! Select2 Bootstrap 3 CSS v1.4.6 | MIT License | github.com/t0m/select2-bootstrap-css */
/**
* Reset Bootstrap 3 .form-control styles which - if applied to the
* original <select>-element the Select2-plugin may be run against -
* are copied to the .select2-container.
*
* 1. Overwrite .select2-container's original display:inline-block
* with Bootstrap 3's default for .form-control, display:block;
* courtesy of @juristr (@see https://github.com/fk/select2-bootstrap-css/pull/1)
*/
.select2-container.form-control {
background: transparent;
box-shadow: none;
border: none;
display: block;
/* 1 */
margin: 0;
padding: 0;
}
/**
* Adjust Select2 inputs to fit Bootstrap 3 default .form-control appearance.
*/
.select2-container .select2-choices .select2-search-field input,
.select2-container .select2-choice,
.select2-container .select2-choices {
background: none;
padding: 0;
border-color: #cccccc;
border-radius: 4px;
color: #555555;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
background-color: white;
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.select2-search input {
border-color: #cccccc;
border-radius: 4px;
color: #555555;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
background-color: white;
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.select2-container .select2-choices .select2-search-field input {
-webkit-box-shadow: none;
box-shadow: none;
}
/**
* Adjust Select2 input heights to match the Bootstrap default.
*/
.select2-container .select2-choice {
height: 34px;
line-height: 1.42857;
}
/**
* Address Multi Select2's height which - depending on how many elements have been selected -
* may grown higher than their initial size.
*/
.select2-container.select2-container-multi.form-control {
height: auto;
}
/**
* Address Bootstrap 3 control sizing classes
* @see http://getbootstrap.com/css/#forms-control-sizes
*/
.select2-container.input-sm .select2-choice,
.input-group-sm .select2-container .select2-choice {
height: 30px;
line-height: 1.5;
border-radius: 3px;
}
.select2-container.input-lg .select2-choice,
.input-group-lg .select2-container .select2-choice {
height: 46px;
line-height: 1.33333;
border-radius: 6px;
}
.select2-container-multi .select2-choices .select2-search-field input {
height: 32px;
}
.select2-container-multi.input-sm .select2-choices .select2-search-field input,
.input-group-sm .select2-container-multi .select2-choices .select2-search-field input {
height: 28px;
}
.select2-container-multi.input-lg .select2-choices .select2-search-field input,
.input-group-lg .select2-container-multi .select2-choices .select2-search-field input {
height: 44px;
}
/**
* Adjust height and line-height for .select2-search-field amd multi-select Select2 widgets.
*
* 1. Class repetition to address missing .select2-chosen in Select2 < 3.3.2.
*/
.select2-container-multi .select2-choices .select2-search-field input {
margin: 0;
}
.select2-chosen,
.select2-choice > span:first-child,
.select2-container .select2-choices .select2-search-field input {
padding: 6px 12px;
}
.input-sm .select2-chosen,
.input-group-sm .select2-chosen,
.input-sm .select2-choice > span:first-child,
.input-group-sm .select2-choice > span:first-child,
.input-sm .select2-choices .select2-search-field input,
.input-group-sm .select2-choices .select2-search-field input {
padding: 5px 10px;
}
.input-lg .select2-chosen,
.input-group-lg .select2-chosen,
.input-lg .select2-choice > span:first-child,
.input-group-lg .select2-choice > span:first-child,
.input-lg .select2-choices .select2-search-field input,
.input-group-lg .select2-choices .select2-search-field input {
padding: 10px 16px;
}
.select2-container-multi .select2-choices .select2-search-choice {
margin-top: 5px;
margin-bottom: 3px;
}
.select2-container-multi.input-sm .select2-choices .select2-search-choice,
.input-group-sm .select2-container-multi .select2-choices .select2-search-choice {
margin-top: 3px;
margin-bottom: 2px;
}
.select2-container-multi.input-lg .select2-choices .select2-search-choice,
.input-group-lg .select2-container-multi .select2-choices .select2-search-choice {
line-height: 24px;
}
/**
* Adjust the single Select2's dropdown arrow button appearance.
*
* 1. For Select2 v.3.3.2.
*/
.select2-container .select2-choice .select2-arrow,
.select2-container .select2-choice div {
border-left: none;
background: none;
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
}
.select2-dropdown-open .select2-choice .select2-arrow,
.select2-dropdown-open .select2-choice div {
border-left-color: transparent;
background: none;
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
}
/**
* Adjust the dropdown arrow button icon position for the single-select Select2 elements
* to make it line up vertically now that we increased the height of .select2-container.
*
* 1. Class repetition to address missing .select2-chosen in Select2 v.3.3.2.
*/
.select2-container .select2-choice .select2-arrow b,
.select2-container .select2-choice div b {
background-position: 0 3px;
}
.select2-dropdown-open .select2-choice .select2-arrow b,
.select2-dropdown-open .select2-choice div b {
background-position: -18px 3px;
}
.select2-container.input-sm .select2-choice .select2-arrow b,
.input-group-sm .select2-container .select2-choice .select2-arrow b,
.select2-container.input-sm .select2-choice div b,
.input-group-sm .select2-container .select2-choice div b {
background-position: 0 1px;
}
.select2-dropdown-open.input-sm .select2-choice .select2-arrow b,
.input-group-sm .select2-dropdown-open .select2-choice .select2-arrow b,
.select2-dropdown-open.input-sm .select2-choice div b,
.input-group-sm .select2-dropdown-open .select2-choice div b {
background-position: -18px 1px;
}
.select2-container.input-lg .select2-choice .select2-arrow b,
.input-group-lg .select2-container .select2-choice .select2-arrow b,
.select2-container.input-lg .select2-choice div b,
.input-group-lg .select2-container .select2-choice div b {
background-position: 0 9px;
}
.select2-dropdown-open.input-lg .select2-choice .select2-arrow b,
.input-group-lg .select2-dropdown-open .select2-choice .select2-arrow b,
.select2-dropdown-open.input-lg .select2-choice div b,
.input-group-lg .select2-dropdown-open .select2-choice div b {
background-position: -18px 9px;
}
/**
* Address Bootstrap's validation states and change Select2's border colors and focus states.
* Apply .has-warning, .has-danger or .has-succes to #select2-drop to match Bootstraps' colors.
*/
.has-warning .select2-choice,
.has-warning .select2-choices {
border-color: #8a6d3b;
}
.has-warning .select2-container-active .select2-choice,
.has-warning .select2-container-multi.select2-container-active .select2-choices {
border-color: #66512c;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;
}
.has-warning.select2-drop-active {
border-color: #66512c;
}
.has-warning.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #66512c;
}
.has-error .select2-choice,
.has-error .select2-choices {
border-color: #a94442;
}
.has-error .select2-container-active .select2-choice,
.has-error .select2-container-multi.select2-container-active .select2-choices {
border-color: #843534;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;
}
.has-error.select2-drop-active {
border-color: #843534;
}
.has-error.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #843534;
}
.has-success .select2-choice,
.has-success .select2-choices {
border-color: #3c763d;
}
.has-success .select2-container-active .select2-choice,
.has-success .select2-container-multi.select2-container-active .select2-choices {
border-color: #2b542c;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;
}
.has-success.select2-drop-active {
border-color: #2b542c;
}
.has-success.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #2b542c;
}
/**
* Make Select2's active-styles - applied to .select2-container when the widget receives focus -
* fit Bootstrap 3's .form-element:focus appearance.
*/
.select2-container-active .select2-choice,
.select2-container-multi.select2-container-active .select2-choices {
border-color: #66afe9;
outline: none;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
-webkit-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
-o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
}
.select2-drop-active {
border-color: #66afe9;
}
.select2-drop-auto-width,
.select2-drop.select2-drop-above.select2-drop-active {
border-top-color: #66afe9;
}
/**
* Select2 widgets in Bootstrap Input Groups
*
* When Select2 widgets are combined with other elements using Bootstrap 3's
* "Input Group" component, we don't want specific edges of the Select2 container
* to have a border-radius.
*
* In Bootstrap 2, input groups required a markup where these style adjustments
* could be bound to a CSS-class identifying if the additional elements are appended,
* prepended or both.
*
* Bootstrap 3 doesn't rely on these classes anymore, so we have to use our own.
* Use .select2-bootstrap-prepend and .select2-bootstrap-append on a Bootstrap 3 .input-group
* to let the contained Select2 widget know which edges should not be rounded as they are
* directly followed by another element.
*
* @see http://getbootstrap.com/components/#input-groups
*/
.input-group.select2-bootstrap-prepend [class^="select2-choice"] {
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
}
.input-group.select2-bootstrap-append [class^="select2-choice"] {
border-bottom-right-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.select2-dropdown-open [class^="select2-choice"] {
border-bottom-right-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.select2-dropdown-open.select2-drop-above [class^="select2-choice"] {
border-top-right-radius: 0 !important;
border-top-left-radius: 0 !important;
border-bottom-right-radius: 4px !important;
border-bottom-left-radius: 4px !important;
background: white;
filter: none;
}
.input-group.select2-bootstrap-prepend .select2-dropdown-open.select2-drop-above [class^="select2-choice"] {
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
}
.input-group.select2-bootstrap-append .select2-dropdown-open.select2-drop-above [class^="select2-choice"] {
border-bottom-right-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.input-group.input-group-sm.select2-bootstrap-prepend .select2-dropdown-open.select2-drop-above [class^="select2-choice"] {
border-bottom-right-radius: 3px !important;
}
.input-group.input-group-lg.select2-bootstrap-prepend .select2-dropdown-open.select2-drop-above [class^="select2-choice"] {
border-bottom-right-radius: 6px !important;
}
.input-group.input-group-sm.select2-bootstrap-append .select2-dropdown-open.select2-drop-above [class^="select2-choice"] {
border-bottom-left-radius: 3px !important;
}
.input-group.input-group-lg.select2-bootstrap-append .select2-dropdown-open.select2-drop-above [class^="select2-choice"] {
border-bottom-left-radius: 6px !important;
}
/**
* Adjust Select2's choices hover and selected styles to match Bootstrap 3's default dropdown styles.
*/
.select2-results .select2-highlighted {
color: white;
background-color: #337ab7;
}
/**
* Adjust alignment of Bootstrap 3 buttons in Bootstrap 3 Input Groups to address
* Multi Select2's height which - depending on how many elements have been selected -
* may grown higher than their initial size.
*/
.select2-bootstrap-append .select2-container-multiple,
.select2-bootstrap-append .input-group-btn,
.select2-bootstrap-append .input-group-btn .btn,
.select2-bootstrap-prepend .select2-container-multiple,
.select2-bootstrap-prepend .input-group-btn,
.select2-bootstrap-prepend .input-group-btn .btn {
vertical-align: top;
}
/**
* Make Multi Select2's choices match Bootstrap 3's default button styles.
*/
.select2-container-multi .select2-choices .select2-search-choice {
color: #555555;
background: white;
border-color: #cccccc;
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
-webkit-box-shadow: none;
box-shadow: none;
}
.select2-container-multi .select2-choices .select2-search-choice-focus {
background: #ebebeb;
border-color: #adadad;
color: #333333;
-webkit-box-shadow: none;
box-shadow: none;
}
/**
* Address Multi Select2's choice close-button vertical alignment.
*/
.select2-search-choice-close {
margin-top: -7px;
top: 50%;
}
/**
* Adjust the single Select2's clear button position (used to reset the select box
* back to the placeholder value and visible once a selection is made
* activated by Select2's "allowClear" option).
*/
.select2-container .select2-choice abbr {
top: 50%;
}
/**
* Adjust "no results" and "selection limit" messages to make use
* of Bootstrap 3's default "Alert" style.
*
* @see http://getbootstrap.com/components/#alerts-default
*/
.select2-results .select2-no-results,
.select2-results .select2-searching,
.select2-results .select2-selection-limit {
background-color: #fcf8e3;
color: #8a6d3b;
}
/**
* Address disabled Select2 styles.
*
* 1. For Select2 v.3.3.2.
* 2. Revert border-left:0 inherited from Select2's CSS to prevent the arrow
* from jumping when switching from disabled to enabled state and vice versa.
*/
.select2-container.select2-container-disabled .select2-choice,
.select2-container.select2-container-disabled .select2-choices {
cursor: not-allowed;
background-color: #eeeeee;
border-color: #cccccc;
}
.select2-container.select2-container-disabled .select2-choice .select2-arrow,
.select2-container.select2-container-disabled .select2-choice div,
.select2-container.select2-container-disabled .select2-choices .select2-arrow,
.select2-container.select2-container-disabled .select2-choices div {
background-color: transparent;
border-left: 1px solid transparent;
/* 2 */
}
/**
* Address Select2's loading indicator position - which should not stick
* to the right edge of Select2's search input.
*
* 1. in .select2-search input
* 2. in Multi Select2's .select2-search-field input
* 3. in the status-message of infinite-scroll with remote data (@see http://ivaynberg.github.io/select2/#infinite)
*
* These styles alter Select2's default background-position of 100%
* and supply the new background-position syntax to browsers which support it:
*
* 1. Android, Safari < 6/Mobile, IE<9: change to a relative background-position of 99%
* 2. Chrome 25+, Firefox 13+, IE 9+, Opera 10.5+: use the new CSS3-background-position syntax
*
* @see http://www.w3.org/TR/css3-background/#background-position
*
* @todo Since both Select2 and Bootstrap 3 only support IE8 and above,
* we could use the :after-pseudo-element to display the loading indicator.
* Alternatively, we could supply an altered loading indicator image which already
* contains an offset to the right.
*/
.select2-search input.select2-active,
.select2-container-multi .select2-choices .select2-search-field input.select2-active,
.select2-more-results.select2-active {
background-position: 99%;
/* 4 */
background-position: right 4px center;
/* 5 */
}
/**
* To support Select2 pre v3.4.2 in combination with Bootstrap v3.2.0,
* ensure that .select2-offscreen width, height and position can not be overwritten.
*
* This adresses changes in Bootstrap somewhere after the initial v3.0.0 which -
* in combination with Select2's pre-v3.4.2 CSS missing the "!important" after
* the following rules - allow Bootstrap to overwrite the latter, which results in
* the original <select> element Select2 is replacing not be properly being hidden
* when used in a "Bootstrap Input Group with Addon".
**/
.select2-offscreen,
.select2-offscreen:focus {
width: 1px !important;
height: 1px !important;
position: absolute !important;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

0
tests/__init__.py Normal file
View File

42
tests/core_tests.py Normal file
View File

@@ -0,0 +1,42 @@
import imp
import os
import unittest
import urllib2
os.environ['PANORAMIX_CONFIG'] = 'tests.panoramix_test_config'
from flask.ext.testing import LiveServerTestCase, TestCase
from panoramix import app, db, models
BASE_DIR = app.config.get("BASE_DIR")
cli = imp.load_source('cli', BASE_DIR + "/bin/panoramix")
class LiveTest(TestCase):
def create_app(self):
app.config['LIVESERVER_PORT'] = 8873
app.config['TESTING'] = True
return app
def setUp(self):
print BASE_DIR
def test_load_examples(self):
cli.load_examples(sample=True)
def test_slices(self):
Slc = models.Slice
for slc in db.session.query(Slc).all():
self.client.get(slc.slice_url)
viz = slc.viz
if hasattr(viz, 'get_json'):
self.client.get(viz.get_json())
def test_dashboard(self):
for dash in db.session.query(models.Dashboard).all():
self.client.get(dash.url)
def tearDown(self):
pass
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,6 @@
from panoramix.config import *
AUTH_USER_REGISTRATION_ROLE = 'alpha'
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/panoramix_unittests.db'
DEBUG = True
PANORAMIX_WEBSERVER_PORT = 8081