pgbus 0.1.4 → 0.1.6

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +326 -11
  3. data/app/controllers/pgbus/api/insights_controller.rb +16 -0
  4. data/app/controllers/pgbus/insights_controller.rb +10 -0
  5. data/app/controllers/pgbus/locks_controller.rb +9 -0
  6. data/app/controllers/pgbus/outbox_controller.rb +10 -0
  7. data/app/controllers/pgbus/queues_controller.rb +10 -0
  8. data/app/helpers/pgbus/application_helper.rb +34 -0
  9. data/app/models/pgbus/job_lock.rb +82 -0
  10. data/app/models/pgbus/job_stat.rb +94 -0
  11. data/app/models/pgbus/outbox_entry.rb +10 -0
  12. data/app/models/pgbus/queue_state.rb +33 -0
  13. data/app/views/layouts/pgbus/application.html.erb +33 -8
  14. data/app/views/pgbus/dashboard/_stats_cards.html.erb +24 -18
  15. data/app/views/pgbus/insights/show.html.erb +161 -0
  16. data/app/views/pgbus/locks/index.html.erb +53 -0
  17. data/app/views/pgbus/outbox/index.html.erb +55 -0
  18. data/app/views/pgbus/queues/_queues_list.html.erb +15 -1
  19. data/config/routes.rb +7 -0
  20. data/lib/generators/pgbus/add_job_locks_generator.rb +52 -0
  21. data/lib/generators/pgbus/add_job_stats_generator.rb +52 -0
  22. data/lib/generators/pgbus/add_outbox_generator.rb +52 -0
  23. data/lib/generators/pgbus/add_queue_states_generator.rb +51 -0
  24. data/lib/generators/pgbus/add_recurring_generator.rb +1 -1
  25. data/lib/generators/pgbus/install_generator.rb +1 -1
  26. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +21 -0
  27. data/lib/generators/pgbus/templates/add_job_stats.rb.erb +18 -0
  28. data/lib/generators/pgbus/templates/add_outbox.rb.erb +25 -0
  29. data/lib/generators/pgbus/templates/add_queue_states.rb.erb +16 -0
  30. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +1 -1
  31. data/lib/pgbus/active_job/adapter.rb +64 -9
  32. data/lib/pgbus/active_job/executor.rb +67 -5
  33. data/lib/pgbus/circuit_breaker.rb +112 -0
  34. data/lib/pgbus/client.rb +127 -50
  35. data/lib/pgbus/configuration.rb +55 -1
  36. data/lib/pgbus/dedup_cache.rb +76 -0
  37. data/lib/pgbus/engine.rb +1 -0
  38. data/lib/pgbus/event_bus/handler.rb +13 -2
  39. data/lib/pgbus/outbox/poller.rb +117 -0
  40. data/lib/pgbus/outbox.rb +30 -0
  41. data/lib/pgbus/process/consumer_priority.rb +64 -0
  42. data/lib/pgbus/process/dispatcher.rb +75 -0
  43. data/lib/pgbus/process/heartbeat.rb +3 -1
  44. data/lib/pgbus/process/lifecycle.rb +111 -0
  45. data/lib/pgbus/process/queue_lock.rb +87 -0
  46. data/lib/pgbus/process/supervisor.rb +46 -6
  47. data/lib/pgbus/process/wake_signal.rb +53 -0
  48. data/lib/pgbus/process/worker.rb +117 -21
  49. data/lib/pgbus/queue_factory.rb +62 -0
  50. data/lib/pgbus/rate_counter.rb +81 -0
  51. data/lib/pgbus/recurring/schedule.rb +1 -1
  52. data/lib/pgbus/uniqueness.rb +169 -0
  53. data/lib/pgbus/version.rb +1 -1
  54. data/lib/pgbus/web/data_source.rb +136 -2
  55. data/lib/pgbus.rb +9 -0
  56. metadata +31 -1
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class JobStat < Pgbus::ApplicationRecord
5
+ self.table_name = "pgbus_job_stats"
6
+
7
+ scope :since, ->(time) { where("created_at >= ?", time) }
8
+ scope :successful, -> { where(status: "success") }
9
+ scope :failed, -> { where(status: "failed") }
10
+ scope :dead_lettered, -> { where(status: "dead_lettered") }
11
+
12
+ # Record a job execution stat. Called by the executor after each job.
13
+ def self.record!(job_class:, queue_name:, status:, duration_ms:)
14
+ return unless table_exists?
15
+
16
+ create!(
17
+ job_class: job_class,
18
+ queue_name: queue_name,
19
+ status: status,
20
+ duration_ms: duration_ms
21
+ )
22
+ rescue StandardError => e
23
+ Pgbus.logger.debug { "[Pgbus] Failed to record job stat: #{e.message}" }
24
+ end
25
+
26
+ # Memoized — intentionally never invalidated at runtime. If the
27
+ # pgbus_job_stats migration runs while the app is already running,
28
+ # a restart is required for stat recording to begin.
29
+ def self.table_exists?
30
+ return @table_exists if defined?(@table_exists)
31
+
32
+ @table_exists = connection.table_exists?(table_name)
33
+ rescue StandardError
34
+ @table_exists = false
35
+ end
36
+
37
+ # Throughput: jobs per minute bucketed by minute for the last N minutes
38
+ def self.throughput(minutes: 60)
39
+ since(minutes.minutes.ago)
40
+ .group("date_trunc('minute', created_at)")
41
+ .order(Arel.sql("date_trunc('minute', created_at)"))
42
+ .count
43
+ end
44
+
45
+ # Average duration by job class
46
+ def self.avg_duration_by_class(minutes: 60)
47
+ since(minutes.minutes.ago)
48
+ .group(:job_class)
49
+ .order(Arel.sql("AVG(duration_ms) DESC"))
50
+ .average(:duration_ms)
51
+ end
52
+
53
+ # Success/fail/DLQ counts
54
+ def self.status_counts(minutes: 60)
55
+ since(minutes.minutes.ago).group(:status).count
56
+ end
57
+
58
+ # Top N slowest job classes by average duration
59
+ def self.slowest_classes(limit: 10, minutes: 60)
60
+ since(minutes.minutes.ago)
61
+ .group(:job_class)
62
+ .order(Arel.sql("AVG(duration_ms) DESC"))
63
+ .limit(limit)
64
+ .pluck(:job_class, Arel.sql("COUNT(*)"), Arel.sql("ROUND(AVG(duration_ms))"), Arel.sql("MAX(duration_ms)"))
65
+ .map { |cls, count, avg, max| { job_class: cls, count: count.to_i, avg_ms: avg.to_i, max_ms: max.to_i } }
66
+ end
67
+
68
+ # Single-query aggregate summary using conditional counts.
69
+ def self.summary(minutes: 60)
70
+ row = since(minutes.minutes.ago).pick(
71
+ Arel.sql("COUNT(*)"),
72
+ Arel.sql("COUNT(*) FILTER (WHERE status = 'success')"),
73
+ Arel.sql("COUNT(*) FILTER (WHERE status = 'failed')"),
74
+ Arel.sql("COUNT(*) FILTER (WHERE status = 'dead_lettered')"),
75
+ Arel.sql("ROUND(AVG(duration_ms)::numeric, 1)"),
76
+ Arel.sql("MAX(duration_ms)")
77
+ )
78
+
79
+ {
80
+ total: row[0].to_i,
81
+ success: row[1].to_i,
82
+ failed: row[2].to_i,
83
+ dead_lettered: row[3].to_i,
84
+ avg_duration_ms: row[4]&.to_f || 0,
85
+ max_duration_ms: row[5].to_i
86
+ }
87
+ end
88
+
89
+ # Cleanup old stats
90
+ def self.cleanup!(older_than:)
91
+ where("created_at < ?", older_than).delete_all
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class OutboxEntry < Pgbus::ApplicationRecord
5
+ self.table_name = "pgbus_outbox_entries"
6
+
7
+ scope :unpublished, -> { where(published_at: nil) }
8
+ scope :published_before, ->(time) { where(published_at: ...time) }
9
+ end
10
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class QueueState < Pgbus::ApplicationRecord
5
+ self.table_name = "pgbus_queue_states"
6
+
7
+ scope :paused, -> { where(paused: true) }
8
+
9
+ def self.paused?(queue_name)
10
+ where(queue_name: queue_name, paused: true).exists?
11
+ end
12
+
13
+ def self.pause!(queue_name, reason: nil)
14
+ record = find_or_initialize_by(queue_name: queue_name)
15
+ record.update!(paused: true, paused_reason: reason, paused_at: Time.current, circuit_breaker_resume_at: nil)
16
+ record
17
+ end
18
+
19
+ def self.resume!(queue_name)
20
+ record = find_by(queue_name: queue_name)
21
+ return unless record
22
+
23
+ record.update!(
24
+ paused: false,
25
+ paused_reason: nil,
26
+ paused_at: nil,
27
+ circuit_breaker_trip_count: 0,
28
+ circuit_breaker_resume_at: nil
29
+ )
30
+ record
31
+ end
32
+ end
33
+ end
@@ -1,15 +1,22 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en" class="h-full bg-gray-50">
2
+ <html lang="en" class="h-full">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Pgbus Dashboard</title>
7
7
  <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = { darkMode: 'class' };
10
+ // Restore dark mode preference
11
+ if (localStorage.getItem('pgbus-dark') === 'true' ||
12
+ (!localStorage.getItem('pgbus-dark') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
13
+ document.documentElement.classList.add('dark');
14
+ }
15
+ </script>
8
16
  <script type="module">
9
17
  import * as Turbo from "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8/dist/turbo.es2017.esm.js";
10
18
 
11
19
  <% if Pgbus.configuration.web_live_updates %>
12
- // Auto-refresh turbo frames, pausing when tab is hidden
13
20
  const interval = <%= Pgbus.configuration.web_refresh_interval %>;
14
21
  if (interval > 0) {
15
22
  let timer;
@@ -24,12 +31,18 @@
24
31
  start();
25
32
  }
26
33
  <% end %>
34
+
35
+ // Dark mode toggle
36
+ window.toggleDarkMode = function() {
37
+ const isDark = document.documentElement.classList.toggle('dark');
38
+ localStorage.setItem('pgbus-dark', isDark);
39
+ };
27
40
  </script>
28
41
  </head>
29
- <body class="h-full">
42
+ <body class="h-full bg-gray-50 dark:bg-gray-950 transition-colors">
30
43
  <div class="min-h-full">
31
44
  <!-- Top nav -->
32
- <nav class="bg-gray-900">
45
+ <nav class="bg-gray-900 dark:bg-gray-950 border-b border-gray-800">
33
46
  <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
34
47
  <div class="flex h-14 items-center justify-between">
35
48
  <div class="flex items-center space-x-8">
@@ -46,8 +59,20 @@
46
59
  <%= pgbus_nav_link "Processes", pgbus.processes_path %>
47
60
  <%= pgbus_nav_link "Events", pgbus.events_path %>
48
61
  <%= pgbus_nav_link "DLQ", pgbus.dead_letter_index_path %>
62
+ <%= pgbus_nav_link "Outbox", pgbus.outbox_index_path %>
63
+ <%= pgbus_nav_link "Locks", pgbus.locks_path %>
64
+ <%= pgbus_nav_link "Insights", pgbus.insights_path %>
49
65
  </div>
50
66
  </div>
67
+
68
+ <button onclick="toggleDarkMode()" class="rounded-md p-2 text-gray-400 hover:text-white focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-900" aria-label="Toggle dark mode">
69
+ <svg class="h-5 w-5 hidden dark:block" fill="currentColor" viewBox="0 0 20 20">
70
+ <path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/>
71
+ </svg>
72
+ <svg class="h-5 w-5 block dark:hidden" fill="currentColor" viewBox="0 0 20 20">
73
+ <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
74
+ </svg>
75
+ </button>
51
76
  </div>
52
77
  </div>
53
78
  </nav>
@@ -55,15 +80,15 @@
55
80
  <!-- Flash messages -->
56
81
  <% if notice %>
57
82
  <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 mt-4">
58
- <div class="rounded-md bg-green-50 p-3">
59
- <p class="text-sm text-green-800"><%= notice %></p>
83
+ <div class="rounded-md bg-green-50 dark:bg-green-900/30 p-3">
84
+ <p class="text-sm text-green-800 dark:text-green-300"><%= notice %></p>
60
85
  </div>
61
86
  </div>
62
87
  <% end %>
63
88
  <% if alert %>
64
89
  <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 mt-4">
65
- <div class="rounded-md bg-red-50 p-3">
66
- <p class="text-sm text-red-800"><%= alert %></p>
90
+ <div class="rounded-md bg-red-50 dark:bg-red-900/30 p-3">
91
+ <p class="text-sm text-red-800 dark:text-red-300"><%= alert %></p>
67
92
  </div>
68
93
  </div>
69
94
  <% end %>
@@ -1,36 +1,42 @@
1
1
  <turbo-frame id="dashboard-stats" data-auto-refresh src="<%= pgbus.root_path(frame: 'stats') %>">
2
- <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5 mb-8">
3
- <div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
4
- <p class="text-sm font-medium text-gray-500">Queues</p>
5
- <p class="mt-1 text-3xl font-semibold text-gray-900"><%= @stats[:total_queues] %></p>
2
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6 mb-8">
3
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
4
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400">Queues</p>
5
+ <p class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white"><%= @stats[:total_queues] %></p>
6
6
  </div>
7
7
 
8
- <div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
9
- <p class="text-sm font-medium text-gray-500">Enqueued</p>
10
- <p class="mt-1 text-3xl font-semibold text-gray-900"><%= pgbus_number(@stats[:total_depth]) %></p>
11
- <p class="text-xs text-gray-400"><%= pgbus_number(@stats[:total_visible]) %> visible</p>
8
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
9
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400">Enqueued</p>
10
+ <p class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white"><%= pgbus_number(@stats[:total_depth]) %></p>
11
+ <p class="text-xs text-gray-400 dark:text-gray-500"><%= pgbus_number(@stats[:total_visible]) %> visible</p>
12
12
  </div>
13
13
 
14
- <div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
15
- <p class="text-sm font-medium text-gray-500">Processes</p>
16
- <p class="mt-1 text-3xl font-semibold <%= @stats[:active_processes] > 0 ? 'text-green-600' : 'text-gray-400' %>">
14
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
15
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400">Processes</p>
16
+ <p class="mt-1 text-3xl font-semibold <%= @stats[:active_processes] > 0 ? 'text-green-600 dark:text-green-400' : 'text-gray-400' %>">
17
17
  <%= @stats[:active_processes] %>
18
18
  </p>
19
19
  </div>
20
20
 
21
- <div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
22
- <p class="text-sm font-medium text-gray-500">Recurring</p>
23
- <p class="mt-1 text-3xl font-semibold text-gray-900"><%= @stats[:recurring_count] %></p>
21
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
22
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400">Recurring</p>
23
+ <p class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white"><%= @stats[:recurring_count] %></p>
24
24
  <p class="text-xs text-gray-400">
25
- <%= link_to "View tasks", pgbus.recurring_tasks_path, class: "text-blue-500 hover:text-blue-700" %>
25
+ <%= link_to "View tasks", pgbus.recurring_tasks_path, class: "text-blue-500 hover:text-blue-700 dark:text-blue-400" %>
26
26
  </p>
27
27
  </div>
28
28
 
29
- <div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
30
- <p class="text-sm font-medium text-gray-500">Failed / DLQ</p>
31
- <p class="mt-1 text-3xl font-semibold <%= (@stats[:failed_count] + @stats[:dlq_depth]) > 0 ? 'text-red-600' : 'text-gray-900' %>">
29
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
30
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400">Failed / DLQ</p>
31
+ <p class="mt-1 text-3xl font-semibold <%= (@stats[:failed_count] + @stats[:dlq_depth]) > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white' %>">
32
32
  <%= @stats[:failed_count] %> / <%= @stats[:dlq_depth] %>
33
33
  </p>
34
34
  </div>
35
+
36
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
37
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400">Throughput</p>
38
+ <p class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white"><%= @stats[:throughput_rate] %></p>
39
+ <p class="text-xs text-gray-400">msgs/s</p>
40
+ </div>
35
41
  </div>
36
42
  </turbo-frame>
@@ -0,0 +1,161 @@
1
+ <div class="mb-6">
2
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Insights</h1>
3
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Job performance metrics for the last hour</p>
4
+ </div>
5
+
6
+ <!-- Summary cards -->
7
+ <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6 mb-8">
8
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
9
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Total Jobs</dt>
10
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_number(@summary[:total]) %></dd>
11
+ </div>
12
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
13
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Succeeded</dt>
14
+ <dd class="mt-1 text-2xl font-semibold text-green-600 dark:text-green-400"><%= pgbus_number(@summary[:success]) %></dd>
15
+ </div>
16
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
17
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Failed</dt>
18
+ <dd class="mt-1 text-2xl font-semibold text-red-600 dark:text-red-400"><%= pgbus_number(@summary[:failed]) %></dd>
19
+ </div>
20
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
21
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Dead Lettered</dt>
22
+ <dd class="mt-1 text-2xl font-semibold text-orange-600 dark:text-orange-400"><%= pgbus_number(@summary[:dead_lettered]) %></dd>
23
+ </div>
24
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
25
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Avg Duration</dt>
26
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_ms_duration(@summary[:avg_duration_ms]) %></dd>
27
+ </div>
28
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
29
+ <dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Max Duration</dt>
30
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_ms_duration(@summary[:max_duration_ms]) %></dd>
31
+ </div>
32
+ </div>
33
+
34
+ <!-- Charts -->
35
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
36
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
37
+ <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Throughput (jobs/min)</h3>
38
+ <div id="throughput-chart" style="height: 280px;"></div>
39
+ </div>
40
+ <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
41
+ <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Status Distribution</h3>
42
+ <div id="status-chart" style="height: 280px;"></div>
43
+ </div>
44
+ </div>
45
+
46
+ <!-- Slowest job classes -->
47
+ <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
48
+ <div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
49
+ <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Slowest Job Classes (avg duration)</h3>
50
+ </div>
51
+ <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
52
+ <thead class="bg-gray-50 dark:bg-gray-900">
53
+ <tr>
54
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Job Class</th>
55
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Count</th>
56
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Avg</th>
57
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Max</th>
58
+ </tr>
59
+ </thead>
60
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
61
+ <% @slowest.each do |row| %>
62
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
63
+ <td class="px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300"><%= row[:job_class] %></td>
64
+ <td class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_number(row[:count]) %></td>
65
+ <td class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_ms_duration(row[:avg_ms]) %></td>
66
+ <td class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_ms_duration(row[:max_ms]) %></td>
67
+ </tr>
68
+ <% end %>
69
+ <% if @slowest.empty? %>
70
+ <tr><td colspan="4" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">No job stats yet</td></tr>
71
+ <% end %>
72
+ </tbody>
73
+ </table>
74
+ </div>
75
+
76
+ <script src="https://cdn.jsdelivr.net/npm/apexcharts@4"></script>
77
+ <script>
78
+ let throughputChart, statusChart;
79
+
80
+ function getThemeColors() {
81
+ const isDark = document.documentElement.classList.contains('dark');
82
+ return {
83
+ isDark,
84
+ text: isDark ? '#9ca3af' : '#6b7280',
85
+ grid: isDark ? '#374151' : '#e5e7eb',
86
+ tooltip: isDark ? 'dark' : 'light',
87
+ dataLabel: isDark ? '#fff' : '#000'
88
+ };
89
+ }
90
+
91
+ function renderCharts(data) {
92
+ const t = getThemeColors();
93
+
94
+ // Destroy existing charts before re-rendering
95
+ if (throughputChart) throughputChart.destroy();
96
+ if (statusChart) statusChart.destroy();
97
+
98
+ // Throughput chart
99
+ const throughputData = data.throughput.map(p => ({
100
+ x: new Date(p.time).getTime(), y: p.count
101
+ }));
102
+
103
+ throughputChart = new ApexCharts(document.querySelector('#throughput-chart'), {
104
+ series: [{ name: 'Jobs/min', data: throughputData }],
105
+ chart: { type: 'area', height: 280, toolbar: { show: false }, background: 'transparent', foreColor: t.text },
106
+ stroke: { curve: 'smooth', width: 2 },
107
+ fill: { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.05, stops: [0, 100] } },
108
+ colors: ['#6366f1'],
109
+ xaxis: { type: 'datetime', labels: { style: { colors: t.text } } },
110
+ yaxis: { labels: { style: { colors: t.text } } },
111
+ grid: { borderColor: t.grid },
112
+ tooltip: { theme: t.tooltip },
113
+ dataLabels: { enabled: false }
114
+ });
115
+ throughputChart.render();
116
+
117
+ // Status distribution chart
118
+ const statusLabels = Object.keys(data.status_counts);
119
+ const statusValues = Object.values(data.status_counts);
120
+ const statusColors = statusLabels.map(s => {
121
+ if (s === 'success') return '#10b981';
122
+ if (s === 'failed') return '#ef4444';
123
+ if (s === 'dead_lettered') return '#f97316';
124
+ return '#6b7280';
125
+ });
126
+
127
+ if (statusLabels.length > 0) {
128
+ statusChart = new ApexCharts(document.querySelector('#status-chart'), {
129
+ series: statusValues, labels: statusLabels,
130
+ chart: { type: 'donut', height: 280, background: 'transparent', foreColor: t.text },
131
+ colors: statusColors,
132
+ legend: { position: 'bottom', labels: { colors: t.text } },
133
+ plotOptions: { pie: { donut: { size: '60%' } } },
134
+ dataLabels: { style: { colors: [t.dataLabel] } },
135
+ tooltip: { theme: t.tooltip }
136
+ });
137
+ statusChart.render();
138
+ } else {
139
+ document.querySelector('#status-chart').innerHTML =
140
+ '<p class="text-center text-sm text-gray-400 dark:text-gray-500 pt-24">No data yet</p>';
141
+ }
142
+ }
143
+
144
+ // Fetch data and render
145
+ let chartData = null;
146
+ fetch('<%= pgbus.api_insights_path %>')
147
+ .then(r => r.json())
148
+ .then(data => { chartData = data; renderCharts(data); })
149
+ .catch(() => {
150
+ const msg = '<p class="text-center text-sm text-gray-400 dark:text-gray-500 pt-24">Failed to load chart data</p>';
151
+ document.querySelector('#throughput-chart').innerHTML = msg;
152
+ document.querySelector('#status-chart').innerHTML = msg;
153
+ });
154
+
155
+ // Re-render charts when dark mode toggles
156
+ const origToggle = window.toggleDarkMode;
157
+ window.toggleDarkMode = function() {
158
+ origToggle();
159
+ if (chartData) renderCharts(chartData);
160
+ };
161
+ </script>
@@ -0,0 +1,53 @@
1
+ <div class="mb-6">
2
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Job Locks</h1>
3
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Active uniqueness locks preventing duplicate job execution</p>
4
+ </div>
5
+
6
+ <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
7
+ <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
8
+ <thead class="bg-gray-50 dark:bg-gray-900">
9
+ <tr>
10
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Lock Key</th>
11
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Job Class</th>
12
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">State</th>
13
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Owner</th>
14
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Age</th>
15
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Expires</th>
16
+ </tr>
17
+ </thead>
18
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
19
+ <% @locks.each do |lock| %>
20
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
21
+ <td class="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300 max-w-xs truncate"><%= lock[:lock_key] %></td>
22
+ <td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"><%= lock[:job_class] %></td>
23
+ <td class="px-4 py-3 text-sm">
24
+ <% if lock[:state] == "executing" %>
25
+ <span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:text-blue-300">Executing</span>
26
+ <% else %>
27
+ <span class="inline-flex items-center rounded-full bg-yellow-100 dark:bg-yellow-900/50 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:text-yellow-300">Queued</span>
28
+ <% end %>
29
+ </td>
30
+ <td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
31
+ <% if lock[:owner_pid] %>
32
+ <span class="font-mono"><%= lock[:owner_pid] %></span>
33
+ <% if lock[:owner_hostname] %>
34
+ <span class="text-xs text-gray-400 dark:text-gray-500">@<%= lock[:owner_hostname] %></span>
35
+ <% end %>
36
+ <% else %>
37
+ <span class="text-gray-400 dark:text-gray-500">—</span>
38
+ <% end %>
39
+ </td>
40
+ <td class="px-4 py-3 text-sm text-right text-gray-500 dark:text-gray-400">
41
+ <% if lock[:age_seconds] %>
42
+ <%= pgbus_duration(lock[:age_seconds]) %>
43
+ <% end %>
44
+ </td>
45
+ <td class="px-4 py-3 text-sm text-right text-gray-500 dark:text-gray-400"><%= pgbus_time_ago_future(lock[:expires_at]) %></td>
46
+ </tr>
47
+ <% end %>
48
+ <% if @locks.empty? %>
49
+ <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">No active locks</td></tr>
50
+ <% end %>
51
+ </tbody>
52
+ </table>
53
+ </div>
@@ -0,0 +1,55 @@
1
+ <div class="mb-6">
2
+ <h1 class="text-2xl font-bold text-gray-900">Outbox</h1>
3
+ <p class="mt-1 text-sm text-gray-500">Transactional outbox entries pending publication to PGMQ</p>
4
+ </div>
5
+
6
+ <div class="mb-6 grid grid-cols-3 gap-4">
7
+ <div class="rounded-lg bg-white p-4 shadow ring-1 ring-gray-200">
8
+ <dt class="text-xs font-medium uppercase text-gray-500">Unpublished</dt>
9
+ <dd class="mt-1 text-2xl font-semibold text-gray-900"><%= pgbus_number(@stats[:unpublished]) %></dd>
10
+ </div>
11
+ <div class="rounded-lg bg-white p-4 shadow ring-1 ring-gray-200">
12
+ <dt class="text-xs font-medium uppercase text-gray-500">Total</dt>
13
+ <dd class="mt-1 text-2xl font-semibold text-gray-900"><%= pgbus_number(@stats[:total]) %></dd>
14
+ </div>
15
+ <div class="rounded-lg bg-white p-4 shadow ring-1 ring-gray-200">
16
+ <dt class="text-xs font-medium uppercase text-gray-500">Oldest Unpublished</dt>
17
+ <dd class="mt-1 text-2xl font-semibold text-gray-900"><%= @stats[:oldest_unpublished_age] ? "#{@stats[:oldest_unpublished_age]}s" : "—" %></dd>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="overflow-hidden rounded-lg bg-white shadow ring-1 ring-gray-200">
22
+ <table class="min-w-full divide-y divide-gray-200">
23
+ <thead class="bg-gray-50">
24
+ <tr>
25
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">ID</th>
26
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Queue / Topic</th>
27
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Payload</th>
28
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Priority</th>
29
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Status</th>
30
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Created</th>
31
+ </tr>
32
+ </thead>
33
+ <tbody class="divide-y divide-gray-100">
34
+ <% @entries.each do |entry| %>
35
+ <tr class="hover:bg-gray-50">
36
+ <td class="px-4 py-3 text-sm font-mono text-gray-700"><%= entry.id %></td>
37
+ <td class="px-4 py-3 text-sm text-gray-700"><%= entry.routing_key || entry.queue_name %></td>
38
+ <td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate"><%= pgbus_json_preview(entry.payload) %></td>
39
+ <td class="px-4 py-3 text-sm text-right text-gray-500"><%= entry.priority %></td>
40
+ <td class="px-4 py-3 text-sm text-right">
41
+ <% if entry.published_at %>
42
+ <span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Published</span>
43
+ <% else %>
44
+ <span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">Pending</span>
45
+ <% end %>
46
+ </td>
47
+ <td class="px-4 py-3 text-sm text-right text-gray-500"><%= pgbus_time_ago(entry.created_at) %></td>
48
+ </tr>
49
+ <% end %>
50
+ <% if @entries.empty? %>
51
+ <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400">No outbox entries</td></tr>
52
+ <% end %>
53
+ </tbody>
54
+ </table>
55
+ </div>
@@ -18,13 +18,27 @@
18
18
  <td class="px-4 py-3 text-sm">
19
19
  <%= link_to q[:name], pgbus.queue_path(name: q[:name]), class: "font-medium text-indigo-600 hover:text-indigo-500", data: { turbo_frame: "_top" } %>
20
20
  <%= pgbus_queue_badge(q[:name]) %>
21
+ <% if q[:paused] %>
22
+ <span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">Paused</span>
23
+ <% end %>
21
24
  </td>
22
25
  <td class="px-4 py-3 text-sm text-right font-mono text-gray-700"><%= pgbus_number(q[:queue_length]) %></td>
23
26
  <td class="px-4 py-3 text-sm text-right font-mono text-gray-700"><%= pgbus_number(q[:queue_visible_length]) %></td>
24
27
  <td class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:oldest_msg_age_sec] || "—" %></td>
25
28
  <td class="px-4 py-3 text-sm text-right text-gray-500"><%= q[:newest_msg_age_sec] || "—" %></td>
26
29
  <td class="px-4 py-3 text-sm text-right text-gray-500"><%= pgbus_number(q[:total_messages]) %></td>
27
- <td class="px-4 py-3 text-sm text-right">
30
+ <td class="px-4 py-3 text-sm text-right space-x-2">
31
+ <% if q[:paused] %>
32
+ <%= button_to "Resume", pgbus.resume_queue_path(name: q[:name]),
33
+ method: :post,
34
+ class: "text-xs text-green-600 hover:text-green-800 font-medium",
35
+ data: { turbo_frame: "_top" } %>
36
+ <% else %>
37
+ <%= button_to "Pause", pgbus.pause_queue_path(name: q[:name]),
38
+ method: :post,
39
+ class: "text-xs text-yellow-600 hover:text-yellow-800 font-medium",
40
+ data: { turbo_confirm: "Pause processing for #{q[:name]}?", turbo_frame: "_top" } %>
41
+ <% end %>
28
42
  <%= button_to "Purge", pgbus.purge_queue_path(name: q[:name]),
29
43
  method: :post,
30
44
  class: "text-xs text-red-600 hover:text-red-800 font-medium",
data/config/routes.rb CHANGED
@@ -6,6 +6,8 @@ Pgbus::Engine.routes.draw do
6
6
  resources :queues, only: %i[index show], param: :name do
7
7
  member do
8
8
  post :purge
9
+ post :pause
10
+ post :resume
9
11
  end
10
12
  end
11
13
 
@@ -46,7 +48,12 @@ Pgbus::Engine.routes.draw do
46
48
  end
47
49
  end
48
50
 
51
+ resources :outbox, only: [:index], controller: "outbox"
52
+ resources :locks, only: [:index]
53
+ resource :insights, only: [:show], controller: "insights"
54
+
49
55
  namespace :api do
50
56
  get :stats, to: "stats#show"
57
+ get :insights, to: "insights#show"
51
58
  end
52
59
  end