7. Creating a Dashboard Portlet

There are three distinct steps to creating a custom dashboard portlet:

  • Writing the Python code that will define the back end data method

  • Writing the JavaScript code defining the portlet

  • Packaging these elements in a 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.

  1. Create the underlying ZenPack.

    First, set up the directory structure. Let the files be empty.

    ReportListPortletPack/
    __init__.py
    ReportListPortlet.js

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

    import Globals
    
    from Products.CMFCore.DirectoryView import registerDirectory
    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 function so that it 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, to register and unregister your portlet. Add the following code:

       from Products.ZenModel.ZenPack import ZenPackBase
    
                class ZenPack(ZenPackBase):
    
                    def install(self, app):
                        ZenPackBase.install(self, app)
    
                    def upgrade(self, app):
                        ZenPackBase.upgrade(self, app)
    
                    def remove(self, app):
                        ZenPackBase.remove(self, app)

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

  2. Write the back end method.

    Since the ReportListPortlet will present its information as tabular data, you'll be using the 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

  3. Write the 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 datasource'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;
  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 pack 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 package up the directory. In the parent directory of ReportListPortletPack, run:

                zipzenpack ReportListPortletPack

    That will create a file called ReportListPortletPack.zip. Distribute away!