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,101 @@
1
+ <%
2
+ pipeline = local_assigns[:pipeline]
3
+ stages = Array(local_assigns[:stages])
4
+ definition = !!local_assigns[:definition]
5
+ edge_count = (local_assigns[:edge_count] || (respond_to?(:mermaid_edge_count) ? mermaid_edge_count(pipeline) : pipeline.dependencies.size)).to_i
6
+ step_count = Array(local_assigns[:steps] || pipeline.steps).size
7
+ overflow = edge_count > 1_000 || !!local_assigns[:overflow]
8
+ graph = if overflow
9
+ ""
10
+ elsif local_assigns.key?(:graph)
11
+ local_assigns[:graph].to_s
12
+ else
13
+ definition ? mermaid_definition_diagram(pipeline) : mermaid_diagram(pipeline)
14
+ end
15
+ large = step_count > 60
16
+ stage_first = large || overflow
17
+ max_stage_count = [stages.map { |stage| stage.respond_to?(:n) ? stage.n.to_i : (stage[:n] || stage["n"] || 1).to_i }.max.to_i, 1].max
18
+ get = lambda do |object, key|
19
+ if object.respond_to?(key)
20
+ object.public_send(key)
21
+ elsif object.respond_to?(:[])
22
+ object[key] || object[key.to_s]
23
+ end
24
+ rescue NameError, IndexError, KeyError, TypeError
25
+ nil
26
+ end
27
+ %>
28
+ <section class="gp-card">
29
+ <h3 class="gp-section-heading">
30
+ <span>dag</span>
31
+ <span><%= step_count %> steps · <%= edge_count %> edges</span>
32
+ <span class="gp-diagram-toolbar">
33
+ <% if overflow %>
34
+ <button class="gp-diagram-btn" type="button" disabled title="Full graph disabled because <%= edge_count %> edges exceeds Mermaid's 1,000-edge safety limit">render full graph</button>
35
+ <% elsif large %>
36
+ <button class="gp-diagram-btn" type="button" data-gp-graph-toggle aria-pressed="false">render full graph</button>
37
+ <% end %>
38
+ <button class="gp-diagram-btn gp-diagram-btn--icon" type="button" data-gp-diagram-action="in" title="Zoom in" aria-label="Zoom in">+</button>
39
+ <button class="gp-diagram-btn gp-diagram-btn--icon" type="button" data-gp-diagram-action="out" title="Zoom out" aria-label="Zoom out">−</button>
40
+ <button class="gp-diagram-btn" type="button" data-gp-diagram-action="reset" title="Reset zoom">reset</button>
41
+ <button class="gp-diagram-btn gp-diagram-btn--icon" type="button" data-gp-diagram-action="fullscreen" title="Fullscreen" aria-label="Fullscreen">⛶</button>
42
+ </span>
43
+ </h3>
44
+ <% if overflow %>
45
+ <p class="gp-edge-warning">Full graph disabled: <%= number_with_delimiter(edge_count) %> edges exceeds Mermaid’s 1,000-edge safety limit. The stage view remains available.</p>
46
+ <% end %>
47
+
48
+ <div class="gp-stage-panel" <%= "hidden" unless stage_first %>>
49
+ <% if definition %>
50
+ <div class="gp-timeline">
51
+ <% stages.each do |stage| %>
52
+ <% role = (get.call(stage, :role) || :step).to_s; count = (get.call(stage, :n) || 1).to_i; jobs = Array(get.call(stage, :job_classes)); label = get.call(stage, :label) || get.call(stage, :stage) %>
53
+ <div class="gp-lane">
54
+ <span class="gp-stage-level">L<%= get.call(stage, :level) || 0 %></span>
55
+ <span class="gp-lane-label" title="<%= label %>"><%= label %></span>
56
+ <span class="gp-stage-role gp-stage-role--<%= role %>" style="--gp-width:<%= (count.to_f / max_stage_count * 60).round(2) %>%"></span>
57
+ <span class="gp-lane-note" title="<%= jobs.join(', ') %>"><%= count > 1 ? "#{count} parallel shards" : (jobs.first || role) %></span>
58
+ </div>
59
+ <% end %>
60
+ </div>
61
+ <% else %>
62
+ <div class="gp-timeline">
63
+ <% status_order = %w[succeeded failed halted enqueued running pending canceled skipped skipped_by_branch] %>
64
+ <% stages.each do |stage| %>
65
+ <%
66
+ counts = get.call(stage, :counts) || {}
67
+ count = [(get.call(stage, :n) || counts.values.sum).to_i, 1].max
68
+ label = get.call(stage, :label) || get.call(stage, :stage)
69
+ notes = status_order.filter_map do |stage_status|
70
+ value = counts[stage_status] || counts[stage_status.to_sym]
71
+ "#{value} #{stage_status.sub('_by_branch', '·br')}" if value.to_i.positive?
72
+ end
73
+ %>
74
+ <div class="gp-lane">
75
+ <span class="gp-stage-level">L<%= get.call(stage, :level) || 0 %></span>
76
+ <span class="gp-lane-label" title="<%= label %>"><%= label %></span>
77
+ <span class="gp-stage-stack">
78
+ <% status_order.each do |stage_status| %>
79
+ <% value = (counts[stage_status] || counts[stage_status.to_sym]).to_i; next if value.zero?; color_status = stage_status == "enqueued" ? "running" : stage_status.start_with?("skipped") ? "skipped" : stage_status; opacity = stage_status == "pending" ? 0.3 : (stage_status.start_with?("skipped") ? 0.45 : 1) %>
80
+ <span style="--gp-width:<%= (value.to_f / count * 100).round(2) %>%;--gp-status:var(--st-<%= color_status %>);--gp-opacity:<%= opacity %>" title="<%= value %> <%= stage_status %>"></span>
81
+ <% end %>
82
+ </span>
83
+ <span class="gp-lane-note" title="<%= notes.join(', ') %>"><%= notes.join(", ") %></span>
84
+ </div>
85
+ <% end %>
86
+ </div>
87
+ <% end %>
88
+ </div>
89
+
90
+ <div class="gp-graph-panel" <%= "hidden" if stage_first %>>
91
+ <div class="gp-diagram-container"
92
+ <% unless overflow %>data-graph="<%= h(graph.to_json) %>"<% end %>
93
+ data-graph-kind="<%= definition ? 'definition' : 'execution' %>"
94
+ data-edge-count="<%= edge_count %>">
95
+ <div class="gp-diagram-viewport">
96
+ <div class="gp-diagram-canvas"><div class="gp-diagram-fallback">Rendering graph…</div></div>
97
+ </div>
98
+ <noscript><div class="gp-diagram-fallback">JavaScript is required to render this pipeline graph.</div></noscript>
99
+ </div>
100
+ </div>
101
+ </section>
@@ -0,0 +1,51 @@
1
+ <%
2
+ filters = local_assigns[:filters] || @filters
3
+ status = filters.respond_to?(:status) ? filters.status : (params[:status].presence || "all")
4
+ time = filters.respond_to?(:time) ? filters.time : (params[:time].presence || "all")
5
+ query = filters.respond_to?(:query) ? filters.query : params[:q].to_s
6
+ base = request.query_parameters.except("page", "expanded")
7
+ status_counts = @status_counts || {}
8
+ %>
9
+ <div class="gp-filter-bar">
10
+ <form class="gp-search-form" action="<%= pipelines_path %>" method="get" role="search" data-gp-search-form>
11
+ <label class="gp-search" for="gp-search">
12
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM11 11l3 3"></path></svg>
13
+ <span class="gp-sr-only">Search pipeline executions</span>
14
+ <input id="gp-search" type="search" name="q" value="<%= query %>" placeholder="search execution id or pipeline type…" autocomplete="off">
15
+ </label>
16
+ </form>
17
+
18
+ <div id="gp-filters">
19
+ <div class="gp-status-filter-scroll">
20
+ <div class="gp-segment" role="group" aria-label="Status filter">
21
+ <span class="gp-segment-label">status</span>
22
+ <% %w[all running canceling succeeded failed halted canceled skipped].each do |value| %>
23
+ <%
24
+ href_params = base.merge("status" => value).except("page", "expanded")
25
+ count = value == "all" ? status_counts.values.sum : status_counts.fetch(value, 0)
26
+ %>
27
+ <%= link_to pipelines_path(href_params),
28
+ class: "gp-segment-link #{'is-active' if status == value}",
29
+ aria: ({ current: "page" } if status == value),
30
+ data: { gp_partial_link: true, gp_segment: "status", gp_segment_value: value,
31
+ gp_status_count: count } do %>
32
+ <span><%= value %></span><span class="gp-segment-count"><%= format_number(count) %></span>
33
+ <% end %>
34
+ <% end %>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="gp-segment" role="group" aria-label="Time filter">
39
+ <span class="gp-segment-label">time</span>
40
+ <% %w[24h 7d 30d all].each do |value| %>
41
+ <% href_params = base.merge("time" => value).except("page", "expanded") %>
42
+ <%= link_to value, pipelines_path(href_params),
43
+ class: "gp-segment-link #{'is-active' if time == value}",
44
+ aria: ({ current: "page" } if time == value),
45
+ data: { gp_partial_link: true, gp_segment: "time", gp_segment_value: value } %>
46
+ <% end %>
47
+ </div>
48
+
49
+ <%= link_to "clear", pipelines_path, class: "gp-filter-clear" %>
50
+ </div>
51
+ </div>
@@ -0,0 +1,74 @@
1
+ <%
2
+ kpis = local_assigns[:kpis] || @kpis
3
+ total = (local_assigns[:total] || @total || 0).to_i
4
+ get = lambda do |key, fallback = nil|
5
+ if kpis.respond_to?(key)
6
+ kpis.public_send(key)
7
+ elsif kpis.respond_to?(:[])
8
+ kpis[key] || kpis[key.to_s] || fallback
9
+ else
10
+ fallback
11
+ end
12
+ rescue NameError, IndexError, KeyError, TypeError
13
+ fallback
14
+ end
15
+ duration = lambda do |seconds|
16
+ next "—" if seconds.nil?
17
+ value = seconds.to_f.to_i
18
+ if value < 60
19
+ "#{value}s"
20
+ elsif value < 3_600
21
+ "#{value / 60}m #{value % 60}s"
22
+ else
23
+ "#{value / 3_600}h #{(value % 3_600) / 60}m"
24
+ end
25
+ end
26
+ failed = get.call(:failed_7d, 0).to_i
27
+ prior = get.call(:failed_prior_7d, 0).to_i
28
+ comparison = if prior.zero?
29
+ "—"
30
+ else
31
+ pct = ((failed - prior).to_f / prior * 100).round
32
+ "#{pct.positive? ? '+' : ''}#{pct}% vs prior"
33
+ end
34
+ sparkline_value = get.call(:sparkline)
35
+ sparkline_value = get.call(:buckets, []) if sparkline_value.nil?
36
+ sparkline = Array(sparkline_value).last(14)
37
+ sparkline = Array.new(14 - sparkline.length, 0) + sparkline if sparkline.length < 14
38
+ spark_max = [sparkline.map(&:to_i).max.to_i, 1].max
39
+ %>
40
+ <div class="gp-kpi-strip">
41
+ <div class="gp-kpi">
42
+ <div class="gp-kpi-label">filtered rows</div>
43
+ <div class="gp-kpi-value"><%= number_with_delimiter(total) %></div>
44
+ <div class="gp-kpi-sub">selected segment</div>
45
+ </div>
46
+ <div class="gp-kpi">
47
+ <div class="gp-kpi-label">running now</div>
48
+ <div class="gp-kpi-value gp-kpi-value--running"><%= number_with_delimiter(get.call(:running_now, 0)) %></div>
49
+ <div class="gp-kpi-sub"><%= number_with_delimiter(get.call(:enqueued_steps, 0)) %> enqueued steps</div>
50
+ </div>
51
+ <div class="gp-kpi">
52
+ <div class="gp-kpi-label">last 24h</div>
53
+ <div class="gp-kpi-value"><%= number_with_delimiter(get.call(:last_24h, 0)) %></div>
54
+ <div class="gp-kpi-sub">executions</div>
55
+ </div>
56
+ <div class="gp-kpi">
57
+ <div class="gp-kpi-label">failed · 7d</div>
58
+ <div class="gp-kpi-value gp-kpi-value--failed"><%= number_with_delimiter(failed) %></div>
59
+ <div class="gp-kpi-sub"><%= comparison %></div>
60
+ </div>
61
+ <div class="gp-kpi">
62
+ <div class="gp-kpi-label">p50 duration</div>
63
+ <div class="gp-kpi-value"><%= duration.call(get.call(:p50)) %></div>
64
+ <div class="gp-kpi-sub">p95 <%= duration.call(get.call(:p95)) %></div>
65
+ </div>
66
+ <div class="gp-kpi">
67
+ <div class="gp-kpi-label">executions · 14d</div>
68
+ <div class="gp-spark" aria-label="Execution counts for the last 14 days">
69
+ <% sparkline.each_with_index do |bucket, index| %>
70
+ <span class="gp-spark-bar" style="--gp-height:<%= (bucket.to_f / spark_max * 100).round(2) %>%" title="<%= bucket %> on day -<%= 13 - index %>"></span>
71
+ <% end %>
72
+ </div>
73
+ </div>
74
+ </div>
@@ -0,0 +1,31 @@
1
+ <%
2
+ page = local_assigns[:page] || @page || {}
3
+ get = ->(key, default) { page.respond_to?(key) ? page.public_send(key) : (page[key] || page[key.to_s] || default) }
4
+ current = get.call(:current, 1).to_i
5
+ last = [get.call(:last, 1).to_i, 1].max
6
+ per_page = get.call(:per_page, 25).to_i
7
+ total = get.call(:total, @total || 0).to_i
8
+ from = total.zero? ? 0 : ((current - 1) * per_page) + 1
9
+ to = [current * per_page, total].min
10
+ base = request.query_parameters.except("expanded", "page")
11
+ %>
12
+ <div class="gp-pager">
13
+ <span class="gp-pager-info"><span class="mono"><%= number_with_delimiter(from) %></span>–<span class="mono"><%= number_with_delimiter(to) %></span><span> of </span><span class="mono"><%= number_with_delimiter(total) %></span></span>
14
+ <span class="gp-pager-buttons">
15
+ <% if current <= 1 %>
16
+ <button class="gp-page-button" type="button" disabled aria-label="First page">«</button>
17
+ <button class="gp-page-button" type="button" disabled aria-label="Previous page">‹</button>
18
+ <% else %>
19
+ <%= link_to "«", pipelines_path(base.merge("page" => 1)), class: "gp-page-button", aria: { label: "First page" }, data: { gp_partial_link: true } %>
20
+ <%= link_to "‹", pipelines_path(base.merge("page" => current - 1)), class: "gp-page-button", aria: { label: "Previous page" }, data: { gp_partial_link: true } %>
21
+ <% end %>
22
+ <span class="gp-page-label">page <span class="mono"><%= current %></span> / <span class="mono"><%= last %></span></span>
23
+ <% if current >= last %>
24
+ <button class="gp-page-button" type="button" disabled aria-label="Next page">›</button>
25
+ <button class="gp-page-button" type="button" disabled aria-label="Last page">»</button>
26
+ <% else %>
27
+ <%= link_to "›", pipelines_path(base.merge("page" => current + 1)), class: "gp-page-button", aria: { label: "Next page" }, data: { gp_partial_link: true } %>
28
+ <%= link_to "»", pipelines_path(base.merge("page" => last)), class: "gp-page-button", aria: { label: "Last page" }, data: { gp_partial_link: true } %>
29
+ <% end %>
30
+ </span>
31
+ </div>
@@ -0,0 +1,103 @@
1
+ <%
2
+ now = @now || Time.current
3
+ expanded_id = filters.respond_to?(:expanded) ? filters.expanded : params[:expanded].presence
4
+ is_open = expanded_id.to_s == pipeline.id.to_s
5
+ status = pipeline.status.to_s
6
+ status_meta = {
7
+ "pending" => ["○", "pending", "pending"],
8
+ "running" => ["●", "running", "running"],
9
+ "enqueued" => ["●", "enqueued", "running"],
10
+ "canceling" => ["◐", "canceling", "canceling"],
11
+ "canceled" => ["⊘", "canceled", "canceled"],
12
+ "succeeded" => ["✓", "succeeded", "succeeded"],
13
+ "failed" => ["✗", "failed", "failed"],
14
+ "halted" => ["⊘", "halted", "halted"],
15
+ "skipped" => ["⊘", "skipped", "skipped"],
16
+ "skipped_by_branch" => ["⊘", "skipped·br", "skipped"]
17
+ }.freeze
18
+ glyph, status_label, status_color = status_meta.fetch(status, ["○", status, "pending"])
19
+ short = respond_to?(:short_type) ? short_type(pipeline.type) : humanized_type(pipeline.type).sub(/\s*Pipeline\z/, "")
20
+ relative = lambda do |time|
21
+ seconds = [(now - time).to_i, 0].max
22
+ if seconds < 60 then "#{seconds}s ago"
23
+ elsif seconds < 3_600 then "#{seconds / 60}m ago"
24
+ elsif seconds < 86_400 then "#{seconds / 3_600}h ago"
25
+ else "#{seconds / 86_400}d ago"
26
+ end
27
+ end
28
+ duration = lambda do |seconds|
29
+ next "—" if seconds.nil?
30
+ value = seconds.to_f.to_i
31
+ if value < 60 then "#{value}s"
32
+ elsif value < 3_600 then "#{value / 60}m #{value % 60}s"
33
+ else "#{value / 3_600}h #{(value % 3_600) / 60}m"
34
+ end
35
+ end
36
+ elapsed = ((pipeline.terminal? ? pipeline.updated_at : now) - pipeline.created_at).to_f
37
+ counts = steps.each_with_object(Hash.new(0)) { |step, memo| memo[step.coordination_status.to_s] += 1 }
38
+ done = counts["succeeded"]
39
+ many = steps.length > 12
40
+ stack_order = %w[succeeded failed halted enqueued running pending canceled skipped skipped_by_branch]
41
+ status_var = ->(value) { value == "enqueued" ? "running" : value.start_with?("skipped") ? "skipped" : value }
42
+ params_preview = pipeline.params.to_h.map { |key, value| "#{key}=#{value.to_json}" }.join(" · ")
43
+ href_params = request.query_parameters.except("expanded")
44
+ href_params["expanded"] = pipeline.id.to_s unless is_open
45
+ expand_url = pipelines_path(href_params)
46
+ %>
47
+ <tr class="gp-row <%= 'is-open' if is_open %>"
48
+ data-gp-expand-url="<%= expand_url %>"
49
+ data-id="<%= pipeline.id %>"
50
+ <% if is_open %>data-collapse-expand<% end %>>
51
+ <td class="gp-td-expand">
52
+ <%= link_to(is_open ? "▾" : "▸", expand_url,
53
+ aria: { label: is_open ? "Collapse execution" : "Expand execution", expanded: is_open },
54
+ data: { gp_partial_link: true }) %>
55
+ </td>
56
+ <td>
57
+ <div class="gp-when-rel"><%= relative.call(pipeline.created_at) %></div>
58
+ <time class="gp-when-abs" datetime="<%= pipeline.created_at.utc.iso8601 %>"><%= pipeline.created_at.utc.strftime("%Y-%m-%d %H:%M:%SZ") %></time>
59
+ </td>
60
+ <td>
61
+ <span class="gp-execution-cell">
62
+ <span class="gp-execution-name"><%= short %></span>
63
+ <span class="gp-execution-id">#<%= pipeline.id.to_s.first(8) %></span>
64
+ <button class="gp-copy" type="button" title="Copy execution id" data-gp-copy="<%= pipeline.id %>" data-gp-copy-label="execution id">
65
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5"></rect><path d="M11 5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h2"></path></svg>
66
+ </button>
67
+ <%= link_to pipeline_path(pipeline), class: "gp-open-detail", title: "Open detail page", aria: { label: "Open execution detail" } do %>
68
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 3.5h3.5V7"></path><path d="M12.5 3.5 7.5 8.5"></path><path d="M12 10v2a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 2 12V5a1.5 1.5 0 0 1 1.5-1.5H6"></path></svg>
69
+ <% end %>
70
+ </span>
71
+ </td>
72
+ <td title="<%= done %> succeeded steps of <%= steps.length %> total steps">
73
+ <% if many %>
74
+ <span class="gp-step-stack" aria-hidden="true">
75
+ <% stack_order.each do |step_status| %>
76
+ <% next if counts[step_status].zero? %>
77
+ <% opacity = step_status == "pending" ? 0.3 : (step_status.start_with?("skipped") ? 0.45 : 1) %>
78
+ <span style="--gp-width:<%= (counts[step_status].to_f / steps.length * 100).round(2) %>%;--gp-status:var(--st-<%= status_var.call(step_status) %>);--gp-opacity:<%= opacity %>" title="<%= counts[step_status] %> <%= step_status %>"></span>
79
+ <% end %>
80
+ </span>
81
+ <% else %>
82
+ <span class="gp-step-dots" aria-hidden="true">
83
+ <% steps.each do |step| %>
84
+ <% step_status = step.coordination_status.to_s; branch = step.respond_to?(:branch_step?) ? step.branch_step? : step.job_class == GoodPipeline::BRANCH_JOB_CLASS; barrier = step.respond_to?(:barrier_step?) ? step.barrier_step? : step.job_class == GoodPipeline::BARRIER_JOB_CLASS %>
85
+ <span class="gp-step-dot <%= 'is-branch' if branch %> <%= 'is-barrier' if barrier %>" style="--gp-status:var(--st-<%= status_var.call(step_status) %>);--gp-opacity:<%= step_status == 'pending' ? 0.35 : 1 %>" title="<%= step_display_key(step) %>: <%= step_status %>"></span>
86
+ <% end %>
87
+ </span>
88
+ <% end %>
89
+ <span class="gp-step-summary"><%= done %>/<%= steps.length %></span>
90
+ </td>
91
+ <td><span class="gp-status gp-status--<%= status %>"><%= glyph %> <%= status_label %></span></td>
92
+ <td class="mono"><%= duration.call(elapsed) %></td>
93
+ <td><span class="gp-params-preview"><%= params_preview %></span></td>
94
+ </tr>
95
+ <% if is_open %>
96
+ <tr class="gp-row-detail">
97
+ <td colspan="7">
98
+ <%= render "row_detail", pipeline: pipeline, steps: steps,
99
+ timings: (@step_timings || {}), lanes: (@stage_lanes || []),
100
+ dependencies: (@expanded_dependencies || []) %>
101
+ </td>
102
+ </tr>
103
+ <% end %>
@@ -0,0 +1,93 @@
1
+ <%
2
+ get = lambda do |object, key|
3
+ if object.respond_to?(key)
4
+ object.public_send(key)
5
+ elsif object.respond_to?(:[])
6
+ object[key] || object[key.to_s]
7
+ end
8
+ rescue NameError, IndexError, KeyError, TypeError
9
+ nil
10
+ end
11
+ status_meta = {
12
+ "pending" => ["○", "pending", "pending"], "running" => ["●", "running", "running"],
13
+ "enqueued" => ["●", "enqueued", "running"], "succeeded" => ["✓", "succeeded", "succeeded"],
14
+ "failed" => ["✗", "failed", "failed"], "halted" => ["⊘", "halted", "halted"],
15
+ "canceled" => ["⊘", "canceled", "canceled"], "skipped" => ["⊘", "skipped", "skipped"],
16
+ "skipped_by_branch" => ["⊘", "skipped·br", "skipped"]
17
+ }.freeze
18
+ duration = lambda do |seconds|
19
+ next "—" if seconds.nil?
20
+ value = seconds.to_f.to_i
21
+ if value < 60 then "#{value}s"
22
+ elsif value < 3_600 then "#{value / 60}m #{value % 60}s"
23
+ else "#{value / 3_600}h #{(value % 3_600) / 60}m"
24
+ end
25
+ end
26
+ timing_for = lambda do |step|
27
+ timings[step.good_job_id] || timings[step.good_job_id.to_s] || timings[step.id] || timings[step.id.to_s]
28
+ end
29
+ timing_parts = lambda do |timing|
30
+ if timing.is_a?(Array)
31
+ timing
32
+ else
33
+ [get.call(timing, :performed_at) || get.call(timing, :started_at), get.call(timing, :finished_at)]
34
+ end
35
+ end
36
+ priority = { "failed" => 0, "halted" => 1, "enqueued" => 2, "running" => 2, "pending" => 3, "canceled" => 4 }.freeze
37
+ table_steps = steps.length > 12 ? steps.sort_by { |step| [priority.fetch(step.coordination_status.to_s, 9), step.created_at, step.id.to_s] } : steps
38
+ done = steps.count { |step| step.coordination_status.to_s == "succeeded" }
39
+ total_duration = ((pipeline.terminal? ? pipeline.updated_at : (@now || Time.current)) - pipeline.created_at).to_f
40
+ ruby_snippet = %(GoodPipeline::PipelineRecord.find("#{pipeline.id}"))
41
+ sql_snippet = "SELECT *\nFROM good_pipeline_pipelines\nWHERE id = '#{pipeline.id}';"
42
+ %>
43
+ <div class="gp-row-detail-inner">
44
+ <div class="gp-detail-grid">
45
+ <section class="gp-detail-section">
46
+ <h3 class="gp-section-heading">steps <span class="gp-heading-meta"><%= steps.length > 12 ? "#{steps.length} steps · failures & active first" : "#{done}/#{steps.length}" %></span></h3>
47
+ <div class="gp-scroll-steps">
48
+ <table class="gp-compact-table">
49
+ <tbody>
50
+ <% table_steps.each do |step| %>
51
+ <% step_status = step.coordination_status.to_s; glyph, label, = status_meta.fetch(step_status, ["○", step_status, "pending"]); timing = timing_for.call(step); started_at, finished_at = timing_parts.call(timing); step_duration = started_at && finished_at ? finished_at - started_at : nil %>
52
+ <tr>
53
+ <td class="gp-step-key"><%= step_display_key(step) %></td>
54
+ <td class="gp-job-class" title="<%= step.job_class %>"><%= step.job_class %></td>
55
+ <td><span class="gp-status gp-status--<%= step_status %>"><%= glyph %> <%= label %></span></td>
56
+ <td class="gp-step-duration"><%= duration.call(step_duration) %></td>
57
+ </tr>
58
+ <% end %>
59
+ </tbody>
60
+ </table>
61
+ </div>
62
+ </section>
63
+
64
+ <section class="gp-detail-section">
65
+ <h3 class="gp-section-heading">params · identity <span class="gp-strategy gp-strategy--<%= pipeline.on_failure_strategy %>">on_failure: <%= pipeline.on_failure_strategy %></span></h3>
66
+ <table class="gp-kv"><tbody>
67
+ <tr><td class="gp-kv-key">id</td><td class="gp-kv-value"><%= pipeline.id %></td></tr>
68
+ <tr><td class="gp-kv-key">type</td><td class="gp-kv-value"><%= pipeline.type %></td></tr>
69
+ <tr><td class="gp-kv-key">params</td><td class="gp-kv-value"><%= pipeline.params.to_json %></td></tr>
70
+ <tr><td class="gp-kv-key">created_at</td><td class="gp-kv-value"><%= pipeline.created_at.utc.strftime("%Y-%m-%d %H:%M:%SZ") %></td></tr>
71
+ <tr><td class="gp-kv-key">status</td><td class="gp-kv-value"><%= pipeline.status %></td></tr>
72
+ </tbody></table>
73
+ <%= render "actions", pipeline: pipeline, detail_link: true %>
74
+ </section>
75
+
76
+ <section class="gp-detail-section gp-detail-wide">
77
+ <h3 class="gp-section-heading"><%= steps.length > 12 ? "stage timeline (#{lanes.length} stages · #{steps.length} steps)" : "step timeline" %><span class="gp-heading-meta">total <%= duration.call(total_duration) %></span></h3>
78
+ <%= render "timeline", lanes: lanes %>
79
+ </section>
80
+
81
+ <section class="gp-detail-section gp-detail-wide">
82
+ <h3 class="gp-section-heading">requery</h3>
83
+ <div class="gp-code-wrap">
84
+ <button class="gp-copy" type="button" title="Copy Ruby snippet" data-gp-copy="<%= ruby_snippet %>" data-gp-copy-label="Ruby snippet"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5"></rect><path d="M11 5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h2"></path></svg></button>
85
+ <pre class="gp-code"><%= ruby_snippet %></pre>
86
+ </div>
87
+ <div class="gp-code-wrap">
88
+ <button class="gp-copy" type="button" title="Copy SQL snippet" data-gp-copy="<%= sql_snippet %>" data-gp-copy-label="SQL snippet"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5"></rect><path d="M11 5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h2"></path></svg></button>
89
+ <pre class="gp-code"><%= sql_snippet %></pre>
90
+ </div>
91
+ </section>
92
+ </div>
93
+ </div>
@@ -0,0 +1,115 @@
1
+ <%
2
+ entries = Array(local_assigns[:entries] || @sidebar_entries)
3
+ filters = local_assigns[:filters] || @filters
4
+ mode = local_assigns.fetch(:mode, :executions)
5
+ get = lambda do |object, key|
6
+ if object.respond_to?(key)
7
+ object.public_send(key)
8
+ elsif object.respond_to?(:[])
9
+ object[key] || object[key.to_s]
10
+ end
11
+ rescue NameError, IndexError, KeyError, TypeError
12
+ nil
13
+ end
14
+ current_type = filters.respond_to?(:pipeline_type) ? filters.pipeline_type : params[:pipeline_type].presence
15
+ counts = entries.map { |entry| (get.call(entry, mode == :definitions ? :step_count : :run_count) || get.call(entry, :count) || 0).to_i }
16
+ max_count = [counts.max.to_i, 1].max
17
+ total_runs = entries.sum { |entry| (get.call(entry, :run_count) || get.call(entry, :count) || 0).to_i }
18
+ base_params = request.query_parameters.except("pipeline_type", "page", "expanded")
19
+ retention_seconds = local_assigns[:retention_seconds] || @retention_seconds
20
+ retention_seconds ||= if defined?(GoodJob) && GoodJob.respond_to?(:configuration)
21
+ GoodJob.configuration.cleanup_preserved_jobs_before_seconds_ago
22
+ end
23
+ retention_label = if retention_seconds
24
+ seconds = retention_seconds.to_i
25
+ if seconds >= 86_400 && (seconds % 86_400).zero?
26
+ "#{seconds / 86_400}d"
27
+ elsif seconds >= 3_600 && (seconds % 3_600).zero?
28
+ "#{seconds / 3_600}h"
29
+ else
30
+ "#{seconds}s"
31
+ end
32
+ else
33
+ "configured"
34
+ end
35
+ %>
36
+ <aside class="gp-sidebar">
37
+ <div class="gp-sidebar-head">
38
+ <span class="gp-brand">
39
+ <span class="gp-brand-mark" aria-hidden="true">gp</span>
40
+ <span class="gp-brand-name">good_pipeline</span>
41
+ <span class="gp-brand-version">v<%= GoodPipeline::VERSION %></span>
42
+ </span>
43
+ </div>
44
+
45
+ <% if mode == :definitions %>
46
+ <%= link_to pipelines_path, class: "gp-sidebar-row gp-sidebar-all" do %>
47
+ <span class="gp-sidebar-label"><span aria-hidden="true">←</span> All executions</span>
48
+ <span class="gp-sidebar-count"><%= number_with_delimiter(total_runs) %></span>
49
+ <% end %>
50
+ <% else %>
51
+ <%= link_to pipelines_path(base_params), class: "gp-sidebar-row gp-sidebar-all #{'is-active' if current_type.blank?}", aria: ({ current: "page" } if current_type.blank?) do %>
52
+ <span class="gp-sidebar-label"><span class="gp-sidebar-dot"></span>All executions</span>
53
+ <span class="gp-sidebar-count"><%= number_with_delimiter(total_runs) %></span>
54
+ <% end %>
55
+ <% end %>
56
+
57
+ <div class="gp-sidebar-scroll">
58
+ <div class="gp-sidebar-group">
59
+ <div class="gp-sidebar-group-head">
60
+ <span><%= mode == :definitions ? "definitions" : "pipelines" %></span>
61
+ <span class="gp-sidebar-group-count"><%= entries.length %></span>
62
+ </div>
63
+ <% entries.each_with_index do |entry, index| %>
64
+ <%
65
+ type = get.call(entry, :type).to_s
66
+ strategy = (get.call(entry, :on_failure_strategy) || get.call(entry, :strategy) || "halt").to_s
67
+ count = (get.call(entry, mode == :definitions ? :step_count : :run_count) || get.call(entry, :count) || 0).to_i
68
+ step_count = (get.call(entry, :step_count) || 0).to_i
69
+ has_branch = !!get.call(entry, :has_branch)
70
+ short = respond_to?(:short_type) ? short_type(type) : humanized_type(type).sub(/\s*Pipeline\z/, "")
71
+ %>
72
+ <% if mode == :definitions %>
73
+ <button type="button"
74
+ class="gp-sidebar-row gp-definition-item <%= 'is-active' if index.zero? %>"
75
+ data-gp-definition-target="gp-definition-<%= index %>"
76
+ data-gp-definition-name="<%= short %>"
77
+ data-gp-definition-meta="<%= step_count %> steps · <%= strategy %>">
78
+ <span class="gp-sidebar-bar" style="--gp-width:<%= (count.to_f / max_count * 100).round(2) %>%"></span>
79
+ <span class="gp-sidebar-label">
80
+ <span class="gp-sidebar-dot strategy-<%= strategy %>" title="on_failure: <%= strategy %>"></span>
81
+ <span class="gp-sidebar-name"><%= short %></span>
82
+ <% if has_branch %><span class="gp-sidebar-tag" title="has conditional branch">br</span><% end %>
83
+ <% if step_count > 60 %><span class="gp-sidebar-tag gp-sidebar-tag--xl" title="<%= step_count %> steps">xl</span><% end %>
84
+ </span>
85
+ <span class="gp-sidebar-count"><%= number_with_delimiter(count) %></span>
86
+ </button>
87
+ <% else %>
88
+ <% active = current_type == type %>
89
+ <%= link_to pipelines_path(base_params.merge("pipeline_type" => type)), class: "gp-sidebar-row #{'is-active' if active}", aria: ({ current: "page" } if active) do %>
90
+ <span class="gp-sidebar-bar" style="--gp-width:<%= (count.to_f / max_count * 100).round(2) %>%"></span>
91
+ <span class="gp-sidebar-label">
92
+ <span class="gp-sidebar-dot strategy-<%= strategy %>" title="on_failure: <%= strategy %>"></span>
93
+ <span class="gp-sidebar-name"><%= short %></span>
94
+ <% if has_branch %><span class="gp-sidebar-tag" title="has conditional branch">br</span><% end %>
95
+ <% if step_count > 60 %><span class="gp-sidebar-tag gp-sidebar-tag--xl" title="<%= step_count %> steps">xl</span><% end %>
96
+ </span>
97
+ <span class="gp-sidebar-count"><%= number_with_delimiter(count) %></span>
98
+ <% end %>
99
+ <% end %>
100
+ <% end %>
101
+ </div>
102
+ </div>
103
+
104
+ <div class="gp-sidebar-foot">
105
+ <div class="gp-legend">
106
+ <span><i class="strategy-halt" style="background:var(--strat-halt)"></i> halt</span>
107
+ <span><i class="strategy-continue" style="background:var(--strat-continue)"></i> continue</span>
108
+ <span><i class="strategy-ignore" style="background:var(--strat-ignore)"></i> ignore</span>
109
+ </div>
110
+ <div class="gp-retention" title="Terminal pipelines are pruned with GoodJob's cleanup cycle">
111
+ <span class="gp-retention-label"><%= mode == :definitions ? "column" : "retention" %></span>
112
+ <span class="gp-retention-value"><%= mode == :definitions ? "steps per definition" : "#{retention_label} · goodjob" %></span>
113
+ </div>
114
+ </div>
115
+ </aside>