solid_queue_lite 0.1.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.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +142 -0
  5. data/Rakefile +3 -0
  6. data/app/assets/stylesheets/soliq_queue_lite/application.css +15 -0
  7. data/app/controllers/concerns/solid_queue_lite/approximate_countable.rb +10 -0
  8. data/app/controllers/solid_queue_lite/application_controller.rb +4 -0
  9. data/app/controllers/solid_queue_lite/dashboards_controller.rb +61 -0
  10. data/app/controllers/solid_queue_lite/jobs_controller.rb +129 -0
  11. data/app/controllers/solid_queue_lite/processes_controller.rb +39 -0
  12. data/app/controllers/solid_queue_lite/queues_controller.rb +31 -0
  13. data/app/helpers/solid_queue_lite/application_helper.rb +27 -0
  14. data/app/jobs/solid_queue_lite/application_job.rb +4 -0
  15. data/app/jobs/solid_queue_lite/telemetry_sampler_job.rb +11 -0
  16. data/app/models/solid_queue_lite/application_record.rb +5 -0
  17. data/app/models/solid_queue_lite/stat.rb +7 -0
  18. data/app/views/layouts/solid_queue_lite/application.html.erb +383 -0
  19. data/app/views/solid_queue_lite/dashboards/show.html.erb +573 -0
  20. data/config/routes.rb +30 -0
  21. data/db/migrate/20260406000000_create_solid_queue_lite_stats.rb +16 -0
  22. data/lib/solid_queue_lite/approximate_counter.rb +87 -0
  23. data/lib/solid_queue_lite/engine.rb +20 -0
  24. data/lib/solid_queue_lite/install.rb +107 -0
  25. data/lib/solid_queue_lite/jobs.rb +236 -0
  26. data/lib/solid_queue_lite/processes.rb +156 -0
  27. data/lib/solid_queue_lite/telemetry.rb +201 -0
  28. data/lib/solid_queue_lite/version.rb +3 -0
  29. data/lib/solid_queue_lite.rb +46 -0
  30. data/lib/tasks/solid_queue_lite_tasks.rake +14 -0
  31. metadata +116 -0
@@ -0,0 +1,573 @@
1
+ <% content_for :head do %>
2
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/chartist@0.11.4/dist/chartist.min.css">
3
+ <style>
4
+ .dashboard-processes-grid {
5
+ grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
6
+ }
7
+
8
+ .range-toggle {
9
+ margin: 0;
10
+ }
11
+
12
+ .range-toggle a[role="button"] {
13
+ padding: 0.45rem 0.9rem;
14
+ min-width: 4rem;
15
+ font-size: 0.95rem;
16
+ }
17
+
18
+ .recurring-grid {
19
+ display: grid;
20
+ gap: 1rem;
21
+ grid-template-columns: repeat(2, minmax(0, 1fr));
22
+ }
23
+
24
+ .recurring-status {
25
+ display: inline-flex;
26
+ align-items: center;
27
+ gap: 0.35rem;
28
+ }
29
+
30
+ .recurring-card-body {
31
+ padding: 1rem;
32
+ display: grid;
33
+ gap: 0.75rem;
34
+ }
35
+
36
+ .recurring-card-value {
37
+ display: block;
38
+ overflow-wrap: anywhere;
39
+ word-break: break-word;
40
+ }
41
+
42
+ @media (max-width: 767px) {
43
+ .dashboard-processes-grid {
44
+ grid-template-columns: minmax(0, 1fr) !important;
45
+ }
46
+
47
+ .recurring-grid {
48
+ grid-template-columns: minmax(0, 1fr);
49
+ }
50
+
51
+ .range-toggle a[role="button"] {
52
+ padding-left: 0.7rem;
53
+ padding-right: 0.7rem;
54
+ min-width: 3.2rem;
55
+ }
56
+ }
57
+ </style>
58
+ <script defer src="https://cdn.jsdelivr.net/npm/chartist@0.11.4/dist/chartist.min.js"></script>
59
+ <script>
60
+ document.addEventListener("alpine:init", () => {
61
+ Alpine.data("dashboardState", ({ initialTab, initialQueue, initialJobsFilter }) => ({
62
+ tab: initialTab,
63
+ selectedQueue: initialQueue,
64
+ jobsFilter: initialJobsFilter,
65
+ queueSearch: "",
66
+ jobClassFilter: "",
67
+ renderedCharts: false,
68
+
69
+ init() {
70
+ if (this.tab === "pulse") {
71
+ this.$nextTick(() => this.renderCharts());
72
+ }
73
+
74
+ this.$watch("tab", (value) => {
75
+ if (value === "pulse") {
76
+ this.$nextTick(() => this.renderCharts());
77
+ }
78
+ });
79
+
80
+ this.$watch("selectedQueue", (value) => {
81
+ if (!value) {
82
+ this.jobClassFilter = "";
83
+ }
84
+ });
85
+ },
86
+
87
+ renderCharts() {
88
+ if (this.renderedCharts || typeof Chartist === "undefined") return;
89
+
90
+ const payloadNode = document.getElementById("dashboard-chart-data");
91
+ const queueChartNode = document.getElementById("chart-queue-size");
92
+ const latencyChartNode = document.getElementById("chart-latency");
93
+ if (!payloadNode || !queueChartNode || !latencyChartNode) return;
94
+
95
+ const payload = JSON.parse(payloadNode.textContent);
96
+ const latencySeries = payload.avg_latencies.map((seconds) => Math.round((seconds || 0) * 1000));
97
+
98
+ new Chartist.Line("#chart-queue-size", {
99
+ labels: payload.labels,
100
+ series: [payload.ready_counts, payload.scheduled_counts]
101
+ }, {
102
+ low: 0,
103
+ showArea: true,
104
+ lineSmooth: Chartist.Interpolation.cardinal({ tension: 0.2 }),
105
+ axisY: { onlyInteger: true }
106
+ });
107
+
108
+ new Chartist.Line("#chart-latency", {
109
+ labels: payload.labels,
110
+ series: [latencySeries]
111
+ }, {
112
+ low: 0,
113
+ showArea: true,
114
+ lineSmooth: Chartist.Interpolation.step()
115
+ });
116
+
117
+ this.renderedCharts = true;
118
+ },
119
+
120
+ visibleQueue(queueName) {
121
+ const query = this.queueSearch.trim().toLowerCase();
122
+ return query.length === 0 || queueName.toLowerCase().includes(query);
123
+ },
124
+
125
+ visibleJob(className) {
126
+ const query = this.jobClassFilter.trim().toLowerCase();
127
+ return query.length === 0 || className.toLowerCase().includes(query);
128
+ }
129
+ }));
130
+ });
131
+ </script>
132
+ <% end %>
133
+
134
+ <% total_execution_samples = @chart_payload[:success_counts].sum + @chart_payload[:failed_counts].sum %>
135
+ <% error_rate = total_execution_samples.zero? ? 0.0 : ((@chart_payload[:failed_counts].sum.to_f / total_execution_samples) * 100).round(1) %>
136
+ <% avg_latency_ms = @latest_stat&.avg_latency ? (@latest_stat.avg_latency.to_f * 1000).round : nil %>
137
+ <% selected_queue_metrics = @selected_queue_metrics || {} %>
138
+
139
+ <section x-data="dashboardState({ initialTab: <%= @active_tab.to_json %>, initialQueue: <%= @selected_queue_name.to_json %>, initialJobsFilter: <%= @jobs_selected_state.to_json %> })">
140
+ <nav class="filter-nav" style="margin-top: 1rem;">
141
+ <a href="<%= dashboard_return_to(tab: "pulse", queue_name: nil, page: nil) %>"
142
+ :class="{ 'active': tab === 'pulse' }"
143
+ @click.prevent="tab = 'pulse'; history.replaceState(null, '', $el.href)">Pulse (Telemetry)</a>
144
+ <a href="<%= dashboard_return_to(tab: "jobs") %>"
145
+ :class="{ 'active': tab === 'jobs' }"
146
+ @click.prevent="tab = 'jobs'; selectedQueue = <%= @selected_queue_name.to_json %>; history.replaceState(null, '', $el.href)">Jobs</a>
147
+ <a href="<%= dashboard_return_to(tab: "processes") %>"
148
+ :class="{ 'active': tab === 'processes' }"
149
+ @click.prevent="tab = 'processes'; history.replaceState(null, '', $el.href)">Processes</a>
150
+ <a href="<%= dashboard_return_to(tab: "recurring") %>"
151
+ :class="{ 'active': tab === 'recurring' }"
152
+ @click.prevent="tab = 'recurring'; history.replaceState(null, '', $el.href)">Recurring</a>
153
+ </nav>
154
+
155
+ <div x-show="tab === 'pulse'" x-cloak>
156
+ <div class="metric-grid">
157
+ <article>
158
+ <header><strong>Ready Jobs</strong></header>
159
+ <h2 style="margin-bottom: 0;"><%= @current_ready_count %></h2>
160
+ <small><span class="status-dot status-active"></span>Normal operating threshold</small>
161
+ </article>
162
+ <article>
163
+ <header><strong>Avg Latency (<%= @selected_range %>)</strong></header>
164
+ <h2 style="margin-bottom: 0;"><%= avg_latency_ms ? "#{avg_latency_ms}ms" : "n/a" %></h2>
165
+ <small><span class="status-dot status-active"></span><%= avg_latency_ms && avg_latency_ms < 500 ? "Below 500ms target" : "Latency spike under load" %></small>
166
+ </article>
167
+ <article>
168
+ <header><strong>Error Rate (<%= @selected_range %>)</strong></header>
169
+ <h2 style="margin-bottom: 0;"><%= error_rate %>%</h2>
170
+ <small><span class="status-dot <%= error_rate >= 1.0 ? "status-warning" : "status-active" %>"></span><%= error_rate >= 1.0 ? "Elevated failure rate detected" : "Slight elevation in default queue" %></small>
171
+ </article>
172
+ </div>
173
+
174
+ <div class="toolbar" style="margin-top: 2rem;">
175
+ <h3 style="margin: 0;">Queue Metrics</h3>
176
+ <fieldset role="group" class="range-toggle">
177
+ <% ["1h", "6h", "24h"].each do |range| %>
178
+ <a href="<%= dashboard_return_to(tab: "pulse", range: range, page: nil, queue_name: nil) %>"
179
+ role="button"
180
+ class="outline <%= "secondary" unless @selected_range == range %>"><%= range %></a>
181
+ <% end %>
182
+ </fieldset>
183
+ </div>
184
+
185
+ <% if @stats.empty? %>
186
+ <article class="empty-state">
187
+ No telemetry samples are available for the selected time window yet.
188
+ </article>
189
+ <% else %>
190
+ <div class="grid">
191
+ <article>
192
+ <header><strong>Queue Size (Ready vs Scheduled)</strong></header>
193
+ <div id="chart-queue-size" class="ct-chart"></div>
194
+ </article>
195
+ <article>
196
+ <header><strong>Execution Latency (ms)</strong></header>
197
+ <div id="chart-latency" class="ct-chart"></div>
198
+ </article>
199
+ </div>
200
+ <script type="application/json" id="dashboard-chart-data"><%= raw json_escape(@chart_payload.to_json) %></script>
201
+ <% end %>
202
+
203
+ </div>
204
+
205
+ <div x-show="tab === 'jobs'" x-cloak>
206
+ <% if @selected_queue_name.blank? %>
207
+ <div class="toolbar">
208
+ <h3 style="margin: 0;">Queues</h3>
209
+ <div style="display: flex; gap: 1rem; align-items: center;">
210
+ <input type="search" x-model="queueSearch" placeholder="Filter queues..." style="margin: 0; width: 250px;">
211
+ </div>
212
+ </div>
213
+
214
+ <% if @queues.empty? %>
215
+ <article class="empty-state">No queues are currently registered.</article>
216
+ <% else %>
217
+ <figure>
218
+ <table role="grid">
219
+ <thead>
220
+ <tr>
221
+ <th scope="col">Queue Name</th>
222
+ <th scope="col">Ready</th>
223
+ <th scope="col">In-Progress</th>
224
+ <th scope="col">Failed</th>
225
+ <th scope="col">Total Jobs</th>
226
+ </tr>
227
+ </thead>
228
+ <tbody>
229
+ <% @queues.each do |queue| %>
230
+ <tr x-show="visibleQueue(<%= queue[:name].to_json %>)">
231
+ <td>
232
+ <a href="<%= dashboard_return_to(tab: "jobs", queue_name: queue[:name], state: @jobs_selected_state, page: 1, per_page: @jobs_per_page) %>"
233
+ style="text-decoration: none;"
234
+ @click.prevent="selectedQueue = <%= queue[:name].to_json %>; window.location = $el.href"><strong><%= queue[:name] %></strong></a>
235
+ </td>
236
+ <td><%= queue[:ready_estimate] %></td>
237
+ <td><%= queue[:in_progress_count] %></td>
238
+ <td><span style="color: var(--sq-danger);"><%= queue[:failed_count] %></span></td>
239
+ <td><%= queue[:total_jobs_count] %></td>
240
+ </tr>
241
+ <% end %>
242
+ </tbody>
243
+ </table>
244
+ </figure>
245
+ <% end %>
246
+ <% else %>
247
+ <div x-data="{ selectedIds: [] }" x-effect="window.dispatchEvent(new CustomEvent('dashboard-pause', { detail: { paused: selectedIds.length > 0 } }))">
248
+ <nav aria-label="breadcrumb" style="margin-bottom: 1.5rem;">
249
+ <ul>
250
+ <li><a href="<%= dashboard_return_to(tab: "jobs", queue_name: nil, page: nil) %>" class="secondary" @click.prevent="selectedQueue = null; window.location = $el.href">All Queues</a></li>
251
+ <li><strong><%= @selected_queue_name %></strong></li>
252
+ </ul>
253
+ </nav>
254
+
255
+ <div class="toolbar">
256
+ <div>
257
+ <h3 style="margin: 0;"><%= @selected_queue_name %></h3>
258
+ <small class="muted">Approx. <%= @jobs_pagination[:approximate_total_count] %> jobs in the <%= @jobs_selected_state.humanize %> view.</small>
259
+ </div>
260
+ <div class="actions-col">
261
+ <% if selected_queue_metrics[:paused] %>
262
+ <form method="post" action="<%= resume_queues_path %>" style="margin: 0;">
263
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
264
+ <input type="hidden" name="queue_name" value="<%= @selected_queue_name %>">
265
+ <input type="hidden" name="return_to" value="<%= dashboard_return_to %>">
266
+ <button type="submit" class="outline">Resume Queue</button>
267
+ </form>
268
+ <% else %>
269
+ <form method="post" action="<%= pause_queues_path %>" style="margin: 0;">
270
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
271
+ <input type="hidden" name="queue_name" value="<%= @selected_queue_name %>">
272
+ <input type="hidden" name="return_to" value="<%= dashboard_return_to %>">
273
+ <button type="submit" class="outline secondary">Pause Queue</button>
274
+ </form>
275
+ <% end %>
276
+
277
+ <form method="post" action="<%= clear_queues_path %>" style="margin: 0;">
278
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
279
+ <input type="hidden" name="queue_name" value="<%= @selected_queue_name %>">
280
+ <input type="hidden" name="return_to" value="<%= dashboard_return_to %>">
281
+ <button type="submit" class="outline secondary">Clear ready jobs</button>
282
+ </form>
283
+ </div>
284
+ </div>
285
+
286
+ <nav class="filter-nav">
287
+ <% {
288
+ "failed" => selected_queue_metrics[:failed_count],
289
+ "in_progress" => selected_queue_metrics[:in_progress_count],
290
+ "ready" => selected_queue_metrics[:ready_estimate],
291
+ "scheduled" => selected_queue_metrics[:scheduled_count],
292
+ "recurring" => selected_queue_metrics[:recurring_count]
293
+ }.each do |state_key, count| %>
294
+ <a href="<%= dashboard_return_to(tab: "jobs", state: state_key, page: 1) %>"
295
+ :class="{ 'active': jobsFilter === <%= state_key.to_json %> }"
296
+ @click.prevent="jobsFilter = <%= state_key.to_json %>; window.location = $el.href">
297
+ <%= state_key.humanize %>
298
+ <% if count.present? %>
299
+ <span class="muted">(<%= count %>)</span>
300
+ <% end %>
301
+ </a>
302
+ <% end %>
303
+ </nav>
304
+
305
+ <div class="toolbar">
306
+ <div style="display: flex; gap: 1rem; align-items: center;">
307
+ <input type="search" x-model="jobClassFilter" placeholder="Filter by Class..." style="margin: 0; width: 250px;">
308
+ <small class="muted">Approx. <%= @jobs_pagination[:approximate_total_count] %> results (Metadata count)</small>
309
+ </div>
310
+
311
+ <div class="actions-col" x-show="jobsFilter === 'failed'" x-cloak>
312
+ <% if @jobs_selected_state == "failed" %>
313
+ <form method="post" action="<%= bulk_retry_jobs_path %>" style="margin: 0;" x-on:submit="if (selectedIds.length === 0) { event.preventDefault(); }">
314
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
315
+ <input type="hidden" name="state" value="<%= @jobs_selected_state %>">
316
+ <input type="hidden" name="queue_name" value="<%= @selected_queue_name %>">
317
+ <input type="hidden" name="page" value="<%= @jobs_pagination[:page] %>">
318
+ <input type="hidden" name="per_page" value="<%= @jobs_pagination[:per_page] %>">
319
+ <input type="hidden" name="return_to" value="<%= dashboard_return_to %>">
320
+ <template x-for="jobId in selectedIds" :key="`retry-${jobId}`">
321
+ <input type="hidden" name="job_ids[]" :value="jobId">
322
+ </template>
323
+ <button type="submit" :disabled="selectedIds.length === 0">Retry All</button>
324
+ </form>
325
+ <% end %>
326
+
327
+ <% if ["ready", "scheduled", "failed"].include?(@jobs_selected_state) %>
328
+ <form method="post" action="<%= bulk_discard_jobs_path %>" style="margin: 0;" x-on:submit="if (selectedIds.length === 0) { event.preventDefault(); }">
329
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
330
+ <input type="hidden" name="state" value="<%= @jobs_selected_state %>">
331
+ <input type="hidden" name="queue_name" value="<%= @selected_queue_name %>">
332
+ <input type="hidden" name="page" value="<%= @jobs_pagination[:page] %>">
333
+ <input type="hidden" name="per_page" value="<%= @jobs_pagination[:per_page] %>">
334
+ <input type="hidden" name="return_to" value="<%= dashboard_return_to %>">
335
+ <template x-for="jobId in selectedIds" :key="`discard-${jobId}`">
336
+ <input type="hidden" name="job_ids[]" :value="jobId">
337
+ </template>
338
+ <button type="submit" class="secondary outline" :disabled="selectedIds.length === 0">Discard All</button>
339
+ </form>
340
+ <% end %>
341
+ </div>
342
+ </div>
343
+
344
+ <% if @jobs.empty? %>
345
+ <article class="empty-state">No jobs matched the current queue and state filter.</article>
346
+ <% else %>
347
+ <figure style="margin: 0;">
348
+ <table role="grid">
349
+ <thead>
350
+ <tr>
351
+ <th scope="col" style="width: 40px; padding-left: 1rem;"><input type="checkbox" aria-label="Select all" disabled></th>
352
+ <th scope="col">Job ID</th>
353
+ <th scope="col">Class</th>
354
+ <th scope="col">Error / Status</th>
355
+ <th scope="col">Created At</th>
356
+ <th scope="col">Actions</th>
357
+ </tr>
358
+ </thead>
359
+ <% @jobs.each do |job| %>
360
+ <% metadata_payload = {
361
+ job_class: job[:class_name],
362
+ job_id: job[:active_job_id],
363
+ provider_job_id: job[:id],
364
+ queue_name: job[:queue_name],
365
+ priority: job[:priority],
366
+ state: job[:state],
367
+ enqueued_at: job[:created_at]
368
+ }.compact %>
369
+ <tbody x-data="{ expanded: false, showMetadata: false }" x-show="visibleJob(<%= job[:class_name].to_json %>)">
370
+ <tr class="job-row" @click="expanded = !expanded">
371
+ <td style="padding-left: 1rem;" @click.stop><input type="checkbox" value="<%= job[:id] %>" x-model="selectedIds"></td>
372
+ <td>
373
+ <svg class="chevron" :class="{ 'expanded': expanded }" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
374
+ <polyline points="9 18 15 12 9 6"></polyline>
375
+ </svg>
376
+ <code><%= job[:id] %></code>
377
+ </td>
378
+ <td><strong><%= job[:class_name] %></strong></td>
379
+ <td>
380
+ <% if job[:failed_execution].present? %>
381
+ <span style="color: var(--sq-danger);"><%= job[:failed_execution][:exception_class] %></span>
382
+ <% else %>
383
+ <span><%= job[:state_label] %></span>
384
+ <% end %>
385
+ </td>
386
+ <td><small><%= dashboard_relative_time(job[:created_at]) %></small></td>
387
+ <td class="actions-col" @click.stop>
388
+ <% if job[:state] == "failed" %>
389
+ <form method="post" action="<%= retry_job_path(job[:id]) %>" style="margin: 0;">
390
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
391
+ <input type="hidden" name="state" value="<%= @jobs_selected_state %>">
392
+ <input type="hidden" name="queue_name" value="<%= @selected_queue_name %>">
393
+ <input type="hidden" name="page" value="<%= @jobs_pagination[:page] %>">
394
+ <input type="hidden" name="per_page" value="<%= @jobs_pagination[:per_page] %>">
395
+ <input type="hidden" name="return_to" value="<%= dashboard_return_to %>">
396
+ <button type="submit" class="outline">Retry</button>
397
+ </form>
398
+ <% end %>
399
+
400
+ <form method="post" action="<%= discard_job_path(job[:id]) %>" style="margin: 0;">
401
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
402
+ <input type="hidden" name="state" value="<%= @jobs_selected_state %>">
403
+ <input type="hidden" name="queue_name" value="<%= @selected_queue_name %>">
404
+ <input type="hidden" name="page" value="<%= @jobs_pagination[:page] %>">
405
+ <input type="hidden" name="per_page" value="<%= @jobs_pagination[:per_page] %>">
406
+ <input type="hidden" name="return_to" value="<%= dashboard_return_to %>">
407
+ <button type="submit" class="outline secondary">Discard</button>
408
+ </form>
409
+ </td>
410
+ </tr>
411
+ <tr x-show="expanded" class="job-detail-row" x-transition>
412
+ <td colspan="6">
413
+ <div class="job-detail-content">
414
+ <div style="margin-bottom: 1.5rem;">
415
+ <strong>Execution Details</strong>
416
+ <ul style="margin-top: 0.5rem; margin-bottom: 0; font-size: 0.9rem;">
417
+ <li><strong>Enqueued At:</strong> <%= job[:created_at] || "n/a" %></li>
418
+ <li><strong>Failed At:</strong> <%= job.dig(:failed_execution, :created_at) || "n/a" %></li>
419
+ <li><strong>Priority:</strong> <%= job[:priority] || "n/a" %></li>
420
+ <li><strong>Queue:</strong> <%= job[:queue_name] %></li>
421
+ <li><strong>State:</strong> <%= job[:state_label] %></li>
422
+ </ul>
423
+ </div>
424
+
425
+ <div style="margin-bottom: 1.5rem;">
426
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
427
+ <strong>Job Payload</strong>
428
+ <a href="#" @click.prevent="showMetadata = !showMetadata" style="font-size: 0.85rem;" x-text="showMetadata ? '- Hide Metadata' : '+ Show Metadata'"></a>
429
+ </div>
430
+ <pre><code><%= JSON.pretty_generate(job[:arguments] || {}) %></code></pre>
431
+
432
+ <div x-show="showMetadata" style="display: none; margin-top: 1rem;" x-transition>
433
+ <strong>ActiveJob Metadata</strong>
434
+ <pre style="margin-top: 0.5rem;"><code><%= JSON.pretty_generate(metadata_payload) %></code></pre>
435
+ </div>
436
+ </div>
437
+
438
+ <% if job[:failed_execution].present? %>
439
+ <div>
440
+ <strong style="color: var(--sq-danger);">Exception Backtrace</strong>
441
+ <pre style="margin-top: 0.5rem;"><code style="color: var(--sq-danger);"><%= ["#{job[:failed_execution][:exception_class]}: #{job[:failed_execution][:message]}", *Array(job[:failed_execution][:backtrace])].join("\n") %></code></pre>
442
+ </div>
443
+ <% end %>
444
+ </div>
445
+ </td>
446
+ </tr>
447
+ </tbody>
448
+ <% end %>
449
+ </table>
450
+ </figure>
451
+
452
+ <nav aria-label="Pagination" style="margin-top: 1rem;">
453
+ <ul>
454
+ <li>
455
+ <% if @jobs_pagination[:page] > 1 %>
456
+ <a href="<%= dashboard_return_to(tab: "jobs", page: @jobs_pagination[:page] - 1) %>" role="button" class="outline secondary">Prev</a>
457
+ <% else %>
458
+ <button class="outline secondary" disabled>Prev</button>
459
+ <% end %>
460
+ </li>
461
+ <li>
462
+ <% if @jobs_pagination[:page] < @jobs_pagination[:total_pages] %>
463
+ <a href="<%= dashboard_return_to(tab: "jobs", page: @jobs_pagination[:page] + 1) %>" role="button" class="outline secondary">Next</a>
464
+ <% else %>
465
+ <button class="outline secondary" disabled>Next</button>
466
+ <% end %>
467
+ </li>
468
+ </ul>
469
+ </nav>
470
+ <% end %>
471
+ </div>
472
+ <% end %>
473
+ </div>
474
+
475
+ <div x-show="tab === 'processes'" x-cloak>
476
+ <div class="toolbar">
477
+ <div>
478
+ <h3 style="margin: 0;">Active Topology</h3>
479
+ <small class="muted">Heartbeat threshold: <%= @heartbeat[:stale_after_seconds] %> seconds</small>
480
+ </div>
481
+ <form method="post" action="<%= prune_processes_path %>" style="margin: 0;">
482
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
483
+ <input type="hidden" name="return_to" value="<%= dashboard_return_to(tab: "processes") %>">
484
+ <button type="submit" class="outline secondary">Prune Dead Processes</button>
485
+ </form>
486
+ </div>
487
+
488
+ <% if @processes.empty? %>
489
+ <article class="empty-state">No active Solid Queue processes are currently registered.</article>
490
+ <% else %>
491
+ <div class="process-grid dashboard-processes-grid">
492
+ <% @processes.each do |process| %>
493
+ <article class="process-card" x-data="{ showMetadata: false }"
494
+ style="<%= "border-color: var(--sq-warning);" if process[:status] == "stale" %><%= "border-color: var(--sq-danger); opacity: 0.8;" if process[:status] == "dead" %>">
495
+ <header style="<%= "background: rgba(245, 158, 11, 0.05);" if process[:status] == "stale" %><%= "background: rgba(239, 68, 68, 0.05);" if process[:status] == "dead" %>">
496
+ <div>
497
+ <span class="status-dot <%= process[:status] == "active" ? "status-active" : (process[:status] == "stale" ? "status-stale" : "status-dead") %>"></span>
498
+ <strong><%= process[:kind] %></strong>
499
+ </div>
500
+ <code>PID: <%= process[:pid] %></code>
501
+ </header>
502
+ <ul>
503
+ <li><span class="pico-color-muted">Hostname</span> <strong><%= process[:hostname].presence || "unknown" %></strong></li>
504
+ <li><span class="pico-color-muted">Heartbeat</span> <strong><%= dashboard_relative_time(process[:last_heartbeat_at]) %></strong></li>
505
+ <% if process[:supervisor_id].present? %>
506
+ <li><span class="pico-color-muted">Supervisor</span> <strong><%= process[:supervisor_id] %></strong></li>
507
+ <% end %>
508
+ </ul>
509
+ <% if process[:metadata].present? %>
510
+ <div style="padding: 0 1rem 1rem 1rem;">
511
+ <a href="#" @click.prevent="showMetadata = !showMetadata" style="font-size: 0.85rem;" x-text="showMetadata ? '- Hide Config' : '+ Show Config'"></a>
512
+ <div x-show="showMetadata" style="display: none; margin-top: 0.5rem;" x-transition>
513
+ <pre style="padding: 0.75rem; font-size: 0.75rem;"><code><%= JSON.pretty_generate(process[:metadata]) %></code></pre>
514
+ </div>
515
+ </div>
516
+ <% end %>
517
+ <footer>
518
+ <div style="font-size: 0.8rem; margin-bottom: 0.5rem; color: var(--pico-muted-color);">Listening to Queues:</div>
519
+ <div>
520
+ <% if process[:queue_names].present? %>
521
+ <% process[:queue_names].each do |queue_name| %>
522
+ <a href="<%= dashboard_return_to(tab: "jobs", queue_name: queue_name, state: "failed", page: 1) %>" class="queue-badge"><%= queue_name %></a>
523
+ <% end %>
524
+ <% else %>
525
+ <span class="pico-color-muted" style="font-size: 0.8rem;"><em>System Process</em></span>
526
+ <% end %>
527
+ </div>
528
+ </footer>
529
+ </article>
530
+ <% end %>
531
+ </div>
532
+ <% end %>
533
+ </div>
534
+
535
+ <div x-show="tab === 'recurring'" x-cloak>
536
+ <div class="toolbar">
537
+ <div>
538
+ <h3 style="margin: 0;">Recurring Tasks</h3>
539
+ <small class="muted">Monitor the latest run and status for all configured recurring work.</small>
540
+ </div>
541
+ </div>
542
+
543
+ <% if @recurring_tasks.blank? %>
544
+ <article class="empty-state">No recurring tasks are configured or synced yet.</article>
545
+ <% else %>
546
+ <div class="recurring-grid">
547
+ <% @recurring_tasks.each do |task| %>
548
+ <% status_class = case task[:last_status]
549
+ when "Failed" then "status-dead"
550
+ when "Running", "Queued" then "status-warning"
551
+ else "status-active"
552
+ end %>
553
+ <article>
554
+ <header style="display: flex; justify-content: space-between; gap: 1rem; align-items: center;">
555
+ <div style="min-width: 0;">
556
+ <strong class="recurring-card-value"><%= task[:description] %></strong>
557
+ <div class="muted recurring-card-value"><%= task[:key] %></div>
558
+ </div>
559
+ <span class="status-pill"><span class="status-dot <%= status_class %>"></span><%= task[:last_status] %></span>
560
+ </header>
561
+ <div class="recurring-card-body">
562
+ <div><span class="muted">Queue</span><br><strong class="recurring-card-value"><%= task[:queue_name] %></strong></div>
563
+ <div><span class="muted">Schedule</span><br><strong class="recurring-card-value"><%= task[:schedule] %></strong></div>
564
+ <div><span class="muted">Class</span><br><strong class="recurring-card-value"><%= task[:class_name] %></strong></div>
565
+ <div><span class="muted">Last Run</span><br><strong class="recurring-card-value"><%= task[:last_run_at] ? dashboard_relative_time(task[:last_run_at]) : "Never" %></strong></div>
566
+ <div><span class="muted">Next Run</span><br><strong class="recurring-card-value"><%= task[:next_run_at] ? task[:next_run_at].strftime("%Y-%m-%d %H:%M UTC") : "n/a" %></strong></div>
567
+ </div>
568
+ </article>
569
+ <% end %>
570
+ </div>
571
+ <% end %>
572
+ </div>
573
+ </section>
data/config/routes.rb ADDED
@@ -0,0 +1,30 @@
1
+ SolidQueueLite::Engine.routes.draw do
2
+ root to: "dashboards#show"
3
+ resource :dashboard, only: [ :show ], controller: :dashboards
4
+
5
+ resources :jobs, only: [ :index, :show ] do
6
+ collection do
7
+ post :bulk_retry
8
+ post :bulk_discard
9
+ end
10
+
11
+ member do
12
+ post :retry
13
+ post :discard
14
+ end
15
+ end
16
+
17
+ resources :processes, only: [ :index ] do
18
+ collection do
19
+ post :prune
20
+ end
21
+ end
22
+
23
+ resources :queues, only: [ :index ], controller: :queues do
24
+ collection do
25
+ post :pause
26
+ post :resume
27
+ post :clear
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ class CreateSolidQueueLiteStats < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :solid_queue_lite_stats do |t|
4
+ t.datetime :timestamp, null: false
5
+ t.string :queue_name, null: false
6
+ t.integer :ready_count, null: false, default: 0
7
+ t.integer :failed_count, null: false, default: 0
8
+ t.integer :scheduled_count, null: false, default: 0
9
+ t.integer :success_count, null: false, default: 0
10
+ t.float :avg_latency
11
+ end
12
+
13
+ add_index :solid_queue_lite_stats, :timestamp
14
+ add_index :solid_queue_lite_stats, [ :queue_name, :timestamp ]
15
+ end
16
+ end