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
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,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1 @@
1
+ /* Solid Queue Dashboard styles are inlined in the layout for self-containment */
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobHarbor
4
+ class ApplicationComponent < ViewComponent::Base
5
+ include JobHarbor::Engine.routes.url_helpers
6
+
7
+ private
8
+
9
+ def sq_config
10
+ JobHarbor.configuration
11
+ end
12
+ end
13
+ end
@@ -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