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