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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +98 -0
  3. data/Rakefile +6 -0
  4. data/app/assets/stylesheets/solidqueue_dashboard/application.css +1 -0
  5. data/app/components/job_harbor/application_component.rb +13 -0
  6. data/app/components/job_harbor/badge_component.rb +26 -0
  7. data/app/components/job_harbor/chart_component.rb +82 -0
  8. data/app/components/job_harbor/empty_state_component.rb +41 -0
  9. data/app/components/job_harbor/failure_rates_component.rb +84 -0
  10. data/app/components/job_harbor/job_filters_component.rb +92 -0
  11. data/app/components/job_harbor/job_row_component.rb +106 -0
  12. data/app/components/job_harbor/nav_link_component.rb +50 -0
  13. data/app/components/job_harbor/pagination_component.rb +72 -0
  14. data/app/components/job_harbor/per_page_selector_component.rb +40 -0
  15. data/app/components/job_harbor/queue_card_component.rb +59 -0
  16. data/app/components/job_harbor/refresh_selector_component.rb +57 -0
  17. data/app/components/job_harbor/stat_card_component.rb +77 -0
  18. data/app/components/job_harbor/theme_toggle_component.rb +48 -0
  19. data/app/components/job_harbor/worker_card_component.rb +86 -0
  20. data/app/controllers/job_harbor/application_controller.rb +44 -0
  21. data/app/controllers/job_harbor/dashboard_controller.rb +17 -0
  22. data/app/controllers/job_harbor/jobs_controller.rb +151 -0
  23. data/app/controllers/job_harbor/queues_controller.rb +40 -0
  24. data/app/controllers/job_harbor/recurring_tasks_controller.rb +35 -0
  25. data/app/controllers/job_harbor/workers_controller.rb +12 -0
  26. data/app/helpers/job_harbor/application_helper.rb +4 -0
  27. data/app/models/job_harbor/chart_data.rb +104 -0
  28. data/app/models/job_harbor/dashboard_stats.rb +90 -0
  29. data/app/models/job_harbor/failure_stats.rb +63 -0
  30. data/app/models/job_harbor/job_presenter.rb +246 -0
  31. data/app/models/job_harbor/queue_stats.rb +77 -0
  32. data/app/views/job_harbor/dashboard/index.html.erb +112 -0
  33. data/app/views/job_harbor/jobs/index.html.erb +100 -0
  34. data/app/views/job_harbor/jobs/search.html.erb +43 -0
  35. data/app/views/job_harbor/jobs/show.html.erb +133 -0
  36. data/app/views/job_harbor/queues/index.html.erb +13 -0
  37. data/app/views/job_harbor/queues/show.html.erb +88 -0
  38. data/app/views/job_harbor/recurring_tasks/index.html.erb +36 -0
  39. data/app/views/job_harbor/recurring_tasks/show.html.erb +97 -0
  40. data/app/views/job_harbor/workers/index.html.erb +33 -0
  41. data/app/views/layouts/job_harbor/application.html.erb +1434 -0
  42. data/config/routes.rb +39 -0
  43. data/lib/job_harbor/configuration.rb +31 -0
  44. data/lib/job_harbor/engine.rb +28 -0
  45. data/lib/job_harbor/version.rb +3 -0
  46. data/lib/job_harbor.rb +19 -0
  47. data/lib/tasks/solidqueue_dashboard_tasks.rake +4 -0
  48. 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
@@ -0,0 +1,4 @@
1
+ module JobHarbor
2
+ module ApplicationHelper
3
+ end
4
+ end