#! /usr/local/bin/ruby # Copyright (c) 2006 Frédéric Senault. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # 3. Neither the name of Frédéric Senault or any contributors may be # used to endorse or promote products derived from this software # without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF # SUCH DAMAGE. # $LOAD_PATH << File.dirname($0) require 'syslog' require 'common' require 'cgi' # The part responsible for the interactions with rrdtool graph and the web server. class GMain < Main # Loads the graphers and instanciate them ; sets up a few default # config options, too. def loadgraphers() $conf[:legends] = [ RRLegend.new('Maximum', 'max', :MAXIMUM), RRLegend.new('Moyenne', 'avg', :AVERAGE), ] Dir.glob($conf[:graphpath]) do |f| $mtime = File.mtime(f) begin require "#{f}" rescue LoadError => err puts err end end $conf[:graphs] = [] Grapher.globgraphs.sort! { |a, b| a.order <=> b.order } Grapher.globgraphs.each do |g| ng = g.new ng.mtime = Grapher.globtimes[g.to_s] $conf[:graphs].push(ng) end end # The first method to be called. Parses the link to know if we're at the # general menu step, or looking at a specific graph (grapher only). def parsequery() c = CGI.new() if(c.has_key?('page')) then p = c['page'] f = ( c['fch'] == '' ? p : c['fch'] ) d = ( c['debug'] != '' ) d = true if(ENV['HTTP_USER_AGENT'] =~ \ /(Crawler|Googlebot|htdig|Inktomi|Scooter|Slurp|Bandit|Jeeves|msnbot)/i) if((p + f) =~ /\^[hs]\^/) then r = ENV['HTTP_REFERER'] if(r.nil?) then l = "#{$conf[:indexurl]}" else y= { 'h' => '', 's' => '' } { 'h' => 'host' , 's' => 'service' }.each do |k, v| if(r =~ /[?&]#{v}=([^&]+)/) then y[k] = $1.to_s.normalize() end p.gsub!(/\^#{k}\^/, y[k]) f.gsub!(/\^#{k}\^/, y[k]) end l = "#{$conf[:graphurl]}?page=#{p}&fch=#{f}" end puts "Location: #{l}" puts else puts 'Content-Type: text/html' puts graphpage(p, f, d) end else puts 'Content-Type: text/html' puts graphindex() end end # Used to generate the general index of the graphs ; calls every plugin in turn to # see what they have to offer. def graphindex() puts "" end # Used to display one graphs page. def graphpage(link, file, debug = false) file = link if(file.nil?) RRG.resetcol $conf[:graphs].each do |gfr| id = [] gfr.register(link) do |i| id.push(i) end if (id.length > 0) then t = '' tg = {} gfr.index(file) do |nl, t| if(nl == link) then break end end id.each do |i| gfx = RRGGroup.new() gfx.debug = debug gfr.graph(link, i, gfx, file) tg[i] = gfx end gfr.initrras puts "

#{CGI.escapeHTML(t)}

" gfr.rras.each do |r| puts "

#{r.title}

" id.each do |i| puts "

" op, tn = tg[i].to_rrdtool(link, i, gfr, r) puts op puts "

" puts "
#{tn}
" unless(tn.nil? || tn == '') end end break end end end end # The main grapher class ; it has to be inherited by all the plugins. # Fort the main page, the flow is : # 1. Query the iterate property. # 2. If it is true, iterate over the filenames in the $conf[:rrdspath] # directory and call the index method with the filename. # Otherwise, try once with a single period (.). # 3. If the index has something to do with that RRD, it has to yield a link id and # a caption # 4. All the captions are sorted, and used to make the main menu. # # For each graph, the flow is : # 1. Query the register method, with the link id. # 2. If the plugin recognises the link, it has to yield one or more graph identifiers. # 3. The index method is queried again, to yield the title of the page. # 4. A RRGGroup object is created per id, to hold the graph informations. # 5. The graph method of the plugin is called, with the link, the id, the RRGGroup # object, and the filename. # 6. The graph method is supposed to call one or more of RRGGroup#adddef, # RRGGroup#addcdef, RRGGroup#addgraph. # 7. At last, the subtitle method is called with the group id to decide the title that # will be written inside the graph. class Grapher # The list of round-robin archives to graph. It should be the same # archives than the ones stored in the file by the testers, but it's not # strictly necessary. By default, the list is $conf[rras]. Alter with # addrra. attr_reader :rras # The last modification time of the plugin source file. Allows to regenerate # the graphs of a page if the code is modified. Otherwise, some discrepancies # will appear, mostly in the longer term graphs. attr_accessor :mtime @@ggraphs = [] @@gtimes = {} # Creation of a new grapher. def initalize() @rras = nil end # This is triggered when the class is inherited ; it allows the class to maintain # a class variable to store it's children. def Grapher.inherited(c) @@ggraphs.push(c) @@gtimes[c.to_s] = $mtime end # The list of plugins. def Grapher.globgraphs @@ggraphs end # The modification times of the plugins. def Grapher.globtimes @@gtimes end # The order in which the plugins will be interrogated (lowest order first). def self.order 999 end # If false, the plugin's method index is called only once. Otherwise, # it's called once per RRD file. By default, true. def iterate true end # Used to write the title inside the graph ; defauts to the name of the class # followed by the identifier. def subtitle(id) self.class.to_s + ', ' + id.to_s end # Used to generate the main menu page, and the title of each graph page. Must # be implemented, and must yield two parameters : the link id, and the caption # of the link / title of the page. def index(file) raise "index must be implemented" end # Used with the link id, to find all the distinct graph groups inside a page. # It must only respond if the link is appropriate, by yielding one ore more # group identifiers. Must be implemented. def register(link) raise "register must be implemented" end # The main graphing method. Gets the link id, the group id, the RRGGroup # object itself, and the file name that triggered the link. Must be # implemented. def graph(link, id, gfx, file) raise "graph must be implemented" end # Called internally ; intiliazes the rra array to the default if it # has not been touched by the plugin. def initrras() @rras = $conf[:rras] if(@rras.nil?) end # Adds a customized rra ; see RRA#new for the parameters ; if this method # is called once, the default $conf[:rras] is not used anymore. def addrra(funct = :AVERAGE, xff = 0.5, steps = nil, \ rows = nil, title = nil, start = nil) @rras ||= [] @rras.push(RRA.new(funct, xff, steps, rows, title, start)) end # Helper method which returns the correct rpn function to implement this ruby # equivalent : # ( value.unknown? ? uvalue : value ) def rpn_ifu(value, uvalue) "#{value},UN,#{uvalue.to_s},#{value},IF" end # Helper method which returns the correct rpn function to implement this ruby # equivalent : # ( value.unknown? ? 0 : value ) def rpn_zu(value) rpn_ifu(value, 0) end # Helper method which returns the correct rpn function to implement a sum # of an array of values (which is at first flattened). If an unknown value # is seen, it's assumed to be 0. def rpn_sum(*values) v = values.flatten v[1..-1].inject(rpn_ifu(v[0], 0)) { |s, e| s += ',' + rpn_ifu(e, 0) + ',+' } end # Helper method which returns the correct rpn function to implement the average # of an array of values (which is at first flattened). If an unknown value # is seen, it's assumed to be 0. def rpn_avg(*values) v = values.flatten rpn_sum(v) + ',/,' + v.length.to_i end end # Represents a group of graphs with the same structure, drawn over different # periods (according to the Grapher#rra array). The properties to use from the # plugins are : # - logarithmic : to set a logarithmic scale # - upperbound, lowerbound, strict : the bounding values, and how they must be # enforced. # - percent : when set to true, enforces bounds of [0..100]. # - base : the base of the unit (usually 1000 - the default, or 1024). # - colors : with a symbol parameter, to set the general drawing area colors # (can be set generally in $config[:colors]) # The methods to use are : # - adddef : to read data from a rrd file (must have at least one, returns a # RRDef object) # - addcdef : to compute a value from other defs and rpn calculations (returns # a RRCDef object). # - addlegend : the legend part of the graph is computed from the visible defs # or cdefs, with one column per legend object ; returns a RRLegend object ; # if left undefined, uses $conf[:legends]. # - addgraph : to actually draw something. # An optional helper function is defined : # - fetch_data : which allows to query existing RRD databases to fech values, # over a defined sample period. class RRGGroup # Use a logarithmic Y scale (default : false) ? attr_accessor :logarithmic # The upper limit of the Y scale (automatically computed by default). attr_accessor :upperbound # The lower limit of the Y scale (automatically computed by default). attr_accessor :lowerbound # Must the upper and lower limits we enforced if values are out of bound # (not by default). attr_accessor :strict # The base for the SI units (1000 by default). attr_accessor :base # In debugging mode, the definition of the graph is issued instead of the # graph itself. attr_accessor :debug # An array of RRLegend objects. attr_reader :legends # The empty RRGGroup is created ; usually, it's done internally; it can be # useful to create one by hand to use the fetch_data method. def initialize() @defs = [] @cdefs = [] @graphs = [] @colors = $conf[:colors].dup @legends = nil @title = nil @strict = false @logarithmic = false @upperbound = nil @lowerbound = nil @base = nil @debug = false #resetcol end # When set to true, sets the graph in percent mode (bounds at [0, # 100], strictly enforced). def percent=(v) if(v) then @strict = true @lowerbound = 0 @upperbound = 100 else @strict = false @lowerbound = nil @upperbound = nil end end # Reset the rotation of the global colors array between each graph. def resetcol RRG.resetcol end # Add a graph element ; returns a RRG object (see RRG#new for the # parameters). def addgraph(type, data, color, label, stack = nil) stack = false if(@graphs.length == 0) g = RRG.new(type, data, color, label, stack) @graphs.push(g) g end # Add a definition ; returns a RRDef object (see RRDef#new for the # parameters) def adddef(name = nil, rrd = nil, data = nil, funct = :AVERAGE, label = '_', fmt = '%7.2lf') d = RRDef.new(name, rrd, data, funct, label, fmt) @defs.push(d) d end # Add a computed line ; returns a RRCDef object (see RRCDef#new for the # parameters) def addcdef(name = nil, expr = nil, label = '_', fmt = '%7.2lf') c = RRCDef.new(name, expr, label, fmt) @cdefs.push(c) c end # Add a legend group ; returns a RRLegend object (see RRLegend#new for the # parameters ; by default, $conf[:legends] is used) def addlegend(label, funct) @legendss = [] if(@legendss.nil?) l = RRLegend.new(label, funct) @legendss.push(l) l end # Removes any legend groups. def nolegend @legendss = [] end # Sets the color of diverse elements of the drawing area. The accepted # colortags are : :BACK, :CANEVAS, :SHADEA, # :SHADEB, :GRID, :MGRID, :FONT, # :AXIS, :FRAME, :ARROW. The colors themselves # can be RGB triplets (with an optional alpha channel), or symbols present # in the $color variable (see Main#colors). def setcolor(colortag, color = nil) if(color.nil?) then @colors.delete(colortag) else if($colors.has_key?(color)) then @colors[colortag] = $colors[color] else @colors[colortag] = color end end end # Helper function to compute an expression list, a hash of variable names # associated with rpn expressions based on the definitions of the RRGroup. # It evaluates the expression on a sample of (by default 10) data # points, using the specified archive (by default, the second more precise # one, usually the monthly one). It returns a hash table with the # variables and their computed results. If something fails, the special # key ##err## contains the message and the rrdtool # invocation line. def fetch_data(exprlist, sample = 10, rra = nil) rd = {} c = [] rra ||= $conf[:rras][1] sample ||= 10 c.push("graph #{$conf[:imgpath]}/tmpfile") c.push("#{$conf[:rrgopts]}") c.push("-e '#{rra.end}'") c.push("-s '#{rra.end - (sample * rra.sec_steps)}'") @defs.each do |d| c.push(d.to_rrdef) end @cdefs.each do |d| c.push(d.to_rrcdef) end exprlist.each do |v, e| t = RRCDef.new(v, e, nil, "%lf") c.push(t.to_rrvdef) c.push(t.to_tempprint) end c.flatten! lc = c.join(' ') od = @debug e = '' if(!od) then r, e = system3($conf[:rrdtool] + ' ' + lc) if(e != '') then od = true else r.scan(/@@([^=]+)=([^@]+)@@/) do |k, v| rd[k] = v.to_f unless(v == 'nan') end end end if(od) then r = "
" r << "Graphe #{@name} : #{e}
" r << c.join("\n").gsub(/[<>&\n]/) do |i| case i when "<" then "<" when ">" then ">" when "&" then "&" when "\n" then "
\n" end end r << "
" rd['##err##'] = r end rd end # The main graphing function, called internally. It generates the # rrdtool graph command line, and returns the link to the # graph, or the error message with the command line. def to_rrdtool(link, id, gfr, rra) @legends = $conf[:legends] if(@legends.nil?) c = [] tt = "#{gfr.subtitle(id)} (#{rra.title})" th = CGI.escapeHTML(tt) fn = $conf[:imgpath] + '/' + link + rra.start.gsub(/-/, '_') + \ '_' + id.to_s + '.' + $conf[:rrgfmt] c.push("graph #{fn}") c.push("#{$conf[:rrgopts]}") c.push("-e '#{rra.end}'") c.push("-s '#{rra.start}'") c.push("-a #{$conf[:rrgfmt].upcase}") c.push("-w #{$conf[:rrgsizex]}") c.push("-h #{$conf[:rrgsizey]}") c.push("-f \"\"") c.push("-t \"#{tt}\"") c.push("-o") if(@logarithmic) c.push("-z") if(File.exists?(fn) && File.mtime(fn) > gfr.mtime) c.push("-u #{@upperbound}") if(@upperbound) c.push("-l #{@lowerbound}") if(@lowerbound) c.push("-r") if(@strict) c.push("-b #{@base}") if(@base) @colors.each do |t, c| c.push("-c #{t.to_s}##{c.to_s}") end @defs.each do |d| c.push(d.to_rrdef) end @cdefs.each do |d| c.push(d.to_rrcdef) end @legends.each do |l| (@defs + @cdefs).each do |d| c.push(l.to_vdef(d)) unless(d.label.nil?) end end c.push(RRLegend.make_comment(@legends, (@defs + @cdefs))) @graphs.each do |g| c.push(g.to_rrg) end c.flatten! lc = c.join(' ') od = @debug e = '' if(!od) then r, e = system3($conf[:rrdtool] + ' ' + lc) if(e != '') then od = true else r.gsub!(/^\d+x\d+[\r\n]*/, '') tn = (File.exists?(fn) ? File.mtime(fn).to_s : 'inconnu') end end if(od) then r = "
" r << "Graphe #{@name} : #{e}
" r << c.join("\n").gsub(/[<>&]/) do |i| case i when "<" then "<" when ">" then ">" when "&" then "&" end end r << "
" tn = '' end [ r, tn ] end end # This class represents a rrdtool def, that is the simplest # kind of data, directly extracted from a data source. # It is associated with a label and a format for the legend generation. class RRDef # The name of the variable, that will then be used for cdefs (via RRCDef # objects) or graphs (via RRGraph objects). attr_accessor :name # The source name ; it is the file name, stripped of its path and its # .rrd extension attr_accessor :rrd # The data source to use. attr_accessor :data # The consolidation function, which must correspond to an existing archive # in the file ; may be :AVERAGE, :MAX, :MIN # or :LAST. attr_accessor :funct # The label of the serie in the legend ; if nil, it will not # generate a legend. attr_accessor :label # The format of the values in the legend. attr_accessor :fmt # Creates a new RRDef ; by default, the function will be :AVERAGE, # the label will be the variable name and the format %7.2lf. def initialize(name = nil, rrd = nil, data = nil, \ funct = :AVERAGE, label = '_', fmt = '%7.2lf') @name = name @rrd = rrd @data = data @funct = funct @label = ( label == '_' ? data : label ) @fmt = fmt end # Used internally to generate the rrdtool graph command line. def to_rrdef [ 'DEF', @name + '=' + $conf[:rrdspath] + '/' + @rrd + '.rrd', \ @data, @funct.to_s ].join(':') end end # This class represents a rrdtool cdef, that is a computed # serie based on defined rrdtool def series, and a rpn formula. # It is associated with a label and a format for the legend generation. # The Grapher#rpn_ifu, Grapher#rpn_zu, Grapher#rpn_sum and Grapher#rpn_avg # helper functions can be used to create rpn expressions. class RRCDef # The variable name. attr_accessor :name # The rpn expression. attr_accessor :expr # The label of the serie in the legend ; if nil, it will not # generate a legend. attr_accessor :label # The format of the values in the legend. attr_accessor :fmt # Creates a new RRCDef ; the label and format defaults are the # same than for the RRDef, that is the variable name and %7.2lf. def initialize(name = nil, expr = nil, label = '_', fmt = '%7.2lf') @name = name @expr = expr @label = ( label == '_' ? name : label ) @fmt = fmt end # Used internally to generate the rrdtool graph line. def to_rrcdef 'CDEF:' + @name + '=' + @expr end # Used internally to generate the rrdtool graph line, this part # for the legends. def to_rrvdef 'VDEF:' + @name + '=' + @expr end # Used internally to generate the rrdtool graph line, this part # being used for RRGGroup#fetch_data. def to_tempprint 'PRINT:' + @name + ":@@#{@name}=#{@fmt}@@" end end # A legend group object, used to generate automatically the legend below the # graphs. This object represents a function and labels that will be applied # to all the RRDef and RRCDef items that define their own labels. The legend # label is used above the columns, while the data labels are used before the # lines. class RRLegend # The label is placed above the columns. attr_accessor :label # The tag is concatenated to each variable name to create an unique # name. attr_accessor :tag # The consolidation function ; can be :AVERAGE, :MINIMUM, # :MAXIMUM, :LAST, :FIRST, :TOTAL, and # a few otehrs. attr_accessor :funct # Create the RRDLegend object. No possible sensible defaults here. def initialize(label = nil, tag = nil, funct = nil) @label = label @tag = tag @funct = funct end # Internally used to generate the rrdtool graph function, # along with a RRDef or RRCDef object. def to_vdef(d) 'VDEF:' + d.name + '_' + @tag + '=' + d.name + ',' + @funct.to_s end # Internally used to estimate the width of a format string. def RRLegend.lfmt(f) t = f.dup t.gsub!(/%([0-9])*\.([0-9])*(?:lf|le)/) do 'x' * (($1 && $1 != '') ? $1.to_i : 5) end t.gsub!(/%(s|S)/) { 'x' } t.length end # Internally used to output the comment and gprint # parts in the rrdtool graph line. If there are two values, # display the labels on the center, with the values on each side. # Otherwise, go to a more classical labels on the left, and column of # values on the right. def RRLegend.make_comment(legends, defs) c = [] dw = legends.inject(0) do |m, l| m = (l.label.length < m ? m : l.label.length) end dw = defs.inject(dw) do |m, d| lf = (d.fmt.nil? ? 0 : RRLegend.lfmt(d.fmt)) m = (lf < m ? m : lf) end c.push('COMMENT:"\s"') if(legends.length == 2) then lw = $conf[:rrgchars] - dw * 2 c.push('COMMENT:"' + \ legends[0].label.rjust(dw) + \ ' ' * lw + \ legends[1].label.rjust(dw) + ' \c"') defs.reject { |d| d.label.nil? }.each do |d| c.push([ 'GPRINT', \ d.name + '_' + legends[0].tag, \ '"' + (' ' * (dw - RRLegend.lfmt(d.fmt))) + d.fmt + \ '\g"' ].join(':')) c.push('COMMENT:"' + (' ' + d.label + ' ').center(lw, '.') + '\g"') c.push([ 'GPRINT', \ d.name + '_' + legends[1].tag, \ '"' + (' ' * (dw - RRLegend.lfmt(d.fmt))) + d.fmt + \ '\c"' ].join(':')) end else dw += 2 lw = $conf[:rrgchars] - dw * legends.length c.push('COMMENT:"' + \ ' ' * lw + \ legends.inject('') { |m, l| m += l.label.rjust(dw) } + \ ' \c"') defs.reject { |d| d.label.nil? }.each do |d| c.push('COMMENT:"' + (d.label + ' ').ljust(lw, '.') + '\g"') legends.each_index do |i| c.push([ 'GPRINT', \ d.name + '_' + legends[i].tag, \ '"' + (' ' * (dw - RRLegend.lfmt(d.fmt))) + d.fmt + \ ( i == legends.length - 1 ? '\c"' : '\g"') ].join(':')) end end end c end end # This represents one serie of data on the graph, based on a RRDef or RRCDef. # If it has a color and a label, it will genereate a small legend below with the # color. It can also automatically attribute colors to the graphs, trying not # to reuse colors even between different groups. class RRG @@ncol = [] # Used to reinitialize the order of the automatic color chooser. def RRG.resetcol @@ncol = [ :blue, :green, :red, :yellow, :cyan, :magenta, :lightgray, :darkgray, :orange, :violet ] end # The type of the element ; can be :AREA, :LINE, with # an optional width parameter (e.g. :LINE3). attr_accessor :type # The name of the RRDef or RRCDef to plot. attr_accessor :data # The color of the element. attr_accessor :color # The label to display in the legend. attr_accessor :label # Should the element be stacked on top of the previous. attr_accessor :stack # Create a new RRG ; the default type is a line, the color will be # chosen so that it's unique between graphs in the group, the label # will be the data name by default, and it will not stack by default ; # for the first serie of a graph, the stack parameter is ignored. def initialize(type = :LINE, data = nil, color = nil, label = nil, stack = nil) @type = ( type.nil? ? :LINE : type ) @data = data if(color.nil?) then @color = $colors[@@ncol.shift] elsif($colors.has_key?(color)) then @color = $colors[color] @@ncol.delete(color) else @color = color end @label = label || data @stack = stack || false end # Called internally to generate the rrdtool graph command line. def to_rrg [ @type.to_s, @data + ( @color == '' ? '' : '#' + @color ), \ '"' + @label + '"' + (@stack ? ':STACK' : '') ].join(':') end end main = GMain.new() main.readparam() main.loadgraphers() main.parsequery()