blob: 603835da7f5eeea82d8b58630615aca4b4419dc1 [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
from Vland.errors import InputError
from Vland.db.db import VlanDB
from Vland.config.config import VlanConfig
from Vland.visualisation.graphics import Graphics,Switch
from Vland.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}
)