Panoramix v1 dashboards is up

This commit is contained in:
Maxime Beauchemin 2015-09-17 18:06:03 -07:00
parent 521b000ab6
commit f6753afa75
9 changed files with 266 additions and 98 deletions

View File

@ -196,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': {

View File

@ -8,11 +8,11 @@ from pydruid import client
from pydruid.utils.filters import Dimension, Filter
from sqlalchemy import (
Column, Integer, String, ForeignKey, Text, Boolean, DateTime)
from panoramix.utils import JSONEncodedDict
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
@ -22,8 +22,8 @@ import sqlparse
import requests
import textwrap
from panoramix import db, get_session
import config
from panoramix import db, get_session, config, utils
from panoramix.viz import viz_types
QueryResult = namedtuple('namedtuple', ['df', 'query', 'duration'])
@ -52,18 +52,32 @@ class Slice(Model, AuditMixin):
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_link(self):
def slice_url(self):
d = json.loads(self.params)
kwargs = "&".join([k + '=' + v for k, v in d.iteritems()])
url = (
from werkzeug.urls import Href
href = Href(
"/panoramix/{self.datasource_type}/"
"{self.datasource_id}/?{kwargs}").format(**locals())
"{self.datasource_id}/".format(self=self))
return href(d)
@property
def slice_link(self):
url = self.slice_url
return '<a href="{url}">{self.slice_name}</a>'.format(**locals())
@property
@ -92,6 +106,7 @@ class Dashboard(Model, AuditMixin):
__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')

View File

@ -7,12 +7,30 @@
{% endfor %}
<link rel="stylesheet" href="{{ url_for('static', filename="jquery.gridster.min.css") }}">
<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: auto;
box-shadow: 2px 2px 2px #AAA;
border-radius: 5px;
background-color: white;
}
.gridster .dragging,
.gridster .resizing {
opacity: 0.5;
}
img.loading {
width: 20px;
@ -23,6 +41,9 @@
}
.slice_title {
text-align: center;
font-weight: bold;
font-size: 14px;
padding: 5px;
}
div.gridster {
visibility: hidden
@ -35,21 +56,52 @@
{% endblock %}
{% block content_fluid %}
<div class="title"><h2>{{ dashboard.dashboard_title }}</h2></div>
<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 %}
<li
id="slice_{{ slice.id }}"
data-row="1"
data-col="{{ loop.index }}"
data-sizex="2"
data-sizey="2">
<div class="slice_title">
<h5>{{ slice.slice_name }}</h5>
</div>
<div id="slice_content_{{ slice.id }}"><img src="/static/loading.gif" class="loading"></div>
</li>
{% 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 }}">
<div class="slice_title" style="height: 0px;">
<div class="row">
<div class="col-md-4 text-left">
<a>
<i class="fa fa-arrows drag"></i>
</a>
</div>
<div class="col-md-4 text-middle">
<span>{{ slice.slice_name }}</span>
</div>
<div class="col-md-4 text-right" style="position: relative;">
<a href="{{ slice.slice_url }}"><i class="fa fa-play"></i></a>
<a class="refresh"><i class="fa fa-refresh"></i></a>
<a><i class="fa fa-gear"></i></a>
<a class="closewidget"><i class="fa fa-close"></i></a>
</div>
</div>
</div>
{{ viz_macros.viz_html(viz) }}
</li>
{% endfor %}
</ul>
</div>
@ -66,32 +118,49 @@
f = d3.format(".4s");
</script>
<script>
$( document ).ready(function() {
$(".gridster ul").gridster({
widget_margins: [5, 5],
widget_base_dimensions: [150, 150],
resize: {enabled: true}
});
var url = "/panoramix/table/2/?flt_col_0=gender&rolling_periods=&datasource_id=2&flt_op_0=in&slice_name=Super%20Slice&viz_type=line&since=50%20years%20ago&groupby=name&metrics=total&limit=25&flt_eq_0=&granularity=one%20day&datasource_name=baby_names&where=&until=now&rolling_type=mean&datasource_type=table&skip_libs=true&standalone=true";
$.ajax({
url: url,
success: function(result){
$("#slice_content_2").html(result);
},
async: true,
});
var url = "/panoramix/table/2/?flt_col_0=gender&datasource_id=2&flt_op_0=in&viz_type=pie&since=50%20years%20ago&until=now&metrics=total&limit=10&granularity=one%20day&datasource_name=baby_names&slice_name=Pie&where=&groupby=name&flt_eq_0=&datasource_type=table&skip_libs=true&standalone=true";
$.ajax({
url: url,
success: function(result){
$("#slice_content_3").html(result);
},
async: true,
});
$("div.gridster").css('visibility', 'visible');
});
$( 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);
});
});
</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

@ -1,6 +1,7 @@
{% macro viz_html(viz) %}
<div id="{{ viz.token }}" style="height:100%; width:100%;">
<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 %}
@ -16,19 +17,29 @@
useUTC: false
},
});
$("#viz_type").click(function(){
$("#queryform").submit();
});
var url = window.location + "&json=true";
console.log(url);
$.getJSON(url, function(data){
console.log(data);
$("#{{ viz.token }}").highcharts('{{ viz.chart_call }}', data);
})
.fail(function(xhr) {
var err = '<div class="alert alert-danger">' + xhr.responseText + '</div>';
$("#{{ viz.token }}").html(err);
});
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>
{% endmacro %}

View File

@ -35,15 +35,16 @@
{% macro viz_js(viz) %}
{% if viz.args.get("async") != "true" %}
<script>
var url = window.location + '&async=true&standalone=true&skip_libs=true';
var url = "{{ viz.get_url(async="true", standalone="true", skip_libs="true")|safe }}";
console.log(url);
$("#{{ viz.token }}").load(url, function(){
var table = $('table').DataTable({
paging: false,
})
searching: false,
});
$("#{{ viz.token }}").show();
table.column('-1').order( 'desc' ).draw();
$("img.loading").hide();
$("#{{ viz.token }} img.loading").hide();
});
</script>
{% endif %}

View File

@ -2,6 +2,34 @@ from datetime import datetime
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):

View File

@ -119,7 +119,7 @@ appbuilder.add_view(
class DashboardModelView(ModelView, DeleteMixin):
datamodel = SQLAInterface(models.Dashboard)
list_columns = ['dashboard_link', 'created_by']
edit_columns = ['dashboard_title', 'slices',]
edit_columns = ['dashboard_title', 'slices', 'position_json']
add_columns = edit_columns
@ -186,7 +186,7 @@ class DatasourceModelView(ModelView, DeleteMixin):
appbuilder.add_view(
DatasourceModelView,
"Druid Datasources",
icon="fa-cube")
icon="fa-cubes")
@app.route('/health')
@ -218,12 +218,13 @@ class Panoramix(BaseView):
viz_type = "table"
obj = viz.viz_types[viz_type](
table,
form_data=request.args, view=self)
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(
@ -233,11 +234,27 @@ class Panoramix(BaseView):
else:
return self.render_template("panoramix/viz.html", viz=obj)
@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("/datasource/<datasource_name>/")
def datasource(self, datasource_name):
viz_type = request.args.get("viz_type")
datasource = (
db.session
.query(models.Datasource)
@ -250,7 +267,7 @@ class Panoramix(BaseView):
viz_type = "table"
obj = viz.viz_types[viz_type](
datasource,
form_data=request.args, view=self)
form_data=request.args)
if request.args.get("json"):
return Response(
json.dumps(obj.get_query(), indent=4),
@ -265,8 +282,13 @@ class Panoramix(BaseView):
@expose("/save/")
def save(self):
session = db.session()
d = request.args.to_dict(flat=False)
as_list = ('metrics', 'groupby')
for m in as_list:
if d[m] and not isinstance(d[m]):
d[m] = [d[m]]
obj = models.Slice(
params=json.dumps(request.args.to_dict()),
params=json.dumps(d, indent=4),
viz_type=request.args.get('viz_type'),
datasource_name=request.args.get('datasource_name'),
datasource_id=request.args.get('datasource_id'),
@ -289,8 +311,11 @@ class Panoramix(BaseView):
.filter(models.Dashboard.id == id_)
.first()
)
pos_dict = {
int(o['slice_id']):o for o in json.loads(dashboard.position_json)}
return self.render_template(
"panoramix/dashboard.html", dashboard=dashboard)
"panoramix/dashboard.html", dashboard=dashboard,
pos_dict=pos_dict)
@has_access
@expose("/refresh_datasources/")

View File

@ -1,12 +1,15 @@
from datetime import datetime
from flask import flash, request
import pandas as pd
from collections import OrderedDict
import config
from datetime import datetime
from urllib import urlencode
import uuid
import numpy as np
from panoramix import utils
from flask import flash
from werkzeug.datastructures import MultiDict
from werkzeug.urls import Href
import numpy as np
import pandas as pd
from panoramix import utils, config
from panoramix.highchart import Highchart, HighchartBubble
from panoramix.forms import form_factory
@ -25,14 +28,28 @@ class BaseViz(object):
js_files = []
css_files = []
def __init__(self, datasource, form_data, view):
self.token = form_data.get('token', 'token_' + uuid.uuid4().hex[:8])
def __init__(self, datasource, form_data):
self.datasource = datasource
self.view = view
if isinstance(form_data, MultiDict):
self.args = form_data.to_dict(flat=True)
else:
self.args = form_data
self.form_data = form_data
self.args = form_data
self.metrics = form_data.getlist('metrics') or ['count']
self.groupby = form_data.getlist('groupby') or []
self.token = self.args.get('token', 'token_' + uuid.uuid4().hex[:8])
as_list = ('metrics', 'groupby')
d = self.args
for m in as_list:
if m in d and d[m] and not isinstance(d[m], list):
d[m] = [d[m]]
self.metrics = self.args.get('metrics') or ['count']
self.groupby = self.args.get('groupby') or []
def get_url(self, **kwargs):
d = self.args.copy()
d.update(kwargs)
href = Href('/panoramix/table/2/')
return href(d)
def get_df(self):
self.error_msg = ""
@ -60,7 +77,7 @@ class BaseViz(object):
return form_factory(self)
def query_filters(self):
args = self.form_data
args = self.args
# Building filters
filters = []
for i in range(1, 10):
@ -78,9 +95,9 @@ class BaseViz(object):
"""
Building a query object
"""
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(
@ -162,17 +179,18 @@ class BubbleViz(HighchartsViz):
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,
@ -213,6 +231,7 @@ class TimeSeriesViz(HighchartsViz):
]
def get_df(self):
args = self.args
df = super(TimeSeriesViz, self).get_df()
metrics = self.metrics
df = df.pivot_table(
@ -220,8 +239,8 @@ class TimeSeriesViz(HighchartsViz):
columns=self.groupby,
values=metrics,)
rolling_periods = request.args.get("rolling_periods")
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))

View File

@ -1,6 +1,6 @@
from setuptools import setup, find_packages
version = '0.2.1'
version = '0.3.0'
setup(
name='panoramix',