module DotHelper def render_agents_diagram(agents) if (command = ENV['USE_GRAPHVIZ_DOT']) && (svg = IO.popen([command, *%w[-Tsvg -q1 -o/dev/stdout /dev/stdin]], 'w+') { |dot| dot.print agents_dot(agents, true) dot.close_write dot.read } rescue false) decorate_svg(svg, agents).html_safe else tag('img', src: URI('https://chart.googleapis.com/chart').tap { |uri| uri.query = URI.encode_www_form(cht: 'gv', chl: agents_dot(agents)) }) end end class DotDrawer def initialize(vars = {}) @dot = '' @vars = vars.symbolize_keys end def method_missing(var, *args) @vars.fetch(var) { super } 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) draw(agents: 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: 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}s " 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') { # statement 'graph', rankdir: 'LR' 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' div['style'] = "width: #{svg['width']}; height: #{svg['height']}" } 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.coffee }.at('div.agent-diagram').to_s end end