pgbus 0.19.4 → 0.20.1

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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +97 -7
  4. data/Rakefile +6 -1
  5. data/app/controllers/pgbus/dashboard_controller.rb +2 -0
  6. data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
  7. data/app/controllers/pgbus/workflows_controller.rb +197 -0
  8. data/app/frontend/pgbus/modules/charts.js +148 -4
  9. data/app/frontend/pgbus/modules/charts.min.js +1 -1
  10. data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
  11. data/app/frontend/pgbus/style.css +1 -1
  12. data/app/helpers/pgbus/button_helper.rb +4 -2
  13. data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
  14. data/app/helpers/pgbus/workflows_helper.rb +165 -0
  15. data/app/models/pgbus/blocked_execution.rb +12 -0
  16. data/app/models/pgbus/uniqueness_key.rb +88 -3
  17. data/app/models/pgbus/workflow_entry.rb +172 -0
  18. data/app/models/pgbus/workflow_event.rb +43 -0
  19. data/app/models/pgbus/workflow_signal.rb +77 -0
  20. data/app/models/pgbus/workflow_step.rb +149 -0
  21. data/app/views/layouts/pgbus/application.html.erb +2 -0
  22. data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
  23. data/app/views/pgbus/workflows/_actions.html.erb +59 -0
  24. data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
  25. data/app/views/pgbus/workflows/_filters.html.erb +29 -0
  26. data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
  27. data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
  28. data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
  29. data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
  30. data/app/views/pgbus/workflows/_timeline.html.erb +49 -0
  31. data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
  32. data/app/views/pgbus/workflows/attention.html.erb +88 -0
  33. data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
  34. data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
  35. data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
  36. data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
  37. data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
  38. data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
  39. data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
  40. data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
  41. data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
  42. data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
  43. data/app/views/pgbus/workflows/index.html.erb +15 -0
  44. data/app/views/pgbus/workflows/not_found.html.erb +5 -0
  45. data/app/views/pgbus/workflows/show.html.erb +93 -0
  46. data/config/i18n-tasks.yml +6 -0
  47. data/config/locales/da.yml +445 -0
  48. data/config/locales/de.yml +445 -0
  49. data/config/locales/en.yml +445 -0
  50. data/config/locales/es.yml +445 -0
  51. data/config/locales/fi.yml +445 -0
  52. data/config/locales/fr.yml +445 -0
  53. data/config/locales/it.yml +445 -0
  54. data/config/locales/ja.yml +445 -0
  55. data/config/locales/nb.yml +445 -0
  56. data/config/locales/nl.yml +445 -0
  57. data/config/locales/pt.yml +445 -0
  58. data/config/locales/sv.yml +445 -0
  59. data/config/routes.rb +26 -0
  60. data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
  61. data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
  62. data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
  63. data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
  64. data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
  65. data/lib/pgbus/active_job/adapter.rb +81 -3
  66. data/lib/pgbus/active_job/delivery.rb +63 -0
  67. data/lib/pgbus/active_job/executor.rb +20 -8
  68. data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
  69. data/lib/pgbus/configuration/workflow_settings.rb +99 -0
  70. data/lib/pgbus/configuration.rb +64 -4
  71. data/lib/pgbus/dead_letter_header.rb +9 -1
  72. data/lib/pgbus/generators/migration_detector.rb +40 -0
  73. data/lib/pgbus/instrumentation.rb +33 -0
  74. data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
  75. data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
  76. data/lib/pgbus/mcp/rack_app.rb +7 -2
  77. data/lib/pgbus/mcp/runner.rb +2 -1
  78. data/lib/pgbus/mcp/serialization.rb +25 -0
  79. data/lib/pgbus/mcp/server.rb +53 -10
  80. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
  81. data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
  82. data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
  83. data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
  84. data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
  85. data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
  86. data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
  87. data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
  88. data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
  89. data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
  90. data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
  91. data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
  92. data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
  93. data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
  94. data/lib/pgbus/mcp/write_tool.rb +134 -0
  95. data/lib/pgbus/mcp.rb +15 -0
  96. data/lib/pgbus/metrics/backends/statsd.rb +6 -0
  97. data/lib/pgbus/metrics/subscriber.rb +14 -1
  98. data/lib/pgbus/metrics/workflow_metrics.rb +75 -0
  99. data/lib/pgbus/process/consumer.rb +2 -1
  100. data/lib/pgbus/process/dispatcher.rb +39 -3
  101. data/lib/pgbus/process/worker.rb +10 -1
  102. data/lib/pgbus/recurring/schedule.rb +1 -1
  103. data/lib/pgbus/streams/presence.rb +65 -36
  104. data/lib/pgbus/uniqueness.rb +23 -6
  105. data/lib/pgbus/version.rb +1 -1
  106. data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
  107. data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
  108. data/lib/pgbus/web/data_source/workflow_list.rb +291 -0
  109. data/lib/pgbus/web/data_source.rb +126 -15
  110. data/lib/pgbus/web/workflow_action_plan.rb +62 -0
  111. data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
  112. data/lib/pgbus/web/workflow_insights_report.rb +171 -0
  113. data/lib/pgbus/web/workflow_payload.rb +36 -0
  114. data/lib/pgbus/web/workflow_query.rb +82 -0
  115. data/lib/pgbus/web/workflow_state.rb +89 -0
  116. data/lib/pgbus/web/workflow_timeline.rb +130 -0
  117. data/lib/pgbus/web/workflow_window.rb +51 -0
  118. data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
  119. data/lib/pgbus/workflow/dsl.rb +105 -0
  120. data/lib/pgbus/workflow/error_capture.rb +63 -0
  121. data/lib/pgbus/workflow/exception_policy.rb +170 -0
  122. data/lib/pgbus/workflow/fan_out.rb +157 -0
  123. data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
  124. data/lib/pgbus/workflow/flow_control.rb +94 -0
  125. data/lib/pgbus/workflow/instance.rb +183 -0
  126. data/lib/pgbus/workflow/outside_verbs.rb +138 -0
  127. data/lib/pgbus/workflow/payload.rb +53 -0
  128. data/lib/pgbus/workflow/registry.rb +53 -0
  129. data/lib/pgbus/workflow/removed_step.rb +14 -0
  130. data/lib/pgbus/workflow/runner.rb +218 -0
  131. data/lib/pgbus/workflow/signal_matcher.rb +53 -0
  132. data/lib/pgbus/workflow/signals.rb +132 -0
  133. data/lib/pgbus/workflow/states.rb +119 -0
  134. data/lib/pgbus/workflow/step_context.rb +58 -0
  135. data/lib/pgbus/workflow/step_definition.rb +133 -0
  136. data/lib/pgbus/workflow/step_execution.rb +242 -0
  137. data/lib/pgbus/workflow/step_job.rb +28 -0
  138. data/lib/pgbus/workflow/step_outcome.rb +151 -0
  139. data/lib/pgbus/workflow/steps.rb +103 -0
  140. data/lib/pgbus/workflow/sweep.rb +156 -0
  141. data/lib/pgbus/workflow/test_helpers.rb +152 -0
  142. data/lib/pgbus/workflow/wait_timeout.rb +94 -0
  143. data/lib/pgbus/workflow/waiting.rb +82 -0
  144. data/lib/pgbus/workflow.rb +183 -0
  145. data/sig/pgbus/configuration.rbs +23 -0
  146. metadata +92 -1
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Pgbus
6
+ # One step attempt (pgbus_workflow_steps). The unique (workflow_id,
7
+ # step_name, attempt) index makes a duplicate attempt impossible, and the
8
+ # guarded claim (scheduled → in_progress) makes a duplicate StepJob a no-op.
9
+ #
10
+ # The after-commit callback below is the ONLY place a
11
+ # Pgbus::Workflow::StepJob is enqueued: start!, an advance, a reattempt,
12
+ # resume!, run_now!, and the sweep all write a row (a new scheduled row, or
13
+ # a bumped enqueued_at) and let it fire once the write is visible, even
14
+ # inside an application transaction.
15
+ class WorkflowStep < BusRecord
16
+ self.table_name = "pgbus_workflow_steps"
17
+
18
+ after_save_commit :enqueue_step_job, if: :needs_enqueue?
19
+
20
+ # Inserts the next attempt of +step_name+: one more than the highest
21
+ # attempt so far (1 for a step that never ran).
22
+ def self.insert_next!(workflow_id:, workflow_class:, step_name:, scheduled_at:)
23
+ attempt = where(workflow_id: workflow_id, step_name: step_name).maximum(:attempt).to_i + 1
24
+ create!(workflow_id: workflow_id, workflow_class: workflow_class, step_name: step_name, attempt: attempt,
25
+ status: "scheduled", scheduled_at: scheduled_at, created_at: Time.current)
26
+ end
27
+
28
+ # scheduled → in_progress, only once the row is due (a second of slack
29
+ # for clock skew between the sender and this worker). A message that
30
+ # arrives early (left behind by reschedule! to a later time) claims
31
+ # nothing; the later send does. Returns the number of rows claimed.
32
+ def self.claim!(id, now:, stall_at:, msg_id:, worker:, app_revision:)
33
+ where(id: id, status: "scheduled").where(scheduled_at: ..(now + 1))
34
+ .update_all(status: "in_progress", started_at: now, stall_at: stall_at,
35
+ msg_id: msg_id, worker: worker, app_revision: app_revision)
36
+ end
37
+
38
+ # Records an attempt's outcome, only while the row is still in_progress.
39
+ # 0 means the attempt was declared stalled (or the run cancelled) while
40
+ # it was still running: its outcome is not applied.
41
+ def self.finish!(id, **attrs)
42
+ where(id: id, status: "in_progress").update_all(attrs)
43
+ end
44
+
45
+ # in_progress → waiting: the step parks on a signal or its fan-out batch
46
+ # (Workflow::Waiting). Returns the number of rows moved.
47
+ def self.park!(id, **attrs)
48
+ where(id: id, status: "in_progress").update_all(attrs.merge(status: "waiting"))
49
+ end
50
+
51
+ # waiting → in_progress, so the outcome of a finished wait (the batch
52
+ # finished, the wait timed out, an operator skipped it) is applied like
53
+ # any attempt's. Returns the number of rows moved.
54
+ def self.unpark!(id)
55
+ where(id: id, status: "waiting").update_all(status: "in_progress")
56
+ end
57
+
58
+ # A waiting row's next deadline (on_timeout: :notify keeps the attempt
59
+ # waiting). Written without the model callback, so no StepJob goes out.
60
+ # Returns the number of rows moved.
61
+ def self.move_deadline!(id, timeout_at:)
62
+ where(id: id, status: "waiting").update_all(timeout_at: timeout_at)
63
+ end
64
+
65
+ # waiting rows of waiting runs past their timeout_at (the sweep applies
66
+ # their on_timeout).
67
+ def self.wait_expired(now: Time.current)
68
+ where(status: "waiting").where(timeout_at: ...now)
69
+ .where(workflow_id: WorkflowEntry.where(status: "waiting").select(:workflow_id))
70
+ end
71
+
72
+ # The scheduled, waiting and in_progress rows of a run, cancelled (an
73
+ # outside cancel!, a resume_at!).
74
+ def self.cancel_open!(workflow_id, now: Time.current)
75
+ where(workflow_id: workflow_id, status: Workflow::States::OPEN_STEP)
76
+ .update_all(status: "cancelled", outcome: "cancelled", finished_at: now)
77
+ end
78
+
79
+ # The newest row of a run: the step it is on.
80
+ def self.latest_for(workflow_id)
81
+ where(workflow_id: workflow_id).order(id: :desc).first
82
+ end
83
+
84
+ # How many of +step_name+'s latest finished attempts in a row were
85
+ # reattempted (what max_reattempts counts). Any other outcome, such as a
86
+ # pause an operator resumed, ends the run of reattempts.
87
+ def self.consecutive_reattempts(workflow_id, step_name)
88
+ outcomes = where(workflow_id: workflow_id, step_name: step_name).where.not(outcome: nil)
89
+ .order(attempt: :desc).pluck(:outcome)
90
+ outcomes.take_while { |outcome| outcome == "reattempted" }.size
91
+ end
92
+
93
+ def self.completed_output(workflow_id, step_name)
94
+ where(workflow_id: workflow_id, step_name: step_name.to_s, status: "completed").order(attempt: :desc).pick(:output)
95
+ end
96
+
97
+ # { step_name => output } of each step's latest completed attempt.
98
+ def self.completed_outputs(workflow_id)
99
+ where(workflow_id: workflow_id, status: "completed").order(:attempt).pluck(:step_name, :output).to_h
100
+ end
101
+
102
+ # in_progress rows past their stall_at.
103
+ def self.stalled(now: Time.current)
104
+ where(status: "in_progress").where(stall_at: ...now)
105
+ end
106
+
107
+ # scheduled rows of ready runs, due more than +threshold+ ago and not
108
+ # sent within it: the sweep sends them again.
109
+ def self.overdue(threshold:, now: Time.current)
110
+ cutoff = now - threshold
111
+ where(status: "scheduled").where(scheduled_at: ...cutoff)
112
+ .where("enqueued_at IS NULL OR enqueued_at < ?", cutoff)
113
+ .where(workflow_id: WorkflowEntry.where(status: "ready").select(:workflow_id))
114
+ end
115
+
116
+ # Fires on a new scheduled row, on a change to scheduled, and on a
117
+ # bumped enqueued_at; never on any other save.
118
+ def needs_enqueue?
119
+ status == "scheduled" && (previously_new_record? || saved_change_to_status? || saved_change_to_enqueued_at?)
120
+ end
121
+
122
+ private
123
+
124
+ # Runs after commit, so a failure cannot roll back the row: it is
125
+ # reported, and the sweep sends the step again once it is
126
+ # workflow_stall_threshold overdue.
127
+ def enqueue_step_job
128
+ job = Workflow::StepJob.set(**step_job_set_options).perform_later(id)
129
+ update_columns(enqueued_at: Time.current, job_id: job.job_id) if job
130
+ rescue StandardError => e
131
+ Pgbus.logger.error do
132
+ "[Pgbus] Workflow StepJob for step #{id} (#{step_name}) could not be enqueued: #{e.class}: #{e.message}"
133
+ end
134
+ ErrorReporter.report(e, { action: "workflow_step_enqueue", workflow_class: workflow_class,
135
+ workflow_id: workflow_id, step_name: step_name, attempt: attempt })
136
+ end
137
+
138
+ # queue/priority from the class and the step (step wins), sent at
139
+ # scheduled_at (which already holds any job_options wait/wait_until, see
140
+ # Workflow::Runner.schedule!).
141
+ def step_job_set_options
142
+ klass = workflow_class.safe_constantize
143
+ options = klass.is_a?(Class) && klass < Workflow ? klass.step_job_options_for(step_name) : {}
144
+ set = { queue: options[:queue] || Pgbus.configuration.workflow_queue, priority: options[:priority] }
145
+ set[:wait_until] = scheduled_at if scheduled_at > Time.current
146
+ set.compact
147
+ end
148
+ end
149
+ end
@@ -130,6 +130,7 @@
130
130
  <%= pgbus_nav_link t("pgbus.layout.nav.processes"), pgbus.processes_path %>
131
131
  <%= pgbus_nav_link t("pgbus.layout.nav.events"), pgbus.events_path %>
132
132
  <%= pgbus_nav_link t("pgbus.layout.nav.batches"), pgbus.batches_path %>
133
+ <%= pgbus_nav_link t("pgbus.layout.nav.workflows"), pgbus.workflows_path %>
133
134
  <%= pgbus_nav_link t("pgbus.layout.nav.dlq"), pgbus.dead_letter_index_path %>
134
135
  <%= pgbus_nav_link t("pgbus.layout.nav.outbox"), pgbus.outbox_index_path %>
135
136
  <%= pgbus_nav_link t("pgbus.layout.nav.locks"), pgbus.locks_path %>
@@ -191,6 +192,7 @@
191
192
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.processes"), pgbus.processes_path %>
192
193
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.events"), pgbus.events_path %>
193
194
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.batches"), pgbus.batches_path %>
195
+ <%= pgbus_mobile_nav_link t("pgbus.layout.nav.workflows"), pgbus.workflows_path %>
194
196
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.dlq"), pgbus.dead_letter_index_path %>
195
197
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.outbox"), pgbus.outbox_index_path %>
196
198
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.locks"), pgbus.locks_path %>
@@ -77,6 +77,23 @@
77
77
  <p class="text-xs text-gray-600 dark:text-gray-300"><%= throughput[:available] ? t("pgbus.dashboard.stats_cards.throughput_detail", minutes: throughput[:window_minutes]) : t("pgbus.dashboard.stats_cards.throughput_unavailable") %></p>
78
78
  <% end %>
79
79
 
80
+ <%# Two links in one card (the list and the attention view), so the card
81
+ is a div; its title and number link to the list. %>
82
+ <% workflows = @workflow_counts %>
83
+ <div data-testid="workflows-card" class="<%= card %>">
84
+ <%= link_to pgbus.workflows_path, data: { turbo_frame: "_top" }, class: "block" do %>
85
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.dashboard.stats_cards.workflows") %></p>
86
+ <p class="<%= number %>"><%= number_with_delimiter(workflows[:ongoing]) %></p>
87
+ <% end %>
88
+ <p class="text-xs text-gray-600 dark:text-gray-300">
89
+ <%= t("pgbus.dashboard.stats_cards.workflows_detail", paused: workflows[:paused]) %>
90
+ <% if workflows[:attention].positive? %>
91
+ · <%= pgbus_link_to t("pgbus.dashboard.stats_cards.workflows_attention", count: workflows[:attention]),
92
+ pgbus.attention_workflows_path, size: :sm, tone: :yellow, data: { turbo_frame: "_top" } %>
93
+ <% end %>
94
+ </p>
95
+ </div>
96
+
80
97
  <%= link_to pgbus.locks_path, data: { turbo_frame: "_top" }, class: card do %>
81
98
  <p class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.dashboard.stats_cards.parked_jobs") %></p>
82
99
  <p class="mt-1 text-3xl font-semibold <%= @stats[:parked_total].to_i > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-900 dark:text-white' %>">
@@ -0,0 +1,59 @@
1
+ <%# The operator actions (issue #542). Which buttons show is
2
+ available_actions (Workflow::States), never view logic; every button
3
+ submits the one form, to its own formaction, so the reason typed once
4
+ goes with whichever is pressed. %>
5
+ <% actions = pgbus_workflow_dashboard_actions(@workflow) %>
6
+ <% if actions.any? %>
7
+ <% id = @workflow[:workflow_id] %>
8
+ <%# The form's own action is the first button's, so Enter in the reason box
9
+ does what the first button does (and asks to confirm it). %>
10
+ <% paths = { resume: pgbus.resume_workflow_path(id), run_now: pgbus.run_now_workflow_path(id),
11
+ skip: pgbus.skip_step_workflow_path(id), rerun: pgbus.rerun_workflow_path(id),
12
+ cancel: pgbus.cancel_workflow_path(id), resume_at: pgbus.resume_at_workflow_path(id) } %>
13
+ <% default_path = paths.fetch(paths.keys.find { |action| actions.include?(action) }) %>
14
+ <section class="mb-8 rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700" aria-labelledby="workflow-actions-title" data-testid="workflow-actions">
15
+ <h2 id="workflow-actions-title" class="text-lg font-medium text-gray-900 dark:text-white"><%= t("pgbus.workflows.show.actions") %></h2>
16
+ <form action="<%= default_path %>" method="post" data-turbo-frame="_top" class="mt-3">
17
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token, id: nil %>
18
+ <div class="max-w-xl">
19
+ <label for="workflow-action-reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.workflows.show.reason_label") %></label>
20
+ <input type="text" autocomplete="off" id="workflow-action-reason" name="reason" maxlength="1000" aria-describedby="workflow-action-reason-hint" class="<%= Pgbus::WorkflowsHelper::INPUT_CSS %>">
21
+ <p id="workflow-action-reason-hint" class="mt-1 text-xs text-gray-600 dark:text-gray-300"><%= t("pgbus.workflows.show.reason_hint") %></p>
22
+ </div>
23
+ <div class="mt-4 flex flex-wrap items-end gap-2">
24
+ <% if actions.include?(:resume) %>
25
+ <%= pgbus_button_tag pgbus_workflow_resume_label(@workflow), variant: :success, formaction: pgbus.resume_workflow_path(id),
26
+ confirm: t("pgbus.workflows.confirm.resume") %>
27
+ <% end %>
28
+ <% if actions.include?(:run_now) %>
29
+ <%= pgbus_button_tag t("pgbus.workflows.actions.run_now"), variant: :primary, formaction: pgbus.run_now_workflow_path(id),
30
+ confirm: t("pgbus.workflows.confirm.run_now") %>
31
+ <% end %>
32
+ <% if actions.include?(:skip) %>
33
+ <%= pgbus_button_tag t("pgbus.workflows.actions.skip_step"), variant: :warning, formaction: pgbus.skip_step_workflow_path(id),
34
+ confirm: t("pgbus.workflows.confirm.skip_step") %>
35
+ <% end %>
36
+ <% if actions.include?(:rerun) %>
37
+ <%= pgbus_button_tag t("pgbus.workflows.actions.rerun"), variant: :primary, formaction: pgbus.rerun_workflow_path(id),
38
+ confirm: t("pgbus.workflows.confirm.rerun") %>
39
+ <% end %>
40
+ <% if actions.include?(:cancel) %>
41
+ <%= pgbus_button_tag t("pgbus.workflows.actions.cancel"), variant: :danger, formaction: pgbus.cancel_workflow_path(id),
42
+ confirm: t("pgbus.workflows.confirm.cancel") %>
43
+ <% end %>
44
+ </div>
45
+ <% if actions.include?(:resume_at) && @workflow[:step_names].any? %>
46
+ <div class="mt-4 flex flex-wrap items-end gap-2">
47
+ <div>
48
+ <label for="workflow-resume-step" class="block text-sm font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.workflows.show.resume_at_label") %></label>
49
+ <select id="workflow-resume-step" name="step" class="<%= Pgbus::WorkflowsHelper::INPUT_CSS %>">
50
+ <%= options_for_select(@workflow[:step_names], @workflow[:current_step]) %>
51
+ </select>
52
+ </div>
53
+ <%= pgbus_button_tag t("pgbus.workflows.actions.resume_at"), variant: :secondary, formaction: pgbus.resume_at_workflow_path(id),
54
+ confirm: t("pgbus.workflows.confirm.resume_at") %>
55
+ </div>
56
+ <% end %>
57
+ </form>
58
+ </section>
59
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <%# The audit trail (issue #542): pgbus_workflow_events, newest first, each
2
+ as "<what> by <actor>: <reason>". %>
3
+ <section class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700" aria-labelledby="workflow-events-title" data-testid="workflow-events">
4
+ <h2 id="workflow-events-title" class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 text-lg font-medium text-gray-900 dark:text-white"><%= t("pgbus.workflows.audit.title") %></h2>
5
+ <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
6
+ <thead class="bg-gray-50 dark:bg-gray-900">
7
+ <tr>
8
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.audit.headers.when") %></th>
9
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.audit.headers.what") %></th>
10
+ </tr>
11
+ </thead>
12
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
13
+ <% @workflow[:events].each do |event| %>
14
+ <tr data-event="<%= event[:event] %>" class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
15
+ <td data-label="<%= t("pgbus.workflows.audit.headers.when") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap"><%= pgbus_time(event[:created_at]) %></td>
16
+ <td data-label="<%= t("pgbus.workflows.audit.headers.what") %>" class="px-4 py-3 text-sm text-gray-900 dark:text-white"><%= pgbus_workflow_event_sentence(event) %></td>
17
+ </tr>
18
+ <% end %>
19
+ <% if @workflow[:events].empty? %>
20
+ <%= render "pgbus/shared/empty_row", colspan: 2, message: t("pgbus.workflows.audit.empty") %>
21
+ <% end %>
22
+ </tbody>
23
+ </table>
24
+ </section>
@@ -0,0 +1,29 @@
1
+ <%# The filters beyond status (issue #542). A GET form: the filters combine,
2
+ the status tab is kept, and the pager carries them on. %>
3
+ <% input_css = Pgbus::WorkflowsHelper::INPUT_CSS %>
4
+ <form action="<%= pgbus.workflows_path %>" method="get" data-turbo-frame="_top" data-testid="workflow-filters"
5
+ class="mb-4 rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
6
+ <input type="hidden" name="status" value="<%= @query.status %>">
7
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
8
+ <% Pgbus::Web::WorkflowQuery::TEXT_FILTERS.each do |filter| %>
9
+ <div>
10
+ <label for="workflow-filter-<%= filter %>" class="block text-xs font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.workflows.filters.#{filter}") %></label>
11
+ <input type="text" autocomplete="off" id="workflow-filter-<%= filter %>" name="<%= filter %>" value="<%= @query.raw[filter] %>" class="<%= input_css %>">
12
+ </div>
13
+ <% end %>
14
+ <% Pgbus::Web::WorkflowQuery::DATE_FILTERS.each do |filter| %>
15
+ <div>
16
+ <label for="workflow-filter-<%= filter %>" class="block text-xs font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.workflows.filters.#{filter}") %></label>
17
+ <% value = @query.raw[filter] %>
18
+ <%# A date-time from a shared URL stays a text field, so it survives the next Apply. %>
19
+ <input type="<%= value.nil? || value.match?(Pgbus::Web::WorkflowQuery::DATE_ONLY) ? "date" : "text" %>" autocomplete="off" id="workflow-filter-<%= filter %>" name="<%= filter %>" value="<%= @query.raw[filter] %>" class="<%= input_css %>">
20
+ </div>
21
+ <% end %>
22
+ </div>
23
+ <div class="mt-3 flex items-center gap-2">
24
+ <%= pgbus_button_tag t("pgbus.workflows.filters.apply"), variant: :primary, size: :sm %>
25
+ <% if @query.filtered? %>
26
+ <%= pgbus_link_to t("pgbus.workflows.filters.clear"), pgbus.workflows_path(status: @query.status), variant: :secondary, size: :sm %>
27
+ <% end %>
28
+ </div>
29
+ </form>
@@ -0,0 +1,31 @@
1
+ <%# Send a signal (issue #542). The payload must be a JSON object; the
2
+ idempotency key is generated per render, so a double submit sends one
3
+ signal. The name is a select of what the waiting step takes, or free text. %>
4
+ <% id = @workflow[:workflow_id] %>
5
+ <section class="mb-8 rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700" aria-labelledby="workflow-signal-title" data-testid="workflow-signal">
6
+ <h2 id="workflow-signal-title" class="text-lg font-medium text-gray-900 dark:text-white"><%= t("pgbus.workflows.signal.title") %></h2>
7
+ <p class="mt-1 text-sm text-gray-600 dark:text-gray-300"><%= t("pgbus.workflows.signal.hint") %></p>
8
+ <form action="<%= pgbus.signal_workflow_path(id) %>" method="post" data-turbo-frame="_top" class="mt-3 grid max-w-xl grid-cols-1 gap-3">
9
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token, id: nil %>
10
+ <input type="hidden" name="idempotency_key" value="<%= SecureRandom.uuid %>">
11
+ <div>
12
+ <label for="workflow-signal-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.workflows.signal.name") %></label>
13
+ <% if @workflow[:signal_names].any? %>
14
+ <select id="workflow-signal-name" name="name" class="<%= Pgbus::WorkflowsHelper::INPUT_CSS %>">
15
+ <% @workflow[:signal_names].each do |name| %>
16
+ <option value="<%= name %>"><%= name %></option>
17
+ <% end %>
18
+ </select>
19
+ <% else %>
20
+ <input type="text" autocomplete="off" id="workflow-signal-name" name="name" required class="<%= Pgbus::WorkflowsHelper::INPUT_CSS %>">
21
+ <% end %>
22
+ </div>
23
+ <div>
24
+ <label for="workflow-signal-payload" class="block text-sm font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.workflows.signal.payload") %></label>
25
+ <textarea id="workflow-signal-payload" name="payload" rows="4" class="<%= Pgbus::WorkflowsHelper::INPUT_CSS %> font-mono">{}</textarea>
26
+ </div>
27
+ <div>
28
+ <%= pgbus_button_tag t("pgbus.workflows.signal.send"), variant: :primary, confirm: t("pgbus.workflows.confirm.signal") %>
29
+ </div>
30
+ </form>
31
+ </section>
@@ -0,0 +1,40 @@
1
+ <%# The signals delivered to this run (issue #542), newest first. %>
2
+ <section class="mb-8 rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700" aria-labelledby="workflow-signals-title" data-testid="workflow-signals">
3
+ <h2 id="workflow-signals-title" class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 text-lg font-medium text-gray-900 dark:text-white"><%= t("pgbus.workflows.signals.title") %></h2>
4
+ <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
5
+ <thead class="bg-gray-50 dark:bg-gray-900">
6
+ <tr>
7
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.signals.headers.name") %></th>
8
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.signals.headers.status") %></th>
9
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.signals.headers.received") %></th>
10
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.signals.headers.claimed") %></th>
11
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.signals.headers.consumed") %></th>
12
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.signals.headers.idempotency_key") %></th>
13
+ </tr>
14
+ </thead>
15
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
16
+ <% @workflow[:signals].each do |signal| %>
17
+ <tr class="align-top hover:bg-gray-50 dark:hover:bg-gray-700/50">
18
+ <td data-label="<%= t("pgbus.workflows.signals.headers.name") %>" class="px-4 py-3 text-sm text-gray-900 dark:text-white">
19
+ <code class="font-mono"><%= signal[:name] %></code>
20
+ <% payload = Pgbus::Web::PayloadFilter.filter(signal[:payload] || {}) %>
21
+ <% if payload.any? %>
22
+ <details class="mt-1">
23
+ <summary class="inline-flex min-h-6 cursor-pointer items-center text-xs font-medium text-indigo-700 hover:text-indigo-900 dark:text-indigo-300 dark:hover:text-indigo-200"><%= t("pgbus.workflows.signals.payload") %></summary>
24
+ <pre tabindex="0" class="mt-1 overflow-auto rounded bg-gray-50 p-2 text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"><%= JSON.pretty_generate(payload) %></pre>
25
+ </details>
26
+ <% end %>
27
+ </td>
28
+ <td data-label="<%= t("pgbus.workflows.signals.headers.status") %>" class="px-4 py-3 text-sm"><%= pgbus_workflow_signal_badge(signal[:status]) %></td>
29
+ <td data-label="<%= t("pgbus.workflows.signals.headers.received") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"><%= pgbus_time(signal[:created_at]) %></td>
30
+ <td data-label="<%= t("pgbus.workflows.signals.headers.claimed") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"><%= pgbus_time(signal[:claimed_at]) %></td>
31
+ <td data-label="<%= t("pgbus.workflows.signals.headers.consumed") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"><%= pgbus_time(signal[:consumed_at]) %></td>
32
+ <td data-label="<%= t("pgbus.workflows.signals.headers.idempotency_key") %>" class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300"><%= signal[:idempotency_key] || Pgbus::Web::TimeFormat::NONE %></td>
33
+ </tr>
34
+ <% end %>
35
+ <% if @workflow[:signals].empty? %>
36
+ <%= render "pgbus/shared/empty_row", colspan: 6, message: t("pgbus.workflows.signals.empty") %>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+ </section>
@@ -0,0 +1,12 @@
1
+ <%# The status tabs (issue #542): ongoing by default, each state, all. A tab
2
+ keeps the filters and goes back to page 1. %>
3
+ <nav class="mb-4 flex flex-wrap gap-2" aria-label="<%= t("pgbus.workflows.index.status_label") %>" data-testid="workflow-status-tabs">
4
+ <% Pgbus::Web::DataSource::WorkflowList::STATUSES.each do |status| %>
5
+ <% active = status == @query.status %>
6
+ <a href="<%= pgbus_workflows_path_with(@query, status: status) %>"
7
+ data-status="<%= status %>" aria-current="<%= active ? "page" : "false" %>"
8
+ class="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium <%= active ? 'bg-gray-800 text-white dark:bg-gray-700' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white' %>">
9
+ <%= t("pgbus.workflows.tabs.#{status}") %>
10
+ </a>
11
+ <% end %>
12
+ </nav>
@@ -0,0 +1,79 @@
1
+ <%# Every attempt, newest first (issue #542): the text equivalent of the
2
+ timeline — the three durations, the worker and app revision, and the
3
+ error with its backtrace and cause chain under a <details>. %>
4
+ <section class="mb-8 rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700" aria-labelledby="workflow-steps-title" data-testid="workflow-steps">
5
+ <h2 id="workflow-steps-title" class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 text-lg font-medium text-gray-900 dark:text-white"><%= t("pgbus.workflows.steps.title") %></h2>
6
+ <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
7
+ <thead class="bg-gray-50 dark:bg-gray-900">
8
+ <tr>
9
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.step") %></th>
10
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.status") %></th>
11
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.scheduled") %></th>
12
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.started") %></th>
13
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.finished") %></th>
14
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.waited") %></th>
15
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.queued") %></th>
16
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.ran") %></th>
17
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.steps.headers.worker") %></th>
18
+ </tr>
19
+ </thead>
20
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
21
+ <% @workflow[:steps].each do |step| %>
22
+ <% durations = step[:durations] || {} %>
23
+ <tr data-step-attempt="<%= step[:step_name] %>-<%= step[:attempt] %>" class="align-top hover:bg-gray-50 dark:hover:bg-gray-700/50">
24
+ <td data-label="<%= t("pgbus.workflows.steps.headers.step") %>" class="px-4 py-3 text-sm text-gray-900 dark:text-white">
25
+ <code class="font-mono"><%= step[:step_name] %></code>
26
+ <span class="text-gray-500 dark:text-gray-400">#<%= step[:attempt] %></span>
27
+ <% if step[:batch_id] %>
28
+ <span class="block text-xs"><%= pgbus_link_to t("pgbus.workflows.steps.batch", batch: step[:batch_id][0, 8]), pgbus.batch_path(step[:batch_id]), size: :sm %></span>
29
+ <% end %>
30
+ <% if step[:wait_for] %>
31
+ <span class="block text-xs text-gray-600 dark:text-gray-300"><%= t("pgbus.workflows.steps.waits_for_html", signal: tag.code(step[:wait_for], class: "font-mono")) %></span>
32
+ <% end %>
33
+ </td>
34
+ <td data-label="<%= t("pgbus.workflows.steps.headers.status") %>" class="px-4 py-3 text-sm max-w-md">
35
+ <%= pgbus_workflow_step_badge(step[:status]) %>
36
+ <% if step[:outcome] && step[:outcome] != step[:status] %>
37
+ <span class="ml-1 text-xs text-gray-600 dark:text-gray-300"><%= t("pgbus.workflows.outcomes.#{step[:outcome]}", default: step[:outcome].to_s.humanize) %></span>
38
+ <% end %>
39
+ <% if step[:error_class] %>
40
+ <p class="mt-1 font-mono text-xs text-red-700 dark:text-red-300"><%= step[:error_class] %></p>
41
+ <p class="text-xs text-gray-700 dark:text-gray-300 break-words"><%= step[:error_message] %></p>
42
+ <% if step[:error_backtrace].present? || step[:error_causes].any? %>
43
+ <details class="mt-1">
44
+ <summary class="inline-flex min-h-6 cursor-pointer items-center text-xs font-medium text-indigo-700 hover:text-indigo-900 dark:text-indigo-300 dark:hover:text-indigo-200"><%= t("pgbus.workflows.steps.show_backtrace") %></summary>
45
+ <% if step[:error_backtrace].present? %>
46
+ <pre tabindex="0" class="mt-1 max-h-64 overflow-auto rounded bg-gray-50 p-2 text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"><%= step[:error_backtrace] %></pre>
47
+ <% end %>
48
+ <% step[:error_causes].each do |cause| %>
49
+ <p class="mt-1 text-xs text-gray-700 dark:text-gray-300"><%= t("pgbus.workflows.steps.caused_by", error: "#{cause["class"]}: #{cause["message"]}") %></p>
50
+ <% end %>
51
+ </details>
52
+ <% end %>
53
+ <% end %>
54
+ </td>
55
+ <td data-label="<%= t("pgbus.workflows.steps.headers.scheduled") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"><%= pgbus_time(step[:scheduled_at]) %></td>
56
+ <td data-label="<%= t("pgbus.workflows.steps.headers.started") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"><%= pgbus_time(step[:started_at]) %></td>
57
+ <td data-label="<%= t("pgbus.workflows.steps.headers.finished") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"><%= pgbus_time(step[:finished_at]) %></td>
58
+ <td data-label="<%= t("pgbus.workflows.steps.headers.waited") %>" class="px-4 py-3 text-right font-mono text-sm text-gray-700 dark:text-gray-300">
59
+ <%= pgbus_duration(durations[:waited]) %>
60
+ <% if durations[:waiting] %>
61
+ <span class="block text-xs text-gray-600 dark:text-gray-300"><%= t("pgbus.workflows.steps.waiting_for", duration: pgbus_duration(durations[:waiting])) %></span>
62
+ <% end %>
63
+ </td>
64
+ <td data-label="<%= t("pgbus.workflows.steps.headers.queued") %>" class="px-4 py-3 text-right font-mono text-sm text-gray-700 dark:text-gray-300"><%= pgbus_duration(durations[:queued]) %></td>
65
+ <td data-label="<%= t("pgbus.workflows.steps.headers.ran") %>" class="px-4 py-3 text-right font-mono text-sm text-gray-700 dark:text-gray-300"><%= pgbus_duration(durations[:ran]) %></td>
66
+ <td data-label="<%= t("pgbus.workflows.steps.headers.worker") %>" class="px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
67
+ <span class="block font-mono"><%= step[:worker] || Pgbus::Web::TimeFormat::NONE %></span>
68
+ <% if step[:app_revision] %>
69
+ <span class="block"><%= t("pgbus.workflows.steps.revision", revision: step[:app_revision]) %></span>
70
+ <% end %>
71
+ </td>
72
+ </tr>
73
+ <% end %>
74
+ <% if @workflow[:steps].empty? %>
75
+ <%= render "pgbus/shared/empty_row", colspan: 9, message: t("pgbus.workflows.steps.empty") %>
76
+ <% end %>
77
+ </tbody>
78
+ </table>
79
+ </section>
@@ -0,0 +1,49 @@
1
+ <%# The timeline (issue #542): one range-bar row per attempt, split into
2
+ waited, queued and ran (ran coloured by how the attempt ended), with the
3
+ waits and pauses as their own segments and a dashed line per missed wait
4
+ deadline. charts.js renderTimeline draws
5
+ it from the payload embedded once below; the attempts table under it is
6
+ the full text alternative. %>
7
+ <% timeline = @workflow[:timeline] %>
8
+ <section class="mb-8 rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700" aria-labelledby="workflow-timeline-title">
9
+ <h2 id="workflow-timeline-title" class="text-lg font-medium text-gray-900 dark:text-white"><%= t("pgbus.workflows.timeline.title") %></h2>
10
+ <% if timeline[:rows].empty? %>
11
+ <p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.timeline.empty") %></p>
12
+ <% else %>
13
+ <% rows = timeline[:rows].size + (timeline[:paused].any? ? 1 : 0) %>
14
+ <figure class="mt-3">
15
+ <div id="workflow-timeline-chart" role="img" aria-label="<%= t("pgbus.workflows.timeline.caption") %>"
16
+ style="min-height: <%= [160, (rows * 36) + 90].max %>px;"></div>
17
+ <figcaption class="mt-3 text-sm text-gray-700 dark:text-gray-300"><%= t("pgbus.workflows.timeline.caption") %></figcaption>
18
+ </figure>
19
+ <%# The pauses as text: the chart's paused row, for those who cannot see it. %>
20
+ <% if timeline[:paused].any? %>
21
+ <ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-300" data-testid="workflow-pauses">
22
+ <% timeline[:paused].each do |period| %>
23
+ <li><%= t("pgbus.workflows.timeline.paused_period_html", from: pgbus_absolute_time(Time.at(period[:from] / 1000.0)),
24
+ duration: period[:duration]) %></li>
25
+ <% end %>
26
+ </ul>
27
+ <% end %>
28
+ <%# The missed wait deadlines as text: the chart's dashed lines (issue #565). %>
29
+ <% if timeline[:wait_timeouts].any? %>
30
+ <ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-300" data-testid="workflow-wait-timeouts">
31
+ <% timeline[:wait_timeouts].each do |mark| %>
32
+ <li><%= t("pgbus.workflows.timeline.wait_timeout_html", step: tag.code(mark[:step], class: "font-mono"),
33
+ time: pgbus_absolute_time(Time.at(mark[:at] / 1000.0)), action: tag.code(mark[:action], class: "font-mono")) %></li>
34
+ <% end %>
35
+ </ul>
36
+ <% end %>
37
+ <%# json_escape keeps a value from closing the tag; a data block needs no nonce. %>
38
+ <%= tag.script(json_escape(timeline.to_json).html_safe, type: "application/json", id: "pgbus-workflow-timeline-data") %> <%# herb:disable erb-no-unsafe-raw %>
39
+ <script type="module" nonce="<%= content_security_policy_nonce %>">
40
+ import { renderTimeline, observeThemeChanges } from "charts";
41
+
42
+ const i18n = <%= json_escape(pgbus_workflow_chart_i18n.to_json).html_safe %>;
43
+ const data = JSON.parse(document.getElementById("pgbus-workflow-timeline-data").textContent);
44
+
45
+ renderTimeline(data, i18n);
46
+ observeThemeChanges(() => data, i18n, renderTimeline);
47
+ </script>
48
+ <% end %>
49
+ </section>
@@ -0,0 +1,59 @@
1
+ <%# The workflow list (issue #542). Each row says, in words, what the run is
2
+ doing and what happens next (Web::WorkflowState). Auto-refreshes; the
3
+ pager keeps the status and the filters. %>
4
+ <turbo-frame id="workflows-list" data-auto-refresh data-turbo-action="advance"
5
+ data-src="<%= pgbus_workflows_path_with(@query, page: (@page if @page > 1), frame: "list") %>">
6
+ <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
7
+ <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
8
+ <thead class="bg-gray-50 dark:bg-gray-900">
9
+ <tr>
10
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.headers.workflow") %></th>
11
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.headers.subject") %></th>
12
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.headers.status") %></th>
13
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.headers.current_step") %></th>
14
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.headers.started") %></th>
15
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.headers.last_activity") %></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
19
+ <% @workflows.each do |row| %>
20
+ <% result = pgbus_workflow_state(row) %>
21
+ <tr data-workflow-id="<%= row[:workflow_id] %>" data-status="<%= row[:status] %>" class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
22
+ <td data-label="<%= t("pgbus.workflows.headers.workflow") %>" class="px-4 py-3 text-sm">
23
+ <div><%= pgbus_link_to row[:workflow_class], pgbus.workflow_path(row[:workflow_id]), data: { turbo_frame: "_top" } %></div>
24
+ <div class="font-mono text-xs text-gray-500 dark:text-gray-400"><%= row[:workflow_id][0, 8] %></div>
25
+ </td>
26
+ <td data-label="<%= t("pgbus.workflows.headers.subject") %>" class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
27
+ <%= pgbus_workflow_subject(row) %>
28
+ </td>
29
+ <td data-label="<%= t("pgbus.workflows.headers.status") %>" class="px-4 py-3 max-w-md">
30
+ <%= pgbus_workflow_badge(result) %>
31
+ <p class="mt-1 text-xs text-gray-600 dark:text-gray-300"><%= pgbus_workflow_next(row, result) %></p>
32
+ </td>
33
+ <td data-label="<%= t("pgbus.workflows.headers.current_step") %>" class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
34
+ <code class="font-mono"><%= row[:current_step] || Pgbus::Web::TimeFormat::NONE %></code>
35
+ <% if row[:current_attempt].to_i > 1 %>
36
+ <span class="block text-xs text-gray-500 dark:text-gray-400"><%= t("pgbus.workflows.attempt", attempt: row[:current_attempt]) %></span>
37
+ <% end %>
38
+ </td>
39
+ <td data-label="<%= t("pgbus.workflows.headers.started") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
40
+ <%= pgbus_time(row[:created_at]) %>
41
+ </td>
42
+ <td data-label="<%= t("pgbus.workflows.headers.last_activity") %>" class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
43
+ <%= pgbus_time(row[:updated_at]) %>
44
+ </td>
45
+ </tr>
46
+ <% end %>
47
+ <% if @workflows.empty? %>
48
+ <%= render "pgbus/shared/empty_row", colspan: 6,
49
+ message: safe_join([content_tag(:p, t("pgbus.workflows.index.empty_title"), class: "text-lg font-medium"),
50
+ content_tag(:p, t("pgbus.workflows.index.empty_hint"), class: "mt-1")]) %>
51
+ <% end %>
52
+ </tbody>
53
+ </table>
54
+ <%= render "pgbus/shared/pager", page: @page, per_page: @per_page, shown: @workflows.size,
55
+ total_count: @workflows_count.total,
56
+ has_more: @workflows_count.capped? && @workflows.size == @per_page,
57
+ page_path: ->(n) { pgbus_workflows_path_with(@query, page: n) } %>
58
+ </div>
59
+ </turbo-frame>