pvl/verkko/hosts.py
author Tero Marttila <terom@paivola.fi>
Sun, 07 Sep 2014 14:21:56 +0300
changeset 424 e77e967d59b0
parent 205 f7658198c224
permissions -rw-r--r--
hgignore: use glob; ignore snmp mibs
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/table.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.
        """

        t = self.request.args.get('t')

        if t :
            # return json
            t = ts2dt(int(t))

        # query
        hosts = self.query()
        
        # always sorted by last_seen
        hosts = hosts.order_by(Host.last_seen.desc())

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

        if t :
            # return json
            hosts = hosts.filter(Host.last_seen > t)
            hosts = list(hosts)
            hosts.reverse()
            
            if hosts :
                # update timestamp to most recent
                t = hosts[-1].last_seen

            # json
            data = dict(
                t       = dt2ts(t),
                hosts   = [dict(self.table.json(host)) for host in hosts],
            )

            return response.json(data)

        else :
            # render html
            hosts = hosts.limit(self.table.PAGE)

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

            # extract timestamp
            for host in hosts :
                self.t = host.last_seen

                break

            else :
                # no hosts :<
                self.t = datetime.datetime.now()
            
            # store
            self.hosts = hosts

    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).
        """
    
        return html.div(id='wrapper')(
            html.input(type='submit', id='refresh', value="Refresh"),
            html.input(type='reset', id='pause', value="Pause"),

            self.table.render(self.hosts)(id='hosts-realtime'),

            html.script(type='text/javascript')(
                """
$(document).ready(HostsRealtime(Table($('#hosts-realtime'), {table_params}), {params}));
                """.format(
                    table_params = json.dumps(dict(
                        item_url        = self.url(ItemHandler, id='0'),
                        columns         = [column.attr for column in self.table.columns],
                    )),
                    params = json.dumps(dict(
                        url     = self.url(),
                        filters = self.filters,
                        t       = dt2ts(self.t),
                    )),
                )
            )
        )