tramway 4.0.2 → 4.1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +59 -3
- data/app/assets/javascripts/tramway/tramway.js +76 -5
- data/app/controllers/tramway/plugins/solid_queue/application_controller.rb +28 -0
- data/app/controllers/tramway/plugins/solid_queue/jobs_controller.rb +82 -0
- data/app/controllers/tramway/plugins/solid_queue/queues_controller.rb +38 -0
- data/app/controllers/tramway/plugins/solid_queue/recurring_tasks_controller.rb +26 -0
- data/app/helpers/tramway/plugins/solid_queue/jobs_helper.rb +27 -0
- data/app/helpers/tramway/plugins/solid_queue/queues_helper.rb +27 -0
- data/app/jobs/tramway/solid_queue/application_job.rb +14 -0
- data/app/jobs/tramway/solid_queue/bulk_destroy_job.rb +13 -0
- data/app/jobs/tramway/solid_queue/bulk_discard_job.rb +13 -0
- data/app/jobs/tramway/solid_queue/bulk_retry_job.rb +13 -0
- data/app/jobs/tramway/solid_queue/destroy_all_job.rb +13 -0
- data/app/models/tramway/solid_queue/jobs_filter.rb +53 -0
- data/app/views/tramway/plugins/solid_queue/_subnav.html.haml +7 -0
- data/app/views/tramway/plugins/solid_queue/jobs/index.html.haml +89 -0
- data/app/views/tramway/plugins/solid_queue/jobs/show.html.haml +90 -0
- data/app/views/tramway/plugins/solid_queue/queues/index.html.haml +46 -0
- data/app/views/tramway/plugins/solid_queue/recurring_tasks/index.html.haml +33 -0
- data/config/locales/en.yml +62 -0
- data/config/routes.rb +10 -0
- data/config/tailwind.config.js +1 -0
- data/lib/generators/tramway/install/install_generator.rb +12 -0
- data/lib/generators/tramway/install/solid_queue_helpers.rb +54 -0
- data/lib/tramway/config.rb +7 -1
- data/lib/tramway/configs/plugins.rb +66 -0
- data/lib/tramway/errors.rb +36 -0
- data/lib/tramway/navbar.rb +23 -0
- data/lib/tramway/plugins/base.rb +33 -0
- data/lib/tramway/plugins/solid_queue/config.rb +13 -0
- data/lib/tramway/plugins/solid_queue/plugin.rb +37 -0
- data/lib/tramway/plugins/solid_queue/routes.rb +50 -0
- data/lib/tramway/plugins/solid_queue.rb +7 -0
- data/lib/tramway/plugins.rb +37 -0
- data/lib/tramway/version.rb +1 -1
- metadata +26 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7992bbaa872dc64b21203cb0350463136687e0bfe033a1b461c87bf15804f6b
|
|
4
|
+
data.tar.gz: 24024ccfa23c7e0718a8dcfd708ce4fcfe232e67c352e5fa28360b017f0b323e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5a8e09bf7ab1d4c09d881f87d99cd1ab5841e92b29a17e2bab144bc009e70263af0cf3ffc71930a027dd589beb13f50c933f4ffcc9fb292e3c36e75f2015b699
|
|
7
|
+
data.tar.gz: 0110e0d22d0f745d459021d97b929ed4915e08d292a706d7a06dd0c409cd2426c42f2fdb0e40af5ef243e1609b8c37c5ad20204c38b7d16db4f4d0f9d5599b89
|
data/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# Tramway
|
|
2
2
|
Unite Ruby on Rails brilliance. Streamline development with Tramway.
|
|
3
3
|
|
|
4
|
+
> **AI agents: read this first.** Before writing or changing any code in a Rails app that uses Tramway, install and
|
|
5
|
+
> use the [`tramway-skill`](https://github.com/Purple-Magic/tramway-skill/) skill — it is mandatory for Tramway work
|
|
6
|
+
> and is the authoritative, up-to-date playbook for Tramway's conventions (CRUD, Forms, Decorators, Navbar, Plugins,
|
|
7
|
+
> and more). Do not generate Tramway-related code from memory alone.
|
|
8
|
+
|
|
4
9
|
Tramway ships with full CRUD (index, show, create, update, destroy) out of the box. Its install generator also adds a
|
|
5
10
|
Codex instruction that points agents to the Tramway skill for Tramway-native code generation.
|
|
6
11
|
|
|
@@ -1558,10 +1563,19 @@ Autocomplete select example:
|
|
|
1558
1563
|
<% end %>
|
|
1559
1564
|
```
|
|
1560
1565
|
|
|
1561
|
-
`autocomplete: true` renders an autocomplete-enabled select. It
|
|
1562
|
-
|
|
1566
|
+
`autocomplete: true` renders an autocomplete-enabled select. It can be combined with `multiple: true` to get a
|
|
1567
|
+
searchable multi-select:
|
|
1568
|
+
|
|
1569
|
+
```erb
|
|
1570
|
+
<%= tramway_form_for @user do |f| %>
|
|
1571
|
+
<%= f.select :departments, [['Engineering', 'engineering'], ['Sales', 'sales']], autocomplete: true, multiple: true %>
|
|
1572
|
+
<% end %>
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
With both options set, the search input stays visible after each selection so you can keep filtering and picking
|
|
1576
|
+
more options; selected options still show as removable tags.
|
|
1563
1577
|
|
|
1564
|
-
When you use `
|
|
1578
|
+
When you use `multiple: true`, make sure any preselected values still exist in the collection you render. Unknown
|
|
1565
1579
|
values are ignored by the widget so the select can still boot safely.
|
|
1566
1580
|
|
|
1567
1581
|
**Examples**
|
|
@@ -1766,6 +1780,48 @@ Tramway.configure do |config|
|
|
|
1766
1780
|
end
|
|
1767
1781
|
```
|
|
1768
1782
|
|
|
1783
|
+
## Plugins
|
|
1784
|
+
|
|
1785
|
+
Plugins add optional, ready-made dashboards to your app, mounted and styled with Tramway. Enable one by name and,
|
|
1786
|
+
optionally, configure it:
|
|
1787
|
+
|
|
1788
|
+
```ruby
|
|
1789
|
+
Tramway.configure do |config|
|
|
1790
|
+
config.plugins = [:solid_queue]
|
|
1791
|
+
config.plugins.solid_queue = {
|
|
1792
|
+
path: '/jobs'
|
|
1793
|
+
}
|
|
1794
|
+
end
|
|
1795
|
+
```
|
|
1796
|
+
|
|
1797
|
+
Enabling a plugin also adds a navbar entry for it automatically (see [Tramway
|
|
1798
|
+
Navbar](#tramway-navbar)).
|
|
1799
|
+
|
|
1800
|
+
### SolidQueue
|
|
1801
|
+
|
|
1802
|
+
The `:solid_queue` plugin is a dashboard for inspecting and managing [SolidQueue](https://github.com/rails/solid_queue)
|
|
1803
|
+
jobs. It requires the `solid_queue` gem in your Gemfile — if it's missing, Tramway raises a clear error explaining
|
|
1804
|
+
how to fix it instead of failing deep inside a controller.
|
|
1805
|
+
|
|
1806
|
+
It gives you:
|
|
1807
|
+
|
|
1808
|
+
- A paginated, filterable jobs list (by status, queue, class name, or free-text search by class name/active job
|
|
1809
|
+
ID/numeric ID), with per-status counts.
|
|
1810
|
+
- A job detail page, including failure details (exception class, message, backtrace) for failed jobs.
|
|
1811
|
+
- Retry, discard and destroy for a single job, or in bulk for a selection of jobs — or "Destroy all" to destroy
|
|
1812
|
+
every job matching the currently active filters at once. Bulk actions and "Destroy all" run in the background
|
|
1813
|
+
(you'll see a flash message confirming the action was queued, not completed instantly) on their own dedicated
|
|
1814
|
+
worker queue, which `tramway:install` sets up for you.
|
|
1815
|
+
- A queues list with size/running/latency/throughput, and pause/resume/clear actions per queue. "Size" only counts
|
|
1816
|
+
jobs waiting to run — "Running" shows jobs currently executing on that queue, so a queue processing one big job
|
|
1817
|
+
(e.g. a large "Destroy all") doesn't look idle just because nothing is waiting behind it.
|
|
1818
|
+
- A recurring tasks list with the ability to enqueue a task immediately.
|
|
1819
|
+
- Jobs, queues, and recurring tasks lists (and the job detail page) refresh themselves in the background every few
|
|
1820
|
+
seconds, so job status changes show up without reloading the page.
|
|
1821
|
+
|
|
1822
|
+
See [docs/users/tramway_solid_queue_plugin.md](docs/users/tramway_solid_queue_plugin.md) for more on what "discard"
|
|
1823
|
+
does and other SolidQueue-specific behavior.
|
|
1824
|
+
|
|
1769
1825
|
## Lantern Color Palette
|
|
1770
1826
|
|
|
1771
1827
|
| Type | Color |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
-
/* global console */
|
|
2
|
+
/* global console, fetch, setInterval, clearInterval, DOMParser */
|
|
3
3
|
|
|
4
4
|
class TramwaySelect extends Controller {
|
|
5
5
|
static targets = ["dropdown", "showSelectedArea", "hiddenInput", "caretDown", "caretUp"]
|
|
@@ -26,7 +26,7 @@ class TramwaySelect extends Controller {
|
|
|
26
26
|
this.items = JSON.parse(this.element.dataset.items).map((item, index) => {
|
|
27
27
|
return {
|
|
28
28
|
index,
|
|
29
|
-
text: item.text,
|
|
29
|
+
text: item.text == null ? '' : item.text.toString(),
|
|
30
30
|
value: item.value.toString(),
|
|
31
31
|
selected: false
|
|
32
32
|
}
|
|
@@ -59,7 +59,7 @@ class TramwaySelect extends Controller {
|
|
|
59
59
|
|
|
60
60
|
let content = allItems;
|
|
61
61
|
|
|
62
|
-
if (this.autocomplete() && this.selectedItems.length === 0) {
|
|
62
|
+
if (this.autocomplete() && (this.multiple() || this.selectedItems.length === 0)) {
|
|
63
63
|
content += this.element.dataset.autocompleteInput;
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -80,7 +80,14 @@ class TramwaySelect extends Controller {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
toggleDropdown() {
|
|
83
|
+
toggleDropdown(event) {
|
|
84
|
+
// Clicking into the autocomplete search input bubbles up to this action. While the dropdown
|
|
85
|
+
// is already open, that click should keep it open (so the user can keep typing/selecting)
|
|
86
|
+
// rather than close it on every refocus.
|
|
87
|
+
if (this.dropdownState === 'open' && event && event.target.tagName === 'INPUT') {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
84
91
|
if (this.dropdownState === 'closed') {
|
|
85
92
|
this.openDropdown();
|
|
86
93
|
} else {
|
|
@@ -103,6 +110,14 @@ class TramwaySelect extends Controller {
|
|
|
103
110
|
|
|
104
111
|
this.caretDownTarget.classList.add('hidden');
|
|
105
112
|
this.caretUpTarget.classList.remove('hidden');
|
|
113
|
+
|
|
114
|
+
if (this.autocomplete()) {
|
|
115
|
+
this.searchInput()?.focus();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
searchInput() {
|
|
120
|
+
return this.showSelectedAreaTarget.querySelector('input');
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
dropdown() {
|
|
@@ -564,4 +579,60 @@ class Collapsible extends Controller {
|
|
|
564
579
|
}
|
|
565
580
|
}
|
|
566
581
|
|
|
567
|
-
|
|
582
|
+
// Periodically re-fetches the current page and swaps the innerHTML of each marked
|
|
583
|
+
// "refreshable" target by id, so pages reflect background changes (e.g. job status moving
|
|
584
|
+
// through SolidQueue) without a full reload. A target is skipped while it has a checked
|
|
585
|
+
// checkbox or focus inside it, so an in-progress bulk selection or form entry isn't clobbered.
|
|
586
|
+
class AutoRefresh extends Controller {
|
|
587
|
+
static targets = ["refreshable"]
|
|
588
|
+
|
|
589
|
+
static values = {
|
|
590
|
+
interval: { type: Number, default: 4000 }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
connect() {
|
|
594
|
+
this.refreshing = false
|
|
595
|
+
this.timer = setInterval(() => this.refresh(), this.intervalValue)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
disconnect() {
|
|
599
|
+
clearInterval(this.timer)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async refresh() {
|
|
603
|
+
if (this.refreshing || document.hidden) return
|
|
604
|
+
|
|
605
|
+
this.refreshing = true
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
const response = await fetch(window.location.href, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
|
609
|
+
|
|
610
|
+
if (!response.ok) return
|
|
611
|
+
|
|
612
|
+
const next = new DOMParser().parseFromString(await response.text(), 'text/html')
|
|
613
|
+
|
|
614
|
+
this.refreshableTargets.forEach((target) => this.updateTarget(target, next))
|
|
615
|
+
} finally {
|
|
616
|
+
this.refreshing = false
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
updateTarget(target, next) {
|
|
621
|
+
if (!target.id || this.hasPendingInput(target)) return
|
|
622
|
+
|
|
623
|
+
const replacement = next.getElementById(target.id)
|
|
624
|
+
|
|
625
|
+
if (replacement) {
|
|
626
|
+
target.innerHTML = replacement.innerHTML
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
hasPendingInput(target) {
|
|
631
|
+
const focused = target.contains(document.activeElement) &&
|
|
632
|
+
document.activeElement.matches('input, textarea, select')
|
|
633
|
+
|
|
634
|
+
return target.querySelector('input:checked') !== null || focused
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export { TramwaySelect, UiCheckbox, Tooltip, Navbar, Collapsible, AutoRefresh }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tramway/plugins'
|
|
4
|
+
|
|
5
|
+
module Tramway
|
|
6
|
+
module Plugins
|
|
7
|
+
module SolidQueue
|
|
8
|
+
# Base controller for the solid_queue plugin dashboard
|
|
9
|
+
class ApplicationController < Tramway.config.application_controller.constantize
|
|
10
|
+
prepend_view_path "#{Gem::Specification.find_by_name('tramway').gem_dir}/app/views"
|
|
11
|
+
|
|
12
|
+
layout 'tramway/layouts/application'
|
|
13
|
+
|
|
14
|
+
helper Tramway::ApplicationHelper
|
|
15
|
+
helper Tramway::Plugins::SolidQueue::JobsHelper
|
|
16
|
+
include Rails.application.routes.url_helpers
|
|
17
|
+
|
|
18
|
+
before_action :ensure_solid_queue_available!
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def ensure_solid_queue_available!
|
|
23
|
+
Tramway::Plugins.fetch(:solid_queue).ensure_available!
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module Plugins
|
|
5
|
+
module SolidQueue
|
|
6
|
+
# Lists, filters and manages SolidQueue jobs for the plugin dashboard
|
|
7
|
+
class JobsController < ApplicationController
|
|
8
|
+
def index
|
|
9
|
+
@jobs = jobs_filter.jobs.order(id: :desc).page(params[:page])
|
|
10
|
+
@status_counts = jobs_filter.status_counts
|
|
11
|
+
@queue_names = ::SolidQueue::Job.distinct.order(:queue_name).pluck(:queue_name)
|
|
12
|
+
@class_names = ::SolidQueue::Job.distinct.order(:class_name).pluck(:class_name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def show
|
|
16
|
+
@job = job
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def destroy
|
|
20
|
+
job.destroy
|
|
21
|
+
|
|
22
|
+
redirect_to jobs_path, notice: t('tramway.plugins.solid_queue.notices.destroyed')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def retry
|
|
26
|
+
job.retry
|
|
27
|
+
|
|
28
|
+
redirect_to jobs_path, notice: t('tramway.plugins.solid_queue.notices.retried')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def discard
|
|
32
|
+
job.discard
|
|
33
|
+
|
|
34
|
+
redirect_to jobs_path, notice: t('tramway.plugins.solid_queue.notices.discarded')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def bulk_retry
|
|
38
|
+
Tramway::SolidQueue::BulkRetryJob.perform_later(selected_job_ids)
|
|
39
|
+
|
|
40
|
+
redirect_to jobs_path, notice: t('tramway.plugins.solid_queue.notices.bulk_retry_enqueued')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bulk_discard
|
|
44
|
+
Tramway::SolidQueue::BulkDiscardJob.perform_later(selected_job_ids)
|
|
45
|
+
|
|
46
|
+
redirect_to jobs_path, notice: t('tramway.plugins.solid_queue.notices.bulk_discard_enqueued')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bulk_destroy
|
|
50
|
+
Tramway::SolidQueue::BulkDestroyJob.perform_later(selected_job_ids)
|
|
51
|
+
|
|
52
|
+
redirect_to jobs_path, notice: t('tramway.plugins.solid_queue.notices.bulk_destroy_enqueued')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def destroy_all
|
|
56
|
+
Tramway::SolidQueue::DestroyAllJob.perform_later(filter_params)
|
|
57
|
+
|
|
58
|
+
redirect_to jobs_path(request.query_parameters.except('page')),
|
|
59
|
+
notice: t('tramway.plugins.solid_queue.notices.destroy_all_enqueued')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def job
|
|
65
|
+
@job ||= ::SolidQueue::Job.find(params.expect(:id))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def jobs_filter
|
|
69
|
+
@jobs_filter ||= Tramway::SolidQueue::JobsFilter.new(filter_params)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def filter_params
|
|
73
|
+
params.permit(:status, :queue_name, :class_name, :query).to_h
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def selected_job_ids
|
|
77
|
+
Array(params[:job_ids]).map(&:to_i)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module Plugins
|
|
5
|
+
module SolidQueue
|
|
6
|
+
# Lists SolidQueue queues and lets the user pause/resume/clear them
|
|
7
|
+
class QueuesController < ApplicationController
|
|
8
|
+
def index
|
|
9
|
+
@queues = ::SolidQueue::Queue.all.sort_by(&:name)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def pause
|
|
13
|
+
queue.pause
|
|
14
|
+
|
|
15
|
+
redirect_to queues_path, notice: t('tramway.plugins.solid_queue.notices.paused')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resume
|
|
19
|
+
queue.resume
|
|
20
|
+
|
|
21
|
+
redirect_to queues_path, notice: t('tramway.plugins.solid_queue.notices.resumed')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear
|
|
25
|
+
queue.clear
|
|
26
|
+
|
|
27
|
+
redirect_to queues_path, notice: t('tramway.plugins.solid_queue.notices.cleared')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def queue
|
|
33
|
+
@queue ||= ::SolidQueue::Queue.find_by_name(params.expect(:name))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module Plugins
|
|
5
|
+
module SolidQueue
|
|
6
|
+
# Lists SolidQueue recurring tasks and lets the user enqueue one immediately
|
|
7
|
+
class RecurringTasksController < ApplicationController
|
|
8
|
+
def index
|
|
9
|
+
@recurring_tasks = ::SolidQueue::RecurringTask.order(:key)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def enqueue
|
|
13
|
+
recurring_task.enqueue(at: Time.current)
|
|
14
|
+
|
|
15
|
+
redirect_to recurring_tasks_path, notice: t('tramway.plugins.solid_queue.notices.enqueued')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def recurring_task
|
|
21
|
+
@recurring_task ||= ::SolidQueue::RecurringTask.find_by!(key: params.expect(:key))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module Plugins
|
|
5
|
+
module SolidQueue
|
|
6
|
+
# View helpers for rendering SolidQueue job statuses in the plugin dashboard
|
|
7
|
+
module JobsHelper
|
|
8
|
+
STATUS_BADGE_TYPES = {
|
|
9
|
+
ready: :hope,
|
|
10
|
+
claimed: :love,
|
|
11
|
+
scheduled: :fear,
|
|
12
|
+
blocked: :warning,
|
|
13
|
+
failed: :rage,
|
|
14
|
+
finished: :will
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def solid_queue_status_badge_type(status)
|
|
18
|
+
STATUS_BADGE_TYPES.fetch(status&.to_sym, :default)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def solid_queue_status_label(status)
|
|
22
|
+
t("tramway.plugins.solid_queue.statuses.#{status}", default: status.to_s.humanize)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module Plugins
|
|
5
|
+
module SolidQueue
|
|
6
|
+
# View helpers for rendering SolidQueue queue throughput and running-job counts in the plugin dashboard
|
|
7
|
+
module QueuesHelper
|
|
8
|
+
THROUGHPUT_WINDOW = 5.minutes
|
|
9
|
+
|
|
10
|
+
def solid_queue_throughput_label(queue)
|
|
11
|
+
t('tramway.plugins.solid_queue.queues.throughput_value', default: '%<rate>s/min',
|
|
12
|
+
rate: solid_queue_throughput(queue))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def solid_queue_throughput(queue)
|
|
16
|
+
finished_count = ::SolidQueue::Job.where(queue_name: queue.name, finished_at: THROUGHPUT_WINDOW.ago..).count
|
|
17
|
+
|
|
18
|
+
(finished_count / THROUGHPUT_WINDOW.in_minutes).round(1)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def solid_queue_running_count(queue)
|
|
22
|
+
::SolidQueue::Job.where(queue_name: queue.name).joins(:claimed_execution).count
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Base job for the plugin's bulk jobs actions (retry/discard/destroy), routed to their
|
|
6
|
+
# own dedicated worker queue so a large bulk action can't starve other application jobs.
|
|
7
|
+
# The `tramway:install` generator configures this queue with 20 worker threads.
|
|
8
|
+
class ApplicationJob < ActiveJob::Base
|
|
9
|
+
BATCH_SIZE = 1000
|
|
10
|
+
|
|
11
|
+
queue_as :tramway_solid_queue_bulk_actions
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Destroys the given jobs, in batches so a large selection doesn't load/destroy
|
|
6
|
+
# everything in a single pass.
|
|
7
|
+
class BulkDestroyJob < ApplicationJob
|
|
8
|
+
def perform(job_ids)
|
|
9
|
+
::SolidQueue::Job.where(id: job_ids).in_batches(of: BATCH_SIZE, &:destroy_all)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Discards the given jobs, in batches so a large selection doesn't load everything
|
|
6
|
+
# in a single pass. `discard` is a per-record SolidQueue operation, not a bulk SQL one.
|
|
7
|
+
class BulkDiscardJob < ApplicationJob
|
|
8
|
+
def perform(job_ids)
|
|
9
|
+
::SolidQueue::Job.where(id: job_ids).find_each(batch_size: BATCH_SIZE, &:discard)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Retries the given jobs, in batches so a large selection doesn't load everything
|
|
6
|
+
# in a single pass. `retry` is a per-record SolidQueue operation, not a bulk SQL one.
|
|
7
|
+
class BulkRetryJob < ApplicationJob
|
|
8
|
+
def perform(job_ids)
|
|
9
|
+
::SolidQueue::Job.where(id: job_ids).find_each(batch_size: BATCH_SIZE, &:retry)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Destroys every job matching the given filters, in batches so a large match set
|
|
6
|
+
# doesn't load/destroy everything in a single pass.
|
|
7
|
+
class DestroyAllJob < ApplicationJob
|
|
8
|
+
def perform(filters = {})
|
|
9
|
+
Tramway::SolidQueue::JobsFilter.new(filters).jobs.in_batches(of: BATCH_SIZE, &:destroy_all)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tramway
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Shared status/queue/class/search filtering for SolidQueue jobs, used by both the
|
|
6
|
+
# jobs controller (for the index page) and the background jobs that process a
|
|
7
|
+
# filtered set of jobs (e.g. "Destroy all"), since a job's `perform` only receives
|
|
8
|
+
# plain serializable arguments, not a live `ActionController::Parameters`.
|
|
9
|
+
class JobsFilter
|
|
10
|
+
STATUS_FILTERS = {
|
|
11
|
+
'ready' => ->(relation) { relation.joins(:ready_execution) },
|
|
12
|
+
'claimed' => ->(relation) { relation.joins(:claimed_execution) },
|
|
13
|
+
'scheduled' => ->(relation) { relation.joins(:scheduled_execution) },
|
|
14
|
+
'blocked' => ->(relation) { relation.joins(:blocked_execution) },
|
|
15
|
+
'failed' => ->(relation) { relation.joins(:failed_execution) },
|
|
16
|
+
'finished' => ->(relation) { relation.finished }
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(filters = {})
|
|
20
|
+
@filters = filters.to_h.stringify_keys
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def jobs
|
|
24
|
+
relation = apply_status_filter(::SolidQueue::Job.all)
|
|
25
|
+
relation = relation.where(queue_name: filters['queue_name']) if filters['queue_name'].present?
|
|
26
|
+
relation = relation.where(class_name: filters['class_name']) if filters['class_name'].present?
|
|
27
|
+
|
|
28
|
+
apply_search(relation).distinct
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def status_counts
|
|
32
|
+
STATUS_FILTERS.transform_values { |scope| scope.call(::SolidQueue::Job.all).count }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :filters
|
|
38
|
+
|
|
39
|
+
def apply_status_filter(relation)
|
|
40
|
+
STATUS_FILTERS.fetch(filters['status']) { ->(rel) { rel } }.call(relation)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def apply_search(relation)
|
|
44
|
+
query = filters['query'].to_s.strip
|
|
45
|
+
|
|
46
|
+
return relation if query.blank?
|
|
47
|
+
return relation.where(id: query) if query.match?(/\A\d+\z/)
|
|
48
|
+
|
|
49
|
+
relation.where('class_name LIKE :query OR active_job_id LIKE :query', query: "%#{query}%")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
.flex.gap-2.mt-2
|
|
2
|
+
= tramway_button tag: :a, text: t('tramway.plugins.solid_queue.jobs.title'), path: jobs_path,
|
|
3
|
+
type: (current_page?(jobs_path) ? :primary : :inverse), size: :small
|
|
4
|
+
= tramway_button tag: :a, text: t('tramway.plugins.solid_queue.queues.title'), path: queues_path,
|
|
5
|
+
type: (current_page?(queues_path) ? :primary : :inverse), size: :small
|
|
6
|
+
= tramway_button tag: :a, text: t('tramway.plugins.solid_queue.recurring_tasks.title'), path: recurring_tasks_path,
|
|
7
|
+
type: (current_page?(recurring_tasks_path) ? :primary : :inverse), size: :small
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
- content_for :title, t('tramway.plugins.solid_queue.jobs.title', default: 'Background Jobs')
|
|
2
|
+
|
|
3
|
+
= tramway_container do
|
|
4
|
+
.flex.justify-between.items-center
|
|
5
|
+
= tramway_title do
|
|
6
|
+
= content_for :title
|
|
7
|
+
|
|
8
|
+
= render 'tramway/plugins/solid_queue/subnav'
|
|
9
|
+
|
|
10
|
+
%div{ data: { controller: 'tramway--auto-refresh' } }
|
|
11
|
+
#tramway-solid-queue-jobs-status-filters.flex.flex-wrap.gap-2.mt-2{ data: { 'tramway--auto-refresh-target': 'refreshable' } }
|
|
12
|
+
- @status_counts.each do |status, count|
|
|
13
|
+
- status_params = request.query_parameters.except('page').merge(status:)
|
|
14
|
+
- button_type = status == params[:status] ? :primary : :inverse
|
|
15
|
+
= tramway_button text: "#{solid_queue_status_label(status)} · #{count}",
|
|
16
|
+
path: jobs_path(status_params), type: button_type, size: :small
|
|
17
|
+
|
|
18
|
+
- if params[:status].present?
|
|
19
|
+
- clear_params = request.query_parameters.except('page', 'status')
|
|
20
|
+
= tramway_button tag: :a, path: jobs_path(clear_params), type: :inverse,
|
|
21
|
+
size: :small, tooltip: { text: t('tramway.actions.clear_filter'), event: :hover } do
|
|
22
|
+
%i.fa.fa-times{ 'aria-hidden' => 'true' }
|
|
23
|
+
|
|
24
|
+
- all_queues_option = [t('tramway.plugins.solid_queue.jobs.all_queues', default: 'All queues'), '']
|
|
25
|
+
- queue_name_options = [all_queues_option] + @queue_names.map { |name| [name, name] }
|
|
26
|
+
- all_classes_option = [t('tramway.plugins.solid_queue.jobs.all_classes', default: 'All classes'), '']
|
|
27
|
+
- class_name_options = [all_classes_option] + @class_names.map { |name| [name, name] }
|
|
28
|
+
|
|
29
|
+
= form_with url: jobs_path, method: :get, local: true, builder: Tramway::Form::Builder, horizontal: true do |f|
|
|
30
|
+
.flex.flex-wrap.items-center.gap-4.mt-2
|
|
31
|
+
= f.select :queue_name, queue_name_options, label: false, selected: params[:queue_name].to_s
|
|
32
|
+
= f.select :class_name, class_name_options, label: false, selected: params[:class_name].to_s
|
|
33
|
+
= f.text_field :query, label: false, value: params[:query], placeholder: t('tramway.actions.search')
|
|
34
|
+
= f.submit nil, 'aria-label' => t('tramway.actions.search') do
|
|
35
|
+
%i.fa.fa-search{ 'aria-hidden' => 'true' }
|
|
36
|
+
|
|
37
|
+
.flex.justify-between.gap-2.mt-4
|
|
38
|
+
= tramway_button text: t('tramway.actions.destroy_all'), path: destroy_all_jobs_path(request.query_parameters.except('page')),
|
|
39
|
+
method: :post, type: :rage, size: :small
|
|
40
|
+
|
|
41
|
+
= form_with url: bulk_destroy_jobs_path, method: :post, id: 'tramway-solid-queue-bulk-jobs-form', local: true do
|
|
42
|
+
.flex.gap-2
|
|
43
|
+
= tramway_button text: t('tramway.actions.retry'), tag: :button, type: :will, size: :small,
|
|
44
|
+
formaction: bulk_retry_jobs_path
|
|
45
|
+
= tramway_button text: t('tramway.actions.discard'), tag: :button, type: :warning, size: :small,
|
|
46
|
+
formaction: bulk_discard_jobs_path
|
|
47
|
+
= tramway_button text: t('tramway.actions.destroy'), tag: :button, type: :rage, size: :small,
|
|
48
|
+
formaction: bulk_destroy_jobs_path
|
|
49
|
+
|
|
50
|
+
#tramway-solid-queue-jobs-table{ data: { 'tramway--auto-refresh-target': 'refreshable' } }
|
|
51
|
+
= tramway_table class: 'mt-4' do
|
|
52
|
+
= tramway_header headers: ['', 'ID', t('tramway.plugins.solid_queue.jobs.class_name', default: 'Class'),
|
|
53
|
+
t('tramway.plugins.solid_queue.jobs.queue_name', default: 'Queue'),
|
|
54
|
+
t('tramway.plugins.solid_queue.jobs.status', default: 'Status'),
|
|
55
|
+
t('tramway.plugins.solid_queue.jobs.scheduled_at', default: 'Scheduled at'),
|
|
56
|
+
t('tramway.table.actions_header')]
|
|
57
|
+
- @jobs.each do |job|
|
|
58
|
+
= tramway_row do
|
|
59
|
+
= tramway_cell do
|
|
60
|
+
= check_box_tag 'job_ids[]', job.id, false, id: "tramway-solid-queue-job-#{job.id}",
|
|
61
|
+
form: 'tramway-solid-queue-bulk-jobs-form'
|
|
62
|
+
= tramway_cell do
|
|
63
|
+
= job.id
|
|
64
|
+
= tramway_cell do
|
|
65
|
+
= job.class_name
|
|
66
|
+
= tramway_cell do
|
|
67
|
+
= job.queue_name
|
|
68
|
+
= tramway_cell do
|
|
69
|
+
= tramway_badge text: solid_queue_status_label(job.status),
|
|
70
|
+
type: solid_queue_status_badge_type(job.status)
|
|
71
|
+
= tramway_cell do
|
|
72
|
+
= job.scheduled_at
|
|
73
|
+
= tramway_cell do
|
|
74
|
+
.flex.gap-2
|
|
75
|
+
= tramway_button tag: :a, path: job_path(job.id), type: :inverse, size: :small do
|
|
76
|
+
%i.fa.fa-eye{ 'aria-hidden' => 'true' }
|
|
77
|
+
- if job.status == :failed
|
|
78
|
+
= tramway_button path: retry_job_path(job.id), method: :post, type: :will, size: :small do
|
|
79
|
+
%i.fa.fa-redo{ 'aria-hidden' => 'true' }
|
|
80
|
+
- if job.status != :finished
|
|
81
|
+
= tramway_button path: discard_job_path(job.id), method: :post, type: :warning, size: :small do
|
|
82
|
+
%i.fa.fa-ban{ 'aria-hidden' => 'true' }
|
|
83
|
+
= tramway_button path: job_path(job.id), method: :delete, type: :rage, size: :small do
|
|
84
|
+
%i.fa.fa-trash{ 'aria-hidden' => 'true' }
|
|
85
|
+
|
|
86
|
+
.flex.flex-col.items-center.mt-4.gap-2
|
|
87
|
+
= paginate @jobs
|
|
88
|
+
%p.sm:hidden.text-xs.text-zinc-500
|
|
89
|
+
= page_entries_info @jobs
|