luci/applications/luci-app-statistics/htdocs/luci-static/resources/statistics/rrdtool.js
Hannu Nyman b43a5da43e luci-app-statistics: shorten delay at graph endpoint to 15s
Shorten the visible delay at the statistics graph endpoint
from 60 seconds to 15 seconds.

The 60s delay was recently added as a way to remove a possible
visible gap at the end of the hourly graph due to absence of
recent data. Default data collection interval is 30s, so that
60s guarantees that there is data upto to the end of the graph.

However, that 60s makes any recent activity to get displayed
really slowly, after a 60-89 second delay.

Shortening the gap to 15s, half of the default data collection
period, should balance things:
* Half of the time there may be a really narrow gap visible and
  half of the time there is no gap at all.
* The most recent displayed data point is from 15-44 seconds ago
  (instead of 60-89 seconds ago).

Neither 15 or 60 seconds makes any impact to the longer graphs.
To accommodate to possibly shorter timespans, and to avoid the
occasional wrong data series selected for longer period graphs
(see #4065) the endpoint delay might be relative to the timespan.

Signed-off-by: Hannu Nyman <hannu.nyman@iki.fi>
2020-12-18 17:41:18 +02:00

758 lines
21 KiB
JavaScript

'use strict';
'require baseclass';
'require fs';
'require uci';
'require tools.prng as random';
function subst(str, val) {
return str.replace(/%(H|pn|pi|dt|di|ds)/g, function(m, p1) {
switch (p1) {
case 'H': return val.host || '';
case 'pn': return val.plugin || '';
case 'pi': return val.pinst || '';
case 'dt': return val.dtype || '';
case 'di': return val.dinst || '';
case 'ds': return val.dsrc || '';
}
});
}
var i18n = L.Class.singleton({
title: function(host, plugin, pinst, dtype, dinst, user_title) {
var title = user_title || 'p=%s/pi=%s/dt=%s/di=%s'.format(
plugin,
pinst || '(nil)',
dtype || '(nil)',
dinst || '(nil)'
);
return subst(title, {
host: host,
plugin: plugin,
pinst: pinst,
dtype: dtype,
dinst: dinst
});
},
label: function(host, plugin, pinst, dtype, dinst, user_label) {
var label = user_label || 'dt=%s/%di=%s'.format(
dtype || '(nil)',
dinst || '(nil)'
);
return subst(label, {
host: host,
plugin: plugin,
pinst: pinst,
dtype: dtype,
dinst: dinst
});
},
ds: function(host, source) {
var label = source.title || 'dt=%s/di=%s/ds=%s'.format(
source.type || '(nil)',
source.instance || '(nil)',
source.ds || '(nil)'
);
return subst(label, {
host: host,
dtype: source.type,
dinst: source.instance,
dsrc: source.ds
}).replace(/:/g, '\\:');
}
});
var colors = L.Class.singleton({
fromString: function(s) {
if (typeof(s) != 'string' || !s.match(/^[0-9a-fA-F]{6}$/))
return null;
return [
parseInt(s.substring(0, 2), 16),
parseInt(s.substring(2, 4), 16),
parseInt(s.substring(4, 6), 16)
];
},
asString: function(c) {
if (!Array.isArray(c) || c.length != 3)
return null;
return '%02x%02x%02x'.format(c[0], c[1], c[2]);
},
defined: function(i) {
var t = [
[230, 25, 75],
[245, 130, 48],
[255, 225, 25],
[60, 180, 75],
[70, 240, 240],
[0, 130, 200],
[0, 0, 128],
[170, 110, 40]
];
return this.asString(t[i % t.length]);
},
random: function() {
var r = random.get(255),
g = random.get(255),
min = 0, max = 255;
if (r + g < 255)
min = 255 - r - g;
else
max = 511 - r - g;
var b = min + Math.floor(random.get() * (max - min));
return [ r, g, b ];
},
faded: function(fg, bg, alpha) {
fg = this.fromString(fg) || (this.asString(fg) ? fg : null);
bg = this.fromString(bg) || (this.asString(bg) ? bg : [255, 255, 255]);
alpha = !isNaN(alpha) ? +alpha : 0.25;
if (!fg)
return null;
return [
(alpha * fg[0]) + ((1.0 - alpha) * bg[0]),
(alpha * fg[1]) + ((1.0 - alpha) * bg[1]),
(alpha * fg[2]) + ((1.0 - alpha) * bg[2])
];
}
});
var rrdtree = {},
graphdefs = {};
return baseclass.extend({
__init__: function() {
this.opts = {};
},
load: function() {
return Promise.all([
L.resolveDefault(fs.list('/www' + L.resource('statistics/rrdtool/definitions')), []),
fs.trimmed('/proc/sys/kernel/hostname'),
uci.load('luci_statistics')
]).then(L.bind(function(data) {
var definitions = data[0],
hostname = data[1];
this.opts.host = uci.get('luci_statistics', 'collectd', 'Hostname') || hostname;
this.opts.timespan = uci.get('luci_statistics', 'rrdtool', 'default_timespan') || 900;
this.opts.width = uci.get('luci_statistics', 'rrdtool', 'image_width') || 400;
this.opts.height = uci.get('luci_statistics', 'rrdtool', 'image_height') || 100;
this.opts.rrdpath = (uci.get('luci_statistics', 'collectd_rrdtool', 'DataDir') || '/tmp/rrd').replace(/\/$/, '');
this.opts.rrasingle = (uci.get('luci_statistics', 'collectd_rrdtool', 'RRASingle') == '1');
this.opts.rramax = (uci.get('luci_statistics', 'collectd_rrdtool', 'RRAMax') == '1');
graphdefs = {};
var tasks = [ this.scan() ];
for (var i = 0; i < definitions.length; i++) {
var m = definitions[i].name.match(/^(.+)\.js$/);
if (definitions[i].type != 'file' || m == null)
continue;
tasks.push(L.require('statistics.rrdtool.definitions.' + m[1]).then(L.bind(function(name, def) {
graphdefs[name] = def;
}, this, m[1])));
}
return Promise.all(tasks);
}, this));
},
ls: function() {
var dir = this.opts.rrdpath;
return L.resolveDefault(fs.list(dir), []).then(function(entries) {
var tasks = [];
for (var i = 0; i < entries.length; i++) {
if (entries[i].type != 'directory')
continue;
tasks.push(L.resolveDefault(fs.list(dir + '/' + entries[i].name), []).then(L.bind(function(entries) {
var tasks = [];
for (var j = 0; j < entries.length; j++) {
if (entries[j].type != 'directory')
continue;
tasks.push(L.resolveDefault(fs.list(dir + '/' + this.name + '/' + entries[j].name), []).then(L.bind(function(entries) {
return Object.assign(this, {
entries: entries.filter(function(e) {
return e.type == 'file' && e.name.match(/\.rrd$/);
})
});
}, entries[j])));
}
return Promise.all(tasks).then(L.bind(function(entries) {
return Object.assign(this, {
entries: entries
});
}, this));
}, entries[i])));
}
return Promise.all(tasks);
});
},
scan: function() {
return this.ls().then(L.bind(function(entries) {
rrdtree = {};
for (var i = 0; i < entries.length; i++) {
var hostInstance = entries[i].name;
rrdtree[hostInstance] = rrdtree[hostInstance] || {};
for (var j = 0; j < entries[i].entries.length; j++) {
var m = entries[i].entries[j].name.match(/^([^-]+)(?:-(.+))?$/);
if (!m)
continue;
var pluginName = m[1],
pluginInstance = m[2] || '';
rrdtree[hostInstance][pluginName] = rrdtree[hostInstance][pluginName] || {};
rrdtree[hostInstance][pluginName][pluginInstance] = rrdtree[hostInstance][pluginName][pluginInstance] || {};
for (var k = 0; k < entries[i].entries[j].entries.length; k++) {
var m = entries[i].entries[j].entries[k].name.match(/^([^-]+)(?:-(.+))?\.rrd$/);
if (!m)
continue;
var dataType = m[1],
dataInstance = m[2] || '';
rrdtree[hostInstance][pluginName][pluginInstance][dataType] = rrdtree[hostInstance][pluginName][pluginInstance][dataType] || [];
rrdtree[hostInstance][pluginName][pluginInstance][dataType].push(dataInstance);
}
}
}
}, this));
},
hostInstances: function() {
return Object.keys(rrdtree).sort();
},
pluginNames: function(hostInstance) {
return Object.keys(rrdtree[hostInstance] || {}).sort();
},
pluginInstances: function(hostInstance, pluginName) {
return Object.keys((rrdtree[hostInstance] || {})[pluginName] || {}).sort(function(a, b) {
var x = a.match(/^(\d+)\b/),
y = b.match(/^(\d+)\b/);
if (!x != !y)
return !x - !y;
else if (x && y && x[0] != y[0])
return +x[0] - +y[0];
else
return a > b;
});
},
dataTypes: function(hostInstance, pluginName, pluginInstance) {
return Object.keys(((rrdtree[hostInstance] || {})[pluginName] || {})[pluginInstance] || {}).sort();
},
dataInstances: function(hostInstance, pluginName, pluginInstance, dataType) {
return ((((rrdtree[hostInstance] || {})[pluginName] || {})[pluginInstance] || {})[dataType] || []).sort();
},
pluginTitle: function(pluginName) {
var def = graphdefs[pluginName];
return (def ? def.title : null) || pluginName;
},
hasDefinition: function(pluginName) {
return (graphdefs[pluginName] != null);
},
hasInstanceDetails: function(hostInstance, pluginName, pluginInstance) {
var def = graphdefs[pluginName];
if (!def || typeof(def.rrdargs) != 'function')
return false;
var optlist = this._forcelol(def.rrdargs(this, hostInstance, pluginName, pluginInstance, null, false));
for (var i = 0; i < optlist.length; i++)
if (optlist[i].detail)
return true;
return false;
},
_mkpath: function(host, plugin, plugin_instance, dtype, data_instance) {
var path = host + '/' + plugin;
if (plugin_instance != null && plugin_instance != '')
path += '-' + plugin_instance;
path += '/' + dtype;
if (data_instance != null && data_instance != '')
path += '-' + data_instance;
return path;
},
mkrrdpath: function(/* ... */) {
return '%s/%s.rrd'.format(
this.opts.rrdpath,
this._mkpath.apply(this, arguments)
).replace(/[\\:]/g, '\\$&');
},
_forcelol: function(list) {
return L.isObject(list[0]) ? list : [ list ];
},
_rrdtool: function(def, rrd, timespan, width, height, cache) {
var cmdline = [
'graph', '-', '-a', 'PNG',
'-s', 'NOW-%s'.format(timespan || this.opts.timespan),
'-e', 'NOW-15',
'-w', width || this.opts.width,
'-h', height || this.opts.height
];
for (var i = 0; i < def.length; i++) {
var opt = String(def[i]);
if (rrd)
opt = opt.replace(/\{file\}/g, rrd);
cmdline.push(opt);
}
if (L.isObject(cache)) {
var key = sfh(cmdline.join('\0'));
if (!cache.hasOwnProperty(key))
cache[key] = fs.exec_direct('/usr/bin/rrdtool', cmdline, 'blob', true);
return cache[key];
}
return fs.exec_direct('/usr/bin/rrdtool', cmdline, 'blob', true);
},
_generic: function(opts, host, plugin, plugin_instance, dtype, index) {
var defs = [],
gopts = this.opts,
_args = [],
_sources = [],
_stack_neg = [],
_stack_pos = [],
_longest_name = 0,
_has_totals = false;
/* use the plugin+instance+type as seed for the prng to ensure the
same pseudo-random color sequence for each render */
random.seed(sfh([plugin, plugin_instance || '', dtype || ''].join('.')));
function __def(source) {
var inst = source.sname,
rrd = source.rrd.replace(/[\\:]/g, '\\$&'),
ds = source.ds || 'value';
_args.push(
'DEF:%s_avg_raw=%s:%s:AVERAGE'.format(inst, rrd, ds),
'CDEF:%s_avg=%s_avg_raw,%s'.format(inst, inst, source.transform_rpn)
);
if (!gopts.rrasingle)
_args.push(
'DEF:%s_min_raw=%s:%s:MIN'.format(inst, rrd, ds),
'CDEF:%s_min=%s_min_raw,%s'.format(inst, inst, source.transform_rpn),
'DEF:%s_max_raw=%s:%s:MAX'.format(inst, rrd, ds),
'CDEF:%s_max=%s_max_raw,%s'.format(inst, inst, source.transform_rpn)
);
_args.push(
'CDEF:%s_nnl=%s_avg,UN,0,%s_avg,IF'.format(inst, inst, inst)
);
}
function __cdef(source) {
var prev;
if (source.flip)
prev = _stack_neg[_stack_neg.length - 1];
else
prev = _stack_pos[_stack_pos.length - 1];
/* is first source in stack or overlay source: source_stk = source_nnl */
if (prev == null || source.overlay) {
/* create cdef statement for cumulative stack (no NaNs) and also
for display (preserving NaN where no points should be displayed) */
if (gopts.rrasingle || !gopts.rramax)
_args.push(
'CDEF:%s_stk=%s_nnl'.format(source.sname, source.sname),
'CDEF:%s_plot=%s_avg'.format(source.sname, source.sname)
);
else
_args.push(
'CDEF:%s_stk=%s_nnl'.format(source.sname, source.sname),
'CDEF:%s_plot=%s_max'.format(source.sname, source.sname)
);
}
/* is subsequent source without overlay: source_stk = source_nnl + previous_stk */
else {
/* create cdef statement */
if (gopts.rrasingle || !gopts.rramax)
_args.push(
'CDEF:%s_stk=%s_nnl,%s_stk,+'.format(source.sname, source.sname, prev),
'CDEF:%s_plot=%s_avg,%s_stk,+'.format(source.sname, source.sname, prev)
);
else
_args.push(
'CDEF:%s_stk=%s_nnl,%s_stk,+'.format(source.sname, source.sname, prev),
'CDEF:%s_plot=%s_max,%s_stk,+'.format(source.sname, source.sname, prev)
);
}
/* create multiply by minus one cdef if flip is enabled */
if (source.flip) {
_args.push('CDEF:%s_neg=%s_plot,-1,*'.format(source.sname, source.sname));
/* push to negative stack if overlay is disabled */
if (!source.overlay)
_stack_neg.push(source.sname);
}
/* no flipping, push to positive stack if overlay is disabled */
else if (!source.overlay) {
/* push to positive stack */
_stack_pos.push(source.sname);
}
/* calculate total amount of data if requested */
if (source.total)
_args.push(
'CDEF:%s_avg_sample=%s_avg,UN,0,%s_avg,IF,sample_len,*'.format(source.sname, source.sname, source.sname),
'CDEF:%s_avg_sum=PREV,UN,0,PREV,IF,%s_avg_sample,+'.format(source.sname, source.sname, source.sname)
);
}
/* local helper: create cdefs required for calculating total values */
function __cdef_totals() {
if (_has_totals)
_args.push(
'CDEF:mytime=%s_avg,TIME,TIME,IF'.format(_sources[0].sname),
'CDEF:sample_len_raw=mytime,PREV(mytime),-',
'CDEF:sample_len=sample_len_raw,UN,0,sample_len_raw,IF'
);
}
/* local helper: create line and area statements */
function __line(source) {
var line_color, area_color, legend, variable;
/* find colors: try source, then opts.colors; fall back to random color */
if (typeof(source.color) == 'string') {
line_color = source.color;
area_color = colors.fromString(line_color);
}
else if (typeof(opts.colors[source.name.replace(/\W/g, '_')]) == 'string') {
line_color = opts.colors[source.name.replace(/\W/g, '_')];
area_color = colors.fromString(line_color);
}
else {
area_color = colors.random();
line_color = colors.asString(area_color);
}
/* derive area background color from line color */
area_color = colors.asString(colors.faded(area_color));
/* choose source_plot or source_neg variable depending on flip state */
variable = source.flip ? 'neg' : 'plot';
/* create legend */
legend = '%%-%us'.format(_longest_name).format(source.title);
/* create area is not disabled */
if (!source.noarea)
_args.push('AREA:%s_%s#%s'.format(source.sname, variable, area_color));
/* create line statement */
_args.push('LINE%d:%s_%s#%s:%s'.format(
source.width || (source.noarea ? 2 : 1),
source.sname, variable, line_color, legend
));
}
/* local helper: create gprint statements */
function __gprint(source) {
var numfmt = opts.number_format || '%6.1lf',
totfmt = opts.totals_format || '%5.1lf%s';
/* don't include MIN if rrasingle is enabled */
if (!gopts.rrasingle)
_args.push('GPRINT:%s_min:MIN:\tMin\\: %s'.format(source.sname, numfmt));
/* don't include AVERAGE if noavg option is set */
if (!source.noavg)
_args.push('GPRINT:%s_avg:AVERAGE:\tAvg\\: %s'.format(source.sname, numfmt));
/* don't include MAX if rrasingle is enabled */
if (!gopts.rrasingle)
_args.push('GPRINT:%s_max:MAX:\tMax\\: %s'.format(source.sname, numfmt));
/* include total count if requested else include LAST */
if (source.total)
_args.push('GPRINT:%s_avg_sum:LAST:(ca. %s Total)\\l'.format(source.sname, totfmt));
else
_args.push('GPRINT:%s_avg:LAST:\tLast\\: %s\\l'.format(source.sname, numfmt));
}
/*
* find all data sources
*/
/* find data types */
var data_types = dtype ? [ dtype ] : (opts.data.types || []);
if (!(dtype || opts.data.types)) {
if (L.isObject(opts.data.instances))
data_types.push.apply(data_types, Object.keys(opts.data.instances));
else if (L.isObject(opts.data.sources))
data_types.push.apply(data_types, Object.keys(opts.data.sources));
}
/* iterate over data types */
for (var i = 0; i < data_types.length; i++) {
/* find instances */
var data_instances;
if (!opts.per_instance) {
if (L.isObject(opts.data.instances) && Array.isArray(opts.data.instances[data_types[i]]))
data_instances = opts.data.instances[data_types[i]];
else
data_instances = this.dataInstances(host, plugin, plugin_instance, data_types[i]);
}
if (!Array.isArray(data_instances) || data_instances.length == 0)
data_instances = [ '' ];
/* iterate over data instances */
for (var j = 0; j < data_instances.length; j++) {
/* construct combined data type / instance name */
var dname = data_types[i];
if (data_instances[j].length)
dname += '_' + data_instances[j];
/* find sources */
var data_sources = [ 'value' ];
if (L.isObject(opts.data.sources)) {
if (Array.isArray(opts.data.sources[dname]))
data_sources = opts.data.sources[dname];
else if (Array.isArray(opts.data.sources[data_types[i]]))
data_sources = opts.data.sources[data_types[i]];
}
/* iterate over data sources */
for (var k = 0; k < data_sources.length; k++) {
var dsname = data_types[i] + '_' + data_instances[j].replace(/\W/g, '_') + '_' + data_sources[k],
altname = data_types[i] + '__' + data_sources[k];
/* find datasource options */
var dopts = {};
if (L.isObject(opts.data.options)) {
if (L.isObject(opts.data.options[dsname]))
dopts = opts.data.options[dsname];
else if (L.isObject(opts.data.options[altname]))
dopts = opts.data.options[altname];
else if (L.isObject(opts.data.options[dname]))
dopts = opts.data.options[dname];
else if (L.isObject(opts.data.options[data_types[i]]))
dopts = opts.data.options[data_types[i]];
}
/* store values */
var source = {
rrd: dopts.rrd || this.mkrrdpath(host, plugin, plugin_instance, data_types[i], data_instances[j]),
color: dopts.color || colors.asString(colors.random()),
flip: dopts.flip || false,
total: dopts.total || false,
overlay: dopts.overlay || false,
transform_rpn: dopts.transform_rpn || '0,+',
noarea: dopts.noarea || false,
noavg: dopts.noavg || false,
title: dopts.title || null,
weight: dopts.weight || (dopts.negweight ? -+data_instances[j] : null) || (dopts.posweight ? +data_instances[j] : null) || null,
ds: data_sources[k],
type: data_types[i],
instance: data_instances[j],
index: _sources.length + 1,
sname: String(_sources.length + 1) + data_types[i]
};
_sources.push(source);
/* generate datasource title */
source.title = i18n.ds(host, source);
/* find longest name */
_longest_name = Math.max(_longest_name, source.title.length);
/* has totals? */
if (source.total)
_has_totals = true;
}
}
}
/*
* construct diagrams
*/
/* if per_instance is enabled then find all instances from the first datasource in diagram */
/* if per_instance is disabled then use an empty pseudo instance and use model provided values */
var instances = [ '' ];
if (opts.per_instance)
instances = this.dataInstances(host, plugin, plugin_instance, _sources[0].type);
/* iterate over instances */
for (var i = 0; i < instances.length; i++) {
/* store title and vlabel */
_args.push(
'-t', i18n.title(host, plugin, plugin_instance, _sources[0].type, instances[i], opts.title),
'-v', i18n.label(host, plugin, plugin_instance, _sources[0].type, instances[i], opts.vlabel)
);
if (opts.y_max)
_args.push('-u', String(opts.y_max));
if (opts.y_min)
_args.push('-l', String(opts.y_min));
if (opts.units_exponent)
_args.push('-X', String(opts.units_exponent));
if (opts.alt_autoscale)
_args.push('-A');
if (opts.alt_autoscale_max)
_args.push('-M');
/* store additional rrd options */
if (Array.isArray(opts.rrdopts))
for (var j = 0; j < opts.rrdopts.length; j++)
_args.push(String(opts.rrdopts[j]));
/* sort sources */
_sources.sort(function(a, b) {
var x = a.weight || a.index || 0,
y = b.weight || b.index || 0;
return +x - +y;
});
/* define colors in order */
if (opts.ordercolor)
for (var j = 0; j < _sources.length; j++)
_sources[j].color = colors.defined(j);
/* create DEF statements for each instance */
for (var j = 0; j < _sources.length; j++) {
/* fixup properties for per instance mode... */
if (opts.per_instance) {
_sources[j].instance = instances[i];
_sources[j].rrd = this.mkrrdpath(host, plugin, plugin_instance, _sources[j].type, instances[i]);
}
__def(_sources[j]);
}
/* create CDEF required for calculating totals */
__cdef_totals();
/* create CDEF statements for each instance in reversed order */
for (var j = _sources.length - 1; j >= 0; j--)
__cdef(_sources[j]);
/* create LINE1, AREA and GPRINT statements for each instance */
for (var j = 0; j < _sources.length; j++) {
__line(_sources[j]);
__gprint(_sources[j]);
}
/* push arg stack to definition list */
defs.push(_args);
/* reset stacks */
_args = [];
_stack_pos = [];
_stack_neg = [];
}
return defs;
},
render: function(plugin, plugin_instance, is_index, hostname, timespan, width, height, cache) {
var pngs = [];
/* check for a whole graph handler */
var def = graphdefs[plugin];
if (def && typeof(def.rrdargs) == 'function') {
/* temporary image matrix */
var _images = [];
/* get diagram definitions */
var optlist = this._forcelol(def.rrdargs(this, hostname, plugin, plugin_instance, null, is_index));
for (var i = 0; i < optlist.length; i++) {
var opt = optlist[i];
if (!is_index || !opt.detail) {
_images[i] = [];
/* get diagram definition instances */
var diagrams = this._generic(opt, hostname, plugin, plugin_instance, null, i);
/* render all diagrams */
for (var j = 0; j < diagrams.length; j++) {
/* exec */
_images[i][j] = this._rrdtool(diagrams[j], null, timespan, width, height, cache);
}
}
}
/* remember images - XXX: fixme (will cause probs with asymmetric data) */
for (var y = 0; y < _images[0].length; y++)
for (var x = 0; x < _images.length; x++)
pngs.push(_images[x][y]);
}
return Promise.all(pngs);
}
});