pvl/verkko/hosts.py
author Tero Marttila <terom@paivola.fi>
Sun, 10 Feb 2013 12:07:16 +0200
changeset 199 ccc34415d6a9
parent 186 0bfb34281141
child 205 f7658198c224
permissions -rw-r--r--
pvl.verkko.hosts: fix RealtimeHandler response.json
from pvl.verkko import web, db, table
from pvl.verkko.utils import parse_timedelta, IPv4Network

from pvl.web import html, response

import re
import datetime
import socket # dns

import logging; log = logging.getLogger('pvl.verkko.hosts')

## Model
import json
import time

def dt2ts (dt) :
    return int(time.mktime(dt.timetuple()))

def ts2dt (ts) :
    return datetime.datetime.fromtimestamp(ts)

# TODO: this should be DHCPHost
class Host (object) :
    DATE_FMT = '%Y%m%d'
    TIME_FMT = '%H:%M:%S'
    
    MAC_HEX = r'([A-Za-z0-9]{2})'
    MAC_SEP = r'[-:.]?'
    MAC_RE = re.compile(MAC_SEP.join([MAC_HEX] * 6))

    @classmethod
    def normalize_mac (cls, mac) :
        match = cls.MAC_RE.search(mac)

        if not match :
            raise ValueError(mac)

        else :
            return ':'.join(hh.lower() for hh in match.groups())

    def __init__ (self, ip, mac, name=None) :
        self.ip = ip
        self.mac = mac
        self.name = name
   
    def render_mac (self) :
        if not self.mac :
            return None

        elif len(self.mac) > (6 * 2 + 5) :
            return u'???'

        else :
            return unicode(self.mac)

    def network (self) :
        return self.gw

    def render_name (self) :
        if self.name :
            return self.name.decode('ascii', 'replace')
        else :
            return None
    
    STATES = {
        'DHCPACK':      'ack',
        'DHCPNAK':      'nak',
        'DHCPRELEASE':  'release',
        'DHCPDISCOVER': 'search',
        'DHCPREQUEST':  'search',
        'DHCPOFFER':    'search',
    }

    def state_class (self) :
        if self.error :
            return 'dhcp-error'

        elif self.state in self.STATES :
            return 'dhcp-' + self.STATES[self.state]

        else :
            return None

    def state_title (self) :
        return self.error # or None

    def render_state (self) :
        if self.error :
            return "{self.state}: {self.error}".format(self=self)
        else :
            return self.state
    
    @classmethod
    def format_datetime (cls, dt) :
        if (datetime.datetime.now() - dt).days :
            return dt.strftime(cls.DATE_FMT)

        else :
            return dt.strftime(cls.TIME_FMT)
    
    def seen (self) :
        return (
                html.span(title=self.first_seen)(self.format_datetime(self.first_seen)),
                '-',
                html.span(title=self.last_seen)(self.format_datetime(self.last_seen))
        )

    def dns (self) :
        """
            Reverse-DNS lookup.
        """

        if not self.ip :
            return None
        
        sockaddrs = set(sockaddr for family, socktype, proto, canonname, sockaddr in socket.getaddrinfo(self.ip, 0, 0, 0, 0, socket.AI_NUMERICHOST))

        for sockaddr in sockaddrs :
            try :
                host, port = socket.getnameinfo(sockaddr, socket.NI_NAMEREQD)
            except socket.gaierror :
                continue

            return host

    def __unicode__ (self) :
        return u"{host.ip} ({host.mac})".format(host=self)

db.mapper(Host, db.dhcp_hosts, properties=dict(
    #id      = db.dhcp_hosts.c.rowid,
    #state   = db.dhcp_hosts.c.,
))

## Controller 
class HostsTable (table.Table) :
    """
        <table> of hosts.
    """

    ITEMS   = "Hosts"
    COLUMNS = (
        table.Column('ip',    "IP",       Host.ip,        
            rowfilter   = True,
        ),
        table.Column('mac',   "MAC",      Host.mac,       Host.render_mac,
            rowfilter   = True,
        ),
        table.Column('name',  "Hostname", Host.name,      Host.render_name, ),
        table.Column('gw',    "Network",  Host.gw,        Host.network,  ),
        table.Column('seen',  "Seen",     Host.last_seen, Host.seen, ),
        table.Column('state', "State",    Host.count,     
            rowtitle    = Host.state_title,
            rowcss      = Host.state_class,
        ),
    )
    
    # XXX: have to set again
    ATTRS = dict((col.attr, col) for col in COLUMNS)

    # default
    SORT = Host.last_seen.desc()
    PAGE = 10

class HostsHandler (table.TableHandler, web.DatabaseHandler) :
    """
        Combined database + <table>
    """

    CSS = web.DatabaseHandler.CSS + table.TableHandler.CSS + (
        "/static/dhcp/hosts.css", 
    )
    
    # view
    TABLE = HostsTable

    def query (self) :
        """
            Database SELECT query.
        """

        return self.db.query(Host)

    def filter_seen (self, value) :
        """
            Return filter expression for given attr == value
        """

        column = Host.last_seen

        if value.isdigit() :
            # specific date
            date = datetime.datetime.strptime(value, Host.DATE_FMT).date()

            return db.between(date.strftime(Host.DATE_FMT), 
                    db.func.strftime(Host.DATE_FMT, Host.first_seen),
                    db.func.strftime(Host.DATE_FMT, Host.last_seen)
            )
        else :
            # recent
            timedelta = parse_timedelta(value)
            
            return ((db.func.now() - Host.last_seen) < timedelta)

            # XXX: for sqlite, pgsql should handle this natively?
            # to seconds
            #timeout = timedelta.days * (24 * 60 * 60) + timedelta.seconds
            
            # WHERE strftime('%s', 'now') - strftime('%s', last_seen) < :timeout
            #filter = (db.func.strftime('%s', 'now') - db.func.strftime('%s', Host.last_seen) < timeout)
        
    def filter_ip (self, value) :
        column = Host.ip

        # column is IPv4 string literal format...
        if '/' in value :
            return (db.func.inet(Host.ip).op('<<')(db.func.cidr(value)))
        else :
            return (db.func.inet(Host.ip) == db.func.inet(value))

    def filter_mac (self, value) :
        return self.filter_attr('mac', Host.normalize_mac(value))

class ItemHandler (HostsHandler) :
    """
        A specific DHCP host, along with a list of related hosts.
    """
    
    def process (self, id) :
        self.hosts = self.query()
        self.host = self.hosts.get(id)
        
        if not self.host :
            raise web.NotFound("No such host: {id}".format(id=id))
        
        self.sorts, self.hosts = self.sort(self.hosts.filter((Host.ip == self.host.ip) | (Host.mac == self.host.mac)))
    
    def title (self) :
        return u"DHCP Host: {self.host}".format(self=self)

    def render_host (self, host) :
        """
            Details for specific host.
        """

        attrs = (
                ('Network',     host.gw),
                ('IP',          host.ip),
                ('MAC',         host.mac),
                ('Hostname',    host.name),
                ('DNS',         host.dns()),
                ('First seen',  host.first_seen),
                ('Last seen',   host.last_seen),
                ('Last state',  host.render_state()),
                ('Total messages',      host.count),
        )
        
        return (
            html.dl(
                (html.dt(title), html.dd(value)) for title, value in attrs
            )
        )

    def render (self) :
        return (
            html.h2('Host'),
            self.render_host(self.host),

            html.h2('Related'),
            self.render_table(self.hosts, sort=self.sorts, hilight=dict(ip=self.host.ip, mac=self.host.mac)),

            html.a(href=self.url(ListHandler))(html('&laquo;'), 'Back'),
        )

class ListHandler (HostsHandler) :
    """
        List of DHCP hosts for given filter.
    """

    TABLE_ITEM_URL = ItemHandler

    def process (self) :
        # super
        table.TableHandler.process(self)
 
    def title (self) :
        if self.filters :
            return "DHCP Hosts: {filters}".format(filters=self.filters_title())
        else :
            return "DHCP Hosts"

    def render (self) :
        return (
            self.render_table(self.query, filters=self.filters, sort=self.sorts, page=self.page),

            #html.a(href=self.url())(html('&laquo;'), 'Back') if self.filters else None,
        )

class RealtimeHandler (HostsHandler) :
    TITLE = "DHCP Hosts: Pseudo-Realtime.."
    CSS = HostsHandler.CSS + (
        'http://code.jquery.com/ui/1.9.0/themes/base/jquery-ui.css',
    )
    JS = (
        #"/static/jquery/jquery.js",
        'http://code.jquery.com/jquery-1.8.2.js',
        'http://code.jquery.com/ui/1.9.0/jquery-ui.js',
        '/static/dhcp/spin.js',
        '/static/dhcp/hosts.js',
    )

    COLUMNS = (
        ( 'ip',     'IP',           lambda host: host.ip    ),
        ( 'mac',    'MAC',          lambda host: host.mac   ),
        ( 'name',   'Hostname',     lambda host: host.name  ),
        ( 'gw',     'Network',      lambda host: host.gw    ),
        ( 'seen',   'Seen',         Host.seen,              ),
        ( 'state',  'State',        lambda host: host.state ),
    )

    def process (self) :
        """
            Either return JSON (if ?t=...), or fetch hosts/t for rendering.
        """

        hosts = self.db.query(Host)
        t = self.request.args.get('t')
        
        # always sorted by last_seen
        hosts = hosts.order_by(Host.last_seen.desc())

        # filter
        self.filters, hosts = self.filter(hosts)

        def host_params (host) :
            yield 'id', host.id

            for name, title, fvalue in self.COLUMNS :
                value = fvalue(host)

                if name == 'seen' :
                    # XXX: hackfix html() rendering
                    value = unicode(html.div(value))

                yield name, value
            
            # special
            yield 'state_class', host.state_class()
        
        if t :
            # return json
            t = ts2dt(int(t))

            hosts = hosts.filter(Host.last_seen > t)
            hosts = list(hosts)
            hosts.reverse()
            
            if hosts :
                t = hosts[-1].last_seen
                hosts = [dict(host_params(host)) for host in hosts]

            else :
                hosts = []

            data = dict(
                t       = dt2ts(t),
                hosts   = hosts,
            )

            return response.json(data)

        else :
            # render html
            hosts = hosts.limit(10)

            # XXX: testing
            hosts = hosts.offset(1)

            self.hosts = list(hosts)
            
            if self.hosts :
                self.t = self.hosts[0].last_seen
            else :
                self.t = datetime.datetime.now()

    def title (self) :
        if self.filters :
            return "{title}: {filters}".format(title=self.TITLE, filters=self.filters_title())
        else :
            return self.TITLE

    def render (self) :
        """
            Render page HTML and initial <table>, along with bootstrap JS (t0, configuration).
        """

        def column (name, title, fvalue, host) :
            cls = name

            if name == 'state' :
                cls = host.state_class()

            return html.td(class_=cls)(fvalue(host))

        params = dict(
            url     = self.url(),
            filters = self.filters,
            t       = dt2ts(self.t),
            host    = self.url(ItemHandler, id='0'),
            columns = [name for name, title, fvalue in self.COLUMNS]
        )
        params = json.dumps(params)
        
        return html.div(id='wrapper')(
            html.input(type='submit', id='refresh', value="Refresh"),
            html.input(type='reset', id='pause', value="Pause"),
            html.table(id='hosts')(
                html.thead(
                    html.tr(
                        html.th('#'),
                        (
                            html.th(class_=name)(title) for name, title, fvalue in self.COLUMNS
                        )
                    ),
                ),
                html.tbody(
                    html.tr(id=host.id)(
                        html.td(html.a(href=self.url(ItemHandler, id=host.id))('#')),
                        (
                            column(name, title, fvalue, host) for name, title, fvalue in self.COLUMNS
                        ),
                    ) for host in self.hosts
                )
            ),
            html.script(type='text/javascript')("""
$(document).ready(hosts_realtime({params}));
""".format(params=params)
            )
        )