ManageIQ/manageiq

View on GitHub
lib/manageiq/reporting/formatter/chart_common.rb

Summary

Maintainability
B
4 hrs
Test Coverage
D
67%
module ManageIQ
  module Reporting
    module Formatter
      module ChartCommon
        def slice_legend(string, limit = LEGEND_LENGTH)
          case string
          when Date, Time, DateTime, ActiveSupport::TimeWithZone
            string.iso8601(3)
          else
            string.to_s.tr("\n", ' ').truncate(limit)
          end
        end

        def nonblank_or_default(value)
          value.blank? ? BLANK_VALUE : value.to_s
        end

        delegate :mri, :to => :options

        def build_document_header
          raise "Can't create a graph without a sortby column" if mri.sortby.nil? &&
                                                                  mri.db != "MiqReport" # MiqReport based charts are already sorted
          raise "Graph type not specified" if mri.graph.nil? ||
                                              (mri.graph.kind_of?(Hash) && mri.graph[:type].nil?)
        end

        delegate :graph_options, :to => :options

        def build_document_body
          return no_records_found_chart if mri.table.nil? || mri.table.data.blank?

          maxcols = 8
          fun = case graph_options[:chart_type]
                when :performance then :build_performance_chart # performance chart (time based)
                when :util_ts     then :build_util_ts_chart     # utilization timestamp chart (grouped columns)
                else                                            # reporting charts
                  mri.graph[:mode] == 'values' ? :build_reporting_chart_numeric : :build_reporting_chart
                end
          method(fun).call(maxcols)
        end

        def build_document_footer
        end

        protected

        # C&U performance charts (Cluster, Host, VM based)
        def build_performance_chart_area(maxcols)
          tz = mri.get_time_zone(Time.zone.name)

          mri.graph[:columns].each_with_index do |col, col_idx|
            next if col_idx >= maxcols

            allnil = true
            tip = graph_options[:trendtip] if col.starts_with?("trend") && graph_options[:trendtip]
            categories = []                      # Store categories and series counts in an array of arrays
            series = series_class.new
            mri.table.data.each_with_index do |r, d_idx|
              rec_time = r["timestamp"].in_time_zone(tz)
              categories.push(rec_time)
              val = r[col]

              if d_idx == mri.table.data.length - 1 && !tip.nil?
                series.push(:value => val, :tooltip => tip)
              else
                series.push(:value => val)
              end
              allnil = false if !val.nil?
            end
            series.set_to_zero(-1) if allnil # XML/SWF Charts can't handle all nils, set the last value to 0
            add_axis_category_text(categories)

            head = mri.graph[:legends] ? mri.graph[:legends][col_idx] : mri.headers[mri.col_order.index(col)] # Use legend overrides, if present

            add_series(head, series)
          end
        end

        def rounded_value(value)
          return 0 if value.blank?

          value.round(graph_options[:decimals] || 0)
        end

        def build_performance_chart_pie(_maxcols)
          col = mri.graph[:columns].first
          mri.table.sort_rows_by!(col, :order => :descending)
          categories = [] # Store categories and series counts in an array of arrays
          series = series_class.new
          cat_cnt = 0
          mri.table.data.each do |r|
            cat = cat_cnt > 6 ? 'Others' : r["resource_name"]
            val = rounded_value(r[col])
            next if val == 0

            if cat.starts_with?("Others") && categories[-1].starts_with?("Others") # Are we past the top 10?
              categories[-1] = "Others"
              series.add_to_value(-1, val) # Accumulate the series value
              next
            end
            categories.push(cat)
            cat_cnt += 1
            series.push(:value => val)
          end

          return no_records_found_chart if series.empty?

          add_axis_category_text(categories)
          series.zip(categories) { |ser, category| ser[:tooltip] = category }
          add_series('', series)
        end

        def format_bytes_human_size_1
          {
            :function => {
              :name      => 'bytes_to_human_size',
              :precision => 1
            }
          }
        end

        # Utilization timestamp charts
        def build_util_ts_chart_column
          categories = []                     # Store categories and series counts in an array of arrays
          series     = []
          mri.graph[:columns].each_with_index do |col, col_idx|
            mri.table.data.each do |r|
              if col_idx == 0                 # First column is the category text
                categories.push(r[col])
              else
                series[col_idx - 1] ||= {}
                series[col_idx - 1][:header] ||= mri.headers[mri.col_order.index(col)] # Add the series header
                series[col_idx - 1][:data] ||= series_class.new
                tip_key = col + '_tip'
                tip = case r[0] # Override the formatting for certain column groups on single day percent utilization chart
                      when "CPU"
                        mri.format(tip_key, r[tip_key], :format => {
                                     :function => {
                                       :name      => "mhz_to_human_size",
                                       :precision => "1"
                                     }})
                      when "Memory"
                        mri.format(tip_key, r[tip_key].to_f * 1024 * 1024, :format => format_bytes_human_size_1)
                      when "Disk"
                        mri.format(tip_key, r[tip_key], :format => format_bytes_human_size_1)
                      else
                        mri.format(tip_key, r[tip_key])
                      end
                series[col_idx - 1][:data].push(
                  :value   => mri.format(col, r[col]).to_f, # ?? .to_f ??
                  :tooltip => tip
                )
              end
            end
          end

          # Remove categories (and associated series values) that have all zero or nil values
          (categories.length - 1).downto(0) do |i|
            sum = series.reduce(0.0) { |a, e| a + e[:data].value_at(i).to_f }
            next if sum != 0

            categories.delete_at(i)
            series.each { |s| s[:data].delete_at(i) } # Remove the data for this cat across all series
          end

          # Remove any series where all values are zero or nil
          series.delete_if { |s| s[:data].sum == 0 }

          if categories.empty?
            no_records_found_chart("No data found for the selected day")
            false
          else
            add_axis_category_text(categories)
            series.each { |s| add_series(s[:header], s[:data]) }
            true
          end
        end

        def keep_and_show_other
          # Show other sum value by default
          mri.graph.kind_of?(Hash) ? [mri.graph[:count].to_i, mri.graph[:other]] : [ReportController::Reports::Editor::GRAPH_MAX_COUNT, true]
        end

        def build_reporting_chart_dim2
          (sort1, sort2) = mri.sortby
          save1 = save2 = counter = save1_nonblank = save2_nonblank = nil
          counts = {} # hash of hashes of counts
          mri.table.data.each_with_index do |r, d_idx|
            if d_idx == 0 || save1 != r[sort1].to_s
              counts[save1_nonblank][save2_nonblank] = counter unless d_idx == 0
              save1 = r[sort1].to_s
              save2 = r[sort2].to_s
              save1_nonblank = nonblank_or_default(save1)
              save2_nonblank = nonblank_or_default(save2)
              counts[save1_nonblank] = Hash.new(0)
              counter = 0
            else
              if save2 != r[sort2].to_s # only the second sort field changed, save the count
                counts[save1_nonblank][save2_nonblank] = counter
                save2 = r[sort2].to_s
                save2_nonblank = nonblank_or_default(save2)
                counter = 0
              end
            end
            counter += 1
          end
          # add the last key/value to the counts hash
          counts[save1_nonblank][save2_nonblank] = counter
          # We have all the counts, now we need to collect all of the . . .
          sort1_vals = []                  # sort field 1 values into an array and . . .
          sort2_vals_counts = Hash.new(0)  # sort field 2 values and counts into a Hash
          counts.each do |key1, hash1|
            sort1_vals.push(key1)
            hash1.each { |key2, count2| sort2_vals_counts[key2] += count2 }
          end
          sort2_vals = sort2_vals_counts.sort { |a, b| b[1] <=> a[1] } # Sort the field values by count size descending

          # trim and add axis_category_text to the chart
          sort1_vals.collect! { |value| slice_legend(value, LABEL_LENGTH) }
          add_axis_category_text(sort1_vals)

          # Now go through the counts hash again and put out a series for each sort field 1 hash of counts
          (keep, show_other) = keep_and_show_other

          # If there are more than keep categories Keep the highest counts
          other = keep < sort2_vals.length ? sort2_vals.slice!(keep..-1) : nil

          sort2_vals.each do |val2|
            series = counts.each_with_object(series_class.new) do |(key1, hash1), a|
              a.push(:value   => hash1[val2[0]],
                     :tooltip => "#{key1} / #{val2[0]}")
            end
            val2[0] = val2[0].to_s.gsub("\\", ' \ ')
            add_series(val2[0].to_s, series)
          end

          if other.present? && show_other # Sum up the other sort2 counts by sort1 value
            series = series_class.new
            counts.each do |key1, hash1|   # Go thru each sort1 key and hash count
              # Add in all of the remaining sort2 key counts
              ocount = other.reduce(0) { |a, e| a + hash1[e[0]] }
              series.push(:value   => ocount,
                          :tooltip => "#{key1} / Other: #{ocount}")
            end
            add_series(_("Other"), series)
          end
          counts
        end

        def extract_column_names
          # examples:
          #  'Vm.hardware-cpu_sockets' gives 'hardware-cpu_sockets'
          #  'Host-v_total_vms'        gives 'v_total_vms'
          #  'Vm-num_cpu:total'        gives 'num_cpu' and 'num_cpu__total'
          #  "Vm::Providers::InfraManager::Vm-num_cpu:total"
          #                            gives 'Vm::Providers::InfraManager::Vm' and 'num_cpu__total'
          stage1, aggreg = mri.graph[:column].split(/(?<!:):(?!:)/) # split by ':', NOT by '::'
          model1, column = stage1.split('-', 2)
          _model, sub_model = model1.split('.', 2)

          @raw_column_name  = sub_model.present? ? "#{sub_model}.#{column}" : column
          @data_column_name = aggreg.blank? ? @raw_column_name : "#{@raw_column_name}__#{aggreg}"
          @aggreg = aggreg.blank? ? nil : aggreg.to_sym
        end

        def aggreg
          extract_column_names unless @raw_column_name
          @aggreg
        end

        def raw_column_name
          extract_column_names unless @raw_column_name
          @raw_column_name
        end

        def data_column_name
          extract_column_names unless @data_column_name
          @data_column_name
        end

        # Options:
        #   sort1            -- labels
        #   data_column_name -- values
        #
        def build_numeric_chart_simple
          categories = []
          (sort1,) = mri.sortby
          (keep, show_other) = keep_and_show_other
          sorted_data = mri.table.data.sort_by { |row| row[data_column_name] || 0 }

          series = sorted_data.reverse.take(keep)
                   .each_with_object(series_class.new(pie_type? ? :pie : :flat)) do |row, a|
            tooltip = row[sort1]
            tooltip = _('no value') if tooltip.blank?
            a.push(:value   => row[data_column_name],
                   :tooltip => tooltip)
            categories.push([tooltip, row[data_column_name]])
          end

          if show_other
            other_sum = Array(sorted_data[0, sorted_data.length - keep])
                        .inject(0) { |sum, row| sum + (row[data_column_name] || 0) }
            series.push(:value => other_sum, :tooltip => _('Other'))
            categories.push([_('Other'), other_sum])
          end

          # Pie charts put categories in legend, else in axis labels
          add_axis_category_text(categories)

          add_series(chart_is_2d? ? mri.chart_header_column : nil, series)
        end

        def build_numeric_chart_grouped
          (keep, show_other) = keep_and_show_other
          show_other &&= (aggreg == :total) # FIXME: we only support :total

          groups = mri.build_subtotals.reject { |k, _| k == :_total_ }
          sorted_data = groups.sort_by { |_, data| data[aggreg][raw_column_name] || 0 }

          categories = []
          series = sorted_data.reverse.take(keep)
                   .each_with_object(series_class.new(pie_type? ? :pie : :flat)) do |(key, data), a|
            tooltip = key
            tooltip = _('no value') if key.blank?
            a.push(:value   => data[aggreg][raw_column_name],
                   :tooltip => tooltip)
            categories.push([tooltip, data[aggreg][raw_column_name]])
          end

          if show_other
            other_sum = Array(sorted_data[0, sorted_data.length - keep])
                        .inject(0) { |sum, (_key, row)| sum + row[aggreg][raw_column_name] }

            series.push(:value => other_sum, :tooltip => _('Other'))
            categories.push([_('Other'), other_sum])
          end

          # Pie charts put categories in legend, else in axis labels
          add_axis_category_text(categories)

          add_series(chart_is_2d? ? mri.chart_header_column : nil, series)
        end

        def build_numeric_chart_grouped_2dim
          (sort1, sort2) = mri.sortby
          (keep, show_other) = keep_and_show_other
          show_other &&= (aggreg == :total) # FIXME: we only support :total

          subtotals = mri.build_subtotals(true).reject { |k, _| k == :_total_ }

          # Group values by sort1
          # 3rd dimension in the chart is defined by sort2
          groups = mri.table.data.group_by { |row| row[sort1] }

          def_range_key2 = subtotals.keys.map { |key| key.split('__')[1] || '' }.sort.uniq

          group_sums = groups.keys.each_with_object({}) do |key1, h|
            h[key1] = def_range_key2.inject(0) do |sum, key2|
              sub_key = "#{key1}__#{key2}"
              subtotals.key?(sub_key) ? sum + subtotals[sub_key][aggreg][raw_column_name] : sum
            end
          end

          sorted_sums = group_sums.sort_by { |_key, sum| sum }

          selected_groups = sorted_sums.reverse.take(keep)

          cathegory_texts = selected_groups.collect do |key, _|
            label = key
            label = _('no value') if label.blank?
            label
          end
          cathegory_texts << _('Other') if show_other

          add_axis_category_text(cathegory_texts)

          if show_other
            other_groups = Array(sorted_sums[0, sorted_sums.length - keep])
            other = other_groups.each_with_object(Hash.new(0)) do |(key, _), o|
              groups[key].each { |row| o[row[sort2]] += row[raw_column_name] }
            end
          end

          # For each value in sort2 column we create a series.
          sort2_values = mri.table.data.map { |row| row[sort2] }.uniq
          sort2_values.each do |val2|
            series = selected_groups.each_with_object(series_class.new) do |(key1, _), a|
              sub_key = "#{key1}__#{val2}"
              value = subtotals.key?(sub_key) ? subtotals[sub_key][aggreg][raw_column_name] : 0

              a.push(:value   => value,
                     :tooltip => "#{key1} / #{val2}")
            end

            series.push(:value   => other[val2],
                        :tooltip => "Other / #{val2}") if show_other
            label = val2 if val2.kind_of?(String)
            label = label.to_s.gsub("\\", ' \ ')
            label = _('no value') if label.blank?
            add_series(label, series)
          end
          groups.keys.collect { |k| (k.presence || _('no value')) }
        end

        def pie_type?
          @pie_type ||= mri.graph[:type] =~ /^(Pie|Donut)/
        end

        def build_reporting_chart_other
          save_key   = nil
          counter    = 0
          categories = []                      # Store categories and series counts in an array of arrays
          mri.table.data.each_with_index do |r, d_idx|
            category_changed = save_key != r[mri.sortby[0]]
            not_first_iteration = d_idx > 0
            if not_first_iteration && category_changed
              categories.push([save_key, counter])    # Push current category and count onto the array
              counter = 0
            end
            save_key = r[mri.sortby[0]]
            counter += 1
          end
          categories.push([save_key, counter])        # Push last category and count onto the array

          (keep, show_other) = keep_and_show_other
          kept_categories = categories
          kept_categories.reject! { |a| a.first.nil? }
          kept_categories = kept_categories.sort_by(&:first).take(keep)
          kept_categories.reverse! if mri.order == "Descending"
          kept_categories.push(["Other", (categories - kept_categories).reduce(0) { |a, e| a + e.last }]) if show_other
          kept_categories.map { |cat| [nonblank_or_default(cat.first), cat.last] }

          series = kept_categories.each_with_object(
            series_class.new(pie_type? ? :pie : :flat)) do |cat, a|
            a.push(:value => cat.last, :tooltip => cat.first)
          end

          # Pie charts put categories in legend, else in axis labels
          add_axis_category_text(kept_categories)
          add_series(chart_is_2d? ? mri.chart_header_column : nil, series)
        end

        # C&U performance charts (Cluster, Host, VM based)
        def build_performance_chart(maxcols)
          case mri.graph[:type]
          when "Area", "AreaThreed", "Line", "StackedArea",
               "StackedThreedArea", "ParallelThreedColumn"
            build_performance_chart_area(maxcols)
          when "Pie", "PieThreed"
            build_performance_chart_pie(maxcols)
          end
        end

        # Utilization timestamp charts
        def build_util_ts_chart(_maxcols)
          build_util_ts_chart_column if %w[Column ColumnThreed].index(mri.graph[:type])
        end

        def build_reporting_chart_numeric(_maxcols)
          return no_records_found_chart(_('Invalid chart definition')) unless mri.graph[:column].present?

          if mri.group.nil?
            build_numeric_chart_simple
          else
            mri.dims == 2 ? build_numeric_chart_grouped_2dim : build_numeric_chart_grouped
          end
        end

        def build_reporting_chart(_maxcols)
          mri.dims == 2 ? build_reporting_chart_dim2 : build_reporting_chart_other
        end
      end
    end
  end
end