good_pipeline 0.4.0 → 0.6.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/Appraisals +25 -0
  3. data/CHANGELOG.md +58 -0
  4. data/README.md +67 -11
  5. data/app/controllers/good_pipeline/frontends_controller.rb +18 -1
  6. data/app/controllers/good_pipeline/pipelines_controller.rb +254 -36
  7. data/app/controllers/good_pipeline/themes_controller.rb +15 -0
  8. data/app/frontend/good_pipeline/dashboard.css +407 -0
  9. data/app/frontend/good_pipeline/dashboard.js +542 -0
  10. data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +225 -66
  11. data/app/helpers/good_pipeline/pipelines_helper.rb +89 -5
  12. data/app/jobs/good_pipeline/pipeline_callback_job.rb +2 -2
  13. data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +1 -2
  14. data/app/jobs/good_pipeline/step_finished_job.rb +6 -2
  15. data/app/models/good_pipeline/pipeline_record.rb +7 -4
  16. data/app/models/good_pipeline/step_record.rb +6 -3
  17. data/app/views/good_pipeline/pipelines/_actions.html.erb +28 -0
  18. data/app/views/good_pipeline/pipelines/_chain_links.html.erb +17 -29
  19. data/app/views/good_pipeline/pipelines/_diagram.html.erb +101 -0
  20. data/app/views/good_pipeline/pipelines/_filter_bar.html.erb +51 -0
  21. data/app/views/good_pipeline/pipelines/_kpi_strip.html.erb +74 -0
  22. data/app/views/good_pipeline/pipelines/_pager.html.erb +31 -0
  23. data/app/views/good_pipeline/pipelines/_row.html.erb +103 -0
  24. data/app/views/good_pipeline/pipelines/_row_detail.html.erb +93 -0
  25. data/app/views/good_pipeline/pipelines/_sidebar.html.erb +115 -0
  26. data/app/views/good_pipeline/pipelines/_steps_table.html.erb +46 -33
  27. data/app/views/good_pipeline/pipelines/_table.html.erb +33 -0
  28. data/app/views/good_pipeline/pipelines/_timeline.html.erb +55 -0
  29. data/app/views/good_pipeline/pipelines/_topbar.html.erb +54 -0
  30. data/app/views/good_pipeline/pipelines/definitions.html.erb +88 -55
  31. data/app/views/good_pipeline/pipelines/index.html.erb +31 -42
  32. data/app/views/good_pipeline/pipelines/show.html.erb +54 -47
  33. data/app/views/layouts/good_pipeline/application.html.erb +24 -153
  34. data/config/routes.rb +4 -0
  35. data/demo/config/environments/development.rb +3 -0
  36. data/demo/config/environments/test.rb +2 -1
  37. data/demo/db/migrate/20260319205325_create_good_jobs.rb +1 -1
  38. data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +1 -1
  39. data/demo/db/migrate/20260810000000_add_good_pipeline_dashboard_indexes.rb +20 -0
  40. data/demo/db/seeds.rb +325 -11
  41. data/demo/docs/screenshots/definitions.png +0 -0
  42. data/demo/docs/screenshots/show.png +0 -0
  43. data/demo/test/good_pipeline/test_bulk_enqueue.rb +54 -1
  44. data/demo/test/good_pipeline/test_cancellation.rb +312 -0
  45. data/demo/test/good_pipeline/test_cleanup.rb +31 -1
  46. data/demo/test/good_pipeline/test_coordinator.rb +39 -0
  47. data/demo/test/good_pipeline/test_dashboard.rb +667 -0
  48. data/demo/test/good_pipeline/test_dashboard_rerun.rb +244 -0
  49. data/demo/test/good_pipeline/test_pipeline_callback_job.rb +11 -0
  50. data/demo/test/good_pipeline/test_pipeline_record.rb +30 -0
  51. data/demo/test/good_pipeline/test_runner.rb +40 -1
  52. data/demo/test/good_pipeline/test_step_record.rb +37 -0
  53. data/demo/test/integration/test_barrier_execution.rb +502 -0
  54. data/demo/test/integration/test_chain_locking.rb +54 -0
  55. data/demo/test/integration/test_pipeline_chaining.rb +96 -0
  56. data/demo/test/test_helper.rb +20 -0
  57. data/docs/architecture.md +45 -29
  58. data/docs/branching.md +4 -0
  59. data/docs/callbacks.md +9 -7
  60. data/docs/cleanup.md +2 -2
  61. data/docs/dag-validation.md +16 -0
  62. data/docs/dashboard.md +84 -22
  63. data/docs/defining-pipelines.md +34 -1
  64. data/docs/failure-strategies.md +24 -1
  65. data/docs/getting-started.md +9 -1
  66. data/docs/index.md +1 -1
  67. data/docs/introduction.md +4 -4
  68. data/docs/monitoring.md +8 -3
  69. data/docs/pipeline-chaining.md +17 -9
  70. data/docs/public/screenshots/definitions.png +0 -0
  71. data/docs/public/screenshots/index.png +0 -0
  72. data/docs/public/screenshots/show.png +0 -0
  73. data/docs/screenshots/definitions.png +0 -0
  74. data/docs/screenshots/index.png +0 -0
  75. data/docs/screenshots/show.png +0 -0
  76. data/gemfiles/rails_7_2.gemfile +24 -0
  77. data/gemfiles/rails_7_2.gemfile.lock +412 -0
  78. data/gemfiles/rails_8_0.gemfile +24 -0
  79. data/gemfiles/rails_8_0.gemfile.lock +411 -0
  80. data/gemfiles/rails_8_1.gemfile +24 -0
  81. data/gemfiles/rails_8_1.gemfile.lock +415 -0
  82. data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +9 -0
  83. data/lib/generators/good_pipeline/upgrade/templates/add_good_pipeline_dashboard_indexes.rb.erb +27 -0
  84. data/lib/generators/good_pipeline/upgrade/upgrade_generator.rb +32 -0
  85. data/lib/good_pipeline/barrier_compiler.rb +106 -0
  86. data/lib/good_pipeline/chain_coordinator.rb +9 -5
  87. data/lib/good_pipeline/constants.rb +2 -0
  88. data/lib/good_pipeline/coordinator.rb +269 -140
  89. data/lib/good_pipeline/dashboard/connection_info.rb +67 -0
  90. data/lib/good_pipeline/dashboard/definition_stages.rb +59 -0
  91. data/lib/good_pipeline/dashboard/filter_set.rb +77 -0
  92. data/lib/good_pipeline/dashboard/kpi_calculator.rb +143 -0
  93. data/lib/good_pipeline/dashboard/sparkline.rb +73 -0
  94. data/lib/good_pipeline/dashboard/stage_lanes.rb +307 -0
  95. data/lib/good_pipeline/dashboard/step_timings.rb +50 -0
  96. data/lib/good_pipeline/dashboard.rb +21 -0
  97. data/lib/good_pipeline/errors.rb +11 -0
  98. data/lib/good_pipeline/pipeline.rb +14 -2
  99. data/lib/good_pipeline/step_definition.rb +16 -1
  100. data/lib/good_pipeline/version.rb +1 -1
  101. data/lib/good_pipeline.rb +7 -1
  102. data/mise.toml +1 -1
  103. metadata +48 -13
  104. data/app/frontend/good_pipeline/style.css +0 -597
  105. data/app/views/good_pipeline/pipelines/_pagination.html.erb +0 -24
  106. data/app/views/good_pipeline/pipelines/_pipeline_row.html.erb +0 -7
@@ -0,0 +1,667 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "active_support/testing/time_helpers"
5
+
6
+ # Integration coverage deliberately verifies several parts of each complete
7
+ # dashboard response in one request.
8
+ # rubocop:disable Minitest/MultipleAssertions
9
+ module GoodPipeline
10
+ class DashboardTest < ActionDispatch::IntegrationTest # rubocop:disable Metrics/ClassLength
11
+ include ActiveSupport::Testing::TimeHelpers
12
+
13
+ setup do
14
+ GoodPipeline.dashboard_mutations_enabled = true
15
+ end
16
+
17
+ teardown do
18
+ GoodPipeline.dashboard_mutations_enabled = nil
19
+ end
20
+
21
+ test "index renders the isolated dark dashboard and versioned assets" do
22
+ execution = create_execution(type: "VideoProcessingPipeline", status: "succeeded")
23
+ create_step(execution, key: "download", coordination_status: "succeeded")
24
+
25
+ get "/good_pipeline"
26
+
27
+ assert_response :success
28
+ assert_includes response.body, %(data-gp-theme="dark")
29
+ assert_includes response.body, %(class="gp-app gp-app--dashboard")
30
+ assert_includes response.body, %(id="gp-kpi")
31
+ assert_includes response.body, %(id="gp-filters")
32
+ assert_select ".gp-status-filter-scroll > .gp-segment[role=group][aria-label='Status filter']", count: 1
33
+ assert_includes response.body, %(id="gp-table")
34
+ assert_no_match(/data-href/, response.body)
35
+ asset_version = GoodPipeline::VERSION.tr(".", "-")
36
+ asset_href = response.body[%r{href="([^"]*/frontend/static/#{asset_version}/dashboard\.css)"}, 1]
37
+
38
+ assert asset_href&.end_with?("/frontend/static/#{asset_version}/dashboard.css")
39
+ refute_includes response.body, "style.css"
40
+ refute_includes response.body, "<script>"
41
+
42
+ assert_dashboard_css(asset_href)
43
+
44
+ get "/good_pipeline/frontend/static/#{asset_version}/dashboard.js"
45
+
46
+ assert_response :success
47
+ assert_includes response.media_type, "javascript"
48
+ assert_includes response.body, "AbortController"
49
+ assert_includes response.body, "form[data-gp-confirm]"
50
+ assert_includes response.body, "window.confirm"
51
+ end
52
+
53
+ test "status time type and search filters compose" do
54
+ now = Time.utc(2026, 8, 10, 12)
55
+ wanted = create_execution(
56
+ type: "VideoProcessingPipeline", status: "failed", created_at: now - 2.hours
57
+ )
58
+ create_step(wanted, key: "download", coordination_status: "failed")
59
+ create_execution(type: "VideoProcessingPipeline", status: "succeeded", created_at: now - 2.hours)
60
+ create_execution(type: "OtherPipeline", status: "failed", created_at: now - 2.hours)
61
+ create_execution(type: "VideoProcessingPipeline", status: "failed", created_at: now - 3.days)
62
+
63
+ travel_to(now) do
64
+ get "/good_pipeline", params: {
65
+ pipeline_type: "VideoProcessingPipeline",
66
+ status: "failed",
67
+ time: "24h",
68
+ q: wanted.id.to_s.first(8)
69
+ }
70
+ end
71
+
72
+ assert_response :success
73
+ assert_includes response.body, wanted.id.to_s.first(8)
74
+ assert_includes response.body, "Video Processing"
75
+ assert_includes response.body, "failed"
76
+ assert_match(/filtered rows.*?1/m, response.body)
77
+ end
78
+
79
+ test "status segment counts ignore only the selected status" do
80
+ 2.times { create_execution(type: "CountPipeline", status: "failed") }
81
+ create_execution(type: "CountPipeline", status: "succeeded")
82
+ create_execution(type: "OtherPipeline", status: "running")
83
+
84
+ get "/good_pipeline", params: { pipeline_type: "CountPipeline", status: "failed" }
85
+
86
+ assert_response :success
87
+ assert_includes response.body, %(data-gp-segment-value="all" data-gp-status-count="3")
88
+ assert_includes response.body, %(data-gp-segment-value="failed" data-gp-status-count="2")
89
+ assert_includes response.body, %(data-gp-segment-value="succeeded" data-gp-status-count="1")
90
+ assert_match(/filtered rows.*?2/m, response.body)
91
+ end
92
+
93
+ test "pagination clamps malformed and out of range pages" do
94
+ 28.times do |index|
95
+ create_execution(type: "PagePipeline", status: "succeeded", created_at: Time.current - index.minutes)
96
+ end
97
+
98
+ %w[0 -3 abc 99999].each do |page|
99
+ get "/good_pipeline", params: { page: page }
100
+
101
+ assert_response :success
102
+ assert_match(%r{page <span class="mono">(?:1|2)</span> / <span class="mono">2</span>}, response.body)
103
+ end
104
+
105
+ get "/good_pipeline", params: { q: "no-such-execution", page: 99_999 }
106
+
107
+ assert_response :success
108
+ assert_match(%r{page <span class="mono">1</span> / <span class="mono">1</span>}, response.body)
109
+ assert_includes response.body, "no executions match these filters"
110
+ end
111
+
112
+ test "unknown expanded id is ignored and a loaded execution expands" do
113
+ execution = create_execution(type: "ExpandablePipeline", status: "running")
114
+ step = create_step(execution, key: "work", coordination_status: "enqueued")
115
+ attach_good_job(step, performed_at: Time.current - 10.seconds, finished_at: nil)
116
+
117
+ get "/good_pipeline", params: { expanded: SecureRandom.uuid }
118
+
119
+ assert_response :success
120
+ refute_includes response.body, "gp-row-detail-inner"
121
+
122
+ get "/good_pipeline", params: { expanded: execution.id }
123
+
124
+ assert_response :success
125
+ assert_includes response.body, "gp-row-detail-inner"
126
+ assert_includes response.body, "is-running"
127
+ assert_includes response.body, %(colspan="7")
128
+ assert_match(/disabled(?:="disabled")? title="available after the pipeline finishes"/, response.body)
129
+ end
130
+
131
+ test "canonical pipeline_type parameter scopes executions" do
132
+ create_execution(type: "AlphaPipeline", status: "succeeded")
133
+ create_execution(type: "BetaPipeline", status: "succeeded")
134
+
135
+ get "/good_pipeline", params: { pipeline_type: "AlphaPipeline" }
136
+
137
+ assert_response :success
138
+ assert_includes response.body, "Alpha"
139
+ refute_match(/gp-execution-name">Beta</, response.body)
140
+ assert_includes response.body, "pipeline_type=AlphaPipeline"
141
+ end
142
+
143
+ test "show keeps GoodJob links error text and safe Mermaid transport" do
144
+ execution = create_execution(type: "UnsafePipeline", status: "failed")
145
+ first = create_step(
146
+ execution,
147
+ key: %(end_node"; x),
148
+ coordination_status: "failed",
149
+ error_class: "DemoError",
150
+ error_message: "unsafe input failed"
151
+ )
152
+ attach_good_job(first, performed_at: Time.current - 4.seconds, finished_at: Time.current)
153
+
154
+ get "/good_pipeline/pipelines/#{execution.id}"
155
+
156
+ assert_response :success
157
+ assert_includes response.body, "DemoError: unsafe input failed"
158
+ assert_includes response.body, "/good_job/jobs/#{first.good_job_id}"
159
+ assert_includes response.body, "data-graph="
160
+ assert_includes response.body, "n0"
161
+ assert_includes response.body, "#quot;"
162
+ refute_includes response.body, "raw mermaid"
163
+ end
164
+
165
+ test "cancel forms render with CSRF and confirmation on show and expanded row" do
166
+ running = create_execution(type: "CancelableRunningPipeline", status: "running")
167
+ create_step(running, key: "work", coordination_status: "enqueued")
168
+ pending = create_execution(type: "CancelablePendingPipeline", status: "pending")
169
+ create_step(pending, key: "work", coordination_status: "pending")
170
+
171
+ assert_equal "/good_pipeline/pipelines/#{running.id}/cancel",
172
+ GoodPipeline::Engine.routes.url_helpers.cancel_pipeline_path(running)
173
+
174
+ with_forgery_protection do
175
+ get "/good_pipeline/pipelines/#{running.id}"
176
+ end
177
+
178
+ assert_response :success
179
+ assert_cancel_form(running)
180
+ assert_select "button[disabled]", text: "re-run pipeline", count: 1
181
+
182
+ with_forgery_protection do
183
+ get "/good_pipeline", params: { expanded: pending.id }
184
+ end
185
+
186
+ assert_response :success
187
+ assert_cancel_form(pending)
188
+ assert_select %(.gp-actions a[href="/good_pipeline/pipelines/#{pending.id}"]),
189
+ text: "detail page + dag ↗", count: 1
190
+ end
191
+
192
+ test "read-only mode hides cancel controls and rejects direct posts" do
193
+ GoodPipeline.dashboard_mutations_enabled = nil
194
+ execution = create_execution(type: "ReadOnlyPipeline", status: "running")
195
+ step = create_step(execution, key: "work", coordination_status: "enqueued")
196
+
197
+ get "/good_pipeline/pipelines/#{execution.id}"
198
+
199
+ assert_response :success
200
+ assert_select "form.gp-action-form", count: 0
201
+ assert_select "button", text: "cancel requested", count: 0
202
+ assert_select "button", text: /re-run pipeline|retry failed step/, count: 0
203
+
204
+ get "/good_pipeline", params: { expanded: execution.id }
205
+
206
+ assert_response :success
207
+ assert_select "form.gp-action-form", count: 0
208
+ assert_select "button", text: /re-run pipeline|retry failed step/, count: 0
209
+
210
+ post "/good_pipeline/pipelines/#{execution.id}/cancel", headers: dashboard_csrf_headers
211
+
212
+ assert_response :forbidden
213
+ assert_equal "running", execution.reload.status
214
+ assert_equal "enqueued", step.reload.coordination_status
215
+ end
216
+
217
+ test "canceling and canceled statuses render without another cancel submission" do
218
+ canceling = create_execution(type: "CancelingPipeline", status: "canceling")
219
+ create_step(canceling, key: "already_drained", coordination_status: "canceled")
220
+ canceled = create_execution(type: "CanceledPipeline", status: "canceled")
221
+
222
+ get "/good_pipeline/pipelines/#{canceling.id}"
223
+
224
+ assert_response :success
225
+ assert_select ".gp-status--canceling", text: /canceling/, minimum: 1
226
+ assert_select "button[disabled]", text: "cancel requested", count: 1
227
+ assert_select %(form[action="/good_pipeline/pipelines/#{canceling.id}/cancel"]), count: 0
228
+ assert_select ".gp-status--canceled", text: /canceled/, minimum: 1
229
+ assert_includes response.body, ":::canceled"
230
+
231
+ get "/good_pipeline/pipelines/#{canceled.id}"
232
+
233
+ assert_response :success
234
+ assert_select ".gp-detail-header .gp-status--canceled", text: /canceled/, count: 1
235
+ assert_select %(form[action="/good_pipeline/pipelines/#{canceled.id}/cancel"]), count: 0
236
+ assert_select "button.gp-action--danger[disabled]", text: "cancel pipeline", count: 1
237
+
238
+ get "/good_pipeline", params: { status: "canceled" }
239
+
240
+ assert_response :success
241
+ assert_select %(tr[data-id="#{canceled.id}"] .gp-status--canceled), text: /canceled/, count: 1
242
+ assert_select '[data-gp-segment-value="canceled"].is-active', count: 1
243
+ end
244
+
245
+ test "cancel posts through the coordinator and redirects back with a notice" do
246
+ execution = create_execution(type: "CancelablePipeline", status: "running")
247
+ create_step(execution, key: "work", coordination_status: "enqueued")
248
+ referer = "http://www.example.com/good_pipeline?expanded=#{execution.id}"
249
+ headers = dashboard_csrf_headers.merge("HTTP_REFERER" => referer)
250
+
251
+ post "/good_pipeline/pipelines/#{execution.id}/cancel", headers: headers
252
+
253
+ assert_response :see_other
254
+ assert_redirected_to referer
255
+ assert_equal "Pipeline cancellation requested.", flash[:notice]
256
+ assert_nil flash[:alert]
257
+ assert_equal "canceling", execution.reload.status
258
+ end
259
+
260
+ test "cancel without a referrer falls back to detail and reports immediate cancellation" do
261
+ execution = create_execution(type: "TestPipeline", status: "pending")
262
+
263
+ post "/good_pipeline/pipelines/#{execution.id}/cancel", headers: dashboard_csrf_headers
264
+
265
+ assert_response :see_other
266
+ assert_redirected_to "/good_pipeline/pipelines/#{execution.id}"
267
+ assert_equal "Pipeline canceled.", flash[:notice]
268
+ assert_equal "canceled", execution.reload.status
269
+
270
+ follow_redirect!
271
+
272
+ assert_response :success
273
+ assert_select ".gp-flash.gp-flash--notice[role=status]", text: "Pipeline canceled.", count: 1
274
+ assert_select ".gp-flash--alert", count: 0
275
+ end
276
+
277
+ test "repeated cancel request remains an idempotent HTML success" do
278
+ execution = create_execution(type: "AlreadyCancelingPipeline", status: "canceling")
279
+ create_step(execution, key: "draining", coordination_status: "enqueued")
280
+
281
+ post "/good_pipeline/pipelines/#{execution.id}/cancel", headers: dashboard_csrf_headers
282
+
283
+ assert_response :see_other
284
+ assert_redirected_to "/good_pipeline/pipelines/#{execution.id}"
285
+ assert_equal "Pipeline cancellation requested.", flash[:notice]
286
+ assert_nil flash[:alert]
287
+ end
288
+
289
+ test "cancel conflict redirects safely and renders only an alert flash" do
290
+ execution = create_execution(type: "CompletedPipeline", status: "succeeded")
291
+ referer = "http://www.example.com/good_pipeline/pipelines/#{execution.id}"
292
+ headers = dashboard_csrf_headers.merge("HTTP_REFERER" => referer)
293
+
294
+ post "/good_pipeline/pipelines/#{execution.id}/cancel", headers: headers
295
+
296
+ assert_response :see_other
297
+ assert_redirected_to referer
298
+ assert_equal "Pipeline is already succeeded and cannot be canceled.", flash[:alert]
299
+ assert_nil flash[:notice]
300
+
301
+ follow_redirect!
302
+
303
+ assert_response :success
304
+ assert_select ".gp-flash.gp-flash--alert[role=alert]",
305
+ text: "Pipeline is already succeeded and cannot be canceled.", count: 1
306
+ assert_select ".gp-flash--notice", count: 0
307
+ end
308
+
309
+ test "GET cannot invoke the cancel member endpoint" do
310
+ execution = create_execution(type: "NonMutatingPipeline", status: "running")
311
+
312
+ get "/good_pipeline/pipelines/#{execution.id}/cancel"
313
+
314
+ assert_response :not_found
315
+ assert_equal "running", execution.reload.status
316
+ end
317
+
318
+ test "show defaults pipelines over sixty steps to stages while retaining the full graph payload" do
319
+ execution = create_execution(type: "ExtraLargePipeline", status: "succeeded")
320
+ steps = insert_steps(execution, count: 61, prefix: "node")
321
+ insert_dependencies(execution, steps.each_cons(2).to_a)
322
+
323
+ get "/good_pipeline/pipelines/#{execution.id}"
324
+
325
+ assert_response :success
326
+ assert_select ".gp-stage-panel:not([hidden])", count: 1
327
+ assert_select ".gp-graph-panel[hidden]", count: 1
328
+ assert_select "[data-gp-graph-toggle]", text: "render full graph", count: 1
329
+ assert_select ".gp-diagram-container[data-graph]", count: 1
330
+ assert_includes response.body, "n60"
331
+ end
332
+
333
+ test "show disables an overflowing dense graph and omits its Mermaid payload" do
334
+ execution = create_execution(type: "DenseFanoutPipeline", status: "succeeded")
335
+ steps = insert_steps(execution, count: 46, prefix: "node")
336
+ edges = steps.each_with_index.flat_map do |downstream, index|
337
+ steps.first(index).map { |upstream| [upstream, downstream] }
338
+ end
339
+ insert_dependencies(execution, edges)
340
+
341
+ get "/good_pipeline/pipelines/#{execution.id}"
342
+
343
+ assert_response :success
344
+ assert_equal 1_035, edges.length
345
+ assert_select ".gp-stage-panel:not([hidden])", count: 1
346
+ assert_select ".gp-diagram-container[data-graph]", count: 0
347
+ assert_select "button[disabled]", text: "render full graph", count: 1
348
+ assert_includes response.body, "1,035 edges exceeds Mermaid’s 1,000-edge safety limit"
349
+ end
350
+
351
+ test "show renders upstream and downstream chain chips" do
352
+ upstream = create_execution(type: "UpstreamPipeline", status: "succeeded")
353
+ execution = create_execution(type: "MiddlePipeline", status: "running")
354
+ downstream = create_execution(type: "DownstreamPipeline", status: "pending")
355
+ [upstream, execution, downstream].each { |pipeline| create_step(pipeline) }
356
+ ChainRecord.create!(upstream_pipeline: upstream, downstream_pipeline: execution)
357
+ ChainRecord.create!(upstream_pipeline: execution, downstream_pipeline: downstream)
358
+
359
+ get "/good_pipeline/pipelines/#{execution.id}"
360
+
361
+ assert_response :success
362
+ assert_select ".gp-chain-chip", count: 2
363
+ assert_select %(.gp-chain-chip[href="/good_pipeline/pipelines/#{upstream.id}"]), count: 1
364
+ assert_select %(.gp-chain-chip[href="/good_pipeline/pipelines/#{downstream.id}"]), count: 1
365
+ assert_includes response.body, "Upstream"
366
+ assert_includes response.body, "Downstream"
367
+ end
368
+
369
+ test "definitions render run counts and declared dependencies" do
370
+ first_run = create_execution(type: "DefinitionPipeline", status: "succeeded", created_at: 2.hours.ago)
371
+ add_linear_steps(first_run, %w[first second])
372
+ latest = create_execution(type: "DefinitionPipeline", status: "succeeded", created_at: 1.hour.ago)
373
+ add_linear_steps(latest, %w[first second terminal])
374
+
375
+ get "/good_pipeline/pipelines/definitions"
376
+
377
+ assert_response :success
378
+ assert_includes response.body, "gp-app--definitions"
379
+ assert_includes response.body, "Definition"
380
+ assert_match(%r{runs</b>\s*2}, response.body)
381
+ assert_includes response.body, "after: first"
382
+ assert_includes response.body, "data-graph="
383
+ end
384
+
385
+ test "theme endpoint validates values persists the cookie and is mount aware" do
386
+ get "/good_pipeline"
387
+ csrf_token = response.body[/name="csrf-token" content="([^"]+)"/, 1]
388
+ headers = { "X-CSRF-Token" => csrf_token }
389
+
390
+ patch "/good_pipeline/theme", params: { theme: "light" }, headers: headers, as: :json
391
+
392
+ assert_response :no_content
393
+ assert_includes Array(response.headers.fetch("Set-Cookie")).join("; "), "good_pipeline_theme=light"
394
+
395
+ patch "/good_pipeline/theme", params: { theme: "neon" }, headers: headers, as: :json
396
+
397
+ assert_response :unprocessable_entity
398
+
399
+ get "/good_pipeline", headers: { "Cookie" => "good_pipeline_theme=neon" }
400
+
401
+ assert_response :success
402
+ assert_includes response.body, %(data-gp-theme="dark")
403
+ assert_includes response.body, %(name="good-pipeline-theme-url" content="/good_pipeline/theme")
404
+ end
405
+
406
+ test "index query budget is seven cold and five warm" do
407
+ now = Time.utc(2026, 8, 10, 12)
408
+ execution = create_execution(type: "BudgetPipeline", status: "succeeded", created_at: now - 1.hour)
409
+ create_step(execution, key: "one", coordination_status: "succeeded")
410
+
411
+ travel_to(now) do
412
+ Rails.cache.clear
413
+ cold = count_dashboard_queries { get "/good_pipeline" }
414
+
415
+ assert_response :success
416
+ warm = count_dashboard_queries { get "/good_pipeline" }
417
+
418
+ assert_response :success
419
+
420
+ assert_equal 7, cold.length, cold.join("\n---\n")
421
+ assert_equal 5, warm.length, warm.join("\n---\n")
422
+ end
423
+ end
424
+
425
+ test "expanding adds only dependency and GoodJob timing queries" do
426
+ now = Time.utc(2026, 8, 10, 12)
427
+ execution = create_execution(type: "BudgetPipeline", status: "running", created_at: now - 1.hour)
428
+ first = create_step(execution, key: "first", coordination_status: "succeeded")
429
+ second = create_step(execution, key: "second", coordination_status: "enqueued")
430
+ DependencyRecord.create!(pipeline: execution, step: second, depends_on_step: first)
431
+ attach_good_job(first, performed_at: now - 50.minutes, finished_at: now - 49.minutes)
432
+ attach_good_job(second, performed_at: now - 1.minute, finished_at: nil)
433
+
434
+ travel_to(now) do
435
+ Rails.cache.clear
436
+ count_dashboard_queries { get "/good_pipeline" }
437
+ collapsed = count_dashboard_queries { get "/good_pipeline" }
438
+ expanded = count_dashboard_queries { get "/good_pipeline", params: { expanded: execution.id } }
439
+
440
+ assert_equal collapsed.length + 2, expanded.length, expanded.join("\n---\n")
441
+ end
442
+ end
443
+
444
+ test "show query count is fixed across step counts with one optional chain lookup" do
445
+ small = create_execution(type: "SmallQueryPipeline", status: "succeeded")
446
+ insert_steps(small, count: 2, prefix: "small", with_good_jobs: true)
447
+ large = create_execution(type: "LargeQueryPipeline", status: "succeeded")
448
+ insert_steps(large, count: 18, prefix: "large", with_good_jobs: true)
449
+
450
+ small_queries = count_dashboard_queries { get "/good_pipeline/pipelines/#{small.id}" }
451
+
452
+ assert_response :success
453
+ large_queries = count_dashboard_queries { get "/good_pipeline/pipelines/#{large.id}" }
454
+
455
+ assert_response :success
456
+ assert_equal small_queries.length, large_queries.length, large_queries.join("\n---\n")
457
+ assert_equal 1, good_job_query_count(small_queries), small_queries.join("\n---\n")
458
+ assert_equal 1, good_job_query_count(large_queries), large_queries.join("\n---\n")
459
+
460
+ related = create_execution(type: "RelatedQueryPipeline", status: "succeeded")
461
+ ChainRecord.create!(upstream_pipeline: related, downstream_pipeline: large)
462
+ chained_queries = count_dashboard_queries { get "/good_pipeline/pipelines/#{large.id}" }
463
+
464
+ assert_response :success
465
+ # Chain rows are always loaded in one query. A populated chain permits
466
+ # exactly one additional batch lookup for the related pipeline records.
467
+ assert_equal large_queries.length + 1, chained_queries.length, chained_queries.join("\n---\n")
468
+ assert_equal 1, good_job_query_count(chained_queries), chained_queries.join("\n---\n")
469
+ end
470
+
471
+ test "sidebar uses one latest execution query and one metadata query regardless of type count" do
472
+ 2.times do |index|
473
+ execution = create_execution(type: "Sidebar#{index}Pipeline", status: "succeeded")
474
+ create_step(execution)
475
+ end
476
+
477
+ first_queries = count_dashboard_queries { get "/good_pipeline" }
478
+
479
+ assert_response :success
480
+ assert_sidebar_query_pair(first_queries)
481
+
482
+ 8.times do |index|
483
+ execution = create_execution(type: "MoreSidebar#{index}Pipeline", status: "succeeded")
484
+ create_step(execution)
485
+ end
486
+ second_queries = count_dashboard_queries { get "/good_pipeline" }
487
+
488
+ assert_response :success
489
+ assert_sidebar_query_pair(second_queries)
490
+ end
491
+
492
+ test "committed dashboard index migration is rerunnable against existing indexes" do
493
+ require Rails.root.join("db/migrate/20260810000000_add_good_pipeline_dashboard_indexes").to_s
494
+ migration = AddGoodPipelineDashboardIndexes.new
495
+
496
+ ActiveRecord::Migration.suppress_messages do
497
+ 2.times { migration.migrate(:up) }
498
+ end
499
+
500
+ names = ActiveRecord::Base.connection.indexes(:good_pipeline_pipelines).map(&:name)
501
+
502
+ assert_equal 1, names.count("index_gp_pipelines_on_type_created_at_id")
503
+ assert_equal 1, names.count("index_gp_pipelines_on_status_created_at_id")
504
+ assert_equal 1, names.count("index_gp_pipelines_on_created_at_id")
505
+ end
506
+
507
+ private
508
+
509
+ def assert_dashboard_css(asset_href)
510
+ get asset_href
511
+
512
+ assert_response :success
513
+ assert_includes response.media_type, "css"
514
+ assert_includes response.body, ".gp-status-filter-scroll"
515
+ assert_match(/\.gp-flash\s*\{[^}]*box-sizing:border-box;/m, response.body)
516
+ end
517
+
518
+ def create_execution(type:, status:, created_at: Time.current - 5.minutes, duration: 1.minute, # rubocop:disable Metrics/MethodLength
519
+ strategy: "halt")
520
+ execution = PipelineRecord.create!(
521
+ type: type,
522
+ status: status,
523
+ params: { source: "test" },
524
+ on_failure_strategy: strategy
525
+ )
526
+ execution.update_columns(
527
+ created_at: created_at,
528
+ updated_at: status == "running" ? created_at : created_at + duration
529
+ )
530
+ execution
531
+ end
532
+
533
+ def assert_cancel_form(execution)
534
+ action = "/good_pipeline/pipelines/#{execution.id}/cancel"
535
+ selector = %(form.gp-action-form[action="#{action}"][method="post"][data-gp-confirm])
536
+ assert_select selector, count: 1 do
537
+ assert_select "button.gp-action--danger", text: "cancel pipeline", count: 1
538
+ assert_select 'input[name="authenticity_token"]', count: 1
539
+ end
540
+ assert_includes response.body, "Future DAG steps won&#39;t be scheduled"
541
+ assert_includes response.body, "enqueued, scheduled, or retrying jobs will continue normally"
542
+ end
543
+
544
+ def dashboard_csrf_headers
545
+ get "/good_pipeline"
546
+ token = response.body[/name="csrf-token" content="([^"]+)"/, 1]
547
+ { "X-CSRF-Token" => token }
548
+ end
549
+
550
+ def with_forgery_protection
551
+ previous = ActionController::Base.allow_forgery_protection
552
+ ActionController::Base.allow_forgery_protection = true
553
+ yield
554
+ ensure
555
+ ActionController::Base.allow_forgery_protection = previous
556
+ end
557
+
558
+ def add_linear_steps(execution, keys) # rubocop:disable Metrics/MethodLength
559
+ previous = nil
560
+ keys.each_with_index do |key, index|
561
+ current = create_step(
562
+ execution,
563
+ key: key,
564
+ coordination_status: "succeeded",
565
+ created_at: execution.created_at + index.seconds
566
+ )
567
+ DependencyRecord.create!(pipeline: execution, step: current, depends_on_step: previous) if previous
568
+ previous = current
569
+ end
570
+ end
571
+
572
+ def attach_good_job(step, performed_at:, finished_at:) # rubocop:disable Metrics/MethodLength
573
+ job_id = SecureRandom.uuid
574
+ scheduled_at = finished_at ? performed_at : Time.utc(2100, 1, 1)
575
+ GoodJob::Job.create!(
576
+ id: job_id,
577
+ active_job_id: job_id,
578
+ queue_name: "good_pipeline_dashboard_test",
579
+ priority: 0,
580
+ serialized_params: {
581
+ "job_class" => step.job_class,
582
+ "job_id" => job_id,
583
+ "queue_name" => "good_pipeline_dashboard_test",
584
+ "arguments" => [],
585
+ "executions" => 0
586
+ },
587
+ scheduled_at: scheduled_at,
588
+ performed_at: performed_at,
589
+ finished_at: finished_at,
590
+ job_class: step.job_class,
591
+ executions_count: finished_at ? 1 : 0
592
+ )
593
+ step.update!(good_job_id: job_id)
594
+ step
595
+ end
596
+
597
+ def insert_steps(execution, count:, prefix:, with_good_jobs: false) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
598
+ now = execution.created_at
599
+ job_ids = Array.new(count) { SecureRandom.uuid }
600
+ rows = Array.new(count) do |index|
601
+ {
602
+ id: SecureRandom.uuid,
603
+ pipeline_id: execution.id,
604
+ key: format("%<prefix>s_%<index>02d", prefix: prefix, index: index),
605
+ job_class: "DashboardStepJob",
606
+ coordination_status: "succeeded",
607
+ good_job_id: with_good_jobs ? job_ids.fetch(index) : nil,
608
+ created_at: now + index.seconds,
609
+ updated_at: now + index.seconds
610
+ }
611
+ end
612
+ StepRecord.insert_all!(rows)
613
+ insert_good_jobs(job_ids, now:) if with_good_jobs
614
+ StepRecord.where(id: rows.pluck(:id)).order(:created_at, :id).to_a
615
+ end
616
+
617
+ def insert_dependencies(execution, pairs)
618
+ DependencyRecord.insert_all!(pairs.map do |upstream, downstream|
619
+ {
620
+ pipeline_id: execution.id,
621
+ step_id: downstream.id,
622
+ depends_on_step_id: upstream.id
623
+ }
624
+ end)
625
+ end
626
+
627
+ def insert_good_jobs(ids, now:) # rubocop:disable Metrics/MethodLength
628
+ GoodJob::Job.insert_all!(ids.map do |id|
629
+ {
630
+ id: id,
631
+ active_job_id: id,
632
+ queue_name: "good_pipeline_dashboard_test",
633
+ priority: 0,
634
+ serialized_params: {
635
+ "job_class" => "DashboardStepJob",
636
+ "job_id" => id,
637
+ "queue_name" => "good_pipeline_dashboard_test",
638
+ "arguments" => [],
639
+ "executions" => 1
640
+ },
641
+ scheduled_at: now,
642
+ performed_at: now,
643
+ finished_at: now + 1.second,
644
+ job_class: "DashboardStepJob",
645
+ executions_count: 1,
646
+ created_at: now,
647
+ updated_at: now + 1.second
648
+ }
649
+ end)
650
+ end
651
+
652
+ def good_job_query_count(queries)
653
+ queries.count { |sql| sql.match?(/\bFROM\s+"good_jobs"/i) }
654
+ end
655
+
656
+ def assert_sidebar_query_pair(queries)
657
+ latest = queries.count do |sql|
658
+ sql.include?("DISTINCT ON (type)") && sql.include?("COUNT(*) OVER (PARTITION BY type)")
659
+ end
660
+ metadata = queries.count { |sql| sql.include?("BOOL_OR(job_class") }
661
+
662
+ assert_equal 1, latest, queries.join("\n---\n")
663
+ assert_equal 1, metadata, queries.join("\n---\n")
664
+ end
665
+ end
666
+ end
667
+ # rubocop:enable Minitest/MultipleAssertions