job_harbor 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.
- checksums.yaml +7 -0
- data/README.md +98 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/solidqueue_dashboard/application.css +1 -0
- data/app/components/job_harbor/application_component.rb +13 -0
- data/app/components/job_harbor/badge_component.rb +26 -0
- data/app/components/job_harbor/chart_component.rb +82 -0
- data/app/components/job_harbor/empty_state_component.rb +41 -0
- data/app/components/job_harbor/failure_rates_component.rb +84 -0
- data/app/components/job_harbor/job_filters_component.rb +92 -0
- data/app/components/job_harbor/job_row_component.rb +106 -0
- data/app/components/job_harbor/nav_link_component.rb +50 -0
- data/app/components/job_harbor/pagination_component.rb +72 -0
- data/app/components/job_harbor/per_page_selector_component.rb +40 -0
- data/app/components/job_harbor/queue_card_component.rb +59 -0
- data/app/components/job_harbor/refresh_selector_component.rb +57 -0
- data/app/components/job_harbor/stat_card_component.rb +77 -0
- data/app/components/job_harbor/theme_toggle_component.rb +48 -0
- data/app/components/job_harbor/worker_card_component.rb +86 -0
- data/app/controllers/job_harbor/application_controller.rb +44 -0
- data/app/controllers/job_harbor/dashboard_controller.rb +17 -0
- data/app/controllers/job_harbor/jobs_controller.rb +151 -0
- data/app/controllers/job_harbor/queues_controller.rb +40 -0
- data/app/controllers/job_harbor/recurring_tasks_controller.rb +35 -0
- data/app/controllers/job_harbor/workers_controller.rb +12 -0
- data/app/helpers/job_harbor/application_helper.rb +4 -0
- data/app/models/job_harbor/chart_data.rb +104 -0
- data/app/models/job_harbor/dashboard_stats.rb +90 -0
- data/app/models/job_harbor/failure_stats.rb +63 -0
- data/app/models/job_harbor/job_presenter.rb +246 -0
- data/app/models/job_harbor/queue_stats.rb +77 -0
- data/app/views/job_harbor/dashboard/index.html.erb +112 -0
- data/app/views/job_harbor/jobs/index.html.erb +100 -0
- data/app/views/job_harbor/jobs/search.html.erb +43 -0
- data/app/views/job_harbor/jobs/show.html.erb +133 -0
- data/app/views/job_harbor/queues/index.html.erb +13 -0
- data/app/views/job_harbor/queues/show.html.erb +88 -0
- data/app/views/job_harbor/recurring_tasks/index.html.erb +36 -0
- data/app/views/job_harbor/recurring_tasks/show.html.erb +97 -0
- data/app/views/job_harbor/workers/index.html.erb +33 -0
- data/app/views/layouts/job_harbor/application.html.erb +1434 -0
- data/config/routes.rb +39 -0
- data/lib/job_harbor/configuration.rb +31 -0
- data/lib/job_harbor/engine.rb +28 -0
- data/lib/job_harbor/version.rb +3 -0
- data/lib/job_harbor.rb +19 -0
- data/lib/tasks/solidqueue_dashboard_tasks.rake +4 -0
- metadata +134 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class PerPageSelectorComponent < ApplicationComponent
|
|
5
|
+
PAGE_SIZES = [ 10, 25, 50, 100 ].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(current_per_page:, current_path:, params: {})
|
|
8
|
+
@current_per_page = current_per_page.to_i
|
|
9
|
+
@current_path = current_path
|
|
10
|
+
@params = params.to_h.symbolize_keys.except(:per_page, :page, :controller, :action)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
content_tag(:div, class: "sqd-per-page-selector") do
|
|
15
|
+
safe_join([
|
|
16
|
+
content_tag(:span, "Show", class: "sqd-per-page-label"),
|
|
17
|
+
content_tag(:select,
|
|
18
|
+
class: "sqd-per-page-select",
|
|
19
|
+
data: { action: "change->per-page#change" }
|
|
20
|
+
) do
|
|
21
|
+
safe_join(PAGE_SIZES.map { |size| option_tag(size) })
|
|
22
|
+
end
|
|
23
|
+
])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def option_tag(size)
|
|
30
|
+
url = build_url(size)
|
|
31
|
+
selected = size == @current_per_page
|
|
32
|
+
content_tag(:option, size, value: url, selected: selected)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_url(per_page)
|
|
36
|
+
query_params = @params.merge(per_page: per_page)
|
|
37
|
+
"#{@current_path}?#{query_params.to_query}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class QueueCardComponent < ApplicationComponent
|
|
5
|
+
def initialize(queue:)
|
|
6
|
+
@queue = queue
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
content_tag(:div, class: "sqd-queue-card") do
|
|
11
|
+
safe_join([
|
|
12
|
+
header,
|
|
13
|
+
stats,
|
|
14
|
+
actions
|
|
15
|
+
])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def header
|
|
22
|
+
content_tag(:div, class: "sqd-queue-header") do
|
|
23
|
+
safe_join([
|
|
24
|
+
link_to(@queue.name, queue_path(@queue.name), class: "sqd-queue-name"),
|
|
25
|
+
render(BadgeComponent.new(status: @queue.paused? ? :paused : :active))
|
|
26
|
+
])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stats
|
|
31
|
+
content_tag(:div, class: "sqd-queue-stats") do
|
|
32
|
+
safe_join([
|
|
33
|
+
stat("Pending", @queue.pending_count),
|
|
34
|
+
stat("Scheduled", @queue.scheduled_count),
|
|
35
|
+
stat("In Progress", @queue.in_progress_count)
|
|
36
|
+
])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stat(label, value)
|
|
41
|
+
content_tag(:div, class: "sqd-queue-stat") do
|
|
42
|
+
safe_join([
|
|
43
|
+
content_tag(:span, value, class: "sqd-queue-stat-value"),
|
|
44
|
+
content_tag(:span, label, class: "sqd-queue-stat-label")
|
|
45
|
+
])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def actions
|
|
50
|
+
content_tag(:div, class: "sqd-actions", style: "margin-top: 1rem;") do
|
|
51
|
+
if @queue.paused?
|
|
52
|
+
button_to "Resume", resume_queue_path(@queue.name), method: :delete, class: "sqd-btn sqd-btn-sm sqd-btn-primary"
|
|
53
|
+
else
|
|
54
|
+
button_to "Pause", pause_queue_path(@queue.name), method: :post, class: "sqd-btn sqd-btn-sm sqd-btn-secondary"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class RefreshSelectorComponent < ApplicationComponent
|
|
5
|
+
INTERVALS = [
|
|
6
|
+
{ label: "Off", value: 0 },
|
|
7
|
+
{ label: "15s", value: 15 },
|
|
8
|
+
{ label: "30s", value: 30 },
|
|
9
|
+
{ label: "1m", value: 60 },
|
|
10
|
+
{ label: "5m", value: 300 }
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(default_interval: nil)
|
|
14
|
+
@default_interval = default_interval || sq_config.poll_interval
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
content_tag(:div, class: "sqd-refresh-selector") do
|
|
19
|
+
safe_join([
|
|
20
|
+
refresh_icon,
|
|
21
|
+
interval_select
|
|
22
|
+
])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def refresh_icon
|
|
29
|
+
content_tag(:svg, REFRESH_ICON.html_safe,
|
|
30
|
+
class: "sqd-refresh-icon",
|
|
31
|
+
viewBox: "0 0 24 24",
|
|
32
|
+
fill: "none",
|
|
33
|
+
stroke: "currentColor",
|
|
34
|
+
"stroke-width": "2",
|
|
35
|
+
"stroke-linecap": "round",
|
|
36
|
+
"stroke-linejoin": "round"
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def interval_select
|
|
41
|
+
content_tag(:select,
|
|
42
|
+
class: "sqd-refresh-select",
|
|
43
|
+
data: { action: "change->refresh-selector#change" },
|
|
44
|
+
"aria-label": "Auto-refresh interval"
|
|
45
|
+
) do
|
|
46
|
+
safe_join(INTERVALS.map { |interval| option_tag(interval) })
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def option_tag(interval)
|
|
51
|
+
selected = interval[:value] == @default_interval
|
|
52
|
+
content_tag(:option, interval[:label], value: interval[:value], selected: selected)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
REFRESH_ICON = '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class StatCardComponent < ApplicationComponent
|
|
5
|
+
ICONS = {
|
|
6
|
+
pending: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>',
|
|
7
|
+
scheduled: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>',
|
|
8
|
+
in_progress: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>',
|
|
9
|
+
failed: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>',
|
|
10
|
+
finished: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
|
|
11
|
+
blocked: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>',
|
|
12
|
+
workers: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>',
|
|
13
|
+
queues: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>',
|
|
14
|
+
throughput: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
COLORS = {
|
|
18
|
+
pending: "info",
|
|
19
|
+
scheduled: "warning",
|
|
20
|
+
in_progress: "info",
|
|
21
|
+
failed: "danger",
|
|
22
|
+
finished: "success",
|
|
23
|
+
blocked: nil,
|
|
24
|
+
workers: "success",
|
|
25
|
+
queues: "info",
|
|
26
|
+
throughput: "success"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(label:, value:, type: nil, link: nil)
|
|
30
|
+
@label = label
|
|
31
|
+
@value = value
|
|
32
|
+
@type = type&.to_sym
|
|
33
|
+
@link = link
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
content_tag(:div, class: "sqd-stat-card") do
|
|
38
|
+
safe_join([
|
|
39
|
+
header,
|
|
40
|
+
value_display
|
|
41
|
+
])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def header
|
|
48
|
+
content_tag(:div, class: "sqd-stat-header") do
|
|
49
|
+
safe_join([
|
|
50
|
+
content_tag(:span, @label, class: "sqd-stat-label"),
|
|
51
|
+
icon_svg
|
|
52
|
+
])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def value_display
|
|
57
|
+
color_class = COLORS[@type]
|
|
58
|
+
classes = [ "sqd-stat-value" ]
|
|
59
|
+
classes << color_class if color_class
|
|
60
|
+
|
|
61
|
+
if @link
|
|
62
|
+
link_to @value, @link, class: classes.join(" ")
|
|
63
|
+
else
|
|
64
|
+
content_tag(:span, @value, class: classes.join(" "))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def icon_svg
|
|
69
|
+
return "" unless @type
|
|
70
|
+
|
|
71
|
+
icon_path = ICONS[@type]
|
|
72
|
+
return "" unless icon_path
|
|
73
|
+
|
|
74
|
+
content_tag(:svg, icon_path.html_safe, class: "sqd-stat-icon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class ThemeToggleComponent < ApplicationComponent
|
|
5
|
+
SUN_ICON = '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>'
|
|
6
|
+
MOON_ICON = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>'
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
content_tag(:button,
|
|
10
|
+
class: "sqd-theme-toggle",
|
|
11
|
+
type: "button",
|
|
12
|
+
aria: { label: "Toggle theme" },
|
|
13
|
+
data: { action: "click->theme-toggle#toggle" }
|
|
14
|
+
) do
|
|
15
|
+
safe_join([
|
|
16
|
+
sun_icon,
|
|
17
|
+
moon_icon
|
|
18
|
+
])
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def sun_icon
|
|
25
|
+
content_tag(:svg, SUN_ICON.html_safe,
|
|
26
|
+
class: "sqd-theme-icon sqd-theme-icon-sun",
|
|
27
|
+
viewBox: "0 0 24 24",
|
|
28
|
+
fill: "none",
|
|
29
|
+
stroke: "currentColor",
|
|
30
|
+
"stroke-width": "2",
|
|
31
|
+
"stroke-linecap": "round",
|
|
32
|
+
"stroke-linejoin": "round"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def moon_icon
|
|
37
|
+
content_tag(:svg, MOON_ICON.html_safe,
|
|
38
|
+
class: "sqd-theme-icon sqd-theme-icon-moon",
|
|
39
|
+
viewBox: "0 0 24 24",
|
|
40
|
+
fill: "none",
|
|
41
|
+
stroke: "currentColor",
|
|
42
|
+
"stroke-width": "2",
|
|
43
|
+
"stroke-linecap": "round",
|
|
44
|
+
"stroke-linejoin": "round"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class WorkerCardComponent < ApplicationComponent
|
|
5
|
+
STALE_THRESHOLD = 5.minutes
|
|
6
|
+
|
|
7
|
+
def initialize(worker:)
|
|
8
|
+
@worker = worker
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
content_tag(:div, class: "sqd-worker-card") do
|
|
13
|
+
safe_join([
|
|
14
|
+
header,
|
|
15
|
+
details,
|
|
16
|
+
queues_list
|
|
17
|
+
])
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def header
|
|
24
|
+
content_tag(:div, class: "sqd-worker-header") do
|
|
25
|
+
safe_join([
|
|
26
|
+
content_tag(:span, worker_name, class: "sqd-worker-name"),
|
|
27
|
+
render(BadgeComponent.new(status: heartbeat_status))
|
|
28
|
+
])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def worker_name
|
|
33
|
+
@worker.name.presence || "Worker ##{@worker.id}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def heartbeat_status
|
|
37
|
+
if stale?
|
|
38
|
+
:blocked
|
|
39
|
+
else
|
|
40
|
+
:active
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stale?
|
|
45
|
+
return true unless @worker.last_heartbeat_at
|
|
46
|
+
|
|
47
|
+
@worker.last_heartbeat_at < STALE_THRESHOLD.ago
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def details
|
|
51
|
+
content_tag(:div, class: "sqd-worker-stats") do
|
|
52
|
+
safe_join([
|
|
53
|
+
stat("Hostname", @worker.hostname),
|
|
54
|
+
stat("PID", @worker.pid),
|
|
55
|
+
stat("Last Heartbeat", heartbeat_time)
|
|
56
|
+
])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stat(label, value)
|
|
61
|
+
content_tag(:div, class: "sqd-worker-stat") do
|
|
62
|
+
safe_join([
|
|
63
|
+
content_tag(:span, value, class: "sqd-worker-stat-value"),
|
|
64
|
+
content_tag(:span, label, class: "sqd-worker-stat-label")
|
|
65
|
+
])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def heartbeat_time
|
|
70
|
+
return "Never" unless @worker.last_heartbeat_at
|
|
71
|
+
|
|
72
|
+
time_ago_in_words(@worker.last_heartbeat_at) + " ago"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def queues_list
|
|
76
|
+
return "" unless @worker.respond_to?(:queues) && @worker.queues.present?
|
|
77
|
+
|
|
78
|
+
content_tag(:div, style: "margin-top: 1rem;") do
|
|
79
|
+
safe_join([
|
|
80
|
+
content_tag(:span, "Queues: ", class: "sqd-text-muted"),
|
|
81
|
+
@worker.queues.map { |q| content_tag(:code, q, class: "sqd-code", style: "margin-right: 0.5rem;") }
|
|
82
|
+
].flatten)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class ApplicationController < ::ApplicationController
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
before_action :authorize_access
|
|
8
|
+
|
|
9
|
+
layout "job_harbor/application"
|
|
10
|
+
|
|
11
|
+
helper_method :sq_config, :nav_counts
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def authorize_access
|
|
16
|
+
return if JobHarbor.configuration.authorize(self)
|
|
17
|
+
|
|
18
|
+
redirect_to main_app.root_path, alert: "Admin access required."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sq_config
|
|
22
|
+
JobHarbor.configuration
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def set_page_title(title)
|
|
26
|
+
@page_title = title
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def nav_counts
|
|
30
|
+
@nav_counts ||= {
|
|
31
|
+
workers: SolidQueue::Process.where(kind: "Worker").count,
|
|
32
|
+
recurring_tasks: recurring_task_count
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def recurring_task_count
|
|
37
|
+
return 0 unless sq_config.enable_recurring_tasks
|
|
38
|
+
|
|
39
|
+
SolidQueue::RecurringTask.count
|
|
40
|
+
rescue
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@stats = DashboardStats.new
|
|
7
|
+
@failure_stats = FailureStats.new.stats if sq_config.enable_failure_stats
|
|
8
|
+
|
|
9
|
+
if sq_config.enable_charts
|
|
10
|
+
@chart_range = params[:chart_range] || sq_config.default_chart_range
|
|
11
|
+
@chart_data = ChartData.new(range: @chart_range).series
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
set_page_title "Dashboard"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class JobsController < ApplicationController
|
|
5
|
+
before_action :set_job, only: [ :show, :retry, :discard ]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@status = params[:status]
|
|
9
|
+
@per_page = per_page_param
|
|
10
|
+
@class_name = params[:class_name]
|
|
11
|
+
@queue_name = params[:queue_name]
|
|
12
|
+
|
|
13
|
+
@pagy, @jobs = JobPresenter.all_with_status(
|
|
14
|
+
@status,
|
|
15
|
+
page: params[:page],
|
|
16
|
+
per_page: @per_page,
|
|
17
|
+
class_name: @class_name,
|
|
18
|
+
queue_name: @queue_name
|
|
19
|
+
)
|
|
20
|
+
@counts = job_counts
|
|
21
|
+
@filter_data = filter_data
|
|
22
|
+
set_page_title @status ? "#{@status.titleize} Jobs" : "All Jobs"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def show
|
|
26
|
+
set_page_title "Job ##{@job.id}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def search
|
|
30
|
+
query = params[:q].to_s.strip
|
|
31
|
+
if query.present?
|
|
32
|
+
@per_page = per_page_param
|
|
33
|
+
@pagy, @jobs = JobPresenter.search(query, page: params[:page], per_page: @per_page)
|
|
34
|
+
set_page_title "Search Results"
|
|
35
|
+
else
|
|
36
|
+
redirect_to jobs_path
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def retry
|
|
41
|
+
if @job.can_retry?
|
|
42
|
+
perform_retry(@job)
|
|
43
|
+
redirect_to job_path(@job), notice: "Job has been queued for retry."
|
|
44
|
+
else
|
|
45
|
+
redirect_to job_path(@job), alert: "This job cannot be retried."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def discard
|
|
50
|
+
if @job.can_discard?
|
|
51
|
+
perform_discard(@job)
|
|
52
|
+
redirect_to jobs_path(status: params[:return_status]), notice: "Job has been discarded."
|
|
53
|
+
else
|
|
54
|
+
redirect_to job_path(@job), alert: "This job cannot be discarded."
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def retry_all
|
|
59
|
+
status = params[:status]
|
|
60
|
+
count = retry_all_jobs(status)
|
|
61
|
+
redirect_to jobs_path(status: status), notice: "#{count} jobs queued for retry."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def discard_all
|
|
65
|
+
status = params[:status]
|
|
66
|
+
count = discard_all_jobs(status)
|
|
67
|
+
redirect_to jobs_path(status: status), notice: "#{count} jobs discarded."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def set_job
|
|
73
|
+
@job = JobPresenter.find(params[:id])
|
|
74
|
+
rescue ActiveRecord::RecordNotFound
|
|
75
|
+
redirect_to jobs_path, alert: "Job not found."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def per_page_param
|
|
79
|
+
per_page = params[:per_page].to_i
|
|
80
|
+
valid_sizes = JobHarbor::PerPageSelectorComponent::PAGE_SIZES
|
|
81
|
+
valid_sizes.include?(per_page) ? per_page : sq_config.jobs_per_page
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def filter_data
|
|
85
|
+
{
|
|
86
|
+
class_names: SolidQueue::Job.distinct.pluck(:class_name).sort,
|
|
87
|
+
queue_names: SolidQueue::Job.distinct.pluck(:queue_name).sort
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def job_counts
|
|
92
|
+
{
|
|
93
|
+
all: SolidQueue::Job.count,
|
|
94
|
+
pending: SolidQueue::ReadyExecution.count,
|
|
95
|
+
scheduled: SolidQueue::ScheduledExecution.count,
|
|
96
|
+
in_progress: SolidQueue::ClaimedExecution.count,
|
|
97
|
+
failed: SolidQueue::FailedExecution.count,
|
|
98
|
+
blocked: SolidQueue::BlockedExecution.count,
|
|
99
|
+
finished: SolidQueue::Job.where.not(finished_at: nil).count
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def perform_retry(job)
|
|
104
|
+
failed_execution = SolidQueue::FailedExecution.find_by(job_id: job.id)
|
|
105
|
+
return unless failed_execution
|
|
106
|
+
|
|
107
|
+
failed_execution.retry
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def perform_discard(job)
|
|
111
|
+
# Remove from any execution tables
|
|
112
|
+
SolidQueue::FailedExecution.where(job_id: job.id).delete_all
|
|
113
|
+
SolidQueue::BlockedExecution.where(job_id: job.id).delete_all
|
|
114
|
+
SolidQueue::ScheduledExecution.where(job_id: job.id).delete_all
|
|
115
|
+
SolidQueue::ReadyExecution.where(job_id: job.id).delete_all
|
|
116
|
+
|
|
117
|
+
# Mark job as finished (discarded)
|
|
118
|
+
SolidQueue::Job.where(id: job.id).update_all(finished_at: Time.current)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def retry_all_jobs(status)
|
|
122
|
+
return 0 unless status == "failed"
|
|
123
|
+
|
|
124
|
+
count = 0
|
|
125
|
+
SolidQueue::FailedExecution.find_each do |fe|
|
|
126
|
+
fe.retry
|
|
127
|
+
count += 1
|
|
128
|
+
end
|
|
129
|
+
count
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def discard_all_jobs(status)
|
|
133
|
+
case status
|
|
134
|
+
when "failed"
|
|
135
|
+
count = SolidQueue::FailedExecution.count
|
|
136
|
+
job_ids = SolidQueue::FailedExecution.pluck(:job_id)
|
|
137
|
+
SolidQueue::FailedExecution.delete_all
|
|
138
|
+
SolidQueue::Job.where(id: job_ids).update_all(finished_at: Time.current)
|
|
139
|
+
count
|
|
140
|
+
when "blocked"
|
|
141
|
+
count = SolidQueue::BlockedExecution.count
|
|
142
|
+
job_ids = SolidQueue::BlockedExecution.pluck(:job_id)
|
|
143
|
+
SolidQueue::BlockedExecution.delete_all
|
|
144
|
+
SolidQueue::Job.where(id: job_ids).update_all(finished_at: Time.current)
|
|
145
|
+
count
|
|
146
|
+
else
|
|
147
|
+
0
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class QueuesController < ApplicationController
|
|
5
|
+
before_action :set_queue, only: [ :show, :pause, :resume ]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@queues = QueueStats.all
|
|
9
|
+
set_page_title "Queues"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@pagy, @jobs = JobPresenter.all_with_status(
|
|
14
|
+
nil,
|
|
15
|
+
page: params[:page],
|
|
16
|
+
per_page: sq_config.jobs_per_page
|
|
17
|
+
)
|
|
18
|
+
# Filter to only jobs in this queue
|
|
19
|
+
@jobs = @jobs.select { |j| j.queue_name == @queue.name }
|
|
20
|
+
set_page_title "Queue: #{@queue.name}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pause
|
|
24
|
+
@queue.pause!
|
|
25
|
+
redirect_to queues_path, notice: "Queue '#{@queue.name}' has been paused."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resume
|
|
29
|
+
@queue.resume!
|
|
30
|
+
redirect_to queues_path, notice: "Queue '#{@queue.name}' has been resumed."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def set_queue
|
|
36
|
+
@queue = QueueStats.find(params[:name])
|
|
37
|
+
redirect_to queues_path, alert: "Queue not found." unless @queue
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class RecurringTasksController < ApplicationController
|
|
5
|
+
before_action :set_task, only: [ :show, :enqueue_now ]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@tasks = SolidQueue::RecurringTask.order(:key)
|
|
9
|
+
set_page_title "Recurring Tasks"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@recent_executions = SolidQueue::RecurringExecution
|
|
14
|
+
.where(task_key: @task.key)
|
|
15
|
+
.order(created_at: :desc)
|
|
16
|
+
.limit(20)
|
|
17
|
+
set_page_title "Task: #{@task.key}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enqueue_now
|
|
21
|
+
@task.enqueue(at: Time.current)
|
|
22
|
+
redirect_to recurring_task_path(@task), notice: "Task '#{@task.key}' has been enqueued."
|
|
23
|
+
rescue => e
|
|
24
|
+
redirect_to recurring_task_path(@task), alert: "Failed to enqueue task: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def set_task
|
|
30
|
+
@task = SolidQueue::RecurringTask.find(params[:id])
|
|
31
|
+
rescue ActiveRecord::RecordNotFound
|
|
32
|
+
redirect_to recurring_tasks_path, alert: "Recurring task not found."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class WorkersController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@workers = SolidQueue::Process.order(last_heartbeat_at: :desc)
|
|
7
|
+
@active_count = @workers.where("last_heartbeat_at > ?", 5.minutes.ago).count
|
|
8
|
+
@stale_count = @workers.count - @active_count
|
|
9
|
+
set_page_title "Workers"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|