Merge pull request #3606 from weblate/weblate-openwrt-luci
[project/luci.git] / applications / luci-app-statistics / luasrc / statistics / rrdtool.lua
1 -- Copyright 2008 Freifunk Leipzig / Jo-Philipp Wich <jow@openwrt.org>
2 -- Licensed to the public under the Apache License 2.0.
3
4 module("luci.statistics.rrdtool", package.seeall)
5
6 local tree = require("luci.statistics.datatree")
7 local colors = require("luci.statistics.rrdtool.colors")
8 local i18n = require("luci.statistics.i18n")
9 local uci = require("luci.model.uci").cursor()
10 local util = require("luci.util")
11 local sys = require("luci.sys")
12 local fs = require("nixio.fs")
13
14
15 Graph = util.class()
16
17 function Graph.__init__( self, timespan, opts )
18
19 opts = opts or { }
20
21 local sections = uci:get_all( "luci_statistics" )
22
23 -- options
24 opts.timespan = timespan or sections.rrdtool.default_timespan or 900
25 opts.rrasingle = opts.rrasingle or ( sections.collectd_rrdtool.RRASingle == "1" )
26 opts.rramax = opts.rramax or ( sections.collectd_rrdtool.RRAMax == "1" )
27 opts.host = opts.host or sections.collectd.Hostname or sys.hostname()
28 opts.width = opts.width or sections.rrdtool.image_width or 400
29 opts.height = opts.height or sections.rrdtool.image_height or 100
30 opts.rrdpath = opts.rrdpath or sections.collectd_rrdtool.DataDir or "/tmp/rrd"
31 opts.imgpath = opts.imgpath or sections.rrdtool.image_path or "/tmp/rrdimg"
32 opts.rrdpath = opts.rrdpath:gsub("/$","")
33 opts.imgpath = opts.imgpath:gsub("/$","")
34
35 -- helper classes
36 self.colors = colors.Instance()
37 self.tree = tree.Instance(opts.host)
38 self.i18n = i18n.Instance( self )
39
40 -- rrdtool default args
41 self.args = {
42 "-a", "PNG",
43 "-s", "NOW-" .. opts.timespan,
44 "-w", opts.width,
45 "-h", opts.height
46 }
47
48 -- store options
49 self.opts = opts
50 end
51
52 function Graph._mkpath( self, plugin, plugin_instance, dtype, dtype_instance )
53 local t = self.opts.host .. "/" .. plugin
54 if type(plugin_instance) == "string" and plugin_instance:len() > 0 then
55 t = t .. "-" .. plugin_instance
56 end
57 t = t .. "/" .. dtype
58 if type(dtype_instance) == "string" and dtype_instance:len() > 0 then
59 t = t .. "-" .. dtype_instance
60 end
61 return t
62 end
63
64 function Graph.mkrrdpath( self, ... )
65 return string.format( "%s/%s.rrd", self.opts.rrdpath, self:_mkpath( ... ):gsub("\\", "\\\\"):gsub(":", "\\:") )
66 end
67
68 function Graph.mkpngpath( self, ... )
69 return string.format( "%s/%s.%i.png", self.opts.imgpath, self:_mkpath( ... ), self.opts.timespan )
70 end
71
72 function Graph.strippngpath( self, path )
73 return path:sub( self.opts.imgpath:len() + 2 )
74 end
75
76 function Graph._forcelol( self, list )
77 if type(list[1]) ~= "table" then
78 return( { list } )
79 end
80 return( list )
81 end
82
83 function Graph._rrdtool( self, def, rrd )
84
85 -- prepare directory
86 local dir = def[1]:gsub("/[^/]+$","")
87 fs.mkdirr( dir )
88
89 -- construct commandline
90 local cmdline = { "rrdtool", "graph" }
91
92 -- copy default arguments to def stack
93 for i, opt in ipairs(self.args) do
94 table.insert( def, 1 + i, opt )
95 end
96
97 -- construct commandline from def stack
98 for i, opt in ipairs(def) do
99 opt = opt .. "" -- force string
100
101 if rrd then
102 opt = opt:gsub( "{file}", rrd )
103 end
104
105 cmdline[#cmdline+1] = util.shellquote(opt)
106 end
107
108 -- execute rrdtool
109 local rrdtool = io.popen(table.concat(cmdline, " "))
110 rrdtool:close()
111 end
112
113 function Graph._generic( self, opts, plugin, plugin_instance, dtype, index )
114
115 -- generated graph defs
116 local defs = { }
117
118 -- internal state variables
119 local _args = { }
120 local _sources = { }
121 local _stack_neg = { }
122 local _stack_pos = { }
123 local _longest_name = 0
124 local _has_totals = false
125
126 -- some convenient aliases
127 local _ti = table.insert
128 local _sf = string.format
129
130 -- local helper: append a string.format() formatted string to given table
131 function _tif( list, fmt, ... )
132 table.insert( list, string.format( fmt, ... ) )
133 end
134
135 -- local helper: create definitions for min, max, avg and create *_nnl (not null) variable from avg
136 function __def(source)
137
138 local inst = source.sname
139 local rrd = source.rrd:gsub(":", "\\:")
140 local ds = source.ds
141
142 if not ds or ds:len() == 0 then ds = "value" end
143
144 _tif( _args, "DEF:%s_avg_raw=%s:%s:AVERAGE", inst, rrd, ds )
145 _tif( _args, "CDEF:%s_avg=%s_avg_raw,%s", inst, inst, source.transform_rpn )
146
147 if not self.opts.rrasingle then
148 _tif( _args, "DEF:%s_min_raw=%s:%s:MIN", inst, rrd, ds )
149 _tif( _args, "CDEF:%s_min=%s_min_raw,%s", inst, inst, source.transform_rpn )
150 _tif( _args, "DEF:%s_max_raw=%s:%s:MAX", inst, rrd, ds )
151 _tif( _args, "CDEF:%s_max=%s_max_raw,%s", inst, inst, source.transform_rpn )
152 end
153
154 _tif( _args, "CDEF:%s_nnl=%s_avg,UN,0,%s_avg,IF", inst, inst, inst )
155 end
156
157 -- local helper: create cdefs depending on source options like flip and overlay
158 function __cdef(source)
159
160 local prev
161
162 -- find previous source, choose stack depending on flip state
163 if source.flip then
164 prev = _stack_neg[#_stack_neg]
165 else
166 prev = _stack_pos[#_stack_pos]
167 end
168
169 -- is first source in stack or overlay source: source_stk = source_nnl
170 if not prev or source.overlay then
171 if self.opts.rrasingle or not self.opts.rramax then
172 -- create cdef statement for cumulative stack (no NaNs) and also
173 -- for display (preserving NaN where no points should be displayed)
174 _tif( _args, "CDEF:%s_stk=%s_nnl", source.sname, source.sname )
175 _tif( _args, "CDEF:%s_plot=%s_avg", source.sname, source.sname )
176 else
177 -- create cdef statement for cumulative stack (no NaNs) and also
178 -- for display (preserving NaN where no points should be displayed)
179 _tif( _args, "CDEF:%s_stk=%s_nnl", source.sname, source.sname )
180 _tif( _args, "CDEF:%s_plot=%s_max", source.sname, source.sname )
181 end
182
183 -- is subsequent source without overlay: source_stk = source_nnl + previous_stk
184 else
185 if self.opts.rrasingle or not self.opts.rramax then
186 -- create cdef statement
187 _tif( _args, "CDEF:%s_stk=%s_nnl,%s_stk,+", source.sname, source.sname, prev )
188 _tif( _args, "CDEF:%s_plot=%s_avg,%s_stk,+", source.sname, source.sname, prev )
189 else
190 -- create cdef statement
191 _tif( _args, "CDEF:%s_stk=%s_nnl,%s_stk,+", source.sname, source.sname, prev )
192 _tif( _args, "CDEF:%s_plot=%s_max,%s_stk,+", source.sname, source.sname, prev )
193 end
194 end
195
196 -- create multiply by minus one cdef if flip is enabled
197 if source.flip then
198
199 -- create cdef statement: source_stk = source_stk * -1
200 _tif( _args, "CDEF:%s_neg=%s_plot,-1,*", source.sname, source.sname )
201
202 -- push to negative stack if overlay is disabled
203 if not source.overlay then
204 _ti( _stack_neg, source.sname )
205 end
206
207 -- no flipping, push to positive stack if overlay is disabled
208 elseif not source.overlay then
209
210 -- push to positive stack
211 _ti( _stack_pos, source.sname )
212 end
213
214 -- calculate total amount of data if requested
215 if source.total then
216 _tif( _args,
217 "CDEF:%s_avg_sample=%s_avg,UN,0,%s_avg,IF,sample_len,*",
218 source.sname, source.sname, source.sname
219 )
220
221 _tif( _args,
222 "CDEF:%s_avg_sum=PREV,UN,0,PREV,IF,%s_avg_sample,+",
223 source.sname, source.sname, source.sname
224 )
225 end
226 end
227
228 -- local helper: create cdefs required for calculating total values
229 function __cdef_totals()
230 if _has_totals then
231 _tif( _args, "CDEF:mytime=%s_avg,TIME,TIME,IF", _sources[1].sname )
232 _ti( _args, "CDEF:sample_len_raw=mytime,PREV(mytime),-" )
233 _ti( _args, "CDEF:sample_len=sample_len_raw,UN,0,sample_len_raw,IF" )
234 end
235 end
236
237 -- local helper: create line and area statements
238 function __line(source)
239
240 local line_color
241 local area_color
242 local legend
243 local var
244
245 -- find colors: try source, then opts.colors; fall back to random color
246 if type(source.color) == "string" then
247 line_color = source.color
248 area_color = self.colors:from_string( line_color )
249 elseif type(opts.colors[source.name:gsub("[^%w]","_")]) == "string" then
250 line_color = opts.colors[source.name:gsub("[^%w]","_")]
251 area_color = self.colors:from_string( line_color )
252 else
253 area_color = self.colors:random()
254 line_color = self.colors:to_string( area_color )
255 end
256
257 -- derive area background color from line color
258 area_color = self.colors:to_string( self.colors:faded( area_color ) )
259
260 -- choose source_plot or source_neg variable depending on flip state
261 if source.flip then
262 var = "neg"
263 else
264 var = "plot"
265 end
266
267 -- create legend
268 legend = _sf( "%-" .. _longest_name .. "s", source.title )
269
270 -- create area if not disabled
271 if not source.noarea then
272 _tif( _args, "AREA:%s_%s#%s", source.sname, var, area_color )
273 end
274
275 -- create line1 statement
276 _tif( _args, "LINE%d:%s_%s#%s:%s",
277 source.width or (source.noarea and 2 or 1),
278 source.sname, var, line_color, legend )
279 end
280
281 -- local helper: create gprint statements
282 function __gprint(source)
283
284 local numfmt = opts.number_format or "%6.1lf"
285 local totfmt = opts.totals_format or "%5.1lf%s"
286
287 -- don't include MIN if rrasingle is enabled
288 if not self.opts.rrasingle then
289 _tif( _args, "GPRINT:%s_min:MIN:\tMin\\: %s", source.sname, numfmt )
290 end
291
292 -- always include AVERAGE
293 _tif( _args, "GPRINT:%s_avg:AVERAGE:\tAvg\\: %s", source.sname, numfmt )
294
295 -- don't include MAX if rrasingle is enabled
296 if not self.opts.rrasingle then
297 _tif( _args, "GPRINT:%s_max:MAX:\tMax\\: %s", source.sname, numfmt )
298 end
299
300 -- include total count if requested else include LAST
301 if source.total then
302 _tif( _args, "GPRINT:%s_avg_sum:LAST:(ca. %s Total)\\l", source.sname, totfmt )
303 else
304 _tif( _args, "GPRINT:%s_avg:LAST:\tLast\\: %s\\l", source.sname, numfmt )
305 end
306 end
307
308
309 --
310 -- find all data sources
311 --
312
313 -- find data types
314 local data_types
315
316 if dtype then
317 data_types = { dtype }
318 else
319 data_types = opts.data.types or { }
320 end
321
322 if not ( dtype or opts.data.types ) then
323 if opts.data.instances then
324 for k, v in pairs(opts.data.instances) do
325 _ti( data_types, k )
326 end
327 elseif opts.data.sources then
328 for k, v in pairs(opts.data.sources) do
329 _ti( data_types, k )
330 end
331 end
332 end
333
334
335 -- iterate over data types
336 for i, dtype in ipairs(data_types) do
337
338 -- find instances
339
340 local data_instances
341
342 if not opts.per_instance then
343 if type(opts.data.instances) == "table" and type(opts.data.instances[dtype]) == "table" then
344 data_instances = opts.data.instances[dtype]
345 else
346 data_instances = self.tree:data_instances( plugin, plugin_instance, dtype )
347 end
348 end
349
350 if type(data_instances) ~= "table" or #data_instances == 0 then data_instances = { "" } end
351
352
353 -- iterate over data instances
354 for i, dinst in ipairs(data_instances) do
355
356 -- construct combined data type / instance name
357 local dname = dtype
358
359 if dinst:len() > 0 then
360 dname = dname .. "_" .. dinst
361 end
362
363
364 -- find sources
365 local data_sources = { "value" }
366
367 if type(opts.data.sources) == "table" then
368 if type(opts.data.sources[dname]) == "table" then
369 data_sources = opts.data.sources[dname]
370 elseif type(opts.data.sources[dtype]) == "table" then
371 data_sources = opts.data.sources[dtype]
372 end
373 end
374
375
376 -- iterate over data sources
377 for i, dsource in ipairs(data_sources) do
378
379 local dsname = dtype .. "_" .. dinst:gsub("[^%w]","_") .. "_" .. dsource
380 local altname = dtype .. "__" .. dsource
381
382 --assert(dtype ~= "ping", dsname .. " or " .. altname)
383
384 -- find datasource options
385 local dopts = { }
386
387 if type(opts.data.options) == "table" then
388 if type(opts.data.options[dsname]) == "table" then
389 dopts = opts.data.options[dsname]
390 elseif type(opts.data.options[altname]) == "table" then
391 dopts = opts.data.options[altname]
392 elseif type(opts.data.options[dname]) == "table" then
393 dopts = opts.data.options[dname]
394 elseif type(opts.data.options[dtype]) == "table" then
395 dopts = opts.data.options[dtype]
396 end
397 end
398
399
400 -- store values
401 _ti( _sources, {
402 rrd = dopts.rrd or self:mkrrdpath( plugin, plugin_instance, dtype, dinst ),
403 color = dopts.color or self.colors:to_string( self.colors:random() ),
404 flip = dopts.flip or false,
405 total = dopts.total or false,
406 overlay = dopts.overlay or false,
407 transform_rpn = dopts.transform_rpn or "0,+",
408 noarea = dopts.noarea or false,
409 title = dopts.title or nil,
410 weight = dopts.weight or
411 (dopts.negweight and -tonumber(dinst)) or
412 (dopts.posweight and tonumber(dinst)) or nil,
413 ds = dsource,
414 type = dtype,
415 instance = dinst,
416 index = #_sources + 1,
417 sname = ( #_sources + 1 ) .. dtype
418 } )
419
420
421 -- generate datasource title
422 _sources[#_sources].title = self.i18n:ds( _sources[#_sources] )
423
424
425 -- find longest name ...
426 if _sources[#_sources].title:len() > _longest_name then
427 _longest_name = _sources[#_sources].title:len()
428 end
429
430
431 -- has totals?
432 if _sources[#_sources].total then
433 _has_totals = true
434 end
435 end
436 end
437 end
438
439
440 --
441 -- construct diagrams
442 --
443
444 -- if per_instance is enabled then find all instances from the first datasource in diagram
445 -- if per_instance is disabled then use an empty pseudo instance and use model provided values
446 local instances = { "" }
447
448 if opts.per_instance then
449 instances = self.tree:data_instances( plugin, plugin_instance, _sources[1].type )
450 end
451
452
453 -- iterate over instances
454 for i, instance in ipairs(instances) do
455
456 -- store title and vlabel
457 _ti( _args, "-t" )
458 _ti( _args, self.i18n:title( plugin, plugin_instance, _sources[1].type, instance, opts.title ) )
459 _ti( _args, "-v" )
460 _ti( _args, self.i18n:label( plugin, plugin_instance, _sources[1].type, instance, opts.vlabel ) )
461 if opts.y_max then
462 _ti ( _args, "-u" )
463 _ti ( _args, opts.y_max )
464 end
465 if opts.y_min then
466 _ti ( _args, "-l" )
467 _ti ( _args, opts.y_min )
468 end
469 if opts.units_exponent then
470 _ti ( _args, "-X" )
471 _ti ( _args, opts.units_exponent )
472 end
473 if opts.alt_autoscale then
474 _ti ( _args, "-A" )
475 end
476 if opts.alt_autoscale_max then
477 _ti ( _args, "-M" )
478 end
479
480 -- store additional rrd options
481 if opts.rrdopts then
482 for i, o in ipairs(opts.rrdopts) do _ti( _args, o ) end
483 end
484
485 -- sort sources
486 table.sort(_sources, function(a, b)
487 local x = a.weight or a.index or 0
488 local y = b.weight or b.index or 0
489 return x < y
490 end)
491
492 -- define colors in order
493 if opts.ordercolor then
494 for i, source in ipairs(_sources) do
495 source.color = self.colors:defined(i)
496 end
497 end
498
499 -- create DEF statements for each instance
500 for i, source in ipairs(_sources) do
501 -- fixup properties for per instance mode...
502 if opts.per_instance then
503 source.instance = instance
504 source.rrd = self:mkrrdpath( plugin, plugin_instance, source.type, instance )
505 end
506
507 __def( source )
508 end
509
510 -- create CDEF required for calculating totals
511 __cdef_totals()
512
513 -- create CDEF statements for each instance in reversed order
514 for i, source in ipairs(_sources) do
515 __cdef( _sources[1 + #_sources - i] )
516 end
517
518 -- create LINE1, AREA and GPRINT statements for each instance
519 for i, source in ipairs(_sources) do
520 __line( source )
521 __gprint( source )
522 end
523
524 -- prepend image path to arg stack
525 _ti( _args, 1, self:mkpngpath( plugin, plugin_instance, index .. instance ) )
526
527 -- push arg stack to definition list
528 _ti( defs, _args )
529
530 -- reset stacks
531 _args = { }
532 _stack_pos = { }
533 _stack_neg = { }
534 end
535
536 return defs
537 end
538
539 function Graph.render( self, plugin, plugin_instance, is_index )
540
541 dtype_instances = dtype_instances or { "" }
542 local pngs = { }
543
544 -- check for a whole graph handler
545 local plugin_def = "luci.statistics.rrdtool.definitions." .. plugin
546 local stat, def = pcall( require, plugin_def )
547
548 if stat and def and type(def.rrdargs) == "function" then
549
550 -- temporary image matrix
551 local _images = { }
552
553 -- get diagram definitions
554 for i, opts in ipairs( self:_forcelol( def.rrdargs( self, plugin, plugin_instance, nil, is_index ) ) ) do
555 if not is_index or not opts.detail then
556 _images[i] = { }
557
558 -- get diagram definition instances
559 local diagrams = self:_generic( opts, plugin, plugin_instance, nil, i )
560
561 -- render all diagrams
562 for j, def in ipairs( diagrams ) do
563 -- remember image
564 _images[i][j] = def[1]
565
566 -- exec
567 self:_rrdtool( def )
568 end
569 end
570 end
571
572 -- remember images - XXX: fixme (will cause probs with asymmetric data)
573 for y = 1, #_images[1] do
574 for x = 1, #_images do
575 table.insert( pngs, _images[x][y] )
576 end
577 end
578 end
579
580 return pngs
581 end