active_storage-async_variants 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +23 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/progress-bar.js +269 -0
- data/app/assets/javascripts/retry.js +11 -0
- data/app/assets/stylesheets/progress.css +30 -0
- data/app/assets/stylesheets/retry.css +98 -0
- data/app/controllers/active_storage/async_variants/callbacks_controller.rb +4 -0
- data/app/controllers/active_storage/async_variants/states_controller.rb +16 -0
- data/app/views/active_storage/async_variants/states/_failed.html.erb +41 -2
- data/app/views/active_storage/async_variants/states/_processing.html.erb +10 -4
- data/app/views/active_storage/async_variants/states/show.html.erb +2 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb +8 -0
- data/features/retry_flow.feature +20 -0
- data/features/retry_visibility.feature +23 -0
- data/features/state_rendering.feature +18 -0
- data/features/step_definitions/dummy_steps.rb +24 -0
- data/gemfiles/rails_7.2.gemfile +4 -0
- data/gemfiles/rails_8.0.gemfile +4 -0
- data/gemfiles/rails_8.1.gemfile +4 -0
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +3 -6
- data/lib/active_storage/async_variants/heartbeat_watchdog_job.rb +40 -0
- data/lib/active_storage/async_variants/preview_extension.rb +12 -0
- data/lib/active_storage/async_variants/process_job.rb +7 -0
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +12 -0
- data/lib/active_storage/async_variants/version.rb +1 -1
- data/lib/active_storage/async_variants.rb +28 -0
- metadata +24 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3d828692f86ff7b409b93f3198daae20c5c6821a0cce10da31b625abe7c04be
|
|
4
|
+
data.tar.gz: '0319ea7873525ea5d76a930e195687b3ba28bebd58922e3fc4aa821238241f28'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9e80ebc6059bf84853cf7bf05d824ed5d25d5b195a4702d86495cdb29366eb02ee390558768fa66a1f5ea85e8e86987a46f9c7a2492de59e1a7287b25ef7074a
|
|
7
|
+
data.tar.gz: 23dc024c9a1ceaa334580d32af52f20e4e327f3d40d22e438616bdc0077f5b62f95deb50670ee5a30b3a1e72716e2ef9604bd6eabe2fc3a699aaa21142518ad8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.6.0]
|
|
2
|
+
|
|
3
|
+
- External transforms can now report progress: a `progress` callback records the percent complete and a heartbeat, readable via `#progress` / `#progress_known?` on variants and previews.
|
|
4
|
+
- Processing variants render a progress bar — indeterminate until progress is reported, then a determinate percentage — served as a cached asset. The turbo-frame poll interval now follows `heartbeat_interval` (default 5s) so each refresh lands on fresh progress.
|
|
5
|
+
- A stalled external transform (no heartbeat within `heartbeat_stale_after`, default 60s) is now marked failed automatically — and therefore retryable — instead of hanging in "processing" forever.
|
|
6
|
+
- Added an `ActiveStorage::AsyncVariants.configure { |config| … }` block for setting all options in one place (see the README's Configuration section).
|
|
7
|
+
- Includes a migration adding the `progress` and `last_heartbeat_at` columns; run it before deploying.
|
|
8
|
+
|
|
9
|
+
## [0.5.0]
|
|
10
|
+
|
|
11
|
+
- Added an opt-in retry affordance to the failed state: hovering a failed variant reveals a control that opens a dialog with the error and a "Retry processing" button that re-runs the transform. Disabled by default; enable it with `ActiveStorage::AsyncVariants.retry_visible_if { … }` (the block runs in the view context, so it can check `current_user`).
|
|
12
|
+
|
|
1
13
|
## [0.4.0]
|
|
2
14
|
|
|
3
15
|
- **Breaking:** Replaced the polling-`<img>` architecture with a `<turbo-frame>` for unprocessed variants. `image_tag`/`video_tag` with `async: true` now emits a normal `<img>`/`<video>` when the variant is already processed, and a `<turbo-frame src="…">` otherwise. The frame hits a new gem-shipped endpoint (`GET /active_storage/async_variants/states/:signed_blob_id/:variation_key`) that renders one of `_processing`/`_failed`/`_processed` partials. Non-terminal renders include an inline `<script>` that schedules the frame's next reload, so the state polls itself until it terminates.
|
data/README.md
CHANGED
|
@@ -101,6 +101,29 @@ The resulting URL is `"#{cdn_host}/#{variant.key}"`.
|
|
|
101
101
|
|
|
102
102
|
No manual wiring is required. The async state partials are self-contained `<turbo-frame>`s. The only requirement is that the host app loads **Turbo** -- the gem depends on `turbo-rails`, which a default Rails app already includes.
|
|
103
103
|
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
Set options in an initializer. The `configure` block groups them (each is also a plain accessor, e.g. `ActiveStorage::AsyncVariants.cdn_host = …`):
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# config/initializers/active_storage_async_variants.rb
|
|
110
|
+
ActiveStorage::AsyncVariants.configure do |config|
|
|
111
|
+
config.cdn_host = "https://d1234abcd.cloudfront.net"
|
|
112
|
+
config.heartbeat_interval = 5.seconds
|
|
113
|
+
config.heartbeat_stale_after = 60.seconds
|
|
114
|
+
config.parent_controller = "ApplicationController"
|
|
115
|
+
config.retry_visible_if { current_user&.admin? }
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Option | Default | Purpose |
|
|
120
|
+
|--------|---------|---------|
|
|
121
|
+
| `cdn_host` | `nil` | Host for `direct:` URLs (`"#{cdn_host}/#{variant.key}"`); falls back to the storage service URL. |
|
|
122
|
+
| `heartbeat_interval` | `5.seconds` | Expected cadence of progress heartbeats; the processing `<turbo-frame>` re-polls at this rate. |
|
|
123
|
+
| `heartbeat_stale_after` | `60.seconds` | A processing variant with no heartbeat for this long is marked `failed`. Must exceed `heartbeat_interval`. |
|
|
124
|
+
| `parent_controller` | `"ActionController::Base"` | Base class for the gem's controllers, so the retry view can reach your app's `current_user`. Set as a String. |
|
|
125
|
+
| `retry_visible_if` | off | Block (run in the view context) gating the failed-state retry affordance. |
|
|
126
|
+
|
|
104
127
|
## Writing a Transformer
|
|
105
128
|
|
|
106
129
|
Transformers come in two flavors: **inline** (the job blocks until processing completes) and **external** (the job kicks off remote work and a webhook signals completion).
|
data/Rakefile
CHANGED
|
@@ -13,4 +13,15 @@ end
|
|
|
13
13
|
desc "Run rspec and cucumber"
|
|
14
14
|
task all: [:spec, :cucumber]
|
|
15
15
|
|
|
16
|
+
namespace :vendor do
|
|
17
|
+
desc "Pull the latest @botandrose/progress-bar from unpkg.com into app/assets"
|
|
18
|
+
task :progress_bar do
|
|
19
|
+
require "open-uri"
|
|
20
|
+
url = "https://unpkg.com/@botandrose/progress-bar"
|
|
21
|
+
dest = File.expand_path("app/assets/javascripts/progress-bar.js", __dir__)
|
|
22
|
+
File.write(dest, URI.open(url).read)
|
|
23
|
+
puts "Vendored #{url} -> #{dest}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
task default: :all
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
const CIRCLE_RADIUS = 40; // fallback r attribute; CSS shrinks it to fit the stroke
|
|
2
|
+
const PATH_LENGTH = 100; // normalize the arc so dash math is independent of the radius
|
|
3
|
+
|
|
4
|
+
const STYLES = `
|
|
5
|
+
:host {
|
|
6
|
+
--progress-color: #2E7D32;
|
|
7
|
+
--error-color: #7a242f;
|
|
8
|
+
--indeterminate-color: #999;
|
|
9
|
+
--track-color: #333333;
|
|
10
|
+
--progress-duration: 120ms;
|
|
11
|
+
--indeterminate-duration: 1.5s;
|
|
12
|
+
--bar-height: 32px;
|
|
13
|
+
--bar-padding: 8px;
|
|
14
|
+
--circular-size: 64px;
|
|
15
|
+
--circular-thickness: 16;
|
|
16
|
+
display: block;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
background: var(--track-color);
|
|
19
|
+
border: 1px solid #999;
|
|
20
|
+
border-radius: 4px;
|
|
21
|
+
min-height: var(--bar-height);
|
|
22
|
+
padding: var(--bar-padding);
|
|
23
|
+
font-size: 13px;
|
|
24
|
+
align-content: center;
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
position: relative;
|
|
27
|
+
}
|
|
28
|
+
.bar {
|
|
29
|
+
position: absolute;
|
|
30
|
+
top: 0;
|
|
31
|
+
left: 0;
|
|
32
|
+
height: 100%;
|
|
33
|
+
width: 0%;
|
|
34
|
+
background: var(--progress-color);
|
|
35
|
+
transition: width var(--progress-duration) ease;
|
|
36
|
+
z-index: 1;
|
|
37
|
+
}
|
|
38
|
+
.text{position: relative; z-index: 2;}
|
|
39
|
+
:host([error]) .bar {
|
|
40
|
+
background: var(--error-color);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Indeterminate (linear): no percent set — a fixed-width segment sweeping across the track. */
|
|
44
|
+
:host(:not([percent])) .bar {
|
|
45
|
+
background: var(--indeterminate-color);
|
|
46
|
+
width: 40%;
|
|
47
|
+
animation: indeterminate-linear var(--indeterminate-duration) infinite linear;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@keyframes indeterminate-linear {
|
|
51
|
+
0% { transform: translateX(-100%); }
|
|
52
|
+
100% { transform: translateX(250%); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Circular mode: the host box styling is for the linear track, so drop it. */
|
|
56
|
+
:host([mode="circular"]) {
|
|
57
|
+
background: transparent;
|
|
58
|
+
border: none;
|
|
59
|
+
overflow: visible;
|
|
60
|
+
padding: 0;
|
|
61
|
+
min-height: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.circular {
|
|
65
|
+
position: relative;
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
width: var(--circular-size);
|
|
68
|
+
height: var(--circular-size);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ring {
|
|
72
|
+
width: 100%;
|
|
73
|
+
height: 100%;
|
|
74
|
+
transform: rotate(-90deg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ring circle {
|
|
78
|
+
fill: none;
|
|
79
|
+
stroke-width: var(--circular-thickness);
|
|
80
|
+
/* Shrink the radius so the stroke's outer edge always lands inside the
|
|
81
|
+
100x100 viewBox, no matter how thick --circular-thickness is. */
|
|
82
|
+
r: calc(49px - var(--circular-thickness) * 0.5px);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.track {
|
|
86
|
+
stroke: var(--track-color);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.progress-ring {
|
|
90
|
+
stroke: var(--progress-color);
|
|
91
|
+
stroke-linecap: round;
|
|
92
|
+
stroke-dasharray: ${PATH_LENGTH};
|
|
93
|
+
stroke-dashoffset: ${PATH_LENGTH};
|
|
94
|
+
transition: stroke-dashoffset var(--progress-duration) ease;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
:host(:not([percent])) .progress-ring {
|
|
98
|
+
stroke: var(--indeterminate-color);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
:host([error]) .progress-ring {
|
|
102
|
+
stroke: var(--error-color);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.label {
|
|
106
|
+
position: absolute;
|
|
107
|
+
inset: 0;
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
justify-content: center;
|
|
111
|
+
color: #fff;
|
|
112
|
+
mix-blend-mode: difference;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* When the host carries its own inline styling (e.g. a color override), opt
|
|
116
|
+
out of the blend trick and just inherit, so the override wins predictably. */
|
|
117
|
+
:host([style]) .label {
|
|
118
|
+
mix-blend-mode: initial;
|
|
119
|
+
color: inherit;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Indeterminate (circular): no percent set — spin a fixed arc around the ring. */
|
|
123
|
+
:host(:not([percent])) .ring {
|
|
124
|
+
animation: indeterminate-rotate var(--indeterminate-duration) infinite linear;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
:host(:not([percent])) .progress-ring {
|
|
128
|
+
stroke-dashoffset: ${PATH_LENGTH * 0.75};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@keyframes indeterminate-rotate {
|
|
132
|
+
0% { transform: rotate(-90deg); }
|
|
133
|
+
100% { transform: rotate(270deg); }
|
|
134
|
+
}
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
const LINEAR_HTML = `
|
|
138
|
+
<div class="bar"></div>
|
|
139
|
+
<div class="text"><slot></slot></div>
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
const CIRCULAR_HTML = `
|
|
143
|
+
<div class="circular">
|
|
144
|
+
<svg class="ring" viewBox="0 0 100 100">
|
|
145
|
+
<circle class="track" cx="50" cy="50" r="${CIRCLE_RADIUS}"></circle>
|
|
146
|
+
<circle class="progress-ring" cx="50" cy="50" r="${CIRCLE_RADIUS}" pathLength="${PATH_LENGTH}"></circle>
|
|
147
|
+
</svg>
|
|
148
|
+
<span class="label"><slot></slot></span>
|
|
149
|
+
</div>
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
let styleSheet = null;
|
|
153
|
+
function getStyleSheet() {
|
|
154
|
+
if (!styleSheet) {
|
|
155
|
+
styleSheet = new CSSStyleSheet();
|
|
156
|
+
styleSheet.replaceSync(STYLES);
|
|
157
|
+
}
|
|
158
|
+
return styleSheet;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function clampPercent(value) {
|
|
162
|
+
const number = Number(value);
|
|
163
|
+
if (Number.isNaN(number)) {
|
|
164
|
+
throw new TypeError(`progress-bar: percent must be numeric, got ${JSON.stringify(value)}`);
|
|
165
|
+
}
|
|
166
|
+
return Math.min(100, Math.max(0, number));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
class ProgressBar extends HTMLElement {
|
|
170
|
+
constructor() {
|
|
171
|
+
super();
|
|
172
|
+
this.attachShadow({ mode: 'open' });
|
|
173
|
+
this._percent = null;
|
|
174
|
+
this._renderedMode = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
connectedCallback() {
|
|
178
|
+
this.render();
|
|
179
|
+
this.setAttribute('role', 'progressbar');
|
|
180
|
+
this.setAttribute('aria-valuemin', '0');
|
|
181
|
+
this.setAttribute('aria-valuemax', '100');
|
|
182
|
+
this.updateBar();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get percent() {
|
|
186
|
+
return this._percent;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
set percent(value) {
|
|
190
|
+
if (value === null || value === undefined) {
|
|
191
|
+
this.removeAttribute('percent');
|
|
192
|
+
} else {
|
|
193
|
+
this.setAttribute('percent', clampPercent(value));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
get error() {
|
|
198
|
+
return this.hasAttribute('error');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
set error(value) {
|
|
202
|
+
this.toggleAttribute('error', Boolean(value));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get indeterminate() {
|
|
206
|
+
return !this.hasAttribute('percent');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get mode() {
|
|
210
|
+
return this.getAttribute('mode') === 'circular' ? 'circular' : 'linear';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
set mode(value) {
|
|
214
|
+
this.setAttribute('mode', value);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
static get observedAttributes() {
|
|
218
|
+
return ['percent', 'mode'];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
222
|
+
if (name === 'percent') {
|
|
223
|
+
this._percent = newValue === null ? null : clampPercent(newValue);
|
|
224
|
+
}
|
|
225
|
+
if (name === 'mode') {
|
|
226
|
+
this.render();
|
|
227
|
+
}
|
|
228
|
+
this.updateBar();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
updateBar() {
|
|
232
|
+
if (this.mode === 'circular') {
|
|
233
|
+
const ring = this.shadowRoot?.querySelector('.progress-ring');
|
|
234
|
+
if (ring) {
|
|
235
|
+
ring.style.strokeDashoffset = this.indeterminate
|
|
236
|
+
? ''
|
|
237
|
+
: String(PATH_LENGTH * (1 - this._percent / 100));
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
const bar = this.shadowRoot?.querySelector('.bar');
|
|
241
|
+
if (bar) {
|
|
242
|
+
// Leave width to the indeterminate CSS animation when unknown.
|
|
243
|
+
bar.style.width = this.indeterminate ? '' : `${this._percent}%`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (this.indeterminate) {
|
|
248
|
+
this.removeAttribute('aria-valuenow');
|
|
249
|
+
} else {
|
|
250
|
+
this.setAttribute('aria-valuenow', String(this._percent));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
render() {
|
|
255
|
+
const mode = this.mode;
|
|
256
|
+
if (this._renderedMode === mode) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this._renderedMode = mode;
|
|
260
|
+
this.shadowRoot.adoptedStyleSheets = [getStyleSheet()];
|
|
261
|
+
this.shadowRoot.innerHTML = mode === 'circular' ? CIRCULAR_HTML : LINEAR_HTML;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!customElements.get('progress-bar')) {
|
|
266
|
+
customElements.define('progress-bar', ProgressBar);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export default ProgressBar;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Declarative shadow roots are only attached by the initial HTML parser, not by
|
|
2
|
+
// fragment insertion (innerHTML / Turbo frame swaps), so promote them on connect.
|
|
3
|
+
customElements.define("async-variant-retry", class extends HTMLElement {
|
|
4
|
+
connectedCallback() {
|
|
5
|
+
if (this.shadowRoot) return
|
|
6
|
+
const template = this.querySelector(":scope > template[shadowrootmode]")
|
|
7
|
+
if (!template) return
|
|
8
|
+
this.attachShadow({ mode: template.getAttribute("shadowrootmode") }).append(template.content)
|
|
9
|
+
template.remove()
|
|
10
|
+
}
|
|
11
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* turbo-frame is inline by default; make it and the wrapper block-level so the
|
|
2
|
+
media box (and the progress bar centered on it) coincides with the parent
|
|
3
|
+
figure box, instead of sitting in a taller line box from the baseline gap --
|
|
4
|
+
otherwise an overlay centered on the figure lands off-center from the ring. */
|
|
5
|
+
turbo-frame:has(> .async-variant-processing) {
|
|
6
|
+
display: block;
|
|
7
|
+
}
|
|
8
|
+
.async-variant-processing {
|
|
9
|
+
position: relative;
|
|
10
|
+
display: block;
|
|
11
|
+
}
|
|
12
|
+
/* Present to reserve the processed media's box, but rendered invisible via
|
|
13
|
+
opacity rather than visibility/display -- so it still has layout and stays
|
|
14
|
+
queryable by Capybara. The spinner floats over it. block drops the baseline
|
|
15
|
+
descender so the ring centers on the media, not a few px below it. */
|
|
16
|
+
.async-variant-processing img,
|
|
17
|
+
.async-variant-processing video {
|
|
18
|
+
opacity: 0.001;
|
|
19
|
+
display: block;
|
|
20
|
+
}
|
|
21
|
+
.async-variant-progress {
|
|
22
|
+
position: absolute;
|
|
23
|
+
top: 50%;
|
|
24
|
+
left: 50%;
|
|
25
|
+
transform: translate(-50%, -50%);
|
|
26
|
+
--circular-size: 80px;
|
|
27
|
+
--progress-color: #2E7D32;
|
|
28
|
+
--indeterminate-color: #2E7D32;
|
|
29
|
+
--track-color: rgba(0, 0, 0, 0.25);
|
|
30
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* Scoped to the retry dialog's shadow root (linked inside its <template>). */
|
|
2
|
+
:host { display: contents; }
|
|
3
|
+
|
|
4
|
+
button { font: inherit; cursor: pointer; }
|
|
5
|
+
|
|
6
|
+
.opener {
|
|
7
|
+
position: absolute;
|
|
8
|
+
top: 6px; right: 6px;
|
|
9
|
+
width: 22px; height: 22px;
|
|
10
|
+
background: #2b7cd6;
|
|
11
|
+
color: #fff;
|
|
12
|
+
border: 2px solid #fff;
|
|
13
|
+
border-radius: 50%;
|
|
14
|
+
font-size: 13px;
|
|
15
|
+
font-weight: bold;
|
|
16
|
+
line-height: 1;
|
|
17
|
+
padding: 0;
|
|
18
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
|
19
|
+
display: var(--retry-opener-display, none);
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
}
|
|
23
|
+
.opener:hover { background: #1a5fb0; }
|
|
24
|
+
|
|
25
|
+
dialog {
|
|
26
|
+
width: 720px;
|
|
27
|
+
max-width: 90vw;
|
|
28
|
+
max-height: 80vh;
|
|
29
|
+
padding: 0;
|
|
30
|
+
border: 1px solid #999;
|
|
31
|
+
border-radius: 6px;
|
|
32
|
+
background: #fff;
|
|
33
|
+
color: #222;
|
|
34
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
35
|
+
font: 13px/1.4 system-ui, -apple-system, sans-serif;
|
|
36
|
+
}
|
|
37
|
+
dialog[open] { display: flex; flex-direction: column; }
|
|
38
|
+
dialog::backdrop { background: rgba(0, 0, 0, 0.5); }
|
|
39
|
+
|
|
40
|
+
dialog > button {
|
|
41
|
+
position: absolute;
|
|
42
|
+
top: 10px;
|
|
43
|
+
right: 12px;
|
|
44
|
+
width: 32px; height: 32px;
|
|
45
|
+
background: transparent;
|
|
46
|
+
border: none;
|
|
47
|
+
color: #888;
|
|
48
|
+
font-size: 24px;
|
|
49
|
+
line-height: 1;
|
|
50
|
+
padding: 0;
|
|
51
|
+
border-radius: 4px;
|
|
52
|
+
z-index: 1;
|
|
53
|
+
}
|
|
54
|
+
dialog > button:hover { color: #000; background: rgba(0, 0, 0, 0.08); }
|
|
55
|
+
|
|
56
|
+
header { padding: 20px 24px 0 24px; }
|
|
57
|
+
header h3 {
|
|
58
|
+
margin: 0;
|
|
59
|
+
font-size: 16px;
|
|
60
|
+
font-weight: bold;
|
|
61
|
+
color: #111;
|
|
62
|
+
padding-right: 32px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
section {
|
|
66
|
+
flex: 1 1 auto;
|
|
67
|
+
overflow: auto;
|
|
68
|
+
padding: 12px 24px 20px 24px;
|
|
69
|
+
min-height: 0;
|
|
70
|
+
}
|
|
71
|
+
section pre {
|
|
72
|
+
background: #f5f5f5;
|
|
73
|
+
color: #222;
|
|
74
|
+
border: 1px solid #ddd;
|
|
75
|
+
padding: 8px;
|
|
76
|
+
font: 11px/1.4 ui-monospace, Menlo, monospace;
|
|
77
|
+
white-space: pre-wrap;
|
|
78
|
+
word-break: break-all;
|
|
79
|
+
margin: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
footer {
|
|
83
|
+
flex: 0 0 auto;
|
|
84
|
+
border-top: 1px solid #ddd;
|
|
85
|
+
padding: 12px 20px;
|
|
86
|
+
display: flex;
|
|
87
|
+
justify-content: flex-end;
|
|
88
|
+
background: #fafafa;
|
|
89
|
+
}
|
|
90
|
+
footer button {
|
|
91
|
+
padding: 8px 16px;
|
|
92
|
+
background: #2a7;
|
|
93
|
+
color: #fff;
|
|
94
|
+
border: none;
|
|
95
|
+
border-radius: 4px;
|
|
96
|
+
font-weight: bold;
|
|
97
|
+
}
|
|
98
|
+
footer button:disabled { opacity: 0.5; cursor: wait; }
|
|
@@ -11,6 +11,10 @@ module ActiveStorage
|
|
|
11
11
|
when "success"
|
|
12
12
|
variant_record.update!(state: "processed")
|
|
13
13
|
apply_reported_metadata(variant_record, params)
|
|
14
|
+
when "progress"
|
|
15
|
+
ActiveStorage::VariantRecord
|
|
16
|
+
.where(id: variant_record.id, state: %w[pending processing])
|
|
17
|
+
.update_all(progress: params[:percent].to_i.clamp(0, 100), last_heartbeat_at: Time.current)
|
|
14
18
|
when "failed"
|
|
15
19
|
# error column is TEXT (64KB); utf8mb4 is up to 4 bytes/char, so cap at 16k chars.
|
|
16
20
|
variant_record.update!(state: "failed", error: params[:error].to_s.truncate(16_000))
|
|
@@ -16,6 +16,22 @@ module ActiveStorage
|
|
|
16
16
|
def show
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def retry
|
|
20
|
+
@variant.blob.variant_records.where(
|
|
21
|
+
variation_digest: @variant.variation.digest,
|
|
22
|
+
state: "failed",
|
|
23
|
+
).destroy_all
|
|
24
|
+
@variant.enqueue!
|
|
25
|
+
|
|
26
|
+
redirect_to Rails.application.routes.url_helpers.async_variant_state_path(
|
|
27
|
+
signed_blob_id: params[:signed_blob_id],
|
|
28
|
+
variation_key: params[:variation_key],
|
|
29
|
+
kind: params[:kind],
|
|
30
|
+
direct: params[:direct],
|
|
31
|
+
opts: async_variant_html_options.presence,
|
|
32
|
+
), status: :see_other
|
|
33
|
+
end
|
|
34
|
+
|
|
19
35
|
helper_method :async_variant_kind, :async_variant_direct?, :async_variant_html_options
|
|
20
36
|
|
|
21
37
|
private
|
|
@@ -1,4 +1,43 @@
|
|
|
1
1
|
<div class="async-variant-state async-variant-failed">
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
<%= image_tag async_variant_representation_path(variant), **html_options.symbolize_keys.except(:controls, :autoplay, :preload) %>
|
|
3
|
+
|
|
4
|
+
<% if ActiveStorage::AsyncVariants.retry_visible?(self) %>
|
|
5
|
+
<% dialog_id = "#{async_variant_frame_id(variant)}-error" %>
|
|
6
|
+
<% form_id = "#{dialog_id}-form" %>
|
|
7
|
+
<%# Light DOM, so the custom property inherits into the shadow root. %>
|
|
8
|
+
<style>
|
|
9
|
+
.async-variant-failed {
|
|
10
|
+
position: relative;
|
|
11
|
+
display: inline-block;
|
|
12
|
+
--retry-opener-display: none;
|
|
13
|
+
}
|
|
14
|
+
.async-variant-failed:hover { --retry-opener-display: inline-flex; }
|
|
15
|
+
</style>
|
|
16
|
+
<%# In the light DOM so its submit reaches Turbo, and so preventDefault on the
|
|
17
|
+
buttons doesn't also cancel the submit. %>
|
|
18
|
+
<form action="<%= Rails.application.routes.url_helpers.async_variant_state_retry_path(signed_blob_id:, variation_key:, kind:, direct:, opts: html_options) %>"
|
|
19
|
+
method="post"
|
|
20
|
+
id="<%= form_id %>"
|
|
21
|
+
data-turbo-frame="<%= async_variant_frame_id(variant) %>"
|
|
22
|
+
hidden></form>
|
|
23
|
+
<async-variant-retry>
|
|
24
|
+
<template shadowrootmode="open">
|
|
25
|
+
<%= ActiveStorage::AsyncVariants.stylesheet_link_tag "retry" %>
|
|
26
|
+
<button class="opener" type="button"
|
|
27
|
+
onclick="event.preventDefault(); event.stopPropagation(); this.getRootNode().getElementById('<%= dialog_id %>').showModal()"
|
|
28
|
+
title="Variant processing failed — click for details">?</button>
|
|
29
|
+
<dialog id="<%= dialog_id %>">
|
|
30
|
+
<button type="button"
|
|
31
|
+
onclick="event.preventDefault(); event.stopPropagation(); this.closest('dialog').close()">×</button>
|
|
32
|
+
<header><h3>Variant processing failed</h3></header>
|
|
33
|
+
<section><pre><%= variant.error %></pre></section>
|
|
34
|
+
<footer>
|
|
35
|
+
<button type="button"
|
|
36
|
+
onclick="event.preventDefault(); event.stopPropagation(); this.disabled = true; this.textContent = 'Retrying…'; document.getElementById('<%= form_id %>').requestSubmit()">Retry processing</button>
|
|
37
|
+
</footer>
|
|
38
|
+
</dialog>
|
|
39
|
+
</template>
|
|
40
|
+
</async-variant-retry>
|
|
41
|
+
<%= ActiveStorage::AsyncVariants.javascript_include_tag "retry", type: "module" %>
|
|
42
|
+
<% end %>
|
|
4
43
|
</div>
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
<div class="async-variant-state async-variant-processing">
|
|
2
2
|
<% method = kind == :video ? :video_tag : :image_tag %>
|
|
3
3
|
<%= send method, async_variant_representation_path(variant), **html_options.symbolize_keys %>
|
|
4
|
+
|
|
5
|
+
<%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress %>
|
|
4
6
|
</div>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
<%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
|
|
9
|
+
<%= ActiveStorage::AsyncVariants.javascript_include_tag "progress-bar", type: "module" %>
|
|
10
|
+
<% interval_ms = ActiveStorage::AsyncVariants.heartbeat_interval.in_seconds * 1000 %>
|
|
8
11
|
<script>
|
|
9
|
-
|
|
12
|
+
(() => {
|
|
13
|
+
const frame = document.currentScript?.closest("turbo-frame")
|
|
14
|
+
setTimeout(() => frame?.reload(), <%= interval_ms %>)
|
|
15
|
+
})()
|
|
10
16
|
</script>
|
data/config/routes.rb
CHANGED
|
@@ -8,4 +8,9 @@ Rails.application.routes.draw do
|
|
|
8
8
|
get "/active_storage/async_variants/states/:signed_blob_id/:variation_key",
|
|
9
9
|
to: "active_storage/async_variants/states#show",
|
|
10
10
|
as: :async_variant_state
|
|
11
|
+
post "/active_storage/async_variants/states/:signed_blob_id/:variation_key/retry",
|
|
12
|
+
to: "active_storage/async_variants/states#retry",
|
|
13
|
+
as: :async_variant_state_retry
|
|
14
|
+
|
|
15
|
+
ActiveStorage::AsyncVariants::Assets.draw(self, "/active_storage/async_variants/assets")
|
|
11
16
|
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddHeartbeatColumnsToVariantRecords < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
add_column :active_storage_variant_records, :progress, :integer, if_not_exists: true
|
|
6
|
+
add_column :active_storage_variant_records, :last_heartbeat_at, :datetime, if_not_exists: true
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Feature: Clicking the retry affordance opens a dialog and resubmits the variant
|
|
2
|
+
|
|
3
|
+
Background:
|
|
4
|
+
Given a user with an attached avatar
|
|
5
|
+
And the retry affordance is visible to everyone
|
|
6
|
+
|
|
7
|
+
Scenario: Clicking the opener reveals the error inside the shadow-DOM dialog
|
|
8
|
+
Given the avatar's :thumb_proc variant is in failed state with error "boom from upstream"
|
|
9
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
10
|
+
And I click the retry opener
|
|
11
|
+
Then the failure dialog should be open
|
|
12
|
+
And the failure dialog should contain "boom from upstream"
|
|
13
|
+
|
|
14
|
+
Scenario: Submitting Retry destroys the failed record and re-enqueues
|
|
15
|
+
Given the avatar's :thumb_proc variant is in failed state with error "boom"
|
|
16
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
17
|
+
And I click the retry opener
|
|
18
|
+
And I click "Retry processing"
|
|
19
|
+
Then the frame should render the processing state
|
|
20
|
+
And the page should not have navigated away
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Feature: The retry affordance is gated by ActiveStorage::AsyncVariants.retry_visible_if
|
|
2
|
+
|
|
3
|
+
Scenario: When retry_visible_if returns true, the retry chrome renders
|
|
4
|
+
Given a user with an attached avatar
|
|
5
|
+
And the retry affordance is visible to everyone
|
|
6
|
+
And the avatar's :thumb_proc variant is in failed state with error "boom"
|
|
7
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
8
|
+
Then the retry affordance should be visible
|
|
9
|
+
|
|
10
|
+
Scenario: When retry_visible_if returns false, the retry chrome is omitted
|
|
11
|
+
Given a user with an attached avatar
|
|
12
|
+
And the retry affordance is hidden
|
|
13
|
+
And the avatar's :thumb_proc variant is in failed state with error "boom"
|
|
14
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
15
|
+
Then the retry affordance should NOT be visible
|
|
16
|
+
|
|
17
|
+
Scenario: A proc that consults current_user gates the affordance per-viewer
|
|
18
|
+
Given a user with an attached avatar
|
|
19
|
+
And the retry affordance requires a signed-in user
|
|
20
|
+
And the avatar's :thumb_proc variant is in failed state with error "boom"
|
|
21
|
+
When I sign in as the user
|
|
22
|
+
And I visit the avatar page for the :thumb_proc variant
|
|
23
|
+
Then the retry affordance should be visible
|
|
@@ -13,8 +13,26 @@ Feature: image_tag with async: true emits the right markup per variant state
|
|
|
13
13
|
Then the page should contain a turbo-frame
|
|
14
14
|
And the frame should render the processing state
|
|
15
15
|
|
|
16
|
+
Scenario: A processing variant renders an indeterminate progress bar before any heartbeat
|
|
17
|
+
Given a user with an attached avatar
|
|
18
|
+
And the avatar's :thumb_proc variant is in processing state
|
|
19
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
20
|
+
Then the page should contain a turbo-frame
|
|
21
|
+
And the frame should render a progress bar
|
|
22
|
+
And the progress bar should be indeterminate
|
|
23
|
+
And the placeholder image should reserve layout
|
|
24
|
+
|
|
25
|
+
Scenario: A processing variant with reported progress renders a determinate progress bar
|
|
26
|
+
Given a user with an attached avatar
|
|
27
|
+
And the avatar's :thumb_proc variant is in processing state
|
|
28
|
+
And the avatar's :thumb_proc variant has reported 42% progress
|
|
29
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
30
|
+
Then the page should contain a turbo-frame
|
|
31
|
+
And the progress bar should show 42%
|
|
32
|
+
|
|
16
33
|
Scenario: A failed variant renders the failed partial inside the frame
|
|
17
34
|
Given a user with an attached avatar
|
|
35
|
+
And the retry affordance is visible to everyone
|
|
18
36
|
And the avatar's :thumb_proc variant is in failed state with error "boom"
|
|
19
37
|
When I visit the avatar page for the :thumb_proc variant
|
|
20
38
|
Then the page should contain a turbo-frame
|
|
@@ -14,6 +14,30 @@ Given /^the avatar's :(\w+) variant is in (pending|processing|processed|failed)
|
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
Given /^the avatar's :(\w+) variant has reported (\d+)% progress$/ do |variant_name, percent|
|
|
18
|
+
variant = @user.avatar.variant(variant_name.to_sym)
|
|
19
|
+
record = variant.blob.variant_records.find_by(variation_digest: variant.variation.digest)
|
|
20
|
+
record.update!(progress: percent.to_i)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Then /^the frame should render a progress bar$/ do
|
|
24
|
+
expect(page).to have_css("turbo-frame progress-bar", visible: :all)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Then /^the progress bar should be indeterminate$/ do
|
|
28
|
+
expect(page).to have_css("turbo-frame progress-bar:not([percent])", visible: :all)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Then /^the progress bar should show (\d+)%$/ do |percent|
|
|
32
|
+
expect(page).to have_css(%(turbo-frame progress-bar[percent="#{percent}"]), visible: :all)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Default (visible-only) matcher: passes only because the placeholder is hidden
|
|
36
|
+
# via opacity (not visibility/display), so Capybara still sees it.
|
|
37
|
+
Then /^the placeholder image should reserve layout$/ do
|
|
38
|
+
expect(page).to have_css("turbo-frame .async-variant-processing img")
|
|
39
|
+
end
|
|
40
|
+
|
|
17
41
|
Given /^the retry affordance is visible to everyone$/ do
|
|
18
42
|
ActiveStorage::AsyncVariants.retry_visible_if { true }
|
|
19
43
|
end
|
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.0.gemfile
CHANGED
data/gemfiles/rails_8.1.gemfile
CHANGED
|
@@ -33,10 +33,7 @@ module ActiveStorage
|
|
|
33
33
|
assert_async_variant!(variant)
|
|
34
34
|
|
|
35
35
|
if async && !async_variant_processed_inline?(variant)
|
|
36
|
-
async_variant_turbo_frame(variant, kind:, direct:, html_options: options)
|
|
37
|
-
# populate TurboFrame with a placeholder image/video, so there's valid markup right away
|
|
38
|
-
yield [async_variant_representation_path(variant), *rest], options.except(:data)
|
|
39
|
-
end
|
|
36
|
+
async_variant_turbo_frame(variant, kind:, direct:, html_options: options)
|
|
40
37
|
else
|
|
41
38
|
yield [async_variant_resolved_src(variant, direct:), *rest], options
|
|
42
39
|
end
|
|
@@ -48,13 +45,13 @@ module ActiveStorage
|
|
|
48
45
|
end
|
|
49
46
|
end
|
|
50
47
|
|
|
51
|
-
def async_variant_turbo_frame(variant, kind:, direct:, html_options
|
|
48
|
+
def async_variant_turbo_frame(variant, kind:, direct:, html_options:)
|
|
52
49
|
content_tag(
|
|
53
50
|
:"turbo-frame",
|
|
51
|
+
"",
|
|
54
52
|
id: async_variant_frame_id(variant),
|
|
55
53
|
src: async_variant_frame_src(variant, kind: kind, direct: direct, html_options: html_options),
|
|
56
54
|
refresh: "morph",
|
|
57
|
-
&block
|
|
58
55
|
)
|
|
59
56
|
end
|
|
60
57
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
# External transforms free the worker at `initiate` and report progress via
|
|
6
|
+
# heartbeats; if the service dies, no terminal callback arrives and the
|
|
7
|
+
# record would sit in "processing" forever. This flips it to "failed" once
|
|
8
|
+
# the heartbeats go stale, re-arming itself each tick until then.
|
|
9
|
+
class HeartbeatWatchdogJob < ActiveJob::Base
|
|
10
|
+
# A retry destroys the record, so a re-armed job has nothing left to watch.
|
|
11
|
+
discard_on ActiveJob::DeserializationError
|
|
12
|
+
|
|
13
|
+
def perform(variant_record)
|
|
14
|
+
stale_after = ActiveStorage::AsyncVariants.heartbeat_stale_after
|
|
15
|
+
active = ActiveStorage::VariantRecord.where(id: variant_record.id, state: %w[pending processing])
|
|
16
|
+
stale = active.where("last_heartbeat_at < ?", Time.current - stale_after)
|
|
17
|
+
|
|
18
|
+
# Atomic check-and-set: a success/progress callback landing in the same
|
|
19
|
+
# instant moves the row out of `stale` and wins, instead of being clobbered.
|
|
20
|
+
marked_failed_count = stale.update_all(
|
|
21
|
+
state: "failed",
|
|
22
|
+
error: "Transcoding stalled: no heartbeat for over #{stale_after.to_i}s",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if marked_failed_count.positive?
|
|
26
|
+
touch_consumers(variant_record)
|
|
27
|
+
elsif active.any?
|
|
28
|
+
self.class.set(wait: stale_after).perform_later(variant_record)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# update_all skips the terminal-state touch, so invalidate consumer caches by hand.
|
|
35
|
+
def touch_consumers(variant_record)
|
|
36
|
+
variant_record.blob.attachments.includes(:record).each { |a| a.record&.touch }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -52,6 +52,18 @@ module ActiveStorage
|
|
|
52
52
|
find_preview_variant_record&.state || "pending"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
def progress
|
|
56
|
+
find_preview_variant_record&.progress
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def progress_known?
|
|
60
|
+
!find_preview_variant_record&.progress.nil?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def last_heartbeat_at
|
|
64
|
+
find_preview_variant_record&.last_heartbeat_at
|
|
65
|
+
end
|
|
66
|
+
|
|
55
67
|
private
|
|
56
68
|
|
|
57
69
|
def async_preview?
|
|
@@ -79,6 +79,13 @@ module ActiveStorage
|
|
|
79
79
|
variant_record_id: variant_record.id,
|
|
80
80
|
**options,
|
|
81
81
|
)
|
|
82
|
+
|
|
83
|
+
# Seed the heartbeat so a transform that dies before its first heartbeat
|
|
84
|
+
# still goes stale, then arm the watchdog.
|
|
85
|
+
variant_record.touch(:last_heartbeat_at)
|
|
86
|
+
ActiveStorage::AsyncVariants::HeartbeatWatchdogJob
|
|
87
|
+
.set(wait: ActiveStorage::AsyncVariants.heartbeat_stale_after)
|
|
88
|
+
.perform_later(variant_record)
|
|
82
89
|
end
|
|
83
90
|
end
|
|
84
91
|
end
|
|
@@ -70,6 +70,18 @@ module ActiveStorage
|
|
|
70
70
|
async_record&.state || "pending"
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
def progress
|
|
74
|
+
async_record&.progress
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def progress_known?
|
|
78
|
+
!async_record&.progress.nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def last_heartbeat_at
|
|
82
|
+
async_record&.last_heartbeat_at
|
|
83
|
+
end
|
|
84
|
+
|
|
73
85
|
private
|
|
74
86
|
|
|
75
87
|
def resolved_async_options
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "turbo-rails"
|
|
4
|
+
require "isolate_assets"
|
|
4
5
|
require_relative "async_variants/version"
|
|
5
6
|
require_relative "async_variants/helper"
|
|
6
7
|
require_relative "async_variants/transformer"
|
|
@@ -13,6 +14,7 @@ require_relative "async_variants/preview_extension"
|
|
|
13
14
|
require_relative "async_variants/attachment_extension"
|
|
14
15
|
require_relative "async_variants/reflection_extension"
|
|
15
16
|
require_relative "async_variants/process_job"
|
|
17
|
+
require_relative "async_variants/heartbeat_watchdog_job"
|
|
16
18
|
require_relative "async_variants/asset_tag_helper_extension"
|
|
17
19
|
|
|
18
20
|
module ActiveStorage
|
|
@@ -22,12 +24,36 @@ module ActiveStorage
|
|
|
22
24
|
PASS_THROUGH_HTML_OPTIONS = %i[alt width height controls autoplay preload].freeze
|
|
23
25
|
|
|
24
26
|
mattr_accessor :cdn_host
|
|
27
|
+
mattr_accessor :retry_visible_proc, default: ->(_view) { false }
|
|
25
28
|
|
|
26
29
|
# Lets the host app plug its auth chain (and thus `current_user`) into the
|
|
27
30
|
# gem's StatesController. Set to a string so resolution is deferred until
|
|
28
31
|
# the host's class is autoloadable. Defaults to ActionController::Base.
|
|
29
32
|
mattr_accessor :parent_controller, default: "ActionController::Base"
|
|
30
33
|
|
|
34
|
+
# How long an external transform may go without a heartbeat before the
|
|
35
|
+
# watchdog fails it. Must exceed the transformer's heartbeat interval.
|
|
36
|
+
mattr_accessor :heartbeat_stale_after, default: 60.seconds
|
|
37
|
+
|
|
38
|
+
# The transformer's heartbeat cadence. The turbo-frame poll matches it so
|
|
39
|
+
# each reload lands on fresh progress; keep heartbeat_stale_after well above it.
|
|
40
|
+
mattr_accessor :heartbeat_interval, default: 5.seconds
|
|
41
|
+
|
|
42
|
+
# Gates the failed-state retry affordance; the block runs in the view context.
|
|
43
|
+
def self.retry_visible_if(&block)
|
|
44
|
+
self.retry_visible_proc = block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.retry_visible?(view)
|
|
48
|
+
!!view.instance_exec(&retry_visible_proc)
|
|
49
|
+
rescue StandardError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.configure
|
|
54
|
+
yield self
|
|
55
|
+
end
|
|
56
|
+
|
|
31
57
|
class Engine < ::Rails::Engine
|
|
32
58
|
# Prepend the core model/reflection extensions before eager_load runs
|
|
33
59
|
# so that models' has_X_attached blocks (and the Variation.wrap calls
|
|
@@ -79,6 +105,8 @@ module ActiveStorage
|
|
|
79
105
|
)
|
|
80
106
|
end
|
|
81
107
|
|
|
108
|
+
Assets = IsolateAssets.register(namespace: self, engine: Engine, route_name: :async_variant_asset)
|
|
109
|
+
|
|
82
110
|
def self.callback_token_for(variant_record)
|
|
83
111
|
ActiveStorage.verifier.generate(variant_record.id, purpose: :async_variant_callback)
|
|
84
112
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_storage-async_variants
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Micah Geisel
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-06-
|
|
10
|
+
date: 2026-06-07 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activestorage
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: isolate_assets
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.4'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.4'
|
|
40
54
|
email:
|
|
41
55
|
- micah@botandrose.com
|
|
42
56
|
executables: []
|
|
@@ -52,6 +66,10 @@ files:
|
|
|
52
66
|
- LICENSE.txt
|
|
53
67
|
- README.md
|
|
54
68
|
- Rakefile
|
|
69
|
+
- app/assets/javascripts/progress-bar.js
|
|
70
|
+
- app/assets/javascripts/retry.js
|
|
71
|
+
- app/assets/stylesheets/progress.css
|
|
72
|
+
- app/assets/stylesheets/retry.css
|
|
55
73
|
- app/controllers/active_storage/async_variants/callbacks_controller.rb
|
|
56
74
|
- app/controllers/active_storage/async_variants/states_controller.rb
|
|
57
75
|
- app/views/active_storage/async_variants/states/_failed.html.erb
|
|
@@ -60,6 +78,9 @@ files:
|
|
|
60
78
|
- app/views/active_storage/async_variants/states/_processing.html.erb
|
|
61
79
|
- app/views/active_storage/async_variants/states/show.html.erb
|
|
62
80
|
- config/routes.rb
|
|
81
|
+
- db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb
|
|
82
|
+
- features/retry_flow.feature
|
|
83
|
+
- features/retry_visibility.feature
|
|
63
84
|
- features/state_rendering.feature
|
|
64
85
|
- features/step_definitions/dummy_steps.rb
|
|
65
86
|
- features/support/env.rb
|
|
@@ -70,6 +91,7 @@ files:
|
|
|
70
91
|
- lib/active_storage/async_variants/asset_tag_helper_extension.rb
|
|
71
92
|
- lib/active_storage/async_variants/attachment_extension.rb
|
|
72
93
|
- lib/active_storage/async_variants/blob_extension.rb
|
|
94
|
+
- lib/active_storage/async_variants/heartbeat_watchdog_job.rb
|
|
73
95
|
- lib/active_storage/async_variants/helper.rb
|
|
74
96
|
- lib/active_storage/async_variants/preview_extension.rb
|
|
75
97
|
- lib/active_storage/async_variants/process_job.rb
|