Browse Source

Merge branch 'autoreload'

Nils Schneider 9 years ago
parent
commit
88d0aa2f92
13 changed files with 474 additions and 336 deletions
  1. 5 1
      app.js
  2. 2 1
      bower.json
  3. 122 65
      lib/forcegraph.js
  4. 5 0
      lib/gui.js
  5. 43 62
      lib/linklist.js
  6. 12 2
      lib/main.js
  7. 85 57
      lib/map.js
  8. 11 7
      lib/meshstats.js
  9. 64 81
      lib/nodelist.js
  10. 20 15
      lib/proportions.js
  11. 46 44
      lib/simplenodelist.js
  12. 57 0
      lib/sorttable.js
  13. 2 1
      tasks/linting.js

+ 5 - 1
app.js

@@ -10,6 +10,7 @@ require.config({
     "d3": "../bower_components/d3/d3.min",
     "numeral": "../bower_components/numeraljs/min/numeral.min",
     "numeral-intl": "../bower_components/numeraljs/min/languages.min",
+    "virtual-dom": "../bower_components/virtual-dom/dist/virtual-dom",
     "helper": "../helper"
   },
   shim: {
@@ -17,7 +18,10 @@ require.config({
     "tablesort": {
       exports: "Tablesort"
     },
-    "numeral-intl": ["numeral"],
+    "numeral-intl": {
+      deps: ["numeral"],
+      exports: "numeral"
+    },
     "tablesort.numeric": ["tablesort"],
     "helper": ["numeral-intl"]
   }

+ 2 - 1
bower.json

@@ -21,7 +21,8 @@
     "r.js": "~2.1.16",
     "d3": "~3.5.5",
     "numeraljs": "~1.5.3",
-    "roboto-fontface": "~0.3.0"
+    "roboto-fontface": "~0.3.0",
+    "virtual-dom": "~2.0.1"
   },
   "authors": [
     "Nils Schneider <nils@nilsschneider.net>"

+ 122 - 65
lib/forcegraph.js

@@ -1,13 +1,14 @@
 define(["d3"], function (d3) {
    return function (config, linkScale, sidebar, router) {
     var self = this
-    var nodes, links
     var svg, vis, link, node
     var nodesDict, linksDict
     var zoomBehavior
     var force
     var el
     var doAnimation = false
+    var intNodes = []
+    var highlight
 
     var LINK_DISTANCE = 70
 
@@ -19,18 +20,18 @@ define(["d3"], function (d3) {
       if (!localStorageTest())
         return
 
-      var save = nodes.map( function (d) {
-        return { id: d.id, x: d.x, y: d.y }
+      var save = intNodes.map( function (d) {
+        return { id: d.o.id, x: d.x, y: d.y }
       })
 
       localStorage.setItem("graph/nodeposition", JSON.stringify(save))
     }
 
     function nodeName(d) {
-      if (d.node && d.node.nodeinfo)
-        return d.node.nodeinfo.hostname
+      if (d.o.node && d.o.node.nodeinfo)
+        return d.o.node.nodeinfo.hostname
       else
-        return d.id
+        return d.o.id
     }
 
     function dragstart(d) {
@@ -97,6 +98,48 @@ define(["d3"], function (d3) {
       animatePanzoom(translate, scale)
     }
 
+    function updateHighlight(nopanzoom) {
+      if (highlight !== undefined)
+        if (highlight.type === "node") {
+          var n = nodesDict[highlight.o.nodeinfo.node_id]
+
+          if (n) {
+            link.classed("highlight", false)
+            node.classed("highlight", function (e) {
+              return e.o.node === n.o.node && n.o.node !== undefined
+            })
+
+            if (!nopanzoom)
+              panzoomTo([n.x, n.y], [n.x, n.y])
+          }
+
+          return
+        } else if (highlight.type === "link") {
+          var l = linksDict[highlight.o.id]
+
+          if (l) {
+            node.classed("highlight", false)
+            link.classed("highlight", function (e) {
+              return e.o === l.o && l.o !== undefined
+            })
+
+            if (!nopanzoom) {
+              var x = d3.extent([l.source, l.target], function (d) { return d.x })
+              var y = d3.extent([l.source, l.target], function (d) { return d.y })
+              panzoomTo([x[0], y[0]], [x[1], y[1]])
+            }
+          }
+
+          return
+        }
+
+      node.classed("highlight", false)
+      link.classed("highlight", false)
+
+      if (!nopanzoom)
+        panzoomTo([0, 0], force.size())
+    }
+
     function tickEvent() {
       link.selectAll("line")
           .attr("x1", function(d) { return d.source.x })
@@ -132,7 +175,7 @@ define(["d3"], function (d3) {
               .gravity(0.05)
               .linkDistance(LINK_DISTANCE)
               .linkStrength(function (d) {
-                return 1 / d.tq
+                return 1 / d.o.tq
               })
               .on("tick", tickEvent)
               .on("end", savePositions)
@@ -145,24 +188,44 @@ define(["d3"], function (d3) {
                           .on("dragend", dragend)
 
     self.setData = function (data) {
-      var nodePositions = {}
+      var oldNodes = {}
 
-      if (localStorageTest()) {
-        var save = JSON.parse(localStorage.getItem("graph/nodeposition"))
+      intNodes.forEach( function (d) {
+        oldNodes[d.o.id] = d
+      })
 
-        if (save)
-          save.forEach( function (d) {
-            nodePositions[d.id] = d
-          })
-      }
+      intNodes = data.graph.nodes.map( function (d) {
+        var e
+        if (d.id in oldNodes)
+          e = oldNodes[d.id]
+        else
+          e = {}
+
+        e.o = d
+
+        return e
+      })
+
+      var newNodesDict = {}
+
+      intNodes.forEach( function (d) {
+        newNodesDict[d.o.id] = d
+      })
 
-      links = data.graph.links.filter( function (d) {
+      var intLinks = data.graph.links.filter( function (d) {
         return !d.vpn
+      }).map( function (d) {
+        var source = newNodesDict[d.source.id]
+        var target = newNodesDict[d.target.id]
+
+        return {o: d, source: source, target: target}
       })
 
       link = vis.select("g.links")
                 .selectAll("g.link")
-                .data(links, function (d) { return d.id })
+                .data(intLinks, function (d) { return d.o.id })
+
+      link.exit().remove()
 
       var linkEnter = link.enter().append("g")
                           .attr("class", "link")
@@ -175,34 +238,34 @@ define(["d3"], function (d3) {
                .append("title")
 
       link.selectAll("line")
-          .style("stroke", function (d) { return linkScale(d.tq) })
+          .style("stroke", function (d) { return linkScale(d.o.tq).hex() })
 
-      link.selectAll("title").text(showTq)
+      link.selectAll("title").text(function (d) { return showTq(d.o) })
 
       linksDict = {}
 
       link.each( function (d) {
-        if (d.source.node && d.target.node)
-          linksDict[d.id] = d
+        if (d.o.source.node && d.o.target.node)
+          linksDict[d.o.id] = d
       })
 
-      nodes = data.graph.nodes
-
       node = vis.select("g.nodes")
                 .selectAll(".node")
-                .data(nodes, function(d) { return d.id })
+                .data(intNodes, function(d) { return d.o.id })
+
+      node.exit().remove()
 
       var nodeEnter = node.enter().append("circle")
                           .attr("r", 8)
                           .on("click", function (d) {
                             if (!d3.event.defaultPrevented)
-                              router.node(d.node)()
+                              router.node(d.o.node)()
                           })
                           .call(draggableNode)
 
       node.attr("class", function (d) {
         var s = ["node"]
-        if (!d.node)
+        if (!d.o.node)
           s.push("unknown")
 
         return s.join(" ")
@@ -211,65 +274,59 @@ define(["d3"], function (d3) {
       nodesDict = {}
 
       node.each( function (d) {
-        if (d.node)
-          nodesDict[d.node.nodeinfo.node_id] = d
+        if (d.o.node)
+          nodesDict[d.o.node.nodeinfo.node_id] = d
       })
 
       nodeEnter.append("title")
-      nodeEnter.each( function (d) {
-        if (nodePositions[d.id]) {
-          d.x = nodePositions[d.id].x
-          d.y = nodePositions[d.id].y
+
+      if (localStorageTest()) {
+        var save = JSON.parse(localStorage.getItem("graph/nodeposition"))
+
+        if (save) {
+          var nodePositions = {}
+          save.forEach( function (d) {
+            nodePositions[d.id] = d
+          })
+
+          nodeEnter.each( function (d) {
+            if (nodePositions[d.o.id]) {
+              d.x = nodePositions[d.o.id].x
+              d.y = nodePositions[d.o.id].y
+            }
+          })
         }
-      })
+      }
 
       node.selectAll("title").text(nodeName)
 
-      var diameter = graphDiameter(nodes)
+      var diameter = graphDiameter(intNodes)
 
-      force.nodes(nodes)
-           .links(links)
+      force.nodes(intNodes)
+           .links(intLinks)
            .size([diameter, diameter])
-           .start()
-    }
 
-    self.resetView = function () {
-      node.classed("highlight", false)
-      link.classed("highlight", false)
+      updateHighlight(true)
 
-      panzoomTo([0, 0], force.size())
+      if (node.enter().size() + link.enter().size() > 0)
+        force.start()
+    }
 
+    self.resetView = function () {
+      highlight = undefined
+      updateHighlight()
       doAnimation = true
     }
 
     self.gotoNode = function (d) {
-      link.classed("highlight", false)
-      node.classed("highlight", function (e) {
-        return e.node === d && d !== undefined
-      })
-
-      var n = nodesDict[d.nodeinfo.node_id]
-
-      if (n)
-        panzoomTo([n.x, n.y], [n.x, n.y])
-
+      highlight = {type: "node", o: d}
+      updateHighlight()
       doAnimation = true
     }
 
     self.gotoLink = function (d) {
-      node.classed("highlight", false)
-      link.classed("highlight", function (e) {
-        return e === d && d !== undefined
-      })
-
-      var l = linksDict[d.id]
-
-      if (l) {
-        var x = d3.extent([l.source, l.target], function (d) { return d.x })
-        var y = d3.extent([l.source, l.target], function (d) { return d.y })
-        panzoomTo([x[0], y[0]], [x[1], y[1]])
-      }
-
+      highlight = {type: "link", o: d}
+      updateHighlight()
       doAnimation = true
     }
 

+ 5 - 0
lib/gui.js

@@ -13,11 +13,16 @@ function (chroma, Map, Sidebar, Tabs, Container, Meshstats, Linklist,
     var linkScale = chroma.scale(chroma.interpolate.bezier(["green", "yellow", "red"])).domain([1, 5])
     var sidebar
 
+    function dataTargetRemove(d) {
+      dataTargets = dataTargets.filter( function (e) { return d !== e })
+    }
+
     function removeContent() {
       if (!content)
         return
 
       router.removeTarget(content)
+      dataTargetRemove(content)
       content.destroy()
       contentDiv.removeChild(content.div)
       content = null

+ 43 - 62
lib/linklist.js

@@ -1,75 +1,56 @@
-define(["tablesort", "tablesort.numeric"], function (Tablesort) {
-  return function(linkScale, router) {
-    var self = this
-    var el
-
-    self.render = function (d)  {
-      el = document.createElement("div")
-      d.appendChild(el)
-    }
-
-    self.setData = function (data) {
-      if (data.graph.links.length === 0)
-        return
-
-      var h2 = document.createElement("h2")
-      h2.textContent = "Verbindungen"
-      el.appendChild(h2)
-
-      var table = document.createElement("table")
-      var thead = document.createElement("thead")
-
-      var tr = document.createElement("tr")
-      var th1 = document.createElement("th")
-      th1.textContent = "Knoten"
-      tr.appendChild(th1)
-
-      var th2 = document.createElement("th")
-      th2.textContent = "TQ"
-      tr.appendChild(th2)
-
-      var th3 = document.createElement("th")
-      th3.textContent = "Entfernung"
-      th3.classList.add("sort-default")
-      tr.appendChild(th3)
-
-      thead.appendChild(tr)
+define(["sorttable", "virtual-dom"], function (SortTable, V) {
+  function linkName(d) {
+    return d.source.node.nodeinfo.hostname + " – " + d.target.node.nodeinfo.hostname
+  }
 
-      table.appendChild(thead)
+  var headings = [{ name: "Knoten",
+                    sort: function (a, b) {
+                      return linkName(a).localeCompare(linkName(b))
+                    },
+                    reverse: false
+                  },
+                  { name: "TQ",
+                    sort: function (a, b) { return a.tq - b.tq},
+                    reverse: true
+                  },
+                  { name: "Entfernung",
+                    sort: function (a, b) {
+                      return (a.distance === undefined ? -1 : a.distance) -
+                             (b.distance === undefined ? -1 : b.distance)
+                    },
+                    reverse: true
+                  }]
 
-      var tbody = document.createElement("tbody")
+  return function(linkScale, router) {
+    var table = new SortTable(headings, 2, renderRow)
 
-      data.graph.links.forEach( function (d) {
-        var row = document.createElement("tr")
-        var td1 = document.createElement("td")
-        var a = document.createElement("a")
-        a.textContent = d.source.node.nodeinfo.hostname + " – " + d.target.node.nodeinfo.hostname
-        a.href = "#"
-        a.onclick = router.link(d)
-        td1.appendChild(a)
-        row.appendChild(td1)
+    function renderRow(d) {
+      var td1Content = [V.h("a", {href: "#", onclick: router.link(d)}, linkName(d))]
 
-        if (d.vpn)
-          td1.appendChild(document.createTextNode(" (VPN)"))
+      if (d.vpn)
+        td1Content.push(" (VPN)")
 
-        var td2 = document.createElement("td")
-        td2.textContent = showTq(d)
-        td2.style.color = linkScale(d.tq)
-        row.appendChild(td2)
+      var td1 = V.h("td", td1Content)
+      var td2 = V.h("td", {style: {color: linkScale(d.tq).hex()}}, showTq(d))
+      var td3 = V.h("td", showDistance(d))
 
-        var td3 = document.createElement("td")
-        td3.textContent = showDistance(d)
-        td3.setAttribute("data-sort", d.distance !== undefined ? -d.distance : 1)
-        row.appendChild(td3)
+      return V.h("tr", [td1, td2, td3])
+    }
 
-        tbody.appendChild(row)
-      })
+    this.render = function (d)  {
+      var el = document.createElement("div")
+      el.last = V.h("div")
+      d.appendChild(el)
 
-      table.appendChild(tbody)
+      var h2 = document.createElement("h2")
+      h2.textContent = "Verbindungen"
+      el.appendChild(h2)
 
-      new Tablesort(table)
+      el.appendChild(table.el)
+    }
 
-      el.appendChild(table)
+    this.setData = function (d) {
+      table.setData(d.graph.links)
     }
   }
 })

+ 12 - 2
lib/main.js

@@ -88,14 +88,24 @@ function (config, moment, Router, L, GUI, numeral) {
     var urls = [ config.dataPath + "nodes.json",
                  config.dataPath + "graph.json"
                ]
+    function update() {
+      return Promise.all(urls.map(getJSON))
+                    .then(handleData)
+    }
 
-    Promise.all(urls.map(getJSON))
-      .then(handleData)
+    update()
       .then(function (d) {
         var gui = new GUI(config, router)
         gui.setData(d)
         router.setData(d)
         router.start()
+
+        window.setInterval(function () {
+          update().then(function (d) {
+            gui.setData(d)
+            router.setData(d)
+          })
+        }, 60000)
       })
       .catch(function (e) {
         console.log(e)

+ 85 - 57
lib/map.js

@@ -57,6 +57,7 @@ define(["d3", "leaflet", "moment", "leaflet.label"], function (d3, L, moment) {
    return function (config, linkScale, sidebar, router) {
     var self = this
     var barycenter
+    var groupOnline, groupOffline, groupNew, groupLost, groupLines
 
     var el = document.createElement("div")
     el.classList.add("map")
@@ -73,6 +74,62 @@ define(["d3", "leaflet", "moment", "leaflet.label"], function (d3, L, moment) {
 
     var nodeDict = {}
     var linkDict = {}
+    var highlight
+
+    function resetMarkerStyles(nodes, links) {
+      Object.keys(nodes).forEach( function (d) {
+        nodes[d].resetStyle()
+      })
+
+      Object.keys(links).forEach( function (d) {
+        links[d].resetStyle()
+      })
+    }
+
+    function setView(bounds) {
+      map.fitBounds(bounds, {paddingTopLeft: [sidebar.getWidth(), 0]})
+    }
+
+    function resetZoom() {
+      setView(barycenter.getBounds())
+    }
+
+    function goto(m) {
+      var bounds
+
+      if ("getBounds" in m)
+        bounds = m.getBounds()
+      else
+        bounds = L.latLngBounds([m.getLatLng()])
+
+      setView(bounds)
+
+      return m
+    }
+
+    function updateView(nopanzoom) {
+      resetMarkerStyles(nodeDict, linkDict)
+      var m
+
+      if (highlight !== undefined)
+        if (highlight.type === "node") {
+          m = nodeDict[highlight.o.nodeinfo.node_id]
+
+          if (m)
+            m.setStyle({ fillColor: m.options.color, color: "orange", weight: 20, fillOpacity: 1, opacity: 0.7 })
+        } else if (highlight.type === "link") {
+          m = linkDict[highlight.o.id]
+
+          if (m)
+            m.setStyle({ weight: 7, opacity: 1, dashArray: "10, 10" })
+        }
+
+      if (!nopanzoom)
+        if (m)
+          goto(m)
+        else
+          resetZoom()
+    }
 
     function calcBarycenter(nodes) {
       nodes = nodes.map(function (d) { return d.nodeinfo.location })
@@ -94,8 +151,23 @@ define(["d3", "leaflet", "moment", "leaflet.label"], function (d3, L, moment) {
       nodeDict = {}
       linkDict = {}
 
+      if (groupOffline)
+        groupOffline.clearLayers()
+
+      if (groupOnline)
+        groupOnline.clearLayers()
+
+      if (groupNew)
+        groupNew.clearLayers()
+
+      if (groupLost)
+        groupLost.clearLayers()
+
+      if (groupLines)
+        groupLines.clearLayers()
+
       var lines = addLinksToMap(linkDict, linkScale, data.graph.links, router)
-      L.featureGroup(lines).addTo(map)
+      groupLines = L.featureGroup(lines).addTo(map)
 
       barycenter = calcBarycenter(data.nodes.all.filter(has_location))
 
@@ -119,71 +191,27 @@ define(["d3", "leaflet", "moment", "leaflet.label"], function (d3, L, moment) {
           return iconLost
         }, router))
 
-      L.featureGroup(markersOffline).addTo(map)
-      L.featureGroup(markersOnline).addTo(map)
-      L.featureGroup(markersNew).addTo(map)
-      L.featureGroup(markersLost).addTo(map)
-    }
+      groupOffline = L.featureGroup(markersOffline).addTo(map)
+      groupOnline = L.featureGroup(markersOnline).addTo(map)
+      groupNew = L.featureGroup(markersNew).addTo(map)
+      groupLost = L.featureGroup(markersLost).addTo(map)
 
-    function resetMarkerStyles(nodes, links) {
-      Object.keys(nodes).forEach( function (d) {
-        nodes[d].resetStyle()
-      })
-
-      Object.keys(links).forEach( function (d) {
-        links[d].resetStyle()
-      })
+      updateView(true)
     }
 
-    function setView(bounds) {
-      map.fitBounds(bounds, {paddingTopLeft: [sidebar.getWidth(), 0]})
+    self.resetView = function () {
+      highlight = undefined
+      updateView()
     }
 
-    function resetView() {
-      resetMarkerStyles(nodeDict, linkDict)
-
-      setView(barycenter.getBounds())
-    }
-
-    function goto(dict, id) {
-      var m = dict[id]
-      if (m === undefined)
-        return undefined
-
-      var bounds
-
-      if ("getBounds" in m)
-        bounds = m.getBounds()
-      else
-        bounds = L.latLngBounds([m.getLatLng()])
-
-      setView(bounds)
-
-      return m
-    }
-
-    self.resetView = resetView
-
     self.gotoNode = function (d) {
-      resetMarkerStyles(nodeDict, linkDict)
-
-      var m = goto(nodeDict, d.nodeinfo.node_id)
-
-      if (m)
-        m.setStyle({ fillColor: m.options.color, color: "orange", weight: 20, fillOpacity: 1, opacity: 0.7 })
-      else
-        resetView()
+      highlight = {type: "node", o: d}
+      updateView()
     }
 
     self.gotoLink = function (d) {
-      resetMarkerStyles(nodeDict, linkDict)
-
-      var m = goto(linkDict, d.id)
-
-      if (m)
-        m.setStyle({ weight: 7, opacity: 1, dashArray: "10, 10" })
-      else
-        resetView()
+      highlight = {type: "link", o: d}
+      updateView()
     }
 
     self.destroy = function () {

+ 11 - 7
lib/meshstats.js

@@ -1,7 +1,7 @@
 define(function () {
   return function () {
     var self = this
-    var p
+    var stats, timestamp
 
     self.setData = function (d) {
       var totalNodes = sum(d.nodes.all.filter(online).map(one))
@@ -12,12 +12,11 @@ define(function () {
         return d.flags.gateway
       }).map(one))
 
-      p.textContent = totalNodes + " Knoten (online), " +
-                      totalClients + " Clients, " +
-                      totalGateways + " Gateways"
+      stats.textContent = totalNodes + " Knoten (online), " +
+                          totalClients + " Clients, " +
+                          totalGateways + " Gateways"
 
-      p.appendChild(document.createElement("br"))
-      p.appendChild(document.createTextNode("Diese Daten sind von " + d.timestamp.format("LLLL") + "."))
+      timestamp.textContent = "Diese Daten sind von " + d.timestamp.format("LLLL") + "."
     }
 
     self.render = function (el) {
@@ -25,8 +24,13 @@ define(function () {
       h2.textContent = "Übersicht"
       el.appendChild(h2)
 
-      p = document.createElement("p")
+      var p = document.createElement("p")
       el.appendChild(p)
+      stats = document.createTextNode("")
+      p.appendChild(stats)
+      p.appendChild(document.createElement("br"))
+      timestamp = document.createTextNode("")
+      p.appendChild(timestamp)
     }
 
     return self

+ 64 - 81
lib/nodelist.js

@@ -1,99 +1,82 @@
-define(["tablesort", "tablesort.numeric"], function (Tablesort) {
-  return function(router) {
-    function showUptime(el, now, d) {
-      var uptime
-      if (d.flags.online && "uptime" in d.statistics)
-        uptime = Math.round(d.statistics.uptime / 3600)
-      else if (!d.flags.online && "lastseen" in d)
-        uptime = Math.round(-(now - d.lastseen) / 3600000)
-
-      var s = ""
-
-      if (uptime !== undefined)
-        if (Math.abs(uptime) >= 24)
-          s = Math.round(uptime / 24) + "d"
-        else
-          s = uptime + "h"
-
-      el.textContent = s
-      el.setAttribute("data-sort", uptime !== undefined ? -uptime : 0)
-    }
-
-    var self = this
-    var el
-
-    self.render = function (d) {
-      el = document.createElement("div")
-      d.appendChild(el)
-    }
+define(["sorttable", "virtual-dom", "numeral"], function (SortTable, V, numeral) {
+  function getUptime(now, d) {
+    if (d.flags.online && "uptime" in d.statistics)
+      return Math.round(d.statistics.uptime / 3600)
+    else if (!d.flags.online && "lastseen" in d)
+      return Math.round(-(now - d.lastseen) / 3600000)
+  }
 
-    self.setData = function (data) {
-      if (data.nodes.all.length === 0)
-        return
+  function showUptime(uptime) {
+    var s = ""
 
-      var h2 = document.createElement("h2")
-      h2.textContent = "Alle Knoten"
-      el.appendChild(h2)
+    if (uptime !== undefined)
+      if (Math.abs(uptime) >= 24)
+        s = Math.round(uptime / 24) + "d"
+      else
+        s = uptime + "h"
 
-      var table = document.createElement("table")
-      var thead = document.createElement("thead")
-
-      var tr = document.createElement("tr")
-      var th1 = document.createElement("th")
-      th1.textContent = "Knoten"
-      th1.classList.add("sort-default")
-      tr.appendChild(th1)
+    return s
+  }
 
-      var th2 = document.createElement("th")
-      th2.textContent = "Uptime"
-      tr.appendChild(th2)
+  var headings = [{ name: "Knoten",
+                    sort: function (a, b) {
+                      return a.nodeinfo.hostname.localeCompare(b.nodeinfo.hostname)
+                    },
+                    reverse: false
+                  },
+                  { name: "Uptime",
+                    sort: function (a, b) { return a.uptime - b.uptime},
+                    reverse: true
+                  },
+                  { name: "Clients",
+                    sort: function (a, b) {
+                      return ("clients" in a.statistics ? a.statistics.clients : -1) -
+                             ("clients" in b.statistics ? b.statistics.clients : -1)
+                    },
+                    reverse: true
+                  }]
 
-      var th3 = document.createElement("th")
-      th3.textContent = "Clients"
-      tr.appendChild(th3)
+  return function(router) {
+    function renderRow(d) {
+      var td1Content = []
+      var aClass = ["hostname", d.flags.online ? "online" : "offline"]
 
-      thead.appendChild(tr)
+      td1Content.push(V.h("a", { className: aClass.join(" "),
+                                 onclick: router.node(d),
+                                 href: "#"
+                               }, d.nodeinfo.hostname))
 
-      table.appendChild(thead)
+      if (has_location(d))
+        td1Content.push(V.h("span", {className: "icon ion-location"}))
 
-      var tbody = document.createElement("tbody")
+      var td1 = V.h("td", td1Content)
+      var td2 = V.h("td", showUptime(d.uptime))
+      var td3 = V.h("td", numeral("clients" in d.statistics ? d.statistics.clients : "").format("0,0"))
 
-      data.nodes.all.forEach( function (d) {
-        var row = document.createElement("tr")
+      return V.h("tr", [td1, td2, td3])
+    }
 
-        var td1 = document.createElement("td")
-        var a = document.createElement("a")
-        a.textContent = d.nodeinfo.hostname
-        a.href = "#"
-        a.onclick = router.node(d)
-        a.classList.add("hostname")
-        a.classList.add(d.flags.online ? "online" : "offline")
-        td1.appendChild(a)
-        row.appendChild(td1)
+    var table = new SortTable(headings, 0, renderRow)
 
-        if (has_location(d)) {
-          var span = document.createElement("span")
-          span.classList.add("icon")
-          span.classList.add("ion-location")
-          td1.appendChild(span)
-        }
+    this.render = function (d) {
+      var el = document.createElement("div")
+      d.appendChild(el)
 
-        var td2 = document.createElement("td")
-        showUptime(td2, data.now, d)
-        row.appendChild(td2)
+      var h2 = document.createElement("h2")
+      h2.textContent = "Alle Knoten"
+      el.appendChild(h2)
 
-        var td3 = document.createElement("td")
-        td3.textContent = "clients" in d.statistics ? d.statistics.clients : ""
-        row.appendChild(td3)
+      el.appendChild(table.el)
+    }
 
-        tbody.appendChild(row)
+    this.setData = function (d) {
+      var data = d.nodes.all.map(function (e) {
+        var n = Object.create(e)
+        n.uptime = getUptime(d.now, e)
+        return n
       })
 
-      table.appendChild(tbody)
-
-      new Tablesort(table)
-
-      el.appendChild(table)
-   }
+      table.setData(data)
+    }
   }
 })

+ 20 - 15
lib/proportions.js

@@ -1,4 +1,6 @@
-define(["chroma-js"], function (Chroma) {
+define(["chroma-js", "virtual-dom", "numeral-intl"],
+  function (Chroma, V, numeral) {
+
   return function () {
     var self = this
     var fwTable, hwTable, autoTable, gwTable
@@ -23,30 +25,33 @@ define(["chroma-js"], function (Chroma) {
     }
 
     function fillTable(table, data) {
+      if (!table.last)
+        table.last = V.h("table")
+
       var max = 0
       data.forEach(function (d) {
         if (d[1] > max)
           max = d[1]
       })
 
-      data.forEach(function (d) {
+      var items = data.map(function (d) {
         var v = d[1] / max
-        var row = document.createElement("tr")
-        var th = document.createElement("th")
-        var td = document.createElement("td")
-        var span = document.createElement("span")
-        th.textContent = d[0]
-        span.style.width = Math.round(v * 100) + "%"
-        span.style.backgroundColor = scale(v).hex()
         var c1 = Chroma.contrast(scale(v), "white")
         var c2 = Chroma.contrast(scale(v), "black")
-        span.style.color = c1 > c2 ? "white" : "black"
-        span.textContent = d[1]
-        td.appendChild(span)
-        row.appendChild(th)
-        row.appendChild(td)
-        table.appendChild(row)
+
+        var th = V.h("th", d[0])
+        var td = V.h("td", V.h("span", {style: {
+                                         width: Math.round(v * 100) + "%",
+                                         backgroundColor: scale(v).hex(),
+                                         color: c1 > c2 ? "white" : "black"
+                                       }}, numeral(d[1]).format("0,0")))
+
+        return V.h("tr", [th, td])
       })
+
+      var tableNew = V.h("table", items)
+      table = V.patch(table, V.diff(table.last, tableNew))
+      table.last = tableNew
     }
 
     self.setData = function (data) {

+ 46 - 44
lib/simplenodelist.js

@@ -1,7 +1,7 @@
-define(["moment"], function (moment) {
+define(["moment", "virtual-dom"], function (moment, V) {
   return function(config, nodes, field, router, title) {
     var self = this
-    var el
+    var el, tbody
 
     self.render = function (d) {
       el = document.createElement("div")
@@ -11,52 +11,54 @@ define(["moment"], function (moment) {
     self.setData = function (data) {
       var list = data.nodes[nodes]
 
-      if (list.length === 0)
+      if (list.length === 0) {
+        while (el.firstChild)
+              el.removeChild(el.firstChild)
+
+        tbody = null
+
         return
+      }
+
+      if (!tbody) {
+        var h2 = document.createElement("h2")
+        h2.textContent = title
+        el.appendChild(h2)
+
+        var table = document.createElement("table")
+        el.appendChild(table)
+
+        tbody = document.createElement("tbody")
+        tbody.last = V.h("tbody")
+        table.appendChild(tbody)
+      }
+
+      var items = list.map( function (d) {
+        var time = moment(d[field]).from(data.now)
+        var td1Content = []
+
+        var aClass = ["hostname", d.flags.online ? "online" : "offline"]
+
+        td1Content.push(V.h("a", { className: aClass.join(" "),
+                                   onclick: router.node(d),
+                                   href: "#"
+                                 }, d.nodeinfo.hostname))
+
+        if (has_location(d))
+          td1Content.push(V.h("span", {className: "icon ion-location"}))
+
+        if ("owner" in d.nodeinfo && config.showContact)
+          td1Content.push(" - " + d.nodeinfo.owner.contact)
+
+        var td1 = V.h("td", td1Content)
+        var td2 = V.h("td", time)
 
-      var h2 = document.createElement("h2")
-      h2.textContent = title
-      el.appendChild(h2)
-      var table = document.createElement("table")
-      el.appendChild(table)
-
-      var tbody = document.createElement("tbody")
-
-      list.forEach( function (d) {
-        var time = moment(d[field]).fromNow()
-
-        var row = document.createElement("tr")
-        var td1 = document.createElement("td")
-        var a = document.createElement("a")
-        a.classList.add("hostname")
-        a.classList.add(d.flags.online ? "online" : "offline")
-        a.textContent = d.nodeinfo.hostname
-        a.href = "#"
-        a.onclick = router.node(d)
-        td1.appendChild(a)
-
-        if (has_location(d)) {
-          var span = document.createElement("span")
-          span.classList.add("icon")
-          span.classList.add("ion-location")
-          td1.appendChild(span)
-        }
-
-        if ("owner" in d.nodeinfo && config.showContact) {
-          var contact = d.nodeinfo.owner.contact
-          td1.appendChild(document.createTextNode(" – " + contact + ""))
-        }
-
-        var td2 = document.createElement("td")
-        td2.textContent = time
-
-        row.appendChild(td1)
-        row.appendChild(td2)
-        tbody.appendChild(row)
+        return V.h("tr", [td1, td2])
       })
 
-      table.appendChild(tbody)
-      el.appendChild(table)
+      var tbodyNew = V.h("tbody", items)
+      tbody = V.patch(tbody, V.diff(tbody.last, tbodyNew))
+      tbody.last = tbodyNew
     }
 
     return self

+ 57 - 0
lib/sorttable.js

@@ -0,0 +1,57 @@
+define(["virtual-dom"], function (V) {
+  return function(headings, sortIndex, renderRow) {
+    var data
+    var sortReverse = false
+    var el = document.createElement("table")
+    var elLast = V.h("table")
+
+    function sortTable(i) {
+      sortReverse = i === sortIndex ? !sortReverse : false
+      sortIndex = i
+
+      updateView()
+    }
+
+    function sortTableHandler(i) {
+      return function () { sortTable(i) }
+    }
+
+    function updateView() {
+      var children = []
+
+      if (data.length !== 0) {
+        var th = headings.map(function (d, i) {
+          var properties = { onclick: sortTableHandler(i),
+                             className: "sort-header"
+                           }
+
+          if (sortIndex === i)
+            properties.className += sortReverse ? " sort-up" : " sort-down"
+
+          return V.h("th", properties, d.name)
+        })
+
+        var links = data.slice(0).sort(headings[sortIndex].sort)
+
+        if (headings[sortIndex].reverse ? !sortReverse : sortReverse)
+          links = links.reverse()
+
+        children.push(V.h("thead", V.h("tr", th)))
+        children.push(V.h("tbody", links.map(renderRow)))
+      }
+
+      var elNew = V.h("table", children)
+      el = V.patch(el, V.diff(elLast, elNew))
+      elLast = elNew
+    }
+
+    this.setData = function (d) {
+      data = d
+      updateView()
+    }
+
+    this.el = el
+
+    return this
+  }
+})

+ 2 - 1
tasks/linting.js

@@ -19,7 +19,8 @@ module.exports = function (grunt) {
           "strict": [2, "never"],
           "no-multi-spaces": 0,
           "no-new": 0,
-          "no-shadow": 0
+          "no-shadow": 0,
+          "no-use-before-define": [1, "nofunc"]
         }
       },
       sources: {