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
@@ -1,33 +1,46 @@
1
- <header><h3>Steps</h3></header>
2
- <table>
3
- <thead>
4
- <tr>
5
- <th>Key</th>
6
- <th>Job Class</th>
7
- <th>Status</th>
8
- <th>Duration</th>
9
- <th>Error</th>
10
- </tr>
11
- </thead>
12
- <tbody>
13
- <% pipeline.steps.each do |step| %>
14
- <tr>
15
- <td>
16
- <% if (url = good_job_step_url(step)) %>
17
- <%= link_to step.key, url, target: "_blank", rel: "noopener" %>
18
- <% else %>
19
- <%= step.key %>
20
- <% end %>
21
- </td>
22
- <td><%= step.job_class %></td>
23
- <td><%= status_badge(step.coordination_status) %></td>
24
- <td><%= step.duration ? "#{step.duration.round(2)}s" : "\u2014" %></td>
25
- <td>
26
- <% if step.error_class %>
27
- <small><%= step.error_class %>: <%= step.error_message %></small>
28
- <% end %>
29
- </td>
30
- </tr>
31
- <% end %>
32
- </tbody>
33
- </table>
1
+ <%
2
+ steps = Array(local_assigns[:steps] || pipeline.steps)
3
+ timings = local_assigns[:timings] || {}
4
+ status_meta = {
5
+ "pending" => ["○", "pending"], "running" => ["●", "running"], "enqueued" => ["●", "enqueued"],
6
+ "succeeded" => ["✓", "succeeded"], "failed" => ["✗", "failed"], "halted" => ["⊘", "halted"],
7
+ "canceled" => ["⊘", "canceled"], "skipped" => ["⊘", "skipped"],
8
+ "skipped_by_branch" => ["⊘", "skipped·br"]
9
+ }.freeze
10
+ duration = lambda do |seconds|
11
+ next "—" if seconds.nil?
12
+ value = seconds.to_f.to_i
13
+ if value < 60 then "#{value}s"
14
+ elsif value < 3_600 then "#{value / 60}m #{value % 60}s"
15
+ else "#{value / 3_600}h #{(value % 3_600) / 60}m"
16
+ end
17
+ end
18
+ priority = { "failed" => 0, "halted" => 1, "enqueued" => 2, "running" => 2, "pending" => 3, "canceled" => 4 }.freeze
19
+ ordered_steps = steps.length > 12 ? steps.sort_by { |step| [priority.fetch(step.coordination_status.to_s, 9), step.created_at, step.id.to_s] } : steps
20
+ %>
21
+ <h3 class="gp-section-heading">steps <span class="gp-heading-meta"><%= steps.length %> total</span></h3>
22
+ <div class="gp-scroll-steps">
23
+ <table class="gp-compact-table">
24
+ <thead class="gp-sr-only"><tr><th>Key</th><th>Job class</th><th>Status</th><th>Duration</th><th>Error</th></tr></thead>
25
+ <tbody>
26
+ <% ordered_steps.each do |step| %>
27
+ <%
28
+ step_status = step.coordination_status.to_s
29
+ glyph, label = status_meta.fetch(step_status, ["○", step_status])
30
+ timing = timings[step.good_job_id] || timings[step.good_job_id.to_s] || timings[step.id] || timings[step.id.to_s]
31
+ performed_at, finished_at = timing.is_a?(Array) ? timing : [timing&.respond_to?(:performed_at) && timing.performed_at, timing&.respond_to?(:finished_at) && timing.finished_at]
32
+ step_duration = performed_at && finished_at ? finished_at - performed_at : nil
33
+ error_text = [step.error_class, step.error_message].compact.join(": ")
34
+ good_job_url = timing.present? ? good_job_step_url(step) : nil
35
+ %>
36
+ <tr>
37
+ <td class="gp-step-key"><% if good_job_url %><%= link_to step_display_key(step), good_job_url, target: "_blank", rel: "noopener" %><% else %><%= step_display_key(step) %><% end %></td>
38
+ <td class="gp-job-class" title="<%= step.job_class %>"><%= step.job_class %></td>
39
+ <td><span class="gp-status gp-status--<%= step_status %>"><%= glyph %> <%= label %></span></td>
40
+ <td class="gp-step-duration"><%= duration.call(step_duration) %></td>
41
+ <td class="gp-error" title="<%= error_text %>"><%= error_text %></td>
42
+ </tr>
43
+ <% end %>
44
+ </tbody>
45
+ </table>
46
+ </div>
@@ -0,0 +1,33 @@
1
+ <%
2
+ pipelines = Array(local_assigns[:pipelines] || @pipelines)
3
+ filters = local_assigns[:filters] || @filters
4
+ steps_by_pipeline = local_assigns[:steps_by_pipeline] || @steps_by_pipeline || {}
5
+ %>
6
+ <div class="gp-table-wrap">
7
+ <table class="gp-table">
8
+ <thead>
9
+ <tr>
10
+ <th class="gp-th-expand"><span class="gp-sr-only">Expand</span></th>
11
+ <th class="gp-th-started">started</th>
12
+ <th>execution</th>
13
+ <th class="gp-th-steps">steps</th>
14
+ <th class="gp-th-status">status</th>
15
+ <th class="gp-th-duration">duration</th>
16
+ <th>params</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% pipelines.each do |pipeline| %>
21
+ <% steps = steps_by_pipeline[pipeline.id] || steps_by_pipeline[pipeline.id.to_s] || [] %>
22
+ <%= render "row", pipeline: pipeline, steps: steps, filters: filters %>
23
+ <% end %>
24
+ </tbody>
25
+ </table>
26
+ <% if pipelines.empty? %>
27
+ <div class="gp-table-empty">
28
+ <div class="gp-empty-mark">∅</div>
29
+ <div class="gp-empty-title">no executions match these filters</div>
30
+ <div class="gp-empty-sub">good_pipeline_pipelines returned 0 rows for the active query</div>
31
+ </div>
32
+ <% end %>
33
+ </div>
@@ -0,0 +1,55 @@
1
+ <%
2
+ lanes = Array(local_assigns[:lanes])
3
+ get = lambda do |object, key|
4
+ if object.respond_to?(key)
5
+ object.public_send(key)
6
+ elsif object.respond_to?(:[])
7
+ object[key] || object[key.to_s]
8
+ end
9
+ rescue NameError, IndexError, KeyError, TypeError
10
+ nil
11
+ end
12
+ status_name = ->(value) { value.to_s == "enqueued" ? "running" : value.to_s.start_with?("skipped") ? "skipped" : value.to_s }
13
+ duration = lambda do |seconds|
14
+ next "—" if seconds.nil?
15
+ value = seconds.to_f.to_i
16
+ if value < 60 then "#{value}s"
17
+ elsif value < 3_600 then "#{value / 60}m #{value % 60}s"
18
+ else "#{value / 3_600}h #{(value % 3_600) / 60}m"
19
+ end
20
+ end
21
+ %>
22
+ <div class="gp-timeline">
23
+ <% lanes.each do |lane| %>
24
+ <%
25
+ label = get.call(lane, :label) || get.call(lane, :stage)
26
+ worst = (get.call(lane, :worst) || get.call(lane, :status) || "pending").to_s
27
+ t0 = get.call(lane, :t0)
28
+ t1 = get.call(lane, :t1)
29
+ lane_duration = get.call(lane, :dur_s) || get.call(lane, :duration) || get.call(lane, :duration_seconds)
30
+ counts = get.call(lane, :counts) || {}
31
+ count = (get.call(lane, :n) || get.call(lane, :count) || 1).to_i
32
+ provided_note = get.call(lane, :note)
33
+ open_ended = !!get.call(lane, :open_ended)
34
+ note = if provided_note.present?
35
+ provided_note
36
+ elsif t0.nil? || t1.nil?
37
+ worst == "skipped_by_branch" ? "skipped·br" : worst
38
+ elsif count > 1 && counts.respond_to?(:map)
39
+ counts.map { |key, value| "#{value} #{key.to_s.sub('_by_branch', '·br')}" }.first(2).join(", ")
40
+ else
41
+ duration.call(lane_duration)
42
+ end
43
+ %>
44
+ <div class="gp-lane">
45
+ <span class="gp-lane-label" title="<%= label %>"><%= label %></span>
46
+ <span class="gp-lane-track">
47
+ <% unless t0.nil? || t1.nil? %>
48
+ <span class="gp-lane-bar <%= 'is-running' if open_ended %>" style="--gp-left:<%= (t0.to_f.clamp(0, 1) * 100).round(2) %>%;--gp-width:<%= ([(t1.to_f.clamp(0, 1) - t0.to_f.clamp(0, 1)) * 100, 1.5].max).round(2) %>%;--gp-status:var(--st-<%= status_name.call(worst) %>)"></span>
49
+ <% end %>
50
+ </span>
51
+ <span class="gp-lane-note" title="<%= note %>"><%= note %></span>
52
+ </div>
53
+ <% end %>
54
+ <% if lanes.empty? %><div class="gp-empty-sub">No step timing data is available.</div><% end %>
55
+ </div>
@@ -0,0 +1,54 @@
1
+ <%
2
+ section = local_assigns.fetch(:section, "executions")
3
+ active = local_assigns[:active]
4
+ meta = local_assigns[:meta]
5
+ back = local_assigns[:back]
6
+ connection = local_assigns[:connection] || @connection_info
7
+ now = local_assigns[:now] || @now || Time.current
8
+ value_for = lambda do |object, key|
9
+ if object.respond_to?(key)
10
+ object.public_send(key)
11
+ elsif object.respond_to?(:[])
12
+ object[key] || object[key.to_s]
13
+ end
14
+ rescue NameError, IndexError, KeyError, TypeError
15
+ nil
16
+ end
17
+ database = value_for.call(connection, :database) || value_for.call(connection, :database_name) || "database"
18
+ pg_version = value_for.call(connection, :version) || value_for.call(connection, :server_version)
19
+ theme = %w[dark light].include?(cookies[:good_pipeline_theme]) ? cookies[:good_pipeline_theme] : "dark"
20
+ %>
21
+ <header class="gp-topbar">
22
+ <div class="gp-crumbs">
23
+ <% if back %>
24
+ <%= link_to "← #{back[:label]}", back[:href], class: "gp-back" %>
25
+ <% end %>
26
+ <span class="gp-crumb">good_pipeline</span>
27
+ <span class="gp-crumb-sep">/</span>
28
+ <% if section == "executions" %>
29
+ <%= link_to "executions", pipelines_path, class: "gp-crumb" %>
30
+ <% unless back %>
31
+ <span class="gp-crumb-sep">/</span>
32
+ <%= link_to "definitions", definitions_pipelines_path, class: "gp-crumb", title: "Pipeline definitions catalog" %>
33
+ <% end %>
34
+ <% else %>
35
+ <%= link_to "definitions", definitions_pipelines_path, class: "gp-crumb" %>
36
+ <% end %>
37
+ <% if active.present? %>
38
+ <span class="gp-crumb-sep">/</span>
39
+ <span class="gp-crumb gp-crumb--active" data-gp-active-crumb><%= active %></span>
40
+ <% end %>
41
+ <% if meta.present? %>
42
+ <span class="gp-crumb-meta" data-gp-active-meta><%= meta %></span>
43
+ <% end %>
44
+ </div>
45
+ <div class="gp-topbar-right">
46
+ <button class="gp-theme-btn" type="button" title="Toggle theme" aria-label="Toggle color theme" data-gp-theme-toggle><%= theme == "dark" ? "◐" : "◑" %></button>
47
+ <span class="gp-connection" title="Dashboard database connection">
48
+ <span class="gp-connection-dot"></span>
49
+ <span><%= database %></span>
50
+ <% if pg_version.present? %><span class="gp-connection-version"><%= pg_version %></span><% end %>
51
+ </span>
52
+ <time class="gp-clock" datetime="<%= now.utc.iso8601 %>"><%= now.utc.strftime("%Y-%m-%d %H:%M:%SZ") %></time>
53
+ </div>
54
+ </header>
@@ -1,61 +1,94 @@
1
- <h1 class="page-title">Pipeline Definitions</h1>
1
+ <%
2
+ pipelines = Array(@pipelines)
3
+ run_counts = @run_counts || {}
4
+ definition_stages = @definition_stages_by_pipeline || @definition_stages || {}
5
+ get_count = ->(type) { (run_counts[type] || run_counts[type.to_s] || 0).to_i }
6
+ sidebar_entries = pipelines.map do |pipeline|
7
+ steps = pipeline.steps.to_a
8
+ {
9
+ type: pipeline.type,
10
+ id: pipeline.id,
11
+ on_failure_strategy: pipeline.on_failure_strategy,
12
+ run_count: get_count.call(pipeline.type),
13
+ step_count: steps.length,
14
+ has_branch: steps.any? { |step| step.respond_to?(:branch_step?) ? step.branch_step? : step.job_class == GoodPipeline::BRANCH_JOB_CLASS }
15
+ }
16
+ end
17
+ first = pipelines.first
18
+ first_short = first && (respond_to?(:short_type) ? short_type(first.type) : humanized_type(first.type).sub(/\s*Pipeline\z/, ""))
19
+ first_meta = first && "#{first.steps.size} steps · #{first.on_failure_strategy}"
20
+ %>
21
+ <div class="gp-app gp-app--definitions">
22
+ <%= render "sidebar", entries: sidebar_entries, mode: :definitions %>
23
+ <main class="gp-main">
24
+ <%= render "topbar", section: "definitions", active: first_short, meta: first_meta, connection: @connection_info, now: @now %>
25
+ <div class="gp-definitions-scroll">
26
+ <% if pipelines.any? %>
27
+ <div class="gp-definitions-content">
28
+ <% pipelines.each_with_index do |pipeline, index| %>
29
+ <%
30
+ steps = pipeline.steps.to_a.sort_by { |step| [step.created_at, step.id.to_s] }
31
+ dependencies = pipeline.dependencies.to_a
32
+ stages = definition_stages[pipeline.id] || definition_stages[pipeline.id.to_s] || definition_stages[pipeline.type] || []
33
+ graph = dependencies.length > 1_000 ? "" : mermaid_definition_diagram(pipeline)
34
+ has_branch = steps.any? { |step| step.respond_to?(:branch_step?) ? step.branch_step? : step.job_class == GoodPipeline::BRANCH_JOB_CLASS }
35
+ keys_by_id = steps.to_h { |step| [step.id, step_display_key(step)] }
36
+ upstream = Hash.new { |hash, key| hash[key] = [] }
37
+ dependencies.each do |dependency|
38
+ label = keys_by_id[dependency.depends_on_step_id]
39
+ downstream = steps.find { |step| step.id == dependency.step_id }
40
+ if downstream&.respond_to?(:branch_arm) && downstream.branch_arm.present?
41
+ label = "#{label}(#{downstream.branch_arm})"
42
+ end
43
+ upstream[dependency.step_id] << label
44
+ end
45
+ %>
46
+ <div id="gp-definition-<%= index %>" class="gp-definition-panel <%= 'is-active' if index.zero? %>" data-pipeline="<%= pipeline.type %>">
47
+ <section class="gp-card">
48
+ <div class="gp-definition-summary">
49
+ <span class="gp-definition-title"><%= humanized_type(pipeline.type) %></span>
50
+ <span class="gp-strategy gp-strategy--<%= pipeline.on_failure_strategy %>">on_failure: <%= pipeline.on_failure_strategy %></span>
51
+ <% if has_branch %><span class="gp-sidebar-tag">conditional branch</span><% end %>
52
+ <%= link_to "view executions ↗", pipelines_path(pipeline_type: pipeline.type), class: "gp-action gp-action--ghost" %>
53
+ </div>
54
+ <div class="gp-definition-metrics">
55
+ <span><b>steps</b> <%= number_with_delimiter(steps.length) %></span>
56
+ <span><b>edges</b> <%= number_with_delimiter(dependencies.length) %></span>
57
+ <span><b>runs</b> <%= number_with_delimiter(get_count.call(pipeline.type)) %></span>
58
+ <span><b>class</b> <%= pipeline.type %></span>
59
+ </div>
60
+ </section>
2
61
 
3
- <% if @pipelines.any? %>
4
- <div class="definitions-layout">
5
- <div class="definitions-sidebar">
6
- <% @pipelines.each_with_index do |pipeline, index| %>
7
- <button type="button" class="definition-item <%= 'active' if index.zero? %>" data-pipeline="<%= pipeline.type %>">
8
- <span class="definition-item-name"><%= humanized_type(pipeline.type) %></span>
9
- <span class="definition-item-meta"><%= pipeline.steps.size %> steps</span>
10
- </button>
11
- <% end %>
12
- </div>
62
+ <%= render "diagram", pipeline: pipeline, graph: graph, steps: steps,
63
+ edge_count: dependencies.length, stages: stages, definition: true %>
13
64
 
14
- <div class="definitions-detail">
15
- <% @pipelines.each_with_index do |pipeline, index| %>
16
- <div class="definition-panel <%= 'active' if index.zero? %>" data-pipeline="<%= pipeline.type %>">
17
- <article>
18
- <header class="definition-header">
19
- <div>
20
- <h2><%= humanized_type(pipeline.type) %></h2>
21
- <span class="definition-subtitle">on failure: <%= pipeline.on_failure_strategy %></span>
22
- </div>
23
- <%= link_to "View Executions \u2192", pipelines_path(pipeline_type: pipeline.type), class: "definition-executions-link" %>
24
- </header>
25
- <% if pipeline.steps.any? %>
26
- <div class="diagram-container">
27
- <div class="diagram-toolbar">
28
- <button type="button" class="diagram-btn" data-action="zoom-in" title="Zoom in">+</button>
29
- <button type="button" class="diagram-btn" data-action="zoom-out" title="Zoom out">&minus;</button>
30
- <button type="button" class="diagram-btn" data-action="zoom-reset" title="Reset zoom">Reset</button>
31
- <button type="button" class="diagram-btn" data-action="fullscreen" title="Fullscreen">&#x26F6;</button>
65
+ <section class="gp-card">
66
+ <h3 class="gp-section-heading">steps <span class="gp-heading-meta"><%= steps.length %> declared</span></h3>
67
+ <div class="gp-definition-list-overflow">
68
+ <table class="gp-compact-table">
69
+ <tbody>
70
+ <% steps.each do |step| %>
71
+ <% after = upstream[step.id] %>
72
+ <tr>
73
+ <td class="gp-step-key" style="width:180px"><%= step_display_key(step) %></td>
74
+ <td class="gp-job-class" title="<%= step.job_class %>"><%= step.job_class %></td>
75
+ <td class="gp-step-duration" title="<%= after.join(', ') %>"><%= after.any? ? "after: #{after.first(3).join(', ')}#{after.length > 3 ? " +#{after.length - 3}" : ''}" : "root" %></td>
76
+ </tr>
77
+ <% end %>
78
+ </tbody>
79
+ </table>
32
80
  </div>
33
- <div class="diagram-viewport">
34
- <div class="diagram-canvas">
35
- <pre class="mermaid"><%= raw mermaid_definition_diagram(pipeline) %></pre>
36
- </div>
37
- </div>
38
- </div>
39
- <% else %>
40
- <p class="empty-state">No steps defined.</p>
41
- <% end %>
42
- </article>
81
+ </section>
82
+ </div>
83
+ <% end %>
84
+ </div>
85
+ <% else %>
86
+ <div class="gp-table-empty">
87
+ <div class="gp-empty-mark">∅</div>
88
+ <div class="gp-empty-title">no pipeline definitions yet</div>
89
+ <div class="gp-empty-sub">Run a pipeline to add its definition to this catalog.</div>
43
90
  </div>
44
91
  <% end %>
45
92
  </div>
46
- </div>
47
-
48
- <script>
49
- document.querySelectorAll('.definition-item').forEach(function(button) {
50
- button.addEventListener('click', function() {
51
- var type = this.dataset.pipeline;
52
- document.querySelectorAll('.definition-item').forEach(function(b) { b.classList.remove('active'); });
53
- document.querySelectorAll('.definition-panel').forEach(function(p) { p.classList.remove('active'); });
54
- this.classList.add('active');
55
- document.querySelector('.definition-panel[data-pipeline="' + type + '"]').classList.add('active');
56
- });
57
- });
58
- </script>
59
- <% else %>
60
- <p class="empty-state">No pipeline executions found yet. Run a pipeline to see its definition here.</p>
61
- <% end %>
93
+ </main>
94
+ </div>
@@ -1,43 +1,32 @@
1
- <h1 class="page-title">Pipeline Executions</h1>
2
-
3
- <div class="filters-row">
4
- <ul class="status-tabs">
5
- <li><%= link_to "All (#{@total_count})", pipelines_path(pipeline_type: @pipeline_type), class: ("active" if @status.nil?) %></li>
6
- <% %w[running succeeded failed halted skipped].each do |status| %>
7
- <li><%= link_to "#{status.titleize} (#{@status_counts[status] || 0})", pipelines_path(status: status, pipeline_type: @pipeline_type), class: ("active" if @status == status), data: { status: status } %></li>
8
- <% end %>
9
- </ul>
10
- <select class="pipeline-type-select" onchange="window.location = this.value;">
11
- <option value="<%= pipelines_path(status: @status) %>" <%= "selected" if @pipeline_type.nil? %>>All Pipelines</option>
12
- <% @pipeline_types.each do |pipeline_type| %>
13
- <option value="<%= pipelines_path(status: @status, pipeline_type: pipeline_type) %>" <%= "selected" if @pipeline_type == pipeline_type %>>
14
- <%= humanized_type(pipeline_type) %>
15
- </option>
16
- <% end %>
17
- </select>
1
+ <%
2
+ filters = @filters
3
+ selected_type = filters.respond_to?(:pipeline_type) ? filters.pipeline_type : params[:pipeline_type].presence
4
+ selected_short = selected_type.present? ? (respond_to?(:short_type) ? short_type(selected_type) : humanized_type(selected_type).sub(/\s*Pipeline\z/, "")) : nil
5
+ selected_entry = Array(@sidebar_entries).find do |entry|
6
+ entry_type = entry.respond_to?(:type) ? entry.type : (entry[:type] || entry["type"])
7
+ entry_type == selected_type
8
+ end
9
+ selected_steps = if selected_entry
10
+ selected_entry.respond_to?(:step_count) ? selected_entry.step_count : (selected_entry[:step_count] || selected_entry["step_count"])
11
+ end
12
+ selected_strategy = if selected_entry
13
+ selected_entry.respond_to?(:on_failure_strategy) ? selected_entry.on_failure_strategy : (selected_entry[:on_failure_strategy] || selected_entry["on_failure_strategy"] || selected_entry[:strategy] || selected_entry["strategy"])
14
+ end
15
+ crumb_meta = selected_type.present? ? [selected_steps && "#{selected_steps} steps", selected_strategy].compact.join(" · ") : "all pipeline types"
16
+ %>
17
+ <div class="gp-app gp-app--dashboard">
18
+ <%= render "sidebar", entries: @sidebar_entries, filters: filters %>
19
+ <main class="gp-main">
20
+ <%= render "topbar", section: "executions", active: selected_short, meta: crumb_meta, connection: @connection_info, now: @now %>
21
+ <div id="gp-dashboard">
22
+ <div id="gp-kpi">
23
+ <%= render "kpi_strip", kpis: @kpis, total: @total %>
24
+ </div>
25
+ <%= render "filter_bar", filters: filters %>
26
+ <div id="gp-table">
27
+ <%= render "table", pipelines: @pipelines, filters: filters, steps_by_pipeline: @steps_by_pipeline %>
28
+ <%= render "pager", page: @page %>
29
+ </div>
30
+ </div>
31
+ </main>
18
32
  </div>
19
-
20
- <% if @pipelines.any? %>
21
- <div class="table-card">
22
- <table>
23
- <thead>
24
- <tr>
25
- <th>Execution ID</th>
26
- <th>Pipeline Name</th>
27
- <th>Status</th>
28
- <th>Start Time</th>
29
- <th>Duration</th>
30
- </tr>
31
- </thead>
32
- <tbody>
33
- <% @pipelines.each do |pipeline| %>
34
- <%= render "pipeline_row", pipeline: pipeline %>
35
- <% end %>
36
- </tbody>
37
- </table>
38
- </div>
39
-
40
- <%= render "pagination" %>
41
- <% else %>
42
- <p class="empty-state">No pipelines found.</p>
43
- <% end %>
@@ -1,52 +1,59 @@
1
- <p><%= link_to "\u2190 All Pipelines", pipelines_path %></p>
1
+ <%
2
+ steps = Array(@steps || @pipeline.steps)
3
+ dependencies = Array(@dependencies || @pipeline.dependencies)
4
+ timings = @step_timings || {}
5
+ lanes = Array(@stage_lanes)
6
+ short = respond_to?(:short_type) ? short_type(@pipeline.type) : humanized_type(@pipeline.type).sub(/\s*Pipeline\z/, "")
7
+ status_data = { "pending" => ["○", "pending"], "running" => ["●", "running"], "canceling" => ["◐", "canceling"], "canceled" => ["⊘", "canceled"], "succeeded" => ["✓", "succeeded"], "failed" => ["✗", "failed"], "halted" => ["⊘", "halted"], "skipped" => ["⊘", "skipped"] }
8
+ glyph, label = status_data.fetch(@pipeline.status.to_s, ["○", @pipeline.status.to_s])
9
+ now = @now || Time.current
10
+ elapsed = ((@pipeline.terminal? ? @pipeline.updated_at : now) - @pipeline.created_at).to_f
11
+ duration = respond_to?(:format_duration) ? format_duration(elapsed) : "#{elapsed.to_i}s"
12
+ ruby_snippet = %(GoodPipeline::PipelineRecord.find("#{@pipeline.id}"))
13
+ sql_snippet = "SELECT *\nFROM good_pipeline_pipelines\nWHERE id = '#{@pipeline.id}';"
14
+ graph = dependencies.length > 1_000 ? "" : mermaid_diagram(@pipeline)
15
+ %>
16
+ <div class="gp-app gp-app--detail">
17
+ <%= render "topbar", section: "executions", active: short, meta: "##{@pipeline.id.to_s.first(8)}", back: { label: "executions", href: pipelines_path }, connection: @connection_info, now: now %>
2
18
 
3
- <div class="pipeline-detail-grid">
4
- <div class="pipeline-detail-left">
5
- <article class="pipeline-details">
6
- <header><h2><%= humanized_type(@pipeline.type) %></h2></header>
7
- <dl>
8
- <dt>Status</dt>
9
- <dd><%= status_badge(@pipeline.status) %></dd>
10
- <dt>Failure Strategy</dt>
11
- <dd><span class="strategy-label"><%= @pipeline.on_failure_strategy %></span></dd>
12
- <dt>Params</dt>
13
- <dd><code><%= @pipeline.params&.to_json %></code></dd>
14
- <dt>Created</dt>
15
- <dd><%= relative_time_tag(@pipeline.created_at) %></dd>
16
- <% if pipeline_duration(@pipeline) %>
17
- <dt>Duration</dt>
18
- <dd><%= pipeline_duration(@pipeline) %></dd>
19
- <% end %>
20
- </dl>
21
- </article>
19
+ <header class="gp-detail-header">
20
+ <span class="gp-detail-title"><%= humanized_type(@pipeline.type) %></span>
21
+ <span class="gp-status gp-status--<%= @pipeline.status %>"><%= glyph %> <%= label %></span>
22
+ <span class="gp-strategy gp-strategy--<%= @pipeline.on_failure_strategy %>">on_failure: <%= @pipeline.on_failure_strategy %></span>
23
+ <span class="gp-detail-uuid"><%= @pipeline.id %><button class="gp-copy" type="button" title="Copy id" data-gp-copy="<%= @pipeline.id %>" data-gp-copy-label="execution id"><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></span>
24
+ <%= render "actions", pipeline: @pipeline, detail_link: false %>
25
+ </header>
22
26
 
23
- <%= render "chain_links", pipeline: @pipeline %>
27
+ <div class="gp-detail-scroll">
28
+ <div class="gp-detail-columns">
29
+ <div class="gp-detail-column">
30
+ <section class="gp-card">
31
+ <h3 class="gp-section-heading">identity</h3>
32
+ <table class="gp-kv"><tbody>
33
+ <tr><td class="gp-kv-key">id</td><td class="gp-kv-value"><%= @pipeline.id %></td></tr>
34
+ <tr><td class="gp-kv-key">type</td><td class="gp-kv-value"><%= @pipeline.type %></td></tr>
35
+ <tr><td class="gp-kv-key">params</td><td class="gp-kv-value"><%= @pipeline.params.to_json %></td></tr>
36
+ <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>
37
+ <tr><td class="gp-kv-key">updated_at</td><td class="gp-kv-value"><%= @pipeline.updated_at.utc.strftime("%Y-%m-%d %H:%M:%SZ") %></td></tr>
38
+ <tr><td class="gp-kv-key">duration</td><td class="gp-kv-value"><%= duration %></td></tr>
39
+ </tbody></table>
40
+ </section>
41
+ <%= render "chain_links", pipeline: @pipeline %>
42
+ <section class="gp-card"><%= render "steps_table", pipeline: @pipeline, steps: steps, timings: timings %></section>
43
+ <section class="gp-card">
44
+ <h3 class="gp-section-heading">requery</h3>
45
+ <div class="gp-code-wrap"><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"><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><pre class="gp-code"><%= ruby_snippet %></pre></div>
46
+ <div class="gp-code-wrap"><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"><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><pre class="gp-code"><%= sql_snippet %></pre></div>
47
+ </section>
48
+ </div>
24
49
 
25
- <article class="table-card">
26
- <%= render "steps_table", pipeline: @pipeline %>
27
- </article>
28
- </div>
29
-
30
- <div class="pipeline-detail-right">
31
- <article>
32
- <header><h3>Pipeline Graph</h3></header>
33
- <% if @pipeline.steps.any? %>
34
- <div class="diagram-container">
35
- <div class="diagram-toolbar">
36
- <button type="button" class="diagram-btn" data-action="zoom-in" title="Zoom in">+</button>
37
- <button type="button" class="diagram-btn" data-action="zoom-out" title="Zoom out">&minus;</button>
38
- <button type="button" class="diagram-btn" data-action="zoom-reset" title="Reset zoom">Reset</button>
39
- <button type="button" class="diagram-btn" data-action="fullscreen" title="Fullscreen">&#x26F6;</button>
40
- </div>
41
- <div class="diagram-viewport">
42
- <div class="diagram-canvas">
43
- <pre class="mermaid"><%= raw mermaid_diagram(@pipeline) %></pre>
44
- </div>
45
- </div>
46
- </div>
47
- <% else %>
48
- <p class="empty-state">No steps defined.</p>
49
- <% end %>
50
- </article>
50
+ <div class="gp-detail-column">
51
+ <%= render "diagram", pipeline: @pipeline, graph: graph, steps: steps, edge_count: dependencies.size, stages: lanes %>
52
+ <section class="gp-card">
53
+ <h3 class="gp-section-heading"><%= steps.length > 12 ? "stage timeline" : "step timeline" %><span class="gp-heading-meta"><%= lanes.length %> lanes</span></h3>
54
+ <%= render "timeline", lanes: lanes %>
55
+ </section>
56
+ </div>
57
+ </div>
51
58
  </div>
52
59
  </div>