good_pipeline 0.3.1 → 0.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.
- checksums.yaml +4 -4
- data/Appraisals +25 -0
- data/CHANGELOG.md +67 -0
- data/README.md +66 -10
- 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 +246 -0
- 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 +666 -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_queue_configuration.rb +157 -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_bulk_enqueue_end_to_end.rb +109 -0
- data/demo/test/integration/test_chain_locking.rb +54 -0
- data/demo/test/integration/test_end_to_end.rb +0 -15
- data/demo/test/integration/test_halt_execution.rb +0 -13
- data/demo/test/integration/test_pipeline_chaining.rb +31 -0
- data/demo/test/integration/test_queue_configuration.rb +82 -0
- data/demo/test/test_helper.rb +35 -0
- data/docs/architecture.md +53 -29
- data/docs/branching.md +6 -0
- data/docs/callbacks.md +10 -6
- data/docs/cleanup.md +2 -2
- data/docs/dag-validation.md +16 -0
- data/docs/dashboard.md +84 -22
- data/docs/defining-pipelines.md +38 -1
- data/docs/failure-strategies.md +19 -0
- data/docs/getting-started.md +21 -1
- data/docs/index.md +1 -1
- data/docs/introduction.md +22 -4
- data/docs/monitoring.md +7 -2
- data/docs/pipeline-chaining.md +10 -4
- 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 +3 -7
- data/lib/good_pipeline/constants.rb +2 -0
- data/lib/good_pipeline/coordinator.rb +332 -122
- 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 +38 -3
- data/lib/good_pipeline/runner.rb +3 -3
- data/lib/good_pipeline/step_definition.rb +16 -1
- data/lib/good_pipeline/version.rb +1 -1
- data/lib/good_pipeline.rb +21 -0
- metadata +52 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8d45ee63fb102b327e2e847cd277c911c625711562117c172119b3384d08447
|
|
4
|
+
data.tar.gz: 888ac688b1e600a74fbc70b36c4ab0aa1d7747d4f986bd8cc9d688a6ab8c8dbc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 642f603b2991bb82256510cf3ea1a13f511f7009a7ba82f10d9fb8c264acdc27d213629842414293a6728c13d055ecf808ba71c555fc9bf4705f3b3b8a23ab5f
|
|
7
|
+
data.tar.gz: 7941dcef31ca4d86f5fb796803a61d3c3d9af3ed8442aaace983c5a6c5d62360c16a4458ab7130f34764d14e37d53cc79167e4296a52b31bc0381300a3bc74b6
|
data/Appraisals
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
appraise "rails-7-2" do
|
|
4
|
+
gem "minitest", "~> 5.25"
|
|
5
|
+
gem "railties", "~> 7.2.0"
|
|
6
|
+
gem "activerecord", "~> 7.2.0"
|
|
7
|
+
gem "activejob", "~> 7.2.0"
|
|
8
|
+
gem "activesupport", "~> 7.2.0"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
appraise "rails-8-0" do
|
|
12
|
+
gem "minitest", "~> 6.0", ">= 6.0.2"
|
|
13
|
+
gem "railties", "~> 8.0.0"
|
|
14
|
+
gem "activerecord", "~> 8.0.0"
|
|
15
|
+
gem "activejob", "~> 8.0.0"
|
|
16
|
+
gem "activesupport", "~> 8.0.0"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
appraise "rails-8-1" do
|
|
20
|
+
gem "minitest", "~> 6.0", ">= 6.0.2"
|
|
21
|
+
gem "railties", "~> 8.1.0"
|
|
22
|
+
gem "activerecord", "~> 8.1.0"
|
|
23
|
+
gem "activejob", "~> 8.1.0"
|
|
24
|
+
gem "activesupport", "~> 8.1.0"
|
|
25
|
+
end
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-09-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Pipeline phase barriers** — the new zero-argument `barrier` DSL verb inserts a persisted structural synchronization step between declaration phases. Every prior phase step converges on one barrier and following phase entries fan out from it, avoiding all-to-all dependency expansion. Barriers resolve synchronously without enqueuing an ActiveJob and appear as structural nodes in the dashboard.
|
|
8
|
+
- **Graceful dashboard cancellation** — administrators can stop future DAG scheduling without terminating already-enqueued, scheduled, or retrying GoodJob work; pipelines remain `canceling` until that work drains, then become terminal `canceled`.
|
|
9
|
+
- **Standalone dashboard re-runs** — administrators can start a fresh execution of a terminal pipeline from its stored parameters and current class definition. The original execution remains unchanged, and historical pipeline-chain relationships are not copied.
|
|
10
|
+
- **Read-only dashboard default** — pipeline mutation controls are hidden and rejected with `403 Forbidden` unless `GoodPipeline.dashboard_mutations_enabled = true` is configured explicitly. This setting does not replace authentication for the dashboard mount.
|
|
11
|
+
- **Redesigned dashboard** — a responsive execution shell with pipeline-type navigation, composable status/time/search filters, offset pagination, KPIs, expandable execution rows, stage timelines, and dedicated execution and definition views.
|
|
12
|
+
- **Persistent light and dark themes** — the dashboard now owns an isolated `data-gp-theme` attribute and persists the topbar toggle through a mount-aware Rails endpoint. Dark is now the default theme; existing users will see the dashboard change from light to dark after upgrading unless they select light.
|
|
13
|
+
- **Scale-aware graph views** — large DAGs default to aggregated stage views, with full Mermaid rendering available up to a 1,000-edge safety limit.
|
|
14
|
+
- **Dashboard upgrade generator** — `good_pipeline:upgrade` creates the three concurrent indexes used by type, status, and chronological dashboard queries, and safely no-ops when that migration already exists.
|
|
15
|
+
- **Rails compatibility matrix** — CI now exercises the Rails 7.2 support floor and the Rails 8.0 and 8.1 lines through Appraisal gemfiles.
|
|
16
|
+
|
|
17
|
+
### Breaking changes
|
|
18
|
+
|
|
19
|
+
- **Coordinator activity hints removed** — `Coordinator.recompute_pipeline_status` no longer accepts `has_active_steps:` or `has_downstream_chains:`. Terminal status is always derived from fresh rows while the pipeline is locked; obsolete callers now fail loudly instead of having their hints ignored.
|
|
20
|
+
- **Bulk enqueue contract tightened** — `Coordinator.bulk_enqueue_steps` raises `ArgumentError` when existing step IDs span multiple pipelines and now consistently returns `nil`. Both bulk and single-step enqueue refuse to schedule work unless the owning pipeline is `running`; `Coordinator.try_enqueue_step` reports that refusal as `false`.
|
|
21
|
+
- **Rails 7.2 minimum** — Rails 7.1 is no longer supported. Rails 7.1 [reached upstream end-of-life in October 2025](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement) and no longer receives bug fixes or security fixes.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Universal dependency normalization** — repeated keys in every `after:` list are deduplicated before validation and persistence, keeping dependency rows consistent with `pending_upstream_count` for pipelines with or without barriers.
|
|
26
|
+
- **Terminal edge-release invariant** — enqueue-time configuration failures and steps skipped by partial `:halt` propagation now release downstream dependency counters before eligible descendants are resolved. Terminalization, counter release, and downstream scheduling remain in one pipeline-locked transaction.
|
|
27
|
+
- **Pipeline-first coordination** — cancellation, completion, single enqueue, bulk enqueue, and status recomputation serialize on the owning pipeline row and then lock step rows in a consistent order. User jobs still execute concurrently; only coordination for the same pipeline is serialized, including downstream resolution and transactional GoodJob insertion.
|
|
28
|
+
- **Fresh terminal derivation** — completion and explicit status recomputation derive running/canceling outcomes through one locked path. A redelivered terminal step also recomputes a nonterminal pipeline, repairing stale terminal status left by older coordinator versions or manual intervention.
|
|
29
|
+
- **Post-commit chain handoff** — terminal chain propagation runs after all surrounding transactions commit, avoiding cross-pipeline lock nesting and making `GoodPipeline.run` safe inside a caller-managed transaction.
|
|
30
|
+
- **Step completion ownership metadata** — new step batches record their `pipeline_id`, avoiding an ownership lookup before completion locking. `StepFinishedJob` falls back to the step row for batches queued before this upgrade.
|
|
31
|
+
- **Callback delivery semantics clarified** — `callbacks_dispatched_at` guarantees one transactional callback-job enqueue, not exactly-once user callback execution. Retry behavior remains application-configured, and callbacks should be idempotent.
|
|
32
|
+
- **Execution pagination** — dashboard lists now use clamped offset pagination with a total page count instead of keyset cursors.
|
|
33
|
+
- **Step timings** — dashboard timing data is batch-loaded from GoodJob, removing per-step lookups while preserving the same retention boundary as job records.
|
|
34
|
+
- **Relative timestamps** — times under one minute now render as exact seconds such as `30s ago` instead of `just now`.
|
|
35
|
+
- **Dashboard dependencies** — versioned dashboard CSS and JavaScript ship with the gem; graph rendering remains build-free and is initialized client-side in strict mode.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Branch-arm pruning with additional dependencies** — every non-selected arm step is reconsidered as soon as its branch resolves and becomes `skipped_by_branch` even when another incoming dependency is unresolved or failed. Branch continuation no longer depends on concurrent completion order.
|
|
40
|
+
- **All-empty branch continuation** — a branch whose arms are all empty now aliases to its structural sentinel, so `after: :branch_key` remains ordered after the branch decision with or without a preceding barrier.
|
|
41
|
+
- **Multiple ignored halt failures** — bulk enqueue-time failures that all override pipeline-level `:halt` with `:ignore` now protect the union of their downstream subtrees instead of allowing each halt pass to skip another ignored subtree.
|
|
42
|
+
- **Skipped dependencies under inherited ignore** — ordinary `skipped` and `canceled` steps are always treated as permanently unsatisfied, preventing descendants from remaining pending when a skipped step inherits pipeline-level `:ignore`.
|
|
43
|
+
- **Dropped chain fan-in wake-ups** — `ChainCoordinator` now waits on a blocking `FOR UPDATE` lock instead of silently skipping a contended downstream pipeline with `SKIP LOCKED`.
|
|
44
|
+
- **Database benchmark pipeline resolution** — dynamically generated benchmark pipeline classes are now registered as constants, allowing the enqueue, completion, recomputation, halt, and full-run sections of `bench/database_bench.rb` to execute.
|
|
45
|
+
|
|
46
|
+
### Upgrade notes
|
|
47
|
+
|
|
48
|
+
- Barrier-aware deployments require two phases: first deploy this gem version to every web and worker process capable of running coordinator code; only after all old processes have stopped should application code using `barrier` be deployed. Older coordinators treat the new structural sentinel as an executable job class and cannot safely process barrier definitions.
|
|
49
|
+
- Existing dashboard mounts remain read-only after upgrading. Protect the engine mount with administrator authentication, then set `GoodPipeline.dashboard_mutations_enabled = true` to expose dashboard mutation controls, including cancellation and re-running terminal pipelines. The theme preference remains available in read-only mode.
|
|
50
|
+
- Coordinator completion and enqueue operations for one pipeline are now serialized for cancellation correctness. Wide fan-in increases coordination query volume because every upstream completion locks and recomputes pipeline state; many concurrent completions, such as leaves in a wide fan-out, can contend on the pipeline row. Recursive fan-out scheduling reuses that lock and issues fewer queries than before.
|
|
51
|
+
- Run `bin/rails generate good_pipeline:upgrade` and `bin/rails db:migrate` to add the dashboard indexes. See `docs/dashboard.md` for recovery steps if a concurrent index build is interrupted.
|
|
52
|
+
- GoodJob may remove timing rows for early steps of a still-running pipeline; those steps render `—` rather than raising or issuing individual lookups.
|
|
53
|
+
|
|
54
|
+
## [0.4.0] - 2026-04-02
|
|
55
|
+
|
|
56
|
+
### Performance
|
|
57
|
+
|
|
58
|
+
- **Bulk root step enqueuing** — pipelines with multiple root steps now enqueue all of them via `GoodJob::Batch.enqueue_all` in a fixed number of queries instead of ~9 queries per step. Both `Runner#enqueue_root_steps` and `ChainCoordinator#start_pipeline` use the new `Coordinator.bulk_enqueue_steps` method.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **Configurable queue names for internal jobs** — new `coordination_queue_name` and `callback_queue_name` settings control which queues `StepFinishedJob`, `PipelineReconciliationJob`, and `PipelineCallbackJob` run on. Configurable globally (`GoodPipeline.coordination_queue_name = "x"`) and per-pipeline via the class DSL. Defaults to `"good_pipeline_coordination"` and `"good_pipeline_callbacks"`.
|
|
63
|
+
- **`Coordinator.bulk_enqueue_steps`** — public method that loads pending steps, partitions branch steps for individual handling, and bulk-enqueues the rest via `Batch.enqueue_all`. Invalid job classes are failed individually without blocking valid steps.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- **Minimum GoodJob version** — bumped from `>= 3.10` to `>= 4.14` (required for `Batch.enqueue_all`).
|
|
68
|
+
- **`run_pipeline_to_completion` test helper** — extracted from 3 integration test files into `test_helper.rb`.
|
|
69
|
+
|
|
3
70
|
## [0.3.1] - 2026-03-26
|
|
4
71
|
|
|
5
72
|
### Added
|
data/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
DAG-based job pipeline orchestration for Rails, built on [GoodJob](https://github.com/bensheldon/good_job).
|
|
4
4
|
|
|
5
|
-
Define multi-step workflows as directed acyclic graphs
|
|
5
|
+
Define multi-step workflows as directed acyclic graphs — not linear chains. Steps run in parallel when they can and wait for dependencies when they must. GoodPipeline handles dependency resolution, parallel execution, failure strategies, conditional branching, pipeline chaining, and lifecycle callbacks. It also ships with a web dashboard.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
9
|
- Ruby >= 3.2
|
|
10
|
-
- Rails >= 7.
|
|
10
|
+
- Rails >= 7.2
|
|
11
11
|
- PostgreSQL
|
|
12
|
-
- GoodJob >=
|
|
12
|
+
- GoodJob >= 4.14 with `preserve_job_records = true`
|
|
13
13
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
@@ -26,6 +26,15 @@ bin/rails generate good_pipeline:install
|
|
|
26
26
|
bin/rails db:migrate
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
When upgrading an existing application to GoodPipeline 0.5, add the dashboard indexes before serving a large execution history:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bin/rails generate good_pipeline:upgrade
|
|
33
|
+
bin/rails db:migrate
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The upgrade generator is idempotent: if its dashboard-index migration already exists, a second invocation reports a no-op instead of creating another file.
|
|
37
|
+
|
|
29
38
|
GoodPipeline requires GoodJob to preserve job records. Add this to your GoodJob configuration:
|
|
30
39
|
|
|
31
40
|
```ruby
|
|
@@ -93,6 +102,22 @@ run :step_key, JobClass,
|
|
|
93
102
|
enqueue: { queue: :media, priority: 10 } # options passed to job.enqueue()
|
|
94
103
|
```
|
|
95
104
|
|
|
105
|
+
### Phase barriers
|
|
106
|
+
|
|
107
|
+
Use `barrier` when every entry into the next declaration phase must wait for the whole preceding phase:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
run :fetch_users, FetchUsersJob
|
|
111
|
+
run :fetch_orders, FetchOrdersJob
|
|
112
|
+
|
|
113
|
+
barrier
|
|
114
|
+
|
|
115
|
+
run :index_users, IndexUsersJob
|
|
116
|
+
run :index_orders, IndexOrdersJob
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The barrier is a persisted structural step, not an ActiveJob. It is resolved synchronously after every step in the preceding phase becomes terminal, creates no GoodJob record, and appears as a Barrier node in the dashboard. Normal failure semantics apply: `:ignore` failures permit progress, while a permanently unsatisfied dependency skips the barrier and the later phase.
|
|
120
|
+
|
|
96
121
|
### Failure strategies
|
|
97
122
|
|
|
98
123
|
Set at the pipeline level with `failure_strategy`:
|
|
@@ -149,7 +174,7 @@ branch :quality_check, after: :analyze, by: :needs_processing do
|
|
|
149
174
|
end
|
|
150
175
|
```
|
|
151
176
|
|
|
152
|
-
The dashboard renders branches as diamond decision nodes with labeled edges.
|
|
177
|
+
The dashboard renders branches as diamond decision nodes with labeled edges and barriers as structural synchronization nodes.
|
|
153
178
|
|
|
154
179
|
### Pipeline chaining
|
|
155
180
|
|
|
@@ -176,7 +201,7 @@ GoodPipeline.run(
|
|
|
176
201
|
).then(MergeMediaPipeline, with: { video_id: 123, audio_id: 456 })
|
|
177
202
|
```
|
|
178
203
|
|
|
179
|
-
If an upstream pipeline fails or
|
|
204
|
+
If an upstream pipeline fails, halts, is canceled, or is skipped, downstream pipelines are automatically skipped.
|
|
180
205
|
|
|
181
206
|
### Monitoring
|
|
182
207
|
|
|
@@ -211,14 +236,45 @@ GoodPipeline includes a mountable web dashboard for inspecting pipeline executio
|
|
|
211
236
|
|
|
212
237
|
```ruby
|
|
213
238
|
# config/routes.rb
|
|
239
|
+
# Protect this mount with your application's administrator authentication.
|
|
214
240
|
mount GoodPipeline::Engine => "/good_pipeline"
|
|
215
241
|
```
|
|
216
242
|
|
|
243
|
+
The engine does not provide authentication. Treat the dashboard as an admin-only interface and see [Securing the dashboard](docs/dashboard.md#securing-the-dashboard) for mount examples.
|
|
244
|
+
|
|
245
|
+
Pipeline mutation controls are read-only by default. After protecting the mount, enable cancellation and re-running explicitly:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
# config/initializers/good_pipeline.rb
|
|
249
|
+
GoodPipeline.dashboard_mutations_enabled = true
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
This setting only enables dashboard actions; it does not authenticate or authorize visitors. The theme preference remains available while pipeline mutations are disabled.
|
|
253
|
+
|
|
217
254
|
The dashboard provides:
|
|
218
255
|
|
|
219
|
-
- Pipeline
|
|
220
|
-
-
|
|
221
|
-
- Pipeline
|
|
256
|
+
- Pipeline executions with composable type, status, time, and text filters, offset pagination, live status counts, and expandable step timelines
|
|
257
|
+
- Operational KPIs for the selected pipeline type, including current activity, fixed-window failure and duration statistics, and a 14-day sparkline
|
|
258
|
+
- Pipeline details with GoodJob links, step errors, chain links, a stage timeline, and an interactive DAG
|
|
259
|
+
- A definition catalog with declared dependencies and structural DAG or stage views
|
|
260
|
+
|
|
261
|
+
Dark is the default theme in 0.5. The topbar toggle persists a light or dark preference in a permanent same-site cookie. Dashboard styles and JavaScript ship with the gem; Mermaid and web fonts are loaded from their CDNs, so there is no application-side asset build step.
|
|
262
|
+
|
|
263
|
+
Large executions remain readable: rows with more than 12 steps use an aggregate status bar, and DAGs with more than 60 steps initially show a stage view. A full graph remains available up to Mermaid's 1,000-edge safety limit. Above that limit, the stage view stays available and full rendering is disabled explicitly.
|
|
264
|
+
|
|
265
|
+
### Canceling a pipeline
|
|
266
|
+
|
|
267
|
+
Dashboard cancellation is graceful: it stops future DAG scheduling, not work already handed to GoodJob. A pending pipeline becomes `canceled` immediately. A running pipeline becomes `canceling`, and its `pending` steps become `canceled`; jobs that are already enqueued, scheduled, or retrying run normally and retain their actual `succeeded`, `failed`, or `halted` outcomes. No GoodJob records are changed and no worker is force-terminated.
|
|
268
|
+
|
|
269
|
+
After the last enqueued job finishes, the pipeline becomes `canceled`. Until then, `canceling` is an active, nonterminal state. A pipeline can remain `canceling` indefinitely if an enqueued job never reaches a terminal outcome.
|
|
270
|
+
|
|
271
|
+
### Re-running a pipeline
|
|
272
|
+
|
|
273
|
+
The dashboard can re-run a terminal pipeline (`succeeded`, `failed`, `halted`, `skipped`, or `canceled`). A re-run is a new standalone execution, not a retry or resumption of the historical one. It starts from the root steps using the source execution's stored JSON parameters and the pipeline's current Ruby definition, so code changes made since the source ran are applied.
|
|
274
|
+
|
|
275
|
+
The source execution, its steps, jobs, callbacks, and status remain unchanged. Pipeline-chain relationships are not copied: upstream pipelines are not attached to the new execution, and downstream pipelines are not re-run. Normal branching and failure rules still apply, and jobs, callbacks, and external side effects may run again. Each confirmed submission intentionally creates another execution.
|
|
276
|
+
|
|
277
|
+
If the pipeline class has been removed, the stored parameters are no longer compatible, or the current definition is invalid, the dashboard creates nothing and reports that the pipeline could not be re-run. Re-run controls remain disabled while an execution is `pending`, `running`, or `canceling`.
|
|
222
278
|
|
|
223
279
|
### Pipeline Executions
|
|
224
280
|
|
|
@@ -232,12 +288,12 @@ The dashboard provides:
|
|
|
232
288
|
|
|
233
289
|

|
|
234
290
|
|
|
235
|
-
No build step. Uses Pico CSS and Mermaid.js from CDN.
|
|
236
|
-
|
|
237
291
|
## Cleanup
|
|
238
292
|
|
|
239
293
|
GoodPipeline automatically cleans up old terminal pipelines when GoodJob runs its own cleanup cycle. No configuration needed, it uses GoodJob's retention period (default 14 days).
|
|
240
294
|
|
|
295
|
+
Pending, running, and canceling pipelines are intentionally retained; canceled pipelines are terminal and follow the normal retention window. GoodJob may still remove old job rows belonging to a long-running or canceling pipeline, so timing for those steps appears as `—` in the dashboard after the retention window; the pipeline and step coordination records remain available.
|
|
296
|
+
|
|
241
297
|
To configure the retention period, set GoodJob's option:
|
|
242
298
|
|
|
243
299
|
```ruby
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module GoodPipeline
|
|
4
4
|
class FrontendsController < ApplicationController
|
|
5
|
+
# Rails' same-origin JavaScript response check treats an engine asset served
|
|
6
|
+
# through a controller like an executable action response. These files are
|
|
7
|
+
# public, versioned static assets and never read or mutate session state.
|
|
8
|
+
skip_forgery_protection only: :static
|
|
9
|
+
|
|
5
10
|
def static
|
|
6
11
|
file_name = "#{sanitized_id}.#{sanitized_format}"
|
|
7
12
|
file_path = assets_directory.join(file_name)
|
|
8
13
|
|
|
9
14
|
if file_path.exist?
|
|
10
|
-
|
|
15
|
+
apply_cache_headers
|
|
11
16
|
send_file file_path, disposition: :inline
|
|
12
17
|
else
|
|
13
18
|
head :not_found
|
|
@@ -16,6 +21,18 @@ module GoodPipeline
|
|
|
16
21
|
|
|
17
22
|
private
|
|
18
23
|
|
|
24
|
+
# The asset URL embeds GoodPipeline::VERSION, so a released build is immutable
|
|
25
|
+
# and safe to cache for a year. In development that version does not change
|
|
26
|
+
# between edits, which would otherwise pin the browser to a stale stylesheet
|
|
27
|
+
# for the life of the release.
|
|
28
|
+
def apply_cache_headers
|
|
29
|
+
if Rails.env.development?
|
|
30
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
31
|
+
else
|
|
32
|
+
expires_in 1.year, public: true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
19
36
|
def sanitized_id
|
|
20
37
|
params[:id].to_s.gsub(/[^a-zA-Z0-9_-]/, "")
|
|
21
38
|
end
|
|
@@ -1,58 +1,276 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module GoodPipeline
|
|
4
|
-
class PipelinesController < ApplicationController
|
|
4
|
+
class PipelinesController < ApplicationController # rubocop:disable Metrics/ClassLength
|
|
5
5
|
PAGE_SIZE = 25
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
before_action :require_dashboard_mutations_enabled!, only: %i[cancel rerun]
|
|
8
|
+
|
|
9
|
+
SidebarEntry = Data.define(
|
|
10
|
+
:type,
|
|
11
|
+
:id,
|
|
12
|
+
:on_failure_strategy,
|
|
13
|
+
:run_count,
|
|
14
|
+
:step_count,
|
|
15
|
+
:has_branch,
|
|
16
|
+
:large
|
|
17
|
+
) do
|
|
18
|
+
alias_method :strategy, :on_failure_strategy
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def index # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
22
|
+
@filters = Dashboard::FilterSet.from_params(params)
|
|
23
|
+
@now = Time.current
|
|
24
|
+
|
|
25
|
+
base_scope = apply_type_time_search(PipelineRecord.all)
|
|
26
|
+
@status_counts = base_scope.group(:status).count
|
|
27
|
+
@total = @filters.status == "all" ? @status_counts.values.sum : @status_counts.fetch(@filters.status, 0)
|
|
28
|
+
|
|
29
|
+
last_page = [(@total.to_f / PAGE_SIZE).ceil, 1].max
|
|
30
|
+
current_page = @filters.page.clamp(1, last_page)
|
|
31
|
+
@page = { current: current_page, last: last_page, total: @total, per_page: PAGE_SIZE }
|
|
32
|
+
|
|
33
|
+
list_scope = @filters.status == "all" ? base_scope : base_scope.where(status: @filters.status)
|
|
34
|
+
@pipelines = list_scope.order(created_at: :desc, id: :desc)
|
|
35
|
+
.offset((current_page - 1) * PAGE_SIZE)
|
|
36
|
+
.limit(PAGE_SIZE)
|
|
37
|
+
.to_a
|
|
38
|
+
|
|
39
|
+
load_page_steps
|
|
40
|
+
load_expanded_row
|
|
41
|
+
@sidebar_entries = load_sidebar_entries
|
|
42
|
+
@pipeline_types = @sidebar_entries.map(&:type)
|
|
43
|
+
@retention_seconds = GoodJob.configuration.cleanup_preserved_jobs_before_seconds_ago
|
|
44
|
+
@kpis = Dashboard::KpiCalculator.new(pipeline_type: @filters.pipeline_type, now: @now).call
|
|
45
|
+
@connection_info = Dashboard::ConnectionInfo.fetch
|
|
15
46
|
end
|
|
16
47
|
|
|
17
|
-
def definitions
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
48
|
+
def definitions # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
49
|
+
@now = Time.current
|
|
50
|
+
@pipelines = PipelineRecord
|
|
51
|
+
.select("DISTINCT ON (type) good_pipeline_pipelines.*")
|
|
52
|
+
.order(:type, created_at: :desc, id: :desc)
|
|
53
|
+
.to_a
|
|
54
|
+
load_graph_records(@pipelines)
|
|
55
|
+
@run_counts = PipelineRecord.group(:type).count
|
|
56
|
+
@definition_runs = @run_counts
|
|
57
|
+
@definition_stages_by_pipeline = @pipelines.to_h do |pipeline|
|
|
58
|
+
stages = Dashboard::DefinitionStages.new(
|
|
59
|
+
steps: @steps_by_pipeline.fetch(pipeline.id, []),
|
|
60
|
+
dependencies: @dependencies_by_pipeline.fetch(pipeline.id, [])
|
|
61
|
+
).call
|
|
62
|
+
[pipeline.id, stages]
|
|
21
63
|
end
|
|
22
|
-
@
|
|
23
|
-
|
|
24
|
-
.sort_by(&:type)
|
|
64
|
+
@definition_stages = @definition_stages_by_pipeline
|
|
65
|
+
@connection_info = Dashboard::ConnectionInfo.fetch
|
|
25
66
|
end
|
|
26
67
|
|
|
27
|
-
def show
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
)
|
|
33
|
-
@
|
|
68
|
+
def show # rubocop:disable Metrics/MethodLength
|
|
69
|
+
@now = Time.current
|
|
70
|
+
@pipeline = PipelineRecord.find(params[:id])
|
|
71
|
+
load_graph_records([@pipeline])
|
|
72
|
+
@steps = @steps_by_pipeline.fetch(@pipeline.id, [])
|
|
73
|
+
@dependencies = @dependencies_by_pipeline.fetch(@pipeline.id, [])
|
|
74
|
+
@step_timings = Dashboard::StepTimings.new(@steps).call
|
|
75
|
+
@stage_lanes = Dashboard::StageLanes.new(
|
|
76
|
+
pipeline: @pipeline,
|
|
77
|
+
steps: @steps,
|
|
78
|
+
dependencies: @dependencies,
|
|
79
|
+
timings: @step_timings,
|
|
80
|
+
now: @now
|
|
81
|
+
).call
|
|
82
|
+
load_chain_records
|
|
83
|
+
@connection_info = Dashboard::ConnectionInfo.fetch
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cancel # rubocop:disable Metrics/MethodLength
|
|
87
|
+
@pipeline = Coordinator.cancel_pipeline(params[:id])
|
|
88
|
+
|
|
89
|
+
notice = if @pipeline.status.to_s == "canceled"
|
|
90
|
+
"Pipeline canceled."
|
|
91
|
+
else
|
|
92
|
+
"Pipeline cancellation requested."
|
|
93
|
+
end
|
|
94
|
+
redirect_back fallback_location: pipeline_path(@pipeline), allow_other_host: false,
|
|
95
|
+
notice: notice, status: :see_other
|
|
96
|
+
rescue GoodPipeline::CancellationConflict => error
|
|
97
|
+
redirect_back fallback_location: pipeline_path(error.pipeline_id),
|
|
98
|
+
allow_other_host: false,
|
|
99
|
+
alert: "Pipeline is already #{error.status} and cannot be canceled.",
|
|
100
|
+
status: :see_other
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def rerun # rubocop:disable Metrics/MethodLength
|
|
104
|
+
source = PipelineRecord.find(params[:id])
|
|
105
|
+
|
|
106
|
+
unless source.terminal?
|
|
107
|
+
return redirect_back fallback_location: pipeline_path(source), allow_other_host: false,
|
|
108
|
+
alert: "Only finished pipelines can be re-run.", status: :see_other
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
pipeline = build_rerun_pipeline(source)
|
|
112
|
+
unless pipeline
|
|
113
|
+
return redirect_back fallback_location: pipeline_path(source), allow_other_host: false,
|
|
114
|
+
alert: "Pipeline could not be re-run with its stored parameters and current definition.",
|
|
115
|
+
status: :see_other
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
rerun = Runner.call(pipeline)
|
|
119
|
+
redirect_to pipeline_path(rerun), notice: "Pipeline re-run started.", status: :see_other
|
|
34
120
|
end
|
|
35
121
|
|
|
36
122
|
private
|
|
37
123
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
124
|
+
def build_rerun_pipeline(source)
|
|
125
|
+
pipeline_class = source.type.safe_constantize
|
|
126
|
+
unless pipeline_class.is_a?(Class) && pipeline_class < GoodPipeline::Pipeline
|
|
127
|
+
raise InvalidPipelineError, "#{source.type} is not an available GoodPipeline::Pipeline"
|
|
128
|
+
end
|
|
129
|
+
raise InvalidPipelineError, "stored pipeline parameters must be an object" unless source.params.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
pipeline_class.build(**source.params.symbolize_keys)
|
|
132
|
+
rescue ArgumentError, NameError, NotImplementedError, GoodPipeline::Error => error
|
|
133
|
+
Rails.error.report(error, handled: true, context: { good_pipeline_source_id: source.id })
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def require_dashboard_mutations_enabled!
|
|
138
|
+
head :forbidden unless GoodPipeline.dashboard_mutations_enabled?
|
|
46
139
|
end
|
|
47
140
|
|
|
48
|
-
def
|
|
49
|
-
|
|
141
|
+
def apply_type_time_search(scope)
|
|
142
|
+
scope = scope.where(type: @filters.pipeline_type) if @filters.pipeline_type
|
|
143
|
+
cutoff = @filters.time_cutoff(@now)
|
|
144
|
+
scope = scope.where("created_at >= ?", cutoff) if cutoff
|
|
145
|
+
return scope if @filters.query.blank?
|
|
50
146
|
|
|
147
|
+
escaped = ActiveRecord::Base.sanitize_sql_like(@filters.query.strip)
|
|
51
148
|
scope.where(
|
|
52
|
-
"(
|
|
53
|
-
|
|
54
|
-
|
|
149
|
+
"CAST(good_pipeline_pipelines.id AS text) ILIKE ? OR good_pipeline_pipelines.type ILIKE ?",
|
|
150
|
+
"#{escaped}%",
|
|
151
|
+
"%#{escaped}%"
|
|
55
152
|
)
|
|
56
153
|
end
|
|
154
|
+
|
|
155
|
+
def load_page_steps
|
|
156
|
+
page_ids = @pipelines.map(&:id)
|
|
157
|
+
steps = StepRecord.where(pipeline_id: page_ids).order(:created_at, :id).to_a
|
|
158
|
+
@steps_by_pipeline = steps.group_by(&:pipeline_id)
|
|
159
|
+
attach_steps(@pipelines, @steps_by_pipeline)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def load_expanded_row # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
163
|
+
@expanded_pipeline = @pipelines.find { |pipeline| pipeline.id.to_s == @filters.expanded.to_s }
|
|
164
|
+
return unless @expanded_pipeline
|
|
165
|
+
|
|
166
|
+
@expanded_steps = @steps_by_pipeline.fetch(@expanded_pipeline.id, [])
|
|
167
|
+
@expanded_dependencies = DependencyRecord.where(pipeline_id: @expanded_pipeline.id).order(:id).to_a
|
|
168
|
+
hydrate_dependencies(@expanded_dependencies, @expanded_steps)
|
|
169
|
+
@step_timings = Dashboard::StepTimings.new(@expanded_steps).call
|
|
170
|
+
@stage_lanes = Dashboard::StageLanes.new(
|
|
171
|
+
pipeline: @expanded_pipeline,
|
|
172
|
+
steps: @expanded_steps,
|
|
173
|
+
dependencies: @expanded_dependencies,
|
|
174
|
+
timings: @step_timings,
|
|
175
|
+
now: @now
|
|
176
|
+
).call
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def load_sidebar_entries # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
180
|
+
latest = PipelineRecord
|
|
181
|
+
.select(<<~SQL.squish)
|
|
182
|
+
DISTINCT ON (type)
|
|
183
|
+
type, id, on_failure_strategy,
|
|
184
|
+
COUNT(*) OVER (PARTITION BY type) AS run_count
|
|
185
|
+
SQL
|
|
186
|
+
.order(:type, created_at: :desc, id: :desc)
|
|
187
|
+
.to_a
|
|
188
|
+
|
|
189
|
+
ids = latest.map(&:id)
|
|
190
|
+
quoted_branch_class = StepRecord.connection.quote(GoodPipeline::BRANCH_JOB_CLASS)
|
|
191
|
+
metadata = StepRecord.where(pipeline_id: ids)
|
|
192
|
+
.group(:pipeline_id)
|
|
193
|
+
.pluck(
|
|
194
|
+
:pipeline_id,
|
|
195
|
+
Arel.sql("COUNT(*)"),
|
|
196
|
+
Arel.sql("BOOL_OR(job_class = #{quoted_branch_class})")
|
|
197
|
+
)
|
|
198
|
+
.to_h { |pipeline_id, count, has_branch| [pipeline_id, [count.to_i, has_branch]] }
|
|
199
|
+
|
|
200
|
+
latest.map do |pipeline|
|
|
201
|
+
step_count, has_branch = metadata.fetch(pipeline.id, [0, false])
|
|
202
|
+
SidebarEntry.new(
|
|
203
|
+
type: pipeline.type,
|
|
204
|
+
id: pipeline.id,
|
|
205
|
+
on_failure_strategy: pipeline.on_failure_strategy,
|
|
206
|
+
run_count: pipeline.attributes.fetch("run_count").to_i,
|
|
207
|
+
step_count: step_count,
|
|
208
|
+
has_branch: has_branch,
|
|
209
|
+
large: step_count > 60
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def load_graph_records(pipelines) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
215
|
+
pipeline_ids = pipelines.map(&:id)
|
|
216
|
+
steps = StepRecord.where(pipeline_id: pipeline_ids).order(:created_at, :id).to_a
|
|
217
|
+
dependencies = DependencyRecord.where(pipeline_id: pipeline_ids).order(:id).to_a
|
|
218
|
+
|
|
219
|
+
@steps_by_pipeline = steps.group_by(&:pipeline_id)
|
|
220
|
+
@dependencies_by_pipeline = dependencies.group_by(&:pipeline_id)
|
|
221
|
+
attach_steps(pipelines, @steps_by_pipeline)
|
|
222
|
+
|
|
223
|
+
pipelines.each do |pipeline|
|
|
224
|
+
pipeline_dependencies = @dependencies_by_pipeline.fetch(pipeline.id, [])
|
|
225
|
+
hydrate_dependencies(pipeline_dependencies, @steps_by_pipeline.fetch(pipeline.id, []))
|
|
226
|
+
association = pipeline.association(:dependencies)
|
|
227
|
+
association.target = pipeline_dependencies
|
|
228
|
+
association.loaded!
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def attach_steps(pipelines, steps_by_pipeline)
|
|
233
|
+
pipelines.each do |pipeline|
|
|
234
|
+
association = pipeline.association(:steps)
|
|
235
|
+
association.target = steps_by_pipeline.fetch(pipeline.id, [])
|
|
236
|
+
association.loaded!
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def hydrate_dependencies(dependencies, steps)
|
|
241
|
+
by_id = steps.index_by(&:id)
|
|
242
|
+
dependencies.each do |dependency|
|
|
243
|
+
step_association = dependency.association(:step)
|
|
244
|
+
step_association.target = by_id[dependency.step_id]
|
|
245
|
+
step_association.loaded!
|
|
246
|
+
upstream_association = dependency.association(:depends_on_step)
|
|
247
|
+
upstream_association.target = by_id[dependency.depends_on_step_id]
|
|
248
|
+
upstream_association.loaded!
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def load_chain_records # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
253
|
+
chains = ChainRecord.where(upstream_pipeline_id: @pipeline.id)
|
|
254
|
+
.or(ChainRecord.where(downstream_pipeline_id: @pipeline.id))
|
|
255
|
+
.to_a
|
|
256
|
+
other_ids = chains.flat_map { |chain| [chain.upstream_pipeline_id, chain.downstream_pipeline_id] }
|
|
257
|
+
.uniq
|
|
258
|
+
.excluding(@pipeline.id)
|
|
259
|
+
related = PipelineRecord.where(id: other_ids).index_by(&:id)
|
|
260
|
+
|
|
261
|
+
@upstream_pipelines = chains.filter_map do |chain|
|
|
262
|
+
related[chain.upstream_pipeline_id] if chain.downstream_pipeline_id == @pipeline.id
|
|
263
|
+
end
|
|
264
|
+
@downstream_pipelines = chains.filter_map do |chain|
|
|
265
|
+
related[chain.downstream_pipeline_id] if chain.upstream_pipeline_id == @pipeline.id
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
upstream = @pipeline.association(:upstream_pipelines)
|
|
269
|
+
upstream.target = @upstream_pipelines
|
|
270
|
+
upstream.loaded!
|
|
271
|
+
downstream = @pipeline.association(:downstream_pipelines)
|
|
272
|
+
downstream.target = @downstream_pipelines
|
|
273
|
+
downstream.loaded!
|
|
274
|
+
end
|
|
57
275
|
end
|
|
58
276
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodPipeline
|
|
4
|
+
class ThemesController < ApplicationController
|
|
5
|
+
THEMES = %w[dark light].freeze
|
|
6
|
+
|
|
7
|
+
def update
|
|
8
|
+
theme = params[:theme].to_s
|
|
9
|
+
return head :unprocessable_entity unless THEMES.include?(theme)
|
|
10
|
+
|
|
11
|
+
cookies.permanent[:good_pipeline_theme] = { value: theme, same_site: :lax }
|
|
12
|
+
head :no_content
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|