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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d596fee450350a33db1b229b2dcbab4cacde1cabfbb5bc6232c74438749678cf
|
|
4
|
+
data.tar.gz: 1ab3b9efa3d6e642b9a5f25fc26a1dfe16b689365858482c3799064162521d4d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6aa86a5daea7137cadd0f68eb6c099beb4ba8aff739bf8a2cd88e839707a4c08d807a61cc386d809657f23574088213319d0f47a871beeef710d2dab5978ad1b
|
|
7
|
+
data.tar.gz: a4665fc176ee6afe8d794d195f9e3799cdecd56129383c6a616d4c572b5a27380542ca2186d89bf4f065ea7d62e9c573272a58a2a93cafbb0f5aafcd96ef8603
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SolidQueue Dashboard
|
|
2
|
+
|
|
3
|
+
A modern, self-contained dashboard for monitoring and managing [Solid Queue](https://github.com/rails/solid_queue) jobs. Built with ViewComponents and vanilla CSS for easy integration with any Rails application.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Dashboard Overview**: Real-time statistics for pending, scheduled, in-progress, failed, blocked, and finished jobs
|
|
8
|
+
- **Job Management**: View, search, retry, and discard jobs with full argument and error inspection
|
|
9
|
+
- **Queue Management**: Monitor queue health, pause/resume queues
|
|
10
|
+
- **Worker Monitoring**: Track active workers with heartbeat status detection
|
|
11
|
+
- **Recurring Tasks**: View and manually trigger recurring jobs
|
|
12
|
+
- **Real-time Updates**: Auto-refresh with configurable polling interval
|
|
13
|
+
- **Dark/Light Themes**: CSS variable-based theming with no external dependencies
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "solidqueue_dashboard"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
Create an initializer at `config/initializers/solidqueue_dashboard.rb`:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
SolidqueueDashboard.configure do |config|
|
|
35
|
+
# Authorization callback - must return true to allow access
|
|
36
|
+
config.authorize_with = -> { current_user&.admin? }
|
|
37
|
+
|
|
38
|
+
# Theme: :dark or :light
|
|
39
|
+
config.theme = :dark
|
|
40
|
+
|
|
41
|
+
# Primary accent color
|
|
42
|
+
config.primary_color = "amber"
|
|
43
|
+
|
|
44
|
+
# Jobs per page
|
|
45
|
+
config.jobs_per_page = 25
|
|
46
|
+
|
|
47
|
+
# Enable recurring tasks management
|
|
48
|
+
config.enable_recurring_tasks = true
|
|
49
|
+
|
|
50
|
+
# Enable auto-refresh
|
|
51
|
+
config.enable_real_time_updates = true
|
|
52
|
+
|
|
53
|
+
# Poll interval in seconds
|
|
54
|
+
config.poll_interval = 5
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Mounting
|
|
59
|
+
|
|
60
|
+
Mount the engine in your routes:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# config/routes.rb
|
|
64
|
+
Rails.application.routes.draw do
|
|
65
|
+
# Basic mount
|
|
66
|
+
mount SolidqueueDashboard::Engine, at: "/jobs"
|
|
67
|
+
|
|
68
|
+
# Or with authentication constraint
|
|
69
|
+
authenticated :user, ->(u) { u.admin? } do
|
|
70
|
+
mount SolidqueueDashboard::Engine, at: "/admin/jobs"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Authorization
|
|
76
|
+
|
|
77
|
+
The dashboard uses a configurable authorization callback. Return `true` to allow access:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Allow all authenticated users
|
|
81
|
+
config.authorize_with = -> { current_user.present? }
|
|
82
|
+
|
|
83
|
+
# Require admin role
|
|
84
|
+
config.authorize_with = -> { current_user&.admin? }
|
|
85
|
+
|
|
86
|
+
# Use Pundit or similar
|
|
87
|
+
config.authorize_with = -> { authorize(:solid_queue, :manage?) }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Dependencies
|
|
91
|
+
|
|
92
|
+
- Rails >= 7.1
|
|
93
|
+
- Solid Queue >= 0.3
|
|
94
|
+
- ViewComponent >= 3.0
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
data/Rakefile
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/* Solid Queue Dashboard styles are inlined in the layout for self-containment */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class BadgeComponent < ApplicationComponent
|
|
5
|
+
VALID_STATUSES = %w[pending scheduled in_progress failed finished blocked active paused].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(status:)
|
|
8
|
+
@status = status.to_s.downcase
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
content_tag(:span, display_text, class: css_classes)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def css_classes
|
|
18
|
+
status_class = VALID_STATUSES.include?(@status) ? @status : "pending"
|
|
19
|
+
"sqd-badge sqd-badge-#{status_class}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def display_text
|
|
23
|
+
@status.titleize.gsub("_", " ")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class ChartComponent < ApplicationComponent
|
|
5
|
+
def initialize(series:, current_range:)
|
|
6
|
+
@series = series
|
|
7
|
+
@current_range = current_range
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
content_tag(:div, class: "sqd-card sqd-chart-card") do
|
|
12
|
+
safe_join([
|
|
13
|
+
header,
|
|
14
|
+
body
|
|
15
|
+
])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def header
|
|
22
|
+
content_tag(:div, class: "sqd-card-header sqd-chart-header") do
|
|
23
|
+
safe_join([
|
|
24
|
+
content_tag(:h3, "Job Activity", class: "sqd-card-title"),
|
|
25
|
+
range_selector
|
|
26
|
+
])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def range_selector
|
|
31
|
+
content_tag(:div, class: "sqd-chart-ranges") do
|
|
32
|
+
safe_join(ChartData.available_ranges.map { |range| range_button(range) })
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def range_button(range)
|
|
37
|
+
active = range[:value] == @current_range
|
|
38
|
+
css_class = "sqd-chart-range-btn#{' active' if active}"
|
|
39
|
+
|
|
40
|
+
link_to range[:label],
|
|
41
|
+
"#{root_path}?chart_range=#{range[:value]}",
|
|
42
|
+
class: css_class
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def body
|
|
46
|
+
content_tag(:div, class: "sqd-card-body") do
|
|
47
|
+
safe_join([
|
|
48
|
+
legend,
|
|
49
|
+
chart_container
|
|
50
|
+
])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def legend
|
|
55
|
+
content_tag(:div, class: "sqd-chart-legend") do
|
|
56
|
+
safe_join([
|
|
57
|
+
legend_item("Completed", "sqd-chart-completed"),
|
|
58
|
+
legend_item("Failed", "sqd-chart-failed"),
|
|
59
|
+
legend_item("Enqueued", "sqd-chart-enqueued")
|
|
60
|
+
])
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def legend_item(label, css_class)
|
|
65
|
+
content_tag(:div, class: "sqd-legend-item") do
|
|
66
|
+
safe_join([
|
|
67
|
+
content_tag(:span, "", class: "sqd-legend-color #{css_class}"),
|
|
68
|
+
content_tag(:span, label, class: "sqd-legend-label")
|
|
69
|
+
])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def chart_container
|
|
74
|
+
content_tag(:div,
|
|
75
|
+
class: "sqd-chart",
|
|
76
|
+
data: { chart_data: @series.to_json }
|
|
77
|
+
) do
|
|
78
|
+
content_tag(:canvas, "", id: "sqd-job-chart", width: "100%", height: "200")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class EmptyStateComponent < ApplicationComponent
|
|
5
|
+
ICONS = {
|
|
6
|
+
jobs: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>',
|
|
7
|
+
queues: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>',
|
|
8
|
+
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"/>',
|
|
9
|
+
search: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>'
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(title:, description: nil, icon: :jobs)
|
|
13
|
+
@title = title
|
|
14
|
+
@description = description
|
|
15
|
+
@icon = icon.to_sym
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
content_tag(:div, class: "sqd-empty-state") do
|
|
20
|
+
safe_join([
|
|
21
|
+
icon_svg,
|
|
22
|
+
content_tag(:h3, @title, class: "sqd-empty-title"),
|
|
23
|
+
description_tag
|
|
24
|
+
].compact)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def icon_svg
|
|
31
|
+
icon_path = ICONS[@icon] || ICONS[:jobs]
|
|
32
|
+
content_tag(:svg, icon_path.html_safe, class: "sqd-empty-icon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def description_tag
|
|
36
|
+
return unless @description
|
|
37
|
+
|
|
38
|
+
content_tag(:p, @description, class: "sqd-empty-description")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class FailureRatesComponent < ApplicationComponent
|
|
5
|
+
def initialize(stats:)
|
|
6
|
+
@stats = stats
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
content_tag(:div, class: "sqd-card sqd-failure-rates") do
|
|
11
|
+
safe_join([
|
|
12
|
+
header,
|
|
13
|
+
body
|
|
14
|
+
])
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def header
|
|
21
|
+
content_tag(:div, class: "sqd-card-header") do
|
|
22
|
+
content_tag(:h3, "Failure Rates (24h)", class: "sqd-card-title")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def body
|
|
27
|
+
content_tag(:div, class: "sqd-card-body") do
|
|
28
|
+
if @stats.empty?
|
|
29
|
+
empty_state
|
|
30
|
+
else
|
|
31
|
+
stats_table
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def empty_state
|
|
37
|
+
content_tag(:p, "No jobs in the last 24 hours", class: "sqd-text-muted")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stats_table
|
|
41
|
+
content_tag(:table, class: "sqd-table sqd-failure-table") do
|
|
42
|
+
safe_join([
|
|
43
|
+
table_header,
|
|
44
|
+
table_body
|
|
45
|
+
])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def table_header
|
|
50
|
+
content_tag(:thead) do
|
|
51
|
+
content_tag(:tr) do
|
|
52
|
+
safe_join([
|
|
53
|
+
content_tag(:th, "Job Class"),
|
|
54
|
+
content_tag(:th, "Total"),
|
|
55
|
+
content_tag(:th, "Failed"),
|
|
56
|
+
content_tag(:th, "Rate")
|
|
57
|
+
])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def table_body
|
|
63
|
+
content_tag(:tbody) do
|
|
64
|
+
safe_join(@stats.first(10).map { |stat| stat_row(stat) })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def stat_row(stat)
|
|
69
|
+
content_tag(:tr) do
|
|
70
|
+
safe_join([
|
|
71
|
+
content_tag(:td, content_tag(:code, stat[:class_name], class: "sqd-code")),
|
|
72
|
+
content_tag(:td, stat[:total]),
|
|
73
|
+
content_tag(:td, stat[:failed]),
|
|
74
|
+
content_tag(:td, rate_badge(stat[:rate]))
|
|
75
|
+
])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rate_badge(rate)
|
|
80
|
+
css_class = FailureStats.rate_badge_class(rate)
|
|
81
|
+
content_tag(:span, "#{rate}%", class: "sqd-rate-badge #{css_class}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class JobFiltersComponent < ApplicationComponent
|
|
5
|
+
def initialize(class_names:, queue_names:, current_class:, current_queue:, current_path:, params: {})
|
|
6
|
+
@class_names = class_names
|
|
7
|
+
@queue_names = queue_names
|
|
8
|
+
@current_class = current_class
|
|
9
|
+
@current_queue = current_queue
|
|
10
|
+
@current_path = current_path
|
|
11
|
+
@params = params.to_h.symbolize_keys.except(:class_name, :queue_name, :page, :controller, :action)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
content_tag(:div, class: "sqd-filters") do
|
|
16
|
+
safe_join([
|
|
17
|
+
class_filter,
|
|
18
|
+
queue_filter
|
|
19
|
+
])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def class_filter
|
|
26
|
+
content_tag(:div, class: "sqd-filter-group") do
|
|
27
|
+
safe_join([
|
|
28
|
+
content_tag(:label, "Class", class: "sqd-filter-label", for: "class_filter"),
|
|
29
|
+
content_tag(:select,
|
|
30
|
+
class: "sqd-filter-select",
|
|
31
|
+
id: "class_filter",
|
|
32
|
+
data: { filter_type: "class_name" }
|
|
33
|
+
) do
|
|
34
|
+
safe_join([
|
|
35
|
+
class_option_tag("All Classes", "", @current_class.blank?),
|
|
36
|
+
*@class_names.map { |name| class_option_tag(name, name, name == @current_class) }
|
|
37
|
+
])
|
|
38
|
+
end
|
|
39
|
+
])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def queue_filter
|
|
44
|
+
content_tag(:div, class: "sqd-filter-group") do
|
|
45
|
+
safe_join([
|
|
46
|
+
content_tag(:label, "Queue", class: "sqd-filter-label", for: "queue_filter"),
|
|
47
|
+
content_tag(:select,
|
|
48
|
+
class: "sqd-filter-select",
|
|
49
|
+
id: "queue_filter",
|
|
50
|
+
data: { filter_type: "queue_name" }
|
|
51
|
+
) do
|
|
52
|
+
safe_join([
|
|
53
|
+
queue_option_tag("All Queues", "", @current_queue.blank?),
|
|
54
|
+
*@queue_names.map { |name| queue_option_tag(name, name, name == @current_queue) }
|
|
55
|
+
])
|
|
56
|
+
end
|
|
57
|
+
])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def class_option_tag(label, value, selected)
|
|
62
|
+
url = build_filter_url(:class_name, value)
|
|
63
|
+
content_tag(:option, label, value: url, selected: selected)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def queue_option_tag(label, value, selected)
|
|
67
|
+
url = build_filter_url(:queue_name, value)
|
|
68
|
+
content_tag(:option, label, value: url, selected: selected)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_filter_url(filter_key, value)
|
|
72
|
+
query_params = @params.dup
|
|
73
|
+
|
|
74
|
+
# Preserve the other filter if set
|
|
75
|
+
query_params[:class_name] = @current_class if @current_class.present? && filter_key != :class_name
|
|
76
|
+
query_params[:queue_name] = @current_queue if @current_queue.present? && filter_key != :queue_name
|
|
77
|
+
|
|
78
|
+
# Set the new filter value
|
|
79
|
+
if value.present?
|
|
80
|
+
query_params[filter_key] = value
|
|
81
|
+
else
|
|
82
|
+
query_params.delete(filter_key)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if query_params.empty?
|
|
86
|
+
@current_path
|
|
87
|
+
else
|
|
88
|
+
"#{@current_path}?#{query_params.to_query}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class JobRowComponent < ApplicationComponent
|
|
5
|
+
def initialize(job:)
|
|
6
|
+
@job = job
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
content_tag(:tr) do
|
|
11
|
+
safe_join([
|
|
12
|
+
id_cell,
|
|
13
|
+
class_cell,
|
|
14
|
+
queue_cell,
|
|
15
|
+
status_cell,
|
|
16
|
+
scheduled_cell,
|
|
17
|
+
actions_cell
|
|
18
|
+
])
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def id_cell
|
|
25
|
+
content_tag(:td) do
|
|
26
|
+
link_to @job.id, job_path(@job), class: "sqd-table-link"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def class_cell
|
|
31
|
+
content_tag(:td) do
|
|
32
|
+
safe_join([
|
|
33
|
+
content_tag(:code, @job.class_name, class: "sqd-code"),
|
|
34
|
+
retry_badge_tag
|
|
35
|
+
].compact)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def retry_badge_tag
|
|
40
|
+
return nil unless @job.respond_to?(:retry_badge) && @job.retry_badge.present?
|
|
41
|
+
|
|
42
|
+
content_tag(:span, @job.retry_badge, class: "sqd-retry-badge")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def queue_cell
|
|
46
|
+
content_tag(:td) do
|
|
47
|
+
link_to @job.queue_name, queue_path(@job.queue_name), class: "sqd-table-link"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def status_cell
|
|
52
|
+
content_tag(:td) do
|
|
53
|
+
safe_join([
|
|
54
|
+
render(BadgeComponent.new(status: @job.status)),
|
|
55
|
+
running_duration_tag
|
|
56
|
+
].compact)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def running_duration_tag
|
|
61
|
+
return nil unless @job.respond_to?(:running_duration) && @job.running_duration.present?
|
|
62
|
+
|
|
63
|
+
content_tag(:span, " (#{@job.running_duration})", class: "sqd-running-duration")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scheduled_cell
|
|
67
|
+
content_tag(:td) do
|
|
68
|
+
if @job.scheduled_at
|
|
69
|
+
time_tag(@job.scheduled_at, @job.scheduled_at.strftime("%b %d, %H:%M:%S"))
|
|
70
|
+
else
|
|
71
|
+
relative_time_tag
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def relative_time_tag
|
|
77
|
+
if @job.respond_to?(:relative_created_at)
|
|
78
|
+
content_tag(:span, @job.relative_created_at, class: "sqd-text-muted sqd-relative-time")
|
|
79
|
+
else
|
|
80
|
+
content_tag(:span, "—", class: "sqd-text-muted")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def actions_cell
|
|
85
|
+
content_tag(:td, class: "sqd-actions") do
|
|
86
|
+
safe_join([
|
|
87
|
+
retry_button,
|
|
88
|
+
discard_button
|
|
89
|
+
].compact)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def retry_button
|
|
94
|
+
return unless @job.can_retry?
|
|
95
|
+
|
|
96
|
+
button_to "Retry", retry_job_path(@job), method: :post, class: "sqd-btn sqd-btn-sm sqd-btn-secondary"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def discard_button
|
|
100
|
+
return unless @job.can_discard?
|
|
101
|
+
|
|
102
|
+
button_to "Discard", discard_job_path(@job), method: :delete, class: "sqd-btn sqd-btn-sm sqd-btn-danger",
|
|
103
|
+
data: { confirm: "Are you sure you want to discard this job?" }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class NavLinkComponent < ApplicationComponent
|
|
5
|
+
ICONS = {
|
|
6
|
+
dashboard: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>',
|
|
7
|
+
jobs: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>',
|
|
8
|
+
queues: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>',
|
|
9
|
+
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"/>',
|
|
10
|
+
recurring: '<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"/>'
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(path:, label:, icon:, active: false, badge: nil)
|
|
14
|
+
@path = path
|
|
15
|
+
@label = label
|
|
16
|
+
@icon = icon.to_sym
|
|
17
|
+
@active = active
|
|
18
|
+
@badge = badge
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
link_to @path, class: css_classes do
|
|
23
|
+
safe_join([
|
|
24
|
+
icon_svg,
|
|
25
|
+
content_tag(:span, @label, class: "sqd-nav-label"),
|
|
26
|
+
badge_tag
|
|
27
|
+
].compact)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def css_classes
|
|
34
|
+
classes = [ "sqd-nav-link" ]
|
|
35
|
+
classes << "active" if @active
|
|
36
|
+
classes.join(" ")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def icon_svg
|
|
40
|
+
icon_path = ICONS[@icon] || ICONS[:dashboard]
|
|
41
|
+
content_tag(:svg, icon_path.html_safe, class: "sqd-nav-icon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def badge_tag
|
|
45
|
+
return nil unless @badge.present? && @badge.to_i > 0
|
|
46
|
+
|
|
47
|
+
content_tag(:span, @badge, class: "sqd-nav-badge")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class PaginationComponent < ApplicationComponent
|
|
5
|
+
def initialize(pagy:)
|
|
6
|
+
@pagy = pagy
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def render?
|
|
10
|
+
@pagy.pages > 1
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
content_tag(:nav, class: "sqd-pagination") do
|
|
15
|
+
safe_join([
|
|
16
|
+
prev_link,
|
|
17
|
+
page_links,
|
|
18
|
+
next_link
|
|
19
|
+
])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def prev_link
|
|
26
|
+
if @pagy.prev
|
|
27
|
+
link_to "← Prev", url_for(page: @pagy.prev), class: "sqd-pagination-link"
|
|
28
|
+
else
|
|
29
|
+
content_tag(:span, "← Prev", class: "sqd-pagination-link sqd-pagination-disabled")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def next_link
|
|
34
|
+
if @pagy.next
|
|
35
|
+
link_to "Next →", url_for(page: @pagy.next), class: "sqd-pagination-link"
|
|
36
|
+
else
|
|
37
|
+
content_tag(:span, "Next →", class: "sqd-pagination-link sqd-pagination-disabled")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def page_links
|
|
42
|
+
series.map do |item|
|
|
43
|
+
case item
|
|
44
|
+
when Integer
|
|
45
|
+
if item == @pagy.page
|
|
46
|
+
content_tag(:span, item, class: "sqd-pagination-current")
|
|
47
|
+
else
|
|
48
|
+
link_to item, url_for(page: item), class: "sqd-pagination-link"
|
|
49
|
+
end
|
|
50
|
+
when :gap
|
|
51
|
+
content_tag(:span, "…", class: "sqd-pagination-link sqd-pagination-disabled")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def series
|
|
57
|
+
# Generate page series with ellipsis for large page counts
|
|
58
|
+
pages = @pagy.pages
|
|
59
|
+
current = @pagy.page
|
|
60
|
+
|
|
61
|
+
if pages <= 7
|
|
62
|
+
(1..pages).to_a
|
|
63
|
+
elsif current <= 4
|
|
64
|
+
[ 1, 2, 3, 4, 5, :gap, pages ]
|
|
65
|
+
elsif current >= pages - 3
|
|
66
|
+
[ 1, :gap, pages - 4, pages - 3, pages - 2, pages - 1, pages ]
|
|
67
|
+
else
|
|
68
|
+
[ 1, :gap, current - 1, current, current + 1, :gap, pages ]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|