From 2a58bcf5f1156820d3969202072870db99fb4407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20R=C3=BCttgers?= Date: Wed, 13 Jan 2016 23:51:13 +0100 Subject: [PATCH] infobox/node: render statistic charts based on prometheus backend history: Frontend generated node charts with graphite backend Resolution for bower conflict Let the user configure the metrics in config.json Fix grunt task for deploying c3 css Fix tooltip layout issue --- app.js | 3 +- bower.json | 8 +- config.json.example | 47 +++++++- html/index.html | 1 + index.html | 1 + lib/infobox/charts.js | 255 ++++++++++++++++++++++++++++++++++++++++++ lib/infobox/node.js | 8 +- scss/_chart.scss | 28 +++++ scss/main.scss | 1 + tasks/build.js | 6 + 10 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 lib/infobox/charts.js create mode 100644 scss/_chart.scss 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 be1795a..001ed0c 100644 --- a/bower.json +++ b/bower.json @@ -25,12 +25,16 @@ "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": [ "Milan Pässler ", "Nils Schneider " ], "license": "AGPL3", - "private": true + "private": true, + "resolutions": { + "d3": "~3.5.5" + } } diff --git a/config.json.example b/config.json.example index c4616e2..a71572d 100644 --- a/config.json.example +++ b/config.json.example @@ -1,6 +1,10 @@ { - "dataPath": "https://map.luebeck.freifunk.net/data/", - "siteName": "Freifunk Lübeck", + "dataPath": [ + "http://map.ffgl.eu/data/", + "http://karte.ffdus.de/data/", + "http://karte.freifunk-iserlohn.de/data/" + ], + "siteName": "Freifunk Fluss", "mapSigmaScale": 0.5, "showContact": true, "maxAge": 14, @@ -19,9 +23,40 @@ } ], "siteNames": [ - { "site": "ffhl", "name": "Lübeck" }, - { "site": "ffeh", "name": "Entenhausen" }, - { "site": "ffgt", "name": "Gothamcity" }, - { "site": "ffal", "name": "Atlantis" } + { "site": "ffgl-bcd", "name": "Burscheid" }, + { "site": "ffgl-bgl", "name": "Bergisch Gladbach" }, + { "site": "ffgl-lln", "name": "Leichlingen" }, + { "site": "ffgl-ode", "name": "Odenthal" }, + { "site": "ffgl-ovr", "name": "Overath" }, + { "site": "ffgl-rrh", "name": "Rösrath" }, + { "site": "ffdus", "name": "Flingern" } + { "site": "ffis", "name": "Iserlohn" } + ], + "nodeCharts": [ + { + "name": "Statistik", + "metrics": [ + { + "id": "clients", + "color": "#1566A9", + "label": "Clients", + "query": "sum(statistics_clients_total{nodeid=%22{{NODE_ID}}%22})" + }, + { + "id": "load", + "color": "1566A9", + "label": "Systemlast", + "query": "sum(statistics_loadavg{nodeid=%22{{NODE_ID}}%22})" + }, + { + "id": "rx", + "color": "#1566A9", + "label": "Traffic (RX, kbps)", + "query": "sum(rate(statistics_traffic_rx_bytes{nodeid=%22{{NODE_ID}}%22}[30m])/125)" + } + ], + "defaultMetric": "clients", + "url": "https://prometheus.map.eulenfunk.de/api/v1/query_range" + } ] } 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..e07f2f2 --- /dev/null +++ b/lib/infobox/charts.js @@ -0,0 +1,255 @@ +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": 8 * 3600, "interval": 15 * 60} + /*{"label": "24h", "from": "26h", "interval": "1h"}, + {"label": "1m", "from": "1mon", "interval": "1d"}, + {"label": "1y", "from": 31536000, "interval": 2628000}*/ + ] + } + 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 urls = [] + var id = this.node.nodeinfo.node_id + var unixStamp = Math.floor(Date.now() / 1000) + var zoomConfig = this.zoomConfig.levels[this.zoomLevel] + + this.chartConfig.metrics.forEach(function(metric) { + var parameters = [ + "start=" + (unixStamp - zoomConfig.from), + "end=" + unixStamp, + "step=" + zoomConfig.interval, + "query=" + metric.query.replace("{{NODE_ID}}", id) + ] + + var url = this.chartConfig.url + "?" + parameters.join("&") + + urls.push(url) + + }.bind(this)) + + Promise.all(urls.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[0].data.result[0].values.forEach(function (tp) { + var time = {"time": new Date(tp[0] * 1000)} + results.forEach(function(result) { + var metric = this.chartConfig.metrics[results.indexOf(result)] + var index = results[0].data.result[0].values.indexOf(tp) + time[metric.id] = this.formatValue(metric.id, result.data.result[0].values[index][1]) + }.bind(this)) + data.push(time) + }.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 aeae59a..cb2d74e 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" @@ -284,6 +284,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..50c66b2 --- /dev/null +++ b/scss/_chart.scss @@ -0,0 +1,28 @@ +.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; + } + } + + .c3-tooltip-container { + width: 150px; + .c3-tooltip { + border-collapse: collapse; + } + } +} + diff --git a/scss/main.scss b/scss/main.scss index 109463d..ccabe24 100644 --- a/scss/main.scss +++ b/scss/main.scss @@ -14,6 +14,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..6c3aa40 100644 --- a/tasks/build.js +++ b/tasks/build.js @@ -53,6 +53,12 @@ module.exports = function(grunt) { expand: true, dest: "build/", cwd: "bower_components/leaflet/dist/" + }, + c3: { + src: ["c3.min.css"], + expand: true, + dest: "build/", + cwd: "bower_components/c3/" } }, sass: {