Browse Source

gluon-status-page: new status page

Nils Schneider 9 years ago
parent
commit
5e5dc5ab18

+ 32 - 2
package/gluon-status-page/Makefile

@@ -4,10 +4,33 @@ PKG_NAME:=gluon-status-page
 PKG_VERSION:=1
 PKG_RELEASE:=1
 
-PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME)
+PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
+PKG_BUILD_DEPENDS:=node/host
 
 include $(INCLUDE_DIR)/package.mk
 
+define Download/rjs
+	FILE:=r.js
+	URL:=http://requirejs.org/docs/release/2.1.10
+	MD5SUM:=270154b3f5d417c3a42f1e58d03e6607
+endef
+
+define Download/Bacon
+	FILE:=Bacon.js
+	URL:=http://cdnjs.cloudflare.com/ajax/libs/bacon.js/0.7.71
+	MD5SUM:=4600a60e1d7ffdb2259dfcce97c860ed
+endef
+
+define Download/almond
+	FILE:=almond.js
+	URL:=https://raw.githubusercontent.com/jrburke/almond/0.3.1
+	MD5SUM:=aa66c0c0cb55a4627bb706df73f3aff5
+endef
+
+$(eval $(call Download,rjs))
+$(eval $(call Download,Bacon))
+$(eval $(call Download,almond))
+
 define Package/gluon-status-page
   SECTION:=gluon
   CATEGORY:=Gluon
@@ -22,16 +45,23 @@ endef
 
 define Build/Prepare
 	mkdir -p $(PKG_BUILD_DIR)
+	$(CP) -t $(PKG_BUILD_DIR) $(DL_DIR)/r.js $(DL_DIR)/Bacon.js $(DL_DIR)/almond.js
 endef
 
 define Build/Configure
+	$(CP) ./src/* $(PKG_BUILD_DIR)/
 endef
 
 define Build/Compile
+	cd $(PKG_BUILD_DIR) && \
+		node r.js -o build.js && \
+		node r.js -o cssIn=css/main.css out=style.css && \
+		$(M4) index.html.m4 > index.html
 endef
 
 define Package/gluon-status-page/install
-	$(CP) ./files/* $(1)/
+	$(INSTALL_DIR) $(1)/lib/gluon/status-page/www/
+	$(INSTALL_DATA) $(PKG_BUILD_DIR)/index.html $(1)/lib/gluon/status-page/www/
 endef
 
 $(eval $(call BuildPackage,gluon-status-page))

+ 0 - 12
package/gluon-status-page/files/lib/gluon/status-page/www/index.html

@@ -1,12 +0,0 @@
-<html>
-  <head>
-    <meta http-equiv="refresh" content="0; URL=/cgi-bin/status">
-    <meta http-equiv="cache-control" content="no-cache">
-    <meta http-equiv="expires" content="0">
-    <meta http-equiv="expires" content="Tue, 01 Jan 1980 1:00:00 GMT">
-    <meta http-equiv="pragma" content="no-cache">
-  </head>
-  <body>
-    <a href="/cgi-bin/status">Redirecting...</a>
-  </body>
-</html>

+ 100 - 0
package/gluon-status-page/iconfont-config.json

@@ -0,0 +1,100 @@
+{
+  "name": "statuspage",
+  "css_prefix_text": "icon-",
+  "css_use_suffix": false,
+  "hinting": true,
+  "units_per_em": 1000,
+  "ascent": 850,
+  "glyphs": [
+    {
+      "uid": "12f4ece88e46abd864e40b35e05b11cd",
+      "css": "ok",
+      "code": 59397,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "5211af474d3a9848f67f945e2ccaf143",
+      "css": "cancel",
+      "code": 59399,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "e15f0d620a7897e2035c18c80142f6d9",
+      "css": "link-ext",
+      "code": 59407,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "c76b7947c957c9b78b11741173c8349b",
+      "css": "attention",
+      "code": 59403,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "559647a6f430b3aeadbecd67194451dd",
+      "css": "menu",
+      "code": 59392,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "2d6150442079cbda7df64522dc24f482",
+      "css": "down-dir",
+      "code": 59393,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "80cd1022bd9ea151d554bec1fa05f2de",
+      "css": "up-dir",
+      "code": 59394,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "9dc654095085167524602c9acc0c5570",
+      "css": "left-dir",
+      "code": 59395,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "fb1c799ffe5bf8fb7f8bcb647c8fe9e6",
+      "css": "right-dir",
+      "code": 59396,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "a73c5deb486c8d66249811642e5d719a",
+      "css": "arrows-cw",
+      "code": 59400,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "750058837a91edae64b03d60fc7e81a7",
+      "css": "ellipsis-vert",
+      "code": 59401,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "56a21935a5d4d79b2e91ec00f760b369",
+      "css": "sort",
+      "code": 59404,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "94103e1b3f1e8cf514178ec5912b4469",
+      "css": "sort-down",
+      "code": 59405,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "65b3ce930627cabfb6ac81ac60ec5ae4",
+      "css": "sort-up",
+      "code": 59406,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "cda0cdcfd38f5f1d9255e722dad42012",
+      "css": "spinner",
+      "code": 59402,
+      "src": "fontawesome"
+    }
+  ]
+}

+ 10 - 0
package/gluon-status-page/src/build.js

@@ -0,0 +1,10 @@
+({
+  paths: {
+    "bacon": "../Bacon"
+  },
+  baseUrl: "js/",
+  name: "../almond",
+  include: "main",
+  optimize: "uglify2",
+  out: "app.js",
+})

+ 85 - 0
package/gluon-status-page/src/css/animation.css

@@ -0,0 +1,85 @@
+/*
+   Animation example, for spinners
+*/
+.animate-spin {
+  -moz-animation: spin 2s infinite linear;
+  -o-animation: spin 2s infinite linear;
+  -webkit-animation: spin 2s infinite linear;
+  animation: spin 2s infinite linear;
+  display: inline-block;
+}
+@-moz-keyframes spin {
+  0% {
+    -moz-transform: rotate(0deg);
+    -o-transform: rotate(0deg);
+    -webkit-transform: rotate(0deg);
+    transform: rotate(0deg);
+  }
+
+  100% {
+    -moz-transform: rotate(359deg);
+    -o-transform: rotate(359deg);
+    -webkit-transform: rotate(359deg);
+    transform: rotate(359deg);
+  }
+}
+@-webkit-keyframes spin {
+  0% {
+    -moz-transform: rotate(0deg);
+    -o-transform: rotate(0deg);
+    -webkit-transform: rotate(0deg);
+    transform: rotate(0deg);
+  }
+
+  100% {
+    -moz-transform: rotate(359deg);
+    -o-transform: rotate(359deg);
+    -webkit-transform: rotate(359deg);
+    transform: rotate(359deg);
+  }
+}
+@-o-keyframes spin {
+  0% {
+    -moz-transform: rotate(0deg);
+    -o-transform: rotate(0deg);
+    -webkit-transform: rotate(0deg);
+    transform: rotate(0deg);
+  }
+
+  100% {
+    -moz-transform: rotate(359deg);
+    -o-transform: rotate(359deg);
+    -webkit-transform: rotate(359deg);
+    transform: rotate(359deg);
+  }
+}
+@-ms-keyframes spin {
+  0% {
+    -moz-transform: rotate(0deg);
+    -o-transform: rotate(0deg);
+    -webkit-transform: rotate(0deg);
+    transform: rotate(0deg);
+  }
+
+  100% {
+    -moz-transform: rotate(359deg);
+    -o-transform: rotate(359deg);
+    -webkit-transform: rotate(359deg);
+    transform: rotate(359deg);
+  }
+}
+@keyframes spin {
+  0% {
+    -moz-transform: rotate(0deg);
+    -o-transform: rotate(0deg);
+    -webkit-transform: rotate(0deg);
+    transform: rotate(0deg);
+  }
+
+  100% {
+    -moz-transform: rotate(359deg);
+    -o-transform: rotate(359deg);
+    -webkit-transform: rotate(359deg);
+    transform: rotate(359deg);
+  }
+}

File diff suppressed because it is too large
+ 2 - 0
package/gluon-status-page/src/css/font.css


+ 171 - 0
package/gluon-status-page/src/css/main.css

@@ -0,0 +1,171 @@
+@import "reset.css";
+@import "font.css";
+@import "menu.css";
+@import "animation.css";
+
+body {
+  background: rgba(0, 0, 0, 0.12);
+  font-family: Roboto, Lucida Grande, sans, Arial;
+  color: rgba(0, 0, 0, 0.87);
+  font-size: 14px;
+}
+
+
+a {
+  color: rgba(220, 0, 103, 0.87);
+  text-decoration: none;
+}
+
+a:hover {
+  text-decoration: underline;
+}
+
+header {
+  display: flex;
+  padding: 0 14px;
+  background: #dc0067;
+  color: rgba(255, 255, 255, 0.98);
+  position: absolute;
+  top: 0;
+  width: 100%;
+  box-sizing: border-box;
+  height: 20vh;
+  z-index: -1;
+  box-shadow: 0px 5px 6px rgba(0, 0, 0, 0.16), 0px 1.5px 3px rgba(0, 0, 0, 0.23);
+  white-space: nowrap;
+}
+
+header h1, header .icons {
+  font-size: 24px;
+  margin: 10px 0;
+  padding: 6px 0;
+}
+
+header h1 {
+  text-overflow: ellipsis;
+  overflow: hidden;
+  flex: 1;
+}
+
+header h1:hover {
+  text-decoration: underline;
+  cursor: pointer;
+}
+
+h1 {
+  font-weight: bold;
+}
+
+h2, h3 {
+  font-size: 16px;
+  color: rgba(0, 0, 0, 0.54);
+}
+
+h2 {
+  padding: 16px 16px;
+}
+
+h3 {
+  padding: 16px 16px 8px;
+}
+
+.container {
+  max-width: 90vw;
+  margin: 64px auto 24px auto;
+  background: rgb(253, 253, 253);
+  box-shadow: 0px 5px 20px rgba(0, 0, 0, 0.19), 0px 3px 6px rgba(0, 0, 0, 0.23);
+}
+
+.container .frame {
+  box-sizing: border-box;
+}
+
+.vertical-split {
+  display: flex;
+}
+
+.vertical-split > .frame {
+  flex: 1;
+  border-style: solid;
+  border-color: rgba(0, 0, 0, 0.12);
+}
+
+.vertical-split > .frame + .frame {
+  border-width: 0 0 0 1px;
+}
+
+dl, pre {
+  padding: 0 16px 16px;
+}
+
+table {
+  margin: 0 16px;
+}
+
+dt, th {
+  font-weight: bold;
+  color: rgba(0, 0, 0, 0.87);
+}
+
+dt {
+  margin-bottom: 4px;
+}
+
+th {
+  text-align: left;
+  padding: 4px 16px 4px 0;
+}
+
+dd, td {
+  font-weight: normal;
+  font-size: 0.9em;
+  color: rgba(0, 0, 0, 0.54);
+}
+
+dd {
+  padding-bottom: 16px;
+}
+
+table.datatable {
+  width: calc(100% - 32px);
+}
+
+table.datatable td {
+  font-size: 1em;
+  padding: 4px 0;
+}
+
+table.datatable tr.inactive {
+  opacity: 0.33;
+}
+
+table.datatable tr.highlight {
+  background: rgba(255, 180, 0, 0.25);
+}
+
+div.signalgraph {
+  margin: 16px;
+}
+
+@media only screen and (max-width: 1250px) {
+  .container {
+    max-width: none;
+    margin: 56px 0 0;
+  }
+
+  header {
+    height: 56px;
+    z-index: 1;
+    position: fixed;
+  }
+}
+
+@media only screen and (max-width: 700px) {
+  .vertical-split {
+    display: block;
+  }
+
+  .vertical-split > .frame + .frame {
+    border-width: 1px 0 0 0;
+  }
+}

+ 60 - 0
package/gluon-status-page/src/css/menu.css

@@ -0,0 +1,60 @@
+.noscroll {
+  overflow: hidden;
+}
+
+.menu-background {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 10;
+}
+
+.menu {
+  background: rgba(255, 255, 255, 1);
+  position: fixed;
+  z-index: 11;
+  padding: 8px 0;
+  box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.24);
+  overflow-y: auto;
+  max-height: 80vh;
+
+  transform-origin: top left;
+  -webkit-animation: new-menu-animation .08s ease-out forwards;
+  -moz-animation: new-menu-animation .08s ease-out forwards;
+}
+
+@-webkit-keyframes new-menu-animation {
+  from {
+    transform: scaleY(0);
+  }
+  to {
+    transform: scaleY(1);
+  }
+}
+
+@-moz-keyframes new-menu-animation {
+  from {
+    transform: scaleY(0);
+  }
+  to {
+    transform: scaleY(1);
+  }
+}
+
+.menu li {
+  cursor: pointer;
+  display: block;
+  font-size: 16px;
+  padding: 16px 32px 16px 16px;
+  color: rgba(0, 0, 0, 0.87);
+}
+
+.menu li:hover {
+  background: rgba(0, 0, 0, 0.07);
+}
+
+.menu li:active {
+  background: rgba(0, 0, 0, 0.07);
+}

+ 86 - 0
package/gluon-status-page/src/css/reset.css

@@ -0,0 +1,86 @@
+/*
+html5doctor.com Reset Stylesheet v1.6.1
+Last Updated: 2010-09-17
+Author: Richard Clark - http://richclarkdesign.com
+*/
+html, body, div, span, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+abbr, address, cite, code,
+del, dfn, em, img, ins, kbd, q, samp,
+small, strong, sub, sup, var,
+b, i,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section, summary,
+time, mark, audio, video {
+    margin:0;
+    padding:0;
+    border:0;
+    outline:0;
+    font-size:100%;
+    vertical-align:baseline;
+    background:transparent;
+}
+body {
+    line-height:1;
+}
+article,aside,details,figcaption,figure,
+footer,header,hgroup,menu,nav,section {
+    display:block;
+}
+nav ul {
+    list-style:none;
+}
+blockquote, q {
+    quotes:none;
+}
+blockquote:before, blockquote:after,
+q:before, q:after {
+    content:'';
+    content:none;
+}
+a {
+    margin:0;
+    padding:0;
+    font-size:100%;
+    vertical-align:baseline;
+    background:transparent;
+}
+/* change colours to suit your needs */
+ins {
+    background-color:#ff9;
+    color:#000;
+    text-decoration:none;
+}
+/* change colours to suit your needs */
+mark {
+    background-color:#ff9;
+    color:#000;
+    font-style:italic;
+    font-weight:bold;
+}
+del {
+    text-decoration: line-through;
+}
+abbr[title], dfn[title] {
+    border-bottom:1px dotted;
+    cursor:help;
+}
+table {
+    border-collapse:collapse;
+    border-spacing:0;
+}
+/* change border colour to suit your needs */
+hr {
+    display:block;
+    height:1px;
+    border:0;
+    border-top:1px solid #cccccc;
+    margin:1em 0;
+    padding:0;
+}
+input, select {
+    vertical-align:middle;
+}

+ 17 - 0
package/gluon-status-page/src/index.html.m4

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no">
+    <style>
+    undivert(style.css)
+    </style>
+    <script>
+      var bootstrapUrl = "/cgi-bin/nodeinfo";
+
+      undivert(app.js)
+    </script>
+  </head>
+  <body>
+  </body>
+</html>

+ 157 - 0
package/gluon-status-page/src/js/lib/gui.js

@@ -0,0 +1,157 @@
+"use strict"
+define([ "lib/gui/nodeinfo"
+       , "lib/gui/statistics"
+       , "lib/gui/neighbours"
+       , "lib/gui/menu"
+       , "lib/streams"
+       , "lib/neighbourstream"
+       ], function ( NodeInfo
+                   , Statistics
+                   , Neighbours
+                   , Menu
+                   , Streams
+                   , NeighbourStream
+                   ) {
+
+  function VerticalSplit(parent) {
+    var el = document.createElement("div")
+    el.className = "vertical-split"
+    parent.appendChild(el)
+
+    el.push = function (child) {
+      var header = document.createElement("h2")
+      header.appendChild(child.title)
+
+      var div = document.createElement("div")
+      div.className = "frame"
+      div.node = child
+      div.appendChild(header)
+
+      el.appendChild(div)
+
+      child.render(div)
+
+      return function () {
+        div.node.destroy()
+        el.removeChild(div)
+      }
+    }
+
+    el.clear = function () {
+      while (el.firstChild) {
+        el.firstChild.node.destroy()
+        el.removeChild(el.firstChild)
+      }
+    }
+
+    return el
+  }
+
+  var h1
+
+  return function (mgmtBus, nodesBus) {
+    function setTitle(node, state) {
+      var title = node ? node.hostname : "(not connected)"
+
+      document.title = title
+      h1.textContent = title
+
+      var icon = document.createElement("i")
+      icon.className = "icon-down-dir"
+
+      h1.appendChild(icon)
+
+      switch (state) {
+        case "connect":
+          stateIcon.className = "icon-arrows-cw animate-spin"
+          break
+        case "fail":
+          stateIcon.className = "icon-attention"
+          break
+        default:
+          stateIcon.className = ""
+          break
+      }
+    }
+
+    var nodes = []
+
+    function nodeMenu() {
+      var myNodes = nodes.slice()
+
+      myNodes.sort(function (a, b) {
+        a = a.hostname
+        b = b.hostname
+        return (a < b) ? -1 : (a > b)
+      })
+
+      var menu = myNodes.map(function (d) {
+        return [d.hostname, function () {
+          mgmtBus.pushEvent("goto", d)
+        }]
+      })
+
+      new Menu(menu).apply(this)
+    }
+
+    var header = document.createElement("header")
+    h1 = document.createElement("h1")
+    header.appendChild(h1)
+
+    h1.onclick = nodeMenu
+
+    var icons = document.createElement("p")
+    icons.className = "icons"
+    header.appendChild(icons)
+
+    var stateIcon = document.createElement("i")
+    icons.appendChild(stateIcon)
+
+    document.body.appendChild(header)
+
+    var container = document.createElement("div")
+    container.className = "container"
+
+    document.body.appendChild(container)
+
+    setTitle()
+
+    var content = new VerticalSplit(container)
+
+    function nodeChanged(nodeInfo) {
+      setTitle(nodeInfo, "connect")
+
+      content.clear()
+      content.push(new NodeInfo(nodeInfo))
+    }
+
+    function nodeNotArrived(nodeInfo) {
+      setTitle(nodeInfo, "fail")
+    }
+
+    function nodeArrived(nodeInfo, ip) {
+      setTitle(nodeInfo)
+
+      var neighbourStream = new NeighbourStream(mgmtBus, nodesBus, ip)
+      var statisticsStream = new Streams.Statistics(ip)
+
+      content.push(new Statistics(statisticsStream))
+      content.push(new Neighbours(nodeInfo, neighbourStream, mgmtBus))
+    }
+
+    function newNodes(d) {
+      nodes = []
+      for (var nodeId in d)
+        nodes.push(d[nodeId])
+    }
+
+    mgmtBus.onEvent({ "goto": nodeChanged
+                    , "arrived": nodeArrived
+                    , "gotoFailed": nodeNotArrived
+                    })
+
+    nodesBus.map(".nodes").onValue(newNodes)
+
+    return this
+  }
+})

+ 39 - 0
package/gluon-status-page/src/js/lib/gui/menu.js

@@ -0,0 +1,39 @@
+"use strict"
+define(function () {
+  return function (menu) {
+    return function () {
+      var background = document.createElement("div")
+      background.className = "menu-background"
+      document.body.appendChild(background)
+      document.body.classList.add("noscroll")
+
+      var offset = this.getBoundingClientRect()
+      var container = document.createElement("ul")
+      container.className = "menu"
+      container.style.top = offset.top + "px"
+      container.style.left = offset.left + "px"
+
+      background.onclick = destroy
+
+      menu.forEach(function (item) {
+        var li = document.createElement("li")
+        li.textContent = item[0]
+        li.action = item[1]
+        li.onclick = function () {
+          destroy()
+          this.action()
+        }
+
+        container.appendChild(li)
+      })
+
+      document.body.appendChild(container)
+
+      function destroy() {
+        document.body.classList.remove("noscroll")
+        document.body.removeChild(background)
+        document.body.removeChild(container)
+      }
+    }
+  }
+})

+ 275 - 0
package/gluon-status-page/src/js/lib/gui/neighbours.js

@@ -0,0 +1,275 @@
+"use strict"
+define([ "lib/helper", "lib/gui/signalgraph", "lib/gui/signal"],
+function (Helper, SignalGraph, Signal) {
+
+  var graphColors = ["#396AB1", "#DA7C30", "#3E9651", "#CC2529", "#535154", "#6B4C9A", "#922428", "#948B3D"]
+  //graphColors = ["#7293CB", "#E1974C", "#84BA5B", "#D35E60", "#808585", "#9067A7", "#AB6857", "#CCC210"];
+
+  var inactiveTime = 200
+
+  function SignalEntry(graph, color, stream) {
+    var signal = new Signal(color)
+    var remove = graph.add(signal)
+
+    var unsubscribe = stream.onValue(update)
+
+    this.destroy = function () {
+      unsubscribe()
+      remove()
+    }
+
+    this.getSignal = function () {
+      return signal
+    }
+
+    return this
+
+    function update(d) {
+      if ("wifi" in d)
+        signal.set(d.wifi.inactive > inactiveTime ? null : d.wifi.signal)
+    }
+  }
+
+  function TableEntry(parent, nodeInfo, color, stream, mgmtBus, signal) {
+    var el = document.createElement("tr")
+    parent.appendChild(el)
+
+    var tdHostname = document.createElement("td")
+    var tdTQ = document.createElement("td")
+    var tdSignal = document.createElement("td")
+    var tdDistance = document.createElement("td")
+    var tdInactive = document.createElement("td")
+
+    el.appendChild(tdHostname)
+    el.appendChild(tdTQ)
+    el.appendChild(tdSignal)
+    el.appendChild(tdDistance)
+    el.appendChild(tdInactive)
+
+    var marker = document.createElement("span")
+    marker.textContent = "⬤ "
+    marker.style.color = color
+    tdHostname.appendChild(marker)
+
+    var hostname = document.createElement("span")
+    tdHostname.appendChild(hostname)
+
+    var infoSet = false
+    var unsubscribe = stream.onValue(update)
+
+    el.onmouseenter = function () {
+      el.classList.add("highlight")
+      signal.setHighlight(true)
+    }
+
+    el.onmouseleave = function () {
+      el.classList.remove("highlight")
+      signal.setHighlight(false)
+    }
+
+    el.destroy = function () {
+      unsubscribe()
+      parent.removeChild(el)
+    }
+
+    return el
+
+    function update(d) {
+      if ("wifi" in d) {
+        var signal = d.wifi.signal
+        var inactive = d.wifi.inactive
+
+        el.classList.toggle("inactive", inactive > inactiveTime)
+
+        tdSignal.textContent = signal
+        tdInactive.textContent = Math.round(inactive / 1000) + " s"
+      }
+
+      if ("batadv" in d)
+        tdTQ.textContent = Math.round(d.batadv.tq / 2.55) + " %"
+      else
+        tdTQ.textContent = "‒"
+
+      if (infoSet)
+        return
+
+      if ("nodeInfo" in d) {
+          infoSet = true
+
+          var link = document.createElement("a")
+          link.textContent = d.nodeInfo.hostname
+          link.href = "#"
+          link.nodeInfo = d.nodeInfo
+          link.onclick = function () {
+            mgmtBus.pushEvent("goto", this.nodeInfo)
+            return false
+          }
+
+          while (hostname.firstChild)
+            hostname.removeChild(hostname.firstChild)
+
+          hostname.appendChild(link)
+
+          try {
+            var distance = Helper.haversine(nodeInfo.location.latitude, nodeInfo.location.longitude,
+                                            d.nodeInfo.location.latitude, d.nodeInfo.location.longitude)
+
+            tdDistance.textContent = Math.round(distance * 1000) + " m"
+          } catch (e) {
+            tdDistance.textContent = "‒"
+          }
+      } else
+        hostname.textContent = d.id
+    }
+  }
+
+  function Interface(parent, nodeInfo, iface, stream, mgmtBus) {
+    var colors = graphColors.slice(0)
+
+    var el = document.createElement("div")
+    el.ifname = iface
+    parent.appendChild(el)
+
+    var h = document.createElement("h3")
+    h.textContent = iface
+    el.appendChild(h)
+
+    var table = document.createElement("table")
+    var tr = document.createElement("tr")
+    table.appendChild(tr)
+    table.classList.add("datatable")
+
+    var th = document.createElement("th")
+    th.textContent = "Knoten"
+    tr.appendChild(th)
+
+    th = document.createElement("th")
+    th.textContent = "TQ"
+    tr.appendChild(th)
+
+    th = document.createElement("th")
+    th.textContent = "dBm"
+    tr.appendChild(th)
+
+    th = document.createElement("th")
+    th.textContent = "Entfernung"
+    tr.appendChild(th)
+
+    th = document.createElement("th")
+    th.textContent = "Inaktiv"
+    tr.appendChild(th)
+
+    el.appendChild(table)
+
+    var wrapper = document.createElement("div")
+    wrapper.className = "signalgraph"
+    el.appendChild(wrapper)
+
+    var canvas = document.createElement("canvas")
+    canvas.className = "signal-history"
+    canvas.height = 200
+    wrapper.appendChild(canvas)
+
+    var graph = new SignalGraph(canvas, -100, 0, true)
+
+    var stopStream = stream.skipDuplicates(sameKeys).onValue(update)
+
+    var managedNeighbours = {}
+
+    function update(d) {
+      var notUpdated = new Set()
+      var id
+
+      for (id in managedNeighbours)
+        notUpdated.add(id)
+
+      for (id in d) {
+        if (!(id in managedNeighbours)) {
+          var neighbourStream = stream.map("."  + id).filter( function (d) { return d !== undefined })
+          var color = colors.shift()
+          var signal = new SignalEntry(graph, color, neighbourStream)
+          managedNeighbours[id] = { views: [ signal,
+                                             new TableEntry(table, nodeInfo, color, neighbourStream, mgmtBus, signal.getSignal())
+                                           ],
+                                    color: color
+                                  }
+        }
+
+        notUpdated.delete(id)
+      }
+
+      for (id in notUpdated) {
+        managedNeighbours[id].views.forEach( function (d) { d.destroy() })
+        colors.push(managedNeighbours[id].color)
+        delete managedNeighbours[id]
+      }
+    }
+
+
+    el.destroy = function () {
+      stopStream()
+
+      for (var id in managedNeighbours)
+        managedNeighbours[id].views.forEach( function (d) { d.destroy() })
+
+      el.removeChild(h)
+      el.removeChild(wrapper)
+      el.removeChild(table)
+    }
+  }
+
+  function sameKeys(a, b) {
+    a = Object.keys(a).sort()
+    b = Object.keys(b).sort()
+
+    return !(a < b || a > b)
+  }
+
+  return function (nodeInfo, stream, mgmtBus) {
+    var stopStream, div
+
+    function render(el) {
+      div = document.createElement("div")
+      el.appendChild(div)
+
+      stopStream = stream.skipDuplicates(sameKeys).onValue(update)
+
+      function update(d) {
+        var have = {}
+        var remove = []
+        if (div.hasChildNodes()) {
+          var children = div.childNodes
+          for (var i = 0; i < children.length; i++) {
+            var a = children[i]
+            if (a.ifname in d)
+              have[a.ifname] = true
+            else {
+              a.destroy()
+              remove.push(a)
+            }
+          }
+        }
+
+        remove.forEach(function (d) { div.removeChild(d) })
+
+        for (var k in d)
+          if (!(k in have))
+            new Interface(div, nodeInfo, k, stream.map("." + k), mgmtBus)
+      }
+    }
+
+    function destroy() {
+      stopStream()
+
+      while (div.firstChild) {
+        div.firstChild.destroy()
+        div.removeChild(div.firstChild)
+      }
+    }
+
+    return { title: document.createTextNode("Nachbarknoten")
+           , render: render
+           , destroy: destroy
+           }
+  }
+})

+ 54 - 0
package/gluon-status-page/src/js/lib/gui/nodeinfo.js

@@ -0,0 +1,54 @@
+"use strict"
+define(["lib/helper"], function (Helper) {
+  return function (nodeInfo) {
+    var el = document.createElement("div")
+
+    update(nodeInfo)
+
+    function dlEntry(dl, dict, key, prettyName) {
+      var v = Helper.dictGet(dict, key.split("."))
+
+      if (v === null)
+        return
+
+      var dt = document.createElement("dt")
+      var dd = document.createElement("dd")
+
+      dt.textContent = prettyName
+      if (v instanceof Array) {
+        var tn = v.map(function (d) { return document.createTextNode(d) })
+        tn.forEach(function (node) {
+          if (dd.hasChildNodes())
+            dd.appendChild(document.createElement("br"))
+
+          dd.appendChild(node)
+        })
+      } else
+        dd.textContent = v
+
+      dl.appendChild(dt)
+      dl.appendChild(dd)
+    }
+
+    function update(nodeInfo) {
+      var list = document.createElement("dl")
+
+      dlEntry(list, nodeInfo, "hostname", "Knotenname")
+      dlEntry(list, nodeInfo, "owner.contact", "Kontakt")
+      dlEntry(list, nodeInfo, "hardware.model", "Modell")
+      dlEntry(list, nodeInfo, "network.mac", "Primäre MAC")
+      dlEntry(list, nodeInfo, "network.addresses", "IP-Adresse")
+      dlEntry(list, nodeInfo, "software.firmware.release", "Firmware")
+      dlEntry(list, nodeInfo, "software.fastd.enabled", "Mesh-VPN")
+      dlEntry(list, nodeInfo, "software.autoupdater.enabled", "Automatische Updates")
+      dlEntry(list, nodeInfo, "software.autoupdater.branch", "Branch")
+
+      el.appendChild(list)
+    }
+
+    return { title: document.createTextNode("Übersicht")
+           , render: function (d) { d.appendChild(el) }
+           , destroy: function () {}
+           }
+  }
+})

+ 48 - 0
package/gluon-status-page/src/js/lib/gui/signal.js

@@ -0,0 +1,48 @@
+"use strict"
+define(function () {
+  return function (color) {
+    var canvas = document.createElement("canvas")
+    var ctx = canvas.getContext("2d")
+    var v = null
+    var radius = 1.2
+    var highlight = false
+
+    function drawPixel(x, y) {
+      ctx.beginPath()
+      ctx.fillStyle = color
+      ctx.arc(x, y, radius, 0, Math.PI * 2, false)
+      ctx.closePath()
+      ctx.fill()
+    }
+
+    this.resize = function (w, h) {
+      canvas.width = w
+      canvas.height = h
+    }
+
+    this.draw = function (x, scale) {
+      var y = scale(v)
+
+      ctx.clearRect(x, 0, 5, canvas.height)
+
+      if (y)
+        drawPixel(x, y)
+    }
+
+    this.canvas = canvas
+
+    this.set = function (d) {
+      v = d
+    }
+
+    this.setHighlight = function (d) {
+      highlight = d
+    }
+
+    this.getHighlight = function () {
+      return highlight
+    }
+
+    return this
+  }
+})

+ 137 - 0
package/gluon-status-page/src/js/lib/gui/signalgraph.js

@@ -0,0 +1,137 @@
+"use strict"
+define(function () {
+  return function (canvas, min, max) {
+    var i = 0
+    var graphWidth
+    var last = 0
+
+    var signals = []
+
+    var ctx = canvas.getContext("2d")
+
+    resize()
+
+    window.addEventListener("resize", resize, false)
+    window.requestAnimationFrame(step)
+
+    function step(timestamp) {
+      var delta = timestamp - last
+
+      if (delta > 40) {
+        draw()
+        last = timestamp
+      }
+
+      window.requestAnimationFrame(step)
+    }
+
+    function drawGrid() {
+      var gridctx = ctx
+      var nLines = Math.floor(canvas.height / 40)
+      gridctx.save()
+      gridctx.lineWidth = 0.5
+      gridctx.strokeStyle = "rgba(0, 0, 0, 0.25)"
+      gridctx.fillStyle = "rgba(0, 0, 0, 0.5)"
+      gridctx.textAlign = "end"
+      gridctx.textBaseline = "bottom"
+
+      gridctx.beginPath()
+
+      for (var i = 0; i < nLines; i++) {
+        var y = canvas.height - i * 40
+        gridctx.moveTo(0, y - 0.5)
+        gridctx.lineTo(canvas.width, y - 0.5)
+        var dBm = Math.round(scaleInverse(y, min, max, canvas.height)) + " dBm"
+
+        gridctx.save()
+        gridctx.strokeStyle = "rgba(255, 255, 255, 0.9)"
+        gridctx.lineWidth = 4
+        gridctx.miterLimit = 2
+        gridctx.strokeText(dBm, canvas.width - 5, y - 2.5)
+        gridctx.fillText(dBm, canvas.width - 5, y - 2.5)
+        gridctx.restore()
+      }
+
+      gridctx.stroke()
+
+      gridctx.strokeStyle = "rgba(0, 0, 0, 0.83)"
+      gridctx.lineWidth = 1.5
+      gridctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1)
+
+      gridctx.restore()
+    }
+
+    function draw() {
+      var anyHighlight = signals.some( function (d) { return d.getHighlight() })
+
+      signals.forEach( function (d) {
+        d.draw(i, function (v) {
+          return scale(v, min, max, canvas.height)
+        })
+      })
+
+      ctx.clearRect(0, 0, canvas.width, canvas.height)
+
+      ctx.save()
+
+      signals.forEach( function (d) {
+        if (anyHighlight)
+          ctx.globalAlpha = 0.1
+
+        if (d.getHighlight())
+          ctx.globalAlpha = 1
+
+        ctx.drawImage(d.canvas, 0, 0)
+      })
+
+      ctx.restore()
+
+      ctx.save()
+      ctx.beginPath()
+      ctx.strokeStyle = "rgba(255, 180, 0, 0.15)"
+      ctx.lineWidth = 5
+      ctx.moveTo(i + 2.5, 0)
+      ctx.lineTo(i + 2.5, canvas.height)
+      ctx.stroke()
+
+      drawGrid()
+
+      i = (i + 1) % graphWidth
+    }
+
+    function scaleInverse(n, min, max, height) {
+      return (min * n + max * height - max * n) / height
+    }
+
+    function scale(n, min, max, height) {
+      return (1 - (n - min) / (max - min)) * height
+    }
+
+    function resize() {
+      var newWidth = canvas.parentNode.clientWidth
+
+      if (newWidth === 0)
+        return
+
+      var lastImage = ctx.getImageData(0, 0, newWidth, canvas.height)
+      canvas.width = newWidth
+      graphWidth = canvas.width
+      ctx.putImageData(lastImage, 0, 0)
+
+      signals.forEach( function (d) {
+        d.resize(canvas.width, canvas.height)
+      })
+    }
+
+    this.add = function (d) {
+      signals.push(d)
+      d.resize(canvas.width, canvas.height)
+
+      return function () {
+        signals = signals.filter( function (e) { return e !== d } )
+      }
+    }
+
+    return this
+  }
+})

+ 272 - 0
package/gluon-status-page/src/js/lib/gui/statistics.js

@@ -0,0 +1,272 @@
+"use strict"
+define(["lib/helper"], function (Helper) {
+  function streamElement(type, stream) {
+    var el = document.createElement(type)
+    el.destroy = stream.onValue(update)
+
+    function update(d) {
+      el.textContent = d
+    }
+
+    return el
+  }
+
+  function streamNode(stream) {
+    var el = document.createTextNode("")
+    el.destroy = stream.onValue(update)
+
+    function update(d) {
+      el.textContent = d
+    }
+
+    return el
+  }
+
+  function mkRow(table, label, stream) {
+    var tr = document.createElement("tr")
+    var th = document.createElement("th")
+    var td = streamElement("td", stream)
+    th.textContent = label
+    tr.appendChild(th)
+    tr.appendChild(td)
+    table.appendChild(tr)
+
+    tr.destroy = function () {
+      td.destroy()
+      table.removeChild(tr)
+    }
+
+    return tr
+  }
+
+  function mkTrafficRow(table, children, label, stream, selector) {
+    var tr = document.createElement("tr")
+    var th = document.createElement("th")
+    var td = document.createElement("td")
+    th.textContent = label
+
+    var traffic = stream.slidingWindow(2, 2)
+    var pkts = streamNode(traffic.map(deltaUptime(selector + ".packets")).map(prettyPackets))
+    var bw = streamNode(traffic.map(deltaUptime(selector + ".bytes")).map(prettyBits))
+    var bytes = streamNode(stream.map(selector).map(".bytes").map(prettyBytes))
+
+    td.appendChild(pkts)
+    td.appendChild(document.createElement("br"))
+    td.appendChild(bw)
+    td.appendChild(document.createElement("br"))
+    td.appendChild(bytes)
+
+    tr.appendChild(th)
+    tr.appendChild(td)
+    table.appendChild(tr)
+
+    children.push(pkts)
+    children.push(bw)
+    children.push(bytes)
+  }
+
+  function mkMeshVPN(el, stream) {
+    var children = {}
+    var init = false
+    var h = document.createElement("h3")
+    h.textContent = "Mesh-VPN"
+
+    var table = document.createElement("table")
+
+    var unsubscribe = stream.onValue( function (d) {
+      function addPeer(peer, path) {
+        return { peer: peer, path: path }
+      }
+
+      function addPeers(d, path) {
+        if (!("peers" in d))
+          return []
+
+        var peers = []
+
+        for (var peer in d.peers)
+          peers.push(addPeer(peer, path + ".peers." + peer))
+
+        return peers
+      }
+
+      function addGroup(d, path) {
+        var peers = []
+
+        peers = peers.concat(addPeers(d, path))
+
+        if ("groups" in d)
+          for (var group in d.groups)
+            peers = peers.concat(addGroup(d.groups[group], path + ".groups." + group))
+
+        return peers
+      }
+
+      if (d === undefined)
+        clear()
+
+      else {
+        if (!init) {
+          init = true
+          el.appendChild(h)
+          el.appendChild(table)
+        }
+
+        var peers = addGroup(d, "")
+        var paths = new Set(peers.map(function (d) { return d.path } ))
+
+        for (var path in children)
+          if (!paths.has(path)) {
+            children[path].destroy()
+            delete children[path]
+          }
+
+        peers.forEach( function (peer) {
+          if (!(peer.path in children))
+            children[peer.path] = mkRow(table, peer.peer,
+                                        stream.startWith(d)
+                                        .map(peer.path)
+                                        .filter(function (d) { return d !== undefined })
+                                        .map(prettyPeer))
+        })
+      }
+    })
+
+    function clear() {
+      if (init) {
+        init = false
+        el.removeChild(h)
+        el.removeChild(table)
+      }
+
+      for (var peer in children)
+        children[peer].destroy()
+
+      children = {}
+    }
+
+    function destroy() {
+      unsubscribe()
+      clear()
+    }
+
+    return { destroy: destroy }
+  }
+
+  function deltaUptime(selector) {
+    return function (d) {
+      var deltaTime = d[1].uptime - d[0].uptime
+      var d0 = Helper.dictGet(d[0], selector.split(".").splice(1))
+      var d1 = Helper.dictGet(d[1], selector.split(".").splice(1))
+
+      return (d1 - d0) / deltaTime
+    }
+  }
+
+  function prettyPeer(d) {
+    if (d === null)
+      return "nicht verbunden"
+    else
+      return "verbunden (" + prettyUptime(d.established) + ")"
+  }
+
+  function prettyPackets(d) {
+    var v = new Intl.NumberFormat("de-DE", {maximumFractionDigits: 0}).format(d)
+    return v + " Pakete/s"
+  }
+
+  function prettyPrefix(prefixes, step, d) {
+    var prefix = 0
+
+    while (d > step && prefix < 4) {
+      d /= step
+      prefix++
+    }
+
+    d = new Intl.NumberFormat("de-DE", {maximumSignificantDigits: 3}).format(d)
+    return d + " " + prefixes[prefix]
+  }
+
+  function prettyBits(d) {
+    return prettyPrefix([ "bps", "kbps", "Mbps", "Gbps" ], 1024, d * 8)
+  }
+
+  function prettyBytes(d) {
+    return prettyPrefix([ "B", "kB", "MB", "GB" ], 1024, d)
+  }
+
+  function prettyUptime(seconds) {
+    var minutes = Math.round(seconds / 60)
+
+    var days = Math.floor(minutes / 1440)
+    var hours = Math.floor((minutes % 1440) / 60)
+    minutes = Math.floor(minutes % 60)
+
+    var out = ""
+
+    if (days === 1)
+      out += "1 Tag, "
+    else if (days > 1)
+      out += days + " Tage, "
+
+    out += hours + ":"
+
+    if (minutes < 10)
+      out += "0"
+
+    out += minutes
+
+    return out
+  }
+
+  function prettyNVRAM(usage) {
+    return new Intl.NumberFormat("de-DE", {maximumSignificantDigits: 3}).format(usage * 100) + "% belegt"
+  }
+
+  function prettyLoad(load) {
+    return new Intl.NumberFormat("de-DE", {maximumSignificantDigits: 3}).format(load)
+  }
+
+  function prettyRAM(memory) {
+    var usage = 1 - (memory.free + memory.buffers + memory.cached) / memory.total
+    return prettyNVRAM(usage)
+  }
+
+  return function (stream) {
+    var children = []
+    var el = document.createElement("div")
+    var table = document.createElement("table")
+
+    children.push(mkRow(table, "Laufzeit", stream.map(".uptime").map(prettyUptime)))
+    children.push(mkRow(table, "Systemlast", stream.map(".loadavg").map(prettyLoad)))
+    children.push(mkRow(table, "RAM", stream.map(".memory").map(prettyRAM)))
+    children.push(mkRow(table, "NVRAM", stream.map(".rootfs_usage").map(prettyNVRAM)))
+    children.push(mkRow(table, "Gateway", stream.map(".gateway")))
+    children.push(mkRow(table, "Clients", stream.map(".clients.total")))
+
+    el.appendChild(table)
+
+    var h = document.createElement("h3")
+    h.textContent = "Traffic"
+    el.appendChild(h)
+
+    table = document.createElement("table")
+
+    mkTrafficRow(table, children, "Gesendet", stream, ".traffic.rx")
+    mkTrafficRow(table, children, "Empfangen", stream, ".traffic.tx")
+    mkTrafficRow(table, children, "Weitergeleitet", stream, ".traffic.forward")
+
+    el.appendChild(table)
+
+    children.push(mkMeshVPN(el, stream.map(".mesh_vpn")))
+
+    function destroy() {
+      children.forEach(function (d) {d.destroy()})
+    }
+
+    return { title: document.createTextNode("Statistik")
+           , render: function (d) { d.appendChild(el) }
+           , destroy: destroy
+           }
+  }
+})

+ 71 - 0
package/gluon-status-page/src/js/lib/helper.js

@@ -0,0 +1,71 @@
+"use strict"
+define([ "bacon" ], function (Bacon) {
+  function get(url) {
+    return Bacon.fromBinder(function(sink) {
+      var req = new XMLHttpRequest()
+      req.open("GET", url)
+
+      req.onload = function() {
+        if (req.status === 200)
+          sink(new Bacon.Next(req.response))
+        else
+          sink(new Bacon.Error(req.statusText))
+        sink(new Bacon.End())
+      }
+
+      req.onerror = function() {
+        sink(new Bacon.Error("network error"))
+        sink(new Bacon.End())
+      }
+
+      req.send()
+
+      return function () {}
+    })
+  }
+
+  function getJSON(url) {
+    return get(url).map(JSON.parse)
+  }
+
+  function buildUrl(ip, object, param) {
+    var url = "http://[" + ip + "]/cgi-bin/" + object
+    if (param) url += "?" + param
+
+    return url
+  }
+
+  function request(ip, object, param) {
+    return getJSON(buildUrl(ip, object, param))
+  }
+
+  function dictGet(dict, key) {
+    var k = key.shift()
+
+    if (!(k in dict))
+      return null
+
+    if (key.length === 0)
+      return dict[k]
+
+    return dictGet(dict[k], key)
+  }
+
+  function haversine() {
+    var radians = Array.prototype.map.call(arguments, function(deg) { return deg / 180.0 * Math.PI })
+    var lat1 = radians[0], lon1 = radians[1], lat2 = radians[2], lon2 = radians[3]
+    var R = 6372.8 // km
+    var dLat = lat2 - lat1
+    var dLon = lon2 - lon1
+    var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2)
+    var c = 2 * Math.asin(Math.sqrt(a))
+    return R * c
+  }
+
+  return { buildUrl: buildUrl
+         , request: request
+         , getJSON: getJSON
+         , dictGet: dictGet
+         , haversine: haversine
+         }
+})

+ 132 - 0
package/gluon-status-page/src/js/lib/neighbourstream.js

@@ -0,0 +1,132 @@
+"use strict"
+define([ "bacon"
+       , "lib/helper"
+       , "lib/streams"
+       ], function(Bacon, Helper, Streams) {
+
+  return function (mgmtBus, nodesBus, ip) {
+    function nodeQuerier() {
+      var asked = {}
+      var timeout = 6000
+
+      return function (ifname) {
+        var now = new Date().getTime()
+
+        if (ifname in asked && now - asked[ifname] < timeout)
+          return Bacon.never()
+
+        asked[ifname] = now
+        return Streams.nodeInfo(ip, ifname).map(function (d) {
+          return { "ifname": ifname
+                 , "nodeInfo": d
+                 }
+        })
+      }
+    }
+
+    var querierAsk = new Bacon.Bus()
+    var querier = querierAsk.flatMap(nodeQuerier())
+    querier.map(".nodeInfo").onValue(mgmtBus, "pushEvent", "nodeinfo")
+
+    function wrapIfname(ifname, d) {
+      return [ifname, d]
+    }
+
+    function extractIfname(d) {
+      var r = {}
+
+      for (var station in d) {
+        var ifname = d[station].ifname
+        delete d[station].ifname
+
+        if (!(ifname in r))
+          r[ifname] = {}
+
+        r[ifname][station] = d[station]
+      }
+
+      return r
+    }
+
+    function stationsStream(ifname) {
+      return new Streams.Stations(ip, ifname).map(wrapIfname, ifname)
+    }
+
+    function magic(interfaces) {
+      var ifnames = Object.keys(interfaces)
+      ifnames.forEach(querierAsk.push)
+
+      var wifiStream = Bacon.fromArray(ifnames)
+                            .flatMap(stationsStream)
+                            .scan({}, function (a, b) {
+                              a[b[0]] = b[1]
+                              return a
+                            })
+
+      var batadvStream = new Streams.Batadv(ip).toProperty({})
+
+      return Bacon.combineWith(combine, wifiStream
+                                      , batadvStream.map(extractIfname)
+                                      , nodesBus.map(".macs")
+                                      )
+    }
+
+    function combine(wifi, batadv, macs) {
+      var interfaces = combineWithIfnames(wifi, batadv)
+
+      for (var ifname in interfaces) {
+        var stations = interfaces[ifname]
+        for (var station in stations) {
+          stations[station].id = station
+
+          if (station in macs)
+            stations[station].nodeInfo = macs[station]
+          else
+            querierAsk.push(ifname)
+        }
+      }
+
+      return interfaces
+    }
+
+    function combineWithIfnames(wifi, batadv) {
+      var ifnames = Object.keys(wifi).concat(Object.keys(batadv))
+
+      // remove duplicates
+      ifnames.filter(function(e, i) {
+        return ifnames.indexOf(e) === i
+      })
+
+      var out = {}
+
+      ifnames.forEach(function (ifname) {
+        out[ifname] = combineWifiBatadv(wifi[ifname], batadv[ifname])
+      })
+
+      return out
+    }
+
+    function combineWifiBatadv(wifi, batadv) {
+      var station
+      var out = {}
+
+      for (station in batadv) {
+        if (!(station in out))
+          out[station] = {}
+
+        out[station].batadv = batadv[station]
+      }
+
+      for (station in wifi) {
+        if (!(station in out))
+          out[station] = {}
+
+        out[station].wifi = wifi[station]
+      }
+
+      return out
+    }
+
+    return Helper.request(ip, "interfaces").flatMap(magic)
+  }
+})

+ 66 - 0
package/gluon-status-page/src/js/lib/streams.js

@@ -0,0 +1,66 @@
+"use strict"
+define(["bacon", "lib/helper"], function(Bacon, Helper) {
+  function nodeInfo(ip, ifname) {
+    return Bacon.fromBinder(function (sink) {
+      var url = Helper.buildUrl(ip, "dyn/neighbours-nodeinfo", ifname)
+      var evtSource = new EventSource(url)
+
+      evtSource.addEventListener("neighbour", function(e) {
+        var r = sink(new Bacon.Next(JSON.parse(e.data)))
+
+        if (r === Bacon.noMore)
+          tearDown()
+      }, false)
+
+      evtSource.addEventListener("eot", function() {
+        evtSource.close()
+        sink(new Bacon.End())
+      }, false)
+
+      function tearDown() {
+        evtSource.close()
+      }
+
+      return tearDown
+    })
+  }
+
+  function simpleStream(url) {
+    return Bacon.fromBinder(function (sink) {
+      var evtSource = new EventSource(url)
+
+      evtSource.onmessage = function (e) {
+        var r = sink(new Bacon.Next(JSON.parse(e.data)))
+        if (r === Bacon.noMore)
+          tearDown()
+      }
+
+      function tearDown() {
+        evtSource.close()
+      }
+
+      return tearDown
+    })
+  }
+
+  function batadv(ip) {
+    var url = Helper.buildUrl(ip, "dyn/neighbours-batadv")
+    return simpleStream(url)
+  }
+
+  function stations(ip, ifname) {
+    var url = Helper.buildUrl(ip, "dyn/stations", ifname)
+    return simpleStream(url)
+  }
+
+  function statistics(ip) {
+    var url = Helper.buildUrl(ip, "dyn/statistics")
+    return simpleStream(url)
+  }
+
+  return { nodeInfo: nodeInfo
+         , Batadv: batadv
+         , Stations: stations
+         , Statistics: statistics
+         }
+})

+ 108 - 0
package/gluon-status-page/src/js/main.js

@@ -0,0 +1,108 @@
+"use strict"
+require([ "bacon"
+        , "lib/helper"
+        , "lib/streams"
+        , "lib/gui"
+        ], function(Bacon, Helper, Streams, GUI) {
+
+  var mgmtBus = new Bacon.Bus()
+
+  mgmtBus.pushEvent = function (key, a) {
+    var v = [key].concat(a)
+    return this.push(v)
+  }
+
+  mgmtBus.onEvent = function (events) {
+    return this.onValue(function (e) {
+      var d = e.slice() // shallow copy so calling shift doesn't change it
+      var ev = d.shift()
+      if (ev in events)
+        events[ev].apply(this, d)
+    })
+  }
+
+  var nodesBusIn = new Bacon.Bus()
+
+  var nodesBus = nodesBusIn.scan({ "nodes": {}
+                                 , "macs": {}
+                                 }, scanNodeInfo)
+
+  new GUI(mgmtBus, nodesBus)
+
+  mgmtBus.onEvent({ "goto": gotoNode
+                  , "nodeinfo": function (d) { nodesBusIn.push(d) }
+                  })
+
+  function tryIp(ip) {
+    return Helper.request(ip, "nodeinfo").map(function () { return ip })
+  }
+
+  var gotoEpoch = 0
+
+  function onEpoch(epoch, f) {
+    return function (d) {
+      if (epoch === gotoEpoch)
+        return f(d)
+    }
+  }
+
+  function gotoNode(nodeInfo) {
+    gotoEpoch++
+
+    var addresses = nodeInfo.network.addresses.filter(function (d) { return !/^fe80:/.test(d) })
+    var race = Bacon.fromArray(addresses).flatMap(tryIp).withStateMachine([], function (acc, ev) {
+      if (ev.isError())
+        return [acc.concat(ev.error), []]
+      else if (ev.isEnd() && acc.length > 0)
+        return [undefined, [new Bacon.Error(acc), ev]]
+      else if (ev.hasValue())
+        return [[], [ev, new Bacon.End()]]
+    })
+
+    race.onValue(onEpoch(gotoEpoch, function (d) {
+          mgmtBus.pushEvent("arrived", [nodeInfo, d])
+        }))
+
+    race.onError(onEpoch(gotoEpoch, function () {
+          mgmtBus.pushEvent("gotoFailed", nodeInfo)
+        }))
+  }
+
+  function scanNodeInfo(a, nodeInfo) {
+    a.nodes[nodeInfo.node_id] = nodeInfo
+
+    var mesh = Helper.dictGet(nodeInfo, ["network", "mesh"])
+
+    if (mesh)
+      for (var m in mesh)
+        for (var ifname in mesh[m].interfaces)
+          mesh[m].interfaces[ifname].forEach( function (d) {
+            a.macs[d] = nodeInfo
+          })
+
+    return a
+  }
+
+  if (localStorage.nodes)
+    JSON.parse(localStorage.nodes).forEach(nodesBusIn.push)
+
+  nodesBus.map(".nodes").onValue(function (nodes) {
+    var out = []
+
+    for (var k in nodes)
+      out.push(nodes[k])
+
+    localStorage.nodes = JSON.stringify(out)
+  })
+
+  var bootstrap = Helper.getJSON(bootstrapUrl)
+
+  bootstrap.onError(function () {
+    console.log("FIXME bootstrapping failed")
+  })
+
+  bootstrap.onValue(function (d) {
+    mgmtBus.pushEvent("nodeinfo", d)
+    mgmtBus.pushEvent("goto", d)
+  })
+})

Some files were not shown because too many files changed in this diff