ductwork 1.4.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/app/assets/javascripts/ductwork/application.js +50 -41
  4. data/app/assets/stylesheets/ductwork/application.css +859 -146
  5. data/app/controllers/ductwork/application_controller.rb +39 -8
  6. data/app/controllers/ductwork/dashboards_controller.rb +44 -11
  7. data/app/controllers/ductwork/pipelines_controller.rb +47 -7
  8. data/app/controllers/ductwork/step_errors_controller.rb +9 -5
  9. data/app/helpers/ductwork/application_helper.rb +107 -18
  10. data/app/views/ductwork/dashboards/show.html.erb +75 -126
  11. data/app/views/ductwork/pipelines/_graph.html.erb +37 -0
  12. data/app/views/ductwork/pipelines/_step.html.erb +76 -0
  13. data/app/views/ductwork/pipelines/index.html.erb +11 -101
  14. data/app/views/ductwork/pipelines/show.html.erb +62 -285
  15. data/app/views/ductwork/shared/_error.html.erb +7 -0
  16. data/app/views/ductwork/shared/_filter_menu.html.erb +13 -0
  17. data/app/views/ductwork/shared/_pager.html.erb +16 -0
  18. data/app/views/ductwork/shared/_runs_table.html.erb +30 -0
  19. data/app/views/ductwork/shared/_status_tabs.html.erb +11 -0
  20. data/app/views/ductwork/step_errors/index.html.erb +52 -96
  21. data/app/views/layouts/ductwork/application.html.erb +19 -23
  22. data/lib/ductwork/engine.rb +0 -2
  23. data/lib/ductwork/models/pipeline.rb +52 -31
  24. data/lib/ductwork/pipeline_graph/layout.rb +161 -0
  25. data/lib/ductwork/pipeline_graph.rb +231 -0
  26. data/lib/ductwork/version.rb +1 -1
  27. metadata +11 -5
  28. data/app/assets/images/ductwork/octagon-x.svg +0 -1
  29. data/app/assets/stylesheets/ductwork/vendor/flexboxgrid.min.css +0 -1
  30. data/app/assets/stylesheets/ductwork/vendor/pico.min.css +0 -4
@@ -0,0 +1,76 @@
1
+ <% executions = step.job&.executions.to_a.sort_by(&:started_at) %>
2
+ <details class="row">
3
+ <summary class="row-grid row-grid-steps">
4
+ <span class="cell-truncate"><span class="table-primary"><%= step.klass %></span> <%= short_id(step.id) %></span>
5
+ <span><%= status_badge(step.status) %></span>
6
+ <span class="wide-only"><%= transition_text(step.to_transition) %></span>
7
+ <span><%= executions.size > 1 ? count_badge(executions.size, tone: "warn") : executions.size %></span>
8
+ <span class="wide-only"><%= timestamp(step.started_at) %></span>
9
+ <span class="wide-only"><%= runtime(step) %></span>
10
+ </summary>
11
+
12
+ <div class="row-body">
13
+ <div class="detail-grid">
14
+ <div>
15
+ <span class="detail-label">Step ID</span>
16
+ <code><%= step.id %></code>
17
+ </div>
18
+ <div>
19
+ <span class="detail-label">Node</span>
20
+ <%= link_to(step.node, pipeline_path(@pipeline.id, node: step.node, anchor: "steps")) %>
21
+ </div>
22
+ <div>
23
+ <span class="detail-label">Input</span>
24
+ <pre class="code"><%= pretty_json(step_input(step.job)) %></pre>
25
+ </div>
26
+ <div>
27
+ <span class="detail-label">Output</span>
28
+ <pre class="code"><%= pretty_json(step_output(step.job)) %></pre>
29
+ </div>
30
+ </div>
31
+
32
+ <% if executions.any? %>
33
+ <div class="detail-block">
34
+ <span class="detail-label">Attempts</span>
35
+ <div class="panel table-scroller">
36
+ <table class="table table-compact">
37
+ <thead>
38
+ <tr>
39
+ <th>#</th>
40
+ <th>Execution</th>
41
+ <th>Available</th>
42
+ <th>Claimed</th>
43
+ <th>Ran</th>
44
+ <th>Completed</th>
45
+ <th>Result</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ <% executions.each do |execution| %>
50
+ <tr>
51
+ <td><%= execution.retry_count + 1 %></td>
52
+ <td><%= short_id(execution.id) %></td>
53
+ <td><%= timestamp(execution.started_at) %></td>
54
+ <td><%= timestamp(execution.availability&.completed_at) %></td>
55
+ <td><%= timestamp(execution.attempt&.started_at) %></td>
56
+ <td><%= timestamp(execution.completed_at) %></td>
57
+ <td><%= execution.result&.result_type&.tr("_", " ") %></td>
58
+ </tr>
59
+ <% end %>
60
+ </tbody>
61
+ </table>
62
+ </div>
63
+ </div>
64
+ <% end %>
65
+
66
+ <% failures = executions.filter_map(&:result).select(&:failure?) %>
67
+ <% if failures.any? %>
68
+ <div class="detail-block">
69
+ <span class="detail-label">Errors</span>
70
+ <% failures.each do |result| %>
71
+ <%= render("ductwork/shared/error", result:) %>
72
+ <% end %>
73
+ </div>
74
+ <% end %>
75
+ </div>
76
+ </details>
@@ -1,105 +1,15 @@
1
- <div>
2
- <h1>All Pipelines</h1>
1
+ <% content_for(:title, "Pipelines") %>
3
2
 
4
- <div class="separate-content">
5
- <div class="filter-group">
6
- <span class="filter-group-item">Filters:</span>
7
-
8
- <details class="dropdown filter-group-item">
9
- <summary><%= params[:klass] || "Class" %></summary>
10
- <ul>
11
- <% @klasses.each do |klass| %>
12
- <li>
13
- <%= link_to(klass, pipelines_path(params.permit(:status).merge(klass: klass))) %>
14
- </li>
15
- <% end %>
16
- </ul>
17
- </details>
18
-
19
- <details class="dropdown filter-group-item">
20
- <summary><%= params[:status]&.gsub("_", "-") || "Status" %></summary>
21
- <ul>
22
- <% @statuses.each do |status| %>
23
- <li>
24
- <%= link_to(status.gsub("_", "-"), pipelines_path(params.permit(:klass).merge(status:))) %>
25
- </li>
26
- <% end %>
27
- </ul>
28
- </details>
29
-
30
- <%= link_to("Clear", pipelines_path) %>
31
- </div>
3
+ <div class="page-head">
4
+ <h1 class="page-title">Pipelines</h1>
5
+ </div>
32
6
 
33
- <div role="group" style="width: auto;">
34
- <button <%= first_page? && "disabled" %> data-href="<%= previous_page_path %>">
35
- <%= link_to(previous_page_path, class: "stealthy-link") do %>
36
- &larr;
37
- <% end %>
38
- </button>
39
- <button data-href="<%= next_page_path %>">
40
- &rarr;
41
- </button>
42
- </div>
7
+ <div class="toolbar">
8
+ <%= render("ductwork/shared/status_tabs", counts: @status_counts, statuses: @statuses) %>
9
+ <div class="menus">
10
+ <%= render("ductwork/shared/filter_menu", label: "Class", param: :klass, options: @klasses) %>
43
11
  </div>
44
-
45
- <table class="striped">
46
- <thead>
47
- <tr>
48
- <th>ID</th>
49
- <th>Class</th>
50
- <th>Status</th>
51
- <th>Errors</th>
52
- <th>Started At</th>
53
- <th>Runtime</th>
54
- </tr>
55
- </thead>
56
- <tbody>
57
- <% @runs.each do |run| %>
58
- <tr class="row-link" data-href="<%= pipeline_path(run.pipeline_id) %>">
59
- <td>
60
- <code><%= run.pipeline_id %></code>
61
- </td>
62
- <td>
63
- <code><%= run.pipeline_klass %></code>
64
- </td>
65
- <td>
66
- <div class="status-pill <%= run.status %>">
67
- <%= run.status.gsub("_", "-") %>
68
- </div>
69
- </td>
70
- <td>
71
- <%
72
- count = Ductwork::Result
73
- .joins(execution: { job: { step: { run: :pipeline }}})
74
- .failure
75
- .where(ductwork_pipelines: { id: run.pipeline_id })
76
- .count
77
- %>
78
- <% if count.zero? %>
79
- 0
80
- <% else %>
81
- <div class="errors">
82
- <%= image_tag("ductwork/octagon-x.svg", class: "icon") %>
83
- <%= count %>
84
- </div>
85
- <% end %>
86
- </td>
87
- <td>
88
- <div class="tight-timestamp">
89
- <%= run.started_at.iso8601(3) %>
90
- </div>
91
- </td>
92
- <td>
93
- <% if run.completed_at.nil? %>
94
- <div data-started-at=<%= run.started_at.iso8601 %>>
95
- <span id="elapsed-timer">0h 0m 0s</span>
96
- </div>
97
- <% else %>
98
- <%= formatted_time_distance(run.started_at, run.completed_at) %>
99
- <% end %>
100
- </td>
101
- </tr>
102
- <% end %>
103
- </tbody>
104
- </table>
105
12
  </div>
13
+
14
+ <%= render("ductwork/shared/runs_table", runs: @runs, error_counts: @error_counts) %>
15
+ <%= render("ductwork/shared/pager", total: @run_count) %>
@@ -1,293 +1,70 @@
1
- <div>
2
- <div class="separate-content">
3
- <h1>
4
- Pipeline <code><%= @pipeline.id %></code>
5
- </h1>
1
+ <% content_for(:title, @pipeline.klass) %>
6
2
 
7
- <h2>
8
- <span class="status-pill big-pill <%= @last_run.status %>">
9
- <%= @last_run.status.gsub("_", "-") %>
10
- </span>
11
- </h2>
3
+ <div class="page-head">
4
+ <div class="crumbs"><%= link_to("Pipelines", pipelines_path) %> / <%= link_to(@pipeline.klass, pipelines_path(klass: @pipeline.klass)) %></div>
5
+ <div class="run-title">
6
+ <h1 class="page-title"><%= @pipeline.klass %></h1>
7
+ <%= status_badge(@last_run.status) %>
12
8
  </div>
9
+ <code class="run-id"><%= @pipeline.id %></code>
10
+ </div>
13
11
 
14
- <article>
15
- <div class="separate-content">
16
- <div>
17
- <div class="metric-heading">
18
- <label>ID</label>
19
- <code><%= @pipeline.id %></code>
20
- </div>
21
- <div>
22
- <label>Class</label>
23
- <code>
24
- <%= link_to(@pipeline.klass, pipelines_path(klass: @pipeline.klass)) %>
25
- </code>
26
- </div>
27
- </div>
28
-
29
- <div>
30
- <div>
31
- <label>Runtime</label>
32
- <span>
33
- <% if @last_run.completed_at.nil? %>
34
- <div data-started-at=<%= @last_run.started_at.iso8601 %>>
35
- <span id="elapsed-timer">0h 0m 0s</span>
36
- </div>
37
- <% else %>
38
- <%= formatted_time_distance(@last_run.started_at, @last_run.completed_at) %>
39
- <% end %>
40
- </span>
41
- </div>
42
- <div class="metric-heading">
43
- <label>Triggered At</label>
44
- <span>
45
- <%= @last_run.triggered_at.iso8601(3) %>
46
- </span>
47
- </div>
48
- </div>
49
-
50
- <div>
51
- <div class="metric-heading">
52
- <label>Started At</label>
53
- <span>
54
- <%= @last_run.started_at.iso8601(3) %>
55
- </span>
56
- </div>
57
- <div>
58
- <label>Completed At</label>
59
- <span>
60
- <%= @last_run.completed_at&.iso8601(3) %>
61
- </span>
62
- </div>
63
- </div>
64
- </div>
65
-
66
- <div style="margin-top: 1rem;">
67
- <label>Definition</label>
68
- <code style="width: 100%;">
69
- <%= @last_run.parsed_definition %>
70
- </code>
71
- </div>
72
- </article>
73
-
74
- <h3>Steps (<%= @last_run.steps.count %>)</h3>
75
-
76
- <div class="separate-content">
77
- <div class="filter-group">
78
- <span class="filter-group-item">Filters:</span>
79
-
80
- <details class="dropdown filter-group-item">
81
- <summary><%= params[:klass] || "Class" %></summary>
82
- <ul>
83
- <% @klasses.each do |klass| %>
84
- <li>
85
- <%= link_to(klass, pipeline_path(@pipeline.id, **params.permit(:status).merge(klass: klass))) %>
86
- </li>
87
- <% end %>
88
- </ul>
89
- </details>
90
-
91
- <details class="dropdown filter-group-item">
92
- <summary><%= params[:status]&.gsub("_", "-") || "Status" %></summary>
93
- <ul>
94
- <% @statuses.each do |status| %>
95
- <li>
96
- <%= link_to(status.gsub("_", "-"), pipeline_path(@pipeline.id, **params.permit(:klass).merge(status:))) %>
97
- </li>
98
- <% end %>
99
- </ul>
100
- </details>
101
-
102
- <%= link_to("Clear", pipeline_path(@pipeline.id)) %>
103
- </div>
12
+ <% if @halts.any? %>
13
+ <p class="notice">Halted on <%= halt_summary(@halts) %></p>
14
+ <% end %>
15
+
16
+ <dl class="facts">
17
+ <div class="fact"><dt>Runtime</dt><dd><%= runtime(@last_run) %></dd></div>
18
+ <div class="fact"><dt>Triggered</dt><dd><%= timestamp(@last_run.triggered_at) %></dd></div>
19
+ <div class="fact"><dt>Started</dt><dd><%= timestamp(@last_run.started_at) %></dd></div>
20
+ <% if @last_run.halted_at.present? %>
21
+ <div class="fact"><dt>Halted</dt><dd><%= timestamp(@last_run.halted_at) %></dd></div>
22
+ <% else %>
23
+ <div class="fact"><dt>Completed</dt><dd><%= timestamp(@last_run.completed_at) %></dd></div>
24
+ <% end %>
25
+ <div class="fact"><dt>Step errors</dt><dd><%= count_badge(@error_count) %></dd></div>
26
+ <% if @run_count > 1 %>
27
+ <div class="fact"><dt>Run</dt><dd>Latest of <%= @run_count %></dd></div>
28
+ <% end %>
29
+ </dl>
30
+
31
+ <% if @graph.present? %>
32
+ <section class="panel" aria-label="Workflow">
33
+ <%= render("graph", graph: @graph) %>
34
+ </section>
35
+ <% end %>
36
+
37
+ <section class="section" id="steps">
38
+ <div class="section-head">
39
+ <h2 class="section-title">
40
+ Steps (<%= @step_count %><%= " of #{@step_total}" if params.values_at(:klass, :node, :status).any?(&:present?) %>)
41
+ </h2>
42
+ </div>
104
43
 
105
- <div role="group" style="width: auto;">
106
- <button <%= first_page? && "disabled" %> data-href="<%= previous_page_path %>">
107
- <%= link_to(previous_page_path, class: "stealthy-link") do %>
108
- &larr;
109
- <% end %>
110
- </button>
111
- <button data-href="<%= next_page_path %>">
112
- &rarr;
113
- </button>
44
+ <div class="toolbar">
45
+ <%= render("ductwork/shared/status_tabs", counts: @status_counts, statuses: @statuses, anchor: "steps") %>
46
+ <div class="menus">
47
+ <%= render("ductwork/shared/filter_menu", label: "Class", param: :klass, options: @klasses, anchor: "steps") %>
48
+ <%= render("ductwork/shared/filter_menu", label: "Node", param: :node, options: @nodes, anchor: "steps") %>
114
49
  </div>
115
50
  </div>
116
51
 
117
- <article style="margin-bottom: 0px;">
118
- <div>
119
- <% @steps.each do |step| %>
120
- <details>
121
- <summary class="details-grid">
122
- <div class="grid">
123
- <div>
124
- <label>ID</label>
125
- <code style="display: block;">
126
- <%= step.id %>
127
- </code>
128
- </div>
129
-
130
- <div>
131
- <label>Status</label>
132
- <div class="status-pill <%= step.status %>">
133
- <%= step.status.gsub("_", "-") %>
134
- </div>
135
- </div>
136
-
137
- <div>
138
- <label>Class</label>
139
- <code>
140
- <%= step.klass %>
141
- </code>
142
- </div>
143
-
144
- <div>
145
- <label>Transition</label>
146
- <code>
147
- <%= step.to_transition == "default" ? "chain" : step.to_transition %>
148
- </code>
149
- </div>
150
-
151
- <div>
152
- <label>Errors</label>
153
- <% if (step.job.executions.count - 1).zero? %>
154
- 0
155
- <% else %>
156
- <div class="errors">
157
- <%= image_tag("ductwork/octagon-x.svg") %>
158
- <%= step.job.executions.count - 1 %>
159
- </div>
160
- <% end %>
161
- </div>
162
-
163
- <div>
164
- <label>Total Runtime</label>
165
- <% if step.completed_at.nil? %>
166
- <div data-started-at=<%= step.started_at.iso8601 %>>
167
- <span id="elapsed-timer">0h 0m 0s</span>
168
- </div>
169
- <% else %>
170
- <%= formatted_time_distance(step.started_at, step.completed_at) %>
171
- <% end %>
172
- </div>
173
- </div>
174
- </summary>
175
-
176
- <div>
177
- <div class="grid">
178
- <div>
179
- <label>Input</label>
180
- <code>
181
- <% if step.job.input_args.present? %>
182
- <%= JSON.parse(step.job.input_args).dig("args", 0).inspect %>
183
- <% else %>
184
- no value
185
- <% end %>
186
- </code>
187
- </div>
188
-
189
- <div>
190
- <label>Output</label>
191
- <code>
192
- <% if step.job.output_payload.present? %>
193
- <%= JSON.parse(step.job.output_payload)["payload"].inspect %>
194
- <% else %>
195
- no value
196
- <% end %>
197
- </code>
198
- </div>
199
-
200
- <div>
201
- <label>Node</label>
202
- <code>
203
- <%= step.node %>
204
- </code>
205
- </div>
206
-
207
- <div>
208
- <label>Started At</label>
209
- <div class="tight-timestamp">
210
- <%= step.started_at.iso8601(3) %>
211
- </div>
212
- </div>
213
-
214
- <div>
215
- <label>Completed At</label>
216
- <div class="tight-timestamp">
217
- <%= step.completed_at&.iso8601(3) %>
218
- </div>
219
- </div>
220
- </div>
221
-
222
- <div style="margin-top: 1rem;">
223
- <label>Executions</label>
224
- <table class="small-table">
225
- <thead>
226
- <tr>
227
- <th>ID</th>
228
- <th>Attempt</th>
229
- <th>Available At</th>
230
- <th>Claimed At</th>
231
- <th>Ran At</th>
232
- <th>Completed At</th>
233
- <th>Error</th>
234
- </tr>
235
- </thead>
236
-
237
- <tbody>
238
- <% step.job.executions.order(:started_at).each do |execution| %>
239
- <tr class="not-clickable">
240
- <td>
241
- <code>
242
- <%= execution.id %>
243
- </code>
244
- </td>
245
- <td><%= execution.retry_count + 1 %></td>
246
- <td><%= execution.started_at.strftime("%H:%M:%S.%3N") %></td>
247
- <td><%= execution.availability.completed_at&.strftime("%H:%M:%S.%3N") %></td>
248
- <td><%= execution.attempt&.started_at&.strftime("%H:%M:%S.%3N") %></td>
249
- <td><%= execution.completed_at&.strftime("%H:%M:%S.%3N") %></td>
250
- <td>
251
- <% if execution.result.present? && execution.result.failure? %>
252
- <button data-open-modal="modal-<%= execution.result.id %>" class="tight-timestamp secondary">
253
- Open
254
- </button>
255
-
256
- <dialog id="modal-<%= execution.result.id %>">
257
- <article>
258
- <header>
259
- <button aria-label="Close" rel="prev" data-close-modal="modal-<%= execution.result.id %>"></button>
260
- <p>
261
- <strong>Error Backtrace</strong>
262
- </p>
263
- </header>
264
- <div>
265
- <h2>
266
- <%= execution.result.error_klass %>
267
- </h2>
268
- <h5>
269
- <%= execution.result.error_message %>
270
- </h5>
271
- <code>
272
- <%= (execution.result.error_backtrace&.split("\n") || []).each do |line| %>
273
- <div><%= line %></div>
274
- <% end %>
275
- </code>
276
- </div>
277
- </article>
278
- </dialog>
279
- <% end %>
280
- </td>
281
- </tr>
282
- <% end %>
283
- </tbody>
284
- </table>
285
- </div>
286
- </div>
287
- </details>
52
+ <div class="panel">
53
+ <% if @steps.empty? %>
54
+ <p class="empty">No steps match.</p>
55
+ <% else %>
56
+ <div class="row-grid row-grid-steps row-head">
57
+ <span>Step</span>
58
+ <span>Status</span>
59
+ <span class="wide-only">Transition</span>
60
+ <span>Attempts</span>
61
+ <span class="wide-only">Started</span>
62
+ <span class="wide-only">Runtime</span>
63
+ <span></span>
64
+ </div>
65
+ <%= render(partial: "step", collection: @steps, as: :step) %>
66
+ <% end %>
67
+ </div>
288
68
 
289
- <hr/>
290
- <% end %>
291
- </div>
292
- </article>
293
- </div>
69
+ <%= render("ductwork/shared/pager", total: @step_count, anchor: "steps") %>
70
+ </section>
@@ -0,0 +1,7 @@
1
+ <div class="error">
2
+ <div class="error-title"><%= result.error_klass %></div>
3
+ <p class="error-message"><%= result.error_message %></p>
4
+ <% if result.error_backtrace.present? %>
5
+ <pre class="code"><%= result.error_backtrace %></pre>
6
+ <% end %>
7
+ </div>
@@ -0,0 +1,13 @@
1
+ <% anchor = local_assigns[:anchor] %>
2
+ <% selected = params[param] %>
3
+ <details class="menu" data-active="<%= selected.present? %>">
4
+ <summary><%= selected.presence || label %></summary>
5
+ <div class="menu-list">
6
+ <% if selected.present? %>
7
+ <%= link_to("All #{label.downcase}", filtered_path(param => nil, anchor:), class: "menu-clear") %>
8
+ <% end %>
9
+ <% options.each do |option| %>
10
+ <%= link_to(option, filtered_path(param => option, anchor:), aria: { current: selected == option }) %>
11
+ <% end %>
12
+ </div>
13
+ </details>
@@ -0,0 +1,16 @@
1
+ <% anchor = local_assigns[:anchor] %>
2
+ <nav class="pager" aria-label="Pagination">
3
+ <span><%= page_summary(total) %></span>
4
+ <div class="pager-links">
5
+ <% if first_page? %>
6
+ <a class="pager-link" aria-disabled="true" aria-label="Previous page">&larr;</a>
7
+ <% else %>
8
+ <%= link_to(previous_page_path(anchor:), class: "pager-link", rel: "prev", aria: { label: "Previous page" }) do %>&larr;<% end %>
9
+ <% end %>
10
+ <% if last_page?(total) %>
11
+ <a class="pager-link" aria-disabled="true" aria-label="Next page">&rarr;</a>
12
+ <% else %>
13
+ <%= link_to(next_page_path(anchor:), class: "pager-link", rel: "next", aria: { label: "Next page" }) do %>&rarr;<% end %>
14
+ <% end %>
15
+ </div>
16
+ </nav>
@@ -0,0 +1,30 @@
1
+ <div class="panel table-scroller">
2
+ <% if runs.empty? %>
3
+ <p class="empty">No pipelines match.</p>
4
+ <% else %>
5
+ <table class="table">
6
+ <thead>
7
+ <tr>
8
+ <th>Pipeline</th>
9
+ <th>ID</th>
10
+ <th>Status</th>
11
+ <th class="numeric">Errors</th>
12
+ <th>Started</th>
13
+ <th>Runtime</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <% runs.each do |run| %>
18
+ <tr data-href="<%= pipeline_path(run.pipeline_id) %>">
19
+ <td><%= link_to(run.pipeline_klass, pipeline_path(run.pipeline_id), class: "table-primary") %></td>
20
+ <td><%= short_id(run.pipeline_id) %></td>
21
+ <td><%= status_badge(run.status) %></td>
22
+ <td class="numeric"><%= count_badge(error_counts.fetch(run.id, 0)) %></td>
23
+ <td><%= timestamp(run.started_at) %></td>
24
+ <td><%= runtime(run) %></td>
25
+ </tr>
26
+ <% end %>
27
+ </tbody>
28
+ </table>
29
+ <% end %>
30
+ </div>
@@ -0,0 +1,11 @@
1
+ <% anchor = local_assigns[:anchor] %>
2
+ <div class="tabs" role="group" aria-label="Filter by status">
3
+ <%= link_to(filtered_path(status: nil, anchor:), class: "tab", aria: { current: params[:status].blank? }) do %>
4
+ All<span class="tab-count"><%= number_with_delimiter(counts.values.sum) %></span>
5
+ <% end %>
6
+ <% statuses.select { |status| counts.key?(status) || params[:status] == status }.each do |status| %>
7
+ <%= link_to(filtered_path(status:, anchor:), class: "tab", aria: { current: params[:status] == status }) do %>
8
+ <%= status_text(status) %><span class="tab-count"><%= number_with_delimiter(counts.fetch(status, 0)) %></span>
9
+ <% end %>
10
+ <% end %>
11
+ </div>