New dynamic WSGI frontend
authorTero Marttila <terom@fixme.fi>
Tue, 02 Nov 2010 04:11:06 +0200
changeset 22 809686edcd4c
parent 21 d4f9fee218e5
child 23 50f1b76b0835
New dynamic WSGI frontend
etc/templates/layout.html
etc/templates/overview-subdir.html
etc/templates/overview.html
rrdweb/html.py
rrdweb/wsgi.py
wsgi-dev.py
--- a/etc/templates/layout.html	Tue Nov 02 00:27:46 2010 +0200
+++ b/etc/templates/layout.html	Tue Nov 02 04:11:06 2010 +0200
@@ -2,26 +2,28 @@
   "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
 <html>
 	<head>
-		<title>Päivölä Network</title>
+		<title>Päivölä Network - %(title)s</title>
 		<link rel="Stylesheet" type="text/css" href="/static/style.css" />
 		<link rel="Stylesheet" type="text/css" href="/static/rrdweb.css" />
 	</head>
 	<body>
 		<div id="header">
-			Päivölä Network
+			Päivölä Network - %(title)s
 		</div>
 
 		<div id="menu">
 			<ul>
-				<li class="open"><a href="/">Main</a></li>
-				<li><a href="/issues/">Issues</a></li>
-				<li><a href="/weathermap/">Traffic</a></li>
-				<li class="open"><a href="/mrtg/">MRTG</a></li>
-				<li><a href="/smokeping/">Smokeping</a></li>
+				<li class="open"><a href="http://verkko.paivola.fi/">Main</a></li>
+				<li><a href="http://verkko.paivola.fi/issues/">Issues</a></li>
+				<li><a href="http://verkko.paivola.fi/weathermap/">Traffic</a></li>
+				<li class="open"><a href="http://verkko.paivola.fi/mrtg/">MRTG</a></li>
+				<li><a href="http://verkko.paivola.fi/smokeping/">Smokeping</a></li>
 			</ul>
 		</div>
 
         <div id="content">
+            <div id="breadcrumb">%(breadcrumb)s</div>
+
             %(content)s
         </div>
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/etc/templates/overview-subdir.html	Tue Nov 02 04:11:06 2010 +0200
@@ -0,0 +1,3 @@
+    <li>
+        <a href="%(dir_url)s">%(dir_name)s/</a>
+    </li>
--- a/etc/templates/overview.html	Tue Nov 02 00:27:46 2010 +0200
+++ b/etc/templates/overview.html	Tue Nov 02 04:11:06 2010 +0200
@@ -1,6 +1,10 @@
-<h1>Daily Overview</h1>
+<h1>Daily Overview for %(dir)s</h1>
 
-<ul id='overview-graphs'>
+<ul id="overview-subdirs">
+    %(overview_subdirs)s
+</ul>
+
+<ul id="overview-graphs">
     %(overview_graphs)s
 </ul>
 
--- a/rrdweb/html.py	Tue Nov 02 00:27:46 2010 +0200
+++ b/rrdweb/html.py	Tue Nov 02 04:11:06 2010 +0200
@@ -5,37 +5,60 @@
 
 import os.path
 
-class Formatter (object) :
+class BaseFormatter (object) :
+    """
+        Trivial HTML template formatter.
+    """
+
+    def __init__ (self, basedir, encoding = 'utf-8') :
+        """
+                basedir     - directory containing the required .html files
+                encoding    - unicode encoding of file content (default: utf-8)
+        """
+
+        self.basedir = os.path.abspath(basedir)
+        self.encoding = encoding
+
+    def render (self, name, **vars) :
+        """
+            Format and return given template.
+
+                name        - basename of template without .html
+                **vars      - template context
+
+            The template is rendered and returned as unicode.
+        """
+
+        path = os.path.join(self.basedir, name) + '.html'
+        
+        # read contents
+        data = open(path).read().decode(self.encoding)
+
+        # format
+        return data % vars
+
+
+# XXX: legacy
+class Formatter (BaseFormatter) :
+
     TEMPLATE_DIR = 'etc/templates'
 
     IMG_URL = '%(prefix)s?t=%(target)s&s=%(style)&i=%(interval)s'
     TARGET_URL = '%(prefix)s?t=%(target)s'
 
     def __init__ (self, template_dir=TEMPLATE_DIR, url_prefix='', img_url=IMG_URL, target_url=TARGET_URL) :
-        self.template_dir = template_dir
+        BaseFormatter.__init__(self, template_dir, 'utf-8')
+
         self.url_prefix = url_prefix
         self.img_url = img_url
         self.target_url = target_url
 
-    def tpl (self, name, **vars) :
-        """
-            Format and return given template
-        """
-
-        path = os.path.join(self.template_dir, name) + '.html'
-        
-        # read contents
-        data = open(path).read()
-
-        # format
-        return data % vars
-
     def page (self, content) :
         """
             Format page contents
         """
 
-        return self.tpl('layout', content=content)
+        return self.render('layout', content=content)
 
     def fmt_img_url (self, style, interval, target) :
         return self.img_url % dict(
@@ -56,9 +79,9 @@
             Format target listing
         """
 
-        return self.page(self.tpl('overview', 
+        return self.page(self.render('overview', 
             overview_graphs      = '\n'.join(
-                self.tpl('overview-target',
+                self.render('overview-target',
                     title               = target.title,
                     daily_overview_img  = self.fmt_img_url('overview', 'daily', target),
                     target_url          = self.fmt_target_url(target),
@@ -72,7 +95,7 @@
             Format a specific target
         """
         
-        return self.page(self.tpl('target',
+        return self.page(self.render('target',
             title               = target.title,
 
             daily_img           = self.fmt_img_url('detail', 'daily', target),
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rrdweb/wsgi.py	Tue Nov 02 04:11:06 2010 +0200
@@ -0,0 +1,459 @@
+"""
+    Dynamic frontend
+"""
+
+import werkzeug
+from werkzeug import exceptions
+from werkzeug import Request, Response
+from werkzeug.routing import Map, Rule
+
+from rrdweb import html, graph
+
+import os, os.path
+import errno
+import logging
+
+
+# logging
+log = logging.getLogger('rrdweb.wsgi')
+
+
+class WSGIApp (object) :
+    def __init__ (self, rrdpath, tplpath, imgpath) :
+        """
+            Configure
+
+                rrdpath         - path to directory containing *.rrd files
+                tplpath         - path to HTML templates
+                imgpath         - path to generated PNG images. Must be writeable
+        """
+
+        self.rrdpath = os.path.abspath(rrdpath)
+        self.templates = html.BaseFormatter(tplpath)
+
+        # XXX: some kind of fancy cache thingie :)
+        self.imgpath = os.path.abspath(imgpath)
+
+
+    # wrap to use werkzeug's Request/Response
+    @Request.application
+    def __call__ (self, req) :
+        """
+            Main WSGI entry point
+        """
+
+        try :
+            response = self.request(req)
+
+        except exceptions.HTTPException, e :
+            # format as response
+            return e.get_response(req.environ)
+
+        else :
+            # a-ok
+            return response
+
+
+    def request (self, req) :
+        """
+            Wrapped request handler
+        """
+
+
+        # map URLs against this request
+        urls = self.URLS.bind_to_environ(req)
+        
+        # lookup URL against endpoint and dict of matched values from URL
+        endpoint, args = urls.match()
+
+        def build_url (method, **args) :
+            """
+                Small wrapper around Werkzeug's routing.MapAdapter.build to suit our puroses
+            """
+
+            return urls.build(method.im_func, args)
+
+        # invoke
+        # XXX: non-methods?
+        response = endpoint(self, req, build_url, **args)
+
+        return response
+
+
+    def scan_dir (self, dir) :
+        """
+            Scan for RRD files and subdirectories directly underneath the given path.
+
+            Returns a ([subdir_name], [rrd_name]) tuple, with the sorted lists of subdirs and rrds.
+        """
+
+        # we need to do this procedurally, because we collect two lists :(
+        subdirs = []
+        rrds = []
+
+        log.debug("Scanning dir %s", dir)
+
+        for name in os.listdir(dir) :
+            # skip hidden files
+            if name.startswith('.') :
+                continue
+
+            # path to file
+            path = os.path.join(dir, name)
+
+            # possible extesion
+            basename, extname = os.path.splitext(name)
+
+            log.debug("\tname=%s - %s", name, extname)
+
+            # collect subdirs
+            if os.path.isdir(path) :
+                subdirs.append(name)
+                
+#                log.debug("\tsubdir: %s", name)
+
+            # collect .rrd's
+            elif extname == '.rrd' :
+                # without the .rrd
+                rrds.append(basename)
+
+#                log.debug("\trrd: %s", basename)
+
+        # return sorted lists
+        subdirs.sort()
+        rrds.sort()
+
+        return subdirs, rrds
+
+    def fmt_page (self, breadcrumb, content) :
+        """
+            Render page with master layout as HTML.
+
+                breadcrumb  - breadcrumb nav as HTML
+                content     - main content as HTML
+        """
+
+        log.debug("content = %r", content)
+
+        return self.templates.render('layout',
+                title       = "MRTG",       # XXX: some context
+                breadcrumb  = breadcrumb,
+                content     = content
+        )
+
+    def fmt_breadcrumb_segment (self, url, node, name) :
+        """
+            Render the breadcrumb link for the given node as HTML.
+        """
+
+        # real path
+        path = self.fs_path(node)
+
+        if os.path.isdir(path) :
+            # index
+            return dict(
+                    url     = url(self.index, dir=node),
+                    name    = name + '/',
+            )
+
+        else :
+            # XXX: assume .rrd
+
+            return dict(
+                    url     = url(self.target, rrd=node),
+                    name    = name,
+            )
+
+    def fmt_breadcrumb_segments (self, url, segments) :
+        """
+            Render a sequence of segments for each of the nodes in the given list of segments.
+        """
+
+        # root node
+        yield dict(
+                url     = url(self.index),
+                name    = "MRTG"
+        )
+
+        path = ''
+
+        for segment in segments :
+            # cumulative path
+            path = os.path.join(path, segment)
+
+            # format the induvidual node
+            yield self.fmt_breadcrumb_segment(url, path, segment)
+
+
+    def fmt_breadcrumb (self, url, node) :
+        """
+            Render the breadcrumb for the given node's path as HTML.
+        """
+
+        # split path to node into segments
+        segments = node.split('/')
+
+        log.debug("%r -> %r", node, segments)
+
+        # join segments together as hrefs
+        return " &raquo ".join(
+            '<a href="%(url)s">%(name)s</a>' % html for html in self.fmt_breadcrumb_segments(url, segments)
+        )
+
+
+    def fmt_overview (self, url, dir, subdirs, rrds) :
+        """
+            Render overview page listing given RRDs to HTML.
+        """
+        
+        if not dir :
+            dir = '/'
+
+        log.debug("Overview for %r with %d subdirs and %d rrds", dir, len(subdirs), len(rrds))
+
+        return self.templates.render('overview', 
+                dir             = dir,
+                overview_subdirs = '\n'.join(
+                    self.fmt_overview_subdir(url, subdir, os.path.join(dir, subdir)) for subdir in subdirs
+                ),
+                overview_graphs = '\n'.join(
+                    self.fmt_overview_target(url, os.path.join(dir, rrd)) for rrd in rrds
+                ),
+        )
+
+    
+    def fmt_overview_subdir (self, url, subdir, dir) :
+        """
+            Render overview item for given subdir to HTML.
+        """
+
+        return self.templates.render('overview-subdir',
+                dir_url             = url(self.index, dir=dir),
+                dir_name            = subdir,
+        )
+
+
+    def fmt_overview_target (self, url, rrd) :
+        """
+            Render overview item for given target to HTML.
+        """
+        
+        return self.templates.render('overview-target',
+                title               = self.rrd_title(rrd),
+                target_url          = url(self.target, rrd=rrd),
+                daily_overview_img  = url(self.graph, rrd=rrd, style='overview'),
+        )
+
+
+    def fmt_target (self, url, rrd) :
+        """
+            Render target overview page to HTML.
+        """
+
+        return self.templates.render('target',
+                title               = self.rrd_title(rrd),
+                daily_img           = url(self.graph, rrd=rrd, style='detail', interval='daily'),
+                weekly_img          = url(self.graph, rrd=rrd, style='detail', interval='weekly'),
+                yearly_img          = url(self.graph, rrd=rrd, style='detail', interval='yearly'),
+        )
+
+
+    def fs_path (self, node) :
+        """
+            Lookup and return the full filesystem path to the given relative RRD/dir path.
+        """
+
+        # dir is relative (no leading slash)
+        # full path
+        path = os.path.normpath(os.path.join(self.rrdpath, node))
+
+        # check inside base path
+        if not path.startswith(self.rrdpath) :
+            # mask
+            raise exceptions.NotFound(node)
+
+        # ok
+        return path
+
+    def rrd_path (self, rrd) :
+        """
+            Lookup and return the full filesystem path to the given RRD name.
+        """
+
+        # real path
+        path = self.fs_path(rrd + '.rrd')
+
+        # found as file?
+        if not os.path.isfile(path) :
+            raise exceptions.NotFound("No such RRD file: %s" % (rrd, ))
+
+        return path
+
+    def rrd_title (self, rrd) :
+        """
+            Generate a neat human-readable title from the given RRD name.
+        """
+
+        # XXX: path components...
+        return " &raquo; ".join(rrd.split('/'))
+
+    def render_graph (self, rrd, style, interval, png_path) :
+        """
+            Render the given graph for the given RRD to the given path.
+        """
+
+        rrd_path = self.rrd_path(rrd)
+
+        # title
+        # this is &raquo;
+        title = " / ".join(rrd.split('/'))
+
+        log.debug("%s -> %s", rrd_path, png_path)
+
+        # XXX: always generate
+        graph.mrtg(style, interval, title, rrd_path, png_path)
+
+    def rrd_graph (self, rrd, style, interval, flush=False) :
+        """
+            Return an open file object representing the given graph's PNG image.
+
+            This is returned directly from cache, if a fresh copy is available. The cached copy is compared against
+            the source RRD file.
+        """
+
+        # real path to .rrd
+        rrd_path = self.rrd_path(rrd)
+
+        # path to cached img
+        img_path = os.path.join(self.imgpath, style, interval, rrd) + '.png'
+
+        # this should always exist..
+        rrd_stat = os.stat(rrd_path)
+
+        try :
+            # this may not exist
+            img_stat = os.stat(img_path)
+
+        except OSError, e :
+            if e.errno == errno.ENOENT :
+                # doesn't exist
+                img_stat = None
+            
+            else :
+                # can't handle
+                raise
+        
+        # check freshness
+        if flush or img_stat is None or rrd_stat.st_mtime > img_stat.st_mtime :
+            # generate containing dir if missiong
+            dir_path = os.path.dirname(img_path)
+
+            if not os.path.isdir(dir_path) :
+                log.warn("makedirs %s", dir_path)
+
+                os.makedirs(dir_path)
+            
+            # re-generate to tmp file
+            tmp_path = os.path.join(self.imgpath, style, interval, rrd) + '.tmp'
+
+            self.render_graph(rrd, style, interval, tmp_path)
+
+            # replace .png with .tmp (semi-atomic, but atomic enough..)
+            os.rename(tmp_path, img_path)
+
+        # open the now-fresh .png and return that
+        return open(img_path)
+
+
+
+    ### Request handlers
+
+
+#    def node (self, url, path) :
+#        """
+#            Arbitrate between URLs to dirs and to RRDs.
+#        """
+#
+
+
+    def index (self, req, url, dir = '') :
+        """
+            Directory overview
+
+                dir     - (optional) relative path to subdir from base rrdpath
+        """
+
+        # lookup fs path
+        path = self.fs_path(dir)
+
+        # found?
+        if not os.path.isdir(path) :
+            raise exceptions.NotFound("No such RRD directory: %s" % (dir, ))
+
+        # scan
+        subdirs, rrds = self.scan_dir(path)
+        
+        # render
+        html = self.fmt_page(
+                self.fmt_breadcrumb(url, dir),
+                self.fmt_overview(url, dir, subdirs, rrds)
+        )
+
+        return Response(html, mimetype='text/html')
+
+
+    def target (self, req, url, rrd) :
+        """
+            Target overview
+
+        """
+
+        # verify existance
+        path = self.rrd_path(rrd)
+
+        # render
+        html = self.fmt_page(
+                self.fmt_breadcrumb(url, rrd),
+                self.fmt_target(url, rrd)
+        )
+
+        return Response(html, mimetype='text/html')
+
+
+    STYLES = graph.STYLE_DEFS.keys()
+    INTERVALS = graph.INTERVAL_DEFS.keys()
+
+    def graph (self, req, url, rrd, style, interval) :
+        """
+            Target graph
+        """
+        
+        # validate style/interval
+        if style not in self.STYLES or interval not in self.INTERVALS :
+            raise exceptions.BadRequest("Invalid style/interval")
+
+        # flush if asked to by ?flush
+        flush = ('flush' in req.args)
+
+        # render
+        png = self.rrd_graph(rrd, style, interval, flush=flush)
+
+        # construct wrapper for response file, using either werkzeug's own wrapper, or the one provided by the WSGI server
+        response_file = werkzeug.wrap_file(req.environ, png)
+        
+        # respond with file wrapper
+        return Response(response_file, mimetype='image/png', direct_passthrough=True)        
+
+
+    # map URLs to various methods
+    # XXX: this uses the method object as the endpoint, which is a bit silly, since it's not bound and we need to pass
+    #      in self explicitly..
+    URLS = Map((
+        Rule('/', endpoint=index, defaults=dict(dir = '')),
+        Rule('/<path:dir>/', endpoint=index),
+        Rule('/<path:rrd>.rrd', endpoint=target),
+        Rule('/<path:rrd>.rrd/<string:style>.png', endpoint=graph, defaults=dict(interval = 'daily')),
+        Rule('/<path:rrd>.rrd/<string:style>/<string:interval>.png', endpoint=graph),
+    ))
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wsgi-dev.py	Tue Nov 02 04:11:06 2010 +0200
@@ -0,0 +1,27 @@
+"""
+    Simple test server/environment for WSGI development
+"""
+
+import werkzeug
+
+from rrdweb import wsgi
+
+import logging
+
+
+if __name__ == '__main__' :
+    logging.basicConfig(format="[%(levelname)5s] %(funcName)25s : %(message)s", level=logging.DEBUG)
+
+    app = wsgi.WSGIApp(
+            rrdpath     = 'rrd/',
+            tplpath     = 'etc/templates',
+            imgpath     = 'img/',
+    )
+
+    # run
+    werkzeug.run_simple('localhost', 8081, app, use_reloader=True, use_debugger=True, 
+            static_files    = {
+                '/static':  'static/',
+            },
+    )
+