Add two data series-level options "negweight" and "posweight" that will use the numerical value of the data instance name as the sorting factor. This enables e.g. sorting by CPU frequency in the cpufreq module. Add new graph-level option "ordercolor" to use colorsfrom a pre-defined color table for the data series. This enables keeping similar colors for similar data series in different plugin instances. E.g. CPU frequencies in several cores that are handled and displayed separately. (note: the table has 8 items and if there are more series, it uses the same colors again. The table can be easily extended/modified) Signed-off-by: Hannu Nyman <hannu.nyman@iki.fi>
581 lines
16 KiB
Lua
581 lines
16 KiB
Lua
-- Copyright 2008 Freifunk Leipzig / Jo-Philipp Wich <jow@openwrt.org>
|
|
-- Licensed to the public under the Apache License 2.0.
|
|
|
|
module("luci.statistics.rrdtool", package.seeall)
|
|
|
|
local tree = require("luci.statistics.datatree")
|
|
local colors = require("luci.statistics.rrdtool.colors")
|
|
local i18n = require("luci.statistics.i18n")
|
|
local uci = require("luci.model.uci").cursor()
|
|
local util = require("luci.util")
|
|
local sys = require("luci.sys")
|
|
local fs = require("nixio.fs")
|
|
|
|
|
|
Graph = util.class()
|
|
|
|
function Graph.__init__( self, timespan, opts )
|
|
|
|
opts = opts or { }
|
|
|
|
local sections = uci:get_all( "luci_statistics" )
|
|
|
|
-- options
|
|
opts.timespan = timespan or sections.rrdtool.default_timespan or 900
|
|
opts.rrasingle = opts.rrasingle or ( sections.collectd_rrdtool.RRASingle == "1" )
|
|
opts.rramax = opts.rramax or ( sections.collectd_rrdtool.RRAMax == "1" )
|
|
opts.host = opts.host or sections.collectd.Hostname or sys.hostname()
|
|
opts.width = opts.width or sections.rrdtool.image_width or 400
|
|
opts.height = opts.height or sections.rrdtool.image_height or 100
|
|
opts.rrdpath = opts.rrdpath or sections.collectd_rrdtool.DataDir or "/tmp/rrd"
|
|
opts.imgpath = opts.imgpath or sections.rrdtool.image_path or "/tmp/rrdimg"
|
|
opts.rrdpath = opts.rrdpath:gsub("/$","")
|
|
opts.imgpath = opts.imgpath:gsub("/$","")
|
|
|
|
-- helper classes
|
|
self.colors = colors.Instance()
|
|
self.tree = tree.Instance(opts.host)
|
|
self.i18n = i18n.Instance( self )
|
|
|
|
-- rrdtool default args
|
|
self.args = {
|
|
"-a", "PNG",
|
|
"-s", "NOW-" .. opts.timespan,
|
|
"-w", opts.width,
|
|
"-h", opts.height
|
|
}
|
|
|
|
-- store options
|
|
self.opts = opts
|
|
end
|
|
|
|
function Graph._mkpath( self, plugin, plugin_instance, dtype, dtype_instance )
|
|
local t = self.opts.host .. "/" .. plugin
|
|
if type(plugin_instance) == "string" and plugin_instance:len() > 0 then
|
|
t = t .. "-" .. plugin_instance
|
|
end
|
|
t = t .. "/" .. dtype
|
|
if type(dtype_instance) == "string" and dtype_instance:len() > 0 then
|
|
t = t .. "-" .. dtype_instance
|
|
end
|
|
return t
|
|
end
|
|
|
|
function Graph.mkrrdpath( self, ... )
|
|
return string.format( "%s/%s.rrd", self.opts.rrdpath, self:_mkpath( ... ):gsub("\\", "\\\\"):gsub(":", "\\:") )
|
|
end
|
|
|
|
function Graph.mkpngpath( self, ... )
|
|
return string.format( "%s/%s.%i.png", self.opts.imgpath, self:_mkpath( ... ), self.opts.timespan )
|
|
end
|
|
|
|
function Graph.strippngpath( self, path )
|
|
return path:sub( self.opts.imgpath:len() + 2 )
|
|
end
|
|
|
|
function Graph._forcelol( self, list )
|
|
if type(list[1]) ~= "table" then
|
|
return( { list } )
|
|
end
|
|
return( list )
|
|
end
|
|
|
|
function Graph._rrdtool( self, def, rrd )
|
|
|
|
-- prepare directory
|
|
local dir = def[1]:gsub("/[^/]+$","")
|
|
fs.mkdirr( dir )
|
|
|
|
-- construct commandline
|
|
local cmdline = { "rrdtool", "graph" }
|
|
|
|
-- copy default arguments to def stack
|
|
for i, opt in ipairs(self.args) do
|
|
table.insert( def, 1 + i, opt )
|
|
end
|
|
|
|
-- construct commandline from def stack
|
|
for i, opt in ipairs(def) do
|
|
opt = opt .. "" -- force string
|
|
|
|
if rrd then
|
|
opt = opt:gsub( "{file}", rrd )
|
|
end
|
|
|
|
cmdline[#cmdline+1] = util.shellquote(opt)
|
|
end
|
|
|
|
-- execute rrdtool
|
|
local rrdtool = io.popen(table.concat(cmdline, " "))
|
|
rrdtool:close()
|
|
end
|
|
|
|
function Graph._generic( self, opts, plugin, plugin_instance, dtype, index )
|
|
|
|
-- generated graph defs
|
|
local defs = { }
|
|
|
|
-- internal state variables
|
|
local _args = { }
|
|
local _sources = { }
|
|
local _stack_neg = { }
|
|
local _stack_pos = { }
|
|
local _longest_name = 0
|
|
local _has_totals = false
|
|
|
|
-- some convenient aliases
|
|
local _ti = table.insert
|
|
local _sf = string.format
|
|
|
|
-- local helper: append a string.format() formatted string to given table
|
|
function _tif( list, fmt, ... )
|
|
table.insert( list, string.format( fmt, ... ) )
|
|
end
|
|
|
|
-- local helper: create definitions for min, max, avg and create *_nnl (not null) variable from avg
|
|
function __def(source)
|
|
|
|
local inst = source.sname
|
|
local rrd = source.rrd:gsub(":", "\\:")
|
|
local ds = source.ds
|
|
|
|
if not ds or ds:len() == 0 then ds = "value" end
|
|
|
|
_tif( _args, "DEF:%s_avg_raw=%s:%s:AVERAGE", inst, rrd, ds )
|
|
_tif( _args, "CDEF:%s_avg=%s_avg_raw,%s", inst, inst, source.transform_rpn )
|
|
|
|
if not self.opts.rrasingle then
|
|
_tif( _args, "DEF:%s_min_raw=%s:%s:MIN", inst, rrd, ds )
|
|
_tif( _args, "CDEF:%s_min=%s_min_raw,%s", inst, inst, source.transform_rpn )
|
|
_tif( _args, "DEF:%s_max_raw=%s:%s:MAX", inst, rrd, ds )
|
|
_tif( _args, "CDEF:%s_max=%s_max_raw,%s", inst, inst, source.transform_rpn )
|
|
end
|
|
|
|
_tif( _args, "CDEF:%s_nnl=%s_avg,UN,0,%s_avg,IF", inst, inst, inst )
|
|
end
|
|
|
|
-- local helper: create cdefs depending on source options like flip and overlay
|
|
function __cdef(source)
|
|
|
|
local prev
|
|
|
|
-- find previous source, choose stack depending on flip state
|
|
if source.flip then
|
|
prev = _stack_neg[#_stack_neg]
|
|
else
|
|
prev = _stack_pos[#_stack_pos]
|
|
end
|
|
|
|
-- is first source in stack or overlay source: source_stk = source_nnl
|
|
if not prev or source.overlay then
|
|
if self.opts.rrasingle or not self.opts.rramax then
|
|
-- create cdef statement for cumulative stack (no NaNs) and also
|
|
-- for display (preserving NaN where no points should be displayed)
|
|
_tif( _args, "CDEF:%s_stk=%s_nnl", source.sname, source.sname )
|
|
_tif( _args, "CDEF:%s_plot=%s_avg", source.sname, source.sname )
|
|
else
|
|
-- create cdef statement for cumulative stack (no NaNs) and also
|
|
-- for display (preserving NaN where no points should be displayed)
|
|
_tif( _args, "CDEF:%s_stk=%s_nnl", source.sname, source.sname )
|
|
_tif( _args, "CDEF:%s_plot=%s_max", source.sname, source.sname )
|
|
end
|
|
|
|
-- is subsequent source without overlay: source_stk = source_nnl + previous_stk
|
|
else
|
|
if self.opts.rrasingle or not self.opts.rramax then
|
|
-- create cdef statement
|
|
_tif( _args, "CDEF:%s_stk=%s_nnl,%s_stk,+", source.sname, source.sname, prev )
|
|
_tif( _args, "CDEF:%s_plot=%s_avg,%s_stk,+", source.sname, source.sname, prev )
|
|
else
|
|
-- create cdef statement
|
|
_tif( _args, "CDEF:%s_stk=%s_nnl,%s_stk,+", source.sname, source.sname, prev )
|
|
_tif( _args, "CDEF:%s_plot=%s_max,%s_stk,+", source.sname, source.sname, prev )
|
|
end
|
|
end
|
|
|
|
-- create multiply by minus one cdef if flip is enabled
|
|
if source.flip then
|
|
|
|
-- create cdef statement: source_stk = source_stk * -1
|
|
_tif( _args, "CDEF:%s_neg=%s_plot,-1,*", source.sname, source.sname )
|
|
|
|
-- push to negative stack if overlay is disabled
|
|
if not source.overlay then
|
|
_ti( _stack_neg, source.sname )
|
|
end
|
|
|
|
-- no flipping, push to positive stack if overlay is disabled
|
|
elseif not source.overlay then
|
|
|
|
-- push to positive stack
|
|
_ti( _stack_pos, source.sname )
|
|
end
|
|
|
|
-- calculate total amount of data if requested
|
|
if source.total then
|
|
_tif( _args,
|
|
"CDEF:%s_avg_sample=%s_avg,UN,0,%s_avg,IF,sample_len,*",
|
|
source.sname, source.sname, source.sname
|
|
)
|
|
|
|
_tif( _args,
|
|
"CDEF:%s_avg_sum=PREV,UN,0,PREV,IF,%s_avg_sample,+",
|
|
source.sname, source.sname, source.sname
|
|
)
|
|
end
|
|
end
|
|
|
|
-- local helper: create cdefs required for calculating total values
|
|
function __cdef_totals()
|
|
if _has_totals then
|
|
_tif( _args, "CDEF:mytime=%s_avg,TIME,TIME,IF", _sources[1].sname )
|
|
_ti( _args, "CDEF:sample_len_raw=mytime,PREV(mytime),-" )
|
|
_ti( _args, "CDEF:sample_len=sample_len_raw,UN,0,sample_len_raw,IF" )
|
|
end
|
|
end
|
|
|
|
-- local helper: create line and area statements
|
|
function __line(source)
|
|
|
|
local line_color
|
|
local area_color
|
|
local legend
|
|
local var
|
|
|
|
-- find colors: try source, then opts.colors; fall back to random color
|
|
if type(source.color) == "string" then
|
|
line_color = source.color
|
|
area_color = self.colors:from_string( line_color )
|
|
elseif type(opts.colors[source.name:gsub("[^%w]","_")]) == "string" then
|
|
line_color = opts.colors[source.name:gsub("[^%w]","_")]
|
|
area_color = self.colors:from_string( line_color )
|
|
else
|
|
area_color = self.colors:random()
|
|
line_color = self.colors:to_string( area_color )
|
|
end
|
|
|
|
-- derive area background color from line color
|
|
area_color = self.colors:to_string( self.colors:faded( area_color ) )
|
|
|
|
-- choose source_plot or source_neg variable depending on flip state
|
|
if source.flip then
|
|
var = "neg"
|
|
else
|
|
var = "plot"
|
|
end
|
|
|
|
-- create legend
|
|
legend = _sf( "%-" .. _longest_name .. "s", source.title )
|
|
|
|
-- create area if not disabled
|
|
if not source.noarea then
|
|
_tif( _args, "AREA:%s_%s#%s", source.sname, var, area_color )
|
|
end
|
|
|
|
-- create line1 statement
|
|
_tif( _args, "LINE%d:%s_%s#%s:%s",
|
|
source.width or (source.noarea and 2 or 1),
|
|
source.sname, var, line_color, legend )
|
|
end
|
|
|
|
-- local helper: create gprint statements
|
|
function __gprint(source)
|
|
|
|
local numfmt = opts.number_format or "%6.1lf"
|
|
local totfmt = opts.totals_format or "%5.1lf%s"
|
|
|
|
-- don't include MIN if rrasingle is enabled
|
|
if not self.opts.rrasingle then
|
|
_tif( _args, "GPRINT:%s_min:MIN:\tMin\\: %s", source.sname, numfmt )
|
|
end
|
|
|
|
-- always include AVERAGE
|
|
_tif( _args, "GPRINT:%s_avg:AVERAGE:\tAvg\\: %s", source.sname, numfmt )
|
|
|
|
-- don't include MAX if rrasingle is enabled
|
|
if not self.opts.rrasingle then
|
|
_tif( _args, "GPRINT:%s_max:MAX:\tMax\\: %s", source.sname, numfmt )
|
|
end
|
|
|
|
-- include total count if requested else include LAST
|
|
if source.total then
|
|
_tif( _args, "GPRINT:%s_avg_sum:LAST:(ca. %s Total)\\l", source.sname, totfmt )
|
|
else
|
|
_tif( _args, "GPRINT:%s_avg:LAST:\tLast\\: %s\\l", source.sname, numfmt )
|
|
end
|
|
end
|
|
|
|
|
|
--
|
|
-- find all data sources
|
|
--
|
|
|
|
-- find data types
|
|
local data_types
|
|
|
|
if dtype then
|
|
data_types = { dtype }
|
|
else
|
|
data_types = opts.data.types or { }
|
|
end
|
|
|
|
if not ( dtype or opts.data.types ) then
|
|
if opts.data.instances then
|
|
for k, v in pairs(opts.data.instances) do
|
|
_ti( data_types, k )
|
|
end
|
|
elseif opts.data.sources then
|
|
for k, v in pairs(opts.data.sources) do
|
|
_ti( data_types, k )
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- iterate over data types
|
|
for i, dtype in ipairs(data_types) do
|
|
|
|
-- find instances
|
|
|
|
local data_instances
|
|
|
|
if not opts.per_instance then
|
|
if type(opts.data.instances) == "table" and type(opts.data.instances[dtype]) == "table" then
|
|
data_instances = opts.data.instances[dtype]
|
|
else
|
|
data_instances = self.tree:data_instances( plugin, plugin_instance, dtype )
|
|
end
|
|
end
|
|
|
|
if type(data_instances) ~= "table" or #data_instances == 0 then data_instances = { "" } end
|
|
|
|
|
|
-- iterate over data instances
|
|
for i, dinst in ipairs(data_instances) do
|
|
|
|
-- construct combined data type / instance name
|
|
local dname = dtype
|
|
|
|
if dinst:len() > 0 then
|
|
dname = dname .. "_" .. dinst
|
|
end
|
|
|
|
|
|
-- find sources
|
|
local data_sources = { "value" }
|
|
|
|
if type(opts.data.sources) == "table" then
|
|
if type(opts.data.sources[dname]) == "table" then
|
|
data_sources = opts.data.sources[dname]
|
|
elseif type(opts.data.sources[dtype]) == "table" then
|
|
data_sources = opts.data.sources[dtype]
|
|
end
|
|
end
|
|
|
|
|
|
-- iterate over data sources
|
|
for i, dsource in ipairs(data_sources) do
|
|
|
|
local dsname = dtype .. "_" .. dinst:gsub("[^%w]","_") .. "_" .. dsource
|
|
local altname = dtype .. "__" .. dsource
|
|
|
|
--assert(dtype ~= "ping", dsname .. " or " .. altname)
|
|
|
|
-- find datasource options
|
|
local dopts = { }
|
|
|
|
if type(opts.data.options) == "table" then
|
|
if type(opts.data.options[dsname]) == "table" then
|
|
dopts = opts.data.options[dsname]
|
|
elseif type(opts.data.options[altname]) == "table" then
|
|
dopts = opts.data.options[altname]
|
|
elseif type(opts.data.options[dname]) == "table" then
|
|
dopts = opts.data.options[dname]
|
|
elseif type(opts.data.options[dtype]) == "table" then
|
|
dopts = opts.data.options[dtype]
|
|
end
|
|
end
|
|
|
|
|
|
-- store values
|
|
_ti( _sources, {
|
|
rrd = dopts.rrd or self:mkrrdpath( plugin, plugin_instance, dtype, dinst ),
|
|
color = dopts.color or self.colors:to_string( self.colors:random() ),
|
|
flip = dopts.flip or false,
|
|
total = dopts.total or false,
|
|
overlay = dopts.overlay or false,
|
|
transform_rpn = dopts.transform_rpn or "0,+",
|
|
noarea = dopts.noarea or false,
|
|
title = dopts.title or nil,
|
|
weight = dopts.weight or
|
|
(dopts.negweight and -tonumber(dinst)) or
|
|
(dopts.posweight and tonumber(dinst)) or nil,
|
|
ds = dsource,
|
|
type = dtype,
|
|
instance = dinst,
|
|
index = #_sources + 1,
|
|
sname = ( #_sources + 1 ) .. dtype
|
|
} )
|
|
|
|
|
|
-- generate datasource title
|
|
_sources[#_sources].title = self.i18n:ds( _sources[#_sources] )
|
|
|
|
|
|
-- find longest name ...
|
|
if _sources[#_sources].title:len() > _longest_name then
|
|
_longest_name = _sources[#_sources].title:len()
|
|
end
|
|
|
|
|
|
-- has totals?
|
|
if _sources[#_sources].total then
|
|
_has_totals = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
--
|
|
-- 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
|
|
local instances = { "" }
|
|
|
|
if opts.per_instance then
|
|
instances = self.tree:data_instances( plugin, plugin_instance, _sources[1].type )
|
|
end
|
|
|
|
|
|
-- iterate over instances
|
|
for i, instance in ipairs(instances) do
|
|
|
|
-- store title and vlabel
|
|
_ti( _args, "-t" )
|
|
_ti( _args, self.i18n:title( plugin, plugin_instance, _sources[1].type, instance, opts.title ) )
|
|
_ti( _args, "-v" )
|
|
_ti( _args, self.i18n:label( plugin, plugin_instance, _sources[1].type, instance, opts.vlabel ) )
|
|
if opts.y_max then
|
|
_ti ( _args, "-u" )
|
|
_ti ( _args, opts.y_max )
|
|
end
|
|
if opts.y_min then
|
|
_ti ( _args, "-l" )
|
|
_ti ( _args, opts.y_min )
|
|
end
|
|
if opts.units_exponent then
|
|
_ti ( _args, "-X" )
|
|
_ti ( _args, opts.units_exponent )
|
|
end
|
|
if opts.alt_autoscale then
|
|
_ti ( _args, "-A" )
|
|
end
|
|
if opts.alt_autoscale_max then
|
|
_ti ( _args, "-M" )
|
|
end
|
|
|
|
-- store additional rrd options
|
|
if opts.rrdopts then
|
|
for i, o in ipairs(opts.rrdopts) do _ti( _args, o ) end
|
|
end
|
|
|
|
-- sort sources
|
|
table.sort(_sources, function(a, b)
|
|
local x = a.weight or a.index or 0
|
|
local y = b.weight or b.index or 0
|
|
return x < y
|
|
end)
|
|
|
|
-- define colors in order
|
|
if opts.ordercolor then
|
|
for i, source in ipairs(_sources) do
|
|
source.color = self.colors:defined(i)
|
|
end
|
|
end
|
|
|
|
-- create DEF statements for each instance
|
|
for i, source in ipairs(_sources) do
|
|
-- fixup properties for per instance mode...
|
|
if opts.per_instance then
|
|
source.instance = instance
|
|
source.rrd = self:mkrrdpath( plugin, plugin_instance, source.type, instance )
|
|
end
|
|
|
|
__def( source )
|
|
end
|
|
|
|
-- create CDEF required for calculating totals
|
|
__cdef_totals()
|
|
|
|
-- create CDEF statements for each instance in reversed order
|
|
for i, source in ipairs(_sources) do
|
|
__cdef( _sources[1 + #_sources - i] )
|
|
end
|
|
|
|
-- create LINE1, AREA and GPRINT statements for each instance
|
|
for i, source in ipairs(_sources) do
|
|
__line( source )
|
|
__gprint( source )
|
|
end
|
|
|
|
-- prepend image path to arg stack
|
|
_ti( _args, 1, self:mkpngpath( plugin, plugin_instance, index .. instance ) )
|
|
|
|
-- push arg stack to definition list
|
|
_ti( defs, _args )
|
|
|
|
-- reset stacks
|
|
_args = { }
|
|
_stack_pos = { }
|
|
_stack_neg = { }
|
|
end
|
|
|
|
return defs
|
|
end
|
|
|
|
function Graph.render( self, plugin, plugin_instance, is_index )
|
|
|
|
dtype_instances = dtype_instances or { "" }
|
|
local pngs = { }
|
|
|
|
-- check for a whole graph handler
|
|
local plugin_def = "luci.statistics.rrdtool.definitions." .. plugin
|
|
local stat, def = pcall( require, plugin_def )
|
|
|
|
if stat and def and type(def.rrdargs) == "function" then
|
|
|
|
-- temporary image matrix
|
|
local _images = { }
|
|
|
|
-- get diagram definitions
|
|
for i, opts in ipairs( self:_forcelol( def.rrdargs( self, plugin, plugin_instance, nil, is_index ) ) ) do
|
|
if not is_index or not opts.detail then
|
|
_images[i] = { }
|
|
|
|
-- get diagram definition instances
|
|
local diagrams = self:_generic( opts, plugin, plugin_instance, nil, i )
|
|
|
|
-- render all diagrams
|
|
for j, def in ipairs( diagrams ) do
|
|
-- remember image
|
|
_images[i][j] = def[1]
|
|
|
|
-- exec
|
|
self:_rrdtool( def )
|
|
end
|
|
end
|
|
end
|
|
|
|
-- remember images - XXX: fixme (will cause probs with asymmetric data)
|
|
for y = 1, #_images[1] do
|
|
for x = 1, #_images do
|
|
table.insert( pngs, _images[x][y] )
|
|
end
|
|
end
|
|
end
|
|
|
|
return pngs
|
|
end
|