horizon/doc/source/topics/tutorial.rst

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.

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">
  <div class="col-sm-12">
  {{ 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.

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!