/*!
Copyright (C) 2018-2021 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',
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)
}
// Save date range in local storage
function saveDateRange(start, end) {
localStorage.setItem('dateRange', JSON.stringify({start: start, end: end}))
}
// 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
const 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
saveDateRange(start, end)
}
function getDateRange() {
const daterangepicker = $('#reportrange').data('daterangepicker')
const start = daterangepicker.startDate.format('YYYY-MM-DD HH:mm')
const end = daterangepicker.endDate.format('YYYY-MM-DD HH:mm')
return {start: start, end: end}
}
// initialise the date range picker
$('#reportrange').daterangepicker({
locale: {
format: 'YYYY-MM-DD HH:mm',
firstDay: 1
},
opens: 'left',
showISOWeekNumbers: true,
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')],
'This week': [moment().subtract(6, 'days').startOf('day'), moment().endOf('day').round10Minutes('ceil')],
'This month': [moment().subtract(32, 'days').startOf('day'), moment().endOf('day').round10Minutes('ceil')],
'This year': [moment().subtract(365, 'days').startOf('month'), moment().endOf('month').round10Minutes('ceil')]
}
}, setDateRange)
// update the ranges when the date range picker is opened
$('#reportrange').on('show.daterangepicker', function (ev, picker) {
picker.ranges = {
Today: [moment().subtract(1, 'days').round10Minutes(), moment().add(1, 'hour').round10Minutes('ceil')],
'This week': [moment().subtract(6, 'days').startOf('day'), moment().endOf('day').round10Minutes('ceil')],
'This month': [moment().subtract(32, 'days').startOf('day'), moment().endOf('day').round10Minutes('ceil')],
'This year': [moment().subtract(365, 'days').startOf('month'), moment().endOf('month').round10Minutes('ceil')]
}
})
const 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']
function getColor(value) {
if (value.startsWith('COLOUR')) {
return defaultColors[parseInt(value.substring(6)) % defaultColors.length]
}
return '#' + value
}
const 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
}
const 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
let updatedata = false
// update the legend
function updateLegend(plot) {
const [minx, maxx] = plot.layout.xaxis.range
Object.keys(plot.legendbyfield).forEach(function (field) {
// calculate minimum
const mintrace = plot.tracebyfield[field + '.min']
const minvalue = Math.min.apply(null, mintrace.y.filter(function (y, idx) {
const x = mintrace.x[idx]
return x >= minx && x <= maxx && y !== null
}))
// calculate average
const avgtrace = plot.tracebyfield[field]
let avgvalue
if (avgtrace.y.length) {
avgvalue = avgtrace.y.map(function (current, idx) {
const 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
const maxtrace = plot.tracebyfield[field + '.max']
const maxvalue = Math.max.apply(null, maxtrace.y.filter(function (y, idx) {
const x = maxtrace.x[idx]
return x >= minx && x <= maxx && y !== null
}))
// update legend
const columns = $(plot.legendbyfield[field]).find('td')
columns[2].textContent = (isNaN(minvalue) || !isFinite(minvalue)) ? '-' : d3.format('.4s')(minvalue)
columns[3].textContent = (isNaN(avgvalue) || !isFinite(avgvalue)) ? '-' : d3.format('.4s')(avgvalue)
columns[4].textContent = (isNaN(maxvalue) || !isFinite(maxvalue)) ? '-' : d3.format('.4s')(maxvalue)
})
}
// load graph data into the plot
function loadGraph(plot, traces, layout) {
// range of the x axis
let url = 'data/' + plot.graph.name
if (plot.layout) {
const [amin, amax] = plot.layout.xaxis.range
url += '?start=' + amin.substring(0, 16).replace(' ', 'T') + '&end=' + amax.substring(0, 16).replace(' ', 'T')
}
d3.csv(url).then(function (data) {
// clear traces
Object.keys(plot.tracebyfield).forEach(function (field) {
plot.tracebyfield[field].x = []
plot.tracebyfield[field].y = []
})
// load new data
data.forEach(function (row) {
Object.keys(plot.tracebyfield).forEach(function (field) {
const value = row[field] ? Number(row[field]) : null
if (row[field] || plot.tracebyfield[field].y.length > 0) {
plot.tracebyfield[field].x.push(row.time)
plot.tracebyfield[field].y.push(value)
}
})
})
// remove trailing nulls from traces
Object.keys(plot.tracebyfield).forEach(function (field) {
while (plot.tracebyfield[field].y[plot.tracebyfield[field].y.length - 1] === null) {
plot.tracebyfield[field].x.pop()
plot.tracebyfield[field].y.pop()
}
})
if (traces) {
// initial plot creation
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()
} else {
plot.layout.datarevision += 1
Plotly.react(plot, plot.data, plot.layout)
}
})
}
// check if we have been flagged to update the data series for the plots
function checkDataUpdates() {
try {
if (updatedata) {
updatedata = false
// go over all plots
$('.myplot').each(function (index, plot) {
if (plot.layout) {
loadGraph(plot)
}
})
}
} finally {
setTimeout(checkDataUpdates, 1000)
}
}
setTimeout(checkDataUpdates, 1000)
// every minute check if we should load new data
function checkNewData() {
setTimeout(checkNewData, 60000)
$('.myplot').each(function (index, plot) {
// if any plot has incomplete data, reload the data
if (plot.layout) {
if (moment().format('YYYY-MM-DD HH:mm') < plot.layout.xaxis.range[1]) { updatedata = true }
}
})
}
setTimeout(checkNewData, 60000)
// add a graph to the list of graphs
function addGraph(graph, size = 'sm') {
const clone = $('#template>:first-child').clone()
const plot = clone.find('.myplot')[0]
const legend = clone.find('.mylegend')
plot.graph = graph
// update graph title
clone.find('.graphtitle').append($('
').addClass('col').text(graph.host + ' / ')
.append($('').text(graph.graph_title))
.attr('title', graph.graph_info || ''))
// set the size changing actions
clone.find('.sizesm').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').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').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').click(function () {
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')
// prepare the graph configuration
const layout = JSON.parse(JSON.stringify(baseLayout))
if (plot.graph.graph_vlabel) {
layout.yaxis.title = plot.graph.graph_vlabel
}
if (plot.graph.graph_args && plot.graph.graph_args.match(/--logarithmic/)) {
layout.yaxis.type = 'log'
layout.yaxis.exponentformat = 'E'
}
// get x axis zoom from date range selector
const range = getDateRange()
layout.xaxis.range = [range.start, range.end]
// prepare the data series configuration
const traces = []
const tracebyfield = {}
plot.tracebyfield = tracebyfield
let stackgroup = 0
for (let i = 0; i < plot.graph.fields.length; i++) {
const field = plot.graph.fields[i]
const color = field.colour ? getColor(field.colour) : defaultColors[i % defaultColors.length]
if (field.draw === 'AREA' || field.draw === 'STACK' || field.draw === 'AREASTACK') {
if (!field.draw.match(/STACK/) && (!plot.graph.fields[i + 1] || plot.graph.fields[i + 1].draw.match(/STACK/))) {
stackgroup += 1
}
const 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) {
const trace = {
field_name: field.name,
name: field.label || field.name,
info: field.info || '',
line: {color: color},
hoverlabel: {bgcolor: color + 'c0'}
}
const minTrace = {
field_name: field.name,
showlegend: false,
hoverinfo: 'skip',
line: {width: 0},
connectgaps: true
}
const maxTrace = {
field_name: field.name,
showlegend: false,
hoverinfo: 'skip',
line: {width: 0},
connectgaps: true,
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) {
const 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) {
const 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 traces
legendrow.mouseover(function () {
if (plot.data) {
Plotly.restyle(plot, 'opacity', plot.data.map(
t => t.field_name === trace.field_name ? 1 : 0.1))
Plotly.restyle(plot, 'fillcolor', plot.data.map(function (t) {
if (t.showlegend === false) {
return (t.fillcolor || '#ffffff').substring(0, 7) + (t.field_name === trace.field_name ? '25' : '05')
}
return (t.fillcolor || '#ffffff').substring(0, 7) + (t.field_name === trace.field_name ? 'ff' : '30')
}))
}
})
}
})
// restore opacity after exiting the legend
legend.mouseout(function () {
if (plot.data) {
Plotly.restyle(plot, 'opacity', plot.data.map(t => 1))
Plotly.restyle(plot, 'fillcolor', plot.data.map(function (t) {
if (t.showlegend === false) {
return (t.fillcolor || '#ffffff').substring(0, 7) + '20'
}
return (t.fillcolor || '#ffffff').substring(0, 7) + 'c0'
}))
}
})
// fetch the data and plot it
loadGraph(plot, traces, layout)
// 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
const hosts = {}
const categories = []
for (const graph in graphs) {
const 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) {
const 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() {
const search = $('#graphfilter').val().toLowerCase().split(' ')
const hostFilter = $('#hostselect').val()
const 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
}
const description = (graph + ' ' + graphs[graph].graph_title + ' ' + graphs[graph].category).toLowerCase()
if (search.some(x => !description.includes(x))) {
return
}
const title = graphs[graph].graph_title || graph.split('/')[2]
const 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
})
}
}
return undefined
}).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
const 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')
})
// JSON serialise the data, compress and BASE64 encode
function compressData(data) {
const stream = new window.CompressionStream('deflate')
const writer = stream.writable.getWriter()
writer.write(new TextEncoder().encode(JSON.stringify(data)))
writer.close()
return new Response(stream.readable).arrayBuffer().then(
compressed => btoa(Array.from(new Uint8Array(compressed)).map(b => String.fromCharCode(b)).join('')))
}
// BASE64 decode, decompress and JSON deserialise the data
function decompressData(text) {
const stream = new window.DecompressionStream('deflate')
const writer = stream.writable.getWriter()
writer.write(Uint8Array.from(atob(text), x => x.charCodeAt(0)).buffer)
writer.close()
return new Response(stream.readable).arrayBuffer().then(
buffer => JSON.parse(new TextDecoder().decode(buffer))
)
}
// make the provided dashboard active
function setDashboard(dashboard) {
if (dashboard.dateRange) {
setDateRange(dashboard.dateRange.start, dashboard.dateRange.end)
}
setGraphs(dashboard.graphs)
if (dashboard.name) {
$('#dashboards button.dropdown-toggle span').text(dashboard.name)
}
}
// output readable but compact JSON
function getCurrentDashboard() {
let value = '{\n'
const name = $('#saveDashboardName').val()
if (name) {
value += ' "name": ' + JSON.stringify(name) + ',\n'
}
if ($('#saveDashboardDateRange:checked').length) {
const range = getDateRange()
value += ' "dateRange": ' + JSON.stringify({start: range.start, end: range.end}) + ',\n'
}
value += ' "graphs": ' + JSON.stringify(getCurrentGraphs()) + '\n}'
return value
}
// get dashboard data from URL
function loadDashboardFromHash() {
if (window.location.hash.length > 2) {
// get list of graphs from URL
decompressData(window.location.hash.slice(1)).then(function (dashboard) {
setDashboard(dashboard)
})
// remove the hash from the URL
history.replaceState(null, '', ' ')
}
}
// configure the dashboards button
$.getJSON('dashboards', function (dashboards) {
if (Object.keys(dashboards).length === 0) {
$('#dashboards').remove()
} else {
Object.keys(dashboards).sort().forEach(function (name) {
const option = $('