newshound 0.2.7 → 1.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58c2540992cef9f0214865a6d365d0e2b2e98e6f7a9d186df9c50affbc254498
4
- data.tar.gz: e1db27edd5f1f665bb30154ce0fe2aea85781397e6caaf71f171d2c5e5a4fc8c
3
+ metadata.gz: 79ace59ee869878db3221ca5d851c9bf10ae50354877d6b4a2b57ccc9489319e
4
+ data.tar.gz: 9a0e6d98ce5ac41b433c19d66ae920b207c090d7550df505645068c46f627feb
5
5
  SHA512:
6
- metadata.gz: fb50eb837a41ceafd5e5b47a5a90762d7e04b5642c520e24a103202a87eead65d241dcb9674939d1cef34fdb9a1fb186d885cd19cdd154ac9d0049f9bb6ccefd
7
- data.tar.gz: 5344958684e704bba2f18ef8bce4bccd04ad954119ed8f0b5e57f6d2e6b1af422ac70642e32eceb7af488946102b0e71474f540db38030b930222b88f8ca97da
6
+ metadata.gz: 7124a65e125a08eea643486de24db7e84436c981ccd6e9c6fa12b205b33f1e2e30b062d38c766ab0f9df18bda79fd3d093b5bfccf23b5d9938b0c45e45d6a23e
7
+ data.tar.gz: d414aa404515671d71525214ebe0f601e456c6704e5bfccf29a49246454ea9f9d83e899576db2903cfe4d0491b8255a915d6b17a4c5cdefb879d5318b0476f66
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.4.3
1
+ ruby 4.0.1
data/CHANGELOG.md CHANGED
@@ -5,14 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.2.7] - 2026-01-30
8
+ ## [1.0.1] - 2026-03-17
9
9
 
10
- ## [] -
10
+ ### Changed
11
11
 
12
- ###
12
+ - The authorize_with initializer will generate with the method inside the configure block. (2d6ef48)
13
13
 
14
- - [0.2.6] -2025-01-13
14
+ ### Added
15
+
16
+ - Configurable banner links for exceptions, jobs, and warnings via string path patterns (1df1636)
17
+ - Record ID in format_for_banner output for linking to individual records (e06d040)
18
+
19
+ ## [0.2.8] - 2026-02-10
20
+
21
+ ### Changed
22
+
23
+ - Ruby support to 3.3+ and added Ruby 4.0 to CI matrix (24e5b86)
24
+ - Job monitoring uses configurable adapter pattern via config.job_source (8d33d30)
25
+
26
+ ### Removed
27
+
28
+ - Ruby 3.1 and 3.2 support (24e5b86)
29
+ - Hard dependency on que gem (8d33d30)
30
+ - QueReporter class (replaced by JobReporter + Jobs adapters) Version: major (8d33d30)
15
31
 
16
32
  ### Fixed
17
33
 
18
- - Solid Errors title is able to present in banner.
34
+ - Set the correct location for the repository on the web. (cb7bb88)
35
+ - test_exceptions rake task calling report instead of banner_data (e440188)
36
+ - Banner overlaying content in apps with !important body padding-top rules (16f22f1)
37
+
38
+ ### Added
39
+
40
+ - Jobs adapter pattern with Base class and registry (c50ba9c)
41
+ - Jobs::Que adapter for Que job backend (c50ba9c)
42
+ - JobReporter that delegates to a configurable job source adapter (c50ba9c)
43
+ - ExceptionReporter#formatted_exception_count and #exception_summary helpers (e440188)
@@ -31,7 +31,7 @@ module Newshound
31
31
  say " rake newshound:test_jobs"
32
32
  say ""
33
33
  say " For more information, visit:", :blue
34
- say " https://github.com/salbanez/newshound"
34
+ say " https://github.com/SOFware/newshound"
35
35
  say "===============================================================================", :green
36
36
  say ""
37
37
  end
@@ -14,6 +14,11 @@ Newshound.configure do |config|
14
14
  # Default is :exception_track
15
15
  config.exception_source = :exception_track # or :solid_errors
16
16
 
17
+ # Job source adapter for monitoring background jobs
18
+ # Uncomment and set to enable job monitoring in the banner
19
+ # config.job_source = :que # or a custom adapter instance
20
+ # See Newshound::Jobs::Base for the adapter interface
21
+
17
22
  # User roles that are authorized to view the Newshound banner
18
23
  # These should match the role values in your User model
19
24
  # Default is [:developer, :super_user]
@@ -23,14 +28,36 @@ Newshound.configure do |config|
23
28
  # Most apps use :current_user (Devise, etc.)
24
29
  # Default is :current_user
25
30
  config.current_user_method = :current_user
26
- end
27
31
 
28
- # Advanced: Custom authorization logic
29
- # If the default role-based authorization doesn't fit your needs,
30
- # you can provide a custom authorization block:
31
- #
32
- # Newshound.authorize_with do |controller|
33
- # # Your custom logic here
34
- # # Return true to show the banner, false to hide it
35
- # controller.current_user&.admin?
36
- # end
32
+ # Links for banner items
33
+ # Configure paths so banner items link to your exception/job dashboards.
34
+ # Use :id in show paths to interpolate the record ID.
35
+ #
36
+ # config.exception_links = {
37
+ # index: "/errors",
38
+ # show: "/errors/:id"
39
+ # }
40
+ #
41
+ # config.job_links = {
42
+ # index: "/background_jobs",
43
+ # show: "/background_jobs/jobs/:id",
44
+ # scheduled: "/background_jobs/scheduled",
45
+ # failed: "/background_jobs/failed",
46
+ # completed: "/background_jobs/completed"
47
+ # }
48
+ #
49
+ # config.warning_links = {
50
+ # index: "/warnings",
51
+ # show: "/warnings/:id"
52
+ # }
53
+
54
+ # Custom authorization logic:
55
+ # If the default role-based authorization doesn't fit your needs,
56
+ # you can provide a custom authorization block:
57
+ #
58
+ # config.authorize_with do |controller|
59
+ # # Your custom logic here
60
+ # # Return true to show the banner, false to hide it
61
+ # controller.current_user&.admin?
62
+ # end
63
+ end
@@ -4,7 +4,8 @@ module Newshound
4
4
  class Configuration
5
5
  attr_accessor :exception_limit, :enabled, :authorized_roles,
6
6
  :current_user_method, :authorization_block, :exception_source,
7
- :warning_source, :warning_limit
7
+ :warning_source, :warning_limit, :job_source,
8
+ :exception_links, :job_links, :warning_links
8
9
 
9
10
  def initialize
10
11
  @exception_limit = 10
@@ -15,6 +16,10 @@ module Newshound
15
16
  @exception_source = :exception_track
16
17
  @warning_source = nil
17
18
  @warning_limit = 10
19
+ @job_source = nil
20
+ @exception_links = {}
21
+ @job_links = {}
22
+ @warning_links = {}
18
23
  end
19
24
 
20
25
  # Allow custom authorization logic
@@ -29,10 +29,24 @@ module Newshound
29
29
  # Returns data formatted for the banner UI
30
30
  def banner_data
31
31
  {
32
- exceptions: recent_exceptions.map { |exception| exception_source.format_for_banner(exception) }
32
+ exceptions: recent_exceptions.map { |exception|
33
+ exception_source.format_for_banner(exception)
34
+ }
33
35
  }
34
36
  end
35
37
 
38
+ def formatted_exception_count(
39
+ format = "Total exceptions: %s"
40
+ )
41
+ format % recent_exceptions.length
42
+ end
43
+
44
+ def exception_summary
45
+ recent_exceptions.map.with_index(1) do |ex, i|
46
+ exception_source.format_for_report(ex, i)
47
+ end
48
+ end
49
+
36
50
  private
37
51
 
38
52
  def resolve_exception_source(source)
@@ -31,7 +31,7 @@ module Newshound
31
31
  # Formats an exception for banner UI display
32
32
  #
33
33
  # @param exception [Object] Exception record from the tracking system
34
- # @return [Hash] Hash with keys: :title, :message, :location, :time
34
+ # @return [Hash] Hash with keys: :id, :title, :message, :location, :time
35
35
  def format_for_banner(exception)
36
36
  raise NotImplementedError, "#{self.class} must implement #format_for_banner"
37
37
  end
@@ -23,6 +23,7 @@ module Newshound
23
23
  details = parse_exception_details(exception)
24
24
 
25
25
  {
26
+ id: exception.try(:id),
26
27
  title: details[:title],
27
28
  message: details[:message].truncate(100),
28
29
  location: details[:location],
@@ -23,6 +23,7 @@ module Newshound
23
23
  details = parse_exception_details(exception)
24
24
 
25
25
  {
26
+ id: exception.try(:error)&.id || exception.try(:id),
26
27
  title: details[:title],
27
28
  message: details[:message].truncate(100),
28
29
  location: details[:location],
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Newshound
4
+ class JobReporter
5
+ attr_reader :job_source, :configuration, :logger
6
+
7
+ def initialize(job_source: nil, configuration: nil, logger: nil)
8
+ @configuration = configuration || Newshound.configuration
9
+ @job_source = resolve_job_source(job_source)
10
+ @logger = logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
11
+ end
12
+
13
+ def generate_report
14
+ return no_jobs_block unless job_source
15
+
16
+ [
17
+ {
18
+ type: "section",
19
+ text: {
20
+ type: "mrkdwn",
21
+ text: "*📊 Job Queue Status*"
22
+ }
23
+ },
24
+ job_counts_section,
25
+ queue_health_section
26
+ ].compact
27
+ end
28
+ alias_method :report, :generate_report
29
+
30
+ def banner_data
31
+ return {queue_stats: {}} unless job_source
32
+
33
+ job_source.format_for_banner
34
+ end
35
+
36
+ private
37
+
38
+ def resolve_job_source(source)
39
+ source ||= @configuration.job_source
40
+ return nil if source.nil?
41
+
42
+ source.is_a?(Symbol) ? Jobs.source(source) : source
43
+ end
44
+
45
+ def job_counts_section
46
+ counts = job_source.job_counts_by_type
47
+
48
+ return no_jobs_section if counts.empty?
49
+
50
+ {
51
+ type: "section",
52
+ text: {
53
+ type: "mrkdwn",
54
+ text: format_job_counts(counts)
55
+ }
56
+ }
57
+ end
58
+
59
+ def format_job_counts(counts)
60
+ lines = ["*Job Counts by Type:*"]
61
+
62
+ counts.each do |job_class, stats|
63
+ status_emoji = (stats[:failed] > 0) ? "⚠️" : "✅"
64
+ lines << "• #{status_emoji} *#{job_class}*: #{stats[:total]} total (#{stats[:success]} success, #{stats[:failed]} failed)"
65
+ end
66
+
67
+ lines.join("\n")
68
+ end
69
+
70
+ def queue_health_section
71
+ stats = job_source.queue_statistics
72
+
73
+ {
74
+ type: "section",
75
+ text: {
76
+ type: "mrkdwn",
77
+ text: format_queue_health(stats)
78
+ }
79
+ }
80
+ end
81
+
82
+ def format_queue_health(stats)
83
+ health_emoji = if stats[:failed] > 10
84
+ "🔴"
85
+ else
86
+ (stats[:failed] > 5) ? "🟡" : "🟢"
87
+ end
88
+
89
+ <<~TEXT
90
+ *Queue Health #{health_emoji}*
91
+ • *Ready to Run:* #{stats[:ready]}
92
+ • *Scheduled:* #{stats[:scheduled]}
93
+ • *Failed (Retry Queue):* #{stats[:failed]}
94
+ • *Completed Today:* #{stats[:finished_today]}
95
+ TEXT
96
+ end
97
+
98
+ def no_jobs_section
99
+ {
100
+ type: "section",
101
+ text: {
102
+ type: "mrkdwn",
103
+ text: "*No jobs found in the queue*"
104
+ }
105
+ }
106
+ end
107
+
108
+ def no_jobs_block
109
+ [
110
+ {
111
+ type: "section",
112
+ text: {
113
+ type: "mrkdwn",
114
+ text: "*✅ No Job Source Configured*"
115
+ }
116
+ }
117
+ ]
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Newshound
4
+ module Jobs
5
+ # Base class for job source adapters
6
+ # Each adapter is responsible for:
7
+ # 1. Fetching queue statistics from its specific job backend
8
+ # 2. Fetching job counts grouped by type
9
+ # 3. Formatting job data for banner display
10
+ #
11
+ # Subclasses must implement:
12
+ # - #queue_statistics - Returns a hash of queue stats
13
+ # - #job_counts_by_type - Returns a hash of job class => counts
14
+ #
15
+ # Unlike Warnings::Base and Exceptions::Base (which format individual records),
16
+ # #format_for_banner takes no arguments and returns aggregate queue statistics.
17
+ # A default implementation is provided that delegates to #queue_statistics.
18
+ class Base
19
+ # Returns queue-level statistics
20
+ #
21
+ # @return [Hash] with keys :ready, :scheduled, :failed, :finished_today
22
+ def queue_statistics
23
+ raise NotImplementedError, "#{self.class} must implement #queue_statistics"
24
+ end
25
+
26
+ # Returns job counts grouped by job class
27
+ #
28
+ # @return [Hash] job_class => { success:, failed:, total: }
29
+ def job_counts_by_type
30
+ raise NotImplementedError, "#{self.class} must implement #job_counts_by_type"
31
+ end
32
+
33
+ # Returns data formatted for the banner UI
34
+ #
35
+ # @return [Hash] with key :queue_stats containing ready_to_run, scheduled, failed, completed_today
36
+ def format_for_banner
37
+ stats = queue_statistics
38
+
39
+ {
40
+ queue_stats: {
41
+ ready_to_run: stats[:ready],
42
+ scheduled: stats[:scheduled],
43
+ failed: stats[:failed],
44
+ completed_today: stats[:finished_today]
45
+ }
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Newshound
4
+ module Jobs
5
+ class Que < Base
6
+ attr_reader :logger
7
+
8
+ def initialize(logger: nil)
9
+ @logger = logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
10
+ end
11
+
12
+ def queue_statistics
13
+ conn = ActiveRecord::Base.connection
14
+ current_time = conn.quote(Time.now)
15
+ beginning_of_day = conn.quote(Date.today.to_time)
16
+
17
+ {
18
+ ready: count_jobs("finished_at IS NULL AND expired_at IS NULL AND run_at <= #{current_time}"),
19
+ scheduled: count_jobs("finished_at IS NULL AND expired_at IS NULL AND run_at > #{current_time}"),
20
+ failed: count_jobs("error_count > 0 AND finished_at IS NULL"),
21
+ finished_today: count_jobs("finished_at >= #{beginning_of_day}")
22
+ }
23
+ rescue => e
24
+ logger.error "Failed to fetch Que statistics: #{e.message}"
25
+ {ready: 0, scheduled: 0, failed: 0, finished_today: 0}
26
+ end
27
+
28
+ def job_counts_by_type
29
+ results = ActiveRecord::Base.connection.execute(<<~SQL)
30
+ SELECT job_class, error_count, COUNT(*) as count
31
+ FROM que_jobs
32
+ WHERE finished_at IS NULL
33
+ GROUP BY job_class, error_count
34
+ ORDER BY job_class
35
+ SQL
36
+
37
+ results.each_with_object({}) do |row, hash|
38
+ job_class = row["job_class"]
39
+ error_count = row["error_count"].to_i
40
+ count = row["count"].to_i
41
+
42
+ hash[job_class] ||= {success: 0, failed: 0, total: 0}
43
+
44
+ if error_count.zero?
45
+ hash[job_class][:success] += count
46
+ else
47
+ hash[job_class][:failed] += count
48
+ end
49
+
50
+ hash[job_class][:total] += count
51
+ end
52
+ rescue => e
53
+ logger.error "Failed to fetch job counts: #{e.message}"
54
+ {}
55
+ end
56
+
57
+ private
58
+
59
+ def count_jobs(where_clause)
60
+ ActiveRecord::Base.connection.select_value(
61
+ "SELECT COUNT(*) FROM que_jobs WHERE #{where_clause}"
62
+ ).to_i
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(__dir__, "jobs", "*.rb")].each { |file| require file }
4
+
5
+ module Newshound
6
+ module Jobs
7
+ @registry = {}
8
+
9
+ class << self
10
+ attr_reader :registry
11
+
12
+ # Register a job adapter class with a symbolic name
13
+ #
14
+ # @param name [Symbol, String] The name to register the adapter under
15
+ # @param adapter_class [Class] The adapter class (must inherit from Jobs::Base)
16
+ def register(name, adapter_class)
17
+ @registry[name.to_sym] = adapter_class
18
+ end
19
+
20
+ # Get a job source adapter instance
21
+ #
22
+ # @param source [Symbol, Object] Either a symbolic name to look up, or an adapter instance
23
+ # @return [Jobs::Base] An instance of the job adapter
24
+ # @raise [RuntimeError] If the source symbol cannot be resolved
25
+ def source(source)
26
+ return source unless source.is_a?(Symbol)
27
+
28
+ if @registry.key?(source)
29
+ return @registry[source].new
30
+ end
31
+
32
+ constant = constants.find { |c| c.to_s.gsub(/(?<!^)([A-Z])/, "_\\1").downcase == source.to_s }
33
+ raise "Invalid job source: #{source}" unless constant
34
+
35
+ const_get(constant).new
36
+ end
37
+
38
+ def clear_registry!
39
+ @registry = {}
40
+ end
41
+ end
42
+ end
43
+ end
@@ -55,11 +55,11 @@ module Newshound
55
55
 
56
56
  def generate_banner_html
57
57
  exception_reporter = Newshound::ExceptionReporter.new
58
- que_reporter = Newshound::QueReporter.new
58
+ job_reporter = Newshound::JobReporter.new
59
59
  warning_reporter = Newshound::WarningReporter.new
60
60
 
61
61
  exception_data = exception_reporter.banner_data
62
- job_data = que_reporter.banner_data
62
+ job_data = job_reporter.banner_data
63
63
  warning_data = warning_reporter.banner_data
64
64
 
65
65
  # Generate HTML from template
@@ -91,25 +91,66 @@ module Newshound
91
91
  <<~JS
92
92
  <script>
93
93
  (function() {
94
+ var cachedPriority, cachedBodyRule;
95
+
96
+ // Detect once whether any non-newshound stylesheet uses !important on body padding-top
97
+ function detectPriority() {
98
+ var styles = document.querySelectorAll('style:not(#newshound-styles)');
99
+
100
+ for (var i = 0; i < styles.length; i++) {
101
+ if (styles[i].textContent.match(/body\\s*{[^}]*padding-top[^;]*!important/s)) {
102
+ return 'important';
103
+ }
104
+ }
105
+
106
+ try {
107
+ for (var s = 0; s < document.styleSheets.length; s++) {
108
+ var sheet = document.styleSheets[s];
109
+ if (sheet.ownerNode && sheet.ownerNode.id === 'newshound-styles') continue;
110
+ var rules = sheet.cssRules || [];
111
+ for (var r = 0; r < rules.length; r++) {
112
+ if (rules[r].selectorText === 'body' &&
113
+ rules[r].style.getPropertyPriority('padding-top') === 'important') {
114
+ return 'important';
115
+ }
116
+ }
117
+ }
118
+ } catch (e) {}
119
+
120
+ return '';
121
+ }
122
+
123
+ // Find the html body rule in the newshound stylesheet once
124
+ function findBodyRule() {
125
+ var styleEl = document.getElementById('newshound-styles');
126
+ if (!styleEl || !styleEl.sheet) return null;
127
+ var rules = styleEl.sheet.cssRules;
128
+ for (var i = 0; i < rules.length; i++) {
129
+ if (rules[i].selectorText === 'html body') return rules[i];
130
+ }
131
+ return null;
132
+ }
133
+
94
134
  window.newshoundUpdatePadding = function() {
95
- // Wait for transition to complete
96
135
  setTimeout(function() {
97
136
  var banner = document.getElementById('newshound-banner');
98
- if (banner) {
99
- var height = banner.offsetHeight;
100
- document.body.style.paddingTop = height + 'px';
101
- }
137
+ if (!banner) return;
138
+
139
+ if (cachedBodyRule === undefined) cachedBodyRule = findBodyRule();
140
+ if (!cachedBodyRule) return;
141
+
142
+ if (cachedPriority === undefined) cachedPriority = detectPriority();
143
+
144
+ cachedBodyRule.style.setProperty('padding-top', banner.offsetHeight + 'px', cachedPriority);
102
145
  }, 300);
103
146
  };
104
147
 
105
- // Set initial padding after page load
106
148
  if (document.readyState === 'loading') {
107
149
  document.addEventListener('DOMContentLoaded', window.newshoundUpdatePadding);
108
150
  } else {
109
151
  window.newshoundUpdatePadding();
110
152
  }
111
153
 
112
- // Also update on window resize
113
154
  window.addEventListener('resize', window.newshoundUpdatePadding);
114
155
  })();
115
156
  </script>
@@ -118,8 +159,8 @@ module Newshound
118
159
 
119
160
  def render_styles
120
161
  <<~CSS
121
- <style>
122
- body {
162
+ <style id="newshound-styles">
163
+ html body {
123
164
  padding-top: 50px;
124
165
  transition: padding-top 0.3s ease-out;
125
166
  }
@@ -157,13 +198,13 @@ module Newshound
157
198
  font-weight: 600;
158
199
  background: rgba(255,255,255,0.2);
159
200
  }
160
- .newshound-badge.error {
201
+ .newshound-error {
161
202
  background: #ef4444;
162
203
  }
163
- .newshound-badge.warning {
204
+ .newshound-warning {
164
205
  background: #f59e0b;
165
206
  }
166
- .newshound-badge.success {
207
+ .newshound-success {
167
208
  background: #10b981;
168
209
  }
169
210
  .newshound-toggle {
@@ -235,6 +276,20 @@ module Newshound
235
276
  text-transform: uppercase;
236
277
  letter-spacing: 0.5px;
237
278
  }
279
+ .newshound-link {
280
+ color: inherit;
281
+ text-decoration: none;
282
+ }
283
+ .newshound-link:hover {
284
+ text-decoration: underline;
285
+ }
286
+ a.newshound-stat {
287
+ color: inherit;
288
+ text-decoration: none;
289
+ }
290
+ a.newshound-stat:hover {
291
+ background: rgba(255,255,255,0.2);
292
+ }
238
293
  </style>
239
294
  CSS
240
295
  end
@@ -245,16 +300,16 @@ module Newshound
245
300
  warning_count = warning_data[:warnings]&.length || 0
246
301
 
247
302
  if exception_count > 0 || failed_jobs > 10
248
- badge_class = "error"
303
+ badge_class = "newshound-error"
249
304
  text = "#{exception_count} exceptions, #{failed_jobs} failed jobs"
250
305
  elsif warning_count > 0 || failed_jobs > 5
251
- badge_class = "warning"
306
+ badge_class = "newshound-warning"
252
307
  parts = []
253
308
  parts << "#{warning_count} warnings" if warning_count > 0
254
309
  parts << "#{failed_jobs} failed jobs" if failed_jobs > 5
255
310
  text = parts.join(", ")
256
311
  else
257
- badge_class = "success"
312
+ badge_class = "newshound-success"
258
313
  text = "All clear"
259
314
  end
260
315
 
@@ -268,23 +323,11 @@ module Newshound
268
323
  return %(<div class="newshound-section"><div class="newshound-section-title">✅ Exceptions</div><div class="newshound-item">No exceptions in the last 24 hours</div></div>)
269
324
  end
270
325
 
271
- items = exceptions.take(5).map do |ex|
272
- <<~HTML
273
- <div class="newshound-item">
274
- <div class="newshound-item-title">#{escape_html(ex[:title])}</div>
275
- <div class="newshound-item-detail">
276
- #{escape_html(ex[:message])} • #{escape_html(ex[:location])} • #{escape_html(ex[:time])}
277
- </div>
278
- </div>
279
- HTML
280
- end.join
281
-
282
- <<~HTML
283
- <div class="newshound-section">
284
- <div class="newshound-section-title">⚠️ Recent Exceptions (#{exceptions.length})</div>
285
- #{items}
286
- </div>
287
- HTML
326
+ render_item_section(
327
+ items: exceptions,
328
+ links: Newshound.configuration.exception_links,
329
+ title: "⚠️ Recent Exceptions (#{exceptions.length})"
330
+ )
288
331
  end
289
332
 
290
333
  def render_warnings(data)
@@ -292,53 +335,81 @@ module Newshound
292
335
 
293
336
  return "" if warnings.empty?
294
337
 
295
- items = warnings.take(5).map do |w|
296
- <<~HTML
297
- <div class="newshound-item">
298
- <div class="newshound-item-title">#{escape_html(w[:title])}</div>
299
- <div class="newshound-item-detail">
300
- #{escape_html(w[:message])} • #{escape_html(w[:location])} • #{escape_html(w[:time])}
301
- </div>
338
+ render_item_section(
339
+ items: warnings,
340
+ links: Newshound.configuration.warning_links,
341
+ title: "⚠️ Warnings (#{warnings.length})"
342
+ )
343
+ end
344
+
345
+ def render_item_section(items:, links:, title:)
346
+ rendered_items = items.take(5).map do |item|
347
+ item_content = <<~HTML
348
+ <div class="newshound-item-title">#{escape_html(item[:title])}</div>
349
+ <div class="newshound-item-detail">
350
+ #{escape_html(item[:message])} • #{escape_html(item[:location])} • #{escape_html(item[:time])}
302
351
  </div>
303
352
  HTML
353
+
354
+ if links[:show] && item[:id]
355
+ url = links[:show].gsub(":id", item[:id].to_s)
356
+ %(<a href="#{escape_html(url)}" class="newshound-link"><div class="newshound-item">#{item_content}</div></a>)
357
+ else
358
+ %(<div class="newshound-item">#{item_content}</div>)
359
+ end
304
360
  end.join
305
361
 
362
+ title_html = if links[:index]
363
+ %(<a href="#{escape_html(links[:index])}" class="newshound-link">#{title}</a>)
364
+ else
365
+ title
366
+ end
367
+
306
368
  <<~HTML
307
369
  <div class="newshound-section">
308
- <div class="newshound-section-title">⚠️ Warnings (#{warnings.length})</div>
309
- #{items}
370
+ <div class="newshound-section-title">#{title_html}</div>
371
+ #{rendered_items}
310
372
  </div>
311
373
  HTML
312
374
  end
313
375
 
314
376
  def render_jobs(data)
315
377
  stats = data[:queue_stats] || {}
378
+ links = Newshound.configuration.job_links
379
+
380
+ section_title = "📊 Job Queue Status"
381
+ title_html = if links[:index]
382
+ %(<a href="#{escape_html(links[:index])}" class="newshound-link">#{section_title}</a>)
383
+ else
384
+ section_title
385
+ end
316
386
 
317
387
  <<~HTML
318
388
  <div class="newshound-section">
319
- <div class="newshound-section-title">📊 Job Queue Status</div>
389
+ <div class="newshound-section-title">#{title_html}</div>
320
390
  <div class="newshound-grid">
321
- <div class="newshound-stat">
322
- <span class="newshound-stat-value">#{stats[:ready_to_run] || 0}</span>
323
- <span class="newshound-stat-label">Ready</span>
324
- </div>
325
- <div class="newshound-stat">
326
- <span class="newshound-stat-value">#{stats[:scheduled] || 0}</span>
327
- <span class="newshound-stat-label">Scheduled</span>
328
- </div>
329
- <div class="newshound-stat">
330
- <span class="newshound-stat-value">#{stats[:failed] || 0}</span>
331
- <span class="newshound-stat-label">Failed</span>
332
- </div>
333
- <div class="newshound-stat">
334
- <span class="newshound-stat-value">#{stats[:completed_today] || 0}</span>
335
- <span class="newshound-stat-label">Completed Today</span>
336
- </div>
391
+ #{render_job_stat(stats[:ready_to_run] || 0, "Ready", links[:index])}
392
+ #{render_job_stat(stats[:scheduled] || 0, "Scheduled", links[:scheduled])}
393
+ #{render_job_stat(stats[:failed] || 0, "Failed", links[:failed])}
394
+ #{render_job_stat(stats[:completed_today] || 0, "Completed Today", links[:completed])}
337
395
  </div>
338
396
  </div>
339
397
  HTML
340
398
  end
341
399
 
400
+ def render_job_stat(value, label, link)
401
+ content = <<~HTML
402
+ <span class="newshound-stat-value">#{value}</span>
403
+ <span class="newshound-stat-label">#{label}</span>
404
+ HTML
405
+
406
+ if link
407
+ %(<a href="#{escape_html(link)}" class="newshound-stat">#{content}</a>)
408
+ else
409
+ %(<div class="newshound-stat">#{content}</div>)
410
+ end
411
+ end
412
+
342
413
  def escape_html(text)
343
414
  return +"" unless text.present?
344
415
  text.to_s
@@ -27,23 +27,23 @@ module Newshound
27
27
  desc "Test exception reporter"
28
28
  task test_exceptions: :environment do
29
29
  reporter = Newshound::ExceptionReporter.new
30
- data = reporter.report
31
30
  puts "Exception Report:"
32
- puts " Total exceptions: #{data[:exceptions]&.length || 0}"
33
- data[:exceptions]&.each_with_index do |ex, i|
34
- puts " #{i + 1}. #{ex[:title]} - #{ex[:message]}"
31
+ puts " #{reporter.formatted_exception_count}"
32
+ reporter.exception_summary.each do |line|
33
+ puts " #{line}"
35
34
  end
36
35
  end
37
36
 
38
37
  desc "Test job reporter"
39
38
  task test_jobs: :environment do
40
- reporter = Newshound::QueReporter.new
41
- data = reporter.report
39
+ reporter = Newshound::JobReporter.new
40
+ data = reporter.banner_data
41
+ stats = data[:queue_stats]
42
42
  puts "Job Queue Report:"
43
- puts " Ready to run: #{data.dig(:queue_stats, :ready_to_run)}"
44
- puts " Scheduled: #{data.dig(:queue_stats, :scheduled)}"
45
- puts " Failed: #{data.dig(:queue_stats, :failed)}"
46
- puts " Completed today: #{data.dig(:queue_stats, :completed_today)}"
43
+ puts " Ready to run: #{stats[:ready_to_run] || 0}"
44
+ puts " Scheduled: #{stats[:scheduled] || 0}"
45
+ puts " Failed: #{stats[:failed] || 0}"
46
+ puts " Completed today: #{stats[:completed_today] || 0}"
47
47
  end
48
48
  end
49
49
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Newshound
4
- VERSION = "0.2.7"
4
+ VERSION = "1.0.1"
5
5
  end
@@ -33,7 +33,7 @@ module Newshound
33
33
  # Formats a warning for banner UI display
34
34
  #
35
35
  # @param warning [Object] Warning record from the data source
36
- # @return [Hash] Hash with keys: :title, :message, :location, :time
36
+ # @return [Hash] Hash with keys: :id, :title, :message, :location, :time
37
37
  def format_for_banner(warning)
38
38
  raise NotImplementedError, "#{self.class} must implement #format_for_banner"
39
39
  end
data/lib/newshound.rb CHANGED
@@ -6,7 +6,8 @@ require_relative "newshound/exceptions"
6
6
  require_relative "newshound/exception_reporter"
7
7
  require_relative "newshound/warnings"
8
8
  require_relative "newshound/warning_reporter"
9
- require_relative "newshound/que_reporter"
9
+ require_relative "newshound/jobs"
10
+ require_relative "newshound/job_reporter"
10
11
  require_relative "newshound/authorization"
11
12
  require_relative "newshound/middleware/banner_injector"
12
13
  require_relative "newshound/railtie" if defined?(Rails)
data/newshound.gemspec CHANGED
@@ -8,11 +8,11 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Savannah Moore"]
9
9
  spec.email = ["savannah.albanez@sofwarellc.com"]
10
10
 
11
- spec.summary = "Real-time web UI banner for monitoring Que jobs and exception tracking"
11
+ spec.summary = "Real-time web UI banner for monitoring background jobs and exception tracking"
12
12
  spec.description = "Newshound displays exceptions and job statuses in a collapsible banner for authorized users in your Rails app"
13
13
  spec.homepage = "https://github.com/SOFware/newshound"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.1.0"
15
+ spec.required_ruby_version = ">= 3.3.0"
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
18
  spec.metadata["source_code_uri"] = spec.homepage
@@ -27,7 +27,12 @@ Gem::Specification.new do |spec|
27
27
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
28
  spec.require_paths = ["lib"]
29
29
 
30
+ spec.post_install_message = <<~MSG
31
+ To complete installation, run:
32
+
33
+ rails generate newshound:install
34
+ MSG
35
+
30
36
  # Runtime dependencies
31
37
  spec.add_dependency "rails", ">= 6.0"
32
- spec.add_dependency "que", ">= 1.0"
33
38
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: newshound
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.7
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Savannah Moore
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '6.0'
26
- - !ruby/object:Gem::Dependency
27
- name: que
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '1.0'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '1.0'
40
26
  description: Newshound displays exceptions and job statuses in a collapsible banner
41
27
  for authorized users in your Rails app
42
28
  email:
@@ -65,8 +51,11 @@ files:
65
51
  - lib/newshound/exceptions/base.rb
66
52
  - lib/newshound/exceptions/exception_track.rb
67
53
  - lib/newshound/exceptions/solid_errors.rb
54
+ - lib/newshound/job_reporter.rb
55
+ - lib/newshound/jobs.rb
56
+ - lib/newshound/jobs/base.rb
57
+ - lib/newshound/jobs/que.rb
68
58
  - lib/newshound/middleware/banner_injector.rb
69
- - lib/newshound/que_reporter.rb
70
59
  - lib/newshound/railtie.rb
71
60
  - lib/newshound/version.rb
72
61
  - lib/newshound/warning_reporter.rb
@@ -80,6 +69,10 @@ metadata:
80
69
  homepage_uri: https://github.com/SOFware/newshound
81
70
  source_code_uri: https://github.com/SOFware/newshound
82
71
  changelog_uri: https://github.com/SOFware/newshound/blob/main/CHANGELOG.md
72
+ post_install_message: |
73
+ To complete installation, run:
74
+
75
+ rails generate newshound:install
83
76
  rdoc_options: []
84
77
  require_paths:
85
78
  - lib
@@ -87,14 +80,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
87
80
  requirements:
88
81
  - - ">="
89
82
  - !ruby/object:Gem::Version
90
- version: 3.1.0
83
+ version: 3.3.0
91
84
  required_rubygems_version: !ruby/object:Gem::Requirement
92
85
  requirements:
93
86
  - - ">="
94
87
  - !ruby/object:Gem::Version
95
88
  version: '0'
96
89
  requirements: []
97
- rubygems_version: 3.6.7
90
+ rubygems_version: 4.0.3
98
91
  specification_version: 4
99
- summary: Real-time web UI banner for monitoring Que jobs and exception tracking
92
+ summary: Real-time web UI banner for monitoring background jobs and exception tracking
100
93
  test_files: []
@@ -1,166 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Newshound
4
- class QueReporter
5
- attr_reader :job_source, :logger
6
-
7
- def initialize(job_source: nil, logger: nil)
8
- @job_source = job_source || (defined?(::Que::Job) ? ::Que::Job : nil)
9
- @logger = logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
10
- end
11
-
12
- def generate_report
13
- [
14
- {
15
- type: "section",
16
- text: {
17
- type: "mrkdwn",
18
- text: "*📊 Que Jobs Status*"
19
- }
20
- },
21
- job_counts_section,
22
- queue_health_section
23
- ].compact
24
- end
25
- alias_method :report, :generate_report
26
-
27
- # Returns data formatted for the banner UI
28
- def banner_data
29
- stats = queue_statistics
30
-
31
- {
32
- queue_stats: {
33
- ready_to_run: stats[:ready],
34
- scheduled: stats[:scheduled],
35
- failed: stats[:failed],
36
- completed_today: stats[:finished_today]
37
- }
38
- }
39
- end
40
-
41
- private
42
-
43
- def job_counts_section
44
- counts = job_counts_by_type
45
-
46
- return no_jobs_section if counts.empty?
47
-
48
- {
49
- type: "section",
50
- text: {
51
- type: "mrkdwn",
52
- text: format_job_counts(counts)
53
- }
54
- }
55
- end
56
-
57
- def job_counts_by_type
58
- return {} unless job_source
59
-
60
- # Use raw SQL since Que::Job may not support ActiveRecord's .group method
61
- results = ActiveRecord::Base.connection.execute(<<~SQL)
62
- SELECT job_class, error_count, COUNT(*) as count
63
- FROM que_jobs
64
- WHERE finished_at IS NULL
65
- GROUP BY job_class, error_count
66
- ORDER BY job_class
67
- SQL
68
-
69
- results.each_with_object({}) do |row, hash|
70
- job_class = row["job_class"]
71
- error_count = row["error_count"].to_i
72
- count = row["count"].to_i
73
-
74
- hash[job_class] ||= {success: 0, failed: 0, total: 0}
75
-
76
- if error_count.zero?
77
- hash[job_class][:success] += count
78
- else
79
- hash[job_class][:failed] += count
80
- end
81
-
82
- hash[job_class][:total] += count
83
- end
84
- rescue => e
85
- logger.error "Failed to fetch job counts: #{e.message}"
86
- {}
87
- end
88
-
89
- def format_job_counts(counts)
90
- lines = ["*Job Counts by Type:*"]
91
-
92
- counts.each do |job_class, stats|
93
- status_emoji = (stats[:failed] > 0) ? "⚠️" : "✅"
94
- lines << "• #{status_emoji} *#{job_class}*: #{stats[:total]} total (#{stats[:success]} success, #{stats[:failed]} failed)"
95
- end
96
-
97
- lines.join("\n")
98
- end
99
-
100
- def queue_health_section
101
- stats = queue_statistics
102
-
103
- {
104
- type: "section",
105
- text: {
106
- type: "mrkdwn",
107
- text: format_queue_health(stats)
108
- }
109
- }
110
- end
111
-
112
- def queue_statistics
113
- return default_stats unless job_source
114
-
115
- conn = ActiveRecord::Base.connection
116
- current_time = conn.quote(Time.now)
117
- beginning_of_day = conn.quote(Date.today.to_time)
118
-
119
- {
120
- ready: count_jobs("finished_at IS NULL AND expired_at IS NULL AND run_at <= #{current_time}"),
121
- scheduled: count_jobs("finished_at IS NULL AND expired_at IS NULL AND run_at > #{current_time}"),
122
- failed: count_jobs("error_count > 0 AND finished_at IS NULL"),
123
- finished_today: count_jobs("finished_at >= #{beginning_of_day}")
124
- }
125
- rescue => e
126
- logger.error "Failed to fetch Que statistics: #{e.message}"
127
- default_stats
128
- end
129
-
130
- def count_jobs(where_clause)
131
- ActiveRecord::Base.connection.select_value(
132
- "SELECT COUNT(*) FROM que_jobs WHERE #{where_clause}"
133
- ).to_i
134
- end
135
-
136
- def default_stats
137
- {ready: 0, scheduled: 0, failed: 0, finished_today: 0}
138
- end
139
-
140
- def format_queue_health(stats)
141
- health_emoji = if stats[:failed] > 10
142
- "🔴"
143
- else
144
- (stats[:failed] > 5) ? "🟡" : "🟢"
145
- end
146
-
147
- <<~TEXT
148
- *Queue Health #{health_emoji}*
149
- • *Ready to Run:* #{stats[:ready]}
150
- • *Scheduled:* #{stats[:scheduled]}
151
- • *Failed (Retry Queue):* #{stats[:failed]}
152
- • *Completed Today:* #{stats[:finished_today]}
153
- TEXT
154
- end
155
-
156
- def no_jobs_section
157
- {
158
- type: "section",
159
- text: {
160
- type: "mrkdwn",
161
- text: "*No jobs found in the queue*"
162
- }
163
- }
164
- end
165
- end
166
- end