maintenance_on_steroids 0.2.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 +7 -0
- data/CHANGELOG.md +267 -0
- data/LICENSE.txt +21 -0
- data/README.md +948 -0
- data/app/controllers/maintenance_on_steroids/application_controller.rb +66 -0
- data/app/controllers/maintenance_on_steroids/dashboard_controller.rb +18 -0
- data/app/controllers/maintenance_on_steroids/jobs_controller.rb +59 -0
- data/app/controllers/maintenance_on_steroids/runs_controller.rb +223 -0
- data/app/jobs/maintenance_on_steroids/run_job.rb +292 -0
- data/app/models/maintenance_on_steroids/application_record.rb +6 -0
- data/app/models/maintenance_on_steroids/artifact.rb +255 -0
- data/app/models/maintenance_on_steroids/run.rb +310 -0
- data/app/views/layouts/maintenance_on_steroids/application.html.erb +48 -0
- data/app/views/maintenance_on_steroids/dashboard/index.html.erb +163 -0
- data/app/views/maintenance_on_steroids/jobs/index.html.erb +35 -0
- data/app/views/maintenance_on_steroids/jobs/show.html.erb +107 -0
- data/app/views/maintenance_on_steroids/jobs/source.html.erb +79 -0
- data/app/views/maintenance_on_steroids/runs/new.html.erb +98 -0
- data/app/views/maintenance_on_steroids/runs/show.html.erb +410 -0
- data/app/views/maintenance_on_steroids/shared/_auto_refresh.html.erb +85 -0
- data/app/views/maintenance_on_steroids/shared/_javascript.html.erb +16 -0
- data/app/views/maintenance_on_steroids/shared/_pager.html.erb +20 -0
- data/app/views/maintenance_on_steroids/shared/_styles.html.erb +649 -0
- data/app/views/maintenance_on_steroids/shared/_task_list_item.html.erb +31 -0
- data/config/routes.rb +22 -0
- data/lib/generators/maintenance_on_steroids/install/install_generator.rb +56 -0
- data/lib/generators/maintenance_on_steroids/install/templates/create_maintenance_on_steroids_tables.rb.erb +56 -0
- data/lib/generators/maintenance_on_steroids/install/templates/initializer.rb +42 -0
- data/lib/generators/maintenance_on_steroids/job/job_generator.rb +17 -0
- data/lib/generators/maintenance_on_steroids/job/templates/job.rb.erb +30 -0
- data/lib/maintenance_on_steroids/about_dsl.rb +51 -0
- data/lib/maintenance_on_steroids/artifact_dsl.rb +82 -0
- data/lib/maintenance_on_steroids/artifacts_proxy.rb +205 -0
- data/lib/maintenance_on_steroids/callbacks_dsl.rb +61 -0
- data/lib/maintenance_on_steroids/csv_artifact.rb +88 -0
- data/lib/maintenance_on_steroids/engine.rb +26 -0
- data/lib/maintenance_on_steroids/form_dsl.rb +63 -0
- data/lib/maintenance_on_steroids/instrumentation.rb +33 -0
- data/lib/maintenance_on_steroids/job_dsl.rb +61 -0
- data/lib/maintenance_on_steroids/job_registry.rb +83 -0
- data/lib/maintenance_on_steroids/jsonb_artifact.rb +39 -0
- data/lib/maintenance_on_steroids/params_proxy.rb +93 -0
- data/lib/maintenance_on_steroids/task.rb +109 -0
- data/lib/maintenance_on_steroids/text_artifact.rb +74 -0
- data/lib/maintenance_on_steroids/version.rb +3 -0
- data/lib/maintenance_on_steroids.rb +187 -0
- metadata +125 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
<% task_title = @task_class_obj&.task_title || @run.task_title %>
|
|
2
|
+
<div class="breadcrumb">
|
|
3
|
+
<a href="<%= jobs_path %>">Tasks</a> ›
|
|
4
|
+
<% if @task_class_obj %>
|
|
5
|
+
<a href="<%= job_path(id: @run.task_class) %>"><%= task_title %></a> ›
|
|
6
|
+
<% else %>
|
|
7
|
+
<span title="Task class no longer exists"><%= task_title %></span> ›
|
|
8
|
+
<% end %>
|
|
9
|
+
Run #<%= @run.id %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="page-header">
|
|
13
|
+
<div>
|
|
14
|
+
<h1>Run #<%= @run.id %></h1>
|
|
15
|
+
<p class="subtitle"><%= task_title %></p>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="btn-group">
|
|
18
|
+
<% if @task_class_obj %>
|
|
19
|
+
<a href="<%= source_job_path(id: @run.task_class) %>" class="btn btn-outline btn-sm">View Source</a>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% if @run.pausable? %>
|
|
22
|
+
<%= button_to "Pause", pause_run_path(@run), class: "btn btn-warning btn-sm", form: { style: "display: inline;" } %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% if @run.resumable? %>
|
|
25
|
+
<%= button_to "Resume", resume_run_path(@run), class: "btn btn-success btn-sm", form: { style: "display: inline;" } %>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% if @run.cancellable? %>
|
|
28
|
+
<%= button_to "Cancel", cancel_run_path(@run), class: "btn btn-danger btn-sm",
|
|
29
|
+
form: { style: "display: inline;", onsubmit: "return confirm('Are you sure you want to cancel this run?');" } %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<%# Status & Progress Card %>
|
|
35
|
+
<div class="card">
|
|
36
|
+
<div class="info-grid" style="margin-bottom: 20px;">
|
|
37
|
+
<div class="info-item">
|
|
38
|
+
<label>Status</label>
|
|
39
|
+
<div class="value">
|
|
40
|
+
<span id="status-badge" class="badge badge-<%= @run.status %>"><%= @run.status %></span>
|
|
41
|
+
<span id="auto-refresh" class="auto-refresh" hidden>
|
|
42
|
+
auto-refresh in <span id="auto-refresh-count"><%= 5 %></span>s
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="info-item">
|
|
47
|
+
<label>Duration</label>
|
|
48
|
+
<div class="value" id="run-duration"><%= @run.formatted_duration %></div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="info-item" id="estimated-duration-item" <%= "hidden" unless @run.formatted_estimated_duration %>>
|
|
51
|
+
<label>Estimated (pending)</label>
|
|
52
|
+
<div class="value" id="estimated-duration"><%= @run.formatted_estimated_duration %></div>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="info-item">
|
|
55
|
+
<label>Started</label>
|
|
56
|
+
<div class="value"><%= @run.started_at&.strftime("%b %d, %Y %H:%M:%S") || "—" %></div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="info-item">
|
|
59
|
+
<label>Completed</label>
|
|
60
|
+
<div class="value"><%= @run.completed_at&.strftime("%b %d, %Y %H:%M:%S") || "—" %></div>
|
|
61
|
+
</div>
|
|
62
|
+
<% if @run.user_id.present? %>
|
|
63
|
+
<div class="info-item">
|
|
64
|
+
<label>Triggered By</label>
|
|
65
|
+
<div class="value"><%= @run.user_display %></div>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<% if @run.progress_total > 0 || @run.active? %>
|
|
71
|
+
<label style="font-size: 12px; color: var(--text-muted); text-transform: uppercase; font-weight: 600; margin-bottom: 8px; display: block;">
|
|
72
|
+
Progress
|
|
73
|
+
</label>
|
|
74
|
+
<div class="progress-container">
|
|
75
|
+
<div
|
|
76
|
+
id="progress-bar"
|
|
77
|
+
class="progress-bar<%= " completed" if @run.completed? %><%= " errored" if @run.errored? %>"
|
|
78
|
+
style="width: <%= @run.progress_percentage %>%;"
|
|
79
|
+
></div>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="progress-text" id="progress-text">
|
|
82
|
+
<%= @run.progress_current %> / <%= @run.progress_total %> (<%= @run.progress_percentage %>%)
|
|
83
|
+
</div>
|
|
84
|
+
<% end %>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<%# Error Card %>
|
|
88
|
+
<% if @run.errored? && @run.error_message.present? %>
|
|
89
|
+
<div class="error-box">
|
|
90
|
+
<h3>Error</h3>
|
|
91
|
+
<p style="margin-top: 4px;"><%= @run.error_message %></p>
|
|
92
|
+
<% if @run.error_backtrace.present? %>
|
|
93
|
+
<pre><%= @run.error_backtrace %></pre>
|
|
94
|
+
<% end %>
|
|
95
|
+
</div>
|
|
96
|
+
<% end %>
|
|
97
|
+
|
|
98
|
+
<%# Parameters Card %>
|
|
99
|
+
<% if @run.params.present? && @run.params.any? %>
|
|
100
|
+
<div class="card" style="margin-top: 16px;">
|
|
101
|
+
<h3 style="margin-bottom: 12px;">Parameters</h3>
|
|
102
|
+
<table>
|
|
103
|
+
<thead>
|
|
104
|
+
<tr>
|
|
105
|
+
<th>Name</th>
|
|
106
|
+
<th>Value</th>
|
|
107
|
+
</tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody>
|
|
110
|
+
<% @run.params.each do |key, value| %>
|
|
111
|
+
<tr>
|
|
112
|
+
<td><strong><%= key %></strong></td>
|
|
113
|
+
<td style="font-family: monospace; font-size: 13px;"><%= value %></td>
|
|
114
|
+
</tr>
|
|
115
|
+
<% end %>
|
|
116
|
+
</tbody>
|
|
117
|
+
</table>
|
|
118
|
+
</div>
|
|
119
|
+
<% end %>
|
|
120
|
+
|
|
121
|
+
<%# Input Files Card %>
|
|
122
|
+
<% input_artifacts = @run.input_artifacts %>
|
|
123
|
+
<% if input_artifacts.any? %>
|
|
124
|
+
<div class="card" style="margin-top: 16px;">
|
|
125
|
+
<h3 style="margin-bottom: 12px;">Input Files</h3>
|
|
126
|
+
<% input_artifacts.each do |artifact| %>
|
|
127
|
+
<div class="artifact-card">
|
|
128
|
+
<span class="artifact-name"><%= artifact.name %></span>
|
|
129
|
+
<span class="artifact-type">· <%= artifact.file_name %> (<%= artifact.human_size %>)</span>
|
|
130
|
+
<% if artifact.downloadable? %>
|
|
131
|
+
<a href="<%= artifact_download_run_path(@run, artifact_id: artifact.id) %>" class="btn btn-outline btn-sm" style="margin-left: 8px;">Download</a>
|
|
132
|
+
<% end %>
|
|
133
|
+
</div>
|
|
134
|
+
<% end %>
|
|
135
|
+
</div>
|
|
136
|
+
<% end %>
|
|
137
|
+
|
|
138
|
+
<%# Output Artifacts Card %>
|
|
139
|
+
<% output_artifacts = @run.output_artifacts %>
|
|
140
|
+
<% artifact_defs = (@task_class_obj&.artifact_definitions || []).index_by { |d| d.name.to_s } %>
|
|
141
|
+
<% if output_artifacts.any? %>
|
|
142
|
+
<div class="card" style="margin-top: 16px;">
|
|
143
|
+
<h3 style="margin-bottom: 12px;">Artifacts</h3>
|
|
144
|
+
<% output_artifacts.each do |artifact| %>
|
|
145
|
+
<% defn = artifact_defs[artifact.name] %>
|
|
146
|
+
<div class="artifact-card">
|
|
147
|
+
<span class="artifact-name"><%= defn&.label || artifact.name.titleize %></span>
|
|
148
|
+
<span class="artifact-type">· <%= artifact.artifact_type %> · <%= artifact.summary %></span>
|
|
149
|
+
<% if artifact.generated_at %>
|
|
150
|
+
<span class="artifact-type" title="<%= artifact.generated_at %>">· <%= time_ago_in_words(artifact.generated_at) %> ago</span>
|
|
151
|
+
<% end %>
|
|
152
|
+
<% if artifact.downloadable? %>
|
|
153
|
+
<a href="<%= artifact_download_run_path(@run, artifact_id: artifact.id) %>" class="btn btn-outline btn-sm" style="margin-left: 8px;">Download</a>
|
|
154
|
+
<% end %>
|
|
155
|
+
<% if defn&.description.present? %>
|
|
156
|
+
<div class="artifact-desc"><%= defn.description %></div>
|
|
157
|
+
<% end %>
|
|
158
|
+
<% if %w[jsonb text].include?(artifact.artifact_type) && (preview = artifact.preview_text).present? %>
|
|
159
|
+
<div class="artifact-pre-wrap">
|
|
160
|
+
<button type="button" class="btn btn-outline btn-sm copy-artifact-btn artifact-copy-overlay" data-copy-label="Copy">Copy</button>
|
|
161
|
+
<pre><%= preview %></pre>
|
|
162
|
+
</div>
|
|
163
|
+
<% if artifact.preview_truncated? %>
|
|
164
|
+
<div class="artifact-desc">Preview truncated — showing the first <%= number_to_human_size(MaintenanceOnSteroids::Artifact::PREVIEW_BYTES) %> of <%= artifact.human_size %>.</div>
|
|
165
|
+
<% end %>
|
|
166
|
+
<% elsif artifact.artifact_type == "csv" && (rows = artifact.csv_rows).present? %>
|
|
167
|
+
<div style="overflow-x: auto;">
|
|
168
|
+
<table class="csv-preview">
|
|
169
|
+
<% rows.first(100).each_with_index do |row, i| %>
|
|
170
|
+
<tr>
|
|
171
|
+
<% row.each do |cell| %>
|
|
172
|
+
<% if i.zero? && defn&.headers %><th><%= cell %></th><% else %><td><%= cell %></td><% end %>
|
|
173
|
+
<% end %>
|
|
174
|
+
</tr>
|
|
175
|
+
<% end %>
|
|
176
|
+
</table>
|
|
177
|
+
</div>
|
|
178
|
+
<% if rows.size > 100 || artifact.preview_truncated? %>
|
|
179
|
+
<div class="artifact-desc">
|
|
180
|
+
Showing first <%= [rows.size, 100].min %> of <%= artifact.metadata&.dig("rows") || rows.size %> rows — download for the full file.
|
|
181
|
+
</div>
|
|
182
|
+
<% end %>
|
|
183
|
+
<% end %>
|
|
184
|
+
</div>
|
|
185
|
+
<% end %>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<%# Copy text/jsonb artifact contents (the sibling <pre>) to the clipboard. %>
|
|
189
|
+
<script>
|
|
190
|
+
(function() {
|
|
191
|
+
function flash(btn) {
|
|
192
|
+
var label = btn.getAttribute("data-copy-label") || "Copy";
|
|
193
|
+
btn.textContent = "Copied!";
|
|
194
|
+
setTimeout(function() { btn.textContent = label; }, 1500);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function fallbackCopy(text, btn) {
|
|
198
|
+
var ta = document.createElement("textarea");
|
|
199
|
+
ta.value = text;
|
|
200
|
+
ta.style.position = "fixed";
|
|
201
|
+
ta.style.opacity = "0";
|
|
202
|
+
document.body.appendChild(ta);
|
|
203
|
+
ta.select();
|
|
204
|
+
try { document.execCommand("copy"); flash(btn); } catch (e) { /* no-op */ }
|
|
205
|
+
document.body.removeChild(ta);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function onClick(e) {
|
|
209
|
+
var btn = e.target.closest && e.target.closest(".copy-artifact-btn");
|
|
210
|
+
if (!btn) { return; }
|
|
211
|
+
var card = btn.closest(".artifact-card");
|
|
212
|
+
var pre = card && card.querySelector("pre");
|
|
213
|
+
if (!pre) { return; }
|
|
214
|
+
var text = pre.textContent;
|
|
215
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
216
|
+
navigator.clipboard.writeText(text).then(function() { flash(btn); })
|
|
217
|
+
.catch(function() { fallbackCopy(text, btn); });
|
|
218
|
+
} else {
|
|
219
|
+
fallbackCopy(text, btn);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function bind() {
|
|
223
|
+
if (window.__mosArtifactCopyBound) { return; }
|
|
224
|
+
window.__mosArtifactCopyBound = true;
|
|
225
|
+
document.addEventListener("click", onClick);
|
|
226
|
+
document.addEventListener("turbo:before-visit", cleanup);
|
|
227
|
+
window.addEventListener("pagehide", cleanup);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Remove the delegated listener and clear the bind flag on Turbo
|
|
231
|
+
// navigation / unload so listeners don't accumulate across run pages.
|
|
232
|
+
function cleanup() {
|
|
233
|
+
document.removeEventListener("click", onClick);
|
|
234
|
+
document.removeEventListener("turbo:before-visit", cleanup);
|
|
235
|
+
window.removeEventListener("pagehide", cleanup);
|
|
236
|
+
window.__mosArtifactCopyBound = false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// bfcache restore doesn't re-run this script, and cleanup ran on pagehide,
|
|
240
|
+
// so re-bind when the page is restored from the back/forward cache.
|
|
241
|
+
window.addEventListener("pageshow", bind);
|
|
242
|
+
bind();
|
|
243
|
+
})();
|
|
244
|
+
</script>
|
|
245
|
+
<% end %>
|
|
246
|
+
|
|
247
|
+
<%# Live polling script for active runs %>
|
|
248
|
+
<%# cancelling is not in ACTIVE_STATUSES but still needs live updates + auto-refresh %>
|
|
249
|
+
<% if @run.active? || @run.status == "cancelling" %>
|
|
250
|
+
<script>
|
|
251
|
+
(function() {
|
|
252
|
+
var POLL_INTERVAL = 2000;
|
|
253
|
+
var MAX_CONSECUTIVE_FAILURES = 5;
|
|
254
|
+
var statusUrl = "<%= status_run_path(@run) %>";
|
|
255
|
+
var initialStatus = "<%= @run.status %>";
|
|
256
|
+
var reloadStatuses = ["completed", "errored", "cancelled", "paused"];
|
|
257
|
+
// Transitional states waiting on the worker to acknowledge; the page
|
|
258
|
+
// auto-refreshes on a visible countdown until the state settles.
|
|
259
|
+
var transitionalStatuses = ["pausing", "cancelling"];
|
|
260
|
+
var AUTO_REFRESH_SECONDS = 5;
|
|
261
|
+
|
|
262
|
+
var timer = null;
|
|
263
|
+
var reloadTimer = null;
|
|
264
|
+
var countdownTimer = null;
|
|
265
|
+
var consecutiveFailures = 0;
|
|
266
|
+
var abortController = new AbortController();
|
|
267
|
+
// The poll is the single reload authority. Once it has scheduled a reload
|
|
268
|
+
// (settled state detected), the transitional countdown must not also fire a
|
|
269
|
+
// reload -- otherwise the page reloads twice.
|
|
270
|
+
var reloadScheduled = false;
|
|
271
|
+
|
|
272
|
+
// Stop polling, pending reloads and in-flight fetches when leaving the
|
|
273
|
+
// page (Turbo navigation or regular unload) so stale responses can't
|
|
274
|
+
// touch a swapped DOM and intervals don't stack across navigations.
|
|
275
|
+
function teardown() {
|
|
276
|
+
if (timer !== null) { clearInterval(timer); timer = null; }
|
|
277
|
+
if (reloadTimer !== null) { clearTimeout(reloadTimer); reloadTimer = null; }
|
|
278
|
+
if (countdownTimer !== null) { clearInterval(countdownTimer); countdownTimer = null; }
|
|
279
|
+
abortController.abort();
|
|
280
|
+
document.removeEventListener("turbo:before-visit", teardown);
|
|
281
|
+
document.removeEventListener("turbo:before-cache", teardown);
|
|
282
|
+
window.removeEventListener("pagehide", teardown);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function startAutoRefreshCountdown() {
|
|
286
|
+
if (countdownTimer !== null) { return; }
|
|
287
|
+
var indicator = document.getElementById("auto-refresh");
|
|
288
|
+
var counter = document.getElementById("auto-refresh-count");
|
|
289
|
+
if (!indicator || !counter) { return; }
|
|
290
|
+
var remaining = AUTO_REFRESH_SECONDS;
|
|
291
|
+
counter.textContent = remaining;
|
|
292
|
+
indicator.hidden = false;
|
|
293
|
+
countdownTimer = setInterval(function() {
|
|
294
|
+
// Don't tick or reload in a background tab.
|
|
295
|
+
if (document.hidden) { return; }
|
|
296
|
+
// The poll already owns the reload; stand down to avoid a double reload.
|
|
297
|
+
if (reloadScheduled) {
|
|
298
|
+
stopAutoRefreshCountdown();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
remaining -= 1;
|
|
302
|
+
if (remaining <= 0) {
|
|
303
|
+
teardown();
|
|
304
|
+
window.location.reload();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
counter.textContent = remaining;
|
|
308
|
+
}, 1000);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function stopAutoRefreshCountdown() {
|
|
312
|
+
if (countdownTimer !== null) { clearInterval(countdownTimer); countdownTimer = null; }
|
|
313
|
+
var indicator = document.getElementById("auto-refresh");
|
|
314
|
+
if (indicator) { indicator.hidden = true; }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
document.addEventListener("turbo:before-visit", teardown);
|
|
318
|
+
document.addEventListener("turbo:before-cache", teardown);
|
|
319
|
+
window.addEventListener("pagehide", teardown);
|
|
320
|
+
|
|
321
|
+
// bfcache restore doesn't re-run this script; the poller was torn down on
|
|
322
|
+
// pagehide. The run may have advanced while away, so reload for fresh state.
|
|
323
|
+
window.addEventListener("pageshow", function(e) {
|
|
324
|
+
if (e.persisted) { window.location.reload(); }
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
function showPollingStopped() {
|
|
328
|
+
var text = document.getElementById("progress-text");
|
|
329
|
+
if (text) {
|
|
330
|
+
text.textContent = "Live updates unavailable - refresh the page to see current progress.";
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function poll() {
|
|
335
|
+
fetch(statusUrl, { headers: { "Accept": "application/json" }, signal: abortController.signal })
|
|
336
|
+
.then(function(res) {
|
|
337
|
+
if (!res.ok) { throw new Error("HTTP " + res.status); }
|
|
338
|
+
return res.json();
|
|
339
|
+
})
|
|
340
|
+
.then(function(data) {
|
|
341
|
+
consecutiveFailures = 0;
|
|
342
|
+
|
|
343
|
+
var badge = document.getElementById("status-badge");
|
|
344
|
+
if (badge) {
|
|
345
|
+
badge.textContent = data.status;
|
|
346
|
+
badge.className = "badge badge-" + data.status;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
var bar = document.getElementById("progress-bar");
|
|
350
|
+
if (bar) {
|
|
351
|
+
bar.style.width = data.progress_percentage + "%";
|
|
352
|
+
if (data.status === "completed") bar.classList.add("completed");
|
|
353
|
+
if (data.status === "errored") bar.classList.add("errored");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
var text = document.getElementById("progress-text");
|
|
357
|
+
if (text) {
|
|
358
|
+
text.textContent = data.progress_current + " / " + data.progress_total + " (" + data.progress_percentage + "%)";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
var duration = document.getElementById("run-duration");
|
|
362
|
+
if (duration) {
|
|
363
|
+
duration.textContent = data.formatted_duration;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
var estimatedItem = document.getElementById("estimated-duration-item");
|
|
367
|
+
var estimated = document.getElementById("estimated-duration");
|
|
368
|
+
if (estimatedItem && estimated) {
|
|
369
|
+
if (data.formatted_estimated_duration) {
|
|
370
|
+
estimated.textContent = data.formatted_estimated_duration;
|
|
371
|
+
estimatedItem.hidden = false;
|
|
372
|
+
} else {
|
|
373
|
+
estimatedItem.hidden = true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (transitionalStatuses.indexOf(data.status) !== -1) {
|
|
378
|
+
startAutoRefreshCountdown();
|
|
379
|
+
} else {
|
|
380
|
+
stopAutoRefreshCountdown();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Reload only on a status *change* into a settled state -- landing
|
|
384
|
+
// directly on e.g. a paused page must not trigger a reload loop.
|
|
385
|
+
if (data.status !== initialStatus && reloadStatuses.indexOf(data.status) !== -1) {
|
|
386
|
+
clearInterval(timer);
|
|
387
|
+
timer = null;
|
|
388
|
+
reloadScheduled = true;
|
|
389
|
+
reloadTimer = setTimeout(function() { window.location.reload(); }, 500);
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
.catch(function(err) {
|
|
393
|
+
if (err && err.name === "AbortError") { return; }
|
|
394
|
+
consecutiveFailures += 1;
|
|
395
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
396
|
+
teardown();
|
|
397
|
+
showPollingStopped();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
timer = setInterval(poll, POLL_INTERVAL);
|
|
403
|
+
|
|
404
|
+
<% if %w[pausing cancelling].include?(@run.status) %>
|
|
405
|
+
// Page landed in a transitional state -- start the countdown immediately.
|
|
406
|
+
startAutoRefreshCountdown();
|
|
407
|
+
<% end %>
|
|
408
|
+
})();
|
|
409
|
+
</script>
|
|
410
|
+
<% end %>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<%# Polls the current page every few seconds and swaps the listed element ids
|
|
2
|
+
in place (no full reload). Pass `target_ids:` -- an array of DOM ids whose
|
|
3
|
+
innerHTML should be refreshed from the freshly fetched page.
|
|
4
|
+
Optional `stop_when_gone:` -- a DOM id; once the freshly fetched page no
|
|
5
|
+
longer contains it (e.g. an "active runs" sentinel), polling stops.
|
|
6
|
+
Used by the dashboard and the tasks list for live in-progress updates. %>
|
|
7
|
+
<script>
|
|
8
|
+
(function() {
|
|
9
|
+
var REFRESH_INTERVAL = 4000;
|
|
10
|
+
var MAX_CONSECUTIVE_FAILURES = 5;
|
|
11
|
+
var TARGET_IDS = JSON.parse('<%= j target_ids.to_json %>');
|
|
12
|
+
var STOP_WHEN_GONE = JSON.parse('<%= j (local_assigns[:stop_when_gone] || "").to_json %>');
|
|
13
|
+
var consecutiveFailures = 0;
|
|
14
|
+
var timer = null;
|
|
15
|
+
var inFlight = null;
|
|
16
|
+
|
|
17
|
+
function teardown() {
|
|
18
|
+
if (timer !== null) { clearInterval(timer); timer = null; }
|
|
19
|
+
if (inFlight !== null) { inFlight.abort(); inFlight = null; }
|
|
20
|
+
document.removeEventListener("turbo:before-visit", teardown);
|
|
21
|
+
document.removeEventListener("turbo:before-cache", teardown);
|
|
22
|
+
window.removeEventListener("pagehide", teardown);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function refresh() {
|
|
26
|
+
if (document.hidden) { return; }
|
|
27
|
+
// Abort any still-in-flight refresh before starting a new one so slow
|
|
28
|
+
// responses can't pile up or land out of order.
|
|
29
|
+
if (inFlight !== null) { inFlight.abort(); }
|
|
30
|
+
var controller = new AbortController();
|
|
31
|
+
inFlight = controller;
|
|
32
|
+
fetch(window.location.href, { headers: { "Accept": "text/html" }, signal: controller.signal })
|
|
33
|
+
.then(function(res) {
|
|
34
|
+
if (!res.ok) { throw new Error("HTTP " + res.status); }
|
|
35
|
+
return res.text();
|
|
36
|
+
})
|
|
37
|
+
.then(function(html) {
|
|
38
|
+
inFlight = null;
|
|
39
|
+
consecutiveFailures = 0;
|
|
40
|
+
var doc = new DOMParser().parseFromString(html, "text/html");
|
|
41
|
+
TARGET_IDS.forEach(function(id) {
|
|
42
|
+
var fresh = doc.getElementById(id);
|
|
43
|
+
var current = document.getElementById(id);
|
|
44
|
+
// Only swap when the markup actually changed -- the common case
|
|
45
|
+
// (nothing changed) skips the swap, avoiding needless DOM churn and
|
|
46
|
+
// shrinking the window in which a swap could detach a control the
|
|
47
|
+
// user is mid-click on. Also skip if the user is interacting with
|
|
48
|
+
// something inside the target (e.g. a focused View/pause link) so it
|
|
49
|
+
// isn't ripped out. (teardown aborts the in-flight fetch, so this
|
|
50
|
+
// .then never runs after teardown -- no detached-node guard needed.)
|
|
51
|
+
if (current && fresh && current.innerHTML !== fresh.innerHTML &&
|
|
52
|
+
!current.contains(document.activeElement)) {
|
|
53
|
+
current.innerHTML = fresh.innerHTML;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
// Stop polling once the freshly fetched page no longer carries the
|
|
57
|
+
// sentinel (e.g. no more active runs) -- after the final swap.
|
|
58
|
+
if (STOP_WHEN_GONE && !doc.getElementById(STOP_WHEN_GONE)) { teardown(); }
|
|
59
|
+
})
|
|
60
|
+
.catch(function(err) {
|
|
61
|
+
inFlight = null;
|
|
62
|
+
if (err && err.name === "AbortError") { return; }
|
|
63
|
+
consecutiveFailures += 1;
|
|
64
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { teardown(); }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function start() {
|
|
69
|
+
if (timer !== null) { return; } // already polling
|
|
70
|
+
document.addEventListener("turbo:before-visit", teardown);
|
|
71
|
+
document.addEventListener("turbo:before-cache", teardown);
|
|
72
|
+
window.addEventListener("pagehide", teardown);
|
|
73
|
+
timer = setInterval(refresh, REFRESH_INTERVAL);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// When a page is restored from the back/forward (bfcache), inline scripts are
|
|
77
|
+
// NOT re-executed, so teardown (fired on pagehide) would leave polling dead.
|
|
78
|
+
// Re-arm here. This listener is intentionally never removed by teardown.
|
|
79
|
+
window.addEventListener("pageshow", function(e) {
|
|
80
|
+
if (e.persisted) { consecutiveFailures = 0; start(); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
start();
|
|
84
|
+
})();
|
|
85
|
+
</script>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
function toggleTheme() {
|
|
3
|
+
var html = document.documentElement;
|
|
4
|
+
var current = html.getAttribute('data-theme');
|
|
5
|
+
var next = current === 'dark' ? 'light' : 'dark';
|
|
6
|
+
html.setAttribute('data-theme', next);
|
|
7
|
+
localStorage.setItem('mos-theme', next);
|
|
8
|
+
updateThemeIcon();
|
|
9
|
+
}
|
|
10
|
+
function updateThemeIcon() {
|
|
11
|
+
var theme = document.documentElement.getAttribute('data-theme');
|
|
12
|
+
var icon = document.getElementById('theme-icon');
|
|
13
|
+
if (icon) icon.textContent = theme === 'dark' ? '\u2600' : '\u263E';
|
|
14
|
+
}
|
|
15
|
+
document.addEventListener('DOMContentLoaded', updateThemeIcon);
|
|
16
|
+
</script>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# Simple prev/next pager (no gem). Locals:
|
|
2
|
+
page -- current 1-based page
|
|
3
|
+
total_pages -- total page count (computed by the caller; single source of truth)
|
|
4
|
+
total -- total row count
|
|
5
|
+
url_proc -- Integer page -> path (e.g. ->(n) { job_path(id: name, page: n) }) %>
|
|
6
|
+
<% if total_pages > 1 %>
|
|
7
|
+
<div class="pager">
|
|
8
|
+
<% if page > 1 %>
|
|
9
|
+
<a class="btn btn-outline btn-sm" href="<%= url_proc.call(page - 1) %>">‹ Prev</a>
|
|
10
|
+
<% else %>
|
|
11
|
+
<span class="btn btn-outline btn-sm pager-disabled">‹ Prev</span>
|
|
12
|
+
<% end %>
|
|
13
|
+
<span class="pager-info">Page <%= page %> of <%= total_pages %> · <%= total %> total</span>
|
|
14
|
+
<% if page < total_pages %>
|
|
15
|
+
<a class="btn btn-outline btn-sm" href="<%= url_proc.call(page + 1) %>">Next ›</a>
|
|
16
|
+
<% else %>
|
|
17
|
+
<span class="btn btn-outline btn-sm pager-disabled">Next ›</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|