/* * Functions to make it easier to write prometheus consoles, such * as graphs. * */ PromConsole = {}; PromConsole.NumberFormatter = {}; PromConsole.NumberFormatter.prefixesBig = ["k", "M", "G", "T", "P", "E", "Z", "Y"]; PromConsole.NumberFormatter.prefixesBig1024 = ["ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi"]; PromConsole.NumberFormatter.prefixesSmall = ["m", "u", "n", "p", "f", "a", "z", "y"]; PromConsole._stripTrailingZero = function(x) { if (x.indexOf("e") == -1) { // It's not safe to strip if it's scientific notation. return x.replace(/\.?0*$/, ''); } return x; }; // Humanize a number. PromConsole.NumberFormatter.humanize = function(x) { if (x === null) { return "null"; } var ret = PromConsole.NumberFormatter._humanize( x, PromConsole.NumberFormatter.prefixesBig, PromConsole.NumberFormatter.prefixesSmall, 1000); x = ret[0]; var prefix = ret[1]; if (Math.abs(x) < 1) { return x.toExponential(3) + prefix; } return PromConsole._stripTrailingZero(x.toFixed(3)) + prefix; }; // Humanize a number, don't use milli/micro/etc. prefixes. PromConsole.NumberFormatter.humanizeNoSmallPrefix = function(x) { if (x === null) { return "null"; } if (Math.abs(x) < 1) { return PromConsole._stripTrailingZero(x.toPrecision(3)); } var ret = PromConsole.NumberFormatter._humanize( x, PromConsole.NumberFormatter.prefixesBig, [], 1000); x = ret[0]; var prefix = ret[1]; return PromConsole._stripTrailingZero(x.toFixed(3)) + prefix; }; // Humanize a number with 1024 as the base, rather than 1000. PromConsole.NumberFormatter.humanize1024 = function(x) { if (x === null) { return "null"; } var ret = PromConsole.NumberFormatter._humanize( x, PromConsole.NumberFormatter.prefixesBig1024, [], 1024); x = ret[0]; var prefix = ret[1]; if (Math.abs(x) < 1) { return x.toExponential(3) + prefix; } return PromConsole._stripTrailingZero(x.toFixed(3)) + prefix; }; // Humanize a number, returning an exact representation. PromConsole.NumberFormatter.humanizeExact = function(x) { if (x === null) { return "null"; } var ret = PromConsole.NumberFormatter._humanize( x, PromConsole.NumberFormatter.prefixesBig, PromConsole.NumberFormatter.prefixesSmall, 1000); return ret[0] + ret[1]; }; PromConsole.NumberFormatter._humanize = function(x, prefixesBig, prefixesSmall, factor) { var prefix = ""; if (x === 0) { /* Do nothing. */ } else if (Math.abs(x) >= 1) { for (var i=0; i < prefixesBig.length && Math.abs(x) >= factor; ++i) { x /= factor; prefix = prefixesBig[i]; } } else { for (var i=0; i < prefixesSmall.length && Math.abs(x) < 1; ++i) { x *= factor; prefix = prefixesSmall[i]; } } return [x, prefix]; }; PromConsole.TimeControl = function() { document.getElementById("prom_graph_duration_shrink").onclick = this.decreaseDuration.bind(this); document.getElementById("prom_graph_duration_grow").onclick = this.increaseDuration.bind(this); document.getElementById("prom_graph_time_back").onclick = this.decreaseEnd.bind(this); document.getElementById("prom_graph_time_forward").onclick = this.increaseEnd.bind(this); document.getElementById("prom_graph_refresh_button").onclick = this.refresh.bind(this); this.durationElement = document.getElementById("prom_graph_duration"); this.endElement = document.getElementById("prom_graph_time_end"); this.durationElement.oninput = this.dispatch.bind(this); this.endElement.oninput = this.dispatch.bind(this); this.endElement.oninput = this.dispatch.bind(this); this.refreshValueElement = document.getElementById("prom_graph_refresh_button_value"); var refreshList = document.getElementById("prom_graph_refresh_intervals"); var refreshIntervals = ["Off", "1m", "5m", "15m", "1h"]; for (var i=0; i < refreshIntervals.length; ++i) { var li = document.createElement("li"); li.onclick = this.setRefresh.bind(this, refreshIntervals[i]); li.textContent = refreshIntervals[i]; refreshList.appendChild(li); } this.durationElement.value = PromConsole.TimeControl.prototype.getHumanDuration( PromConsole.TimeControl._initialValues.duration); if (!PromConsole.TimeControl._initialValues.endTimeNow) { this.endElement.value = PromConsole.TimeControl.prototype.getHumanDate( new Date(PromConsole.TimeControl._initialValues.endTime * 1000)); } }; PromConsole.TimeControl.timeFactors = { "y": 60 * 60 * 24 * 365, "w": 60 * 60 * 24 * 7, "d": 60 * 60 * 24, "h": 60 * 60, "m": 60, "s": 1 }; PromConsole.TimeControl.stepValues = [ "10s", "1m", "5m", "15m", "30m", "1h", "2h", "6h", "12h", "1d", "2d", "1w", "2w", "4w", "8w", "1y", "2y" ]; PromConsole.TimeControl.prototype._setHash = function() { var duration = this.parseDuration(this.durationElement.value); var endTime = this.getEndDate() / 1000; window.location.hash = "#pctc" + encodeURIComponent(JSON.stringify( {duration: duration, endTime: endTime})); }; PromConsole.TimeControl._initialValues = function() { var hash = window.location.hash; var values; if (hash.indexOf('#pctc') === 0) { values = JSON.parse(decodeURIComponent(hash.substring(5))); } else { values = {duration: 3600, endTime: 0}; } if (values.endTime == 0) { values.endTime = new Date().getTime() / 1000; values.endTimeNow = true; } return values; }(); PromConsole.TimeControl.prototype.parseDuration = function(durationText) { var durationRE = new RegExp("^([0-9]+)([ywdhms]?)$"); var matches = durationText.match(durationRE); if (!matches) { return 3600; } var value = parseInt(matches[1]); var unit = matches[2] || 's'; return value * PromConsole.TimeControl.timeFactors[unit]; }; PromConsole.TimeControl.prototype.getHumanDuration = function(duration) { var units = []; for (var key in PromConsole.TimeControl.timeFactors) { units.push([PromConsole.TimeControl.timeFactors[key], key]); } units.sort(function(a, b) { return b[0] - a[0]; }); for (var i = 0; i < units.length; ++i) { if (duration % units[i][0] === 0) { return (duration / units[i][0]) + units[i][1]; } } return duration; }; PromConsole.TimeControl.prototype.increaseDuration = function() { var durationSeconds = this.parseDuration(this.durationElement.value); for (var i = 0; i < PromConsole.TimeControl.stepValues.length; i++) { if (durationSeconds < this.parseDuration(PromConsole.TimeControl.stepValues[i])) { this.setDuration(PromConsole.TimeControl.stepValues[i]); this.dispatch(); return; } } }; PromConsole.TimeControl.prototype.decreaseDuration = function() { var durationSeconds = this.parseDuration(this.durationElement.value); for (var i = PromConsole.TimeControl.stepValues.length - 1; i >= 0; i--) { if (durationSeconds > this.parseDuration(PromConsole.TimeControl.stepValues[i])) { this.setDuration(PromConsole.TimeControl.stepValues[i]); this.dispatch(); return; } } }; PromConsole.TimeControl.prototype.setDuration = function(duration) { this.durationElement.value = duration; this._setHash(); }; PromConsole.TimeControl.prototype.getEndDate = function() { if (this.endElement.value === '') { return null; } var dateParts = this.endElement.value.split(/[- :]/) return Date.UTC(dateParts[0], dateParts[1] - 1, dateParts[2], dateParts[3], dateParts[4]); }; PromConsole.TimeControl.prototype.getOrSetEndDate = function() { var date = this.getEndDate(); if (date) { return date; } date = new Date(); this.setEndDate(date); return date.getTime(); }; PromConsole.TimeControl.prototype.getHumanDate = function(date) { var hours = date.getUTCHours() < 10 ? '0' + date.getUTCHours() : date.getUTCHours(); var minutes = date.getUTCMinutes() < 10 ? '0' + date.getUTCMinutes() : date.getUTCMinutes(); return date.getUTCFullYear() + "-" + (date.getUTCMonth()+1) + "-" + date.getUTCDate() + " " + hours + ":" + minutes; }; PromConsole.TimeControl.prototype.setEndDate = function(date) { this.setRefresh("Off"); this.endElement.value = this.getHumanDate(date); this._setHash(); }; PromConsole.TimeControl.prototype.increaseEnd = function() { // Increase duration 25% range & convert ms to s. this.setEndDate(new Date(this.getOrSetEndDate() + this.parseDuration(this.durationElement.value) * 1000/4 )); this.dispatch(); }; PromConsole.TimeControl.prototype.decreaseEnd = function() { this.setEndDate(new Date(this.getOrSetEndDate() - this.parseDuration(this.durationElement.value) * 1000/4 )); this.dispatch(); }; PromConsole.TimeControl.prototype.refresh = function() { this.endElement.value = ''; this._setHash(); this.dispatch(); }; PromConsole.TimeControl.prototype.dispatch = function() { var durationSeconds = this.parseDuration(this.durationElement.value); var end = this.getEndDate(); if (end === null) { end = new Date().getTime(); } for (var i = 0; i< PromConsole._graph_registry.length; i++) { var graph = PromConsole._graph_registry[i]; graph.params.duration = durationSeconds; graph.params.endTime = end / 1000; graph.dispatch(); } }; PromConsole.TimeControl.prototype._refreshInterval = null; PromConsole.TimeControl.prototype.setRefresh = function(duration) { if (this._refreshInterval !== null) { window.clearInterval(this._refreshInterval); this._refreshInterval = null; } if (duration != "Off") { if (this.endElement.value !== '') { this.refresh(); } var durationSeconds = this.parseDuration(duration); this._refreshInterval = window.setInterval(this.dispatch.bind(this), durationSeconds * 1000); } this.refreshValueElement.textContent = duration; }; // List of all graphs, used by time controls. PromConsole._graph_registry = []; PromConsole.graphDefaults = { expr: null, // Expression to graph. Can be a list of strings. node: null, // DOM node to place graph under. // How long the graph is over, in seconds. duration: PromConsole.TimeControl._initialValues.duration, // The unixtime the graph ends at. endTime: PromConsole.TimeControl._initialValues.endTime, width: null, // Height of the graph div, excluding titles and legends. // Defaults to auto-detection. height: 200, // Height of the graph div, excluding titles and legends. min: "auto", // Minimum Y-axis value, defaults to lowest data value. max: undefined, // Maximum Y-axis value, defaults to highest data value. renderer: 'line', // Type of graphs, options are 'line' and 'area'. name: null, // What to call plots, defaults to trying to do // something reasonable. // If a string, it'll use that. [[ label ]] will be substituted. // If a function it'll be called with a map of keys to values, // and should return the name to use. // Can be a list of strings/functions, each element // will be applied to the plots from the corresponding // element of the expr list. xTitle: "Time", // The title of the x axis. yUnits: "", // The units of the y axis. yTitle: "", // The title of the y axis. // Number formatter for y axis. yAxisFormatter: PromConsole.NumberFormatter.humanize, // Number formatter for y values hover detail. yHoverFormatter: PromConsole.NumberFormatter.humanizeExact, // Color scheme to be used by the plots. Can be either a list of hex color // codes or one of the color scheme names supported by Rickshaw. colorScheme: null, }; PromConsole.Graph = function(params) { for (var k in PromConsole.graphDefaults) { if (!(k in params)) { params[k] = PromConsole.graphDefaults[k]; } } if (typeof params.expr == "string") { params.expr = [params.expr]; } if (typeof params.name == "string" || typeof params.name == "function") { var name = []; for (var i = 0; i < params.expr.length; i++) { name.push(params.name); } params.name = name; } this.params = params; this.rendered_data = null; // Keep a reference so that further updates (e.g. annotations) can be made // by the user in their templates. this.rickshawGraph = null; PromConsole._graph_registry.push(this); /* * Table layout: * | yTitle | Graph | * | | xTitle | * | /graph | Legend | */ var table = document.createElement("table"); table.className = "prom_graph_table"; params.node.appendChild(table); var tr = document.createElement("tr"); table.appendChild(tr); var yTitleTd = document.createElement("td"); tr.appendChild(yTitleTd); var yTitleDiv = document.createElement("td"); yTitleTd.appendChild(yTitleDiv); yTitleDiv.className = "prom_graph_ytitle"; yTitleDiv.textContent = params.yTitle + (params.yUnits ? " (" + params.yUnits.trim() + ")" : ""); this.graphTd = document.createElement("td"); tr.appendChild(this.graphTd); this.graphTd.className = "rickshaw_graph"; this.graphTd.width = params.width; this.graphTd.height = params.height; tr = document.createElement("tr"); table.appendChild(tr); tr.appendChild(document.createElement("td")); var xTitleTd = document.createElement("td"); tr.appendChild(xTitleTd); xTitleTd.className = "prom_graph_xtitle"; xTitleTd.textContent = params.xTitle; tr = document.createElement("tr"); table.appendChild(tr); var graphLinkTd = document.createElement("td"); tr.appendChild(graphLinkTd); var graphLinkA = document.createElement("a"); graphLinkTd.appendChild(graphLinkA); graphLinkA.className = "prom_graph_link"; graphLinkA.textContent = "+"; graphLinkA.href = PromConsole._graphsToSlashGraphURL(params.expr); var legendTd = document.createElement("td"); tr.appendChild(legendTd); this.legendDiv = document.createElement("div"); legendTd.width = params.width; legendTd.appendChild(this.legendDiv); window.addEventListener('resize', function() { if(this.rendered_data !== null) { this._render(this.rendered_data); } }.bind(this)); this.dispatch(); }; PromConsole.Graph.prototype._parseValue = function(value) { var val = parseFloat(value); if (isNaN(val)) { // "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). The // can't be graphed, so show them as gaps (null). return null; } return val; }; PromConsole.Graph.prototype._escapeHTML = function(string) { var entityMap = { "&": "&", "<": "<", ">": ">", '"': '"', "'": ''', "/": '/' }; return string.replace(/[&<>"'\/]/g, function (s) { return entityMap[s]; }); }; PromConsole.Graph.prototype._render = function(data) { var self = this; var palette = new Rickshaw.Color.Palette({scheme: this.params.colorScheme}); var series = []; // This will be used on resize. this.rendered_data = data; var nameFuncs = []; if (this.params.name === null) { var chooser = PromConsole._chooseNameFunction(data); for (var i = 0; i < this.params.expr.length; i++) { nameFuncs.push(chooser); } } else { for (var i = 0; i < this.params.name.length; i++) { if (typeof this.params.name[i] == "string") { nameFuncs.push(function(i, metric) { return PromConsole._interpolateName(this.params.name[i], metric); }.bind(this, i)); } else { nameFuncs.push(this.params.name[i]); } } } // Get the data into the right format. var seriesLen = 0; for (var e = 0; e < data.length; e++) { for (var i = 0; i < data[e].data.result.length; i++) { series[seriesLen] = { data: data[e].data.result[i].values.map(function(s) { return {x: s[0], y: self._parseValue(s[1])}; }), color: palette.color(), name: self._escapeHTML(nameFuncs[e](data[e].data.result[i].metric)), }; // Insert nulls for all missing steps. var newSeries = []; var pos = 0; var start = self.params.endTime - self.params.duration; var step = Math.floor(self.params.duration / this.graphTd.offsetWidth * 1000) / 1000; for (var t = start; t <= self.params.endTime; t += step) { // Allow for floating point inaccuracy. if (series[seriesLen].data.length > pos && series[seriesLen].data[pos].x < t + step / 100) { newSeries.push(series[seriesLen].data[pos]); pos++; } else { newSeries.push({x: t, y: null}); } } series[seriesLen].data = newSeries; seriesLen++; } } this._clearGraph(); if (!series.length) { var errorText = document.createElement("div"); errorText.className = 'prom_graph_error'; errorText.textContent = 'No timeseries returned'; this.graphTd.appendChild(errorText); return; } // Render. var graph = new Rickshaw.Graph({ interpolation: "linear", width: this.graphTd.offsetWidth, height: this.params.height, element: this.graphTd, renderer: this.params.renderer, max: this.params.max, min: this.params.min, series: series }); var hoverDetail = new Rickshaw.Graph.HoverDetail({ graph: graph, onRender: function() { var xLabel = this.element.getElementsByClassName("x_label")[0]; var item = this.element.getElementsByClassName("item")[0]; if (xLabel.offsetWidth + xLabel.offsetLeft + this.element.offsetLeft > graph.element.offsetWidth || item.offsetWidth + item.offsetLeft + this.element.offsetLeft > graph.element.offsetWidth) { xLabel.classList.add("prom_graph_hover_flipped"); item.classList.add("prom_graph_hover_flipped"); } else { xLabel.classList.remove("prom_graph_hover_flipped"); item.classList.remove("prom_graph_hover_flipped"); } }, yFormatter: function(y) { if (y === null) { return ""; } return this.params.yHoverFormatter(y) + this.params.yUnits; }.bind(this) }); var yAxis = new Rickshaw.Graph.Axis.Y({ graph: graph, tickFormat: this.params.yAxisFormatter }); var xAxis = new Rickshaw.Graph.Axis.Time({ graph: graph, }); var legend = new Rickshaw.Graph.Legend({ graph: graph, element: this.legendDiv }); xAxis.render(); yAxis.render(); graph.render(); this.rickshawGraph = graph; }; PromConsole.Graph.prototype._clearGraph = function() { while (this.graphTd.lastChild) { this.graphTd.removeChild(this.graphTd.lastChild); } while (this.legendDiv.lastChild) { this.legendDiv.removeChild(this.legendDiv.lastChild); } this.rickshawGraph = null; }; PromConsole.Graph.prototype._xhrs = []; PromConsole.Graph.prototype.buildQueryUrl = function(expr) { var p = this.params; return PATH_PREFIX + "/api/v1/query_range?query=" + encodeURIComponent(expr) + "&step=" + Math.floor(p.duration / this.graphTd.offsetWidth * 1000) / 1000 + "&start=" + (p.endTime - p.duration) + "&end=" + p.endTime; }; PromConsole.Graph.prototype.dispatch = function() { for (var j = 0; j < this._xhrs.length; j++) { this._xhrs[j].abort(); } var all_data = new Array(this.params.expr.length); this._xhrs = new Array(this.params.expr.length); var pending_requests = this.params.expr.length; for (var i = 0; i < this.params.expr.length; ++i) { var endTime = this.params.endTime; var url = this.buildQueryUrl(this.params.expr[i]); var xhr = new XMLHttpRequest(); xhr.open('get', url, true); xhr.responseType = 'json'; xhr.onerror = function(xhr, i) { this._clearGraph(); var errorText = document.createElement("div"); errorText.className = 'prom_graph_error'; errorText.textContent = 'Error loading data'; this.graphTd.appendChild(errorText); console.log('Error loading data for ' + this.params.expr[i]); pending_requests = 0; // onabort gets any aborts. for (var j = 0; j < pending_requests; j++) { this._xhrs[j].abort(); } }.bind(this, xhr, i); xhr.onload = function(xhr, i) { if (pending_requests === 0) { // Got an error before this success. return; } var data = xhr.response; if (typeof data !== "object") { data = JSON.parse(xhr.responseText); } pending_requests -= 1; all_data[i] = data; if (pending_requests === 0) { this._xhrs = []; this._render(all_data); } }.bind(this, xhr, i); xhr.send(); this._xhrs[i] = xhr; } var loadingImg = document.createElement("img"); loadingImg.src = PATH_PREFIX + '/static/img/ajax-loader.gif'; loadingImg.alt = 'Loading...'; loadingImg.className = 'prom_graph_loading'; this.graphTd.appendChild(loadingImg); }; // Substitute the value of 'label' for [[ label ]]. PromConsole._interpolateName = function(name, metric) { var re = /(.*?)\[\[\s*(\w+)+\s*\]\](.*?)/g; var result = ''; while (match = re.exec(name)) { result = result + match[1] + metric[match[2]] + match[3]; } if (!result) { return name; } return result; }; // Given the data returned by the API, return an appropriate function // to return plot names. PromConsole._chooseNameFunction = function(data) { // By default, use the full metric name. var nameFunc = function (metric) { var name = metric.__name__ + "{"; for (var label in metric) { if (label.substring(0,2) == "__") { continue; } name += label + "='" + metric[label] + "',"; } return name + "}"; }; // If only one label varies, use that value. var labelValues = {}; for (var e = 0; e < data.length; e++) { for (var i = 0; i < data[e].data.result.length; i++) { for (var label in data[e].data.result[i].metric) { if (!(label in labelValues)) { labelValues[label] = {}; } labelValues[label][data[e].data.result[i].metric[label]] = 1; } } } var multiValueLabels = []; for (var label in labelValues) { if (Object.keys(labelValues[label]).length > 1) { multiValueLabels.push(label); } } if (multiValueLabels.length == 1) { nameFunc = function(metric) { return metric[multiValueLabels[0]]; }; } return nameFunc; }; // Given a list of expressions, produce the /graph url for them. PromConsole._graphsToSlashGraphURL = function(exprs) { var data = []; for (var i = 0; i < exprs.length; ++i) { data.push({'expr': exprs[i], 'tab': 0}); } return PATH_PREFIX + '/graph#' + encodeURIComponent(JSON.stringify(data)); };