mirror of https://github.com/apache/superset.git
Integrated the admin
This commit is contained in:
parent
66dca37c9c
commit
3bce904454
|
@ -0,0 +1,86 @@
|
|||
{% import 'admin/layout.html' as layout with context -%}
|
||||
{% import 'admin/static.html' as admin_static with context %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}{% if admin_view.category %}{{ admin_view.category }} - {% endif %}{{ admin_view.name }} - {{ admin_view.admin.name }}{% endblock %}</title>
|
||||
{% block head_meta %}
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
{% endblock %}
|
||||
{% block head_css %}
|
||||
<link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='bootstrap-theme.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='main.css') }}" rel="stylesheet">
|
||||
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css') }}" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{url_for('static', filename='select2-bootstrap.css')}}">
|
||||
<link rel="stylesheet" href="{{url_for('static', filename='select2.min.css')}}">
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
{% block head_tail %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
|
||||
<div class="container">
|
||||
<!-- Brand and toggle get grouped for better mobile display -->
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#admin-navbar-collapse">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
{% block brand %}
|
||||
<a class="navbar-brand" href="#">{{ admin_view.admin.name }}</a>
|
||||
{% endblock %}
|
||||
</div>
|
||||
<!-- navbar content -->
|
||||
<div class="collapse navbar-collapse" id="admin-navbar-collapse">
|
||||
{% block main_menu %}
|
||||
<ul class="nav navbar-nav">
|
||||
{{ layout.menu() }}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
{% block menu_links %}
|
||||
<ul class="nav navbar-right">
|
||||
{{ layout.menu_links() }}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
{% block access_control %}
|
||||
{% endblock %}
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% block messages %}
|
||||
{{ layout.messages() }}
|
||||
{% endblock %}
|
||||
|
||||
{% set render_ctx = h.resolve_ctx() %}
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block tail_js %}
|
||||
<script src="{{ admin_static.url(filename='vendor/jquery-2.1.1.min.js') }}" type="text/javascript"></script>
|
||||
<script src="{{ admin_static.url(filename='bootstrap/bootstrap3/js/bootstrap.min.js') }}" type="text/javascript"></script>
|
||||
<script src="{{ admin_static.url(filename='vendor/moment-2.8.4.min.js') }}" type="text/javascript"></script>
|
||||
<script src="{{ admin_static.url(filename='vendor/select2/select2.min.js') }}" type="text/javascript"></script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block tail %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
216
panoramix/app.py
216
panoramix/app.py
|
@ -1,40 +1,78 @@
|
|||
from dateutil.parser import parse
|
||||
from datetime import timedelta
|
||||
from flask import Flask, request, Blueprint
|
||||
from panoramix import settings, viz
|
||||
from flask import Flask, request, Blueprint, url_for, Markup
|
||||
from flask.ext.sqlalchemy import SQLAlchemy
|
||||
from flask.ext.admin import Admin, BaseView, expose, AdminIndexView
|
||||
from panoramix import settings, viz, models
|
||||
from flask_bootstrap import Bootstrap
|
||||
from wtforms import Form, SelectMultipleField, SelectField, TextField
|
||||
from wtforms.fields import Field
|
||||
import pandas as pd
|
||||
from flask_admin.contrib import sqla
|
||||
|
||||
|
||||
pd.set_option('display.max_colwidth', -1)
|
||||
since_l = {
|
||||
'1hour': timedelta(hours=1),
|
||||
'1day': timedelta(days=1),
|
||||
'7days': timedelta(days=7),
|
||||
'28days': timedelta(days=28),
|
||||
'all': timedelta(days=365*100)
|
||||
}
|
||||
|
||||
client = settings.get_pydruid_client()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class DruidDataSource(object):
|
||||
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.cols = self.latest_metadata()
|
||||
self.col_names = sorted([col for col in self.cols.keys()])
|
||||
self.col_names = sorted([
|
||||
col for col in self.cols.keys()
|
||||
if not col.startswith("_") and col not in self.metrics])
|
||||
|
||||
def latest_metadata(self):
|
||||
max_time = client.time_boundary(
|
||||
datasource=self.name)[0]['result']['maxTime']
|
||||
results = client.time_boundary(datasource=self.name)
|
||||
max_time = results[0]['result']['maxTime']
|
||||
max_time = parse(max_time)
|
||||
intervals = (max_time - timedelta(seconds=1)).isoformat() + '/'
|
||||
intervals += max_time.isoformat()
|
||||
return client.segment_metadata(
|
||||
intervals += (max_time + timedelta(seconds=1)).isoformat()
|
||||
segment_metadata = client.segment_metadata(
|
||||
datasource=self.name,
|
||||
intervals=intervals)[-1]['columns']
|
||||
intervals=intervals)
|
||||
return segment_metadata[-1]['columns']
|
||||
|
||||
@property
|
||||
def metrics(self):
|
||||
return [
|
||||
k for k, v in self.cols.items()
|
||||
if v['type'] != 'STRING' and not k.startswith('_')]
|
||||
|
||||
def sync_to_db(self):
|
||||
DS = Datasource
|
||||
datasource = DS.query.filter_by(datasource_name=self.name).first()
|
||||
if not datasource:
|
||||
db.session.add(DS(datasource_name=self.name))
|
||||
for col in self.cols:
|
||||
col_obj = Column.query.filter_by(datasource_name=self.name, column_name=col).first()
|
||||
datatype = self.cols[col]['type']
|
||||
if not col_obj:
|
||||
col_obj = Column(datasource_name=self.name, column_name=col)
|
||||
db.session.add(col_obj)
|
||||
if datatype == "STRING":
|
||||
col_obj.groupby = True
|
||||
if col_obj:
|
||||
col_obj.type = self.cols[col]['type']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def form_factory(datasource, form_args=None):
|
||||
grain = ['all', 'none', 'minute', 'hour', 'day']
|
||||
|
@ -50,48 +88,154 @@ def form_factory(datasource, form_args=None):
|
|||
except:
|
||||
pass
|
||||
|
||||
class QueryForm(Form):
|
||||
class QueryForm(OmgWtForm):
|
||||
viz_type = SelectField(
|
||||
'Viz',
|
||||
choices=[(k, v.verbose_name) for k, v in viz.viz_types.items()])
|
||||
metric = SelectField(
|
||||
'Metric', choices=[(m, m) for m in datasource.metrics])
|
||||
groupby = SelectMultipleField(
|
||||
'Group by', choices=[(m, m) for m in datasource.col_names])
|
||||
granularity = SelectField(
|
||||
'Granularity', choices=[(g, g) for g in grain])
|
||||
since = SelectField(
|
||||
'Since', choices=[(s, s) for s in since_l.keys()])
|
||||
'Since', choices=[(s, s) for s in settings.since_l.keys()],
|
||||
default="all")
|
||||
limit = SelectField(
|
||||
'Limit', choices=[(s, s) for s in limits])
|
||||
flt_col_1 = SelectField(
|
||||
'Filter 1', choices=[(m, m) for m in datasource.col_names])
|
||||
flt_op_1 = SelectField(
|
||||
'Filter 1', choices=[(m, m) for m in ['==', '!=', 'in',]])
|
||||
flt_eq_1 = TextField("Super")
|
||||
for i in range(10):
|
||||
setattr(QueryForm, 'flt_col_' + str(i), SelectField(
|
||||
'Filter 1', choices=[(m, m) for m in datasource.col_names]))
|
||||
setattr(QueryForm, 'flt_op_' + str(i), SelectField(
|
||||
'Filter 1', choices=[(m, m) for m in ['==', '!=', 'in',]]))
|
||||
setattr(QueryForm, 'flt_eq_' + str(i), TextField("Super"))
|
||||
return QueryForm
|
||||
|
||||
|
||||
"""
|
||||
bp = Blueprint(
|
||||
'panoramix', __name__,
|
||||
template_folder='templates',
|
||||
static_folder='static')
|
||||
"""
|
||||
|
||||
|
||||
@bp.route("/datasource/<name>/")
|
||||
def datasource(name):
|
||||
viz_type = request.args.get("viz_type", "table")
|
||||
datasource = DruidDataSource(name)
|
||||
obj = viz.viz_types[viz_type](
|
||||
datasource,
|
||||
form_class=form_factory(datasource, request.args),
|
||||
form_data=request.args)
|
||||
return obj.render()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = settings.SQLALCHEMY_DATABASE_URI
|
||||
db = SQLAlchemy(app)
|
||||
app.secret_key = "monkeys"
|
||||
#app.register_blueprint(bp, url_prefix='/panoramix')
|
||||
Bootstrap(app)
|
||||
admin = Admin(
|
||||
app, name = "Panoramix",
|
||||
template_mode='bootstrap3')
|
||||
|
||||
|
||||
|
||||
class Datasource(db.Model):
|
||||
__tablename__ = 'datasources'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
datasource_name = db.Column(db.String(256), unique=True)
|
||||
is_featured = db.Column(db.Boolean, default=False)
|
||||
is_hidden = db.Column(db.Boolean, default=False)
|
||||
description = db.Column(db.Text)
|
||||
created_dttm = db.Column(db.DateTime, default=db.func.now())
|
||||
|
||||
|
||||
class Column(db.Model):
|
||||
__tablename__ = 'columns'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
datasource_name = db.Column(
|
||||
db.String(256),
|
||||
db.ForeignKey('datasources.datasource_name'))
|
||||
column_name = db.Column(db.String(256))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
type = db.Column(db.String(32))
|
||||
groupby = db.Column(db.Boolean, default=False)
|
||||
count_distinct = db.Column(db.Boolean, default=False)
|
||||
sum = db.Column(db.Boolean, default=False)
|
||||
max = db.Column(db.Boolean, default=False)
|
||||
min = db.Column(db.Boolean, default=False)
|
||||
datasource = db.relationship('Datasource',
|
||||
backref=db.backref('columns', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return self.column_name
|
||||
|
||||
|
||||
class JsUdf(db.Model):
|
||||
__tablename__ = 'udfs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
datasource_name = db.Column(
|
||||
db.String(256),
|
||||
db.ForeignKey('datasources.datasource_name'))
|
||||
udf_name = db.Column(db.String(256))
|
||||
column_list = db.Column(db.String(1024))
|
||||
code = db.Column(db.Text)
|
||||
datasource = db.relationship('Datasource',
|
||||
backref=db.backref('udfs', lazy='dynamic'))
|
||||
|
||||
|
||||
def datasource_link(v, c, m, p):
|
||||
url = '/admin/datasourceview/datasource/{}/'.format(m.datasource_name)
|
||||
return Markup('<a href="{url}">{m.datasource_name}</a>'.format(**locals()))
|
||||
|
||||
|
||||
class DatasourceAdmin(sqla.ModelView):
|
||||
inline_models = (Column, JsUdf,)
|
||||
column_formatters = dict(datasource_name=datasource_link)
|
||||
|
||||
|
||||
class DatasourceView(BaseView):
|
||||
@expose('/')
|
||||
def index(self):
|
||||
return ""
|
||||
@expose("/datasource/<datasource_name>/")
|
||||
def datasource(self, datasource_name):
|
||||
viz_type = request.args.get("viz_type", "table")
|
||||
datasource = DruidDataSource(datasource_name)
|
||||
obj = viz.viz_types[viz_type](
|
||||
datasource,
|
||||
form_class=form_factory(datasource, request.args),
|
||||
form_data=request.args,
|
||||
admin_view=self)
|
||||
if obj.df is None or obj.df.empty:
|
||||
return obj.render_no_data()
|
||||
return obj.render()
|
||||
|
||||
|
||||
@expose("/datasources/")
|
||||
def datasources():
|
||||
import requests
|
||||
import json
|
||||
endpoint = (
|
||||
"http://{COORDINATOR_HOST}:{COORDINATOR_PORT}/"
|
||||
"{COORDINATOR_BASE_ENDPOINT}/datasources"
|
||||
).format(**settings.__dict__)
|
||||
datasources = json.loads(requests.get(endpoint).text)
|
||||
for datasource in datasources:
|
||||
ds = DruidDataSource(datasource)
|
||||
ds.sync_to_db()
|
||||
|
||||
return json.dumps(datasources, indent=4)
|
||||
|
||||
|
||||
@expose("/datasource_metadata/<name>/")
|
||||
def datasource_metadata(name):
|
||||
import requests
|
||||
import json
|
||||
endpoint = (
|
||||
"http://{COORDINATOR_HOST}:{COORDINATOR_PORT}/"
|
||||
"{COORDINATOR_BASE_ENDPOINT}/datasource"
|
||||
).format(**settings.__dict__)
|
||||
|
||||
return str(datasources)
|
||||
|
||||
admin.add_view(DatasourceView(name="Datasource"))
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "monkeys"
|
||||
app.register_blueprint(bp, url_prefix='/panoramix')
|
||||
Bootstrap(app)
|
||||
|
||||
db.create_all()
|
||||
admin.add_view(DatasourceAdmin(Datasource, db.session, name="Datasources"))
|
||||
app.debug = True
|
||||
app.run(host='0.0.0.0', port=settings.FLASK_APP_PORT)
|
||||
|
|
|
@ -1,11 +1,26 @@
|
|||
from datetime import timedelta
|
||||
|
||||
FLASK_APP_PORT = 8088
|
||||
|
||||
ROW_LIMIT = 10000
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:////tmp/panoramix.db"
|
||||
|
||||
DRUID_HOST = '10.181.47.80'
|
||||
DRUID_PORT = 8088
|
||||
DRUID_PORT = 8080
|
||||
DRUID_BASE_ENDPOINT = 'druid/v2'
|
||||
|
||||
COORDINATOR_HOST = '10.168.176.249'
|
||||
COORDINATOR_PORT = '8080'
|
||||
COORDINATOR_BASE_ENDPOINT = 'druid/coordinator/v1'
|
||||
|
||||
since_l = {
|
||||
'1hour': timedelta(hours=1),
|
||||
'1day': timedelta(days=1),
|
||||
'7days': timedelta(days=7),
|
||||
'28days': timedelta(days=28),
|
||||
'all': timedelta(days=365*100)
|
||||
}
|
||||
|
||||
def get_pydruid_client():
|
||||
from pydruid import client
|
||||
return client.PyDruid(
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
|
@ -19,53 +19,10 @@ 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;
|
||||
}
|
||||
|
@ -97,18 +54,26 @@ input, select {
|
|||
font-family: monospace;
|
||||
}
|
||||
|
||||
#sql {
|
||||
border: 1px solid #CCC;
|
||||
border-radius: 5px;
|
||||
table.dataframe {
|
||||
font-size: 12px;
|
||||
}
|
||||
.ace_editor div {
|
||||
font: inherit!important
|
||||
table.dataframe tbody tr td {
|
||||
padding: 2px;
|
||||
}
|
||||
#ace_container {
|
||||
margin: 10px 0px;
|
||||
table thead th {
|
||||
background-color: #F3F3F3;
|
||||
}
|
||||
#sql_ace {
|
||||
visibility: hidden;
|
||||
table.dataframe.dataTable thead > tr > th {
|
||||
padding: 10px 20px 10px 10px;
|
||||
}
|
||||
table.dataTable.dataframe thead .sorting {
|
||||
background: url('sort_both.png') no-repeat center right
|
||||
}
|
||||
table.dataTable.dataframe thead .sorting_desc {
|
||||
background: url('sort_desc.png') no-repeat center right
|
||||
}
|
||||
table.dataTable.dataframe thead .sorting_asc {
|
||||
background: url('sort_asc.png') no-repeat center right
|
||||
}
|
||||
.no-wrap {
|
||||
white-space: nowrap;
|
||||
|
@ -123,85 +88,3 @@ body div.panel {
|
|||
.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 */
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 165 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.5 MiB |
|
@ -0,0 +1,2 @@
|
|||
{% extends "admin/base.html" %}
|
||||
|
|
@ -1,64 +1,2 @@
|
|||
{% extends "bootstrap/base.html" %}
|
||||
{% extends "index.html" %}
|
||||
|
||||
{% block title %}Panoramix - A Druid UI{% endblock %}
|
||||
{% block html_attribs %} lang="en"{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{super()}}
|
||||
<link rel="stylesheet" href="{{url_for('.static', filename='bootstrap-theme.css')}}">
|
||||
<link rel="stylesheet" href="{{url_for('.static', filename='main.css')}}">
|
||||
<link rel="stylesheet" href="{{url_for('.static', filename='select2.min.css')}}">
|
||||
<link rel="stylesheet" href="{{url_for('.static', filename='select2-bootstrap.css')}}">
|
||||
{% endblock %}
|
||||
|
||||
{% block navbar %}
|
||||
<nav class="navbar navbar-inverse navbar-fixed-top">
|
||||
<div class="container">
|
||||
<!-- Brand and toggle get grouped for better mobile display -->
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="#" class="pull-left">
|
||||
Panoramix
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Collect the nav links, forms, and other content for toggling -->
|
||||
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="active"><a href="#">Link <span class="sr-only">(current)</span></a></li>
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Dropdown <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#">Action</a></li>
|
||||
<li><a href="#">Another action</a></li>
|
||||
<li><a href="#">Something else here</a></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li><a href="#">Separated link</a></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li><a href="#">One more separated link</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
|
||||
</ul>
|
||||
</div><!-- /.navbar-collapse -->
|
||||
</div><!-- /.container-fluid -->
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script src="{{url_for('.static', filename='select2.min.js')}}"></script>
|
||||
<script>
|
||||
$( document ).ready(function() {
|
||||
$(".select2").select2();
|
||||
$(".select2_tags").select2({tags: true});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,44 +1,89 @@
|
|||
{% extends "panoramix/base.html" %}
|
||||
{% block content %}
|
||||
{% block styles %}
|
||||
{{super()}}
|
||||
<style>
|
||||
form .row {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
form .col {
|
||||
padding-right:0px;
|
||||
padding-left:0px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<div class="col-md-3">
|
||||
<h3>{{ datasource }}</h3>
|
||||
<form method="GET">
|
||||
<div>{{ form.viz_type.label }}: {{ form.viz_type(class_="form-control select2") }}</div>
|
||||
<div>{{ form.granularity.label }}: {{ form.granularity(class_="form-control select2") }}</div>
|
||||
<div>{{ form.since.label }}: {{ form.since(class_="form-control select2") }}</div>
|
||||
<div>{{ form.groupby.label }}: {{ form.groupby(class_="form-control select2") }}</div>
|
||||
<div>{{ form.limit.label }}: {{ form.limit(class_="form-control select2_tags") }}</div>
|
||||
<hr>
|
||||
<h4>Filters</h4>
|
||||
<div>
|
||||
<span style="width: 100px;">{{ form.flt_col_1(class_="form-control select2") }}</span>
|
||||
<span>{{ form.flt_op_1(class_="form-control select2 input-sm") }}</span>
|
||||
<span>{{ form.flt_eq_1(class_="form-control") }}</span>
|
||||
<div class="col-md-3">
|
||||
<h3>
|
||||
{{ datasource.name }}
|
||||
<a href="/admin/datasource/edit/?id={{ datasource.id }}"><span class="glyphicon glyphicon-edit"></span></a>
|
||||
</h3>
|
||||
|
||||
<hr>
|
||||
<form method="GET">
|
||||
<div>{{ form.viz_type.label }}: {{ form.viz_type(class_="form-control") }}</div>
|
||||
<div>{{ form.metric.label }}: {{ form.metric(class_="form-control select2") }}</div>
|
||||
<div>{{ form.granularity.label }}: {{ form.granularity(class_="form-control") }}</div>
|
||||
<div>{{ form.since.label }}: {{ form.since(class_="form-control") }}</div>
|
||||
<div>{{ form.groupby.label }}: {{ form.groupby(class_="form-control") }}</div>
|
||||
<div>{{ form.limit.label }}: {{ form.limit(class_="form-control") }}</div>
|
||||
<hr>
|
||||
<h4>Filters</h4>
|
||||
<div id="filters">
|
||||
{% for i in range(10) %}
|
||||
<div id="flt{{ i }}" class="{{ "hidden" if i != 1 }}">
|
||||
<div class="row">
|
||||
<span class="" style="width: 100px;">{{ form['flt_col_' ~ i](class_="form-control select2 inc") }}</span>
|
||||
</div>
|
||||
<hr>
|
||||
<input type="submit" class="btn btn-primary" value="Druidify!">
|
||||
<hr>
|
||||
<img src="{{ url_for("static", filename="tux_panoramix.png") }}" width=250>
|
||||
</form><br>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="col col-md-3">{{ form['flt_op_' ~ i](class_="form-control select2 input-sm inc") }}</span>
|
||||
<span class="col col-md-7">{{ form['flt_eq_' ~ i](class_="form-control inc") }}</span>
|
||||
<button type="col col-md-2" class="btn btn-sm" aria-label="Delete filter">
|
||||
<span class="glyphicon glyphicon-minus" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<hr/>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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>
|
||||
<input type="submit" class="btn btn-primary" value="Druidify!">
|
||||
<hr style="margin-bottom: 0px;">
|
||||
<img src="{{ url_for("static", filename="panoramix.png") }}" width=250>
|
||||
</form><br>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
<h3>{{ verbose_viz_type }}</h3>
|
||||
{% block viz %}
|
||||
{% endblock %}
|
||||
<div class="col-md-9">
|
||||
<h3>{{ viz.verbose_name }}</h3>
|
||||
<hr/>
|
||||
{% block viz %}
|
||||
{% endblock %}
|
||||
|
||||
{% if debug %}
|
||||
<h3>Results</h3>
|
||||
<pre>
|
||||
{{ results }}
|
||||
</pre>
|
||||
{% if debug %}
|
||||
<h3>Results</h3>
|
||||
<pre>
|
||||
{{ results }}
|
||||
</pre>
|
||||
|
||||
<h3>Latest Segment Metadata</h3>
|
||||
<pre>
|
||||
<h3>Latest Segment Metadata</h3>
|
||||
<pre>
|
||||
{{ latest_metadata }}
|
||||
</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block tail %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
$( document ).ready(function() {
|
||||
//`:$(".select2").select2();
|
||||
// $(".select2_tags").select2({tags: true});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{% extends "panoramix/datasource.html" %}
|
||||
|
||||
{% block viz %}
|
||||
<span class="alert alert-danger">No data: review your incantations.</span>
|
||||
{% endblock %}
|
|
@ -0,0 +1,56 @@
|
|||
{% extends "bootstrap/base.html" %}
|
||||
|
||||
{% block title %}Panoramix - A Druid UI{% endblock %}
|
||||
{% block html_attribs %} lang="en"{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{super()}}
|
||||
<link rel="icon" type="image/png" href="{{url_for('.static', filename='chaudron.png')}}">
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
{{super()}}
|
||||
<link rel="stylesheet" href="{{url_for('.static', filename='bootstrap-theme.css')}}">
|
||||
<link rel="stylesheet" href="{{url_for('.static', filename='main.css')}}">
|
||||
<link rel="stylesheet" href="{{url_for('.static', filename='select2.min.css')}}">
|
||||
<link rel="stylesheet" href="{{url_for('.static', filename='select2-bootstrap.css')}}">
|
||||
{% endblock %}
|
||||
|
||||
{% block navbar %}
|
||||
<nav class="navbar navbar-inverse navbar-fixed-top">
|
||||
<div class="container">
|
||||
<!-- Brand and toggle get grouped for better mobile display -->
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="#" class="pull-left">
|
||||
Panoramix
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Collect the nav links, forms, and other content for toggling -->
|
||||
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="active"><a href="#">Link <span class="sr-only">(current)</span></a></li>
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Dropdown <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#">Action</a></li>
|
||||
<li><a href="#">Another action</a></li>
|
||||
<li><a href="#">Something else here</a></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li><a href="#">Separated link</a></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li><a href="#">One more separated link</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right"></ul>
|
||||
</div><!-- /.navbar-collapse -->
|
||||
</div><!-- /.container-fluid -->
|
||||
</nav>
|
||||
{% endblock %}
|
|
@ -3,7 +3,7 @@
|
|||
<div id="chart"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% block tail %}
|
||||
{{ super() }}
|
||||
<script src="{{ url_for("static", filename="highcharts.js") }}"></script>
|
||||
<script>
|
||||
|
|
|
@ -5,4 +5,7 @@
|
|||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
$('table').css('background-color', 'white');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
181
panoramix/viz.py
181
panoramix/viz.py
|
@ -1,9 +1,11 @@
|
|||
from pydruid import client
|
||||
from pydruid.utils.filters import Dimension, Filter
|
||||
from datetime import datetime
|
||||
from flask import render_template, flash
|
||||
import pandas as pd
|
||||
from pandas_highcharts.core import serialize
|
||||
from panoramix import settings
|
||||
from pydruid.utils import aggregators as agg
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
CHART_ARGS = {
|
||||
|
@ -12,33 +14,24 @@ CHART_ARGS = {
|
|||
'render_to': 'chart',
|
||||
}
|
||||
|
||||
# temp hack
|
||||
metric = "count"
|
||||
|
||||
|
||||
class BaseViz(object):
|
||||
verbose_name = "Base Viz"
|
||||
template = "panoramix/datasource.html"
|
||||
def __init__(self, datasource, form_class, form_data):
|
||||
def __init__(self, datasource, form_class, form_data, admin_view):
|
||||
self.datasource = datasource
|
||||
self.form_class = form_class
|
||||
self.form_data = form_data
|
||||
self.metric = form_data.get('metric')
|
||||
self.admin_view = admin_view
|
||||
self.df = self.bake_query()
|
||||
if self.df is not None:
|
||||
self.df.timestamp = pd.to_datetime(self.df.timestamp)
|
||||
self.df_prep()
|
||||
self.form_prep()
|
||||
|
||||
def bake_query(self):
|
||||
ds = self.datasource
|
||||
def query_filters(self):
|
||||
args = self.form_data
|
||||
groupby = args.getlist("groupby") or []
|
||||
granularity = args.get("granularity")
|
||||
metric = "count"
|
||||
limit = int(args.get("limit", ROW_LIMIT)) or ROW_LIMIT
|
||||
since = args.get("since", "all")
|
||||
from_dttm = (datetime.now() - since_l[since]).isoformat()
|
||||
|
||||
# Building filters
|
||||
i = 1
|
||||
filters = None
|
||||
|
@ -57,8 +50,7 @@ class BaseViz(object):
|
|||
for s in eq.split(','):
|
||||
s = s.strip()
|
||||
fields.append(Filter.build_filter(Dimension(col)==s))
|
||||
cond = Filter(type="or", fields=fields)
|
||||
|
||||
cond = Filter(type="and", fields=fields)
|
||||
|
||||
if filters:
|
||||
filters = cond and filters
|
||||
|
@ -67,29 +59,42 @@ class BaseViz(object):
|
|||
else:
|
||||
break
|
||||
i += 1
|
||||
return filters
|
||||
|
||||
kw = {}
|
||||
if filters:
|
||||
kw['filter'] = filters
|
||||
query.groupby(
|
||||
datasource=ds.name,
|
||||
granularity=granularity or 'all',
|
||||
intervals=from_dttm + '/' + datetime.now().isoformat(),
|
||||
dimensions=groupby,
|
||||
aggregations={"count": client.doublesum(metric)},
|
||||
#filter=filters,
|
||||
limit_spec={
|
||||
def query_obj(self):
|
||||
ds = self.datasource
|
||||
args = self.form_data
|
||||
groupby = args.getlist("groupby") or []
|
||||
granularity = args.get("granularity")
|
||||
metric = "count"
|
||||
limit = int(
|
||||
args.get("limit", settings.ROW_LIMIT)) or settings.ROW_LIMIT
|
||||
since = args.get("since", "all")
|
||||
from_dttm = (datetime.now() - settings.since_l[since]).isoformat()
|
||||
d = {
|
||||
'datasource': ds.name,
|
||||
'granularity': granularity or 'all',
|
||||
'intervals': from_dttm + '/' + datetime.now().isoformat(),
|
||||
'dimensions': groupby,
|
||||
'aggregations': {"count": agg.doublesum(metric)},
|
||||
'limit_spec': {
|
||||
"type": "default",
|
||||
"limit": limit,
|
||||
"columns": [{
|
||||
"dimension" : metric,
|
||||
"direction" : "descending",
|
||||
},],
|
||||
"dimension": metric,
|
||||
"direction": "descending",
|
||||
}],
|
||||
},
|
||||
**kw
|
||||
)
|
||||
return query.export_pandas()
|
||||
}
|
||||
filters = self.query_filters()
|
||||
if filters:
|
||||
d['filter'] = filters
|
||||
return d
|
||||
|
||||
def bake_query(self):
|
||||
client = settings.get_pydruid_client()
|
||||
client.groupby(**self.query_obj())
|
||||
return client.export_pandas()
|
||||
|
||||
def df_prep(self, ):
|
||||
pass
|
||||
|
@ -97,17 +102,21 @@ class BaseViz(object):
|
|||
def form_prep(self):
|
||||
pass
|
||||
|
||||
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 render_template(
|
||||
self.template, form=form)
|
||||
return self.admin_view.render(
|
||||
self.template, form=form, viz=self, datasource=self.datasource,
|
||||
*args, **kwargs)
|
||||
|
||||
|
||||
class TableViz(BaseViz):
|
||||
verbose_name = "Table View"
|
||||
template = 'panoramix/viz_table.html'
|
||||
def render(self):
|
||||
form = self.form_class(self.form_data)
|
||||
if self.df is None or self.df.empty:
|
||||
flash("No data.", "error")
|
||||
table = None
|
||||
|
@ -115,53 +124,85 @@ class TableViz(BaseViz):
|
|||
if self.form_data.get("granularity") == "all":
|
||||
del self.df['timestamp']
|
||||
table = self.df.to_html(
|
||||
classes=["table", "table-striped", 'table-bordered'],
|
||||
classes=[
|
||||
'table', 'table-striped', 'table-bordered',
|
||||
'table-condensed'],
|
||||
index=False)
|
||||
return render_template(
|
||||
self.template, form=form, table=table)
|
||||
return super(TableViz, self).render(table=table)
|
||||
|
||||
|
||||
class HighchartsViz(BaseViz):
|
||||
verbose_name = "Base Highcharts Viz"
|
||||
template = 'panoramix/viz_highcharts.html'
|
||||
chart_kind = 'line'
|
||||
def render(self, *args, **kwargs):
|
||||
form = self.form_class(self.form_data)
|
||||
if self.df is None or self.df.empty:
|
||||
flash("No data.", "error")
|
||||
else:
|
||||
table = self.df.to_html(
|
||||
classes=["table", "table-striped", 'table-bordered'],
|
||||
index=False)
|
||||
return render_template(
|
||||
self.template, form=form, table=table,
|
||||
*args, **kwargs)
|
||||
|
||||
|
||||
class TimeSeriesViz(HighchartsViz):
|
||||
verbose_name = "Time Series - Line Chart"
|
||||
chart_kind = "line"
|
||||
|
||||
def render(self):
|
||||
metric = self.metric
|
||||
df = self.df
|
||||
df = df.pivot_table(
|
||||
index="timestamp",
|
||||
columns=[
|
||||
col for col in df.columns if col not in ["timestamp", metric]],
|
||||
values=[metric])
|
||||
chart_js = serialize(
|
||||
df, kind=self.chart_kind, **CHART_ARGS)
|
||||
chart_js = serialize(df, kind=self.chart_kind, **CHART_ARGS)
|
||||
return super(TimeSeriesViz, self).render(chart_js=chart_js)
|
||||
|
||||
def bake_query(self):
|
||||
"""
|
||||
Doing a 2 phase query where we limit the number of series.
|
||||
"""
|
||||
client = settings.get_pydruid_client()
|
||||
qry = self.query_obj()
|
||||
qry['granularity'] = "all"
|
||||
client.groupby(**qry)
|
||||
df = client.export_pandas()
|
||||
dims = qry['dimensions']
|
||||
filters = []
|
||||
for index, row in df.iterrows():
|
||||
fields = []
|
||||
for dim in dims:
|
||||
f = Filter.build_filter(Dimension(dim) == row[dim])
|
||||
fields.append(f)
|
||||
if len(fields) > 1:
|
||||
filters.append(Filter.build_filter(Filter(type="and", fields=fields)))
|
||||
elif fields:
|
||||
filters.append(fields[0])
|
||||
|
||||
qry = self.query_obj()
|
||||
if filters:
|
||||
ff = Filter(type="or", fields=filters)
|
||||
qry['filter'] = ff
|
||||
del qry['limit_spec']
|
||||
client.groupby(**qry)
|
||||
return client.export_pandas()
|
||||
|
||||
|
||||
class TimeSeriesAreaViz(TimeSeriesViz):
|
||||
verbose_name = "Time Series - Area Chart"
|
||||
chart_kind = "area"
|
||||
|
||||
|
||||
class TimeSeriesBarViz(TimeSeriesViz):
|
||||
verbose_name = "Time Series - Bar Chart"
|
||||
chart_kind = "bar"
|
||||
|
||||
|
||||
class DistributionBarViz(HighchartsViz):
|
||||
verbose_name = "Distribution - Bar Chart"
|
||||
chart_kind = "bar"
|
||||
|
||||
def query_obj(self):
|
||||
d = super(DistributionBarViz, self).query_obj()
|
||||
d['granularity'] = "all"
|
||||
return d
|
||||
|
||||
def render(self):
|
||||
metric = self.metric
|
||||
df = self.df
|
||||
df = df.pivot_table(
|
||||
index=[
|
||||
|
@ -172,9 +213,33 @@ class DistributionBarViz(HighchartsViz):
|
|||
df, kind=self.chart_kind, **CHART_ARGS)
|
||||
return super(DistributionBarViz, self).render(chart_js=chart_js)
|
||||
|
||||
viz_types = {
|
||||
'table': TableViz,
|
||||
'line': TimeSeriesViz,
|
||||
'area': TimeSeriesAreaViz,
|
||||
'dist_bar': DistributionBarViz,
|
||||
}
|
||||
|
||||
class DistributionPieViz(HighchartsViz):
|
||||
verbose_name = "Distribution - Pie Chart"
|
||||
chart_kind = "pie"
|
||||
|
||||
def query_obj(self):
|
||||
d = super(DistributionPieViz, self).query_obj()
|
||||
d['granularity'] = "all"
|
||||
return d
|
||||
|
||||
def render(self):
|
||||
metric = self.metric
|
||||
df = self.df
|
||||
df = df.pivot_table(
|
||||
index=[
|
||||
col for col in df.columns if col not in ['timestamp', metric]],
|
||||
values=[metric])
|
||||
df = df.sort(metric, ascending=False)
|
||||
chart_js = serialize(
|
||||
df, kind=self.chart_kind, **CHART_ARGS)
|
||||
return super(DistributionPieViz, self).render(chart_js=chart_js)
|
||||
|
||||
viz_types = OrderedDict([
|
||||
['table', TableViz],
|
||||
['line', TimeSeriesViz],
|
||||
['area', TimeSeriesAreaViz],
|
||||
['bar', TimeSeriesBarViz],
|
||||
['dist_bar', DistributionBarViz],
|
||||
['pie', DistributionPieViz],
|
||||
])
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
flask
|
||||
flask-admin
|
||||
flask-bootstrap
|
||||
flask-sqlalchemy
|
||||
pandas
|
||||
pandas-highcharts
|
||||
pydruid
|
||||
python-dateutil
|
||||
requests
|
||||
wtforms
|
||||
|
|
Loading…
Reference in New Issue