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
.
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 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/
, directory, we should see the followingZenPacks.myexample.portlet
/Zenpacks/myexample
/portlet
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.
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
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;
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 menu item. That will create a new egg file called
. Distribute away!ZenPacks.myexample.portlet
.egg