4031: Refresh provenance helper graph generation to fix bugs and make better
[arvados.git] / apps / workbench / app / helpers / provenance_helper.rb
1 module ProvenanceHelper
2
3   class GenerateGraph
4     def initialize(pdata, opts)
5       @pdata = pdata
6       @opts = opts
7       @visited = {}
8       @jobs = {}
9       @node_extra = {}
10     end
11
12     def self.collection_uuid(uuid)
13       Keep::Locator.parse(uuid).andand.strip_hints.andand.to_s
14     end
15
16     def url_for u
17       p = { :host => @opts[:request].host,
18         :port => @opts[:request].port,
19         :protocol => @opts[:request].protocol }
20       p.merge! u
21       Rails.application.routes.url_helpers.url_for (p)
22     end
23
24     def determine_fillcolor(n)
25       fillcolor = %w(666666 669966 666699 666666 996666)[n || 0] || '666666'
26       "style=\"filled\",color=\"#ffffff\",fillcolor=\"##{fillcolor}\",fontcolor=\"#ffffff\""
27     end
28
29     def describe_node(uuid, describe_opts={})
30       bgcolor = determine_fillcolor (describe_opts[:pip] || @opts[:pips].andand[uuid])
31
32       if GenerateGraph::collection_uuid(uuid)
33         if Collection.is_empty_blob_locator? uuid.to_s
34           # special case
35           return "\"#{uuid}\" [label=\"(empty collection)\"];\n"
36         end
37
38         href = url_for ({:controller => Collection.to_s.tableize,
39                           :action => :show,
40                           :id => uuid.to_s })
41
42         return "\"#{uuid}\" [label=\"#{encode_quotes(describe_opts[:label] || @pdata[uuid][:name] || uuid)}\",shape=box,href=\"#{href}\",#{bgcolor}];\n"
43       else
44         "\"#{uuid}\" [label=\"#{encode_quotes(describe_opts[:label] || uuid)}\",#{bgcolor},shape=#{describe_opts[:shape] || 'box'}];\n"
45       end
46     end
47
48     def job_uuid(job)
49       d = Digest::MD5.hexdigest(job[:script_parameters].to_json)
50       if @opts[:combine_jobs] == :script_only
51         uuid = "#{job[:script]}_#{d}"
52       elsif @opts[:combine_jobs] == :script_and_version
53         uuid = "#{job[:script]}_#{job[:script_version]}_#{d}"
54       else
55         uuid = "#{job[:uuid]}"
56       end
57
58       @jobs[uuid] = [] unless @jobs[uuid]
59       @jobs[uuid] << job unless @jobs[uuid].include? job
60
61       uuid
62     end
63
64     def edge(tail, head, extra)
65       if @opts[:direction] == :bottom_up
66         gr = "\"#{head}\" -> \"#{tail}\""
67       else
68         gr = "\"#{tail}\" -> \"#{head}\""
69       end
70
71       if extra.length > 0
72         gr += " ["
73         extra.each do |k, v|
74           gr += "#{k}=\"#{v}\","
75         end
76         gr += "]"
77       end
78       gr += ";\n"
79       gr
80     end
81
82     def script_param_edges(uuid, prefix, sp)
83       gr = ""
84
85       case sp
86       when Hash
87         sp.each do |k, v|
88           if prefix.size > 0
89             k = prefix + "::" + k.to_s
90           end
91           gr += script_param_edges(uuid, k.to_s, v)
92         end
93       when Array
94         i = 0
95         node = ""
96         count = 0
97         sp.each do |v|
98           if GenerateGraph::collection_uuid(v)
99             gr += script_param_edges(uuid, "#{prefix}[#{i}]", v)
100           elsif @opts[:all_script_parameters]
101             t = "#{v}"
102             nl = (if (count+t.length) > 60 then "\\n" else " " end)
103             count = 0 if (count+t.length) > 60
104             node += "',#{nl}'" unless node == ""
105             node = "['" if node == ""
106             node += t
107             count += t.length
108           end
109           i += 1
110         end
111         unless node == ""
112           node += "']"
113           node_value = encode_quotes node
114           gr += "\"#{node_value}\" [label=\"#{node_value}\"];\n"
115           gr += edge(uuid, node_value, {:label => prefix})
116         end
117       when String
118         return '' if sp.empty?
119         m = GenerateGraph::collection_uuid(sp)
120         if m and (@pdata[m] or (not @opts[:pdata_only]))
121           gr += edge(m, uuid, {:label => prefix})
122         elsif @opts[:all_script_parameters]
123           sp_value = encode_quotes sp
124           gr += "\"#{sp_value}\" [label=\"\\\"#{sp_value}\\\"\",shape=box];\n"
125           gr += edge(sp_value, uuid, {:label => prefix})
126         end
127       end
128       gr
129     end
130
131     def job_edges job, edge_opts={}
132       uuid = job_uuid(job)
133       gr = ""
134
135       gr += script_param_edges(uuid, "", job[:script_parameters])
136       if job[:docker_image_locator]
137         gr += describe_node(job[:docker_image_locator], {label: (job[:runtime_constraints].andand[:docker_image] || job[:docker_image_locator])})
138         gr += edge(job[:docker_image_locator], uuid, {:label => "docker_image"})
139       end
140
141       if @opts[:script_version_nodes]
142         #gr += describe_node(job[:script_version])
143         gr += edge(job[:script_version], uuid, {:label => "script_version"})
144       end
145
146       gr += edge(uuid, job[:output], {label: "output" }) if job[:output] and !edge_opts[:no_output]
147       #gr += edge(uuid, job[:log], {label: "log"}) if job[:log] and !edge_opts[:no_log]
148
149       gr
150     end
151
152     def generate_provenance_edges(uuid)
153       gr = ""
154       m = GenerateGraph::collection_uuid(uuid)
155       uuid = m if m
156
157       if uuid.nil? or uuid.empty? or @visited[uuid]
158         return ""
159       end
160
161       if @pdata[uuid].nil?
162         return ""
163       else
164         @visited[uuid] = true
165       end
166
167       if uuid.start_with? "component_"
168         # Pipeline component inputs
169         job = @pdata[@pdata[uuid][:job].andand[:uuid]]
170
171         gr += describe_node(job_uuid(job), {label: uuid[38..-1], pip: @opts[:pips].andand[job[:uuid]], shape: "oval"})
172         gr += job_edges job, {no_output: true, no_log: true}
173
174         # Pipeline component output
175         outuuid = @pdata[uuid][:output_uuid]
176         outcollection = @pdata[outuuid]
177         gr += edge(job_uuid(job), outcollection[:portable_data_hash], {label: "output"}) if outuuid
178         gr += describe_node(outcollection[:portable_data_hash], {label: outcollection[:name]})
179       else
180         rsc = ArvadosBase::resource_class_for_uuid uuid
181
182         if rsc == Job
183           job = @pdata[uuid]
184           gr += job_edges job if job
185         elsif rsc == Link
186           # do nothing
187         else
188           gr += describe_node(uuid)
189         end
190       end
191
192       @pdata.each do |k, link|
193         if link[:head_uuid] == uuid.to_s and link[:link_class] == "provenance"
194           href = url_for ({:controller => Link.to_s.tableize,
195                             :action => :show,
196                             :id => link[:uuid] })
197
198           gr += describe_node(link[:tail_uuid])
199           gr += edge(link[:head_uuid], link[:tail_uuid], {:label => link[:name], :href => href})
200           gr += generate_provenance_edges(link[:tail_uuid])
201         end
202       end
203
204       gr
205     end
206
207     def describe_jobs
208       gr = ""
209       @jobs.each do |k, v|
210         href = url_for ({:controller => Job.to_s.tableize,
211                           :action => :index })
212
213         gr += "\"#{k}\" [href=\"#{href}?"
214
215         n = 0
216         v.each do |u|
217           gr += ";" unless gr.end_with? "?"
218           gr += "uuid%5b%5d=#{u[:uuid]}"
219           n |= @opts[:pips][u[:uuid]] if @opts[:pips] and @opts[:pips][u[:uuid]]
220         end
221
222         gr += "\",label=\""
223
224         if @opts[:combine_jobs] == :script_only
225           gr += "#{v[0][:script]}"
226         elsif @opts[:combine_jobs] == :script_and_version
227           gr += "#{v[0][:script]}" # Just show the name but the nodes will be distinct
228         else
229           gr += "#{v[0][:script]}\\n#{v[0][:finished_at]}"
230         end
231         gr += "\",#{determine_fillcolor n}];\n"
232       end
233       gr
234     end
235
236     def encode_quotes value
237       value.andand.gsub("\"", "\\\"")
238     end
239   end
240
241   def self.create_provenance_graph(pdata, svgId, opts={})
242     if pdata.is_a? Array or pdata.is_a? ArvadosResourceList
243       p2 = {}
244       pdata.each do |k|
245         p2[k[:uuid]] = k if k[:uuid]
246       end
247       pdata = p2
248     end
249
250     unless pdata.is_a? Hash
251       raise "create_provenance_graph accepts Array or Hash for pdata only, pdata is #{pdata.class}"
252     end
253
254     gr = """strict digraph {
255 node [fontsize=10,fontname=\"Helvetica,Arial,sans-serif\"];
256 edge [fontsize=10,fontname=\"Helvetica,Arial,sans-serif\"];
257 """
258
259     if opts[:direction] == :bottom_up
260       gr += "edge [dir=back];"
261     end
262
263     g = GenerateGraph.new(pdata, opts)
264
265     pdata.each do |k, v|
266       if !opts[:only_components] or k.start_with? "component_"
267         gr += g.generate_provenance_edges(k)
268       else
269         #gr += describe_node(k)
270       end
271     end
272
273     if !opts[:only_components]
274       gr += g.describe_jobs
275     end
276
277     gr += "}"
278     svg = ""
279
280     require 'open3'
281
282     Open3.popen2("dot", "-Tsvg") do |stdin, stdout, wait_thr|
283       stdin.print(gr)
284       stdin.close
285       svg = stdout.read()
286       wait_thr.value
287       stdout.close()
288     end
289
290     svg = svg.sub(/<\?xml.*?\?>/m, "")
291     svg = svg.sub(/<!DOCTYPE.*?>/m, "")
292     svg = svg.sub(/<svg /, "<svg id=\"#{svgId}\" ")
293   end
294
295   def self.find_collections(sp, &b)
296     case sp
297     when ArvadosBase
298       sp.class.columns.each do |c|
299         find_collections(sp[c.name.to_sym], &b)
300       end
301     when Hash
302       sp.each do |k, v|
303         find_collections(v, &b)
304       end
305     when Array
306       sp.each do |v|
307         find_collections(v, &b)
308       end
309     when String
310       if m = /[a-f0-9]{32}\+\d+/.match(sp)
311         yield m[0], nil
312       elsif m = /[0-9a-z]{5}-4zz18-[0-9a-z]{15}/.match(sp)
313         yield nil, m[0]
314       end
315     end
316   end
317 end