cantino/huginn

View on GitHub
app/helpers/dot_helper.rb

Summary

Maintainability
A
0 mins
Test Coverage
module DotHelper
  def render_agents_diagram(agents, layout: nil)
    if svg = dot_to_svg(agents_dot(agents, rich: true, layout:))
      decorate_svg(svg, agents).html_safe
    else
      # Google chart request url
      faraday = Faraday.new { |builder|
        builder.request :url_encoded
        builder.adapter Faraday.default_adapter
      }
      response = faraday.post('https://chart.googleapis.com/chart', { cht: 'gv', chl: agents_dot(agents) })

      case response.status
      when 200
        # Display Base64-Encoded images
        tag('img', src: 'data:image/jpg;base64,' + Base64.encode64(response.body))
      when 400
        "The diagram can't be displayed because it has too many nodes. Max allowed is 80."
      when 413
        "The diagram can't be displayed because it is too large."
      else
        "Unknow error. Response code is #{response.status}."
      end
    end
  end

  private def dot_to_svg(dot)
    command = ENV['USE_GRAPHVIZ_DOT'] or return nil

    IO.popen(%W[#{command} -Tsvg -q1 -o/dev/stdout /dev/stdin], 'w+') do |rw|
      rw.print dot
      rw.close_write
      rw.read
    rescue StandardError
    end
  end

  class DotDrawer
    def initialize(vars = {})
      @dot = ''
      vars.each do |key, value|
        define_singleton_method(key) { value }
      end
    end

    def to_s
      @dot
    end

    def self.draw(*args, &block)
      drawer = new(*args)
      drawer.instance_exec(&block)
      drawer.to_s
    end

    def raw(string)
      @dot << string
    end

    ENDL = ';'.freeze

    def endl
      @dot << ENDL
    end

    def escape(string)
      # Backslash escaping seems to work for the backslash itself,
      # though it's not documented in the DOT language docs.
      string.gsub(/[\\"\n]/,
                  "\\" => "\\\\",
                  "\"" => "\\\"",
                  "\n" => "\\n")
    end

    def id(value)
      case string = value.to_s
      when /\A(?!\d)\w+\z/, /\A(?:\.\d+|\d+(?:\.\d*)?)\z/
        raw string
      else
        raw '"'
        raw escape(string)
        raw '"'
      end
    end

    def ids(values)
      values.each_with_index { |id, i|
        raw ' ' if i > 0
        id id
      }
    end

    def attr_list(attrs = nil)
      return if attrs.nil?

      attrs = attrs.select { |_key, value| value.present? }
      return if attrs.empty?

      raw '['
      attrs.each_with_index { |(key, value), i|
        raw ',' if i > 0
        id key
        raw '='
        id value
      }
      raw ']'
    end

    def node(id, attrs = nil)
      id id
      attr_list attrs
      endl
    end

    def edge(from, to, attrs = nil, op = '->')
      id from
      raw op
      id to
      attr_list attrs
      endl
    end

    def statement(ids, attrs = nil)
      ids Array(ids)
      attr_list attrs
      endl
    end

    def block(*ids, &block)
      ids ids
      raw '{'
      block.call
      raw '}'
    end
  end

  private

  def draw(vars = {}, &block)
    DotDrawer.draw(vars, &block)
  end

  def agents_dot(agents, rich: false, layout: nil)
    draw(agents:,
         agent_id: ->(agent) { 'a%d' % agent.id },
         agent_label: ->(agent) {
           agent.name.gsub(/(.{20}\S*)\s+/) {
             # Fold after every 20+ characters
             $1 + "\n"
           }
         },
         agent_url: ->(agent) { agent_path(agent.id) },
         rich:) {
      @disabled = '#999999'

      def agent_node(agent)
        node(agent_id[agent],
             label: agent_label[agent],
             tooltip: (agent.short_type.titleize if rich),
             URL: (agent_url[agent] if rich),
             style: ('rounded,dashed' if agent.unavailable?),
             color: (@disabled if agent.unavailable?),
             fontcolor: (@disabled if agent.unavailable?))
      end

      def agent_edge(agent, receiver)
        edge(agent_id[agent],
             agent_id[receiver],
             style: ('dashed' unless receiver.propagate_immediately?),
             label: (" #{agent.control_action.pluralize} " if agent.can_control_other_agents?),
             arrowhead: ('empty' if agent.can_control_other_agents?),
             color: (@disabled if agent.unavailable? || receiver.unavailable?))
      end

      block('digraph', 'Agent Event Flow') {
        layout ||= ENV['DIAGRAM_DEFAULT_LAYOUT'].presence
        if rich && /\A[a-z]+\z/ === layout
          statement 'graph', layout:, overlap: 'false'
        end
        statement 'node',
                  shape: 'box',
                  style: 'rounded',
                  target: '_blank',
                  fontsize: 10,
                  fontname: ('Helvetica' if rich)

        statement 'edge',
                  fontsize: 10,
                  fontname: ('Helvetica' if rich)

        agents.each.with_index { |agent, _index|
          agent_node(agent)

          [
            *agent.receivers,
            *(agent.control_targets if agent.can_control_other_agents?)
          ].each { |receiver|
            agent_edge(agent, receiver) if agents.include?(receiver)
          }
        }
      }
    }
  end

  def decorate_svg(xml, agents)
    svg = Nokogiri::XML(xml).at('svg')

    Nokogiri::HTML::Document.new.tap { |doc|
      doc << root = Nokogiri::XML::Node.new('div', doc) { |div|
        div['class'] = 'agent-diagram'
      }

      svg['class'] = 'diagram'

      root << svg
      root << overlay_container = Nokogiri::XML::Node.new('div', doc) { |div|
        div['class'] = 'overlay-container'
      }
      overlay_container << overlay = Nokogiri::XML::Node.new('div', doc) { |div|
        div['class'] = 'overlay'
      }

      svg.xpath('//xmlns:g[@class="node"]', svg.namespaces).each { |node|
        agent_id = (node.xpath('./xmlns:title/text()', svg.namespaces).to_s[/\d+/] or next).to_i
        agent = agents.find { |a| a.id == agent_id }

        count = agent.events_count
        next unless count && count > 0

        overlay << Nokogiri::XML::Node.new('a', doc) { |badge|
          badge['id'] = id = 'b%d' % agent_id
          badge['class'] = 'badge'
          badge['href'] = agent_events_path(agent)
          badge['target'] = '_blank'
          badge['title'] = "#{count} events created"
          badge.content = count.to_s

          node['data-badge-id'] = id

          badge << Nokogiri::XML::Node.new('span', doc) { |label|
            # a dummy label only to obtain the background color
            label['class'] = [
              'label',
              if agent.unavailable?
                'label-warning'
              elsif agent.working?
                'label-success'
              else
                'label-danger'
              end
            ].join(' ')
            label['style'] = 'display: none'
          }
        }
      }
      # See also: app/assets/diagram.js
    }.at('div.agent-diagram').to_s
  end
end