/*! Copyright (C) 2018-2020 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('./apple-touch-icon.png') require('./favicon-16x16.png') require('./favicon-32x32.png') require('./favicon-64x64.png') require('./favicon.ico') require('bootstrap') require('bootstrap/scss/bootstrap.scss') require('jquery-ui/ui/widgets/draggable') require('jquery-ui/ui/widgets/sortable') require('daterangepicker') require('daterangepicker/daterangepicker.css') require('@fortawesome/fontawesome-free/js/all') require('./munin-plot.css') $(document).ready(function () { // make list of graphs draggable $('#draggablelist').sortable({ handle: '.draghandle', start: function () { // hide and disable all tooltips $('.tooltip').tooltip('hide').tooltip('disable') }, stop: function () { // enable tooltips again $('.tooltip').tooltip('enable') }, update: function (event, ui) { // after any changes, save the current list of graphs saveCurrentGraphs() } }) moment.fn.round10Minutes = function (how) { how = how || 'round' return this.minutes(Math[how](this.minutes() / 10) * 10).seconds(0).seconds(0).milliseconds(0) } // set the date range across graphs and date range picker function setDateRange(start, end) { // ensure start and end are moments if (typeof start !== 'object') { start = moment(start) } if (typeof end !== 'object') { end = moment(end) } // round times to 10 minute intervals start.round10Minutes('floor') end.round10Minutes('ceil') // update the date range picker var daterangepicker = $('#reportrange').data('daterangepicker') daterangepicker.setStartDate(start) daterangepicker.setEndDate(end) // ensure start and end are strings start = start.format('YYYY-MM-DD HH:mm') end = end.format('YYYY-MM-DD HH:mm') // update range for picker label $('#reportrange span').text(start + ' - ' + end) // update graphs as needed $('.myplot').each(function () { if (this.layout) { const xaxis = this.layout.xaxis if ((xaxis.range[0] !== start) || (xaxis.range[1] !== end)) { Plotly.relayout(this, {'xaxis.range[0]': start, 'xaxis.range[1]': end}) } } }) // save date range in local storage localStorage.setItem('dateRange', JSON.stringify({start: start, end: end})) } // initialise the date range picker $('#reportrange').daterangepicker({ locale: { format: 'YYYY-MM-DD HH:mm' }, opens: 'left', timePicker: true, timePickerIncrement: 10, timePicker24Hour: true, showDropdowns: true, showCustomRangeLabel: false, alwaysShowCalendars: true, ranges: { Today: [moment().subtract(1, 'days').round10Minutes(), moment().add(1, 'hour').round10Minutes('ceil')], Yesterday: [moment().subtract(1, 'days').startOf('day'), moment().subtract(1, 'days').endOf('day').round10Minutes('ceil')], 'Last 7 days': [moment().subtract(6, 'days').startOf('day'), moment().endOf('day').round10Minutes('ceil')], 'Last 30 days': [moment().subtract(29, 'days').startOf('day'), moment().endOf('day').round10Minutes('ceil')], 'This month': [moment().startOf('month'), moment().endOf('month').round10Minutes('ceil')], 'Last month': [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month').round10Minutes('ceil')], 'This year': [moment().subtract(365, 'days').startOf('month'), moment().endOf('month').round10Minutes('ceil')] } }, setDateRange) try { // restore the previously saved date range var data = JSON.parse(localStorage.getItem('dateRange')) setDateRange(data.start, data.end) } catch (error) { // set a default date range setDateRange(moment().subtract(2, 'days'), moment().add(1, 'hour').round10Minutes('ceil')) } var defaultColors = [ '#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 baseLayout = { 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 } // whether new data should be loaded var updatedata = false // update the legend function updateLegend(plot) { var [minx, maxx] = plot.layout.xaxis.range Object.keys(plot.legendbyfield).forEach(function (field) { // calculate minimum var mintrace = plot.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 = plot.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 = plot.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 var columns = $(plot.legendbyfield[field]).find('td') 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(baseLayout)) 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 date range selector var daterangepicker = $('#reportrange').data('daterangepicker') layout.xaxis.range = [ daterangepicker.startDate.format('YYYY-MM-DD HH:mm'), daterangepicker.endDate.format('YYYY-MM-DD HH:mm')] // 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 : defaultColors[i % defaultColors.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) { trace = { field_name: field.name, name: field.label || field.name, info: field.info || '', line: {color: color}, hoverlabel: {bgcolor: color + 'c0'} } var minTrace = { field_name: field.name, showlegend: false, hoverinfo: 'skip', line: {width: 0} } var maxTrace = { field_name: field.name, showlegend: false, hoverinfo: 'skip', line: {width: 0}, fill: 'tonexty', fillcolor: color + '20' } traces.push(trace, minTrace, maxTrace) tracebyfield[field.name] = trace tracebyfield[field.name + '.min'] = minTrace tracebyfield[field.name + '.max'] = maxTrace } } // make placeholders for data in traces Object.keys(tracebyfield).forEach(function (field) { tracebyfield[field].x = [] tracebyfield[field].y = [] }) // if there are too many traces only hover on the nearest if (traces.filter(trace => trace.showlegend !== false).length > 6) { layout.hovermode = 'closest' } // build the legend plot.legendbyfield = {} traces.slice().reverse().forEach(function (trace) { if (trace.showlegend !== false) { var legendrow = $('') legendrow.append($('')) legendrow.append($('')) legendrow.append($('')) legendrow.append($('')) legendrow.append($('')) if (trace.fillcolor) { legendrow.find('svg').attr('style', 'stroke: ' + trace.fillcolor + ';stroke-width:8') } else { legendrow.find('svg').attr('style', 'stroke: ' + trace.line.color + ';stroke-width:2') } legendrow.find('span').attr('title', trace.info).text(trace.name) legend.find('tbody').append(legendrow) plot.legendbyfield[trace.field_name] = legendrow[0] // handle showing/hiding the trace legendrow.click(function () { if (plot.data) { var visible = (trace.visible === false) $(this).css('opacity', visible ? 1 : 0.2) plot.data.forEach(function (t) { if (t.field_name === trace.field_name) { t.visible = visible } }) saveCurrentGraphs() Plotly.redraw(plot) } }) // highlight the trace by lowering the opacity of the others legendrow.mouseover(function () { if (plot.data) { var vals = plot.data.map(t => t.field_name === trace.field_name ? 1 : 0.1) Plotly.restyle(plot, 'opacity', vals) 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.mouseout(function () { if (plot.data) { 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 // TODO: probably skip this initial load??? Plotly.d3.csv('data/' + graph.name, function (data) { for (var i = 0; i < data.length; i++) { var row = data[i] Object.keys(tracebyfield).forEach(function (field) { tracebyfield[field].x.push(row.time) tracebyfield[field].y.push(Number(row[field])) }) } plot.innerHTML = '' Plotly.react(plot, traces, layout, config) updateLegend(plot) updatedata = true // handle plot zoom changes plot.on('plotly_relayout', function (data) { if (data['xaxis.range[0]'] && data['xaxis.range[1]']) { updatedata = true setDateRange(data['xaxis.range[0]'], data['xaxis.range[1]']) // update the legend values updateLegend(plot) } }) // after any changes, save the current list of graphs saveCurrentGraphs() }) } // 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 $('.myplot').each(function () { var plot = this if (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 var url = 'data/' + plot.graph.name + '?start=' + amin.substring(0, 16) + '&end=' + dmin.substring(0, 16) Plotly.d3.csv(url, function (data) { // prepend new data if (data) { for (var i = data.length - 1; i >= 0; i--) { var row = data[i] var time = row.time Object.entries(plot.tracebyfield).forEach(function ([field, trace]) { 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 past the currently loaded range if (amax > plot.lmax) { plot.lmax = amax // load data from dmax to amax and append url = 'data/' + plot.graph.name + '?start=' + dmax.substring(0, 16) + '&end=' + amax.substring(0, 16) Plotly.d3.csv(url, function (data) { // append new data if (data) { for (var i = 0; i < data.length; i++) { var row = data[i] var time = row.time Object.entries(plot.tracebyfield).forEach(function ([field, trace]) { 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) $('.myplot').each(function () { this.lmax = undefined }) updatedata = true } setTimeout(checkNewData, 60000) function addGraph(graph, size = 'sm') { var clone = $('#template>:first-child').clone() var plot = clone.find('.myplot')[0] var legend = clone.find('.mylegend') plot.graph = graph // update graph title clone.find('.graphtitle').text(graph.host + ' / ') .append($('').text(graph.graph_title)) .tooltip({title: graph.graph_info || ''}) // tooltip for drag handle clone.find('.draghandle').tooltip({placement: 'right'}) // set the size changing actions clone.find('.sizesm').tooltip({placement: 'right'}).click(function () { clone.find('.sizeactive').removeClass('sizeactive') $(this).addClass('sizeactive') $(plot).addClass('plot-sm').removeClass('plot-md plot-lg') legend.addClass('legend-sm').removeClass('legend-md legend-lg') Plotly.relayout(plot, {}) saveCurrentGraphs() }) clone.find('.sizemd').tooltip({placement: 'right'}).click(function () { clone.find('.sizeactive').removeClass('sizeactive') $(this).addClass('sizeactive') $(plot).addClass('plot-md').removeClass('plot-sm plot-lg') legend.addClass('legend-md').removeClass('legend-sm legend-lg') Plotly.relayout(plot, {}) saveCurrentGraphs() }) clone.find('.sizelg').tooltip({placement: 'right'}).click(function () { clone.find('.sizeactive').removeClass('sizeactive') $(this).addClass('sizeactive') $(plot).addClass('plot-lg').removeClass('plot-sm plot-md') legend.addClass('legend-lg').removeClass('legend-sm legend-md') Plotly.relayout(plot, {}) saveCurrentGraphs() }) // configure the close button clone.find('.closegraph').tooltip({placement: 'right'}).click(function () { $(this).tooltip('dispose') clone.hide(400, function () { Plotly.purge(plot) $(this).remove() // after any changes, save the current list of graphs saveCurrentGraphs() }) }) // set the wanted size $(plot).addClass('plot-' + size) legend.addClass('legend-' + size) clone.find('.sizeactive').removeClass('sizeactive') clone.find('.size' + size).addClass('sizeactive') // load the graph data loadGraph(plot, legend, graph) // enable tooltips on legend clone.find('.mylegend *[title]').tooltip({placement: 'bottom', container: 'body'}) // show the graph clone.appendTo('#draggablelist') return plot } // 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 Object.keys(hosts).sort().forEach(function (group) { var groupElement = $('').attr('label', group) hosts[group].sort().forEach(function (host) { groupElement.append($('').attr('value', group + '/' + host).text(host)) }) $('#hostselect').append(groupElement) }) // update options in category selector categories.sort().forEach(function (category) { $('#categoryselect').append($('').attr('value', category).text(category)) }) // handler for updating the choices in the graph select function updateGraphList() { var search = $('#graphfilter').val().toLowerCase().split(' ') var hostFilter = $('#hostselect').val() var categoryFilter = $('#categoryselect').val() $('#graphselect').empty() Object.keys(graphs).sort().forEach(function (graph) { if (hostFilter && !graph.startsWith(hostFilter + '/')) { return } if (categoryFilter && graphs[graph].category !== categoryFilter) { return } var descripton = (graph + ' ' + graphs[graph].graph_title + ' ' + graphs[graph].category).toLowerCase() if (search.some(x => !descripton.includes(x))) { return } var title = graphs[graph].graph_title || graph.split('/')[2] var graphelement = $('').text(title) // add the host graph unless a host graph has been selected if (!hostFilter) { graphelement.prepend($('').text(graph.split('/')[1] + ' / ')) } $('#graphselect').append(graphelement) graphelement.click(function () { addGraph(graphs[graph]) }) }) $('#graphfilter').focus() } $('#hostselect').change(updateGraphList) $('#categoryselect').change(updateGraphList) $('#graphfilter').on('input', updateGraphList) $('.addgraph button').click(function () { setTimeout(function () { $('#graphfilter').focus() }, 100) }) updateGraphList() } // return a list of currently shown graphs function getCurrentGraphs() { return $('.myplot').map(function () { if (this && this.layout) { return { name: this.graph.name, size: (function (graph) { if ($(graph).hasClass('plot-sm')) { return 'sm' } else if ($(graph).hasClass('plot-md')) { return 'md' } else if ($(graph).hasClass('plot-lg')) { return 'lg' } })(this), hidden: Object.entries(this.tracebyfield).filter(function ([field, trace]) { return trace.visible === false && trace.showlegend !== false }).map(function ([field, trace]) { return field }) } } }).toArray() } // save the current graph status to local storage function saveCurrentGraphs() { localStorage.setItem('shownGraphs', JSON.stringify(getCurrentGraphs())) } // remove all graphs from the view function clearGraphs() { $('#draggablelist li').hide(400, function () { Plotly.purge($('.myplot')) $(this).remove() // after any changes, save the current list of graphs saveCurrentGraphs() $('#clearGraphs').blur() }) } // restore the list of graphs as defined in the provided list function setGraphs(graphs) { clearGraphs() graphs.forEach(function (graph) { // lookup the graph by name var plot = addGraph(document.graph_data[graph.name], graph.size || 'sm') // hide fields if (graph.hidden && graph.hidden.length) { graph.hidden.forEach(function (field) { if (plot.tracebyfield[field]) { plot.tracebyfield[field].visible = false if (plot.tracebyfield[field + '.min']) { plot.tracebyfield[field + '.min'].visible = false plot.tracebyfield[field + '.min'].showlegend = false plot.tracebyfield[field + '.max'].visible = false plot.tracebyfield[field + '.max'].showlegend = false } plot.legendbyfield[field].style.opacity = 0.2 } }) } }) } // configure the clearGraphs button $('#clearGraphs').click(function () { clearGraphs() $('#dashboards button.dropdown-toggle span').text('Dashboards') }) // configure the dashboards button $.getJSON('dashboards', function (dashboards) { if (Object.keys(dashboards).length === 0) { $('#dashboards').remove() } else { Object.keys(dashboards).sort().forEach(function (name) { var option = $('