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.
- checksums.yaml +4 -4
- data/Appraisals +25 -0
- data/CHANGELOG.md +58 -0
- data/README.md +67 -11
- data/app/controllers/good_pipeline/frontends_controller.rb +18 -1
- data/app/controllers/good_pipeline/pipelines_controller.rb +254 -36
- data/app/controllers/good_pipeline/themes_controller.rb +15 -0
- data/app/frontend/good_pipeline/dashboard.css +407 -0
- data/app/frontend/good_pipeline/dashboard.js +542 -0
- data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +225 -66
- data/app/helpers/good_pipeline/pipelines_helper.rb +89 -5
- data/app/jobs/good_pipeline/pipeline_callback_job.rb +2 -2
- data/app/jobs/good_pipeline/pipeline_reconciliation_job.rb +1 -2
- data/app/jobs/good_pipeline/step_finished_job.rb +6 -2
- data/app/models/good_pipeline/pipeline_record.rb +7 -4
- data/app/models/good_pipeline/step_record.rb +6 -3
- data/app/views/good_pipeline/pipelines/_actions.html.erb +28 -0
- data/app/views/good_pipeline/pipelines/_chain_links.html.erb +17 -29
- data/app/views/good_pipeline/pipelines/_diagram.html.erb +101 -0
- data/app/views/good_pipeline/pipelines/_filter_bar.html.erb +51 -0
- data/app/views/good_pipeline/pipelines/_kpi_strip.html.erb +74 -0
- data/app/views/good_pipeline/pipelines/_pager.html.erb +31 -0
- data/app/views/good_pipeline/pipelines/_row.html.erb +103 -0
- data/app/views/good_pipeline/pipelines/_row_detail.html.erb +93 -0
- data/app/views/good_pipeline/pipelines/_sidebar.html.erb +115 -0
- data/app/views/good_pipeline/pipelines/_steps_table.html.erb +46 -33
- data/app/views/good_pipeline/pipelines/_table.html.erb +33 -0
- data/app/views/good_pipeline/pipelines/_timeline.html.erb +55 -0
- data/app/views/good_pipeline/pipelines/_topbar.html.erb +54 -0
- data/app/views/good_pipeline/pipelines/definitions.html.erb +88 -55
- data/app/views/good_pipeline/pipelines/index.html.erb +31 -42
- data/app/views/good_pipeline/pipelines/show.html.erb +54 -47
- data/app/views/layouts/good_pipeline/application.html.erb +24 -153
- data/config/routes.rb +4 -0
- data/demo/config/environments/development.rb +3 -0
- data/demo/config/environments/test.rb +2 -1
- data/demo/db/migrate/20260319205325_create_good_jobs.rb +1 -1
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +1 -1
- data/demo/db/migrate/20260810000000_add_good_pipeline_dashboard_indexes.rb +20 -0
- data/demo/db/seeds.rb +325 -11
- data/demo/docs/screenshots/definitions.png +0 -0
- data/demo/docs/screenshots/show.png +0 -0
- data/demo/test/good_pipeline/test_bulk_enqueue.rb +54 -1
- data/demo/test/good_pipeline/test_cancellation.rb +312 -0
- data/demo/test/good_pipeline/test_cleanup.rb +31 -1
- data/demo/test/good_pipeline/test_coordinator.rb +39 -0
- data/demo/test/good_pipeline/test_dashboard.rb +667 -0
- data/demo/test/good_pipeline/test_dashboard_rerun.rb +244 -0
- data/demo/test/good_pipeline/test_pipeline_callback_job.rb +11 -0
- data/demo/test/good_pipeline/test_pipeline_record.rb +30 -0
- data/demo/test/good_pipeline/test_runner.rb +40 -1
- data/demo/test/good_pipeline/test_step_record.rb +37 -0
- data/demo/test/integration/test_barrier_execution.rb +502 -0
- data/demo/test/integration/test_chain_locking.rb +54 -0
- data/demo/test/integration/test_pipeline_chaining.rb +96 -0
- data/demo/test/test_helper.rb +20 -0
- data/docs/architecture.md +45 -29
- data/docs/branching.md +4 -0
- data/docs/callbacks.md +9 -7
- data/docs/cleanup.md +2 -2
- data/docs/dag-validation.md +16 -0
- data/docs/dashboard.md +84 -22
- data/docs/defining-pipelines.md +34 -1
- data/docs/failure-strategies.md +24 -1
- data/docs/getting-started.md +9 -1
- data/docs/index.md +1 -1
- data/docs/introduction.md +4 -4
- data/docs/monitoring.md +8 -3
- data/docs/pipeline-chaining.md +17 -9
- data/docs/public/screenshots/definitions.png +0 -0
- data/docs/public/screenshots/index.png +0 -0
- data/docs/public/screenshots/show.png +0 -0
- data/docs/screenshots/definitions.png +0 -0
- data/docs/screenshots/index.png +0 -0
- data/docs/screenshots/show.png +0 -0
- data/gemfiles/rails_7_2.gemfile +24 -0
- data/gemfiles/rails_7_2.gemfile.lock +412 -0
- data/gemfiles/rails_8_0.gemfile +24 -0
- data/gemfiles/rails_8_0.gemfile.lock +411 -0
- data/gemfiles/rails_8_1.gemfile +24 -0
- data/gemfiles/rails_8_1.gemfile.lock +415 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +9 -0
- data/lib/generators/good_pipeline/upgrade/templates/add_good_pipeline_dashboard_indexes.rb.erb +27 -0
- data/lib/generators/good_pipeline/upgrade/upgrade_generator.rb +32 -0
- data/lib/good_pipeline/barrier_compiler.rb +106 -0
- data/lib/good_pipeline/chain_coordinator.rb +9 -5
- data/lib/good_pipeline/constants.rb +2 -0
- data/lib/good_pipeline/coordinator.rb +269 -140
- data/lib/good_pipeline/dashboard/connection_info.rb +67 -0
- data/lib/good_pipeline/dashboard/definition_stages.rb +59 -0
- data/lib/good_pipeline/dashboard/filter_set.rb +77 -0
- data/lib/good_pipeline/dashboard/kpi_calculator.rb +143 -0
- data/lib/good_pipeline/dashboard/sparkline.rb +73 -0
- data/lib/good_pipeline/dashboard/stage_lanes.rb +307 -0
- data/lib/good_pipeline/dashboard/step_timings.rb +50 -0
- data/lib/good_pipeline/dashboard.rb +21 -0
- data/lib/good_pipeline/errors.rb +11 -0
- data/lib/good_pipeline/pipeline.rb +14 -2
- data/lib/good_pipeline/step_definition.rb +16 -1
- data/lib/good_pipeline/version.rb +1 -1
- data/lib/good_pipeline.rb +7 -1
- data/mise.toml +1 -1
- metadata +48 -13
- data/app/frontend/good_pipeline/style.css +0 -597
- data/app/views/good_pipeline/pipelines/_pagination.html.erb +0 -24
- 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'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
|