123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- 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
|