app/helpers/dot_helper.rb
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