rrdweb/wsgi.py
author Tero Marttila <terom@fixme.fi>
Tue, 02 Nov 2010 05:09:09 +0200
changeset 24 29a523db66a8
parent 22 809686edcd4c
child 32 47e977c23ba2
permissions -rw-r--r--
odd/even for overview graphs
"""
    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), idx) for idx, rrd in enumerate(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, idx) :
        """
            Render overview item for given target to HTML.
        """
        
        return self.templates.render('overview-target',
                oddeven             = 'odd' if idx % 2 else 'even',
                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),
    ))