Blog from January, 2014

ecflow standalone GUIs

Among reasons to use ecFlow is certainly its simplicity with simple Python clients.

Tkinter, as the graphical interface available with Python, may be used then, to customize simple GUIs.

Some time may be dedicated learning the basics (Tkinter)

Application skeleton starts with:

from Tkinter import *
root = Tk()
w = Label(root, text="Hello, world!")
w.pack()
root.mainloop()

There may be the remembering of the 'overview' interface: it displays current tasks in submitted, active and aborted states from a server.

To do so with ecFlow, Python and Tkinter, we may use a ScrolledList widget to list such tasks.

It is trivial to add an update button so that user can request most recent content.

A dedicated class is also created to cause regular refresh (PaceKeeper)

Queue module shall then be used to deal with the asynchronous aspect.

Overview
import Tkinter as tk
import ecflow  as ec
from threading import Thread
import Queue
import sys
PROGRAM_NAME =  "ecflowview-overview"
BUTTON_FONT  =  ('times', 12)
MONO_FONT    =  ('lucidatypewriter', 14, 'bold')
class MenuBar(tk.Frame):
    def __init__(self, parent):
        tk.Frame.__init__(self, parent)
        self.__helpButton = self.__createHelp()
        self.__helpButton.grid(row=0, column=3)
        self.__updateButton = tk.Button(self, text='Update',
                                        font= BUTTON_FONT,
                                        command= parent.update)
        self.__updateButton.grid(row=0, column=2)
        self.__quitButton = tk.Button(
            self, text='Quit',
            font= BUTTON_FONT, command= self.quit)
        self.__quitButton.grid(row=0, column=0)
        
    def __createHelp(self):
        mb = tk.Menubutton(self, font=BUTTON_FONT,
                        relief= tk.RAISED,
                        text= 'Help')
        menu = tk.Menu(mb)
        mb['menu'] = menu
        return mb
from scrolledlist import ScrolledList
class TaskList(tk.Frame):
    NAME_WIDTH = 80
    NAME_LINES = 22
    def __init__(self, parent, kind):
        tk.Frame.__init__(self, parent)
        self.__kind = kind
        self.__callback = None
        self.__label = tk.Label(self, font=BUTTON_FONT,
                                text= kind)
        rowx = 0
        self.__label.grid(row=rowx, column=0, sticky= tk.W)
        rowx += 1
        self.__scrolledList = ScrolledList(
            self, 
            width= self.NAME_WIDTH,
            height= self.NAME_LINES,
            callback= self.__callback)
        self.__scrolledList.grid(row=rowx, column=0)
    def insert(self, node):
        self.__scrolledList.append(node.get_abs_node_path())
    def clear(self): 
        self.__scrolledList.clear()
running = [True]
class PaceKeeper():
    PACE = 60
    def __init__(self, item, queue): 
        thr = Thread(target=self.process, 
               args=(queue, running))
        self._item = item
        thr.start()
        
    def process(self, queue, running):
        import time
        while running:
            queue.put(self._item.update)
            time.sleep(self.PACE)
    def run(self): self.update()
    def update(self, verbose=False): 
        import time
        while True:
            print time.clock()
            self._item.update()
            time.sleep(self.PACE)
    
class Application(tk.Frame):
    def __init__(self, master=None, client=None, queue=None):
        tk.Frame.__init__(self, master)
        self.__client = client
        self.__queue = queue
        # if client is None: self.__client = ec.Client("localhost", 31415)
        width = 400
        height = 300
        self.canvas = tk.Canvas(width=width, height=height, bg='black')
        self.grid()
        self.createWidgets()
        self.canvas.after(50, self.check_queue)
    def createWidgets(self):
        if 0:
            self.quitButton = tk.Button(self, text='Quit',
                                        command=self.quit)
            self.quitButton.grid(row=1, column=1)
        self.__menuBar = MenuBar(self)
        self.__menuBar.grid(row=0, column=0, columnspan=3,
                            sticky=tk.W)
        self.__wins = dict()
        rowx = 1
        colx = 0
        for kind in ("active", "aborted", "submitted"):
            self.__wins[kind] = TaskList(self, kind)
            self.__wins[kind].grid(row=rowx, column= colx)
            colx += 1
        self.update()
    def check_queue(self):
        try: 
            self.__queue.get(block=False)
        except Queue.Empty: 
            pass
        else: 
            self.update()
        self.canvas.after(50, self.check_queue)
    def process_node(self, node):
        for kind, win in self.__wins.items():
            status = "%s" % node.get_state()
            if status != kind: continue
            win.insert(node)
            print node.get_abs_node_path(), status
    def process_nc(self, node):
        for node in node.nodes:
            if not isinstance(node, ec.Task):
                self.process_nc(node)
            else:
                self.process_node(node)
    def update(self):
        self.__client.sync_local() # get changes, 
        defs = self.__client.get_defs()
        if defs is None: raise BaseException("empty content")
        for kind, win in self.__wins.items(): 
            win.clear()
        print 
        for suite in defs.suites: 
            self.process_nc(suite)
def get_username():
    return pwd.getpwuid( os.getuid() )[ 0 ]

if __name__ == '__main__':
    host = "localhost"
    port = 31415
    if len(sys.argv) > 2:
        host = sys.argv[1]
        port = int(sys.argv[2])
    print "# " + PROGRAM_NAME + ": contact is %s %d" % ( host, port)
    queue = Queue.Queue()
    if port < 65535 or sys.argv[-1] != "sms":
        client = ec.Client(host, port)
        app = Application(client=client, queue=queue)
    else: app = AppSms(host, port, queue)
    app.master.title(PROGRAM_NAME)
    PaceKeeper(app, queue)
    app.mainloop()

It is not really far ahead to provide a treeview display of the server content, using ttk.Treeview widget.

ecFlow is "destination agnostic": it just submits local commands that can lead to remote jobs submission. The Treeview interface may bridge the ecFlow tree understanding of the current situation with the system side. It can display the target node (HPC, linux_cluster, localhost, workstation), the id of the active job, or the identification of the submitted job (qid). Check command is then expected to validate and match the server information with the query from the queuing systems, or remote machine.

A contextual menu is added to display task manual, script, job, or output, when available.

Treeview display
import ecflow  as ec
from threading import Thread
import Queue
import sys
try:
    import Tkinter as tk
    import tkFont
except ImportError: # 3.x
    import tkinter as tk # Tkinter
    import tkinter.font as tkFont

import ttk

PROGRAM_NAME =  "ecflowview-treeview"
BUTTON_FONT  =  ('times', 12)
MONO_FONT    =  ('lucidatypewriter', 14, 'bold')
tree_columns = (# 'status', # 'since', 
    'path', "host", "id", "checked", "node")
colors = { 'complete': "yellow",
           'active': "green",
           'aborted': "red",
           'submitted': "turquoise",
           "shutdown": "pink",
           "halted": "violet",
           "queued": "lightblue",
           "unknown": "grey",
           "suspended": "orange" }
displays = dict()

running = True
PACE = 30

def guess_host(node, kind=None): 
    if node is None: return ""

    value = None
    for var in node.variables:
        name = var.name()
        if kind is None and name == "ECF_JOB_CMD":
            if "%WSHOST%" in var.value(): 
                return guess_host(node, "WSHOST")
            elif "%SCHOST%" in var.value(): 
                return guess_host(node, "SCHOST")
            elif "%HOST%" in var.value(): 
                return guess_host(node, "HOST")
            else: value = var.value()
        elif kind == "SCHOST" and name == "SCHOST":
            return var.value()
        elif kind == "WSHOST" and name == "WSHOST":
            return var.value()
        elif kind == "HOST" and name == "HOST":
            return var.value()
        else: pass
    if value is None and node.get_parent():
        return guess_host(node.get_parent(), kind)
            
    return "???"

def guess_qid(node, path=None): 
    if node is None: return ""
    if path is None and not isinstance(node, ec.Task): return ""

    if 0:
        content = "%s" % node
        for line in content.split("\n"): 
            print line
            if "edit ECF_JOB " in line:
                dummy, name, value = line.split()
                print line, dummy, name, value
                return value
    elif 0:
        for var in node.variables:
            if var.name() == "ECF_JOB": return var.value() + ".sub"
    elif path is None: 
        return guess_qid(node.get_parent(), node.get_abs_node_path())
    else: pass
    loc = None
    for var in node.variables:
        if var.name() == "ECF_HOME": loc = var.value() + path; break
    if loc is None and node.get_parent() is not None:
        return guess_qid(node.get_parent(), path)
    elif loc is None: return "???"
    import commands
    a = commands.getstatusoutput("ls -ltr %s*.sub | awk '{print $8}'" % loc)
    item  =a[1].split('\n')
    print path, item[0]
    fd = open(item[0])
    line = "???"
    for line in fd.readlines():
        if "has been submitted" in line:     
            full = line.split()
            if "llsubmit:" in line: return full[3]
            return full[2]
    print "# last: ", line
    return line

    
def guess_check(master, item, node, host, qid):
    SLURM_ROOT="/usr/local/apps/slurm/current/bin"
    qstat="/usr/local/apps/propbs/bin/qstat" # PBS
    llq="/usr/lpp/LoadL/full/bin/llq -f %id %jn %o %c %st %nh"
    import commands
    if "lxa" in host or "lxb" in host or host=="linux_cluster":
        print node, host, qid
        SGE_ROOT="/usr/local/apps/sge"
        SGE_ROOT="/usr/local/apps/sge/sge6_2u5"
        sge="/usr/local/apps/sge/sge6_2u5/bin/lx24-amd64/qstat -u emos -f"
        cmd = "ssh lxab 'SGE_ROOT=%s %s' | grep %d" % (SGE_ROOT, sge, int(qid))        
        a = commands.getstatusoutput(cmd)
        print cmd, a
        if a[0] != 0: return
        full = a[1].split()
        if len(full) > 6 and full[4]=='r':
            print "#YES %s is running since %s %s" % (node, full[5], full[6])
            master._tree.set(item, column="checked", value="%s" % full[6])
        else: print full
    elif "c2a" in host or "c2b" in host or "ecg" in host:
        print node, host, qid
        a = commands.getstatusoutput("rsh %s %s %s | grep %s" % (host, llq, qid, qid))
        print a
    elif "cc" in host:
        print node, host, qid
        a = commands.getstatusoutput("ssh %s qstat %s" % (host, qid))
        print a
    else: print node, host, qid

def textw(clnt, fullpath, kind, node=None):
    # create child window
    win = tk.Toplevel()
    # display message
    if kind == "variables":
        msg = ""
        for var in node.variables:
            msg += "%-20s: %s\n" %(var.name(), var.value())
        message = msg
    elif kind == "info":
        message = "%s" % node
    else: message = clnt.client.get_file(fullpath, kind)
    # text = tk.Label(win, text=message)

    win.title(fullpath + " - " + kind)
    s = tk.Scrollbar(win)
    T = tk.Text(win)

    T.focus_set()
    s.pack(side=tk.RIGHT, fill=tk.Y)
    T.pack(side=tk.LEFT, fill=tk.Y)
    s.config(command=T.yview)
    T.config(yscrollcommand=s.set)

    for i in message.split("\n"):
        T.insert(tk.END, i + "\n")
        T.yview(tk.MOVETO, 1.0)
    
    # ysb = ttk.Scrollbar(text, orien='vertical', 
    #                     command=text.yview)
    # xsb = ttk.Scrollbar(self, orien='horizontal', 
    #                     command=text.xview)
    # text.configure(yscrollcommand=ysb.set, 
    #                xscrollcommand=xsb.set)
    # text.pack()
    # quit child window and return to root window
    # the button is optional here, simply use the corner x of the child window
    # tk.Button(win, text='close', command=win.destroy).pack()

class PaceKeeper():

    def __init__(self, master, queue): 
        self.thr = Thread(target=self.process, 
               args=(queue, running))
        self._master = master
        self.thr.start()
        
    def process(self, queue, running):
        import time
        if not running: self.thr.stop(); return
        queue.put(self._master.update)
        time.sleep(PACE)

    def run(self): self.update()

    def update(self, verbose=False): 
        import time
        if 1: # while running:
            print time.clock()
            self._master.update()
            time.sleep(PACE)

class ContextMenu(object):
    def __init__(self, dad):
        self.dad = dad
        self.cmn = tk.Menu(dad, tearoff=0)
        self.cmn.add_command(label="Info",   command=dad.info)
        self.cmn.add_command(label="Script", command=dad.script)
        self.cmn.add_command(label="Manual", command=dad.manual)
        self.cmn.add_command(label="Job",    command=dad.job)
        self.cmn.add_command(label="Output",    command=dad.jobout)
        self.cmn.add_command(label="Why",    command=dad.why)
        self.cmn.add_command(label="TimeLine",    command=dad.timeline)
        self.cmn.add_command(label="Variables",    command=dad.variables)
        self.cmn.add_command(label="Messages",     command=dad.messages)
        
class MenuBar(tk.Frame):
    def __init__(self, parent):
        tk.Frame.__init__(self, parent)
        self.__helpButton = self.__createHelp()

        self.__updateButton = tk.Button(self, text='Update',
                                        font= BUTTON_FONT,
                                        command= parent.update)

        self.__checkButton = tk.Button(self, text='Check',
                                        font= BUTTON_FONT,
                                        command= parent.check)

        self.__quitButton = tk.Button(
            self, text='Quit',
            font= BUTTON_FONT, command= parent.quit)

        col = 0
        self.__quitButton.grid(row=0, column=col)
        col += 1
        self.__updateButton.grid(row=0, column=col)
        col += 1
        self.__checkButton.grid(row=0, column=col)
        col += 1

        self.__helpButton.grid(row=0, column=col)
        col += 1

        timed = tk.Label(self, font=BUTTON_FONT,
                        textvariable= parent.last_update, )
        timed.grid(row=0, column=col)

        self.__createCheck(col+1)

    def __createHelp(self):
        mb = tk.Menubutton(self, font=BUTTON_FONT,
                        relief= tk.RAISED,
                        text= 'Help')
        menu = tk.Menu(mb)
        mb['menu'] = menu
        menu.add_command(command= self.__url,
                         label="confluence tutorial?")
        menu.add_command(command= self.__url2,
                         label="tkinter?")
        return mb

    def __url2(self): self.__url(1)

    def __url(self, num=0):
        import os
        url = "https://confluence.ecmwf.int/display/ECFLOW/Documentation"

        if num == 1: url = "http://effbot.org/tkinterbook/"

        os.system("firefox " + url)

    def __createCheck(self, col):
        col=4
        
        keys = colors.keys()
        keys.append("unfold")
        for kind in sorted(keys):
            var = tk.IntVar()
            displays[kind] = var
            check = tk.Checkbutton(self, text= kind, variable= var,
                                onvalue= 1, offvalue= 0,)
            if kind in ("active", "aborted", "submitted", "unfold"):
                check.select()
            check.grid(row=0, column=col)
            col += 1
        
def sortby(tree, col, desc, node=''):
    data = []
    for item in tree.get_children(node):
        for kid in tree.get_children(item):
            data.append((tree.set(kid, col), kid, item))

    data.sort(reverse=desc)

    for idx, item in enumerate(data): 
        tree.move(item[1], item[2], idx)
    tree.heading(
        col, command= lambda col= col: sortby(tree, col, int(not desc)))

class Client(object):
    def __init__(self, host, port):
        self.client = ec.Client(host, port)
        self.host   = host
        self.port   = port

    def sync(self, top, parent):
        self.client.sync_local()
        load = self.defs()
        if load is None: print "empty server %s %d" %(self.host, self.port)
        for item in load.suites:
            self.process(top, parent, item)

    def defs(self): return self.client.get_defs()

    def process(self, top, parent, item):
        status = "%s" % item.get_state()
        fullpath = item.get_abs_node_path()

        if not displays[status].get(): return

        unfold = displays["unfold"].get()
        if isinstance(item, ec.Task) or not unfold:
            name = item.name()
            if isinstance(item, ec.Task): 
                host = guess_host(item)
                checked = "nope"
                qid = guess_qid(item)
            else: host= ""; qid = ""; checked = ""
            if unfold: name = fullpath; fullpath = ""
            dad = top._tree.insert(parent, 'end', 
                                     values= (fullpath, 
                                              host,
                                              qid, 
                                              checked,
                                              ),
                                     tags= status,
                                     text=name, open=unfold)
        else: dad = parent
        if 0:
            ilen = tkFont.Font().measure(fullpath)
            if ilen > 80: self._tree.column(1, width=ilen)
            else: pass
        if not isinstance(item, ec.Task):
            for kid in item.nodes: self.process(top, dad, kid)
        else: pass

class Application(tk.Frame):
    def __init__(self, master=None, client=None, queue=None):
        width = 768
        height = 480
        tk.Frame.__init__(self, master, width=width, height=height,)
        self.__queue = queue
        if client is None: 
            HOST = "vsms1"; 
            self.__client = [ ]
            PORT = 43333; self.__client.append(Client(HOST, PORT))
            PORT = 32222; self.__client.append(Client(HOST, PORT))
            PORT = 31415; self.__client.append(Client(HOST, PORT))
            print self.__client
        else: self.__client = [client ]
        # self.canvas = tk.Canvas(width=width, height=height, bg='black')
        self.grid()

        self.last_update = tk.StringVar()
        self.last_update.set("00:00")

        self.createWidgets()
        self.cwn = None
        # self.canvas.after(50, self.check_queue)

    def createWidgets(self):
        self.__menuBar = MenuBar(self)
        row = 0
        self.__menuBar.grid(row=row, column=0, columnspan=3,
                            sticky=tk.W)
        row += 1
        self.__wins = dict()
        rowx = 1
        colx = 0
        if 1:
            self._tree = ttk.Treeview(self, columns=tree_columns,
                                       selectmode='extended',
                                       # justify="right",
                                       # show="headings" # path disappear
                                       )

            ysb = ttk.Scrollbar(self, orien='vertical', 
                                command=self._tree.yview)
            xsb = ttk.Scrollbar(self, orien='horizontal', 
                                command=self._tree.xview)
            self._tree.configure(yscrollcommand=ysb.set, 
                                  # justify="right",
                                  xscrollcommand=xsb.set)

            row=1
            self._tree.grid(row=row, column=0,                                   
                             sticky='nsew', in_=self)
            ysb.grid(row=row, column=len(tree_columns)+1, sticky='wens')
            xsb.grid(sticky='ewns', columnspan=len(tree_columns),)
            self.grid_columnconfigure(0, weight=1,)
            self.grid_rowconfigure(row, weight=1)

            pos = 0
            self.process()
            for kind, color in colors.items():
                self._tree.tag_configure(kind, background=color)

            self._tree.bind("<Double-1>", self.OnDoubleClick)

            # create a popup menu
            self.cmn = ContextMenu(self)
            self._tree.bind("<Button-3>", self.popup)

            for col in tree_columns:
                self._tree.heading(
                    col, text=col.title(),
                    command= lambda c=col: sortby(self._tree, c, 0))
                width = tkFont.Font().measure(col.title())
                if col == "status":
                    ilen = tkFont.Font().measure(kind)
                    if ilen > width: width = ilen
                else: width = 80
                self._tree.column(col, width= width, stretch= True, anchor=tk.E)
                pos += 1
            self.grid()            
            self.pack(fill='both', expand=True)



        self.update()

    def info(self): return self.cmgen("info")
    def manual(self): return self.cmgen("manual")
    def script(self): return self.cmgen("script")
    def job(self): return self.cmgen("job")
    def jobout(self): return self.cmgen("jobout")
    def why(self): return self.cmgen("why")
    def timeline(self): return self.cmgen("timeline")
    def variables(self): return self.cmgen("variables")
    def messages(self): return self.cmgen("messages")

    def cmgen(self, kind="info"): 
        item = self.cwn
        full = self._tree.item(item,"values")
        path = self._tree.item(item,"text")
        print path, full, item
        print "#hello:" # , node.get_abs_node_path()
        upp = item; print  self._tree.parent(upp)
        while self._tree.parent(upp) != '': 
            upp = self._tree.parent(upp)
        idx = self._tree.index(upp)
        if path[0] == '/': fullpath = path
        else: fullpath = full[0]
        if idx < 0 or idx >= len(self.__client): return
        clnt = self.__client[idx]
        defs = clnt.defs()
        node = defs.find_abs_node(fullpath)
        if kind in ("script", "manual", "job", "output", "jobout"):            
            textw(clnt, fullpath, kind)
        elif kind in ("variables",):
            textw(clnt, fullpath, kind, node)
        elif kind in ("info"):
            textw(clnt, fullpath, kind, node)
        else:
            print "# not yet", node

    def popup(self, event):
        item = self._tree.identify('row',event.x,event.y)
        if item is None: print "???"; return
        print "context for", self._tree.item(item,"values"), \
            self._tree.item(item,"text")
        self.cwn = item
        self.cmn.cmn.post(event.x_root, event.y_root)

    def OnDoubleClick(self, event):
        item = self._tree.identify('row',event.x,event.y)
        if item is None: print "???"; return
        print "you clicked on", self._tree.item(item,"values"), \
            self._tree.item(item,"text")

    def check_queue(self):
        try: 
            self.__queue.get(block=False)
        except Queue.Empty: 
            pass
        else: self.update()
        self.canvas.after(50, self.check_queue)

    def unfold(self, item=None):
        if item is None:
            for item in self._tree.get_children(''):
                self.unfold(item)
            return
        item.open()
        for kid in self._tree.get_children(item):
            self.unfold(kid)

    def quit(self):
        global running
        running = False
        self.destroy()
        sys.exit(0)

    def check(self, item=None):
        if item is None:
            for item in self._tree.get_children(''): self.check(item)
            else: pass
        else:
            for kid in self._tree.get_children(item): self.check(kid)    
            full = self._tree.item(item, 'values'), 
            text = self._tree.item(item, 'text'), 
            if len(full[0]) > 3:
                full = full[0]
                if full[1] != "???":
                    unfold = displays["unfold"].get()            
                    if unfold: path = text
                    else: path = full[0]
                    guess_check(self, item, path, full[1], full[2])
          
    def process(self):
        for clt in self.__client:
            parent = self._tree.insert('', 'end', 
                                       text="%s@%s" % (clt.host, 
                                                       clt.port),
                                       open= True)
            clt.sync(self, parent)

    def update(self):
            import time
            now = time.localtime(time.time())
            hhmmss = "%02d:%02d:%02d" % (now.tm_hour, now.tm_min, now.tm_sec)
            self.last_update.set(hhmmss)
            for item in self._tree.get_children(''): self._tree.delete(item)
            self.process()

if __name__ == '__main__':
    if len(sys.argv) > 2:
        HOST = sys.argv[1]
        PORT = int(sys.argv[2])
        print "# " + PROGRAM_NAME + ": contact %s %d" % (HOST, PORT)
        client = Client(HOST, PORT)
    else: client = None
    queue = Queue.Queue()
    app = Application(client=client, queue=queue)
    app.master.title(PROGRAM_NAME)

    PaceKeeper(app, queue)

    app.mainloop()

ecflow watchdogs

A simple python client script may be used to watch over a suite:

simple example: ecflow_client.py
#!/usr/bin/env python
import ecf as ec
import sys
import time
class Observer(object):
    """ simple ecflow watchdog class """

    def __init__(self, defs, node, port, path):
        super(Observer, self).__init__()
        self.path = path
        self.node = node
        self.port = port
 
    def process_node(self, node):
        status = "%s" % node.get_dstate()
        if status not in ("queued", "complete"):
            print node.get_abs_node_path(), status
        for kid in node.nodes:
            self.process_node(kid)

    def clear(self):
        for n in range(0, 64, 1): print("\r\n")

    def run(self):
        client = ec.Client(self.node, self.port)
        while 1: 
            client.sync_local() # get changes, 
            node = client.get_defs().find_abs_node(self.path)
            assert node is not None
            self.clear()
            self.process_node(node)
            time.sleep(90)

if __name__ == "__main__":
    import os.path
    if len(sys.argv) < 3: 
        node = "localhost"
        port = "31415"
        path = "/"
    else:
        node = sys.argv[1]
        port = sys.argv[2]
        path = sys.argv[3]
    if 1:  client = Observer(None, node, port, path)
    else:  client = ObserverHtml(None, node, port, path)
    client.run()
 python ecflow_client.py localhost 31415 /compo/main/12

This simple example may be extended to generate a html page.

html client
CSS = "/home/ma/map/http/sms.css"
HEADER = '''<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="refresh" content="600">
  <title>Status tree of {node_full_name} in {hostname}-{hostport}</title>
  <link rel="stylesheet" type="text/css" href="file:{css}">
</head>
<body>
  <table border=2 cellpadding=2> <tr>'''
FOOTER = '''</table>\n</body>\n</html>'''
class ObserverHtml(Observer):
    """ simple ecflow watchdog class + html output """
    def __init__(self, defs, node, port, path):
        super(ObserverHtml, self).__init__(defs, node, port, path)
        self.userdepth = 3
        self.status_mask = ("queued", "complete", "unknown")
        self.maxdepth = 100
        self.openkids = True
    def run(self):
        client = ec.Client(self.node, self.port)
        path   = self.path
        while 1:
            client.sync_local() # get changes, synced with local definition
            node = client.get_defs().find_abs_node(path)
            if 0: assert node is not None
            if node is None: node = client.get_defs() 
            self.html_write(node)
            time.sleep(90)
    def header(self):
        print >>self.fp, HEADER.format(node_full_name=self.path,
                                       hostname= self.node,
                                       css= CSS,
                                       hostport=self.port)
    def tail(self, node=None):
        print >>self.fp, FOOTER
    def countnext(self, anything, depth):
      np = anything;
      n = 0;
      if np is not None and depth > self.maxdepth:    
          self.maxdepth = depth;
      while np is not None:
          n += 1
          np = np.next()
      return n;
    def count(self, np=None, depth=1):
       n = 0;
       if np is None: return
       if ("%s" % np.get_state() not in self.status_mask
           or depth > self.userdepth):
           return
       if depth > self.maxdepth: self.maxdepth = depth;
  
    def html_write(self, np, fpp=None):
      if fpp is None: 
          fpp = open("example.html", "w")
      self.count(np,0);
      self.fp = fpp;
      self.header();
      self.lineno = 0;
      self.table(np,0);
      self.tail(np);
      fpp.close()
    def write_link(self, node, horizontal, base, target):
      print >>self.fp, '<base target="{target}">'.format(target=target)
      for np in node.nodes:
          print >>self.fp,'      <td class="{status}">'.format(status="%s" % np.get_state())
          print >>self.fp,'<a href="{base}/{name}.html">{name}</a>'.format(
              base=base, name=np.name())
          if horizontal and np.next():
              print >>self.fp,"    </tr>"
              print >>self.fp,"    <tr>"
          print >>self.fp, "    </tr>"
    def img(self, name):
        print >>self.fp, "<img src=\"../gifs/%s.gif\" alt=\"[%s]\">" % (name,name)
    def decorations(self, node):
        status =  node.get_state()
        if status == "halted": self.img("halted")
        if status == "shutdown": self.img("shutdown")
        # rerun
        # messages
        # late
        # clock
    def html_links(self, np, fpp=None, title=0, horizontal=0):
      base = os.getenv("htmlbase");
      target = os.getenv("htmltarget");
      if base is not None: base = "od";
      if target is not None: target = "level1";
      self.fp = fpp;
      self.header();
      if title:      print >>self.fp,"      <th>{name}</th>".format(name=np.name())
      if horizontal: print >>self.fp,"    </tr>"
      if np.nodes:   print >>self.fp, "    <tr>"
      self.write_link(np, horizontal, base, target);
      self.tail();
    def table(self, node, depth):
        n = 0
        if node is None: return 0
        # if depth > self.userdepth: return 0 # depth
        try: print node.get_abs_node_path()
        except: pass
        if 1: 
              try:    status = "%s" % node.get_state() # node
              except: 
                  status = node.value()            # attribute
                  print >>self.fp, "<td>%s:%s" % (node.name(),status),"</td>"
                  return 1
              if status in self.status_mask: return 0
              try:
                  print >>self.fp, "<td class='%s'>" % status, "%s" % node.name(),"</td>"
              except:
                  for suite in node.suites: self.table(suite, depth) # Defs
                  return 0
              # decorations?
              if self.openkids:
                  # n += self.table(node.get_repeat(), depth+1)
                  for item in node.meters:
                      n += self.table(item, depth+1)
                  for item in node.events:
                      n += self.table(item, depth+1)
                  for item in node.labels:
                      n += self.table(item, depth+1)
                  
                  i = 0
                  for kid in node.nodes: 
                      if 1: print >>self.fp, "<tr>"
                      n += self.table(kid, depth+1)
                      i += 1
        if n == 0: print >>self.fp, "</tr>"; n=1
        return n

It clearly needs to be refined to provide the expected output.

Next step may be to introduce javaScript and jQuery, using JSON format to dump the tree content into a file.

JavaScript + jQuery
JQUERY = HOME + "jquery-1.10.2.min.js"
SIMPLE_CSS = """
<style type="text/css">
body {background: #ffffff; color: #000000 }
A:link {color: blue}
A:visited {color: purple}
A:active {color: blue}

A:link, A:visited, A:active {text-decoration: underline; }
li.unknown   { background: #bfbfbf }
li.complete  { background: yellow }
li.queued    { background: #add9e7 }
li.submitted { background: #04e1d1 }
li.active    { background: #00ff00 }
li.suspended { background: #ffa600 }
li.aborted   { background: #ff0000 }
li.shutdown  { background: #ffc1cc }
li.halted    { background: #ef83ef }
li.set       { background: #bbbbff }
li.clear     { background: #bbbbbb }

/* ul li { list-style: disc; }
ul ul li { list-style: circle; }
ul ul ul li { list-style: square; } */
</style>
"""
# NODES: def, suite, family, task
# ATTRIBUTES: autocancel, clock, complete, cron, date, day, defstatus, edit
# event, inlimit, label, late, limit, meter, repeat, time, today, trigger

class ObserverJS(Observer):
    """ simple ecflow watchdog class + html output """

    def __init__(self, defs, node, port, path, fname="ecflow.html"):
        super(ObserverJS, self).__init__(defs, node, port, path)
        self.fname = fname
        self.status_mask = ("queued", "complete", "unknown")

    def process_node(self, node, fp):
        if node is None: return
        elif isinstance(node, ec.Alias): return 
        elif isinstance(node, ec.Defs):
            status = "%s" % node.get_server_state()
            status = status.lower()
            name   = "%s@%s" % (self.node, self.port)
        else: 
            status = "%s" % node.get_state()
            name   = node.name()
            if status in self.status_mask: return

        print >>fp, "<ul>"
        print >>fp, "<li class='%s fold'>%s" % (status, name)      
        if isinstance(node, ec.Defs):
            for kid in node.suites:
                self.process_node(kid, fp)
        else:
            for kid in node.nodes:
                self.process_node(kid, fp)
        print >>fp, "</li></ul>"

    def gen_html(self, fp, node):
        print >>fp, HEADER.format(node_full_name= self.path,
                                  hostname= self.node,
                                  css= "", # CSS,
                                  hostport= self.port)

        print >>fp,"""
<h2>Status tree of {node_full_name} in {hostname}-{hostport}
</h2>""".format(node_full_name= self.path,
                hostname= self.node,
                hostport= self.port)

        print >>fp, """<!-- 
wget http://code.jquery.com/jquery-1.10.2.min.js
python ecflow_client.py localhost 31415 /verify example.html
firefox example.html
wget https://github.com/mbostock/d3/archive/master.zip # http://d3js.org/
# http://www.randelshofer.ch/treeviz/
-->
<script src="{jquery}"></script>""".format(jquery= JQUERY)
        print >>fp, SIMPLE_CSS

        if isinstance(node, ec.Defs):
            status = "%s" % node.get_server_state()
            d = [ { "name": "%s-%s" % (self.node, self.port),
                  "status": status.lower(),
                  "kids": [ self.get_dict(kids)
                            for kid in node.suites ] } ]
        elif (isinstance(node, ec.Suite) 
              or isinstance(node, ec.Family) 
              or isinstance(node, ec.Task)):
            d = [ self.get_dict(node) ]

        else: d = {}; print type(node); return
        import json
        print d
        print >>fp, """<script type="text/javascript">
var html = [];
var tree= """, json.dumps(d), """;

function createList(arr) {
html.push('<ul>');

$.each(arr, function(index, item) {
if (item==null) { return; }
html.push("<li class='" + item.status + " fold'>"+item.name);
if (item.kids) { createList(item.kids); }
html.push("</li>"); });

html.push('</ul>');
}

createList(tree);
$('body').append(html.join(''));
</script>
"""
        self.process_node(node, fp)
        print >>fp, FOOTER

    def get_dict(self, node):
        status = "%s" % node.get_state()
        if status in self.status_mask: return # reduce output
        return { "name": node.name(),
                 "status": status.lower(),
                 "kids": [ self.get_dict(kid) 
                           for kid in node.nodes ]}
    def run(self):
        client = ec.Client(self.node, self.port)
        while 1: 
            client.sync_local() # get changes, 
            if self.path == "/": node = client.get_defs()
            else: node = client.get_defs().find_abs_node(self.path)
            if 0: assert node is not None

            fp = open(self.fname, "w")
            self.gen_html(fp, node)
            fp.close()
            time.sleep(90)

Or even to use one of these nice libraries for fancy rendering (d3 as Treemap or SunBurst):

TreeMap
SIZE = {
    "aborted": 50,
    "queued": 10,
    "complete": 10,
    "unknown": 10,
    "submitted": 30,
    "active": 50,
    "running": 10,
    "shutdown": 30,
    "halted": 50,
}

STATUS = {
    "aborted": 5,
    "queued": 3,
    "complete": 2,
    "unknown": 10,
    "submitted": 4,
    "active": 6,
    "running": 10,
    "shutdown": 7,
    "suspended": 1,
    "halted": 8,
}

class ObserverD3(Observer):
    """ simple ecflow watchdog class + html output """

    def __init__(self, defs, node, port, path, fname="ecflow.html"):
        super(ObserverD3, self).__init__(defs, node, port, path)
        self.fname = fname
        self.status_mask = ("queued", "complete", "unknown")
        self.d3body = """
<!DOCTYPE html>
<meta charset="utf-8">
<style>

body {
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  margin: auto;
  position: relative;
  width: 960px;
}

form {
  position: absolute;
  right: 10px;
  top: 10px;
}

.node {
  border: solid 1px white;
  font: 10px sans-serif;
  line-height: 12px;
  overflow: hidden;
  position: absolute;
  text-indent: 2px;
}

</style>
<form>
  <label><input type="radio" name="mode" value="size" checked>Size</label>
  <label><input type="radio" name="mode" value="count"> Count</label>
</form>
<script src="http://d3js.org/d3.v3.min.js"></script>
<script>

var margin = {top: 40, right: 10, bottom: 10, left: 10},
    width = 960 - margin.left - margin.right,
    height = 500 - margin.top - margin.bottom;

var color = d3.scale.category20c();

var treemap = d3.layout.treemap()
    .size([width, height])
    .sticky(true)
    .value(function(d) { return d.size; });

var div = d3.select("body").append("div")
    .style("position", "relative")
    .style("width", (width + margin.left + margin.right) + "px")
    .style("height", (height + margin.top + margin.bottom) + "px")
    .style("left", margin.left + "px")
    .style("top", margin.top + "px");

d3.json("ecflow.json", function(error, root) {
  var node = div.datum(root).selectAll(".node")
      .data(treemap.nodes)
    .enter().append("div")
      .attr("class", "node")
      .call(position)
      .style("background", function(d) { return d.children ? color(d.name) : null; })
      .text(function(d) { return d.childrenkids ? null : d.name; });

  d3.selectAll("input").on("change", function change() {
    var value = this.value === "count"
        ? function() { return 1; }
        : function(d) { return d.size; };

    node
        .data(treemap.value(value).nodes)
      .transition()
        .duration(1500)
        .call(position);
  });
});

function position() {
  this.style("left", function(d) { return d.x + "px"; })
      .style("top", function(d) { return d.y + "px"; })
      .style("width", function(d) { return Math.max(0, d.dx - 1) + "px"; })
      .style("height", function(d) { return Math.max(0, d.dy - 1) + "px"; });
}

</script>
"""

    def process_node(self, node, fp):
        if node is None: return
        elif isinstance(node, ec.Alias): return 
        elif isinstance(node, ec.Defs):
            status = "%s" % node.get_server_state()
            status = status.lower()
            name   = "%s@%s" % (self.node, self.port)
        else: 
            status = "%s" % node.get_state()
            name   = node.name()
            if status in self.status_mask: return

        print >>fp, "<ul>"
        print >>fp, "<li class='%s fold'>%s" % (status, name)      
        if isinstance(node, ec.Defs):
            for kid in node.suites:
                self.process_node(kid, fp)
        else:
            for kid in node.nodes:
                self.process_node(kid, fp)
        print >>fp, "</li></ul>"
        # def, suite, family, task
        # autocancel, clock, complete, cron, date, day, defstatus, edit
        # event, inlimit, label, late, limit, meter, repeat, time, today, trigger

    def get_dict(self, node):
        if isinstance(node, ec.Alias): return 
        status = "%s" % node.get_state()
        try: a = SIZE[status],
        except: print SIZE.keys(), status; raise
        return { "name": node.name(),
                "status": STATUS[status],
                "state": status,
                "size":  SIZE[status], 
                "children": [ self.get_dict(kid) for kid in node.nodes ]}

    def run(self):
        client = ec.Client(self.node, self.port)
        fp = open(self.fname, "w")
        print >> fp, self.d3body
        fp.close()
        while 1: 
            client.sync_local() # get changes, 
            if self.path == "/": node = client.get_defs()
            else: node = client.get_defs().find_abs_node(self.path)
            if 0: assert node is not None

            if isinstance(node, ec.Defs):
                status = "%s" % node.get_server_state()
                d = {"name": "%s-%s" % (self.node, self.port),
                     "status": STATUS[status.lower()],
                     "size":  SIZE[status.lower()],
                     "state": status,
                     "children": [self.get_dict(kid) for kid in node.suites]}

            elif isinstance(node, ec.Alias): pass

            elif (isinstance(node, ec.Suite) 
                  or isinstance(node, ec.Family) 
                  or isinstance(node, ec.Task)):
                d = self.get_dict(node)
                     
            else: d = {}; print type(node) # FIXME
            import json
            print d
            fp = open("ecflow.json", "w")
            print >>fp, json.dumps(d)
            fp.close()
            time.sleep(90)

SunBurst
class ObserverD3SunBurst(ObserverD3):
    def __init__(self, defs, node, port, path, fname="ecflow.html"):
        super(ObserverD3SunBurst, self).__init__(defs, node, port, path, fname)
        self.d3body = """
<!DOCTYPE html>
<meta charset="utf-8">
<style>

path {
  stroke: #fff;
  fill-rule: evenodd;
}

</style>
<body>
<script src="http://d3js.org/d3.v3.min.js"></script>
<script>

var width = 960,
    height = 700,
    radius = Math.min(width, height) / 2;

var x = d3.scale.linear()
    .range([0, 2 * Math.PI]);

var y = d3.scale.sqrt()
    .range([0, radius]);

var color = d3.scale.category20c();

var svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height)
  .append("g")
    .attr("transform", "translate(" + width / 2 + "," + (height / 2 + 10) + ")");

var partition = d3.layout.partition()
    .value(function(d) { return d.size; });

var arc = d3.svg.arc()
    .startAngle(function(d) { return Math.max(0, Math.min(2 * Math.PI, x(d.x))); })
    .endAngle(function(d) { return Math.max(0, Math.min(2 * Math.PI, x(d.x + d.dx))); })
    .innerRadius(function(d) { return Math.max(0, y(d.y)); })
    .outerRadius(function(d) { return Math.max(0, y(d.y + d.dy)); });

d3.json("ecflow.json", function(error, root) {
  var path = svg.selectAll("path")
      .data(partition.nodes(root))
    .enter().append("path")
      .attr("d", arc)
      .attr("name", function(d) { return d.name; })
      .style("fill", function(d) { return color((d.children ? d : d.parent).name); })
      .on("click", click);

  function click(d) {
    path.transition()
      .duration(750)
      .attrTween("d", arcTween(d));
  }
});

d3.select(self.frameElement).style("height", height + "px");

// Interpolate the scales!
function arcTween(d) {
  var xd = d3.interpolate(x.domain(), [d.x, d.x + d.dx]),
      yd = d3.interpolate(y.domain(), [d.y, 1]),
      yr = d3.interpolate(y.range(), [d.y ? 20 : 0, radius]);
  return function(d, i) {
    return i
        ? function(t) { return arc(d); }
        : function(t) { x.domain(xd(t)); y.domain(yd(t)).range(yr(t)); return arc(d); };
  };
}

</script>
"""