merge
authorTero Marttila <terom@paivola.fi>
Sat, 26 Jan 2013 21:24:01 +0200
changeset 182 c89d0f6f22e0
parent 181 889bf8472525 (current diff)
parent 180 e6bca452ce72 (diff)
child 185 2e5e1a130070
merge
pvl/verkko/urls.py
pvl/verkko/wsgi.py
--- a/bin/pvl.verkko-dhcp	Sat Jan 26 21:23:39 2013 +0200
+++ b/bin/pvl.verkko-dhcp	Sat Jan 26 21:24:01 2013 +0200
@@ -5,7 +5,8 @@
 from pvl import __version__
 import pvl.args
 import pvl.web.args
-import pvl.verkko.wsgi
+import pvl.verkko
+import pvl.verkko.dhcp
 
 import optparse
 import logging; log = logging.getLogger('main')
@@ -49,8 +50,11 @@
     # parse cmdline
     options, args = parse_argv(argv, doc=__doc__)
 
+    # open
+    database = pvl.verkko.Database(options.database_read)
+
     # app
-    application = pvl.web.args.apply(options, pvl.verkko.wsgi.Application, options.database_read)
+    application = pvl.web.args.apply(options, pvl.verkko.dhcp.Application, database)
 
     # wsgi wrapper
     run_simple('0.0.0.0', 8080, application,
--- a/pvl/verkko/__init__.py	Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/verkko/__init__.py	Sat Jan 26 21:24:01 2013 +0200
@@ -0,0 +1,4 @@
+
+from pvl.verkko import db
+from pvl.verkko.db import Database
+
--- a/pvl/verkko/db.py	Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/verkko/db.py	Sat Jan 26 21:24:01 2013 +0200
@@ -1,3 +1,7 @@
+"""
+    SQLAlchemy Database tables model for pvl.verkko
+"""
+
 import sqlalchemy
 from sqlalchemy import *
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/dhcp.py	Sat Jan 26 21:24:01 2013 +0200
@@ -0,0 +1,72 @@
+# encoding: utf-8
+import pvl.web
+import pvl.verkko.web
+
+from pvl.web import html, urls
+from pvl.verkko import hosts
+
+import logging; log = logging.getLogger('pvl.verkko.dhcp')
+
+class Index (pvl.verkko.web.DatabaseHandler) :
+    TITLE = u"Päivölä Verkko"
+
+    CSS = pvl.verkko.web.DatabaseHandler.CSS + (
+            '/static/dhcp/forms.css',
+    )
+
+    def render_link (self, title, **opts) :
+        return html.a(href=self.url(hosts.ListHandler, **opts))(title)
+
+    def render_links (self, attr, titlevalues) :
+        return html.ul(
+                html.li(
+                    self.render_link(title, **{attr: value})
+                ) for title, value in titlevalues
+        )
+
+    def render (self) :
+        return (
+            html.h2("Interval"),
+            self.render_links('seen', (
+                        ("Hour",    '1h'),
+                        ("Day",     '1d'),
+                        #("Month",   '30d'),
+                        #("Year",    '365d'),
+            )),
+            html.h2("State"),
+            self.render_links('state', (
+                        ("Valid",       ('DHCPACK', 'DHCPRELEASE')),
+                        ("Incomplete",  ('DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST')),
+                        ("Invalid",     ('DHCPNAK', )),
+            )),
+
+            html.h2("IP/MAC"),
+            html.form(action=self.url(hosts.ListHandler), method='get')(
+                html.fieldset(
+                    html.ul(
+                        html.li(
+                            html.label(for_='ip')("IP"),
+                            html.input(type='text', name='ip'),
+                        ),
+
+                        html.li(
+                            html.label(for_='mac')("MAC"),
+                            html.input(type='text', name='mac'),
+                        ),
+
+                        html.li(
+                            html.input(type='submit', value="Search"),
+                        ),
+                    )
+                )
+            ),
+        )
+
+class Application (pvl.verkko.web.Application) :
+    URLS = urls.Map((
+        urls.rule('/',                       Index),
+        urls.rule('/hosts/',                 hosts.ListHandler),
+        urls.rule('/hosts/<int:id>',         hosts.ItemHandler),
+        urls.rule('/hosts/realtime',         hosts.RealtimeHandler),
+    ))
+
--- a/pvl/verkko/hosts.py	Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/verkko/hosts.py	Sat Jan 26 21:24:01 2013 +0200
@@ -1,4 +1,4 @@
-from pvl.verkko import db, web
+from pvl.verkko import web, db, table
 from pvl.verkko.utils import parse_timedelta, IPv4Network
 
 from pvl.web import html
@@ -10,7 +10,17 @@
 
 import logging; log = logging.getLogger('pvl.verkko.hosts')
 
-# XXX: this should actually be DHCPHost
+## 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'
@@ -54,6 +64,9 @@
         else :
             return unicode(self.mac)
 
+    def network (self) :
+        return self.gw
+
     def render_name (self) :
         if self.name :
             return self.name.decode('ascii', 'replace')
@@ -95,7 +108,7 @@
 
         else :
             return dt.strftime(cls.TIME_FMT)
-
+    
     def seen (self) :
         return (
                 html.span(title=self.first_seen)(self.format_datetime(self.first_seen)),
@@ -129,285 +142,102 @@
     #state   = db.dhcp_hosts.c.,
 ))
 
+## Controller 
+class HostsTable (table.Table) :
+    """
+        Table of hosts.
+    """
 
-   
- 
-class BaseHandler (web.Handler) :
-    HOST_ATTRS = {
-        'id':       Host.id,
-        'net':      Host.gw,
-        'ip':       Host.ip,
-        'mac':      Host.mac,
-        'name':     Host.name,
-        'seen':     Host.last_seen,
-        'state':    Host.state,
-        'count':    Host.count,
-    }
+    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)
 
-    HOST_SORT = Host.last_seen.desc()
+    # XXX: set later
+    TABLE_URL = ITEM_URL = None
+
+    # 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", 
+    )
+    
+    # model
+    TABLE = HostsTable
 
     def query (self) :
-        return self.db.query(Host)
-    
-    def sort (self, hosts, default=HOST_SORT) :
-        sort = self.request.args.get('sort')
-
-        if sort :
-            name = sort.lstrip('+-')
-        else :
-            name = None
+        """
+            Database SELECT query.
+        """
 
-        if name :
-            order_by = self.HOST_ATTRS[name]
-        else :
-            order_by = default
-        
-        # prefix
-        if not sort :
-            pass
-        elif sort.startswith('+') :
-            order_by = order_by.asc()
-        elif sort.startswith('-') :
-            order_by = order_by.desc()
-        else :
-            pass
+        return self.db.query(Host)
 
-        log.debug("sort: %s", order_by)
-        
-        hosts = hosts.order_by(order_by)
-
-        # k
-        return sort, hosts
-    
-    def filter_attr (self, attr, value) :
+    def filter_seen (self, value) :
         """
             Return filter expression for given attr == value
         """
 
-        if attr == 'seen' :
-            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)
-        
-        elif attr == 'ip' :
-            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))
-
-        else :
-            # preprocess
-            like = False
-
-            if value.endswith('*') :
-                like = value.replace('*', '%')
-
-            elif attr == 'mac' :
-                value = Host.normalize_mac(value)
-
-            # filter
-            column = self.HOST_ATTRS[attr]
-
-            if like :
-                return (column.like(like))
-            else :
-                return (column == value)
-
-    def filter (self, hosts) :
-        """
-            Apply filters from request.args against given hosts.
-
-            Returns (filters, hosts).
-        """
-
-        # filter?
-        filters = {}
-
-        for attr in self.HOST_ATTRS :
-            values = [value.strip() for value in self.request.args.getlist(attr) if value.strip()]
-
-            # ignore empty fields
-            if not values :
-                continue
-            
-            # build query expression
-            filter = db.or_(*[self.filter_attr(attr, value) for value in values])
-
-            log.debug("filter %s: %s", attr, filter)
-
-            hosts = hosts.filter(filter)
-            filters[attr] = values
-
-        return filters, hosts
-
-    def filters_title (self) :
-        """
-            Return a string representing the applied filters.
-        """
-
-        return ', '.join(value for values in self.filters.itervalues() for value in values)
- 
-    def render_hosts (self, hosts, title=None, filters=False, page=None, hilight=None) :
-        COLS = (
-            #title          sort        filter      class
-            ('IP',          'ip',       'ip',       'ip'    ),
-            ('MAC',         'mac',      'mac',      'mac'   ),
-            ('Hostname',    'name',     False,      False   ),
-            ('Network',     'net',      'net',      False   ),
-            ('Seen',        'seen',     'seen',     'seen'  ),
-            ('State',       'state',    'state',    False   ), 
-        )
-
-        def url (**opts) :
-            args = dict()
-
-            if filters :
-                args.update(filters)
-
-            args.update(opts)
-
-            return self.url(**args)
+        column = Host.last_seen
 
-        def sortlink (attr) :
-            if not self.sorts :
-                sort = attr
-            elif self.sorts.lstrip('+-') != attr :
-                sort = attr
-            elif self.sorts.startswith('-') :
-                sort = "+" + attr
-            else :
-                sort = "-" + attr
-
-            return html.a(href=url(sort=sort))
-
-        def paginate (page, count=None) :
-            """
-                Render pagination.
-            """
-
-            if count is not None :
-                pages = int(math.ceil(count / self.PAGE)) # XXX: bad self.PAGE
-            else :
-                pages = None
-
-            if page > 0 :
-                yield html.a(href=url(page=0))(html("&laquo;&laquo; First"))
-                yield html.a(href=url(page=(page - 1)))(html("&laquo; Prev"))
-            
-            yield html.span("Page {page} of {pages}".format(page=(page + 1), pages=(pages or '???')))
-
-            yield html.a(href=url(page=(page + 1)))(html("&raquo; Next"))
-
-        def render_filter (filter) :
-            value = filters.get(filter)
-
-            if value :
-                # XXX: multi-valued filters?
-                value = value[0]
-            else :
-                value = None
-
-            return html.input(type='text', name=filter, value=value)
-
-        def render_cell (attr, value, cssclass=True, filter=None, htmlvalue=None) :
-            if htmlvalue :
-                cell = htmlvalue
-            else :
-                cell = value
-
-            if filter :
-                cell = html.a(href=self.url(ListHandler, **{attr: value}))(cell)
-
-            if cssclass is True :
-                cssclass = attr
-
-            css = (cssclass, 'hilight' if (hilight and attr in hilight and value in hilight[attr]) else None)
-            css = ' '.join(cls for cls in css if cls)
-            
-            return html.td(class_=css)(cell)
+        if value.isdigit() :
+            # specific date
+            date = datetime.datetime.strptime(value, Host.DATE_FMT).date()
 
-        table = html.table(
-            html.caption(title) if title else None,
-            html.thead(
-                html.tr(
-                    html.th('#'),
-                    (
-                        html.th(
-                            sortlink(sort)(title) if sort else (title)
-                        ) for title, sort, filter, class_ in COLS
-                    )
-                ),
-                html.tr(class_='filter')(
-                    html.td(
-                        html.input(type='submit', value=u'\u00BF'),
-                    ),
-                    (
-                        html.td(class_=class_)(
-                            render_filter(filter) if filter else None
-                        ) for title, sort, filter, class_ in COLS
-                    )
-                ) if filters is not False else None
-            ),
-            html.tbody(
-                html.tr(class_=('alternate' if i % 2 else None), id=host.id)(
-                    html.th(
-                        html.a(href=self.url(ItemHandler, id=host.id))(
-                            '#' #host.id
-                        )
-                    ),
+            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)
 
-                    render_cell('ip', host.ip, filter=True),
-                    render_cell('mac', host.mac, filter=True, htmlvalue=host.render_mac()),
-                    render_cell('name', host.name, htmlvalue=host.render_name()),
-                    render_cell('gw', host.gw),
+            # 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
 
-                    render_cell('seen', host.seen()),
-                    html.td(class_=host.state_class(), title=host.state_title())(host.state),
-                ) for i, host in enumerate(hosts)
-            ),
-            html.tfoot(
-                html.tr(
-                    html.td(colspan=(1 + len(COLS)))(
-                        paginate(page) if page is not None else (
-                            # XXX: does separate SELECT count()
-                            "{count} hosts".format(count=hosts.count())
-                        )
-                    )
-                )
-            )
-        )
-        
-        if filters is False :
-            return table
+        # column is IPv4 string literal format...
+        if '/' in value :
+            return (db.func.inet(Host.ip).op('<<')(db.func.cidr(value)))
         else :
-            return html.form(method='get', action=self.url())(
-                html.input(type='hidden', name='sort', value=self.sorts),
-                table,
-            )
+            return (db.func.inet(Host.ip) == db.func.inet(value))
 
-class ItemHandler (BaseHandler) :
+    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)
@@ -449,80 +279,38 @@
             self.render_host(self.host),
 
             html.h2('Related'),
-            self.render_hosts(self.hosts, hilight=dict(ip=self.host.ip, mac=self.host.mac)),
+            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 (BaseHandler) :
-    # pagination
-    PAGE = 10
+class ListHandler (HostsHandler) :
+    """
+        List of DHCP hosts for given filter.
+    """
 
-    # views
-    VIEWS = (
-        ("Last hour",   dict(seen='1h')),
-        ("Last day",    dict(seen='24h')),
-        ("All",         dict()),
-    ) + tuple(
-        ("Network " + network,          dict(ip=network)) for network in (
-            '194.197.235.0/24',
-            '10.1.0.0/16',
-            '10.4.0.0/16',
-            '10.5.0.0/16',
-            '10.6.0.0/16',
-            '10.10.0.0/16',
-        )
-    ) + (
-        ("Valid",       dict(state=('DHCPACK', 'DHCPRELEASE'))),
-        ("Incomplete",  dict(state=('DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST'))),
-        ("Invalid",     dict(state=('DHCPNAK', ))),
-    )
+    TABLE_ITEM_URL = ItemHandler
 
     def process (self) :
-        hosts = self.query()
-
-        # filter
-        self.filters, hosts = self.filter(hosts)
-
-        # sort XXX: default per filter column?
-        self.sorts, hosts = self.sort(hosts)
-        
-        # page?
-        self.page = self.request.args.get('page')
-
-        if self.page :
-            self.page = int(self.page)
-
-            hosts = hosts.offset(self.page * self.PAGE).limit(self.PAGE)
-
-        self.hosts = hosts
-  
+        # 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_hosts(self.hosts, filters=self.filters, page=self.page),
+            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,
+            #html.a(href=self.url())(html('&laquo;'), 'Back') if self.filters else None,
         )
 
-import json
-import time
-
-def dt2ts (dt) :
-    return int(time.mktime(dt.timetuple()))
-
-def ts2dt (ts) :
-    return datetime.datetime.fromtimestamp(ts)
-
-class RealtimeHandler (BaseHandler) :
-    TITLE = "Pseudo-Realtime hosts.."
-    CSS = web.Handler.CSS + (
+class RealtimeHandler (HostsHandler) :
+    TITLE = "DHCP Pseudo-Realtime hosts.."
+    CSS = HostsHandler.CSS + (
         'http://code.jquery.com/ui/1.9.0/themes/base/jquery-ui.css',
     )
     JS = (
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/table.py	Sat Jan 26 21:24:01 2013 +0200
@@ -0,0 +1,526 @@
+from pvl.web import html
+from pvl.verkko import web, db
+
+import logging; log = logging.getLogger('pvl.verkko.table')
+
+class Column (object) :
+    """
+        web.Table column spec, representing a property of an SQLAlchemy ORM model class.
+    """
+
+    def __init__ (self, attr, title, column, rowhtml=None, sort=True, filter=True, colcss=True, rowfilter=False, rowtitle=None, rowcss=None) :
+        """
+            attr        - name of column value, used for http query arg, html css class, model getattr()
+            title       - title of column
+            column      - the model column property, used to build queries
+            rowhtml     - function returning column value as HTML for model
+            sort        - allow sorting by column
+            filter      - allow filtering by column
+            colcss      - apply css class for column; True -> attr
+            rowfilter   - allow filtering by row value
+            rowtitle    - function returning column title for model
+            rowcss      - function returning column class for model
+        """
+
+        if colcss is True :
+            colcss = attr
+
+        self.attr = attr
+        self.title = title
+        self.column = column
+
+        # column attrs
+        self.sort = sort
+        self.filter = filter
+        self.colcss = colcss
+        
+        # row attrs
+        self.rowhtml = rowhtml
+        self.rowfilter = rowfilter
+        self.rowtitle = rowtitle
+        self.rowcss = rowcss
+
+    def render_header (self, sorturl) :
+        """
+            Render <th> for <thead> in given Table.
+        """
+
+        header = self.title
+
+        if self.sort :
+            header = html.a(href=sorturl)(header)
+
+        return html.th(header)
+
+    def render_filter_input (self, filters) :
+        """
+            Render filter <input> in given Table.
+        """
+        
+        value = filters.get(self.attr)
+
+        if value :
+            # XXX: multi-valued filters?
+            value = value[0]
+        else :
+            value = None
+
+        return html.input(type='text', name=self.attr, value=value)
+
+    def render_header_filter (self, filters) :
+        """
+            Render <td><input> for <thead> in given Table.
+        """
+
+        if self.filter :
+            input = self.render_filter_input(filters)
+        else  :
+            input = None
+
+        return html.td(class_=self.colcss)(input)
+
+    def render_cell (self, item, table, hilight=None) :
+        """
+            Render <td> for item in <tbody> in given Table.
+
+                hilight     - optionally higlight given { attr: value }'s using CSS.
+        """
+
+        # XXX: this is sometimes broken, figure out how to index by column
+        value = getattr(item, self.attr)
+
+        if self.rowhtml :
+            rowhtml = self.rowhtml(item)
+        else :
+            rowhtml = value
+
+        if self.rowcss :
+            rowcss = self.rowcss(item)
+        else :
+            rowcss = None
+
+        if hilight :
+            # lookup attr/value
+            hilight = self.attr in hilight and value in hilight[self.attr]
+
+        if self.rowfilter :
+            # filter-link by row-value
+            filters = { self.attr: value }
+        else :
+            filters = None
+
+        # css classes to apply, or None's
+        css = (
+                self.colcss, 
+                rowcss, 
+                'hilight' if hilight else None
+        )
+
+        if self.rowtitle :
+            rowtitle = self.rowtitle(item)
+        else :
+            rowtitle = None
+
+        yield table.render_cell(rowhtml,
+                css         = css,
+                filters     = filters,
+                rowtitle    = rowtitle,
+        )
+
+class Table (object) :
+    """
+        Render <table> with Columns from SQLAlchemy ORM model class.
+    """
+
+    COLUMNS = ()
+    
+    # attr -> column
+    ATTRS = dict((col.attr, col) for col in COLUMNS)
+ 
+    # items per page
+    PAGE = 10
+    
+    def __init__ (self, url, columns=None, table_url=None, item_url=None, caption=None, page=None) :
+        """
+                url         - pvl.web.Handler.url()
+                table_url   - ListHandler, or self?
+                item_url    - ItemHandler, or self#id
+                columns     - sequence of Columns
+                caption     - optional <caption>
+                page        - items per page
+        """
+        
+        self.url = url
+
+        self.columns = columns or self.COLUMNS
+        self.table_url = table_url
+        self.item_url = item_url
+
+        self.caption = caption
+        self.page = page or self.PAGE
+
+    def tableurl (self, filters=None, **opts) :
+        """
+            URL for table with given opts, keeping our sorting/filtering unless overriden.
+        """
+
+        args = dict()
+
+        # apply
+        if filters :
+            args.update(filters)
+        
+        if opts :
+            args.update(opts)
+
+        return self.url(self.table_url, **args)
+
+    def sorturl (self, attr, sort=None, **opts) :
+        """
+            URL for table sorted by given column, reversing direction if already sorting by given column.
+        """
+
+        if not sort :
+            sort = attr
+        elif sort.lstrip('+-') != attr :
+            sort = attr
+        elif sort.startswith('-') :
+            sort = "+" + attr
+        else :
+            sort = "-" + attr
+
+        return self.tableurl(sort=sort, **opts)
+
+    def itemurl (self, item) :
+        """
+            URL for given item, by id.
+        """
+
+        if self.item_url :
+            # separate page
+            return self.url(self.item_url, id=item.id)
+        else :
+            # to our table
+            return '#{id}'.format(id=item.id)
+
+    def render_head (self, filters=None, sort=None) :
+        """
+            Yield header columns in table header.
+        """
+        
+        # id
+        yield html.th('#')
+        
+        for column in self.columns :
+            yield column.render_header(sorturl=self.sorturl(column.attr, sort=sort, filters=filters))
+
+    def render_head_filters (self, filters=None) :
+        """
+            Yield filter columns in table header.
+        """
+
+        # id
+        yield html.td(html.input(type='submit', value=u'\u00BF'))
+        
+        for column in self.columns :
+            yield column.render_header_filter(filters)
+
+    def render_cell (self, rowhtml, css=(), filters=None, rowtitle=None) :
+        """
+            Render a single cell.
+
+                htmlvalue   - rendered value
+                css         - css classes to apply
+                filters     - render filter link for filter-values?
+                title       - mouseover title for cell
+        """
+
+        if filters :
+            cell = html.a(href=self.tableurl(**filters))(rowhtml)
+        else :
+            cell = rowhtml
+
+        css = ' '.join(cls for cls in css if cls)
+        
+        return html.td(class_=css, title=rowtitle)(cell)
+   
+    def render_row (self, item, **opts) :
+        """
+            Yield columns for row.
+        """
+        
+        for column in self.columns :
+            yield column.render_cell(item, self, **opts)
+
+    def render_body (self, rows, **opts) :
+        """
+            Yield body rows.
+        """
+
+        for i, item in enumerate(rows) :
+            yield html.tr(class_=('alternate' if i % 2 else None), id=item.id)(
+                html.th(
+                    html.a(href=self.itemurl(item))("#")
+                ),
+
+                self.render_row(item, **opts)
+            )
+
+    def render_pagination (self, page, count=None) :
+        """
+            Render pagination links.
+        """
+
+        if count is not None :
+            pages = int(math.ceil(count / self.page))
+        else :
+            pages = None
+
+        if page > 0 :
+            yield html.a(href=self.tableurl(page=0))(html("&laquo;&laquo; First"))
+            yield html.a(href=self.tableurl(page=(page - 1)))(html("&laquo; Prev"))
+        
+        yield html.span("Page {page} of {pages}".format(page=(page + 1), pages=(pages or '???')))
+
+        yield html.a(href=self.tableurl(page=(page + 1)))(html("&raquo; Next"))
+
+    def render_foot (self, query, page) :
+        """
+            Render pagination/host count in footer.
+        """
+
+        # XXX: does separate SELECT count()
+        count = query.count()
+
+        if page :
+            return self.render_pagination(page, count)
+        else :
+            return "{count} hosts".format(count=count)
+
+    def render (self, query, filters=None, sort=None, page=None, hilight=None) :
+        """
+            Return <table> element. Wrapped in <form> if filters.
+                query   - filter()'d sort()'d SELECT query()
+                filters - None for no filtering ui, dict of filters otherwise.
+                sort    - None for no sorting ui, sort-attr otherwise.
+                page    - display pagination for given page
+                hilight - { attr: value } cells to hilight
+        """
+
+        # render table
+        table = html.table(
+            html.caption(self.caption) if self.caption else None,
+            html.thead(
+                html.tr(
+                    self.render_head(filters=filters, sort=sort)
+                ),
+                (
+                    html.tr(class_='filter')(self.render_head_filters(filters=filters))
+                ) if filters is not None else None,
+            ),
+            html.tbody(
+                self.render_body(query,
+                    hilight = hilight,
+                )
+            ),
+            html.tfoot(
+                html.tr(
+                    html.td(colspan=(1 + len(self.columns)))(
+                        self.render_foot(query, page)
+                    )
+                )
+            )
+        )
+        
+        # filters form?
+        if filters is None :
+            return table
+        else :
+            return html.form(method='get', action=self.tableurl())(
+                html.input(type='hidden', name='sort', value=sort),
+                table,
+            )
+
+class TableHandler (object) :
+    """
+        Mixin for handling Table args/rendering.
+    """
+
+    CSS = (
+        "/static/dhcp/table.css", 
+    )
+    
+    TABLE = None
+
+    # target Handlers for table links
+    TABLE_URL = None
+    TABLE_ITEM_URL = None
+ 
+    def query (self) :
+        """
+            Database SELECT query.
+        """
+
+        raise NotImplementedError()
+    
+    def sort (self, query) :
+        """
+            Apply ?sort= from requset args to query.
+
+            Return { attr: sort }, query
+        """
+
+        sort = self.request.args.get('sort')
+
+        if sort :
+            name = sort.lstrip('+-')
+        else :
+            name = None
+
+        if name :
+            order_by = self.TABLE.ATTRS[name].column
+        else :
+            order_by = self.TABLE.SORT # default
+        
+        # prefix -> ordering
+        if not sort :
+            pass
+        elif sort.startswith('+') :
+            order_by = order_by.asc()
+        elif sort.startswith('-') :
+            order_by = order_by.desc()
+        else :
+            pass
+        
+        # apply
+        log.debug("sort: %s", order_by)
+        
+        query = query.order_by(order_by)
+
+        return sort, query
+    
+    def filter_attr (self, attr, value) :
+        """
+            Return filter expression for given attr == value
+        """
+
+        # preprocess
+        like = False
+
+        if value.endswith('*') :
+            like = value.replace('*', '%')
+
+        # filter
+        column = self.TABLE.ATTRS[attr].column
+
+        if like :
+            return (column.like(like))
+        else :
+            return (column == value)
+ 
+    def _filter (self, attr, values) :
+        """
+            Apply filters for given attr -> (value, expression)
+        """
+
+        for value in values :
+            value = value.strip()
+            
+            # ignore empty fields
+            if not value :
+                continue
+
+            # lookup attr-specific filter
+            filter = getattr(self, 'filter_{attr}'.format(attr=attr), None)
+
+            if filter :
+                filter = filter(value)
+            else :
+                # use generic
+                filter = self.filter_attr(attr, value)
+            
+            log.debug("%s: %s: %s", attr, value, filter)
+            
+            yield value, filter
+
+    def filter (self, query) :
+        """
+            Apply filters from request.args against given hosts.
+
+            Returns (filters, hosts).
+        """
+
+        # filter?
+        filters = {}
+
+        for attr in self.TABLE.ATTRS :
+            # from request args
+            values = self.request.args.getlist(attr)
+
+            # lookup attr filters as expressions
+            value_filters = list(self._filter(attr, values))
+
+            # ignore empty fields
+            if not value_filters :
+                continue
+        
+            # filtering values, and filter expressions
+            values, expressions = zip(*value_filters)
+
+            # apply
+            query = query.filter(db.or_(*expressions))
+            filters[attr] = values
+
+        return filters, query
+
+    def filters_title (self) :
+        """
+            Return a string representing the applied filters.
+        """
+
+        return ', '.join(value for values in self.filters.itervalues() for value in values)
+
+    def process (self) :
+        """
+            Process request args -> self.filters, self.sorts, self.page, self.query
+        """
+
+        query = self.query()
+
+        # filter
+        self.filters, query = self.filter(query)
+
+        # sort
+        # TODO: sort per filter column by default?
+        self.sorts, query = self.sort(query)
+        
+        # page?
+        self.page = self.request.args.get('page')
+
+        if self.page :
+            self.page = int(self.page)
+
+            query = query.offset(self.page * self.TABLE.PAGE).limit(self.TABLE.PAGE)
+
+        self.query = query
+ 
+    def render_table (self, query, filters=None, sort=None, page=None, hilight=None) :
+        """
+            Render table
+                query       - SELECT query for rows
+                filters     - applied filters
+                sort        - applied sort
+                page        - applied page
+                hilight     - hilight given { attr: value } cells
+        """
+
+        return self.TABLE(self.url,
+                table_url   = self.TABLE_URL,
+                item_url    = self.TABLE_ITEM_URL,
+        ).render(query,
+                filters = filters,
+                sort    = sort,
+                page    = page,
+                hilight = hilight,
+        )
+        
--- a/pvl/verkko/urls.py	Sat Jan 26 21:23:39 2013 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-from werkzeug.routing import Map, Rule
-
-def rule (string, endpoint, **opts) :
-    return Rule(string, endpoint=endpoint, **opts)
-
-# URL -> Handler
-from pvl.verkko import hosts
-
-# index page here :)
-from pvl.verkko import web
-
-class Index (web.Handler) :
-    def render (self) :
-        html = web.html
-
-        return (
-            html.ul(
-                html.li(
-                    "DHCP Hosts",
-                    html.ul(
-                        html.li(html.a(href=self.url(hosts.ListHandler, **opts))(title)) for title, opts in hosts.ListHandler.VIEWS
-                    )
-                ),
-            )
-        )
-
-urls = Map((
-    rule('/',                       Index),
-    rule('/hosts/',                 hosts.ListHandler),
-    rule('/hosts/<int:id>',         hosts.ItemHandler),
-    rule('/hosts/realtime',         hosts.RealtimeHandler),
-))
-
--- a/pvl/verkko/web.py	Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/verkko/web.py	Sat Jan 26 21:24:01 2013 +0200
@@ -1,41 +1,20 @@
 # encoding: utf-8
-import pvl.web.application
-
-# view
-from pvl.web.html import tags as html
+import pvl.web
+from pvl.web import html, urls
 
-from werkzeug.wrappers import Response
-from werkzeug.exceptions import (
-        HTTPException, 
-        BadRequest,         # 400
-        NotFound,           # 404
-)
-from werkzeug.utils import redirect
+import logging; log = logging.getLogger('pvl.verkko.web')
 
-class Handler (pvl.web.application.Handler) :
-    CSS = (
-        "/static/dhcp/hosts.css", 
-    )
-    JS = (
-        #"/static/jquery/jquery.js"
-    )
+class DatabaseHandler (pvl.web.Handler) :
+    """
+        Request handler with pvl.verkko.Database session
+    """
 
     def __init__ (self, app, request, urls, params) :
-        super(Handler, self).__init__(app, request, urls, params)
+        super(DatabaseHandler, self).__init__(app, request, urls, params)
 
         # new ORM session per request
         self.db = app.db.session() 
 
-    def title (self) :
-        """
-            Render site/page title as text.
-        """
-        
-        if self.TITLE :
-            return u"Päivölä Verkko :: {title}".format(title=self.TITLE)
-        else :
-            return u"Päivölä Verkko"
-
     def cleanup (self) :
         """
             After request processing.
@@ -43,3 +22,18 @@
         
         # XXX: SQLAlchemy doesn't automatically close these...?
         self.db.close()
+
+class Application (pvl.web.Application) :
+    """
+        Application with pvl.verkko.Database
+    """
+
+    def __init__ (self, db, **opts) :
+        """
+            db      - pvl.verkko.Database
+        """
+
+        super(Application, self).__init__(**opts)
+
+        self.db = db
+
--- a/pvl/verkko/wsgi.py	Sat Jan 26 21:23:39 2013 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,18 +0,0 @@
-import pvl.web
-
-import logging; log = logging.getLogger('pvl.verkko.wsgi')
-
-from pvl.verkko import urls, db as database
-
-class Application (pvl.web.Application) :
-    URLS  = urls.urls
-
-    def __init__ (self, db, **opts) :
-        """
-            Initialize app with db.
-        """
-
-        super(Application, self).__init__(**opts)
-
-        self.db = database.Database(db)
-
--- a/pvl/web/application.py	Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/web/application.py	Sat Jan 26 21:24:01 2013 +0200
@@ -1,5 +1,5 @@
 """
-    WSGI Application.
+    WSGI Application with pvl.web.urls-mapped Handlers building pvl.web.html.
 """
 
 import werkzeug
@@ -135,6 +135,9 @@
         )
         body = html(self.render())
 
+        if not title :
+            raise Exception("%s: no page title!" % (self, ))
+
         if self.app.layout :
             return self.app.layout.format(
                 TITLE   = unicode(title),
--- a/pvl/web/html.py	Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/web/html.py	Sat Jan 26 21:24:01 2013 +0200
@@ -7,6 +7,7 @@
 """
 
 # XXX: needs some refactoring for Text vs Tag now
+# XXX: not all tags work in self-closing form, e.g. empty html.title() breaks badly
 
 import itertools as itertools
 import types as types
--- a/setup_verkko.py	Sat Jan 26 21:23:39 2013 +0200
+++ b/setup_verkko.py	Sat Jan 26 21:24:01 2013 +0200
@@ -17,6 +17,7 @@
     packages    = [
         'pvl',
         'pvl.syslog',
+        'pvl.dhcp',
         'pvl.rrd',
         'pvl.web',
         'pvl.verkko',
--- a/static/dhcp/hosts.css	Sat Jan 26 21:23:39 2013 +0200
+++ b/static/dhcp/hosts.css	Sat Jan 26 21:24:01 2013 +0200
@@ -7,136 +7,6 @@
 }
 
 /*
- * Tables
- */
-table
-{
-    width: 80%;
-
-    margin: auto;
-    padding: 0px;
-
-    border-collapse: collapse;
-    border: 1px solid #aaa;
-
-    font-size: small;
-}
-
-/* A caption looks similar to a h2 */
-table caption 
-{
-    font-size: large;
-    font-weight: bold;
-
-    padding: 0.5em;
-    margin: 0em 0em 0.5em;
-
-    background-color: #e5e5e5;
-    border: 1px dashed #c5c5c5;
-}
-
-/* Table header */
-thead tr
-{
-    background-color: #d0d0d0;
-}
-
-thead
-{
-    border-bottom: 2px solid #aaa;
-}
-
-/* Filter */
-tr.filter
-{
-    height: 1em;
-}
-
-tr.filter td
-{
-    padding: 0em 1em;
-}
-
-tr.filter td input
-{
-    display: block;
-    width: 100%;
-
-    border: 0px;
-    padding: 0em; 
-
-    background-color: #d0d0d0;
-
-    font-family: inherit;
-    font-size: inherit;
-}
-
-tr.filter td input:hover
-{
-    background-color: #e0e0e0;
-}
-
-tr.filter td input:focus
-{
-    background-color: #ffffff;
-}
-
-/* Rows */
-tr
-{
-    background-color: #e0e0e0;
-}
-
-tr.alternate
-{
-    background-color: #c8c8c8;
-}
-
-/* Link to tr with URL #foo fragment */
-tr:target
-{
-    background-color: #F5F3B8;
-}
-
-/* Cells */
-td, th
-{
-    border: thin solid #ffffff;
-
-    padding: 0.25em 1em;
-    
-    /* Do not normally wrap */
-    white-space: nowrap;
-}
-
-thead a,
-tbody a
-{
-    display: block;
-}
-
-th
-{
-    text-align: center;
-}
-
-td.hilight
-{
-    font-weight: bold;
-}
-
-/* Footer */
-tfoot
-{
-    border-top: 2px solid #aaa;
-}
-
-tfoot tr
-{
-    text-align: center;
-}
-
-/*
  * Details
  */
 div.info {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/dhcp/table.css	Sat Jan 26 21:24:01 2013 +0200
@@ -0,0 +1,130 @@
+/*
+ * Tables
+ */
+table
+{
+    width: 80%;
+
+    margin: auto;
+    padding: 0px;
+
+    border-collapse: collapse;
+    border: 1px solid #aaa;
+
+    font-size: small;
+}
+
+/* A caption looks similar to a h2 */
+table caption 
+{
+    font-size: large;
+    font-weight: bold;
+
+    padding: 0.5em;
+    margin: 0em 0em 0.5em;
+
+    background-color: #e5e5e5;
+    border: 1px dashed #c5c5c5;
+}
+
+/* Table header */
+thead tr
+{
+    background-color: #d0d0d0;
+}
+
+thead
+{
+    border-bottom: 2px solid #aaa;
+}
+
+/* Filter */
+tr.filter
+{
+    height: 1em;
+}
+
+tr.filter td
+{
+    padding: 0em 1em;
+}
+
+tr.filter td input
+{
+    display: block;
+    width: 100%;
+
+    border: 0px;
+    padding: 0em; 
+
+    background-color: #d0d0d0;
+
+    font-family: inherit;
+    font-size: inherit;
+}
+
+tr.filter td input:hover
+{
+    background-color: #e0e0e0;
+}
+
+tr.filter td input:focus
+{
+    background-color: #ffffff;
+}
+
+/* Rows */
+tr
+{
+    background-color: #e0e0e0;
+}
+
+tr.alternate
+{
+    background-color: #c8c8c8;
+}
+
+/* Link to tr with URL #foo fragment */
+tr:target
+{
+    background-color: #F5F3B8;
+}
+
+/* Cells */
+td, th
+{
+    border: thin solid #ffffff;
+
+    padding: 0.25em 1em;
+    
+    /* Do not normally wrap */
+    white-space: nowrap;
+}
+
+thead a,
+tbody a
+{
+    display: block;
+}
+
+th
+{
+    text-align: center;
+}
+
+td.hilight
+{
+    font-weight: bold;
+}
+
+/* Footer */
+tfoot
+{
+    border-top: 2px solid #aaa;
+}
+
+tfoot tr
+{
+    text-align: center;
+}
+