* luci/statistics: implement flip, overlay and total options for diagram models,...
[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.util")
7 require("luci.bits")
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         opts.width = opts.width or "400"
17
18         self.colors = luci.statistics.rrdtool.colors.Instance()
19         self.defs   = luci.statistics.rrdtool.definitions.Instance()
20         self.tree   = luci.statistics.datatree.Instance()
21
22         -- rrdtool defalt args
23         self.args   = {
24                 "-a", "PNG",
25                 "-s", "NOW-" .. ( timespan or 900 ),
26                 "-w", opts.width
27         }
28 end
29
30 function Graph.mktitle( self, host, plugin, plugin_instance, dtype, dtype_instance )
31         local t = host .. "/" .. plugin
32         if type(plugin_instance) == "string" and plugin_instance:len() > 0 then
33                 t = t .. "-" .. plugin_instance
34         end
35         t = t .. "/" .. dtype
36         if type(dtype_instance) == "string" and dtype_instance:len() > 0 then
37                 t = t .. "-" .. dtype_instance
38         end
39         return t
40 end
41
42 function Graph.mkrrdpath( self, ... )
43         return string.format( "/tmp/%s.rrd", self:mktitle( ... ) )
44 end
45
46 function Graph.mkpngpath( self, ... )
47         return string.format( "/tmp/rrdimg/%s.png", self:mktitle( ... ) )
48 end
49
50 function Graph._push( self, elem )
51
52         if type(elem) == "string" then
53                 table.insert( self.args, elem )
54         else
55                 for i, item in ipairs(elem) do
56                         table.insert( self.args, item )
57                 end
58         end
59
60         return( self.args )
61 end
62
63 function Graph._clearargs( self )
64         for i = #self.args, 7, -1 do
65                 table.remove( self.args, i )
66         end
67 end
68
69 function Graph._forcelol( self, list )
70         if type(list[1]) ~= "table" then
71                 return( { list } )
72         end
73         return( list )
74 end
75
76 function Graph._rrdtool( self, png, rrd )
77
78         -- prepare directory
79         local dir = png:gsub("/[^/]+$","")
80         luci.fs.mkdir( dir, true )
81
82         -- construct commandline
83         local cmdline = "rrdtool graph " .. png
84
85         for i, opt in ipairs(self.args) do
86
87                 opt = opt .. ""    -- force string
88                 
89                 if rrd then
90                         opt = opt:gsub( "{file}", rrd )
91                 end
92
93                 if opt:match("[^%w]") then
94                         cmdline = cmdline .. " '" .. opt .. "'"
95                 else
96                         cmdline = cmdline .. " " .. opt
97                 end
98         end
99
100         -- execute rrdtool
101         local rrdtool = io.popen( cmdline )
102         rrdtool:close()
103 end
104
105 function Graph._generic( self, opts )
106
107         local images     = { }
108         local rrasingle  = false        -- XXX: fixme
109
110         -- internal state variables
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: create definitions for min, max, avg and create *_nnl (not null) variable from avg
121         function __def(source)
122
123                 local inst = source.sname
124                 local rrd  = source.rrd
125                 local ds   = source.ds
126
127                 if not ds or ds:len() == 0 then ds = "value" end
128
129                 local rv = { _sf( "DEF:%s_avg=%s:%s:AVERAGE", inst, rrd, ds ) }
130
131                 if not rrasingle then
132                         _ti( rv, _sf( "DEF:%s_min=%s:%s:MIN", inst, rrd, ds ) )
133                         _ti( rv, _sf( "DEF:%s_max=%s:%s:MAX", inst, rrd, ds ) )
134                 end
135
136                 _ti( rv, _sf( "CDEF:%s_nnl=%s_avg,UN,0,%s_avg,IF", inst, inst, inst ) )
137
138                 return rv
139         end
140
141         -- local helper: create cdefs depending on source options like flip and overlay
142         function __cdef(source)
143
144                 local rv = { }
145                 local prev
146
147                 -- find previous source, choose stack depending on flip state
148                 if source.flip then
149                         prev = _stack_neg[#_stack_neg]
150                 else
151                         prev = _stack_pos[#_stack_pos]
152                 end
153
154                 -- is first source in stack or overlay source: source_stk = source_nnl
155                 if not prev or source.overlay then
156                         -- create cdef statement
157                         _ti( rv, _sf( "CDEF:%s_stk=%s_nnl", source.sname, source.sname ) )
158
159                 -- is subsequent source without overlay: source_stk = source_nnl + previous_stk
160                 else
161                         -- create cdef statement                                
162                         _ti( rv, _sf(
163                                 "CDEF:%s_stk=%s_nnl,%s_stk,+", source.sname, source.sname, prev
164                         ) )
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                         _ti( rv, _sf( "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                         _ti( rv, _sf(
188                                 "CDEF:%s_avg_sample=%s_avg,UN,0,%s_avg,IF,sample_len,*",
189                                 source.sname, source.sname, source.sname
190                         ) )
191
192                         _ti( rv, _sf(
193                                 "CDEF:%s_avg_sum=PREV,UN,0,PREV,IF,%s_avg_sample,+",
194                                 source.sname, source.sname, source.sname
195                         ) )
196                 end
197
198
199                 return rv
200         end
201
202         -- local helper: create cdefs required for calculating total values
203         function __cdef_totals()
204                 if _has_totals then
205                         return {
206                                 _sf( "CDEF:mytime=%s_avg,TIME,TIME,IF", opts.sources[1].sname ),
207                                 "CDEF:sample_len_raw=mytime,PREV(mytime),-",
208                                 "CDEF:sample_len=sample_len_raw,UN,0,sample_len_raw,IF"
209                         }
210                 else
211                         return { }
212                 end
213         end
214
215         -- local helper: create line and area statements
216         function __area(source)
217
218                 local line_color
219                 local area_color
220                 local legend
221                 local var
222
223                 -- find colors: try source, then opts.colors; fall back to random color
224                 if type(source.color) == "string" then
225                         line_color = source.color
226                         area_color = self.colors:from_string( line_color )
227                 elseif type(opts.colors[source.name:gsub("[^%w]","_")]) == "string" then
228                         line_color = opts.colors[source.name:gsub("[^%w]","_")]
229                         area_color = self.colors:from_string( line_color )
230                 else
231                         area_color = self.colors:random()
232                         line_color = self.colors:to_string( area_color )
233                 end
234
235                 -- derive area background color from line color
236                 area_color = self.colors:to_string( self.colors:faded( area_color ) )
237
238                 -- choose source_stk or source_neg variable depending on flip state
239                 if source.flip then
240                         var = "neg"
241                 else
242                         var = "stk"
243                 end
244
245                 -- create legend
246                 legend = _sf( "%-" .. _longest_name .. "s", source.name )
247
248                 -- create area and line1 statement
249                 return {
250                         _sf( "AREA:%s_%s#%s", source.sname, var, area_color ),
251                         _sf( "LINE1:%s_%s#%s:%s", source.sname, var, line_color, legend )
252                 }
253         end
254
255         -- local helper: create gprint statements
256         function __gprint(source)
257
258                 local rv     = { }
259                 local numfmt = opts.number_format or "%6.1lf"
260                 local totfmt = opts.totals_format or "%5.1lf%s"
261
262                 -- don't include MIN if rrasingle is enabled
263                 if not rrasingle then
264                         _ti( rv, _sf( "GPRINT:%s_min:MIN:%s Min", source.sname, numfmt ) )
265                 end
266
267                 -- always include AVERAGE
268                 _ti( rv, _sf( "GPRINT:%s_avg:AVERAGE:%s Avg", source.sname, numfmt ) )
269
270                 -- don't include MAX if rrasingle is enabled
271                 if not rrasingle then
272                         _ti( rv, _sf( "GPRINT:%s_max:MAX:%s Max", source.sname, numfmt ) )
273                 end
274
275                 -- include total count if requested else include LAST
276                 if source.total then
277                         _ti( rv, _sf( "GPRINT:%s_avg_sum:LAST:(ca. %s Total)", source.sname, totfmt ) )
278                 else
279                         _ti( rv, _sf( "GPRINT:%s_avg:LAST:%s Last", source.sname, numfmt ) )
280                 end
281
282                 -- end label line
283                 rv[#rv] = rv[#rv] .. "\\l"
284
285
286                 return rv                                       
287         end
288
289
290         -- remember images
291         _ti( images, opts.image )
292
293         -- insert provided addition rrd options
294         self:_push( { "-t", opts.title or "Unknown title" } )
295         self:_push( opts.rrd )
296
297         -- store index and safe instance name within each source object,
298         -- find longest instance name
299         for i, source in ipairs(opts.sources) do
300
301                 if source.name:len() > _longest_name then
302                         _longest_name = source.name:len()
303                 end
304
305                 if source.total then
306                         _has_totals = true
307                 end
308
309                 source.index = i
310                 source.sname = i .. source.name:gsub("[^A-Za-z0-9%-_]","_")
311         end
312
313         -- create DEF statements for each instance, find longest instance name
314         for i, source in ipairs(opts.sources) do
315                 self:_push( __def( source ) )
316         end
317
318         -- create CDEF required for calculating totals
319         self:_push( __cdef_totals() )
320
321         -- create CDEF statements for each instance in reversed order
322         for i, source in ipairs(opts.sources) do
323                 self:_push( __cdef( opts.sources[1 + #opts.sources - i] ) )
324         end
325
326         -- create LINE1, AREA and GPRINT statements for each instance
327         for i, source in ipairs(opts.sources) do
328                 self:_push( __area( source ) )
329                 self:_push( __gprint( source ) )
330         end
331
332         return images
333 end
334
335 function Graph.render( self, host, plugin, plugin_instance )
336
337         dtype_instances = dtype_instances or { "" }
338         local pngs = { }
339
340         -- check for a whole graph handler
341         local plugin_def = "luci.statistics.rrdtool.definitions." .. plugin
342         local stat, def = pcall( require, plugin_def )
343
344         if stat and def and type(def.rrdargs) == "function" then
345                 for i, opts in ipairs( self:_forcelol( def.rrdargs( self, host, plugin, plugin_instance, dtype ) ) ) do
346                         for i, png in ipairs( self:_generic( opts ) ) do
347                                 table.insert( pngs, png )
348
349                                 -- exec
350                                 self:_rrdtool( png )
351
352                                 -- clear args
353                                 self:_clearargs()
354                         end
355                 end
356         else
357
358                 -- no graph handler, iterate over data types
359                 for i, dtype in ipairs( self.tree:data_types( plugin, plugin_instance ) ) do
360
361                         -- check for data type handler
362                         local dtype_def = plugin_def .. "." .. dtype
363                         local stat, def = pcall( require, dtype_def )
364
365                         if stat and def and type(def.rrdargs) == "function" then
366                                 for i, opts in ipairs( self:_forcelol( def.rrdargs( self, host, plugin, plugin_instance, dtype ) ) ) do
367                                         for i, png in ipairs( self:_generic( opts ) ) do
368                                                 table.insert( pngs, png )
369
370                                                 -- exec
371                                                 self:_rrdtool( png )
372
373                                                 -- clear args
374                                                 self:_clearargs()
375                                         end
376                                 end
377                         else
378
379                                 -- no data type handler, fall back to builtin definition
380                                 if type(self.defs.definitions[dtype]) == "table" then
381
382                                         -- iterate over data type instances
383                                         for i, inst in ipairs( self.tree:data_instances( plugin, plugin_instance, dtype ) ) do
384
385                                                 local title = self:mktitle( host, plugin, plugin_instance, dtype, inst )
386                                                 local png   = self:mkpngpath( host, plugin, plugin_instance, dtype, inst )
387                                                 local rrd   = self:mkrrdpath( host, plugin, plugin_instance, dtype, inst )
388
389                                                 self:_push( { "-t", title } )
390                                                 self:_push( self.defs.definitions[dtype] )
391
392                                                 table.insert( pngs, png )
393
394                                                 -- exec
395                                                 self:_rrdtool( png, rrd )
396
397                                                 -- clear args
398                                                 self:_clearargs()
399                                         end
400                                 end
401                         end
402                 end
403         end
404
405         return pngs
406 end
407