dot_helper.rb 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. module DotHelper
  2. def render_agents_diagram(agents, layout: nil)
  3. if svg = dot_to_svg(agents_dot(agents, rich: true, layout:))
  4. decorate_svg(svg, agents).html_safe
  5. else
  6. # Google chart request url
  7. faraday = Faraday.new { |builder|
  8. builder.request :url_encoded
  9. builder.adapter Faraday.default_adapter
  10. }
  11. response = faraday.post('https://chart.googleapis.com/chart', { cht: 'gv', chl: agents_dot(agents) })
  12. case response.status
  13. when 200
  14. # Display Base64-Encoded images
  15. tag('img', src: 'data:image/jpg;base64,' + Base64.encode64(response.body))
  16. when 400
  17. "The diagram can't be displayed because it has too many nodes. Max allowed is 80."
  18. when 413
  19. "The diagram can't be displayed because it is too large."
  20. else
  21. "Unknow error. Response code is #{response.status}."
  22. end
  23. end
  24. end
  25. private def dot_to_svg(dot)
  26. command = ENV['USE_GRAPHVIZ_DOT'] or return nil
  27. IO.popen(%W[#{command} -Tsvg -q1 -o/dev/stdout /dev/stdin], 'w+') do |rw|
  28. rw.print dot
  29. rw.close_write
  30. rw.read
  31. rescue StandardError
  32. end
  33. end
  34. class DotDrawer
  35. def initialize(vars = {})
  36. @dot = ''
  37. vars.each do |key, value|
  38. define_singleton_method(key) { value }
  39. end
  40. end
  41. def to_s
  42. @dot
  43. end
  44. def self.draw(*args, &block)
  45. drawer = new(*args)
  46. drawer.instance_exec(&block)
  47. drawer.to_s
  48. end
  49. def raw(string)
  50. @dot << string
  51. end
  52. ENDL = ';'.freeze
  53. def endl
  54. @dot << ENDL
  55. end
  56. def escape(string)
  57. # Backslash escaping seems to work for the backslash itself,
  58. # though it's not documented in the DOT language docs.
  59. string.gsub(/[\\"\n]/,
  60. "\\" => "\\\\",
  61. "\"" => "\\\"",
  62. "\n" => "\\n")
  63. end
  64. def id(value)
  65. case string = value.to_s
  66. when /\A(?!\d)\w+\z/, /\A(?:\.\d+|\d+(?:\.\d*)?)\z/
  67. raw string
  68. else
  69. raw '"'
  70. raw escape(string)
  71. raw '"'
  72. end
  73. end
  74. def ids(values)
  75. values.each_with_index { |id, i|
  76. raw ' ' if i > 0
  77. id id
  78. }
  79. end
  80. def attr_list(attrs = nil)
  81. return if attrs.nil?
  82. attrs = attrs.select { |_key, value| value.present? }
  83. return if attrs.empty?
  84. raw '['
  85. attrs.each_with_index { |(key, value), i|
  86. raw ',' if i > 0
  87. id key
  88. raw '='
  89. id value
  90. }
  91. raw ']'
  92. end
  93. def node(id, attrs = nil)
  94. id id
  95. attr_list attrs
  96. endl
  97. end
  98. def edge(from, to, attrs = nil, op = '->')
  99. id from
  100. raw op
  101. id to
  102. attr_list attrs
  103. endl
  104. end
  105. def statement(ids, attrs = nil)
  106. ids Array(ids)
  107. attr_list attrs
  108. endl
  109. end
  110. def block(*ids, &block)
  111. ids ids
  112. raw '{'
  113. block.call
  114. raw '}'
  115. end
  116. end
  117. private
  118. def draw(vars = {}, &block)
  119. DotDrawer.draw(vars, &block)
  120. end
  121. def agents_dot(agents, rich: false, layout: nil)
  122. draw(agents:,
  123. agent_id: ->(agent) { 'a%d' % agent.id },
  124. agent_label: ->(agent) {
  125. agent.name.gsub(/(.{20}\S*)\s+/) {
  126. # Fold after every 20+ characters
  127. $1 + "\n"
  128. }
  129. },
  130. agent_url: ->(agent) { agent_path(agent.id) },
  131. rich:) {
  132. @disabled = '#999999'
  133. def agent_node(agent)
  134. node(agent_id[agent],
  135. label: agent_label[agent],
  136. tooltip: (agent.short_type.titleize if rich),
  137. URL: (agent_url[agent] if rich),
  138. style: ('rounded,dashed' if agent.unavailable?),
  139. color: (@disabled if agent.unavailable?),
  140. fontcolor: (@disabled if agent.unavailable?))
  141. end
  142. def agent_edge(agent, receiver)
  143. edge(agent_id[agent],
  144. agent_id[receiver],
  145. style: ('dashed' unless receiver.propagate_immediately?),
  146. label: (" #{agent.control_action.pluralize} " if agent.can_control_other_agents?),
  147. arrowhead: ('empty' if agent.can_control_other_agents?),
  148. color: (@disabled if agent.unavailable? || receiver.unavailable?))
  149. end
  150. block('digraph', 'Agent Event Flow') {
  151. layout ||= ENV['DIAGRAM_DEFAULT_LAYOUT'].presence
  152. if rich && /\A[a-z]+\z/ === layout
  153. statement 'graph', layout:, overlap: 'false'
  154. end
  155. statement 'node',
  156. shape: 'box',
  157. style: 'rounded',
  158. target: '_blank',
  159. fontsize: 10,
  160. fontname: ('Helvetica' if rich)
  161. statement 'edge',
  162. fontsize: 10,
  163. fontname: ('Helvetica' if rich)
  164. agents.each.with_index { |agent, _index|
  165. agent_node(agent)
  166. [
  167. *agent.receivers,
  168. *(agent.control_targets if agent.can_control_other_agents?)
  169. ].each { |receiver|
  170. agent_edge(agent, receiver) if agents.include?(receiver)
  171. }
  172. }
  173. }
  174. }
  175. end
  176. def decorate_svg(xml, agents)
  177. svg = Nokogiri::XML(xml).at('svg')
  178. Nokogiri::HTML::Document.new.tap { |doc|
  179. doc << root = Nokogiri::XML::Node.new('div', doc) { |div|
  180. div['class'] = 'agent-diagram'
  181. }
  182. svg['class'] = 'diagram'
  183. root << svg
  184. root << overlay_container = Nokogiri::XML::Node.new('div', doc) { |div|
  185. div['class'] = 'overlay-container'
  186. }
  187. overlay_container << overlay = Nokogiri::XML::Node.new('div', doc) { |div|
  188. div['class'] = 'overlay'
  189. }
  190. svg.xpath('//xmlns:g[@class="node"]', svg.namespaces).each { |node|
  191. agent_id = (node.xpath('./xmlns:title/text()', svg.namespaces).to_s[/\d+/] or next).to_i
  192. agent = agents.find { |a| a.id == agent_id }
  193. count = agent.events_count
  194. next unless count && count > 0
  195. overlay << Nokogiri::XML::Node.new('a', doc) { |badge|
  196. badge['id'] = id = 'b%d' % agent_id
  197. badge['class'] = 'badge'
  198. badge['href'] = agent_events_path(agent)
  199. badge['target'] = '_blank'
  200. badge['title'] = "#{count} events created"
  201. badge.content = count.to_s
  202. node['data-badge-id'] = id
  203. badge << Nokogiri::XML::Node.new('span', doc) { |label|
  204. # a dummy label only to obtain the background color
  205. label['class'] = [
  206. 'label',
  207. if agent.unavailable?
  208. 'label-warning'
  209. elsif agent.working?
  210. 'label-success'
  211. else
  212. 'label-danger'
  213. end
  214. ].join(' ')
  215. label['style'] = 'display: none'
  216. }
  217. }
  218. }
  219. # See also: app/assets/diagram.js
  220. }.at('div.agent-diagram').to_s
  221. end
  222. end