diff options
author | Arthur de Jong <arthur@arthurdejong.org> | 2019-12-28 15:27:34 +0100 |
---|---|---|
committer | Arthur de Jong <arthur@arthurdejong.org> | 2019-12-30 22:12:10 +0100 |
commit | 3ef2055341335b3809cd2f910191696cd9b502ba (patch) | |
tree | 2287c97dbde3646bfd78075fd2993f8bd5a80f92 /src/munin-plot.js | |
parent | dc6b2ff25ec3064afc6256d45a6255a1e62154d5 (diff) |
Use webpack to build Javascript part
This uses npm to install the required packages and builds the files with
webpack.
Diffstat (limited to 'src/munin-plot.js')
-rw-r--r-- | src/munin-plot.js | 530 |
1 files changed, 530 insertions, 0 deletions
diff --git a/src/munin-plot.js b/src/munin-plot.js new file mode 100644 index 0000000..ec81986 --- /dev/null +++ b/src/munin-plot.js @@ -0,0 +1,530 @@ +/*! + Copyright (C) 2018-2019 Arthur de Jong + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +*/ + +require('bootstrap'); +require('jquery-ui/ui/widgets/draggable'); +require('jquery-ui/ui/widgets/sortable'); + +$(document).ready(function() { + jQuery(function($) { + var panelList = $('#draggablelist'); + panelList.sortable({ + handle: '.draghandle', + update: function() { + $('.panel', panelList).each(function(index, elem) { + var $listItem = $(elem), newIndex = $listItem.index(); + }); + } + }); + }); +}); +var default_colors = [ + '#00cc00', '#0066b3', '#ff8000', '#dbc300', '#330099', '#990099', + '#bce617', '#ff0000', '#808080', '#008f00', '#00487d', '#b35a00', + '#b38f00', '#6b006b', '#8fb300', '#b30000', '#bebebe', '#80ff80', + '#80c9ff', '#ffc080', '#ffe680', '#aa80ff', '#ee00cc', '#ff8080', + '#666600', '#ffbfff', '#00ffcc', '#cc6699', '#999900']; + +var base_layout = { + margin: { l: 48, t: 0, r: 8, b: 32 }, + autosize: true, + showlegend: false, + dragmode: 'pan', + selectdirection: 'h', + xaxis: { + type: 'date', + tickfont: { + size: 10, + color: '#7f7f7f' + }, + hoverformat: '%a %Y-%m-%d %H:%M' + }, + yaxis: { + fixedrange: true, + tickfont: { + size: 10, + color: '#7f7f7f' + }, + titlefont: { + size: 10, + color: '#7f7f7f' + }, + exponentformat: 'SI', + hoverformat: '.4s' + }, + legend: { + bgcolor: '#ffffffa0', + xanchor: 'auto', + x: 1.2 + }, + datarevision: 1 +}; + +var config = { + showLink: false, + displaylogo: false, + autosizable: true, + responsive: true, + scrollZoom: true, + displayModeBar: false, + modeBarButtonsToRemove: [ + 'sendDataToCloud', + 'toImage', + 'lasso2d', + 'resetScale2d', + 'hoverClosestCartesian', + 'hoverCompareCartesian' + ], + showTips: false + // toImageButtonOptions +} + +// whether new data should be loaded +var updatedata = false; + +function htmlescape(text) { + var p = document.createElement('p'); + p.appendChild(document.createTextNode(text)); + return p.innerHTML.replace(/"/g, '"'); +} + +// update the legend +function updateLegend(plot, tracebyfield, legendbyfield) { + var [minx, maxx] = plot.layout.xaxis.range; + Object.keys(legendbyfield).forEach(function(field) { + var columns = legendbyfield[field].getElementsByTagName('td'); + // calculate minimum + var mintrace = tracebyfield[field + '.min']; + var minvalue = Math.min.apply(null, mintrace.y.filter(function (el, idx) { + var x = mintrace.x[idx]; + return x >= minx && x <= maxx; + })); + // calculate average + var avgtrace = tracebyfield[field]; + if (avgtrace.y.length) { + var avgvalue = avgtrace.y.map(function (current, idx) { + var x = avgtrace.x[idx]; + if (idx > 0 && x >= minx && x <= maxx) + return [current, Date.parse(x) - Date.parse(avgtrace.x[idx - 1])]; + else + return [current, 0]; + }).reduce(function(acc, current, currentIndex, array) { + return [acc[0] + (current[0] * current[1]), acc[1] + current[1]]; + }); + avgvalue = avgvalue[0] / avgvalue[1]; + } else + avgvalue = undefined; + // calculate maximum + var maxtrace = tracebyfield[field + '.max']; + var maxvalue = Math.max.apply(null, maxtrace.y.filter(function (el, idx) { + var x = maxtrace.x[idx]; + return x >= minx && x <= maxx; + })); + // update legend + columns[2].textContent = (isNaN(minvalue) || !isFinite(minvalue)) ? '-' : Plotly.d3.format('.4s')(minvalue); + columns[3].textContent = (isNaN(avgvalue) || !isFinite(avgvalue)) ? '-' : Plotly.d3.format('.4s')(avgvalue); + columns[4].textContent = (isNaN(maxvalue) || !isFinite(maxvalue)) ? '-' : Plotly.d3.format('.4s')(maxvalue); + }); +} + +// load graph data into the plot +function loadGraph(plot, legend, graph) { + // prepare the graph configuration + var layout = JSON.parse(JSON.stringify(base_layout)); + if (graph.graph_vlabel) + layout.yaxis.title = graph.graph_vlabel; + if (graph.graph_args && graph.graph_args.match(/--logarithmic/)) { + layout.yaxis.type = 'log'; + layout.yaxis.exponentformat = 'E'; + } + // get x axis zoom from another plot + var existingplot = document.querySelector('#draggablelist .myplot'); + if (existingplot && existingplot.layout && existingplot.layout.xaxis.range) { + layout.xaxis.range = [existingplot.layout.xaxis.range[0], existingplot.layout.xaxis.range[1]]; + } + // prepare the data series configuration + var traces = []; + var tracebyfield = {}; + plot.tracebyfield = tracebyfield; + var stackgroup = 0; + for (var i = 0; i < graph.fields.length; i++) { + var field = graph.fields[i]; + var color = field.colour ? '#' + field.colour : default_colors[i % default_colors.length]; + if (field.draw == 'AREA' || field.draw == 'STACK' || field.draw == 'AREASTACK') { + if (!field.draw.match(/STACK/) && (!graph.fields[i + 1] || graph.fields[i + 1].draw.match(/STACK/))) { + stackgroup += 1; + } + var trace = { + field_name: field.name, + name: field.label || field.name, + info: field.info || '', + line: {width: 0}, + fillcolor: color + 'c0', + hoverlabel: {bgcolor: color + 'c0'}, + stackgroup: 'stack' + stackgroup + }; + traces.push(trace); + tracebyfield[field.name] = trace; + tracebyfield[field.name + '.min'] = {}; + tracebyfield[field.name + '.max'] = {}; + } else if (field.draw) { + var trace = { + field_name: field.name, + name: field.label || field.name, + info: field.info || '', + line: {color: color}, + hoverlabel: {bgcolor: color + 'c0'} + }; + var trace_min = { + field_name: field.name, + showlegend: false, + hoverinfo: 'skip', + line: {width: 0} + }; + var trace_max = { + field_name: field.name, + showlegend: false, + hoverinfo: 'skip', + line: {width: 0}, + fill: 'tonexty', + fillcolor: color + '20' + }; + traces.push(trace, trace_min, trace_max); + tracebyfield[field.name] = trace; + tracebyfield[field.name + '.min'] = trace_min; + tracebyfield[field.name + '.max'] = trace_max; + } + } + // make placeholders for data in traces + Object.keys(tracebyfield).forEach(function(field) { + tracebyfield[field].x = []; + tracebyfield[field].y = []; + }); + // build the legend + var legendbyfield = {}; + traces.slice().reverse().forEach(function(trace) { + if (trace.showlegend != false) { + var legendrow = document.createElement('tr') + var style; + if (trace.fillcolor) + style = 'stroke: ' + trace.fillcolor + ';stroke-width:8'; + else + style = 'stroke: ' + trace.line.color + ';stroke-width:2'; + legendrow.innerHTML += '<td style="width: 30px;"><svg height="10" width="20"><line x1="0" y1="5" x2="20" y2="5" style="' + style + '" /></svg></td>'; + legendrow.innerHTML += '<td><span title="' + htmlescape(trace.info) + '">' + htmlescape(trace.name) + '</span></td>'; + legendrow.innerHTML += '<td></td><td></td><td></td>'; + legend.getElementsByTagName('tbody')[0].appendChild(legendrow); + legendbyfield[trace.field_name] = legendrow; + // handle showing/hiding the trace + legendrow.addEventListener('click', function() { + visible = (trace.visible == false); + legendrow.style.opacity = visible ? 1 : 0.2; + plot.data.forEach(function(t) { + if (t.field_name == trace.field_name) + t.visible = visible; + }); + Plotly.redraw(plot); + }); + // highlight the trace by lowering the opacity of the others + legendrow.addEventListener('mouseover', function() { + var vals = plot.data.map(t => t.field_name == trace.field_name ? 1 : 0.1); + Plotly.restyle(plot, 'opacity', vals); + var vals = plot.data.map(function(t) { + if (t.showlegend === false) + return t.fillcolor; + return (t.fillcolor || '#ffffff').substring(0, 7) + (t.field_name == trace.field_name ? 'ff' : '30'); + }); + Plotly.restyle(plot, 'fillcolor', vals); + }); + } + }); + // reset opacity after exiting the legend + legend.addEventListener('mouseout', function() { + Plotly.restyle(plot, 'opacity', plot.data.map(t => 1)); + var vals = plot.data.map(function(t) { + if (t.showlegend === false) + return t.fillcolor; + return (t.fillcolor || '#ffffff').substring(0, 7) + 'c0'; + }); + Plotly.restyle(plot, 'fillcolor', vals); + }); + // fetch the data and plot it + Plotly.d3.csv('data/' + graph.name, function(data) { + for (var i = 0; i < data.length; i++) { + row = data[i]; + time = row['time']; + Object.keys(tracebyfield).forEach(function(field) { + tracebyfield[field].x.push(time); + tracebyfield[field].y.push(Number(row[field])); + }); + } + plot.innerHTML = ''; + Plotly.react(plot, traces, layout, config); + updateLegend(plot, tracebyfield, legendbyfield); + updatedata = true; + // handle plot changes + plot.on('plotly_relayout', function(ed) { + updatedata = true; + // make zoom levels consistent across graphs + [].forEach.call(document.getElementsByClassName('myplot'), plot => { + if (plot.layout) { + let xaxis = plot.layout.xaxis; + if ((ed['xaxis.range[0]'] && xaxis.range[0] != ed['xaxis.range[0]']) || + (ed['xaxis.range[1]'] && xaxis.range[1] != ed['xaxis.range[1]']) || + (ed['xaxis.autorange'] && ed['xaxis.autorange'] != xaxis.autorange)) + Plotly.relayout(plot, ed); + } + }); + // update the legend values + updateLegend(plot, tracebyfield, legendbyfield); + }); + }); +} + +// check if the axis match the data range and load more data as needed +function checkDataUpdates() { + try { + if (updatedata) { + updatedata = false; + // go over all plots + [].forEach.call(document.getElementsByClassName('myplot'), plot => { + if (plot && plot.layout) { + // range of the x axis + var [amin, amax] = plot.layout.xaxis.range; + // range of the currently loaded data + var dmin = plot.data.map(t => t.x[0]).reduce((a, c) => a < c ? a : c); + var dmax = plot.data.map(t => t.x[t.x.length - 1]).reduce((a, c) => a > c ? a : c); + // range that we have marked as loaded + // (to avoid retrying to load data that isn't there) + if (!plot.lmin) + plot.lmin = dmin; + if (!plot.lmax) + plot.lmax = dmax; + // see if we need to load data before the currently loaded range + if (amin < plot.lmin) { + plot.lmin = amin; + Plotly.d3.csv('data/' + plot.graph.name + '?start=' + amin.split('.')[0] + '&end=' + dmin.split('.')[0], function(data) { + // prepend new data + if (data) { + for (var i = data.length - 1; i >= 0; i--) { + row = data[i]; + time = row['time']; + Object.keys(plot.tracebyfield).forEach(function(field) { + var trace = plot.tracebyfield[field]; + if (time < trace.x[0]) { + trace.x.splice(0, 0, time); + trace.y.splice(0, 0, Number(row[field])); + } + }); + } + plot.layout.datarevision += 1; + Plotly.react(plot, plot.data, plot.layout); + } + }); + } + // see if we need to load data paste the currently loaded range + if (amax > plot.lmax) { + plot.lmax = amax; + // load data from dmax to amax and append + Plotly.d3.csv('data/' + plot.graph.name + '?start=' + dmax.split('.')[0] + '&end=' + amax.split('.')[0], function(data) { + // append new data + if (data) { + for (var i = 0; i < data.length; i++) { + row = data[i]; + time = row['time']; + Object.keys(plot.tracebyfield).forEach(function(field) { + var trace = plot.tracebyfield[field]; + if (time > trace.x[trace.x.length - 1]) { + trace.x.push(time); + trace.y.push(Number(row[field])); + } + }); + } + plot.layout.datarevision += 1; + Plotly.react(plot, plot.data, plot.layout); + } + }); + } + } + }); + } + } finally { + setTimeout(checkDataUpdates, 1000); + } +} +setTimeout(checkDataUpdates, 1000); + +// every minute check if there is any new data +function checkNewData() { + setTimeout(checkNewData, 60000); + [].forEach.call(document.getElementsByClassName('myplot'), plot => { + plot.lmax = undefined; + }); + updatedata = true; +} +setTimeout(checkNewData, 60000); + +function addGraph(graph, size='150px') { + var clone = document.getElementById('template').firstElementChild.cloneNode(true); + var plot = clone.getElementsByClassName('myplot')[0]; + var legend = clone.getElementsByClassName('mylegend')[0]; + plot.graph = graph; + // update the graph info + [].forEach.call(clone.querySelectorAll('.graphinfo .dropdown-menu'), em => { + var info = '<tt class="dropdown-item">' + htmlescape(graph.group + '/' + graph.host) + '</tt>'; + info += '<h3 class="dropdown-item">' + graph.graph_title + '</h3>'; + if (graph.graph_info) + info += '<div class="dropdown-item">' + htmlescape(graph.graph_info) + '</div>'; + em.innerHTML = info; + }); + // set the size changing actions + [].forEach.call(clone.getElementsByClassName('setsize'), button => { + button.addEventListener('click', function() { + if (button.getElementsByClassName('sizesm').length) { + plot.style.height = '150px'; + legend.style.height = '150px'; + } else if (button.getElementsByClassName('sizemd').length) { + plot.style.height = '200px'; + legend.style.height = '200px'; + } else if (button.getElementsByClassName('sizelg').length) { + plot.style.height = '250px'; + legend.style.height = '250px'; + } + Plotly.relayout(plot, {}); + }); + }); + // set the wanted size + plot.style.height = size; + legend.style.height = size; + // set the close action + [].forEach.call(clone.getElementsByClassName('closegraph'), button => { + button.addEventListener('click', function() { + Plotly.purge(plot); + clone.parentNode.removeChild(clone); + }); + }); + // load the graph data + loadGraph(plot, legend, graph); + // show the graph + document.getElementById('draggablelist').appendChild(clone); +} + +// update the select widget to be able to list the known graphs +function updateSelect(graphs) { + // make lists of groups, hosts and categories + var hosts = {}; + var categories = []; + for (var graph in graphs) { + var parts = graph.split('/') + if (!hosts[parts[0]]) + hosts[parts[0]] = [] + if (hosts[parts[0]].indexOf(parts[1]) < 0) + hosts[parts[0]].push(parts[1]); + if (graphs[graph].category && categories.indexOf(graphs[graph].category) < 0) + categories.push(graphs[graph].category); + } + // update options in host selector + var groups = Object.keys(hosts); + groups.sort(); + var hostselect = document.getElementById('hostselect'); + for (var i = 0; i < groups.length; i++) { + var group = groups[i]; + var groupelement = document.createElement('optgroup'); + groupelement.setAttribute('label', group); + hosts[group].sort(); + for (var j = 0; j < hosts[group].length; j++) { + var hostelement = document.createElement('option'); + hostelement.setAttribute('value', group + '/' + hosts[group][j]); + hostelement.textContent = hosts[group][j]; + groupelement.appendChild(hostelement); + } + hostselect.appendChild(groupelement); + } + // update options in category selector + categories.sort(); + var categoryselect = document.getElementById('categoryselect'); + for (var i = 0; i < categories.length; i++) { + var categoryelement = document.createElement('option'); + categoryelement.setAttribute('value', categories[i]); + categoryelement.textContent = categories[i]; + categoryselect.appendChild(categoryelement); + } + // build list of graphs + var graphnames = Object.keys(graphs); + graphnames.sort(); + var graphfilter = document.getElementById('graphfilter'); + // handler for updating the choices in the graph select + function updateGraphList() { + var host = hostselect.options[hostselect.selectedIndex].value; + var category = categoryselect.options[categoryselect.selectedIndex].value; + var search = graphfilter.value.toLowerCase().split(' '); + var graphselect = document.getElementById('graphselect'); + graphselect.innerHTML = ''; + graphnames.forEach(function(graph) { + if (host && !graph.startsWith(host)) + return; + if (category && graphs[graph].category != category) + return; + var descripton = (graph + ' ' + graphs[graph].graph_title + ' ' + graphs[graph].category).toLowerCase(); + if (search.some(x => !descripton.includes(x))) + return; + var graphelement = document.createElement('a'); + graphelement.setAttribute('href', '#'); + graphelement.setAttribute('class', 'list-group-item list-group-item-action'); + graphelement.setAttribute('data-toggle', 'collapse'); + graphelement.setAttribute('data-target', '#addgraph'); + graphelement.textContent = graphs[graph].graph_title || graph.split('/')[2]; + // add the host graph unless a host graph has been selected + if (!host) { + var hostelement = document.createElement('small'); + hostelement.textContent = graph.split('/')[1] + ' / '; + graphelement.prepend(hostelement); + } + graphselect.appendChild(graphelement); + graphelement.addEventListener('click', function() { + addGraph(graphs[graph]); + }); + }); + graphfilter.focus(); + } + hostselect.addEventListener('change', updateGraphList, false); + categoryselect.addEventListener('change', updateGraphList, false); + graphfilter.addEventListener('input', updateGraphList, false); + document.getElementsByClassName('addgraph')[0].getElementsByClassName('btn')[0].addEventListener('click', function() { + setTimeout(function() {graphfilter.focus()}, 100); + }, false); + updateGraphList(); +} + +// load all graphs +var request = new XMLHttpRequest(); +request.overrideMimeType('application/json'); +request.open('GET', 'graphs', true); +request.onreadystatechange = function () { + if (request.readyState == 4 && request.status == '200') { + var graphs = JSON.parse(request.responseText); + updateSelect(graphs); + document.getElementsByClassName('addgraph')[0].style.display = 'flex'; + document.getElementsByClassName('loadingrow')[0].style.display = 'none'; + } +}; +request.send(null); |