ductwork 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/audit-common/accepted-tradeoffs.md +0 -5
  3. data/CHANGELOG.md +19 -0
  4. data/app/assets/javascripts/ductwork/application.js +50 -41
  5. data/app/assets/stylesheets/ductwork/application.css +859 -146
  6. data/app/controllers/ductwork/application_controller.rb +39 -8
  7. data/app/controllers/ductwork/dashboards_controller.rb +44 -11
  8. data/app/controllers/ductwork/pipelines_controller.rb +47 -7
  9. data/app/controllers/ductwork/step_errors_controller.rb +9 -5
  10. data/app/helpers/ductwork/application_helper.rb +107 -18
  11. data/app/views/ductwork/dashboards/show.html.erb +75 -126
  12. data/app/views/ductwork/pipelines/_graph.html.erb +37 -0
  13. data/app/views/ductwork/pipelines/_step.html.erb +76 -0
  14. data/app/views/ductwork/pipelines/index.html.erb +11 -101
  15. data/app/views/ductwork/pipelines/show.html.erb +62 -285
  16. data/app/views/ductwork/shared/_error.html.erb +7 -0
  17. data/app/views/ductwork/shared/_filter_menu.html.erb +13 -0
  18. data/app/views/ductwork/shared/_pager.html.erb +16 -0
  19. data/app/views/ductwork/shared/_runs_table.html.erb +30 -0
  20. data/app/views/ductwork/shared/_status_tabs.html.erb +11 -0
  21. data/app/views/ductwork/step_errors/index.html.erb +52 -96
  22. data/app/views/layouts/ductwork/application.html.erb +19 -23
  23. data/lib/ductwork/cli.rb +47 -8
  24. data/lib/ductwork/engine.rb +0 -2
  25. data/lib/ductwork/models/pipeline.rb +52 -31
  26. data/lib/ductwork/pipeline_graph/layout.rb +161 -0
  27. data/lib/ductwork/pipeline_graph.rb +231 -0
  28. data/lib/ductwork/version.rb +1 -1
  29. data/lib/ductwork.rb +20 -19
  30. metadata +11 -5
  31. data/app/assets/images/ductwork/octagon-x.svg +0 -1
  32. data/app/assets/stylesheets/ductwork/vendor/flexboxgrid.min.css +0 -1
  33. data/app/assets/stylesheets/ductwork/vendor/pico.min.css +0 -4
@@ -4,13 +4,20 @@ module Ductwork
4
4
  class ApplicationController < ActionController::Base
5
5
  DEFAULT_PER_PAGE = 50
6
6
 
7
+ helper_method :current_page, :per_page
8
+
7
9
  def query_pipeline_runs
8
- Ductwork::Run
9
- .includes(steps: { job: { executions: :result } })
10
- .then(&method(:filter_by_klass))
11
- .then(&method(:filter_by_status))
10
+ filtered_runs
12
11
  .then(&method(:paginate))
13
- .order(started_at: :desc)
12
+ .order(started_at: :desc, id: :desc)
13
+ end
14
+
15
+ def filtered_runs
16
+ runs_of_klass.then(&method(:filter_by_status))
17
+ end
18
+
19
+ def runs_of_klass
20
+ Ductwork::Run.all.then(&method(:filter_by_klass))
14
21
  end
15
22
 
16
23
  def filter_by_klass(relation)
@@ -30,10 +37,34 @@ module Ductwork
30
37
  end
31
38
 
32
39
  def paginate(relation)
33
- per_page = @per_page || DEFAULT_PER_PAGE
34
- offset = params[:page].to_i * per_page
40
+ relation.limit(per_page).offset(current_page * per_page)
41
+ end
42
+
43
+ def per_page
44
+ @per_page || DEFAULT_PER_PAGE
45
+ end
46
+
47
+ def current_page
48
+ [params[:page].to_i, 0].max
49
+ end
50
+
51
+ def error_counts(run_ids)
52
+ Ductwork::Result
53
+ .failure
54
+ .joins(execution: { job: :step })
55
+ .where(ductwork_steps: { run_id: run_ids })
56
+ .group("ductwork_steps.run_id")
57
+ .count
58
+ end
35
59
 
36
- relation.limit(per_page).offset(offset)
60
+ def halt_reasons(run_ids)
61
+ Ductwork::Branch
62
+ .where(run_id: run_ids, status: "halted")
63
+ .group(:run_id, :halt_reason)
64
+ .count
65
+ .each_with_object({}) do |((run_id, reason), count), reasons|
66
+ (reasons[run_id] ||= {})[reason] = count
67
+ end
37
68
  end
38
69
  end
39
70
  end
@@ -2,18 +2,51 @@
2
2
 
3
3
  module Ductwork
4
4
  class DashboardsController < Ductwork::ApplicationController
5
+ RECENT_RUNS = 10
6
+ HALTED_RUNS = 5
7
+
5
8
  def show
6
- @metrics = Ductwork::Pipeline
7
- .group(:status)
8
- .count
9
- .slice("completed", "in_progress", "halted")
10
- .with_indifferent_access
11
- .reverse_merge(completed: 0, in_progress: 0, halted: 0)
12
- @metrics[:step_errors] = Ductwork::Result.failure.count
13
- @klasses = Ductwork::Pipeline.group(:klass).pluck(:klass).sort
14
- @statuses = Ductwork::Pipeline.statuses.keys
15
- @per_page = 25
16
- @runs = query_pipeline_runs
9
+ @metrics = metrics
10
+ @halted_runs = Ductwork::Run
11
+ .halted
12
+ .where(pipeline_id: Ductwork::Pipeline.halted.select(:id))
13
+ .order(halted_at: :desc, id: :desc)
14
+ .limit(HALTED_RUNS)
15
+ @halts = halt_reasons(@halted_runs.map(&:id))
16
+ @class_summaries = class_summaries
17
+ @runs = Ductwork::Run.order(started_at: :desc, id: :desc).limit(RECENT_RUNS)
18
+ @error_counts = error_counts(@runs.map(&:id))
19
+ end
20
+
21
+ private
22
+
23
+ def metrics
24
+ Ductwork::Pipeline
25
+ .group(:status)
26
+ .count
27
+ .slice("completed", "in_progress", "halted")
28
+ .with_indifferent_access
29
+ .reverse_merge(completed: 0, in_progress: 0, halted: 0)
30
+ .merge(step_errors: Ductwork::Result.failure.count)
31
+ end
32
+
33
+ def class_summaries
34
+ last_started_at = Ductwork::Run.group(:pipeline_klass).maximum(:started_at)
35
+ counts = Ductwork::Pipeline.group(:klass, :status).count
36
+
37
+ counts.keys.map(&:first).uniq.sort.map do |klass|
38
+ total = counts.sum { |(key, _), count| key == klass ? count : 0 }
39
+ halted = counts.fetch([klass, "halted"], 0)
40
+ completed = counts.fetch([klass, "completed"], 0)
41
+
42
+ {
43
+ klass: klass,
44
+ active: total - halted - completed,
45
+ halted: halted,
46
+ completed: completed,
47
+ last_started_at: last_started_at[klass],
48
+ }
49
+ end
17
50
  end
18
51
  end
19
52
  end
@@ -3,29 +3,69 @@
3
3
  module Ductwork
4
4
  class PipelinesController < Ductwork::ApplicationController
5
5
  def index
6
+ @run_count = filtered_runs.count
7
+ @status_counts = runs_of_klass.group(:status).count
6
8
  @runs = query_pipeline_runs
9
+ @error_counts = error_counts(@runs.map(&:id))
7
10
  @klasses = Ductwork::Pipeline.group(:klass).pluck(:klass).sort
8
11
  @statuses = Ductwork::Pipeline.statuses.keys
9
12
  end
10
13
 
11
14
  def show
15
+ @per_page = 25
12
16
  @pipeline = Ductwork::Pipeline.find(params[:id])
13
17
  @last_run = @pipeline.runs.order(started_at: :desc).first
14
- @per_page = 10
18
+ @run_count = @pipeline.runs.count
19
+ @graph = Ductwork::PipelineGraph.new(@last_run)
20
+ @error_count = error_counts([@last_run.id]).fetch(@last_run.id, 0)
21
+ @halts = halt_reasons([@last_run.id]).fetch(@last_run.id, {})
22
+ load_steps
23
+ end
24
+
25
+ private
26
+
27
+ def load_steps
28
+ @status_counts = steps_in_scope.group(:status).count
29
+ @step_total = @last_run.steps.count
30
+ @step_count = filtered_steps.count
15
31
  @steps = query_steps
16
32
  @klasses = @last_run.steps.group(:klass).pluck(:klass).sort
33
+ @nodes = @last_run.steps.group(:node).pluck(:node).sort
17
34
  @statuses = Ductwork::Step.statuses.keys
18
35
  end
19
36
 
20
- private
21
-
22
37
  def query_steps
38
+ filtered_steps
39
+ .includes(job: { executions: %i[availability attempt result] })
40
+ .then(&method(:paginate))
41
+ .order(:started_at, :id)
42
+ end
43
+
44
+ def filtered_steps
45
+ steps_in_scope.then(&method(:filter_by_status))
46
+ end
47
+
48
+ def steps_in_scope
23
49
  @last_run
24
50
  .steps
25
- .then(&method(:filter_by_klass))
26
- .then(&method(:filter_by_status))
27
- .then(&method(:paginate))
28
- .order(:started_at)
51
+ .then(&method(:filter_by_step_klass))
52
+ .then(&method(:filter_by_node))
53
+ end
54
+
55
+ def filter_by_step_klass(relation)
56
+ if params[:klass].present?
57
+ relation.where(klass: params[:klass])
58
+ else
59
+ relation
60
+ end
61
+ end
62
+
63
+ def filter_by_node(relation)
64
+ if params[:node].present?
65
+ relation.where(node: params[:node])
66
+ else
67
+ relation
68
+ end
29
69
  end
30
70
  end
31
71
  end
@@ -3,23 +3,27 @@
3
3
  module Ductwork
4
4
  class StepErrorsController < Ductwork::ApplicationController
5
5
  def index
6
+ @error_count = filtered_errors.count
6
7
  @step_errors = query_step_errors
7
8
  @klasses = Ductwork::Result
8
9
  .failure
9
10
  .joins(execution: { job: :step })
10
11
  .group("ductwork_steps.klass")
11
12
  .pluck("ductwork_steps.klass")
13
+ .sort
12
14
  end
13
15
 
14
16
  private
15
17
 
16
18
  def query_step_errors
17
- Ductwork::Result
18
- .failure
19
- .then(&method(:filter_by_step_klass))
19
+ filtered_errors
20
20
  .then(&method(:paginate))
21
- .includes(execution: { job: :step })
22
- .order(created_at: :desc)
21
+ .includes(execution: { job: { step: :run } })
22
+ .order(created_at: :desc, id: :desc)
23
+ end
24
+
25
+ def filtered_errors
26
+ Ductwork::Result.failure.then(&method(:filter_by_step_klass))
23
27
  end
24
28
 
25
29
  def filter_by_step_klass(relation)
@@ -2,38 +2,127 @@
2
2
 
3
3
  module Ductwork
4
4
  module ApplicationHelper
5
+ NO_VALUE = "—"
6
+ SHORT_ID_LENGTH = 8
7
+
5
8
  def formatted_time_distance(started_at, completed_at)
6
9
  elapsed = completed_at - started_at
10
+
11
+ return "#{elapsed.round(2)}s" if elapsed < 60
12
+
7
13
  hours = (elapsed / 3600).floor
8
14
  minutes = ((elapsed % 3600) / 60).floor
9
- seconds = (elapsed % 60).round(2)
10
- hours_string = hours.zero? ? "" : "#{hours}h "
11
- minutes_string = minutes.zero? ? "" : "#{minutes}m "
12
- seconds_string = seconds.zero? ? "" : "#{seconds}s"
15
+ seconds = (elapsed % 60).floor
16
+ parts = { "h" => hours, "m" => minutes, "s" => seconds }.reject { |_, value| value.zero? }
17
+
18
+ parts.map { |unit, value| "#{value}#{unit}" }.join(" ")
19
+ end
20
+
21
+ def runtime(record)
22
+ finished_at = record.completed_at || record.try(:halted_at)
23
+
24
+ if finished_at.present?
25
+ formatted_time_distance(record.started_at, finished_at)
26
+ elsif record.started_at.nil? || record.try(:failed?)
27
+ no_value
28
+ else
29
+ tag.span(
30
+ formatted_time_distance(record.started_at, Time.current),
31
+ data: { started_at: record.started_at.iso8601(3) }
32
+ )
33
+ end
34
+ end
35
+
36
+ def timestamp(time)
37
+ return no_value if time.nil?
38
+
39
+ iso8601 = time.iso8601(3)
40
+
41
+ tag.time(time.strftime("%b %-d, %H:%M:%S"), datetime: iso8601, title: iso8601)
42
+ end
43
+
44
+ # NOTE: Primary keys are UUID v7, so the leading characters are a timestamp
45
+ # and match across records created together. The tail is the random part.
46
+ def short_id(id)
47
+ tag.code(id.to_s.last(SHORT_ID_LENGTH), title: id)
48
+ end
49
+
50
+ def status_badge(status)
51
+ tag.span(status_text(status), class: "status", data: { status: })
52
+ end
53
+
54
+ def status_text(status)
55
+ status.tr("_", "-")
56
+ end
57
+
58
+ def transition_text(transition)
59
+ transition == "default" ? "chain" : transition
60
+ end
13
61
 
14
- "#{hours_string}#{minutes_string}#{seconds_string}"
62
+ def count_badge(count, tone: "alert")
63
+ count.zero? ? tag.span(0, class: "faint") : tag.span(number_with_delimiter(count), class: "count-#{tone}")
64
+ end
65
+
66
+ def halt_summary(reasons)
67
+ reasons
68
+ .map do |reason, count|
69
+ text = reason&.humanize&.downcase || "unknown reason"
70
+
71
+ "#{pluralize(count, "branch")}: #{text}"
72
+ end
73
+ .join(", ")
74
+ end
75
+
76
+ def pretty_json(value)
77
+ value.nil? ? "no value" : JSON.pretty_generate(value)
78
+ end
79
+
80
+ def step_input(job)
81
+ JSON.parse(job.input_args)["args"] if job&.input_args.present?
82
+ end
83
+
84
+ def step_output(job)
85
+ job.return_value if job&.output_payload.present?
86
+ end
87
+
88
+ def filtered_path(anchor: nil, **changes)
89
+ url_for(**link_params.except(:page).merge(changes).compact, anchor:)
15
90
  end
16
91
 
17
92
  def first_page?
18
- params[:page].to_i.zero?
93
+ current_page.zero?
94
+ end
95
+
96
+ def last_page?(total)
97
+ (current_page + 1) * per_page >= total
19
98
  end
20
99
 
21
- def next_page_path
22
- next_page = params[:page].to_i + 1
23
- next_params = params
24
- .permit(:controller, :action, :id, :klass, :status, :page)
25
- .merge(page: next_page)
100
+ def page_summary(total)
101
+ first = (current_page * per_page) + 1
102
+ last = [first + per_page - 1, total].min
103
+
104
+ first > total ? "Showing 0 of #{total}" : "Showing #{first}-#{last} of #{total}"
105
+ end
26
106
 
27
- url_for(**next_params)
107
+ def next_page_path(anchor: nil)
108
+ url_for(**link_params, page: current_page + 1, anchor: anchor)
28
109
  end
29
110
 
30
- def previous_page_path
31
- previous_page = params[:page].to_i - 1
32
- previous_params = params
33
- .permit(:controller, :action, :id, :klass, :status, :page)
34
- .merge(page: previous_page)
111
+ def previous_page_path(anchor: nil)
112
+ url_for(**link_params, page: [current_page - 1, 0].max, anchor: anchor)
113
+ end
114
+
115
+ private
116
+
117
+ def link_params
118
+ params
119
+ .permit(:controller, :action, :id, :klass, :node, :status, :page)
120
+ .to_h
121
+ .symbolize_keys
122
+ end
35
123
 
36
- url_for(**previous_params)
124
+ def no_value
125
+ tag.span(NO_VALUE, class: "faint")
37
126
  end
38
127
  end
39
128
  end
@@ -1,136 +1,85 @@
1
- <div>
2
- <div class="grid metrics">
3
- <%= link_to(pipelines_path(status: "completed"), class: "stealthy-link") do %>
4
- <article>
5
- <div class="metric-heading">Completed Pipelines</div>
6
- <h1 class="metric-value"><%= number_with_delimiter(@metrics[:completed]) %></h1>
7
- </article>
8
- <% end %>
1
+ <% content_for(:title, "Overview") %>
9
2
 
10
- <%= link_to(pipelines_path(status: "in_progress"), class: "stealthy-link") do %>
11
- <article>
12
- <div class="metric-heading">In-Progress Pipelines</div>
13
- <h1 class="metric-value"><%= number_with_delimiter(@metrics[:in_progress]) %></h1>
14
- </article>
3
+ <section class="section">
4
+ <div class="panel stats">
5
+ <%= link_to(pipelines_path(status: "in_progress"), class: "stat") do %>
6
+ <span class="stat-value"><%= number_with_delimiter(@metrics[:in_progress]) %></span>
7
+ In progress
15
8
  <% end %>
16
-
17
- <%= link_to(pipelines_path(status: "halted"), class: "stealthy-link") do %>
18
- <article>
19
- <div class="metric-heading">Halted Pipelines</div>
20
- <h1 class="metric-value"><%= number_with_delimiter(@metrics[:halted]) %></h1>
21
- </article>
9
+ <%= link_to(pipelines_path(status: "halted"), class: "stat", data: { alert: @metrics[:halted].positive? }) do %>
10
+ <span class="stat-value"><%= number_with_delimiter(@metrics[:halted]) %></span>
11
+ Halted
22
12
  <% end %>
23
-
24
- <%= link_to(step_errors_path, class: "stealthy-link") do %>
25
- <article>
26
- <div class="metric-heading">Step Errors</div>
27
- <h1 class="metric-value"><%= @metrics[:step_errors] %></h1>
28
- </article>
13
+ <%= link_to(pipelines_path(status: "completed"), class: "stat") do %>
14
+ <span class="stat-value"><%= number_with_delimiter(@metrics[:completed]) %></span>
15
+ Completed
16
+ <% end %>
17
+ <%= link_to(step_errors_path, class: "stat") do %>
18
+ <span class="stat-value"><%= number_with_delimiter(@metrics[:step_errors]) %></span>
19
+ Step errors
29
20
  <% end %>
30
21
  </div>
22
+ </section>
31
23
 
32
- <div>
33
- <h1>All Pipelines</h1>
34
- <div class="separate-content">
35
- <div class="filter-group">
36
- <span class="filter-group-item">Filters:</span>
37
-
38
- <details class="dropdown filter-group-item">
39
- <summary>Class</summary>
40
- <ul>
41
- <% @klasses.each do |klass| %>
42
- <li>
43
- <%= link_to(klass, pipelines_path(klass:)) %>
44
- </li>
45
- <% end %>
46
- </ul>
47
- </details>
48
-
49
- <details class="dropdown filter-group-item">
50
- <summary>Status</summary>
51
- <ul>
52
- <% @statuses.each do |status| %>
53
- <li>
54
- <%= link_to(status.gsub("_", "-"), pipelines_path(status:)) %>
55
- </li>
56
- <% end %>
57
- </ul>
58
- </details>
59
- </div>
60
-
61
- <div role="group" style="width: auto;">
62
- <button <%= first_page? && "disabled" %> data-href="<%= previous_page_path %>">
63
- &larr;
64
- </button>
65
- <button data-href="<%= next_page_path %>">
66
- &rarr;
67
- </button>
68
- </div>
24
+ <% if @halted_runs.any? %>
25
+ <section class="section">
26
+ <div class="section-head">
27
+ <h2 class="section-title">Needs attention</h2>
28
+ <%= link_to("All halted", pipelines_path(status: "halted")) %>
29
+ </div>
30
+ <div class="panel">
31
+ <% @halted_runs.each do |run| %>
32
+ <div class="halt">
33
+ <div>
34
+ <%= link_to(run.pipeline_klass, pipeline_path(run.pipeline_id), class: "table-primary") %>
35
+ <%= short_id(run.pipeline_id) %>
36
+ </div>
37
+ <div class="halt-reason"><%= halt_summary(@halts.fetch(run.id, {})) %></div>
38
+ <div class="halt-time"><%= timestamp(run.halted_at) %></div>
39
+ </div>
40
+ <% end %>
69
41
  </div>
42
+ </section>
43
+ <% end %>
70
44
 
71
- <table class="striped">
72
- <thead>
73
- <tr>
74
- <th>ID</th>
75
- <th>Class</th>
76
- <th>Status</th>
77
- <th>Errors</th>
78
- <th>Started At</th>
79
- <th>Runtime</th>
80
- </tr>
81
- </thead>
82
- <tbody>
83
- <% @runs.each do |run| %>
84
- <tr class="row-link" data-href="<%= pipeline_path(run.pipeline_id) %>">
85
- <td>
86
- <code>
87
- <%= run.pipeline_id %>
88
- </code>
89
- </td>
90
- <td>
91
- <code>
92
- <%= run.pipeline_klass %>
93
- </code>
94
- </td>
95
- <td>
96
- <div class="status-pill <%= run.status %>">
97
- <%= run.status.gsub("_", "-") %>
98
- </div>
99
- </td>
100
- <td>
101
- <%
102
- count = Ductwork::Result
103
- .joins(execution: { job: { step: { run: :pipeline }}})
104
- .failure
105
- .where(ductwork_pipelines: { id: run.pipeline_id })
106
- .count
107
- %>
108
- <% if count.zero? %>
109
- 0
110
- <% else %>
111
- <div class="errors">
112
- <%= image_tag("ductwork/octagon-x.svg", class: "icon") %>
113
- <%= count %>
114
- </div>
115
- <% end %>
116
- </td>
117
- <td>
118
- <div class="tight-timestamp">
119
- <%= run.started_at.iso8601(3) %>
120
- </div>
121
- </td>
122
- <td>
123
- <% if run.completed_at.nil? %>
124
- <div data-started-at=<%= run.started_at.iso8601 %>>
125
- <span id="elapsed-timer">0h 0m 0s</span>
126
- </div>
127
- <% else %>
128
- <%= formatted_time_distance(run.started_at, run.completed_at) %>
129
- <% end %>
130
- </td>
45
+ <section class="section">
46
+ <div class="section-head">
47
+ <h2 class="section-title">Pipelines by class</h2>
48
+ </div>
49
+ <div class="panel table-scroller">
50
+ <% if @class_summaries.empty? %>
51
+ <p class="empty">No pipelines have been triggered yet.</p>
52
+ <% else %>
53
+ <table class="table">
54
+ <thead>
55
+ <tr>
56
+ <th>Class</th>
57
+ <th class="numeric">Active</th>
58
+ <th class="numeric">Halted</th>
59
+ <th class="numeric">Completed</th>
60
+ <th>Last started</th>
131
61
  </tr>
132
- <% end %>
133
- </tbody>
134
- </table>
62
+ </thead>
63
+ <tbody>
64
+ <% @class_summaries.each do |summary| %>
65
+ <tr data-href="<%= pipelines_path(klass: summary[:klass]) %>">
66
+ <td><%= link_to(summary[:klass], pipelines_path(klass: summary[:klass]), class: "table-primary") %></td>
67
+ <td class="numeric"><%= number_with_delimiter(summary[:active]) %></td>
68
+ <td class="numeric"><%= count_badge(summary[:halted]) %></td>
69
+ <td class="numeric"><%= number_with_delimiter(summary[:completed]) %></td>
70
+ <td><%= timestamp(summary[:last_started_at]) %></td>
71
+ </tr>
72
+ <% end %>
73
+ </tbody>
74
+ </table>
75
+ <% end %>
76
+ </div>
77
+ </section>
78
+
79
+ <section class="section">
80
+ <div class="section-head">
81
+ <h2 class="section-title">Recent runs</h2>
82
+ <%= link_to("All pipelines", pipelines_path) %>
135
83
  </div>
136
- </div>
84
+ <%= render("ductwork/shared/runs_table", runs: @runs, error_counts: @error_counts) %>
85
+ </section>
@@ -0,0 +1,37 @@
1
+ <div class="graph-scroller">
2
+ <svg class="graph" width="<%= graph.width %>" height="<%= graph.height %>" viewBox="0 0 <%= graph.width %> <%= graph.height %>" role="figure" aria-label="Pipeline graph">
3
+ <% graph.edges.each do |edge| %>
4
+ <path class="graph-edge" data-state="<%= edge.state %>" d="<%= edge.path %>"></path>
5
+ <% end %>
6
+
7
+ <% graph.nodes.each do |node| %>
8
+ <g class="graph-node" data-state="<%= node.state %>" data-selected="<%= params[:node] == node.id %>">
9
+ <title><%= node.klass %> (<%= node.id %>)</title>
10
+ <rect class="graph-card" x="<%= node.x %>" y="<%= node.y %>" width="<%= graph.card_width %>" height="<%= graph.card_height %>" rx="8"></rect>
11
+ <rect class="graph-bar-track" x="<%= node.x + Ductwork::PipelineGraph::BAR_INSET %>" y="<%= node.y + Ductwork::PipelineGraph::BAR_TOP %>" width="<%= graph.bar_width %>" height="<%= Ductwork::PipelineGraph::BAR_HEIGHT %>" rx="3"></rect>
12
+ <% node.segments.each do |segment| %>
13
+ <rect class="graph-bar-segment" data-status="<%= segment.status %>" x="<%= node.x + segment.x %>" y="<%= node.y + Ductwork::PipelineGraph::BAR_TOP %>" width="<%= segment.width %>" height="<%= Ductwork::PipelineGraph::BAR_HEIGHT %>"></rect>
14
+ <% end %>
15
+
16
+ <foreignObject x="<%= node.x %>" y="<%= node.y %>" width="<%= graph.card_width %>" height="<%= Ductwork::PipelineGraph::HEADER_HEIGHT %>">
17
+ <div class="graph-header">
18
+ <div class="graph-transition"><%= node.transition %></div>
19
+ <%= link_to(node.name, params[:node] == node.id ? pipeline_path(@pipeline.id, anchor: "steps") : pipeline_path(@pipeline.id, node: node.id, anchor: "steps"), class: "graph-name") %>
20
+ </div>
21
+ </foreignObject>
22
+
23
+ <foreignObject x="<%= node.x %>" y="<%= node.y + Ductwork::PipelineGraph::LABELS_TOP %>" width="<%= graph.card_width %>" height="<%= graph.card_height - Ductwork::PipelineGraph::LABELS_TOP %>">
24
+ <div class="graph-labels">
25
+ <% node.labels.each do |label| %>
26
+ <% if label.status %>
27
+ <%= link_to(label.text, pipeline_path(@pipeline.id, node: node.id, status: label.status, anchor: "steps"), class: "graph-label", data: { tone: label.tone }) %>
28
+ <% else %>
29
+ <span class="graph-label <%= "graph-label-wide" if label.wide %>" data-tone="<%= label.tone %>" title="<%= label.text %>"><%= label.text %></span>
30
+ <% end %>
31
+ <% end %>
32
+ </div>
33
+ </foreignObject>
34
+ </g>
35
+ <% end %>
36
+ </svg>
37
+ </div>