Changing tutorial to use the newer method for loading dashboards. The tutorial needs a new revision in general, but that is beyond the scope of this change. Closes-bug: #1277325 Change-Id: Ie197b5ad9aafa125235a839fbcc7cd95e352e96a
19 KiB
Building on Horizon
This tutorial covers how to use the various components in Horizon to build an example dashboard and panel with a data table and tabs.
As an example, we'll build on the Nova instances API to create a new and novel "visualizations" dashboard with a "flocking" panel that presents the instance data in a different manner.
You can find a reference implementation of the code being described here on github at https://github.com/gabrielhurley/horizon_demo.
Note
There are a variety of other resources which may be helpful to read
first, since this is a more advanced tutorial. For example, you may want
to start with the Horizon quickstart guide </quickstart>
or the Django tutorial.
Creating a dashboard
Note
It is perfectly valid to create a panel without a dashboard, and
incorporate it into an existing dashboard. See the section overrides <overrides>
later in this document.
The quick version
Horizon provides a custom management command to create a typical base dashboard structure for you. The following command generates most of the boilerplate code explained below:
./run_tests.sh -m startdash visualizations
It's still recommended that you read the rest of this section to understand what that command creates and why.
Structure
The recommended structure for a dashboard (or panel) follows suit with the typical Django application layout. We'll name our dashboard "visualizations":
visualizations
|--__init__.py
|--dashboard.py
|--templates/
|--static/
The dashboard.py
module will contain our dashboard class
for use by Horizon; the templates
and static
directories give us homes for our Django template files and static media
respectively.
Within the static
and templates
directories
it's generally good to namespace your files like so:
templates/
|--visualizations/
static/
|--visualizations/
|--css/
|--js/
|--img/
With those files and directories in place, we can move on to writing our dashboard class.
Defining a dashboard
A dashboard class can be incredibly simple (about 3 lines at minimum), defining nothing more than a name and a slug:
import horizon
class VizDash(horizon.Dashboard):
name = _("Visualizations")
slug = "visualizations"
In practice, a dashboard class will usually contain more information, such as a list of panels, which panel is the default, and any permissions required to access this dashboard:
class VizDash(horizon.Dashboard):
name = _("Visualizations")
slug = "visualizations"
panels = ('flocking',)
default_panel = 'flocking'
permissions = ('openstack.roles.admin',)
Building from that previous example we may also want to define a grouping of panels which share a common theme and have a sub-heading in the navigation:
class InstanceVisualizations(horizon.PanelGroup):
slug = "instance_visualizations"
name = _("Instance Visualizations")
panels = ('flocking',)
class VizDash(horizon.Dashboard):
name = _("Visualizations")
slug = "visualizations"
panels = (InstanceVisualizations,)
default_panel = 'flocking'
permissions = ('openstack.roles.admin',)
The PanelGroup
can be added to the dashboard class'
panels
list just like the slug of the panel can.
Once our dashboard class is complete, all we need to do is register it:
horizon.register(VizDash)
The typical place for that would be the bottom of the
dashboard.py
file, but it could also go elsewhere, such as
in an override file (see below).
Creating a panel
Now that we have our dashboard written, we can also create our panel. We'll call it "flocking".
Note
You don't need to write a custom dashboard to add a panel. The structure here is for the sake of completeness in the tutorial.
The quick version
Horizon provides a custom management command to create a typical base panel structure for you. The following command generates most of the boilerplate code explained below:
./run_tests.sh -m startpanel flocking --dashboard=visualizations --target=auto
The dashboard
argument is required, and tells the
command which dashboard this panel will be registered with. The
target
argument is optional, and respects auto
as a special value which means that the files for the panel should be
created inside the dashboard module as opposed to the current directory
(the default).
It's still recommended that you read the rest of this section to understand what that command creates and why.
Structure
A panel is a relatively flat structure with the exception that
templates for a panel in a dashboard live in the dashboard's
templates
directory rather than in the panel's
templates
directory. Continuing our visualization/flocking
example, let's see what the file structure looks like:
# stand-alone panel structure
flocking/
|--__init__.py
|--panel.py
|--urls.py
|--views.py
|--templates/
|--flocking/
|--index.html
# panel-in-a-dashboard structure
visualizations/
|--__init__.py
|--dashboard.py
|--flocking/
|--__init__.py
|--panel.py
|--urls.py
|--views.py
|--templates/
|--visualizations/
|--flocking/
|--index.html
That follows standard Django namespacing conventions for apps and submodules within apps. It also works cleanly with Django's automatic template discovery in both cases.
Defining a panel
The panel.py
file referenced above has a special
meaning. Within a dashboard, any module name listed in the
panels
attribute on the dashboard class will be
auto-discovered by looking for panel.py
file in a
corresponding directory (the details are a bit magical, but have been
thoroughly vetted in Django's admin codebase).
Inside the panel.py
module we define our
Panel
class:
class Flocking(horizon.Panel):
name = _("Flocking")
slug = 'flocking'
Simple, right? Once we've defined it, we register it with the dashboard:
from visualizations import dashboard
dashboard.VizDash.register(Flocking)
Easy! There are more options you can set to customize the
Panel
class, but it makes some intelligent guesses about
what the defaults should be.
URLs
One of the intelligent assumptions the Panel
class makes
is that it can find a urls.py
file in your panel directory
which will define a view named index
that handles the
default view for that panel. This is what your urls.py
file
might look like:
from django.conf.urls import patterns, url
from .views import IndexView
urlpatterns = patterns('',
url(r'^$', IndexView.as_view(), name='index')
)
There's nothing there that isn't 100% standard Django code. This
example (and Horizon in general) uses the class-based views introduced
in Django 1.3 to make code more reusable. Hence the view class is
imported in the example above, and the as_view()
method is
called in the URL pattern.
This, of course, presumes you have a view class, and takes us into
the meat of writing a Panel
.
Tables, Tabs, and Views
Now we get to the really exciting parts; everything before this was structural.
Starting with the high-level view, our end goal is to create a view
(our IndexView
class referenced above) which uses Horizon's
DataTable
class to display data and Horizon's
TabGroup
class to give us a user-friendly tabbed interface
in the browser.
We'll start with the table, combine that with the tabs, and then build our view from the pieces.
Defining a table
Horizon provides a ~horizon.tables.DataTable
class which simplifies the
vast majority of displaying data to an end-user. We're just going to
skim the surface here, but it has a tremendous number of
capabilities.
In this case, we're going to be presenting data about tables, so
let's start defining our table (and a tables.py
module:
from horizon import tables
class FlockingInstancesTable(tables.DataTable):
host = tables.Column("OS-EXT-SRV-ATTR:host", verbose_name=_("Host"))
tenant = tables.Column('tenant_name', verbose_name=_("Tenant"))
user = tables.Column('user_name', verbose_name=_("user"))
vcpus = tables.Column('flavor_vcpus', verbose_name=_("VCPUs"))
memory = tables.Column('flavor_memory', verbose_name=_("Memory"))
age = tables.Column('age', verbose_name=_("Age"))
class Meta:
name = "instances"
verbose_name = _("Instances")
There are several things going on here... we created a table
subclass, and defined six columns on it. Each of those columns defines
what attribute it accesses on the instance object as the first argument,
and since we like to make everything translatable, we give each column a
verbose_name
that's marked for translation.
Lastly, we added a Meta
class which defines some
properties about our table, notably its (translatable) verbose name, and
a semi-unique "slug"-like name to identify it.
Note
This is a slight simplification from the reality of how the instance object is actually structured. In reality, accessing the flavor, tenant, and user attributes on it requires an additional step. This code can be seen in the example code available on github.
Defining tabs
So we have a table, ready to receive our data. We could go straight
to a view from here, but we can think bigger. In this case we're also
going to use Horizon's ~horizon.tabs.TabGroup
class. This gives us a clean,
no-fuss tabbed interface to display both our visualization and,
optionally, our data table.
First off, let's make a tab for our visualization:
class VizTab(tabs.Tab):
name = _("Visualization")
slug = "viz"
template_name = "visualizations/flocking/_flocking.html"
def get_context_data(self, request):
return None
This is about as simple as you can get. Since our visualization will ultimately use AJAX to load it's data we don't need to pass any context to the template, and all we need to define is the name and which template it should use.
Now, we also need a tab for our data table:
from .tables import FlockingInstancesTable
class DataTab(tabs.TableTab):
name = _("Data")
slug = "data"
table_classes = (FlockingInstancesTable,)
template_name = "horizon/common/_detail_table.html"
preload = False
def get_instances_data(self):
try:
instances = utils.get_instances_data(self.tab_group.request)
except:
instances = []
exceptions.handle(self.tab_group.request,
_('Unable to retrieve instance list.'))
return instances
This tab gets a little more complicated. Foremost, it's a special
type of tab--one that handles data tables (and all their associated
features)--and it also uses the preload
attribute to
specify that this tab shouldn't be loaded by default. It will instead be
loaded via AJAX when someone clicks on it, saving us on API calls in the
vast majority of cases.
Lastly, this code introduces the concept of error handling in
Horizon. The horizon.exceptions.handle
function is a centralized
error handling mechanism that takes all the guess-work and inconsistency
out of dealing with exceptions from the API. Use it everywhere.
Tying it together in a view
There are lots of pre-built class-based views in Horizon. We try to provide starting points for all the common combinations of components.
In this case we want a starting view type that works with both tabs
and tables... that'd be the ~horizon.tabs.TabbedTableView
class. It takes the
best of the dynamic delayed-loading capabilities tab groups provide and
mixes in the actions and AJAX-updating that tables are capable of with
almost no work on the user's end. Let's see what the code would look
like:
from .tables import FlockingInstancesTable
from .tabs import FlockingTabs
class IndexView(tabs.TabbedTableView):
tab_group_class = FlockingTabs
table_class = FlockingInstancesTable
template_name = 'visualizations/flocking/index.html'
That would get us 100% of the way to what we need if this particular
demo didn't involve an extra AJAX call to fetch back our visualization
data via AJAX. Because of that we need to override the class'
get()
method to return the right data for an AJAX call:
from .tables import FlockingInstancesTable
from .tabs import FlockingTabs
class IndexView(tabs.TabbedTableView):
tab_group_class = FlockingTabs
table_class = FlockingInstancesTable
template_name = 'visualizations/flocking/index.html'
def get(self, request, *args, **kwargs):
if self.request.is_ajax() and self.request.GET.get("json", False):
try:
instances = utils.get_instances_data(self.request)
except:
instances = []
exceptions.handle(request,
_('Unable to retrieve instance list.'))
data = json.dumps([i._apiresource._info for i in instances])
return http.HttpResponse(data)
else:
return super(IndexView, self).get(request, *args, **kwargs)
In this instance, we override the get()
method such that
if it's an AJAX request and has the GET parameter we're looking for, it
returns our instance data in JSON format; otherwise it simply returns
the view function as per the usual.
The template
We need three templates here: one for the view, and one for each of our two tabs. The view template (in this case) can inherit from one of the other dashboards:
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Flocking" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Flocking") %}
{% endblock page_header %}
{% block main %}
<div class="row-fluid">
<div class="span12">
{{ tab_group.render }}
</div>
</div>
{% endblock %}
This gives us a custom page title, a header, and render our tab group provided by the view.
For the tabs, the one using the table is handled by a reusable
template, "horizon/common/_detail_table.html"
. This is
appropriate for any tab that only displays a single table.
The second tab is a bit of secret sauce for the visualization, but it's still quite simple and can be investigated in the github example.
The takeaway here is that each tab needs a template associated with it.
With all our code in place, the only thing left to do is to integrated it into our OpenStack Dashboard site.
Setting up a project
The vast majority of people will just customize the OpenStack Dashboard example project that ships with Horizon. As such, this tutorial will start from that and just illustrate the bits that can be customized.
Structure
A site built on Horizon takes the form of a very typical Django project:
site/
|--__init__.py
|--manage.py
|--demo_dashboard/
|--__init__.py
|--models.py # required for Django even if unused
|--settings.py
|--templates/
|--static/
The key bits here are that demo_dashboard
is on our
python path, and that the settings.py
file here will
contain our customized Horizon config.
The settings file
There are several key things you will generally want to customize in your site's settings file: specifying custom dashboards and panels, catching your client's exception classes, and (possibly) specifying a file for advanced overrides.
Specifying dashboards
Adding your own dashboard is as simple as creating a file in the
openstack_dashboard/local/enabled
directory named
_50_visualizations.py
. The contents of this file should
resemble:
DASHBOARD = 'visualizations'
DEFAULT = True
ADD_EXCEPTIONS = {}
ADD_INSTALLED_APPS = ['openstack_dashboard.dashboards.visualizations']
For more information on the significance of the file naming and an
explanation of the contents, check out Pluggable Settings for Dashboards </topics/settings>
In this case, we've added our visualizations
dashboard
to the list of dashboards to load. Note that the name here is the name
of the dashboard's module on the python path. It will find our
dashboard.py
file inside of it and load both the dashboard
and its panels automatically from there.
Error handling
Adding custom error handler for your API client is quite easy. While
it's not necessary for this example, it would be done by customizing the
ADD_EXCEPTIONS
dictionary in the file added to
openstack/local/enabled
:
import my_api.exceptions as my_api
ADD_EXCEPTIONS: {
'recoverable': [my_api.Error, my_api.ClientConnectionError],
'not_found': [my_api.NotFound],
'unauthorized': [my_api.NotAuthorized]
}
Override file
The override file is the "god-mode" dashboard editor. The hook for this file sits right between the automatic discovery mechanisms and the final setup routines for the entire site. By specifying an override file you can alter any behavior you like in existing code. This tutorial won't go in-depth, but let's just say that with great power comes great responsibility.
To specify an override file, you set the
'customization_module'
value in the
HORIZON_CONFIG
dictionary to the dotted python path of your
override module:
HORIZON_CONFIG = {
'customization_module': 'demo_dashboard.overrides'
}
This file is capable of adding dashboards, adding panels to existing dashboards, renaming existing dashboards and panels (or altering other attributes on them), removing panels from existing dashboards, and so on.
We could say more, but it only gets more dangerous...
Conclusion
Sadly, the cake was a lie. The information in this "tutorial" was never meant to leave you with a working dashboard. It's close. But there's waaaaaay too much javascript involved in the visualization to cover it all here, and it'd be irrelevant to Horizon anyway.
If you want to see the finished product, check out the github example referenced at the beginning of this tutorial.
Clone the repository and simply run
./run_tests.sh --runserver
. That'll give you a 100% working
dashboard that uses every technique in this tutorial.
What you've learned here, however, is the fundamentals of almost everything you need to know to start writing interfaces for your own project based on the components Horizon provides.
If you have questions, or feedback on how this tutorial could be improved, please feel free to pass them along!