diff mbox

[Branch,~linaro-validation/lava-scheduler/trunk] Rev 136: * add infrastructure to make it really easy to have ajax-backed tables

Message ID 20120302031814.4041.8277.launchpad@ackee.canonical.com
State Accepted
Headers show

Commit Message

Michael-Doyle Hudson March 2, 2012, 3:18 a.m. UTC
Merge authors:
  Michael Hudson-Doyle (mwhudson)
Related merge proposals:
  https://code.launchpad.net/~mwhudson/lava-scheduler/ajax-tables/+merge/95311
  proposed by: Michael Hudson-Doyle (mwhudson)
------------------------------------------------------------
revno: 136 [merge]
committer: Michael Hudson-Doyle <michael.hudson@linaro.org>
branch nick: trunk
timestamp: Fri 2012-03-02 16:16:26 +1300
message:
  * add infrastructure to make it really easy to have ajax-backed tables
  * convert all existing tables to be ajax-backed
added:
  lava_scheduler_app/tables.py
  lava_scheduler_app/templates/lava_scheduler_app/ajax_table.html
modified:
  .bzrignore
  lava_scheduler_app/extension.py
  lava_scheduler_app/templates/lava_scheduler_app/_content.html
  lava_scheduler_app/templates/lava_scheduler_app/alljobs.html
  lava_scheduler_app/templates/lava_scheduler_app/device.html
  lava_scheduler_app/templates/lava_scheduler_app/health_jobs.html
  lava_scheduler_app/templates/lava_scheduler_app/index.html
  lava_scheduler_app/templates/lava_scheduler_app/labhealth.html
  lava_scheduler_app/urls.py
  lava_scheduler_app/views.py
  setup.py


--
lp:lava-scheduler
https://code.launchpad.net/~linaro-validation/lava-scheduler/trunk

You are subscribed to branch lp:lava-scheduler.
To unsubscribe from this branch go to https://code.launchpad.net/~linaro-validation/lava-scheduler/trunk/+edit-subscription
diff mbox

Patch

=== modified file '.bzrignore'
--- .bzrignore	2011-11-26 21:49:36 +0000
+++ .bzrignore	2012-03-02 03:16:26 +0000
@@ -4,3 +4,4 @@ 
 ./_trial_temp
 ./twisted/plugins/dropin.cache
 ./dist
+./build

=== modified file 'lava_scheduler_app/extension.py'
--- lava_scheduler_app/extension.py	2011-12-01 12:33:01 +0000
+++ lava_scheduler_app/extension.py	2012-02-29 23:59:27 +0000
@@ -61,3 +61,7 @@ 
         import lava_scheduler_app
         return versiontools.format_version(
             lava_scheduler_app.__version__, lava_scheduler_app)
+
+    def contribute_to_settings(self, settings_module):
+        super(SchedulerExtension, self).contribute_to_settings(settings_module)
+        settings_module['INSTALLED_APPS'].append('django_tables2')

=== added file 'lava_scheduler_app/tables.py'
--- lava_scheduler_app/tables.py	1970-01-01 00:00:00 +0000
+++ lava_scheduler_app/tables.py	2012-03-01 22:02:21 +0000
@@ -0,0 +1,85 @@ 
+import simplejson
+
+import django_tables2 as tables
+from django_tables2.rows import BoundRow
+from django_tables2.utils import AttributeDict
+
+from lava.utils.data_tables.views import DataTableView
+from lava.utils.data_tables.backends import QuerySetBackend
+
+
+class AjaxColumn(tables.Column):
+
+    def __init__(self, *args, **kw):
+        sort_expr = kw.pop('sort_expr', None)
+        width = kw.pop('width', None)
+        super(AjaxColumn, self).__init__(*args, **kw)
+        self.sort_expr = sort_expr
+        self.width = width
+
+
+class _ColWrapper(object):
+
+    def __init__(self, name, sort_expr, table):
+        self.name = name
+        if sort_expr is not None:
+            self.sort_expr = sort_expr
+        else:
+            self.sort_expr = name
+        self.table = table
+
+    def callback(self, record):
+        # It _might_ make life more convenient to handle certain non-JSONable
+        # datatypes here -- particularly, applying unicode() to model objects
+        # would be more consistent with the way templates work.
+        return BoundRow(self.table, record)[self.name]
+
+
+class AjaxTable(tables.Table):
+    datatable_opts = None
+    searchable_columns = []
+
+    def __init__(self, id, source, **kw):
+        if 'template' not in kw:
+            kw['template'] = 'lava_scheduler_app/ajax_table.html'
+        super(AjaxTable, self).__init__(data=[], **kw)
+        self.source = source
+        self.attrs = AttributeDict({
+            'id': id,
+            'class': 'display',
+            })
+
+    @classmethod
+    def json(cls, request, queryset):
+        table = cls(None, None)
+        our_cols = [_ColWrapper(name, col.sort_expr, table)
+                    for name, col in cls.base_columns.iteritems()]
+        return DataTableView.as_view(
+            backend=QuerySetBackend(
+                queryset=queryset,
+                columns=our_cols,
+                searching_columns=cls.searchable_columns)
+            )(request)
+
+    def datatable_options(self):
+        if self.datatable_opts:
+            opts = self.datatable_opts.copy()
+        else:
+            opts = {}
+        opts.update({
+            'bJQueryUI': True,
+            'bServerSide': True,
+            'bProcessing': True,
+            'sAjaxSource': self.source,
+            'bFilter': bool(self.searchable_columns)
+            })
+        aoColumnDefs = opts['aoColumnDefs'] = []
+        for col in self.columns:
+            aoColumnDefs.append({
+                'bSortable': bool(col.sortable),
+                'mDataProp': col.name,
+                'aTargets': [col.name],
+                })
+            if col.column.width:
+                aoColumnDefs[-1]['sWidth'] = col.column.width
+        return simplejson.dumps(opts)

=== modified file 'lava_scheduler_app/templates/lava_scheduler_app/_content.html'
--- lava_scheduler_app/templates/lava_scheduler_app/_content.html	2011-10-28 00:24:13 +0000
+++ lava_scheduler_app/templates/lava_scheduler_app/_content.html	2012-03-02 00:04:07 +0000
@@ -3,23 +3,6 @@ 
 {% block extrahead %}
 {{ block.super }}
 <link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}lava-server/css/demo_table_jui.css"/>
-<script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/FixedHeader.min.js"></script> 
-<script type="text/javascript" src="{{ STATIC_URL }}lava-server/js/jquery.dataTables.min.js"></script> 
-<script type="text/javascript">
-jQuery.fn.dataTableExt.oSort['num-html-asc']  = function(a,b) {
-	var x = a.replace( /<.*?>/g, "" );
-	var y = b.replace( /<.*?>/g, "" );
-	x = parseFloat( x );
-	y = parseFloat( y );
-	return ((x < y) ? -1 : ((x > y) ?  1 : 0));
-};
-
-jQuery.fn.dataTableExt.oSort['num-html-desc'] = function(a,b) {
-	var x = a.replace( /<.*?>/g, "" );
-	var y = b.replace( /<.*?>/g, "" );
-	x = parseFloat( x );
-	y = parseFloat( y );
-	return ((x < y) ?  1 : ((x > y) ? -1 : 0));
-};
-</script>
+<script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/FixedHeader.min.js"></script>
+<script type="text/javascript" src="{{ STATIC_URL }}lava-server/js/jquery.dataTables.min.js"></script>
 {% endblock %}

=== added file 'lava_scheduler_app/templates/lava_scheduler_app/ajax_table.html'
--- lava_scheduler_app/templates/lava_scheduler_app/ajax_table.html	1970-01-01 00:00:00 +0000
+++ lava_scheduler_app/templates/lava_scheduler_app/ajax_table.html	2012-03-01 04:04:21 +0000
@@ -0,0 +1,25 @@ 
+{% extends "django_tables2/table.html" %}
+
+{% block table.thead %}
+<thead>
+  <tr>
+    {% for column in table.columns %}
+    <th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
+    {% endfor %}
+  </tr>
+</thead>
+{% endblock table.thead %}
+
+{% block table %}
+{{ block.super }}
+{% comment %}
+The div below is a ridiculous hack to avoid the jQuery.details.js
+setting the display: of the script element to "block" when showing a
+table that is in a <details> element.
+{% endcomment %}
+<div>
+<script type="text/javascript">
+$("#{{ table.attrs.id }}").dataTable({{ table.datatable_options|safe }});
+</script>
+</div>
+{% endblock %}

=== modified file 'lava_scheduler_app/templates/lava_scheduler_app/alljobs.html'
--- lava_scheduler_app/templates/lava_scheduler_app/alljobs.html	2012-02-10 19:44:59 +0000
+++ lava_scheduler_app/templates/lava_scheduler_app/alljobs.html	2012-03-01 04:30:48 +0000
@@ -1,62 +1,10 @@ 
 {% extends "lava_scheduler_app/_content.html" %}
 
+{% load django_tables2 %}
 
 {% block content %}
 <h2>All Jobs</h2>
 
-<table class="data display">
-  <thead>
-    <tr>
-      <th class="id">ID</th>
-      <th class="status">Status</th>
-      <th class="device">Device</th>
-      <th class="description">Description</th>
-      <th class="submitter">Submitter</th>
-      <th class="submit_time">Submit Time</th>
-    </tr>
-  </thead>
-  <tbody>
-  </tbody>
-</table>
-
-<script>
-$(document).ready(
-  function() {
-    $("table.data").dataTable({
-      bJQueryUI: true,
-      bServerSide: true,
-      bProcessing: true,
-      bFilter: true,
-      sAjaxSource: "{% url lava_scheduler_app.views.alljobs_json %}",
-      aaSorting: [[0, "desc"]],
-      iDisplayLength: 25,
-      aoColumnDefs: [
-        {
-          aTargets: ["id"],
-          fnRender: function (o) { return '<a href="' + o.aData.id.link + '">' + o.aData.id.id + '</a>'; }
-        },
-        {aTargets: ["status"], mDataProp: 'status'},
-        {aTargets: ["description"], mDataProp: 'description'},
-        {aTargets: ["submitter"], mDataProp: 'submitter'},
-        {aTargets: ["submit_time"], mDataProp: 'submit_time'},
-        {
-          aTargets: ["device"],
-          fnRender: function (o) {
-            o = o.aData.device;
-            r = o.name;
-            if (o.requested) {
-              r = '<i>' + r + '</i>';
-            }
-            if (o.link) {
-              return '<a href="' + o.link + '">' + r + '</a>';
-            }
-            return r;
-         }
-        }
-      ]
-    });
-  }
-);
-</script>
+{% render_table alljobs_table %}
 
 {% endblock %}

=== modified file 'lava_scheduler_app/templates/lava_scheduler_app/device.html'
--- lava_scheduler_app/templates/lava_scheduler_app/device.html	2012-02-16 02:27:05 +0000
+++ lava_scheduler_app/templates/lava_scheduler_app/device.html	2012-03-01 04:04:21 +0000
@@ -1,5 +1,7 @@ 
 {% extends "lava_scheduler_app/_content.html" %}
 
+{% load django_tables2 %}
+
 {% block extrahead %}
 {{ block.super }}
 <style type="text/css">
@@ -89,79 +91,18 @@ 
   <div style="clear: both"></div>
 </div>
 
-<table class="jobs display">
-  <thead>
-    <tr>
-      <th class="id">ID</th>
-      <th>Status</th>
-      <th>Device</th>
-      <th>Submitter</th>
-      <th>Start Time</th>
-      <th>Finish Time</th>
-    </tr>
-  </thead>
-  <tbody>
-    {% for job in recent_job_list %}
-    <tr>
-      <td><a href="{{ job.get_absolute_url }}">{{ job.id }}</a></td>
-      <td>{{ job.get_status_display }}</td>
-      <td>{{ job.actual_device|default:'' }}</td>
-      <td>{{ job.submitter }}</td>
-      <td>{{ job.start_time }}</td>
-      <td>{{ job.end_time|default:"not finished" }}</td>
-    </tr>
-    {% endfor %}
-  </tbody>
-</table>
+{% render_table recent_job_table %}
 
 <details>
   <summary>See status transitions</summary>
-  <table class="transitions display">
-    <thead>
-      <tr>
-        <th>When</th>
-        <th>Transition</th>
-        <th>By</th>
-        <th>Reason</th>
-      </tr>
-    </thead>
-    <tbody>
-      {% for tr in transition_list %}
-      <tr>
-        <td>
-          {{ tr.0|date:"Y-m-d H:i" }}
-          {% if tr.1 %}
-            (after {{ tr.1|timesince:tr.0 }})
-          {% endif %}
-        </td>
-        <td>{{ tr.2 }} &rarr; {{ tr.3 }}</td>
-        <td>{{ tr.4 }}</td>
-        <td>
-          {% if tr.5 %}
-          {{ tr.5 }}
-          {% endif %}
-        </td>
-      </tr>
-      {% endfor %}
-    </tbody>
-  </table>
+  {% render_table transition_table %}
 </details>
 <script>
 $(document).ready(
   function() {
     $('html').addClass($.fn.details.support ? 'details' : 'no-details');
     $('details').details();
-    $("table.jobs").dataTable({
-      "bJQueryUI": true,
-      "aoColumnDefs": [
-        { "sType": "num-html", "aTargets": [ "id" ] }
-      ],
-      "aaSorting": [[4, 'desc', 4]]
-    });
-    $("table.transitions").dataTable({
-      "bJQueryUI": true,
-      "bSort": false
-    });
+    $('script').css('visibility', 'hidden');
 {% if show_maintenance %}
     $("#maintenance-button").button();
     $("#maintenance-button").click(function (e) {

=== modified file 'lava_scheduler_app/templates/lava_scheduler_app/health_jobs.html'
--- lava_scheduler_app/templates/lava_scheduler_app/health_jobs.html	2012-02-16 02:27:05 +0000
+++ lava_scheduler_app/templates/lava_scheduler_app/health_jobs.html	2012-03-01 23:38:16 +0000
@@ -1,5 +1,7 @@ 
 {% extends "lava_scheduler_app/_content.html" %}
 
+{% load django_tables2 %}
+
 {% block extrahead %}
 {{ block.super }}
 <style type="text/css">
@@ -61,45 +63,7 @@ 
   <div style="clear: both"></div>
 </div>
 
-<table class="data display">
-  <thead>
-    <tr>
-      <th class="id">ID</th>
-      <th>Status</th>
-      <th>Device</th>
-      <th>Submitter</th>
-      <th>Start Time</th>
-      <th>Finish Time</th>
-    </tr>
-  </thead>
-  <tbody>
-    {% for job in recent_job_list %}
-    <tr>
-      <td><a href="{{ job.get_absolute_url }}">{{ job.id }}</a></td>
-      <td>{{ job.get_status_display }}</td>
-      <td>{{ job.actual_device|default:'' }}</td>
-      <td>{{ job.submitter }}</td>
-      <td>{{ job.start_time }}</td>
-      <td>{{ job.end_time|default:"not finished" }}</td>
-    </tr>
-    {% endfor %}
-  </tbody>
-</table>
 
-<script>
-$(document).ready(
-  function() {
-    $("table.data").dataTable({
-      "bJQueryUI": true,
-      "aoColumnDefs": [
-        { "sType": "num-html", "aTargets": [ "id" ] }
-      ],
-      "aaSorting": [[4, 'desc', 4]]
-    });
-    $("#maintenance-button").button();
-    $("#online-button").button();
-  }
-);
-</script>
+{% render_table health_job_table %}
 
 {% endblock %}

=== modified file 'lava_scheduler_app/templates/lava_scheduler_app/index.html'
--- lava_scheduler_app/templates/lava_scheduler_app/index.html	2012-02-16 02:20:50 +0000
+++ lava_scheduler_app/templates/lava_scheduler_app/index.html	2012-03-01 22:13:53 +0000
@@ -1,86 +1,18 @@ 
 {% extends "lava_scheduler_app/_content.html" %}
 
+{% load django_tables2 %}
+
 {% block content %}
 <h2>Devices</h2>
 
-<table class="display data device">
-  <thead>
-    <tr>
-      <th>Hostname</th>
-      <th>Type</th>
-      <th>Status</th>
-      <th>Health Status</th>
-    </tr>
-  </thead>
-  <tbody>
-    {% for device in devices %}
-    <tr>
-      <td>
-        <a href="{{ device.get_absolute_url }}">{{ device.hostname }}</a>
-      </td>
-      <td>{{ device.device_type }}</td>
-      <td>{{ device.get_status_display }}</td>
-      <td>{{ device.get_health_status_display }}</td>
-    </tr>
-    {% endfor %}
-  </tbody>
-</table>
+{% render_table devices_table %}
 
 <a href="{% url lava.scheduler.labhealth %}">All Devices Health</a>
 
 <h2>Active Jobs</h2>
 
-<table class="data display job">
-  <thead>
-    <tr>
-      <th class="id">ID</th>
-      <th>Status</th>
-      <th>Device</th>
-      <th>Description</th>
-      <th>Submitter</th>
-      <th>Submit Time</th>
-    </tr>
-  </thead>
-  <tbody>
-    {% for job in jobs %}
-    <tr>
-      <td><a href="{{ job.get_absolute_url }}">{{ job.id }}</a></td>
-      <td>{{ job.get_status_display }}</td>
-      {% if job.actual_device %}
-      <td><a href="{{ job.actual_device.get_absolute_url }}"
-          >{{ job.actual_device }}</a></td>
-      {% else %}{% if job.requested_device %}
-      <td><a href="{{ job.requested_device.get_absolute_url }}"
-          ><i>{{ job.requested_device }}</i></a></td>
-      {% else %}
-      <td><i>{{ job.requested_device_type|default:'' }}</i></td>
-      {% endif %}{% endif %}
-      <td>{{ job.description|default:'' }}</td>
-      <td>{{ job.submitter }}</td>
-      <td>{{ job.submit_time }}</td>
-    </tr>
-    {% endfor %}
-  </tbody>
-</table>
+{% render_table active_jobs_table %}
 
 <a href="{% url lava.scheduler.job.list %}">All jobs</a>
 
-<script>
-$(document).ready(
-  function() {
-    $("table.data.device").dataTable({
-      "bJQueryUI": true,
-      "aaSorting": [[0, "asc"]]
-    });
-    $("table.data.job").dataTable({
-      "bJQueryUI": true,
-      "aoColumnDefs": [
-        { "sType": "num-html", "aTargets": [ "id" ] }
-      ],
-      "aaSorting": [[0, "asc"]]
-    });
-  }
-);
-</script>
-
 {% endblock %}

=== modified file 'lava_scheduler_app/templates/lava_scheduler_app/labhealth.html'
--- lava_scheduler_app/templates/lava_scheduler_app/labhealth.html	2012-02-10 11:15:14 +0000
+++ lava_scheduler_app/templates/lava_scheduler_app/labhealth.html	2012-03-01 23:14:16 +0000
@@ -1,43 +1,10 @@ 
 {% extends "lava_scheduler_app/_content.html" %}
 
+{% load django_tables2 %}
 
 {% block content %}
 <h2>Lab Health</h2>
 
-<table class="data display">
-  <thead>
-    <tr>
-      <th class="device">Hostname</th>
-      <th>Health Status</th>
-      <th>Last Report Time</th>
-      <th>Last Report Job</th>
-    </tr>
-  </thead>
-  <tbody>
-    {% for device_health in device_health_list %}
-    <tr>
-      <td><a href="{{ device_health.get_device_health_url }}">{{ device_health.hostname }}</a></td>
-      <td>{{ device_health.get_health_status_display }}</td>
-      <td>{{ device_health.last_health_report_job.end_time }}</a></td>
-      <td><a href="{{ device_health.last_health_report_job.get_absolute_url }}">{{ device_health.last_health_report_job.id }}</a></td>
-    </tr>
-    {% endfor %}
-  </tbody>
-</table>
-
-<script>
-$(document).ready(
-  function() {
-    $("table.data").dataTable({
-      "bJQueryUI": true,
-      "aoColumnDefs": [
-        { "sType": "num-html", "aTargets": [ "device" ] }
-      ],
-      "aaSorting": [[0, "desc"]],
-      "iDisplayLength": 25
-    });
-  }
-);
-</script>
+{% render_table device_health_table %}
 
 {% endblock %}

=== modified file 'lava_scheduler_app/urls.py'
--- lava_scheduler_app/urls.py	2012-02-16 05:41:14 +0000
+++ lava_scheduler_app/urls.py	2012-03-01 23:38:16 +0000
@@ -6,6 +6,12 @@ 
     url(r'^$', 
         'index',
         name='lava.scheduler'),
+    url(r'^active_jobs_json$', 
+        'index_active_jobs_json',
+        name='lava.scheduler'),
+    url(r'^devices_json$', 
+        'index_devices_json',
+        name='lava.scheduler'),
     url(r'^alljobs$', 
         'job_list', 
         name='lava.scheduler.job.list'),
@@ -15,6 +21,12 @@ 
     url(r'^device/(?P<pk>[-_a-zA-Z0-9]+)$', 
         'device_detail', 
         name='lava.scheduler.device.detail'),
+    url(r'^device/(?P<pk>[-_a-zA-Z0-9]+)/recent_jobs_json$', 
+        'recent_jobs_json', 
+        name='lava.scheduler.device.recent_jobs_json'),
+    url(r'^device/(?P<pk>[-_a-zA-Z0-9]+)/transition_json$', 
+        'transition_json', 
+        name='lava.scheduler.device.transition_json'),
     url(r'^device/(?P<pk>[-_a-zA-Z0-9]+)/maintenance$', 
         'device_maintenance_mode', 
         name='lava.scheduler.device.maintenance'),
@@ -24,9 +36,15 @@ 
     url(r'^labhealth/$',
         'lab_health',
         name='lava.scheduler.labhealth'),
+    url(r'^labhealth/health_json$',
+        'lab_health_json',
+        name='lava.scheduler.labhealth_json'),
     url(r'^labhealth/device/(?P<pk>[-_a-zA-Z0-9]+)$',
         'health_job_list',
         name='lava.scheduler.labhealth.detail'),
+    url(r'^labhealth/device/(?P<pk>[-_a-zA-Z0-9]+)/job_json$',
+        'health_jobs_json',
+        name='lava.scheduler.labhealth.health_jobs_json'),
     url(r'^job/(?P<pk>[0-9]+)$',
         'job_detail',
         name='lava.scheduler.job.detail'),

=== modified file 'lava_scheduler_app/views.py'
--- lava_scheduler_app/views.py	2012-02-16 05:41:14 +0000
+++ lava_scheduler_app/views.py	2012-03-01 23:38:16 +0000
@@ -20,9 +20,6 @@ 
 from django.template import RequestContext
 from django.template import defaultfilters as filters
 
-from lava.utils.data_tables.views import DataTableView
-from lava.utils.data_tables.backends import QuerySetBackend, Column
-
 from lava_server.views import index as lava_index
 from lava_server.bread_crumbs import (
     BreadCrumb,
@@ -39,6 +36,10 @@ 
     DeviceStateTransition,
     TestJob,
     )
+from lava_scheduler_app.tables import (
+    AjaxColumn,
+    AjaxTable,
+    )
 
 
 
@@ -50,61 +51,167 @@ 
     return decorated
 
 
+class DateColumn(AjaxColumn):
+
+    def __init__(self, **kw):
+        self._format = kw.get('date_format', settings.DATETIME_FORMAT)
+        super(DateColumn, self).__init__(**kw)
+
+    def render(self, value):
+        return filters.date(value, self._format)
+
+
+class IDLinkColumn(AjaxColumn):
+
+    def __init__(self, verbose_name="ID", **kw):
+        kw['verbose_name'] = verbose_name
+        super(IDLinkColumn, self).__init__(**kw)
+
+    def render(self, record):
+        return '<a href="%s">%s</a>' % (record.get_absolute_url(), record.pk)
+
+
+def all_jobs_with_device_sort():
+    return TestJob.objects.select_related(
+        "actual_device", "requested_device", "requested_device_type",
+        "submitter").extra(
+        select={
+            'device_sort': 'coalesce(actual_device_id, requested_device_id, requested_device_type_id)'
+            }).all()
+
+
+class JobTable(AjaxTable):
+
+    def render_device(self, record):
+        if record.actual_device:
+            return '<a href="%s">%s</a>' % (
+                record.actual_device.get_absolute_url(), record.actual_device.pk)
+        elif record.requested_device:
+            return '<a href="%s">%s</a>' % (
+                record.requested_device.get_absolute_url(), record.requested_device.pk)
+        else:
+            return '<i>' + record.requested_device_type.pk + '</i>'
+
+    id = IDLinkColumn()
+    status = AjaxColumn()
+    device = AjaxColumn(sort_expr='device_sort')
+    description = AjaxColumn(width="30%")
+    submitter = AjaxColumn(accessor='submitter.username')
+    submit_time = DateColumn()
+    end_time = DateColumn()
+
+    datatable_opts = {
+        'aaSorting': [[0, 'desc']],
+        }
+    searchable_columns=['description']
+
+
+class IndexJobTable(JobTable):
+    class Meta:
+        exclude = ('end_time',)
+
+
+def index_active_jobs_json(request):
+    return IndexJobTable.json(
+        request, all_jobs_with_device_sort().filter(
+            status__in=[TestJob.SUBMITTED, TestJob.RUNNING]))
+
+
+class DeviceTable(AjaxTable):
+
+    hostname = IDLinkColumn("hostname")
+    device_type = AjaxColumn(accessor='device_type.pk')
+    status = AjaxColumn()
+    health_status = AjaxColumn()
+
+    searchable_columns=['hostname']
+
+
+def index_devices_json(request):
+    return DeviceTable.json(
+        request, Device.objects.select_related("device_type"))
+
+
 @BreadCrumb("Scheduler", parent=lava_index)
 def index(request):
     return render_to_response(
         "lava_scheduler_app/index.html",
         {
-            'devices': Device.objects.select_related("device_type"),
-            'jobs': TestJob.objects.select_related(
-                "actual_device", "requested_device", "requested_device_type",
-                "submitter").filter(status__in=[
-                TestJob.SUBMITTED, TestJob.RUNNING]),
+            'devices_table': DeviceTable('devices', reverse(index_devices_json)),
+            'active_jobs_table': IndexJobTable(
+                'active_jobs', reverse(index_active_jobs_json)),
             'bread_crumb_trail': BreadCrumbTrail.leading_to(index),
         },
         RequestContext(request))
 
 
-@BreadCrumb("All Jobs", parent=index)
-def job_list(request):
-    return render_to_response(
-        "lava_scheduler_app/alljobs.html",
-        {
-            'bread_crumb_trail': BreadCrumbTrail.leading_to(job_list),
-        },
-        RequestContext(request))
+class DeviceHealthTable(AjaxTable):
+
+    def render_hostname(self, record):
+        return '<a href="%s">%s</a>' % (record.get_device_health_url(), record.pk)
+
+    def render_last_report_job(self, record):
+        report = record.last_health_report_job
+        if report is None:
+            return ''
+        else:
+            return '<a href="%s">%s</a>' % (report.get_absolute_url(), report.pk)
+
+    hostname = AjaxColumn("hostname")
+    health_status = AjaxColumn()
+    last_report_time = DateColumn(accessor="last_health_report_job.end_time")
+    last_report_job = AjaxColumn()
+
+    searchable_columns=['hostname']
+    datatable_opts = {
+        "iDisplayLength": 25
+        }
+
+
+def lab_health_json(request):
+    return DeviceHealthTable.json(
+        request, Device.objects.select_related(
+            "hostname", "last_health_report_job"))
+
 
 @BreadCrumb("All Device Health", parent=index)
 def lab_health(request):
-    device_health_list = Device.objects.select_related(
-                "hostname", "health_status").all()
     return render_to_response(
         "lava_scheduler_app/labhealth.html",
         {
-            'device_health_list': device_health_list,
+            'device_health_table': DeviceHealthTable(
+                'device_health', reverse(lab_health_json)),
             'bread_crumb_trail': BreadCrumbTrail.leading_to(lab_health),
         },
         RequestContext(request))
 
+
+class HealthJobTable(JobTable):
+    class Meta:
+        exclude = ('description', 'device')
+
+
+
+def health_jobs_json(request, pk):
+    device = get_object_or_404(Device, pk=pk)
+    return HealthJobTable.json(
+        request, TestJob.objects.select_related(
+            "submitter",
+        ).filter(
+            actual_device=device,
+            health_check=True))
+
+
 @BreadCrumb("All Health Jobs on Device {pk}", parent=index, needs=['pk'])
 def health_job_list(request, pk):
     device = get_object_or_404(Device, pk=pk)
-    recent_health_jobs = TestJob.objects.select_related(
-            "actual_device",
-            "health_check",
-            "end_time",
-        ).filter(
-            actual_device=device,
-            health_check=True
-        ).order_by(
-            '-end_time'
-        )
 
     return render_to_response(
         "lava_scheduler_app/health_jobs.html",
         {
             'device': device,
-            'recent_job_list': recent_health_jobs,
+            'health_job_table': HealthJobTable(
+                'health_jobs', reverse(health_jobs_json, kwargs=dict(pk=pk))),
             'show_maintenance': device.can_admin(request.user) and \
                 device.status in [Device.IDLE, Device.RUNNING],
             'show_online': device.can_admin(request.user) and \
@@ -113,51 +220,30 @@ 
         },
         RequestContext(request))
 
-def device_callback(job):
-    if job.actual_device:
-        return dict(
-            name=job.actual_device.pk, requested=False,
-            link=reverse(device_detail, kwargs=dict(pk=job.actual_device.pk)))
-    elif job.requested_device:
-        return dict(
-            name=job.requested_device.pk, requested=True,
-            link=reverse(device_detail, kwargs=dict(pk=job.requested_device.pk)))
-    else:
-        return dict(name=job.requested_device_type.pk, requested=True)
-
-
-def id_callback(job):
-    if job is None:
-        return job
-    else:
-        return dict(id=job.id, link=reverse(job_detail, kwargs=dict(pk=job.id)))
-
-
-alljobs_json = DataTableView.as_view(
-    backend=QuerySetBackend(
-        queryset=TestJob.objects.select_related(
-            "actual_device", "requested_device", "requested_device_type",
-            "submitter").extra(
-            select={
-                'device_sort': 'coalesce(actual_device_id, requested_device_id, requested_device_type_id)'
-                }).all(),
-        columns=[
-            Column(
-                'id', 'id', id_callback),
-            Column(
-                'status', 'status', lambda job: job.get_status_display()),
-            Column(
-                'device', 'device_sort', device_callback),
-            Column(
-                'description', 'description', lambda job: job.description),
-            Column(
-                'submitter', 'submitter', lambda job: job.submitter.username),
-            Column(
-                'submit_time', 'submit_time',
-                lambda job: filters.date(
-                    job.submit_time, settings.DATETIME_FORMAT)),
-            ],
-        searching_columns=['description']))
+
+class AllJobsTable(JobTable):
+
+    datatable_opts = JobTable.datatable_opts.copy()
+
+    datatable_opts.update({
+        'iDisplayLength': 25,
+        })
+
+
+def alljobs_json(request):
+    return AllJobsTable.json(
+        request, all_jobs_with_device_sort())
+
+
+@BreadCrumb("All Jobs", parent=index)
+def job_list(request):
+    return render_to_response(
+        "lava_scheduler_app/alljobs.html",
+        {
+            'bread_crumb_trail': BreadCrumbTrail.leading_to(job_list),
+            'alljobs_table': AllJobsTable('alljobs', reverse(alljobs_json)),
+        },
+        RequestContext(request))
 
 
 @BreadCrumb("Job #{pk}", parent=index, needs=['pk'])
@@ -333,6 +419,59 @@ 
     return HttpResponse(json_text, content_type=content_type)
 
 
+class RecentJobsTable(JobTable):
+    class Meta:
+        exclude = ('device',)
+
+
+def recent_jobs_json(request, pk):
+    device = get_object_or_404(Device, pk=pk)
+    return RecentJobsTable.json(request, device.recent_jobs())
+
+
+class DeviceTransitionTable(AjaxTable):
+
+    def render_created_on(self, record):
+        t = record
+        base = filters.date(t.created_on, "Y-m-d H:i")
+        if t.prev:
+            base += ' (after %s)' % (filters.timesince(t.prev, t.created_on))
+        return base
+
+    def render_transition(self, record):
+        t = record
+        return '%s &rarr; %s' % (t.get_old_state_display(), t.get_new_state_display(),)
+
+    def render_message(self, value):
+        if value is None:
+            return ''
+        else:
+            return value
+
+    created_on = AjaxColumn('when', width="40%")
+    transition = AjaxColumn('transition', sortable=False)
+    created_by = AjaxColumn('by', accessor='created_by.username')
+    message = AjaxColumn('reason')
+
+    datatable_opts = {
+        'aaSorting': [[0, 'desc']],
+        }
+
+
+def transition_json(request, pk):
+    device = get_object_or_404(Device, pk=pk)
+    qs = device.transitions.select_related('created_by')
+    qs = qs.extra(select={'prev': """
+    select t.created_on
+      from lava_scheduler_app_devicestatetransition as t
+     where t.device_id=%s and t.created_on < lava_scheduler_app_devicestatetransition.created_on
+     order by t.created_on desc
+     limit 1 """},
+                  select_params=[device.pk])
+    return DeviceTransitionTable.json(request, qs)
+
+
+
 @BreadCrumb("Device {pk}", parent=index, needs=['pk'])
 def device_detail(request, pk):
     device = get_object_or_404(Device, pk=pk)
@@ -343,26 +482,15 @@ 
             transition = None
     else:
         transition = None
-    transition_models = device.transitions.order_by('created_on').select_related('created_by')
-    transition_list = []
-    if transition_models:
-        for i, t in enumerate(transition_models):
-            if i > 0:
-                before = transition_models[i-1].created_on
-            else:
-                before = None
-            transition_list.append(
-                (t.created_on, before,
-                 t.get_old_state_display(), t.get_new_state_display(),
-                 t.created_by, t.message))
-        transition_list.reverse()
     return render_to_response(
         "lava_scheduler_app/device.html",
         {
             'device': device,
             'transition': transition,
-            'transition_list': transition_list,
-            'recent_job_list': device.recent_jobs,
+            'transition_table': DeviceTransitionTable(
+                'transitions', reverse(transition_json, kwargs=dict(pk=device.pk))),
+            'recent_job_table': RecentJobsTable(
+                'jobs', reverse(recent_jobs_json, kwargs=dict(pk=device.pk))),
             'show_maintenance': device.can_admin(request.user) and \
                 device.status in [Device.IDLE, Device.RUNNING],
             'show_online': device.can_admin(request.user) and \

=== modified file 'setup.py'
--- setup.py	2012-02-14 22:53:27 +0000
+++ setup.py	2012-03-01 04:34:01 +0000
@@ -33,6 +33,7 @@ 
     scheduler = lava_scheduler_app.extension:SchedulerExtension
     """,
     install_requires=[
+        "django-tables2",
         "lava-server >= 0.10",
         "simplejson",
         "south >= 0.7.3",