prometheus/web/ui/static/js/prom_console.js
Brian Brazil ce838ad6fc
Ensure that step is in milliseconds in console graphs. ()
Further precision is truncated by the Prometheus API, so the
steps don't end up quite aligning subsequently.

Fixes 

Signed-off-by: Brian Brazil <brian.brazil@robustperception.io>
2020-08-11 12:33:40 +01:00

685 lines
22 KiB
JavaScript

/*
* 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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': '&quot;',
"'": '&#39;',
"/": '&#x2F;'
};
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));
};