rrdweb/wsgi.py
author Tero Marttila <terom@fixme.fi>
Tue, 25 Jan 2011 01:28:06 +0200
changeset 32 47e977c23ba2
parent 24 29a523db66a8
permissions -rw-r--r--
implement rendering of pmacct rrd graphs, and a dir/top.png feature
"""
    Dynamic frontend
"""

import werkzeug
from werkzeug import exceptions
from werkzeug import Request, Response
from werkzeug.routing import Map, Rule

from rrdweb import html, graph, backend

import os, os.path
import errno
import logging


# logging
log = logging.getLogger('rrdweb.wsgi')


class WSGIApp (object) :
    def __init__ (self, rrdpath, tplpath, imgpath, rrdgraph=graph.pmacct_bytes) :
        """
            Configure

                rrdpath         - path to directory containing *.rrd files
                tplpath         - path to HTML templates
                imgpath         - path to generated PNG images. Must be writeable

                rrdgraph        - the graph.*_data function for rendering the graphs.
        """

        self.rrdpath = os.path.abspath(rrdpath)
        self.templates = html.BaseFormatter(tplpath)
        self.imgpath = os.path.abspath(imgpath)

        self.rrd_graph_func = rrdgraph


    # 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 fs_path (self, name) :
        """
            Lookup and return the full filesystem path to the given relative RRD file/dir.

            The given name must be a relative path (no leading /).

            Raises NotFound for invalid paths.
        """

        # full path
        path = os.path.normpath(os.path.join(self.rrdpath, name))

        # check inside base path
        if not path.startswith(self.rrdpath) :
            # not found
            raise exceptions.NotFound(name)

        # ok
        return path

    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),
                hourly_img          = url(self.graph, rrd=rrd, style='detail', interval='hourly'),
                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 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, returning the opened file object.
        """

        rrd_path = self.rrd_path(rrd)

        # title
        # this is &raquo;
        title = " / ".join(rrd.split('/'))

        log.debug("%s -> %s", rrd_path, png_path)

        # generate using the variant function given
        w, h, report, png_file = graph.graph_single(style, interval, title, self.rrd_graph_func, rrd_path, png_path)
        
        # the open'd .tmp file
        return png_file

    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'
            
            # open and write the graph image
            img_file = self.render_graph(rrd, style, interval, tmp_path)

            # replace .png with .tmp (semi-atomic, but atomic enough..)
            # XXX: probably not portable to windows, what with img_file
            os.rename(tmp_path, img_path)
        
        else :
            # use existing file
            img_file = open(img_path, 'rb')
        
        # return the now-fresh .png and return that
        return img_file

    ### Request handlers
    def index (self, req, url, dir = '') :
        """
            Directory overview

                dir     - (optional) relative path to subdir from base rrdpath
        """
        
        # lookup
        path = self.fs_path(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)        
    
    def graph_top (self, req, url, dir = '', count=5) :
        """
            Show top N hosts by peak/average.
        """

        # find
        path = self.fs_path(dir)

        # scan
        subdirs, rrds = self.scan_dir(path)

        # get top N hosts
        hosts = backend.calc_top_hosts(path, rrds, 'in', 'out', count=count)
        
        # draw graph with hosts
        w, h, data, img = graph.graph_multi('hourly', 
            "Top %d hosts" % count, 
            [('%s/%s.rrd' % (path, name), name) for name in hosts],
            self.rrd_graph_func,
            None   # to tmpfile
        )
        
        # wrap file output
        response_file = werkzeug.wrap_file(req.environ, img)

        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('/top.png', endpoint=graph_top, defaults=dict(dir = '')),
        Rule('/<path:dir>/', endpoint=index),
        Rule('/<path:dir>/top.png', endpoint=graph_top),
        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),
    ))