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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +97 -7
- data/Rakefile +6 -1
- data/app/controllers/pgbus/dashboard_controller.rb +2 -0
- data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
- data/app/controllers/pgbus/workflows_controller.rb +197 -0
- data/app/frontend/pgbus/modules/charts.js +148 -4
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/style.css +1 -1
- data/app/helpers/pgbus/button_helper.rb +4 -2
- data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
- data/app/helpers/pgbus/workflows_helper.rb +165 -0
- data/app/models/pgbus/blocked_execution.rb +12 -0
- data/app/models/pgbus/uniqueness_key.rb +88 -3
- data/app/models/pgbus/workflow_entry.rb +172 -0
- data/app/models/pgbus/workflow_event.rb +43 -0
- data/app/models/pgbus/workflow_signal.rb +77 -0
- data/app/models/pgbus/workflow_step.rb +149 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
- data/app/views/pgbus/workflows/_actions.html.erb +59 -0
- data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
- data/app/views/pgbus/workflows/_filters.html.erb +29 -0
- data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
- data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
- data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
- data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +49 -0
- data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
- data/app/views/pgbus/workflows/attention.html.erb +88 -0
- data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
- data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
- data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
- data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
- data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
- data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
- data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
- data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
- data/app/views/pgbus/workflows/index.html.erb +15 -0
- data/app/views/pgbus/workflows/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/show.html.erb +93 -0
- data/config/i18n-tasks.yml +6 -0
- data/config/locales/da.yml +445 -0
- data/config/locales/de.yml +445 -0
- data/config/locales/en.yml +445 -0
- data/config/locales/es.yml +445 -0
- data/config/locales/fi.yml +445 -0
- data/config/locales/fr.yml +445 -0
- data/config/locales/it.yml +445 -0
- data/config/locales/ja.yml +445 -0
- data/config/locales/nb.yml +445 -0
- data/config/locales/nl.yml +445 -0
- data/config/locales/pt.yml +445 -0
- data/config/locales/sv.yml +445 -0
- data/config/routes.rb +26 -0
- data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
- data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
- data/lib/pgbus/active_job/adapter.rb +81 -3
- data/lib/pgbus/active_job/delivery.rb +63 -0
- data/lib/pgbus/active_job/executor.rb +20 -8
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +64 -4
- data/lib/pgbus/dead_letter_header.rb +9 -1
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +33 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
- data/lib/pgbus/mcp/rack_app.rb +7 -2
- data/lib/pgbus/mcp/runner.rb +2 -1
- data/lib/pgbus/mcp/serialization.rb +25 -0
- data/lib/pgbus/mcp/server.rb +53 -10
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
- data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
- data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
- data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
- data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
- data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
- data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
- data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
- data/lib/pgbus/mcp/write_tool.rb +134 -0
- data/lib/pgbus/mcp.rb +15 -0
- data/lib/pgbus/metrics/backends/statsd.rb +6 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +75 -0
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/dispatcher.rb +39 -3
- data/lib/pgbus/process/worker.rb +10 -1
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/streams/presence.rb +65 -36
- data/lib/pgbus/uniqueness.rb +23 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
- data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
- data/lib/pgbus/web/data_source/workflow_list.rb +291 -0
- data/lib/pgbus/web/data_source.rb +126 -15
- data/lib/pgbus/web/workflow_action_plan.rb +62 -0
- data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
- data/lib/pgbus/web/workflow_insights_report.rb +171 -0
- data/lib/pgbus/web/workflow_payload.rb +36 -0
- data/lib/pgbus/web/workflow_query.rb +82 -0
- data/lib/pgbus/web/workflow_state.rb +89 -0
- data/lib/pgbus/web/workflow_timeline.rb +130 -0
- data/lib/pgbus/web/workflow_window.rb +51 -0
- data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
- data/lib/pgbus/workflow/dsl.rb +105 -0
- data/lib/pgbus/workflow/error_capture.rb +63 -0
- data/lib/pgbus/workflow/exception_policy.rb +170 -0
- data/lib/pgbus/workflow/fan_out.rb +157 -0
- data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
- data/lib/pgbus/workflow/flow_control.rb +94 -0
- data/lib/pgbus/workflow/instance.rb +183 -0
- data/lib/pgbus/workflow/outside_verbs.rb +138 -0
- data/lib/pgbus/workflow/payload.rb +53 -0
- data/lib/pgbus/workflow/registry.rb +53 -0
- data/lib/pgbus/workflow/removed_step.rb +14 -0
- data/lib/pgbus/workflow/runner.rb +218 -0
- data/lib/pgbus/workflow/signal_matcher.rb +53 -0
- data/lib/pgbus/workflow/signals.rb +132 -0
- data/lib/pgbus/workflow/states.rb +119 -0
- data/lib/pgbus/workflow/step_context.rb +58 -0
- data/lib/pgbus/workflow/step_definition.rb +133 -0
- data/lib/pgbus/workflow/step_execution.rb +242 -0
- data/lib/pgbus/workflow/step_job.rb +28 -0
- data/lib/pgbus/workflow/step_outcome.rb +151 -0
- data/lib/pgbus/workflow/steps.rb +103 -0
- data/lib/pgbus/workflow/sweep.rb +156 -0
- data/lib/pgbus/workflow/test_helpers.rb +152 -0
- data/lib/pgbus/workflow/wait_timeout.rb +94 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +23 -0
- 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>
|