diff --git a/app.js b/app.js index e848121..b4b3ace 100644 --- a/app.js +++ b/app.js @@ -14,7 +14,8 @@ require.config({ "virtual-dom": "../bower_components/virtual-dom/dist/virtual-dom", "rbush": "../bower_components/rbush/rbush", "helper": "../helper", - "jshashes": "../bower_components/jshashes/hashes" + "jshashes": "../bower_components/jshashes/hashes", + "c3": "../bower_components/c3/c3.min" }, shim: { "leaflet.label": ["leaflet"], diff --git a/bower.json b/bower.json index 540768f..cab7816 100644 --- a/bower.json +++ b/bower.json @@ -25,11 +25,15 @@ "virtual-dom": "~2.0.1", "leaflet-providers": "~1.0.27", "rbush": "https://github.com/mourner/rbush.git#~1.3.5", - "jshashes": "~1.0.5" + "jshashes": "~1.0.5", + "c3": "~0.4.10" }, "authors": [ "Nils Schneider " ], "license": "GPL3", - "private": true + "private": true, + "resolutions": { + "d3": "~3.5.5" + } } diff --git a/config.json.example b/config.json.example index 8d7e9ed..3af8a2a 100644 --- a/config.json.example +++ b/config.json.example @@ -17,5 +17,39 @@ { "name": "Stamen.TonerLite" } + ], + "nodeCharts": [ + { + "name": "Statistik", + "metrics": [ + { + "id": "clientcount", + "color": "#1566A9", + "label": "Clients" + }, + { + "id": "loadavg", + "color": "#1566A9", + "label": "Load" + }, + { + "id": "uptime", + "color": "#1566A9", + "label": "Uptime" + } + ], + "defaultMetric": "clientcount", + "data": { + "url": "http://137.226.33.62:8002/render", + "parameters": [ + "format=json", + "from=-###ZOOM_FROM###", + "target=alias(summarize(freifunk.nodes-legacy.###NODE_ID###.clientcount,\"###ZOOM_INTERVAL###\",\"max\"),\"clientcount\")", + "target=alias(summarize(freifunk.nodes-legacy.###NODE_ID###.loadavg,\"###ZOOM_INTERVAL###\",\"avg\"),\"loadavg\")", + "target=alias(summarize(freifunk.nodes-legacy.###NODE_ID###.uptime,\"###ZOOM_INTERVAL###\",\"last\"),\"uptime\")" + ] + }, + "quirks": [ "id_to_mac" ] + } ] } diff --git a/html/index.html b/html/index.html index fc4ff6e..14042a4 100644 --- a/html/index.html +++ b/html/index.html @@ -6,6 +6,7 @@ + diff --git a/index.html b/index.html index fcbf858..d0f6930 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ + diff --git a/lib/infobox/charts.js b/lib/infobox/charts.js new file mode 100644 index 0000000..bada130 --- /dev/null +++ b/lib/infobox/charts.js @@ -0,0 +1,265 @@ +define(["c3", "d3"], function (c3, d3) { + + var charts = function (node, config) { + this.node = node + + this.chartConfig = config + this.chart = null + + this.zoomConfig = { + "levels": [ + {"label": "8h", "from": "8h", "interval": "15min"}, + {"label": "24h", "from": "26h", "interval": "1h"}, + {"label": "1m", "from": "1mon", "interval": "1d"}, + {"label": "1y", "from": "1y", "interval": "1mon"} + ] + } + this.zoomLevel = 0 + + this.c3Config = { + "size": { + "height": 240 + }, + padding: { + bottom: 30 + }, + "legend": { + "item": { + "onclick": function (id) { + this.api.hide() + this.api.show(id) + } + } + }, + "tooltip": { + "format": { + "value": this.c3FormatToolTip.bind(this) + } + }, + "axis": { + "x": { + "type": "timeseries", + "tick": { + "format": this.c3FormatXAxis.bind(this), + "rotate": -45 + } + }, + "y": { + "min": 0, + "padding": { + "bottom": 0 + } + } + } + } + + this.cache = [] + + this.init() + } + + charts.prototype = { + + init: function () { + // Workaround for endless loop bug + if (this.c3Config.axis.x.tick && this.c3Config.axis.x.tick.format && typeof this.c3Config.axis.x.tick.format === "function") { + if (this.c3Config.axis.x.tick.format && !this.c3Config.axis.x.tick._format) + this.c3Config.axis.x.tick._format = this.c3Config.axis.x.tick.format + this.c3Config.axis.x.tick.format = function (val) { + return this.c3Config.axis.x.tick._format(val) + }.bind(this) + } + + // Configure metrics + this.c3Config.data = { + "keys": { + "x": "time", + "value": this.chartConfig.metrics.map(function (metric) { + return metric.id + }) + }, + "colors": this.chartConfig.metrics.reduce(function (collector, metric) { + collector[metric.id] = metric.color + return collector + }, {}), + "names": this.chartConfig.metrics.reduce(function (collector, metric) { + collector[metric.id] = metric.label + return collector + }, {}), + "hide": this.chartConfig.metrics.map(function (metric) { + return metric.id + }).filter(function (id) { + return id !== this.chartConfig.defaultMetric + }.bind(this)) + } + }, + + render: function () { + var div = document.createElement("div") + div.classList.add("chart") + var h4 = document.createElement("h4") + h4.textContent = this.chartConfig.name + div.appendChild(h4) + + + // Render chart + this.load(function (data) { + div.appendChild(this.renderChart(data)) + + // Render zoom controls + if (this.zoomConfig.levels.length > 0) + div.appendChild(this.renderZoomControls()) + + }.bind(this)) + + return div + }, + + renderChart: function (data) { + this.c3Config.data.json = data + this.chart = c3.generate(this.c3Config) + return this.chart.element + }, + + updateChart: function (data) { + this.c3Config.data.json = data + this.chart.load(this.c3Config.data) + }, + + renderZoomControls: function () { + // Draw zoom controls + var zoomDiv = document.createElement("div") + zoomDiv.classList.add("zoom-buttons") + + var zoomButtons = [] + this.zoomConfig.levels.forEach(function (v, level) { + var btn = document.createElement("button") + btn.classList.add("zoom-button") + btn.setAttribute("data-zoom-level", level) + + if (level === this.zoomLevel) + btn.classList.add("active") + + btn.onclick = function () { + if (level !== this.zoomLevel) { + zoomButtons.forEach(function (v, k) { + if (level !== k) + v.classList.remove("active") + else + v.classList.add("active") + }) + this.setZoomLevel(level) + } + }.bind(this) + btn.textContent = v.label + zoomButtons[level] = btn + zoomDiv.appendChild(btn) + }.bind(this)) + return zoomDiv + }, + + setZoomLevel: function (level) { + if (level !== this.zoomLevel) { + this.zoomLevel = level + this.load(this.updateChart.bind(this)) + } + }, + + load: function (callback) { + if (this.cache[this.zoomLevel]) + callback(this.cache[this.zoomLevel]) + else { + var url = this.chartConfig.data.url + + "?" + + this.chartConfig.data.parameters.join("&") + + var zoomConfig = this.zoomConfig.levels[this.zoomLevel] + + var id = this.node.nodeinfo.node_id + + if (this.chartConfig.quirks + && Array.isArray(this.chartConfig.quirks) + && this.chartConfig.quirks.indexOf("id_to_mac") >= 0) { + + // Quirk for legacy graphite data of Freifunk Aachen (data is stored by the node's mac address) + var regex = /^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i + var match = regex.exec(id) + if (match) + id = match[1] + ":" + match[2] + ":" + match[3] + ":" + match[4] + ":" + match[5] + ":" + match[6] + } + + // Using split as workaround for replacing all occurrences (to avoid regexps) + url = url.split("###NODE_ID###").join(id) + url = url.split("###ZOOM_FROM###").join(zoomConfig.from) + url = url.split("###ZOOM_INTERVAL###").join(zoomConfig.interval) + + // In case we will have multiple urls in the future + Promise.all([url].map(getJSON)).then(function (data) { + this.cache[this.zoomLevel] = this.parse(data) + callback(this.cache[this.zoomLevel]) + }.bind(this)) + } + }, + + parse: function (results) { + var data = [] + results.forEach(function (d) { + if (d[0] && d[0].target && d[0].datapoints) + d[0].datapoints.forEach(function (dp, dpk) { + var tmp = {"time": new Date(dp[1] * 1000)} + for (var i = 0; i < d.length; i++) { + var target = d[i].target + var v = (d[i].datapoints[dpk] ? d[i].datapoints[dpk][0] : 0) + tmp[target] = this.formatValue(target, v) + } + data.push(tmp) + }.bind(this)) + }.bind(this)) + return data + }, + + c3FormatToolTip: function (d, ratio, id) { + switch (id) { + case "uptime": + return d.toFixed(1) + " Tage" + default: + return d + } + }, + + c3FormatXAxis: function (d) { + var pad = function (number, pad) { + var N = Math.pow(10, pad) + return number < N ? ("" + (N + number)).slice(1) : "" + number + } + switch (this.zoomLevel) { + case 0: // 8h + case 1: // 24h + return pad(d.getHours(), 2) + ":" + pad(d.getMinutes(), 2) + case 2: // 1m + return pad(d.getDate(), 2) + "." + pad(d.getMonth() + 1, 2) + case 3: // 1y + return ["Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"][d.getMonth()] + default: + break + } + }, + + formatValue: function (id, value) { + switch (id) { + case "loadavg": + return (d3.format(".2r")(value)) + case "clientcount": + return (Math.ceil(value)) + case "uptime": + return (value / 86400) + default: + return value + } + } + + } + + + return charts +}) diff --git a/lib/infobox/node.js b/lib/infobox/node.js index cde1c8d..6d9ebbf 100644 --- a/lib/infobox/node.js +++ b/lib/infobox/node.js @@ -1,5 +1,5 @@ -define(["moment", "numeral", "tablesort", "tablesort.numeric"], - function (moment, numeral, Tablesort) { +define(["moment", "numeral", "tablesort", "infobox/charts", "tablesort.numeric"], + function (moment, numeral, Tablesort, Charts) { function showGeoURI(d) { function showLatitude(d) { var suffix = Math.sign(d) > -1 ? "' N" : "' S" @@ -200,6 +200,10 @@ define(["moment", "numeral", "tablesort", "tablesort.numeric"], el.appendChild(attributes) + if (!d.flags.gateway && config.nodeCharts) + config.nodeCharts.forEach( function (config) { + el.appendChild((new Charts(d, config)).render()) + }) if (config.nodeInfos) config.nodeInfos.forEach( function (nodeInfo) { diff --git a/scss/_chart.scss b/scss/_chart.scss new file mode 100644 index 0000000..3271805 --- /dev/null +++ b/scss/_chart.scss @@ -0,0 +1,20 @@ +.infobox .chart { + position: relative; + & > .c3 { + margin-right: 20px; + } + + .zoom-buttons { + position: absolute; + top: 0px; + right: 20px; + + button { + font-size: 10pt; + width: 3em; + height: 3em; + border-radius: 1.5em; + margin-left: 6px; + } + } +} \ No newline at end of file diff --git a/scss/main.scss b/scss/main.scss index d723673..61da4f7 100644 --- a/scss/main.scss +++ b/scss/main.scss @@ -13,6 +13,7 @@ $buttondistance: 12pt; @import '_map'; @import '_forcegraph'; @import '_legend'; +@import '_chart'; .content { position: fixed; diff --git a/tasks/build.js b/tasks/build.js index 7b3ee0a..8697e36 100644 --- a/tasks/build.js +++ b/tasks/build.js @@ -100,6 +100,12 @@ module.exports = function(grunt) { out: "build/app.js" } } + }, + c3: { + src: ["c3.min.css"], + expand: true, + dest: "build/", + cwd: "bower_components/c3/" } })