Initial implementation of visualisation

Run an embedded webserver on another port, generating trivial web
pages and graphics to show the various VLANs we have in use.

Change-Id: I1dc6333147c9ee673d7806aad759f63d1548c4eb
diff --git a/visualisation/__init__.py b/visualisation/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/visualisation/__init__.py
diff --git a/visualisation/graphics.py b/visualisation/graphics.py
new file mode 100644
index 0000000..fb861ea
--- /dev/null
+++ b/visualisation/graphics.py
@@ -0,0 +1,482 @@
+#! /usr/bin/python
+
+#  Copyright 2015 Linaro Limited
+#
+#  This program is free software; you can redistribute it and/or modify
+#  it under the terms of the GNU General Public License as published by
+#  the Free Software Foundation; either version 2 of the License, or
+#  (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software
+#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+#  MA 02110-1301, USA.
+#
+#  Visualisation graphics module for VLANd
+#
+#  This code uses python-gd to generate graphics ready for insertion
+#  into our web interface. Example code in the self-test at the
+#  bottom.
+
+import gd, os, sys
+
+if __name__ == '__main__':
+    vlandpath = os.path.abspath(os.path.normpath(os.path.dirname(sys.argv[0])))
+    sys.path.insert(0, vlandpath)
+    sys.path.insert(0, "%s/.." % vlandpath)
+
+from errors import InputError
+
+class Graphics:
+    """ Code and config for the visualisation graphics module """
+
+    font = None
+
+    # Default font size for the small labels
+    small_font_size = 12
+
+    # And the size for the top-level label
+    label_font_size = 24
+
+    # Size in pixels of that font, calculated later
+    twocharwidth = 0
+    charheight = 0
+
+    # How big a gap to leave between trunk connections
+    trunk_gap = 8
+
+    # Details of the legend
+    legend_width = 0
+    legend_height = 0
+    legend_text_width = 0
+    legend_text_height = 0
+    legend_total_width = 0
+
+    # Basic colour definitions used later
+    colour_defs = {}
+    colour_defs['black'] = (0, 0, 0)
+    colour_defs['white'] = (255, 255, 255)
+    colour_defs['purple'] = (255, 0, 255)
+    colour_defs['blue'] = (0, 0, 255)
+    colour_defs['darkgrey'] = (60, 60, 60)
+    colour_defs['yellow'] = (255, 255, 0)
+    colour_defs['red'] = (255, 0, 0)
+    colour_defs['aqua'] = (0, 255, 255)
+
+    pallette = {}
+
+    # colours for the background
+    pallette['bg_colour'] = 'purple'
+    pallette['transparent_colour'] = 'purple'
+    pallette['graphic_label_colour'] = 'black'
+
+    # switch colours
+    pallette['switch_outline_colour'] = 'black'
+    pallette['switch_fill_colour'] = 'darkgrey'
+    pallette['switch_label_colour'] = 'white'
+
+    # verious sets of port colours, matching the 'highlight' options in
+    # draw_port()
+    port_pallette = {}
+    port_pallette['normal'] = {}
+    port_pallette['normal']['port_box'] = 'white'
+    port_pallette['normal']['port_bg'] = 'black'
+    port_pallette['normal']['port_label'] = 'white'
+    port_pallette['normal']['trace'] = 'black'
+
+    port_pallette['trunk'] = {}
+    port_pallette['trunk']['port_box'] = 'white'
+    port_pallette['trunk']['port_bg'] = 'blue'
+    port_pallette['trunk']['port_label'] = 'yellow'
+    port_pallette['trunk']['trace'] = 'blue'
+
+    port_pallette['locked'] = {}
+    port_pallette['locked']['port_box'] = 'white'
+    port_pallette['locked']['port_bg'] = 'red'
+    port_pallette['locked']['port_label'] = 'yellow'
+    port_pallette['locked']['trace'] = 'red'
+
+    port_pallette['VLAN'] = {}
+    port_pallette['VLAN']['port_box'] = 'white'
+    port_pallette['VLAN']['port_bg'] = 'aqua'
+    port_pallette['VLAN']['port_label'] = 'black'
+    port_pallette['VLAN']['trace'] = 'aqua'
+
+    im = None
+
+    # TODO: make colours configurable, add maybe parsing for
+    # /etc/X11/rgb.txt to allow people to use arbitrary names?
+
+    # Choose a font for our graphics to use. Pass in a list of fonts
+    # to be tried, in priority order.
+    def set_font(self, fontlist):
+        for font in fontlist:
+            if os.path.exists(font):
+                self.font = os.path.abspath(font)
+                break
+
+        # Work out how big we need to be for the biggest possible text
+        # in a 2-digit number. Grotty, but we need to know this later.
+        for value in range (0, 100):
+            (width, height) = self.get_label_size(repr(value), self.small_font_size)
+            self.twocharwidth = max(self.twocharwidth, width)
+            self.charheight = max(self.charheight, height)
+
+        # Now we can also calulate other stuff
+        self._calc_legend_size()
+
+    # Create a canvas and set things up ready for use
+    def create_canvas(self, x, y):
+        im = gd.image((x, y))
+        
+        # Allocate our colours in the image's colour map
+        for key in self.colour_defs.iterkeys():
+            im.colorAllocate((self.colour_defs[key][0],
+                              self.colour_defs[key][1],
+                              self.colour_defs[key][2]))
+
+        im.fill((0,0), im.colorExact(self.colour_defs[self.pallette['bg_colour']]))
+        im.colorTransparent(im.colorExact(self.colour_defs[self.pallette['transparent_colour']]))
+        im.interlace(0)
+        self.im = im
+
+    # Using our selected font, what dimensions will a particular piece
+    # of text take?
+    def get_label_size(self, label, font_size):
+        tmp_im = gd.image((200, 200))
+        (llx, lly, lrx, lry, urx, ury, ulx, uly) = tmp_im.get_bounding_rect(self.font,
+                                                                            font_size,
+                                                                            0.0,
+                                                                            (10, 100), label)
+        width = max(lrx, urx) - min(llx, ulx)
+        height = max(lly, lry) - min(uly, ury)
+        return (width, height)
+
+    # Draw a trunk connection between two ports
+    #
+    # Ports are defined as (x, y, top): x, y co-ordinates and whether
+    # the port is on the top or bottom row of a switch, i.e. does the
+    # wire come up or down when it leaves the port.
+    def draw_trunk(self, trunknum, node1, node2, colour):
+        for node in (node1, node2):
+            (x1,y1,top) = node
+            x2 = x1
+            if (top):
+                y2 = y1 - (self.trunk_gap * (trunknum + 1))
+            else:
+                y2 = y1 + (self.trunk_gap * (trunknum + 1))
+            # Quick hack - use 2-pixel wide rectangles as thick lines :-)
+            # First line, vertically up/down from the port
+            self.im.rectangle((x1-1,y1), (x2,y2), self.im.colorExact(self.colour_defs[colour]))
+            # Now draw horizontally across to the left margin space
+            x3 = self.trunk_gap * (trunknum + 1)
+            self.im.rectangle((x3, y2), (x2,y2+1), self.im.colorExact(self.colour_defs[colour]))
+        # Now join up the trunks vertically
+        (x1,y1,top1) = node1
+        if (top1):
+            y1 -= self.trunk_gap * (trunknum + 1)
+        else:
+            y1 += self.trunk_gap * (trunknum + 1)
+        (x2,y2,top2) = node2
+        if (top2):
+            y2 -= self.trunk_gap * (trunknum + 1)
+        else:
+            y2 += self.trunk_gap * (trunknum + 1)
+        x1 = self.trunk_gap * (trunknum + 1)
+        self.im.rectangle((x1, y1), (x1+1,y2), self.im.colorExact(self.colour_defs[colour]))
+
+    # How big is the legend?
+    def _calc_legend_size(self):
+        max_width = 0
+        max_height = 0
+
+        for value in self.port_pallette.iterkeys():
+            (width, height) = self.get_label_size(value, self.small_font_size)
+            max_width = max(max_width, width)
+            max_height = max(max_height, height)
+
+        (width, height) = self.get_label_size('##', self.small_font_size)
+        self.legend_box_width = width + 6
+        self.legend_box_height = height + 6        
+        self.legend_width = max_width + self.legend_box_width + 10
+        self.legend_height = 3 + self.legend_box_height + 3
+        self.legend_text_width = max_width
+        self.legend_text_height = max_height
+        self.legend_total_width = 6 + (len(self.port_pallette) * self.legend_width)
+
+    # Return the legend dimensions
+    def get_legend_dimensions(self):
+        return (self.legend_total_width, self.legend_height)
+
+    # Draw the legend using (left, top) as the top left corner
+    def draw_legend(self, left, top):
+        lrx = left + self.legend_total_width - 1
+        lry = top + self.legend_height - 1
+        self.im.rectangle((left, top), (lrx, lry),
+                        self.im.colorExact(self.colour_defs[self.pallette['switch_outline_colour']]),
+                        self.im.colorExact(self.colour_defs[self.pallette['switch_fill_colour']]))
+        curr_x = left + 3
+        curr_y = top + 3
+
+        for value in sorted(self.port_pallette):
+            box_colour = self.im.colorExact(self.colour_defs[self.port_pallette[value]['port_box']])
+            box_bg_colour = self.im.colorExact(self.colour_defs[self.port_pallette[value]['port_bg']])
+            text_colour = self.im.colorExact(self.colour_defs[self.port_pallette[value]['port_label']])
+            lrx = curr_x + self.legend_box_width - 1
+            lry = curr_y + self.legend_box_height - 1
+            self.im.rectangle((curr_x,curr_y), (lrx,lry), box_colour, box_bg_colour)
+            
+            llx = curr_x + 4
+            lly = curr_y + self.legend_box_height - 4
+            self.im.string_ttf(self.font, self.small_font_size, 0.0, (llx, lly), '##', text_colour)
+            curr_x += self.legend_box_width
+            self.im.string_ttf(self.font, self.small_font_size, 0.0, (curr_x + 3, lly), value,
+                               self.im.colorExact(self.colour_defs[self.pallette['switch_label_colour']]))
+            curr_x += self.legend_text_width + 10
+
+    # Draw the graphic's label using (left, top) as the top left
+    # corner with a box around
+    def draw_label(self, left, top, label, gap):
+        box_colour = self.im.colorExact(self.colour_defs[self.pallette['switch_label_colour']])
+        box_bg_colour = self.im.colorExact(self.colour_defs[self.pallette['switch_fill_colour']])
+        text_colour = self.im.colorExact(self.colour_defs[self.pallette['switch_label_colour']])
+        (width, height) = self.get_label_size(label, self.label_font_size)
+        curr_x = left
+        curr_y = top
+        lrx = curr_x + width + gap
+        lry = curr_y + height + 20
+        self.im.rectangle((curr_x,curr_y), (lrx,lry), box_colour, box_bg_colour)
+        curr_x = left + 10
+        curr_y = top + height + 6
+        self.im.string_ttf(self.font, self.label_font_size, 0.0, (curr_x, curr_y), label, text_colour)
+
+
+class Switch:
+    """ Code and config for dealing with a switch """
+    port_width = 0
+    port_height = 0
+    text_width = 0
+    text_height = 0
+    label_left = 0
+    label_bot = 0
+    total_width = 0
+    total_height = 0
+    num_ports = 0
+    left = None
+    top = None
+    name = None
+
+    # Set up a new switch instance; calculate all the sizes so we can
+    # size our canvas
+    def __init__(self, g, num_ports, name):
+        self.num_ports = num_ports
+        self.name = name
+        self._calc_port_size(g)
+        self._calc_switch_size(g)
+
+    # How big is a port and the text within it?
+    def _calc_port_size(self, g):
+        max_width = 0
+        max_height = 0
+        self.text_width = g.twocharwidth
+        self.text_height = g.charheight
+        # Leave enough space around the text for a nice clear box
+        self.port_width = self.text_width + 6
+        self.port_height = self.text_height + 6
+
+    # How big is the full switch, including all the ports and the
+    # switch name label?
+    def _calc_switch_size(self, g):
+        (label_width, label_height) = g.get_label_size(self.name, g.small_font_size)
+        num_ports = self.num_ports
+        # Make sure we have an even number for 2 rows
+        if (self.num_ports & 1):
+            num_ports += 1
+        self.label_left = 3 + (num_ports * self.port_width / 2) + 3
+        self.label_bot = self.port_height - 2
+        self.total_width = self.label_left + label_width + 3
+        self.total_height = 3 + max(label_height, (2 * self.port_height)) + 3
+
+    # Return the switch dimensions
+    def get_dimensions(self):
+        return (self.total_width, self.total_height)
+
+    # Draw the basic switch outline and label using (left, top) as the
+    # top left corner. The switch object will remember this origin for
+    # later use when drawing ports.
+    def draw_switch(self, g, left, top):
+        self.left = left
+        self.top = top
+        ulx = left
+        uly = top
+        lrx = left + self.total_width -1
+        lry = top + self.total_height - 1
+        g.im.rectangle((left, top), (lrx, lry),
+                       g.im.colorExact(g.colour_defs[g.pallette['switch_outline_colour']]),
+                       g.im.colorExact(g.colour_defs[g.pallette['switch_fill_colour']]))
+        llx = left + self.label_left
+        lly = top + self.label_bot
+        g.im.string_ttf(g.font, g.small_font_size, 0.0, (llx, lly), self.name,
+                        g.im.colorExact(g.colour_defs[g.pallette['switch_label_colour']]))
+
+    # Draw a port inside the switch, using a specified colour scheme
+    # to denote its type. The switch outline must have been drawn
+    # first, for its origin to be set.
+    def draw_port(self, g, portnum, highlight):
+        if portnum < 1 or portnum > self.num_ports:
+            raise InputError('port number out of range')
+        if not self.left or not self.top:
+            raise InputError('cannot draw ports before switch is drawn')
+        if highlight not in g.port_pallette.iterkeys():
+            raise InputError('unknown highlight type \"%s\"' % highlight)
+
+        box_colour = g.im.colorExact(g.colour_defs[g.port_pallette[highlight]['port_box']])
+        box_bg_colour = g.im.colorExact(g.colour_defs[g.port_pallette[highlight]['port_bg']])
+        text_colour = g.im.colorExact(g.colour_defs[g.port_pallette[highlight]['port_label']])
+
+        if (portnum & 1): # odd port number, so top row
+            ulx = self.left + 3 + ((portnum-1) * self.port_width / 2)
+            uly = self.top + 3
+        else: # bottom row
+            ulx = self.left + 3 + ((portnum-2) * self.port_width / 2)
+            uly = self.top + 3 + self.port_height
+        lrx = ulx + self.port_width - 1
+        lry = uly + self.port_height - 1
+        g.im.rectangle((ulx,uly), (lrx,lry), box_colour, box_bg_colour)
+
+        # centre the text
+        (width, height) = g.get_label_size(repr(portnum), g.small_font_size)
+        llx = ulx + 3 + (self.text_width - width) / 2
+        lly = uly + max(height, self.text_height) + 1
+        g.im.string_ttf(g.font, g.small_font_size,
+                        0.0, (llx, lly), repr(portnum), text_colour)
+
+    # Quick helper: draw all the ports for a switch in the default
+    # colour scheme.
+    def draw_default_ports(self, g):
+        for portnum in range(1, self.num_ports + 1):
+            self.draw_port(g, portnum, 'normal')
+
+    # Get the (x,y) co-ordinates of the middle of the external edge of
+    # the port box (i.e. top of a top-row port, bottom of a bottom-row
+    # port) so that we can draw a connection to that point
+    def get_port_location(self, portnum):
+        if portnum > self.num_ports:
+            raise InputError('port number out of range')
+
+        if (portnum & 1): # odd port number, so top row
+            ulx = self.left + 3 + ((portnum-1) * self.port_width / 2)
+            uly = self.top + 3
+            mid_edge = ulx + int((self.port_width / 2))
+            return (mid_edge, uly, True)
+        else: # bottom row
+            ulx = self.left + 3 + ((portnum-2) * self.port_width / 2)
+            uly = self.top + 3 + self.port_height
+            lry = uly + self.port_height
+            mid_edge = ulx + int((self.port_width / 2))
+            return (mid_edge, lry, False)
+
+    # Debug: print some of the state of the switch object
+    def dump_state(self):
+        print 'port_width %d' % self.port_width
+        print 'port_height %d' % self.port_height
+        print 'text_width %d' % self.text_width
+        print 'text_height %d' % self.text_height
+        print 'label_left %d' % self.label_left
+        print 'label_bot %d' % self.label_bot
+        print 'total_width %d' % self.total_width
+        print 'total_height %d' % self.total_height
+
+# Test harness - generate a PNG using fake data
+if __name__ == '__main__':
+    gim = Graphics()
+    gim.set_font(['/usr/share/fonts/truetype/inconsolata/Inconsolata.otf',
+                  '/usr/share/fonts/truetype/freefont/FreeMono.ttf'])
+    try:
+        gim.font
+    except NameError:
+        print 'no fonts found'
+        sys.exit(1)
+
+    switch = {}
+    size_x = {}
+    size_y = {}
+    switch[0] = Switch(gim, 48, 'lngswitch01')
+    switch[1] = Switch(gim, 24, 'lngswitch02')
+    switch[2] = Switch(gim, 52, 'lngswitch03')
+    label = "VLAN 4jj"
+    
+    # Need to set gaps big enough for the number of trunks, at least.
+    num_trunks = 3
+    y_gap = max(20, 15 * num_trunks)
+    x_gap = max(20, 15 * num_trunks)
+    
+    x = 0
+    y = y_gap
+
+    for i in range (0, 3):
+        (size_x[i], size_y[i]) = switch[i].get_dimensions()
+        x = max(x, size_x[i])
+        y += size_y[i] + y_gap
+
+    # Add space for the legend and the label
+    (legend_width, legend_height) = gim.get_legend_dimensions()
+    (label_width, label_height) = gim.get_label_size(label, gim.label_font_size)
+
+    x = max(x, legend_width + 2*x_gap + label_width)
+    x = x_gap + x + x_gap
+    y = y + max(legend_height + y_gap, label_height)    
+    
+    gim.create_canvas(x, y)
+
+    curr_y = y_gap
+    switch[0].draw_switch(gim, x_gap, curr_y)
+    switch[0].draw_default_ports(gim)
+    switch[0].draw_port(gim, 2, 'VLAN')
+    switch[0].draw_port(gim, 5, 'locked')
+    switch[0].draw_port(gim, 11, 'trunk')
+    switch[0].draw_port(gim, 44, 'trunk')
+    curr_y += size_y[0] + y_gap
+
+    switch[1].draw_switch(gim, x_gap, curr_y)
+    switch[1].draw_default_ports(gim)
+    switch[1].draw_port(gim, 5, 'VLAN')
+    switch[1].draw_port(gim, 8, 'locked')
+    switch[1].draw_port(gim, 13, 'trunk')
+    switch[1].draw_port(gim, 16, 'trunk')
+    curr_y += size_y[2] + y_gap
+
+    switch[2].draw_switch(gim, x_gap, curr_y)
+    switch[2].draw_default_ports(gim)
+    switch[2].draw_port(gim, 1, 'trunk')
+    switch[2].draw_port(gim, 2, 'locked')
+    switch[2].draw_port(gim, 14, 'trunk')
+    switch[2].draw_port(gim, 19, 'VLAN')
+    curr_y += size_y[2] + y_gap
+
+    # Now let's try and draw some trunks!
+    gim.draw_trunk(0,
+                 switch[0].get_port_location(11),
+                 switch[1].get_port_location(16),
+                 gim.port_pallette['trunk']['trace'])
+    gim.draw_trunk(1,
+                 switch[1].get_port_location(13),
+                 switch[2].get_port_location(1),
+                 gim.port_pallette['trunk']['trace'])
+    gim.draw_trunk(2,
+                 switch[0].get_port_location(44),
+                 switch[2].get_port_location(14),
+                 gim.port_pallette['trunk']['trace'])
+
+    gim.draw_legend(x_gap, curr_y)
+    gim.draw_label(x - label_width - 2*x_gap, curr_y, label, int(x_gap / 2))
+
+    f=open('xx.png','w')
+    gim.im.writePng(f)
+    f.close()
+    print 'Test graphic written to xx.png'
diff --git a/visualisation/visualisation.py b/visualisation/visualisation.py
new file mode 100644
index 0000000..efc8b3d
--- /dev/null
+++ b/visualisation/visualisation.py
@@ -0,0 +1,263 @@
+#! /usr/bin/python
+
+#  Copyright 2015 Linaro Limited
+#
+#  This program is free software; you can redistribute it and/or modify
+#  it under the terms of the GNU General Public License as published by
+#  the Free Software Foundation; either version 2 of the License, or
+#  (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software
+#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+#  MA 02110-1301, USA.
+#
+#  Visualisation module for VLANd. Fork a trivial webserver
+#  implementation on an extra, and generate a simple set of pages and
+#  graphics on demand.
+#
+
+import os, sys, logging, time, datetime, re
+from multiprocessing import Process
+from BaseHTTPServer import BaseHTTPRequestHandler
+from BaseHTTPServer import HTTPServer
+import urlparse
+
+if __name__ == '__main__':
+    vlandpath = os.path.abspath(os.path.normpath(os.path.dirname(sys.argv[0])))
+    sys.path.insert(0, vlandpath)
+    sys.path.insert(0, "%s/.." % vlandpath)
+
+from errors import InputError
+from db.db import VlanDB
+from config.config import VlanConfig
+from graphics import Graphics,Switch
+from util import VlanUtil
+class VlandHTTPServer(HTTPServer):
+    """ Trivial wrapper for HTTPServer so we can include our own state. """
+    def __init__(self, server_address, handler, state):
+        HTTPServer.__init__(self, server_address, handler)
+        self.state = state
+
+class Visualisation(object):
+    """ Code and config for the visualisation graphics module. """
+
+    state = None
+    p = None
+
+    # Fork a new process for the visualisation webserver
+    def __init__(self, state):
+        self.state = state
+        self.p = Process(target=self.visloop, args=())
+        self.p.start()
+
+    # The main loop for the visualisation webserver
+    def visloop(self):
+        self.state.db = VlanDB(db_name=self.state.config.database.dbname,
+                               username=self.state.config.database.username)
+
+        loglevel = VlanUtil().set_logging_level(self.state.config.logging.level)
+
+        # Should we log to stderr?
+        if self.state.config.logging.filename is None:
+            logging.basicConfig(level = loglevel,
+                                format = '%(asctime)s %(levelname)-8s %(message)s')
+        else:
+            logging.basicConfig(level = loglevel,
+                                format = '%(asctime)s %(levelname)-8s VIS  %(message)s',
+                                datefmt = '%Y-%m-%d %H:%M:%S %Z',
+                                filename = self.state.config.logging.filename,
+                                filemode = 'a')
+            logging.info('%s visualisation starting up', self.state.banner)
+
+        server = VlandHTTPServer(('localhost', self.state.config.visualisation.port),
+                                 GetHandler, self.state)
+        server.serve_forever()
+
+    # Kill the webserver
+    def shutdown(self):
+        self.p.terminate()
+
+class GetHandler(BaseHTTPRequestHandler):
+    """ Methods to generate and serve the pages """
+
+    parsed_path = None
+
+    # Trivial top-level page. Link to images for each of the VLANs we
+    # know about.
+    def send_index(self):
+        self.send_response(200)
+        self.wfile.write('Content-type: text/html\r\n')
+        self.end_headers()
+        page = []
+        page.append('<html>')
+        page.append('<head>')
+        page.append('<TITLE>VLANd visualisation</TITLE>')
+        page.append('</HEAD>')
+        page.append('<body>')
+        page.append('<h1>VLANd visualisation, version %s</h1>' % self.server.state.version)
+
+        switches = self.server.state.db.all_switches()
+        if len(switches) == 0:
+            page.append('<p>No switches found in the database, nothing to show...</p>')
+        else:
+            vlans = self.server.state.db.all_vlans()
+            page.append('<h2>VLANs currently in use: %d</h2>' % len(vlans))
+            for vlan in vlans:
+                page.append('<h3>VLAN id %d, tag %d, name %s</h3>' % (vlan.vlan_id, vlan.tag, vlan.name))
+                page.append('<p><a href="/images/vlan/%d.png"><img src="/images/vlan/%d.png" alt="VLAN %d diagram"></a></p>' % (vlan.vlan_id,vlan.vlan_id,vlan.vlan_id))
+
+        page.append('Current time: %s' % datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"))
+        page.append('</body>')
+        self.wfile.write('\r\n'.join(page))
+
+    # Trivial style sheet, TODO!
+    def send_style(self):
+        self.send_response(200)
+        self.wfile.write('Content-type: text/css\r\n')
+        self.end_headers()
+        self.wfile.write('style.css')
+
+    # Generate a PNG showing the layout of switches/port/trunks for a
+    # specific VLAN
+    def send_graphic(self):
+        vlan_id = 0
+        vlan_re = re.compile(r'^/images/vlan/(\d+).png$')
+        match = vlan_re.match(self.parsed_path.path)
+        if match:
+            vlan_id = match.group(1)
+        db = self.server.state.db
+        vlan = db.get_vlan_by_id(vlan_id)
+        # We've been asked for a VLAN that doesn't exist
+        if vlan is None:
+            self.send_response(404)
+            self.wfile.write('Content-type: text/html\r\n')
+            self.end_headers()
+            self.wfile.write('404 Not Found')
+            return
+
+        gim = Graphics()
+
+        print "Looking at vlan_id %d, tag %d" % (int(vlan_id), int(vlan.tag))
+
+        # Pick fonts. TODO: Make these configurable?
+        gim.set_font(['/usr/share/fonts/truetype/inconsolata/Inconsolata.otf',
+                      '/usr/share/fonts/truetype/freefont/FreeMono.ttf'])
+        try:
+            gim.font
+        # If we can't get the font we need, fail
+        except NameError:
+            self.send_response(500)
+            self.wfile.write('Content-type: text/html\r\n')
+            self.end_headers()
+            self.wfile.write('500 Internal Server Error')
+            return
+
+        switch = {}
+        size_x = {}
+        size_y = {}
+
+        switches = db.all_switches()
+
+        # Need to set gaps big enough for the number of trunks, at least.
+        trunks = db.all_trunks()
+        y_gap = max(20, 15 * len(trunks))
+        x_gap = max(20, 15 * len(trunks))
+
+        x = 0
+        y = y_gap
+
+        # Work out how much space we need for the switches
+        for i in range(0, len(switches)):
+            ports = db.get_ports_by_switch(switches[i].switch_id)
+            switch[i] = Switch(gim, len(ports), switches[i].name)
+            (size_x[i], size_y[i]) = switch[i].get_dimensions()
+            x = max(x, size_x[i])
+            y += size_y[i] + y_gap
+
+        # Add space for the legend and the label
+        label = "VLAN %d - %s" % (vlan.tag, vlan.name)
+        (legend_width, legend_height) = gim.get_legend_dimensions()
+        (label_width, label_height) = gim.get_label_size(label, gim.label_font_size)
+        x = max(x, legend_width + 2*x_gap + label_width)
+        x = x_gap + x + x_gap
+        y = y + max(legend_height + y_gap, label_height)
+
+        # Create a canvas of the right size
+        gim.create_canvas(x, y)
+
+        # Draw the switches and ports in it
+        curr_y = y_gap
+        for i in range(0, len(switches)):
+            switch[i].draw_switch(gim, x_gap, curr_y)
+            ports = db.get_ports_by_switch(switches[i].switch_id)
+            for port_id in ports:
+                port = db.get_port_by_id(port_id)
+                if port.is_locked:
+                    switch[i].draw_port(gim, port.number, 'locked')
+                elif port.is_trunk:
+                    switch[i].draw_port(gim, port.number, 'trunk')
+                elif port.current_vlan_id == int(vlan_id):
+                    switch[i].draw_port(gim, port.number, 'VLAN')
+                else:
+                    switch[i].draw_port(gim, port.number, 'normal')
+            curr_y += size_y[i] + y_gap
+
+        # Now add the trunks
+        for i in range(0, len(trunks)):
+            ports = db.get_ports_by_trunk(trunks[i].trunk_id)
+            port1 = db.get_port_by_id(ports[0])
+            port2 = db.get_port_by_id(ports[1])
+            for s in range(0, len(switches)):
+                if switches[s].switch_id == port1.switch_id:
+                    switch1 = s
+                if switches[s].switch_id == port2.switch_id:
+                    switch2 = s
+            gim.draw_trunk(i,
+                           switch[switch1].get_port_location(port1.number),
+                           switch[switch2].get_port_location(port2.number),
+                           gim.port_pallette['trunk']['trace'])
+
+        # And the legend and label
+        gim.draw_legend(x_gap, curr_y)
+        gim.draw_label(x - label_width - 2*x_gap, curr_y, label, int(x_gap / 2))
+
+        # All done - send it down the http socket
+        self.send_response(200)
+        self.wfile.write('Content-type: image/png\r\n')
+        self.end_headers()
+        gim.im.writePng(self.wfile)
+
+    # Implement an HTTP GET handler for the HTTPServer instance
+    def do_GET(self):
+        # Compare the URL path to any of the names we recognise and
+        # call the right generator function if we get a match
+        self.parsed_path = urlparse.urlparse(self.path)
+        for url in self.functionMap:
+            match = re.match(url['re'], self.parsed_path.path)
+            if match:
+                return url['fn'](self)
+
+        # Fall-through for any files we don't recognise
+        self.send_response(404)
+        self.wfile.write('Content-type: text/html\r\n')
+        self.end_headers()
+        self.wfile.write('404 Not Found')
+        return
+
+    # Override the BaseHTTPRequestHandler log_message() method so we
+    # can log requests properly
+    def log_message(self, format, *args):
+        """Log an arbitrary message. """
+        logging.info('%s %s', self.client_address[0], format%args)
+
+    functionMap = (
+        {'re': '^/$', 'fn': send_index},
+        {'re': '^/style.css$', 'fn': send_style},
+        {'re': '^/images/vlan/(\d+).png$', 'fn': send_graphic}
+    )