blob: e1ca9e87c057af09a37e9ab676571113c08fc0db [file] [log] [blame]
#! /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, signal
from multiprocessing import Process
from BaseHTTPServer import BaseHTTPRequestHandler
from BaseHTTPServer import HTTPServer
import urlparse
import cStringIO
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 GraphicsCache(object):
""" Cache for graphics state, to avoid having to recalculate every
query too many times. """
last_update = None
graphics = {}
def __init__(self):
# Pick an epoch older than any sensible use
self.last_update = datetime.datetime(2000, 01, 01)
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()
def _receive_signal(self, signum, stack):
if signum == signal.SIGUSR1:
self.state.db_ok = True
# The main loop for the visualisation webserver
def visloop(self):
self.state.db_ok = False
self.state.cache = GraphicsCache()
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)
# Wait for main process to signal to us that it's finished with any
# database upgrades and we can open it without any problems.
signal.signal(signal.SIGUSR1, self._receive_signal)
while not self.state.db_ok:
logging.info('%s visualisation waiting for db_ok signal', self.state.banner)
time.sleep(1)
logging.info('%s visualisation received db_ok signal', self.state.banner)
self.state.db = VlanDB(db_name=self.state.config.database.dbname,
username=self.state.config.database.username,
readonly=True)
server = VlandHTTPServer(('', self.state.config.visualisation.port),
GetHandler, self.state)
server.serve_forever()
# Kill the webserver
def shutdown(self):
self.p.terminate()
# Kill the webserver
def signal_db_ok(self):
os.kill(self.p.pid, signal.SIGUSR1)
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()
config = self.server.state.config.visualisation
cache = self.server.state.cache
db = self.server.state.db
switches = db.all_switches()
vlans = db.all_vlans()
vlan_tags = {}
for vlan in vlans:
vlan_tags[vlan.vlan_id] = vlan.tag
if cache.last_update < self.server.state.db.get_last_modified_time():
logging.debug('Cache is out of date')
# Fill the cache with all the information we need:
# * the graphics themselves
# * the data to match each graphic, so we can generate imagemap/tooltips
cache.graphics = {}
if len(switches) > 0:
for vlan in vlans:
cache.graphics[vlan.vlan_id] = self.generate_graphic(vlan.vlan_id)
cache.last_update = datetime.datetime.utcnow()
page = []
page.append('<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">')
page.append('<html>')
page.append('<head>')
page.append('<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">')
page.append('<TITLE>VLANd visualisation</TITLE>')
page.append('<link rel="stylesheet" type="text/css" href="style.css">')
if config.refresh and config.refresh > 0:
page.append('<meta http-equiv="refresh" content="%d">' % config.refresh)
page.append('</HEAD>')
page.append('<body>')
# Generate left-hand menu with links to each VLAN diagram
page.append('<div class="menu">')
if len(switches) > 0:
page.append('<h2>Menu</h2>')
page.append('<p>VLANs: %d</p>' % len(vlans))
page.append('<ul>')
for vlan in vlans:
page.append('<li><a href="./#vlan%d">VLAN ID %d, Tag %d<br>(%s)</a>' % (vlan.vlan_id, vlan.vlan_id, vlan.tag, vlan.name))
page.append('</ul>')
page.append('<div class="date"><p>Current time: %s</p>' % datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"))
page.append('<p>version %s</p>' % self.server.state.version)
page.append('</div>')
page.append('</div>')
# Now the main content area with the graphics
page.append('<div class="content">')
page.append('<h1>VLANd visualisation</h1>')
# Bail early if we have nothing to show!
if len(switches) == 0:
page.append('<p>No switches found in the database, nothing to show...</p>')
page.append('</div>')
page.append('</body>')
self.wfile.write('\r\n'.join(page))
return
# Trivial javascript helpers for tooltip control
page.append('<SCRIPT LANGUAGE="javascript">')
page.append('function popup(v,p) {')
page.append('a=v.toString();')
page.append('b=p.toString();')
page.append('id="port".concat("",a).concat(".",b);')
page.append('document.getElementById(id).style.visibility="visible";')
page.append('}')
page.append('function popdown(v,p) {')
page.append('a=v.toString();')
page.append('b=p.toString();')
page.append('id="port".concat("",a).concat(".",b);')
page.append('document.getElementById(id).style.visibility="hidden";')
page.append('}')
page.append('</SCRIPT>')
# For each VLAN, add a graphic
for vlan in vlans:
this_image = cache.graphics[vlan.vlan_id]
page.append('<a name="vlan%d"></a>' % vlan.vlan_id)
page.append('<h3>VLAN ID %d, Tag %d, name %s</h3>' % (vlan.vlan_id, vlan.tag, vlan.name))
# Link to an image we generate from our data
page.append('<p><img src="images/vlan/%d.png" ' % vlan.vlan_id)
page.append('width="%d" height="%d" ' % ( this_image['image']['width'], this_image['image']['height']))
page.append('alt="VLAN %d diagram" usemap="#MAPVLAN%d">' % (vlan.vlan_id,vlan.vlan_id))
# Generate an imagemap describing all the ports, with
# javascript hooks to pop up/down a tooltip box based on
# later data.
page.append('<map name="MAPVLAN%d">' % vlan.vlan_id)
for switch in this_image['ports'].keys():
for portnum in this_image['ports'][switch].keys():
this_port = this_image['ports'][switch][portnum]
port = this_port['db']
((ulx,uly),(lrx,lry),upper) = this_port['location']
page.append('<area shape="rect" ')
page.append('coords="%d,%d,%d,%d" ' % (ulx,uly,lrx,lry))
page.append('onMouseOver="popup(%d,%d)" onMouseOut="popdown(%d,%d)">' % (vlan.vlan_id, port.port_id, vlan.vlan_id, port.port_id))
page.append('</map></p>')
page.append('<hr>')
page.append('</div>') # End of normal content, all the VLAN graphics shown
# Now generate the tooltip boxes for the ports. Each is
# fully-formed but invisible, ready for our javascript helper
# to pop visible on demand.
for vlan in vlans:
this_image = cache.graphics[vlan.vlan_id]
for switch in this_image['ports'].keys():
for portnum in this_image['ports'][switch].keys():
this_port = this_image['ports'][switch][portnum]
port = this_port['db']
page.append('<div class="port" id="port%d.%d">' % (vlan.vlan_id, port.port_id))
page.append('Port ID: %d<br>' % port.port_id)
page.append('Port Number: %d<br>' % port.number)
page.append('Port Name: %s<br>' % port.name)
if port.is_locked:
page.append('Locked - ')
if (port.lock_reason is not None
and len(port.lock_reason) > 1):
page.append(port.lock_reason)
else:
page.append('unknown reason')
page.append('<br>')
if port.is_trunk:
page.append('Trunk')
if port.trunk_id != -1:
page.append(' (Trunk ID %d)' % port.trunk_id)
page.append('<br>')
else:
page.append('Current VLAN ID: %d (Tag %d)<br>' % (port.current_vlan_id, vlan_tags[port.current_vlan_id]))
page.append('Base VLAN ID: %d (Tag %d)<br>' % (port.base_vlan_id, vlan_tags[port.base_vlan_id]))
page.append('</div>')
page.append('</body>')
self.wfile.write('\r\n'.join(page))
# Simple-ish style sheet
def send_style(self):
self.send_response(200)
self.wfile.write('Content-type: text/css\r\n')
self.end_headers()
cache = self.server.state.cache
page = []
page.append('body {')
page.append(' background: white;')
page.append(' color: black;')
page.append(' font-size: 12pt;')
page.append('}')
page.append('')
page.append('.menu {')
page.append(' position:fixed;')
page.append(' float:left;')
page.append(' font-family: arial, Helvetica, sans-serif;')
page.append(' width:20%;')
page.append(' height:100%;')
page.append(' font-size: 10pt;')
page.append(' padding-top: 10px;')
page.append('}')
page.append('')
page.append('.content {')
page.append(' position:relative;')
page.append(' padding-top: 10px;')
page.append(' width: 80%;')
page.append(' max-width:80%;')
page.append(' margin-left: 21%;')
page.append(' margin-top: 50px;')
page.append(' height:100%;')
page.append('}')
page.append('')
page.append('h1,h2,h3 {')
page.append(' font-family: arial, Helvetica, sans-serif;')
page.append(' padding-right:3pt;')
page.append(' padding-top:2pt;')
page.append(' padding-bottom:2pt;')
page.append(' margin-top:8pt;')
page.append(' margin-bottom:8pt;')
page.append(' border-style:none;')
page.append(' border-width:thin;')
page.append('}')
page.append('A:link { text-decoration: none; }')
page.append('A:visited { text-decoration: none}')
page.append('h1 { font-size: 18pt; }')
page.append('h2 { font-size: 14pt; }')
page.append('h3 { font-size: 12pt; }')
page.append('dl,ul { margin-top: 1pt; text-indent: 0 }')
page.append('ol { margin-top: 1pt; text-indent: 0 }')
page.append('div.date { font-size: 8pt; }')
page.append('div.sig { font-size: 8pt; }')
page.append('div.port {')
page.append(' display: block;')
page.append(' position: fixed;')
page.append(' left: 0px;')
page.append(' bottom: 0px;')
page.append(' z-index: 99;')
page.append(' background: #FFFF00;')
page.append(' border-style: solid;')
page.append(' border-width: 3pt;')
page.append(' border-color: #3B3B3B;')
page.append(' margin: 1;')
page.append(' padding: 5px;')
page.append(' font-size: 10pt;')
page.append(' font-family: Courier,monotype;')
page.append(' visibility: hidden;')
page.append('}')
self.wfile.write('\r\n'.join(page))
# 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 = int(match.group(1))
cache = self.server.state.cache
# Do we have a graphic for this VLAN ID?
if not vlan_id in cache.graphics.keys():
logging.debug('asked for vlan_id %s', vlan_id)
logging.debug(cache.graphics.keys())
self.send_response(404)
self.wfile.write('Content-type: text/plain\r\n')
self.end_headers()
self.wfile.write('404 Not Found\r\n')
self.wfile.write('%s' % self.parsed_path.path)
logging.error('VLAN graphic not found - asked for %s', self.parsed_path.path)
return
# Yes - just send it from the cache
self.send_response(200)
self.wfile.write('Content-type: image/png\r\n')
self.end_headers()
self.wfile.write(cache.graphics[vlan_id]['image']['png'].getvalue())
return
# Generate a PNG showing the layout of switches/port/trunks for a
# specific VLAN, and return that PNG along with geometry details
def generate_graphic(self, vlan_id):
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:
return None
data = {}
data['image'] = {}
data['ports'] = {}
gim = Graphics()
# 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/plain\r\n')
self.end_headers()
self.wfile.write('500 Internal Server Error\r\n')
logging.error('Unable to generate graphic, no fonts found - asked for %s',
self.parsed_path.path)
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)
data['ports'][i] = {}
for port_id in ports:
port = db.get_port_by_id(port_id)
port_location = switch[i].get_port_location(port.number)
data['ports'][i][port.number] = {}
data['ports'][i][port.number]['db'] = port
data['ports'][i][port.number]['location'] = port_location
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 - push the image file into the cache for this vlan
data['image']['png'] = cStringIO.StringIO()
gim.im.writePng(data['image']['png'])
data['image']['width'] = x
data['image']['height'] = y
return data
# 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/plain\r\n')
self.end_headers()
self.wfile.write('404 Not Found')
self.wfile.write('%s' % self.parsed_path.path)
logging.error('File not supported - asked for %s', self.parsed_path.path)
return
# Override the BaseHTTPRequestHandler log_message() method so we
# can log requests properly
def log_message(self, fmt, *args):
"""Log an arbitrary message. """
logging.info('%s %s', self.client_address[0], fmt%args)
functionMap = (
{'re': r'^/$', 'fn': send_index},
{'re': r'^/style.css$', 'fn': send_style},
{'re': r'^/images/vlan/(\d+).png$', 'fn': send_graphic}
)