Developer's Guide

  • Docs Home
  • Community Home

7. Creating a Dashboard Portlet

There are just a few distinct steps to creating a custom dashboard portlet:

  • Create the ZenPack as a container to hold everything

  • Write the Python code that will define the back-end data methods

  • Write the JavaScript code defining the portlet

  • Testing the new ZenPack

This tutorial will walk through examples of each of these in the creation of a simple portlet that provides a table listing links to reports under a given ReportClass.

7.1. Create a ZenPack

First, set up the directory structure by going into Zenoss, and from the navigation bar, go to the Settings area. From here, click on the ZenPacks tab and from the page menu select the Create a ZenPack... menu item.

For the sake of our example, we'll use the name ZenPacks.myexample.portlet as the name for our new ZenPack. When we take a look at the ZenPack from the filesystem level in the $ZENHOME/ZenPacks/ZenPacks.myexample.portlet/Zenpacks/myexample/portlet, directory, we should see the following

ReportListPortletPack/
__init__.py
ReportListPortlet.js

Next, add the following Python code to __init__.py:

import Globals
import os.path

skinsDir= os.path.join( os.path.dirname(__file__), 'skins' )
from Products.CMFCore.DirectoryView import registerDirectory
if os.path.isdir(skinsDir):
    registerDirectory("skins", globals())
        

This satisfies the ZenPack requirements for the skins directory.

The skins directory is required, although you won't be using it in this portlet. Normally it contains Zope templates specific to your ZenPack.

The __init__.py is a requirement for Python modules (of which Zope products, and by extension ZenPacks, are a type). When the ZenPack is loaded on Zenoss startup, code in __init__.py will be run. This is where you'll place the back-end functions so that your portlet gets attached to the Zenoss portal object and made available to the portlet front-end.

Finally, you'll need to make a ZenPack object so that you can hook into installation, upgrade and removal methods, as well as to register and unregister your portlet. Add the following code into __init__.py:

from Products.ZenModel.ZenPack import ZenPackBase

class ZenPack(ZenPackBase):
    """
    Portlet ZenPack class
    """

    def install(self, app):
        """
        Initial installation of the ZenPack
        """
        ZenPackBase.install(self, app)


    def upgrade(self, app):
        """
        Upgrading the ZenPack procedures
        """
        ZenPackBase.upgrade(self, app)


    def remove(self, app, leaveObjects=False ):
        """
        Remove the ZenPack from Zenoss
        """
        # NB: As of Zenoss 2.2, this function now takes three arguments.
        ZenPackBase.remove(self, app, leaveObjects)

As you can see, nothing special has been done yet; that will come later.

7.2. Write the Python back-end code

Since the ReportListPortlet will present its information as tabular data, you'll be using the JavaScript YUI library's TableDatasource on the front-end (more about that in the next section). That datasource accepts data as a JSON object with the following structure:

      {
             'columns': ['Column1', 'Column2'],
             'data': [
                       {
                           'Column1':'row 1 value',
                           'Column2':'another row 1 value'
                       },
                       {
                           'Column1':'row 2 value',
                           'Column2':'another row 2 value'

                       }
                     ]
            }

Thus you need a method in Zenoss to structure your list of reports accordingly and serialize it as JSON. You then need to place that method in Zenoss so that it's accessible to the browser via an ordinary HTTP request. This method should accept a path to a ReportClass whose reports are to be listed.

Here's the final method (we'll go through it piece by piece in a moment):

        import simplejson

        def getJSONReportList(self, path='/Device Reports'):
            """
            Given a report class path, returns a list of links to child
            reports in a format suitable for a TableDatasource.
            """

            # This function will be monkey-patched onto zport, so
            # references to self should be taken as referring to zport

            # Add the base path to the path given
            path = '/zport/dmd/Reports/' + path.strip('/')

            # Create the empty structure of the response object
            response = { 'columns': ['Report'], 'data': [] }

            # Retrieve the ReportClass object for the path given. If
            # nothing can be found, return an empty response
            try:
                reportClass = self.dmd.unrestrictedTraverse(path)
            except KeyError:
                return simplejson.dumps(response)

            # Get the list of reports under the class as (url, title) pairs
            reports = reportClass.reports()
            reportpairs = [(r.absolute_url_path(), r.id) for r in reports]

            # Iterate over the reports, create links, and append them to
            # the response object
            for url, title in reportpairs:
                link = "<a href='%s'>%s</a>" % (url, title)
                row = { 'Report': link }
                response['data'].append(row)

            # Serialize the response and return it
            return simplejson.dumps(response)

        # Monkey-patch onto zport
        from Products.ZenModel.ZentinelPortal import ZentinelPortal
        ZentinelPortal.getJSONReportList = getJSONReportList

This function will be defined in __init__.py.

First, you'll need simplejson to serialize the response:

     import simplejson

That's it for the method. This should now be in __init__.py. Next, set up the monkey-patch by importing zport's class:

     from Products.ZenModel.ZentinelPortal import ZentinelPortal

Then set your function as a class method:

            ZentinelPortal.getJSONReportList = getJSONReportList

And that's it! Now this method is accessible wherever zport is; for example, via HTTP:

http://myzenoss:8080/zport/getJSONReportList?path=Device%20Reports

7.3. Write the JavaScript Portlet

Zenoss portlets rely on elements of both the MochiKit and Yahoo! UI JavaScript libraries. JavaScript is a prototype-based language, not a class-based language; as a result, innumerable efforts have been made to create class-like JavaScript objects. Zenoss is no exception. It does not use YUI's class-like objects, but instead its own constructor, based on the Prototype library's Class, that allows simple subclassing.

Similarly, Zenoss uses its own Datasource object that wraps around YUI's DataSource component; this allows for the use of datasource subclassing, as well as simple JSON serialization.

As a result of using these custom components, creating a new Portlet is fairly straightforward. Each portlet must have a corresponding Datasource, which handles communication with the server.

The ReportListPortlet will use the predefined TableDatasource, so no separate datasource class definition is needed. See $ZENHOME/Products/ZenWidgets/ZenossPortlets/GoogleMapsPortlet.js for an example of a customized datasource.

The global YAHOO object defines a namespace; YAHOO.zenoss is where all custom Zenoss components are stored. The complete portlet definition, which should be placed in ReportListPortlet.js, follows. As before, we'll go over it step by step in a moment.

            var ReportListPortlet = YAHOO.zenoss.Subclass.create(
     YAHOO.zenoss.portlet.Portlet);

 ReportListPortlet.prototype = {

     // Define the class name for serialization
     __class__:"YAHOO.zenoss.portlet.ReportListPortlet",

     // __init__ is run on instantiation (feature of Class object)
     __init__: function(args) {

         // args comprises the attributes of this portlet, restored
         // from serialization. Take them if they're defined,
         // otherwise provide sensible defaults.
         args = args || {};
         id = 'id' in args? args.id : getUID('ReportList');
         title = 'title' in args? args.title: "Reports";
         bodyHeight = 'bodyHeight' in args? args.bodyHeight:200;

         // You don't need a refresh time for this portlet. In case
         // someone wants one, it's available, but default is 0
         refreshTime = 'refreshTime' in args? args.refreshTime: 0;

         // The datasource has already been restored from
         // serialization, but if not make a new one.
         datasource = 'datasource' in args? args.datasource :
             new YAHOO.zenoss.portlet.TableDatasource({

                 // Query string will never be that long, so GET
                 // is appropriate here
                 method:'GET',

                 // Here's where you call the back end method
                 url:'/zport/getJSONReportList',

                 // Set up the path argument and set a default ReportClass
                 queryArguments: {'path':'/Device Reports'}
             });


         // Call Portlet's __init__ method with your new args
         this.superclass.__init__(
             {id:id,
              title:title,
              datasource:datasource,
              refreshTime: refreshTime,
              bodyHeight: bodyHeight
             }
         );

         // Create the settings pane for the portlet
         this.buildSettingsPane();
     },

     // buildSettingsPane creates the DOM elements that populate the
     // settings pane.
     buildSettingsPane: function() {

         // settingsSlot is the div that holds the elements
         var s = this.settingsSlot;

         // Make a function that, given a string, creates an option
         // element that is either selected or not based on the
         // settings you've already got.
         var getopt = method(this, function(x) {
             opts = {'value':x};
             path = this.datasource.queryArguments.path;
             if (path==x) opts['selected']=true;
             return OPTION(opts, x); });

         // Create the select element
         this.pathselect = SELECT(null, null);

         // A function to create the option elements from a list of
         // strings
         var createOptions = method(this, function(jsondoc) {
             forEach(jsondoc, method(this, function(x) {
                 opt = getopt(x);
                 appendChildNodes(this.pathselect, opt);
             }));
         });

         // Wrap these elements in a DIV with the right CSS class,
         // and give it a label, so it looks pretty
         mycontrol = DIV({'class':'portlet-settings-control'}, [
                 DIV({'class':'control-label'}, 'Report Class'),
                  this.pathselect
                ]);

         // Put the thing in the settings pane
         appendChildNodes(s, mycontrol);

         // Go get the strings that will populate your select element.
         d = loadJSONDoc('/zport/dmd/Reports/getOrganizerNames');
         d.addCallback(method(this, createOptions));
     },

     // submitSettings puts the current values of the elements in
     // the settingsPane into their proper places.
     submitSettings: function(e, settings) {

         // Get your ReportClass value and put it in the datasource
         var mypath = this.pathselect.value;
         this.datasource.queryArguments.path = mypath;

         // Call Portlet's submitSettings
         this.superclass.submitSettings(e, {'queryArguments':
             {'path': mypath}
         });
     }
 }
 YAHOO.zenoss.portlet.ReportListPortlet = ReportListPortlet;

The dashboard template loads all the dependencies for portlets, including the two important ones: YAHOO.zenoss.Subclass and YAHOO.zenoss.portlet.Portlet.

First, create your ReportListPortlet as a subclass of YAHOO.zenoss.portlet.Portlet (which is defined in $ZENHOME/Products/ZenWidgets/skins/zenui/javascript/portlet.js, if you care to look at its code):

            var ReportListPortlet = YAHOO.zenoss.Subclass.create(
                YAHOO.zenoss.portlet.Portlet);

Most of the Portlet class's options are fine here; you'll be adding a select element to the settings pane, to select the base report class, and defining a TableDatasource, to get data from your server-side method. To customize the subclass, modify the prototype object of the portlet. When ReportListPortlet is called as a constructor, the attributes of Portlet's prototype are copied to ReportListPortlet, except for those that ReportListPortlet has defined itself. Portlet's prototype is also made available as ReportListPortlet.superclass.

            ReportListPortlet.prototype = {

The __class__ attribute will be used when the portlet is restored from serialization. It points to the correct code, so define it as the eventual place of your Portlet in the YAHOO.zenoss namespace.

                __class__:"YAHOO.zenoss.portlet.ReportListPortlet",

The __init__ method is called when a ReportListPortlet is created (a feature of YAHOO.zenoss.Class). The entity that restores portlets from saved settings will pass in an object containing those settings as attributes, so you'll need to go through those, making any changes necessary and supplying defaults if settings don't exist.

                __init__: function(args) {

                    args = args || {};
                    id = 'id' in args? args.id : getUID('ReportList');
                    title = 'title' in args? args.title: "Reports";
                    bodyHeight = 'bodyHeight' in args? args.bodyHeight:200;
                    refreshTime = 'refreshTime' in args? args.refreshTime: 0;

In the process of iterating over settings, the method will come across the datasource. If it doesn't exist yet, you'll need to create one. Since these are tabular data, you'll use TableDatasource.

                    datasource = 'datasource' in args? args.datasource :
                        new YAHOO.zenoss.portlet.TableDatasource({

                            method:'GET',

Set the data source's URL to the path to the method on zport that you wrote previously:

                                    url:'/zport/getJSONReportList',

And set up the arguments that get passed to that method, providing a default:

                    this.superclass.__init__(
                        {id:id,
                         title:title,
                         datasource:datasource,
                         refreshTime: refreshTime,
                         bodyHeight: bodyHeight
                        }
                    );

Since you're going to have a modified settings pane, containing the select element by which the base ReportClass is chosen, you'll need to call a method to add that to the default elements.

                    this.buildSettingsPane();
                },

Now write that method, since you've finished the initialization.

                  buildSettingsPane: function() {

Portlet.settingsSlot is the reference to the div element that contains the settings pane.

                    var s = this.settingsSlot;

Since your settings pane will include a select element, you'll need to create options to be chosen, using MochiKit's OPTION(); also, you want the select element to show the current value. This function will accept a string representing an existing ReportClass and build an option element, setting it as selected if it matches the current value.

                    var getopt = method(this, function(x) {
                        opts = {'value':x};
                        path = this.datasource.queryArguments.path;
                        if (path==x) opts['selected']=true;
                        return OPTION(opts, x); });

Now create the select element to hold the options, again using MochiKit's SELECT():

                    this.pathselect = SELECT(null, null);

Set up the function that accepts a list of strings and iterates over them, turning them into options and appending them to your select element:

                    var createOptions = method(this, function(jsondoc) {
                        forEach(jsondoc, method(this, function(x) {
                            opt = getopt(x[0]);
                            appendChildNodes(this.pathselect, opt);
                        }));
                    });

Now put the (currently empty) select element into a div with the proper CSS class defined, so that it will organize itself properly in the settings pane, and have a label:

                    mycontrol = DIV({'class':'portlet-settings-control'}, [
                            DIV({'class':'control-label'}, 'Report Class'),
                             this.pathselect
                           ]);

                    appendChildNodes(s, mycontrol);

Finally, you're ready to get the data for all of your option elements. You'll use MochiKit's handy loadJSONDoc(), which accepts a URL, fires off an XHR, parses the response text as JSON, and returns a JavaScript object, with which you'll call back to your option-building method:

                    d = loadJSONDoc('/zport/dmd/Reports/getOrganizerNames');
                    d.addCallback(method(this, createOptions));
                },

Lastly, you need to hook into the method that saves changed settings, so it will include your ReportClass string:

                submitSettings: function(e, settings) {

                    var mypath = this.pathselect.value;
                    this.datasource.queryArguments.path = mypath;

                    // Call Portlet's submitSettings
                    this.superclass.submitSettings(e, {'queryArguments':
                        {'path': mypath}
                    });
                }
            }

All that's left is to assign the ReportListPortlet constructor to the YAHOO.zenoss namespace:

            YAHOO.zenoss.portlet.ReportListPortlet = ReportListPortlet;

7.4. Register the portlet

Now you need to tell Zenoss about the portlet and assign permissions. Open up __init__.py again, and add the following Python code to the top:

from Products.ZenModel.ZenossSecurity import ZEN_COMMON
from Products.ZenUtils.Utils import zenPath

Next, modify the ZenPack class you defined way back in step 1. Since upgrading and installing the portlet will amount to the same thing, create a method on your ZenPack class to cover those steps:

def _registerReportListPortlet(self, app):
      zpm = app.zport.ZenPortletManager
      portletsrc = zenPath('Products', 'ReportListPortletPack',
                           'ReportListPortlet.js')
      zpm.register_portlet(
          sourcepath=portletsrc,
          id='ReportListPortlet',
          title='Report List',
          permission=ZEN_COMMON)

That method will let ZenPortletManager, the object on zport that, unsurprisingly, manages portlets, know about the portlet source code. The zenPath() function is a utility that joins strings together to create a filesystem path under $ZENHOME -- in this case, pointing to the directory where your ZenPack will be installed. When registering a portlet, you provide an id, a title, and the permissions for the portlet (as this portlet should be visible to everyone, ZEN_COMMON is the appropriate permission).

Now you can modify your install(), upgrade() and remove() methods:

def install(self, app):
    ZenPackBase.install(self, app)
    self._registerReportListPortlet(app)

def upgrade(self, app):
    ZenPackBase.upgrade(self, app)
    self._registerReportListPortlet(app)

def remove(self, app):
    ZenPackBase.remove(self, app) zpm =
    app.zport.ZenPortletManager
    zpm.unregister_portlet('ReportListPortlet')

Save and exit. You can test your ZenPack at this point by navigating to the parent directory of ReportListPortletPack and running:

zenpack --install ReportListPortletPack

Load up the Zenoss UI in your browser and click Add Portlet on your dashboard. Make sure the Report List portlet appears as an option. If so, add one and check that you can change the base ReportClass. Also make sure it shows reports.

Now all that's left is to export the ZenPack from Zenoss. From the ZenPacks tab under Settings, click on your new ZenPack. From the page menu, select the Export ZenPack... menu item. That will create a new egg file called ZenPacks.myexample.portlet.egg. Distribute away!