plutonium 0.68.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
- data/CHANGELOG.md +20 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +3 -1
- data/config/locales/en/async.yml +11 -1
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/reference/behavior/async-interactions.md +62 -5
- data/docs/reference/generators/lite.md +30 -6
- data/docs/reference/i18n.md +1 -1
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
- data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
- data/gemfiles/rails_7.gemfile.lock +5 -5
- data/gemfiles/rails_8.0.gemfile.lock +3 -3
- data/gemfiles/rails_8.1.gemfile.lock +3 -3
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
- data/lib/plutonium/definition/presentable.rb +4 -1
- data/lib/plutonium/interaction/async/configuration.rb +15 -0
- data/lib/plutonium/interaction/async/context.rb +12 -2
- data/lib/plutonium/interaction/async/executor.rb +54 -0
- data/lib/plutonium/interaction/async/run.rb +128 -14
- data/lib/plutonium/interaction/async/run_definition.rb +5 -4
- data/lib/plutonium/interaction/async/run_policy.rb +5 -5
- data/lib/plutonium/interaction/response/redirect.rb +19 -7
- data/lib/plutonium/ui/breadcrumbs.rb +26 -8
- data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
- data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
- data/lib/plutonium/ui/layout/base.rb +0 -9
- data/lib/plutonium/ui/page/index.rb +23 -4
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +9 -0
- data/src/css/font_awesome.css +2328 -0
- data/src/css/plutonium.css +1 -0
- metadata +6 -2
|
@@ -27,6 +27,15 @@ module Plutonium
|
|
|
27
27
|
IN_PROGRESS_STATES = %w[pending running].freeze
|
|
28
28
|
FAILURE_POLICIES = %i[halt continue transactional].freeze
|
|
29
29
|
|
|
30
|
+
# Tags the errors_log entries a pre_check rejection writes, which is what
|
|
31
|
+
# #outcome reads "rejected" from. A key in the existing JSON rather than a
|
|
32
|
+
# column: older entries simply lack it.
|
|
33
|
+
PRE_CHECK_STAGE = "pre_check"
|
|
34
|
+
|
|
35
|
+
# Raised by #reject called anywhere but inside pre_check, where nothing
|
|
36
|
+
# would collect the rejection.
|
|
37
|
+
class RejectOutsidePreCheckError < StandardError; end
|
|
38
|
+
|
|
30
39
|
# Routes, paths and helpers are all derived from model_name, and the
|
|
31
40
|
# default would spell the gem's own namespace out in every URL
|
|
32
41
|
# (/admin/plutonium/interaction/runs) and every helper
|
|
@@ -65,6 +74,7 @@ module Plutonium
|
|
|
65
74
|
|
|
66
75
|
scope :in_progress, -> { where(state: IN_PROGRESS_STATES) }
|
|
67
76
|
scope :for_target, ->(klass) { where(target_type: klass.to_s) }
|
|
77
|
+
scope :finished_since, ->(time) { where.not(state: IN_PROGRESS_STATES).where(finished_at: time..) }
|
|
68
78
|
|
|
69
79
|
# In progress with no recorded activity since +before+ — see
|
|
70
80
|
# Async::ReapJob. +last_activity_at+ is nil until a job actually claims
|
|
@@ -130,9 +140,17 @@ module Plutonium
|
|
|
130
140
|
#
|
|
131
141
|
# @return [String]
|
|
132
142
|
def outcome
|
|
143
|
+
return "rejected" if rejected?
|
|
144
|
+
|
|
133
145
|
(state == "completed" && errors_log.any?) ? "completed_with_errors" : state
|
|
134
146
|
end
|
|
135
147
|
|
|
148
|
+
# Failed because its pre_check refused the input, as opposed to failing
|
|
149
|
+
# while doing the work. Nothing was processed. state stays "failed", so
|
|
150
|
+
# every reader keyed on state (ReapJob, in_progress, the banner) is
|
|
151
|
+
# unaffected.
|
|
152
|
+
def rejected? = state == "failed" && errors_log.any? { |entry| entry["stage"] == PRE_CHECK_STAGE }
|
|
153
|
+
|
|
136
154
|
# @return [Integer] recorded target failures, run-level entries included
|
|
137
155
|
def error_count = errors_log.size
|
|
138
156
|
|
|
@@ -153,23 +171,42 @@ module Plutonium
|
|
|
153
171
|
|
|
154
172
|
# Runs have no name or title, so Labeling would fall back to
|
|
155
173
|
# "Async run #12" — true but silent about the only thing that
|
|
156
|
-
# distinguishes one row from the next.
|
|
157
|
-
#
|
|
158
|
-
# Demodulized: a host's run class is as likely to be
|
|
159
|
-
# Billing::ReissueInvoicesRun as a top-level one, and the namespace adds
|
|
160
|
-
# nothing to a label that already sits under the run's own breadcrumb.
|
|
161
|
-
#
|
|
162
|
-
# Except when demodulizing is what throws the name away. A run declared
|
|
163
|
-
# by Dispatchable#async is Blogging::ArchivePosts::Run, and every
|
|
164
|
-
# one of them would render "Run #12" — the failure mode this method
|
|
165
|
-
# exists to avoid, reintroduced for the shape most authors will write.
|
|
166
|
-
# For those, the enclosing segment IS the name, so it is folded back in.
|
|
174
|
+
# distinguishes one row from the next. The dispatching interaction's
|
|
175
|
+
# label is that thing: "Import Posts #12".
|
|
167
176
|
#
|
|
168
177
|
# @return [String]
|
|
169
178
|
def to_label
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
I18n.t("plutonium.async.run.label", name: interaction_label, id: to_param)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The interaction's label (its `presents label:`), or a name derived from
|
|
183
|
+
# the run class for a run with no interaction behind it.
|
|
184
|
+
#
|
|
185
|
+
# @return [String]
|
|
186
|
+
def interaction_label
|
|
187
|
+
interaction_class&.label || run_class_label
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The interaction this run belongs to. A run declared by
|
|
191
|
+
# Dispatchable#async is <Interaction>::Run, so it is the enclosing class.
|
|
192
|
+
# nil for a standalone run class, which may be shared by several
|
|
193
|
+
# interactions and so belongs to none of them.
|
|
194
|
+
#
|
|
195
|
+
# @return [Class, nil]
|
|
196
|
+
def interaction_class
|
|
197
|
+
parent = self.class.module_parent
|
|
198
|
+
parent if parent < Plutonium::Interaction::Base
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The index's progress cell: "1 / 4", or nil for opaque work. Two
|
|
202
|
+
# counter columns showed opaque work as "0" done of nothing, which reads
|
|
203
|
+
# as a run that did nothing rather than one with no count to report.
|
|
204
|
+
#
|
|
205
|
+
# @return [String, nil]
|
|
206
|
+
def progress
|
|
207
|
+
return nil if progress_total.nil?
|
|
208
|
+
|
|
209
|
+
I18n.t("plutonium.async.run.progress", done: progress_done, total: progress_total)
|
|
173
210
|
end
|
|
174
211
|
|
|
175
212
|
# nil means INDETERMINATE, not zero: opaque work has no denominator, and
|
|
@@ -222,6 +259,53 @@ module Plutonium
|
|
|
222
259
|
)
|
|
223
260
|
end
|
|
224
261
|
|
|
262
|
+
# Fails the run with its pre_check's rejections, in ONE write, for the
|
|
263
|
+
# reason record_target_failures! gives: a crash between two writes must
|
|
264
|
+
# not leave a failed run without its reasons, or reasons on a run still
|
|
265
|
+
# marked running. Entries carry the run-level nil target_id, so progress
|
|
266
|
+
# and handled_target_ids are untouched: nothing was processed.
|
|
267
|
+
#
|
|
268
|
+
# @param messages [Array<String>] the rejections that were stored
|
|
269
|
+
# @param overflow [Integer] how many more were rejected past max_rejections
|
|
270
|
+
def reject_run!(messages, overflow: 0)
|
|
271
|
+
entries = messages.map { |message| pre_check_entry(message) }
|
|
272
|
+
entries << pre_check_entry(I18n.t("plutonium.async.executor.rejections_truncated", count: overflow)) if overflow.positive?
|
|
273
|
+
|
|
274
|
+
now = Time.current
|
|
275
|
+
update!(errors_log: errors_log + entries, state: "failed", finished_at: now, last_activity_at: now)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Runs the block with #reject live, and closes it again afterwards.
|
|
279
|
+
# Called by Async::Executor around pre_check.
|
|
280
|
+
#
|
|
281
|
+
# @return [Array(Array<String>, Integer)] the stored messages, and how
|
|
282
|
+
# many more were rejected past max_rejections
|
|
283
|
+
def collect_rejections
|
|
284
|
+
@rejections = []
|
|
285
|
+
@rejection_count = 0
|
|
286
|
+
yield
|
|
287
|
+
[@rejections, @rejection_count - @rejections.size]
|
|
288
|
+
ensure
|
|
289
|
+
@rejections = @rejection_count = nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Records one problem with the run's input and returns, so a check can
|
|
293
|
+
# report every bad row in one pass. Only live inside pre_check: a
|
|
294
|
+
# rejection from perform would be collected by nothing.
|
|
295
|
+
#
|
|
296
|
+
# Counts past max_rejections but stores no more than that, so memory
|
|
297
|
+
# stays bounded and the "and N more" count is exact.
|
|
298
|
+
#
|
|
299
|
+
# @param message [String]
|
|
300
|
+
# @return [void]
|
|
301
|
+
def reject(message)
|
|
302
|
+
raise RejectOutsidePreCheckError, I18n.t("plutonium.async.run.reject_outside_pre_check", run_class: self.class) if @rejections.nil?
|
|
303
|
+
|
|
304
|
+
@rejection_count += 1
|
|
305
|
+
@rejections << message.to_s if @rejections.size < Plutonium.configuration.async_interactions.max_rejections
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
|
|
225
309
|
# Target ids not yet dispositioned — what a resumed run still has to
|
|
226
310
|
# do. See {#record_target_failures!} and Async::Executor#advance!, which
|
|
227
311
|
# are what populate handled_target_ids.
|
|
@@ -232,6 +316,16 @@ module Plutonium
|
|
|
232
316
|
target_ids.reject { |id| handled.include?(id.to_s) }
|
|
233
317
|
end
|
|
234
318
|
|
|
319
|
+
# Whether an earlier pass got as far as applying a target, which it can
|
|
320
|
+
# only have done after its pre_check passed. A handled id with no
|
|
321
|
+
# errors_log entry was applied; failures (unresolved or raised) all carry one.
|
|
322
|
+
#
|
|
323
|
+
# @return [Boolean]
|
|
324
|
+
def any_target_applied?
|
|
325
|
+
failed = errors_log.filter_map { |entry| entry["target_id"]&.to_s }.to_set
|
|
326
|
+
handled_target_ids.any? { |id| !failed.include?(id.to_s) }
|
|
327
|
+
end
|
|
328
|
+
|
|
235
329
|
# The interaction's validated inputs, with the types dispatch put in.
|
|
236
330
|
#
|
|
237
331
|
# The column is JSON, so Dispatchable#dispatch_options writes it through
|
|
@@ -377,8 +471,28 @@ module Plutonium
|
|
|
377
471
|
"#perform_on(record) for work over targets, or #perform for opaque work"
|
|
378
472
|
end
|
|
379
473
|
|
|
474
|
+
# Checks the run's input before any work, and refuses it with #reject.
|
|
475
|
+
# Optional: the default checks nothing. Override it as
|
|
476
|
+
# +pre_check(records)+ in a run with +perform_on+ (it receives the
|
|
477
|
+
# resolved, authorized records), or +pre_check+ for opaque work.
|
|
478
|
+
# See Async::Executor#pre_check_passed?.
|
|
479
|
+
def pre_check(*) = nil
|
|
480
|
+
|
|
380
481
|
private
|
|
381
482
|
|
|
483
|
+
def pre_check_entry(message) = {"target_id" => nil, "message" => message, "stage" => PRE_CHECK_STAGE}
|
|
484
|
+
|
|
485
|
+
# For a standalone run class, passed as `async Billing::ReissueInvoicesRun`,
|
|
486
|
+
# which has no interaction around it to take a label from. Demodulized,
|
|
487
|
+
# since the namespace adds nothing under the run's own breadcrumb. A
|
|
488
|
+
# <Something>::Run that is not inside an interaction keeps the enclosing
|
|
489
|
+
# segment, or every one of them would read "Run".
|
|
490
|
+
def run_class_label
|
|
491
|
+
parts = self.class.name.split("::")
|
|
492
|
+
name = (parts.last == "Run" && parts.size > 1) ? "#{parts[-2]}Run" : parts.last
|
|
493
|
+
name.titleize
|
|
494
|
+
end
|
|
495
|
+
|
|
382
496
|
# Not swallowed: after_commit runs synchronously, so re-raising here
|
|
383
497
|
# still surfaces to the dispatching request rather than leaving a
|
|
384
498
|
# permanently invisible, un-enqueued ghost.
|
|
@@ -25,19 +25,20 @@ module Plutonium
|
|
|
25
25
|
# neither a type to infer nor a badge to infer one from (`state` is a
|
|
26
26
|
# plain string column with an inclusion validation, not a Rails enum).
|
|
27
27
|
#
|
|
28
|
-
# `running` and `
|
|
29
|
-
# is in the badge component's semantic table, and its fallback is a
|
|
28
|
+
# `running`, `completed_with_errors` and `rejected` are coloured explicitly:
|
|
29
|
+
# none is in the badge component's semantic table, and its fallback is a
|
|
30
30
|
# stable-but-meaningless colour.
|
|
31
31
|
field :outcome, as: :string
|
|
32
32
|
display :outcome, as: :badge,
|
|
33
|
-
colors: {running: :info, completed_with_errors: :warning}
|
|
33
|
+
colors: {running: :info, completed_with_errors: :warning, rejected: :warning}
|
|
34
34
|
# Declared for the table too: the index is where an operator scans for the
|
|
35
35
|
# run that did not do what it was asked, and a bare
|
|
36
36
|
# "completed_with_errors" string is the one place that reads worse than
|
|
37
37
|
# the badge.
|
|
38
38
|
column :outcome, as: :badge,
|
|
39
|
-
colors: {running: :info, completed_with_errors: :warning}
|
|
39
|
+
colors: {running: :info, completed_with_errors: :warning, rejected: :warning}
|
|
40
40
|
|
|
41
|
+
field :interaction_label, label: t("plutonium.async.run.interaction")
|
|
41
42
|
field :target_label, label: t("plutonium.async.run.target_type")
|
|
42
43
|
|
|
43
44
|
# The details tab, with the live panel above the fields.
|
|
@@ -63,22 +63,22 @@ module Plutonium
|
|
|
63
63
|
# unreadably.
|
|
64
64
|
def permitted_attributes_for_read
|
|
65
65
|
%i[
|
|
66
|
-
|
|
66
|
+
interaction_label outcome progress
|
|
67
67
|
target_label initiator started_at finished_at created_at
|
|
68
68
|
]
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
# The show page drops the progress
|
|
71
|
+
# The show page drops the progress cell, for the reason errors_log
|
|
72
72
|
# is absent above: the panel already renders them, as "5 of 5 targets
|
|
73
73
|
# (100%)" over a bar. Two renderings of one number is the same report
|
|
74
74
|
# twice — and they disagree, because only the panel is inside the polled
|
|
75
75
|
# frame. A page that says "Completed" and "0 done" at once is worse than
|
|
76
76
|
# either alone.
|
|
77
77
|
#
|
|
78
|
-
# The INDEX keeps
|
|
79
|
-
#
|
|
78
|
+
# The INDEX keeps it. There is no progress bar in a table row, so the
|
|
79
|
+
# cell is the only progress it can show.
|
|
80
80
|
def permitted_attributes_for_show
|
|
81
|
-
permitted_attributes_for_read - %i[
|
|
81
|
+
permitted_attributes_for_read - %i[progress]
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
end
|
|
@@ -24,10 +24,13 @@ module Plutonium
|
|
|
24
24
|
# appending the options hash raises ArgumentError rather than
|
|
25
25
|
# producing a formatted URL. A caller handing over a complete URL has
|
|
26
26
|
# already decided what it points at.
|
|
27
|
+
#
|
|
28
|
+
# Also skipped for a turbo-stream request: it is answered below with a
|
|
29
|
+
# visit, and a visit wants the page, not a .turbo_stream URL.
|
|
27
30
|
fully_formed_url = redirect_args.size == 1 && redirect_args.first.is_a?(String)
|
|
28
31
|
|
|
29
32
|
url_options = redirect_args.last.is_a?(Hash) ? redirect_args.last : {}
|
|
30
|
-
if !fully_formed_url && !url_options.key?(:format) && request.format.symbol
|
|
33
|
+
if !fully_formed_url && !url_options.key?(:format) && !request.format.symbol.in?(%i[html turbo_stream])
|
|
31
34
|
url_options = url_options.merge(format: request.format.symbol)
|
|
32
35
|
redirect_args = [*redirect_args[0...-1], url_options] if redirect_args.last.is_a?(Hash)
|
|
33
36
|
redirect_args = [*redirect_args, url_options] unless redirect_args.last.is_a?(Hash)
|
|
@@ -36,14 +39,23 @@ module Plutonium
|
|
|
36
39
|
url = fully_formed_url ? redirect_args.first : url_for(*redirect_args)
|
|
37
40
|
|
|
38
41
|
respond_to do |format|
|
|
42
|
+
# Answered with a stream action wherever the form lives, the way
|
|
43
|
+
# every other Plutonium success path answers a turbo-stream post.
|
|
44
|
+
# A plain redirect_to here is followed by fetch and its page
|
|
45
|
+
# rendered into whatever frame submitted the form.
|
|
46
|
+
#
|
|
47
|
+
# turbo_stream_redirect is a client-side visit, so it skips the
|
|
48
|
+
# open-redirect check redirect_to applies. It is applied here the
|
|
49
|
+
# same way, with redirect_to's own default for allow_other_host:
|
|
50
|
+
# resolved up front from raise_on_open_redirects before Rails 8.1,
|
|
51
|
+
# left nil from 8.1 on, where the check resolves it and nil also
|
|
52
|
+
# drives action_on_open_redirect (:log / :notify).
|
|
39
53
|
format.turbo_stream do
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
helpers.turbo_stream_redirect(url)
|
|
43
|
-
]
|
|
44
|
-
else
|
|
45
|
-
redirect_to(url, **redirect_options)
|
|
54
|
+
allow_other_host = redirect_options.fetch(:allow_other_host) do
|
|
55
|
+
_allow_other_host if ActionPack.gem_version < Gem::Version.new("8.1")
|
|
46
56
|
end
|
|
57
|
+
_enforce_open_redirect_protection(url, allow_other_host:)
|
|
58
|
+
render turbo_stream: helpers.turbo_stream_redirect(url)
|
|
47
59
|
end
|
|
48
60
|
|
|
49
61
|
format.any { redirect_to(url, **redirect_options) }
|
|
@@ -48,10 +48,24 @@ module Plutonium
|
|
|
48
48
|
def breadcrumb_items
|
|
49
49
|
@breadcrumb_items ||= [].tap do |items|
|
|
50
50
|
collect_parent_breadcrumbs(items) if current_parent.present?
|
|
51
|
-
|
|
51
|
+
if resource_record?
|
|
52
|
+
collect_resource_breadcrumbs(items)
|
|
53
|
+
elsif collection_page?
|
|
54
|
+
collect_resource_index_breadcrumb(items)
|
|
55
|
+
end
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
|
|
59
|
+
# A page that hangs off the resource list without being about one record:
|
|
60
|
+
# a resource action (import), a wizard launched from the index, or a
|
|
61
|
+
# custom collection route (calendar). It has no record to name, but the
|
|
62
|
+
# list it came from is still a level up. The index itself is excluded
|
|
63
|
+
# because its page title already says where you are, and a singular
|
|
64
|
+
# resource has no index to point back to.
|
|
65
|
+
def collection_page?
|
|
66
|
+
action_name != "index" && !singular_resource_context?
|
|
67
|
+
end
|
|
68
|
+
|
|
55
69
|
# The segments eligible to be folded into the overflow menu. The last one
|
|
56
70
|
# is never foldable — it is the deepest thing the trail knows about, which
|
|
57
71
|
# is the record on new/edit pages but the resource index on show pages,
|
|
@@ -151,21 +165,25 @@ module Plutonium
|
|
|
151
165
|
end
|
|
152
166
|
|
|
153
167
|
def collect_plural_resource_breadcrumbs(items)
|
|
168
|
+
collect_resource_index_breadcrumb(items)
|
|
169
|
+
|
|
170
|
+
# Record itself (for non-singular routes only)
|
|
171
|
+
return unless resource_record!.persisted? && action_name != "show"
|
|
172
|
+
|
|
173
|
+
items << segment(display_name_of(resource_record!), resource_url_for(resource_record!))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def collect_resource_index_breadcrumb(items)
|
|
154
177
|
# No `parent:` here on purpose: Resource::Controller#resource_url_args_for
|
|
155
178
|
# already defaults it to `current_parent` (and supplies
|
|
156
|
-
# `current_nested_association` with it), so
|
|
179
|
+
# `current_nested_association` with it), so the link nests itself
|
|
157
180
|
# when the request arrived through a nesting. Passing it explicitly reads
|
|
158
|
-
# like it changes something; it doesn't. The parent segments
|
|
181
|
+
# like it changes something; it doesn't. The parent segments pass
|
|
159
182
|
# `parent: nil` precisely to opt OUT of that default.
|
|
160
183
|
items << segment(
|
|
161
184
|
nestable_resource_name_plural(resource_class),
|
|
162
185
|
resource_url_for(resource_class)
|
|
163
186
|
)
|
|
164
|
-
|
|
165
|
-
# Record itself (for non-singular routes only)
|
|
166
|
-
return unless resource_record!.persisted? && action_name != "show"
|
|
167
|
-
|
|
168
|
-
items << segment(display_name_of(resource_record!), resource_url_for(resource_record!))
|
|
169
187
|
end
|
|
170
188
|
|
|
171
189
|
# Stands in for whatever the controller folded away, the way GitHub
|
|
@@ -43,6 +43,8 @@ module Plutonium
|
|
|
43
43
|
"running" => "info",
|
|
44
44
|
"completed" => "success",
|
|
45
45
|
"completed_with_errors" => "warning",
|
|
46
|
+
# The input was refused, nothing broke: see Run#rejected?.
|
|
47
|
+
"rejected" => "warning",
|
|
46
48
|
"failed" => "danger"
|
|
47
49
|
}.freeze
|
|
48
50
|
|
|
@@ -83,7 +85,9 @@ module Plutonium
|
|
|
83
85
|
div(class: "pu-card pu-run-progress", **poll_attributes) do
|
|
84
86
|
div(class: "pu-card-body space-y-4") do
|
|
85
87
|
render_status
|
|
86
|
-
|
|
88
|
+
# A rejected run never started: an empty progress line beside
|
|
89
|
+
# "Nothing was processed" would say the same thing twice.
|
|
90
|
+
run.rejected? ? render_rejected : render_progress
|
|
87
91
|
render_failures if run.errors_log.any?
|
|
88
92
|
end
|
|
89
93
|
end
|
|
@@ -123,15 +127,24 @@ module Plutonium
|
|
|
123
127
|
# The count rides alongside the badge rather than only in the list
|
|
124
128
|
# below: a partially-applied :continue run ends as "completed", and
|
|
125
129
|
# the one thing a reader must not be able to miss is that it did not
|
|
126
|
-
# do everything it was asked to.
|
|
130
|
+
# do everything it was asked to. Muted, because the alert below
|
|
131
|
+
# carries the colour (danger or warning) for what the problems mean.
|
|
127
132
|
if run.error_count.positive?
|
|
128
|
-
span(class: "text-xs font-medium text-[var(--pu-text-
|
|
133
|
+
span(class: "text-xs font-medium text-[var(--pu-text-muted)]") do
|
|
129
134
|
plain t("plutonium.async.progress.error_count", count: run.error_count)
|
|
130
135
|
end
|
|
131
136
|
end
|
|
132
137
|
end
|
|
133
138
|
end
|
|
134
139
|
|
|
140
|
+
# In place of the progress line: "0 of 50 targets" reads as a run that
|
|
141
|
+
# died at its first target. A rejected run never started, and says so.
|
|
142
|
+
def render_rejected
|
|
143
|
+
div(class: "text-xs text-[var(--pu-text-muted)]") do
|
|
144
|
+
plain t("plutonium.async.progress.rejected")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
135
148
|
# NEVER rendered without {#render_status} above it. After a refused
|
|
136
149
|
# partial batch (:halt / :transactional), progress_done counts targets
|
|
137
150
|
# DISPOSITIONED, not applied — so a run that performed nothing at all can
|
|
@@ -201,19 +214,20 @@ module Plutonium
|
|
|
201
214
|
((Time.current - started) * 1000).to_i % SWEEP_DURATION_MS
|
|
202
215
|
end
|
|
203
216
|
|
|
217
|
+
# The framework's inline alert, so the problems read like every other
|
|
218
|
+
# message Plutonium shows rather than as a panel of their own.
|
|
204
219
|
def render_failures
|
|
205
|
-
div(class: "
|
|
206
|
-
|
|
207
|
-
plain
|
|
208
|
-
end
|
|
209
|
-
ul(class: "pu-run-progress-failures text-xs space-y-1") do
|
|
210
|
-
run.errors_log.each do |entry|
|
|
211
|
-
li(class: "text-[var(--pu-text-danger)]") { plain failure_text(entry) }
|
|
212
|
-
end
|
|
220
|
+
div(class: "pu-alert pu-alert-#{failures_variant} pu-run-progress-failures", role: "alert") do
|
|
221
|
+
ul(class: "pu-alert-message") do
|
|
222
|
+
run.errors_log.each { |entry| li { plain failure_text(entry) } }
|
|
213
223
|
end
|
|
214
224
|
end
|
|
215
225
|
end
|
|
216
226
|
|
|
227
|
+
# Danger only for a run that broke. Refused input (rejected) and partial
|
|
228
|
+
# application (completed_with_errors) are warnings, matching the badge.
|
|
229
|
+
def failures_variant = (run.outcome == "failed") ? "danger" : "warning"
|
|
230
|
+
|
|
217
231
|
# A nil target_id is the RUN-LEVEL sentinel (see AsyncRun#record_target_failure!),
|
|
218
232
|
# not a target whose id happens to be blank.
|
|
219
233
|
def failure_text(entry)
|
|
@@ -4,7 +4,9 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Interaction
|
|
6
6
|
module Async
|
|
7
|
-
# Lists the runs currently working on THIS resource, above its collection
|
|
7
|
+
# Lists the runs currently working on THIS resource, above its collection,
|
|
8
|
+
# followed by the viewer's own runs that finished within
|
|
9
|
+
# +recent_runs_window+ (see Page::Index#render_running_banner).
|
|
8
10
|
#
|
|
9
11
|
# == Why it exists
|
|
10
12
|
#
|
|
@@ -16,8 +18,9 @@ module Plutonium
|
|
|
16
18
|
#
|
|
17
19
|
# +target_type+ is a real column rather than a key inside the +options+
|
|
18
20
|
# JSON precisely so this predicate can be indexed — see the migration's own
|
|
19
|
-
# comment. An opaque run
|
|
20
|
-
#
|
|
21
|
+
# comment. An opaque run dispatched from a resource still records that
|
|
22
|
+
# resource as its +target_type+, so it shows on that resource's index; one
|
|
23
|
+
# dispatched from nowhere has none and appears on no resource index.
|
|
21
24
|
#
|
|
22
25
|
# == Why it can render nothing
|
|
23
26
|
#
|
|
@@ -51,13 +54,26 @@ module Plutonium
|
|
|
51
54
|
# own page renders the pair.
|
|
52
55
|
span(class: "font-medium text-[var(--pu-text)]") { plain run.to_label }
|
|
53
56
|
plain " "
|
|
54
|
-
|
|
57
|
+
run.in_progress? ? render_state(run) : render_outcome(run)
|
|
55
58
|
end
|
|
56
59
|
a(href: resource_url_for(run), class: "pu-btn pu-btn-xs pu-btn-soft-primary shrink-0") do
|
|
57
|
-
plain t("plutonium.async.banner.view_progress")
|
|
60
|
+
plain t(run.in_progress? ? "plutonium.async.banner.view_progress" : "plutonium.async.banner.view_run")
|
|
58
61
|
end
|
|
59
62
|
end
|
|
60
63
|
end
|
|
64
|
+
|
|
65
|
+
def render_state(run)
|
|
66
|
+
span(class: "text-[var(--pu-text-muted)]") { plain(Plutonium::Translation.value_label(run.class, :state, run.state) || run.state.humanize) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A finished run is listed for what it ended as, so outcome rather than
|
|
70
|
+
# state ("completed" hides a partial :continue run; "failed" hides a
|
|
71
|
+
# rejection), in the progress panel's colours.
|
|
72
|
+
def render_outcome(run)
|
|
73
|
+
span(class: "pu-badge pu-badge-#{RunProgress::BADGE_VARIANTS.fetch(run.outcome, "neutral")}") do
|
|
74
|
+
plain(Plutonium::Translation.value_label(run.class, :outcome, run.outcome) || run.outcome.humanize)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
61
77
|
end
|
|
62
78
|
end
|
|
63
79
|
end
|
|
@@ -244,15 +244,6 @@ module Plutonium
|
|
|
244
244
|
"sha384-uqD/OYCNfagd1EgXMgl5QedTD5K+B3e9b8GYo/41t7+Serf7CBxvl+tU1gHd+qd1",
|
|
245
245
|
crossorigin: "anonymous"
|
|
246
246
|
)
|
|
247
|
-
# EasyMDE's toolbar uses Font Awesome 4 class names (renamed in 5+).
|
|
248
|
-
link(
|
|
249
|
-
rel: "stylesheet",
|
|
250
|
-
href:
|
|
251
|
-
"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css",
|
|
252
|
-
integrity:
|
|
253
|
-
"sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN",
|
|
254
|
-
crossorigin: "anonymous"
|
|
255
|
-
)
|
|
256
247
|
end
|
|
257
248
|
|
|
258
249
|
def render_scripts
|
|
@@ -4,6 +4,11 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Page
|
|
6
6
|
class Index < Base
|
|
7
|
+
# How many recently finished runs the running banner lists at most. It
|
|
8
|
+
# sits above the collection; a burst of dispatches must not push the
|
|
9
|
+
# collection off the screen. The run index has the rest.
|
|
10
|
+
RECENT_RUNS_LIMIT = 5
|
|
11
|
+
|
|
7
12
|
# Cookie name carrying a per-resource view preference. Single
|
|
8
13
|
# source of truth — Table::Resource, Grid::Resource, and the
|
|
9
14
|
# Stimulus view-switcher controller all read from here. Underscored
|
|
@@ -101,12 +106,26 @@ module Plutonium
|
|
|
101
106
|
# a URL for a route that doesn't exist here.
|
|
102
107
|
return unless registered_resources.include?(Plutonium::Interaction::Async::Run)
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
relation:
|
|
109
|
+
run_class = Plutonium::Interaction::Async::Run
|
|
110
|
+
in_progress = authorized_resource_scope(
|
|
111
|
+
run_class, relation: run_class.for_target(resource_class).in_progress
|
|
112
|
+
).to_a
|
|
113
|
+
|
|
114
|
+
# The current user's own runs that settled moments ago. A dispatch
|
|
115
|
+
# sends them back here, and a short run (or one its pre_check rejected)
|
|
116
|
+
# finishes before this page renders, so "in progress" alone would show
|
|
117
|
+
# them nothing. Only their own: someone else's finished run is not news.
|
|
118
|
+
recent = authorized_resource_scope(
|
|
119
|
+
run_class,
|
|
120
|
+
relation: run_class.for_target(resource_class)
|
|
121
|
+
.where(initiator: current_user)
|
|
122
|
+
.finished_since(Plutonium.configuration.async_interactions.recent_runs_window.ago)
|
|
123
|
+
.order(finished_at: :desc)
|
|
124
|
+
.limit(RECENT_RUNS_LIMIT)
|
|
107
125
|
).to_a
|
|
108
126
|
|
|
109
|
-
|
|
127
|
+
# uniq: a run that finishes between the two queries is in both.
|
|
128
|
+
render Plutonium::UI::Interaction::Async::RunningBanner.new(runs: (in_progress + recent).uniq)
|
|
110
129
|
end
|
|
111
130
|
|
|
112
131
|
def selected_view
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -1222,6 +1222,15 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
1222
1222
|
margin-left: var(--pu-drag-marker-gap, 4px);
|
|
1223
1223
|
}
|
|
1224
1224
|
|
|
1225
|
+
/* The failures on an async run's progress panel: an inline alert holding a
|
|
1226
|
+
list, so it sits flush in the card and the list keeps its bullets. */
|
|
1227
|
+
.pu-run-progress-failures {
|
|
1228
|
+
@apply mb-0 items-start;
|
|
1229
|
+
}
|
|
1230
|
+
.pu-run-progress-failures .pu-alert-message {
|
|
1231
|
+
@apply ms-0 ps-4 list-disc space-y-1;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1225
1234
|
/* Indeterminate progress for an async run with no denominator (opaque work).
|
|
1226
1235
|
A short segment sweeping the track: the motion is what says "alive", which a
|
|
1227
1236
|
static bar at any width cannot, and a full-width bar would misread as done. */
|